rtlamr-python 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,17 @@
1
+ from rtlamr_python.listener import (
2
+ ALL_PROTOCOLS,
3
+ DEFAULT_PROTOCOLS,
4
+ ListenerHandle,
5
+ listen,
6
+ listen_once,
7
+ start_listening,
8
+ )
9
+
10
+ __all__ = [
11
+ "ALL_PROTOCOLS",
12
+ "DEFAULT_PROTOCOLS",
13
+ "ListenerHandle",
14
+ "listen",
15
+ "listen_once",
16
+ "start_listening",
17
+ ]
@@ -0,0 +1,4 @@
1
+ from rtlamr_python.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
rtlamr_python/cli.py ADDED
@@ -0,0 +1,233 @@
1
+ """rtlamr-python — Multi-protocol ERT smart meter receiver.
2
+
3
+ Reads IQ samples from an RTL-SDR dongle (or a raw sample file for testing),
4
+ decodes ERT packets, and prints each message as a JSON line to stdout.
5
+
6
+ Supported protocols (use --protocol to select; defaults to all Manchester ones):
7
+ scmplus — Standard Consumption Message Plus (16 bytes, most electric meters)
8
+ scm — Standard Consumption Message (12 bytes, older electric meters)
9
+ idm — Interval Data Message (92 bytes, hourly interval data)
10
+ netidm — Net Meter Interval Data Message (92 bytes, net-metering variant)
11
+ r900 — Neptune R900 water meters (different center freq: 912.38 MHz)
12
+
13
+ Usage:
14
+ # All Manchester protocols on live hardware
15
+ rtlamr
16
+
17
+ # Single protocol
18
+ rtlamr --protocol scmplus
19
+
20
+ # From a recorded capture file
21
+ rtlamr --sample-file /path/to/capture.bin
22
+
23
+ # Filter to specific meters
24
+ rtlamr --meter-id 12345678
25
+
26
+ # Alternate between Manchester and R900 (different center frequencies)
27
+ rtlamr --protocol scmplus r900
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import argparse
33
+ import json
34
+ import logging
35
+ import signal
36
+ import sys
37
+ import threading
38
+
39
+ from rtlamr_python.listener import ALL_PROTOCOLS, DEFAULT_PROTOCOLS, listen
40
+ from rtlamr_python.poster import ApiPoster
41
+
42
+ LOG = logging.getLogger(__name__)
43
+
44
+
45
+ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
46
+ p = argparse.ArgumentParser(description="Multi-protocol ERT smart meter receiver")
47
+ p.add_argument(
48
+ "--config",
49
+ default=None,
50
+ metavar="PATH",
51
+ help="TOML config file (default: none)",
52
+ )
53
+ p.add_argument(
54
+ "--verbose",
55
+ action="store_true",
56
+ default=None,
57
+ help="print per-decoder stats to stderr every 500 blocks",
58
+ )
59
+ p.add_argument(
60
+ "--chip-length",
61
+ type=int,
62
+ default=None,
63
+ metavar="N",
64
+ help="chip length in samples (default: 72 → ~2.36 MHz sample rate)",
65
+ )
66
+ p.add_argument(
67
+ "--gain",
68
+ default=None,
69
+ metavar="GAIN",
70
+ help='tuner gain in dB or "auto" (default: auto)',
71
+ )
72
+ p.add_argument(
73
+ "--freq-correction",
74
+ type=int,
75
+ default=None,
76
+ metavar="PPM",
77
+ dest="freq_correction",
78
+ help="frequency correction for the RTL-SDR oscillator in parts per million "
79
+ "(negative if signals appear below their expected frequency)",
80
+ )
81
+ p.add_argument(
82
+ "--meter-id",
83
+ type=int,
84
+ nargs="+",
85
+ default=None,
86
+ metavar="ID",
87
+ dest="meter_id",
88
+ help="only forward readings from these endpoint IDs (space-separated); "
89
+ "overrides meter_ids in the config file",
90
+ )
91
+ p.add_argument(
92
+ "--sample-file",
93
+ default=None,
94
+ metavar="PATH",
95
+ help="read raw IQ bytes from a file instead of live hardware",
96
+ )
97
+ p.add_argument(
98
+ "--duration",
99
+ type=float,
100
+ default=None,
101
+ metavar="SECONDS",
102
+ help="stop after this many seconds (0 = run forever)",
103
+ )
104
+ p.add_argument(
105
+ "--protocol",
106
+ nargs="+",
107
+ choices=list(ALL_PROTOCOLS),
108
+ default=None,
109
+ metavar="PROTO",
110
+ help="protocols to decode (space-separated); default: all Manchester",
111
+ )
112
+ p.add_argument(
113
+ "--api-url",
114
+ default=None,
115
+ metavar="URL",
116
+ help='base URL of the REST API (e.g. "http://localhost:8000/api"); '
117
+ "if omitted, readings are only written to stdout",
118
+ )
119
+ p.add_argument(
120
+ "--api-key",
121
+ default=None,
122
+ metavar="KEY",
123
+ help="value for the X-API-Key header (only needed when the API requires auth)",
124
+ )
125
+ p.add_argument(
126
+ "--switch-timeout",
127
+ type=float,
128
+ default=None,
129
+ metavar="SECONDS",
130
+ help="alternating mode: switch frequency after this many seconds without "
131
+ "a message (default: 60)",
132
+ )
133
+ return p.parse_args(argv)
134
+
135
+
136
+ def _load_config(path: str | None) -> dict:
137
+ if path is None:
138
+ return {}
139
+ import tomllib
140
+ try:
141
+ with open(path, "rb") as f:
142
+ return tomllib.load(f)
143
+ except FileNotFoundError:
144
+ return {}
145
+
146
+
147
+ _DEFAULTS: dict = {
148
+ "chip_length": 72,
149
+ "gain": "auto",
150
+ "freq_correction": 0,
151
+ "duration": 0.0,
152
+ "verbose": False,
153
+ "switch_timeout": 60.0,
154
+ }
155
+
156
+ _CONFIG_KEYS = {
157
+ "api_url", "api_key", "meter_id", "meter_ids", "protocol", "switch_timeout",
158
+ "gain", "freq_correction", "chip_length", "duration", "verbose",
159
+ }
160
+
161
+
162
+ def _apply_config(args: argparse.Namespace, cfg: dict) -> None:
163
+ """Back-fill args still at None from cfg, then apply built-in defaults."""
164
+ # Normalize meter ID to list[int] or None.
165
+ if args.meter_id is None:
166
+ if "meter_ids" in cfg:
167
+ args.meter_id = list(cfg["meter_ids"])
168
+ elif "meter_id" in cfg:
169
+ args.meter_id = [cfg["meter_id"]] # legacy single-int key
170
+
171
+ # Normalize protocol to list[str] or None.
172
+ if args.protocol is None and "protocol" in cfg:
173
+ raw = cfg["protocol"]
174
+ if isinstance(raw, list):
175
+ args.protocol = raw
176
+ else:
177
+ args.protocol = [p.strip() for p in str(raw).split(",")]
178
+
179
+ for key in _CONFIG_KEYS - {"meter_id", "meter_ids", "protocol"}:
180
+ if getattr(args, key) is None and key in cfg:
181
+ setattr(args, key, cfg[key])
182
+ for key, default in _DEFAULTS.items():
183
+ if getattr(args, key) is None:
184
+ setattr(args, key, default)
185
+
186
+
187
+ def main(argv: list[str] | None = None) -> int:
188
+ logging.basicConfig(
189
+ level=logging.INFO,
190
+ format="%(asctime)s %(levelname)s %(message)s",
191
+ stream=sys.stderr,
192
+ )
193
+
194
+ args = parse_args(argv)
195
+ _apply_config(args, _load_config(args.config))
196
+
197
+ poster = ApiPoster(args.api_url, args.api_key) if args.api_url else None
198
+
199
+ # Graceful shutdown on SIGINT.
200
+ stop_event = threading.Event()
201
+
202
+ def _stop(signum, frame):
203
+ LOG.info("Received signal %s, stopping.", signum)
204
+ stop_event.set()
205
+
206
+ signal.signal(signal.SIGINT, _stop)
207
+
208
+ try:
209
+ for record in listen(
210
+ protocols=args.protocol or list(DEFAULT_PROTOCOLS),
211
+ meter_id=args.meter_id,
212
+ chip_length=args.chip_length,
213
+ gain=args.gain,
214
+ freq_correction=args.freq_correction,
215
+ sample_file=args.sample_file,
216
+ switch_timeout=args.switch_timeout,
217
+ duration=args.duration,
218
+ verbose=args.verbose,
219
+ stop_event=stop_event,
220
+ ):
221
+ sys.stdout.write(json.dumps(record) + "\n")
222
+ sys.stdout.flush()
223
+ if poster:
224
+ poster.submit(record)
225
+ except ValueError as exc:
226
+ LOG.error(str(exc))
227
+ return 1
228
+
229
+ return 0
230
+
231
+
232
+ if __name__ == "__main__":
233
+ raise SystemExit(main())
rtlamr_python/crc.py ADDED
@@ -0,0 +1,75 @@
1
+ """CCITT-16 CRC used by the SCM+ protocol (CRC-16/GENIBUS variant).
2
+
3
+ Port of rtlamr-go/crc/crc.go.
4
+
5
+ Polynomial : 0x1021
6
+ Init value : 0xFFFF
7
+ XOR-out : 0xFFFF (meters complement the CRC before embedding it as PacketCRC)
8
+ Valid residue: 0x1D0F (checksum over bytes[2:] of a valid SCM+ packet equals this)
9
+
10
+ To embed a valid PacketCRC when constructing a packet:
11
+ pkt_crc = checksum(bytes[2:14]) ^ 0xFFFF
12
+
13
+ To verify a received packet:
14
+ valid(packet) — checks checksum(packet[2:]) == RESIDUE
15
+ """
16
+
17
+ _POLY = 0x1021
18
+ _INIT = 0xFFFF
19
+ RESIDUE = 0x1D0F
20
+
21
+ # Build 256-entry lookup table once at import time.
22
+ _table: list[int] = []
23
+ for _i in range(256):
24
+ _crc = _i << 8
25
+ for _ in range(8):
26
+ if _crc & 0x8000:
27
+ _crc = (_crc << 1) ^ _POLY
28
+ else:
29
+ _crc <<= 1
30
+ _crc &= 0xFFFF
31
+ _table.append(_crc)
32
+
33
+
34
+ def checksum(data: bytes | bytearray, init: int = _INIT) -> int:
35
+ """Return the CCITT-16 CRC of *data* starting from *init*."""
36
+ crc = init
37
+ for byte in data:
38
+ crc = ((crc << 8) ^ _table[(crc >> 8) ^ byte]) & 0xFFFF
39
+ return crc
40
+
41
+
42
+ def valid(packet: bytes | bytearray) -> bool:
43
+ """Return True when the CRC of packet[2:] equals the expected residue."""
44
+ return checksum(packet[2:]) == RESIDUE
45
+
46
+
47
+ # ---------------------------------------------------------------------------
48
+ # BCH-16 CRC used by the SCM protocol
49
+ # ---------------------------------------------------------------------------
50
+
51
+ _BCH_POLY = 0x6F63
52
+
53
+ _bch_table: list[int] = []
54
+ for _i in range(256):
55
+ _crc = _i << 8
56
+ for _ in range(8):
57
+ if _crc & 0x8000:
58
+ _crc = (_crc << 1) ^ _BCH_POLY
59
+ else:
60
+ _crc <<= 1
61
+ _crc &= 0xFFFF
62
+ _bch_table.append(_crc)
63
+
64
+
65
+ def bch_checksum(data: bytes | bytearray, init: int = 0) -> int:
66
+ """Return the BCH-16 CRC of *data* (poly=0x6F63, init=0, xorout=0)."""
67
+ crc = init
68
+ for byte in data:
69
+ crc = ((crc << 8) ^ _bch_table[(crc >> 8) ^ byte]) & 0xFFFF
70
+ return crc
71
+
72
+
73
+ def bch_valid(packet: bytes | bytearray) -> bool:
74
+ """Return True when bch_checksum(packet[2:12]) == 0 (SCM validation)."""
75
+ return bch_checksum(packet[2:12]) == 0
@@ -0,0 +1,275 @@
1
+ """Signal processing pipeline for ERT smart meter packets.
2
+
3
+ Port of rtlamr-go/protocol/decode.go.
4
+
5
+ Pipeline per block:
6
+ 1. magnitude() — uint8 IQ pairs → float64 signal via MagLUT
7
+ 2. matched_filter() — Manchester-coded cumulative-sum filter → quantized bits
8
+ 3. _search() — find preamble positions in quantized signal
9
+ 4. _slice_packets() — extract packet bytes at each preamble position
10
+ 5. parser callable — protocol-specific CRC check and field extraction
11
+
12
+ The Decoder class maintains two rolling buffers (signal + quantized) so that
13
+ packets spanning a block boundary are not lost.
14
+
15
+ Each protocol supplies its own Config (via make_config()) and parse callable.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import math
21
+ from dataclasses import dataclass, field
22
+ from typing import Any, Callable
23
+
24
+ import numpy as np
25
+
26
+ # SCM+ protocol constants (from rtlamr-go/scmplus/scmplus.go)
27
+ _CENTER_FREQ = 912_600_155 # Hz
28
+ _DATA_RATE = 32_768 # bps
29
+
30
+ # Default preamble for SCM+ — 0x16A3 = 0001011010100011
31
+ _PREAMBLE_BITS = np.array(
32
+ [int(b) for b in "0001011010100011"], dtype=np.uint8
33
+ )
34
+ _PACKET_SYMBOLS = 128 # 16 bytes × 8 bits
35
+
36
+
37
+ @dataclass
38
+ class Config:
39
+ chip_length: int = 72
40
+ preamble_bits: np.ndarray = field(
41
+ default_factory=lambda: _PREAMBLE_BITS.copy()
42
+ )
43
+ packet_symbols: int = _PACKET_SYMBOLS
44
+ center_freq: int = _CENTER_FREQ
45
+
46
+ # All fields below are derived from the above in __post_init__.
47
+ symbol_length: int = field(init=False)
48
+ sample_rate: int = field(init=False)
49
+ preamble_symbols: int = field(init=False)
50
+ preamble_length: int = field(init=False)
51
+ packet_length: int = field(init=False)
52
+ block_size: int = field(init=False)
53
+ block_size2: int = field(init=False)
54
+ buffer_length: int = field(init=False)
55
+
56
+ def __post_init__(self) -> None:
57
+ self.preamble_symbols = len(self.preamble_bits)
58
+ self.symbol_length = self.chip_length * 2
59
+ self.sample_rate = _DATA_RATE * self.chip_length
60
+ self.preamble_length = self.preamble_symbols * self.symbol_length
61
+ self.packet_length = self.packet_symbols * self.symbol_length
62
+ self.block_size = _next_power_of_2(self.preamble_length)
63
+ self.block_size2 = self.block_size * 2
64
+ self.buffer_length = self.packet_length + self.block_size
65
+
66
+
67
+ def _next_power_of_2(v: int) -> int:
68
+ return 1 << math.ceil(math.log2(v))
69
+
70
+
71
+ # ---------------------------------------------------------------------------
72
+ # MagLUT — pre-computed normalised squared magnitudes for each uint8 value
73
+ # ---------------------------------------------------------------------------
74
+
75
+ _mag_lut: np.ndarray = ((127.5 - np.arange(256, dtype=np.float64)) / 127.5) ** 2
76
+
77
+
78
+ def magnitude(block: np.ndarray) -> np.ndarray:
79
+ """Convert raw uint8 IQ block to float64 magnitude array.
80
+
81
+ Input shape: (2N,) — interleaved I, Q bytes.
82
+ Output shape: (N,) — sum of squared normalised I and Q.
83
+ """
84
+ samples = _mag_lut[block]
85
+ return samples[0::2] + samples[1::2]
86
+
87
+
88
+ # ---------------------------------------------------------------------------
89
+ # Matched filter for Manchester-coded signal
90
+ # ---------------------------------------------------------------------------
91
+
92
+ def matched_filter(signal: np.ndarray, chip_length: int) -> np.ndarray:
93
+ """Apply Manchester-coded matched filter; return quantized 0/1 uint8 array.
94
+
95
+ Uses a cumulative-sum approach identical to Decoder.Filter in the Go code.
96
+ """
97
+ symbol_length = chip_length * 2
98
+ csum = np.empty(len(signal) + 1, dtype=np.float64)
99
+ csum[0] = 0.0
100
+ np.cumsum(signal, out=csum[1:])
101
+
102
+ n_out = len(signal) - symbol_length + 1
103
+ lower = csum[chip_length: chip_length + n_out]
104
+ base = csum[:n_out]
105
+ upper = csum[symbol_length: symbol_length + n_out]
106
+
107
+ f = (lower - base) - (upper - lower)
108
+ return (f >= 0).astype(np.uint8)[:n_out]
109
+
110
+
111
+ # ---------------------------------------------------------------------------
112
+ # Preamble search
113
+ # ---------------------------------------------------------------------------
114
+
115
+ def _search(quantized: np.ndarray, preamble: np.ndarray, cfg: Config) -> list[int]:
116
+ """Return sample indices where *preamble* exists in *quantized*.
117
+
118
+ Two-pass approach ported from Decoder.Search:
119
+ 1. Coarse byte-level pass — eliminate bytes that can't contain preamble start.
120
+ 2. Fine bit-level pass — verify exact preamble match at symbol boundaries.
121
+ """
122
+ sym = cfg.symbol_length
123
+ block = cfg.block_size
124
+
125
+ # Pack quantized signal to bytes (8 bits each).
126
+ packed = np.packbits(quantized[:block + cfg.preamble_length])
127
+
128
+ sym_bytes = sym >> 3
129
+
130
+ # Coarse pass: find byte positions where the first preamble bit might start.
131
+ first_bit_mask = 0x00 if preamble[0] == 1 else 0xFF
132
+ candidates = [i for i, b in enumerate(packed[:block >> 3]) if b != first_bit_mask]
133
+
134
+ # Eliminate byte-level candidates for each subsequent preamble bit.
135
+ for p_idx in range(1, len(preamble)):
136
+ bit_mask = 0x00 if preamble[p_idx] == 1 else 0xFF
137
+ offset = p_idx * sym_bytes
138
+ candidates = [i for i in candidates if packed[i + offset] != bit_mask]
139
+ if not candidates:
140
+ return []
141
+
142
+ # Expand byte candidates to bit-level indices.
143
+ bit_candidates = [byte_idx * 8 + bit_off
144
+ for byte_idx in candidates
145
+ for bit_off in range(8)]
146
+
147
+ # Fine pass: verify the full preamble at each bit-level index.
148
+ valid: list[int] = []
149
+ q = quantized
150
+ for idx in bit_candidates:
151
+ for p_idx, p_bit in enumerate(preamble):
152
+ if q[idx + p_idx * sym] != p_bit:
153
+ break
154
+ else:
155
+ valid.append(idx)
156
+
157
+ return valid
158
+
159
+
160
+ # ---------------------------------------------------------------------------
161
+ # Packet slicing
162
+ # ---------------------------------------------------------------------------
163
+
164
+ def _slice_packets(quantized: np.ndarray, indices: list[int], cfg: Config) -> list[bytes]:
165
+ """Extract and pack packet bits at each preamble index."""
166
+ packets = []
167
+ for idx in indices:
168
+ if idx > cfg.block_size:
169
+ # Packet will be fully available in the next block.
170
+ continue
171
+ bits = quantized[idx: idx + cfg.packet_symbols * cfg.symbol_length: cfg.symbol_length]
172
+ if len(bits) < cfg.packet_symbols:
173
+ continue
174
+ packed = np.packbits(bits[:cfg.packet_symbols])
175
+ packets.append(packed.tobytes())
176
+ return packets
177
+
178
+
179
+ # ---------------------------------------------------------------------------
180
+ # Decoder
181
+ # ---------------------------------------------------------------------------
182
+
183
+ class Decoder:
184
+ """Stateful decoder that accumulates sample blocks and emits messages.
185
+
186
+ Matches Go's rolling-buffer design:
187
+ _signal — length block_size + symbol_length (short window for filter continuity)
188
+ _quantized — length buffer_length (shifted left each block, tail filled)
189
+
190
+ Pass a protocol-specific parser callable to decode into a specific message type.
191
+ Defaults to SCM+ if no parser is provided.
192
+ """
193
+
194
+ def __init__(self, cfg: Config | None = None, parser: Callable | None = None) -> None:
195
+ self.cfg = cfg or Config()
196
+
197
+ if parser is None:
198
+ from rtlamr_python.protocols.scmplus import parse as _default_parse
199
+ self._parser: Callable = _default_parse
200
+ else:
201
+ self._parser = parser
202
+
203
+ # Signal window: [symbol_length carry-over | block_size new magnitudes]
204
+ self._signal = np.zeros(self.cfg.block_size + self.cfg.symbol_length, dtype=np.float64)
205
+
206
+ # Quantized history: buffer_length, shifted left each block.
207
+ self._quantized = np.zeros(self.cfg.buffer_length, dtype=np.uint8)
208
+
209
+ # De-duplicate messages seen in consecutive blocks (same packet at block boundary).
210
+ self._prev_digests: set[bytes] = set()
211
+ self._next_digests: set[bytes] = set()
212
+
213
+ # Diagnostic counters — reset never; read externally for logging.
214
+ self.stats: dict[str, int] = {
215
+ "candidates": 0, # preamble positions found by _search
216
+ "raw_packets": 0, # packets sliced from those positions
217
+ "parse_ok": 0, # packets accepted by the parser
218
+ "dedup_drop": 0, # valid packets suppressed as duplicates
219
+ }
220
+
221
+ @property
222
+ def block_size2(self) -> int:
223
+ return self.cfg.block_size2
224
+
225
+ def reset(self) -> None:
226
+ """Zero rolling buffers and clear dedup sets. Call after an SDR retune."""
227
+ self._signal[:] = 0
228
+ self._quantized[:] = 0
229
+ self._prev_digests.clear()
230
+ self._next_digests.clear()
231
+
232
+ def decode(self, raw_block: bytes) -> list[Any]:
233
+ """Process one raw IQ block; return any newly decoded messages."""
234
+ cfg = self.cfg
235
+ block = np.frombuffer(raw_block, dtype=np.uint8)
236
+
237
+ # Shift signal left by block_size, keeping symbol_length samples as carry-over.
238
+ self._signal[:cfg.symbol_length] = self._signal[cfg.block_size:]
239
+ mag = magnitude(block) # block_size2 bytes → block_size magnitudes
240
+ self._signal[cfg.symbol_length:] = mag
241
+
242
+ # Filter signal window → trim to exactly block_size quantized outputs.
243
+ filt = matched_filter(self._signal, cfg.chip_length)[:cfg.block_size]
244
+
245
+ # Shift quantized left by block_size, fill tail with new filter output.
246
+ self._quantized[:cfg.packet_length] = self._quantized[cfg.block_size:]
247
+ self._quantized[cfg.packet_length:] = filt
248
+
249
+ indices = _search(self._quantized, cfg.preamble_bits, cfg)
250
+ raw_packets = _slice_packets(self._quantized, indices, cfg)
251
+
252
+ self.stats["candidates"] += len(indices)
253
+ self.stats["raw_packets"] += len(raw_packets)
254
+
255
+ self._next_digests.clear()
256
+ messages: list[Any] = []
257
+
258
+ for raw in raw_packets:
259
+ msg = self._parser(raw)
260
+ if msg is None:
261
+ continue
262
+
263
+ self.stats["parse_ok"] += 1
264
+
265
+ # Use raw bytes as dedup key — same transmission → same bytes.
266
+ if raw in self._prev_digests or raw in self._next_digests:
267
+ self.stats["dedup_drop"] += 1
268
+ continue
269
+
270
+ self._next_digests.add(raw)
271
+ messages.append(msg)
272
+
273
+ self._prev_digests, self._next_digests = self._next_digests, self._prev_digests
274
+
275
+ return messages