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,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)
@@ -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
+ )