# 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

* **rtl_433**, 270 s across 866.9 / 867.3 / 867.9 MHz, every decoder enabled → 0 frames. Not a
  tuning problem: the sync word matches, but our 43.5 µs symbols match none of the 30 decoders
  (they expect 26–500 µs; the weather ones want 56–58 µs).
* **Payload splitting by idle-resumption** → unstable length (see §4).
* **Second frame type hidden in the 38 rejected bursts** → ruled out. 0 of 38 contain the sync;
  they are bit-clock slips, not another format.
* **Correlating bytes with weather** → impossible so far because the payload never changes (§6).

---

## 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:

```bash
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.
