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.
- rtlamr_python-1.0.0/LICENSE +21 -0
- rtlamr_python-1.0.0/PKG-INFO +154 -0
- rtlamr_python-1.0.0/README.md +123 -0
- rtlamr_python-1.0.0/pyproject.toml +56 -0
- rtlamr_python-1.0.0/setup.cfg +4 -0
- rtlamr_python-1.0.0/src/rtlamr_python/__init__.py +17 -0
- rtlamr_python-1.0.0/src/rtlamr_python/__main__.py +4 -0
- rtlamr_python-1.0.0/src/rtlamr_python/cli.py +233 -0
- rtlamr_python-1.0.0/src/rtlamr_python/crc.py +75 -0
- rtlamr_python-1.0.0/src/rtlamr_python/decoder.py +275 -0
- rtlamr_python-1.0.0/src/rtlamr_python/listener.py +332 -0
- rtlamr_python-1.0.0/src/rtlamr_python/poster.py +96 -0
- rtlamr_python-1.0.0/src/rtlamr_python/protocols/__init__.py +0 -0
- rtlamr_python-1.0.0/src/rtlamr_python/protocols/commodity.py +36 -0
- rtlamr_python-1.0.0/src/rtlamr_python/protocols/idm.py +131 -0
- rtlamr_python-1.0.0/src/rtlamr_python/protocols/netidm.py +119 -0
- rtlamr_python-1.0.0/src/rtlamr_python/protocols/r900.py +63 -0
- rtlamr_python-1.0.0/src/rtlamr_python/protocols/scm.py +74 -0
- rtlamr_python-1.0.0/src/rtlamr_python/protocols/scmplus.py +92 -0
- rtlamr_python-1.0.0/src/rtlamr_python/r900_decoder.py +316 -0
- rtlamr_python-1.0.0/src/rtlamr_python/sdr.py +102 -0
- rtlamr_python-1.0.0/src/rtlamr_python.egg-info/PKG-INFO +154 -0
- rtlamr_python-1.0.0/src/rtlamr_python.egg-info/SOURCES.txt +35 -0
- rtlamr_python-1.0.0/src/rtlamr_python.egg-info/dependency_links.txt +1 -0
- rtlamr_python-1.0.0/src/rtlamr_python.egg-info/entry_points.txt +2 -0
- rtlamr_python-1.0.0/src/rtlamr_python.egg-info/requires.txt +7 -0
- rtlamr_python-1.0.0/src/rtlamr_python.egg-info/top_level.txt +1 -0
- rtlamr_python-1.0.0/tests/test_alternating_config.py +67 -0
- rtlamr_python-1.0.0/tests/test_config.py +74 -0
- rtlamr_python-1.0.0/tests/test_crc.py +46 -0
- rtlamr_python-1.0.0/tests/test_decoder.py +149 -0
- rtlamr_python-1.0.0/tests/test_idm.py +137 -0
- rtlamr_python-1.0.0/tests/test_listener.py +135 -0
- rtlamr_python-1.0.0/tests/test_poster.py +93 -0
- rtlamr_python-1.0.0/tests/test_r900.py +135 -0
- rtlamr_python-1.0.0/tests/test_scm.py +106 -0
- 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,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,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
|