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,119 @@
1
+ """NetIDM (Net Meter Interval Data Message) protocol parser.
2
+
3
+ Port of rtlamr-go/netidm/netidm.go.
4
+
5
+ Same preamble, packet size, and CRC checks as IDM. Fields differ starting
6
+ at byte 15: the NetIDM carries LastGeneration, LastConsumption,
7
+ LastConsumptionNet, and 27 × 14-bit differential intervals (vs IDM's
8
+ 47 × 9-bit intervals).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import struct
14
+ from dataclasses import dataclass
15
+
16
+ import numpy as np
17
+
18
+ from rtlamr_python import crc
19
+ from rtlamr_python.protocols.idm import _PREAMBLE, _PACKET_SYMBOLS, _serial_crc_buf
20
+
21
+
22
+ def make_config(chip_length: int = 72):
23
+ from rtlamr_python.decoder import Config
24
+ return Config(
25
+ chip_length=chip_length,
26
+ preamble_bits=_PREAMBLE.copy(),
27
+ packet_symbols=_PACKET_SYMBOLS,
28
+ )
29
+
30
+
31
+ @dataclass
32
+ class NetIDM:
33
+ preamble: int
34
+ protocol_id: int
35
+ packet_length: int
36
+ hamming_code: int
37
+ application_version: int
38
+ ert_type: int
39
+ ert_serial_number: int
40
+ consumption_interval_count: int
41
+ programming_state: int
42
+ last_generation: int # 3 bytes, bytes[28:31]
43
+ last_consumption: int # 3 bytes, bytes[25:28]
44
+ last_consumption_net: int # 4 bytes, bytes[34:38]
45
+ differential_consumption_intervals: list # 27 × 14-bit values
46
+ transmit_time_offset: int
47
+ serial_number_crc: int
48
+ packet_crc: int
49
+
50
+ def as_dict(self) -> dict:
51
+ return {
52
+ "type": "NetIDM",
53
+ "endpoint_id": self.ert_serial_number,
54
+ "ert_type": self.ert_type,
55
+ "consumption_interval_count": self.consumption_interval_count,
56
+ "last_consumption": self.last_consumption,
57
+ "last_generation": self.last_generation,
58
+ "last_consumption_net": self.last_consumption_net,
59
+ "differential_consumption_intervals": self.differential_consumption_intervals,
60
+ }
61
+
62
+
63
+ def parse(raw: bytes | bytearray) -> NetIDM | None:
64
+ if len(raw) < 92:
65
+ return None
66
+ if crc.checksum(raw[4:92]) != crc.RESIDUE:
67
+ return None
68
+ if crc.checksum(_serial_crc_buf(raw)) != crc.RESIDUE:
69
+ return None
70
+
71
+ preamble = struct.unpack_from(">I", raw, 0)[0]
72
+ protocol_id = raw[4]
73
+ packet_length_b = raw[5]
74
+ hamming_code = raw[6]
75
+ application_version = raw[7]
76
+ ert_type = raw[8] & 0x0F
77
+ ert_serial_number = struct.unpack_from(">I", raw, 9)[0]
78
+
79
+ if ert_serial_number == 0:
80
+ return None
81
+
82
+ consumption_interval_count = raw[13]
83
+ programming_state = raw[14]
84
+
85
+ # 3-byte big-endian values not aligned to 4-byte boundaries.
86
+ last_consumption = (raw[25] << 16) | (raw[26] << 8) | raw[27]
87
+ last_generation = (raw[28] << 16) | (raw[29] << 8) | raw[30]
88
+ last_consumption_net = struct.unpack_from(">I", raw, 34)[0]
89
+
90
+ # 27 differential intervals packed at 14 bits each, starting at bit 304.
91
+ bits = "".join(f"{b:08b}" for b in raw[:92])
92
+ intervals = []
93
+ offset = 304
94
+ for _ in range(27):
95
+ intervals.append(int(bits[offset:offset + 14], 2))
96
+ offset += 14
97
+
98
+ transmit_time_offset = struct.unpack_from(">H", raw, 86)[0]
99
+ serial_number_crc = struct.unpack_from(">H", raw, 88)[0]
100
+ packet_crc = struct.unpack_from(">H", raw, 90)[0]
101
+
102
+ return NetIDM(
103
+ preamble=preamble,
104
+ protocol_id=protocol_id,
105
+ packet_length=packet_length_b,
106
+ hamming_code=hamming_code,
107
+ application_version=application_version,
108
+ ert_type=ert_type,
109
+ ert_serial_number=ert_serial_number,
110
+ consumption_interval_count=consumption_interval_count,
111
+ programming_state=programming_state,
112
+ last_generation=last_generation,
113
+ last_consumption=last_consumption,
114
+ last_consumption_net=last_consumption_net,
115
+ differential_consumption_intervals=intervals,
116
+ transmit_time_offset=transmit_time_offset,
117
+ serial_number_crc=serial_number_crc,
118
+ packet_crc=packet_crc,
119
+ )
@@ -0,0 +1,63 @@
1
+ """R900 (Neptune R900) water meter protocol.
2
+
3
+ Port of rtlamr-go/r900/r900.go.
4
+
5
+ R900 uses a completely different signal chain from the Manchester protocols:
6
+ - 4-chip symbols (6 possible values) instead of 2-chip Manchester
7
+ - Reed-Solomon error correction over GF(32, 37, 2)
8
+ - Different center frequency: 912,380,000 Hz
9
+
10
+ Signal decoding is handled by R900Decoder in src/r900_decoder.py.
11
+ This module only contains the message dataclass and bit-field extraction.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass
17
+
18
+
19
+ _R900_CENTER_FREQ = 912_380_000 # Hz — different from other ERT protocols
20
+
21
+
22
+ @dataclass
23
+ class R900:
24
+ meter_id: int # 32 bits
25
+ unkn1: int # 8 bits
26
+ no_use: int # 6 bits — day bins with no usage
27
+ backflow: int # 2 bits — backflow detected past 35 days (hi/lo)
28
+ consumption: int # 24 bits
29
+ unkn3: int # 2 bits
30
+ leak: int # 4 bits — day bins with leak
31
+ leak_now: int # 2 bits — leak past 24 hours (hi/lo)
32
+
33
+ def as_dict(self) -> dict:
34
+ return {
35
+ "type": "R900",
36
+ "endpoint_id": self.meter_id,
37
+ "consumption": self.consumption,
38
+ "no_use": self.no_use,
39
+ "backflow": self.backflow,
40
+ "leak": self.leak,
41
+ "leak_now": self.leak_now,
42
+ }
43
+
44
+
45
+ def parse_bits(bits: str) -> R900 | None:
46
+ """Parse an R900 message from a 80-bit string of '0'/'1' characters.
47
+
48
+ Input is the payload bits after Reed-Solomon verification, derived from
49
+ 21 base-6 symbols (each encoded as 5 bits).
50
+ """
51
+ if len(bits) < 80:
52
+ return None
53
+ meter_id = int(bits[0:32], 2)
54
+ if meter_id == 0:
55
+ return None
56
+ unkn1 = int(bits[32:40], 2)
57
+ no_use = int(bits[40:46], 2)
58
+ backflow = int(bits[46:48], 2)
59
+ consumption = int(bits[48:72], 2)
60
+ unkn3 = int(bits[72:74], 2)
61
+ leak = int(bits[74:78], 2)
62
+ leak_now = int(bits[78:80], 2)
63
+ return R900(meter_id, unkn1, no_use, backflow, consumption, unkn3, leak, leak_now)
@@ -0,0 +1,74 @@
1
+ """SCM (Standard Consumption Message) protocol parser.
2
+
3
+ Port of rtlamr-go/scm/scm.go.
4
+
5
+ Packet: 12 bytes (96 bits total, including 21-bit preamble).
6
+ CRC: BCH-16 (poly=0x6F63, init=0, xorout=0) over bytes[2:12].
7
+ Fields are extracted from bit positions within the 96-bit packet because
8
+ the 21-bit preamble is not byte-aligned.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass
14
+
15
+ import numpy as np
16
+
17
+ from rtlamr_python import crc
18
+
19
+ _PREAMBLE = np.array([int(b) for b in "111110010101001100000"], dtype=np.uint8)
20
+ _PACKET_SYMBOLS = 96 # 12 bytes × 8 bits
21
+
22
+
23
+ def make_config(chip_length: int = 72):
24
+ from rtlamr_python.decoder import Config
25
+ return Config(
26
+ chip_length=chip_length,
27
+ preamble_bits=_PREAMBLE.copy(),
28
+ packet_symbols=_PACKET_SYMBOLS,
29
+ )
30
+
31
+
32
+ @dataclass
33
+ class SCM:
34
+ endpoint_id: int # 26-bit ERT ID
35
+ endpoint_type: int # 4-bit meter type
36
+ tamper_phy: int # 2-bit physical tamper indicator
37
+ tamper_enc: int # 2-bit encoder tamper indicator
38
+ consumption: int # 24-bit consumption value
39
+ packet_crc: int # 16-bit checksum
40
+
41
+ def as_dict(self) -> dict:
42
+ return {
43
+ "type": "SCM",
44
+ "endpoint_id": self.endpoint_id,
45
+ "endpoint_type": self.endpoint_type,
46
+ "tamper_phy": self.tamper_phy,
47
+ "tamper_enc": self.tamper_enc,
48
+ "consumption": self.consumption,
49
+ }
50
+
51
+
52
+ def parse(raw: bytes | bytearray) -> SCM | None:
53
+ if len(raw) < 12:
54
+ return None
55
+ if not crc.bch_valid(raw):
56
+ return None
57
+ bits = "".join(f"{b:08b}" for b in raw[:12])
58
+ # Fields are at non-byte-aligned positions within the 96-bit packet.
59
+ # Bit layout from rtlamr-go/scm/scm.go NewSCM():
60
+ # bits[21:23] + bits[56:80] → 26-bit ERT ID
61
+ # bits[24:26] → TamperPhy
62
+ # bits[26:30] → ERTType
63
+ # bits[30:32] → TamperEnc
64
+ # bits[32:56] → Consumption
65
+ # bits[80:96] → Checksum
66
+ endpoint_id = int(bits[21:23] + bits[56:80], 2)
67
+ if endpoint_id == 0:
68
+ return None
69
+ endpoint_type = int(bits[26:30], 2)
70
+ tamper_phy = int(bits[24:26], 2)
71
+ tamper_enc = int(bits[30:32], 2)
72
+ consumption = int(bits[32:56], 2)
73
+ packet_crc = int(bits[80:96], 2)
74
+ return SCM(endpoint_id, endpoint_type, tamper_phy, tamper_enc, consumption, packet_crc)
@@ -0,0 +1,92 @@
1
+ """SCM+ (Standard Consumption Message Plus) packet parser.
2
+
3
+ Port of rtlamr-go/scmplus/scmplus.go.
4
+
5
+ Packet layout (16 bytes, big-endian):
6
+ offset size field
7
+ 0 2 FrameSync uint16
8
+ 2 1 ProtocolID uint8 must == 0x1E
9
+ 3 1 EndpointType uint8
10
+ 4 4 EndpointID uint32 must be non-zero
11
+ 8 4 Consumption uint32
12
+ 12 2 Tamper uint16
13
+ 14 2 PacketCRC uint16
14
+
15
+ CRC covers bytes[2:14] (the 12 bytes before PacketCRC); valid when
16
+ CCITT16(bytes[2:]) == 0x1D0F (the residue includes PacketCRC itself).
17
+ """
18
+
19
+ import struct
20
+ from dataclasses import dataclass
21
+
22
+ import numpy as np
23
+
24
+ from rtlamr_python import crc
25
+
26
+ _STRUCT = struct.Struct(">HBBIIHH")
27
+ _PROTOCOL_ID = 0x1E
28
+
29
+ _PREAMBLE = np.array([int(b) for b in "0001011010100011"], dtype=np.uint8)
30
+ _PACKET_SYMBOLS = 128 # 16 bytes × 8 bits
31
+
32
+
33
+ def make_config(chip_length: int = 72):
34
+ from rtlamr_python.decoder import Config
35
+ return Config(
36
+ chip_length=chip_length,
37
+ preamble_bits=_PREAMBLE.copy(),
38
+ packet_symbols=_PACKET_SYMBOLS,
39
+ )
40
+
41
+
42
+ @dataclass
43
+ class SCMPlus:
44
+ frame_sync: int
45
+ protocol_id: int
46
+ endpoint_type: int
47
+ endpoint_id: int
48
+ consumption: int
49
+ tamper: int
50
+ packet_crc: int
51
+
52
+ def msg_type(self) -> str:
53
+ return "SCM+"
54
+
55
+ def as_dict(self) -> dict:
56
+ return {
57
+ "type": self.msg_type(),
58
+ "endpoint_id": self.endpoint_id,
59
+ "endpoint_type": self.endpoint_type,
60
+ "consumption": self.consumption,
61
+ "tamper": f"0x{self.tamper:04X}",
62
+ "packet_crc": f"0x{self.packet_crc:04X}",
63
+ }
64
+
65
+
66
+ def parse(raw: bytes | bytearray) -> SCMPlus | None:
67
+ """Parse 16 raw bytes into an SCMPlus message.
68
+
69
+ Returns None if the packet fails CRC, has an invalid ProtocolID, or
70
+ has a zero EndpointID.
71
+ """
72
+ if len(raw) < 16:
73
+ return None
74
+ if not crc.valid(raw[:16]):
75
+ return None
76
+
77
+ frame_sync, protocol_id, endpoint_type, endpoint_id, consumption, tamper, packet_crc = (
78
+ _STRUCT.unpack(raw[:16])
79
+ )
80
+
81
+ if protocol_id != _PROTOCOL_ID or endpoint_id == 0:
82
+ return None
83
+
84
+ return SCMPlus(
85
+ frame_sync=frame_sync,
86
+ protocol_id=protocol_id,
87
+ endpoint_type=endpoint_type,
88
+ endpoint_id=endpoint_id,
89
+ consumption=consumption,
90
+ tamper=tamper,
91
+ packet_crc=packet_crc,
92
+ )
@@ -0,0 +1,316 @@
1
+ """R900 signal processing pipeline.
2
+
3
+ Port of rtlamr-go/r900/r900.go and rtlamr-go/r900/gf/gf.go.
4
+
5
+ R900 uses a 4-chip/6-symbol encoding rather than Manchester coding:
6
+
7
+ Six symbols (each 4 chips):
8
+ 0: 0011 3: 1100 (inverse of 0)
9
+ 1: 0101 4: 1010 (inverse of 1)
10
+ 2: 0110 5: 1001 (inverse of 2)
11
+
12
+ A matched filter computes three correlation values per sample position,
13
+ corresponding to the three base chip patterns. The symbol with the highest
14
+ absolute correlation value is selected; the sign determines whether it is
15
+ the base (0-2) or inverted (3-5) form.
16
+
17
+ Reed-Solomon verification over GF(32, polynomial=37, generator=2) is applied
18
+ to the 21-symbol payload before field extraction.
19
+
20
+ Preamble search uses the Manchester-quantized signal (same rolling-buffer
21
+ architecture as the other decoders) since the R900 training sequence produces
22
+ a predictable bit pattern through the Manchester filter. The 4-chip filter
23
+ is applied in parallel to the same magnitude signal, and the preamble hit
24
+ indices from the Manchester pass are used to locate payloads in the 6-symbol
25
+ quantized buffer.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import math
31
+ from typing import Any
32
+
33
+ import numpy as np
34
+
35
+ from rtlamr_python.decoder import magnitude, matched_filter, _search, _next_power_of_2
36
+ from rtlamr_python.protocols.r900 import R900, parse_bits, _R900_CENTER_FREQ
37
+
38
+ # R900 protocol constants (from rtlamr-go/r900/r900.go)
39
+ _DATA_RATE = 32_768
40
+ _PREAMBLE_BITS = np.array(
41
+ [int(b) for b in "00000000000000001110010101100100"], dtype=np.uint8
42
+ )
43
+ _PREAMBLE_SYMBOLS = 32
44
+ _PACKET_SYMBOLS = 116 # in Manchester-symbol units (ChipLength * 2 per symbol)
45
+ _PAYLOAD_SYMBOLS = 42 # R900 4-chip symbols in the payload (21 pairs)
46
+
47
+
48
+ # ---------------------------------------------------------------------------
49
+ # Galois Field GF(32, 37, 2) — port of rtlamr-go/r900/gf/gf.go
50
+ # ---------------------------------------------------------------------------
51
+
52
+ class _GF:
53
+ """Galois Field arithmetic for Reed-Solomon syndrome computation."""
54
+
55
+ def __init__(self, order: int, poly: int, alpha: int) -> None:
56
+ n = order - 1
57
+ self._n = n
58
+ log = [0] * order
59
+ exp = [0] * (2 * n)
60
+ x = 1
61
+ for i in range(n):
62
+ exp[i] = x
63
+ exp[i + n] = x
64
+ log[x] = i
65
+ x = self._mul_gf(x, alpha, order, poly)
66
+ log[0] = n
67
+ self._log = log
68
+ self._exp = exp
69
+
70
+ @staticmethod
71
+ def _mul_gf(x: int, y: int, order: int, poly: int) -> int:
72
+ z = 0
73
+ while x > 0:
74
+ if x & 1:
75
+ z ^= y
76
+ x >>= 1
77
+ y <<= 1
78
+ if y & order:
79
+ y ^= poly
80
+ return z
81
+
82
+ def exp(self, e: int) -> int:
83
+ if e < 0:
84
+ return 0
85
+ return self._exp[e % self._n]
86
+
87
+ def log(self, x: int) -> int:
88
+ return -1 if x == 0 else self._log[x]
89
+
90
+ def mul(self, x: int, y: int) -> int:
91
+ if x == 0 or y == 0:
92
+ return 0
93
+ return self._exp[self._log[x] + self._log[y]]
94
+
95
+ def syndrome(self, message: list[int], parity_count: int, offset: int) -> list[int]:
96
+ result = []
97
+ for i in range(parity_count):
98
+ syn = message[0]
99
+ for v in message[1:]:
100
+ syn = self.mul(syn, self.exp(offset + i)) ^ v
101
+ result.append(syn)
102
+ return result
103
+
104
+
105
+ _GF32 = _GF(32, 37, 2)
106
+
107
+
108
+ # ---------------------------------------------------------------------------
109
+ # 4-chip matched filter for R900
110
+ # ---------------------------------------------------------------------------
111
+
112
+ def _r900_filter(signal: np.ndarray, chip_length: int) -> np.ndarray:
113
+ """Compute the 3-value 4-chip matched filter output.
114
+
115
+ Returns shape (N, 3) where each row is [f0, f1, f2] corresponding to
116
+ the three base chip patterns: 1100, 1010, 1001.
117
+ """
118
+ cl = chip_length
119
+ n = len(signal) - 4 * cl + 1
120
+ if n <= 0:
121
+ return np.zeros((0, 3), dtype=np.float64)
122
+
123
+ csum = np.empty(len(signal) + 1, dtype=np.float64)
124
+ csum[0] = 0.0
125
+ np.cumsum(signal, out=csum[1:])
126
+
127
+ c0 = csum[:n]
128
+ c1 = csum[cl: cl + n] * 2
129
+ c2 = csum[2 * cl: 2 * cl + n] * 2
130
+ c3 = csum[3 * cl: 3 * cl + n] * 2
131
+ c4 = csum[4 * cl: 4 * cl + n]
132
+
133
+ f = np.empty((n, 3), dtype=np.float64)
134
+ f[:, 0] = c2 - c4 - c0 # 1100
135
+ f[:, 1] = c1 - c2 + c3 - c4 - c0 # 1010
136
+ f[:, 2] = c1 - c3 + c4 - c0 # 1001
137
+ return f
138
+
139
+
140
+ def _r900_quantize(filtered: np.ndarray) -> np.ndarray:
141
+ """Map each filtered row to a 6-symbol value (0-5)."""
142
+ abs_f = np.abs(filtered)
143
+ argmax = np.argmax(abs_f, axis=1).astype(np.uint8)
144
+ signs = filtered[np.arange(len(filtered)), argmax]
145
+ quantized = argmax.copy()
146
+ quantized[signs > 0] += 3
147
+ return quantized
148
+
149
+
150
+ # ---------------------------------------------------------------------------
151
+ # R900Decoder
152
+ # ---------------------------------------------------------------------------
153
+
154
+ class R900Decoder:
155
+ """Stateful R900 decoder.
156
+
157
+ Architecture mirrors Decoder but maintains two parallel signal chains:
158
+ - Manchester chain: preamble search (same rolling-buffer logic)
159
+ - R900 4-chip chain: payload symbol extraction
160
+
161
+ Both are shifted by block_size each call, keeping them in sync so that
162
+ a preamble hit index from the Manchester chain maps to the same position
163
+ in the R900 quantized buffer.
164
+ """
165
+
166
+ def __init__(self, chip_length: int = 72) -> None:
167
+ chip = chip_length
168
+ sym = chip * 2 # Manchester symbol length
169
+
170
+ preamble_len = _PREAMBLE_SYMBOLS * sym
171
+ packet_len = _PACKET_SYMBOLS * sym
172
+ block_size = _next_power_of_2(preamble_len)
173
+ buf_len = packet_len + block_size
174
+
175
+ self._chip = chip
176
+ self._sym = sym
177
+ self._block_size = block_size
178
+ self._preamble_len = preamble_len
179
+ self._packet_len = packet_len
180
+ self._buf_len = buf_len
181
+ self.center_freq = _R900_CENTER_FREQ
182
+ self.sample_rate = _DATA_RATE * chip
183
+ self.block_size2 = block_size * 2
184
+
185
+ # Manchester chain (for preamble search)
186
+ self._man_signal = np.zeros(block_size + sym, dtype=np.float64)
187
+ self._man_quantized = np.zeros(buf_len, dtype=np.uint8)
188
+
189
+ # R900 signal chain
190
+ # block_size extra samples at the front so the rolling shift can read
191
+ # buf_len elements starting at block_size without going out of bounds.
192
+ # The chip*4 tail provides filter continuity beyond buf_len.
193
+ self._r900_signal = np.zeros(block_size + buf_len + chip * 4, dtype=np.float64)
194
+ self._r900_quantized = np.zeros(buf_len, dtype=np.uint8)
195
+
196
+ self._prev_seen: set[str] = set()
197
+ self._next_seen: set[str] = set()
198
+
199
+ def reset(self) -> None:
200
+ """Zero rolling buffers and clear dedup sets. Call after an SDR retune."""
201
+ self._man_signal[:] = 0
202
+ self._man_quantized[:] = 0
203
+ self._r900_signal[:] = 0
204
+ self._r900_quantized[:] = 0
205
+ self._prev_seen.clear()
206
+ self._next_seen.clear()
207
+
208
+ def decode(self, raw_block: bytes) -> list[R900]:
209
+ chip = self._chip
210
+ sym = self._sym
211
+ block_size = self._block_size
212
+ preamble_len = self._preamble_len
213
+ packet_len = self._packet_len
214
+ buf_len = self._buf_len
215
+
216
+ block = np.frombuffer(raw_block, dtype=np.uint8)
217
+ mag = magnitude(block) # block_size2 bytes → block_size magnitude samples
218
+
219
+ # --- Manchester chain (preamble search) ---
220
+ self._man_signal[:sym] = self._man_signal[block_size:]
221
+ self._man_signal[sym:] = mag
222
+ filt_man = matched_filter(self._man_signal, chip)[:block_size]
223
+ self._man_quantized[:packet_len] = self._man_quantized[block_size:]
224
+ self._man_quantized[packet_len:] = filt_man
225
+
226
+ # --- R900 4-chip chain (payload decoding) ---
227
+ # Roll the signal buffer left by block_size. The buffer is
228
+ # (block_size + buf_len + chip*4) samples wide so [block_size:] has exactly
229
+ # (buf_len + chip*4) elements — the same count as [:-block_size].
230
+ # New magnitudes fill position [packet_len:packet_len+block_size] so that
231
+ # _r900_signal[packet_len+k] == mag[k], matching the Manchester chain's
232
+ # alignment (_man_quantized[packet_len+k] also comes from mag[k]).
233
+ self._r900_signal[:-block_size] = self._r900_signal[block_size:]
234
+ self._r900_signal[packet_len: packet_len + block_size] = mag
235
+
236
+ r900_filt = _r900_filter(self._r900_signal[:buf_len + chip * 4], chip)
237
+ r900_q = _r900_quantize(r900_filt)
238
+ # Trim to buf_len to match the rolling buffer size.
239
+ self._r900_quantized[:] = r900_q[:buf_len]
240
+
241
+ # --- Preamble search in Manchester-quantized buffer ---
242
+ # Fake a minimal Config-like object for _search.
243
+ cfg = _SearchCfg(block_size, preamble_len, sym, _PREAMBLE_SYMBOLS)
244
+ indices = _search(self._man_quantized, _PREAMBLE_BITS, cfg)
245
+
246
+ self._next_seen.clear()
247
+ messages: list[R900] = []
248
+
249
+ for idx in indices:
250
+ if idx > block_size:
251
+ continue
252
+
253
+ # Payload starts after the 32-symbol preamble in the 4-chip quantized
254
+ # stream. The index into the Manchester buffer maps to the same
255
+ # position in the R900 buffer since both shift by block_size each call.
256
+ # Adjust: preamble_len samples → skip preamble, subtract one sym for
257
+ # alignment (matches Go: payloadIdx = pkt.Idx + preambleLength - SymbolLength).
258
+ payload_idx = idx + preamble_len - sym
259
+
260
+ # Extract 42 symbols (21 pairs) at 4-chip intervals.
261
+ symbols: list[int] = []
262
+ for k in range(_PAYLOAD_SYMBOLS):
263
+ q_idx = payload_idx + k * chip * 4
264
+ if q_idx >= buf_len:
265
+ break
266
+ symbols.append(int(self._r900_quantized[q_idx]))
267
+ if len(symbols) < _PAYLOAD_SYMBOLS:
268
+ continue
269
+
270
+ # Convert pairs of base-6 symbols to base-6 digit string.
271
+ digits = "".join(str(s) for s in symbols)
272
+
273
+ # Decode symbol pairs into 5-bit GF(32) elements.
274
+ gf_symbols: list[int] = []
275
+ bad = False
276
+ bits_str = ""
277
+ for k in range(0, len(digits), 2):
278
+ val = int(digits[k: k + 2], 6)
279
+ if val > 31:
280
+ bad = True
281
+ break
282
+ gf_symbols.append(val)
283
+ bits_str += f"{val:05b}"
284
+ if bad:
285
+ continue
286
+
287
+ # Reed-Solomon check: 5 parity symbols at positions 26-30 (offset 29).
288
+ rs_buf = [0] * 31
289
+ rs_buf[:16] = gf_symbols[:16]
290
+ rs_buf[26:] = gf_symbols[16:]
291
+ syndromes = _GF32.syndrome(rs_buf, 5, 29)
292
+ if any(s != 0 for s in syndromes):
293
+ continue
294
+
295
+ if digits in self._prev_seen or digits in self._next_seen:
296
+ continue
297
+ self._next_seen.add(digits)
298
+
299
+ msg = parse_bits(bits_str)
300
+ if msg is None:
301
+ continue
302
+ messages.append(msg)
303
+
304
+ self._prev_seen, self._next_seen = self._next_seen, self._prev_seen
305
+ return messages
306
+
307
+
308
+ class _SearchCfg:
309
+ """Minimal duck-typed Config for _search()."""
310
+ __slots__ = ("block_size", "preamble_length", "symbol_length", "preamble_symbols")
311
+
312
+ def __init__(self, block_size: int, preamble_length: int, symbol_length: int, preamble_symbols: int) -> None:
313
+ self.block_size = block_size
314
+ self.preamble_length = preamble_length
315
+ self.symbol_length = symbol_length
316
+ self.preamble_symbols = preamble_symbols