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,332 @@
|
|
|
1
|
+
"""Library entry points for listening to ERT smart meter transmissions.
|
|
2
|
+
|
|
3
|
+
This module contains the single core SDR read/decode loop used by every
|
|
4
|
+
public API this package exposes:
|
|
5
|
+
|
|
6
|
+
listen() — generator; yields each decoded reading as a dict
|
|
7
|
+
listen_once() — blocks until exactly one reading decodes, returns it
|
|
8
|
+
start_listening() — runs listen() on a background thread, calls a callback
|
|
9
|
+
|
|
10
|
+
The CLI (rtlamr_python.cli) is a thin wrapper around listen() that adds
|
|
11
|
+
argument parsing, JSON-lines stdout output, and REST API posting.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
import threading
|
|
18
|
+
import time
|
|
19
|
+
from collections import defaultdict
|
|
20
|
+
from collections.abc import Callable, Iterable, Iterator
|
|
21
|
+
|
|
22
|
+
from rtlamr_python.decoder import Config, Decoder
|
|
23
|
+
from rtlamr_python.protocols import idm, netidm, scm, scmplus
|
|
24
|
+
from rtlamr_python.r900_decoder import R900Decoder
|
|
25
|
+
from rtlamr_python.sdr import open_source
|
|
26
|
+
|
|
27
|
+
LOG = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
_MANCHESTER_PROTOCOLS = {
|
|
30
|
+
"scmplus": (scmplus.make_config, scmplus.parse),
|
|
31
|
+
"scm": (scm.make_config, scm.parse),
|
|
32
|
+
"idm": (idm.make_config, idm.parse),
|
|
33
|
+
"netidm": (netidm.make_config, netidm.parse),
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
# Default set omits IDM/NetIDM — their large block sizes (16 384 bytes) add
|
|
37
|
+
# enough Python loop overhead to cause SDR ring-buffer overflow at 2.36 MSPS
|
|
38
|
+
# when combined with SCM+/SCM. Pass protocols=["idm"] / ["netidm"] to
|
|
39
|
+
# decode those explicitly.
|
|
40
|
+
DEFAULT_PROTOCOLS = ("scmplus", "scm", "idm", "netidm")
|
|
41
|
+
|
|
42
|
+
ALL_PROTOCOLS = tuple(_MANCHESTER_PROTOCOLS) + ("r900",)
|
|
43
|
+
|
|
44
|
+
_STATS_INTERVAL = 500
|
|
45
|
+
_HEARTBEAT_INTERVAL = 30
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def listen(
|
|
49
|
+
protocols: Iterable[str] | None = None,
|
|
50
|
+
meter_id: Iterable[int] | None = None,
|
|
51
|
+
chip_length: int = 72,
|
|
52
|
+
gain: str | float = "auto",
|
|
53
|
+
freq_correction: int = 0,
|
|
54
|
+
sample_file: str | None = None,
|
|
55
|
+
switch_timeout: float = 60.0,
|
|
56
|
+
duration: float = 0.0,
|
|
57
|
+
verbose: bool = False,
|
|
58
|
+
stop_event: threading.Event | None = None,
|
|
59
|
+
) -> Iterator[dict]:
|
|
60
|
+
"""Read from an RTL-SDR dongle (or *sample_file*), decode packets, and
|
|
61
|
+
yield each matching reading as a dict.
|
|
62
|
+
|
|
63
|
+
This opens the SDR (or sample file) on first iteration and closes it
|
|
64
|
+
when the generator is exhausted, closed (``gen.close()``), or garbage
|
|
65
|
+
collected — so use it in a ``for`` loop, or call ``.close()`` explicitly
|
|
66
|
+
if you stop consuming it early.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
protocols: protocol names to decode, e.g. ``["scmplus", "r900"]``.
|
|
70
|
+
Defaults to all Manchester-encoded protocols (scmplus, scm, idm,
|
|
71
|
+
netidm). Include "r900" to also (or only) decode R900 water meters;
|
|
72
|
+
combining it with any Manchester protocol switches between the two
|
|
73
|
+
center frequencies every *switch_timeout* seconds of silence.
|
|
74
|
+
meter_id: if given, only readings from these endpoint IDs are yielded.
|
|
75
|
+
chip_length: samples per chip (default 72 → ~2.36 MHz sample rate).
|
|
76
|
+
gain: tuner gain in dB, or "auto".
|
|
77
|
+
freq_correction: frequency correction in parts per million.
|
|
78
|
+
sample_file: read raw IQ bytes from this file instead of live hardware.
|
|
79
|
+
switch_timeout: alternating mode — switch frequency after this many
|
|
80
|
+
seconds without a message.
|
|
81
|
+
duration: stop after this many seconds (0 = run forever).
|
|
82
|
+
verbose: log periodic heartbeat/stats messages.
|
|
83
|
+
stop_event: if given, checked once per read/decode cycle; set it from
|
|
84
|
+
another thread to stop the loop.
|
|
85
|
+
|
|
86
|
+
Yields:
|
|
87
|
+
dict records, e.g. ``{"time": "...", "type": "SCM+", "endpoint_id": 123,
|
|
88
|
+
"endpoint_type": 7, "consumption": 456, "tamper": "0x00", ...}``.
|
|
89
|
+
"""
|
|
90
|
+
protocols = list(protocols) if protocols is not None else list(DEFAULT_PROTOCOLS)
|
|
91
|
+
unknown = set(protocols) - set(ALL_PROTOCOLS)
|
|
92
|
+
if unknown:
|
|
93
|
+
raise ValueError(f"Unknown protocol(s): {sorted(unknown)} (known: {ALL_PROTOCOLS})")
|
|
94
|
+
meter_ids = set(meter_id) if meter_id is not None else None
|
|
95
|
+
|
|
96
|
+
use_r900 = "r900" in protocols
|
|
97
|
+
manchester_names = [p for p in protocols if p != "r900"]
|
|
98
|
+
manchester_protos = {k: _MANCHESTER_PROTOCOLS[k] for k in manchester_names}
|
|
99
|
+
|
|
100
|
+
decoders: list[Decoder] = []
|
|
101
|
+
for name, (make_cfg, parser) in manchester_protos.items():
|
|
102
|
+
cfg = make_cfg(chip_length)
|
|
103
|
+
LOG.info(
|
|
104
|
+
"Protocol %s: chip_length=%d sample_rate=%d center_freq=%d block_size=%d",
|
|
105
|
+
name, cfg.chip_length, cfg.sample_rate, cfg.center_freq, cfg.block_size,
|
|
106
|
+
)
|
|
107
|
+
decoders.append(Decoder(cfg, parser))
|
|
108
|
+
|
|
109
|
+
r900_decoder: R900Decoder | None = None
|
|
110
|
+
if use_r900:
|
|
111
|
+
r900_decoder = R900Decoder(chip_length)
|
|
112
|
+
LOG.info(
|
|
113
|
+
"Protocol r900: chip_length=%d sample_rate=%d center_freq=%d",
|
|
114
|
+
chip_length, r900_decoder.sample_rate, r900_decoder.center_freq,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
if not decoders and r900_decoder is None:
|
|
118
|
+
raise ValueError("No protocols selected.")
|
|
119
|
+
|
|
120
|
+
# Determine center_freq and sample_rate for the SDR source.
|
|
121
|
+
# Alternating mode starts on Manchester; R900-only starts on R900.
|
|
122
|
+
if decoders:
|
|
123
|
+
center_freq = decoders[0].cfg.center_freq
|
|
124
|
+
sample_rate = decoders[0].cfg.sample_rate
|
|
125
|
+
else:
|
|
126
|
+
center_freq = r900_decoder.center_freq
|
|
127
|
+
sample_rate = r900_decoder.sample_rate
|
|
128
|
+
|
|
129
|
+
source = open_source(
|
|
130
|
+
center_freq=center_freq,
|
|
131
|
+
sample_rate=sample_rate,
|
|
132
|
+
gain=gain,
|
|
133
|
+
ppm=freq_correction,
|
|
134
|
+
sample_file=sample_file,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
start = time.monotonic()
|
|
138
|
+
|
|
139
|
+
all_decoder_objs = decoders + ([r900_decoder] if r900_decoder else [])
|
|
140
|
+
min_block = min(d.block_size2 for d in all_decoder_objs)
|
|
141
|
+
read_size = min_block * 8
|
|
142
|
+
buffers: dict[int, bytearray] = defaultdict(bytearray)
|
|
143
|
+
|
|
144
|
+
proto_names = list(manchester_protos) + (["r900"] if r900_decoder else [])
|
|
145
|
+
_stats: dict[int, dict] = {
|
|
146
|
+
id(d): {"name": name, "blocks": 0, "messages": 0}
|
|
147
|
+
for d, name in zip(all_decoder_objs, proto_names)
|
|
148
|
+
}
|
|
149
|
+
_total_chunks = 0
|
|
150
|
+
_last_heartbeat = start
|
|
151
|
+
|
|
152
|
+
def _running() -> bool:
|
|
153
|
+
return stop_event is None or not stop_event.is_set()
|
|
154
|
+
|
|
155
|
+
def _drain_decoder(d) -> Iterator[dict]:
|
|
156
|
+
"""Yield a dict for each message decoded from *d*'s buffered blocks."""
|
|
157
|
+
buf = buffers[id(d)]
|
|
158
|
+
while len(buf) >= d.block_size2:
|
|
159
|
+
block_bytes = bytes(buf[: d.block_size2])
|
|
160
|
+
del buf[: d.block_size2]
|
|
161
|
+
_stats[id(d)]["blocks"] += 1
|
|
162
|
+
for msg in d.decode(block_bytes):
|
|
163
|
+
record = {"time": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())}
|
|
164
|
+
record.update(msg.as_dict())
|
|
165
|
+
if meter_ids is not None and record.get("endpoint_id") not in meter_ids:
|
|
166
|
+
continue
|
|
167
|
+
_stats[id(d)]["messages"] += 1
|
|
168
|
+
yield record
|
|
169
|
+
|
|
170
|
+
def _log_stats_if_due(mode: str | None) -> None:
|
|
171
|
+
nonlocal _last_heartbeat
|
|
172
|
+
if not verbose:
|
|
173
|
+
return
|
|
174
|
+
now = time.monotonic()
|
|
175
|
+
if now - _last_heartbeat >= _HEARTBEAT_INTERVAL:
|
|
176
|
+
elapsed = now - start
|
|
177
|
+
total_printed = sum(s["messages"] for s in _stats.values())
|
|
178
|
+
if mode:
|
|
179
|
+
LOG.info(
|
|
180
|
+
"listening… t=%ds chunks=%d messages=%d mode=%s",
|
|
181
|
+
elapsed, _total_chunks, total_printed, mode,
|
|
182
|
+
)
|
|
183
|
+
else:
|
|
184
|
+
LOG.info(
|
|
185
|
+
"listening… t=%ds chunks=%d messages=%d",
|
|
186
|
+
elapsed, _total_chunks, total_printed,
|
|
187
|
+
)
|
|
188
|
+
_last_heartbeat = now
|
|
189
|
+
if mode is None and _total_chunks % _STATS_INTERVAL == 0:
|
|
190
|
+
elapsed = time.monotonic() - start
|
|
191
|
+
parts = [f"t={elapsed:.0f}s chunks={_total_chunks}"]
|
|
192
|
+
for d, s in zip(all_decoder_objs, _stats.values()):
|
|
193
|
+
ds = getattr(d, "stats", {})
|
|
194
|
+
parts.append(
|
|
195
|
+
f"{s['name']}:blocks={s['blocks']}"
|
|
196
|
+
f",cands={ds.get('candidates', '?')}"
|
|
197
|
+
f",ok={ds.get('parse_ok', '?')}"
|
|
198
|
+
f",dedup={ds.get('dedup_drop', '?')}"
|
|
199
|
+
f",printed={s['messages']}"
|
|
200
|
+
)
|
|
201
|
+
LOG.info("stats: %s", " | ".join(parts))
|
|
202
|
+
|
|
203
|
+
alternating = bool(decoders) and r900_decoder is not None
|
|
204
|
+
|
|
205
|
+
try:
|
|
206
|
+
if alternating:
|
|
207
|
+
_MAN_FREQ = decoders[0].cfg.center_freq
|
|
208
|
+
_R900_FREQ = r900_decoder.center_freq
|
|
209
|
+
_SETTLE_BLOCKS = 4 # blocks to discard after retuning (~14 ms)
|
|
210
|
+
|
|
211
|
+
def _retune(new_freq, reset_targets):
|
|
212
|
+
source.set_center_freq(new_freq)
|
|
213
|
+
for _ in range(_SETTLE_BLOCKS):
|
|
214
|
+
source.read_block(read_size)
|
|
215
|
+
for d in reset_targets:
|
|
216
|
+
d.reset()
|
|
217
|
+
buffers[id(d)].clear()
|
|
218
|
+
|
|
219
|
+
mode = "manchester"
|
|
220
|
+
|
|
221
|
+
while _running():
|
|
222
|
+
if duration > 0 and (time.monotonic() - start) >= duration:
|
|
223
|
+
LOG.info("Duration reached.")
|
|
224
|
+
break
|
|
225
|
+
|
|
226
|
+
active = decoders if mode == "manchester" else [r900_decoder]
|
|
227
|
+
mode_deadline = time.monotonic() + switch_timeout
|
|
228
|
+
found = False
|
|
229
|
+
|
|
230
|
+
while _running() and not found:
|
|
231
|
+
if time.monotonic() >= mode_deadline:
|
|
232
|
+
LOG.info("Switch timeout on %s — switching.", mode)
|
|
233
|
+
break
|
|
234
|
+
|
|
235
|
+
chunk = source.read_block(read_size)
|
|
236
|
+
if not chunk:
|
|
237
|
+
return
|
|
238
|
+
_total_chunks += 1
|
|
239
|
+
|
|
240
|
+
for d in active:
|
|
241
|
+
buffers[id(d)] += chunk
|
|
242
|
+
for record in _drain_decoder(d):
|
|
243
|
+
found = True
|
|
244
|
+
yield record
|
|
245
|
+
|
|
246
|
+
_log_stats_if_due(mode)
|
|
247
|
+
|
|
248
|
+
if mode == "manchester":
|
|
249
|
+
mode = "r900"
|
|
250
|
+
LOG.info("Switching to R900 mode (center_freq=%d).", _R900_FREQ)
|
|
251
|
+
_retune(_R900_FREQ, [r900_decoder])
|
|
252
|
+
else:
|
|
253
|
+
mode = "manchester"
|
|
254
|
+
LOG.info("Switching to Manchester mode (center_freq=%d).", _MAN_FREQ)
|
|
255
|
+
_retune(_MAN_FREQ, decoders)
|
|
256
|
+
|
|
257
|
+
else:
|
|
258
|
+
# Normal loop: all selected decoders run on the same frequency concurrently.
|
|
259
|
+
while _running():
|
|
260
|
+
if duration > 0 and (time.monotonic() - start) >= duration:
|
|
261
|
+
LOG.info("Duration reached.")
|
|
262
|
+
break
|
|
263
|
+
|
|
264
|
+
chunk = source.read_block(read_size)
|
|
265
|
+
if not chunk:
|
|
266
|
+
break
|
|
267
|
+
|
|
268
|
+
_total_chunks += 1
|
|
269
|
+
|
|
270
|
+
for d in all_decoder_objs:
|
|
271
|
+
buffers[id(d)] += chunk
|
|
272
|
+
yield from _drain_decoder(d)
|
|
273
|
+
|
|
274
|
+
_log_stats_if_due(None)
|
|
275
|
+
|
|
276
|
+
finally:
|
|
277
|
+
source.close()
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def listen_once(**kwargs) -> dict:
|
|
281
|
+
"""Block until exactly one matching reading decodes, then return it.
|
|
282
|
+
|
|
283
|
+
Opens the SDR (or sample file), closes it before returning. Accepts the
|
|
284
|
+
same keyword arguments as listen(), except *duration* and *stop_event*
|
|
285
|
+
(which would risk returning nothing).
|
|
286
|
+
"""
|
|
287
|
+
kwargs.pop("duration", None)
|
|
288
|
+
kwargs.pop("stop_event", None)
|
|
289
|
+
gen = listen(**kwargs)
|
|
290
|
+
try:
|
|
291
|
+
return next(gen)
|
|
292
|
+
finally:
|
|
293
|
+
gen.close()
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
class ListenerHandle:
|
|
297
|
+
"""Handle returned by start_listening(); use it to stop the background thread."""
|
|
298
|
+
|
|
299
|
+
def __init__(self, thread: threading.Thread, stop_event: threading.Event) -> None:
|
|
300
|
+
self._thread = thread
|
|
301
|
+
self._stop_event = stop_event
|
|
302
|
+
|
|
303
|
+
def stop(self, timeout: float | None = None) -> None:
|
|
304
|
+
"""Signal the listener to stop and wait for its thread to exit."""
|
|
305
|
+
self._stop_event.set()
|
|
306
|
+
self._thread.join(timeout)
|
|
307
|
+
|
|
308
|
+
def is_running(self) -> bool:
|
|
309
|
+
return self._thread.is_alive()
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def start_listening(
|
|
313
|
+
on_message: Callable[[dict], None],
|
|
314
|
+
**kwargs,
|
|
315
|
+
) -> ListenerHandle:
|
|
316
|
+
"""Run listen() on a background daemon thread, calling on_message(reading)
|
|
317
|
+
for each decoded reading. Returns a ListenerHandle; call .stop() on it to
|
|
318
|
+
end the loop.
|
|
319
|
+
|
|
320
|
+
Accepts the same keyword arguments as listen(), except *stop_event*
|
|
321
|
+
(start_listening manages its own).
|
|
322
|
+
"""
|
|
323
|
+
kwargs.pop("stop_event", None)
|
|
324
|
+
stop_event = threading.Event()
|
|
325
|
+
|
|
326
|
+
def _run() -> None:
|
|
327
|
+
for record in listen(stop_event=stop_event, **kwargs):
|
|
328
|
+
on_message(record)
|
|
329
|
+
|
|
330
|
+
thread = threading.Thread(target=_run, daemon=True, name="rtlamr-listener")
|
|
331
|
+
thread.start()
|
|
332
|
+
return ListenerHandle(thread, stop_event)
|
rtlamr_python/poster.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""HTTP poster — ships decoded meter readings to the REST API in the background.
|
|
2
|
+
|
|
3
|
+
The main SDR loop calls ApiPoster.submit(record) which is non-blocking.
|
|
4
|
+
A daemon thread drains the internal queue and POSTs each reading.
|
|
5
|
+
Errors are logged but never propagated to the caller.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import queue
|
|
13
|
+
import threading
|
|
14
|
+
import urllib.error
|
|
15
|
+
import urllib.request
|
|
16
|
+
from typing import Optional
|
|
17
|
+
|
|
18
|
+
LOG = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _to_payload(record: dict) -> dict | None:
|
|
22
|
+
"""Map a decoder record dict to a MeterReadingIn payload dict.
|
|
23
|
+
|
|
24
|
+
Returns None if a required field is missing.
|
|
25
|
+
"""
|
|
26
|
+
consumption = record.get("consumption") or record.get("last_consumption_count")
|
|
27
|
+
if consumption is None:
|
|
28
|
+
return None
|
|
29
|
+
|
|
30
|
+
tamper: int | None = None
|
|
31
|
+
if "tamper" in record and record["tamper"] is not None:
|
|
32
|
+
raw = record["tamper"]
|
|
33
|
+
tamper = int(raw, 16) if isinstance(raw, str) else int(raw)
|
|
34
|
+
elif "tamper_phy" in record or "tamper_enc" in record:
|
|
35
|
+
tamper = (record.get("tamper_phy", 0) << 2) | record.get("tamper_enc", 0)
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
"timestamp": record["time"],
|
|
39
|
+
"endpoint_id": record["endpoint_id"],
|
|
40
|
+
"protocol": record.get("type", ""),
|
|
41
|
+
"endpoint_type": str(raw_et) if (raw_et := record.get("endpoint_type")) is not None else None,
|
|
42
|
+
"consumption": consumption,
|
|
43
|
+
"tamper": tamper,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ApiPoster:
|
|
48
|
+
"""Daemon thread that POSTs meter readings to the API without blocking the SDR loop."""
|
|
49
|
+
|
|
50
|
+
def __init__(self, api_url: str, api_key: Optional[str] = None):
|
|
51
|
+
self._url = api_url
|
|
52
|
+
self._headers: dict[str, str] = {"Content-Type": "application/json"}
|
|
53
|
+
if api_key:
|
|
54
|
+
self._headers["X-API-Key"] = api_key
|
|
55
|
+
|
|
56
|
+
self._queue: queue.Queue[dict] = queue.Queue(maxsize=1000)
|
|
57
|
+
self._thread = threading.Thread(target=self._run, daemon=True, name="api-poster")
|
|
58
|
+
self._thread.start()
|
|
59
|
+
LOG.info("API poster started → %s", self._url)
|
|
60
|
+
|
|
61
|
+
def submit(self, record: dict) -> None:
|
|
62
|
+
"""Non-blocking enqueue. Drops silently if the queue is full."""
|
|
63
|
+
try:
|
|
64
|
+
self._queue.put_nowait(record)
|
|
65
|
+
except queue.Full:
|
|
66
|
+
LOG.warning(
|
|
67
|
+
"API poster queue full — dropping reading for endpoint %s",
|
|
68
|
+
record.get("endpoint_id"),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
def _run(self) -> None:
|
|
72
|
+
while True:
|
|
73
|
+
self._post(self._queue.get())
|
|
74
|
+
|
|
75
|
+
def _post(self, record: dict) -> None:
|
|
76
|
+
payload = _to_payload(record)
|
|
77
|
+
if payload is None:
|
|
78
|
+
return
|
|
79
|
+
try:
|
|
80
|
+
req = urllib.request.Request(
|
|
81
|
+
self._url,
|
|
82
|
+
data=json.dumps(payload).encode(),
|
|
83
|
+
headers=self._headers,
|
|
84
|
+
method="POST",
|
|
85
|
+
)
|
|
86
|
+
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
87
|
+
if resp.status not in (200, 201):
|
|
88
|
+
LOG.warning(
|
|
89
|
+
"API returned HTTP %d for endpoint %s",
|
|
90
|
+
resp.status,
|
|
91
|
+
payload["endpoint_id"],
|
|
92
|
+
)
|
|
93
|
+
except urllib.error.URLError as exc:
|
|
94
|
+
LOG.warning("API post failed for endpoint %s: %s", payload["endpoint_id"], exc)
|
|
95
|
+
except Exception as exc:
|
|
96
|
+
LOG.warning("Unexpected error posting to API: %s", exc)
|
|
File without changes
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""ERT type → commodity classification.
|
|
2
|
+
|
|
3
|
+
Source: https://github.com/bemasher/rtlamr/blob/master/meters.csv
|
|
4
|
+
Ambiguous types resolved by predominant field usage:
|
|
5
|
+
4 → electric (Itron AMI/C/R300 series; Sensus R-275 gas is the outlier)
|
|
6
|
+
12 → gas (Itron 100G series; Schlumberger CENTRON electric is the outlier)
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
COMMODITY_ERT_TYPES: dict[str, list[int | str]] = {
|
|
11
|
+
"electric": [4, 5, 7, 8],
|
|
12
|
+
"gas": [0, 1, 2, 9, 12],
|
|
13
|
+
"water": [3, 11, 13, "r900"],
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
# Reverse lookup: int type code or lowercase string key → commodity name.
|
|
17
|
+
_REVERSE: dict[int | str, str] = {
|
|
18
|
+
code: commodity
|
|
19
|
+
for commodity, codes in COMMODITY_ERT_TYPES.items()
|
|
20
|
+
for code in codes
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def commodity_for_endpoint_type(endpoint_type: str | None) -> str | None:
|
|
25
|
+
"""Return "electric", "gas", "water", or None.
|
|
26
|
+
|
|
27
|
+
Accepts the CharField value stored on MeterReading.endpoint_type:
|
|
28
|
+
either a stringified integer ("7") or a protocol name ("R900", "IDM").
|
|
29
|
+
"""
|
|
30
|
+
if endpoint_type is None:
|
|
31
|
+
return None
|
|
32
|
+
try:
|
|
33
|
+
return _REVERSE.get(int(endpoint_type))
|
|
34
|
+
except ValueError:
|
|
35
|
+
pass
|
|
36
|
+
return _REVERSE.get(endpoint_type.lower())
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""IDM (Interval Data Message) protocol parser.
|
|
2
|
+
|
|
3
|
+
Port of rtlamr-go/idm/idm.go.
|
|
4
|
+
|
|
5
|
+
Packet: 92 bytes (736 bits total).
|
|
6
|
+
Preamble: 32 symbols — 16 training bits + 16-bit frame sync (0x16A3).
|
|
7
|
+
CRC: CCITT-16 (same as SCM+), two checks:
|
|
8
|
+
1. checksum(bytes[4:92]) == RESIDUE (packet CRC)
|
|
9
|
+
2. checksum(bytes[9:13] + bytes[88:90]) == RESIDUE (serial number CRC)
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import struct
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
|
|
19
|
+
from rtlamr_python import crc
|
|
20
|
+
|
|
21
|
+
_PREAMBLE = np.array(
|
|
22
|
+
[int(b) for b in "01010101010101010001011010100011"], dtype=np.uint8
|
|
23
|
+
)
|
|
24
|
+
_PACKET_SYMBOLS = 92 * 8 # 736 bits
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def make_config(chip_length: int = 72):
|
|
28
|
+
from rtlamr_python.decoder import Config
|
|
29
|
+
return Config(
|
|
30
|
+
chip_length=chip_length,
|
|
31
|
+
preamble_bits=_PREAMBLE.copy(),
|
|
32
|
+
packet_symbols=_PACKET_SYMBOLS,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class IDM:
|
|
38
|
+
preamble: int # bytes[0:4]
|
|
39
|
+
packet_type_id: int # bytes[4]
|
|
40
|
+
packet_length: int # bytes[5]
|
|
41
|
+
hamming_code: int # bytes[6]
|
|
42
|
+
application_version: int # bytes[7]
|
|
43
|
+
ert_type: int # bytes[8] & 0x0F
|
|
44
|
+
ert_serial_number: int # bytes[9:13]
|
|
45
|
+
consumption_interval_count: int # bytes[13]
|
|
46
|
+
module_programming_state: int # bytes[14]
|
|
47
|
+
tamper_counters: bytes # bytes[15:21]
|
|
48
|
+
asynchronous_counters: int # bytes[21:23]
|
|
49
|
+
power_outage_flags: bytes # bytes[23:29]
|
|
50
|
+
last_consumption_count: int # bytes[29:33]
|
|
51
|
+
differential_consumption_intervals: list # 47 × 9-bit values
|
|
52
|
+
transmit_time_offset: int # bytes[86:88]
|
|
53
|
+
serial_number_crc: int # bytes[88:90]
|
|
54
|
+
packet_crc: int # bytes[90:92]
|
|
55
|
+
|
|
56
|
+
def as_dict(self) -> dict:
|
|
57
|
+
return {
|
|
58
|
+
"type": "IDM",
|
|
59
|
+
"endpoint_id": self.ert_serial_number,
|
|
60
|
+
"ert_type": self.ert_type,
|
|
61
|
+
"consumption_interval_count": self.consumption_interval_count,
|
|
62
|
+
"last_consumption_count": self.last_consumption_count,
|
|
63
|
+
"differential_consumption_intervals": self.differential_consumption_intervals,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _serial_crc_buf(raw: bytes) -> bytes:
|
|
68
|
+
"""Build the 6-byte buffer used for the serial number CRC check."""
|
|
69
|
+
buf = bytearray(6)
|
|
70
|
+
buf[0:4] = raw[9:13]
|
|
71
|
+
buf[4:6] = raw[88:90]
|
|
72
|
+
return bytes(buf)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def parse(raw: bytes | bytearray) -> IDM | None:
|
|
76
|
+
if len(raw) < 92:
|
|
77
|
+
return None
|
|
78
|
+
if crc.checksum(raw[4:92]) != crc.RESIDUE:
|
|
79
|
+
return None
|
|
80
|
+
if crc.checksum(_serial_crc_buf(raw)) != crc.RESIDUE:
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
preamble = struct.unpack_from(">I", raw, 0)[0]
|
|
84
|
+
packet_type_id = raw[4]
|
|
85
|
+
packet_length_b = raw[5]
|
|
86
|
+
hamming_code = raw[6]
|
|
87
|
+
application_version = raw[7]
|
|
88
|
+
ert_type = raw[8] & 0x0F
|
|
89
|
+
ert_serial_number = struct.unpack_from(">I", raw, 9)[0]
|
|
90
|
+
|
|
91
|
+
if ert_serial_number == 0:
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
consumption_interval_count = raw[13]
|
|
95
|
+
module_programming_state = raw[14]
|
|
96
|
+
tamper_counters = bytes(raw[15:21])
|
|
97
|
+
asynchronous_counters = struct.unpack_from(">H", raw, 21)[0]
|
|
98
|
+
power_outage_flags = bytes(raw[23:29])
|
|
99
|
+
last_consumption_count = struct.unpack_from(">I", raw, 29)[0]
|
|
100
|
+
|
|
101
|
+
# 47 differential intervals packed at 9 bits each, starting at bit 264.
|
|
102
|
+
bits = "".join(f"{b:08b}" for b in raw[:92])
|
|
103
|
+
intervals = []
|
|
104
|
+
offset = 264
|
|
105
|
+
for _ in range(47):
|
|
106
|
+
intervals.append(int(bits[offset:offset + 9], 2))
|
|
107
|
+
offset += 9
|
|
108
|
+
|
|
109
|
+
transmit_time_offset = struct.unpack_from(">H", raw, 86)[0]
|
|
110
|
+
serial_number_crc = struct.unpack_from(">H", raw, 88)[0]
|
|
111
|
+
packet_crc = struct.unpack_from(">H", raw, 90)[0]
|
|
112
|
+
|
|
113
|
+
return IDM(
|
|
114
|
+
preamble=preamble,
|
|
115
|
+
packet_type_id=packet_type_id,
|
|
116
|
+
packet_length=packet_length_b,
|
|
117
|
+
hamming_code=hamming_code,
|
|
118
|
+
application_version=application_version,
|
|
119
|
+
ert_type=ert_type,
|
|
120
|
+
ert_serial_number=ert_serial_number,
|
|
121
|
+
consumption_interval_count=consumption_interval_count,
|
|
122
|
+
module_programming_state=module_programming_state,
|
|
123
|
+
tamper_counters=tamper_counters,
|
|
124
|
+
asynchronous_counters=asynchronous_counters,
|
|
125
|
+
power_outage_flags=power_outage_flags,
|
|
126
|
+
last_consumption_count=last_consumption_count,
|
|
127
|
+
differential_consumption_intervals=intervals,
|
|
128
|
+
transmit_time_offset=transmit_time_offset,
|
|
129
|
+
serial_number_crc=serial_number_crc,
|
|
130
|
+
packet_crc=packet_crc,
|
|
131
|
+
)
|