The Bath Automation: Part 2, the radio

The Bath Automation: Part 2, the radio

spectroman
spectroman
69 views

In Part 1 I wrote a reader for the NIBE heat pump, so I could know whether there was hot water before stepping into the shower. The bathroom had a second problem, though, and it was sitting right above my head: the ventilation.

We have a DucoBox Silent extracting air from the house. It does its job, slowly, on its own schedule. After a shower the bathroom stays a steam room until someone remembers to press the button on the wall remote — and nobody remembers.

So the plan was already a bit bigger than what I wrote in Part 1. The display and a humidity sensor were to merge into one thing, and this part was the most challenging one: making a Raspberry Pi turn on the ventilation by itself, over the air, with no cloud, no vendor app and no wire to the box. Those rules were not optional: the challenge was to take a commercial product with an undocumented protocol and very little information about it, and build a hacked gateway into it.

The result is in a public repository:

Source code: git.spectronet.org/public-access/ducobox-cc1101


The one-paragraph version

A CC1101 868 MHz radio on the Pi speaks the DucoBox's RF protocol. A humidity sensor reports every 10 seconds. When humidity rises fast — not when it is merely high — the daemon (ducod) sends the box a HIGH command as if a wall remote had been pressed, and the box extracts for its usual cycle. A small TFT panel shows hot water and ventilation together, Zabbix watches all of it and also supplies the baseline: the median humidity of the other rooms in the house. Comparing the bathroom against that baseline is what lets ducod make an educated guess whether a rise in humidity is a shower or just a humid day. Depending on the configuration the baseline is optional, but in the current setup it is a key part of the decision.

Simple to say. It took several iterations, each one adding pieces, and several months of development. The radio alone took more than eight months.


Three RPi generations, named after satellites

Like the rest of the house, the Raspberry Pis are named after satellites — in this case Earth-observation missions that watch water and the atmosphere, which is what a humidity controller does in miniature.

host the mission what it did here
SWOT Surface Water and Ocean Topography generation 1: the heat-pump display from Part 1, no radio
Aqua EOS PM-1, the water cycle generation 2: the radio, on a breadboard
Aura EOS CH-1, atmospheric chemistry generation 3: soldered, done properly

Aqua and Aura fly in the same **A-Train** constellation, minutes apart, Aqua leading. So did mine: the controller was built on Aqua and handed over to Aura.


The RFM69 months

The first radio was an RFM69 — a good, cheap, well-supported 868 MHz transceiver. Months went into it, and it never talked to the box.

There is no public information about what radio lives inside a DucoBox. No protocol document, no teardown. The only way to find out what the box speaks was to put something on 868 MHz and listen, and a capable general-purpose transceiver is the correct opening move when the alternative is not starting at all.

It could never have worked, and the reason only became knowable much later:

  • The DucoBox speaks CC1101, natively. Its packets carry a CRC-16 with polynomial 0x8005, initial value 0xFFFF — which is exactly what a CC1101's hardware packet engine produces.
  • The RFM69 computes a different CRC (CRC-16-CCITT), and there is no register that changes it. Every valid Duco frame arrives at an RFM69 and gets discarded as corrupt; every frame it sends gets rejected by the box for the same reason. The radio works perfectly and nothing gets through — the worst kind of bug to chase. Switching the CRC check off (CrcOn=0) and computing 0x8005 on the Pi gets around this on paper: receive everything, verify it in software, append the right CRC by hand before sending. In practice it removes the radio's only filter. Every 868 MHz burst in the neighbourhood (utility meters, weather sensors, the neighbours' remotes) lands in the FIFO, has to be fetched over SPI and judged by the Pi, and the frame still has the wrong shape for the RFM69, which is the next problem.
  • The frame doesn't fit the RFM69's mould. Size was never the problem in raw bytes: a Duco frame is at most 32 bytes, and the RFM69's FIFO holds 66. The problem is the format. The libraries that drive an RFM69 cap the payload at around 61 bytes and wrap it in their own header (target node, sender node, control byte), so they read a Duco frame's message type as a node ID and drop anything that isn't their own packet. Underneath the libraries it doesn't line up either: Duco packs four 5-bit addresses (source, destination, original source, original destination) and a counter across byte boundaries, which the RFM69's byte-wide address filter can't match. And the receiving code expects the two status bytes a CC1101 appends to every frame (RSSI, and LQI with the CRC-OK flag), which an RFM69 doesn't produce.
  • Escaping the packet engine costs you the packet engine. Raw mode hands you a bit every 26 µs at 38.4 kbps, to be sampled by a Linux userspace process with no real-time guarantees. Not a tuning problem — the wrong side of a hardware/software boundary.

