rtlamr-python 1.0.0__tar.gz

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.
Files changed (37) hide show
  1. rtlamr_python-1.0.0/LICENSE +21 -0
  2. rtlamr_python-1.0.0/PKG-INFO +154 -0
  3. rtlamr_python-1.0.0/README.md +123 -0
  4. rtlamr_python-1.0.0/pyproject.toml +56 -0
  5. rtlamr_python-1.0.0/setup.cfg +4 -0
  6. rtlamr_python-1.0.0/src/rtlamr_python/__init__.py +17 -0
  7. rtlamr_python-1.0.0/src/rtlamr_python/__main__.py +4 -0
  8. rtlamr_python-1.0.0/src/rtlamr_python/cli.py +233 -0
  9. rtlamr_python-1.0.0/src/rtlamr_python/crc.py +75 -0
  10. rtlamr_python-1.0.0/src/rtlamr_python/decoder.py +275 -0
  11. rtlamr_python-1.0.0/src/rtlamr_python/listener.py +332 -0
  12. rtlamr_python-1.0.0/src/rtlamr_python/poster.py +96 -0
  13. rtlamr_python-1.0.0/src/rtlamr_python/protocols/__init__.py +0 -0
  14. rtlamr_python-1.0.0/src/rtlamr_python/protocols/commodity.py +36 -0
  15. rtlamr_python-1.0.0/src/rtlamr_python/protocols/idm.py +131 -0
  16. rtlamr_python-1.0.0/src/rtlamr_python/protocols/netidm.py +119 -0
  17. rtlamr_python-1.0.0/src/rtlamr_python/protocols/r900.py +63 -0
  18. rtlamr_python-1.0.0/src/rtlamr_python/protocols/scm.py +74 -0
  19. rtlamr_python-1.0.0/src/rtlamr_python/protocols/scmplus.py +92 -0
  20. rtlamr_python-1.0.0/src/rtlamr_python/r900_decoder.py +316 -0
  21. rtlamr_python-1.0.0/src/rtlamr_python/sdr.py +102 -0
  22. rtlamr_python-1.0.0/src/rtlamr_python.egg-info/PKG-INFO +154 -0
  23. rtlamr_python-1.0.0/src/rtlamr_python.egg-info/SOURCES.txt +35 -0
  24. rtlamr_python-1.0.0/src/rtlamr_python.egg-info/dependency_links.txt +1 -0
  25. rtlamr_python-1.0.0/src/rtlamr_python.egg-info/entry_points.txt +2 -0
  26. rtlamr_python-1.0.0/src/rtlamr_python.egg-info/requires.txt +7 -0
  27. rtlamr_python-1.0.0/src/rtlamr_python.egg-info/top_level.txt +1 -0
  28. rtlamr_python-1.0.0/tests/test_alternating_config.py +67 -0
  29. rtlamr_python-1.0.0/tests/test_config.py +74 -0
  30. rtlamr_python-1.0.0/tests/test_crc.py +46 -0
  31. rtlamr_python-1.0.0/tests/test_decoder.py +149 -0
  32. rtlamr_python-1.0.0/tests/test_idm.py +137 -0
  33. rtlamr_python-1.0.0/tests/test_listener.py +135 -0
  34. rtlamr_python-1.0.0/tests/test_poster.py +93 -0
  35. rtlamr_python-1.0.0/tests/test_r900.py +135 -0
  36. rtlamr_python-1.0.0/tests/test_scm.py +106 -0
  37. rtlamr_python-1.0.0/tests/test_scmplus.py +59 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ryan Bagwell
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,154 @@
1
+ Metadata-Version: 2.4
2
+ Name: rtlamr-python
3
+ Version: 1.0.0
4
+ Summary: Python implementation of RTLAMR-go — a multi-protocol ERT smart meter receiver
5
+ Author-email: Ryan Bagwell <ryan@ryanbagwell.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/ryanbagwell/rtlamr-python
8
+ Project-URL: Repository, https://github.com/ryanbagwell/rtlamr-python
9
+ Project-URL: Issues, https://github.com/ryanbagwell/rtlamr-python/issues
10
+ Keywords: rtlamr,rtl-sdr,sdr,ert,smart-meter,scm,idm,r900
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Communications
20
+ Classifier: Topic :: Home Automation
21
+ Requires-Python: >=3.11
22
+ Description-Content-Type: text/markdown
23
+ License-File: LICENSE
24
+ Requires-Dist: numpy>=1.24
25
+ Requires-Dist: pyrtlsdr==0.3.0
26
+ Requires-Dist: pyusb>=1.3.1
27
+ Requires-Dist: setuptools
28
+ Provides-Extra: dev
29
+ Requires-Dist: pytest; extra == "dev"
30
+ Dynamic: license-file
31
+
32
+ # rtlamr-python
33
+
34
+ A Python implementation of [rtlamr](https://github.com/bemasher/rtlamr) — a multi-protocol
35
+ ERT (Encoder Receiver Transmitter) smart meter receiver. Reads IQ samples from an RTL-SDR
36
+ dongle (or a raw sample file), decodes ERT packets, and prints each message as a JSON line
37
+ to stdout.
38
+
39
+ ## Supported protocols
40
+
41
+ | Protocol | Description |
42
+ |-----------|-----------------------------------------------------------------|
43
+ | `scmplus` | Standard Consumption Message Plus (16 bytes, most electric meters) |
44
+ | `scm` | Standard Consumption Message (12 bytes, older electric meters) |
45
+ | `idm` | Interval Data Message (92 bytes, hourly interval data) |
46
+ | `netidm` | Net Meter Interval Data Message (92 bytes, net-metering variant)|
47
+ | `r900` | Neptune R900 water meters (different center freq: 912.38 MHz) |
48
+
49
+ By default, all Manchester-encoded protocols (`scmplus`, `scm`, `idm`, `netidm`) are decoded
50
+ simultaneously. `r900` uses a different center frequency and must be selected explicitly
51
+ (alone, or alternated with the Manchester set).
52
+
53
+ ## Installation
54
+
55
+ Requires Python 3.11+ and an RTL-SDR dongle (or [librtlsdr](https://github.com/librtlsdr/librtlsdr) installed) for live capture.
56
+
57
+ ```bash
58
+ pip install rtlamr-python
59
+ ```
60
+
61
+ This installs an `rtlamr` command on your `PATH`.
62
+
63
+ ## Usage
64
+
65
+ ```bash
66
+ # All Manchester protocols on live hardware
67
+ rtlamr
68
+
69
+ # Single protocol
70
+ rtlamr --protocol scmplus
71
+
72
+ # From a recorded capture file
73
+ rtlamr --sample-file /path/to/capture.bin
74
+
75
+ # Filter to specific meters
76
+ rtlamr --meter-id 12345678
77
+
78
+ # Alternate between Manchester and R900 (different center frequencies)
79
+ rtlamr --protocol scmplus r900
80
+ ```
81
+
82
+ Run `rtlamr --help` for the full list of options (gain, frequency correction, chip length,
83
+ posting readings to a REST API, etc.).
84
+
85
+ ### Configuration file
86
+
87
+ Options can also be supplied via a TOML config file with `--config path/to/rtlamr.toml`.
88
+ Command-line flags always take precedence over the config file. Example:
89
+
90
+ ```toml
91
+ meter_ids = [12345678, 87654321]
92
+ protocol = ["scmplus", "scm"]
93
+ gain = "auto"
94
+ api_url = "http://localhost:8000/api"
95
+ api_key = "secret"
96
+ switch_timeout = 60.0
97
+ ```
98
+
99
+ ## Library usage
100
+
101
+ `rtlamr-python` can also be used as a library rather than a CLI, e.g. to decode readings
102
+ from within another application. All three forms below share the same options as the CLI
103
+ (`protocols`, `meter_id`, `chip_length`, `gain`, `freq_correction`, `sample_file`,
104
+ `switch_timeout`, `duration`, `verbose`).
105
+
106
+ ### Iterate over readings as they arrive
107
+
108
+ ```python
109
+ from rtlamr_python import listen
110
+
111
+ for reading in listen(protocols=["scmplus"], meter_id=[12345678]):
112
+ print(reading)
113
+ # break whenever you've got what you need — the SDR is closed on exit
114
+ ```
115
+
116
+ ### Block until a single reading decodes
117
+
118
+ ```python
119
+ from rtlamr_python import listen_once
120
+
121
+ reading = listen_once(protocols=["scmplus"], meter_id=[12345678])
122
+ print(reading) # SDR is already closed here
123
+ ```
124
+
125
+ ### Run in the background with a callback
126
+
127
+ ```python
128
+ from rtlamr_python import start_listening
129
+
130
+ def on_message(reading):
131
+ print(reading)
132
+
133
+ handle = start_listening(on_message, protocols=["scmplus"])
134
+ ...
135
+ handle.stop() # signals the background thread and waits for it to exit
136
+ ```
137
+
138
+ Each reading is a dict, e.g.:
139
+
140
+ ```json
141
+ {"time": "2026-01-01T00:00:00Z", "type": "SCM+", "endpoint_id": 12345678,
142
+ "endpoint_type": 4, "consumption": 112233, "tamper": "0x0000", "packet_crc": "0x972F"}
143
+ ```
144
+
145
+ ## Development
146
+
147
+ ```bash
148
+ pip install -e ".[dev]"
149
+ pytest
150
+ ```
151
+
152
+ ## License
153
+
154
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,123 @@
1
+ # rtlamr-python
2
+
3
+ A Python implementation of [rtlamr](https://github.com/bemasher/rtlamr) — a multi-protocol
4
+ ERT (Encoder Receiver Transmitter) smart meter receiver. Reads IQ samples from an RTL-SDR
5
+ dongle (or a raw sample file), decodes ERT packets, and prints each message as a JSON line
6
+ to stdout.
7
+
8
+ ## Supported protocols
9
+
10
+ | Protocol | Description |
11
+ |-----------|-----------------------------------------------------------------|
12
+ | `scmplus` | Standard Consumption Message Plus (16 bytes, most electric meters) |
13
+ | `scm` | Standard Consumption Message (12 bytes, older electric meters) |
14
+ | `idm` | Interval Data Message (92 bytes, hourly interval data) |
15
+ | `netidm` | Net Meter Interval Data Message (92 bytes, net-metering variant)|
16
+ | `r900` | Neptune R900 water meters (different center freq: 912.38 MHz) |
17
+
18
+ By default, all Manchester-encoded protocols (`scmplus`, `scm`, `idm`, `netidm`) are decoded
19
+ simultaneously. `r900` uses a different center frequency and must be selected explicitly
20
+ (alone, or alternated with the Manchester set).
21
+
22
+ ## Installation
23
+
24
+ Requires Python 3.11+ and an RTL-SDR dongle (or [librtlsdr](https://github.com/librtlsdr/librtlsdr) installed) for live capture.
25
+
26
+ ```bash
27
+ pip install rtlamr-python
28
+ ```
29
+
30
+ This installs an `rtlamr` command on your `PATH`.
31
+
32
+ ## Usage
33
+
34
+ ```bash
35
+ # All Manchester protocols on live hardware
36
+ rtlamr
37
+
38
+ # Single protocol
39
+ rtlamr --protocol scmplus
40
+
41
+ # From a recorded capture file
42
+ rtlamr --sample-file /path/to/capture.bin
43
+
44
+ # Filter to specific meters
45
+ rtlamr --meter-id 12345678
46
+
47
+ # Alternate between Manchester and R900 (different center frequencies)
48
+ rtlamr --protocol scmplus r900
49
+ ```
50
+
51
+ Run `rtlamr --help` for the full list of options (gain, frequency correction, chip length,
52
+ posting readings to a REST API, etc.).
53
+
54
+ ### Configuration file
55
+
56
+ Options can also be supplied via a TOML config file with `--config path/to/rtlamr.toml`.
57
+ Command-line flags always take precedence over the config file. Example:
58
+
59
+ ```toml
60
+ meter_ids = [12345678, 87654321]
61
+ protocol = ["scmplus", "scm"]
62
+ gain = "auto"
63
+ api_url = "http://localhost:8000/api"
64
+ api_key = "secret"
65
+ switch_timeout = 60.0
66
+ ```
67
+
68
+ ## Library usage
69
+
70
+ `rtlamr-python` can also be used as a library rather than a CLI, e.g. to decode readings
71
+ from within another application. All three forms below share the same options as the CLI
72
+ (`protocols`, `meter_id`, `chip_length`, `gain`, `freq_correction`, `sample_file`,
73
+ `switch_timeout`, `duration`, `verbose`).
74
+
75
+ ### Iterate over readings as they arrive
76
+
77
+ ```python
78
+ from rtlamr_python import listen
79
+
80
+ for reading in listen(protocols=["scmplus"], meter_id=[12345678]):
81
+ print(reading)
82
+ # break whenever you've got what you need — the SDR is closed on exit
83
+ ```
84
+
85
+ ### Block until a single reading decodes
86
+
87
+ ```python
88
+ from rtlamr_python import listen_once
89
+
90
+ reading = listen_once(protocols=["scmplus"], meter_id=[12345678])
91
+ print(reading) # SDR is already closed here
92
+ ```
93
+
94
+ ### Run in the background with a callback
95
+
96
+ ```python
97
+ from rtlamr_python import start_listening
98
+
99
+ def on_message(reading):
100
+ print(reading)
101
+
102
+ handle = start_listening(on_message, protocols=["scmplus"])
103
+ ...
104
+ handle.stop() # signals the background thread and waits for it to exit
105
+ ```
106
+
107
+ Each reading is a dict, e.g.:
108
+
109
+ ```json
110
+ {"time": "2026-01-01T00:00:00Z", "type": "SCM+", "endpoint_id": 12345678,
111
+ "endpoint_type": 4, "consumption": 112233, "tamper": "0x0000", "packet_crc": "0x972F"}
112
+ ```
113
+
114
+ ## Development
115
+
116
+ ```bash
117
+ pip install -e ".[dev]"
118
+ pytest
119
+ ```
120
+
121
+ ## License
122
+
123
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,56 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "rtlamr-python"
7
+ version = "1.0.0"
8
+ description = "Python implementation of RTLAMR-go — a multi-protocol ERT smart meter receiver"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.11"
13
+ authors = [
14
+ { name = "Ryan Bagwell", email = "ryan@ryanbagwell.com" },
15
+ ]
16
+ keywords = ["rtlamr", "rtl-sdr", "sdr", "ert", "smart-meter", "scm", "idm", "r900"]
17
+ classifiers = [
18
+ "Development Status :: 4 - Beta",
19
+ "Environment :: Console",
20
+ "Intended Audience :: Developers",
21
+ "Operating System :: OS Independent",
22
+ "Programming Language :: Python :: 3",
23
+ "Programming Language :: Python :: 3.11",
24
+ "Programming Language :: Python :: 3.12",
25
+ "Programming Language :: Python :: 3.13",
26
+ "Topic :: Communications",
27
+ "Topic :: Home Automation",
28
+ ]
29
+ dependencies = [
30
+ "numpy>=1.24",
31
+ "pyrtlsdr==0.3.0",
32
+ "pyusb>=1.3.1",
33
+ "setuptools",
34
+ ]
35
+
36
+ [project.optional-dependencies]
37
+ dev = [
38
+ "pytest",
39
+ ]
40
+
41
+ [project.urls]
42
+ Homepage = "https://github.com/ryanbagwell/rtlamr-python"
43
+ Repository = "https://github.com/ryanbagwell/rtlamr-python"
44
+ Issues = "https://github.com/ryanbagwell/rtlamr-python/issues"
45
+
46
+ [project.scripts]
47
+ rtlamr = "rtlamr_python.cli:main"
48
+
49
+ [tool.setuptools]
50
+ package-dir = { "" = "src" }
51
+
52
+ [tool.setuptools.packages.find]
53
+ where = ["src"]
54
+
55
+ [tool.black]
56
+ line-length = 80
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,17 @@
1
+ from rtlamr_python.listener import (
2
+ ALL_PROTOCOLS,
3
+ DEFAULT_PROTOCOLS,
4
+ ListenerHandle,
5
+ listen,
6
+ listen_once,
7
+ start_listening,
8
+ )
9
+
10
+ __all__ = [
11
+ "ALL_PROTOCOLS",
12
+ "DEFAULT_PROTOCOLS",
13
+ "ListenerHandle",
14
+ "listen",
15
+ "listen_once",
16
+ "start_listening",
17
+ ]
@@ -0,0 +1,4 @@
1
+ from rtlamr_python.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
@@ -0,0 +1,233 @@
1
+ """rtlamr-python — Multi-protocol ERT smart meter receiver.
2
+
3
+ Reads IQ samples from an RTL-SDR dongle (or a raw sample file for testing),
4
+ decodes ERT packets, and prints each message as a JSON line to stdout.
5
+
6
+ Supported protocols (use --protocol to select; defaults to all Manchester ones):
7
+ scmplus — Standard Consumption Message Plus (16 bytes, most electric meters)
8
+ scm — Standard Consumption Message (12 bytes, older electric meters)
9
+ idm — Interval Data Message (92 bytes, hourly interval data)
10
+ netidm — Net Meter Interval Data Message (92 bytes, net-metering variant)
11
+ r900 — Neptune R900 water meters (different center freq: 912.38 MHz)
12
+
13
+ Usage:
14
+ # All Manchester protocols on live hardware
15
+ rtlamr
16
+
17
+ # Single protocol
18
+ rtlamr --protocol scmplus
19
+
20
+ # From a recorded capture file
21
+ rtlamr --sample-file /path/to/capture.bin
22
+
23
+ # Filter to specific meters
24
+ rtlamr --meter-id 12345678
25
+
26
+ # Alternate between Manchester and R900 (different center frequencies)
27
+ rtlamr --protocol scmplus r900
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import argparse
33
+ import json
34
+ import logging
35
+ import signal
36
+ import sys
37
+ import threading
38
+
39
+ from rtlamr_python.listener import ALL_PROTOCOLS, DEFAULT_PROTOCOLS, listen
40
+ from rtlamr_python.poster import ApiPoster
41
+
42
+ LOG = logging.getLogger(__name__)
43
+
44
+
45
+ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
46
+ p = argparse.ArgumentParser(description="Multi-protocol ERT smart meter receiver")
47
+ p.add_argument(
48
+ "--config",
49
+ default=None,
50
+ metavar="PATH",
51
+ help="TOML config file (default: none)",
52
+ )
53
+ p.add_argument(
54
+ "--verbose",
55
+ action="store_true",
56
+ default=None,
57
+ help="print per-decoder stats to stderr every 500 blocks",
58
+ )
59
+ p.add_argument(
60
+ "--chip-length",
61
+ type=int,
62
+ default=None,
63
+ metavar="N",
64
+ help="chip length in samples (default: 72 → ~2.36 MHz sample rate)",
65
+ )
66
+ p.add_argument(
67
+ "--gain",
68
+ default=None,
69
+ metavar="GAIN",
70
+ help='tuner gain in dB or "auto" (default: auto)',
71
+ )
72
+ p.add_argument(
73
+ "--freq-correction",
74
+ type=int,
75
+ default=None,
76
+ metavar="PPM",
77
+ dest="freq_correction",
78
+ help="frequency correction for the RTL-SDR oscillator in parts per million "
79
+ "(negative if signals appear below their expected frequency)",
80
+ )
81
+ p.add_argument(
82
+ "--meter-id",
83
+ type=int,
84
+ nargs="+",
85
+ default=None,
86
+ metavar="ID",
87
+ dest="meter_id",
88
+ help="only forward readings from these endpoint IDs (space-separated); "
89
+ "overrides meter_ids in the config file",
90
+ )
91
+ p.add_argument(
92
+ "--sample-file",
93
+ default=None,
94
+ metavar="PATH",
95
+ help="read raw IQ bytes from a file instead of live hardware",
96
+ )
97
+ p.add_argument(
98
+ "--duration",
99
+ type=float,
100
+ default=None,
101
+ metavar="SECONDS",
102
+ help="stop after this many seconds (0 = run forever)",
103
+ )
104
+ p.add_argument(
105
+ "--protocol",
106
+ nargs="+",
107
+ choices=list(ALL_PROTOCOLS),
108
+ default=None,
109
+ metavar="PROTO",
110
+ help="protocols to decode (space-separated); default: all Manchester",
111
+ )
112
+ p.add_argument(
113
+ "--api-url",
114
+ default=None,
115
+ metavar="URL",
116
+ help='base URL of the REST API (e.g. "http://localhost:8000/api"); '
117
+ "if omitted, readings are only written to stdout",
118
+ )
119
+ p.add_argument(
120
+ "--api-key",
121
+ default=None,
122
+ metavar="KEY",
123
+ help="value for the X-API-Key header (only needed when the API requires auth)",
124
+ )
125
+ p.add_argument(
126
+ "--switch-timeout",
127
+ type=float,
128
+ default=None,
129
+ metavar="SECONDS",
130
+ help="alternating mode: switch frequency after this many seconds without "
131
+ "a message (default: 60)",
132
+ )
133
+ return p.parse_args(argv)
134
+
135
+
136
+ def _load_config(path: str | None) -> dict:
137
+ if path is None:
138
+ return {}
139
+ import tomllib
140
+ try:
141
+ with open(path, "rb") as f:
142
+ return tomllib.load(f)
143
+ except FileNotFoundError:
144
+ return {}
145
+
146
+
147
+ _DEFAULTS: dict = {
148
+ "chip_length": 72,
149
+ "gain": "auto",
150
+ "freq_correction": 0,
151
+ "duration": 0.0,
152
+ "verbose": False,
153
+ "switch_timeout": 60.0,
154
+ }
155
+
156
+ _CONFIG_KEYS = {
157
+ "api_url", "api_key", "meter_id", "meter_ids", "protocol", "switch_timeout",
158
+ "gain", "freq_correction", "chip_length", "duration", "verbose",
159
+ }
160
+
161
+
162
+ def _apply_config(args: argparse.Namespace, cfg: dict) -> None:
163
+ """Back-fill args still at None from cfg, then apply built-in defaults."""
164
+ # Normalize meter ID to list[int] or None.
165
+ if args.meter_id is None:
166
+ if "meter_ids" in cfg:
167
+ args.meter_id = list(cfg["meter_ids"])
168
+ elif "meter_id" in cfg:
169
+ args.meter_id = [cfg["meter_id"]] # legacy single-int key
170
+
171
+ # Normalize protocol to list[str] or None.
172
+ if args.protocol is None and "protocol" in cfg:
173
+ raw = cfg["protocol"]
174
+ if isinstance(raw, list):
175
+ args.protocol = raw
176
+ else:
177
+ args.protocol = [p.strip() for p in str(raw).split(",")]
178
+
179
+ for key in _CONFIG_KEYS - {"meter_id", "meter_ids", "protocol"}:
180
+ if getattr(args, key) is None and key in cfg:
181
+ setattr(args, key, cfg[key])
182
+ for key, default in _DEFAULTS.items():
183
+ if getattr(args, key) is None:
184
+ setattr(args, key, default)
185
+
186
+
187
+ def main(argv: list[str] | None = None) -> int:
188
+ logging.basicConfig(
189
+ level=logging.INFO,
190
+ format="%(asctime)s %(levelname)s %(message)s",
191
+ stream=sys.stderr,
192
+ )
193
+
194
+ args = parse_args(argv)
195
+ _apply_config(args, _load_config(args.config))
196
+
197
+ poster = ApiPoster(args.api_url, args.api_key) if args.api_url else None
198
+
199
+ # Graceful shutdown on SIGINT.
200
+ stop_event = threading.Event()
201
+
202
+ def _stop(signum, frame):
203
+ LOG.info("Received signal %s, stopping.", signum)
204
+ stop_event.set()
205
+
206
+ signal.signal(signal.SIGINT, _stop)
207
+
208
+ try:
209
+ for record in listen(
210
+ protocols=args.protocol or list(DEFAULT_PROTOCOLS),
211
+ meter_id=args.meter_id,
212
+ chip_length=args.chip_length,
213
+ gain=args.gain,
214
+ freq_correction=args.freq_correction,
215
+ sample_file=args.sample_file,
216
+ switch_timeout=args.switch_timeout,
217
+ duration=args.duration,
218
+ verbose=args.verbose,
219
+ stop_event=stop_event,
220
+ ):
221
+ sys.stdout.write(json.dumps(record) + "\n")
222
+ sys.stdout.flush()
223
+ if poster:
224
+ poster.submit(record)
225
+ except ValueError as exc:
226
+ LOG.error(str(exc))
227
+ return 1
228
+
229
+ return 0
230
+
231
+
232
+ if __name__ == "__main__":
233
+ raise SystemExit(main())
@@ -0,0 +1,75 @@
1
+ """CCITT-16 CRC used by the SCM+ protocol (CRC-16/GENIBUS variant).
2
+
3
+ Port of rtlamr-go/crc/crc.go.
4
+
5
+ Polynomial : 0x1021
6
+ Init value : 0xFFFF
7
+ XOR-out : 0xFFFF (meters complement the CRC before embedding it as PacketCRC)
8
+ Valid residue: 0x1D0F (checksum over bytes[2:] of a valid SCM+ packet equals this)
9
+
10
+ To embed a valid PacketCRC when constructing a packet:
11
+ pkt_crc = checksum(bytes[2:14]) ^ 0xFFFF
12
+
13
+ To verify a received packet:
14
+ valid(packet) — checks checksum(packet[2:]) == RESIDUE
15
+ """
16
+
17
+ _POLY = 0x1021
18
+ _INIT = 0xFFFF
19
+ RESIDUE = 0x1D0F
20
+
21
+ # Build 256-entry lookup table once at import time.
22
+ _table: list[int] = []
23
+ for _i in range(256):
24
+ _crc = _i << 8
25
+ for _ in range(8):
26
+ if _crc & 0x8000:
27
+ _crc = (_crc << 1) ^ _POLY
28
+ else:
29
+ _crc <<= 1
30
+ _crc &= 0xFFFF
31
+ _table.append(_crc)
32
+
33
+
34
+ def checksum(data: bytes | bytearray, init: int = _INIT) -> int:
35
+ """Return the CCITT-16 CRC of *data* starting from *init*."""
36
+ crc = init
37
+ for byte in data:
38
+ crc = ((crc << 8) ^ _table[(crc >> 8) ^ byte]) & 0xFFFF
39
+ return crc
40
+
41
+
42
+ def valid(packet: bytes | bytearray) -> bool:
43
+ """Return True when the CRC of packet[2:] equals the expected residue."""
44
+ return checksum(packet[2:]) == RESIDUE
45
+
46
+
47
+ # ---------------------------------------------------------------------------
48
+ # BCH-16 CRC used by the SCM protocol
49
+ # ---------------------------------------------------------------------------
50
+
51
+ _BCH_POLY = 0x6F63
52
+
53
+ _bch_table: list[int] = []
54
+ for _i in range(256):
55
+ _crc = _i << 8
56
+ for _ in range(8):
57
+ if _crc & 0x8000:
58
+ _crc = (_crc << 1) ^ _BCH_POLY
59
+ else:
60
+ _crc <<= 1
61
+ _crc &= 0xFFFF
62
+ _bch_table.append(_crc)
63
+
64
+
65
+ def bch_checksum(data: bytes | bytearray, init: int = 0) -> int:
66
+ """Return the BCH-16 CRC of *data* (poly=0x6F63, init=0, xorout=0)."""
67
+ crc = init
68
+ for byte in data:
69
+ crc = ((crc << 8) ^ _bch_table[(crc >> 8) ^ byte]) & 0xFFFF
70
+ return crc
71
+
72
+
73
+ def bch_valid(packet: bytes | bytearray) -> bool:
74
+ """Return True when bch_checksum(packet[2:12]) == 0 (SCM validation)."""
75
+ return bch_checksum(packet[2:12]) == 0