868.2 MHz (channel 117) link between inner/outer unit, decoded to MQTT for Home Assistant. Chip: Nordic nRF9E5 (nRF905 transceiver + 8051 MCU) on U1.
Decoder lives in the fork, not this repo:
andy778/rtl_433, branch add-uponor-clean1.
It is enabled, CRC-gated, and tested end-to-end against a real capture
(zero false positives on an unrelated 433 MHz capture).
Building a decoder, a TX implementation, or extending this one? Start with docs/rtl433-implementation-spec.md — a self-contained spec with test vectors, meant to be implementable without reading the rest of this page first. This page is the narrative/provenance; that one is the reference.
rtl_sdr -f 868.20M -s 1024000 -g 49 -n $((1024000*300)) capture.cu8- Tune 150 kHz low (868.20M, not 868.35M) — the real carrier clears the RTL-SDR's DC self-mixing spike; centered on it, rtl_433 sees nothing.
-g 49: high gain, low gain sits near the noise floor.- Cadence: one event every ~62 s, a two-packet exchange (poll + response).
- Boot burst (all 5 alarm types):
rtl_433 -R 321 -f 868.2M -Y minmax -F json:boot.json, then power-cycle the outer unit.FUN_e372blasts all states on reconnect.
Flex decoder, before the C decoder existed:
rtl_433 -f 868.20M -Y minmax -X 'n=uclean1,m=FSK_MC_ZEROBIT,s=10,r=100,bits>=200,invert'm=FSK_MC_ZEROBIT decodes Manchester at the demodulator level (the decode_mc
post-filter fails — phase-offset, noisy packets). -Y minmax is required or
the weaker far-unit packet fragments. invert matches raw 01→1 / 10→0.
[preamble] [address: EA EA EA EA] [payload: 32 B] [CRC-16: 2 B]
Every field below is set by one write: F_156/F_120
(radio_config_resend/peripheral_init, identical
bytes at boot and on resend) puts opcode W_RF_CONFIG (0x00) + 10 config
bytes (CR0–CR9) on the nRF905's SPI bus, then a separate W_TX_ADDRESS
(0x22) write repeats the address. So the config write itself — not just a
matching capture — is the source for each row, decoded against the
config-register layout in register-map.md:
| Field | Value | Config byte(s) | Firmware detail |
|---|---|---|---|
| Carrier | 868.2 MHz (channel 117) | CR0=0x75, CR1=0x06 |
9-bit CH_NO=0x075=117, HFREQ_PLL=1: fRF = (422.4 + 117/10) × 2 = 868.2 MHz — matches the measured carrier exactly. PA_PWR=01 = −2 dBm output |
| Line rate | 100 kbps, Manchester-coded on air | — | capture only — neither firmware does Manchester in software, it's a PHY effect |
| Address | EA EA EA EA, 4 bytes |
CR2=0x44 (width); CR5–8 + W_TX_ADDRESS = EA EA EA EA |
RX_AFW=TX_AFW=4; the same 4 bytes are written twice — once as RX_ADDRESS (RX match), once as TX_ADDRESS (TX prefix) — confirmed on-air |
| Payload | 32 bytes | CR3=0x20, CR4=0x20 |
RX_PW=TX_PW=32 |
| CRC | CRC-16, poly 0x1021, init 0xFFFF, over address+payload |
CR9=0xD4 |
CRC_MODE=1, CRC_EN=1 (XOF=010 = 16 MHz crystal); verified live by tools/rtl433/probe_capture.py and in the decoder |
Confirmed by a ~20 h log spanning a real counter tick: both heartbeat frames stayed byte-identical the whole span — the counter is never on air. The only variation was a ~55 min burst matching a treatment batch running.
The full byte map lives in docs/ghidra/README.md (the
canonical source, derived from the MC9S08 serializer FUN_ce01). In short, the
32-byte payload is a messaging protocol, not telemetry:
[0]=12+N [1]=CC [2]=6E [3]=flags|dir [4:10]=two node-IDs [10]=N [11..]=body [..]=stale
- Bytes
[4:10]are two 3-byte node IDs, a poll/ack cookie — each side sends "my ID" and echoes "your ID" (dirbyte0x40poll /0x80response). Stable per device across captures days apart: outer unit80 0D 6E, infopanelC0 23 4B. - Byte
[10]=N, the message-body length;[11 .. 10+N]= the body.N=0ACK,N=1heartbeat (0x24),N=2alarm ([type, state]). - Bytes past
10+Nare stale buffer, not data — they vary run-to-run for identical logical frames (CRC still passes; U2 CRCs whatever it sends).
Confirmed absent from the payload: CYCLE COUNTER (RAM 0x0607) and the
PLANT STATUS phase (RAM 0x0613/0x0614) — neither is radio'd out. The panel
has no numeric display, so the radio only carries what the panel can show: the
5 alarm symbols + status. Get the counter/phase via the serial path instead:
docs/u2-serial-protocol.md.
- 4 of 5 alarm types confirmed on air (
0x20/0x21/0x22/0x23, boot burst 2026-07-06, allstate=0). Only0x26(device_fault) still needs catching — it was dropped in that burst. Recipe below. - Every capture so far is
state=0(all-clear). Nostate=1frame has ever been recorded, so the state byte's meaning is firmware-derived, not observed. The same recipe fixes both gaps at once.
Rather than waiting for a real failure or hoping for a lucky boot burst, the
actuator self-test can be made to fail on demand. FUN_a138 walks all 7
output channels and flags any whose readback bit never clears; FUN_c9e1
turns that into E040–E045, which maps to radio type 0x26 (see
eeprom-map.md). So an open-circuit on one channel is exactly
the condition it looks for:
- Power off the outer unit at the mains.
- Disconnect one actuator from the output row's screw terminals — e.g.
MV5(aeration,E045). One channel only, so the resulting code is unambiguous. - Start the capture first:
rtl_433 -R 321 -f 868.2M -Y minmax -F json:fault.json - Power on. The self-test runs during startup; the fault should broadcast as
msg_type=0x26,msg_state=1. - Power off, reconnect the actuator, power on, and confirm the panel returns
to
E000/ all-clear (which should also yield astate=0frame — the clear transition, also never yet captured).
Expect this to also raise E045 on the display and, if configured, send an
SMS — it is a real fault as far as the unit is concerned, just an induced one.
Keep it brief and don't run a treatment cycle with an actuator disconnected.
The decoder lives on the add-uponor-clean1 branch of
andy778/rtl_433, a fork of upstream
merbanan/rtl_433 — not yet upstreamed.
git clone https://github.com/andy778/rtl_433 && cd rtl_433
git checkout add-uponor-clean1
mkdir build && cd build && cmake .. && make -j
./src/rtl_433 -r capture.cu8 -F json # decoder is enabled, no -R neededFor live capture: tune ~150 kHz low (-f 868.20M) so the carrier clears the
RTL-SDR DC spike, -Y minmax (needed for the weaker far-unit packet), MQTT via
-F "mqtt://localhost:1883,events=uclean1/events".