The honest summary: the RFM69 could not have worked, and trying it was still the right thing to do. When the target is undocumented, the first radio is an instrument, not a solution.


The real watershed: software-defined radio

The turning point was not a chip. It was discovering SDR at all.

Until then every question about the box had to be asked through a transceiver — a device that only reports what it already decided to decode. If its packet engine rejects a frame, you learn nothing about why.

An RTL-SDR Blog V4 costs less than the radios it replaces and hands you raw IQ samples: the signal itself, before any decision. One 96-second capture of the real box in gqrx, demodulated offline with a bit of numpy, and the guessing was over:

measured on air value
centre frequency 868.275 MHz (all nodes within ±5 kHz)
bit rate 38.34 kbps
modulation GFSK, 20.6 kHz deviation
sync word D391 D391
frames passing the CC1101 CRC 173 of 208

That capture also handed the Go encoder and decoder a set of genuine on-air frames to be tested against, which the test suite still does.

(The same SDR then went straight into another project — ADS-B.)

The CC1101 turn

The break came from published work on the Duco protocol around the CC1101 — most usefully the Ducobox ESPEasy plugin by arnemauer, whose register settings and frame layout could be read and adapted. Once the chip matched the protocol, the hardware did the hard part: sync detection, length handling and the correct CRC all happen in the CC1101, and its FIFO hands over a whole, verified frame.

The plugin itself was not a drop-in solution: it could not be used as-is against the Duco installed here. But what it documented was enough to build a new system in Go that speaks to the box successfully.


Impersonate the remote, do not join the network

The obvious way to command the box is to join it as a new device. It works, and it is close to irreversible: every join burns a permanent device slot, the box holds about 30, and the only way back is a factory reset that also forgets every real remote in the house.

The capture showed there was no need. The box authenticates nothing beyond network ID plus source address, and the handheld remote always sends the same counter. Replay the remote's own packet and it is indistinguishable from a button press. ducoctl emulate does exactly that.

  • no device slot consumed — nothing is registered in the box at all;
  • completely reversible — stop the daemon and nothing is left behind;
  • the physical remote keeps working, because nothing was replaced, only imitated.

The one rule it introduces: an address may have exactly one emulator. Two daemons impersonating the same remote are two people talking over each other with the same voice — so only one host ever runs ducod.


The trigger: rate of rise, not a level

A fixed threshold ("ventilate above 75 %") is the obvious design and the wrong one. Humidity drifts with the weather, and by the time a shower pushes the room past a fixed line, it is already a sauna.

So ducod fires on how fast humidity climbs: by default +3 %RH within 5 minutes. From a real shower (57 % ambient, 77.7 % peak) that fires about seven minutes earlier than a 75 % threshold, while ordinary evening drift stays under +2 % and never triggers.

A gap in the samples voids the window instead of looking like a jump — a reboot must not read as a shower. And the box decides how long each extraction cycle lasts; ducod only re-asserts HIGH while humidity stays well above where the boost started.


Who owns what

flowchart TB subgraph DUCOD["ducod — owns the sensor and the radio"] D[ducod daemon] end D -->|"every 10 s"| S[humidity sensor] S -.->|"humidity, temperature"| D D <-->|SPI| CC[CC1101 radio] CC <-->|"868 MHz — commands out, announcements in"| BOX[(DucoBox)] D -->|writes| SJ["status.json"] D -->|appends| CSV["humidity log"] API[(myUplink API)] -->|polls| NR["nibe-reader (Part 1)"] NR -->|writes| PTS["heat pump points"] subgraph DISP["display — owns the panel, writes nothing else"] P[TFT panel] end SJ -.->|polls| P PTS -.->|polls| P SJ -.->|"reads once a minute"| ZBX[(Zabbix)] classDef owner fill:#1565c0,stroke:#0d47a1,color:#fff classDef file fill:#37474f,stroke:#263238,color:#fff classDef hw fill:#2e7d32,stroke:#1b5e20,color:#fff class D,P owner class SJ,CSV,PTS file class S,CC,BOX hw

The design rule is exclusive ownership of hardware. ducod drives the sensor and holds the radio; nothing else touches either. The panel is a pure reader — every arrow into it is dotted — which is why it keeps working, showing stale-but-labelled data, when ducod or the heat-pump reader is down.


Breadboard versus solder

