From c385f026c321d5b00880f85292a5ed0ee52eca8e Mon Sep 17 00:00:00 2001 From: Tore Amundsen Date: Mon, 23 Dec 2024 08:53:51 +0100 Subject: [PATCH] Remove deprecated typing alias (#79) * Dev container image * From deprecated HomeAssistantType to current HomeAssistant * Changed from deprecated EventType to current Event * Min hass version * Min hass version --- .devcontainer.json | 19 +- .github/ISSUE_TEMPLATE/feature_request.md | 32 +- .github/ISSUE_TEMPLATE/issue.md | 82 +- .github/workflows/codeql-analysis.yml | 140 +- .github/workflows/cron.yaml | 38 +- .github/workflows/publish.yml | 62 +- .github/workflows/pull.yml | 58 +- .github/workflows/push.yml | 64 +- .gitignore | 54 +- .vscode/extensions.json | 10 +- .vscode/launch.json | 42 +- .vscode/settings.json | 14 +- .vscode/tasks.json | 20 +- LICENSE | 42 +- README.en.md | 110 +- README.md | 74 +- custom_components/amshan/__init__.py | 722 ++++----- custom_components/amshan/config_flow.py | 1148 +++++++------- custom_components/amshan/const.py | 96 +- custom_components/amshan/diagnostics.py | 30 +- custom_components/amshan/manifest.json | 36 +- custom_components/amshan/metercon.py | 501 ++++--- custom_components/amshan/sensor.py | 1321 ++++++++--------- custom_components/amshan/strings.json | 124 +- custom_components/amshan/translations/nb.json | 124 +- custom_components/amshan/translations/nn.json | 124 +- custom_components/amshan/translations/sv.json | 124 +- hacs.json | 4 +- requirements.txt | 6 +- setup.cfg | 66 +- 30 files changed, 2644 insertions(+), 2643 deletions(-) diff --git a/.devcontainer.json b/.devcontainer.json index 16f0a7d..eaadcaf 100644 --- a/.devcontainer.json +++ b/.devcontainer.json @@ -1,35 +1,38 @@ { "name": "AMSHAN", - "image": "mcr.microsoft.com/vscode/devcontainers/python:0-3.10-bullseye", + "image": "mcr.microsoft.com/devcontainers/python:3.13", "postCreateCommand": "scripts/setup", "forwardPorts": [ 8123 ], + "portsAttributes": { + "8123": { + "label": "Home Assistant", + "onAutoForward": "notify" + } + }, "customizations": { "vscode": { "extensions": [ - "ms-python.python", "github.vscode-pull-request-github", - "ryanluker.vscode-coverage-gutters", - "ms-python.vscode-pylance" + "ms-python.python", + "ms-python.vscode-pylance", + "ryanluker.vscode-coverage-gutters" ], "settings": { "files.eol": "\n", "editor.tabSize": 4, - "python.pythonPath": "/usr/bin/python3", "python.analysis.autoSearchPaths": false, "python.linting.pylintEnabled": true, "python.linting.enabled": true, "python.linting.flake8Enabled": true, "python.formatting.provider": "black", "python.formatting.blackPath": "/usr/local/py-utils/bin/black", + "python.defaultInterpreterPath": "/usr/local/bin/python", "editor.formatOnPaste": false, "editor.formatOnSave": true, "editor.formatOnType": true, "files.trimTrailingWhitespace": true - }, - "remoteEnv": { - "PATH": "${containerEnv:PATH}:/root/.local/bin/", } } }, diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index 6bcce42..507f06e 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -1,17 +1,17 @@ ---- -name: Feature request -about: Suggest an idea for this project - ---- - -**Is your feature request related to a problem? Please describe.** -A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] - -**Describe the solution you'd like** -A clear and concise description of what you want to happen. - -**Describe alternatives you've considered** -A clear and concise description of any alternative solutions or features you've considered. - -**Additional context** +--- +name: Feature request +about: Suggest an idea for this project + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** Add any other context or screenshots about the feature request here. \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/issue.md b/.github/ISSUE_TEMPLATE/issue.md index bbd0345..70f3d5b 100644 --- a/.github/ISSUE_TEMPLATE/issue.md +++ b/.github/ISSUE_TEMPLATE/issue.md @@ -1,42 +1,42 @@ ---- -name: Issue -about: Create a report to help us improve - ---- - - - -## Version of the custom_component - - -## Configuration - -```yaml - -Add your logs here. - -``` - -## Describe the bug -A clear and concise description of what the bug is. - - -## Debug log - - - -```text - -Add your logs here. - +--- +name: Issue +about: Create a report to help us improve + +--- + + + +## Version of the custom_component + + +## Configuration + +```yaml + +Add your logs here. + +``` + +## Describe the bug +A clear and concise description of what the bug is. + + +## Debug log + + + +```text + +Add your logs here. + ``` \ No newline at end of file diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 4db1b08..0036905 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -1,70 +1,70 @@ -# For most projects, this workflow file will not need changing; you simply need -# to commit it to your repository. -# -# You may wish to alter this file to override the set of languages analyzed, -# or to provide custom queries or build logic. -# -# ******** NOTE ******** -# We have attempted to detect the languages in your repository. Please check -# the `language` matrix defined below to confirm you have the correct set of -# supported CodeQL languages. -# -name: "CodeQL" - -on: - push: - branches: [ master ] - pull_request: - # The branches below must be a subset of the branches above - branches: [ master ] - schedule: - - cron: '25 21 * * 0' - -jobs: - analyze: - name: Analyze - runs-on: ubuntu-latest - permissions: - actions: read - contents: read - security-events: write - - strategy: - fail-fast: false - matrix: - language: [ 'python' ] - # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] - # Learn more about CodeQL language support at https://git.io/codeql-language-support - - steps: - - name: Checkout repository - uses: actions/checkout@v2 - - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v2 - with: - languages: ${{ matrix.language }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. - # queries: ./path/to/local/query, your-org/your-repo/queries@main - - # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). - # If this step fails, then you should remove it and run the build manually (see below) - - name: Autobuild - uses: github/codeql-action/autobuild@v2 - - # ℹ️ Command-line programs to run using the OS shell. - # 📚 https://git.io/JvXDl - - # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines - # and modify them (or add more) to build your code if your project - # uses a compiled language - - #- run: | - # make bootstrap - # make release - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL" + +on: + push: + branches: [ master ] + pull_request: + # The branches below must be a subset of the branches above + branches: [ master ] + schedule: + - cron: '25 21 * * 0' + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: [ 'python' ] + # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] + # Learn more about CodeQL language support at https://git.io/codeql-language-support + + steps: + - name: Checkout repository + uses: actions/checkout@v2 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v2 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + # queries: ./path/to/local/query, your-org/your-repo/queries@main + + # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@v2 + + # ℹ️ Command-line programs to run using the OS shell. + # 📚 https://git.io/JvXDl + + # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines + # and modify them (or add more) to build your code if your project + # uses a compiled language + + #- run: | + # make bootstrap + # make release + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v2 diff --git a/.github/workflows/cron.yaml b/.github/workflows/cron.yaml index 933e863..a463e96 100644 --- a/.github/workflows/cron.yaml +++ b/.github/workflows/cron.yaml @@ -1,20 +1,20 @@ -name: Cron actions - -on: - schedule: - - cron: '0 0 * * *' - -jobs: - validate: - runs-on: "ubuntu-latest" - name: Validate - steps: - - uses: "actions/checkout@v2" - - - name: HACS validation - uses: "hacs/action@main" - with: - category: "integration" - - - name: Hassfest validation +name: Cron actions + +on: + schedule: + - cron: '0 0 * * *' + +jobs: + validate: + runs-on: "ubuntu-latest" + name: Validate + steps: + - uses: "actions/checkout@v2" + + - name: HACS validation + uses: "hacs/action@main" + with: + category: "integration" + + - name: Hassfest validation uses: "home-assistant/actions/hassfest@master" \ No newline at end of file diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index eea2e3f..d56b6ce 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,31 +1,31 @@ -name: Publish - -on: - release: - types: [published] - push: - branches: [main] - -jobs: - release_zip_file: - name: Publish amshan.zip file asset - runs-on: ubuntu-latest - steps: - - name: Check out repository - uses: actions/checkout@v2 - - - name: ZIP amshan folder - if: ${{ github.event_name == 'release' }} - run: | - cd ${{ github.workspace }}/custom_components/amshan - zip amshan.zip -r ./ - - - name: Upload zip to release - uses: svenstaro/upload-release-action@v1-release - if: ${{ github.event_name == 'release' }} - with: - repo_token: ${{ secrets.GITHUB_TOKEN }} - file: ${{ github.workspace }}/custom_components/amshan/amshan.zip - asset_name: amshan.zip - tag: ${{ github.ref }} - overwrite: true +name: Publish + +on: + release: + types: [published] + push: + branches: [main] + +jobs: + release_zip_file: + name: Publish amshan.zip file asset + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v2 + + - name: ZIP amshan folder + if: ${{ github.event_name == 'release' }} + run: | + cd ${{ github.workspace }}/custom_components/amshan + zip amshan.zip -r ./ + + - name: Upload zip to release + uses: svenstaro/upload-release-action@v1-release + if: ${{ github.event_name == 'release' }} + with: + repo_token: ${{ secrets.GITHUB_TOKEN }} + file: ${{ github.workspace }}/custom_components/amshan/amshan.zip + asset_name: amshan.zip + tag: ${{ github.ref }} + overwrite: true diff --git a/.github/workflows/pull.yml b/.github/workflows/pull.yml index d037a9f..b52f322 100644 --- a/.github/workflows/pull.yml +++ b/.github/workflows/pull.yml @@ -1,30 +1,30 @@ -name: Pull actions - -on: - pull_request: - -jobs: - validate: - runs-on: "ubuntu-latest" - name: Validate - steps: - - uses: "actions/checkout@v2" - - - name: HACS validation - uses: "hacs/action@main" - with: - category: "integration" - - - name: Hassfest validation - uses: "home-assistant/actions/hassfest@master" - - style: - runs-on: "ubuntu-latest" - name: Check style formatting - steps: - - uses: "actions/checkout@v2" - - uses: "actions/setup-python@v1" - with: - python-version: "3.x" - - run: python3 -m pip install black +name: Pull actions + +on: + pull_request: + +jobs: + validate: + runs-on: "ubuntu-latest" + name: Validate + steps: + - uses: "actions/checkout@v2" + + - name: HACS validation + uses: "hacs/action@main" + with: + category: "integration" + + - name: Hassfest validation + uses: "home-assistant/actions/hassfest@master" + + style: + runs-on: "ubuntu-latest" + name: Check style formatting + steps: + - uses: "actions/checkout@v2" + - uses: "actions/setup-python@v1" + with: + python-version: "3.x" + - run: python3 -m pip install black - run: black . \ No newline at end of file diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index bb539b1..a7addc0 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -1,33 +1,33 @@ -name: Push actions - -on: - push: - branches: - - master - - dev - -jobs: - validate: - runs-on: "ubuntu-latest" - name: Validate - steps: - - uses: "actions/checkout@v2" - - - name: HACS validation - uses: "hacs/action@main" - with: - category: "integration" - - - name: Hassfest validation - uses: "home-assistant/actions/hassfest@master" - - style: - runs-on: "ubuntu-latest" - name: Check style formatting - steps: - - uses: "actions/checkout@v2" - - uses: "actions/setup-python@v1" - with: - python-version: "3.x" - - run: python3 -m pip install black +name: Push actions + +on: + push: + branches: + - master + - dev + +jobs: + validate: + runs-on: "ubuntu-latest" + name: Validate + steps: + - uses: "actions/checkout@v2" + + - name: HACS validation + uses: "hacs/action@main" + with: + category: "integration" + + - name: Hassfest validation + uses: "home-assistant/actions/hassfest@master" + + style: + runs-on: "ubuntu-latest" + name: Check style formatting + steps: + - uses: "actions/checkout@v2" + - uses: "actions/setup-python@v1" + with: + python-version: "3.x" + - run: python3 -m pip install black - run: black . \ No newline at end of file diff --git a/.gitignore b/.gitignore index 91bf093..e37c807 100644 --- a/.gitignore +++ b/.gitignore @@ -1,28 +1,28 @@ -# artifacts -__pycache__ -.pytest* -*.egg-info -*/build/* -*/dist/* - - -# misc -.coverage -.vscode -coverage.xml -.DS_Store - -# Home Assistant configuration -.cloud -.HA_VERSION -.storage -automations.yaml -blueprints -configuration.yaml -deps -home-assistant_v2* -home-assistant.log* -tts -scenes.yaml -scripts.yaml +# artifacts +__pycache__ +.pytest* +*.egg-info +*/build/* +*/dist/* + + +# misc +.coverage +.vscode +coverage.xml +.DS_Store + +# Home Assistant configuration +.cloud +.HA_VERSION +.storage +automations.yaml +blueprints +configuration.yaml +deps +home-assistant_v2* +home-assistant.log* +tts +scenes.yaml +scripts.yaml secrets.yaml \ No newline at end of file diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 2329e76..96415ed 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,6 +1,6 @@ -{ - "recommendations": [ - "esbenp.prettier-vscode", - "ms-python.python" - ] +{ + "recommendations": [ + "esbenp.prettier-vscode", + "ms-python.python" + ] } \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json index e5ec564..4b209b7 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,22 +1,22 @@ -{ - // Use IntelliSense to learn about possible attributes. - // Hover to view descriptions of existing attributes. - // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 - "version": "0.2.0", - "configurations": [ - { - "name": "Python: Remote Attach", - "type": "python", - "request": "attach", - "justMyCode": false, - "port": 5678, - "host": "localhost", - "pathMappings": [ - { - "localRoot": "${workspaceFolder}", - "remoteRoot": "." - } - ] - } - ] +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Python: Remote Attach", + "type": "python", + "request": "attach", + "justMyCode": false, + "port": 5678, + "host": "localhost", + "pathMappings": [ + { + "localRoot": "${workspaceFolder}", + "remoteRoot": "." + } + ] + } + ] } \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 770349b..da32ef2 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,8 +1,8 @@ -{ - "[python]": { - "editor.codeActionsOnSave": { - "source.organizeImports": true - } - }, - "python.pythonPath": "/usr/local/bin/python3.9", +{ + "[python]": { + "editor.codeActionsOnSave": { + "source.organizeImports": "explicit" + } + }, + "python.pythonPath": "/usr/local/bin/python3.9", } \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 3aa1c50..54ba9c4 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -1,11 +1,11 @@ -{ - "version": "2.0.0", - "tasks": [ - { - "label": "Run Home Assistant on port 8123", - "type": "shell", - "command": "scripts/develop", - "problemMatcher": [] - } - ] +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Run Home Assistant on port 8123", + "type": "shell", + "command": "scripts/develop", + "problemMatcher": [] + } + ] } \ No newline at end of file diff --git a/LICENSE b/LICENSE index b0a295e..2fb4c32 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,21 @@ -MIT License - -Copyright (c) 2020 Tore Amundsen - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +MIT License + +Copyright (c) 2020 Tore Amundsen + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.en.md b/README.en.md index a24ae06..7171cf3 100644 --- a/README.en.md +++ b/README.en.md @@ -1,55 +1,55 @@ -[![GitHub Release](https://img.shields.io/github/release/toreamun/amshan-homeassistant?style=for-the-badge)](https://github.com/toreamun/amshan-homeassistant/releases) -[![Language grade: Python](https://img.shields.io/lgtm/grade/python/g/toreamun/amshan-homeassistant.svg?logo=lgtm&logoWidth=18&style=for-the-badge)](https://lgtm.com/projects/g/toreamun/amshan-homeassistant/context:python) -[![License](https://img.shields.io/github/license/toreamun/amshan-homeassistant?style=for-the-badge)](LICENSE) - -[![hacs_badge](https://img.shields.io/badge/HACS-Default-orange.svg?style=for-the-badge)](https://github.com/custom-components/hacs) -![Project Maintenance](https://img.shields.io/badge/maintainer-Tore%20Amundsen%20%40toreamun-blue.svg?style=for-the-badge) -[![buy me a coffee](https://img.shields.io/badge/If%20you%20like%20it-Buy%20me%20a%20coffee-orange.svg?style=for-the-badge)](https://www.buymeacoffee.com/toreamun) - -# AMS HAN Home Assistant integration - -**THIS PAGE IS OUTDATED** - -Integrate HAN-port of Aidon, Kaifa and Kamstrum meters used in Norway with Home Assistant. The integration uses [local push](https://www.home-assistant.io/blog/2016/02/12/classifying-the-internet-of-things/), and Home Assistant will be notified as soon as a new measurement is available (2 sec, 10 sec and every hour depending on sensor type and meter type). - -This integration supports connecting to [M-BUS](https://en.wikipedia.org/wiki/Meter-Bus) (also called Meter-Bus) device using serial port (often USB) or TCP-IP address/port. - -![image](https://user-images.githubusercontent.com/12134766/145044580-4c072af7-2bdf-4b6c-894c-38d5789ba9be.png) - -## Connecting M-BUS device - -You need to have a M-BUS slave device connected to to the HAN (Home Area Network) port of your meter. The HAN-port is a RJ45 socket where only pin 1 and 2 is used. Connect wires from pin 1 and 2 to the M-BUS slave device. Then connect the M-BUS device to your computer. Most USB devices become a serial device when connected. You can then relay (i.e. using net2ser and socat) the signal to TCP/IP if your device is connected to a remote computer. - -## M-BUS device - -This integration has been tested with several simple USB devices sold on e-bay. Search for M-BUS USB slave. Note that some devices uses EVEN parity (default is ODD) when connecting. - -## Setup - -Search for AMSHAN on Configuration/Integrations page after installing (most simple is to use [HACS](https://hacs.xyz/)). -Please not that some M-BUS serial devices uses EVEN parity (the default is ODD). - -When using serial device setup, it is often usefull to use a device-by-id device name on Linux to have a stable device name. You then use a device name starting with /dev/serial/by-id/. You can find the device id in hardware menu of the host if you are running Hassio (select Supervisor -> System -> Host -> ... -> Hardware). -![image](https://user-images.githubusercontent.com/12134766/145182598-d3fa3e7b-2784-4f6a-9aed-b90c66de20fa.png) - -## Options - -It is possible to configure a scale factor of currents, power and energy measurements. Some meters are known to not be connected directly to main power, but through a current transformer with a reduction factor of 50. In that case you can use the scale factor to get correct values. - -## Remote M-BUS - -You can connect to a remote M-BUS device using TCP/IP by selecting connection type "network" in setup. - -If your device is connected to a Linux host, then [ser2net](https://github.com/cminyard/ser2net) is a good choice to run on the host to bridge the typical M-BUS serial interface to TCP/IP. This [ser2net](https://github.com/cminyard/ser2net) config (/etc/ser2net.conf) line makes a 2400 baud, even parity, 8 data bits and one stop bit serial M-BUS device available on TCP/IP port 3001: -`3001:raw:600:/dev/ttyUSB0:2400 8DATABITS EVEN 1STOPBIT` - -Similar for [ser2net](https://github.com/cminyard/ser2net) yaml config (/etc/ser2net.yaml): - -``` -connection: &han - accepter: tcp,3001 - enable: on - options: - kickolduser: true - connector: serialdev,/dev/ttyUSB0,2400e81,local -``` +[![GitHub Release](https://img.shields.io/github/release/toreamun/amshan-homeassistant?style=for-the-badge)](https://github.com/toreamun/amshan-homeassistant/releases) +[![Language grade: Python](https://img.shields.io/lgtm/grade/python/g/toreamun/amshan-homeassistant.svg?logo=lgtm&logoWidth=18&style=for-the-badge)](https://lgtm.com/projects/g/toreamun/amshan-homeassistant/context:python) +[![License](https://img.shields.io/github/license/toreamun/amshan-homeassistant?style=for-the-badge)](LICENSE) + +[![hacs_badge](https://img.shields.io/badge/HACS-Default-orange.svg?style=for-the-badge)](https://github.com/custom-components/hacs) +![Project Maintenance](https://img.shields.io/badge/maintainer-Tore%20Amundsen%20%40toreamun-blue.svg?style=for-the-badge) +[![buy me a coffee](https://img.shields.io/badge/If%20you%20like%20it-Buy%20me%20a%20coffee-orange.svg?style=for-the-badge)](https://www.buymeacoffee.com/toreamun) + +# AMS HAN Home Assistant integration + +**THIS PAGE IS OUTDATED** + +Integrate HAN-port of Aidon, Kaifa and Kamstrum meters used in Norway with Home Assistant. The integration uses [local push](https://www.home-assistant.io/blog/2016/02/12/classifying-the-internet-of-things/), and Home Assistant will be notified as soon as a new measurement is available (2 sec, 10 sec and every hour depending on sensor type and meter type). + +This integration supports connecting to [M-BUS](https://en.wikipedia.org/wiki/Meter-Bus) (also called Meter-Bus) device using serial port (often USB) or TCP-IP address/port. + +![image](https://user-images.githubusercontent.com/12134766/145044580-4c072af7-2bdf-4b6c-894c-38d5789ba9be.png) + +## Connecting M-BUS device + +You need to have a M-BUS slave device connected to to the HAN (Home Area Network) port of your meter. The HAN-port is a RJ45 socket where only pin 1 and 2 is used. Connect wires from pin 1 and 2 to the M-BUS slave device. Then connect the M-BUS device to your computer. Most USB devices become a serial device when connected. You can then relay (i.e. using net2ser and socat) the signal to TCP/IP if your device is connected to a remote computer. + +## M-BUS device + +This integration has been tested with several simple USB devices sold on e-bay. Search for M-BUS USB slave. Note that some devices uses EVEN parity (default is ODD) when connecting. + +## Setup + +Search for AMSHAN on Configuration/Integrations page after installing (most simple is to use [HACS](https://hacs.xyz/)). +Please not that some M-BUS serial devices uses EVEN parity (the default is ODD). + +When using serial device setup, it is often usefull to use a device-by-id device name on Linux to have a stable device name. You then use a device name starting with /dev/serial/by-id/. You can find the device id in hardware menu of the host if you are running Hassio (select Supervisor -> System -> Host -> ... -> Hardware). +![image](https://user-images.githubusercontent.com/12134766/145182598-d3fa3e7b-2784-4f6a-9aed-b90c66de20fa.png) + +## Options + +It is possible to configure a scale factor of currents, power and energy measurements. Some meters are known to not be connected directly to main power, but through a current transformer with a reduction factor of 50. In that case you can use the scale factor to get correct values. + +## Remote M-BUS + +You can connect to a remote M-BUS device using TCP/IP by selecting connection type "network" in setup. + +If your device is connected to a Linux host, then [ser2net](https://github.com/cminyard/ser2net) is a good choice to run on the host to bridge the typical M-BUS serial interface to TCP/IP. This [ser2net](https://github.com/cminyard/ser2net) config (/etc/ser2net.conf) line makes a 2400 baud, even parity, 8 data bits and one stop bit serial M-BUS device available on TCP/IP port 3001: +`3001:raw:600:/dev/ttyUSB0:2400 8DATABITS EVEN 1STOPBIT` + +Similar for [ser2net](https://github.com/cminyard/ser2net) yaml config (/etc/ser2net.yaml): + +``` +connection: &han + accepter: tcp,3001 + enable: on + options: + kickolduser: true + connector: serialdev,/dev/ttyUSB0,2400e81,local +``` diff --git a/README.md b/README.md index 2524de7..6415d96 100644 --- a/README.md +++ b/README.md @@ -1,37 +1,37 @@ -logo - -[![GitHub Release](https://img.shields.io/github/release/toreamun/amshan-homeassistant?style=for-the-badge)](https://github.com/toreamun/amshan-homeassistant/releases) -[![License](https://img.shields.io/github/license/toreamun/amshan-homeassistant?style=for-the-badge)](LICENSE) - -[![hacs_badge](https://img.shields.io/badge/HACS-Default-orange.svg?style=for-the-badge)](https://github.com/hacs/integration) -![Project Maintenance](https://img.shields.io/badge/maintainer-Tore%20Amundsen%20%40toreamun-blue.svg?style=for-the-badge) -[![buy me a coffee](https://img.shields.io/badge/If%20you%20like%20it-Buy%20me%20a%20coffee-orange.svg?style=for-the-badge)](https://www.buymeacoffee.com/toreamun) - -[English](README.en.md) - -# AMS HAN Home Assistant integrasjon - -Home Assistant integrasjon for norske og svenske strømmålere. Både DLMS og P1 fortmater støttes. Integrasjonen skal i prinsippet fungere med alle typer leserer som videresender datastrømmen fra måleren direkte ([serieport/TCP-IP](https://github.com/toreamun/amshan-homeassistant/wiki/Lesere-serieport-og-nettverk)) eller oppdelt som [meldinger til MQTT](https://github.com/toreamun/amshan-homeassistant/wiki/Lesere-MQTT). Noen aktuelle lesere er: -| Leser | stream/MQTT | DLMS/P1 |Land| -| ------------------------------------------------------------------------------------------------- | ----------- | ---------- |--| -| [Tibber Pulse](https://github.com/toreamun/amshan-homeassistant/wiki/Lesere-MQTT#tibber-pulse) | MQTT | DLMS og P1 | NO, SE| -| [energyintelligence.se P1 elmätaravläsare](https://github.com/toreamun/amshan-homeassistant/wiki/Lesere-MQTT#energyintelligencese-p1-elm%C3%A4taravl%C3%A4sare) | MQTT | P1 | SE | -| [AmsToMqttBridge og amsleser.no](https://github.com/toreamun/amshan-homeassistant/wiki/Lesere-MQTT#amstomqttbridge-og-amsleserno) [ver 2.1](https://github.com/gskjold/AmsToMqttBridge/milestone/22) | MQTT | DLMS | NO, SE? | -| [M-BUS slave](https://github.com/toreamun/amshan-homeassistant/wiki/Lesere-serieport-og-nettverk#m-bus-enhet) | stream | DLMS | NO, SE | -| [Oss brikken](https://github.com/toreamun/amshan-homeassistant/wiki/Lesere-serieport-og-nettverk#oss-brikken) | stream | DLMS | NO | - -Integrasjonen benytter [local push](https://www.home-assistant.io/blog/2016/02/12/classifying-the-internet-of-things/), og Home Assistant blir derfor oppdatert umiddelbart etter at måleren har sendt ut nye data på porten (2 sekund, 10 sekund, og hver hele time, avhengig av informasjonselement og målertype). Flere målere kan være tilknyttet samme Home Assistant installasjon. - -**Se [Wiki](https://github.com/toreamun/amshan-homeassistant/wiki/) for installasjon og tips.** - -# Home assistant muligheter - -![image](https://user-images.githubusercontent.com/12134766/150297088-535246b1-bd95-406c-8f52-6007a6220e6d.png) - -Totalmålingene for import og eksport (aktuelt hvis du produserer strøm) kan kobles til [Home Assistant Energy](https://www.home-assistant.io/blog/2021/08/04/home-energy-management/): - -![image](https://user-images.githubusercontent.com/12134766/150021268-28f01386-0583-4f76-9b78-b35882d2019e.png) - -Sensorene for spenning og forbruk kan man f.eks selv sette opp i Home Assistant som sensor ("speedometer") eller historikk (graf). - -![image](https://user-images.githubusercontent.com/12134766/150298075-d21617c5-7e17-44b3-8fc7-196af9f22f08.png) +logo + +[![GitHub Release](https://img.shields.io/github/release/toreamun/amshan-homeassistant?style=for-the-badge)](https://github.com/toreamun/amshan-homeassistant/releases) +[![License](https://img.shields.io/github/license/toreamun/amshan-homeassistant?style=for-the-badge)](LICENSE) + +[![hacs_badge](https://img.shields.io/badge/HACS-Default-orange.svg?style=for-the-badge)](https://github.com/hacs/integration) +![Project Maintenance](https://img.shields.io/badge/maintainer-Tore%20Amundsen%20%40toreamun-blue.svg?style=for-the-badge) +[![buy me a coffee](https://img.shields.io/badge/If%20you%20like%20it-Buy%20me%20a%20coffee-orange.svg?style=for-the-badge)](https://www.buymeacoffee.com/toreamun) + +[English](README.en.md) + +# AMS HAN Home Assistant integrasjon + +Home Assistant integrasjon for norske og svenske strømmålere. Både DLMS og P1 fortmater støttes. Integrasjonen skal i prinsippet fungere med alle typer leserer som videresender datastrømmen fra måleren direkte ([serieport/TCP-IP](https://github.com/toreamun/amshan-homeassistant/wiki/Lesere-serieport-og-nettverk)) eller oppdelt som [meldinger til MQTT](https://github.com/toreamun/amshan-homeassistant/wiki/Lesere-MQTT). Noen aktuelle lesere er: +| Leser | stream/MQTT | DLMS/P1 |Land| +| ------------------------------------------------------------------------------------------------- | ----------- | ---------- |--| +| [Tibber Pulse](https://github.com/toreamun/amshan-homeassistant/wiki/Lesere-MQTT#tibber-pulse) | MQTT | DLMS og P1 | NO, SE| +| [energyintelligence.se P1 elmätaravläsare](https://github.com/toreamun/amshan-homeassistant/wiki/Lesere-MQTT#energyintelligencese-p1-elm%C3%A4taravl%C3%A4sare) | MQTT | P1 | SE | +| [AmsToMqttBridge og amsleser.no](https://github.com/toreamun/amshan-homeassistant/wiki/Lesere-MQTT#amstomqttbridge-og-amsleserno) [ver 2.1](https://github.com/gskjold/AmsToMqttBridge/milestone/22) | MQTT | DLMS | NO, SE? | +| [M-BUS slave](https://github.com/toreamun/amshan-homeassistant/wiki/Lesere-serieport-og-nettverk#m-bus-enhet) | stream | DLMS | NO, SE | +| [Oss brikken](https://github.com/toreamun/amshan-homeassistant/wiki/Lesere-serieport-og-nettverk#oss-brikken) | stream | DLMS | NO | + +Integrasjonen benytter [local push](https://www.home-assistant.io/blog/2016/02/12/classifying-the-internet-of-things/), og Home Assistant blir derfor oppdatert umiddelbart etter at måleren har sendt ut nye data på porten (2 sekund, 10 sekund, og hver hele time, avhengig av informasjonselement og målertype). Flere målere kan være tilknyttet samme Home Assistant installasjon. + +**Se [Wiki](https://github.com/toreamun/amshan-homeassistant/wiki/) for installasjon og tips.** + +# Home assistant muligheter + +![image](https://user-images.githubusercontent.com/12134766/150297088-535246b1-bd95-406c-8f52-6007a6220e6d.png) + +Totalmålingene for import og eksport (aktuelt hvis du produserer strøm) kan kobles til [Home Assistant Energy](https://www.home-assistant.io/blog/2021/08/04/home-energy-management/): + +![image](https://user-images.githubusercontent.com/12134766/150021268-28f01386-0583-4f76-9b78-b35882d2019e.png) + +Sensorene for spenning og forbruk kan man f.eks selv sette opp i Home Assistant som sensor ("speedometer") eller historikk (graf). + +![image](https://user-images.githubusercontent.com/12134766/150298075-d21617c5-7e17-44b3-8fc7-196af9f22f08.png) diff --git a/custom_components/amshan/__init__.py b/custom_components/amshan/__init__.py index 6e83049..9cbe3be 100644 --- a/custom_components/amshan/__init__.py +++ b/custom_components/amshan/__init__.py @@ -1,361 +1,361 @@ -"""The AMS HAN meter integration.""" -from __future__ import annotations - -import asyncio -from dataclasses import dataclass -import datetime as dt -from enum import Enum -import logging -from typing import Callable, Mapping, cast - -from han import common as han_type, meter_connection, obis_map -from homeassistant import const as ha_const -from homeassistant.components import sensor as ha_sensor -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import CALLBACK_TYPE, callback -from homeassistant.helpers import entity_registry -from homeassistant.helpers.typing import ConfigType, EventType, HomeAssistantType - -from .const import ( - CONF_CONNECTION_CONFIG, - CONF_CONNECTION_TYPE, - CONF_MQTT_TOPICS, - CONF_TCP_HOST, - DOMAIN, -) -from .metercon import async_setup_meter_mqtt_subscriptions, setup_meter_connection - -_LOGGER: logging.Logger = logging.getLogger(__name__) - -PLATFORM_TYPE = ha_const.Platform.SENSOR - - -class ConnectionType(Enum): - """Meter connection type.""" - - SERIAL = "serial" - NETWORK = "network_tcpip" - MQTT = "hass_mqtt" - - -class AmsHanIntegration: - """AMS HAN integration.""" - - def __init__(self) -> None: - """Initialize AmsHanIntegration.""" - self._connection_manager: meter_connection.ConnectionManager | None = None - self._mqtt_unsubscribe: CALLBACK_TYPE | None = None - self._listeners: list[CALLBACK_TYPE] = [] - self._tasks: list[asyncio.Task] = [] - self.measure_queue: asyncio.Queue[han_type.MeterMessageBase] = asyncio.Queue() - - async def async_setup_receiver( - self, hass: HomeAssistantType, config_data: Mapping - ) -> None: - """Set up MQTT or serial/tcp-ip receiver.""" - connection_type = ConnectionType(config_data[CONF_CONNECTION_TYPE]) - if ConnectionType.MQTT == connection_type: - self._mqtt_unsubscribe = await async_setup_meter_mqtt_subscriptions( - hass, - config_data[CONF_CONNECTION_CONFIG], - self.measure_queue, - ) - else: - manager = setup_meter_connection( - hass.loop, - config_data[CONF_CONNECTION_CONFIG], - self.measure_queue, - ) - hass.loop.create_task(manager.connect_loop()) - self._connection_manager = manager - - _LOGGER.debug("Configured %s receiver.", connection_type) - - def add_listener(self, listener_unsubscribe: CALLBACK_TYPE) -> None: - """Add listener to be removed on unload.""" - self._listeners.append(listener_unsubscribe) - - def add_task(self, task: asyncio.Task) -> None: - """Add task to be cancelled on close/unload.""" - self._tasks.append(task) - - async def async_close_all(self) -> None: - """Stop receive, unsubscribe listeners and cancel tasks.""" - self.stop_receive() - - for unsub in self._listeners: - unsub() - self._listeners.clear() - - for task in self._tasks: - task.cancel() - await asyncio.gather(*self._tasks) - self._tasks.clear() - - def stop_receive(self) -> None: - """Stop receivers (serial/tcp-ip and/or MQTT.""" - # signal processor to exit processing loop by sending empty bytes on the queue - self.measure_queue.put_nowait(StopMessage()) - - if self._connection_manager: - self._connection_manager.close() - self._connection_manager = None - if self._mqtt_unsubscribe: - self._mqtt_unsubscribe() - self._mqtt_unsubscribe = None - - -async def async_setup(hass: HomeAssistantType, _: ConfigType) -> bool: - """Set up the amshan component.""" - hass.data[DOMAIN] = {} - return True - - -async def async_setup_entry(hass: HomeAssistantType, config_entry: ConfigEntry) -> bool: - """Set up amshan from a config entry.""" - integration = AmsHanIntegration() - - await integration.async_setup_receiver(hass, config_entry.data) - - # Listen for Home Assistant stop event - @callback - async def on_hass_stop(event: EventType) -> None: - _LOGGER.debug("%s received. Close down integration.", event.event_type) - integration.stop_receive() - - integration.add_listener( - hass.bus.async_listen_once(ha_const.EVENT_HOMEASSISTANT_STOP, on_hass_stop) - ) - - # Listen for config entry changes and reload when changed. - integration.add_listener( - config_entry.add_update_listener(async_config_entry_changed) - ) - - hass.data[DOMAIN][config_entry.entry_id] = integration - await hass.config_entries.async_forward_entry_setup(config_entry, PLATFORM_TYPE) - - _LOGGER.debug("async_setup_entry complete.") - - return True - - -async def async_migrate_config_entry( - hass: HomeAssistantType, config_entry: ConfigEntry -) -> bool: - """Migrate config when ConfigFlow version has changed.""" - initial_version = config_entry.version - current_data = config_entry.data - _LOGGER.debug("Check for config entry migration of version %d", initial_version) - - if config_entry.version == 1: - await _async_migrate_entries( - hass, config_entry.entry_id, _migrate_entity_entry_from_v1_to_v2 - ) - config_entry.version = 2 - current_data = { - CONF_CONNECTION_TYPE: ( - ConnectionType.MQTT - if CONF_MQTT_TOPICS in config_entry.data - else ConnectionType.NETWORK - if CONF_TCP_HOST in config_entry.data - else ConnectionType.SERIAL - ).value, - CONF_CONNECTION_CONFIG: {**current_data}, - } - _LOGGER.debug("Config entry migrated to version 2") - - if config_entry.version == 2: - config_entry.version = 3 - await _async_migrate_entries( - hass, config_entry.entry_id, _migrate_entity_entry_from_v2_to_v3 - ) - _LOGGER.debug("Config entry migrated to version 3") - - hass.config_entries.async_update_entry(config_entry, data=current_data) - _LOGGER.debug( - "Config entry migration from %d to %d successfull.", - initial_version, - config_entry.version, - ) - - return True - - -async def async_unload_entry( - hass: HomeAssistantType, config_entry: ConfigEntry -) -> bool: - """Handle removal of an entry.""" - is_plaform_unload_success = await hass.config_entries.async_forward_entry_unload( - config_entry, PLATFORM_TYPE - ) - - if is_plaform_unload_success: - _LOGGER.info("Integrations is unloading.") - ctx: AmsHanIntegration = hass.data[DOMAIN].pop(config_entry.entry_id) - await ctx.async_close_all() - - return is_plaform_unload_success - - -@callback -async def async_config_entry_changed( - hass: HomeAssistantType, config_entry: ConfigEntry -): - """Handle config entry changed callback.""" - _LOGGER.info("Config entry has changed. Reload integration.") - await hass.config_entries.async_reload(config_entry.entry_id) - - -def _migrate_entity_entry_from_v1_to_v2(entity: entity_registry.RegistryEntry): - def replace_ending(source, old, new): - if source.endswith(old): - return source[: -len(old)] + new - return source - - update = {} - if entity.unique_id.endswith("_hour"): - new_unique_id = replace_ending(entity.unique_id, "_hour", "_total") - _LOGGER.info("Migrate unique_id from %s to %s", entity.unique_id, new_unique_id) - update["new_unique_id"] = new_unique_id - return update - - -def _migrate_entity_entry_from_v2_to_v3(entity: entity_registry.RegistryEntry): - update = {} - - v3_migrate_fields = [ - obis_map.FIELD_METER_ID, - obis_map.FIELD_METER_MANUFACTURER, - obis_map.FIELD_METER_TYPE, - obis_map.FIELD_OBIS_LIST_VER_ID, - obis_map.FIELD_ACTIVE_POWER_IMPORT, - obis_map.FIELD_ACTIVE_POWER_EXPORT, - obis_map.FIELD_REACTIVE_POWER_IMPORT, - obis_map.FIELD_REACTIVE_POWER_EXPORT, - obis_map.FIELD_CURRENT_L1, - obis_map.FIELD_CURRENT_L2, - obis_map.FIELD_CURRENT_L3, - obis_map.FIELD_VOLTAGE_L1, - obis_map.FIELD_VOLTAGE_L2, - obis_map.FIELD_VOLTAGE_L3, - obis_map.FIELD_ACTIVE_POWER_IMPORT_TOTAL, - obis_map.FIELD_ACTIVE_POWER_EXPORT_TOTAL, - obis_map.FIELD_REACTIVE_POWER_IMPORT_TOTAL, - obis_map.FIELD_REACTIVE_POWER_EXPORT_TOTAL, - ] - - for measure_id in v3_migrate_fields: - if entity.unique_id.endswith(f"-{measure_id}"): - manufacturer = entity.unique_id[: entity.unique_id.find("-")] - new_entity_id = f"sensor.{manufacturer}_{measure_id}".lower() - if new_entity_id != entity.entity_id: - update["new_entity_id"] = new_entity_id - _LOGGER.info( - "Migrate entity_id from %s to %s", - entity.entity_id, - new_entity_id, - ) - - if measure_id in ( - obis_map.FIELD_REACTIVE_POWER_IMPORT, - obis_map.FIELD_REACTIVE_POWER_EXPORT, - ): - update["device_class"] = ha_sensor.SensorDeviceClass.REACTIVE_POWER - update["unit_of_measurement"] = ha_const.POWER_VOLT_AMPERE_REACTIVE - _LOGGER.info( - "Migrated %s to device class %s with unit %s", - entity.unique_id, - ha_sensor.SensorDeviceClass.REACTIVE_POWER, - ha_const.POWER_VOLT_AMPERE_REACTIVE, - ) - - break - - return update - - -async def _async_migrate_entries( - hass: HomeAssistantType, - config_entry_id: str, - entry_callback: Callable[[entity_registry.RegistryEntry], dict | None], -) -> None: - ent_reg = await entity_registry.async_get_registry(hass) - - # Workaround: - # entity_registry.async_migrate_entries fails with: - # "RuntimeError: dictionary keys changed during iteration" - # Try to get all entries from the dictionary before working on them. - # The migration dows not directly change any keys of the registry. Concurrency problem in HA? - - entries = [] - for entry in ent_reg.entities.values(): - if entry.config_entry_id == config_entry_id: - entries.append(entry) - - for entry in entries: - updates = entry_callback(entry) - if updates is not None: - ent_reg.async_update_entity(entry.entity_id, **updates) - - -@dataclass -class MeterInfo: - """Info about meter.""" - - manufacturer: str | None - manufacturer_id: str | None - type: str | None - type_id: str | None - list_version_id: str - meter_id: str | None - - @property - def unique_id(self) -> str | None: - """Meter unique id.""" - if self.meter_id: - return f"{self.manufacturer}-{self.type}-{self.meter_id}".lower() - return None - - @classmethod - def from_measure_data( - cls, measure_data: dict[str, str | int | float | dt.datetime] - ) -> MeterInfo: - """Create MeterInfo from measure_data dictionary.""" - return cls( - *[ - cast(str, measure_data.get(key)) - for key in [ - obis_map.FIELD_METER_MANUFACTURER, - obis_map.FIELD_METER_MANUFACTURER_ID, - obis_map.FIELD_METER_TYPE, - obis_map.FIELD_METER_TYPE_ID, - obis_map.FIELD_OBIS_LIST_VER_ID, - obis_map.FIELD_METER_ID, - ] - ] - ) - - -class StopMessage(han_type.MeterMessageBase): - """Special message top signal stop. No more messages.""" - - @property - def message_type(self) -> han_type.MeterMessageType: - """Return MeterMessageType of message.""" - return han_type.MeterMessageType.UNKNOWN - - @property - def is_valid(self) -> bool: - """Return False for stop message.""" - return False - - @property - def as_bytes(self) -> bytes | None: - """Return None for stop message.""" - return None - - @property - def payload(self) -> bytes | None: - """Return None for stop message.""" - return None +"""The AMS HAN meter integration.""" +from __future__ import annotations + +import asyncio +from dataclasses import dataclass +import datetime as dt +from enum import Enum +import logging +from typing import Callable, Mapping, cast + +from han import common as han_type, meter_connection, obis_map +from homeassistant import const as ha_const +from homeassistant.components import sensor as ha_sensor +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import CALLBACK_TYPE, callback, HomeAssistant, Event +from homeassistant.helpers import entity_registry +from homeassistant.helpers.typing import ConfigType + +from .const import ( + CONF_CONNECTION_CONFIG, + CONF_CONNECTION_TYPE, + CONF_MQTT_TOPICS, + CONF_TCP_HOST, + DOMAIN, +) +from .metercon import async_setup_meter_mqtt_subscriptions, setup_meter_connection + +_LOGGER: logging.Logger = logging.getLogger(__name__) + +PLATFORM_TYPE = ha_const.Platform.SENSOR + + +class ConnectionType(Enum): + """Meter connection type.""" + + SERIAL = "serial" + NETWORK = "network_tcpip" + MQTT = "hass_mqtt" + + +class AmsHanIntegration: + """AMS HAN integration.""" + + def __init__(self) -> None: + """Initialize AmsHanIntegration.""" + self._connection_manager: meter_connection.ConnectionManager | None = None + self._mqtt_unsubscribe: CALLBACK_TYPE | None = None + self._listeners: list[CALLBACK_TYPE] = [] + self._tasks: list[asyncio.Task] = [] + self.measure_queue: asyncio.Queue[han_type.MeterMessageBase] = asyncio.Queue() + + async def async_setup_receiver( + self, hass: HomeAssistant, config_data: Mapping + ) -> None: + """Set up MQTT or serial/tcp-ip receiver.""" + connection_type = ConnectionType(config_data[CONF_CONNECTION_TYPE]) + if ConnectionType.MQTT == connection_type: + self._mqtt_unsubscribe = await async_setup_meter_mqtt_subscriptions( + hass, + config_data[CONF_CONNECTION_CONFIG], + self.measure_queue, + ) + else: + manager = setup_meter_connection( + hass.loop, + config_data[CONF_CONNECTION_CONFIG], + self.measure_queue, + ) + hass.loop.create_task(manager.connect_loop()) + self._connection_manager = manager + + _LOGGER.debug("Configured %s receiver.", connection_type) + + def add_listener(self, listener_unsubscribe: CALLBACK_TYPE) -> None: + """Add listener to be removed on unload.""" + self._listeners.append(listener_unsubscribe) + + def add_task(self, task: asyncio.Task) -> None: + """Add task to be cancelled on close/unload.""" + self._tasks.append(task) + + async def async_close_all(self) -> None: + """Stop receive, unsubscribe listeners and cancel tasks.""" + self.stop_receive() + + for unsub in self._listeners: + unsub() + self._listeners.clear() + + for task in self._tasks: + task.cancel() + await asyncio.gather(*self._tasks) + self._tasks.clear() + + def stop_receive(self) -> None: + """Stop receivers (serial/tcp-ip and/or MQTT.""" + # signal processor to exit processing loop by sending empty bytes on the queue + self.measure_queue.put_nowait(StopMessage()) + + if self._connection_manager: + self._connection_manager.close() + self._connection_manager = None + if self._mqtt_unsubscribe: + self._mqtt_unsubscribe() + self._mqtt_unsubscribe = None + + +async def async_setup(hass: HomeAssistant, _: ConfigType) -> bool: + """Set up the amshan component.""" + hass.data[DOMAIN] = {} + return True + + +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Set up amshan from a config entry.""" + integration = AmsHanIntegration() + + await integration.async_setup_receiver(hass, config_entry.data) + + # Listen for Home Assistant stop event + @callback + async def on_hass_stop(event: Event) -> None: + _LOGGER.debug("%s received. Close down integration.", event.event_type) + integration.stop_receive() + + integration.add_listener( + hass.bus.async_listen_once(ha_const.EVENT_HOMEASSISTANT_STOP, on_hass_stop) + ) + + # Listen for config entry changes and reload when changed. + integration.add_listener( + config_entry.add_update_listener(async_config_entry_changed) + ) + + hass.data[DOMAIN][config_entry.entry_id] = integration + await hass.config_entries.async_forward_entry_setup(config_entry, PLATFORM_TYPE) + + _LOGGER.debug("async_setup_entry complete.") + + return True + + +async def async_migrate_config_entry( + hass: HomeAssistant, config_entry: ConfigEntry +) -> bool: + """Migrate config when ConfigFlow version has changed.""" + initial_version = config_entry.version + current_data = config_entry.data + _LOGGER.debug("Check for config entry migration of version %d", initial_version) + + if config_entry.version == 1: + await _async_migrate_entries( + hass, config_entry.entry_id, _migrate_entity_entry_from_v1_to_v2 + ) + config_entry.version = 2 + current_data = { + CONF_CONNECTION_TYPE: ( + ConnectionType.MQTT + if CONF_MQTT_TOPICS in config_entry.data + else ConnectionType.NETWORK + if CONF_TCP_HOST in config_entry.data + else ConnectionType.SERIAL + ).value, + CONF_CONNECTION_CONFIG: {**current_data}, + } + _LOGGER.debug("Config entry migrated to version 2") + + if config_entry.version == 2: + config_entry.version = 3 + await _async_migrate_entries( + hass, config_entry.entry_id, _migrate_entity_entry_from_v2_to_v3 + ) + _LOGGER.debug("Config entry migrated to version 3") + + hass.config_entries.async_update_entry(config_entry, data=current_data) + _LOGGER.debug( + "Config entry migration from %d to %d successfull.", + initial_version, + config_entry.version, + ) + + return True + + +async def async_unload_entry( + hass: HomeAssistant, config_entry: ConfigEntry +) -> bool: + """Handle removal of an entry.""" + is_plaform_unload_success = await hass.config_entries.async_forward_entry_unload( + config_entry, PLATFORM_TYPE + ) + + if is_plaform_unload_success: + _LOGGER.info("Integrations is unloading.") + ctx: AmsHanIntegration = hass.data[DOMAIN].pop(config_entry.entry_id) + await ctx.async_close_all() + + return is_plaform_unload_success + + +@callback +async def async_config_entry_changed( + hass: HomeAssistant, config_entry: ConfigEntry +): + """Handle config entry changed callback.""" + _LOGGER.info("Config entry has changed. Reload integration.") + await hass.config_entries.async_reload(config_entry.entry_id) + + +def _migrate_entity_entry_from_v1_to_v2(entity: entity_registry.RegistryEntry): + def replace_ending(source, old, new): + if source.endswith(old): + return source[: -len(old)] + new + return source + + update = {} + if entity.unique_id.endswith("_hour"): + new_unique_id = replace_ending(entity.unique_id, "_hour", "_total") + _LOGGER.info("Migrate unique_id from %s to %s", entity.unique_id, new_unique_id) + update["new_unique_id"] = new_unique_id + return update + + +def _migrate_entity_entry_from_v2_to_v3(entity: entity_registry.RegistryEntry): + update = {} + + v3_migrate_fields = [ + obis_map.FIELD_METER_ID, + obis_map.FIELD_METER_MANUFACTURER, + obis_map.FIELD_METER_TYPE, + obis_map.FIELD_OBIS_LIST_VER_ID, + obis_map.FIELD_ACTIVE_POWER_IMPORT, + obis_map.FIELD_ACTIVE_POWER_EXPORT, + obis_map.FIELD_REACTIVE_POWER_IMPORT, + obis_map.FIELD_REACTIVE_POWER_EXPORT, + obis_map.FIELD_CURRENT_L1, + obis_map.FIELD_CURRENT_L2, + obis_map.FIELD_CURRENT_L3, + obis_map.FIELD_VOLTAGE_L1, + obis_map.FIELD_VOLTAGE_L2, + obis_map.FIELD_VOLTAGE_L3, + obis_map.FIELD_ACTIVE_POWER_IMPORT_TOTAL, + obis_map.FIELD_ACTIVE_POWER_EXPORT_TOTAL, + obis_map.FIELD_REACTIVE_POWER_IMPORT_TOTAL, + obis_map.FIELD_REACTIVE_POWER_EXPORT_TOTAL, + ] + + for measure_id in v3_migrate_fields: + if entity.unique_id.endswith(f"-{measure_id}"): + manufacturer = entity.unique_id[: entity.unique_id.find("-")] + new_entity_id = f"sensor.{manufacturer}_{measure_id}".lower() + if new_entity_id != entity.entity_id: + update["new_entity_id"] = new_entity_id + _LOGGER.info( + "Migrate entity_id from %s to %s", + entity.entity_id, + new_entity_id, + ) + + if measure_id in ( + obis_map.FIELD_REACTIVE_POWER_IMPORT, + obis_map.FIELD_REACTIVE_POWER_EXPORT, + ): + update["device_class"] = ha_sensor.SensorDeviceClass.REACTIVE_POWER + update["unit_of_measurement"] = ha_const.POWER_VOLT_AMPERE_REACTIVE + _LOGGER.info( + "Migrated %s to device class %s with unit %s", + entity.unique_id, + ha_sensor.SensorDeviceClass.REACTIVE_POWER, + ha_const.POWER_VOLT_AMPERE_REACTIVE, + ) + + break + + return update + + +async def _async_migrate_entries( + hass: HomeAssistant, + config_entry_id: str, + entry_callback: Callable[[entity_registry.RegistryEntry], dict | None], +) -> None: + ent_reg = await entity_registry.async_get_registry(hass) + + # Workaround: + # entity_registry.async_migrate_entries fails with: + # "RuntimeError: dictionary keys changed during iteration" + # Try to get all entries from the dictionary before working on them. + # The migration dows not directly change any keys of the registry. Concurrency problem in HA? + + entries = [] + for entry in ent_reg.entities.values(): + if entry.config_entry_id == config_entry_id: + entries.append(entry) + + for entry in entries: + updates = entry_callback(entry) + if updates is not None: + ent_reg.async_update_entity(entry.entity_id, **updates) + + +@dataclass +class MeterInfo: + """Info about meter.""" + + manufacturer: str | None + manufacturer_id: str | None + type: str | None + type_id: str | None + list_version_id: str + meter_id: str | None + + @property + def unique_id(self) -> str | None: + """Meter unique id.""" + if self.meter_id: + return f"{self.manufacturer}-{self.type}-{self.meter_id}".lower() + return None + + @classmethod + def from_measure_data( + cls, measure_data: dict[str, str | int | float | dt.datetime] + ) -> MeterInfo: + """Create MeterInfo from measure_data dictionary.""" + return cls( + *[ + cast(str, measure_data.get(key)) + for key in [ + obis_map.FIELD_METER_MANUFACTURER, + obis_map.FIELD_METER_MANUFACTURER_ID, + obis_map.FIELD_METER_TYPE, + obis_map.FIELD_METER_TYPE_ID, + obis_map.FIELD_OBIS_LIST_VER_ID, + obis_map.FIELD_METER_ID, + ] + ] + ) + + +class StopMessage(han_type.MeterMessageBase): + """Special message top signal stop. No more messages.""" + + @property + def message_type(self) -> han_type.MeterMessageType: + """Return MeterMessageType of message.""" + return han_type.MeterMessageType.UNKNOWN + + @property + def is_valid(self) -> bool: + """Return False for stop message.""" + return False + + @property + def as_bytes(self) -> bytes | None: + """Return None for stop message.""" + return None + + @property + def payload(self) -> bytes | None: + """Return None for stop message.""" + return None diff --git a/custom_components/amshan/config_flow.py b/custom_components/amshan/config_flow.py index aaf5b38..025ac8c 100644 --- a/custom_components/amshan/config_flow.py +++ b/custom_components/amshan/config_flow.py @@ -1,574 +1,574 @@ -"""Config flow for AMS HAN meter integration.""" -from __future__ import annotations - -import asyncio -import logging -import os -import socket -from typing import Any, cast - -import async_timeout -from han import autodecoder, common as han_type, obis_map -from homeassistant import config_entries -from homeassistant.components import mqtt -from homeassistant.core import callback -from homeassistant.data_entry_flow import FlowResult -from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.typing import HomeAssistantType -import serial -import voluptuous as vol - -from . import ConnectionType, MeterInfo -from .const import ( - CONF_CONNECTION_CONFIG, - CONF_CONNECTION_TYPE, - CONF_MQTT_TOPICS, - CONF_OPTIONS_SCALE_FACTOR, - CONF_SERIAL_BAUDRATE, - CONF_SERIAL_BYTESIZE, - CONF_SERIAL_DSRDTR, - CONF_SERIAL_PARITY, - CONF_SERIAL_PORT, - CONF_SERIAL_RTSCTS, - CONF_SERIAL_STOPBITS, - CONF_SERIAL_XONXOFF, - CONF_TCP_HOST, - CONF_TCP_PORT, - HOSTNAME_IP4_IP6_REGEX, -) -from .const import DOMAIN # pylint: disable=unused-import -from .metercon import get_connection_factory, get_meter_message - -_LOGGER = logging.getLogger(__name__) - - -# max number of frames to search for needed meter information -# Some meters sends 3 frames containing minimal of data between larger frames. Skip them. -# Some frames may be abortet correctly. Add some for that. -# A max count of 4 should be the normal situation, but a little more is more robust. -MAX_FRAME_SEARCH_COUNT = 6 - -# Kamstrup sends data frame every 10 sec. Aidon every 2.5 sec. Kaifa evry 2 sec. -MAX_FRAME_WAIT_TIME = 12 - -# Error codes -# Use the key base if you want to show an error unrelated to a specific field. -# The specified errors need to refer to a key in a translation file. -VALIDATION_ERROR_BASE = "base" -VALIDATION_ERROR_CONNECT = "cannot_connect" -VALIDATION_ERROR_TIMEOUT_CONNECT = "timeout_connect" -VALIDATION_ERROR_TIMEOUT_READ_MESSAGE = "timeout_read_messages" -VALIDATION_ERROR_HOST_CHECK = "host_check" -VALIDATION_ERROR_VOLUPTUOUS_BASE = "voluptuous_" -VALIDATION_ERROR_SERIAL_EXCEPTION_GENERAL = "serial_exception_general" -VALIDATION_ERROR_SERIAL_EXCEPTION_ERRNO_2 = "serial_exception_errno_2" -VALIDATION_ERROR_MQTT_NOT_AVAILAVLE = "mqtt_not_available" -VALIDATION_ERROR_MQTT_INVALID_SUBSCRIBE_TOPIC = "invalid_subscribe_topic" - - -class AmsHanConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): - """Handle a config flow for amshan.""" - - VERSION = 3 - CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH - - def __init__(self) -> None: - """Initialize AmsHanConfigFlow class.""" - self._validator = ConfigFlowValidation() - - @staticmethod - @callback - def async_get_options_flow( - config_entry: config_entries.ConfigEntry, - ) -> config_entries.OptionsFlow: - """Get options flow handler.""" - return AmsHanOptionsFlowHandler(config_entry) - - async def async_step_user( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Handle the initial step.""" - errors = {} - if user_input is not None: - connection_type = self._validator.validate_connection_type_input(user_input) - - if connection_type == ConnectionType.NETWORK: - return await self.async_step_network_connection() - - if connection_type == ConnectionType.SERIAL: - return await self.async_step_serial_connection() - - if connection_type == ConnectionType.MQTT: - if self._is_mqtt_available(): - return await self.async_step_hass_mqtt_connection() - errors[VALIDATION_ERROR_BASE] = VALIDATION_ERROR_MQTT_NOT_AVAILAVLE - - return self.async_show_form( - step_id="user", - data_schema=vol.Schema( - {vol.Required("type"): vol.In(["serial", "network", "MQTT"])} - ), - errors=errors, - ) - - async def async_step_serial_connection( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Handle the serial_connection step.""" - if user_input: - entry_result = await self._async_try_create_entry( - ConnectionType.SERIAL, user_input - ) - if entry_result: - return entry_result - else: - # set defaults - port = await self.hass.async_add_executor_job( - self._try_get_first_available_serial - ) - - user_input = { - CONF_SERIAL_PORT: port if port else "", - CONF_SERIAL_BAUDRATE: 2400, - CONF_SERIAL_PARITY: "N", - CONF_SERIAL_BYTESIZE: "8", - CONF_SERIAL_STOPBITS: "1", - CONF_SERIAL_XONXOFF: False, - CONF_SERIAL_RTSCTS: False, - CONF_SERIAL_DSRDTR: False, - } - - return self.async_show_form( - step_id="serial_connection", - data_schema=vol.Schema( - { - vol.Required( - CONF_SERIAL_PORT, default=user_input[CONF_SERIAL_PORT] - ): cv.string, - vol.Optional( - CONF_SERIAL_BAUDRATE, default=user_input[CONF_SERIAL_BAUDRATE] - ): cv.positive_int, - vol.Optional( - CONF_SERIAL_PARITY, default=user_input[CONF_SERIAL_PARITY] - ): vol.In(["N", "E", "O"]), - vol.Optional( - CONF_SERIAL_BYTESIZE, default=user_input[CONF_SERIAL_BYTESIZE] - ): vol.In( - ["5", "6", "7", "8"] - ), # use string as workaround gui bug - vol.Optional( - CONF_SERIAL_STOPBITS, default=user_input[CONF_SERIAL_STOPBITS] - ): vol.In( - ["1", "1.5", "2"] - ), # use string as workaround gui bug - vol.Optional( - CONF_SERIAL_XONXOFF, default=user_input[CONF_SERIAL_XONXOFF] - ): cv.boolean, - vol.Optional( - CONF_SERIAL_RTSCTS, default=user_input[CONF_SERIAL_RTSCTS] - ): cv.boolean, - vol.Optional( - CONF_SERIAL_DSRDTR, default=user_input[CONF_SERIAL_DSRDTR] - ): cv.boolean, - } - ), - errors=self._validator.errors, - ) - - async def async_step_network_connection( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Handle the network_connection step.""" - if user_input: - entry_result = await self._async_try_create_entry( - ConnectionType.NETWORK, user_input - ) - if entry_result: - return entry_result - else: - # set defaults - user_input = { - CONF_TCP_HOST: "", - CONF_TCP_PORT: None, - } - - return self.async_show_form( - step_id="network_connection", - data_schema=vol.Schema( - { - vol.Required( - CONF_TCP_HOST, default=user_input[CONF_TCP_HOST] - ): cv.string, - vol.Required( - CONF_TCP_PORT, default=user_input[CONF_TCP_PORT] - ): cv.positive_int, - } - ), - errors=self._validator.errors, - ) - - async def async_step_hass_mqtt_connection( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Handle hass_mqtt_connection step.""" - if user_input: - entry_result = await self._async_try_create_entry( - ConnectionType.MQTT, user_input - ) - if entry_result: - return entry_result - else: - # set defaults - user_input = { - CONF_MQTT_TOPICS: "", - } - - return self.async_show_form( - step_id="hass_mqtt_connection", - data_schema=vol.Schema( - { - vol.Required( - CONF_MQTT_TOPICS, default=user_input[CONF_MQTT_TOPICS] - ): cv.string, - } - ), - errors=self._validator.errors, - ) - - async def _async_try_create_entry( - self, connection_type: ConnectionType, user_input: dict[str, Any] - ) -> FlowResult | None: - config = dict(user_input) # create a copy to be able to make changes - if connection_type == ConnectionType.SERIAL: - config[CONF_SERIAL_BYTESIZE] = int(config[CONF_SERIAL_BYTESIZE]) - config[CONF_SERIAL_STOPBITS] = float(config[CONF_SERIAL_STOPBITS]) - elif connection_type == ConnectionType.NETWORK: - config[CONF_TCP_PORT] = int(config[CONF_TCP_PORT]) - elif connection_type == ConnectionType.MQTT: - # strip empty elements - topics = [ - x.strip() for x in config[CONF_MQTT_TOPICS].split(",") if x.strip() - ] - config[CONF_MQTT_TOPICS] = ",".join(topics) - - meter_info = await self._validator.async_validate_connection_input( - cast(HomeAssistantType, self.hass), - connection_type, - config, - ) - - if not self._validator.errors and meter_info: - if meter_info.unique_id: - await self.async_set_unique_id(meter_info.unique_id) - self._abort_if_unique_id_configured() - - manufacturer = ( - meter_info.manufacturer - if meter_info.manufacturer - else meter_info.manufacturer_id - ) - meter_type = ( - meter_info.type - if meter_info.type - else meter_info.type_id - if meter_info.type_id - else "" - ) - - return self.async_create_entry( - title=f"{manufacturer} {meter_type} ({connection_type.name.lower()})", - data={ - CONF_CONNECTION_TYPE: connection_type.value, - CONF_CONNECTION_CONFIG: config, - }, - ) - - return None - - def _is_mqtt_available(self) -> bool: - return mqtt.DOMAIN in self.hass.config.components - - @staticmethod - def _try_get_first_available_serial() -> str | None: - by_id = "/dev/serial/by-id" - if not os.path.isdir(by_id): - return None - - for device in (entry.path for entry in os.scandir(by_id) if entry.is_symlink()): - try: - # Open to test if port is available... - port = serial.Serial(device) - except serial.SerialException: - # It has some error, skip this one - continue - else: - port.close() - return device - - return None - - -class ConfigFlowValidation: - """ConfigFlow input validation.""" - - def __init__(self) -> None: - """Initialize ConfigFlowValidation class.""" - self.errors: dict[str, Any] = {} - - def _set_base_error(self, error_key: str) -> None: - """ - Show an error unrelated to a specific field. - - :param error_key: error key that needs to refer to a key in a translation file. - """ - self.errors[VALIDATION_ERROR_BASE] = error_key - - async def _async_get_meter_info( - self, measure_queue: asyncio.Queue[han_type.MeterMessageBase] - ) -> MeterInfo: - """Decode meter data stream and return meter information if available.""" - decoder = autodecoder.AutoDecoder() - - for _ in range(MAX_FRAME_SEARCH_COUNT): - measure = await self._async_try_get_message(measure_queue) - if measure is not None: - decoded_measure = decoder.decode_message(measure) - if decoded_measure: - if ( - obis_map.FIELD_METER_MANUFACTURER_ID in decoded_measure - or obis_map.FIELD_METER_MANUFACTURER in decoded_measure - ): - return MeterInfo.from_measure_data(decoded_measure) - - _LOGGER.debug("Decoded measure data is missing required info.") - - raise TimeoutError() - - async def _async_try_get_message( - self, measure_queue: asyncio.Queue[han_type.MeterMessageBase] - ) -> han_type.MeterMessageBase | None: - async with async_timeout.timeout(MAX_FRAME_WAIT_TIME): - try: - return await measure_queue.get() - except (TimeoutError, asyncio.CancelledError): - _LOGGER.debug( - "Timout waiting %d seconds for meter measure.", MAX_FRAME_WAIT_TIME - ) - return None - - async def _async_validate_device_connection( - self, loop: asyncio.AbstractEventLoop, user_input: dict[str, Any] - ) -> MeterInfo | None: - """Try to connect and get meter information to validate connection data.""" - measure_queue: asyncio.Queue[han_type.MeterMessageBase] = asyncio.Queue() - connection_factory = get_connection_factory(loop, user_input, measure_queue) - - transport = None - try: - try: - transport, _ = await connection_factory() - except TimeoutError as ex: - _LOGGER.debug("Timeout when connecting to HAN-port: %s", ex) - self._set_base_error(VALIDATION_ERROR_TIMEOUT_CONNECT) - return None - except serial.SerialException as ex: - if ex.errno == 2: - # No such file or directory - self._set_base_error(VALIDATION_ERROR_SERIAL_EXCEPTION_ERRNO_2) - _LOGGER.debug( - "Serial exception when connecting to HAN-port: %s", ex - ) - else: - self._set_base_error(VALIDATION_ERROR_SERIAL_EXCEPTION_GENERAL) - _LOGGER.error( - "Serial exception when connecting to HAN-port: %s", ex - ) - return None - except ConnectionError as ex: - self._set_base_error(VALIDATION_ERROR_CONNECT) - _LOGGER.error( - "Network exception (%s) when connecting to HAN-port: %s", - type(ex).__name__, - ex, - ) - - except Exception as ex: - _LOGGER.exception("Unexpected error connecting to HAN-port: %s", ex) - raise - - try: - return await self._async_get_meter_info(measure_queue) - except TimeoutError: - self._set_base_error(VALIDATION_ERROR_TIMEOUT_READ_MESSAGE) - return None - finally: - if transport: - transport.close() - - async def _async_validate_mqtt_connection( - self, hass: HomeAssistantType, user_input: dict[str, Any] - ) -> MeterInfo | None: - measure_queue: asyncio.Queue[han_type.MeterMessageBase] = asyncio.Queue() - - @callback - def message_received(mqtt_message: mqtt.models.ReceiveMessage) -> None: - """Handle new MQTT messages.""" - meter_message = get_meter_message(mqtt_message) - if meter_message: - measure_queue.put_nowait(meter_message) - - unsubscibers = [] - topics = {x.strip() for x in user_input[CONF_MQTT_TOPICS].split(",")} - for topic in topics: - unsubscibers.append( - await mqtt.async_subscribe( - hass, topic, message_received, 1, encoding=None - ) - ) - - try: - return await self._async_get_meter_info(measure_queue) - except TimeoutError: - self._set_base_error(VALIDATION_ERROR_TIMEOUT_READ_MESSAGE) - return None - finally: - for ubsubscribe in unsubscibers: - ubsubscribe() - - # workaround MQTT bug when topic is re-subscribed at setup at the same - # time as unsubscribe runs as a background job - await asyncio.sleep(1) - - async def _async_validate_host_address( - self, loop: asyncio.AbstractEventLoop, user_input: dict[str, Any] - ) -> None: - try: - await loop.getaddrinfo( - user_input[CONF_TCP_HOST], - None, - family=0, - type=socket.SOCK_STREAM, - proto=0, - flags=0, - ) - except OSError as ex: - _LOGGER.debug( - "Could not resolve host '%s': %s", user_input[CONF_TCP_HOST], ex - ) - self.errors[CONF_TCP_HOST] = VALIDATION_ERROR_HOST_CHECK - - def _validate_topics(self, user_input: dict[str, Any]) -> None: - topics = {x.strip() for x in user_input[CONF_MQTT_TOPICS].split(",")} - if not topics: - self.errors[ - CONF_MQTT_TOPICS - ] = VALIDATION_ERROR_MQTT_INVALID_SUBSCRIBE_TOPIC - - for topic in topics: - try: - mqtt.valid_subscribe_topic(topic) - except vol.Invalid: - self.errors[ - CONF_MQTT_TOPICS - ] = VALIDATION_ERROR_MQTT_INVALID_SUBSCRIBE_TOPIC - - def _validate_schema( - self, connection_type: ConnectionType, user_input: dict[str, Any] - ) -> None: - if connection_type == ConnectionType.SERIAL: - schema = vol.Schema( - { - vol.Required(CONF_SERIAL_PORT): cv.string, - vol.Optional(CONF_SERIAL_BAUDRATE): cv.positive_int, - }, - extra=vol.ALLOW_EXTRA, - ) - elif connection_type == ConnectionType.NETWORK: - schema = vol.Schema( - { - vol.Required(CONF_TCP_HOST): cv.matches_regex( - HOSTNAME_IP4_IP6_REGEX - ), - vol.Required(CONF_TCP_PORT): cv.port, - } - ) - elif connection_type == ConnectionType.MQTT: - schema = vol.Schema( - { - vol.Required(CONF_MQTT_TOPICS): cv.string, - } - ) - else: - raise ValueError(f"Unexpected connection type {connection_type}") - - try: - schema(user_input) - except vol.MultipleInvalid as ex: - for err in ex.errors: - for element in err.path: - self.errors[element] = VALIDATION_ERROR_VOLUPTUOUS_BASE + element - - def validate_connection_type_input( - self, user_input: dict[str, Any] - ) -> ConnectionType: - """Validate user input from first step.""" - self.errors = {} - return ConnectionType[user_input["type"].upper()] - - async def async_validate_connection_input( - self, - hass: HomeAssistantType, - connection_type: ConnectionType, - user_input: dict[str, Any], - ) -> MeterInfo | None: - """Validate user input from connection step and try connection.""" - self.errors = {} - - self._validate_schema(connection_type, user_input) - - if not self.errors and connection_type == ConnectionType.NETWORK: - await self._async_validate_host_address(hass.loop, user_input) - elif not self.errors and connection_type == ConnectionType.MQTT: - self._validate_topics(user_input) - - if not self.errors and connection_type in ( - ConnectionType.NETWORK, - ConnectionType.SERIAL, - ): - return await self._async_validate_device_connection(hass.loop, user_input) - - if not self.errors and connection_type == ConnectionType.MQTT: - return await self._async_validate_mqtt_connection(hass, user_input) - - return None - - -class AmsHanOptionsFlowHandler(config_entries.OptionsFlow): - """Config flow options handler.""" - - def __init__(self, config_entry) -> None: - """Initialize options flow.""" - self.config_entry = config_entry - self.options = dict(config_entry.options) - - async def async_step_init(self, user_input=None): # pylint: disable=unused-argument - """Manage the options.""" - return await self.async_step_user() - - async def async_step_user(self, user_input=None): - """Handle a flow initialized by the user.""" - if user_input is not None: - self.options.update(user_input) - return self.async_create_entry(title="", data=self.options) - - options = { - vol.Optional( - CONF_OPTIONS_SCALE_FACTOR, - default=self.options.get(CONF_OPTIONS_SCALE_FACTOR, 1.0), - ): cv.positive_float, - } - - return self.async_show_form( - step_id="user", - data_schema=vol.Schema(options), - ) +"""Config flow for AMS HAN meter integration.""" +from __future__ import annotations + +import asyncio +import logging +import os +import socket +from typing import Any, cast + +import async_timeout +from han import autodecoder, common as han_type, obis_map +from homeassistant import config_entries +from homeassistant.components import mqtt +from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import config_validation as cv +from homeassistant.core import HomeAssistant +import serial +import voluptuous as vol + +from . import ConnectionType, MeterInfo +from .const import ( + CONF_CONNECTION_CONFIG, + CONF_CONNECTION_TYPE, + CONF_MQTT_TOPICS, + CONF_OPTIONS_SCALE_FACTOR, + CONF_SERIAL_BAUDRATE, + CONF_SERIAL_BYTESIZE, + CONF_SERIAL_DSRDTR, + CONF_SERIAL_PARITY, + CONF_SERIAL_PORT, + CONF_SERIAL_RTSCTS, + CONF_SERIAL_STOPBITS, + CONF_SERIAL_XONXOFF, + CONF_TCP_HOST, + CONF_TCP_PORT, + HOSTNAME_IP4_IP6_REGEX, +) +from .const import DOMAIN # pylint: disable=unused-import +from .metercon import get_connection_factory, get_meter_message + +_LOGGER = logging.getLogger(__name__) + + +# max number of frames to search for needed meter information +# Some meters sends 3 frames containing minimal of data between larger frames. Skip them. +# Some frames may be abortet correctly. Add some for that. +# A max count of 4 should be the normal situation, but a little more is more robust. +MAX_FRAME_SEARCH_COUNT = 6 + +# Kamstrup sends data frame every 10 sec. Aidon every 2.5 sec. Kaifa evry 2 sec. +MAX_FRAME_WAIT_TIME = 12 + +# Error codes +# Use the key base if you want to show an error unrelated to a specific field. +# The specified errors need to refer to a key in a translation file. +VALIDATION_ERROR_BASE = "base" +VALIDATION_ERROR_CONNECT = "cannot_connect" +VALIDATION_ERROR_TIMEOUT_CONNECT = "timeout_connect" +VALIDATION_ERROR_TIMEOUT_READ_MESSAGE = "timeout_read_messages" +VALIDATION_ERROR_HOST_CHECK = "host_check" +VALIDATION_ERROR_VOLUPTUOUS_BASE = "voluptuous_" +VALIDATION_ERROR_SERIAL_EXCEPTION_GENERAL = "serial_exception_general" +VALIDATION_ERROR_SERIAL_EXCEPTION_ERRNO_2 = "serial_exception_errno_2" +VALIDATION_ERROR_MQTT_NOT_AVAILAVLE = "mqtt_not_available" +VALIDATION_ERROR_MQTT_INVALID_SUBSCRIBE_TOPIC = "invalid_subscribe_topic" + + +class AmsHanConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for amshan.""" + + VERSION = 3 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH + + def __init__(self) -> None: + """Initialize AmsHanConfigFlow class.""" + self._validator = ConfigFlowValidation() + + @staticmethod + @callback + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> config_entries.OptionsFlow: + """Get options flow handler.""" + return AmsHanOptionsFlowHandler(config_entry) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + errors = {} + if user_input is not None: + connection_type = self._validator.validate_connection_type_input(user_input) + + if connection_type == ConnectionType.NETWORK: + return await self.async_step_network_connection() + + if connection_type == ConnectionType.SERIAL: + return await self.async_step_serial_connection() + + if connection_type == ConnectionType.MQTT: + if self._is_mqtt_available(): + return await self.async_step_hass_mqtt_connection() + errors[VALIDATION_ERROR_BASE] = VALIDATION_ERROR_MQTT_NOT_AVAILAVLE + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + {vol.Required("type"): vol.In(["serial", "network", "MQTT"])} + ), + errors=errors, + ) + + async def async_step_serial_connection( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the serial_connection step.""" + if user_input: + entry_result = await self._async_try_create_entry( + ConnectionType.SERIAL, user_input + ) + if entry_result: + return entry_result + else: + # set defaults + port = await self.hass.async_add_executor_job( + self._try_get_first_available_serial + ) + + user_input = { + CONF_SERIAL_PORT: port if port else "", + CONF_SERIAL_BAUDRATE: 2400, + CONF_SERIAL_PARITY: "N", + CONF_SERIAL_BYTESIZE: "8", + CONF_SERIAL_STOPBITS: "1", + CONF_SERIAL_XONXOFF: False, + CONF_SERIAL_RTSCTS: False, + CONF_SERIAL_DSRDTR: False, + } + + return self.async_show_form( + step_id="serial_connection", + data_schema=vol.Schema( + { + vol.Required( + CONF_SERIAL_PORT, default=user_input[CONF_SERIAL_PORT] + ): cv.string, + vol.Optional( + CONF_SERIAL_BAUDRATE, default=user_input[CONF_SERIAL_BAUDRATE] + ): cv.positive_int, + vol.Optional( + CONF_SERIAL_PARITY, default=user_input[CONF_SERIAL_PARITY] + ): vol.In(["N", "E", "O"]), + vol.Optional( + CONF_SERIAL_BYTESIZE, default=user_input[CONF_SERIAL_BYTESIZE] + ): vol.In( + ["5", "6", "7", "8"] + ), # use string as workaround gui bug + vol.Optional( + CONF_SERIAL_STOPBITS, default=user_input[CONF_SERIAL_STOPBITS] + ): vol.In( + ["1", "1.5", "2"] + ), # use string as workaround gui bug + vol.Optional( + CONF_SERIAL_XONXOFF, default=user_input[CONF_SERIAL_XONXOFF] + ): cv.boolean, + vol.Optional( + CONF_SERIAL_RTSCTS, default=user_input[CONF_SERIAL_RTSCTS] + ): cv.boolean, + vol.Optional( + CONF_SERIAL_DSRDTR, default=user_input[CONF_SERIAL_DSRDTR] + ): cv.boolean, + } + ), + errors=self._validator.errors, + ) + + async def async_step_network_connection( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the network_connection step.""" + if user_input: + entry_result = await self._async_try_create_entry( + ConnectionType.NETWORK, user_input + ) + if entry_result: + return entry_result + else: + # set defaults + user_input = { + CONF_TCP_HOST: "", + CONF_TCP_PORT: None, + } + + return self.async_show_form( + step_id="network_connection", + data_schema=vol.Schema( + { + vol.Required( + CONF_TCP_HOST, default=user_input[CONF_TCP_HOST] + ): cv.string, + vol.Required( + CONF_TCP_PORT, default=user_input[CONF_TCP_PORT] + ): cv.positive_int, + } + ), + errors=self._validator.errors, + ) + + async def async_step_hass_mqtt_connection( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle hass_mqtt_connection step.""" + if user_input: + entry_result = await self._async_try_create_entry( + ConnectionType.MQTT, user_input + ) + if entry_result: + return entry_result + else: + # set defaults + user_input = { + CONF_MQTT_TOPICS: "", + } + + return self.async_show_form( + step_id="hass_mqtt_connection", + data_schema=vol.Schema( + { + vol.Required( + CONF_MQTT_TOPICS, default=user_input[CONF_MQTT_TOPICS] + ): cv.string, + } + ), + errors=self._validator.errors, + ) + + async def _async_try_create_entry( + self, connection_type: ConnectionType, user_input: dict[str, Any] + ) -> FlowResult | None: + config = dict(user_input) # create a copy to be able to make changes + if connection_type == ConnectionType.SERIAL: + config[CONF_SERIAL_BYTESIZE] = int(config[CONF_SERIAL_BYTESIZE]) + config[CONF_SERIAL_STOPBITS] = float(config[CONF_SERIAL_STOPBITS]) + elif connection_type == ConnectionType.NETWORK: + config[CONF_TCP_PORT] = int(config[CONF_TCP_PORT]) + elif connection_type == ConnectionType.MQTT: + # strip empty elements + topics = [ + x.strip() for x in config[CONF_MQTT_TOPICS].split(",") if x.strip() + ] + config[CONF_MQTT_TOPICS] = ",".join(topics) + + meter_info = await self._validator.async_validate_connection_input( + self.hass, + connection_type, + config, + ) + + if not self._validator.errors and meter_info: + if meter_info.unique_id: + await self.async_set_unique_id(meter_info.unique_id) + self._abort_if_unique_id_configured() + + manufacturer = ( + meter_info.manufacturer + if meter_info.manufacturer + else meter_info.manufacturer_id + ) + meter_type = ( + meter_info.type + if meter_info.type + else meter_info.type_id + if meter_info.type_id + else "" + ) + + return self.async_create_entry( + title=f"{manufacturer} {meter_type} ({connection_type.name.lower()})", + data={ + CONF_CONNECTION_TYPE: connection_type.value, + CONF_CONNECTION_CONFIG: config, + }, + ) + + return None + + def _is_mqtt_available(self) -> bool: + return mqtt.DOMAIN in self.hass.config.components + + @staticmethod + def _try_get_first_available_serial() -> str | None: + by_id = "/dev/serial/by-id" + if not os.path.isdir(by_id): + return None + + for device in (entry.path for entry in os.scandir(by_id) if entry.is_symlink()): + try: + # Open to test if port is available... + port = serial.Serial(device) + except serial.SerialException: + # It has some error, skip this one + continue + else: + port.close() + return device + + return None + + +class ConfigFlowValidation: + """ConfigFlow input validation.""" + + def __init__(self) -> None: + """Initialize ConfigFlowValidation class.""" + self.errors: dict[str, Any] = {} + + def _set_base_error(self, error_key: str) -> None: + """ + Show an error unrelated to a specific field. + + :param error_key: error key that needs to refer to a key in a translation file. + """ + self.errors[VALIDATION_ERROR_BASE] = error_key + + async def _async_get_meter_info( + self, measure_queue: asyncio.Queue[han_type.MeterMessageBase] + ) -> MeterInfo: + """Decode meter data stream and return meter information if available.""" + decoder = autodecoder.AutoDecoder() + + for _ in range(MAX_FRAME_SEARCH_COUNT): + measure = await self._async_try_get_message(measure_queue) + if measure is not None: + decoded_measure = decoder.decode_message(measure) + if decoded_measure: + if ( + obis_map.FIELD_METER_MANUFACTURER_ID in decoded_measure + or obis_map.FIELD_METER_MANUFACTURER in decoded_measure + ): + return MeterInfo.from_measure_data(decoded_measure) + + _LOGGER.debug("Decoded measure data is missing required info.") + + raise TimeoutError() + + async def _async_try_get_message( + self, measure_queue: asyncio.Queue[han_type.MeterMessageBase] + ) -> han_type.MeterMessageBase | None: + async with async_timeout.timeout(MAX_FRAME_WAIT_TIME): + try: + return await measure_queue.get() + except (TimeoutError, asyncio.CancelledError): + _LOGGER.debug( + "Timout waiting %d seconds for meter measure.", MAX_FRAME_WAIT_TIME + ) + return None + + async def _async_validate_device_connection( + self, loop: asyncio.AbstractEventLoop, user_input: dict[str, Any] + ) -> MeterInfo | None: + """Try to connect and get meter information to validate connection data.""" + measure_queue: asyncio.Queue[han_type.MeterMessageBase] = asyncio.Queue() + connection_factory = get_connection_factory(loop, user_input, measure_queue) + + transport = None + try: + try: + transport, _ = await connection_factory() + except TimeoutError as ex: + _LOGGER.debug("Timeout when connecting to HAN-port: %s", ex) + self._set_base_error(VALIDATION_ERROR_TIMEOUT_CONNECT) + return None + except serial.SerialException as ex: + if ex.errno == 2: + # No such file or directory + self._set_base_error(VALIDATION_ERROR_SERIAL_EXCEPTION_ERRNO_2) + _LOGGER.debug( + "Serial exception when connecting to HAN-port: %s", ex + ) + else: + self._set_base_error(VALIDATION_ERROR_SERIAL_EXCEPTION_GENERAL) + _LOGGER.error( + "Serial exception when connecting to HAN-port: %s", ex + ) + return None + except ConnectionError as ex: + self._set_base_error(VALIDATION_ERROR_CONNECT) + _LOGGER.error( + "Network exception (%s) when connecting to HAN-port: %s", + type(ex).__name__, + ex, + ) + + except Exception as ex: + _LOGGER.exception("Unexpected error connecting to HAN-port: %s", ex) + raise + + try: + return await self._async_get_meter_info(measure_queue) + except TimeoutError: + self._set_base_error(VALIDATION_ERROR_TIMEOUT_READ_MESSAGE) + return None + finally: + if transport: + transport.close() + + async def _async_validate_mqtt_connection( + self, hass: HomeAssistant, user_input: dict[str, Any] + ) -> MeterInfo | None: + measure_queue: asyncio.Queue[han_type.MeterMessageBase] = asyncio.Queue() + + @callback + def message_received(mqtt_message: mqtt.models.ReceiveMessage) -> None: + """Handle new MQTT messages.""" + meter_message = get_meter_message(mqtt_message) + if meter_message: + measure_queue.put_nowait(meter_message) + + unsubscibers = [] + topics = {x.strip() for x in user_input[CONF_MQTT_TOPICS].split(",")} + for topic in topics: + unsubscibers.append( + await mqtt.async_subscribe( + hass, topic, message_received, 1, encoding=None + ) + ) + + try: + return await self._async_get_meter_info(measure_queue) + except TimeoutError: + self._set_base_error(VALIDATION_ERROR_TIMEOUT_READ_MESSAGE) + return None + finally: + for ubsubscribe in unsubscibers: + ubsubscribe() + + # workaround MQTT bug when topic is re-subscribed at setup at the same + # time as unsubscribe runs as a background job + await asyncio.sleep(1) + + async def _async_validate_host_address( + self, loop: asyncio.AbstractEventLoop, user_input: dict[str, Any] + ) -> None: + try: + await loop.getaddrinfo( + user_input[CONF_TCP_HOST], + None, + family=0, + type=socket.SOCK_STREAM, + proto=0, + flags=0, + ) + except OSError as ex: + _LOGGER.debug( + "Could not resolve host '%s': %s", user_input[CONF_TCP_HOST], ex + ) + self.errors[CONF_TCP_HOST] = VALIDATION_ERROR_HOST_CHECK + + def _validate_topics(self, user_input: dict[str, Any]) -> None: + topics = {x.strip() for x in user_input[CONF_MQTT_TOPICS].split(",")} + if not topics: + self.errors[ + CONF_MQTT_TOPICS + ] = VALIDATION_ERROR_MQTT_INVALID_SUBSCRIBE_TOPIC + + for topic in topics: + try: + mqtt.valid_subscribe_topic(topic) + except vol.Invalid: + self.errors[ + CONF_MQTT_TOPICS + ] = VALIDATION_ERROR_MQTT_INVALID_SUBSCRIBE_TOPIC + + def _validate_schema( + self, connection_type: ConnectionType, user_input: dict[str, Any] + ) -> None: + if connection_type == ConnectionType.SERIAL: + schema = vol.Schema( + { + vol.Required(CONF_SERIAL_PORT): cv.string, + vol.Optional(CONF_SERIAL_BAUDRATE): cv.positive_int, + }, + extra=vol.ALLOW_EXTRA, + ) + elif connection_type == ConnectionType.NETWORK: + schema = vol.Schema( + { + vol.Required(CONF_TCP_HOST): cv.matches_regex( + HOSTNAME_IP4_IP6_REGEX + ), + vol.Required(CONF_TCP_PORT): cv.port, + } + ) + elif connection_type == ConnectionType.MQTT: + schema = vol.Schema( + { + vol.Required(CONF_MQTT_TOPICS): cv.string, + } + ) + else: + raise ValueError(f"Unexpected connection type {connection_type}") + + try: + schema(user_input) + except vol.MultipleInvalid as ex: + for err in ex.errors: + for element in err.path: + self.errors[element] = VALIDATION_ERROR_VOLUPTUOUS_BASE + element + + def validate_connection_type_input( + self, user_input: dict[str, Any] + ) -> ConnectionType: + """Validate user input from first step.""" + self.errors = {} + return ConnectionType[user_input["type"].upper()] + + async def async_validate_connection_input( + self, + hass: HomeAssistant, + connection_type: ConnectionType, + user_input: dict[str, Any], + ) -> MeterInfo | None: + """Validate user input from connection step and try connection.""" + self.errors = {} + + self._validate_schema(connection_type, user_input) + + if not self.errors and connection_type == ConnectionType.NETWORK: + await self._async_validate_host_address(hass.loop, user_input) + elif not self.errors and connection_type == ConnectionType.MQTT: + self._validate_topics(user_input) + + if not self.errors and connection_type in ( + ConnectionType.NETWORK, + ConnectionType.SERIAL, + ): + return await self._async_validate_device_connection(hass.loop, user_input) + + if not self.errors and connection_type == ConnectionType.MQTT: + return await self._async_validate_mqtt_connection(hass, user_input) + + return None + + +class AmsHanOptionsFlowHandler(config_entries.OptionsFlow): + """Config flow options handler.""" + + def __init__(self, config_entry) -> None: + """Initialize options flow.""" + self.config_entry = config_entry + self.options = dict(config_entry.options) + + async def async_step_init(self, user_input=None): # pylint: disable=unused-argument + """Manage the options.""" + return await self.async_step_user() + + async def async_step_user(self, user_input=None): + """Handle a flow initialized by the user.""" + if user_input is not None: + self.options.update(user_input) + return self.async_create_entry(title="", data=self.options) + + options = { + vol.Optional( + CONF_OPTIONS_SCALE_FACTOR, + default=self.options.get(CONF_OPTIONS_SCALE_FACTOR, 1.0), + ): cv.positive_float, + } + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema(options), + ) diff --git a/custom_components/amshan/const.py b/custom_components/amshan/const.py index a374fd8..8b57932 100644 --- a/custom_components/amshan/const.py +++ b/custom_components/amshan/const.py @@ -1,48 +1,48 @@ -"""Constants for the AMS HAN meter integration.""" -from __future__ import annotations - -from homeassistant import const as ha_const - -DOMAIN = "amshan" - -ICON_POWER_IMPORT = "mdi:flash" -ICON_POWER_EXPORT = "mdi:flash-outline" -ICON_CURRENT = "mdi:current-ac" -ICON_VOLTAGE = "mdi:alpha-v-box-outline" -ICON_COUNTER = "mdi:counter" - -UNIT_KILO_VOLT_AMPERE_REACTIVE_HOURS = "kVArh" - -IPV4_ADR_REGEX = ( - "(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}" - "([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])" -) -IPV6_ADR_REGEX = "(?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4}" -HOSTNAME_REGEX = ( - "(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\\-]*[a-zA-Z0-9])\\.)*" - "([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\\-]*[A-Za-z0-9])" -) -HOSTNAME_IP4_IP6_REGEX = ( - "^(" + IPV4_ADR_REGEX + ")|(" + HOSTNAME_REGEX + ")|(" + IPV6_ADR_REGEX + ")$" -) - -# Configuration and options - -CONF_CONNECTION_TYPE = "connection_type" -CONF_CONNECTION_CONFIG = "connection" - -CONF_SERIAL_PORT = ha_const.CONF_PORT -CONF_SERIAL_BAUDRATE = "baudrate" -CONF_SERIAL_PARITY = "parity" -CONF_SERIAL_BYTESIZE = "bytesize" -CONF_SERIAL_STOPBITS = "stopbits" -CONF_SERIAL_XONXOFF = "xonxoff" -CONF_SERIAL_RTSCTS = "rtscts" -CONF_SERIAL_DSRDTR = "dsrdtr" - -CONF_TCP_HOST = ha_const.CONF_HOST -CONF_TCP_PORT = ha_const.CONF_PORT - -CONF_MQTT_TOPICS = "mqtt_topics" - -CONF_OPTIONS_SCALE_FACTOR = "scale_factor" +"""Constants for the AMS HAN meter integration.""" +from __future__ import annotations + +from homeassistant import const as ha_const + +DOMAIN = "amshan" + +ICON_POWER_IMPORT = "mdi:flash" +ICON_POWER_EXPORT = "mdi:flash-outline" +ICON_CURRENT = "mdi:current-ac" +ICON_VOLTAGE = "mdi:alpha-v-box-outline" +ICON_COUNTER = "mdi:counter" + +UNIT_KILO_VOLT_AMPERE_REACTIVE_HOURS = "kVArh" + +IPV4_ADR_REGEX = ( + "(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}" + "([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])" +) +IPV6_ADR_REGEX = "(?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4}" +HOSTNAME_REGEX = ( + "(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\\-]*[a-zA-Z0-9])\\.)*" + "([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\\-]*[A-Za-z0-9])" +) +HOSTNAME_IP4_IP6_REGEX = ( + "^(" + IPV4_ADR_REGEX + ")|(" + HOSTNAME_REGEX + ")|(" + IPV6_ADR_REGEX + ")$" +) + +# Configuration and options + +CONF_CONNECTION_TYPE = "connection_type" +CONF_CONNECTION_CONFIG = "connection" + +CONF_SERIAL_PORT = ha_const.CONF_PORT +CONF_SERIAL_BAUDRATE = "baudrate" +CONF_SERIAL_PARITY = "parity" +CONF_SERIAL_BYTESIZE = "bytesize" +CONF_SERIAL_STOPBITS = "stopbits" +CONF_SERIAL_XONXOFF = "xonxoff" +CONF_SERIAL_RTSCTS = "rtscts" +CONF_SERIAL_DSRDTR = "dsrdtr" + +CONF_TCP_HOST = ha_const.CONF_HOST +CONF_TCP_PORT = ha_const.CONF_PORT + +CONF_MQTT_TOPICS = "mqtt_topics" + +CONF_OPTIONS_SCALE_FACTOR = "scale_factor" diff --git a/custom_components/amshan/diagnostics.py b/custom_components/amshan/diagnostics.py index 4fabab2..b657536 100644 --- a/custom_components/amshan/diagnostics.py +++ b/custom_components/amshan/diagnostics.py @@ -1,15 +1,15 @@ -"""AMSHAN diagnostics.""" -from __future__ import annotations - -from typing import Any - -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant - - -async def async_get_config_entry_diagnostics( - hass: HomeAssistant, config_entry: ConfigEntry -) -> dict[str, Any]: - """Return diagnostics for a config entry.""" - diagnostics = {"config_entry": config_entry.as_dict()} - return diagnostics +"""AMSHAN diagnostics.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + diagnostics = {"config_entry": config_entry.as_dict()} + return diagnostics diff --git a/custom_components/amshan/manifest.json b/custom_components/amshan/manifest.json index a2dc886..892d631 100644 --- a/custom_components/amshan/manifest.json +++ b/custom_components/amshan/manifest.json @@ -1,18 +1,18 @@ -{ - "domain": "amshan", - "name": "AMS HAN meter", - "after_dependencies": [ - "mqtt" - ], - "codeowners": [ - "@toreamun" - ], - "config_flow": true, - "documentation": "https://github.com/toreamun/amshan-homeassistant", - "iot_class": "local_push", - "issue_tracker": "https://github.com/toreamun/amshan-homeassistant/issues", - "requirements": [ - "amshan[serial]==2.1.1" - ], - "version": "2023.6.0" -} +{ + "domain": "amshan", + "name": "AMS HAN meter", + "after_dependencies": [ + "mqtt" + ], + "codeowners": [ + "@toreamun" + ], + "config_flow": true, + "documentation": "https://github.com/toreamun/amshan-homeassistant", + "iot_class": "local_push", + "issue_tracker": "https://github.com/toreamun/amshan-homeassistant/issues", + "requirements": [ + "amshan[serial]==2.1.1" + ], + "version": "2023.6.0" +} diff --git a/custom_components/amshan/metercon.py b/custom_components/amshan/metercon.py index 2525fc6..4823e9d 100644 --- a/custom_components/amshan/metercon.py +++ b/custom_components/amshan/metercon.py @@ -1,251 +1,250 @@ -"""Meter connection module.""" - -from __future__ import annotations - -import asyncio -import json -import logging -from typing import Any, Callable, Mapping - -from han import ( - common as han_type, - dlde, - hdlc, - meter_connection, - serial_connection_factory as han_serial, - tcp_connection_factory as han_tcp, -) -from homeassistant.components import mqtt -from homeassistant.core import callback -from homeassistant.helpers.typing import HomeAssistantType - -from .const import ( - CONF_MQTT_TOPICS, - CONF_SERIAL_BAUDRATE, - CONF_SERIAL_BYTESIZE, - CONF_SERIAL_DSRDTR, - CONF_SERIAL_PARITY, - CONF_SERIAL_PORT, - CONF_SERIAL_RTSCTS, - CONF_SERIAL_STOPBITS, - CONF_SERIAL_XONXOFF, - CONF_TCP_HOST, - CONF_TCP_PORT, -) - -_LOGGER: logging.Logger = logging.getLogger(__name__) - - -def setup_meter_connection( - loop: asyncio.AbstractEventLoop, - config: Mapping[str, Any], - measure_queue: asyncio.Queue[han_type.MeterMessageBase], -) -> meter_connection.ConnectionManager: - """Initialize ConnectionManager using configured connection type.""" - connection_factory = get_connection_factory(loop, config, measure_queue) - return meter_connection.ConnectionManager(connection_factory) - - -def get_connection_factory( - loop: asyncio.AbstractEventLoop, - config: Mapping[str, Any], - measure_queue: asyncio.Queue[han_type.MeterMessageBase], -) -> meter_connection.AsyncConnectionFactory: - """Get connection factory based on configured connection type.""" - - async def tcp_connection_factory() -> meter_connection.MeterTransportProtocol: - return await han_tcp.create_tcp_message_connection( - measure_queue, - loop, - None, - host=config[CONF_TCP_HOST], - port=config[CONF_TCP_PORT], - ) - - async def serial_connection_factory() -> meter_connection.MeterTransportProtocol: - return await han_serial.create_serial_message_connection( - measure_queue, - loop, - None, - url=config[CONF_SERIAL_PORT], - baudrate=config[CONF_SERIAL_BAUDRATE], - parity=config[CONF_SERIAL_PARITY], - bytesize=config[CONF_SERIAL_BYTESIZE], - stopbits=float(config[CONF_SERIAL_STOPBITS]), - xonxoff=config[CONF_SERIAL_XONXOFF], - rtscts=config[CONF_SERIAL_RTSCTS], - dsrdtr=config[CONF_SERIAL_DSRDTR], - ) - - # select tcp or serial connection factory - connection_factory = ( - tcp_connection_factory if CONF_TCP_HOST in config else serial_connection_factory - ) - - return connection_factory - - -async def async_setup_meter_mqtt_subscriptions( - hass: HomeAssistantType, - config: Mapping[str, Any], - measure_queue: asyncio.Queue[han_type.MeterMessageBase], -) -> Callable: - """Set up MQTT topic subscriptions.""" - - @callback - def message_received(mqtt_message: mqtt.models.ReceiveMessage) -> None: - """Handle new MQTT messages.""" - _LOGGER.debug( - ( - "Message with timestamp %s, QOS %d, retain flagg %s, and payload length %d received " - "from topic %s from subscription to topic %s" - ), - mqtt_message.timestamp, - mqtt_message.qos, - bool(mqtt_message.retain), - len(mqtt_message.payload), - mqtt_message.topic, - mqtt_message.subscribed_topic, - ) - meter_message = get_meter_message(mqtt_message) - if meter_message: - measure_queue.put_nowait(meter_message) - - unsubscibers: list[Callable] = [] - topics = {x.strip() for x in config[CONF_MQTT_TOPICS].split(",")} - - _LOGGER.debug("Try to subscribe to %d MQTT topic(s): %s", len(topics), topics) - for topic in topics: - unsubscibers.append( - await mqtt.async_subscribe(hass, topic, message_received, 1, encoding=None) - ) - _LOGGER.debug( - "Successfully subscribed to %d MQTT topic(s): %s", len(topics), topics - ) - - @callback - def unsubscribe_mqtt(): - _LOGGER.debug("Unsubscribe %d MQTT topic(s): %s", len(unsubscibers), topics) - for unsubscribe in unsubscibers: - unsubscribe() - - return unsubscribe_mqtt - - -def get_meter_message( - mqtt_message: mqtt.models.ReceiveMessage, -) -> han_type.MeterMessageBase | None: - """Get frame information part from mqtt message.""" - # Try first to read as HDLC-frame. - message = _try_read_meter_message(mqtt_message.payload) - if message is not None: - if message.message_type == han_type.MeterMessageType.P1: - if message.is_valid: - _LOGGER.debug( - "Got valid P1 message from topic %s: %s", - mqtt_message.topic, - mqtt_message.payload.hex(), - ) - return message - - _LOGGER.debug( - "Got invalid P1 message from topic %s: %s", - mqtt_message.topic, - mqtt_message.payload.hex(), - ) - - return None - - if message.is_valid: - if message.payload is not None: - _LOGGER.debug( - "Got valid frame of expected length with correct checksum from topic %s: %s", - mqtt_message.topic, - mqtt_message.payload.hex(), - ) - return message - - _LOGGER.debug( - "Got empty frame of expected length with correct checksum from topic %s: %s", - mqtt_message.topic, - mqtt_message.payload.hex(), - ) - - _LOGGER.debug( - "Got invalid frame from topic %s: %s", - mqtt_message.topic, - mqtt_message.payload.hex(), - ) - return None - - try: - json_data = json.loads(mqtt_message.payload) - if isinstance(json_data, dict): - _LOGGER.debug( - "Ignore JSON in payload without HDLC framing from topic %s: %s", - mqtt_message.topic, - json_data, - ) - return None - except ValueError: - pass - - _LOGGER.debug( - "Got payload without HDLC framing from topic %s: %s", - mqtt_message.topic, - mqtt_message.payload.hex(), - ) - - binary = ( - _payload_to_binary(mqtt_message.payload) - if _is_hex_string(mqtt_message.payload) - else mqtt_message.payload - ) - return han_type.DlmsMessage(binary) - - -def _try_read_meter_message(payload: bytes) -> han_type.MeterMessageBase | None: - """Try to parse HDLC-frame from payload.""" - if payload.startswith(b"/"): - try: - return dlde.DataReadout(payload) - except ValueError as ex: - _LOGGER.debug("Starts with '/', but not a valid P1 message: %s", ex) - - frame_reader = hdlc.HdlcFrameReader(False) - - # Reader expects flag sequence in start and end. - flag_seqeuence = hdlc.HdlcFrameReader.FLAG_SEQUENCE.to_bytes(1, byteorder="big") - if not payload.startswith(flag_seqeuence): - frame_reader.read(flag_seqeuence) - - frames = frame_reader.read(payload) - if len(frames) == 0: - # add flag sequence to the end - frames = frame_reader.read(flag_seqeuence) - - if len(frames) > 0: - return frames[0] - - if not _is_hex_string(payload): - return None - - return _try_read_meter_message(_payload_to_binary(payload)) - - -def _is_hex_string(payload: bytes) -> bool: - if (len(payload) % 2) == 0: - try: - int(payload, 16) - return True - except ValueError: - return False - return False - - -def _payload_to_binary(payload) -> bytes: - return ( - bytes.fromhex(payload) - if isinstance(payload, str) - else bytes.fromhex(payload.decode("utf8")) - ) +"""Meter connection module.""" + +from __future__ import annotations + +import asyncio +import json +import logging +from typing import Any, Callable, Mapping + +from han import ( + common as han_type, + dlde, + hdlc, + meter_connection, + serial_connection_factory as han_serial, + tcp_connection_factory as han_tcp, +) +from homeassistant.components import mqtt +from homeassistant.core import callback, HomeAssistant + +from .const import ( + CONF_MQTT_TOPICS, + CONF_SERIAL_BAUDRATE, + CONF_SERIAL_BYTESIZE, + CONF_SERIAL_DSRDTR, + CONF_SERIAL_PARITY, + CONF_SERIAL_PORT, + CONF_SERIAL_RTSCTS, + CONF_SERIAL_STOPBITS, + CONF_SERIAL_XONXOFF, + CONF_TCP_HOST, + CONF_TCP_PORT, +) + +_LOGGER: logging.Logger = logging.getLogger(__name__) + + +def setup_meter_connection( + loop: asyncio.AbstractEventLoop, + config: Mapping[str, Any], + measure_queue: asyncio.Queue[han_type.MeterMessageBase], +) -> meter_connection.ConnectionManager: + """Initialize ConnectionManager using configured connection type.""" + connection_factory = get_connection_factory(loop, config, measure_queue) + return meter_connection.ConnectionManager(connection_factory) + + +def get_connection_factory( + loop: asyncio.AbstractEventLoop, + config: Mapping[str, Any], + measure_queue: asyncio.Queue[han_type.MeterMessageBase], +) -> meter_connection.AsyncConnectionFactory: + """Get connection factory based on configured connection type.""" + + async def tcp_connection_factory() -> meter_connection.MeterTransportProtocol: + return await han_tcp.create_tcp_message_connection( + measure_queue, + loop, + None, + host=config[CONF_TCP_HOST], + port=config[CONF_TCP_PORT], + ) + + async def serial_connection_factory() -> meter_connection.MeterTransportProtocol: + return await han_serial.create_serial_message_connection( + measure_queue, + loop, + None, + url=config[CONF_SERIAL_PORT], + baudrate=config[CONF_SERIAL_BAUDRATE], + parity=config[CONF_SERIAL_PARITY], + bytesize=config[CONF_SERIAL_BYTESIZE], + stopbits=float(config[CONF_SERIAL_STOPBITS]), + xonxoff=config[CONF_SERIAL_XONXOFF], + rtscts=config[CONF_SERIAL_RTSCTS], + dsrdtr=config[CONF_SERIAL_DSRDTR], + ) + + # select tcp or serial connection factory + connection_factory = ( + tcp_connection_factory if CONF_TCP_HOST in config else serial_connection_factory + ) + + return connection_factory + + +async def async_setup_meter_mqtt_subscriptions( + hass: HomeAssistant, + config: Mapping[str, Any], + measure_queue: asyncio.Queue[han_type.MeterMessageBase], +) -> Callable: + """Set up MQTT topic subscriptions.""" + + @callback + def message_received(mqtt_message: mqtt.models.ReceiveMessage) -> None: + """Handle new MQTT messages.""" + _LOGGER.debug( + ( + "Message with timestamp %s, QOS %d, retain flagg %s, and payload length %d received " + "from topic %s from subscription to topic %s" + ), + mqtt_message.timestamp, + mqtt_message.qos, + bool(mqtt_message.retain), + len(mqtt_message.payload), + mqtt_message.topic, + mqtt_message.subscribed_topic, + ) + meter_message = get_meter_message(mqtt_message) + if meter_message: + measure_queue.put_nowait(meter_message) + + unsubscibers: list[Callable] = [] + topics = {x.strip() for x in config[CONF_MQTT_TOPICS].split(",")} + + _LOGGER.debug("Try to subscribe to %d MQTT topic(s): %s", len(topics), topics) + for topic in topics: + unsubscibers.append( + await mqtt.async_subscribe(hass, topic, message_received, 1, encoding=None) + ) + _LOGGER.debug( + "Successfully subscribed to %d MQTT topic(s): %s", len(topics), topics + ) + + @callback + def unsubscribe_mqtt(): + _LOGGER.debug("Unsubscribe %d MQTT topic(s): %s", len(unsubscibers), topics) + for unsubscribe in unsubscibers: + unsubscribe() + + return unsubscribe_mqtt + + +def get_meter_message( + mqtt_message: mqtt.models.ReceiveMessage, +) -> han_type.MeterMessageBase | None: + """Get frame information part from mqtt message.""" + # Try first to read as HDLC-frame. + message = _try_read_meter_message(mqtt_message.payload) + if message is not None: + if message.message_type == han_type.MeterMessageType.P1: + if message.is_valid: + _LOGGER.debug( + "Got valid P1 message from topic %s: %s", + mqtt_message.topic, + mqtt_message.payload.hex(), + ) + return message + + _LOGGER.debug( + "Got invalid P1 message from topic %s: %s", + mqtt_message.topic, + mqtt_message.payload.hex(), + ) + + return None + + if message.is_valid: + if message.payload is not None: + _LOGGER.debug( + "Got valid frame of expected length with correct checksum from topic %s: %s", + mqtt_message.topic, + mqtt_message.payload.hex(), + ) + return message + + _LOGGER.debug( + "Got empty frame of expected length with correct checksum from topic %s: %s", + mqtt_message.topic, + mqtt_message.payload.hex(), + ) + + _LOGGER.debug( + "Got invalid frame from topic %s: %s", + mqtt_message.topic, + mqtt_message.payload.hex(), + ) + return None + + try: + json_data = json.loads(mqtt_message.payload) + if isinstance(json_data, dict): + _LOGGER.debug( + "Ignore JSON in payload without HDLC framing from topic %s: %s", + mqtt_message.topic, + json_data, + ) + return None + except ValueError: + pass + + _LOGGER.debug( + "Got payload without HDLC framing from topic %s: %s", + mqtt_message.topic, + mqtt_message.payload.hex(), + ) + + binary = ( + _payload_to_binary(mqtt_message.payload) + if _is_hex_string(mqtt_message.payload) + else mqtt_message.payload + ) + return han_type.DlmsMessage(binary) + + +def _try_read_meter_message(payload: bytes) -> han_type.MeterMessageBase | None: + """Try to parse HDLC-frame from payload.""" + if payload.startswith(b"/"): + try: + return dlde.DataReadout(payload) + except ValueError as ex: + _LOGGER.debug("Starts with '/', but not a valid P1 message: %s", ex) + + frame_reader = hdlc.HdlcFrameReader(False) + + # Reader expects flag sequence in start and end. + flag_seqeuence = hdlc.HdlcFrameReader.FLAG_SEQUENCE.to_bytes(1, byteorder="big") + if not payload.startswith(flag_seqeuence): + frame_reader.read(flag_seqeuence) + + frames = frame_reader.read(payload) + if len(frames) == 0: + # add flag sequence to the end + frames = frame_reader.read(flag_seqeuence) + + if len(frames) > 0: + return frames[0] + + if not _is_hex_string(payload): + return None + + return _try_read_meter_message(_payload_to_binary(payload)) + + +def _is_hex_string(payload: bytes) -> bool: + if (len(payload) % 2) == 0: + try: + int(payload, 16) + return True + except ValueError: + return False + return False + + +def _payload_to_binary(payload) -> bytes: + return ( + bytes.fromhex(payload) + if isinstance(payload, str) + else bytes.fromhex(payload.decode("utf8")) + ) diff --git a/custom_components/amshan/sensor.py b/custom_components/amshan/sensor.py index 059be75..765b931 100644 --- a/custom_components/amshan/sensor.py +++ b/custom_components/amshan/sensor.py @@ -1,661 +1,660 @@ -"""amshan platform.""" - -from __future__ import annotations - -import asyncio -from dataclasses import dataclass -import datetime as dt -import logging -import math -from typing import Callable, Iterable, cast - -from han import autodecoder, common as han_type, obis_map -from homeassistant import const as ha_const -from homeassistant.components.sensor import ( - SensorDeviceClass, - SensorEntity, - SensorEntityDescription, - SensorStateClass, -) -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import State, callback -from homeassistant.helpers import dispatcher, entity, restore_state -from homeassistant.helpers.typing import HomeAssistantType -from homeassistant.util import dt as dt_util - -from . import AmsHanIntegration, MeterInfo, StopMessage -from .const import ( - CONF_OPTIONS_SCALE_FACTOR, - DOMAIN, - ICON_COUNTER, - ICON_CURRENT, - ICON_POWER_EXPORT, - ICON_POWER_IMPORT, - ICON_VOLTAGE, - UNIT_KILO_VOLT_AMPERE_REACTIVE_HOURS, -) - -_LOGGER: logging.Logger = logging.getLogger(__name__) - - -@dataclass -class AmsHanSensorEntityDescription(SensorEntityDescription): - """A class that describes sensor entities.""" - - scale: float | None = None - """Scaling, if any, to be done one the measured value to be in correct unit.""" - - decimals: int | None = None - """Specify a number to round the measure source value to that number of decimals.""" - - use_configured_scaling: bool = False - """Use custom configured scaling.""" - - is_hour_sensor: bool = False - """Is the sensor updated only each hour.""" - - -SENSOR_TYPES: dict[str, AmsHanSensorEntityDescription] = { - sensor.key: sensor - for sensor in [ - AmsHanSensorEntityDescription( - key=obis_map.FIELD_METER_ID, - entity_category=entity.EntityCategory.DIAGNOSTIC, - name="Meter ID", - use_configured_scaling=False, - ), - AmsHanSensorEntityDescription( - key=obis_map.FIELD_METER_MANUFACTURER, - entity_category=entity.EntityCategory.DIAGNOSTIC, - name="Meter manufacturer", - use_configured_scaling=False, - ), - AmsHanSensorEntityDescription( - key=obis_map.FIELD_METER_MANUFACTURER_ID, - entity_category=entity.EntityCategory.DIAGNOSTIC, - name="Meter manufacturer ID", - use_configured_scaling=False, - ), - AmsHanSensorEntityDescription( - key=obis_map.FIELD_METER_TYPE, - entity_category=entity.EntityCategory.DIAGNOSTIC, - name="Meter type", - use_configured_scaling=False, - ), - AmsHanSensorEntityDescription( - key=obis_map.FIELD_OBIS_LIST_VER_ID, - entity_category=entity.EntityCategory.DIAGNOSTIC, - name="OBIS List version identifier", - use_configured_scaling=False, - ), - AmsHanSensorEntityDescription( - key=obis_map.FIELD_ACTIVE_POWER_IMPORT, - device_class=SensorDeviceClass.POWER, - native_unit_of_measurement=ha_const.POWER_WATT, - state_class=SensorStateClass.MEASUREMENT, - icon=ICON_POWER_IMPORT, - name="Active power import (Q1+Q4)", - decimals=0, - use_configured_scaling=True, - ), - AmsHanSensorEntityDescription( - key=obis_map.FIELD_ACTIVE_POWER_EXPORT, - device_class=SensorDeviceClass.POWER, - native_unit_of_measurement=ha_const.POWER_WATT, - state_class=SensorStateClass.MEASUREMENT, - icon=ICON_POWER_EXPORT, - name="Active power export (Q2+Q3)", - decimals=0, - use_configured_scaling=True, - ), - AmsHanSensorEntityDescription( - key=obis_map.FIELD_REACTIVE_POWER_IMPORT, - device_class=SensorDeviceClass.REACTIVE_POWER, - native_unit_of_measurement=ha_const.POWER_VOLT_AMPERE_REACTIVE, - state_class=SensorStateClass.MEASUREMENT, - icon=ICON_POWER_IMPORT, - name="Reactive power import (Q1+Q2)", - decimals=0, - use_configured_scaling=True, - ), - AmsHanSensorEntityDescription( - key=obis_map.FIELD_REACTIVE_POWER_EXPORT, - device_class=SensorDeviceClass.REACTIVE_POWER, - native_unit_of_measurement=ha_const.POWER_VOLT_AMPERE_REACTIVE, - state_class=SensorStateClass.MEASUREMENT, - icon=ICON_POWER_EXPORT, - name="Reactive power export (Q3+Q4)", - decimals=0, - use_configured_scaling=True, - ), - AmsHanSensorEntityDescription( - key=obis_map.FIELD_CURRENT_L1, - device_class=SensorDeviceClass.CURRENT, - native_unit_of_measurement=ha_const.ELECTRIC_CURRENT_AMPERE, - state_class=SensorStateClass.MEASUREMENT, - icon=ICON_CURRENT, - name="Current phase L1", - decimals=3, - use_configured_scaling=True, - ), - AmsHanSensorEntityDescription( - key=obis_map.FIELD_CURRENT_L2, - device_class=SensorDeviceClass.CURRENT, - native_unit_of_measurement=ha_const.ELECTRIC_CURRENT_AMPERE, - state_class=SensorStateClass.MEASUREMENT, - name="Current phase L2", - decimals=3, - use_configured_scaling=True, - ), - AmsHanSensorEntityDescription( - key=obis_map.FIELD_CURRENT_L3, - device_class=SensorDeviceClass.CURRENT, - native_unit_of_measurement=ha_const.ELECTRIC_CURRENT_AMPERE, - state_class=SensorStateClass.MEASUREMENT, - name="Current phase L3", - decimals=3, - use_configured_scaling=True, - ), - AmsHanSensorEntityDescription( - key=obis_map.FIELD_VOLTAGE_L1, - device_class=SensorDeviceClass.VOLTAGE, - native_unit_of_measurement=ha_const.ELECTRIC_POTENTIAL_VOLT, - state_class=SensorStateClass.MEASUREMENT, - icon=ICON_VOLTAGE, - name="Phase L1 voltage", - decimals=1, - use_configured_scaling=False, - ), - AmsHanSensorEntityDescription( - key=obis_map.FIELD_VOLTAGE_L2, - device_class=SensorDeviceClass.VOLTAGE, - native_unit_of_measurement=ha_const.ELECTRIC_POTENTIAL_VOLT, - state_class=SensorStateClass.MEASUREMENT, - icon=ICON_VOLTAGE, - name="Phase L2 voltage", - decimals=1, - use_configured_scaling=False, - ), - AmsHanSensorEntityDescription( - key=obis_map.FIELD_VOLTAGE_L3, - device_class=SensorDeviceClass.VOLTAGE, - native_unit_of_measurement=ha_const.ELECTRIC_POTENTIAL_VOLT, - state_class=SensorStateClass.MEASUREMENT, - icon=ICON_VOLTAGE, - name="Phase L3 voltage", - decimals=1, - use_configured_scaling=False, - ), - AmsHanSensorEntityDescription( - key=obis_map.FIELD_ACTIVE_POWER_IMPORT_TOTAL, - device_class=SensorDeviceClass.ENERGY, - native_unit_of_measurement=ha_const.ENERGY_KILO_WATT_HOUR, - state_class=SensorStateClass.TOTAL_INCREASING, - icon=ICON_COUNTER, - name="Cumulative hourly active import energy (A+) (Q1+Q4)", - scale=0.001, - decimals=2, - use_configured_scaling=True, - is_hour_sensor=True, - ), - AmsHanSensorEntityDescription( - key=obis_map.FIELD_ACTIVE_POWER_EXPORT_TOTAL, - device_class=SensorDeviceClass.ENERGY, - native_unit_of_measurement=ha_const.ENERGY_KILO_WATT_HOUR, - state_class=SensorStateClass.TOTAL_INCREASING, - icon=ICON_COUNTER, - name="Cumulative hourly active export energy (A-) (Q2+Q3)", - scale=0.001, - decimals=2, - use_configured_scaling=True, - is_hour_sensor=True, - ), - AmsHanSensorEntityDescription( - key=obis_map.FIELD_REACTIVE_POWER_IMPORT_TOTAL, - native_unit_of_measurement=UNIT_KILO_VOLT_AMPERE_REACTIVE_HOURS, - state_class=SensorStateClass.TOTAL_INCREASING, - icon=ICON_COUNTER, - name="Cumulative hourly reactive import energy (R+) (Q1+Q2)", - scale=0.001, - decimals=2, - use_configured_scaling=True, - is_hour_sensor=True, - ), - AmsHanSensorEntityDescription( - key=obis_map.FIELD_REACTIVE_POWER_EXPORT_TOTAL, - native_unit_of_measurement=UNIT_KILO_VOLT_AMPERE_REACTIVE_HOURS, - state_class=SensorStateClass.TOTAL_INCREASING, - icon=ICON_COUNTER, - name="Cumulative hourly reactive export energy (R-) (Q3+Q4)", - scale=0.001, - decimals=2, - use_configured_scaling=True, - is_hour_sensor=True, - ), - ] -} - - -async def async_setup_entry( - hass: HomeAssistantType, - config_entry: ConfigEntry, - async_add_entities: Callable[[list[entity.Entity], bool], None], -): - """Add hantest sensor platform from a config_entry.""" - _LOGGER.debug("Sensor async_setup_entry starting.") - - integration: AmsHanIntegration = hass.data[DOMAIN][config_entry.entry_id] - processor: MeterMeasureProcessor = MeterMeasureProcessor( - hass, config_entry, async_add_entities, integration.measure_queue - ) - - # start processing loop task - integration.add_task(hass.loop.create_task(processor.async_process_measures_loop())) - - _LOGGER.debug("Sensor async_setup_entry ended.") - - -class AmsHanEntity(SensorEntity): - """Representation of a AmsHan sensor.""" - - def __init__( - self, - entity_description: AmsHanSensorEntityDescription, - measure_data: dict[str, str | int | float | dt.datetime], - new_measure_signal_name: str, - scale_factor: float, - meter_info: MeterInfo, - config_entry_id: str, - ) -> None: - """Initialize AmsHanEntity class.""" - if entity_description is None: - raise TypeError("entity_description is required") - if measure_data is None: - raise TypeError("measure_data is required") - if obis_map.FIELD_METER_ID not in measure_data and ( - obis_map.FIELD_METER_MANUFACTURER not in measure_data - and obis_map.FIELD_METER_MANUFACTURER_ID not in measure_data - ): - raise ValueError( - ( - f"Expected element {obis_map.FIELD_METER_ID} or " - f"{obis_map.FIELD_METER_MANUFACTURER} / {obis_map.FIELD_METER_MANUFACTURER_ID} not in measure_data." - ) - ) - if new_measure_signal_name is None: - raise TypeError("new_measure_signal_name is required") - - self.entity_description: AmsHanSensorEntityDescription = entity_description - self._measure_data = measure_data - self._new_measure_signal_name = new_measure_signal_name - self._async_remove_dispatcher: Callable[[], None] | None = None - self._meter_info: MeterInfo = ( - meter_info if meter_info else MeterInfo.from_measure_data(measure_data) - ) - self._scale_factor = ( - int(scale_factor) - if scale_factor == math.floor(scale_factor) - else scale_factor - ) - self._config_entry_id = config_entry_id - - manufacturer = ( - self._meter_info.manufacturer - if self._meter_info.manufacturer - else self._meter_info.manufacturer_id - ) - self.entity_id = f"sensor.{manufacturer}_{entity_description.key}".lower() - self._unique_id = None - - @staticmethod - def is_measure_id_supported(measure_id: str) -> bool: - """Check if an entity can be created for measure id.""" - return measure_id in SENSOR_TYPES - - async def async_added_to_hass(self) -> None: - """Run when entity about to be added to hass.""" - - @callback - def on_new_measure( - measure_data: dict[str, str | int | float | dt.datetime] - ) -> None: - if self.measure_id in measure_data: - self._measure_data = measure_data - if _LOGGER.isEnabledFor(logging.DEBUG): - _LOGGER.debug( - "Update sensor %s with state %s", - self.unique_id, - self.state, - ) - self.async_write_ha_state() - - # subscribe to update events for this meter - self._async_remove_dispatcher = dispatcher.async_dispatcher_connect( - cast(HomeAssistantType, self.hass), - self._new_measure_signal_name, - on_new_measure, - ) - - async def async_will_remove_from_hass(self) -> None: - """Run when entity will be removed from hass.""" - if self._async_remove_dispatcher: - self._async_remove_dispatcher() - - @property - def measure_id(self) -> str: - """Return the measure_id handled by this entity.""" - return self.entity_description.key - - @property - def should_poll(self) -> bool: - """Return False since updates are pushed from this sensor.""" - return False - - @property - def unique_id(self) -> str | None: - """Return the unique id.""" - if self._unique_id is None: - if self._meter_info.meter_id: - self._unique_id = ( - f"{self._meter_info.manufacturer}-{self._meter_info.meter_id}-" - f"{self.measure_id}" - ) - else: - manufacturer = { - self._meter_info.manufacturer_id - if self._meter_info.manufacturer_id - else self._meter_info.manufacturer - } - self._unique_id = ( - f"CEID-{self._config_entry_id}-" - f"{manufacturer}{self._meter_info.type_id}" - f"-{self.measure_id}" - ) - return self._unique_id - - @property - def native_value(self) -> None | str | int | float: - """Return the native value of the entity.""" - measure = self._measure_data.get(self.measure_id) - - if measure is None: - return None - - if isinstance(measure, str): - return measure - - if isinstance(measure, dt.datetime): - return measure.isoformat() - - if self.entity_description.scale is not None: - measure = measure * self.entity_description.scale - - if self.entity_description.use_configured_scaling: - measure = measure * self._scale_factor - - if self.entity_description.decimals is not None: - measure = ( - round(measure) - if self.entity_description.decimals == 0 - else round(measure, self.entity_description.decimals) - ) - - return measure - - @property - def device_info(self) -> entity.DeviceInfo: - """Return device specific attributes.""" - manufacturer = ( - self._meter_info.manufacturer - if self._meter_info.manufacturer - else self._meter_info.manufacturer_id - ) - - meter_type = ( - self._meter_info.type if self._meter_info.type else self._meter_info.type_id - ) - - return entity.DeviceInfo( - name=f"{manufacturer} {meter_type}", - identifiers={ - ( - DOMAIN, - self._meter_info.unique_id - if self._meter_info.unique_id - else self._config_entry_id, - ) - }, - manufacturer=manufacturer, - model=meter_type, - sw_version=self._meter_info.list_version_id, - ) - - -class AmsHanHourlyEntity(AmsHanEntity, restore_state.RestoreEntity): - """Representation of a AmsHan sensor each hour.""" - - def __init__( - self, - entity_description: AmsHanSensorEntityDescription, - measure_data: dict[str, str | int | float | dt.datetime], - new_measure_signal_name: str, - scale_factor: float, - meter_info: MeterInfo, - config_entry_id: str, - ) -> None: - """Initialize AmsHanHourlyEntity class.""" - super().__init__( - entity_description, - measure_data, - new_measure_signal_name, - scale_factor, - meter_info, - config_entry_id, - ) - self._restored_last_state: State | None = None - - async def async_added_to_hass(self) -> None: - """Run when entity about to be added to hass.""" - self._restored_last_state = await self.async_get_last_state() - if ( - self._restored_last_state - and self._restored_last_state.state == ha_const.STATE_UNKNOWN - ): - _LOGGER.debug( - "Restored state from %s for sensor %s is unknown. No need to keep.", - self._restored_last_state.last_updated, - self.unique_id, - ) - self._restored_last_state = None - - await super().async_added_to_hass() - - @property - def native_value(self) -> None | str | int | float: - """Return the native value from current measure or cache if from current hour.""" - measured_value = super().native_value - if measured_value is not None: - self._restored_last_state = None # no need for restored state anymore - return measured_value - - if self._restored_last_state: - if self._is_restored_state_from_current_hour(): - _LOGGER.debug( - "Use restored state from %s for sensor %s", - self._restored_last_state.last_updated, - self.unique_id, - ) - return self._restored_last_state.state - - _LOGGER.debug( - "Restored state from %s for sensor %s is too old to be used", - self._restored_last_state.last_updated, - self.unique_id, - ) - - self._restored_last_state = None - - return None - - def _is_restored_state_from_current_hour(self): - now = dt_util.utcnow() - time_since_update = now - self._restored_last_state.last_updated - return ( - now.hour == self._restored_last_state.last_updated.hour - and time_since_update < dt.timedelta(hours=1) - ) - - -class MeterMeasureProcessor: - """Process meter measures from queue and setup/update entities.""" - - def __init__( - self, - hass: HomeAssistantType, - config_entry: ConfigEntry, - async_add_entities: Callable[[list[entity.Entity], bool], None], - measure_queue: asyncio.Queue[han_type.MeterMessageBase], - ) -> None: - """Initialize MeterMeasureProcessor class.""" - self._hass = hass - self._async_add_entities = async_add_entities - self._measure_queue = measure_queue - self._decoder: autodecoder.AutoDecoder = autodecoder.AutoDecoder() - self._known_measures: set[str] = set() - self._new_measure_signal_name: str | None = None - self._scale_factor = float( - config_entry.options.get(CONF_OPTIONS_SCALE_FACTOR, 1) - ) - self._config_entry_id: str = config_entry.entry_id - self._meter_info: MeterInfo | None = None - - async def async_process_measures_loop(self) -> None: - """Start the processing loop. The method exits when StopMessage is received from queue.""" - _LOGGER.debug("Processing loop starting.") - while True: - try: - message = await self._async_decode_next_valid_message() - if not message: - _LOGGER.debug("Received stop signal. Exit processing.") - return - - _LOGGER.debug("Received meter measures: %s", message) - self._update_entities(message) - except asyncio.CancelledError: - _LOGGER.debug("Processing loop cancelled.") - return - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Error processing meter readings") - - async def _async_decode_next_valid_message( - self, - ) -> dict[str, str | int | float | dt.datetime]: - while True: - message = await self._measure_queue.get() - if isinstance(message, StopMessage): - # stop signal reveived - return dict() - - try: - decoded_measure = self._decoder.decode_message(message) - if decoded_measure: - _LOGGER.debug("Decoded meter message: %s", decoded_measure) - return decoded_measure - - _LOGGER.warning( - "Could not decode meter message: %s", - message.as_bytes.hex() if message.as_bytes else bytes(), - ) - except Exception: # pylint: disable=broad-except - _LOGGER.exception( - "Exception when decoding meter message: %s", - message.as_bytes.hex() if message.as_bytes else bytes(), - ) - - def _update_entities( - self, measure_data: dict[str, str | int | float | dt.datetime] - ) -> None: - self._ensure_entities_are_created(measure_data) - - # signal all entities to update with new measure data - if self._known_measures: - assert self._new_measure_signal_name is not None - dispatcher.async_dispatcher_send( - self._hass, self._new_measure_signal_name, measure_data - ) - - def _ensure_entities_are_created( - self, measure_data: dict[str, str | int | float | dt.datetime] - ) -> None: - # Norwegian short message does not have enough data to register entities with unique_id. - # Check for voltage to detect if this is not a short message - if obis_map.FIELD_VOLTAGE_L1 in measure_data: - missing_measures = measure_data.keys() - self._known_measures - - if missing_measures: - - # Add hourly sensors before measurement is available to avoid long delay - hour_sensors = { - s.key for s in SENSOR_TYPES.values() if s.is_hour_sensor - } - missing_hour_sensors = hour_sensors - self._known_measures - if missing_hour_sensors: - missing_measures.update(missing_hour_sensors) - - new_enitities = self._create_entities( - missing_measures, - str(measure_data.get(obis_map.FIELD_METER_ID)), - measure_data, - ) - if new_enitities: - self._add_entities(new_enitities) - - def _add_entities(self, entities: list[AmsHanEntity]): - new_measures = [x.measure_id for x in entities] - self._known_measures.update(new_measures) - _LOGGER.debug( - "Register new entities for measures: %s", - new_measures, - ) - self._async_add_entities(list(entities), True) - - def _create_entities( - self, - new_measures: Iterable[str], - meter_id: str, - measure_data: dict[str, str | int | float | dt.datetime], - ) -> list[AmsHanEntity]: - new_enitities: list[AmsHanEntity] = [] - for measure_id in new_measures: - if AmsHanEntity.is_measure_id_supported(measure_id): - if not self._new_measure_signal_name: - self._new_measure_signal_name = ( - f"{DOMAIN}_measure_available_meterid_{meter_id}" - ) - if not self._meter_info: - self._meter_info = MeterInfo.from_measure_data(measure_data) - - entity_description = SENSOR_TYPES[measure_id] - new_entity = ( - AmsHanHourlyEntity( - entity_description, - measure_data, - self._new_measure_signal_name, - self._scale_factor, - self._meter_info, - self._config_entry_id, - ) - if entity_description.is_hour_sensor - else AmsHanEntity( - entity_description, - measure_data, - self._new_measure_signal_name, - self._scale_factor, - self._meter_info, - self._config_entry_id, - ) - ) - new_enitities.append(cast(AmsHanEntity, new_entity)) - else: - _LOGGER.debug("Ignore unhandled measure_id %s", measure_id) - return new_enitities +"""amshan platform.""" + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass +import datetime as dt +import logging +import math +from typing import Callable, Iterable, cast + +from han import autodecoder, common as han_type, obis_map +from homeassistant import const as ha_const +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import State, callback, HomeAssistant +from homeassistant.helpers import dispatcher, entity, restore_state +from homeassistant.util import dt as dt_util + +from . import AmsHanIntegration, MeterInfo, StopMessage +from .const import ( + CONF_OPTIONS_SCALE_FACTOR, + DOMAIN, + ICON_COUNTER, + ICON_CURRENT, + ICON_POWER_EXPORT, + ICON_POWER_IMPORT, + ICON_VOLTAGE, + UNIT_KILO_VOLT_AMPERE_REACTIVE_HOURS, +) + +_LOGGER: logging.Logger = logging.getLogger(__name__) + + +@dataclass +class AmsHanSensorEntityDescription(SensorEntityDescription): + """A class that describes sensor entities.""" + + scale: float | None = None + """Scaling, if any, to be done one the measured value to be in correct unit.""" + + decimals: int | None = None + """Specify a number to round the measure source value to that number of decimals.""" + + use_configured_scaling: bool = False + """Use custom configured scaling.""" + + is_hour_sensor: bool = False + """Is the sensor updated only each hour.""" + + +SENSOR_TYPES: dict[str, AmsHanSensorEntityDescription] = { + sensor.key: sensor + for sensor in [ + AmsHanSensorEntityDescription( + key=obis_map.FIELD_METER_ID, + entity_category=entity.EntityCategory.DIAGNOSTIC, + name="Meter ID", + use_configured_scaling=False, + ), + AmsHanSensorEntityDescription( + key=obis_map.FIELD_METER_MANUFACTURER, + entity_category=entity.EntityCategory.DIAGNOSTIC, + name="Meter manufacturer", + use_configured_scaling=False, + ), + AmsHanSensorEntityDescription( + key=obis_map.FIELD_METER_MANUFACTURER_ID, + entity_category=entity.EntityCategory.DIAGNOSTIC, + name="Meter manufacturer ID", + use_configured_scaling=False, + ), + AmsHanSensorEntityDescription( + key=obis_map.FIELD_METER_TYPE, + entity_category=entity.EntityCategory.DIAGNOSTIC, + name="Meter type", + use_configured_scaling=False, + ), + AmsHanSensorEntityDescription( + key=obis_map.FIELD_OBIS_LIST_VER_ID, + entity_category=entity.EntityCategory.DIAGNOSTIC, + name="OBIS List version identifier", + use_configured_scaling=False, + ), + AmsHanSensorEntityDescription( + key=obis_map.FIELD_ACTIVE_POWER_IMPORT, + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=ha_const.POWER_WATT, + state_class=SensorStateClass.MEASUREMENT, + icon=ICON_POWER_IMPORT, + name="Active power import (Q1+Q4)", + decimals=0, + use_configured_scaling=True, + ), + AmsHanSensorEntityDescription( + key=obis_map.FIELD_ACTIVE_POWER_EXPORT, + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=ha_const.POWER_WATT, + state_class=SensorStateClass.MEASUREMENT, + icon=ICON_POWER_EXPORT, + name="Active power export (Q2+Q3)", + decimals=0, + use_configured_scaling=True, + ), + AmsHanSensorEntityDescription( + key=obis_map.FIELD_REACTIVE_POWER_IMPORT, + device_class=SensorDeviceClass.REACTIVE_POWER, + native_unit_of_measurement=ha_const.POWER_VOLT_AMPERE_REACTIVE, + state_class=SensorStateClass.MEASUREMENT, + icon=ICON_POWER_IMPORT, + name="Reactive power import (Q1+Q2)", + decimals=0, + use_configured_scaling=True, + ), + AmsHanSensorEntityDescription( + key=obis_map.FIELD_REACTIVE_POWER_EXPORT, + device_class=SensorDeviceClass.REACTIVE_POWER, + native_unit_of_measurement=ha_const.POWER_VOLT_AMPERE_REACTIVE, + state_class=SensorStateClass.MEASUREMENT, + icon=ICON_POWER_EXPORT, + name="Reactive power export (Q3+Q4)", + decimals=0, + use_configured_scaling=True, + ), + AmsHanSensorEntityDescription( + key=obis_map.FIELD_CURRENT_L1, + device_class=SensorDeviceClass.CURRENT, + native_unit_of_measurement=ha_const.ELECTRIC_CURRENT_AMPERE, + state_class=SensorStateClass.MEASUREMENT, + icon=ICON_CURRENT, + name="Current phase L1", + decimals=3, + use_configured_scaling=True, + ), + AmsHanSensorEntityDescription( + key=obis_map.FIELD_CURRENT_L2, + device_class=SensorDeviceClass.CURRENT, + native_unit_of_measurement=ha_const.ELECTRIC_CURRENT_AMPERE, + state_class=SensorStateClass.MEASUREMENT, + name="Current phase L2", + decimals=3, + use_configured_scaling=True, + ), + AmsHanSensorEntityDescription( + key=obis_map.FIELD_CURRENT_L3, + device_class=SensorDeviceClass.CURRENT, + native_unit_of_measurement=ha_const.ELECTRIC_CURRENT_AMPERE, + state_class=SensorStateClass.MEASUREMENT, + name="Current phase L3", + decimals=3, + use_configured_scaling=True, + ), + AmsHanSensorEntityDescription( + key=obis_map.FIELD_VOLTAGE_L1, + device_class=SensorDeviceClass.VOLTAGE, + native_unit_of_measurement=ha_const.ELECTRIC_POTENTIAL_VOLT, + state_class=SensorStateClass.MEASUREMENT, + icon=ICON_VOLTAGE, + name="Phase L1 voltage", + decimals=1, + use_configured_scaling=False, + ), + AmsHanSensorEntityDescription( + key=obis_map.FIELD_VOLTAGE_L2, + device_class=SensorDeviceClass.VOLTAGE, + native_unit_of_measurement=ha_const.ELECTRIC_POTENTIAL_VOLT, + state_class=SensorStateClass.MEASUREMENT, + icon=ICON_VOLTAGE, + name="Phase L2 voltage", + decimals=1, + use_configured_scaling=False, + ), + AmsHanSensorEntityDescription( + key=obis_map.FIELD_VOLTAGE_L3, + device_class=SensorDeviceClass.VOLTAGE, + native_unit_of_measurement=ha_const.ELECTRIC_POTENTIAL_VOLT, + state_class=SensorStateClass.MEASUREMENT, + icon=ICON_VOLTAGE, + name="Phase L3 voltage", + decimals=1, + use_configured_scaling=False, + ), + AmsHanSensorEntityDescription( + key=obis_map.FIELD_ACTIVE_POWER_IMPORT_TOTAL, + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=ha_const.ENERGY_KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + icon=ICON_COUNTER, + name="Cumulative hourly active import energy (A+) (Q1+Q4)", + scale=0.001, + decimals=2, + use_configured_scaling=True, + is_hour_sensor=True, + ), + AmsHanSensorEntityDescription( + key=obis_map.FIELD_ACTIVE_POWER_EXPORT_TOTAL, + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=ha_const.ENERGY_KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + icon=ICON_COUNTER, + name="Cumulative hourly active export energy (A-) (Q2+Q3)", + scale=0.001, + decimals=2, + use_configured_scaling=True, + is_hour_sensor=True, + ), + AmsHanSensorEntityDescription( + key=obis_map.FIELD_REACTIVE_POWER_IMPORT_TOTAL, + native_unit_of_measurement=UNIT_KILO_VOLT_AMPERE_REACTIVE_HOURS, + state_class=SensorStateClass.TOTAL_INCREASING, + icon=ICON_COUNTER, + name="Cumulative hourly reactive import energy (R+) (Q1+Q2)", + scale=0.001, + decimals=2, + use_configured_scaling=True, + is_hour_sensor=True, + ), + AmsHanSensorEntityDescription( + key=obis_map.FIELD_REACTIVE_POWER_EXPORT_TOTAL, + native_unit_of_measurement=UNIT_KILO_VOLT_AMPERE_REACTIVE_HOURS, + state_class=SensorStateClass.TOTAL_INCREASING, + icon=ICON_COUNTER, + name="Cumulative hourly reactive export energy (R-) (Q3+Q4)", + scale=0.001, + decimals=2, + use_configured_scaling=True, + is_hour_sensor=True, + ), + ] +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: Callable[[list[entity.Entity], bool], None], +): + """Add hantest sensor platform from a config_entry.""" + _LOGGER.debug("Sensor async_setup_entry starting.") + + integration: AmsHanIntegration = hass.data[DOMAIN][config_entry.entry_id] + processor: MeterMeasureProcessor = MeterMeasureProcessor( + hass, config_entry, async_add_entities, integration.measure_queue + ) + + # start processing loop task + integration.add_task(hass.loop.create_task(processor.async_process_measures_loop())) + + _LOGGER.debug("Sensor async_setup_entry ended.") + + +class AmsHanEntity(SensorEntity): + """Representation of a AmsHan sensor.""" + + def __init__( + self, + entity_description: AmsHanSensorEntityDescription, + measure_data: dict[str, str | int | float | dt.datetime], + new_measure_signal_name: str, + scale_factor: float, + meter_info: MeterInfo, + config_entry_id: str, + ) -> None: + """Initialize AmsHanEntity class.""" + if entity_description is None: + raise TypeError("entity_description is required") + if measure_data is None: + raise TypeError("measure_data is required") + if obis_map.FIELD_METER_ID not in measure_data and ( + obis_map.FIELD_METER_MANUFACTURER not in measure_data + and obis_map.FIELD_METER_MANUFACTURER_ID not in measure_data + ): + raise ValueError( + ( + f"Expected element {obis_map.FIELD_METER_ID} or " + f"{obis_map.FIELD_METER_MANUFACTURER} / {obis_map.FIELD_METER_MANUFACTURER_ID} not in measure_data." + ) + ) + if new_measure_signal_name is None: + raise TypeError("new_measure_signal_name is required") + + self.entity_description: AmsHanSensorEntityDescription = entity_description + self._measure_data = measure_data + self._new_measure_signal_name = new_measure_signal_name + self._async_remove_dispatcher: Callable[[], None] | None = None + self._meter_info: MeterInfo = ( + meter_info if meter_info else MeterInfo.from_measure_data(measure_data) + ) + self._scale_factor = ( + int(scale_factor) + if scale_factor == math.floor(scale_factor) + else scale_factor + ) + self._config_entry_id = config_entry_id + + manufacturer = ( + self._meter_info.manufacturer + if self._meter_info.manufacturer + else self._meter_info.manufacturer_id + ) + self.entity_id = f"sensor.{manufacturer}_{entity_description.key}".lower() + self._unique_id = None + + @staticmethod + def is_measure_id_supported(measure_id: str) -> bool: + """Check if an entity can be created for measure id.""" + return measure_id in SENSOR_TYPES + + async def async_added_to_hass(self) -> None: + """Run when entity about to be added to hass.""" + + @callback + def on_new_measure( + measure_data: dict[str, str | int | float | dt.datetime] + ) -> None: + if self.measure_id in measure_data: + self._measure_data = measure_data + if _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug( + "Update sensor %s with state %s", + self.unique_id, + self.state, + ) + self.async_write_ha_state() + + # subscribe to update events for this meter + self._async_remove_dispatcher = dispatcher.async_dispatcher_connect( + self.hass, + self._new_measure_signal_name, + on_new_measure, + ) + + async def async_will_remove_from_hass(self) -> None: + """Run when entity will be removed from hass.""" + if self._async_remove_dispatcher: + self._async_remove_dispatcher() + + @property + def measure_id(self) -> str: + """Return the measure_id handled by this entity.""" + return self.entity_description.key + + @property + def should_poll(self) -> bool: + """Return False since updates are pushed from this sensor.""" + return False + + @property + def unique_id(self) -> str | None: + """Return the unique id.""" + if self._unique_id is None: + if self._meter_info.meter_id: + self._unique_id = ( + f"{self._meter_info.manufacturer}-{self._meter_info.meter_id}-" + f"{self.measure_id}" + ) + else: + manufacturer = { + self._meter_info.manufacturer_id + if self._meter_info.manufacturer_id + else self._meter_info.manufacturer + } + self._unique_id = ( + f"CEID-{self._config_entry_id}-" + f"{manufacturer}{self._meter_info.type_id}" + f"-{self.measure_id}" + ) + return self._unique_id + + @property + def native_value(self) -> None | str | int | float: + """Return the native value of the entity.""" + measure = self._measure_data.get(self.measure_id) + + if measure is None: + return None + + if isinstance(measure, str): + return measure + + if isinstance(measure, dt.datetime): + return measure.isoformat() + + if self.entity_description.scale is not None: + measure = measure * self.entity_description.scale + + if self.entity_description.use_configured_scaling: + measure = measure * self._scale_factor + + if self.entity_description.decimals is not None: + measure = ( + round(measure) + if self.entity_description.decimals == 0 + else round(measure, self.entity_description.decimals) + ) + + return measure + + @property + def device_info(self) -> entity.DeviceInfo: + """Return device specific attributes.""" + manufacturer = ( + self._meter_info.manufacturer + if self._meter_info.manufacturer + else self._meter_info.manufacturer_id + ) + + meter_type = ( + self._meter_info.type if self._meter_info.type else self._meter_info.type_id + ) + + return entity.DeviceInfo( + name=f"{manufacturer} {meter_type}", + identifiers={ + ( + DOMAIN, + self._meter_info.unique_id + if self._meter_info.unique_id + else self._config_entry_id, + ) + }, + manufacturer=manufacturer, + model=meter_type, + sw_version=self._meter_info.list_version_id, + ) + + +class AmsHanHourlyEntity(AmsHanEntity, restore_state.RestoreEntity): + """Representation of a AmsHan sensor each hour.""" + + def __init__( + self, + entity_description: AmsHanSensorEntityDescription, + measure_data: dict[str, str | int | float | dt.datetime], + new_measure_signal_name: str, + scale_factor: float, + meter_info: MeterInfo, + config_entry_id: str, + ) -> None: + """Initialize AmsHanHourlyEntity class.""" + super().__init__( + entity_description, + measure_data, + new_measure_signal_name, + scale_factor, + meter_info, + config_entry_id, + ) + self._restored_last_state: State | None = None + + async def async_added_to_hass(self) -> None: + """Run when entity about to be added to hass.""" + self._restored_last_state = await self.async_get_last_state() + if ( + self._restored_last_state + and self._restored_last_state.state == ha_const.STATE_UNKNOWN + ): + _LOGGER.debug( + "Restored state from %s for sensor %s is unknown. No need to keep.", + self._restored_last_state.last_updated, + self.unique_id, + ) + self._restored_last_state = None + + await super().async_added_to_hass() + + @property + def native_value(self) -> None | str | int | float: + """Return the native value from current measure or cache if from current hour.""" + measured_value = super().native_value + if measured_value is not None: + self._restored_last_state = None # no need for restored state anymore + return measured_value + + if self._restored_last_state: + if self._is_restored_state_from_current_hour(): + _LOGGER.debug( + "Use restored state from %s for sensor %s", + self._restored_last_state.last_updated, + self.unique_id, + ) + return self._restored_last_state.state + + _LOGGER.debug( + "Restored state from %s for sensor %s is too old to be used", + self._restored_last_state.last_updated, + self.unique_id, + ) + + self._restored_last_state = None + + return None + + def _is_restored_state_from_current_hour(self): + now = dt_util.utcnow() + time_since_update = now - self._restored_last_state.last_updated + return ( + now.hour == self._restored_last_state.last_updated.hour + and time_since_update < dt.timedelta(hours=1) + ) + + +class MeterMeasureProcessor: + """Process meter measures from queue and setup/update entities.""" + + def __init__( + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: Callable[[list[entity.Entity], bool], None], + measure_queue: asyncio.Queue[han_type.MeterMessageBase], + ) -> None: + """Initialize MeterMeasureProcessor class.""" + self._hass = hass + self._async_add_entities = async_add_entities + self._measure_queue = measure_queue + self._decoder: autodecoder.AutoDecoder = autodecoder.AutoDecoder() + self._known_measures: set[str] = set() + self._new_measure_signal_name: str | None = None + self._scale_factor = float( + config_entry.options.get(CONF_OPTIONS_SCALE_FACTOR, 1) + ) + self._config_entry_id: str = config_entry.entry_id + self._meter_info: MeterInfo | None = None + + async def async_process_measures_loop(self) -> None: + """Start the processing loop. The method exits when StopMessage is received from queue.""" + _LOGGER.debug("Processing loop starting.") + while True: + try: + message = await self._async_decode_next_valid_message() + if not message: + _LOGGER.debug("Received stop signal. Exit processing.") + return + + _LOGGER.debug("Received meter measures: %s", message) + self._update_entities(message) + except asyncio.CancelledError: + _LOGGER.debug("Processing loop cancelled.") + return + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Error processing meter readings") + + async def _async_decode_next_valid_message( + self, + ) -> dict[str, str | int | float | dt.datetime]: + while True: + message = await self._measure_queue.get() + if isinstance(message, StopMessage): + # stop signal reveived + return dict() + + try: + decoded_measure = self._decoder.decode_message(message) + if decoded_measure: + _LOGGER.debug("Decoded meter message: %s", decoded_measure) + return decoded_measure + + _LOGGER.warning( + "Could not decode meter message: %s", + message.as_bytes.hex() if message.as_bytes else bytes(), + ) + except Exception: # pylint: disable=broad-except + _LOGGER.exception( + "Exception when decoding meter message: %s", + message.as_bytes.hex() if message.as_bytes else bytes(), + ) + + def _update_entities( + self, measure_data: dict[str, str | int | float | dt.datetime] + ) -> None: + self._ensure_entities_are_created(measure_data) + + # signal all entities to update with new measure data + if self._known_measures: + assert self._new_measure_signal_name is not None + dispatcher.async_dispatcher_send( + self._hass, self._new_measure_signal_name, measure_data + ) + + def _ensure_entities_are_created( + self, measure_data: dict[str, str | int | float | dt.datetime] + ) -> None: + # Norwegian short message does not have enough data to register entities with unique_id. + # Check for voltage to detect if this is not a short message + if obis_map.FIELD_VOLTAGE_L1 in measure_data: + missing_measures = measure_data.keys() - self._known_measures + + if missing_measures: + + # Add hourly sensors before measurement is available to avoid long delay + hour_sensors = { + s.key for s in SENSOR_TYPES.values() if s.is_hour_sensor + } + missing_hour_sensors = hour_sensors - self._known_measures + if missing_hour_sensors: + missing_measures.update(missing_hour_sensors) + + new_enitities = self._create_entities( + missing_measures, + str(measure_data.get(obis_map.FIELD_METER_ID)), + measure_data, + ) + if new_enitities: + self._add_entities(new_enitities) + + def _add_entities(self, entities: list[AmsHanEntity]): + new_measures = [x.measure_id for x in entities] + self._known_measures.update(new_measures) + _LOGGER.debug( + "Register new entities for measures: %s", + new_measures, + ) + self._async_add_entities(list(entities), True) + + def _create_entities( + self, + new_measures: Iterable[str], + meter_id: str, + measure_data: dict[str, str | int | float | dt.datetime], + ) -> list[AmsHanEntity]: + new_enitities: list[AmsHanEntity] = [] + for measure_id in new_measures: + if AmsHanEntity.is_measure_id_supported(measure_id): + if not self._new_measure_signal_name: + self._new_measure_signal_name = ( + f"{DOMAIN}_measure_available_meterid_{meter_id}" + ) + if not self._meter_info: + self._meter_info = MeterInfo.from_measure_data(measure_data) + + entity_description = SENSOR_TYPES[measure_id] + new_entity = ( + AmsHanHourlyEntity( + entity_description, + measure_data, + self._new_measure_signal_name, + self._scale_factor, + self._meter_info, + self._config_entry_id, + ) + if entity_description.is_hour_sensor + else AmsHanEntity( + entity_description, + measure_data, + self._new_measure_signal_name, + self._scale_factor, + self._meter_info, + self._config_entry_id, + ) + ) + new_enitities.append(cast(AmsHanEntity, new_entity)) + else: + _LOGGER.debug("Ignore unhandled measure_id %s", measure_id) + return new_enitities diff --git a/custom_components/amshan/strings.json b/custom_components/amshan/strings.json index 5b53d20..e3f0634 100644 --- a/custom_components/amshan/strings.json +++ b/custom_components/amshan/strings.json @@ -1,63 +1,63 @@ -{ - "config": { - "abort": { - "already_configured": "Device is already configured" - }, - "error": { - "cannot_connect": "Failed to connect, please try again", - "timeout_connect": "Timeout connecting", - "timeout_read_messages": "Timeout reading data", - "serial_exception_general": "Could not open serial port. See log for details", - "serial_exception_errno_2": "Serial device not found", - "unknown": "Unexpected error", - "host_check": "Must be a valid hostname or IP-address", - "voluptuous_host": "Must be a valid hostname or IP-address", - "voluptuous_port": "Must be a valid port number (between 0 and 65535)", - "mqtt_not_available": "Please make sure MQTT integration has been installed", - "invalid_subscribe_topic": "Invalid topic name" - }, - "step": { - "network_connection": { - "data": { - "host": "Host address (name or IP)", - "port": "TCP/IP port number" - }, - "title": "Connect to network host" - }, - "serial_connection": { - "data": { - "baudrate": "Baud rate such as 2400, 9600, or 115200 etc.", - "bytesize": "Number of data bits", - "dsrdtr": "Enable hardware (DSR/DTR) flow control", - "parity": "Parity checking", - "port": "Serial device name", - "rtscts": "Enable hardware (RTS/CTS) flow control", - "stopbits": "Number of stop bits", - "xonxoff": "Enable software flow control" - }, - "title": "Connect to serial device" - }, - "hass_mqtt_connection": { - "title": "Subscribe to MQTT topic(s)", - "data": { - "mqtt_topics": "MQTT topic(s) separated by comma" - } - }, - "user": { - "data": { - "type": "Connection type" - }, - "title": "Select connection type" - } - } - }, - "options": { - "step": { - "user": { - "data": { - "scale_factor": "Currents, power, and energy scale factor. Default, and most common, is 1." - } - } - } - } +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "cannot_connect": "Failed to connect, please try again", + "timeout_connect": "Timeout connecting", + "timeout_read_messages": "Timeout reading data", + "serial_exception_general": "Could not open serial port. See log for details", + "serial_exception_errno_2": "Serial device not found", + "unknown": "Unexpected error", + "host_check": "Must be a valid hostname or IP-address", + "voluptuous_host": "Must be a valid hostname or IP-address", + "voluptuous_port": "Must be a valid port number (between 0 and 65535)", + "mqtt_not_available": "Please make sure MQTT integration has been installed", + "invalid_subscribe_topic": "Invalid topic name" + }, + "step": { + "network_connection": { + "data": { + "host": "Host address (name or IP)", + "port": "TCP/IP port number" + }, + "title": "Connect to network host" + }, + "serial_connection": { + "data": { + "baudrate": "Baud rate such as 2400, 9600, or 115200 etc.", + "bytesize": "Number of data bits", + "dsrdtr": "Enable hardware (DSR/DTR) flow control", + "parity": "Parity checking", + "port": "Serial device name", + "rtscts": "Enable hardware (RTS/CTS) flow control", + "stopbits": "Number of stop bits", + "xonxoff": "Enable software flow control" + }, + "title": "Connect to serial device" + }, + "hass_mqtt_connection": { + "title": "Subscribe to MQTT topic(s)", + "data": { + "mqtt_topics": "MQTT topic(s) separated by comma" + } + }, + "user": { + "data": { + "type": "Connection type" + }, + "title": "Select connection type" + } + } + }, + "options": { + "step": { + "user": { + "data": { + "scale_factor": "Currents, power, and energy scale factor. Default, and most common, is 1." + } + } + } + } } \ No newline at end of file diff --git a/custom_components/amshan/translations/nb.json b/custom_components/amshan/translations/nb.json index 2095560..cfc0dfe 100644 --- a/custom_components/amshan/translations/nb.json +++ b/custom_components/amshan/translations/nb.json @@ -1,63 +1,63 @@ -{ - "config": { - "abort": { - "already_configured": "Enheten er allerede konfigurert" - }, - "error": { - "cannot_connect": "Kunne ikke koble til, vennligst prøv igjen", - "timeout_connect": "Tidsavbrudd ved tilkobling", - "timeout_read_messages": "Tidsavbrudd ved lesing av data", - "serial_exception_general": "Kunne ikke åpne serieport. Se logg for mer informasjon", - "serial_exception_errno_2": "Kunne ikke finne serieport", - "unknown": "Ukjent feil", - "host_check": "Må være et gyldig DNS-navn eller en IP-adresse", - "voluptuous_host": "Må være et gyldig DNS-navn eller en IP-adresse", - "voluptuous_port": "Må være et gyldig portnummer (mellom 0 og 65535)", - "mqtt_not_available": "Vennligst sjekk at MQTT integrasjonen er installert", - "invalid_subscribe_topic": "Ugyldig emnenavn" - }, - "step": { - "network_connection": { - "data": { - "host": "Nettadresse (DNS-navn eller IP-adresse)", - "port": "TCP/IP portnummer" - }, - "title": "Koble til nettverksenhet" - }, - "serial_connection": { - "data": { - "baudrate": "Baudrate, for eksempel 2400, 9600 eller 115200", - "bytesize": "Antall databits", - "dsrdtr": "Aktiver DSR/DTR-flytkontroll", - "parity": "Paritetsjekk", - "port": "Serieport", - "rtscts": "Aktiver RTS/CTS-flytkontroll", - "stopbits": "Antall stoppbits", - "xonxoff": "Aktiver programvareflytkontroll" - }, - "title": "Koble til serieport" - }, - "hass_mqtt_connection": { - "title": "Abonner på MQTT emne(r)", - "data": { - "mqtt_topics": "MQTT emne(r) separert med komma" - } - }, - "user": { - "data": { - "type": "Tilkoblingstype" - }, - "title": "Velg tilkoblingstype" - } - } - }, - "options": { - "step": { - "user": { - "data": { - "scale_factor": "Skaleringsfaktor for strømtrekk, effekt og energi. Normalt satt til 1." - } - } - } - } +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert" + }, + "error": { + "cannot_connect": "Kunne ikke koble til, vennligst prøv igjen", + "timeout_connect": "Tidsavbrudd ved tilkobling", + "timeout_read_messages": "Tidsavbrudd ved lesing av data", + "serial_exception_general": "Kunne ikke åpne serieport. Se logg for mer informasjon", + "serial_exception_errno_2": "Kunne ikke finne serieport", + "unknown": "Ukjent feil", + "host_check": "Må være et gyldig DNS-navn eller en IP-adresse", + "voluptuous_host": "Må være et gyldig DNS-navn eller en IP-adresse", + "voluptuous_port": "Må være et gyldig portnummer (mellom 0 og 65535)", + "mqtt_not_available": "Vennligst sjekk at MQTT integrasjonen er installert", + "invalid_subscribe_topic": "Ugyldig emnenavn" + }, + "step": { + "network_connection": { + "data": { + "host": "Nettadresse (DNS-navn eller IP-adresse)", + "port": "TCP/IP portnummer" + }, + "title": "Koble til nettverksenhet" + }, + "serial_connection": { + "data": { + "baudrate": "Baudrate, for eksempel 2400, 9600 eller 115200", + "bytesize": "Antall databits", + "dsrdtr": "Aktiver DSR/DTR-flytkontroll", + "parity": "Paritetsjekk", + "port": "Serieport", + "rtscts": "Aktiver RTS/CTS-flytkontroll", + "stopbits": "Antall stoppbits", + "xonxoff": "Aktiver programvareflytkontroll" + }, + "title": "Koble til serieport" + }, + "hass_mqtt_connection": { + "title": "Abonner på MQTT emne(r)", + "data": { + "mqtt_topics": "MQTT emne(r) separert med komma" + } + }, + "user": { + "data": { + "type": "Tilkoblingstype" + }, + "title": "Velg tilkoblingstype" + } + } + }, + "options": { + "step": { + "user": { + "data": { + "scale_factor": "Skaleringsfaktor for strømtrekk, effekt og energi. Normalt satt til 1." + } + } + } + } } \ No newline at end of file diff --git a/custom_components/amshan/translations/nn.json b/custom_components/amshan/translations/nn.json index 2450977..f14a2be 100644 --- a/custom_components/amshan/translations/nn.json +++ b/custom_components/amshan/translations/nn.json @@ -1,63 +1,63 @@ -{ - "config": { - "abort": { - "already_configured": "Eininga er allereie konfigurert" - }, - "error": { - "cannot_connect": "Kunne ikkje kople til, ver vennleg og prøv igjen", - "timeout_connect": "Tidsavbrudd ved tilkopling", - "timeout_read_messages": "Tidsavbrudd ved lesing av data", - "serial_exception_general": "Kunne ikkje opna serieport. Sjå logg for meir informasjon", - "serial_exception_errno_2": "Kunne ikkje finna serieport", - "unknown": "Ukjent feil", - "host_check": "Må vera eit gyldig DNS-namn eller ei IP-adresse", - "voluptuous_host": "Må vera eit gyldig DNS-namn eller ei IP-adresse", - "voluptuous_port": "Må vera eit gyldig portnummer (mellom 0 og 65535)", - "mqtt_not_available": "Ver vennleg og sjekk at MQTT integrasjonen er installert", - "invalid_subscribe_topic": "Ugyldig emnenavn" - }, - "step": { - "network_connection": { - "data": { - "host": "Nettadresse (DNS-namn eller IP-adresse)", - "port": "TCP/IP portnummer" - }, - "title": "Kople til nettverkseining" - }, - "serial_connection": { - "data": { - "baudrate": "Baudrate, for eksempel 2400, 9600 eller 115200", - "bytesize": "Antal databits", - "dsrdtr": "Aktiver hardware (DSR/DTR) flytkontroll", - "parity": "Paritetsjekk", - "port": "Serieport", - "rtscts": "Aktiver hardware (RTS/CTS) flytkontroll", - "stopbits": "Antal stoppbits", - "xonxoff": "Aktiver software flytkontroll" - }, - "title": "Kople til serieport" - }, - "hass_mqtt_connection": { - "title": "Abonner på MQTT emne(r)", - "data": { - "mqtt_topics": "MQTT emne(r) separert med komma" - } - }, - "user": { - "data": { - "type": "Tilkoplingstype" - }, - "title": "Velg tilkoplingstype" - } - } - }, - "options": { - "step": { - "user": { - "data": { - "scale_factor": "Skaleringsfaktor for straumtrekk, effekt og energi. Normalt sett til 1." - } - } - } - } +{ + "config": { + "abort": { + "already_configured": "Eininga er allereie konfigurert" + }, + "error": { + "cannot_connect": "Kunne ikkje kople til, ver vennleg og prøv igjen", + "timeout_connect": "Tidsavbrudd ved tilkopling", + "timeout_read_messages": "Tidsavbrudd ved lesing av data", + "serial_exception_general": "Kunne ikkje opna serieport. Sjå logg for meir informasjon", + "serial_exception_errno_2": "Kunne ikkje finna serieport", + "unknown": "Ukjent feil", + "host_check": "Må vera eit gyldig DNS-namn eller ei IP-adresse", + "voluptuous_host": "Må vera eit gyldig DNS-namn eller ei IP-adresse", + "voluptuous_port": "Må vera eit gyldig portnummer (mellom 0 og 65535)", + "mqtt_not_available": "Ver vennleg og sjekk at MQTT integrasjonen er installert", + "invalid_subscribe_topic": "Ugyldig emnenavn" + }, + "step": { + "network_connection": { + "data": { + "host": "Nettadresse (DNS-namn eller IP-adresse)", + "port": "TCP/IP portnummer" + }, + "title": "Kople til nettverkseining" + }, + "serial_connection": { + "data": { + "baudrate": "Baudrate, for eksempel 2400, 9600 eller 115200", + "bytesize": "Antal databits", + "dsrdtr": "Aktiver hardware (DSR/DTR) flytkontroll", + "parity": "Paritetsjekk", + "port": "Serieport", + "rtscts": "Aktiver hardware (RTS/CTS) flytkontroll", + "stopbits": "Antal stoppbits", + "xonxoff": "Aktiver software flytkontroll" + }, + "title": "Kople til serieport" + }, + "hass_mqtt_connection": { + "title": "Abonner på MQTT emne(r)", + "data": { + "mqtt_topics": "MQTT emne(r) separert med komma" + } + }, + "user": { + "data": { + "type": "Tilkoplingstype" + }, + "title": "Velg tilkoplingstype" + } + } + }, + "options": { + "step": { + "user": { + "data": { + "scale_factor": "Skaleringsfaktor for straumtrekk, effekt og energi. Normalt sett til 1." + } + } + } + } } \ No newline at end of file diff --git a/custom_components/amshan/translations/sv.json b/custom_components/amshan/translations/sv.json index 2646204..a92a1c3 100644 --- a/custom_components/amshan/translations/sv.json +++ b/custom_components/amshan/translations/sv.json @@ -1,63 +1,63 @@ -{ - "config": { - "abort": { - "already_configured": "Enheten är redan konfigurerad" - }, - "error": { - "cannot_connect": "Kunde inte ansluta, försök igen senare", - "timeout_connect": "Anslutningen tog för lång tid", - "timeout_read_messages": "Utläsning av data tog för lång tid", - "serial_exception_general": "Kunde inte upprätta kontakt med serieporten. Se loggen för mer information", - "serial_exception_errno_2": "Seriell enhet hittades inte", - "unknown": "Oväntat fel", - "host_check": "Måste vara ett giltigt värdnamn eller IP-adress", - "voluptuous_host": "Måste vara ett giltigt värdnamn eller IP-adress", - "voluptuous_port": "Måste vara ett giltigt portnummer (mellan 0 och 65535)", - "mqtt_not_available": "Kontrollera att MQTT integration är installerad", - "invalid_subscribe_topic": "Ogiltigt ämnesnamn" - }, - "step": { - "network_connection": { - "data": { - "host": "Värdadress (namn eller IP)", - "port": "TCP/IP portnummer" - }, - "title": "Anslut till nätverksvärd" - }, - "serial_connection": { - "data": { - "baudrate": "Baud rate som 2400, 9600, eller 115200 etc.", - "bytesize": "Antal databitar", - "dsrdtr": "Slå på (DSR/DTR) flödeskontroll", - "parity": "Paritetskontroll", - "port": "Namn seriell enhet", - "rtscts": "Slå på (RTS/CTS) flödeskontroll", - "stopbits": "Antal stopbitar", - "xonxoff": "Slå på mjukvarubaserad flödeskontroll" - }, - "title": "Anslut till seriell enhet" - }, - "hass_mqtt_connection": { - "title": "Prenumera på MQTT topic(s)", - "data": { - "mqtt_topics": "MQTT topic(s) kommaseparerat" - } - }, - "user": { - "data": { - "type": "Anslutningstyp" - }, - "title": "Välj anslutningstyp" - } - } - }, - "options": { - "step": { - "user": { - "data": { - "scale_factor": "Ström, styrka och energi- skalningsfaktor. Standard, och vanligast, är 1." - } - } - } - } +{ + "config": { + "abort": { + "already_configured": "Enheten är redan konfigurerad" + }, + "error": { + "cannot_connect": "Kunde inte ansluta, försök igen senare", + "timeout_connect": "Anslutningen tog för lång tid", + "timeout_read_messages": "Utläsning av data tog för lång tid", + "serial_exception_general": "Kunde inte upprätta kontakt med serieporten. Se loggen för mer information", + "serial_exception_errno_2": "Seriell enhet hittades inte", + "unknown": "Oväntat fel", + "host_check": "Måste vara ett giltigt värdnamn eller IP-adress", + "voluptuous_host": "Måste vara ett giltigt värdnamn eller IP-adress", + "voluptuous_port": "Måste vara ett giltigt portnummer (mellan 0 och 65535)", + "mqtt_not_available": "Kontrollera att MQTT integration är installerad", + "invalid_subscribe_topic": "Ogiltigt ämnesnamn" + }, + "step": { + "network_connection": { + "data": { + "host": "Värdadress (namn eller IP)", + "port": "TCP/IP portnummer" + }, + "title": "Anslut till nätverksvärd" + }, + "serial_connection": { + "data": { + "baudrate": "Baud rate som 2400, 9600, eller 115200 etc.", + "bytesize": "Antal databitar", + "dsrdtr": "Slå på (DSR/DTR) flödeskontroll", + "parity": "Paritetskontroll", + "port": "Namn seriell enhet", + "rtscts": "Slå på (RTS/CTS) flödeskontroll", + "stopbits": "Antal stopbitar", + "xonxoff": "Slå på mjukvarubaserad flödeskontroll" + }, + "title": "Anslut till seriell enhet" + }, + "hass_mqtt_connection": { + "title": "Prenumera på MQTT topic(s)", + "data": { + "mqtt_topics": "MQTT topic(s) kommaseparerat" + } + }, + "user": { + "data": { + "type": "Anslutningstyp" + }, + "title": "Välj anslutningstyp" + } + } + }, + "options": { + "step": { + "user": { + "data": { + "scale_factor": "Ström, styrka och energi- skalningsfaktor. Standard, och vanligast, är 1." + } + } + } + } } \ No newline at end of file diff --git a/hacs.json b/hacs.json index ac25de1..ed86d50 100644 --- a/hacs.json +++ b/hacs.json @@ -1,5 +1,5 @@ { "name": "AmsHan", - "homeassistant": "2022.8.0b0", + "homeassistant": "2024.5.0b0", "render_readme": true -} +} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 17dfa6b..d3c16a9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,3 @@ -pip>=21.0,<23.1 -colorlog -homeassistant +pip>=21.0,<23.1 +colorlog +homeassistant diff --git a/setup.cfg b/setup.cfg index 02a86a2..8019d76 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,33 +1,33 @@ -[flake8] -exclude = .venv,.git,.tox,docs,venv,bin,lib,deps,build -doctests = True -# To work with Black -max-line-length = 88 -# E501: line too long -# W503: Line break occurred before a binary operator -# E203: Whitespace before ':' -# D202 No blank lines allowed after function docstring -# W504 line break after binary operator -ignore = - E501, - W503, - E203, - D202, - W504 - -[isort] -# https://github.com/timothycrosley/isort -# https://github.com/timothycrosley/isort/wiki/isort-Settings -# splits long import on multiple lines indented by 4 spaces -multi_line_output = 3 -include_trailing_comma=True -force_grid_wrap=0 -use_parentheses=True -line_length=88 -indent = " " -# will group `import x` and `from x import` of the same module. -force_sort_within_sections = true -sections = FUTURE,STDLIB,THIRDPARTY,FIRSTPARTY,LOCALFOLDER -default_section = THIRDPARTY -known_first_party = custom_components.amshan -combine_as_imports = true +[flake8] +exclude = .venv,.git,.tox,docs,venv,bin,lib,deps,build +doctests = True +# To work with Black +max-line-length = 88 +# E501: line too long +# W503: Line break occurred before a binary operator +# E203: Whitespace before ':' +# D202 No blank lines allowed after function docstring +# W504 line break after binary operator +ignore = + E501, + W503, + E203, + D202, + W504 + +[isort] +# https://github.com/timothycrosley/isort +# https://github.com/timothycrosley/isort/wiki/isort-Settings +# splits long import on multiple lines indented by 4 spaces +multi_line_output = 3 +include_trailing_comma=True +force_grid_wrap=0 +use_parentheses=True +line_length=88 +indent = " " +# will group `import x` and `from x import` of the same module. +force_sort_within_sections = true +sections = FUTURE,STDLIB,THIRDPARTY,FIRSTPARTY,LOCALFOLDER +default_section = THIRDPARTY +known_first_party = custom_components.amshan +combine_as_imports = true