Patrick Lidstone
Self-hosted

ISM-band decoder: a native, config-driven rtl_433-style device receiver

A clean-room 433/868/915 MHz ISM receiver that turns SDR IQ into a pulse train, slices it per modulation, and matches it against a declarative catalogue of device definitions — so new sensors are data, not code.

Rafe project · app/radio/ism.py, app/radio/rtl433/ · SDR-fed (RTL-SDR)


Abstract

The ISM decoder is a self-contained receiver for the crowded unlicensed bands (433.92 / 868.3 / 915 MHz) where consumer telemetry lives: weather-station thermo-hygrometers, tyre-pressure sensors, doorbells, remotes, and energy meters. It reproduces the architecture of rtl_433one demodulator front-end feeding a table of declarative device definitions — but as a compact NumPy implementation with no external decoder binary and no SoapySDR. Complex baseband IQ arrives from the project's low-level ctypes SDR layer (the same sdr_sources.py that feeds native AIS), is reduced to an amplitude envelope, and is sliced into a pulse train of (mark_us, gap_us) durations. A config-driven engine then applies each device definition in a catalogue — modulation kind, short/long/reset timings, accepted bit-count, field bit-layout, and checksum spec — to that pulse train, and emits any decode that fits the timing, bit-count, and CRC gates. Two products go to the UI: the decoded device events (model + named physical fields) and the raw pulse-timing frames that drive a URH-style square-wave pulse scope. The novelty for this project is the same one rtl_433 exploits: because a device is a dict of protocol facts, the hundreds of consumer protocols become a catalogue you extend by editing data, not by writing and compiling a new decoder. The shipped catalogue seeds six representative devices across the two OOK pulse-coding families (PWM and PPM) plus a Manchester/FSK TPMS entry; the engine, checksums, and full IQ→event pipeline are covered by a self-consistent test suite (encode a frame → synthesise OOK → extract → decode → assert the fields survive round-trip).


1. Background

1.1 What is on the ISM bands

The 433.05–434.79 MHz (EU), 863–870 MHz (EU SRD), and 902–928 MHz (US) unlicensed "ISM/SRD" bands are saturated with tiny battery-powered transmitters that beacon a handful of physical readings every few seconds: outdoor temperature/humidity sensors talking to a weather console, TPMS sensors reporting tyre pressure and temperature, remote doorbells and PIRs, garage/gate remotes, soil and pool probes, and utility (gas/water/power) meters. Each is a simplex device: it wakes, transmits a short repeated frame with an identity, one or two measurements, a battery flag and usually a checksum, then sleeps. There is no handshake and no standard — every vendor invents its own on-air format.

1.2 Why a config-driven engine (the rtl_433 idea)

Because there is no standard, a receiver that wants to cover the ecosystem must know hundreds of idiosyncratic formats. rtl_433's insight is that these formats differ only along a small number of axes — modulation, symbol timing, bit count, field layout, checksum — and are otherwise the same problem. So instead of hundreds of bespoke decoders, you write one demodulator plus a catalogue of declarative device descriptions, and adding a device is a data edit. This receiver adopts that philosophy verbatim; the package docstring states it directly:

"Instead of hard-coding hundreds of device decoders, devices are described in a declarative config … modulation + timing + bit count + fields + checksum. The engine applies any config to a demodulated pulse train — so new devices are data, not code (the same philosophy as rtl_433's own -X 'flex' decoder). Clean-room: a config is the protocol facts." — app/radio/rtl433/__init__.py

The "clean-room" note matters: the catalogue transcribes each protocol's documented/observed timing and layout parameters (protocol facts), not any code (devices.py).

1.3 Modulation and pulse-coding families

