868 MHz sensor — reverse-engineering dossier

Silicon Labs Si4x3x framing · 23.000 kBd 2-FSK · 866.7–867.9 MHz hopping · captured with an RTL-SDR Blog V4 in Uster, CH
snapshot 2026-11-15 14:53
frames in dataset 2,035
package 21 files
size 1.9 MB
bundle 579.1 KB
This is a working dossier, not a finished result. Framing, device ID, counter and flags are established and reproducible; the ~54-byte data region is still opaque, and every negative result we obtained is recorded below rather than dropped. A 3.5-hour capture was running when this snapshot was taken — see §5 for the state of that experiment.

Download the whole package (handoff.tar.gz)

Handoff dossier — unknown 868 MHz sensor near Uster (ZH)

Purpose. Everything needed to finish reverse-engineering one specific radio transmission, without access to the receiver. Written for a stronger model (or a patient human) to pick up cold.

One-line status. Physical layer and framing are solid; the payload format is not solved. The remaining blocker is not signal quality (−18 dBFS bursts, 55 dB in-band SNR) but that the payload has been bit-identical in every frame for ≥ 20 minutes, so there is nothing varying to correlate against weather — see §6 for why that may itself be the finding.


1. What is established, with confidence

Claim Confidence Evidence
2-FSK, constant envelope, ±13 kHz two-rail Certain Bimodal discriminator histogram, no AM
Symbol period 43.478 µs = 23.000 kBd, spread 0.004 µs Certain Grid fitted to the alternating preamble + idle runs (see §4)
Preamble = alternating 1010… (0xAA pattern), ends into sync Certain 63 symbols before sync in every clean frame
Sync word = 0x2DD4 (0010110111010100) Certain 31 of 36 clean frames; this is the Silicon Labs Si4x3x sync
Frame = preamble + sync + payload + alternating idle Certain 594 symbols total; idle confirmed as pure alternation
Two frame classes: 9-byte beacon and 51–66-byte data frames High 34 identical beacons + 9 distinct data frames in one capture (§4)
Bursts: 25.9 ms long, every 62.77 ms, hopping a new carrier each time Certain 1,825 bursts across 293 s; 72 distinct carriers in 74 bursts
Hop set 866.71–867.92 MHz (receiver corrected −18.6 ppm) Certain Two independent absolute references agree to 0.3 ppm
rtl_433 cannot decode it Certain 270 s at 866.9 / 867.3 / 867.9 MHz, all decoders, 0 frames
Payload constant for ≥ 20 min Certain 10 s capture and a separate 293 s capture, same 9-byte string

Not a battery-powered weather station reporting normally: the duty cycle is 41 % and the channel is 600 kHz outside the 868.0–868.6 sensor band. The owner says the unit is mains-wired, which removes the battery-lifetime objection to the duty cycle but not the channel or the rate.


2. Signal parameters

centre (hops)   866.71 … 867.92 MHz   (receiver reads 18.6 ppm low)
modulation      2-FSK, h ≈ 1.13, ±13 kHz deviation, constant envelope
symbol rate     ~22 973 Bd from the modulation spectrum; clock scan prefers 22 720 Bd
symbol period   43.5 µs
burst length    25.9 ms  (594 symbols)
burst period    62.77 ms, stdev < 1 ms, occasional skipped slots
duty            41 %
levels          burst −17.4 dBFS, noise −56.1 dBFS, SNR 55 dB in 46 kHz
chip family     Silicon Labs Si4x3x (sync 0x2DD4), i.e. Si443x/Si446x/Si4010 class

Exact timing, hop list and per-burst data: data/phy.json, data/frames.json, data/bitstreams.txt.


3. Frame layout (as decoded)

[ 63 × alternating 1010… ][ 0x2D 0xD4 ][ ~7 data bytes ][ alternating 1010… to end ]
                            sync word     payload            idle filler (~450 symbols)

One complete frame (t = 0.121 s, 867.54721 MHz, 593 symbols):

010101010101010101010101010101010101010101010101010101010101010 0 010110111010100 0001101010100011 …
└──────────────────── preamble, 63 symbols ──────────────────┘   └─ sync 2D D4 ─┘ └─ payload ─

Hex of the whole post-sync string, as reported by the naive splitter: 2DD41AA30918F0420 / 2DD41AA30918F04102 depending on rate.

Payload byte hypotheses are not trustworthy yet — see §6.


4. Frame structure — RESOLVED, and the payload is opaque (third pass)

Clock. A fixed samples-per-symbol grid drifts across 594 symbols; fitting one period to the alternating runs (preamble + idle, both pure 1010…) gives 43.478 µs ± 0.004 µs = 23.000 kBd. Sampling phase is then locked by matching the sync word. Decoding is now stable.

Scale. An 8-minute capture at 867.3 MHz (2.0 GB IQ, deep/long4.iq) yields 2,035 decoded frames, 1,604 byte-aligned: 976 beacons, 405 data frames. The earlier nine data frames were far too few to analyse.

Frame layout (payload begins immediately after the sync — an earlier bug had 15 bits of sync mixed into every "payload", which invalidated the first checksum tests):

