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
rtlamr_python/sdr.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""SDR sample sources.
|
|
2
|
+
|
|
3
|
+
Two implementations share the SampleSource protocol:
|
|
4
|
+
UsbSdr — live RTL-SDR dongle via pyrtlsdr
|
|
5
|
+
FileSampleSource — reads raw IQ bytes from a file (loops on EOF)
|
|
6
|
+
|
|
7
|
+
Select via the --sample-file CLI flag; omit it for live USB.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import io
|
|
13
|
+
from typing import Protocol, runtime_checkable
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@runtime_checkable
|
|
17
|
+
class SampleSource(Protocol):
|
|
18
|
+
def read_block(self, n_bytes: int) -> bytes:
|
|
19
|
+
"""Return exactly *n_bytes* of raw uint8 IQ data."""
|
|
20
|
+
...
|
|
21
|
+
|
|
22
|
+
def set_center_freq(self, freq: int) -> None:
|
|
23
|
+
"""Retune the source to *freq* Hz."""
|
|
24
|
+
...
|
|
25
|
+
|
|
26
|
+
def close(self) -> None:
|
|
27
|
+
...
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class UsbSdr:
|
|
31
|
+
"""Live RTL-SDR dongle via pyrtlsdr."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, center_freq: int, sample_rate: int, gain: str | float = "auto", ppm: int = 0) -> None:
|
|
34
|
+
try:
|
|
35
|
+
# pyrtlsdr ≤ 0.3.0 does `import pkg_resources` at module level for
|
|
36
|
+
# version detection. setuptools ≥ 80 removed pkg_resources, so
|
|
37
|
+
# inject a minimal stub when the real module is absent.
|
|
38
|
+
import sys
|
|
39
|
+
if 'pkg_resources' not in sys.modules:
|
|
40
|
+
import importlib.util
|
|
41
|
+
import types
|
|
42
|
+
if importlib.util.find_spec('pkg_resources') is None:
|
|
43
|
+
sys.modules['pkg_resources'] = types.ModuleType('pkg_resources')
|
|
44
|
+
from rtlsdr import RtlSdr
|
|
45
|
+
except ImportError:
|
|
46
|
+
raise ImportError(
|
|
47
|
+
"pyrtlsdr is required for live hardware. "
|
|
48
|
+
"Install it with: pip install 'rtlamr-python'"
|
|
49
|
+
)
|
|
50
|
+
self._sdr = RtlSdr()
|
|
51
|
+
self._sdr.center_freq = center_freq
|
|
52
|
+
self._sdr.sample_rate = sample_rate
|
|
53
|
+
self._sdr.ppm_error = ppm
|
|
54
|
+
if gain == "auto":
|
|
55
|
+
self._sdr.gain = "auto"
|
|
56
|
+
else:
|
|
57
|
+
self._sdr.gain = float(gain)
|
|
58
|
+
|
|
59
|
+
def read_block(self, n_bytes: int) -> bytes:
|
|
60
|
+
"""Read *n_bytes* uint8 IQ samples from the dongle."""
|
|
61
|
+
return bytes(self._sdr.read_bytes(n_bytes))
|
|
62
|
+
|
|
63
|
+
def set_center_freq(self, freq: int) -> None:
|
|
64
|
+
self._sdr.center_freq = freq
|
|
65
|
+
|
|
66
|
+
def close(self) -> None:
|
|
67
|
+
self._sdr.close()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class FileSampleSource:
|
|
71
|
+
"""Read raw IQ bytes from a binary file; loop on EOF for continuous testing."""
|
|
72
|
+
|
|
73
|
+
def __init__(self, path: str) -> None:
|
|
74
|
+
self._path = path
|
|
75
|
+
self._fh = open(path, "rb")
|
|
76
|
+
|
|
77
|
+
def read_block(self, n_bytes: int) -> bytes:
|
|
78
|
+
data = self._fh.read(n_bytes)
|
|
79
|
+
if len(data) < n_bytes:
|
|
80
|
+
# Loop back to the start of the file.
|
|
81
|
+
self._fh.seek(0)
|
|
82
|
+
data = self._fh.read(n_bytes)
|
|
83
|
+
return data
|
|
84
|
+
|
|
85
|
+
def set_center_freq(self, freq: int) -> None:
|
|
86
|
+
pass # no-op; file captures are frequency-agnostic
|
|
87
|
+
|
|
88
|
+
def close(self) -> None:
|
|
89
|
+
self._fh.close()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def open_source(
|
|
93
|
+
center_freq: int,
|
|
94
|
+
sample_rate: int,
|
|
95
|
+
gain: str | float = "auto",
|
|
96
|
+
ppm: int = 0,
|
|
97
|
+
sample_file: str | None = None,
|
|
98
|
+
) -> SampleSource:
|
|
99
|
+
"""Return the appropriate SampleSource based on whether a file path was given."""
|
|
100
|
+
if sample_file:
|
|
101
|
+
return FileSampleSource(sample_file)
|
|
102
|
+
return UsbSdr(center_freq, sample_rate, gain, ppm)
|
|
@@ -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,22 @@
|
|
|
1
|
+
rtlamr_python/__init__.py,sha256=zJf0EA8egm-vibQMLYCQJbQb5QIxRg-LNnhWrGv2a28,290
|
|
2
|
+
rtlamr_python/__main__.py,sha256=ND39a5xe1Uq9cGh-ZJpfsOYIN8rFxcGASw6kz9Id44Q,92
|
|
3
|
+
rtlamr_python/cli.py,sha256=tm59q0lIIPuhRXRZ7zzMH8kMsv3r6Fck9VE_aFpAWuc,6860
|
|
4
|
+
rtlamr_python/crc.py,sha256=nXPLxCxE9CYC8t-PJ7zBg8Z1fweSL4kC_su9rmNb0cE,2157
|
|
5
|
+
rtlamr_python/decoder.py,sha256=tnvf_Hk3Q3u938tL-ggy-FNynucOY9w_fhCqWhyO5O0,10427
|
|
6
|
+
rtlamr_python/listener.py,sha256=KaHS2FeUFjz6Z7zEMaL-YK6MT0J3Au7Cbait5nNwdRQ,12568
|
|
7
|
+
rtlamr_python/poster.py,sha256=Y1mN5mhVv1GZiZL2sg5qMH7DqofIapa7sVZEAAROXYI,3382
|
|
8
|
+
rtlamr_python/r900_decoder.py,sha256=U3psGQlRtNqxjyCpQ2Ve14k4jHYFCW9HDVAkeWkYc2E,11667
|
|
9
|
+
rtlamr_python/sdr.py,sha256=uiLRQy_mCPWS_nD_qiEBME5el4QOaOomT_J5ARXiYoE,3217
|
|
10
|
+
rtlamr_python/protocols/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
|
+
rtlamr_python/protocols/commodity.py,sha256=_AIZFvhgkHArHXh1Ti6AnLcddhdYhqXDoMghy5kP-mA,1196
|
|
12
|
+
rtlamr_python/protocols/idm.py,sha256=OmDrBhdtjrzvqv369Vfyz30mxUFs94CCTShx2Ogjyqo,4616
|
|
13
|
+
rtlamr_python/protocols/netidm.py,sha256=9KoKfuo7DCkmXQRZQM2TbcM9eS180xtOMBlLES8Ywtw,3884
|
|
14
|
+
rtlamr_python/protocols/r900.py,sha256=W-eGGc0rl28_yhKOCFP31GQbIxX8Hkug3e4VkSxDJcM,2044
|
|
15
|
+
rtlamr_python/protocols/scm.py,sha256=FmZnXL2-ft8kOKuqA1-Fc46dn30kphdfI826zPdbRfU,2348
|
|
16
|
+
rtlamr_python/protocols/scmplus.py,sha256=Qr4HOUKtBdFrG3ZndnW3LfXL_jD_mFz4YdZSRRi-zuQ,2405
|
|
17
|
+
rtlamr_python-1.0.0.dist-info/licenses/LICENSE,sha256=xzcGj3DR_4EbJer9xFCahm4Q2n1ud8C8C9CnmEr7NPw,1069
|
|
18
|
+
rtlamr_python-1.0.0.dist-info/METADATA,sha256=KmBEx4jNlyq0otiPXBRHKw8j7lBc0NH3AU0C_h8m6eU,4783
|
|
19
|
+
rtlamr_python-1.0.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
20
|
+
rtlamr_python-1.0.0.dist-info/entry_points.txt,sha256=8LqK70DFQWCZTjeKsqMXZVd_UxTed4Q-HKmjHk8PWUs,50
|
|
21
|
+
rtlamr_python-1.0.0.dist-info/top_level.txt,sha256=GX1_wGjMfRbIUgHQjdY-tKIfm6u446OrI7-DCTsyRtk,14
|
|
22
|
+
rtlamr_python-1.0.0.dist-info/RECORD,,
|
|
@@ -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 @@
|
|
|
1
|
+
rtlamr_python
|