Nearly all of these devices use OOK/ASK — On-Off Keying / Amplitude-Shift Keying — where the carrier is simply switched on and off. The information is not in the amplitude levels themselves but in the timing of the on/off transitions, which is why the natural intermediate representation is a pulse train: a list of (mark, gap) durations, "mark" being carrier-on and "gap" carrier-off. Three pulse-coding families dominate, and the engine slices all three:

  • PWM (Pulse-Width Modulation) — every bit is one on-pulse; a short mark means one bit value, a long mark the other. The gaps are ~constant.
  • PPM / PWM-gap (Pulse-Position/-Distance Modulation) — the marks are ~constant and the gap length carries the bit (short gap = 0, long gap = 1). Common on weather sensors (Nexus, Prologue).
  • Manchester (bi-phase) — a fixed symbol clock; each bit is a transition within its period (a level pair), so the code is self-clocking and DC-balanced. Common on FSK devices such as TPMS. "PCM" (raw level-per-clock, no bi-phase encoding) is the fourth, degenerate case.

A minority of devices — notably TPMS — use FSK (frequency-shift keying) rather than OOK; there the two symbols are two frequencies, so the front-end must frequency-discriminate first and then the same pulse/level extraction applies. The library carries an FM discriminator for exactly this (pulse.frequency(), pulse.py); see §3.5 and §8 for its wiring status.


2. System overview

        SDR (RTL-SDR @ 250 kS/s)                       app/radio/ism.py
  ┌──────────────────────────────┐
  │ sdr_sources.open_source()    │  complex64 IQ, 25 000-sample (~100 ms) reads
  └──────────────┬───────────────┘
                 ▼
        pulse.magnitude(iq)          |IQ| envelope (OOK)     ── rtl433/pulse.py
                 ▼
        pulse.extract_pulses(mag …)  /  extract_pulses_fsk(iq …)
                 │   Schmitt-trigger threshold → on/off runs
                 │   → packets of (mark_us, gap_us), split at the 9 ms reset gap
                 ▼
        for each packet  (≥ 8 pulses):
           ├─▶ {"event":"pulses", …}  ── raw timings → URH-style scope
           └─▶ engine.decode_all(pkt, subset)         ── rtl433/engine.py
                     │  per device config:
                     │   _demod()  pulse slicer (PWM/PPM/MC/PCM) → bit list
                     │   bit-count gate  →  checksum.verify()  →  field extract
                     ▼
                  {"event":"decode", "device": {"model":…, <fields>}}
                                        │
                     manager._on_ism → WS  {"t":"ism", …}  → app.js onIsm()

The receiver proper is app/radio/ism.py (the SDR loop and event emitter); the demodulation library is the app/radio/rtl433/ package:

file role
app/radio/ism.py IsmReceiver: SDR read loop, envelope, packetise, emit events
app/radio/rtl433/pulse.py IQ → magnitude / FM-discriminator → (mark,gap) pulse train
app/radio/rtl433/engine.py per-modulation pulse slicer, bit assembly, field extract, decode/encode
app/radio/rtl433/devices.py the device catalogue (dicts) + JSON loader + UI summary
app/radio/rtl433/checksum.py CRC-8 / sum-8 / xor-8 validators referenced by configs
app/radio/manager.py subscribe/ism_local() wiring; emits catalogue on subscribe
app/main.py WebSocket bridge (cmd:"ism" in, {"t":"ism"} out)
app/static/app.js, index.html URH-style pulse scope + decode list + controls
test_rtl433.py round-trip + pipeline + catalogue self-consistency tests

The 705 transceiver cannot tune the ISM bands, so this mode is explicitly SDR-fed — "the 705 can't do ISM-band SDR, so this needs an RTL-SDR" (ism.py). It shares the single SDR with the AIS and satellite decoders, so starting ISM first stops those (manager.py).


3. Signal structure — from IQ to a pulse train

3.1 Sample rate and the timing quantum

The receiver runs the SDR at a fixed RATE = 250000 S/s — "250 kS/s covers OOK ISM" (ism.py) — and reads in ~100 ms blocks:

