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.
- rtlamr_python/__init__.py +17 -0
- rtlamr_python/__main__.py +4 -0
- rtlamr_python/cli.py +233 -0
- rtlamr_python/crc.py +75 -0
- rtlamr_python/decoder.py +275 -0
- rtlamr_python/listener.py +332 -0
- rtlamr_python/poster.py +96 -0
- rtlamr_python/protocols/__init__.py +0 -0
- rtlamr_python/protocols/commodity.py +36 -0
- rtlamr_python/protocols/idm.py +131 -0
- rtlamr_python/protocols/netidm.py +119 -0
- rtlamr_python/protocols/r900.py +63 -0
- rtlamr_python/protocols/scm.py +74 -0
- rtlamr_python/protocols/scmplus.py +92 -0
- rtlamr_python/r900_decoder.py +316 -0
- rtlamr_python/sdr.py +102 -0
- rtlamr_python-1.0.0.dist-info/METADATA +154 -0
- rtlamr_python-1.0.0.dist-info/RECORD +22 -0
- rtlamr_python-1.0.0.dist-info/WHEEL +5 -0
- rtlamr_python-1.0.0.dist-info/entry_points.txt +2 -0
- rtlamr_python-1.0.0.dist-info/licenses/LICENSE +21 -0
- rtlamr_python-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -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
|
+
]
|
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
|
rtlamr_python/decoder.py
ADDED
|
@@ -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
|