2D D4 | 1A A3 09 18 | XX | 41 | 02 | <27…54 bytes>
sync     constant      ↑    ↑    ↑     data region
                    counter flag  type
field evidence
1A A3 09 18 in 1,434 of 1,604 frames — this device's ID
XX a counter: correlates with time at r = 0.988, slope ≈ 6 counts/s; deltas 1–4 across the frames we catch
41 / C1 one flag bit (0x40) — set in 65 of 1,434 frames
02 constant
beacon 7-byte frame with XX = F0, payload stops and the carrier idles for ~450 symbols
data same header with a live counter, then ~54 bytes filling the burst — data frames have almost no idle, unlike beacons

Other devices. ~170 of 1,604 frames carry different prefixes (1a a3 09 41, 1a a3 09 19, 1a 31 e0 82, 1a a3 7a 82 …): several sensors share this channel and this framing.

What the data region resists — all tested, all negative

test result
PN9 de-whitening every seed (0x1FF, 0x001, 0x0AA, 0x155, 0x03F, 0x1F0) × offset (0–16) raises entropy (7.98 vs 7.75 raw) → not whitened
CRC-8 / CRC-16 no poly/init/reflect/xorout reproduces the tail, byte 4, or byte 5
bit-shift 0–7 no shift improves entropy (6.5–6.6 throughout)
fixed bit fields (4–20 bits) no sentinel column; all-ones fields over-represented but spread across positions
832 encoding combinations width 4–16 × bit offset 0–15 × MSB/LSB-first × {plain, differential, Manchester}, scored on |r| at lags 1–12 (an interleaved multi-channel stream correlates at lag = channel count, not lag 1). Best = nibbles, r 0.193 at lag 1, 8× the random baseline (0.025) — but that is the 00/FF padding runs, not periodicity. No lag-k peak anywhere. Manchester rejected: chip pairs not complementary. Digits 0–9 = 60.3 %, so not BCD (BCD would be ~90 %).
same counter value 102 groups share (XX, flag); 0 are identical → the data is not a function of the counter
sliding-window buffer consecutive frames share only 6 % of bytes at the best of 40 shifts (random 0.4 %), no consistent shift → not a chunked buffer dump
concatenated sub-frames sync 2DD4 and header 1AA30918 appear 0 times inside any data region
entropy 7.75 bits/byte over 14,310 bytes (uniform expectation 7.99), with 00/FF bytes 10× over chance

Conclusion. Framing, ID, counter and flags are understood and reproducible. The 54-byte data region is high-entropy with only faint structure — consistent with encryption or compression, not with bit-packed sensor fields. Without the vendor spec or a known-plaintext lever, black-box analysis stops here.

5. What has been tried and failed


6. The payload is frozen — and that is probably the finding

Across 293 s (1,825 bursts, 70 frames demodulated) there were 2 distinct payloads, 69 of them identical. The same string appears in the earlier 10 s capture too. Whatever the fields are, they are not updating.

For a sensor, that means either (a) the radio is transmitting a constant registration/beacon packet rather than measurements, or (b) it is sampling a dead sensor and sending a constant sentinel, or (c) it is not a sensor at all but a link/streaming device that only emits status here and sends data in a mode we have not captured.

Consequence for the next model: weather correlation needs varying payloads. Either capture over hours (temperature/humidity drift should eventually flip low-order bits) or force a change (warm the sensor, cover a solar panel, block a rain gauge).


7. Weather context (for correlation once the payload varies)

Model data, not a station reading — Uster, 2026-11-15, capture window 12:20–12:45 CET:

quantity value likely encodings to search
temperature 9.6 °C 96 (0.1 °C), 0x0960, 9/10 BCD, +40 offset
humidity 79 % 0x4F, 79, nibble-swapped
pressure 1013.0 hPa 10130, 1013, 0x2792, 0x3F5
wind 3.9 km/h from 158° 39 (0.1), 1.1 m/s, dir 158/16 sectors
gusts 9.7 km/h 97
cloud 24 % 24, 0x18

Current payload candidates to test against these: 2DD41AA30918F04102, 2DD41AA30918F82081, D16EA0D51848C7820. Full data: data/weather_uster.json.

Note the incidental near-misses in 1A A3 09 18 F0 41 02: 09 ≈ 9 °C, 18 = 24 (cloud %), 41 = 65. These are almost certainly coincidence at n=1 — do not build on them.


8. Candidate protocols sharing this framing

data/candidates.md lists all 30 rtl_433 decoders that key on 0xAA 0xAA 0x2D 0xD4, with their pulse widths and sources. Reading two or three of them (e.g. ambientweather_wh31e.c, bresser_5in1.c, lacrosse_tx34.c) is the fastest way to learn the field conventions used by this chip family: nibble-packed BCD, inverted bytes, one's-complement check bytes, and how they handle the 0xAA preamble. None matches our timing, but the style is likely shared.


