- C++ 86.5%
- Python 13.5%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
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.
|
||
| components/r900 | ||
| .gitignore | ||
| example.yaml | ||
| README.md | ||
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/misoto thespi:bus,csntocs_pin, and GDO0 togdo0_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):
- Install the ESPHome add-on in Home Assistant (Settings → Add-ons → Add-on Store), if you haven't already.
- 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__.pyetc.). - Open the ESPHome dashboard (HA sidebar → ESPHome), create a new device,
and use
example.yamlin this repo as your starting config — it already points at the localcomponents/folder:external_components: - source: type: local path: components - Fill in your own
spi:/pin assignments, wire up the CC1101 per Hardware above, and leavemeter_idblank/unset at first —log_unknown_meters: truewill log every R900 ID heard so you can find yours in the ESPHome dashboard's logs. - 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. - Add
sensor:/binary_sensor:/text_sensor:entries with the meter ID you found, perexample.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_mseach), 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 missesFOLLOW_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)
- CC1101 in
PKT_FORMAT=3(async serial) + OOK, GDO0 outputs the raw envelope-detected bitstream. - A GPIO edge interrupt timestamps every transition;
loop()turns the intervals into constant-level runs and each run intochip_period = 1/32768 schips. 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. - The rolling chip buffer is searched for the 56-chip preamble/sync
0x555555A9666965(Hamming distance ≤ 8 accepted, matching the sniffer's tolerance). - The following 168 chips are 42 four-chip groups, each exactly two-of-four bits set, decoded to base-6 digits.
- 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. - 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 isd0*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) matchesrs_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'ssweep/fields.pyR900_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'sleak_nowreadings 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
ORDERsequence): 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'ssubghz-sweepproject (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/4data 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.MDMCFG4channel bandwidth 135 kHz. 116–162 kHz is a plateau (~50–54 % of bursts decoded), 81 kHz 47 %, 271 kHz 19 %. TheCHANBW_M = 0settings (102 and 203 kHz) did much worse (12 %, 9 %) than their neighbours on the tested module, so avoid them.AGCCTRL0OOK 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.