Aqua worked, and its wiring was always the limit. On jumper wires the CC1101's chip-select floated: roughly a quarter of register reads were valid, and from its spot the box was only heard at −102 dBm, too weak to answer a direct command — everything went through a relay node.

Aura is the same design soldered onto a Perma-Proto board, with a 100 nF ceramic at each module and a 10 µF capacitor on the 3.3 V rail. Same physical spot:

Aqua (jumpers) Aura (soldered)
box heard at −102 dBm −83 dBm
direct command acknowledged never — relay needed 5/5 on the first send
register reads agreeing ~25 % 200/200

The Perma-Proto board in Fritzing: the Pi, the CC1101, the panel and the sensor, wired by hand

About 20 dB better at the same distance. It was proven the same evening by a shower: +6.5 %RH / 5 min, HIGH sent, acknowledged, fan on.

Then reality: some of my solder joints did not survive the first days, and Aqua had to take the box back while Aura went back to the bench with a multimeter and a DSO138 oscilloscope. Electronics teaches humility quickly. (Small lesson from that bench, too: a ceramic disc marked 104 is 100 nF, not 100 µF — 10 followed by four zeros, in picofarads.)

And the sensor changed

The project started with a DHT22. One day it began sending frames that pass their own checksum and decode to a perfectly plausible 25.5 °C / 25.5 %RH — while the room was at 82 %. No amount of checksum checking catches that. It was replaced by an AHT30 on I²C, which also freed the one-wire GPIO dance entirely.


Deployment

CI builds the Go binaries (ducoctl and ducod) and publishes them to the GitLab package registry; Ansible installs them, renders the configuration and sets up the SPI overlays. Nothing compiles on the Pi. Anything tuned by hand on the host is reverted on the next run — which is the point.


The configuration

Everything ducod does is driven by one file, /etc/duco/ducod.yaml, rendered by Ansible from the role's defaults merged with the host's playbook. This is the one running in the bathroom, with the house-specific values replaced:

# /etc/duco/ducod.yaml — rendered by Ansible; change the playbook, not this file

radio:
  net: "0000abcd"         # the house's Duco network ID, read off the SDR capture
  node: 2                 # the address we impersonate: the handheld wall remote
  via: 0                  # 0 = talk to the box directly; 3 = go through relay node 3

sensor:
  cmd: /usr/local/bin/ahtread   # prints {"temperature": ..., "humidity": ...}
  interval: 10s
  median: 3               # median of the last 3 samples, against single glitches
  # dropout filter — these are the built-in defaults, shown for completeness
  ref_window: 1h          # history kept for the reference median
  max_drop: 8             # never reject a fall smaller than this...
  fall_rate: 30           # ...or slower than this, in %RH per minute
  ref_min: 30             # samples needed before the filter has an opinion

trigger:
  rise_points: 3          # a shower is +3 %RH...
  rise_window: 5m         # ...within 5 minutes
  max_gap: 90s            # a hole longer than this voids the window
  absolute_on: 0          # 0 = off; else also trigger at or above this %RH
  cooldown: 10m           # no new trigger this soon after our own HIGH
  house_max_share: 1.0    # subtract 100 % of the other rooms' rise
  rise_max: 12            # a bigger rise than this is a sensor fault
  house_divergence_max: 35  # so is a bathroom this far from the house level
  maintain:
    enabled: true
    points: 10            # re-boost while humidity >= anchor + 10
    max_cycles: 4         # at most 4 re-boosts...
    max_duration: 90m     # ...and 90 minutes, whichever comes first
    max_spread: 12        # refuse an anchor this far below the trigger

release:
  mode: none              # let the box's own timer end HIGH
  "off": 65               # threshold mode only: back to AUTO at or below this
  min_run: 15m            # threshold mode only: never release sooner
  max_run: 45m            # threshold cap, or the length in timer mode

baseline:
  enabled: true
  interval: 1m            # poll Zabbix once a minute
  timeout: 5s             # per request; the sampler never waits on it
  backoff: 5m             # after 3 failures, poll this slowly
  zabbix:
    url: https://zabbix.example.com/api_jsonrpc.php
    token_file: /etc/duco/zabbix.token   # read-only API token, mode 0600
    ca_file: /etc/duco/zabbix-ca.pem
    sources:              # the other rooms the bathroom is compared against
      - {name: same-floor,  host: hallway.example.com,  key: "dht_module.go[h]"}
      - {name: floor-above, host: bedroom.example.com,  key: "dht_module.go[h]"}
      - {name: floor-below, host: living.example.com,   key: "dht_module.go[h]"}