9. Suggested next steps, in order

  1. Stabilise the clock (highest value). Continuous clock recovery, or estimate the rate per burst from the idle alternation, then check whether every burst yields the same payload length. Success criterion: one length in > 95 % of bursts.
  2. Capture long. Hours, not minutes, and log only decoded payloads with timestamps (cheap: no IQ storage needed). Watch for any bit flipping as the day cools.
  3. Force a change if the sensor is reachable: warmth, shade, or a battery pull.
  4. Test structure hypotheses on the stabilised payload: check for a CRC-8/16 over the first N bytes (try polys 0x07, 0x31, 0x1021; LSB and MSB first), a constant ID vs a changing tail, and nibble-swapped BCD against §7.
  5. Identify the chirp-free stream: confirm the hopping sequence is a fixed cycle (log the channel order over hundreds of bursts and look for a period) — that alone may name the chip's FHSS demo firmware.

10. Files

README.md                     this dossier
manifest.json                 file list + sizes + sha256 prefixes
data/phy.json                 measured PHY, calibration, timing, hop set
data/frames.json              every demodulated burst: t, mhz, full bitstring
data/bitstreams.txt           same, one line per burst, tab-separated
data/weather_uster.json       weather at capture time + search targets
data/candidates.md            the 30 Silicon Labs decoders (pulse width, source file)
iq/excerpt_867.6MHz_2048k.u8  300 ms of raw IQ, interleaved uint8, contains full bursts
iq/excerpt_867.6MHz_2048k.json format and provenance for the above
code/decode.py                self-contained decoder — start here, one command
code/locked_demod.py          phase-locked decoder (the one that resolved §4)
code/checksum_pn9.py          PN9 + CRC-8/16 tests (all negative)
code/bulk_demod.py            streaming decoder for multi-GB captures
code/encoding_sweep.py        832-encoding autocorrelation sweep (§4)
data/bulk4.jsonl              2,035 frames from the 8-minute capture
data/locked_frames.json       payload bits per burst from the phase-locked decoder
code/fsk_demod.py             single-burst demodulator + plots (numpy/scipy/matplotlib)
code/clock_scan.py            the rate sweep that produced §4
code/all_frames.py            batch demodulator that produced frames.json

Reproduce from the IQ excerpt — verified working, prints 4 bursts, sync at symbol 63:

python3 code/decode.py iq/excerpt_867.6MHz_2048k.u8 867.6 22720

Output for the excerpt (post-sync bits, idle filler truncated):

  0 t=  0.064s  866.99724 MHz dur= 25.9 ms  preamble= 63  sync@63  bits=587
    post-sync: 0001101010100011000010010001100011110000010000010000001001101010…
  1 t=  0.127s  866.69756 MHz dur= 25.9 ms  preamble= 63  sync@63  bits=587
    post-sync: 0001101010100011000010010001100011110000100000100000010010101010…
  2 t=  0.190s  866.79727 MHz dur= 25.9 ms  preamble= 62  sync@62  bits=586
  3 t=  0.253s  867.79727 MHz dur= 25.9 ms  preamble= 62  sync@62  bits=586

Note bursts 0 and 1 disagree in the tail by one bit position — visible clock slip, exactly the problem described in §4. Compare at other rates (22680, 22973, 23120, 23640) and look for the rate at which all four agree.


11. Provenance

Captured 2026-11-15, ~12:15–12:45 CET, Uster/ZH area, RTL-SDR Blog V4 (R828D) via rtl-sdr 2.0.3, 2.048 MS/s, 8-bit IQ, gain 40.2 dB, stock whip indoors. Antenna gain at 868 MHz unknown, so all absolute levels carry ±10 dB of uncertainty; the frequency axis is good to ±20 ppm after calibration against ADS-B 1090.000 MHz and the 869.525 MHz SRD channel.

Package contents

Every file is listed with its size and SHA-256 prefix. The tarball above contains all of them.

pathsizesha256
README.md13.5 KBf3c9e9eaf6185784
code/all_frames.py3.4 KB335385fca465cd08
code/analyse_bulk.py4.7 KBd16a5efd2b0cc830
code/bulk_demod.py5.3 KBe4bd86f5d80b2610
code/checksum_pn9.py3.8 KB1ac8cc51f0a0aab1
code/clock_scan.py4.0 KB89c5bf895d8f3d2e
code/decode.py3.1 KB7f95798379c491f0
code/encoding_sweep.py4.3 KBb48c1d336378173d
code/fsk_demod.py4.5 KBf4a4d9f7e2b71840
code/grid_demod.py5.1 KBccd1dde2078307c6
code/locked_demod.py5.8 KB887e076cfe0533c6
data/bitstreams.txt29.7 KBa139c5db005d05ff
data/bulk4.jsonl615.2 KB9d5e8baeba6a3e0a
data/candidates.md2.9 KB474ba172d320abd7
data/frames.json36.2 KBc3573d32940f62e7
data/locked_frames.json44.6 KB7dd6b278c2325d52
data/phy.json1.9 KBf741a1b45584bf05
data/weather_uster.json792 B7d7d3906157f063b
iq/excerpt_867.6MHz_2048k.json330 B024ff630b3c346de
iq/excerpt_867.6MHz_2048k.u81.2 MBaa986cd8d1c716b4
iq/excerpt_shape.npy136 Be48a60e5a7858c60