├── .devcontainer └── devcontainer.json ├── .github ├── ISSUE_TEMPLATE │ └── bug_report.md ├── dependabot.yml └── workflows │ └── main.yml ├── .gitignore ├── .vscode └── tasks.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── repository.yaml └── rtlamr2mqtt-addon ├── .pylint ├── Dockerfile ├── Dockerfile.mock ├── app ├── helpers │ ├── __init__.py │ ├── buildcmd.py │ ├── config.py │ ├── ha_messages.py │ ├── info.py │ ├── mqtt_client.py │ ├── read_output.py │ ├── sdl_ids.txt │ └── usb_utils.py ├── rtlamr2mqtt.py └── rtlamr2mqtt.yaml ├── config.yaml ├── icon.png ├── mock ├── rtl_tcp └── rtlamr └── requirements.txt /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Example devcontainer for add-on repositories", 3 | "image": "ghcr.io/home-assistant/devcontainer:2-addons", 4 | "appPort": ["7123:8123", "7357:4357"], 5 | "postStartCommand": "bash devcontainer_bootstrap", 6 | "runArgs": ["-e", "GIT_EDITOR=code --wait", "--privileged"], 7 | "containerEnv": { 8 | "WORKSPACE_DIRECTORY": "${containerWorkspaceFolder}" 9 | }, 10 | "customizations": { 11 | "vscode": { 12 | "extensions": ["timonwong.shellcheck", "esbenp.prettier-vscode"], 13 | "settings": { 14 | "terminal.integrated.profiles.linux": { 15 | "zsh": { 16 | "path": "/usr/bin/zsh" 17 | } 18 | }, 19 | "terminal.integrated.defaultProfile.linux": "zsh", 20 | "editor.formatOnPaste": false, 21 | "editor.formatOnSave": true, 22 | "editor.formatOnType": true, 23 | "files.trimTrailingWhitespace": true 24 | } 25 | } 26 | }, 27 | "mounts": [ 28 | "type=volume,target=/var/lib/docker", 29 | "type=volume,target=/mnt/supervisor" 30 | ] 31 | } -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[BUG]" 5 | labels: '' 6 | assignees: allangood 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | I am using this Configuration: 16 | ``` 17 | insert here your configuration 18 | remove any sensitive data 19 | ``` 20 | 21 | **Expected behavior** 22 | A clear and concise description of what you expected to happen. 23 | 24 | **Screenshots or logs** 25 | If applicable, add screenshots or logs to help explain your problem. 26 | 27 | **Version Information:** 28 | - OS: HAOS/Linux/Docker 29 | - Version [e.g. 2025.6.1] 30 | 31 | **Additional context** 32 | Add any other context about the problem here. 33 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # Maintain dependencies for GitHub Actions 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: "daily" 8 | 9 | - package-ecosystem: "docker" 10 | directory: "/" 11 | schedule: 12 | interval: "daily" 13 | 14 | - package-ecosystem: "pip" 15 | directory: "/" 16 | schedule: 17 | interval: "daily" 18 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: "Docker build" 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | tags: 7 | description: "Manual trigger" 8 | push: 9 | paths-ignore: 10 | - "**.md" 11 | - "**.json" 12 | - "**.yaml" 13 | - "LICENSE" 14 | - "examples/**" 15 | branches: 16 | - main 17 | - dev 18 | tags: 19 | - "*.*.*" 20 | 21 | env: 22 | IMAGE_NAME: rtlamr2mqtt 23 | 24 | jobs: 25 | release: 26 | runs-on: ubuntu-latest 27 | steps: 28 | - name: Checkout 29 | uses: actions/checkout@v4 30 | - name: Docker meta 31 | id: meta 32 | uses: docker/metadata-action@v5 33 | with: 34 | # list of Docker images to use as base name for tags 35 | images: | 36 | ${{ github.actor }}/rtlamr2mqtt 37 | # generate Docker tags based on the following events/attributes 38 | tags: | 39 | type=ref,event=branch 40 | type=semver,pattern={{version}} 41 | type=sha 42 | - name: Set up QEMU 43 | id: qemu 44 | uses: docker/setup-qemu-action@v3 45 | with: 46 | platforms: "amd64,arm64,arm/v7,arm/v6" 47 | - name: Set up Docker Buildx 48 | id: buildx 49 | uses: docker/setup-buildx-action@v3 50 | - name: Available platforms 51 | run: echo ${{ steps.buildx.outputs.platforms }} 52 | - name: Login to DockerHub 53 | if: github.event_name != 'pull_request' 54 | uses: docker/login-action@v3 55 | with: 56 | username: ${{ secrets.DOCKER_USERNAME }} 57 | password: ${{ secrets.DOCKER_PASSWORD }} 58 | - name: Build and push 59 | uses: docker/build-push-action@v6 60 | with: 61 | context: rtlamr2mqtt-addon/ 62 | platforms: linux/amd64,linux/arm64 63 | push: ${{ github.event_name != 'pull_request' }} 64 | tags: ${{ steps.meta.outputs.tags }} 65 | labels: ${{ steps.meta.outputs.labels }} 66 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/* 2 | *.py[cod] 3 | *.pyo 4 | *.pyd 5 | *.egg-info 6 | *.egg 7 | *.whl 8 | *.log -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "Start Home Assistant", 6 | "type": "shell", 7 | "command": "supervisor_run", 8 | "group": { 9 | "kind": "test", 10 | "isDefault": true 11 | }, 12 | "presentation": { 13 | "reveal": "always", 14 | "panel": "new" 15 | }, 16 | "problemMatcher": [] 17 | } 18 | ] 19 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ### 2025-05-28 - Major changes!!! 2 | 3 | **MAJOR REWRITE** 4 | After a long break without working on this project 5 | I am back with a major rewrite. 6 | The old code was too hard to maintain 7 | This is a completly new code. 8 | You old entities should be cleaned manually from your MQTT broker 9 | 10 | **Changes** 11 | 12 | - I've tried to keep the configuration compatible with this new version 13 | but some of the parameters had to change and I had to add some others. 14 | Please check the tlamr2mqtt.yaml` file to see all the changes 15 | 16 | ### 2022-05-17 17 | 18 | - Bug fixes for remote rtl_tcp and usb_reset logic #123 19 | - Code changes to load config file and merge defaults 20 | - Added vscode files to test the Addon development (finally!) 21 | 22 | ### 2022-04-12 23 | 24 | - **REMOVED PARAMETER** usb_reset 25 | - **ADDED PARAMETER** device_id 26 | - **Changed Dockerfile**: Much smaller docker container 27 | - Deprecated Anomaly detection (looks like no one is using it and it's not very reliable) 28 | 29 | ### 2022-04-12 30 | 31 | - New `tls_enabled` parameter to avoid confusions 32 | - Some fixes for the Add-On regarding the TLS configuration 33 | 34 | ### 2022-04-04 35 | 36 | - New TLS parameters to MQTT connection 37 | - New parameter: USB_RESET to address problem mentioned on #98 38 | 39 | ### 2022-02-11 40 | 41 | - New configuration parameter: `state_class` (thanks to @JeffreyFalgout) 42 | - Automatic MQTT configuration when using the Addon (thanks to @JeffreyFalgout) 43 | - Fixed 255 characters limit for state value #86 44 | 45 | ### 2022-01-11 46 | 47 | - Happy new year! :) 48 | - Added "tickle_rtl_tcp" parameter to enable/disable the feature (explained below) 49 | - Added date/time to the log output 50 | - Added device_class configuration option #66 (thanks to @phidauex) 51 | - Some clean up in the README file! 52 | - Machine Learning to detect leaks still experimental and needs a lot of love to work properly 53 | 54 | ### 2021-12-01 55 | 56 | - Lots of changes! 57 | - Changed Docker container to use Debian Bullseye instead of Alpine 58 | - Added TinyDB to store past readings 59 | - Added Linear Regression to flag anomaly usage 60 | - Problems with the official python docker base image :( 61 | 62 | ### 2021-10-27 63 | 64 | - Many fixes regarding error handling 65 | - More comments inside the code 66 | - Some code cleanup 67 | - Fix a bug for MQTT anonymous message publishing discovered by @jeffeb3 68 | - Using latest code for both rtl-sdr and rtamr in the Dockerfile 69 | 70 | ### 2021-10-12 71 | 72 | - The HA-ADDON is working now! A shout-out to @AnthonyPluth for his hard work!!! \o/ 73 | - New feature to allow this container to run with a remote rtl_tcp. Thanks to @jonbloom 74 | - A bug was introduced by #28 and has been fixed. 75 | 76 | ### 2021-09-23: 77 | 78 | - New images are based on Alpine 3.14 **_ IMPORTANT _** 79 | - If this container stops to work after you upgrade, please read this: [https://docs.linuxserver.io/faq](https://docs.linuxserver.io/faq) 80 | - We are working in a new image: HA-ADDON! Thanks to @AnthonyPluth ! Stay tuned for news about it! 81 | 82 | ### 2021-09-13: 83 | 84 | - A new configuration parameter has been added: _verbosity_ 85 | - Environment variable _DEBUG_ has been renamed to _LISTEN_ONLY_ to prevent confusion 86 | - Better error handling and output (still work in progress) 87 | 88 | ### 2021-09-09 89 | 90 | - Added last Will and testment messages 91 | - Added availability status topic 92 | - Added RTL_MSGTYPE to debug mode 93 | 94 | ### 2021-09-03 95 | 96 | - Added DEBUG Mode 97 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Allan Gomes GooD 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ### RTLAMR2MQTT 2 | 3 | ![Docker Pulls](https://img.shields.io/docker/pulls/allangood/rtlamr2mqtt) 4 | [![GitHub license](https://img.shields.io/github/license/allangood/rtlamr2mqtt)](https://github.com/allangood/rtlamr2mqtt/blob/main/LICENSE) 5 | [![GitHub stars](https://img.shields.io/github/stars/allangood/rtlamr2mqtt)](https://github.com/allangood/rtlamr2mqtt/stargazers) 6 | ![GitHub contributors](https://img.shields.io/github/contributors/allangood/rtlamr2mqtt) 7 | [![GitHub issues](https://img.shields.io/github/issues/allangood/rtlamr2mqtt)](https://github.com/allangood/rtlamr2mqtt/issues) 8 | 9 | [![Open your Home Assistant instance and show the add add-on repository dialog with a specific repository URL pre-filled.](https://my.home-assistant.io/badges/supervisor_add_addon_repository.svg)](https://my.home-assistant.io/redirect/supervisor_add_addon_repository/?repository_url=https%3A%2F%2Fgithub.com%2Fallangood%2Frtlamr2mqtt) 10 | 11 | ### Platforms: 12 | 13 | [![AMD64](https://img.shields.io/badge/AMD64-Yes-greenb)](https://img.shields.io/badge/AMD64-Yes-greenb) 14 | [![AARCH64](https://img.shields.io/badge/AARCH64-Yes-greenb)](https://img.shields.io/badge/AARCH64-Yes-greenb) 15 | 16 | RTLAMR2MQTT is a small Python program to read your utility meter such as water, gas and energy using an inexpensive USB RTL-SDR device and send these readings to a MQTT broker to be integrated with Home Assistant or NodeRed. 17 | 18 | > [!CAUTION] 19 | > The latest version is crrently broken. Do not update. 20 | > I will update the status when everything is working again. 21 | 22 | ### Current features 23 | 24 | - Custom parameters for `rtl_tcp` and `rtlamr` (`custom_parameters` config option) 25 | - It can run `rtl_tcp` locally or use an external instance running somewhere else (`custom_parameters` config option) 26 | - MQTT TLS support (`tls_enabled` config option) 27 | - Reset USB port before open it (`device_id` config option) 28 | - Format reading number. Some meters reports a flat number that should be formatted with decimals (`format` config option) 29 | - Sleep after successful reading to avoid heating the CPU too much (`sleep_for` config option) 30 | - Support multiple meters with one instance 31 | - Run as an Addon for Home Assistant with Supervisor support and MQTT auto configuration 32 | - Full sensor customization: `name`, `state_class`, `device_class`, `icon` and `unit_of_measurement` 33 | 34 | ### Planned features 35 | 36 | - Function to find your meter ID based on your meter reading 37 | 38 | ### Changes 39 | 40 | > [!CAUTION] 41 | > **Major code rewrite** \ 42 | > After a long break without working on this project, I am back with a major rewrite. \ 43 | > The old code was too hard to maintain. \ 44 | > Your old entities should be cleaned manually from your MQTT broker 45 | 46 | > [!CAUTION] 47 | > This new version does **not** have the LISTEN MODE!!! \ 48 | > It is planned, but not implemented yet. 49 | 50 | 51 | # Readme starts here 52 | 53 | ### What do I need? 54 | 55 | **1) You need a smart meter** 56 | First and most important, you must have a "smart" water/gas/energy meter. You can find a list of compatible meters [here](https://github.com/bemasher/rtlamr/blob/master/meters.csv) 57 | 58 | **2) You need an USB RTL-SDR device** 59 | I am using this one: [NooElec NESDR Mini USB](https://www.amazon.ca/NooElec-NESDR-Mini-Compatible-Packages/dp/B009U7WZCA/ref=sr_1_1_sspa?crid=JGS4RV7RXGQQ&keywords=rtl-sdr) 60 | 61 | **3) You need a MQTT broker** (Like [Mosquitto](https://mosquitto.org/) ) 62 | 63 | **4) [Home Assistant](https://www.home-assistant.io/)** is optional, but highly recommended, because it is awesome! 64 | 65 | ### How it looks like? 66 | 67 | ![image](https://user-images.githubusercontent.com/757086/117556120-207bd200-b02b-11eb-9149-58eaf9c6c4ea.png) 68 | 69 | ![image](https://user-images.githubusercontent.com/757086/169098091-bdd93660-daf5-4c8a-bde1-c4b66e7bdb87.png) 70 | 71 | ### How to run and configure? 72 | 73 | #### Home Assistant Add-On: 74 | 75 | [![Open your Home Assistant instance and show the add add-on repository dialog with a specific repository URL pre-filled.](https://my.home-assistant.io/badges/supervisor_add_addon_repository.svg)](https://my.home-assistant.io/redirect/supervisor_add_addon_repository/?repository_url=https%3A%2F%2Fgithub.com%2Fallangood%2Frtlamr2mqtt) 76 | 77 | [![Open your Home Assistant instance and show the dashboard of a Supervisor add-on.](https://my.home-assistant.io/badges/supervisor_addon.svg)](https://my.home-assistant.io/redirect/supervisor_addon/?repository_url=https%3A%2F%2Fgithub.com%2Fallangood%2Frtlamr2mqtt&addon=6713e36e_rtlamr2mqtt) 78 | 79 | Manually: 80 | 81 | - Navigate to your Add-Ons (Configuration > Add-ons, Backups, & Supervisor) 82 | - Click the Add-On Store button 83 | - Navigate to Repositories (3 dots in the top-right corner > Repositories) 84 | - Add this repository (https://github.com/allangood/rtlamr2mqtt) and click 'Add' 85 | - You should now see the 'rtlamr' Add-On at the bottom of your Add-On Store. Click to install and configure. 86 | 87 | #### Docker or Docker-Compose 88 | 89 | If you are not [running the add-on](https://www.home-assistant.io/common-tasks/os#installing-third-party-add-ons), you must write the **rtlamr2mqtt.yaml** configuration file. 90 | 91 | #### Run with docker 92 | 93 | If you want to run with docker alone, run this command: 94 | 95 | ``` 96 | docker run --name rtlamr2mqtt \ 97 | -v /opt/rtlamr2mqtt/rtlamr2mqtt.yaml:/etc/rtlamr2mqtt.yaml \ 98 | --device /dev/bus/usb:/dev/bus/usb \ 99 | --restart unless-stopped \ 100 | allangood/rtlamr2mqtt 101 | ``` 102 | 103 | #### Run with docker-compose 104 | 105 | If you use docker-compose (recommended), add this to your compose file: 106 | 107 | ``` 108 | version: "3" 109 | services: 110 | rtlamr: 111 | container_name: rtlamr2mqtt 112 | image: allangood/rtlamr2mqtt 113 | restart: unless-stopped 114 | devices: 115 | - /dev/bus/usb 116 | volumes: 117 | - /opt/rtlamr2mqtt/rtlamr2mqtt.yaml:/etc/rtlamr2mqtt.yaml:ro 118 | ``` 119 | 120 | ### Home Assistant utility meter configuration (sample): 121 | 122 | To add your meters to Home Assistant, add a section like this: 123 | 124 | ``` 125 | utility_meter: 126 | hourly_water: 127 | source: sensor. 128 | cycle: hourly 129 | daily_water: 130 | source: sensor. 131 | cycle: daily 132 | monthly_water: 133 | source: sensor. 134 | cycle: monthly 135 | ``` 136 | 137 | #### Multiple RTL devices 138 | 139 | If you have multiple RTL devices, you will need to specify the USB device you want to use 140 | 141 | Using lsusb to find USB Device ID : 142 | 143 | ``` 144 | $ lsusb 145 | Bus 008 Device 001: ID 1d6b:0001 Linux Foundation 1.1 root hub 146 | Bus 005 Device 002: ID 0bda:2838 Realtek Semiconductor Corp. RTL2838 DVB-T <<< I want to use this device 147 | Bus 005 Device 001: ID 1d6b:0002 Linux Foundation 2.0 root hub 148 | Bus 002 Device 004: ID 0bda:2838 Realtek Semiconductor Corp. RTL2838 DVB-T 149 | ``` 150 | 151 | USB Device => **005:002** 152 | 153 | ### I don't know my meters ID, what can I do? 154 | 155 | This is a planned feature... 156 | 157 | ### Thanks to 158 | 159 | A big thank you to all kind [contributions](https://github.com/allangood/rtlamr2mqtt/graphs/contributors)! 160 | 161 | ### Credits to: 162 | 163 | RTLAMR - https://github.com/bemasher/rtlamr 164 | 165 | RTL_TCP - https://osmocom.org/projects/rtl-sdr/wiki/Rtl-sdr 166 | 167 | Icon by: 168 | [Sound icons created by Plastic Donut - Flaticon]("https://www.flaticon.com/free-icons/sound") 169 | -------------------------------------------------------------------------------- /repository.yaml: -------------------------------------------------------------------------------- 1 | name: rtlamr2mqtt 2 | url: https://github.com/allangood/rtlamr2mqtt 3 | maintainer: allangood 4 | -------------------------------------------------------------------------------- /rtlamr2mqtt-addon/.pylint: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | 3 | # A comma-separated list of package or module names from where C extensions may 4 | # be loaded. Extensions are loading into the active Python interpreter and may 5 | # run arbitrary code. 6 | extension-pkg-whitelist= 7 | 8 | # Add files or directories to the blacklist. They should be base names, not 9 | # paths. 10 | ignore=CVS 11 | 12 | # Add files or directories matching the regex patterns to the blacklist. The 13 | # regex matches against base names, not paths. 14 | ignore-patterns= 15 | 16 | # Python code to execute, usually for sys.path manipulation such as 17 | # pygtk.require(). 18 | #init-hook= 19 | 20 | # Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the 21 | # number of processors available to use. 22 | jobs=1 23 | 24 | # Control the amount of potential inferred values when inferring a single 25 | # object. This can help the performance when dealing with large functions or 26 | # complex, nested conditions. 27 | limit-inference-results=100 28 | 29 | # List of plugins (as comma separated values of python module names) to load, 30 | # usually to register additional checkers. 31 | load-plugins= 32 | 33 | # Pickle collected data for later comparisons. 34 | persistent=yes 35 | 36 | # Specify a configuration file. 37 | #rcfile= 38 | 39 | # When enabled, pylint would attempt to guess common misconfiguration and emit 40 | # user-friendly hints instead of false-positive error messages. 41 | suggestion-mode=yes 42 | 43 | # Allow loading of arbitrary C extensions. Extensions are imported into the 44 | # active Python interpreter and may run arbitrary code. 45 | unsafe-load-any-extension=no 46 | 47 | 48 | [MESSAGES CONTROL] 49 | 50 | # Only show warnings with the listed confidence levels. Leave empty to show 51 | # all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED. 52 | confidence= 53 | 54 | # Disable the message, report, category or checker with the given id(s). You 55 | # can either give multiple identifiers separated by comma (,) or put this 56 | # option multiple times (only on the command line, not in the configuration 57 | # file where it should appear only once). You can also use "--disable=all" to 58 | # disable everything first and then reenable specific checks. For example, if 59 | # you want to run only the similarities checker, you can use "--disable=all 60 | # --enable=similarities". If you want to run only the classes checker, but have 61 | # no Warning level messages displayed, use "--disable=all --enable=classes 62 | # --disable=W". 63 | disable=print-statement, 64 | parameter-unpacking, 65 | unpacking-in-except, 66 | old-raise-syntax, 67 | backtick, 68 | long-suffix, 69 | old-ne-operator, 70 | old-octal-literal, 71 | import-star-module-level, 72 | non-ascii-bytes-literal, 73 | raw-checker-failed, 74 | bad-inline-option, 75 | locally-disabled, 76 | file-ignored, 77 | suppressed-message, 78 | useless-suppression, 79 | deprecated-pragma, 80 | use-symbolic-message-instead, 81 | apply-builtin, 82 | basestring-builtin, 83 | buffer-builtin, 84 | cmp-builtin, 85 | coerce-builtin, 86 | execfile-builtin, 87 | file-builtin, 88 | long-builtin, 89 | raw_input-builtin, 90 | reduce-builtin, 91 | standarderror-builtin, 92 | unicode-builtin, 93 | xrange-builtin, 94 | coerce-method, 95 | delslice-method, 96 | getslice-method, 97 | setslice-method, 98 | no-absolute-import, 99 | old-division, 100 | dict-iter-method, 101 | dict-view-method, 102 | next-method-called, 103 | metaclass-assignment, 104 | indexing-exception, 105 | raising-string, 106 | reload-builtin, 107 | oct-method, 108 | hex-method, 109 | nonzero-method, 110 | cmp-method, 111 | input-builtin, 112 | round-builtin, 113 | intern-builtin, 114 | unichr-builtin, 115 | map-builtin-not-iterating, 116 | zip-builtin-not-iterating, 117 | range-builtin-not-iterating, 118 | filter-builtin-not-iterating, 119 | using-cmp-argument, 120 | eq-without-hash, 121 | div-method, 122 | idiv-method, 123 | rdiv-method, 124 | exception-message-attribute, 125 | invalid-str-codec, 126 | sys-max-int, 127 | bad-python3-import, 128 | deprecated-string-function, 129 | deprecated-str-translate-call, 130 | deprecated-itertools-function, 131 | deprecated-types-field, 132 | next-method-defined, 133 | dict-items-not-iterating, 134 | dict-keys-not-iterating, 135 | dict-values-not-iterating, 136 | deprecated-operator-function, 137 | deprecated-urllib-function, 138 | xreadlines-attribute, 139 | deprecated-sys-function, 140 | exception-escape, 141 | comprehension-escape, 142 | invalid-name, 143 | line-too-long, 144 | missing-function-docstring, 145 | missing-module-docstring 146 | 147 | # Enable the message, report, category or checker with the given id(s). You can 148 | # either give multiple identifier separated by comma (,) or put this option 149 | # multiple time (only on the command line, not in the configuration file where 150 | # it should appear only once). See also the "--disable" option for examples. 151 | enable=c-extension-no-member 152 | 153 | 154 | [REPORTS] 155 | 156 | # Python expression which should return a score less than or equal to 10. You 157 | # have access to the variables 'error', 'warning', 'refactor', and 'convention' 158 | # which contain the number of messages in each category, as well as 'statement' 159 | # which is the total number of statements analyzed. This score is used by the 160 | # global evaluation report (RP0004). 161 | evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) 162 | 163 | # Template used to display messages. This is a python new-style format string 164 | # used to format the message information. See doc for all details. 165 | #msg-template= 166 | 167 | # Set the output format. Available formats are text, parseable, colorized, json 168 | # and msvs (visual studio). You can also give a reporter class, e.g. 169 | # mypackage.mymodule.MyReporterClass. 170 | output-format=text 171 | 172 | # Tells whether to display a full report or only the messages. 173 | reports=no 174 | 175 | # Activate the evaluation score. 176 | score=yes 177 | 178 | 179 | [REFACTORING] 180 | 181 | # Maximum number of nested blocks for function / method body 182 | max-nested-blocks=5 183 | 184 | # Complete name of functions that never returns. When checking for 185 | # inconsistent-return-statements if a never returning function is called then 186 | # it will be considered as an explicit return statement and no message will be 187 | # printed. 188 | never-returning-functions=sys.exit 189 | 190 | 191 | [LOGGING] 192 | 193 | # Format style used to check logging format string. `old` means using % 194 | # formatting, `new` is for `{}` formatting,and `fstr` is for f-strings. 195 | logging-format-style=old 196 | 197 | # Logging modules to check that the string format arguments are in logging 198 | # function parameter format. 199 | logging-modules=logging 200 | 201 | 202 | [SPELLING] 203 | 204 | # Limits count of emitted suggestions for spelling mistakes. 205 | max-spelling-suggestions=4 206 | 207 | # Spelling dictionary name. Available dictionaries: none. To make it work, 208 | # install the python-enchant package. 209 | spelling-dict= 210 | 211 | # List of comma separated words that should not be checked. 212 | spelling-ignore-words= 213 | 214 | # A path to a file that contains the private dictionary; one word per line. 215 | spelling-private-dict-file= 216 | 217 | # Tells whether to store unknown words to the private dictionary (see the 218 | # --spelling-private-dict-file option) instead of raising a message. 219 | spelling-store-unknown-words=no 220 | 221 | 222 | [SIMILARITIES] 223 | 224 | # Ignore comments when computing similarities. 225 | ignore-comments=yes 226 | 227 | # Ignore docstrings when computing similarities. 228 | ignore-docstrings=yes 229 | 230 | # Ignore imports when computing similarities. 231 | ignore-imports=no 232 | 233 | # Minimum lines number of a similarity. 234 | min-similarity-lines=4 235 | 236 | 237 | [TYPECHECK] 238 | 239 | # List of decorators that produce context managers, such as 240 | # contextlib.contextmanager. Add to this list to register other decorators that 241 | # produce valid context managers. 242 | contextmanager-decorators=contextlib.contextmanager 243 | 244 | # List of members which are set dynamically and missed by pylint inference 245 | # system, and so shouldn't trigger E1101 when accessed. Python regular 246 | # expressions are accepted. 247 | generated-members= 248 | 249 | # Tells whether missing members accessed in mixin class should be ignored. A 250 | # mixin class is detected if its name ends with "mixin" (case insensitive). 251 | ignore-mixin-members=yes 252 | 253 | # Tells whether to warn about missing members when the owner of the attribute 254 | # is inferred to be None. 255 | ignore-none=yes 256 | 257 | # This flag controls whether pylint should warn about no-member and similar 258 | # checks whenever an opaque object is returned when inferring. The inference 259 | # can return multiple potential results while evaluating a Python object, but 260 | # some branches might not be evaluated, which results in partial inference. In 261 | # that case, it might be useful to still emit no-member and other checks for 262 | # the rest of the inferred objects. 263 | ignore-on-opaque-inference=yes 264 | 265 | # List of class names for which member attributes should not be checked (useful 266 | # for classes with dynamically set attributes). This supports the use of 267 | # qualified names. 268 | ignored-classes=optparse.Values,thread._local,_thread._local 269 | 270 | # List of module names for which member attributes should not be checked 271 | # (useful for modules/projects where namespaces are manipulated during runtime 272 | # and thus existing member attributes cannot be deduced by static analysis). It 273 | # supports qualified module names, as well as Unix pattern matching. 274 | ignored-modules= 275 | 276 | # Show a hint with possible names when a member name was not found. The aspect 277 | # of finding the hint is based on edit distance. 278 | missing-member-hint=yes 279 | 280 | # The minimum edit distance a name should have in order to be considered a 281 | # similar match for a missing member name. 282 | missing-member-hint-distance=1 283 | 284 | # The total number of similar names that should be taken in consideration when 285 | # showing a hint for a missing member. 286 | missing-member-max-choices=1 287 | 288 | # List of decorators that change the signature of a decorated function. 289 | signature-mutators= 290 | 291 | 292 | [VARIABLES] 293 | 294 | # List of additional names supposed to be defined in builtins. Remember that 295 | # you should avoid defining new builtins when possible. 296 | additional-builtins= 297 | 298 | # Tells whether unused global variables should be treated as a violation. 299 | allow-global-unused-variables=yes 300 | 301 | # List of strings which can identify a callback function by name. A callback 302 | # name must start or end with one of those strings. 303 | callbacks=cb_, 304 | _cb 305 | 306 | # A regular expression matching the name of dummy variables (i.e. expected to 307 | # not be used). 308 | dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ 309 | 310 | # Argument names that match this expression will be ignored. Default to name 311 | # with leading underscore. 312 | ignored-argument-names=_.*|^ignored_|^unused_ 313 | 314 | # Tells whether we should check for unused import in __init__ files. 315 | init-import=no 316 | 317 | # List of qualified module names which can have objects that can redefine 318 | # builtins. 319 | redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io 320 | 321 | 322 | [MISCELLANEOUS] 323 | 324 | # List of note tags to take in consideration, separated by a comma. 325 | notes=FIXME, 326 | XXX, 327 | TODO 328 | 329 | 330 | [FORMAT] 331 | 332 | # Expected format of line ending, e.g. empty (any line ending), LF or CRLF. 333 | expected-line-ending-format= 334 | 335 | # Regexp for a line that is allowed to be longer than the limit. 336 | ignore-long-lines=^\s*(# )??$ 337 | 338 | # Number of spaces of indent required inside a hanging or continued line. 339 | indent-after-paren=4 340 | 341 | # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 342 | # tab). 343 | indent-string=' ' 344 | 345 | # Maximum number of characters on a single line. 346 | max-line-length=100 347 | 348 | # Maximum number of lines in a module. 349 | max-module-lines=1000 350 | 351 | # List of optional constructs for which whitespace checking is disabled. `dict- 352 | # separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}. 353 | # `trailing-comma` allows a space between comma and closing bracket: (a, ). 354 | # `empty-line` allows space-only lines. 355 | no-space-check=trailing-comma, 356 | dict-separator 357 | 358 | # Allow the body of a class to be on the same line as the declaration if body 359 | # contains single statement. 360 | single-line-class-stmt=no 361 | 362 | # Allow the body of an if to be on the same line as the test if there is no 363 | # else. 364 | single-line-if-stmt=no 365 | 366 | 367 | [STRING] 368 | 369 | # This flag controls whether the implicit-str-concat-in-sequence should 370 | # generate a warning on implicit string concatenation in sequences defined over 371 | # several lines. 372 | check-str-concat-over-line-jumps=no 373 | 374 | 375 | [BASIC] 376 | 377 | # Naming style matching correct argument names. 378 | argument-naming-style=snake_case 379 | 380 | # Regular expression matching correct argument names. Overrides argument- 381 | # naming-style. 382 | #argument-rgx= 383 | 384 | # Naming style matching correct attribute names. 385 | attr-naming-style=snake_case 386 | 387 | # Regular expression matching correct attribute names. Overrides attr-naming- 388 | # style. 389 | #attr-rgx= 390 | 391 | # Bad variable names which should always be refused, separated by a comma. 392 | bad-names=foo, 393 | bar, 394 | baz, 395 | toto, 396 | tutu, 397 | tata 398 | 399 | # Naming style matching correct class attribute names. 400 | class-attribute-naming-style=any 401 | 402 | # Regular expression matching correct class attribute names. Overrides class- 403 | # attribute-naming-style. 404 | #class-attribute-rgx= 405 | 406 | # Naming style matching correct class names. 407 | class-naming-style=PascalCase 408 | 409 | # Regular expression matching correct class names. Overrides class-naming- 410 | # style. 411 | #class-rgx= 412 | 413 | # Naming style matching correct constant names. 414 | const-naming-style=UPPER_CASE 415 | 416 | # Regular expression matching correct constant names. Overrides const-naming- 417 | # style. 418 | #const-rgx= 419 | 420 | # Minimum line length for functions/classes that require docstrings, shorter 421 | # ones are exempt. 422 | docstring-min-length=-1 423 | 424 | # Naming style matching correct function names. 425 | function-naming-style=snake_case 426 | 427 | # Regular expression matching correct function names. Overrides function- 428 | # naming-style. 429 | #function-rgx= 430 | 431 | # Good variable names which should always be accepted, separated by a comma. 432 | good-names=i, 433 | j, 434 | k, 435 | ex, 436 | Run, 437 | _ 438 | 439 | # Include a hint for the correct naming format with invalid-name. 440 | include-naming-hint=no 441 | 442 | # Naming style matching correct inline iteration names. 443 | inlinevar-naming-style=any 444 | 445 | # Regular expression matching correct inline iteration names. Overrides 446 | # inlinevar-naming-style. 447 | #inlinevar-rgx= 448 | 449 | # Naming style matching correct method names. 450 | method-naming-style=snake_case 451 | 452 | # Regular expression matching correct method names. Overrides method-naming- 453 | # style. 454 | #method-rgx= 455 | 456 | # Naming style matching correct module names. 457 | module-naming-style=snake_case 458 | 459 | # Regular expression matching correct module names. Overrides module-naming- 460 | # style. 461 | #module-rgx= 462 | 463 | # Colon-delimited sets of names that determine each other's naming style when 464 | # the name regexes allow several styles. 465 | name-group= 466 | 467 | # Regular expression which should only match function or class names that do 468 | # not require a docstring. 469 | no-docstring-rgx=^_ 470 | 471 | # List of decorators that produce properties, such as abc.abstractproperty. Add 472 | # to this list to register other decorators that produce valid properties. 473 | # These decorators are taken in consideration only for invalid-name. 474 | property-classes=abc.abstractproperty 475 | 476 | # Naming style matching correct variable names. 477 | variable-naming-style=snake_case 478 | 479 | # Regular expression matching correct variable names. Overrides variable- 480 | # naming-style. 481 | #variable-rgx= 482 | 483 | 484 | [DESIGN] 485 | 486 | # Maximum number of arguments for function / method. 487 | max-args=5 488 | 489 | # Maximum number of attributes for a class (see R0902). 490 | max-attributes=7 491 | 492 | # Maximum number of boolean expressions in an if statement (see R0916). 493 | max-bool-expr=5 494 | 495 | # Maximum number of branch for function / method body. 496 | max-branches=12 497 | 498 | # Maximum number of locals for function / method body. 499 | max-locals=15 500 | 501 | # Maximum number of parents for a class (see R0901). 502 | max-parents=7 503 | 504 | # Maximum number of public methods for a class (see R0904). 505 | max-public-methods=20 506 | 507 | # Maximum number of return / yield for function / method body. 508 | max-returns=6 509 | 510 | # Maximum number of statements in function / method body. 511 | max-statements=50 512 | 513 | # Minimum number of public methods for a class (see R0903). 514 | min-public-methods=2 515 | 516 | 517 | [IMPORTS] 518 | 519 | # List of modules that can be imported at any level, not just the top level 520 | # one. 521 | allow-any-import-level= 522 | 523 | # Allow wildcard imports from modules that define __all__. 524 | allow-wildcard-with-all=no 525 | 526 | # Analyse import fallback blocks. This can be used to support both Python 2 and 527 | # 3 compatible code, which means that the block might have code that exists 528 | # only in one or another interpreter, leading to false positives when analysed. 529 | analyse-fallback-blocks=no 530 | 531 | # Deprecated modules which should not be used, separated by a comma. 532 | deprecated-modules=optparse,tkinter.tix 533 | 534 | # Create a graph of external dependencies in the given file (report RP0402 must 535 | # not be disabled). 536 | ext-import-graph= 537 | 538 | # Create a graph of every (i.e. internal and external) dependencies in the 539 | # given file (report RP0402 must not be disabled). 540 | import-graph= 541 | 542 | # Create a graph of internal dependencies in the given file (report RP0402 must 543 | # not be disabled). 544 | int-import-graph= 545 | 546 | # Force import order to recognize a module as part of the standard 547 | # compatibility libraries. 548 | known-standard-library= 549 | 550 | # Force import order to recognize a module as part of a third party library. 551 | known-third-party=enchant 552 | 553 | # Couples of modules and preferred modules, separated by a comma. 554 | preferred-modules= 555 | 556 | 557 | [CLASSES] 558 | 559 | # List of method names used to declare (i.e. assign) instance attributes. 560 | defining-attr-methods=__init__, 561 | __new__, 562 | setUp, 563 | __post_init__ 564 | 565 | # List of member names, which should be excluded from the protected access 566 | # warning. 567 | exclude-protected=_asdict, 568 | _fields, 569 | _replace, 570 | _source, 571 | _make 572 | 573 | # List of valid names for the first argument in a class method. 574 | valid-classmethod-first-arg=cls 575 | 576 | # List of valid names for the first argument in a metaclass class method. 577 | valid-metaclass-classmethod-first-arg=cls 578 | 579 | 580 | [EXCEPTIONS] 581 | 582 | # Exceptions that will emit a warning when being caught. Defaults to 583 | # "BaseException, Exception". 584 | overgeneral-exceptions=BaseException, 585 | Exception 586 | -------------------------------------------------------------------------------- /rtlamr2mqtt-addon/Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1 2 | FROM golang:1.24 AS go-builder 3 | 4 | WORKDIR /go/src/app 5 | 6 | RUN go install github.com/bemasher/rtlamr@latest \ 7 | && apt-get update \ 8 | && apt-get install -y libusb-1.0-0-dev build-essential git cmake \ 9 | && git clone https://git.osmocom.org/rtl-sdr.git \ 10 | && cd rtl-sdr \ 11 | && mkdir build && cd build \ 12 | && cmake .. -DDETACH_KERNEL_DRIVER=ON -DENABLE_ZEROCOPY=ON -Wno-dev \ 13 | && make \ 14 | && make install 15 | 16 | FROM python:3.13-slim 17 | 18 | ENV VIRTUAL_ENV=/opt/venv 19 | RUN python3 -m venv $VIRTUAL_ENV 20 | ENV PATH="$VIRTUAL_ENV/bin:$PATH" 21 | 22 | COPY --from=go-builder /usr/local/lib/librtl* /lib/ 23 | COPY --from=go-builder /go/bin/rtlamr* /usr/bin/ 24 | COPY --from=go-builder /usr/local/bin/rtl* /usr/bin/ 25 | COPY requirements.txt /tmp 26 | COPY ./app/ $VIRTUAL_ENV/app/ 27 | 28 | RUN apt-get update \ 29 | && apt-get install -o Dpkg::Options::="--force-confnew" -y \ 30 | libusb-1.0-0 \ 31 | && apt-get --purge autoremove -y \ 32 | && apt-get clean \ 33 | && find /var/lib/apt/lists/ -type f -delete \ 34 | && pip install -r /tmp/requirements.txt \ 35 | && rm -rf /usr/share/doc /tmp/requirements.txt 36 | 37 | STOPSIGNAL SIGTERM 38 | 39 | ENTRYPOINT ["python", "/opt/venv/app/rtlamr2mqtt.py"] 40 | -------------------------------------------------------------------------------- /rtlamr2mqtt-addon/Dockerfile.mock: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1 2 | FROM python:3.13-slim 3 | 4 | ENV VIRTUAL_ENV=/opt/venv 5 | RUN python3 -m venv $VIRTUAL_ENV 6 | ENV PATH="$VIRTUAL_ENV/bin:$PATH" 7 | ENV RTLAMR2MQTT_USE_MOCK=1 8 | 9 | COPY mock/ /usr/bin/ 10 | COPY requirements.txt /tmp 11 | COPY ./app/ $VIRTUAL_ENV/app/ 12 | 13 | RUN pip install -r /tmp/requirements.txt \ 14 | && rm -rf /usr/share/doc /tmp/requirements.txt 15 | 16 | STOPSIGNAL SIGTERM 17 | 18 | ENTRYPOINT ["python", "/opt/venv/app/rtlamr2mqtt.py"] 19 | -------------------------------------------------------------------------------- /rtlamr2mqtt-addon/app/helpers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/allangood/rtlamr2mqtt/639c17787070b6ca14e49d2ae11e1d1fbe03970f/rtlamr2mqtt-addon/app/helpers/__init__.py -------------------------------------------------------------------------------- /rtlamr2mqtt-addon/app/helpers/buildcmd.py: -------------------------------------------------------------------------------- 1 | """ 2 | Helper functions for building command for rtl_tcp and rtlamr 3 | """ 4 | 5 | import helpers.usb_utils as usbutils 6 | 7 | def get_comma_separated_str(key, list_of_dict): 8 | """ 9 | Get a comma-separated string of values for a given key from a list of dictionaries. 10 | """ 11 | c = [] 12 | for d in list_of_dict: 13 | if key in list_of_dict[d]: 14 | print(list_of_dict[d][key]) 15 | c.append(str(list_of_dict[d][key])) 16 | return ','.join(c) 17 | 18 | def partial_match_remove(k, l): 19 | """ 20 | Remove items from a list of dictionaries that partially match a key. 21 | Args: 22 | k (str): The key to check for partial matches. 23 | l (list): The list of dictionaries to check. 24 | Returns: 25 | l: The modified list of dictionaries. 26 | """ 27 | for n in l: 28 | if k in n: 29 | l.remove(n) 30 | return l 31 | 32 | def build_rtlamr_args(config): 33 | """ 34 | Build the command line arguments for the rtlamr command. 35 | Args: 36 | config (dict): The configuration dictionary. 37 | Returns: 38 | list: The command line arguments. 39 | """ 40 | # Build the command line arguments for the rtlamr command 41 | # based on the configuration file 42 | meters = config['meters'] 43 | default_args = [ '-format=json' ] 44 | rtltcp_host = [ f'-server={config["general"]["rtltcp_host"]}' ] 45 | if 'rtlamr' in config['custom_parameters']: 46 | custom_parameters = [ config['custom_parameters']['rtlamr'] ] 47 | else: 48 | custom_parameters = [ '-unique=true' ] 49 | default_args = partial_match_remove('server', default_args) 50 | 51 | # Build a comma-separated string of meter IDs 52 | ids = ','.join(list(meters.keys())) 53 | filterid_arg = [ f'-filterid={ids}' ] 54 | 55 | # Build a comma-separated string of message types 56 | msgtypes = get_comma_separated_str('protocol', meters) 57 | msgtype_arg = [ f'-msgtype={msgtypes}' ] 58 | 59 | return default_args + rtltcp_host + custom_parameters + filterid_arg + msgtype_arg 60 | 61 | def build_rtltcp_args(config): 62 | """ 63 | Build the command line arguments for the rtl_tcp command. 64 | Args: 65 | config (dict): The configuration dictionary. 66 | Returns: 67 | list: The command line arguments. 68 | """ 69 | # Build the command line arguments for the rtlamr command 70 | # based on the configuration file 71 | custom_parameters = '' 72 | if 'rtltcp' in config['custom_parameters']: 73 | custom_parameters = config['custom_parameters']['rtltcp'] 74 | device_id = config['general']['device_id'] 75 | sdl_devices = usbutils.find_rtl_sdr_devices() 76 | dev_arg = '-d 0' 77 | if device_id != '0' and device_id in sdl_devices: 78 | dev_arg = f'-d {sdl_devices.index(device_id)}' 79 | return [ custom_parameters, dev_arg] 80 | -------------------------------------------------------------------------------- /rtlamr2mqtt-addon/app/helpers/config.py: -------------------------------------------------------------------------------- 1 | """ 2 | Helper functions for loading configuration files 3 | """ 4 | 5 | import os 6 | from json import load 7 | from yaml import safe_load 8 | 9 | def load_config(config_path=None): 10 | """ 11 | Load the configuration file. 12 | """ 13 | # If no config path is provided, search for the config file in the default locations 14 | search_paths = [ 15 | '/data/options.json', 16 | '/data/options.js', 17 | '/data/options.yaml', 18 | '/data/options.yml', 19 | '/etc/rtlamr2mqtt.yaml' 20 | ] 21 | if config_path is None: 22 | for path in search_paths: 23 | if os.path.isfile(path) and os.access(path, os.R_OK): 24 | config_path = path 25 | break 26 | if config_path is None: 27 | return ('error', 'No config file found.', None) 28 | ############################################################## 29 | 30 | # Check if the file exists and is readable 31 | if not os.path.isfile(config_path): 32 | return ('error', 'Config file not found.', None) 33 | if not os.access(config_path, os.R_OK): 34 | return ('error', 'Config file not readable.', None) 35 | 36 | # Get file extension 37 | file_extension = os.path.splitext(config_path)[1] 38 | if file_extension in ['.json', '.js']: 39 | with open(config_path, 'r', encoding='utf-8') as file: 40 | config = load(file) 41 | elif file_extension in ['.yaml', '.yml']: 42 | with open(config_path, 'r', encoding='utf-8') as file: 43 | config = safe_load(file) 44 | else: 45 | return ('error', 'Config file format not supported.', None) 46 | 47 | # Get values and set defauls 48 | general, mqtt, custom_parameters = {}, {}, {} 49 | if 'general' in config and config['general'] is not None: 50 | general = config['general'] 51 | if 'mqtt' in config and config['mqtt'] is not None: 52 | mqtt = config['mqtt'] 53 | if 'custom_parameters' in config and config['custom_parameters'] is not None: 54 | custom_parameters = config['custom_parameters'] 55 | if 'meters' not in config: 56 | return ('error', 'No meters section found in config file.', None) 57 | # General section 58 | general['sleep_for'] = int(general.get('sleep_for', 0)) 59 | general['verbosity'] = str(general.get('verbosity', 'info')) 60 | general['device_id'] = str(general.get('device_id', '0')) 61 | general['rtltcp_host'] = str(general.get('rtltcp_host', '127.0.0.1:1234')) 62 | # MQTT section 63 | mqtt['host'] = str(mqtt.get('host', 'localhost')) 64 | mqtt['port'] = int(mqtt.get('port', 1883)) 65 | mqtt['user'] = mqtt.get('user', None) 66 | mqtt['password'] = mqtt.get('password', None) 67 | mqtt['tls_enabled'] = bool(mqtt.get('tls_enabled', False)) 68 | mqtt['tls_insecure'] = bool(mqtt.get('tls_insecure', False)) 69 | mqtt['tls_ca'] = mqtt.get('tls_ca', None) 70 | mqtt['tls_cert'] = mqtt.get('tls_cert', None) 71 | mqtt['tls_keyfile'] = mqtt.get('tls_keyfile', None) 72 | mqtt['base_topic'] = str(mqtt.get('base_topic', 'rtlamr')) 73 | mqtt['ha_status_topic'] = str(mqtt.get('ha_status_topic', 'homeassistant/status')) 74 | mqtt['ha_autodiscovery_topic'] = mqtt.get('ha_autodiscovery_topic', 'homeassistant') 75 | 76 | # Custom parameters section 77 | custom_parameters['rtltcp'] = str(custom_parameters.get('rtltcp', '-s 2048000')) 78 | custom_parameters['rtlamr'] = str(custom_parameters.get('rtlamr', '-unique=true')) 79 | 80 | # Convert meters to a dictionary with IDs as keys 81 | meters = {} 82 | meters_allowed_keys = [ 83 | 'id', 84 | 'protocol', 85 | 'name', 86 | 'format', 87 | 'unit_of_measurement', 88 | 'icon', 89 | 'device_class', 90 | 'state_class', 91 | 'expire_after', 92 | 'force_update' 93 | ] 94 | for m in config['meters']: 95 | # Get only allowed keys and drop anything else 96 | meters[str(m['id'])] = { key: value for key, value in m.items() if key in meters_allowed_keys } 97 | 98 | # Build config 99 | config = { 100 | 'general': general, 101 | 'mqtt': mqtt, 102 | 'custom_parameters': custom_parameters, 103 | 'meters': meters, 104 | } 105 | return ('success', 'Config loaded successfully', config) 106 | -------------------------------------------------------------------------------- /rtlamr2mqtt-addon/app/helpers/ha_messages.py: -------------------------------------------------------------------------------- 1 | """ 2 | Helper functions for writing MQTT payloads 3 | """ 4 | 5 | import helpers.info as i 6 | 7 | def meter_discover_payload(base_topic, meter_config): 8 | """ 9 | Returns the discovery payload for Home Assistant. 10 | """ 11 | 12 | meter_id = meter_config.pop('id') 13 | meter_name = meter_config.get('name', 'Unknown Meter') 14 | meter_config.pop('name', None) 15 | 16 | template_payload = { 17 | "device": { 18 | "identifiers": f"meter_{meter_id}", 19 | "name": meter_name, 20 | "manufacturer": "RTLAMR2MQTT", 21 | "model": "Smart Meter", 22 | "sw_version": "1.0", 23 | "serial_number": meter_id, 24 | "hw_version": "1.0" 25 | }, 26 | "origin": { 27 | "name":"2mqtt", 28 | "sw_version": i.version(), 29 | "support_url": i.origin_url() 30 | }, 31 | "components": { 32 | f"{meter_id}_reading": { 33 | "platform": "sensor", 34 | "name": "Reading", 35 | "value_template": "{{ value_json.reading|float }}", 36 | "json_attributes_topic": f"{base_topic}/{meter_id}/attributes", 37 | "unique_id": f"{meter_id}_reading" 38 | }, 39 | f"{meter_id}_lastseen": { 40 | "platform": "sensor", 41 | "name": "Last Seen", 42 | "device_class": "date", 43 | "value_template":"{{ value_json.lastseen }}", 44 | "unique_id": f"{meter_id}_lastseen" 45 | } 46 | }, 47 | "state_topic": f"{base_topic}/{meter_id}/state", 48 | "availability_topic": f"{base_topic}/status", 49 | "qos": 1 50 | } 51 | 52 | template_payload['components'][f'{meter_id}_reading'].update(meter_config) 53 | 54 | return template_payload 55 | -------------------------------------------------------------------------------- /rtlamr2mqtt-addon/app/helpers/info.py: -------------------------------------------------------------------------------- 1 | """ 2 | Returns information about the application. 3 | """ 4 | 5 | def version(): 6 | """ 7 | Returns the version of the application. 8 | """ 9 | return '2025.6.1' 10 | 11 | def origin_url(): 12 | """ 13 | Returns the origin URL of the application. 14 | """ 15 | return 'https://github.com/allangood/rtlamr2mqtt' 16 | -------------------------------------------------------------------------------- /rtlamr2mqtt-addon/app/helpers/mqtt_client.py: -------------------------------------------------------------------------------- 1 | """ 2 | Helper functions for MQTT connection 3 | """ 4 | 5 | import ssl 6 | import paho.mqtt.client as mqtt 7 | 8 | class MQTTClient: 9 | """ 10 | A class to handle MQTT client operations. 11 | """ 12 | def __init__(self, logger, broker, port, username=None, password=None, tls_enabled=False, ca_cert=None, client_cert=None, tls_insecure=False, client_key=None, log_level=4): 13 | """ 14 | Initialize the MQTT client. 15 | """ 16 | self.client = mqtt.Client(client_id='rtlamr2mqtt') 17 | self.broker = broker 18 | self.port = port 19 | self.logger = logger 20 | self.log_level = log_level 21 | 22 | # Set username and password if provided 23 | if username and password: 24 | self.client.username_pw_set(username, password) 25 | 26 | # Configure TLS if enabled 27 | if tls_enabled: 28 | self.client.tls_set( 29 | ca_certs=ca_cert, 30 | certfile=client_cert, 31 | keyfile=client_key, 32 | tls_version=ssl.PROTOCOL_TLSv1_2, 33 | cert_reqs=ssl.CERT_NONE if tls_insecure else ssl.CERT_REQUIRED 34 | ) 35 | self.client.tls_insecure_set(tls_insecure) 36 | 37 | def set_last_will(self, topic, payload, qos=0, retain=False): 38 | """ 39 | Set the Last Will and Testament (LWT). 40 | """ 41 | self.client.will_set(topic, payload=payload, qos=qos, retain=retain) 42 | 43 | def connect(self): 44 | """ 45 | Connect to the MQTT broker. 46 | """ 47 | if self.log_level >= 3: 48 | self.logger.info(f"Connecting to MQTT broker at {self.broker}:{self.port}") 49 | self.client.connect(self.broker, self.port) 50 | 51 | def publish(self, topic, payload, qos=0, retain=False): 52 | """ 53 | Publish a message to a topic. 54 | """ 55 | if self.log_level >= 3: 56 | self.logger.info(f"Publishing to {topic}: {payload}") 57 | self.client.publish(topic, payload=payload, qos=qos, retain=retain) 58 | 59 | def subscribe(self, topic, qos=0): 60 | """ 61 | Subscribe to a topic. 62 | """ 63 | if self.log_level >= 3: 64 | self.logger.info(f"Subscribing to {topic}") 65 | self.client.subscribe(topic, qos=qos) 66 | 67 | def set_on_message_callback(self, callback): 68 | """ 69 | Set the callback for incoming messages. 70 | """ 71 | self.client.on_message = callback 72 | 73 | def loop_start(self): 74 | """ 75 | Start the MQTT client loop. 76 | """ 77 | self.client.loop_start() 78 | 79 | def loop_stop(self): 80 | """ 81 | Stop the MQTT client loop. 82 | """ 83 | self.client.loop_stop() 84 | 85 | def loop(self): 86 | """ 87 | Stop the MQTT client loop. 88 | """ 89 | self.client.loop() 90 | 91 | def disconnect(self): 92 | """ 93 | Disconnect from the MQTT broker. 94 | """ 95 | if self.log_level >= 3: 96 | self.logger.info("Disconnecting from MQTT broker") 97 | self.client.disconnect() 98 | -------------------------------------------------------------------------------- /rtlamr2mqtt-addon/app/helpers/read_output.py: -------------------------------------------------------------------------------- 1 | """ 2 | Helper functions for loading rtlamr output 3 | """ 4 | 5 | from json import loads 6 | 7 | def list_intersection(a, b): 8 | """ 9 | Find the first element in the intersection of two lists 10 | """ 11 | result = list(set(a).intersection(set(b))) 12 | return result[0] if result else None 13 | 14 | 15 | 16 | def format_number(number, f): 17 | """ 18 | Format a number according to a given format. 19 | """ 20 | return str(f.replace('#', '{}').format(*str(number).zfill(f.count('#')))) 21 | 22 | 23 | 24 | def is_json(test_string): 25 | """ 26 | Check if a string is valid JSON 27 | """ 28 | try: 29 | loads(test_string) 30 | except ValueError: 31 | return False 32 | return True 33 | 34 | 35 | 36 | def read_rtlamr_output(output): 37 | """ 38 | Read a line a check if it is valid JSON 39 | """ 40 | if is_json(output): 41 | return loads(output) 42 | 43 | 44 | 45 | def get_message_for_ids(rtlamr_output, meter_ids_list): 46 | """ 47 | Search for meter IDs in the rtlamr output and return the first match. 48 | """ 49 | meter_id, consumption = None, None 50 | json_output = read_rtlamr_output(rtlamr_output) 51 | if json_output is not None and 'Message' in json_output: 52 | message = json_output['Message'] 53 | meter_id_key = list_intersection(message, ['EndpointID', 'ID', 'ERTSerialNumber']) 54 | if meter_id_key is not None: 55 | meter_id = str(message[meter_id_key]) 56 | if meter_id in meter_ids_list: 57 | message.pop(meter_id_key) 58 | consumption_key = list_intersection(message, ['Consumption', 'LastConsumption', 'LastConsumptionCount']) 59 | if consumption_key is not None: 60 | consumption = message[consumption_key] 61 | message.pop(consumption_key) 62 | 63 | if meter_id is not None and consumption is not None: 64 | return { 'meter_id': str(meter_id), 'consumption': int(consumption), 'message': message } 65 | return None 66 | -------------------------------------------------------------------------------- /rtlamr2mqtt-addon/app/helpers/sdl_ids.txt: -------------------------------------------------------------------------------- 1 | # All supported USB SDR-RTL devices IDs: 2 | # Source: https://osmocom.org/projects/rtl-sdr/wiki/Rtl-sdr#Supported-Hardware 3 | # and 4 | # Source: https://www.reddit.com/r/RTLSDR/comments/s6ddo/rtlsdr_compatibility_list_v2_work_in_progress/ 5 | 0458:707f 6 | 048d:9135 7 | 0bda:2832 8 | 0bda:2838 9 | 0ccd:00a9 10 | 0ccd:00b3 11 | 0ccd:00d3 12 | 0ccd:00e0 13 | 185b:0620 14 | 185b:0650 15 | 1b80:d393 16 | 1b80:d394 17 | 1b80:d395 18 | 1b80:d39d 19 | 1b80:d3a4 20 | 1d19:1101 21 | 1d19:1102 22 | 1d19:1103 23 | 1f4d:b803 24 | 1f4d:c803 25 | 1f4d:d803 26 | -------------------------------------------------------------------------------- /rtlamr2mqtt-addon/app/helpers/usb_utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | Helper functions for USB handling 3 | """ 4 | 5 | from fcntl import ioctl 6 | from stat import S_ISCHR 7 | import os 8 | import re 9 | import usb.core 10 | 11 | def load_id_file(sdl_ids_file): 12 | """ 13 | Load SDL file id 14 | """ 15 | device_ids = [] 16 | with open(sdl_ids_file, 'r', encoding='utf-8') as f: 17 | for line in f: 18 | li = line.strip() 19 | if re.match(r"(^(0[xX])?[A-Fa-f0-9]+:(0[xX])?[A-Fa-f0-9]+$)", li) is not None: 20 | device_ids.append(line.rstrip().lstrip().lower()) 21 | return device_ids 22 | 23 | def find_rtl_sdr_devices(): 24 | """ 25 | Find a valid RTL device 26 | """ 27 | # Load the list of all supported device ids 28 | sdl_file_path = os.path.join(os.path.dirname(__file__), 'sdl_ids.txt') 29 | DEVICE_IDS = load_id_file(sdl_file_path) 30 | devices_found = [] 31 | for dev in usb.core.find(find_all = True): 32 | for known_dev in DEVICE_IDS: 33 | usb_id, usb_vendor = known_dev.split(':') 34 | if dev.idVendor == int(usb_id, 16) and dev.idProduct == int(usb_vendor, 16): 35 | devices_found.append(f'{dev.bus:03d}:{dev.address:03d}') 36 | break 37 | return devices_found 38 | 39 | def reset_usb_device(usbdev): 40 | """ 41 | Reset USB port 42 | """ 43 | if usbdev is not None and ':' in usbdev: 44 | busnum, devnum = [int(x) for x in usbdev.split(':')] 45 | filename = f"/dev/bus/usb/{busnum:03d}/{devnum:03d}" 46 | if os.path.exists(filename) and S_ISCHR(os.stat(filename).st_mode): 47 | #define USBDEVFS_RESET_IO('U', 20) 48 | USBDEVFS_RESET = ord('U') << (4*2) | 20 49 | fd = open(filename, "wb") 50 | result = int(ioctl(fd, USBDEVFS_RESET, 0)) == 0 51 | fd.close() 52 | return result 53 | return False 54 | -------------------------------------------------------------------------------- /rtlamr2mqtt-addon/app/rtlamr2mqtt.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | """ 4 | rtlamr2mqtt - A Home Assistant add-on for RTLAMR 5 | https://github.com/allangood/rtlamr2mqtt/blob/main/LICENSE 6 | 7 | This add-on uses the code from: 8 | - https://github.com/bemasher/rtlamr 9 | - https://git.osmocom.org/rtl-sdr 10 | """ 11 | 12 | import os 13 | import sys 14 | import logging 15 | import subprocess 16 | import signal 17 | from datetime import datetime 18 | from subprocess import Popen, PIPE 19 | from json import dumps 20 | from time import sleep, time 21 | import helpers.config as cnf 22 | import helpers.buildcmd as cmd 23 | import helpers.mqtt_client as m 24 | import helpers.ha_messages as ha_msgs 25 | import helpers.read_output as ro 26 | import helpers.usb_utils as usbutil 27 | import helpers.info as i 28 | 29 | 30 | # Set up logging 31 | logger = logging.getLogger(__name__) 32 | logging.basicConfig(format='[%(asctime)s] %(levelname)s:%(message)s', level=logging.DEBUG) 33 | LOG_LEVEL = 0 34 | logger.info('Starting rtlamr2mqtt %s', i.version()) 35 | 36 | 37 | 38 | def shutdown(rtlamr=None, rtltcp=None, mqtt_client=None, base_topic='rtlamr'): 39 | """ Shutdown function to terminate processes and clean up """ 40 | if LOG_LEVEL >= 3: 41 | logger.info('Shutting down...') 42 | # Terminate RTLAMR 43 | if rtlamr is not None: 44 | if LOG_LEVEL >= 3: 45 | logger.info('Terminating RTLAMR...') 46 | rtlamr.terminate() 47 | try: 48 | rtlamr.communicate(timeout=1) 49 | except subprocess.TimeoutExpired: 50 | rtlamr.kill() 51 | rtlamr.communicate() 52 | if LOG_LEVEL >= 3: 53 | logger.info('RTLAMR Terminitaed.') 54 | # Terminate RTL_TCP 55 | if rtltcp is not None: 56 | if LOG_LEVEL >= 3: 57 | logger.info('Terminating RTL_TCP...') 58 | rtltcp.terminate() 59 | try: 60 | rtltcp.communicate(timeout=1) 61 | except subprocess.TimeoutExpired: 62 | rtltcp.kill() 63 | rtltcp.communicate() 64 | if LOG_LEVEL >= 3: 65 | logger.info('RTL_TCP Terminitaed.') 66 | if mqtt_client is not None: 67 | mqtt_client.publish( 68 | topic=f'{base_topic}/status', 69 | payload='offline', 70 | qos=1, 71 | retain=False 72 | ) 73 | mqtt_client.loop_stop() 74 | mqtt_client.disconnect() 75 | if LOG_LEVEL >= 3: 76 | logger.info('All done. Bye!') 77 | 78 | 79 | 80 | def signal_handler(signum, frame): 81 | """ Signal handler for SIGINT and SIGTERM """ 82 | raise RuntimeError(f'Signal {signum} received.') 83 | 84 | 85 | 86 | 87 | def on_message(client, userdata, message): 88 | """ Callback function for MQTT messages """ 89 | if LOG_LEVEL >= 3: 90 | logger.info('Received message "%s" on topic "%s"', message.payload.decode(), message.topic) 91 | 92 | 93 | 94 | def get_iso8601_timestamp(): 95 | """ 96 | Get the current timestamp in ISO 8601 format 97 | """ 98 | return datetime.now().astimezone().replace(microsecond=0).isoformat() 99 | 100 | 101 | def start_rtltcp(config): 102 | """ Start RTL_TCP process """ 103 | # Search for RTL-SDR devices 104 | usb_id_list = usbutil.find_rtl_sdr_devices() 105 | 106 | if 'RTLAMR2MQTT_USE_MOCK' in os.environ: 107 | usb_id_list = [ '001:001'] 108 | 109 | usb_id = config['general']['device_id'] 110 | if config['general']['device_id'] == '0': 111 | if len(usb_id_list) > 0: 112 | usb_id = usb_id_list[0] 113 | else: 114 | logger.critical('No RTL-SDR devices found. Exiting...') 115 | return None 116 | 117 | 118 | if 'RTLAMR2MQTT_USE_MOCK' not in os.environ: 119 | if LOG_LEVEL >= 3: 120 | logger.debug('Reseting USB device: %s', usb_id) 121 | usbutil.reset_usb_device(usb_id) 122 | 123 | rtltcp_args = cmd.build_rtltcp_args(config) 124 | 125 | if LOG_LEVEL >= 3: 126 | logger.info('Starting RTL_TCP using "rtl_tcp %s"', " ".join(rtltcp_args)) 127 | try: 128 | rtltcp = Popen(["rtl_tcp"] + rtltcp_args, close_fds=True, stdout=PIPE) 129 | except Exception as e: 130 | logger.critical('Failed to start RTL_TCP. %s', e) 131 | return None 132 | 133 | rtltcp_is_ready = False 134 | # Wait for rtl_tcp to be ready 135 | while not rtltcp_is_ready: 136 | # Read the output in chunks 137 | try: 138 | rtltcp_output = rtltcp.stdout.read1().decode('utf-8').strip('\n') 139 | except KeyboardInterrupt: 140 | logger.critical('Interrupted by user.') 141 | rtltcp_is_ready = False 142 | sys.exit(1) 143 | except Exception as e: 144 | logger.critical(e) 145 | rtltcp_is_ready = False 146 | sys.exit(1) 147 | if rtltcp_output: 148 | if LOG_LEVEL >= 4: 149 | logger.debug(rtltcp_output) 150 | if "listening..." in rtltcp_output: 151 | rtltcp_is_ready = True 152 | if LOG_LEVEL >= 3: 153 | logger.info('RTL_TCP started!') 154 | # Check rtl_tcp status 155 | rtltcp.poll() 156 | if rtltcp.returncode is not None: 157 | logger.critical('RTL_TCP failed to start errcode: %d', int(rtltcp.returncode)) 158 | sys.exit(1) 159 | return rtltcp 160 | 161 | 162 | 163 | def start_rtlamr(config): 164 | """ Start RTLAMR process """ 165 | rtlamr_args = cmd.build_rtlamr_args(config) 166 | if LOG_LEVEL >= 3: 167 | logger.info('Starting RTLAMR using "rtlamr %s"', " ".join(rtlamr_args)) 168 | try: 169 | rtlamr = Popen(["rtlamr"] + rtlamr_args, close_fds=True, stdout=PIPE) 170 | except Exception: 171 | logger.critical('Failed to start RTLAMR. Exiting...') 172 | return None 173 | rtlamr_is_ready = False 174 | while not rtlamr_is_ready: 175 | try: 176 | rtlamr_output = rtlamr.stdout.read1().decode('utf-8').strip('\n') 177 | except KeyboardInterrupt: 178 | logger.critical('Interrupted by user.') 179 | rtlamr_is_ready = False 180 | sys.exit(1) 181 | except Exception as e: 182 | logger.critical(e) 183 | rtlamr_is_ready = False 184 | sys.exit(1) 185 | if rtlamr_output: 186 | if LOG_LEVEL >= 4: 187 | logger.debug(rtlamr_output) 188 | if 'set gain mode' in rtlamr_output: 189 | rtlamr_is_ready = True 190 | if LOG_LEVEL >= 3: 191 | logger.info('RTLAMR started!') 192 | # Check rtl_tcp status 193 | rtlamr.poll() 194 | if rtlamr.returncode is not None: 195 | logger.critical('RTLAMR failed to start errcode: %d', rtlamr.returncode) 196 | sys.exit(1) 197 | return rtlamr 198 | 199 | 200 | 201 | def main(): 202 | """ 203 | Main function 204 | """ 205 | # Signal handlers/call back 206 | signal.signal(signal.SIGTERM, signal_handler) 207 | signal.signal(signal.SIGINT, signal_handler) 208 | 209 | # Load the configuration file 210 | if len(sys.argv) == 2: 211 | config_path = os.path.join(os.path.dirname(__file__), sys.argv[1]) 212 | else: 213 | config_path = None 214 | err, msg, config = cnf.load_config(config_path) 215 | 216 | if err != 'success': 217 | # Error loading configuration file 218 | logger.critical(msg) 219 | sys.exit(1) 220 | # Configuration file loaded successfully 221 | # Use LOG_LEVEL as a global variable 222 | global LOG_LEVEL 223 | # Convert verbosity to a number and store as LOG_LEVEL 224 | LOG_LEVEL = ['none', 'error', 'warning', 'info', 'debug'].index(config['general']['verbosity']) 225 | if LOG_LEVEL >= 3: 226 | logger.info(msg) 227 | ################################################################## 228 | 229 | # Get a list of meters ids to watch 230 | meter_ids_list = list(config['meters'].keys()) 231 | 232 | # Create the info reading variable 233 | reading_info = {} 234 | for m_id in meter_ids_list: 235 | reading_info[m_id] = { 'n_readings': 0, 'last_reading': 0 } 236 | 237 | # Create MQTT Client and connect to the broker 238 | mqtt_client = m.MQTTClient( 239 | broker=config['mqtt']['host'], 240 | port=config['mqtt']['port'], 241 | username=config['mqtt']['user'], 242 | password=config['mqtt']['password'], 243 | tls_enabled=config['mqtt']['tls_enabled'], 244 | tls_insecure=config['mqtt']['tls_insecure'], 245 | ca_cert=config['mqtt']['tls_ca'], 246 | client_cert=config['mqtt']['tls_cert'], 247 | client_key=config['mqtt']['tls_keyfile'], 248 | log_level=LOG_LEVEL, 249 | logger=logger, 250 | ) 251 | 252 | # Set Last Will and Testament 253 | mqtt_client.set_last_will( 254 | topic=f'{config["mqtt"]["base_topic"]}/status', 255 | payload="offline", 256 | qos=1, 257 | retain=False 258 | ) 259 | 260 | try: 261 | mqtt_client.connect() 262 | except Exception as e: 263 | logger.critical('Failed to connect to MQTT broker: %s', e) 264 | sys.exit(1) 265 | 266 | # Set on_message callback 267 | mqtt_client.set_on_message_callback(on_message) 268 | 269 | # Subscribe to Home Assistant status topic 270 | mqtt_client.subscribe(config['mqtt']['ha_status_topic'], qos=1) 271 | 272 | # Start the MQTT client loop 273 | mqtt_client.loop_start() 274 | 275 | # Publish the discovery messages for all meters 276 | for meter in config['meters']: 277 | discovery_payload = ha_msgs.meter_discover_payload(config["mqtt"]["base_topic"], config['meters'][meter]) 278 | mqtt_client.publish( 279 | topic=f'{config["mqtt"]["ha_autodiscovery_topic"]}/device/{meter}/config', 280 | payload=dumps(discovery_payload), 281 | qos=1, 282 | retain=False 283 | ) 284 | 285 | # Give some time for the MQTT client to connect and publish 286 | sleep(1) 287 | # Publish the initial status 288 | mqtt_client.publish( 289 | topic=f'{config["mqtt"]["base_topic"]}/status', 290 | payload='online', 291 | qos=1, 292 | retain=False 293 | ) 294 | 295 | ################################################################## 296 | keep_reading = True 297 | while keep_reading: 298 | missing_readings = meter_ids_list.copy() 299 | # Start RTL_TCP 300 | rtltcp = start_rtltcp(config) 301 | if rtltcp is None: 302 | logger.critical('Failed to start RTL_TCP. Exiting...') 303 | shutdown(rtlamr=None, rtltcp=None, mqtt_client=mqtt_client, base_topic=config["mqtt"]["base_topic"]) 304 | sys.exit(1) 305 | 306 | # Start RTLAMR 307 | rtlamr = start_rtlamr(config) 308 | if rtlamr is None: 309 | logger.critical('Failed to start RTLAMR. Exiting...') 310 | shutdown(rtlamr=None, rtltcp=rtltcp, mqtt_client=mqtt_client, base_topic=config["mqtt"]["base_topic"]) 311 | sys.exit(1) 312 | ################################################################## 313 | 314 | # Read the output from RTLAMR 315 | while keep_reading: 316 | try: 317 | rtlamr_output = rtlamr.stdout.read1().decode('utf-8') 318 | except KeyboardInterrupt: 319 | logger.critical('Interrupted by user.') 320 | keep_reading = False 321 | break 322 | except Exception as e: 323 | logger.critical(e) 324 | keep_reading = False 325 | break 326 | # Search for ID in the output 327 | reading = ro.get_message_for_ids( 328 | rtlamr_output = rtlamr_output, 329 | meter_ids_list = meter_ids_list 330 | ) 331 | 332 | if reading is not None: 333 | # Remove the meter_id from the list of missing readings 334 | if reading['meter_id'] in missing_readings: 335 | missing_readings.remove(reading['meter_id']) 336 | 337 | # Update the reading info 338 | reading_info[reading['meter_id']]['n_readings'] += 1 339 | reading_info[reading['meter_id']]['last_reading'] = int(time()) 340 | 341 | if config['meters'][reading['meter_id']]['format'] is not None: 342 | r = ro.format_number(reading['consumption'], config['meters'][reading['meter_id']]['format']) 343 | else: 344 | r = reading['consumption'] 345 | 346 | # Publish the reading to MQTT 347 | payload = { 'reading': r, 'lastseen': get_iso8601_timestamp() } 348 | mqtt_client.publish( 349 | topic=f'{config["mqtt"]["base_topic"]}/{reading["meter_id"]}/state', 350 | payload=dumps(payload), 351 | qos=1, 352 | retain=False 353 | ) 354 | 355 | # Publish the meter attributes to MQTT 356 | # Add the meter protocol to the list of attributes 357 | reading['message']['protocol'] = config['meters'][reading['meter_id']]['protocol'] 358 | mqtt_client.publish( 359 | topic=f'{config["mqtt"]["base_topic"]}/{reading["meter_id"]}/attributes', 360 | payload=dumps(reading['message']), 361 | qos=1, 362 | retain=False 363 | ) 364 | 365 | if config['general']['sleep_for'] > 0 and len(missing_readings) == 0: 366 | # We have our readings, so we can sleep 367 | if LOG_LEVEL >= 3: 368 | logger.info('All readings received.') 369 | logger.info('Sleeping for %d seconds...', config["general"]["sleep_for"]) 370 | # Shutdown everything, but mqtt_client 371 | shutdown(rtlamr=rtlamr, rtltcp=rtltcp, mqtt_client=None) 372 | try: 373 | sleep(int(config['general']['sleep_for'])) 374 | except KeyboardInterrupt: 375 | logger.critical('Interrupted by user.') 376 | keep_reading = False 377 | shutdown(rtlamr=rtlamr, rtltcp=rtltcp, mqtt_client=mqtt_client, base_topic=config['mqtt']['base_topic']) 378 | break 379 | except Exception: 380 | logger.critical('Term siganal received. Exiting...') 381 | keep_reading = False 382 | shutdown(rtlamr=rtlamr, rtltcp=rtltcp, mqtt_client=mqtt_client, base_topic=config['mqtt']['base_topic']) 383 | break 384 | if LOG_LEVEL >= 3: 385 | logger.info('Time to wake up!') 386 | break 387 | 388 | # Shutdown 389 | shutdown(rtlamr = rtlamr, rtltcp = rtltcp, mqtt_client = mqtt_client, base_topic=config['mqtt']['base_topic']) 390 | 391 | 392 | if __name__ == '__main__': 393 | # Call main function 394 | main() 395 | -------------------------------------------------------------------------------- /rtlamr2mqtt-addon/app/rtlamr2mqtt.yaml: -------------------------------------------------------------------------------- 1 | general: 2 | # Sleep for this amount of seconds before each reading 3 | # If this value is 0, it means we will never stop reading 4 | sleep_for: 0 5 | # Verbose output. It can be: debug, info, warning, error, none 6 | verbosity: info 7 | # if you have multiple RTL devices, set the device id to use with this instance. 8 | # Get the ID running the lsusb command. If not specified, the first device will be used. 9 | # Example: 10 | # device_id: '001:010' 11 | # RTL_TCP host and port to connect. Default, use the internal server 12 | # If you want to use a remote rtl_tcp server, set the host and port here 13 | # rtltcp_host: 'remote_host:1234' 14 | 15 | mqtt: 16 | # Broker host 17 | host: 127.0.0.1 18 | # Broker port 19 | port: 1883 20 | # Username 21 | # user: test 22 | # Password 23 | # password: testpassword 24 | # Use TLS with MQTT? 25 | tls_enabled: false 26 | # TLS insecure. Must be true for self-signed certificates. Defaults to False 27 | tls_insecure: true 28 | # Path to CA certificate to use. Mandatory if tls_enabled = true 29 | tls_ca: "/etc/ssl/certs/ca-certificates.crt" 30 | # Path to certificate file to use. Optional 31 | tls_cert: "/etc/ssl/my_self_signed_cert.crt" 32 | # Certificate key file to use. Optional 33 | tls_keyfile: "/etc/ssl/my_self_signed_cert_key.key" 34 | # Which topic for the auto discover to use? 35 | ha_autodiscovery_topic: homeassistant 36 | # Home Assistant status topic 37 | ha_status_topic: hass/status 38 | # Base topic to send status and state information 39 | # i.e.: status = /status 40 | base_topic: "rtlamr" 41 | 42 | # Optional section 43 | # If you need to pass parameters to rtl_tcp or rtlamr 44 | # custom_parameters: 45 | # rtltcp: "-s 2048000" 46 | # rtlamr: "-unique=true" 47 | 48 | # Mandatory section: Meters definition 49 | # You can define multiple meters 50 | # Check here for more info: 51 | # https://www.home-assistant.io/integrations/sensor.mqtt 52 | meters: 53 | # Meter ID 54 | - id: 33333333 55 | # Protocol: scm, scm+, idm, netidm, r900 and r900bcd 56 | protocol: scm+ 57 | # A nice name to show on HA 58 | name: my_water_meter 59 | # How to format the number of your meter. Each '#' is a digit 60 | format: "######.###" 61 | # Unit of measurement to show on HA 62 | unit_of_measurement: "m³" 63 | # Icon to show on HA 64 | icon: mdi:gauge 65 | # device_class on HA 66 | device_class: water 67 | # HA state_class. It can be measurement|total|total_increasing 68 | # state_class: total_increasing 69 | # If set, it defines the number of seconds after the sensor’s state expires, 70 | # if it’s not updated. After expiry, the sensor’s state becomes unavailable. 71 | # expire_after: 0 72 | # Sends update events even if the value hasn’t changed. 73 | # Useful if you want to have meaningful value graphs in history. 74 | # force_update: true 75 | - id: 22222222 76 | # Protocol: scm, scm+, idm, netidm, r900 and r900bcd 77 | protocol: r900 78 | # A nice name to show on HA 79 | name: my_energy_meter 80 | # How to format the number of your meter. Each '#' is a digit 81 | format: "######.###" 82 | # Unit of measurement to show on HA 83 | unit_of_measurement: "KWh" 84 | # Icon to show on HA 85 | icon: mdi:gauge 86 | # device_class on HA 87 | device_class: energy 88 | -------------------------------------------------------------------------------- /rtlamr2mqtt-addon/config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: rtlamr2mqtt 3 | version: 2025.6.1 4 | slug: rtlamr2mqtt 5 | panel_icon: mdi:gauge 6 | description: RTLAMR to MQTT Bridge 7 | startup: application 8 | boot: auto 9 | map: 10 | - config:rw 11 | url: https://github.com/allangood/rtlamr2mqtt 12 | uart: true 13 | udev: true 14 | usb: true 15 | host_network: false 16 | hassio_api: true 17 | arch: 18 | - amd64 19 | - aarch64 20 | services: 21 | - mqtt:need 22 | options: 23 | general: 24 | sleep_for: 60 25 | verbosity: info 26 | device_id: "0" 27 | rtltcp_host: "127.0.0.1:1234" 28 | mqtt: 29 | host: 127.0.0.1 30 | port: 1883 31 | ha_autodiscovery_topic: homeassistant 32 | ha_status_topic: hass/status 33 | base_topic: "rtlamr" 34 | custom_parameters: 35 | rtltcp: "-s 2048000" 36 | rtlamr: "-unique=true" 37 | meters: 38 | - id: 22222222 39 | protocol: r900 40 | name: my_energy_meter 41 | format: "######.###" 42 | # device_class on HA 43 | device_class: energy 44 | 45 | schema: 46 | general: 47 | sleep_for: "int?" 48 | verbosity: "list(debug|info|warning|critical|none)?" 49 | device_id: "match((^0|^(0[xX])?[A-Fa-f0-9]+:(0[xX])?[A-Fa-f0-9]+$))?" 50 | rtltcp_host: match(([\w\d\.]+):(\d+)) 51 | mqtt: 52 | host: "str?" 53 | port: "int?" 54 | user: "str?" 55 | password: str? 56 | tls_enabled: "bool?" 57 | tls_insecure: "bool?" 58 | tls_ca: "str?" 59 | tls_cert: "str?" 60 | tls_keyfile: "str?" 61 | ha_autodiscovery_topic: "str?" 62 | ha_status_topic: "str?" 63 | base_topic: "str?" 64 | custom_parameters: 65 | rtltcp: "str?" 66 | rtlamr: "str?" 67 | meters: 68 | - id: int 69 | protocol: list(idm|netidm|r900|r900bcd|scm|scm+) 70 | name: "str" 71 | format: "str?" 72 | unit_of_measurement: "str?" 73 | icon: "str?" 74 | device_class: list(none|current|energy|gas|power|water) 75 | state_class: list(measurement|total|total_increasing)? 76 | expire_after: int? 77 | force_update: bool? 78 | -------------------------------------------------------------------------------- /rtlamr2mqtt-addon/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/allangood/rtlamr2mqtt/639c17787070b6ca14e49d2ae11e1d1fbe03970f/rtlamr2mqtt-addon/icon.png -------------------------------------------------------------------------------- /rtlamr2mqtt-addon/mock/rtl_tcp: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # This is a mock script to simulate the output of a real RTL_TCP device. 3 | 4 | be_bad=$(echo "$@"|grep "bad") 5 | 6 | good="Found 1 device(s): 7 | 0: Realtek, RTL2838UHIDIR, SN: 00000001 8 | 9 | Using device 0: Generic RTL2832U OEM 10 | Detached kernel driver 11 | Found Rafael Micro R820T tuner 12 | [R82XX] PLL not locked! 13 | Tuned to 100000000 Hz. 14 | listening... 15 | Use the device argument 'rtl_tcp=127.0.0.1:1234' in OsmoSDR (gr-osmosdr) source 16 | to receive samples in GRC and control rtl_tcp parameters (frequency, gain, ...). 17 | " 18 | 19 | bad="No supported devices found." 20 | 21 | if [ "$be_bad" ]; then 22 | echo "${bad}" 23 | exit 1 24 | else 25 | while IFS= read -r line; do 26 | echo "$line" 27 | sleep 0.1 28 | done <<< "$good" 29 | fi 30 | 31 | while :; do 32 | sleep 1 33 | done 34 | -------------------------------------------------------------------------------- /rtlamr2mqtt-addon/mock/rtlamr: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # This is a mock script to simulate the output of a real RTLAMR device. 3 | 4 | header='client accepted! localhost 56348 5 | Allocating 15 zero-copy buffers 6 | set freq 912600155 7 | 21:25:03.790381 decode.go:45: CenterFreq: 912600155 8 | 21:25:03.790583 decode.go:46: SampleRate: 2359296 9 | 21:25:03.790600 decode.go:47: DataRate: 32768 10 | 21:25:03.790614 decode.go:48: ChipLength: 72 11 | 21:25:03.790621 decode.go:49: PreambleSymbols: 32 12 | 21:25:03.790634 decode.go:50: PreambleLength: 4608 13 | 21:25:03.790641 decode.go:51: PacketSymbols: 736 14 | 21:25:03.790659 decode.go:52: PacketLength: 105984 15 | 21:25:03.790675 decode.go:59: Protocols: idm,r900,scm,scm+ 16 | 21:25:03.790684 decode.go:60: Preambles: 01010101010101010001011010100011,00000000000000001110010101100100,111110010101001100000,0001011010100011 17 | 21:25:03.790703 main.go:124: GainCount: 29 18 | set sample rate 2359296 19 | set gain mode 0' 20 | 21 | reads='{"Time":"2025-05-05T21:25:04.891578823Z","Offset":0,"Length":0,"Type":"IDM","Message":{"Preamble":1431639715,"PacketTypeID":28,"PacketLength":92,"HammingCode":198,"ApplicationVersion":4,"ERTType":7,"ERTSerialNumber":33333333,"ConsumptionIntervalCount":76,"ModuleProgrammingState":188,"TamperCounters":"AwIAcw4A","AsynchronousCounters":0,"PowerOutageFlags":"AAAAAAAA","LastConsumptionCount":1978208,"DifferentialConsumptionIntervals":[26,26,27,26,26,24,24,24,24,24,26,26,26,27,26,26,26,26,24,23,48,23,24,25,25,24,25,24,25,25,23,23,22,23,23,23,24,25,25,25,25,25,26,23,23,22,23],"TransmitTimeOffset":3911,"SerialNumberCRC":43319,"PacketCRC":49515}} 22 | {"Time":"2025-05-05T21:25:06.548266268Z","Offset":0,"Length":0,"Type":"SCM","Message":{"ID":60706301,"Type":7,"TamperPhy":1,"TamperEnc":2,"Consumption":7621974,"ChecksumVal":48922}} 23 | {"Time":"2025-05-05T21:25:08.660603188Z","Offset":0,"Length":0,"Type":"SCM","Message":{"ID":00000000,"Type":7,"TamperPhy":3,"TamperEnc":1,"Consumption":206082,"ChecksumVal":20730}} 24 | {"Time":"2025-05-05T21:25:10.905527969Z","Offset":0,"Length":0,"Type":"R900","Message":{"ID":1111111111,"Unkn1":163,"NoUse":0,"BackFlow":0,"Consumption":4555831,"Unkn3":0,"Leak":2,"LeakNow":0}} 25 | {"Time":"2025-05-05T21:25:11.001486809Z","Offset":0,"Length":0,"Type":"SCM","Message":{"ID":22222222,"Type":7,"TamperPhy":0,"TamperEnc":1,"Consumption":9480653,"ChecksumVal":8042}} 26 | {"Time":"2025-05-05T21:25:11.959372062Z","Offset":0,"Length":0,"Type":"SCM","Message":{"ID":33333333,"Type":7,"TamperPhy":3,"TamperEnc":2,"Consumption":1978226,"ChecksumVal":60151}} 27 | {"Time":"2025-05-05T21:25:14.555435329Z","Offset":0,"Length":0,"Type":"IDM","Message":{"Preamble":1431639715,"PacketTypeID":28,"PacketLength":92,"HammingCode":198,"ApplicationVersion":4,"ERTType":7,"ERTSerialNumber":44444444,"ConsumptionIntervalCount":68,"ModuleProgrammingState":188,"TamperCounters":"BAUAdhEA","AsynchronousCounters":0,"PowerOutageFlags":"AAAAAAAA","LastConsumptionCount":9386150,"DifferentialConsumptionIntervals":[57,85,51,41,69,43,42,43,43,43,43,44,44,44,44,46,41,27,43,44,43,43,43,43,43,44,70,52,44,45,46,44,47,44,43,24,41,43,45,44,43,44,41,25,43,44,43],"TransmitTimeOffset":4068,"SerialNumberCRC":11642,"PacketCRC":20482}} 28 | {"Time":"2025-05-05T21:25:15.387177726Z","Offset":0,"Length":0,"Type":"IDM","Message":{"Preamble":1431639715,"PacketTypeID":52,"PacketLength":92,"HammingCode":198,"ApplicationVersion":140,"ERTType":6,"ERTSerialNumber":22222222,"ConsumptionIntervalCount":164,"ModuleProgrammingState":184,"TamperCounters":"BAEAbQEA","AsynchronousCounters":0,"PowerOutageFlags":"AAAACAAA","LastConsumptionCount":1217440205,"DifferentialConsumptionIntervals":[7,6,70,70,6,6,22,2,358,5,6,5,24,2,262,136,13,87,6,6,6,6,6,5,6,5,129,390,6,6,7,6,7,8,5,6,7,6,6,7,7,6,6,22,7,6,22],"TransmitTimeOffset":3938,"SerialNumberCRC":40239,"PacketCRC":58148}} 29 | {"Time":"2025-05-05T21:25:16.459564528Z","Offset":0,"Length":0,"Type":"IDM","Message":{"Preamble":1431639715,"PacketTypeID":28,"PacketLength":92,"HammingCode":198,"ApplicationVersion":4,"ERTType":7,"ERTSerialNumber":33333333,"ConsumptionIntervalCount":76,"ModuleProgrammingState":188,"TamperCounters":"AwIAcw4A","AsynchronousCounters":0,"PowerOutageFlags":"AAAAAAAA","LastConsumptionCount":1978208,"DifferentialConsumptionIntervals":[26,26,27,26,26,24,24,24,24,24,26,26,26,27,26,26,26,26,24,23,48,23,24,25,25,24,25,24,25,25,23,23,22,23,23,23,24,25,25,25,25,25,26,23,23,22,23],"TransmitTimeOffset":4096,"SerialNumberCRC":43319,"PacketCRC":38431}} 30 | {"Time":"2025-05-05T21:25:18.0592798Z","Offset":0,"Length":0,"Type":"IDM","Message":{"Preamble":1431639715,"PacketTypeID":28,"PacketLength":92,"HammingCode":198,"ApplicationVersion":4,"ERTType":7,"ERTSerialNumber":00000000,"ConsumptionIntervalCount":83,"ModuleProgrammingState":188,"TamperCounters":"AwEAgQ0A","AsynchronousCounters":0,"PowerOutageFlags":"AAAAAAAA","LastConsumptionCount":206053,"DifferentialConsumptionIntervals":[30,17,37,38,17,32,41,19,29,45,30,19,42,35,17,37,41,19,27,43,36,17,31,40,16,27,41,28,17,37,37,17,25,42,22,18,41,32,17,31,39,17,24,42,20,18,40],"TransmitTimeOffset":4091,"SerialNumberCRC":30874,"PacketCRC":37394}} 31 | {"Time":"2025-05-05T21:25:18.404352806Z","Offset":0,"Length":0,"Type":"SCM","Message":{"ID":22222222,"Type":7,"TamperPhy":0,"TamperEnc":1,"Consumption":9480653,"ChecksumVal":8042}} 32 | {"Time":"2025-05-05T21:25:19.118764497Z","Offset":0,"Length":0,"Type":"SCM","Message":{"ID":00000000,"Type":7,"TamperPhy":3,"TamperEnc":1,"Consumption":206082,"ChecksumVal":20730}} 33 | {"Time":"2025-05-05T21:25:21.825465663Z","Offset":0,"Length":0,"Type":"SCM","Message":{"ID":33333333,"Type":7,"TamperPhy":3,"TamperEnc":2,"Consumption":1978226,"ChecksumVal":60151}} 34 | {"Time":"2025-05-05T21:25:23.017740828Z","Offset":0,"Length":0,"Type":"IDM","Message":{"Preamble":1431639715,"PacketTypeID":28,"PacketLength":92,"HammingCode":198,"ApplicationVersion":4,"ERTType":7,"ERTSerialNumber":44444444,"ConsumptionIntervalCount":68,"ModuleProgrammingState":188,"TamperCounters":"BAUAdhEA","AsynchronousCounters":0,"PowerOutageFlags":"AAAAAAAA","LastConsumptionCount":9386150,"DifferentialConsumptionIntervals":[57,85,51,41,69,43,42,43,43,43,43,44,44,44,44,46,41,27,43,44,43,43,43,43,43,44,70,52,44,45,46,44,47,44,43,24,41,43,45,44,43,44,41,25,43,44,43],"TransmitTimeOffset":4204,"SerialNumberCRC":11642,"PacketCRC":3504}} 35 | {"Time":"2025-05-05T21:25:24.220854724Z","Offset":0,"Length":0,"Type":"IDM","Message":{"Preamble":1431639715,"PacketTypeID":28,"PacketLength":92,"HammingCode":198,"ApplicationVersion":4,"ERTType":7,"ERTSerialNumber":22222222,"ConsumptionIntervalCount":164,"ModuleProgrammingState":188,"TamperCounters":"hAUAbQFQ","AsynchronousCounters":0,"PowerOutageFlags":"AiEAgDAA","LastConsumptionCount":9480653,"DifferentialConsumptionIntervals":[7,6,6,6,6,6,6,6,6,5,6,5,8,7,6,8,7,7,7,6,6,6,6,5,6,5,7,6,6,6,7,6,7,8,5,6,7,6,6,7,7,6,6,6,7,6,6],"TransmitTimeOffset":4079,"SerialNumberCRC":40239,"PacketCRC":39471}} 36 | {"Time":"2025-05-05T21:25:24.741295887Z","Offset":0,"Length":0,"Type":"IDM","Message":{"Preamble":1431639715,"PacketTypeID":28,"PacketLength":92,"HammingCode":198,"ApplicationVersion":4,"ERTType":7,"ERTSerialNumber":00000000,"ConsumptionIntervalCount":83,"ModuleProgrammingState":188,"TamperCounters":"AwEAgQ0A","AsynchronousCounters":0,"PowerOutageFlags":"AAAAAAAA","LastConsumptionCount":206053,"DifferentialConsumptionIntervals":[30,17,37,38,17,32,41,19,29,45,30,19,42,35,17,37,41,19,27,43,36,17,31,40,16,27,41,28,17,37,37,17,25,42,22,18,41,32,17,31,39,17,24,42,20,18,40],"TransmitTimeOffset":4198,"SerialNumberCRC":30874,"PacketCRC":26419}} 37 | {"Time":"2025-05-05T21:25:25.826760611Z","Offset":0,"Length":0,"Type":"R900","Message":{"ID":1578829012,"Unkn1":163,"NoUse":32,"BackFlow":0,"Consumption":554416,"Unkn3":0,"Leak":2,"LeakNow":0}} 38 | {"Time":"2025-05-05T21:25:26.782067598Z","Offset":0,"Length":0,"Type":"IDM","Message":{"Preamble":1431639715,"PacketTypeID":28,"PacketLength":92,"HammingCode":198,"ApplicationVersion":4,"ERTType":7,"ERTSerialNumber":59897587,"ConsumptionIntervalCount":76,"ModuleProgrammingState":188,"TamperCounters":"QwVEdi1I","AsynchronousCounters":0,"PowerOutageFlags":"IglEBEYB","LastConsumptionCount":2691204,"DifferentialConsumptionIntervals":[49,31,31,37,50,34,39,53,31,37,47,43,43,46,45,43,49,46,44,50,66,78,46,50,55,31,32,32,32,31,32,31,31,31,32,31,31,31,32,29,29,30,29,29,29,30,29],"TransmitTimeOffset":4143,"SerialNumberCRC":51697,"PacketCRC":1042}} 39 | {"Time":"2025-05-05T21:25:27.459184873Z","Offset":0,"Length":0,"Type":"SCM","Message":{"ID":00000000,"Type":7,"TamperPhy":3,"TamperEnc":1,"Consumption":206082,"ChecksumVal":20730}} 40 | {"Time":"2025-05-05T21:25:27.722258648Z","Offset":0,"Length":0,"Type":"SCM","Message":{"ID":59897593,"Type":7,"TamperPhy":3,"TamperEnc":0,"Consumption":9233791,"ChecksumVal":17174}} 41 | {"Time":"2025-05-05T21:25:29.005225619Z","Offset":0,"Length":0,"Type":"SCM","Message":{"ID":22222222,"Type":7,"TamperPhy":0,"TamperEnc":1,"Consumption":9480659,"ChecksumVal":4878}} 42 | {"Time":"2025-05-05T21:25:29.39390036Z","Offset":0,"Length":0,"Type":"IDM","Message":{"Preamble":1431639715,"PacketTypeID":28,"PacketLength":92,"HammingCode":198,"ApplicationVersion":4,"ERTType":7,"ERTSerialNumber":33333333,"ConsumptionIntervalCount":76,"ModuleProgrammingState":188,"TamperCounters":"AwIAcw4A","AsynchronousCounters":0,"PowerOutageFlags":"AAAAAAAA","LastConsumptionCount":1978208,"DifferentialConsumptionIntervals":[26,26,27,26,26,24,24,24,24,24,26,26,26,27,26,26,26,26,24,23,48,23,24,25,25,24,25,24,25,25,23,23,22,23,23,23,24,25,25,25,25,25,26,23,23,22,23],"TransmitTimeOffset":4303,"SerialNumberCRC":43319,"PacketCRC":40153}} 43 | {"Time":"2025-05-05T21:25:30.953363523Z","Offset":0,"Length":0,"Type":"SCM","Message":{"ID":33333333,"Type":7,"TamperPhy":3,"TamperEnc":2,"Consumption":1978226,"ChecksumVal":60151}} 44 | {"Time":"2025-05-05T21:25:34.961312694Z","Offset":0,"Length":0,"Type":"IDM","Message":{"Preamble":1431639715,"PacketTypeID":28,"PacketLength":92,"HammingCode":198,"ApplicationVersion":4,"ERTType":7,"ERTSerialNumber":00000000,"ConsumptionIntervalCount":83,"ModuleProgrammingState":188,"TamperCounters":"AwEAgQUg","AsynchronousCounters":0,"PowerOutageFlags":"AAAEAAAA","LastConsumptionCount":206053,"DifferentialConsumptionIntervals":[30,17,37,38,17,32,41,19,29,45,30,19,42,35,17,37,41,19,27,43,36,17,31,40,16,27,41,28,17,37,37,17,25,42,22,18,41,32,17,31,39,17,24,42,20,18,40],"TransmitTimeOffset":4361,"SerialNumberCRC":30874,"PacketCRC":42717}} 45 | {"Time":"2025-05-05T21:25:36.340216552Z","Offset":0,"Length":0,"Type":"IDM","Message":{"Preamble":1431639715,"PacketTypeID":28,"PacketLength":92,"HammingCode":198,"ApplicationVersion":4,"ERTType":7,"ERTSerialNumber":22222222,"ConsumptionIntervalCount":164,"ModuleProgrammingState":188,"TamperCounters":"BAEAbQEA","AsynchronousCounters":0,"PowerOutageFlags":"AAAAAAAA","LastConsumptionCount":9480653,"DifferentialConsumptionIntervals":[7,6,6,6,6,6,6,6,6,5,6,5,8,7,6,8,7,7,7,6,6,6,6,5,6,5,7,6,6,6,7,6,7,8,5,6,7,6,6,7,7,6,6,6,7,6,6],"TransmitTimeOffset":4273,"SerialNumberCRC":40239,"PacketCRC":4265}}' 46 | 47 | while IFS= read -r line; do 48 | echo "$line" 49 | sleep 0.1 50 | done <<< "$header" 51 | 52 | while :; do 53 | while IFS= read -r line; do 54 | echo "$line" 55 | sleep 0.1 56 | done <<< "$reads" 57 | sleep "$((RANDOM % 10))s" 58 | done 59 | -------------------------------------------------------------------------------- /rtlamr2mqtt-addon/requirements.txt: -------------------------------------------------------------------------------- 1 | paho-mqtt==2.1.0 2 | pyyaml==6.0.2 3 | requests==2.32.3 4 | pyusb==1.3.1 5 | --------------------------------------------------------------------------------