paths:
  status: /run/duco/status.json          # full state, for the panel
  zabbix_out: /run/duco/status.txt       # key=value lines, for Zabbix
  state: /var/lib/duco/ducod-state.json  # survives restarts
  csv_dir: /data/duco/logs               # every sample, one CSV per day

api:
  enabled: true
  listen: "0.0.0.0:8080"
  token_file: /etc/duco/api.token        # bearer token, mode 0600
  allow_auto: true        # callers may hand the box back to AUTO
  queue_size: 8           # queued commands before new ones get a 503
  min_resend: 30s         # ignore a repeat of the same mode this soon

retry_gap: 30s            # after a command fails, wait this long
dry_run: false            # true = decide and log, never transmit

What each part is for:

  • radio is the impersonation from earlier. net is the network ID the SDR capture revealed, node: 2 is the handheld remote whose voice we borrow, and via picks the path: the old breadboard build needed relay node 3 to be heard at all, the soldered one talks to the box directly.
  • sensor says how to get a reading and how much to trust it. ahtread is a small binary that reads the AHT30 over I²C and prints JSON, so swapping sensors means swapping that one line — which is exactly how the dead DHT22 was replaced. The four dropout values are the filter from stage 1 of the diagram above.
  • trigger is the whole diagram in numbers: the rise that counts as a shower, the vetoes that say "that's the sensor, not the room", and maintain, the logic that keeps a long shower ventilated after the box's own timer gives up.
  • release decides who ends a boost. With none the DucoBox does it, the way it would after a press on the wall remote; threshold and timer exist for a box whose own timer is not trusted.
  • baseline is how the rest of the house gets a vote. Three other rooms already report humidity to Zabbix; ducod reads them once a minute with a read-only token. It is deliberately optional: if Zabbix is slow or down, the timeout and backoff keep it out of the way and the bathroom triggers on its own curve.
  • paths are where it reports. The panel reads status.json, Zabbix reads status.txt, and ducod-state.json remembers an ongoing boost, the cooldown and the anchor across a restart — a reboot in the middle of a shower picks up where it left off instead of starting over.
  • api lets other things ask for ventilation: the touch panel's buttons, or a sensor in another room. Every request needs the bearer token; repeats inside 30 seconds are absorbed, because a command holds the radio for a few seconds and a second press only queues noise.
  • dry_run is how a second controller can run safely: it listens, decides and logs everything it would have done, and never transmits. Only one host may impersonate the remote at a time, so the other one always runs with dry_run: true.

How it decides

"Humidity rose, turn the fan on" is the one-line version. What actually runs is a pipeline, because a single humidity sensor in a bathroom lies in more ways than it tells the truth: it drops out, it glitches, it drifts with the weather, and it can fail in a way that looks exactly like a shower. Every sample, every 10 seconds, has to answer three questions in turn.

1. Is the sample worth anything?

