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,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
|