iq = src.read(RATE // 10)     # 25 000 complex samples ≈ 100 ms   (ism.py)

There is no decimation: the envelope and pulse extraction run at the full 250 kS/s. The consequence is the pulse-timing resolution. extract_pulses converts sample indices to microseconds with

us = fs / 1e6                 # = 0.25 samples per microsecond   (pulse.py)

so at 250 kS/s each sample is 4 µs — the quantisation step of every measured mark/gap. This is comfortable for OOK ISM, whose shortest symbols are hundreds of microseconds (LaCrosse's 208 µs short pulse = 52 samples; the engine's 40 µs floor = 10 samples). It is marginal for the FSK/Manchester TPMS entry whose 52 µs half-symbol is only 13 samples — one of several reasons that class is aspirational (§8).

3.2 The envelope (OOK demod)

For OOK the "demodulator" is just the instantaneous magnitude of the complex baseband — the carrier's amplitude, on or off:

def magnitude(iq):
    iq = np.asarray(iq)
    return np.abs(iq) if np.iscomplexobj(iq) else np.abs(iq.astype(float))

pulse.py. This is |I + jQ|, a per-sample envelope; no filtering or squaring/averaging is applied, so the "magnitude threshold" downstream works directly on the raw envelope.

3.3 What a pulse train is

extract_pulses(mag, fs, reset_us=None, min_us=40) (pulse.py) turns the envelope into the receiver's central data structure: a list of packets, each a list of (mark_us, gap_us) pairs. A mark is a contiguous run of "carrier on"; the gap is the "carrier off" run until the next mark begins. All durations are in microseconds. A packet is a maximal run of pulses separated by gaps shorter than the reset gap; a gap ≥ reset ends the packet (the device's inter-frame silence).

3.4 The threshold and edge detection

The on/off decision is an adaptive two-level Schmitt trigger (_level_on), recomputed per block from the signal's own extremes with a hysteresis band so noise near the threshold cannot chatter into spurious edges:

mid  = 0.5 * (x.min() + x.max())       # midpoint of the two levels
band = 0.10 * (x.max() - x.min())      # hysteresis half-width
th_hi, th_lo = mid + band, mid - band
state = x[0] > mid
for i in range(x.size):                # stateful: only cross on th_hi / th_lo
    if state and x[i] < th_lo:  state = False
    elif not state and x[i] > th_hi:  state = True
    on[i] = state