flowchart TB S(["every 10 s:
read the sensor"]) --> OK{"read and
decoded?"} OK -->|no| FAIL["count a failure"] OK -->|yes| DROP{"falling faster than
a room can dry?"} DROP -->|yes| REJ["reject it, never
learn it as normal"] DROP -->|no| MED["median of the
last 3 samples"] MED --> WIN{"5-minute window
covered?"} WIN -->|no| GAP["wait: a reboot must
not look like a jump"] WIN -->|yes| RISE(["bathroom rise
over 5 minutes"]) classDef veto fill:#b71c1c,stroke:#7f0000,color:#fff classDef out fill:#1b5e20,stroke:#0b3d0b,color:#fff class REJ veto class RISE out

A sensor that drops out reads as the room suddenly drying, so a fall of more than 8 points at over 30 %RH per minute is thrown away — counted, but never fed into anything, so a run of bad readings cannot teach the daemon that the room is normally dry. The median of three smooths single glitches. And the rise is only computed when at least 70 % of the five minutes has real samples, with no hole longer than 90 seconds.

2. Is it a shower?

flowchart TB RISE(["bathroom rise"]) --> EFF["minus the median rise
of the other rooms"] HOUSE[("other rooms,
via Zabbix")] --> EFF EFF --> TRIG{"3 points
or more?"} TRIG -->|no| QUIET["no shower"] TRIG -->|yes| MAX{"more than
12 points?"} MAX -->|yes| V1["veto:
sensor fault"] MAX -->|no| DIV{"35+ points from
the house level?"} HOUSE -.-> DIV DIV -->|yes| V2["veto:
sensor fault"] DIV -->|no| BUSY{"already HIGH,
or in cooldown?"} BUSY -->|yes| HOLD["wait"] BUSY -->|no| SEND["send HIGH, as
the wall remote"] SEND --> ACK{"box
confirmed?"} ACK -->|no| RETRY["retry on a
later sample"] ACK -->|yes| EP(["boost episode"]) classDef veto fill:#b71c1c,stroke:#7f0000,color:#fff classDef act fill:#1b5e20,stroke:#0b3d0b,color:#fff classDef ext fill:#1565c0,stroke:#0d47a1,color:#fff class V1,V2 veto class SEND,EP act class HOUSE ext
  • The other rooms are what make the threshold low. A weather front pushes every room up together; a shower moves one. Subtracting the house-wide rise lets the trigger sit at just +3 points and still stay quiet on a rainy afternoon.
  • The vetoes exist because faults are louder than showers. A broken sensor jumping 40 points would otherwise be the most convincing trigger the detector ever saw. A rise past 12 points, or a bathroom reading 35 points away from every other room, is the sensor talking about itself.
  • Zabbix is never a dependency. If no other room can be read, the bathroom triggers on its own curve, and the log says it went out unverified.
  • "Already HIGH" expires. The box's last announced mode only counts while it is fresh. A radio that went deaf once left the daemon believing the fan was on — and it held off every shower for 21 hours. Re-sending HIGH to a box already in HIGH costs one harmless packet; never sending it costs the whole point.
  • "Confirmed" means an ACK or the box announcing HIGH. The ACK is a separate packet that sometimes gets lost while the command landed fine; the cooldown (10 minutes) stops the plateau after a shower from firing again.

3. Is the room dry yet?

flowchart TB EP(["boost episode"]) --> ANC{"starting point
plausible?"} ANC -->|no| ONE["one box cycle,
no follow-up"] ANC -->|yes| TIMER["box runs HIGH,
about 15 minutes"] TIMER --> BACK{"box went
back to?"} BACK -->|LOW / MID| HUMAN["a person chose:
stand down"] BACK -->|AUTO| STILL{"anchor + 10
or above?"} STILL -->|no| DONE["dry: done"] STILL -->|yes| CAPS{"under 4 re-boosts
and 90 minutes?"} CAPS -->|no| GIVE["give up"] CAPS -->|yes| AGAIN["re-boost HIGH"] AGAIN --> TIMER classDef veto fill:#b71c1c,stroke:#7f0000,color:#fff classDef act fill:#1b5e20,stroke:#0b3d0b,color:#fff class GIVE veto class AGAIN act

The box holds HIGH for its own timer and then drops back to AUTO by itself — fine for a quick shower, useless after a long one or a second person going in straight after. So the episode remembers the anchor: the humidity the rise started from, not the value that tripped it. Each time the box lets go, the room must still be at least 10 points above that anchor to earn another cycle. Judging against the anchor rather than a fixed number is what makes this work in a house whose normal humidity moves with the weather.

An anchor more than 12 points below the humidity that triggered is refused — it describes a room that gained an impossible amount of water, almost always a sensor dropout at the start of the window. And the caps exist because the input is one sensor: a probe stuck high, or a window left open, must not be able to hold the fan on forever. Re-boosts ignore the cooldown, because "is the room still wet?" is a level question, not a rise.

All the numbers are configuration, not code — these are the values running in the bathroom now.


How it looks

What each part of the screen is telling you:

The bathroom panel, explained

What is next

The controller works. But the panel on the wall was a tiny 160×128 screen, permanently at full brightness, readable only if you walked right up to it — and the electronics were living in an open box.

That is Part 3: a 2.4" touch panel, a light sensor that dims it with the room, and a proper case.

Source code: git.spectronet.org/public-access/ducobox-cc1101

Related Posts

Magic Mirror
linuxinternetDIYtinkeringRaspberry Pi

Magic Mirror

The Fun DIY Anyone Can Do!

3/27/2026Read More →
The Bath Automation: Part 1, Heat Pump NIBE Reader
linuxRaspberry Piheat pumpgolangmonitoring

The Bath Automation: Part 1, Heat Pump NIBE Reader

A Golang reader for NIBE heat pumps using the myUplink developer API, the first part of a larger project

3/27/2026Read More →
Trixie Hostname Persistence on RaspiOS
linuxRaspberry Pi

Trixie Hostname Persistence on RaspiOS

Have you ever need to change the hostname of a RaspiOS (Trixie) and see all your obvious tries fail?

4/17/2026Read More →