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 value0xFFFF— 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 computing0x8005on 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
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 |

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 transmitWhat each part is for:
radiois the impersonation from earlier.netis the network ID the SDR capture revealed,node: 2is the handheld remote whose voice we borrow, andviapicks the path: the old breadboard build needed relay node 3 to be heard at all, the soldered one talks to the box directly.sensorsays how to get a reading and how much to trust it.ahtreadis 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.triggeris the whole diagram in numbers: the rise that counts as a shower, the vetoes that say "that's the sensor, not the room", andmaintain, the logic that keeps a long shower ventilated after the box's own timer gives up.releasedecides who ends a boost. Withnonethe DucoBox does it, the way it would after a press on the wall remote;thresholdandtimerexist for a box whose own timer is not trusted.baselineis how the rest of the house gets a vote. Three other rooms already report humidity to Zabbix;ducodreads 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.pathsare where it reports. The panel readsstatus.json, Zabbix readsstatus.txt, andducod-state.jsonremembers 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.apilets 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_runis 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 withdry_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?
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?
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?
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:

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