pulse.py _level_on. The threshold is data-adaptive (it tracks each 100 ms block's own noise floor and carrier level) and hysteretic (a 10 %-of-range band, not a single hard comparator). The same _level_on is reused for both the OOK envelope and the FSK frequency track (§3.5). Edges are then found and marks/gaps measured rise-to-fall and fall-to-rise:

for k in range(len(rises)):
    start = rises[k]
    end   = next((f for f in falls if f > start), len(on))   # this mark's fall
    mark  = (end - start) / us
    nxt   = rises[k + 1] if k + 1 < len(rises) else None
    gap   = ((nxt - end) / us) if nxt is not None else reset_us or 0
    if mark < min_us:                     # drop sub-40 µs spikes / glitches
        continue
    cur.append((mark, gap))
    if gap >= (reset_us or 1e9):               # reset gap → flush packet
        packets.append(cur); cur = []

pulse.py. Two filters here are load-bearing: min_us = 40 discards runt marks (envelope spikes narrower than 40 µs / 10 samples), and the reset gap segments the stream into frames. The trailing pulse of a block is given a gap equal to the reset value so the final packet flushes.

3.5 The FSK path

For FSK devices pulse.frequency() provides a standard FM discriminator — the derivative of the unwrapped instantaneous phase:

def frequency(iq, fs):
    iq = np.asarray(iq, complex)
    return np.diff(np.unwrap(np.angle(iq))) * fs / (2 * np.pi)   # (pulse.py)

extract_pulses_fsk(iq, fs, …) discriminates first, then slices the resulting frequency track with the same _level_on Schmitt trigger and _slice_on used for OOK (mark = the higher tone, gap = the lower), so PWM/PPM/Manchester decode unchanged. The live IsmReceiver._run loop partitions the catalogue into ook_devs / fsk_devs (by the device fsk flag) and builds two pulse trains per block — extract_pulses(mag,…) for OOK devices and extract_pulses_fsk(iq,…) for FSK devices — so an FSK device such as the FSK-Manchester TPMS entry now decodes from live IQ (ism.py, pulse.py:extract_pulses_fsk).

3.6 What the receiver does per packet

The catalogue is split once by the device fsk flag, and each 100 ms block is turned into two pulse trains — OOK from the envelope, FSK from the discriminated frequency — so each device subset only sees the train it belongs on:

ook_devs = [d for d in cat if not d.get("fsk")]        # envelope train
fsk_devs = [d for d in cat if d.get("fsk")]            # frequency train
...
mag = pulse.magnitude(iq)
trains = [(pulse.extract_pulses(mag, RATE, reset_us=9000), ook_devs)]
if fsk_devs:
    trains.append((pulse.extract_pulses_fsk(iq, RATE, reset_us=9000), fsk_devs))
for packets, subset in trains:
    for pkt in packets:
        if len(pkt) < 8:                               # drop noise: need >= 8 pulses
            continue
        self.on_event({"event": "pulses",
                       "pulses": [[int(m), int(g)] for m, g in pkt[:400]]})
        for d in engine.decode_all(pkt, subset):
            self.on_event({"event": "decode", "device": d})

Both trains extract at a deliberately generous reset_us=9000 (9 ms) so a device's whole burst — including repeated frames — stays in one packet; the per-device reset is then honoured downstream by engine.decode, which re-splits the pulse train into candidate frames at gaps ≥ the device's own reset and returns the first frame that fits (engine._split_by_reset_decode_frame). Packets with fewer than 8 pulses are dropped as noise, and the pulse-scope frame is capped at 400 pulses (pkt[:400]).


4. The decode engine, step by step

engine.decode(pulses, dev) applies one device config to one packet: it first splits the packet into candidate frames at the device's own reset gap (_split_by_reset), then runs the per-frame decoder _decode_frame on each and returns the first field dict that fits (or None). The four steps below are what _decode_frame does. decode_all runs every config and returns all that fit:

def decode_all(pulses, devices):
    return [d for d in (decode(pulses, dev) for dev in devices) if d]

4.1 Step 1 — the pulse slicer (_demod)

_demod(pulses, dev) (engine.py) converts the packet into a bit list according to the device's modulation, using its short/long/tolerance. The timing match is a relative tolerance:

def _near(x, target, tol):
    return abs(x - target) <= tol * target      # ±(tol·target) µs   (engine.py)

so a tolerance of 0.3 accepts ±30 % of the target width. The four slicers:

  • OOK_PWM — per pulse, classify the mark: near short → bit 1, near long → bit 0; anything else is silently skipped (engine.py).
  • OOK_PPM — per pulse, classify the gap: near short → bit 0, near long → bit 1 (engine.py). (Note the polarity is the opposite of PWM — short=1 for widths, short=0 for gaps.)
  • OOK_MC / PCM — treat short as the symbol period and expand each pulse into level samples: max(1, round(mark/period)) ones (at least one) followed by round(gap/period) zeros. For OOK_MC the level stream is then reduced Manchester-wise, one bit per level pair: 1 if bits[i] > bits[i+1] else 0 (engine.py) — a high→low transition is a 1, low→high a 0. PCM keeps the raw level stream.

4.2 Step 2 — the bit-count gate

lo, hi = dev.get("bits", [0, 1 << 30])
if not (lo <= len(bits) <= hi):
    return None                                  # engine.py

bits is an inclusive [min, max] range. This is the first cheap rejection: a packet whose sliced length falls outside the device's expected frame size cannot be that device. Ranges are tight (often a single value like [36,36], sometimes [40,41] to tolerate a trailing sync bit).

4.3 Step 3 — checksum / CRC

if dev.get("checksum") and not checksum.verify(bits, dev["checksum"]):
    return None                                  # engine.py

If the config declares a checksum, it must validate or the decode is rejected. This is the strongest false-positive filter (§4.6). Devices with "checksum": None pass this step unconditionally and rely solely on the timing + bit-count gates.

4.4 Step 4 — field extraction

Surviving bits are sliced into named physical fields. Bits are assembled MSB-first, big-endian, with optional two's-complement sign:

def get_bits(bits, offset, length, signed=False):
    v = 0
    for i in range(length):
        v = (v << 1) | bits[offset + i]
    if signed and (v >> (length - 1)) & 1:
        v -= 1 << length
    return v                                      # engine.py

Each field then applies an affine transform to physical units and rounds floats to 3 decimals:

out = {"model": dev["name"]}
for f in dev["fields"]:
    v = get_bits(bits, f["offset"], f["length"], f.get("signed", False))
    v = v * f.get("scale", 1) + f.get("add", 0)
    out[f["name"]] = round(v, 3) if isinstance(v, float) else v

engine.py. Fields address bits by absolute offset/length, so they may skip reserved bits or overlap; e.g. Nexus places battery_ok at bit 8 and channel at bit 10 (skipping bit 9). The output dict is the decoded event: {"model": …, <field name>: <value>, …}.

4.5 Encode (the inverse, for tests)

engine.encode(dev, values) (engine.py) does the reverse — pack fields MSB-first into a bit array, write the checksum via checksum.apply, and synthesise a (mark, gap) pulse train — but only for OOK_PWM and OOK_PPM (engine.py). This is what makes the round-trip tests possible; OOK_MC and PCM are decode-only, so the catalogue self-consistency test skips them (test_rtl433.py).

4.6 On multiple matches

decode_all returns every config that passes all three gates — there is no best-match arbitration or de-duplication. In practice cross-matches are rare because each device is guarded by (a) a modulation-specific slicer, (b) a tight bit-count range, and (c), for many, a checksum. The checksum-less weather sensors (Nexus, LaCrosse, Prologue) lean entirely on the timing + bit-count gates, so those are the ones most prone to occasional spurious decodes on noise.


5. The device catalogue

5.1 Format

A device is a plain Python dict (JSON-serialisable), living in the CATALOGUE list in devices.py. The engine's own docstring (engine.py) is the schema of record; the keys are:

key meaning
name model string; emitted as the "model" of every decode
freq_mhz advisory band label (shown in the UI); does not tune the SDR in _demod
modulation "OOK_PWM" | "OOK_PPM" | "OOK_MC" | "PCM"
fsk routes the device to the FSK front-end — _run sends fsk-flagged devices through extract_pulses_fsk (set on the TPMS entry)
short, long µs — mark widths (PWM), gap widths (PPM), or symbol period (MC/PCM)
gap µs — nominal inter-pulse gap (used by the encode side)
reset µs — packet reset gap; the live extractor uses a generous global 9000 µs, then engine.decode re-splits per device at this value
tolerance fractional timing tolerance for _near (default 0.3 if absent)
bits [min, max] accepted sliced-bit-count, inclusive
fields list of {name, offset, length, scale?, add?, signed?}
checksum {type, start, len, check, poly?, init?} or None

The catalogue is loaded once by the receiver — cat = devices.CATALOGUE (ism.py) — and devices.load_file(path) (devices.py) can pull an external list from JSON (a bare list, or {"devices": [...]}; note the docstring mentions YAML aspirationally but the loader is JSON-only). The UI dropdown is fed by catalogue_summary() (devices.py), which projects each device to {name, modulation, freq_mhz, fields:[names]}.

5.2 A representative entry — Nexus-TH (OOK_PPM, checksum-less)

{
    "name": "Nexus-TH", "freq_mhz": 433.92,
    "modulation": "OOK_PPM", "short": 980, "long": 1960, "gap": 490,
    "reset": 3900, "tolerance": 0.3, "bits": [36, 36],
    "fields": [
        {"name": "id", "offset": 0, "length": 8},
        {"name": "battery_ok", "offset": 8, "length": 1},
        {"name": "channel", "offset": 10, "length": 2},
        {"name": "temperature_C", "offset": 12, "length": 12,
         "scale": 0.1, "signed": True},
        {"name": "humidity", "offset": 28, "length": 8},
    ],
    "checksum": None,
}

devices.py. Read this as: a 36-bit PPM frame where a 490 µs mark is followed by either a 980 µs gap (→ 0) or a 1960 µs gap (→ 1); the 12-bit signed temperature at bit 12 is deci-degrees Celsius (scale 0.1), humidity is a raw byte at bit 28, and a decode is accepted purely on hitting 36 bits with those gap widths (no checksum).

5.3 A checksummed entry — Acurite-609TXC (OOK_PWM + sum-8)

{
    "name": "Acurite-609TXC", …, "modulation": "OOK_PWM",
    "short": 512, "long": 976, "gap": 480, "reset": 8000,
    "tolerance": 0.35, "bits": [40, 40],
    "fields": [ …id/battery_low/temperature_C(scale 0.1, signed)/humidity… ],
    "checksum": {"type": "sum8", "start": 0, "len": 32, "check": 32},
}

devices.py. Here a 512 µs mark is bit 1, 976 µs is bit 0; the frame is 40 bits, and bits 32–39 must equal the 8-bit modular sum of bytes 0–3 (start:0, len:32, check:32) or the decode is dropped.

5.4 The catalogue at a glance

# name modulation short/long µs bits checksum
1 Nexus-TH OOK_PPM 980 / 1960 (gap 490) 36
2 Acurite-609TXC OOK_PWM 512 / 976 40 sum8 over 32b
3 LaCrosse-TX141TH OOK_PWM 208 / 417 40–41
4 Prologue-TH OOK_PPM 2000 / 4000 36–37
5 Generic-Doorbell-PWM OOK_PWM 350 / 1050 24–25
6 TPMS-FSK-Manchester OOK_MC (fsk:true) 52 / 52 64–80 crc8 poly 0x07 over 56b

(devices.py.) The six span both OOK pulse-coding families, signed and unsigned fields, scale/add unit conversions, checksummed and checksum-less frames, and the Manchester/FSK class — enough to exercise every engine path.


6. Constants & tables

6.1 Front-end / pipeline constants

constant value where
SDR sample rate RATE 250 000 S/s ism.py
read block RATE // 10 = 25 000 samp ≈ 100 ms ism.py
timing quantum 1 / (fs/1e6) = 4 µs / sample derived, pulse.py
default tune 433.92 MHz ism.py
level slicer Schmitt trigger 0.5·(max+min) ± 0.1·(max−min) (OOK env. + FSK freq.) pulse.py:_level_on
min pulse width min_us = 40 µs pulse.py _slice_on
live reset gap reset_us = 9000 µs generous; per-device reset re-split in engine.decode ism.py, engine._split_by_reset
min pulses / packet 8 ism.py
pulse-scope frame cap 400 pulses (pkt[:400]) ism.py
default timing tolerance 0.3 (fractional, relative to target) engine.py
bit assembly order MSB-first, big-endian; two's-complement if signed engine.py

6.2 Checksum / CRC kinds

The checksum module implements the "handful of variants ISM devices actually use" (checksum.py). All operate on the frame reduced to bytes MSB-first (_bits_to_bytes, checksum.py, whole bytes only) over the slice bits[start : start+len]:

type algorithm parameters source
crc8 bytewise CRC-8, MSB-first, no input/output reflection, no final XOR poly (default 0x07), init (default 0x00) checksum.py
sum8 sum(bytes) & 0xFF checksum.py
xor8 XOR of all bytes checksum.py

verify(bits, spec) reads the transmitted 8-bit check field MSB-first at bit offset spec["check"] and compares it to compute() (checksum.py); an unknown type raises ValueError (checksum.py). apply(bits, spec) writes the computed byte back for the encode/test path (checksum.py). The catalogue and tests exercise three polynomials: 0x07 (CRC-8/SMBUS, x⁸+x²+x+1 — the default and the TPMS entry), 0x31 (x⁸+x⁵+x⁴+1, a Maxim/Dallas-style poly used by the test PWM config, test_rtl433.py), and the non-CRC sum8/xor8.

6.3 Event frames emitted to the UI

IsmReceiver.on_event emits four frame kinds, relayed over the WebSocket as {"t":"ism", …} (main.py) and dispatched by onIsm (app.js):

event payload consumer
catalogue {devices:[{name,modulation,freq_mhz,fields}]} (sent on subscribe, manager.py) device filter dropdown
status {text, running} status line / RX button
pulses {pulses:[[mark_us, gap_us], …]} (≤ 400) URH-style square-wave scope
decode {device:{model, <fields>}} scrolling decode list

6.4 The URH-style pulse scope

The pulses frames drive a canvas that redraws the packet as a square wave — marks high, gaps low — the same visual an SDR tool like Universal Radio Hacker presents for reverse-engineering an unknown device. drawIsm() (app.js) sums all mark+gap durations, scales the packet to the canvas width, and strokes a rise → mark-high → fall → gap-low path per pulse (app.js), annotating it with the pulse count, total duration in ms, and the tuned frequency (app.js). This lets an operator eyeball the timing structure of a device the catalogue does not yet describe, then write a config for it.


7. Interoperability & validation

7.1 Relative to rtl_433

This is a clean-room re-implementation of rtl_433's architecture, not its code or its full database. The correspondence is deliberate:

  • rtl_433's pulse detector ↔︎ pulse.extract_pulses (envelope → (mark,gap) list, split at a reset gap).
  • rtl_433's pulse_slicer_pwm / pulse_slicer_ppm / pulse_slicer_manchester_zerobit ↔︎ the OOK_PWM / OOK_PPM / OOK_MC branches of _demod (engine.py).
  • rtl_433's -X 'flex' declarative decoder spec (modulation, short/long/ reset, bits, get field slices) ↔︎ the device dict schema — the package docstring names this parallel explicitly (__init__.py).

The device timings and layouts are transcribed from each protocol's documented/observed parameters as protocol facts (devices.py); the exact CRC polynomial and field offsets for a specific unit are stated to be "the on-air validation step" (devices.py) — i.e. confirmed against real captures, not assumed.

7.2 The test suite

test_rtl433.py validates the engine and the full IQ pipeline without hardware by using the encodesynth_ook inverse path:

test what it proves
test_pwm_roundtrip_with_crc PWM encode→decode preserves all fields incl. a CRC-8 poly 0x31
test_ppm_roundtrip PPM encode→decode preserves a 20-bit id + button
test_crc_rejects_corruption flipping one pulse (short↔︎long) makes the CRC fail → decode returns None
test_full_ook_pipeline encode → synth_ook(noise 0.05) → extract_pulses → decode recovers id/temp/humidity at fs=250000
test_decode_all_and_config_load devices.load_file (JSON) + decode_all round-trips a loaded config
test_checksum_variants crc8, sum8, xor8 each applyverify cleanly
test_catalogue_devices_selfconsistent every PWM/PPM catalogue device encodes+decodes random field values back to themselves
test_catalogue_summary_shape the UI summary has one entry per device with name/modulation/fields
test_fsk_extract_roundtrip synth_fsk → extract_pulses_fsk recovers the mark widths (200/400/200 µs) of an FSK burst (mark = the higher tone)
test_fsk_full_pipeline an fsk:true device decodes end-to-end from IQ: synth_fsk(noise 0.03) → extract_pulses_fsk → decode_all recovers id/value
test_per_device_reset_split two frames merged into one packet still decode — engine.decode re-splits at the device reset

The pipeline test is the strongest of the OOK set: it drives the exact same pulse.extract_pulses + engine.decode used live, with additive noise, and asserts a signed negative temperature (-5.2 °C) survives quantisation and slicing. The catalogue self-consistency test is the seeded-catalogue guard — it fuzzes field values through every OOK_PWM/OOK_PPM device and requires exact recovery. Three later tests cover the FSK-wiring / per-device-reset changes directly: test_fsk_extract_roundtrip and test_fsk_full_pipeline exercise the frequency front-end via pulse.synth_fsk, which lays down a +25 000 Hz tone for a mark and −25 000 Hz for a gap (dev_hz = 25000.0), and test_per_device_reset_split proves the re-split at the device's own reset. The OOK_MC/PCM modulations stay decode-only — encode synthesises only PWM/PPM (§4.5), so those two slicers are not round-trip-tested — but the FSK transport now is, through a PWM-over-FSK device.


8. Limitations

Honest scope of the current implementation:

  • FSK now wired. IsmReceiver._run builds both an OOK train (extract_pulses on the envelope) and an FSK train (extract_pulses_fsk on the FM-discriminated frequency), routing fsk-flagged devices to the latter, so the TPMS-FSK-Manchester entry decodes from live IQ. Residual: the FSK slicer is a simple two-tone Schmitt trigger with no preamble/sync detection, so FSK frames that open on a low (space) half-symbol before any mark can lose their leading bit — robust framing (preamble correlation) is the remaining refinement.
  • Threshold assumes signal present. The Schmitt trigger derives its band from 0.5·(max+min) ± 0.1·(max−min), so a 100 ms block containing only noise yields an arbitrary threshold (mitigated downstream by the 8-pulse and checksum gates); it is not a true adaptive noise-floor estimator.
  • Encode/round-trip covers PWM + PPM only. Manchester and PCM are decode-only (engine.py), so those paths lack the self-consistency safety net the OOK pulse-width/position devices enjoy.
  • No best-match arbitration. decode_all returns all configs that validate (engine.py); checksum-less devices (Nexus, LaCrosse, Prologue, doorbell) can in principle produce spurious decodes on noise, guarded only by timing + bit-count.
  • JSON-only external catalogue. load_file parses JSON (devices.py); the YAML mentioned in the docstring is not implemented.
  • Small seeded catalogue. Six devices vs rtl_433's 200+. The value is the engine — new devices are catalogue edits — but out of the box the coverage is a representative sample (weather thermo-hygrometers, a doorbell, a TPMS template), not the ecosystem.
  • Single fixed sample rate / no decimation. 250 kS/s (4 µs quantum) is fine for typical OOK ISM but coarse for very fast Manchester/FSK symbols (the 52 µs TPMS half-symbol is 13 samples).
  • Manchester polarity/alignment is fixed. _demod uses one bi-phase convention (1 if bits[i] > bits[i+1] else 0, engine.py) and a fixed pair alignment; devices using the opposite convention or needing a sync-bit offset would need a config knob that does not yet exist.

None of these are architectural; each is a bounded addition on a working, test-covered base.


9. References

  1. B. Merbanan et al., rtl_433 — the reference open-source ISM device decoder; its one-front-end-plus-declarative-decoder architecture, the -X 'flex' spec, and the PWM/PPM/Manchester pulse slicers this engine mirrors. https://github.com/merbanan/rtl_433
  2. rtl_433 device-decoder documentation — docs/PULSE_SLICERS.md and the flex-decoder spec (modulation, short/long/reset, bits, get= field slices), the direct analogue of the device dict schema (engine.py).
  3. Universal Radio Hacker (URH) — the reverse-engineering GUI whose pulse-timing view this receiver's scope reproduces (mark high, gap low). https://github.com/jopohl/urh
  4. Osmocom rtl-sdr / librtlsdr — the RTL2832U driver behind the ctypes RtlSource in sdr_sources.py; the 8-bit unsigned-IQ blocking-read source that feeds this decoder.
  5. On-Off Keying / ASK and the PWM / PPM(PDM) / Manchester pulse-coding families — standard digital-modulation background for short-range ISM telemetry.
  6. CRC-8 (SMBUS, poly 0x07) and Maxim/Dallas CRC-8 (poly 0x31) — the polynomials the catalogue and tests use (checksum.py, devices.py, test_rtl433.py).