No description
  • C++ 86.5%
  • Python 13.5%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Quantum 544cd80202 r900: fix channel map and demod so meters actually decode
Nothing was ever decoded. Verified on hardware against live meters (timing
predicted from subghz-sweep's hop law, cross-checked with rtlamr), the
causes were:

- Hop table values are channel indices into the 50-channel list, not comb
  slots. Map through r900_comb_slot() ({0..16} u {29..61}); before, 12 of
  50 hops landed in the unused 913.31-914.75 MHz hole and most others were
  off-channel.
- The chip buffer was trimmed to 288 chips *before* try_decode_frame_()
  ran, and cleared on any long idle gap, so most frames were discarded
  unread. Trim after decoding instead, keeping one frame minus a chip.
- CC1101 async OOK slicer distortion: high runs come out ~8 us short and
  low runs ~8 us long, and noise splits runs with few-us glitches. Plain
  rounding slipped whole chips. Runs are now bias-corrected, glitches
  merged back into their neighbours, and each run's level is taken from
  the edge that started it (inferring !level of the closing edge ate the
  last digit of every frame).
- DRATE was 4.8 kBaud, which low-passes a 32.768 kchip/s signal even in
  async mode; now 99.97 kBaud.
- RX bandwidth 135 kHz and 4 dB OOK decision boundary, each picked by an
  interleaved decode-rate comparison on live meters (see README).
- The RSSI read sent 0xB4 without the burst bit, i.e. an SRX strobe.
- AUTO_LOAD sensor/binary_sensor/text_sensor so a discovery-only config
  (no meter_id yet, as the README suggests) compiles.

Also log follow-mode lock/miss events, document that a 433 MHz CC1101 board
is deaf at 915 MHz, and correct example.yaml's meter ID hint: it is the R900
radio ID, not the serial number on the register face.
2026-09-23 00:01:20 -04:00
components/r900 r900: fix channel map and demod so meters actually decode 2026-09-23 00:01:20 -04:00
.gitignore r900: fix channel map and demod so meters actually decode 2026-09-23 00:01:20 -04:00
example.yaml r900: fix channel map and demod so meters actually decode 2026-09-23 00:01:20 -04:00
README.md r900: fix channel map and demod so meters actually decode 2026-09-23 00:01:20 -04:00

ESPHome R900 (Neptune water meter) follower — CC1101

An ESPHome external component that turns a CC1101 sub-GHz module + any ESPHome-supported MCU (ESP32 recommended) into a Neptune R900 water-meter receiver: it hops the same 50-channel comb the meters use, demodulates the OOK chip stream in software, applies the RS(31,26)/GF(32) FEC, and exposes consumption / leak / backflow as normal Home Assistant entities via the ESPHome API.

The protocol constants (channel grid, hop order, preamble, chip rate, field offsets, RS parameters) were pulled from this machine's subghz-sweep project (docs/r900.md, sweep/protocols.py, sweep/follow.py, sweep/bench/r900_hop/law.py), which decodes R900 from an RTL-SDR capture. This component reimplements the same math on-device.

Hardware

  • Any ESP32 (needs a few KB of RAM for the edge buffer + enough CPU headroom to service a GPIO interrupt at chip rate) + a CC1101 module (SPI + GDO0).
  • The CC1101 module must be an 868/915 MHz build. The chip tunes 915 MHz either way, but the common "433 MHz" boards carry a 433 MHz balun and low-pass filter that leave them effectively deaf at 915 MHz (tested: no R900 energy above the noise floor at all). Removing that board's low-pass filter recovers enough for nearby meters, but a proper 868/915 module is the real fix. Use a 915 MHz antenna (~8.2 cm quarter wave).
  • Wire sck/mosi/miso to the spi: bus, csn to cs_pin, and GDO0 to gdo0_pin — GDO0 is configured as the CC1101's raw asynchronous serial data output, and the ESP reads it as a plain OOK bitstream. GDO2 is unused for RX-only operation.

Installation (via the ESPHome add-on in Home Assistant)

Recommended: local copy. This avoids network-dependent builds entirely (no GitHub clone at compile time — a common source of github:// external-component failures behind flaky DNS/proxies or on rate-limited connections):

  1. Install the ESPHome add-on in Home Assistant (Settings → Add-ons → Add-on Store), if you haven't already.
  2. Using the Samba or File editor add-on (or SSH), copy this project's components/r900/ folder into your Home Assistant config at /config/esphome/components/r900/ (so the final path is /config/esphome/components/r900/__init__.py etc.).
  3. Open the ESPHome dashboard (HA sidebar → ESPHome), create a new device, and use example.yaml in this repo as your starting config — it already points at the local components/ folder:
    external_components:
      - source:
          type: local
          path: components
    
  4. Fill in your own spi:/pin assignments, wire up the CC1101 per Hardware above, and leave meter_id blank/unset at first — log_unknown_meters: true will log every R900 ID heard so you can find yours in the ESPHome dashboard's logs.
  5. Click Install in the dashboard (USB for the first flash, OTA after). Once it's on your Wi-Fi with the ESPHome api: component running, Home Assistant auto-discovers it (Settings → Devices & Services shows a "Discovered" prompt) — the sensors/binary_sensors show up as one device, no separate HA-side integration to install.
  6. Add sensor:/binary_sensor:/text_sensor: entries with the meter ID you found, per example.yaml, and reflash.

Alternative: github:// source, once you've pushed this to your own GitHub repo — more convenient for pulling updates, but depends on the HA host being able to reach GitHub at build time:

external_components:
  - source: github://yourname/esphome-r900@main
    components: [r900]

If this fails to build, the usual causes are: no/limited internet from the HA host, a stale cached clone (Settings → clear ESPHome build files, or the dashboard's "Refresh" button), or GitHub rate-limiting on a shared connection. When in doubt, fall back to the local-copy method above — it has no such dependency.

How it hops: scan-to-acquire, then camp-and-follow

Every R900 meter transmits once per its own ~14.000 s crystal period, on a channel chosen from the fixed order:

f(n) = 911.0815 MHz + n * 131.072 kHz,   n in {0..16} u {29..61}   (50 channels)
ORDER = [1,26,11,36,21,46,6,31,16,41, 2,27,12,37,22,47,7,32,17,42,
         3,28,13,38,23,48,8,33,18,43, 4,29,14,39,24,49,9,34,19,44,
         0,25,10,35,20,45,5,30,15,40]

ORDER holds channel indices 0..49 into the 50 channels in ascending frequency, not comb slots n: index i is slot n = i for i < 17 and n = i + 12 above that (skipping the unused 913.31–914.75 MHz hole).

Only a per-meter phase offset into ORDER differs between meters — the sequence itself advances by exactly one position every period, for every meter. That means one confirmed decode tells you exactly which channel and roughly when the next transmission from that same meter will be, without needing to predict phase from scratch.

Pure round-robin scanning is not reliable for continuously following a specific meter. A full scan cycle at the default 220 ms/channel dwell is 50 x 220 ms ≈ 11 s, which doesn't evenly divide the meter's ~14 s period — so your scan phase and the meter's transmit phase drift against each other run to run, and whether you're tuned to the right channel at the right 15 ms instant is essentially down to luck each cycle. It'll catch a given meter eventually, but with no bound on how long "eventually" takes.

So this component uses two modes, selected automatically by whether you've configured any meters (i.e. added sensor:/binary_sensor: entries with a meter_id):

  • No meters configured -> discovery scan. Continuous round-robin across all 50 channels (dwell_ms each), logging every meter ID heard (log_unknown_meters: true) so you can find your meter's ID.
  • Meters configured -> acquire, then camp-and-follow. Round-robin scans until each configured meter ID has been decoded once. From that point, each target's next channel is known exactly (ORDER[(pos+1) % 50]), and its next transmit time is predicted from the last decode time plus a period estimate that's refined on every subsequent hit (accounts for each meter's individual crystal drift). The radio parks on whichever target's window is coming up soonest, tuning in a little early (FOLLOW_GUARD_MS, 500 ms) to absorb ESP clock drift. If a target misses FOLLOW_MAX_MISSES (4) consecutive predicted windows, lock is dropped and it falls back to a full rescan to reacquire.

With multiple configured meters, they're served by priority of nearest predicted window — one CC1101 can only listen to one channel at a time, so if two targets' windows overlap, whichever is dropped that cycle just waits one more period (14 s) for its next chance, rather than being lost entirely.

Decode pipeline (on-device)

  1. CC1101 in PKT_FORMAT=3 (async serial) + OOK, GDO0 outputs the raw envelope-detected bitstream.
  2. A GPIO edge interrupt timestamps every transition; loop() turns the intervals into constant-level runs and each run into chip_period = 1/32768 s chips. Two corrections, both measured on real bursts, make that work: the CC1101's OOK slicer returns high runs ~8 µs short and low runs ~8 µs long (a 1-chip high reads ~22 µs), so each run is bias-corrected before rounding; and noise occasionally splits one run with a few-µs dropout, so any run shorter than half a chip after correction is merged back into its neighbours. Without both, whole chips slip and no frame passes the digit checks.
  3. The rolling chip buffer is searched for the 56-chip preamble/sync 0x555555A9666965 (Hamming distance ≤ 8 accepted, matching the sniffer's tolerance).
  4. The following 168 chips are 42 four-chip groups, each exactly two-of-four bits set, decoded to base-6 digits.
  5. Digit pairs are packed into 21 five-bit GF(32) symbols (16 data + 5 RS parity, RS(31,26) shortened, primitive polynomial x^5+x^2+1), and Berlekamp-Massey/Chien/Forney correct up to 2 symbol errors.
  6. Fields are pulled from the corrected 105-bit frame per the offsets in docs/r900.md / sweep/fields.py (meter_id, consumption, leak, leaknow, backflow, ...).

Provenance / confidence of the numbers used

  • Digit/symbol packing: confirmed verbatim against bemasher/rtlamr's actual Go source (r900/r900.go). Each symbol is d0*6 + d1 (two base-6 digits read as a base-6 number), and any result > 31 is an invalid symbol — rejected outright, not masked/wrapped (an earlier draft of this component incorrectly used & 0x1F, which would silently accept corrupt symbols as wrong values; that's fixed now). rtlamr's RS/GF(32) setup (field order 32, poly 37, generator 2, syndrome roots from 29, 5 parity symbols, 16 data symbols) matches rs_correct_() and also matches subghz-sweep's independently-implemented decoder.
  • Field bit offsets: meter_id = bits[0:32], backflow = bits[46:48], consumption = bits[48:72] (low 24 bits), wrap = bits[72:75] (high 3 bits of the consumption register — combined they're a 27-bit value in tenths of a gallon, converted to gallons before publishing), leak_days = bits[75:78], leak_now = bits[78:80] (0=none, 1=intermittent, 2=continuous). This intentionally follows subghz-sweep's sweep/fields.py R900_FIELDS layout (itself the rtl_433-style 3/3 wrap+leak_days cut) rather than rtlamr's 2/4 unkn3+leak cut at the same bit range — subghz-sweep validated the 3/3 cut against real captures as more accurate (differs from rtlamr's cut on only 7 of 2559 frames, all judged rtlamr false-accepts there). If your meter's leak_now readings look wrong, this 3-bit/2-bit boundary at offset 75 vs 74 is the first thing to double-check against a fresh capture.
  • Frequency hop schedule (911.0815 MHz base, 131.072 kHz step, 50 channels, the specific ORDER sequence): not confirmed against rtlamr — rtlamr doesn't hop at all, it just tunes to one fixed center frequency (912.38 MHz) and demodulates within a wide enough capture bandwidth to catch the meter wherever it lands. This schedule instead comes solely from this machine's subghz-sweep project (docs/r900.md / sweep/bench/r900_hop/law.py), which fit it against real captures with 590/604 (97.7%) accuracy across 9 meters — good odds, but if channel hopping in the field doesn't line up, that table is the first thing to re-derive from your own captures rather than the digit-decode math above.
  • Preamble, chip rate, RS parameters: consistent across both sources and treated as solid.

CC1101 register notes

cc1101_init_radio_() starts from TI AN047-style OOK/ASK values (autocalibrate on idle→RX). Three registers were then tuned against live meters, each by interleaving settings window-by-window over the meters' predicted transmissions and counting decodes:

  • MDMCFG3/4 data rate 99.97 kBaud. Even in async serial mode the demodulator output is sampled against the programmed data rate; the old 4.8 kBaud setting low-passed the 32.768 kchip/s signal into mush.
  • MDMCFG4 channel bandwidth 135 kHz. 116–162 kHz is a plateau (~50–54 % of bursts decoded), 81 kHz 47 %, 271 kHz 19 %. The CHANBW_M = 0 settings (102 and 203 kHz) did much worse (12 %, 9 %) than their neighbours on the tested module, so avoid them.
  • AGCCTRL0 OOK decision boundary 4 dB (FILTER_LENGTH = 0): 29 % of bursts decoded vs 8 % at 8 dB.

Limitations

  • One CC1101 can only listen to one channel at a time — simultaneous meters on different channels within the same dwell window aren't caught until the hop cycle reaches their channel.
  • No transmit/spoofing support — this is receive-only, by design.
  • rs_correct_() corrects up to 2 symbol errors; frames needing more are dropped (not published) rather than guessed at.