├── .github ├── ISSUE_TEMPLATE.md ├── issue-label-bot.yaml ├── workflows │ ├── ruff.yaml │ ├── hassfest.yml │ ├── hacs.yml │ ├── release-drafter.yaml │ ├── labels.yaml │ ├── pr-labels.yaml │ ├── stale.yaml │ └── build.yaml ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature_request.md │ ├── change_request.md │ └── bug_report.md ├── release-drafter.yml ├── PULL_REQUEST_TEMPLATE.md └── labels.yml ├── .gitignore ├── hacs.json ├── ruff.toml ├── custom_components └── victron │ ├── manifest.json │ ├── base.py │ ├── strings.json │ ├── __init__.py │ ├── translations │ ├── en.json │ ├── sv.json │ ├── nl.json │ ├── es.json │ ├── de.json │ ├── fr.json │ └── it.json │ ├── button.py │ ├── binary_sensor.py │ ├── switch.py │ ├── hub.py │ ├── select.py │ ├── coordinator.py │ ├── sensor.py │ ├── number.py │ └── config_flow.py ├── README.md ├── LICENSE └── pyproject.toml /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | custom_components/victron/__pycache__ 2 | .DS_Store 3 | .idea -------------------------------------------------------------------------------- /.github/issue-label-bot.yaml: -------------------------------------------------------------------------------- 1 | label-alias: 2 | bug: 'bug' 3 | feature_request: 'feature_request' 4 | question: 'question' -------------------------------------------------------------------------------- /.github/workflows/ruff.yaml: -------------------------------------------------------------------------------- 1 | name: Ruff 2 | 3 | on: [ pull_request ] 4 | jobs: 5 | ruff: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@v4 9 | - uses: astral-sh/ruff-action@v3.2.1 -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: GitHub Community Support 4 | url: https://github.com/sfstar/hass-victron/discussions 5 | about: Please ask and answer questions here. -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Victron GX modbus TCP", 3 | "render_readme": true, 4 | "zip_release": true, 5 | "filename": "victron.zip", 6 | "hide_default_branch": false, 7 | "homeassistant": "2025.9", 8 | "hacs": "1.28.4" 9 | } 10 | -------------------------------------------------------------------------------- /ruff.toml: -------------------------------------------------------------------------------- 1 | # This extend our general Ruff rules specifically for tests 2 | extend = "pyproject.toml" 3 | 4 | [lint] 5 | extend-ignore = [ 6 | "INP001", # File is part of an implicit namespace package. Add an `__init__.py`. 7 | ] 8 | 9 | [lint.isort] 10 | known-third-party = [ 11 | "pylint", 12 | ] 13 | -------------------------------------------------------------------------------- /.github/workflows/hassfest.yml: -------------------------------------------------------------------------------- 1 | name: Validate with hassfest 2 | 3 | on: 4 | pull_request: 5 | schedule: 6 | - cron: '0 0 * * *' 7 | 8 | jobs: 9 | validate: 10 | runs-on: "ubuntu-latest" 11 | steps: 12 | - uses: "actions/checkout@v4.2.2" 13 | - uses: "home-assistant/actions/hassfest@master" 14 | -------------------------------------------------------------------------------- /.github/workflows/hacs.yml: -------------------------------------------------------------------------------- 1 | name: HACS Action 2 | 3 | on: 4 | pull_request: 5 | schedule: 6 | - cron: "0 0 * * *" 7 | 8 | jobs: 9 | hacs: 10 | name: HACS Action 11 | runs-on: "ubuntu-latest" 12 | steps: 13 | - name: HACS Action 14 | uses: "hacs/action@main" 15 | with: 16 | category: "integration" 17 | -------------------------------------------------------------------------------- /custom_components/victron/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "victron", 3 | "name": "victron", 4 | "codeowners": [ 5 | "@sfstar" 6 | ], 7 | "config_flow": true, 8 | "dependencies": [], 9 | "documentation": "https://github.com/sfstar/hass-victron", 10 | "homekit": {}, 11 | "integration_type": "hub", 12 | "iot_class": "local_polling", 13 | "issue_tracker": "https://github.com/sfstar/hass-victron/issues", 14 | "requirements": [ 15 | "pymodbus>=3.8.0" 16 | ], 17 | "ssdp": [], 18 | "version": "v0.0.0", 19 | "zeroconf": [] 20 | } 21 | -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Release Drafter 3 | 4 | # yamllint disable-line rule:truthy 5 | on: 6 | push: 7 | branches: 8 | - main 9 | workflow_dispatch: 10 | 11 | jobs: 12 | update_release_draft: 13 | permissions: 14 | contents: write 15 | pull-requests: write 16 | name: ✏️ Draft release 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: 🚀 Run Release Drafter 20 | uses: release-drafter/release-drafter@v6.1.0 21 | env: 22 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /.github/workflows/labels.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Sync labels 3 | 4 | # yamllint disable-line rule:truthy 5 | on: 6 | push: 7 | branches: 8 | - main 9 | paths: 10 | - .github/labels.yml 11 | workflow_dispatch: 12 | 13 | jobs: 14 | labels: 15 | name: ♻️ Sync labels 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: ⤵️ Check out code from GitHub 19 | uses: actions/checkout@v4 20 | 21 | - name: 🚀 Run Label Syncer 22 | uses: micnncim/action-label-syncer@v1.3.0 23 | env: 24 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /.github/workflows/pr-labels.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: PR Labels 3 | 4 | # yamllint disable-line rule:truthy 5 | on: 6 | pull_request_target: 7 | types: 8 | - opened 9 | - labeled 10 | - unlabeled 11 | - synchronize 12 | workflow_call: 13 | 14 | jobs: 15 | pr_labels: 16 | name: Verify 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: 🏷 Verify PR has a valid label 20 | uses: jesusvasquez333/verify-pr-label-action@v1.4.0 21 | with: 22 | pull-request-number: "${{ github.event.pull_request.number }}" 23 | github-token: "${{ secrets.GITHUB_TOKEN }}" 24 | valid-labels: >- 25 | breaking-change, bugfix, documentation, enhancement, 26 | refactor, performance, new-feature, maintenance, ci, dependencies 27 | disable-reviews: true -------------------------------------------------------------------------------- /.github/workflows/stale.yaml: -------------------------------------------------------------------------------- 1 | name: "Close stale issues/pull requests" 2 | on: 3 | schedule: 4 | - cron: "0 0 * * *" 5 | issue_comment: 6 | types: [created, deleted] 7 | workflow_dispatch: 8 | jobs: 9 | stale: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/stale@v9.1.0 13 | with: 14 | repo-token: ${{ secrets.GITHUB_TOKEN }} 15 | stale-issue-message: 'This issue is stale because it has been open 60 days with no activity. Remove stale label or comment or this will be closed in 30 days' 16 | stale-pr-message: 'This pull request is stale because it has been open 60 days with no activity. Remove stale label or comment or this will be closed in 30 days' 17 | days-before-stale: 60 18 | days-before-close: 30 19 | operations-per-run: 500 20 | exempt-issue-labels: 'on-hold' -------------------------------------------------------------------------------- /custom_components/victron/base.py: -------------------------------------------------------------------------------- 1 | """Module defines entity descriptions for Victron components.""" 2 | 3 | from collections.abc import Callable 4 | from dataclasses import dataclass 5 | 6 | from homeassistant.helpers.entity import EntityDescription 7 | from homeassistant.helpers.typing import StateType 8 | 9 | 10 | @dataclass 11 | class VictronBaseEntityDescription(EntityDescription): 12 | """An extension of EntityDescription for Victron components.""" 13 | 14 | @staticmethod 15 | def lambda_func(): 16 | """Return an entitydescription.""" 17 | return lambda data, slave, key: data["data"][str(slave) + "." + str(key)] 18 | 19 | slave: int = None 20 | value_fn: Callable[[dict], StateType] = lambda_func() 21 | 22 | 23 | @dataclass 24 | class VictronWriteBaseEntityDescription(VictronBaseEntityDescription): 25 | """An extension of VictronBaseEntityDescription for writeable Victron components.""" 26 | 27 | address: int = None 28 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Request 3 | about: Request a new feature for the integration. 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | --- 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | **Describe the feature** 18 | A clear and concise description of what the feature should be. 19 | 20 | **Screenshots** 21 | If applicable, add screenshots or edits of the proposed feature that help describe it. 22 | 23 | **Home Assistant (please complete the following information):** 24 | - Home Assistant Core Version: 25 | - solaredge-modbus-multi Version: 26 | 27 | **Additional context** 28 | Add any other context about the request here. -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/change_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Change Request 3 | about: Request an update or change for the integration. 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | --- 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | **Describe the current behavior** 18 | A clear and concise description of what the feature should be. 19 | 20 | **What should be updated or changed?** 21 | A clear and concise description of the proposed new or updated behavior. 22 | 23 | **Home Assistant (please complete the following information):** 24 | - Home Assistant Core Version: 25 | - solaredge-modbus-multi Version: 26 | 27 | **Additional context** 28 | Add any other context about the request here. -------------------------------------------------------------------------------- /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | on: 2 | release: 3 | types: [published] 4 | 5 | jobs: 6 | build: 7 | name: 🚀 Release 8 | runs-on: ubuntu-latest 9 | permissions: 10 | contents: write 11 | id-token: write 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | - name: Get version 17 | id: version 18 | uses: home-assistant/actions/helpers/version@master 19 | 20 | - name: 🔢 Adjust version number 21 | run: | 22 | sed -i 's/v0.0.0/${{ steps.version.outputs.version }}/' custom_components/victron/manifest.json 23 | 24 | - name: 📦 Created zipped release package 25 | shell: bash 26 | run: | 27 | cd "${{ github.workspace }}/custom_components/victron" 28 | zip victron.zip ./* -x '.*' 29 | 30 | - name: 🔏 Sign release package 31 | uses: sigstore/gh-action-sigstore-python@v3.0.0 32 | with: 33 | inputs: ${{ github.workspace }}/custom_components/victron/victron.zip 34 | 35 | - name: ⬆️ Upload zip to release 36 | uses: softprops/action-gh-release@v2.2.1 37 | with: 38 | files: ${{ github.workspace }}/custom_components/victron/victron.zip -------------------------------------------------------------------------------- /custom_components/victron/strings.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "data": { 6 | "host": "[%key:common::config_flow::data::host%]", 7 | "port": "[%key:common::config_flow::data::port%]", 8 | "interval": "[%key:common::config_flow::data::interval%]", 9 | "advanced": "%key:common::config_flow::data::advanced%" 10 | } 11 | }, 12 | "advanced": { 13 | "data": { 14 | "use_sliders": "[%key:common::config_flow::data::use_sliders%]", 15 | "number_of_phases": "[%key:common::config_flow::data::number_of_phases%]", 16 | "ac_voltage": "[%key:common::config_flow::data::ac_voltage%]", 17 | "dc_voltage": "[%key:common::config_flow::data::dc_voltage%]", 18 | "dc_current": "[%key:common::config_flow::data::ac_current%]", 19 | "ac_current": "[%key:common::config_flow::data::dc_current%]" 20 | } 21 | } 22 | }, 23 | "error": { 24 | "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", 25 | "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", 26 | "unknown": "[%key:common::config_flow::error::unknown%]" 27 | }, 28 | "abort": { 29 | "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name-template: "v$RESOLVED_VERSION" 3 | tag-template: "v$RESOLVED_VERSION" 4 | change-template: "- $TITLE @$AUTHOR (#$NUMBER)" 5 | sort-direction: ascending 6 | 7 | categories: 8 | - title: "🚨 Breaking changes" 9 | labels: 10 | - "breaking-change" 11 | - title: "🐛 Bug fixes" 12 | labels: 13 | - "bugfix" 14 | - title: "✨ New features" 15 | labels: 16 | - "new-feature" 17 | - title: "🚀 Enhancements" 18 | collapse-after: 4 19 | labels: 20 | - "enhancement" 21 | - "refactor" 22 | - "performance" 23 | - title: "🧰 Maintenance" 24 | collapse-after: 4 25 | labels: 26 | - "maintenance" 27 | - "ci" 28 | - title: "📚 Documentation" 29 | collapse-after: 4 30 | labels: 31 | - "documentation" 32 | - title: "⬆️ Dependency updates" 33 | collapse-after: 4 34 | labels: 35 | - "dependencies" 36 | 37 | version-resolver: 38 | major: 39 | labels: 40 | - "major" 41 | - "breaking-change" 42 | minor: 43 | labels: 44 | - "minor" 45 | - "new-feature" 46 | patch: 47 | labels: 48 | - "bugfix" 49 | - "chore" 50 | - "ci" 51 | - "decoding" 52 | - "dependencies" 53 | - "documentation" 54 | - "enhancement" 55 | - "performance" 56 | - "refactor" 57 | default: patch 58 | 59 | exclude-contributors: 60 | - sfstar 61 | 62 | template: | 63 | ## Changes 64 | 65 | $CHANGES 66 | 67 | Special thanks to $CONTRIBUTORS for this release! 68 | 69 | 70 | autolabeler: 71 | - label: 'chore' 72 | files: 73 | - '*.md' 74 | branch: 75 | - '/documentation\/.+/' 76 | - label: 'bug' 77 | branch: 78 | - '/bugfix\/.+/' 79 | title: 80 | - '/bugfix/i' 81 | - label: 'new-feature' 82 | branch: 83 | - '/feature\/.+/' 84 | body: 85 | - '/adds/' 86 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report Request 3 | about: Report an issue experienced with the integration. 4 | title: '' 5 | labels: ["bugfix"] 6 | assignees: '' 7 | --- 8 | 9 | 13 | ## The problem 14 | 18 | 19 | 20 | ## Environment 21 | 26 | 27 | - Home Assistant Core release with the issue: 28 | - Last working Home Assistant Core release (if known): 29 | - Integration version causing this issue: 30 | 31 | ## Problem-relevant `configuration dialog` 32 | 37 | 38 | ```yaml 39 | 40 | ``` 41 | 42 | ## Traceback/Error logs 43 | 46 | 47 | ```txt 48 | 49 | ``` 50 | 51 | ## In case of unit ID issue, missing devices or non-updating devices 52 | 55 | 56 | ## Additional information 57 | 58 | -------------------------------------------------------------------------------- /custom_components/victron/__init__.py: -------------------------------------------------------------------------------- 1 | """The victron integration.""" 2 | 3 | from __future__ import annotations 4 | 5 | from homeassistant.config_entries import ConfigEntry 6 | from homeassistant.const import Platform 7 | from homeassistant.core import HomeAssistant 8 | 9 | from .const import CONF_HOST, CONF_INTERVAL, CONF_PORT, DOMAIN, SCAN_REGISTERS 10 | from .coordinator import victronEnergyDeviceUpdateCoordinator as Coordinator 11 | 12 | PLATFORMS: list[Platform] = [ 13 | Platform.SENSOR, 14 | Platform.SWITCH, 15 | Platform.NUMBER, 16 | Platform.SELECT, 17 | Platform.BINARY_SENSOR, 18 | Platform.BUTTON, 19 | ] 20 | 21 | 22 | async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: 23 | """Set up victron from a config entry.""" 24 | 25 | hass.data.setdefault(DOMAIN, {}) 26 | # TODO 1. Create API instance 27 | # TODO 2. Validate the API connection (and authentication) 28 | # TODO 3. Store an API object for your platforms to access 29 | # hass.data[DOMAIN][entry.entry_id] = MyApi(...) 30 | 31 | coordinator = Coordinator( 32 | hass, 33 | config_entry.options[CONF_HOST], 34 | config_entry.options[CONF_PORT], 35 | config_entry.data[SCAN_REGISTERS], 36 | config_entry.options[CONF_INTERVAL], 37 | ) 38 | # try: 39 | # await coordinator.async_config_entry_first_refresh() 40 | # except ConfigEntryNotReady: 41 | # await coordinator.api.close() 42 | # raise 43 | 44 | # Finalize 45 | hass.data.setdefault(DOMAIN, {}) 46 | hass.data[DOMAIN][config_entry.entry_id] = coordinator 47 | 48 | await coordinator.async_config_entry_first_refresh() 49 | config_entry.async_on_unload(config_entry.add_update_listener(update_listener)) 50 | await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) 51 | 52 | return True 53 | 54 | 55 | async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: 56 | """Unload a config entry.""" 57 | if unload_ok := await hass.config_entries.async_unload_platforms( 58 | config_entry, PLATFORMS 59 | ): 60 | hass.data[DOMAIN].pop(config_entry.entry_id) 61 | 62 | return unload_ok 63 | 64 | 65 | async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> None: 66 | """Update listener.""" 67 | await hass.config_entries.async_reload(config_entry.entry_id) 68 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 5 | ## Breaking change 6 | 13 | 14 | 15 | ## Proposed change 16 | 22 | 23 | 24 | ## Type of change 25 | 31 | 32 | - [ ] Dependency upgrade 33 | - [ ] Bugfix (non-breaking change which fixes an issue) 34 | - [ ] New integration (thank you!) 35 | - [ ] New feature (which adds functionality to an existing integration) 36 | - [ ] Deprecation (breaking change to happen in the future) 37 | - [ ] Breaking change (fix/feature causing existing functionality to break) 38 | - [ ] Code quality improvements to existing code or addition of tests 39 | 40 | ## Additional information 41 | 45 | 46 | - This PR fixes or closes issue: fixes # 47 | - This PR is related to issue: 48 | - Link to documentation pull request: 49 | 50 | ## Checklist 51 | 57 | 58 | - [ ] The code change is tested and works locally. 59 | - [ ] There is no commented out code in this PR. 60 | - [ ] The code has been formatted using Ruff (`ruff format custom_components/victron`) 61 | -------------------------------------------------------------------------------- /custom_components/victron/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "already_configured": "Device is already configured" 5 | }, 6 | "error": { 7 | "cannot_connect": "Failed to connect", 8 | "invalid_auth": "Invalid authentication", 9 | "unknown": "Unexpected error", 10 | "already_configured": "Only one instance of the victron integration supported at this time" 11 | }, 12 | "step": { 13 | "user": { 14 | "data": { 15 | "host": "Host", 16 | "port": "Port", 17 | "interval": "update interval in (s)", 18 | "advanced": "Enables write support and allows setting write limits" 19 | } 20 | }, 21 | "advanced": { 22 | "data": { 23 | "ac_voltage": "The AC voltage of your grid in V", 24 | "ac_current": "The AC (per phase) current limit of your grid in A", 25 | "dc_voltage": "The DC voltage of your battery in V", 26 | "dc_current": "The DC current limit of your battery in A", 27 | "number_of_phases": "The phase configuration of your system", 28 | "use_sliders": "Use stepped sliders for writeable number entities" 29 | } 30 | }, 31 | "reconfigure": { 32 | "data": { 33 | "host": "Host", 34 | "port": "Port" 35 | } 36 | } 37 | } 38 | }, 39 | "options": { 40 | "step": { 41 | "init_write": { 42 | "data": { 43 | "rescan": "Rescan available devices. This will rescan all available devices", 44 | "interval": "Update interval in (s)", 45 | "ac_voltage": "The AC voltage of your grid in V", 46 | "ac_current": "The AC (per phase) current limit of your grid in A", 47 | "dc_voltage": "The DC voltage of your battery in V", 48 | "dc_current": "The DC current limit of your battery in A", 49 | "number_of_phases": "The phase configuration of your system", 50 | "use_sliders": "Use stepped sliders for writeable number entities", 51 | "advanced": "switch to read only mode if unchecked (when submitted)" 52 | } 53 | }, 54 | "init_read": { 55 | "data": { 56 | "rescan": "Rescan available devices. This will rescan all available devices", 57 | "interval": "Update interval in (s)", 58 | "advanced": "Enable write support" 59 | } 60 | }, 61 | "advanced": { 62 | "data": { 63 | "ac_voltage": "The AC voltage of your grid in V", 64 | "ac_current": "The AC current limit of your grid in A", 65 | "dc_voltage": "The DC voltage of your battery in V", 66 | "dc_current": "The DC current limit of your battery in A", 67 | "number_of_phases": "The phase configuration of your system", 68 | "use_sliders": "Use stepped sliders for writeable number entities" 69 | } 70 | } 71 | } 72 | } 73 | } -------------------------------------------------------------------------------- /custom_components/victron/translations/sv.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "already_configured": "Enheten är redan konfigurerad" 5 | }, 6 | "error": { 7 | "cannot_connect": "Misslyckades med att ansluta", 8 | "invalid_auth": "Ogiltig autentisering", 9 | "unknown": "Oväntat fel", 10 | "already_configured": "Endast en instans av Victron-integrationen stöds för närvarande" 11 | }, 12 | "step": { 13 | "user": { 14 | "data": { 15 | "host": "Värd", 16 | "port": "Port", 17 | "interval": "Uppdateringsintervall i (s)", 18 | "advanced": "Aktiverar skrivstöd och möjliggör inställning av skrivgränser" 19 | } 20 | }, 21 | "advanced": { 22 | "data": { 23 | "ac_voltage": "Växelspänningen i ditt elnät i V", 24 | "ac_current": "Växelströmbegränsningen (per fas) i ditt elnät i A", 25 | "dc_voltage": "Likspänningen i ditt batteri i V", 26 | "dc_current": "Likströmsbegränsningen i ditt batteri i A", 27 | "number_of_phases": "Faskonfigurationen i ditt system", 28 | "use_sliders": "Använder stegade reglage för skrivbara numeriska värden" 29 | } 30 | }, 31 | "reconfigure": { 32 | "data": { 33 | "host": "Värd", 34 | "port": "Port" 35 | } 36 | } 37 | } 38 | }, 39 | "options": { 40 | "step": { 41 | "init_write": { 42 | "data": { 43 | "rescan": "Skanna om tillgängliga enheter. Detta kommer att skanna om alla tillgängliga enheter", 44 | "interval": "Uppdateringsintervall i (s)", 45 | "ac_voltage": "Växelspänningen i ditt elnät i V", 46 | "ac_current": "Växelströmbegränsningen (per fas) i ditt elnät i A", 47 | "dc_voltage": "Likspänningen i ditt batteri i V", 48 | "dc_current": "Likströmsbegränsningen i ditt batteri i A", 49 | "number_of_phases": "Faskonfigurationen i ditt system", 50 | "use_sliders": "Använder stegade reglage för skrivbara numeriska värden", 51 | "advanced": "Växla till skrivskyddat läge om det inte är markerat (vid insändning)" 52 | } 53 | }, 54 | "init_read": { 55 | "data": { 56 | "rescan": "Skanna om tillgängliga enheter. Detta kommer att skanna om alla tillgängliga enheter", 57 | "interval": "Uppdateringsintervall i (s)", 58 | "advanced": "Aktivera skrivstöd" 59 | } 60 | }, 61 | "advanced": { 62 | "data": { 63 | "ac_voltage": "Växelspänningen i ditt elnät i V", 64 | "ac_current": "Växelströmbegränsningen i ditt elnät i A", 65 | "dc_voltage": "Likspänningen i ditt batteri i V", 66 | "dc_current": "Likströmsbegränsningen i ditt batteri i A", 67 | "number_of_phases": "Faskonfigurationen i ditt system", 68 | "use_sliders": "Använder stegade reglage för skrivbara numeriska värden" 69 | } 70 | } 71 | } 72 | } 73 | } -------------------------------------------------------------------------------- /custom_components/victron/translations/nl.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "already_configured": "Apparaat is al geconfigureerd" 5 | }, 6 | "error": { 7 | "cannot_connect": "Verbinding mislukt", 8 | "invalid_auth": "Ongeldige authenticatie", 9 | "unknown": "Onverwachte fout", 10 | "already_configured": "Er wordt op dit moment slechts één instance van de Victron-integratie ondersteund" 11 | }, 12 | "step": { 13 | "user": { 14 | "data": { 15 | "host": "Host", 16 | "port": "Poort", 17 | "interval": "Update-interval in (s)", 18 | "advanced": "Schakelt schrijfondersteuning in en maakt het instellen van schrijfbeperkingen mogelijk" 19 | } 20 | }, 21 | "advanced": { 22 | "data": { 23 | "ac_voltage": "De AC-spanning van uw netwerk in V", 24 | "ac_current": "De AC (per fase) stroomlimiet van uw netwerk in A", 25 | "dc_voltage": "De DC-spanning van uw batterij in V", 26 | "dc_current": "De DC-stroomlimiet van uw batterij in A", 27 | "number_of_phases": "De faseconfiguratie van uw systeem", 28 | "use_sliders": "Gebruik trapsgewijze schuifregelaars voor beschrijfbare numerieke eenheden" 29 | } 30 | }, 31 | "reconfigure": { 32 | "data": { 33 | "host": "Host", 34 | "port": "Poort" 35 | } 36 | } 37 | } 38 | }, 39 | "options": { 40 | "step": { 41 | "init_write": { 42 | "data": { 43 | "rescan": "Beschikbare apparaten opnieuw scannen. Dit scant alle beschikbare apparaten opnieuw", 44 | "interval": "Update-interval in (s)", 45 | "ac_voltage": "De AC-spanning van uw netwerk in V", 46 | "ac_current": "De AC (per fase) stroomlimiet van uw netwerk in A", 47 | "dc_voltage": "De DC-spanning van uw batterij in V", 48 | "dc_current": "De DC-stroomlimiet van uw batterij in A", 49 | "number_of_phases": "De faseconfiguratie van uw systeem", 50 | "use_sliders": "Gebruik trapsgewijze schuifregelaars voor beschrijfbare numerieke eenheden", 51 | "advanced": "Schakel over naar alleen-lezen modus als uitgeschakeld (bij indienen)" 52 | } 53 | }, 54 | "init_read": { 55 | "data": { 56 | "rescan": "Beschikbare apparaten opnieuw scannen. Dit scant alle beschikbare apparaten opnieuw", 57 | "interval": "Update-interval in (s)", 58 | "advanced": "Schrijfondersteuning inschakelen" 59 | } 60 | }, 61 | "advanced": { 62 | "data": { 63 | "ac_voltage": "De AC-spanning van uw netwerk in V", 64 | "ac_current": "De AC-stroomlimiet van uw netwerk in A", 65 | "dc_voltage": "De DC-spanning van uw batterij in V", 66 | "dc_current": "De DC-stroomlimiet van uw batterij in A", 67 | "number_of_phases": "De faseconfiguratie van uw systeem", 68 | "use_sliders": "Gebruik trapsgewijze schuifregelaars voor beschrijfbare numerieke eenheden" 69 | } 70 | } 71 | } 72 | } 73 | } -------------------------------------------------------------------------------- /custom_components/victron/translations/es.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "already_configured": "El dispositivo ya está configurado" 5 | }, 6 | "error": { 7 | "cannot_connect": "Error al conectar", 8 | "invalid_auth": "Autenticación inválida", 9 | "unknown": "Error inesperado", 10 | "already_configured": "Solo se admite una instancia de la integración Victron en este momento" 11 | }, 12 | "step": { 13 | "user": { 14 | "data": { 15 | "host": "Host", 16 | "port": "Puerto", 17 | "interval": "Intervalo de actualización en (s)", 18 | "advanced": "Habilita el soporte de escritura y permite establecer límites de escritura" 19 | } 20 | }, 21 | "advanced": { 22 | "data": { 23 | "ac_voltage": "El voltaje AC de tu red en V", 24 | "ac_current": "El límite de corriente AC (por fase) de tu red en A", 25 | "dc_voltage": "El voltaje DC de tu batería en V", 26 | "dc_current": "El límite de corriente DC de tu batería en A", 27 | "number_of_phases": "La configuración de fases de tu sistema", 28 | "use_sliders": "Usar controles deslizantes escalonados para entidades numéricas editables" 29 | } 30 | }, 31 | "reconfigure": { 32 | "data": { 33 | "host": "Host", 34 | "port": "Puerto" 35 | } 36 | } 37 | } 38 | }, 39 | "options": { 40 | "step": { 41 | "init_write": { 42 | "data": { 43 | "rescan": "Reescanear dispositivos disponibles. Esto reescaneará todos los dispositivos disponibles", 44 | "interval": "Intervalo de actualización en (s)", 45 | "ac_voltage": "El voltaje AC de tu red en V", 46 | "ac_current": "El límite de corriente AC (por fase) de tu red en A", 47 | "dc_voltage": "El voltaje DC de tu batería en V", 48 | "dc_current": "El límite de corriente DC de tu batería en A", 49 | "number_of_phases": "La configuración de fases de tu sistema", 50 | "use_sliders": "Usar controles deslizantes escalonados para entidades numéricas editables", 51 | "advanced": "Cambiar al modo solo lectura si no está seleccionado (al enviar)" 52 | } 53 | }, 54 | "init_read": { 55 | "data": { 56 | "rescan": "Reescanear dispositivos disponibles. Esto reescaneará todos los dispositivos disponibles", 57 | "interval": "Intervalo de actualización en (s)", 58 | "advanced": "Habilitar soporte de escritura" 59 | } 60 | }, 61 | "advanced": { 62 | "data": { 63 | "ac_voltage": "El voltaje AC de tu red en V", 64 | "ac_current": "El límite de corriente AC de tu red en A", 65 | "dc_voltage": "El voltaje DC de tu batería en V", 66 | "dc_current": "El límite de corriente DC de tu batería en A", 67 | "number_of_phases": "La configuración de fases de tu sistema", 68 | "use_sliders": "Usar controles deslizantes escalonados para entidades numéricas editables" 69 | } 70 | } 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /custom_components/victron/translations/de.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "already_configured": "Gerät ist bereits konfiguriert" 5 | }, 6 | "error": { 7 | "cannot_connect": "Verbindung fehlgeschlagen", 8 | "invalid_auth": "Ungültige Authentifizierung", 9 | "unknown": "Unerwarteter Fehler", 10 | "already_configured": "Nur eine Instanz der Victron-Integration wird derzeit unterstützt" 11 | }, 12 | "step": { 13 | "user": { 14 | "data": { 15 | "host": "Host", 16 | "port": "Port", 17 | "interval": "Aktualisierungsintervall in (s)", 18 | "advanced": "Aktiviert Schreibunterstützung und ermöglicht das Setzen von Schreibgrenzen" 19 | } 20 | }, 21 | "advanced": { 22 | "data": { 23 | "ac_voltage": "Die Wechselspannung Ihres Netzes in V", 24 | "ac_current": "Die Wechselstromgrenze (pro Phase) Ihres Netzes in A", 25 | "dc_voltage": "Die Gleichspannung Ihrer Batterie in V", 26 | "dc_current": "Die Gleichstromgrenze Ihrer Batterie in A", 27 | "number_of_phases": "Die Phasenkonfiguration Ihres Systems", 28 | "use_sliders": "Verwendet abgestufte Schieberegler für beschreibbare Zahlenwerte" 29 | } 30 | }, 31 | "reconfigure": { 32 | "data": { 33 | "host": "Host", 34 | "port": "Port" 35 | } 36 | } 37 | } 38 | }, 39 | "options": { 40 | "step": { 41 | "init_write": { 42 | "data": { 43 | "rescan": "Verfügbare Geräte erneut scannen. Dadurch werden alle verfügbaren Geräte erneut gescannt", 44 | "interval": "Aktualisierungsintervall in (s)", 45 | "ac_voltage": "Die Wechselspannung Ihres Netzes in V", 46 | "ac_current": "Die Wechselstromgrenze (pro Phase) Ihres Netzes in A", 47 | "dc_voltage": "Die Gleichspannung Ihrer Batterie in V", 48 | "dc_current": "Die Gleichstromgrenze Ihrer Batterie in A", 49 | "number_of_phases": "Die Phasenkonfiguration Ihres Systems", 50 | "use_sliders": "Verwendet abgestufte Schieberegler für beschreibbare Zahlenwerte", 51 | "advanced": "Wechselt in den Nur-Lesen-Modus, wenn nicht markiert (beim Absenden)" 52 | } 53 | }, 54 | "init_read": { 55 | "data": { 56 | "rescan": "Verfügbare Geräte erneut scannen. Dadurch werden alle verfügbaren Geräte erneut gescannt", 57 | "interval": "Aktualisierungsintervall in (s)", 58 | "advanced": "Schreibunterstützung aktivieren" 59 | } 60 | }, 61 | "advanced": { 62 | "data": { 63 | "ac_voltage": "Die Wechselspannung Ihres Netzes in V", 64 | "ac_current": "Die Wechselstromgrenze Ihres Netzes in A", 65 | "dc_voltage": "Die Gleichspannung Ihrer Batterie in V", 66 | "dc_current": "Die Gleichstromgrenze Ihrer Batterie in A", 67 | "number_of_phases": "Die Phasenkonfiguration Ihres Systems", 68 | "use_sliders": "Verwendet abgestufte Schieberegler für beschreibbare Zahlenwerte" 69 | } 70 | } 71 | } 72 | } 73 | } -------------------------------------------------------------------------------- /custom_components/victron/translations/fr.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "already_configured": "L'appareil est déjà configuré" 5 | }, 6 | "error": { 7 | "cannot_connect": "Échec de la connexion", 8 | "invalid_auth": "Authentification invalide", 9 | "unknown": "Erreur inattendue", 10 | "already_configured": "Une seule instance de l'intégration Victron est prise en charge pour le moment" 11 | }, 12 | "step": { 13 | "user": { 14 | "data": { 15 | "host": "Hôte", 16 | "port": "Port", 17 | "interval": "Intervalle de mise à jour en (s)", 18 | "advanced": "Active le support en écriture et permet de définir des limites d'écriture" 19 | } 20 | }, 21 | "advanced": { 22 | "data": { 23 | "ac_voltage": "La tension AC de votre réseau en V", 24 | "ac_current": "La limite de courant AC (par phase) de votre réseau en A", 25 | "dc_voltage": "La tension DC de votre batterie en V", 26 | "dc_current": "La limite de courant DC de votre batterie en A", 27 | "number_of_phases": "La configuration de phase de votre système", 28 | "use_sliders": "Utilise des curseurs gradués pour les entités numériques modifiables" 29 | } 30 | }, 31 | "reconfigure": { 32 | "data": { 33 | "host": "Hôte", 34 | "port": "Port" 35 | } 36 | } 37 | } 38 | }, 39 | "options": { 40 | "step": { 41 | "init_write": { 42 | "data": { 43 | "rescan": "Rescanner les appareils disponibles. Cela rescannera tous les appareils disponibles", 44 | "interval": "Intervalle de mise à jour en (s)", 45 | "ac_voltage": "La tension AC de votre réseau en V", 46 | "ac_current": "La limite de courant AC (par phase) de votre réseau en A", 47 | "dc_voltage": "La tension DC de votre batterie en V", 48 | "dc_current": "La limite de courant DC de votre batterie en A", 49 | "number_of_phases": "La configuration de phase de votre système", 50 | "use_sliders": "Utilise des curseurs gradués pour les entités numériques modifiables", 51 | "advanced": "Basculer en mode lecture seule si décoché (lors de l'envoi)" 52 | } 53 | }, 54 | "init_read": { 55 | "data": { 56 | "rescan": "Rescanner les appareils disponibles. Cela rescannera tous les appareils disponibles", 57 | "interval": "Intervalle de mise à jour en (s)", 58 | "advanced": "Activer le support en écriture" 59 | } 60 | }, 61 | "advanced": { 62 | "data": { 63 | "ac_voltage": "La tension AC de votre réseau en V", 64 | "ac_current": "La limite de courant AC de votre réseau en A", 65 | "dc_voltage": "La tension DC de votre batterie en V", 66 | "dc_current": "La limite de courant DC de votre batterie en A", 67 | "number_of_phases": "La configuration de phase de votre système", 68 | "use_sliders": "Utilise des curseurs gradués pour les entités numériques modifiables" 69 | } 70 | } 71 | } 72 | } 73 | } -------------------------------------------------------------------------------- /custom_components/victron/translations/it.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "already_configured": "Il dispositivo è già configurato" 5 | }, 6 | "error": { 7 | "cannot_connect": "Connessione fallita", 8 | "invalid_auth": "Autenticazione non valida", 9 | "unknown": "Errore inaspettato", 10 | "already_configured": "È supportata solo un'istanza dell'integrazione Victron al momento" 11 | }, 12 | "step": { 13 | "user": { 14 | "data": { 15 | "host": "Host", 16 | "port": "Porta", 17 | "interval": "Intervallo di aggiornamento in (s)", 18 | "advanced": "Abilita il supporto in scrittura e consente di impostare limiti di scrittura" 19 | } 20 | }, 21 | "advanced": { 22 | "data": { 23 | "ac_voltage": "La tensione AC della tua rete in V", 24 | "ac_current": "Il limite di corrente AC (per fase) della tua rete in A", 25 | "dc_voltage": "La tensione DC della tua batteria in V", 26 | "dc_current": "Il limite di corrente DC della tua batteria in A", 27 | "number_of_phases": "La configurazione delle fasi del tuo sistema", 28 | "use_sliders": "Utilizza cursori a passi per entità numeriche scrivibili" 29 | } 30 | }, 31 | "reconfigure": { 32 | "data": { 33 | "host": "Host", 34 | "port": "Porta" 35 | } 36 | } 37 | } 38 | }, 39 | "options": { 40 | "step": { 41 | "init_write": { 42 | "data": { 43 | "rescan": "Esegui nuovamente la scansione dei dispositivi disponibili. Questo eseguirà la scansione di tutti i dispositivi disponibili", 44 | "interval": "Intervallo di aggiornamento in (s)", 45 | "ac_voltage": "La tensione AC della tua rete in V", 46 | "ac_current": "Il limite di corrente AC (per fase) della tua rete in A", 47 | "dc_voltage": "La tensione DC della tua batteria in V", 48 | "dc_current": "Il limite di corrente DC della tua batteria in A", 49 | "number_of_phases": "La configurazione delle fasi del tuo sistema", 50 | "use_sliders": "Utilizza cursori a passi per entità numeriche scrivibili", 51 | "advanced": "Passa alla modalità sola lettura se deselezionato (al momento dell'invio)" 52 | } 53 | }, 54 | "init_read": { 55 | "data": { 56 | "rescan": "Esegui nuovamente la scansione dei dispositivi disponibili. Questo eseguirà la scansione di tutti i dispositivi disponibili", 57 | "interval": "Intervallo di aggiornamento in (s)", 58 | "advanced": "Abilita il supporto in scrittura" 59 | } 60 | }, 61 | "advanced": { 62 | "data": { 63 | "ac_voltage": "La tensione AC della tua rete in V", 64 | "ac_current": "Il limite di corrente AC della tua rete in A", 65 | "dc_voltage": "La tensione DC della tua batteria in V", 66 | "dc_current": "Il limite di corrente DC della tua batteria in A", 67 | "number_of_phases": "La configurazione delle fasi del tuo sistema", 68 | "use_sliders": "Utilizza cursori a passi per entità numeriche scrivibili" 69 | } 70 | } 71 | } 72 | } 73 | } -------------------------------------------------------------------------------- /.github/labels.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: "breaking-change" 3 | color: ee0701 4 | description: "A breaking change for existing users." 5 | - name: "bugfix" 6 | color: ee0701 7 | description: "Inconsistencies or issues which will cause a problem for users or implementors." 8 | - name: "documentation" 9 | color: 0052cc 10 | description: "Solely about the documentation of the project." 11 | - name: "enhancement" 12 | color: 1d76db 13 | description: "Enhancement of the code, not introducing new features." 14 | - name: "refactor" 15 | color: 1d76db 16 | description: "Improvement of existing code, not introducing new features." 17 | - name: "performance" 18 | color: 1d76db 19 | description: "Improving performance, not introducing new features." 20 | - name: "new-feature" 21 | color: 0e8a16 22 | description: "New features or options." 23 | - name: "maintenance" 24 | color: 2af79e 25 | description: "Generic maintenance tasks." 26 | - name: "ci" 27 | color: 1d76db 28 | description: "Work that improves the continue integration." 29 | - name: "dependencies" 30 | color: 1d76db 31 | description: "Upgrade or downgrade of project dependencies." 32 | 33 | - name: "in-progress" 34 | color: fbca04 35 | description: "Issue is currently being resolved by a developer." 36 | - name: "stale" 37 | color: fef2c0 38 | description: "There has not been activity on this issue or PR for quite some time." 39 | - name: "no-stale" 40 | color: fef2c0 41 | description: "This issue or PR is exempted from the stable bot." 42 | 43 | - name: "security" 44 | color: ee0701 45 | description: "Marks a security issue that needs to be resolved asap." 46 | - name: "incomplete" 47 | color: fef2c0 48 | description: "Marks a PR or issue that is missing information." 49 | - name: "invalid" 50 | color: fef2c0 51 | description: "Marks a PR or issue that is missing information." 52 | 53 | - name: "beginner-friendly" 54 | color: 0e8a16 55 | description: "Good first issue for people wanting to contribute to the project." 56 | - name: "help-wanted" 57 | color: 0e8a16 58 | description: "We need some extra helping hands or expertise in order to resolve this." 59 | 60 | - name: "priority-critical" 61 | color: ee0701 62 | description: "This should be dealt with ASAP. Not fixing this issue would be a serious error." 63 | - name: "priority-high" 64 | color: b60205 65 | description: "After critical issues are fixed, these should be dealt with before any further issues." 66 | - name: "priority-medium" 67 | color: 0e8a16 68 | description: "This issue may be useful, and needs some attention." 69 | - name: "priority-low" 70 | color: e4ea8a 71 | description: "Nice addition, maybe... someday..." 72 | 73 | - name: "major" 74 | color: b60205 75 | description: "This PR causes a major version bump in the version number." 76 | - name: "minor" 77 | color: 0e8a16 78 | description: "This PR causes a minor version bump in the version number." 79 | 80 | 81 | - name: "shipped" 82 | color: 006b75 83 | description: "This issue has been shipped. 🚢" 84 | - name: "on-hold" 85 | color: 0052cc 86 | description: "This issue is on hold and will not be worked on until further notice." 87 | - name: "duplicate" 88 | color: fef2c0 89 | description: "This reported issue already exists." 90 | - name: "wontfix" 91 | color: ffffff 92 | description: "This issue will not be fixed." 93 | 94 | - name: "connection stability" 95 | color: 32AE82 96 | description: "Issue related to the stability of the connection." 97 | - name: "data accuracy" 98 | color: 32AE82 99 | description: "Issue related to the format or representation of returned/to be returned data." 100 | - name: "specsheet mismatch" 101 | color: d93f0b 102 | description: "Issue related to the mismatch between the victron specsheet and the actual implementation." 103 | - name: "shipping next release (do not close)🚢" 104 | color: b60205 105 | description: "This issue is scheduled to be shipped in the next release." -------------------------------------------------------------------------------- /custom_components/victron/button.py: -------------------------------------------------------------------------------- 1 | """Support for Victron energy button sensors.""" 2 | 3 | from __future__ import annotations 4 | 5 | from dataclasses import dataclass 6 | import logging 7 | 8 | from homeassistant.components.button import ( 9 | DOMAIN as BUTTON_DOMAIN, 10 | ButtonDeviceClass, 11 | ButtonEntity, 12 | ButtonEntityDescription, 13 | ) 14 | from homeassistant.config_entries import ConfigEntry 15 | from homeassistant.core import HassJob, HomeAssistant 16 | from homeassistant.helpers import entity 17 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 18 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 19 | 20 | from .base import VictronWriteBaseEntityDescription 21 | from .const import CONF_ADVANCED_OPTIONS, DOMAIN, ButtonWriteType, register_info_dict 22 | from .coordinator import victronEnergyDeviceUpdateCoordinator 23 | 24 | _LOGGER = logging.getLogger(__name__) 25 | 26 | 27 | async def async_setup_entry( 28 | hass: HomeAssistant, 29 | config_entry: ConfigEntry, 30 | async_add_entities: AddEntitiesCallback, 31 | ) -> None: 32 | """Set up Victron energy binary sensor entries.""" 33 | _LOGGER.debug("attempting to setup button entities") 34 | victron_coordinator: victronEnergyDeviceUpdateCoordinator = hass.data[DOMAIN][ 35 | config_entry.entry_id 36 | ] 37 | descriptions = [] 38 | # TODO cleanup 39 | register_set = victron_coordinator.processed_data()["register_set"] 40 | for slave, registerLedger in register_set.items(): 41 | for name in registerLedger: 42 | for register_name, registerInfo in register_info_dict[name].items(): 43 | _LOGGER.debug( 44 | "unit == %s registerLedger == %s registerInfo", 45 | slave, 46 | registerLedger, 47 | ) 48 | if not config_entry.options[CONF_ADVANCED_OPTIONS]: 49 | continue 50 | 51 | if isinstance(registerInfo.entityType, ButtonWriteType): 52 | description = VictronEntityDescription( 53 | key=register_name, 54 | name=register_name.replace("_", " "), 55 | slave=slave, 56 | device_class=ButtonDeviceClass.RESTART, 57 | address=registerInfo.register, 58 | ) 59 | _LOGGER.debug("composed description == %s", description) 60 | descriptions.append(description) 61 | 62 | entities = [] 63 | entity = {} 64 | for description in descriptions: 65 | entity = description 66 | entities.append(VictronBinarySensor(victron_coordinator, entity)) 67 | 68 | async_add_entities(entities, True) 69 | 70 | 71 | @dataclass 72 | class VictronEntityDescription( 73 | ButtonEntityDescription, VictronWriteBaseEntityDescription 74 | ): 75 | """Describes victron sensor entity.""" 76 | 77 | 78 | class VictronBinarySensor(CoordinatorEntity, ButtonEntity): 79 | """A button implementation for Victron energy device.""" 80 | 81 | def __init__( 82 | self, 83 | coordinator: victronEnergyDeviceUpdateCoordinator, 84 | description: VictronEntityDescription, 85 | ) -> None: 86 | """Initialize the sensor.""" 87 | self.description: VictronEntityDescription = description 88 | self._attr_device_class = description.device_class 89 | self._attr_name = f"{description.name}" 90 | 91 | self._attr_unique_id = f"{self.description.slave}_{self.description.key}" 92 | if self.description.slave not in (100, 225): 93 | self.entity_id = f"{BUTTON_DOMAIN}.{DOMAIN}_{self.description.key}_{self.description.slave}" 94 | else: 95 | self.entity_id = f"{BUTTON_DOMAIN}.{DOMAIN}_{self.description.key}" 96 | 97 | self._update_job = HassJob(self.async_schedule_update_ha_state) 98 | self._unsub_update = None 99 | 100 | super().__init__(coordinator) 101 | 102 | async def async_press(self) -> None: 103 | """Handle the button press.""" 104 | self.coordinator.write_register( 105 | unit=self.description.slave, address=self.description.address, value=1 106 | ) 107 | 108 | @property 109 | def available(self) -> bool: 110 | """Return True if entity available.""" 111 | full_key = str(self.description.slave) + "." + self.description.key 112 | return self.coordinator.processed_data()["availability"][full_key] 113 | 114 | @property 115 | def device_info(self) -> entity.DeviceInfo: 116 | """Return the device info.""" 117 | return entity.DeviceInfo( 118 | identifiers={(DOMAIN, self.unique_id.split("_")[0])}, 119 | name=self.unique_id.split("_")[1], 120 | model=self.unique_id.split("_")[0], 121 | manufacturer="victron", 122 | ) 123 | -------------------------------------------------------------------------------- /custom_components/victron/binary_sensor.py: -------------------------------------------------------------------------------- 1 | """Support for Victron Energy binary sensors.""" 2 | 3 | from __future__ import annotations 4 | 5 | from dataclasses import dataclass 6 | import logging 7 | from typing import cast 8 | 9 | from homeassistant.components.binary_sensor import ( 10 | DOMAIN as BINARY_SENSOR_DOMAIN, 11 | BinarySensorEntity, 12 | BinarySensorEntityDescription, 13 | ) 14 | from homeassistant.config_entries import ConfigEntry 15 | from homeassistant.core import HassJob, HomeAssistant 16 | from homeassistant.helpers import entity 17 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 18 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 19 | 20 | from .base import VictronBaseEntityDescription 21 | from .const import DOMAIN, BoolReadEntityType, register_info_dict 22 | from .coordinator import victronEnergyDeviceUpdateCoordinator 23 | 24 | _LOGGER = logging.getLogger(__name__) 25 | 26 | 27 | async def async_setup_entry( 28 | hass: HomeAssistant, 29 | config_entry: ConfigEntry, 30 | async_add_entities: AddEntitiesCallback, 31 | ) -> None: 32 | """Set up Victron energy binary sensor entries.""" 33 | _LOGGER.debug("attempting to setup binary sensor entities") 34 | victron_coordinator: victronEnergyDeviceUpdateCoordinator = hass.data[DOMAIN][ 35 | config_entry.entry_id 36 | ] 37 | _LOGGER.debug(victron_coordinator.processed_data()["register_set"]) 38 | _LOGGER.debug(victron_coordinator.processed_data()["data"]) 39 | descriptions = [] 40 | # TODO cleanup 41 | register_set = victron_coordinator.processed_data()["register_set"] 42 | for slave, registerLedger in register_set.items(): 43 | for name in registerLedger: 44 | for register_name, registerInfo in register_info_dict[name].items(): 45 | _LOGGER.debug( 46 | "unit == %s registerLedger == %s registerInfo", 47 | slave, 48 | registerLedger, 49 | ) 50 | 51 | if isinstance(registerInfo.entityType, BoolReadEntityType): 52 | description = VictronEntityDescription( 53 | key=register_name, 54 | name=register_name.replace("_", " "), 55 | slave=slave, 56 | ) 57 | _LOGGER.debug("composed description == %s", description) 58 | descriptions.append(description) 59 | 60 | entities = [] 61 | entity = {} 62 | for description in descriptions: 63 | entity = description 64 | entities.append(VictronBinarySensor(victron_coordinator, entity)) 65 | 66 | async_add_entities(entities, True) 67 | 68 | 69 | @dataclass 70 | class VictronEntityDescription( 71 | BinarySensorEntityDescription, VictronBaseEntityDescription 72 | ): 73 | """Describes victron sensor entity.""" 74 | 75 | 76 | class VictronBinarySensor(CoordinatorEntity, BinarySensorEntity): 77 | """A binary sensor implementation for Victron energy device.""" 78 | 79 | def __init__( 80 | self, 81 | coordinator: victronEnergyDeviceUpdateCoordinator, 82 | description: VictronEntityDescription, 83 | ) -> None: 84 | """Initialize the binary sensor.""" 85 | self.description: VictronEntityDescription = description 86 | # this needs to be changed to allow multiple of the same type 87 | self._attr_device_class = description.device_class 88 | self._attr_name = f"{description.name}" 89 | 90 | self._attr_unique_id = f"{self.description.slave}_{self.description.key}" 91 | if self.description.slave not in (100, 225): 92 | self.entity_id = f"{BINARY_SENSOR_DOMAIN}.{DOMAIN}_{self.description.key}_{self.description.slave}" 93 | else: 94 | self.entity_id = f"{BINARY_SENSOR_DOMAIN}.{DOMAIN}_{self.description.key}" 95 | 96 | self._update_job = HassJob(self.async_schedule_update_ha_state) 97 | self._unsub_update = None 98 | 99 | super().__init__(coordinator) 100 | 101 | @property 102 | def is_on(self) -> bool: 103 | """Return True if the binary sensor is on.""" 104 | data = self.description.value_fn( 105 | self.coordinator.processed_data(), 106 | self.description.slave, 107 | self.description.key, 108 | ) 109 | return cast("bool", data) 110 | 111 | @property 112 | def available(self) -> bool: 113 | """Return True if entity is available.""" 114 | full_key = str(self.description.slave) + "." + self.description.key 115 | return self.coordinator.processed_data()["availability"][full_key] 116 | 117 | @property 118 | def device_info(self) -> entity.DeviceInfo: 119 | """Return the device info.""" 120 | return entity.DeviceInfo( 121 | identifiers={(DOMAIN, self.unique_id.split("_")[0])}, 122 | name=self.unique_id.split("_")[1], 123 | model=self.unique_id.split("_")[0], 124 | manufacturer="victron", 125 | ) 126 | -------------------------------------------------------------------------------- /custom_components/victron/switch.py: -------------------------------------------------------------------------------- 1 | """Support for victron energy switches.""" 2 | 3 | from __future__ import annotations 4 | 5 | from dataclasses import dataclass 6 | import logging 7 | from typing import Any, cast 8 | 9 | from homeassistant.components.switch import ( 10 | DOMAIN as SWITCH_DOMAIN, 11 | SwitchEntity, 12 | SwitchEntityDescription, 13 | ) 14 | from homeassistant.config_entries import ConfigEntry 15 | from homeassistant.core import HassJob, HomeAssistant 16 | from homeassistant.helpers import entity 17 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 18 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 19 | 20 | from .base import VictronWriteBaseEntityDescription 21 | from .const import CONF_ADVANCED_OPTIONS, DOMAIN, SwitchWriteType, register_info_dict 22 | from .coordinator import victronEnergyDeviceUpdateCoordinator 23 | 24 | _LOGGER = logging.getLogger(__name__) 25 | 26 | 27 | async def async_setup_entry( 28 | hass: HomeAssistant, 29 | config_entry: ConfigEntry, 30 | async_add_entities: AddEntitiesCallback, 31 | ) -> None: 32 | """Set up victron switch devices.""" 33 | victron_coordinator: victronEnergyDeviceUpdateCoordinator = hass.data[DOMAIN][ 34 | config_entry.entry_id 35 | ] 36 | _LOGGER.debug("attempting to setup switch entities") 37 | descriptions = [] 38 | # TODO cleanup 39 | if config_entry.options[CONF_ADVANCED_OPTIONS]: 40 | register_set = victron_coordinator.processed_data()["register_set"] 41 | for slave, registerLedger in register_set.items(): 42 | for name in registerLedger: 43 | for register_name, registerInfo in register_info_dict[name].items(): 44 | _LOGGER.debug( 45 | "unit == %s registerLedger == %s registerInfo == %s", 46 | slave, 47 | registerLedger, 48 | registerInfo, 49 | ) 50 | 51 | if isinstance(registerInfo.entityType, SwitchWriteType): 52 | description = VictronEntityDescription( 53 | key=register_name, 54 | name=register_name.replace("_", " "), 55 | slave=slave, 56 | address=registerInfo.register, 57 | ) 58 | descriptions.append(description) 59 | _LOGGER.debug("composed description == %s", description) 60 | 61 | entities = [] 62 | entity = {} 63 | for description in descriptions: 64 | entity = description 65 | entities.append(VictronSwitch(hass, victron_coordinator, entity)) 66 | _LOGGER.debug("adding switches") 67 | _LOGGER.debug(entities) 68 | async_add_entities(entities) 69 | 70 | 71 | @dataclass 72 | class VictronEntityDescription( 73 | SwitchEntityDescription, VictronWriteBaseEntityDescription 74 | ): 75 | """Describes victron sensor entity.""" 76 | 77 | 78 | class VictronSwitch(CoordinatorEntity, SwitchEntity): 79 | """Representation of a Victron switch.""" 80 | 81 | def __init__( 82 | self, 83 | hass: HomeAssistant, 84 | coordinator: victronEnergyDeviceUpdateCoordinator, 85 | description: VictronEntityDescription, 86 | ) -> None: 87 | """Initialize the switch.""" 88 | self.coordinator = coordinator 89 | self.description: VictronEntityDescription = description 90 | self._attr_name = f"{description.name}" 91 | self.data_key = str(self.description.slave) + "." + str(self.description.key) 92 | 93 | self._attr_unique_id = f"{description.slave}_{self.description.key}" 94 | if description.slave not in (100, 225): 95 | self.entity_id = ( 96 | f"{SWITCH_DOMAIN}.{DOMAIN}_{self.description.key}_{description.slave}" 97 | ) 98 | else: 99 | self.entity_id = f"{SWITCH_DOMAIN}.{DOMAIN}_{self.description.key}" 100 | 101 | self._update_job = HassJob(self.async_schedule_update_ha_state) 102 | self._unsub_update = None 103 | super().__init__(coordinator) 104 | 105 | async def async_turn_on(self, **kwargs: Any) -> None: 106 | """Turn on the device.""" 107 | self.coordinator.write_register( 108 | unit=self.description.slave, address=self.description.address, value=1 109 | ) 110 | await self.coordinator.async_update_local_entry(self.data_key, 1) 111 | 112 | async def async_turn_off(self, **kwargs: Any) -> None: 113 | """Turn off the device.""" 114 | self.coordinator.write_register( 115 | unit=self.description.slave, address=self.description.address, value=0 116 | ) 117 | await self.coordinator.async_update_local_entry(self.data_key, 0) 118 | 119 | @property 120 | def is_on(self) -> bool: 121 | """Return true if switch is on.""" 122 | data = self.description.value_fn( 123 | self.coordinator.processed_data(), 124 | self.description.slave, 125 | self.description.key, 126 | ) 127 | return cast("bool", data) 128 | 129 | @property 130 | def available(self) -> bool: 131 | """Return True if entity is available.""" 132 | full_key = str(self.description.slave) + "." + self.description.key 133 | return self.coordinator.processed_data()["availability"][full_key] 134 | 135 | @property 136 | def device_info(self) -> entity.DeviceInfo: 137 | """Return the device info.""" 138 | return entity.DeviceInfo( 139 | identifiers={(DOMAIN, self.unique_id.split("_")[0])}, 140 | name=self.unique_id.split("_")[1], 141 | model=self.unique_id.split("_")[0], 142 | manufacturer="victron", 143 | ) 144 | -------------------------------------------------------------------------------- /custom_components/victron/hub.py: -------------------------------------------------------------------------------- 1 | """Support for Victron Energy devices.""" 2 | 3 | from collections import OrderedDict 4 | import logging 5 | import threading 6 | 7 | from packaging import version 8 | import pymodbus 9 | from pymodbus.client import ModbusTcpClient 10 | 11 | from homeassistant.exceptions import HomeAssistantError 12 | 13 | from .const import ( 14 | INT16, 15 | INT32, 16 | INT64, 17 | STRING, 18 | UINT16, 19 | UINT32, 20 | UINT64, 21 | register_info_dict, 22 | valid_unit_ids, 23 | ) 24 | 25 | _LOGGER = logging.getLogger(__name__) 26 | 27 | 28 | class VictronHub: 29 | """Victron Hub.""" 30 | 31 | def __init__(self, host: str, port: int) -> None: 32 | """Initialize.""" 33 | self.host = host 34 | self.port = port 35 | self._client = ModbusTcpClient(host=self.host, port=self.port) 36 | self._lock = threading.Lock() 37 | 38 | def is_still_connected(self): 39 | """Check if the connection is still open.""" 40 | return self._client.is_socket_open() 41 | 42 | def convert_string_from_register(self, segment, string_encoding="ascii"): 43 | """Convert from registers to the appropriate data type.""" 44 | if ( 45 | version.parse("3.8.0") 46 | <= version.parse(pymodbus.__version__) 47 | <= version.parse("3.8.4") 48 | ): 49 | return self._client.convert_from_registers( 50 | segment, self._client.DATATYPE.STRING 51 | ).split("\x00")[0] 52 | return self._client.convert_from_registers( 53 | segment, self._client.DATATYPE.STRING, string_encoding=string_encoding 54 | ).split("\x00")[0] 55 | 56 | def convert_number_from_register(self, segment, dataType): 57 | """Convert from registers to the appropriate data type.""" 58 | if dataType == UINT16: 59 | raw = self._client.convert_from_registers( 60 | segment, data_type=self._client.DATATYPE.UINT16 61 | ) 62 | elif dataType == INT16: 63 | raw = self._client.convert_from_registers( 64 | segment, data_type=self._client.DATATYPE.INT16 65 | ) 66 | elif dataType == UINT32: 67 | raw = self._client.convert_from_registers( 68 | segment, data_type=self._client.DATATYPE.UINT32 69 | ) 70 | elif dataType == INT32: 71 | raw = self._client.convert_from_registers( 72 | segment, data_type=self._client.DATATYPE.INT32 73 | ) 74 | return raw 75 | 76 | def connect(self): 77 | """Connect to the Modbus TCP server.""" 78 | return self._client.connect() 79 | 80 | def disconnect(self): 81 | """Disconnect from the Modbus TCP server.""" 82 | if self._client.is_socket_open(): 83 | return self._client.close() 84 | return None 85 | 86 | def write_register(self, unit, address, value): 87 | """Write a register.""" 88 | slave = int(unit) if unit else 1 89 | return self._client.write_register( 90 | address=address, value=value, device_id=slave 91 | ) 92 | 93 | def read_holding_registers(self, unit, address, count): 94 | """Read holding registers.""" 95 | slave = int(unit) if unit else 1 96 | _LOGGER.info("Reading unit %s address %s count %s", unit, address, count) 97 | return self._client.read_holding_registers( 98 | address=address, count=count, device_id=slave 99 | ) 100 | 101 | def calculate_register_count(self, registerInfoDict: OrderedDict): 102 | """Calculate the number of registers to read.""" 103 | first_key = next(iter(registerInfoDict)) 104 | last_key = next(reversed(registerInfoDict)) 105 | end_correction = 1 106 | if registerInfoDict[last_key].dataType in (INT32, UINT32): 107 | end_correction = 2 108 | elif registerInfoDict[last_key].dataType in (INT64, UINT64): 109 | end_correction = 4 110 | elif isinstance(registerInfoDict[last_key].dataType, STRING): 111 | end_correction = registerInfoDict[last_key].dataType.length 112 | 113 | return ( 114 | registerInfoDict[last_key].register - registerInfoDict[first_key].register 115 | ) + end_correction 116 | 117 | def get_first_register_id(self, registerInfoDict: OrderedDict): 118 | """Return first register id.""" 119 | first_register = next(iter(registerInfoDict)) 120 | return registerInfoDict[first_register].register 121 | 122 | def determine_present_devices(self): 123 | """Determine which devices are present.""" 124 | valid_devices = {} 125 | 126 | _LOGGER.debug("Determining present devices") 127 | 128 | for unit in valid_unit_ids: 129 | working_registers = [] 130 | for key, register_definition in register_info_dict.items(): 131 | _LOGGER.debug("Checking unit %s for register set %s", unit, key) 132 | # VE.CAN device zero is present under unit 100. This seperates non system / settings entities into the seperate can device 133 | if unit == 100 and not key.startswith(("settings", "system")): 134 | continue 135 | 136 | try: 137 | address = self.get_first_register_id(register_definition) 138 | count = self.calculate_register_count(register_definition) 139 | result = self.read_holding_registers(unit, address, count) 140 | if result.isError(): 141 | _LOGGER.debug( 142 | "result is error for unit: %s address: %s count: %s", 143 | unit, 144 | address, 145 | count, 146 | ) 147 | else: 148 | working_registers.append(key) 149 | except HomeAssistantError as e: 150 | _LOGGER.error(e) 151 | 152 | if len(working_registers) > 0: 153 | valid_devices[unit] = working_registers 154 | else: 155 | _LOGGER.debug("no registers found for unit: %s", unit) 156 | 157 | return valid_devices 158 | -------------------------------------------------------------------------------- /custom_components/victron/select.py: -------------------------------------------------------------------------------- 1 | """Support for Victron energy switches.""" 2 | 3 | from __future__ import annotations 4 | 5 | from dataclasses import dataclass 6 | from datetime import timedelta 7 | from enum import Enum 8 | import logging 9 | 10 | from homeassistant.components.select import ( 11 | DOMAIN as SELECT_DOMAIN, 12 | SelectEntity, 13 | SelectEntityDescription, 14 | ) 15 | from homeassistant.config_entries import ConfigEntry 16 | from homeassistant.core import HassJob, HomeAssistant 17 | from homeassistant.helpers import entity, event 18 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 19 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 20 | from homeassistant.util import utcnow 21 | 22 | from .base import VictronWriteBaseEntityDescription 23 | from .const import CONF_ADVANCED_OPTIONS, DOMAIN, SelectWriteType, register_info_dict 24 | from .coordinator import victronEnergyDeviceUpdateCoordinator 25 | 26 | _LOGGER = logging.getLogger(__name__) 27 | 28 | 29 | async def async_setup_entry( 30 | hass: HomeAssistant, 31 | config_entry: ConfigEntry, 32 | async_add_entities: AddEntitiesCallback, 33 | ) -> None: 34 | """Set up victron select devices.""" 35 | victron_coordinator: victronEnergyDeviceUpdateCoordinator = hass.data[DOMAIN][ 36 | config_entry.entry_id 37 | ] 38 | _LOGGER.debug("attempting to setup select entities") 39 | descriptions = [] 40 | # TODO cleanup 41 | if config_entry.options[CONF_ADVANCED_OPTIONS]: 42 | register_set = victron_coordinator.processed_data()["register_set"] 43 | for slave, registerLedger in register_set.items(): 44 | for name in registerLedger: 45 | for register_name, registerInfo in register_info_dict[name].items(): 46 | if isinstance(registerInfo.entityType, SelectWriteType): 47 | _LOGGER.debug( 48 | "unit == %s registerLedger == %s registerInfo", 49 | slave, 50 | registerLedger, 51 | ) 52 | 53 | description = VictronEntityDescription( 54 | key=register_name, 55 | name=register_name.replace("_", " "), 56 | slave=slave, 57 | options=registerInfo.entityType.options, 58 | address=registerInfo.register, 59 | ) 60 | 61 | descriptions.append(description) 62 | _LOGGER.debug("composed description == %s", description) 63 | 64 | entities = [] 65 | entity = {} 66 | for description in descriptions: 67 | entity = description 68 | entities.append(VictronSelect(hass, victron_coordinator, entity)) 69 | _LOGGER.debug("adding selects") 70 | _LOGGER.debug(entities) 71 | async_add_entities(entities) 72 | 73 | 74 | @dataclass 75 | class VictronEntityDescription( 76 | SelectEntityDescription, VictronWriteBaseEntityDescription 77 | ): 78 | """Describes victron sensor entity.""" 79 | 80 | options: Enum = None 81 | 82 | 83 | class VictronSelect(CoordinatorEntity, SelectEntity): 84 | """Representation of a Victron switch.""" 85 | 86 | description: VictronEntityDescription 87 | 88 | def __init__( 89 | self, 90 | hass: HomeAssistant, 91 | coordinator: victronEnergyDeviceUpdateCoordinator, 92 | description: VictronEntityDescription, 93 | ) -> None: 94 | """Initialize the select.""" 95 | self._attr_native_value = None 96 | _LOGGER.debug("select init") 97 | self.coordinator = coordinator 98 | self.description: VictronEntityDescription = description 99 | # this needs to be changed to allow multiple of the same type 100 | self._attr_name = f"{description.name}" 101 | 102 | self._attr_unique_id = f"{self.description.slave}_{self.description.key}" 103 | if self.description.slave not in (100, 225): 104 | self.entity_id = f"{SELECT_DOMAIN}.{DOMAIN}_{self.description.key}_{self.description.slave}" 105 | else: 106 | self.entity_id = f"{SELECT_DOMAIN}.{DOMAIN}_{self.description.key}" 107 | 108 | self._update_job = HassJob(self.async_schedule_update_ha_state) 109 | self._unsub_update = None 110 | super().__init__(coordinator) 111 | 112 | async def async_update(self) -> None: 113 | """Get the latest data and updates the states.""" 114 | _LOGGER.debug("select_async_update") 115 | try: 116 | self._attr_native_value = self.description.value_fn( 117 | self.coordinator.processed_data(), 118 | self.description.slave, 119 | self.description.key, 120 | ) 121 | except (TypeError, IndexError): 122 | _LOGGER.debug("failed to retrieve value") 123 | # No data available 124 | self._attr_native_value = None 125 | 126 | # Cancel the currently scheduled event if there is any 127 | if self._unsub_update: 128 | self._unsub_update() 129 | self._unsub_update = None 130 | 131 | # Schedule the next update at exactly the next whole hour sharp 132 | self._unsub_update = event.async_track_point_in_utc_time( 133 | self.hass, 134 | self._update_job, 135 | utcnow() + timedelta(seconds=self.coordinator.interval), 136 | ) 137 | 138 | @property 139 | def current_option(self) -> str: 140 | """Return the currently selected option.""" 141 | return self.description.options( 142 | self.description.value_fn( 143 | self.coordinator.processed_data(), 144 | self.description.slave, 145 | self.description.key, 146 | ) 147 | ).name 148 | 149 | @property 150 | def options(self) -> list: 151 | """Return a list of available options.""" 152 | return [option.name for option in self.description.options] 153 | 154 | async def async_select_option(self, option: str) -> None: 155 | """Change the selected option.""" 156 | self.coordinator.write_register( 157 | unit=self.description.slave, 158 | address=self.description.address, 159 | value=self.coordinator.encode_scaling( 160 | int(self.description.options[option].value), "", 0 161 | ), 162 | ) 163 | 164 | # TODO extract these type of property definitions to base class 165 | @property 166 | def available(self) -> bool: 167 | """Return True if entity available.""" 168 | full_key = str(self.description.slave) + "." + self.description.key 169 | return self.coordinator.processed_data()["availability"][full_key] 170 | 171 | @property 172 | def device_info(self) -> entity.DeviceInfo: 173 | """Return the device info.""" 174 | return entity.DeviceInfo( 175 | identifiers={(DOMAIN, self.unique_id.split("_")[0])}, 176 | name=self.unique_id.split("_")[1], 177 | model=self.unique_id.split("_")[0], 178 | manufacturer="victron", 179 | ) 180 | -------------------------------------------------------------------------------- /custom_components/victron/coordinator.py: -------------------------------------------------------------------------------- 1 | """Define the Victron Energy Device Update Coordinator.""" 2 | 3 | from __future__ import annotations 4 | 5 | from collections import OrderedDict 6 | from datetime import timedelta 7 | import logging 8 | 9 | import pymodbus 10 | 11 | if "3.7.0" <= pymodbus.__version__ <= "3.7.4": 12 | from pymodbus.pdu.register_read_message import ReadHoldingRegistersResponse 13 | else: 14 | from pymodbus.pdu.register_message import ReadHoldingRegistersResponse 15 | 16 | from homeassistant.core import HomeAssistant 17 | from homeassistant.exceptions import HomeAssistantError 18 | from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed 19 | 20 | from .const import ( 21 | DOMAIN, 22 | INT16, 23 | INT32, 24 | INT64, 25 | STRING, 26 | UINT16, 27 | UINT32, 28 | UINT64, 29 | RegisterInfo, 30 | register_info_dict, 31 | ) 32 | from .hub import VictronHub 33 | 34 | _LOGGER = logging.getLogger(__name__) 35 | 36 | 37 | class victronEnergyDeviceUpdateCoordinator(DataUpdateCoordinator): 38 | """Gather data for the energy device.""" 39 | 40 | api: VictronHub 41 | 42 | def __init__( 43 | self, 44 | hass: HomeAssistant, 45 | host: str, 46 | port: str, 47 | decodeInfo: OrderedDict, 48 | interval: int, 49 | ) -> None: 50 | """Initialize Update Coordinator.""" 51 | 52 | super().__init__( 53 | hass, _LOGGER, name=DOMAIN, update_interval=timedelta(seconds=interval) 54 | ) 55 | self.api = VictronHub(host, port) 56 | self.api.connect() 57 | self.decodeInfo = decodeInfo 58 | self.interval = interval 59 | 60 | # async def force_update_data(self) -> None: 61 | # data = await self._async_update_data() 62 | # self.async_set_updated_data(data) 63 | 64 | async def _async_update_data(self) -> dict: 65 | """Fetch all device and sensor data from api.""" 66 | data = "" 67 | """Get the latest data from victron""" 68 | self.logger.debug("Fetching victron data") 69 | self.logger.debug(self.decodeInfo) 70 | 71 | parsed_data = OrderedDict() 72 | unavailable_entities = OrderedDict() 73 | 74 | if self.data is None: 75 | self.data = {"data": OrderedDict(), "availability": OrderedDict()} 76 | 77 | for unit, registerInfo in self.decodeInfo.items(): 78 | for name in registerInfo: 79 | data = await self.fetch_registers(unit, register_info_dict[name]) 80 | # TODO safety check if result is actual data if not unavailable 81 | if data.isError(): 82 | # raise error 83 | # TODO change this to work with partial updates 84 | for key in register_info_dict[name]: 85 | full_key = str(unit) + "." + key 86 | # self.data["data"][full_key] = None 87 | unavailable_entities[full_key] = False 88 | 89 | _LOGGER.warning( 90 | "No valid data returned for entities of slave: %s (if the device continues to no longer update) check if the device was physically removed. Before opening an issue please force a rescan to attempt to resolve this issue", 91 | unit, 92 | ) 93 | else: 94 | parsed_data = OrderedDict( 95 | list(parsed_data.items()) 96 | + list( 97 | self.parse_register_data( 98 | data, register_info_dict[name], unit 99 | ).items() 100 | ) 101 | ) 102 | for key in register_info_dict[name]: 103 | full_key = str(unit) + "." + key 104 | unavailable_entities[full_key] = True 105 | 106 | return { 107 | "register_set": self.decodeInfo, 108 | "data": parsed_data, 109 | "availability": unavailable_entities, 110 | } 111 | 112 | def parse_register_data( 113 | self, 114 | buffer: ReadHoldingRegistersResponse, 115 | registerInfo: OrderedDict[str, RegisterInfo], 116 | unit: int, 117 | ) -> dict: 118 | """Parse the register data using convert_from_registers.""" 119 | decoded_data = OrderedDict() 120 | registers = buffer.registers 121 | offset = 0 122 | 123 | for key, value in registerInfo.items(): 124 | full_key = f"{unit}.{key}" 125 | count = 0 126 | if value.dataType in (INT16, UINT16): 127 | count = 1 128 | elif value.dataType in (INT32, UINT32): 129 | count = 2 130 | elif value.dataType in (INT64, UINT64): 131 | count = 4 132 | elif isinstance(value.dataType, STRING): 133 | count = value.dataType.length 134 | segment = registers[offset : offset + count] 135 | 136 | if isinstance(value.dataType, STRING): 137 | raw = self.api.convert_string_from_register(segment) 138 | decoded_data[full_key] = raw 139 | else: 140 | raw = self.api.convert_number_from_register(segment, value.dataType) 141 | # _LOGGER.warning("trying to decode %s with value %s", key, raw) 142 | decoded_data[full_key] = self.decode_scaling( 143 | raw, value.scale, value.unit 144 | ) 145 | offset += count 146 | 147 | return decoded_data 148 | 149 | def decode_scaling(self, number, scale, unit): 150 | """Decode the scaling.""" 151 | if unit == "" and scale == 1: 152 | return round(number) 153 | return number / scale 154 | 155 | def encode_scaling(self, value, unit, scale): 156 | """Encode the scaling.""" 157 | if scale == 0: 158 | return value 159 | if unit == "" and scale == 1: 160 | return int(round(value)) 161 | return int(value * scale) 162 | 163 | def get_data(self): 164 | """Return the data.""" 165 | return self.data 166 | 167 | async def async_update_local_entry(self, key, value): 168 | """Update the local entry.""" 169 | data = self.data 170 | data["data"][key] = value 171 | self.async_set_updated_data(data) 172 | 173 | await self.async_request_refresh() 174 | 175 | def processed_data(self): 176 | """Return the processed data.""" 177 | return self.data 178 | 179 | async def fetch_registers(self, unit, registerData): 180 | """Fetch the registers.""" 181 | try: 182 | # run api_update in async job 183 | return await self.hass.async_add_executor_job( 184 | self.api_update, unit, registerData 185 | ) 186 | 187 | except HomeAssistantError as e: 188 | raise UpdateFailed("Fetching registers failed") from e 189 | 190 | def write_register(self, unit, address, value): 191 | """Write to the register.""" 192 | # try: 193 | 194 | self.api_write(unit, address, value) 195 | 196 | # except HomeAssistantError as e: 197 | # TODO raise specific write error 198 | # _LOGGER.error("failed to write to option:", e 199 | 200 | def api_write(self, unit, address, value): 201 | """Write to the api.""" 202 | # recycle connection 203 | return self.api.write_register(unit=unit, address=address, value=value) 204 | 205 | def api_update(self, unit, registerInfo): 206 | """Update the api.""" 207 | # recycle connection 208 | return self.api.read_holding_registers( 209 | unit=unit, 210 | address=self.api.get_first_register_id(registerInfo), 211 | count=self.api.calculate_register_count(registerInfo), 212 | ) 213 | 214 | 215 | class DecodeDataTypeUnsupported(Exception): 216 | """Exception for unsupported data type.""" 217 | 218 | 219 | class DataEntry: 220 | """Data entry class.""" 221 | 222 | def __init__(self, unit, value) -> None: 223 | """Initialize the data entry.""" 224 | self.unit = unit 225 | self.value = value 226 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![hacs_badge](https://img.shields.io/badge/HACS-Default-41BDF5.svg?style=for-the-badge)](https://github.com/hacs/integration) 2 | 3 | # Victron GX modbusTCP integration 4 | This integration scans for all available registers of a provided GX device. 5 | It then uses the defined register ledgers to create entities for each and every register that is provided by the GX device. 6 | 7 | # Project release state 8 | Please note that the integration is currently in an acceptance state. 9 | This means that there are no breaking changes planned. 10 | If a breaking change were to be introduced expect the release notes to reflect this. 11 | If you are missing a feature or experience any issues please report them in the issue tracker. 12 | 13 | ## Limitations 14 | The current state of this integration provides the following limitations regarding its use: 15 | - Configuring the integration will be relatively slow if a discovery scan is required. 16 | - This integration wasn't tested with a three-phase system. Although basic functionality should work minor bugs could have gone unnoticed 17 | 18 | ## Important Note 19 | This integration was written an tested with the latest victron firmware running. 20 | GX version: v3.10 (support validated from v2.92) 21 | Multiplus version: 492 22 | 23 | Victron continuously improves upon the modbus implementation by adding new registers. 24 | Therefore, older firmware versions might not expose all registers this integration expects to be present. 25 | This might (depending on your firmware and connected devices) cause odd behaviour where some but not all devices connected to your GX device will be correctly detected by this integration. 26 | 27 | The best solution for this issue is to upgrade to the latest firmware. 28 | You can also open an issue to get your firmware version supported. 29 | This issue should contain the following information: 30 | - Connected devices 31 | - Firmware versions of the connected devices 32 | - Missing device type (grid, vebus, bms can etc.) 33 | - Missing unit id (among other 30, 100, 227, 228) 34 | 35 | Please note that it might take some time for older firmware versions to get full support (after a ticket is opened). 36 | 37 | ## Currently planned improvements 38 | - Fully Switch to async 39 | - Investigate if scan without causing (ignorable) errors at the gx device is possible 40 | - Improve connection loss resilience (mark / unmark availability of entities) 41 | - Revisit datatypes used for storing register info 42 | 43 | # Installing the integration 44 | You can install this integration by following the steps for your preferred installation method 45 | 46 | ## Warning 47 | This integration uses pymodbus 3.0.2 or higher. 48 | As of november 2022 the built-in home assistant modbus integration runs on a version < 3.0.0 49 | If you install this integration the built-in modbus integration will stop to work due to breaking changes between 2.x.x and 3.0.0 50 | 51 | ## Important announcement: 52 | Starting from home assistant core version 2025.1.x the built-in modbus integration now uses pymodbus version 3.7.4. 53 | Version 0.4.0 (and up) of this integration will also use the 3.7.4 pymodbus version. 54 | 55 | Although core version >= 2023.2 and previous versions of this integration should be compatible it is recommended that all users update both core and this integration in the same patch round. 56 | Since having multiple library version requirements might cause the built-in 3.1.1 library to be overwritten by 3.0.2 reference of versions 0.0.6 and earlier. 57 | This could cause issues if you are using specific configuration options of the built-in modbus integration that weren't working with pymodbus 3.0.2 and were fixed in 3.1.1 58 | 59 | ## Manual 60 | 1. Clone the repository to your machine running home assistant: `git clone https://github.com/sfstar/hass-victron.git` 61 | 2. Create the custom_components folder in your home assistant home folder if it doesn't exist: `mkdir homeassitant/custom_components` 62 | 3. Copy the custom_components/victron folder (from step 1.) into the homeassistant/custom_components folder on your machine: `cp custom_components/victron homeassitant/custom_components` 63 | 4. Restart home assistant 64 | 5. Goto `Settings -> Devices and Services -> Integration` 65 | 6. Click on `Add Integration` 66 | 7. Search for `victron` 67 | 8. Fill in the correct options and submit 68 | 69 | ## HACS 70 | 71 | ### Default 72 | 1. Add the integration through this link: 73 | [![Open your Home Assistant instance and open a repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=sfstar&repository=hass-victron&category=integration) 74 | 3. Restart home assistant 75 | 4. Setup integration via the integration page. 76 | 77 | # GX device errors 78 | The integration scans for available units and register ranges upon installation and when selected in the options menu. 79 | This will cause "errors" in the GX device under "settings -> services -> modbus TCP" due to not every register set and unit being available (and victron not providing a discover unit / register to query) 80 | These errors can be cleared without any issue and should not be reported unless (after scanning) errors keep getting reported. 81 | 82 | # Disclaimer 83 | This integration speaks to the victron GX device. 84 | The GX device is an exposed integration point for a system capable of running on high voltages and currents. 85 | If the system were to become unstable it might lead to damage of equipment, fires and/or electrocution. 86 | Although this integration speaks to the (exposed by victron) modbusTCP server it might cause the system to become unstable in circumstances like (but not limited to): 87 | - High request frequency 88 | - (when implemented) writing to write_registers (for example changing the ess setpoint value) 89 | 90 | Therefore the following applies to anyone using this code: 91 | - This code is provided as is. 92 | - The developer does not assume any liability for issues caused by the use of this integration. 93 | - Use at your own risk. 94 | 95 | # Options 96 | The integration provides end users with the following configuration options. 97 | 98 | ## Host 99 | The IP address of the victron GX device running the modbusTCP service. 100 | It's only configurable during setup and it's recommended to make the GX device static in your router 101 | 102 | ## Port 103 | The port on which victron exposes the modbusTCP service. 104 | Victron exposes the service on port 502, but this configuration option is present to allow for proxy configuration (via nginx etc). 105 | The average user doesn't need to change the port being used 106 | 107 | ## Interval 108 | interval determines the number of rounded seconds to use between updating the entities provided by this integration. 109 | For slower systems setting the interval to lower than 5 seconds might cause issues. 110 | Setting a interval of 0 will result in an interval of 1 seconds being used. 111 | 112 | ## Advanced 113 | Ticking the write support option enables an "advanced" users mode. 114 | If write support is disabled the integration is "safer" to use. 115 | Since you can't accidentally change any setting that might cause damage (i.e. to High currents for your cabling). 116 | 117 | It is currently unknown and untested if the ModbusTCP server write registers are guard-railed. (Have protections/limits in place that prevent damage) 118 | Therefore, this integration offers users the ability to set "soft" guard rails by requiring current and voltage settings and limits to be specified by the end user. 119 | Although this make the integration safer, one should always double-check if the provided write entities aren't capable of going to high / low for your system. 120 | 121 | Currently, the options are tailored to the US / EU base settings and lifepo4 battery voltages. 122 | If you use another grid specification or battery type you can submit an issue to have them included. 123 | 124 | ### AC Current 125 | The maximum current in Amps that your system is designed to handle. 126 | This doesn't make a difference between the AC OUT and the AC IN side of your setup. 127 | Please keep a small margin below your rated grid connection current (for example if you have 1x40A then set the integration to max 39 AMPS). 128 | 129 | This advice does assume that your system is fully setup to handle these currents. 130 | 131 | ### DC current 132 | The maximum current in Amps that your battery and battery cabling/busbars are rated to handle. 133 | 134 | ### DC Voltage 135 | The voltage profile to use in order to calculate the voltage boundaries (i.e. 4s, 8s and 16s lifepo4 configurations). 136 | 137 | ### AC Voltage 138 | The AC voltage for a single phase in your region (currently supported is US: 120v and EU: 230v) 139 | This setting is used in combination with AC current to automatically calculate the max wattage for applicable wattage settings. 140 | 141 | # Resources 142 | The following links can be helpful resources: 143 | - [setting up modbusTCP on the gx device](https://www.victronenergy.com/live/ccgx:modbustcp_faq) 144 | - [Great UI card for the gx device data](https://github.com/flyrmyr/system-flow-card) 145 | ![image](https://user-images.githubusercontent.com/6717280/236457703-5c9219bd-ad88-487e-80b9-28d51859175e.png) 146 | 147 | -------------------------------------------------------------------------------- /custom_components/victron/sensor.py: -------------------------------------------------------------------------------- 1 | """Support for Victron energy sensors.""" 2 | 3 | from dataclasses import dataclass 4 | import logging 5 | 6 | from homeassistant.components.sensor import ( 7 | DOMAIN as SENSOR_DOMAIN, 8 | SensorDeviceClass, 9 | SensorEntity, 10 | SensorEntityDescription, 11 | ) 12 | from homeassistant.config_entries import ConfigEntry 13 | from homeassistant.const import ( 14 | PERCENTAGE, 15 | UnitOfElectricCurrent, 16 | UnitOfElectricPotential, 17 | UnitOfEnergy, 18 | UnitOfFrequency, 19 | UnitOfPower, 20 | UnitOfPressure, 21 | UnitOfSpeed, 22 | UnitOfTemperature, 23 | UnitOfTime, 24 | UnitOfVolume, 25 | ) 26 | from homeassistant.core import HassJob, HomeAssistant, callback 27 | from homeassistant.helpers import entity 28 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 29 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 30 | 31 | from .base import VictronBaseEntityDescription 32 | from .const import ( 33 | CONF_ADVANCED_OPTIONS, 34 | DOMAIN, 35 | BoolReadEntityType, 36 | ReadEntityType, 37 | TextReadEntityType, 38 | register_info_dict, 39 | ) 40 | from .coordinator import victronEnergyDeviceUpdateCoordinator 41 | 42 | _LOGGER = logging.getLogger(__name__) 43 | 44 | 45 | async def async_setup_entry( 46 | hass: HomeAssistant, 47 | config_entry: ConfigEntry, 48 | async_add_entities: AddEntitiesCallback, 49 | ) -> None: 50 | """Set up Victron energy sensor entries.""" 51 | _LOGGER.debug("attempting to setup sensor entities") 52 | victron_coordinator: victronEnergyDeviceUpdateCoordinator = hass.data[DOMAIN][ 53 | config_entry.entry_id 54 | ] 55 | _LOGGER.debug(victron_coordinator.processed_data()["register_set"]) 56 | _LOGGER.debug(victron_coordinator.processed_data()["data"]) 57 | descriptions = [] 58 | # TODO cleanup 59 | register_set = victron_coordinator.processed_data()["register_set"] 60 | for slave, registerLedger in register_set.items(): 61 | for name in registerLedger: 62 | for register_name, registerInfo in register_info_dict[name].items(): 63 | _LOGGER.debug( 64 | "unit == %s registerLedger == %s registerInfo", 65 | slave, 66 | registerLedger, 67 | ) 68 | if config_entry.options[CONF_ADVANCED_OPTIONS]: 69 | if not isinstance( 70 | registerInfo.entityType, ReadEntityType 71 | ) or isinstance(registerInfo.entityType, BoolReadEntityType): 72 | continue 73 | 74 | description = VictronEntityDescription( 75 | key=register_name, 76 | name=register_name.replace("_", " "), 77 | native_unit_of_measurement=registerInfo.unit, 78 | state_class=registerInfo.determine_stateclass(), 79 | slave=slave, 80 | device_class=determine_victron_device_class( 81 | register_name, registerInfo.unit 82 | ), 83 | entity_type=registerInfo.entityType 84 | if isinstance(registerInfo.entityType, TextReadEntityType) 85 | else None, 86 | ) 87 | _LOGGER.debug("composed description == %s", description) 88 | 89 | descriptions.append(description) 90 | 91 | entities = [] 92 | entity = {} 93 | for description in descriptions: 94 | entity = description 95 | entities.append(VictronSensor(victron_coordinator, entity)) 96 | 97 | # Add an entity for each sensor type 98 | async_add_entities(entities, True) 99 | 100 | 101 | def determine_victron_device_class(name, unit): 102 | """Determine the device class of a sensor based on its name and unit.""" 103 | if unit == PERCENTAGE and "soc" in name: 104 | return SensorDeviceClass.BATTERY 105 | if unit == PERCENTAGE: 106 | return None # Device classes aren't supported for voltage deviation and other % based entities that do not report SOC, moisture or humidity 107 | if unit in [member.value for member in UnitOfPower]: 108 | return SensorDeviceClass.POWER 109 | if unit in [member.value for member in UnitOfEnergy]: 110 | _LOGGER.debug("unit of energy") 111 | return SensorDeviceClass.ENERGY 112 | if unit == UnitOfFrequency.HERTZ: 113 | return SensorDeviceClass.FREQUENCY 114 | if unit == UnitOfTime.SECONDS: 115 | return SensorDeviceClass.DURATION 116 | if unit in [member.value for member in UnitOfTemperature]: 117 | return SensorDeviceClass.TEMPERATURE 118 | if unit in [member.value for member in UnitOfVolume]: 119 | return ( 120 | SensorDeviceClass.VOLUME_STORAGE 121 | ) # Perhaps change this to water if only water is measured in volume units 122 | if unit in [member.value for member in UnitOfSpeed]: 123 | if "meteo" in name: 124 | return SensorDeviceClass.WIND_SPEED 125 | return SensorDeviceClass.SPEED 126 | if unit in [member.value for member in UnitOfPressure]: 127 | return SensorDeviceClass.PRESSURE 128 | if unit == UnitOfElectricPotential.VOLT: 129 | return SensorDeviceClass.VOLTAGE 130 | if unit == UnitOfElectricCurrent.AMPERE: 131 | return SensorDeviceClass.CURRENT 132 | return None 133 | 134 | 135 | @dataclass 136 | class VictronEntityDescription(SensorEntityDescription, VictronBaseEntityDescription): 137 | """Describes victron sensor entity.""" 138 | 139 | entity_type: ReadEntityType = None 140 | 141 | 142 | class VictronSensor(CoordinatorEntity, SensorEntity): 143 | """Representation of a Victron energy sensor.""" 144 | 145 | def __init__( 146 | self, 147 | coordinator: victronEnergyDeviceUpdateCoordinator, 148 | description: VictronEntityDescription, 149 | ) -> None: 150 | """Initialize the sensor.""" 151 | self.description: VictronEntityDescription = description 152 | self._attr_device_class = description.device_class 153 | self._attr_name = f"{description.name}" 154 | self._attr_native_unit_of_measurement = description.native_unit_of_measurement 155 | self._attr_state_class = description.state_class 156 | self.entity_type = description.entity_type 157 | 158 | self._attr_unique_id = f"{description.slave}_{self.description.key}" 159 | if description.slave not in (0, 100, 225): 160 | self.entity_id = ( 161 | f"{SENSOR_DOMAIN}.{DOMAIN}_{self.description.key}_{description.slave}" 162 | ) 163 | else: 164 | self.entity_id = f"{SENSOR_DOMAIN}.{DOMAIN}_{self.description.key}" 165 | 166 | self._update_job = HassJob(self.async_schedule_update_ha_state) 167 | self._unsub_update = None 168 | 169 | super().__init__(coordinator) 170 | 171 | @callback 172 | def _handle_coordinator_update(self) -> None: 173 | """Get the latest data and updates the states.""" 174 | try: 175 | if self.available: 176 | data = self.description.value_fn( 177 | self.coordinator.processed_data(), 178 | self.description.slave, 179 | self.description.key, 180 | ) 181 | if self.entity_type is not None and isinstance( 182 | self.entity_type, TextReadEntityType 183 | ): 184 | if data in {item.value for item in self.entity_type.decodeEnum}: 185 | self._attr_native_value = self.entity_type.decodeEnum( 186 | data 187 | ).name.split("_DUPLICATE")[0] 188 | else: 189 | self._attr_native_value = "NONDECODABLE" 190 | _LOGGER.error( 191 | "The reported value %s for entity %s isn't a decobale value. Please report this error to the integrations maintainer", 192 | data, 193 | self._attr_name, 194 | ) 195 | else: 196 | self._attr_native_value = data 197 | 198 | self.async_write_ha_state() 199 | except (TypeError, IndexError): 200 | _LOGGER.debug("failed to retrieve value") 201 | # No data available 202 | self._attr_native_value = None 203 | 204 | @property 205 | def available(self) -> bool: 206 | """Return True if entity is available.""" 207 | full_key = str(self.description.slave) + "." + self.description.key 208 | return self.coordinator.processed_data()["availability"][full_key] 209 | 210 | @property 211 | def device_info(self) -> entity.DeviceInfo: 212 | """Return the device info.""" 213 | return entity.DeviceInfo( 214 | identifiers={(DOMAIN, self.unique_id.split("_")[0])}, 215 | name=self.unique_id.split("_")[1], 216 | model=self.unique_id.split("_")[0], 217 | manufacturer="victron", # to be dynamically set for gavazzi and redflow 218 | ) 219 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /custom_components/victron/number.py: -------------------------------------------------------------------------------- 1 | """Support for victron energy slider number entities.""" 2 | 3 | from __future__ import annotations 4 | 5 | from dataclasses import dataclass 6 | import logging 7 | 8 | from homeassistant import config_entries 9 | from homeassistant.components.number import ( 10 | DOMAIN as NUMBER_DOMAIN, 11 | NumberEntity, 12 | NumberEntityDescription, 13 | NumberMode, 14 | ) 15 | from homeassistant.const import ( 16 | PERCENTAGE, 17 | UnitOfElectricCurrent, 18 | UnitOfElectricPotential, 19 | UnitOfPower, 20 | ) 21 | from homeassistant.core import HomeAssistant 22 | from homeassistant.helpers import entity 23 | from homeassistant.helpers.entity import EntityCategory 24 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 25 | 26 | from .base import VictronWriteBaseEntityDescription 27 | from .const import ( 28 | CONF_AC_CURRENT_LIMIT, 29 | CONF_AC_SYSTEM_VOLTAGE, 30 | CONF_ADVANCED_OPTIONS, 31 | CONF_DC_CURRENT_LIMIT, 32 | CONF_DC_SYSTEM_VOLTAGE, 33 | CONF_NUMBER_OF_PHASES, 34 | CONF_USE_SLIDERS, 35 | DOMAIN, 36 | UINT16_MAX, 37 | SliderWriteType, 38 | register_info_dict, 39 | ) 40 | from .coordinator import victronEnergyDeviceUpdateCoordinator 41 | 42 | _LOGGER = logging.getLogger(__name__) 43 | 44 | 45 | async def async_setup_entry( 46 | hass: HomeAssistant, 47 | config_entry: config_entries.ConfigEntry, 48 | async_add_entities: AddEntitiesCallback, 49 | ) -> None: 50 | """Set up victron switch devices.""" 51 | victron_coordinator: victronEnergyDeviceUpdateCoordinator = hass.data[DOMAIN][ 52 | config_entry.entry_id 53 | ] 54 | _LOGGER.debug("attempting to setup number entities") 55 | descriptions = [] 56 | _LOGGER.debug(config_entry) 57 | # TODO cleanup 58 | if config_entry.options[CONF_ADVANCED_OPTIONS]: 59 | register_set = victron_coordinator.processed_data()["register_set"] 60 | for slave, registerLedger in register_set.items(): 61 | for name in registerLedger: 62 | for register_name, registerInfo in register_info_dict[name].items(): 63 | _LOGGER.debug( 64 | "unit == %s registerLedger == %s registerInfo", 65 | slave, 66 | registerLedger, 67 | ) 68 | 69 | if isinstance(registerInfo.entityType, SliderWriteType): 70 | description = VictronEntityDescription( 71 | key=register_name, 72 | name=register_name.replace("_", " "), 73 | slave=slave, 74 | native_unit_of_measurement=registerInfo.unit, 75 | mode=NumberMode.SLIDER 76 | if config_entry.options[CONF_USE_SLIDERS] 77 | else NumberMode.BOX, 78 | native_min_value=determine_min_value( 79 | registerInfo.unit, 80 | config_entry.options, 81 | registerInfo.entityType.powerType, 82 | registerInfo.entityType.negative, 83 | ), 84 | native_max_value=determine_max_value( 85 | registerInfo.unit, 86 | config_entry.options, 87 | registerInfo.entityType.powerType, 88 | ), 89 | entity_category=EntityCategory.CONFIG, 90 | address=registerInfo.register, 91 | scale=registerInfo.scale, 92 | native_step=registerInfo.step, 93 | ) 94 | _LOGGER.debug("composed description == %s", descriptions) 95 | descriptions.append(description) 96 | 97 | entities = [] 98 | entity = {} 99 | for description in descriptions: 100 | entity = description 101 | entities.append(VictronNumber(victron_coordinator, entity)) 102 | _LOGGER.debug("adding number") 103 | async_add_entities(entities) 104 | _LOGGER.debug("adding numbering") 105 | 106 | 107 | def determine_min_value( 108 | unit, config_entry: config_entries.ConfigEntry, powerType, negative: bool 109 | ) -> int: 110 | """Determine the minimum value for a number entity.""" 111 | if unit == PERCENTAGE: 112 | return 0 113 | if unit == UnitOfElectricPotential.VOLT: 114 | series_type = ( 115 | int(config_entry[CONF_DC_SYSTEM_VOLTAGE]) / 3 116 | ) # statically based on lifepo4 cells 117 | return series_type * 2.5 # statically based on lifepo4 cells 118 | if unit == UnitOfPower.WATT: 119 | if negative: 120 | min_value = ( 121 | ( 122 | int(config_entry[CONF_AC_SYSTEM_VOLTAGE]) 123 | * int(config_entry[CONF_NUMBER_OF_PHASES]) 124 | * config_entry[CONF_AC_CURRENT_LIMIT] 125 | ) 126 | if powerType == "AC" 127 | else ( 128 | int(config_entry[CONF_DC_SYSTEM_VOLTAGE]) 129 | * config_entry[CONF_DC_CURRENT_LIMIT] 130 | ) 131 | ) 132 | rounded_min = -round(min_value / 100) * 100 133 | _LOGGER.debug(rounded_min) 134 | return rounded_min 135 | return 0 136 | if unit == UnitOfElectricCurrent.AMPERE: 137 | if negative: 138 | if powerType == "AC": 139 | return -config_entry[CONF_AC_CURRENT_LIMIT] 140 | if powerType == "DC": 141 | return -config_entry[CONF_DC_CURRENT_LIMIT] 142 | return None 143 | return 0 144 | return 0 145 | 146 | 147 | def determine_max_value( 148 | unit, config_entry: config_entries.ConfigEntry, powerType 149 | ) -> int: 150 | """Determine the maximum value for a number entity.""" 151 | if unit == PERCENTAGE: 152 | return 100 153 | if unit == UnitOfElectricPotential.VOLT: 154 | series_type = ( 155 | int(config_entry[CONF_DC_SYSTEM_VOLTAGE]) / 3 156 | ) # statically based on lifepo4 cells 157 | return series_type * 3.65 # statically based on lifepo4 cells 158 | if unit == UnitOfPower.WATT: 159 | max_value = ( 160 | ( 161 | int(config_entry[CONF_AC_SYSTEM_VOLTAGE]) 162 | * int(config_entry[CONF_NUMBER_OF_PHASES]) 163 | * config_entry[CONF_AC_CURRENT_LIMIT] 164 | ) 165 | if powerType == "AC" 166 | else ( 167 | int(config_entry[CONF_DC_SYSTEM_VOLTAGE]) 168 | * config_entry[CONF_DC_CURRENT_LIMIT] 169 | ) 170 | ) 171 | return round(max_value / 100) * 100 172 | if unit == UnitOfElectricCurrent.AMPERE: 173 | if powerType == "AC": 174 | return config_entry[CONF_AC_CURRENT_LIMIT] 175 | if powerType == "DC": 176 | return config_entry[CONF_DC_CURRENT_LIMIT] 177 | return None 178 | if powerType == "uint16": 179 | return UINT16_MAX 180 | return 0 181 | 182 | 183 | @dataclass 184 | class VictronNumberMixin: 185 | """A class that describes number entities.""" 186 | 187 | scale: int | None = None 188 | mode: bool | None = None 189 | 190 | 191 | @dataclass 192 | class VictronNumberStep: 193 | """A class that adds stepping to number entities.""" 194 | 195 | native_step: float = 0 196 | 197 | 198 | @dataclass 199 | class VictronEntityDescription( 200 | NumberEntityDescription, 201 | VictronWriteBaseEntityDescription, 202 | VictronNumberMixin, 203 | VictronNumberStep, 204 | ): 205 | """Describes victron number entity.""" 206 | 207 | # Overwrite base entitydescription property to resolve automatic property ordering issues when a mix of non-default and default properties are used 208 | key: str | None = None 209 | 210 | 211 | class VictronNumber(NumberEntity): 212 | """Victron number.""" 213 | 214 | description: VictronEntityDescription 215 | 216 | def __init__( 217 | self, 218 | coordinator: victronEnergyDeviceUpdateCoordinator, 219 | description: VictronEntityDescription, 220 | ) -> None: 221 | """Initialize the entity.""" 222 | self.coordinator = coordinator 223 | self.description = description 224 | self._attr_name = f"{description.name}" 225 | 226 | self.data_key = str(self.description.slave) + "." + str(self.description.key) 227 | 228 | self._attr_native_value = self.description.value_fn( 229 | self.coordinator.processed_data(), 230 | self.description.slave, 231 | self.description.key, 232 | ) 233 | 234 | self._attr_unique_id = f"{self.description.slave}_{self.description.key}" 235 | if self.description.slave not in (100, 225): 236 | self.entity_id = f"{NUMBER_DOMAIN}.{DOMAIN}_{self.description.key}_{self.description.slave}" 237 | else: 238 | self.entity_id = f"{NUMBER_DOMAIN}.{DOMAIN}_{self.description.key}" 239 | 240 | self._attr_mode = self.description.mode 241 | 242 | async def async_set_native_value(self, value: float) -> None: 243 | """Update the current value.""" 244 | # TODO convert float to int again with scale respected 245 | if value < 0: 246 | value = UINT16_MAX + value 247 | self.coordinator.write_register( 248 | unit=self.description.slave, 249 | address=self.description.address, 250 | value=self.coordinator.encode_scaling( 251 | value, 252 | self.description.native_unit_of_measurement, 253 | self.description.scale, 254 | ), 255 | ) 256 | await self.coordinator.async_update_local_entry(self.data_key, int(value)) 257 | 258 | @property 259 | def native_value(self) -> float: 260 | """Return the state of the entity.""" 261 | value = self.description.value_fn( 262 | data=self.coordinator.processed_data(), 263 | slave=self.description.slave, 264 | key=self.description.key, 265 | ) 266 | if value > round( 267 | UINT16_MAX / 2 268 | ): # Half of the UINT16 is reserved for positive and half for negative values 269 | value = value - UINT16_MAX 270 | return value 271 | 272 | @property 273 | def native_step(self) -> float | None: 274 | """Return the step width of the entity.""" 275 | if ( 276 | self.description.mode != NumberMode.SLIDER 277 | ): # allow users to skip stepping in case of box mode 278 | return None 279 | if self.description.native_step > 0: 280 | return self.description.native_step 281 | _max = self.native_max_value 282 | _min = self.native_min_value 283 | gap = len(list(range(int(_min), int(_max), 1))) 284 | # TODO optimize gap step selection 285 | if gap >= 3000: 286 | return 100 287 | if 3000 > gap > 100: 288 | return 10 289 | return 1 290 | 291 | @property 292 | def native_min_value(self) -> float: 293 | """Return the minimum value of the entity.""" 294 | return self.description.native_min_value 295 | 296 | @property 297 | def native_max_value(self) -> float: 298 | """Return the maximum value of the entity.""" 299 | return self.description.native_max_value 300 | 301 | @property 302 | def available(self) -> bool: 303 | """Return True if entity is available.""" 304 | full_key = str(self.description.slave) + "." + self.description.key 305 | return self.coordinator.processed_data()["availability"][full_key] 306 | 307 | @property 308 | def device_info(self) -> entity.DeviceInfo: 309 | """Return the device info.""" 310 | return entity.DeviceInfo( 311 | identifiers={(DOMAIN, self.unique_id.split("_")[0])}, 312 | name=self.unique_id.split("_")[1], 313 | model=self.unique_id.split("_")[0], 314 | manufacturer="victron", 315 | ) 316 | -------------------------------------------------------------------------------- /custom_components/victron/config_flow.py: -------------------------------------------------------------------------------- 1 | """Config flow for victron integration.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from typing import Any 7 | 8 | import voluptuous as vol 9 | 10 | from homeassistant import config_entries 11 | from homeassistant.config_entries import ConfigEntry 12 | from homeassistant.core import HomeAssistant, callback 13 | from homeassistant.data_entry_flow import FlowResult 14 | from homeassistant.exceptions import HomeAssistantError 15 | from homeassistant.helpers.selector import ( 16 | SelectOptionDict, 17 | SelectSelector, 18 | SelectSelectorConfig, 19 | ) 20 | 21 | from .const import ( 22 | AC_VOLTAGES, 23 | CONF_AC_CURRENT_LIMIT, 24 | CONF_AC_SYSTEM_VOLTAGE, 25 | CONF_ADVANCED_OPTIONS, 26 | CONF_DC_CURRENT_LIMIT, 27 | CONF_DC_SYSTEM_VOLTAGE, 28 | CONF_HOST, 29 | CONF_INTERVAL, 30 | CONF_NUMBER_OF_PHASES, 31 | CONF_PORT, 32 | CONF_USE_SLIDERS, 33 | DC_VOLTAGES, 34 | DOMAIN, 35 | PHASE_CONFIGURATIONS, 36 | SCAN_REGISTERS, 37 | RegisterInfo, 38 | ) 39 | from .hub import VictronHub 40 | 41 | _LOGGER = logging.getLogger(__name__) 42 | 43 | CONF_RESCAN = "rescan" 44 | 45 | STEP_USER_DATA_SCHEMA = vol.Schema( 46 | { 47 | vol.Required(CONF_HOST): str, 48 | vol.Required(CONF_PORT, default=502): int, 49 | vol.Required(CONF_INTERVAL, default=30): int, 50 | vol.Optional(CONF_ADVANCED_OPTIONS, default=False): bool, 51 | } 52 | ) 53 | 54 | 55 | async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: 56 | """Validate the user input allows us to connect. 57 | 58 | Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. 59 | """ 60 | # TODO validate the data can be used to set up a connection. 61 | 62 | # If your PyPI package is not built with async, pass your methods 63 | # to the executor: 64 | # await hass.async_add_executor_job( 65 | # your_validate_func, data["username"], data["password"] 66 | # ) 67 | 68 | _LOGGER.debug("host = %s", data[CONF_HOST]) 69 | _LOGGER.debug("port = %s", data[CONF_PORT]) 70 | hub = VictronHub(data[CONF_HOST], data[CONF_PORT]) 71 | 72 | try: 73 | hub.connect() 74 | _LOGGER.debug("connection was succesfull") 75 | discovered_devices = await scan_connected_devices(hub=hub) 76 | _LOGGER.debug("successfully discovered devices") 77 | except HomeAssistantError: 78 | _LOGGER.error("Failed to connect to the victron device:") 79 | return {"title": DOMAIN, "data": discovered_devices} 80 | 81 | 82 | async def scan_connected_devices(hub: VictronHub) -> list: 83 | """Scan for connected devices.""" 84 | return hub.determine_present_devices() 85 | 86 | 87 | class VictronFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): 88 | """Handle a config flow for victron.""" 89 | 90 | _LOGGER = logging.getLogger(__name__) 91 | VERSION = 1 92 | 93 | def __init__(self): 94 | """Initialize the config flow.""" 95 | self.advanced_options = None 96 | self.interval = None 97 | self.port = None 98 | self.host = None 99 | 100 | @staticmethod 101 | @callback 102 | def async_get_options_flow( 103 | config_entry: ConfigEntry, 104 | ) -> VictronOptionFlowHandler: 105 | """Get the options flow for this handler.""" 106 | return VictronOptionFlowHandler(config_entry) 107 | 108 | async def async_step_user( 109 | self, user_input: dict[str, Any] | None = None 110 | ) -> FlowResult: 111 | """Handle the initial step.""" 112 | if user_input is None: 113 | return self.async_show_form( 114 | step_id="user", data_schema=STEP_USER_DATA_SCHEMA 115 | ) 116 | if user_input[CONF_ADVANCED_OPTIONS]: 117 | _LOGGER.debug("configuring advanced options") 118 | self.host = user_input[CONF_HOST] 119 | self.port = user_input[CONF_PORT] 120 | self.interval = user_input[CONF_INTERVAL] 121 | self.advanced_options = user_input[CONF_ADVANCED_OPTIONS] 122 | return await self.async_step_advanced() 123 | 124 | errors = {} 125 | already_configured = False 126 | 127 | user_input[CONF_INTERVAL] = max(user_input[CONF_INTERVAL], 1) 128 | 129 | try: 130 | # not yet working 131 | await self.async_set_unique_id("victron") 132 | self._abort_if_unique_id_configured() 133 | except HomeAssistantError as e: 134 | errors["base"] = f"already_configured: {e!s}" 135 | already_configured = True 136 | 137 | if not already_configured: 138 | try: 139 | info = await validate_input(self.hass, user_input) 140 | except CannotConnect: 141 | errors["base"] = "cannot_connect" 142 | except InvalidAuth: 143 | errors["base"] = "invalid_auth" 144 | except HomeAssistantError: 145 | _LOGGER.exception("Unexpected exception:") 146 | errors["base"] = "unknown" 147 | 148 | # data property can't be changed in options flow if user wants to refresh 149 | options = user_input 150 | return self.async_create_entry( 151 | title=info["title"], 152 | data={SCAN_REGISTERS: info["data"]}, 153 | options=options, 154 | ) 155 | 156 | return self.async_show_form( 157 | step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors 158 | ) 159 | 160 | async def async_step_advanced(self, user_input=None): 161 | """Handle write support and limit settings if requested.""" 162 | errors = {} 163 | 164 | if user_input is not None: 165 | if self.host is not None: 166 | options = user_input 167 | options[CONF_HOST] = self.host 168 | options[CONF_PORT] = self.port 169 | options[CONF_INTERVAL] = self.interval 170 | options[CONF_ADVANCED_OPTIONS] = bool(self.advanced_options) 171 | options[CONF_NUMBER_OF_PHASES] = int(user_input[CONF_NUMBER_OF_PHASES]) 172 | options[CONF_USE_SLIDERS] = bool(user_input[CONF_USE_SLIDERS]) 173 | options[CONF_AC_SYSTEM_VOLTAGE] = int( 174 | user_input[CONF_AC_SYSTEM_VOLTAGE] 175 | ) 176 | options[CONF_DC_SYSTEM_VOLTAGE] = int( 177 | user_input[CONF_DC_SYSTEM_VOLTAGE] 178 | ) 179 | 180 | try: 181 | info = await validate_input(self.hass, user_input) 182 | except CannotConnect: 183 | errors["base"] = "cannot_connect" 184 | except InvalidAuth: 185 | errors["base"] = "invalid_auth" 186 | except HomeAssistantError: 187 | _LOGGER.exception("Unexpected exception") 188 | errors["base"] = "unknown" 189 | _LOGGER.debug("setting up extra entry") 190 | return self.async_create_entry( 191 | title=info["title"], 192 | data={SCAN_REGISTERS: info["data"]}, 193 | options=options, 194 | ) 195 | 196 | return self.async_show_form( 197 | step_id="advanced", 198 | errors=errors, 199 | data_schema=vol.Schema( 200 | { 201 | vol.Required( 202 | CONF_AC_SYSTEM_VOLTAGE, default=str(AC_VOLTAGES["US (120)"]) 203 | ): SelectSelector( 204 | SelectSelectorConfig( 205 | options=[ 206 | SelectOptionDict(value=str(value), label=key) 207 | for key, value in AC_VOLTAGES.items() 208 | ] 209 | ), 210 | ), 211 | vol.Required( 212 | CONF_NUMBER_OF_PHASES, 213 | default=str(PHASE_CONFIGURATIONS["single phase"]), 214 | ): SelectSelector( 215 | SelectSelectorConfig( 216 | options=[ 217 | SelectOptionDict(value=str(value), label=key) 218 | for key, value in PHASE_CONFIGURATIONS.items() 219 | ] 220 | ), 221 | ), 222 | vol.Required(CONF_AC_CURRENT_LIMIT, default=0): vol.All( 223 | vol.Coerce(int, "must be a number") 224 | ), 225 | vol.Required( 226 | CONF_DC_SYSTEM_VOLTAGE, default=str(DC_VOLTAGES["lifepo4_12v"]) 227 | ): SelectSelector( 228 | SelectSelectorConfig( 229 | options=[ 230 | SelectOptionDict(value=str(value), label=key) 231 | for key, value in DC_VOLTAGES.items() 232 | ] 233 | ), 234 | ), 235 | vol.Required(CONF_DC_CURRENT_LIMIT, default=0): vol.All( 236 | vol.Coerce(int, "must be a number") 237 | ), 238 | vol.Optional(CONF_USE_SLIDERS, default=True): bool, 239 | } 240 | ), 241 | ) 242 | 243 | async def async_step_reconfigure(self, user_input: dict[str, Any] | None = None): 244 | """Add reconfigure step to allow to reconfigure a config entry.""" 245 | config_entry = self.hass.config_entries.async_get_entry( 246 | self.context["entry_id"] 247 | ) 248 | errors = {} 249 | 250 | if user_input is not None: 251 | try: 252 | hub = VictronHub(user_input[CONF_HOST], user_input[CONF_PORT]) 253 | await hub.connect() 254 | _LOGGER.info("connection was succesfull") 255 | except HomeAssistantError as e: 256 | errors["base"] = f"cannot_connect ({e!s})" 257 | 258 | else: 259 | new_options = config_entry.options | { 260 | CONF_HOST: user_input[CONF_HOST], 261 | CONF_PORT: user_input[CONF_PORT], 262 | } 263 | return self.async_update_reload_and_abort( 264 | config_entry, 265 | title=DOMAIN, 266 | options=new_options, 267 | reason="reconfigure_successful", 268 | ) 269 | 270 | return self.async_show_form( 271 | step_id="reconfigure", 272 | data_schema=vol.Schema( 273 | { 274 | vol.Required( 275 | CONF_HOST, default=config_entry.options[CONF_HOST] 276 | ): str, 277 | vol.Required( 278 | CONF_PORT, default=config_entry.options[CONF_PORT] 279 | ): int, 280 | } 281 | ), 282 | errors=errors, 283 | ) 284 | 285 | 286 | class parsedEntry: 287 | """Parsed entry.""" 288 | 289 | def __init__(self, decoderInfo: RegisterInfo, value): 290 | """Initialize the parsed entry.""" 291 | self.decoderInfo = decoderInfo 292 | self.value = value 293 | 294 | 295 | class VictronOptionFlowHandler(config_entries.OptionsFlow): 296 | """Handle options.""" 297 | 298 | logger = logging.getLogger(__name__) 299 | 300 | def __init__(self, config_entry: ConfigEntry) -> None: 301 | """Initialize options flow.""" 302 | self.area = None 303 | 304 | async def async_step_advanced(self, user_input=None): 305 | """Handle write support and limit settings if requested.""" 306 | config = dict(self.config_entry.options) 307 | user_input.pop(CONF_RESCAN, None) 308 | # combine dictionaries with priority given to user_input 309 | dict_priority = {1: user_input, 2: config} 310 | combined_config = {**dict_priority[2], **dict_priority[1]} 311 | return self.async_create_entry(title="", data=combined_config) 312 | 313 | async def async_step_init_read(self, user_input=None): 314 | """Handle write support and limit settings if requested.""" 315 | config = dict(self.config_entry.options) 316 | # combine dictionaries with priority given to user_input 317 | if user_input[CONF_RESCAN]: 318 | info = await validate_input(self.hass, config) 319 | self.hass.config_entries.async_update_entry( 320 | self.config_entry, data={SCAN_REGISTERS: info["data"]}, title="" 321 | ) 322 | 323 | user_input.pop(CONF_RESCAN, None) 324 | dict_priority = {1: user_input, 2: config} 325 | combined_config = {**dict_priority[2], **dict_priority[1]} 326 | 327 | if user_input[CONF_ADVANCED_OPTIONS]: 328 | self.hass.config_entries.async_update_entry( 329 | self.config_entry, options=combined_config, title="" 330 | ) 331 | _LOGGER.debug("returning step init because advanced options were selected") 332 | errors = {} 333 | # move to dedicated function (the write show form) to allow for re-use 334 | return self.init_write_form(errors) 335 | return self.async_create_entry(title="", data=combined_config) 336 | 337 | async def async_step_init_write(self, user_input=None): 338 | """Handle write support and limit settings if requested.""" 339 | config = dict(self.config_entry.options) 340 | # remove temp options = 341 | if user_input[CONF_RESCAN]: 342 | info = await validate_input(self.hass, config) 343 | self.hass.config_entries.async_update_entry( 344 | self.config_entry, data={SCAN_REGISTERS: info["data"]}, title="" 345 | ) 346 | 347 | user_input.pop(CONF_RESCAN, None) 348 | # combine dictionaries with priority given to user_input 349 | dict_priority = {1: user_input, 2: config} 350 | combined_config = {**dict_priority[2], **dict_priority[1]} 351 | 352 | if not user_input[CONF_ADVANCED_OPTIONS]: 353 | self.hass.config_entries.async_update_entry( 354 | self.config_entry, options=combined_config, title="" 355 | ) 356 | _LOGGER.debug("returning step init because advanced options were selected") 357 | errors = {} 358 | # move to dedicated function (the write show form) to allow for re-use 359 | return self.init_read_form(errors) 360 | 361 | return self.async_create_entry(title="", data=combined_config) 362 | 363 | async def async_step_init( 364 | self, user_input: dict[str, Any] | None = None 365 | ) -> FlowResult: 366 | """Handle a flow initiated by the user.""" 367 | errors = {} 368 | 369 | config = dict(self.config_entry.options) 370 | 371 | if user_input is not None: 372 | if user_input[CONF_INTERVAL] not in (None, ""): 373 | config[CONF_INTERVAL] = user_input[CONF_INTERVAL] 374 | 375 | try: 376 | if user_input[CONF_RESCAN]: 377 | info = await validate_input(self.hass, self.config_entry.options) 378 | # config[SCAN_REGISTERS] = info["data"] 379 | _LOGGER.debug(info) 380 | except CannotConnect: 381 | errors["base"] = "cannot_connect" 382 | except InvalidAuth: 383 | errors["base"] = "invalid_auth" 384 | except HomeAssistantError: 385 | _LOGGER.exception("Unexpected exception") 386 | errors["base"] = "unknown" 387 | 388 | if user_input[CONF_RESCAN]: 389 | self.hass.config_entries.async_update_entry( 390 | self.config_entry, data={SCAN_REGISTERS: info["data"]}, title="" 391 | ) 392 | # return self.async_create_entry(title="", data={}) 393 | 394 | return self.async_create_entry(title="", data=config) 395 | 396 | if config[CONF_ADVANCED_OPTIONS]: 397 | _LOGGER.debug("advanced options is set") 398 | 399 | return self.init_write_form(errors) 400 | if user_input is None: 401 | return self.init_read_form(errors) 402 | return None 403 | 404 | def init_read_form(self, errors: dict): 405 | """Handle read support and limit settings if requested.""" 406 | return self.async_show_form( 407 | step_id="init_read", 408 | errors=errors, 409 | data_schema=vol.Schema( 410 | { 411 | vol.Required( 412 | CONF_INTERVAL, default=self.config_entry.options[CONF_INTERVAL] 413 | ): vol.All(vol.Coerce(int)), 414 | vol.Optional(CONF_RESCAN, default=False): bool, 415 | vol.Optional(CONF_ADVANCED_OPTIONS, default=False): bool, 416 | }, 417 | ), 418 | ) 419 | 420 | def init_write_form(self, errors: dict): 421 | """Handle write support and limit settings if requested.""" 422 | config = dict(self.config_entry.options) 423 | system_ac_voltage_default = self.config_entry.options.get( 424 | CONF_AC_SYSTEM_VOLTAGE, AC_VOLTAGES["US (120)"] 425 | ) 426 | system_dc_voltage_default = self.config_entry.options.get( 427 | CONF_DC_SYSTEM_VOLTAGE, DC_VOLTAGES["lifepo4_12v"] 428 | ) 429 | system_number_of_phases_default = self.config_entry.options.get( 430 | CONF_NUMBER_OF_PHASES, PHASE_CONFIGURATIONS["single phase"] 431 | ) 432 | errors = {} 433 | return self.async_show_form( 434 | step_id="init_write", 435 | errors=errors, 436 | data_schema=vol.Schema( 437 | { 438 | vol.Required( 439 | CONF_INTERVAL, default=self.config_entry.options[CONF_INTERVAL] 440 | ): vol.All(vol.Coerce(int)), 441 | vol.Required( 442 | CONF_AC_SYSTEM_VOLTAGE, default=str(system_ac_voltage_default) 443 | ): SelectSelector( 444 | SelectSelectorConfig( 445 | options=[ 446 | SelectOptionDict(value=str(value), label=key) 447 | for key, value in AC_VOLTAGES.items() 448 | ] 449 | ), 450 | ), 451 | vol.Required( 452 | CONF_NUMBER_OF_PHASES, 453 | default=str(system_number_of_phases_default), 454 | ): SelectSelector( 455 | SelectSelectorConfig( 456 | options=[ 457 | SelectOptionDict(value=str(value), label=key) 458 | for key, value in PHASE_CONFIGURATIONS.items() 459 | ] 460 | ), 461 | ), 462 | vol.Required( 463 | CONF_AC_CURRENT_LIMIT, 464 | default=config.get(CONF_AC_CURRENT_LIMIT, 1), 465 | ): vol.All( 466 | vol.Coerce( 467 | int, "must be the max current of a single phase as a number" 468 | ) 469 | ), 470 | vol.Required( 471 | CONF_DC_SYSTEM_VOLTAGE, default=str(system_dc_voltage_default) 472 | ): SelectSelector( 473 | SelectSelectorConfig( 474 | options=[ 475 | SelectOptionDict(value=str(value), label=key) 476 | for key, value in DC_VOLTAGES.items() 477 | ] 478 | ), 479 | ), 480 | vol.Required( 481 | CONF_DC_CURRENT_LIMIT, 482 | default=config.get(CONF_DC_CURRENT_LIMIT, 1), 483 | ): vol.All( 484 | vol.Coerce( 485 | int, 486 | "must be the total DC current for the system as a number", 487 | ) 488 | ), 489 | vol.Optional( 490 | CONF_USE_SLIDERS, 491 | default=config.get( 492 | CONF_USE_SLIDERS, config.get(CONF_USE_SLIDERS, True) 493 | ), 494 | ): bool, 495 | vol.Optional(CONF_RESCAN, default=False): bool, 496 | vol.Optional(CONF_ADVANCED_OPTIONS, default=True): bool, 497 | }, 498 | ), 499 | ) 500 | 501 | @staticmethod 502 | def get_dict_key(dict, val): 503 | """Get the key from a dictionary.""" 504 | for key, value in dict.items(): 505 | if val == value: 506 | return key 507 | 508 | return "key doesn't exist" 509 | 510 | 511 | class CannotConnect(HomeAssistantError): 512 | """Error to indicate we cannot connect.""" 513 | 514 | 515 | class InvalidAuth(HomeAssistantError): 516 | """Error to indicate there is invalid auth.""" 517 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools==75.1.0"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "hass-victron" 7 | version = "0.5.0" 8 | license = {text = "Apache-2.0"} 9 | description = "Open-source integration for the victron modbus endpoint" 10 | readme = "README.md" 11 | keywords = ["home", "automation","homeassistant","victron"] 12 | requires-python = ">=3.13.0" 13 | 14 | [project.urls] 15 | "Source Code" = "https://github.com/sfstar/hass-victron" 16 | "Bug Reports" = "https://github.com/home-assistant/sfstar/hass-victron/issues" 17 | 18 | [tool.pylint.MAIN] 19 | py-version = "3.13" 20 | # Use a conservative default here; 2 should speed up most setups and not hurt 21 | # any too bad. Override on command line as appropriate. 22 | jobs = 2 23 | init-hook = """\ 24 | from pathlib import Path; \ 25 | import sys; \ 26 | 27 | from pylint.config import find_default_config_files; \ 28 | 29 | sys.path.append( \ 30 | str(Path(next(find_default_config_files())).parent.joinpath('pylint/plugins')) 31 | ) \ 32 | """ 33 | load-plugins = [ 34 | "pylint.extensions.code_style", 35 | "pylint.extensions.typing", 36 | "pylint_per_file_ignores", 37 | ] 38 | persistent = false 39 | extension-pkg-allow-list = [ 40 | "av.audio.stream", 41 | "av.logging", 42 | "av.stream", 43 | "ciso8601", 44 | "orjson", 45 | "cv2", 46 | ] 47 | fail-on = [ 48 | "I", 49 | ] 50 | 51 | [tool.pylint.BASIC] 52 | class-const-naming-style = "any" 53 | 54 | [tool.pylint."MESSAGES CONTROL"] 55 | # Reasons disabled: 56 | # format - handled by ruff 57 | # locally-disabled - it spams too much 58 | # duplicate-code - unavoidable 59 | # cyclic-import - doesn't test if both import on load 60 | # abstract-class-little-used - prevents from setting right foundation 61 | # unused-argument - generic callbacks and setup methods create a lot of warnings 62 | # too-many-* - are not enforced for the sake of readability 63 | # too-few-* - same as too-many-* 64 | # abstract-method - with intro of async there are always methods missing 65 | # inconsistent-return-statements - doesn't handle raise 66 | # too-many-ancestors - it's too strict. 67 | # wrong-import-order - isort guards this 68 | # possibly-used-before-assignment - too many errors / not necessarily issues 69 | # --- 70 | # Pylint CodeStyle plugin 71 | # consider-using-namedtuple-or-dataclass - too opinionated 72 | # consider-using-assignment-expr - decision to use := better left to devs 73 | disable = [ 74 | "format", 75 | "abstract-method", 76 | "cyclic-import", 77 | "duplicate-code", 78 | "inconsistent-return-statements", 79 | "locally-disabled", 80 | "not-context-manager", 81 | "too-few-public-methods", 82 | "too-many-ancestors", 83 | "too-many-arguments", 84 | "too-many-instance-attributes", 85 | "too-many-lines", 86 | "too-many-locals", 87 | "too-many-public-methods", 88 | "too-many-boolean-expressions", 89 | "too-many-positional-arguments", 90 | "wrong-import-order", 91 | "consider-using-namedtuple-or-dataclass", 92 | "consider-using-assignment-expr", 93 | "possibly-used-before-assignment", 94 | 95 | # Handled by ruff 96 | # Ref: 97 | "await-outside-async", # PLE1142 98 | "bad-str-strip-call", # PLE1310 99 | "bad-string-format-type", # PLE1307 100 | "bidirectional-unicode", # PLE2502 101 | "continue-in-finally", # PLE0116 102 | "duplicate-bases", # PLE0241 103 | "misplaced-bare-raise", # PLE0704 104 | "format-needs-mapping", # F502 105 | "function-redefined", # F811 106 | # Needed because ruff does not understand type of __all__ generated by a function 107 | # "invalid-all-format", # PLE0605 108 | "invalid-all-object", # PLE0604 109 | "invalid-character-backspace", # PLE2510 110 | "invalid-character-esc", # PLE2513 111 | "invalid-character-nul", # PLE2514 112 | "invalid-character-sub", # PLE2512 113 | "invalid-character-zero-width-space", # PLE2515 114 | "logging-too-few-args", # PLE1206 115 | "logging-too-many-args", # PLE1205 116 | "missing-format-string-key", # F524 117 | "mixed-format-string", # F506 118 | "no-method-argument", # N805 119 | "no-self-argument", # N805 120 | "nonexistent-operator", # B002 121 | "nonlocal-without-binding", # PLE0117 122 | "not-in-loop", # F701, F702 123 | "notimplemented-raised", # F901 124 | "return-in-init", # PLE0101 125 | "return-outside-function", # F706 126 | "syntax-error", # E999 127 | "too-few-format-args", # F524 128 | "too-many-format-args", # F522 129 | "too-many-star-expressions", # F622 130 | "truncated-format-string", # F501 131 | "undefined-all-variable", # F822 132 | "undefined-variable", # F821 133 | "used-prior-global-declaration", # PLE0118 134 | "yield-inside-async-function", # PLE1700 135 | "yield-outside-function", # F704 136 | "anomalous-backslash-in-string", # W605 137 | "assert-on-string-literal", # PLW0129 138 | "assert-on-tuple", # F631 139 | "bad-format-string", # W1302, F 140 | "bad-format-string-key", # W1300, F 141 | "bare-except", # E722 142 | "binary-op-exception", # PLW0711 143 | "cell-var-from-loop", # B023 144 | # "dangerous-default-value", # B006, ruff catches new occurrences, needs more work 145 | "duplicate-except", # B014 146 | "duplicate-key", # F601 147 | "duplicate-string-formatting-argument", # F 148 | "duplicate-value", # F 149 | "eval-used", # S307 150 | "exec-used", # S102 151 | "expression-not-assigned", # B018 152 | "f-string-without-interpolation", # F541 153 | "forgotten-debug-statement", # T100 154 | "format-string-without-interpolation", # F 155 | # "global-statement", # PLW0603, ruff catches new occurrences, needs more work 156 | "global-variable-not-assigned", # PLW0602 157 | "implicit-str-concat", # ISC001 158 | "import-self", # PLW0406 159 | "inconsistent-quotes", # Q000 160 | "invalid-envvar-default", # PLW1508 161 | "keyword-arg-before-vararg", # B026 162 | "logging-format-interpolation", # G 163 | "logging-fstring-interpolation", # G 164 | "logging-not-lazy", # G 165 | "misplaced-future", # F404 166 | "named-expr-without-context", # PLW0131 167 | "nested-min-max", # PLW3301 168 | "pointless-statement", # B018 169 | "raise-missing-from", # B904 170 | "redefined-builtin", # A001 171 | "try-except-raise", # TRY302 172 | "unused-argument", # ARG001, we don't use it 173 | "unused-format-string-argument", #F507 174 | "unused-format-string-key", # F504 175 | "unused-import", # F401 176 | "unused-variable", # F841 177 | "useless-else-on-loop", # PLW0120 178 | "wildcard-import", # F403 179 | "bad-classmethod-argument", # N804 180 | "consider-iterating-dictionary", # SIM118 181 | "empty-docstring", # D419 182 | "invalid-name", # N815 183 | "line-too-long", # E501, disabled globally 184 | "missing-class-docstring", # D101 185 | "missing-final-newline", # W292 186 | "missing-function-docstring", # D103 187 | "missing-module-docstring", # D100 188 | "multiple-imports", #E401 189 | "singleton-comparison", # E711, E712 190 | "subprocess-run-check", # PLW1510 191 | "superfluous-parens", # UP034 192 | "ungrouped-imports", # I001 193 | "unidiomatic-typecheck", # E721 194 | "unnecessary-direct-lambda-call", # PLC3002 195 | "unnecessary-lambda-assignment", # PLC3001 196 | "unnecessary-pass", # PIE790 197 | "unneeded-not", # SIM208 198 | "useless-import-alias", # PLC0414 199 | "wrong-import-order", # I001 200 | "wrong-import-position", # E402 201 | "comparison-of-constants", # PLR0133 202 | "comparison-with-itself", # PLR0124 203 | "consider-alternative-union-syntax", # UP007 204 | "consider-merging-isinstance", # PLR1701 205 | "consider-using-alias", # UP006 206 | "consider-using-dict-comprehension", # C402 207 | "consider-using-generator", # C417 208 | "consider-using-get", # SIM401 209 | "consider-using-set-comprehension", # C401 210 | "consider-using-sys-exit", # PLR1722 211 | "consider-using-ternary", # SIM108 212 | "literal-comparison", # F632 213 | "property-with-parameters", # PLR0206 214 | "super-with-arguments", # UP008 215 | "too-many-branches", # PLR0912 216 | "too-many-return-statements", # PLR0911 217 | "too-many-statements", # PLR0915 218 | "trailing-comma-tuple", # COM818 219 | "unnecessary-comprehension", # C416 220 | "use-a-generator", # C417 221 | "use-dict-literal", # C406 222 | "use-list-literal", # C405 223 | "useless-object-inheritance", # UP004 224 | "useless-return", # PLR1711 225 | "no-else-break", # RET508 226 | "no-else-continue", # RET507 227 | "no-else-raise", # RET506 228 | "no-else-return", # RET505 229 | "broad-except", # BLE001 230 | "protected-access", # SLF001 231 | "broad-exception-raised", # TRY002 232 | "consider-using-f-string", # PLC0209 233 | # "no-self-use", # PLR6301 # Optional plugin, not enabled 234 | 235 | # Handled by mypy 236 | # Ref: 237 | "abstract-class-instantiated", 238 | "arguments-differ", 239 | "assigning-non-slot", 240 | "assignment-from-no-return", 241 | "assignment-from-none", 242 | "bad-exception-cause", 243 | "bad-format-character", 244 | "bad-reversed-sequence", 245 | "bad-super-call", 246 | "bad-thread-instantiation", 247 | "catching-non-exception", 248 | "comparison-with-callable", 249 | "deprecated-class", 250 | "dict-iter-missing-items", 251 | "format-combined-specification", 252 | "global-variable-undefined", 253 | "import-error", 254 | "inconsistent-mro", 255 | "inherit-non-class", 256 | "init-is-generator", 257 | "invalid-class-object", 258 | "invalid-enum-extension", 259 | "invalid-envvar-value", 260 | "invalid-format-returned", 261 | "invalid-hash-returned", 262 | "invalid-metaclass", 263 | "invalid-overridden-method", 264 | "invalid-repr-returned", 265 | "invalid-sequence-index", 266 | "invalid-slice-index", 267 | "invalid-slots-object", 268 | "invalid-slots", 269 | "invalid-star-assignment-target", 270 | "invalid-str-returned", 271 | "invalid-unary-operand-type", 272 | "invalid-unicode-codec", 273 | "isinstance-second-argument-not-valid-type", 274 | "method-hidden", 275 | "misplaced-format-function", 276 | "missing-format-argument-key", 277 | "missing-format-attribute", 278 | "missing-kwoa", 279 | "no-member", 280 | "no-value-for-parameter", 281 | "non-iterator-returned", 282 | "non-str-assignment-to-dunder-name", 283 | "nonlocal-and-global", 284 | "not-a-mapping", 285 | "not-an-iterable", 286 | "not-async-context-manager", 287 | "not-callable", 288 | "not-context-manager", 289 | "overridden-final-method", 290 | "raising-bad-type", 291 | "raising-non-exception", 292 | "redundant-keyword-arg", 293 | "relative-beyond-top-level", 294 | "self-cls-assignment", 295 | "signature-differs", 296 | "star-needs-assignment-target", 297 | "subclassed-final-class", 298 | "super-without-brackets", 299 | "too-many-function-args", 300 | "typevar-double-variance", 301 | "typevar-name-mismatch", 302 | "unbalanced-dict-unpacking", 303 | "unbalanced-tuple-unpacking", 304 | "unexpected-keyword-arg", 305 | "unhashable-member", 306 | "unpacking-non-sequence", 307 | "unsubscriptable-object", 308 | "unsupported-assignment-operation", 309 | "unsupported-binary-operation", 310 | "unsupported-delete-operation", 311 | "unsupported-membership-test", 312 | "used-before-assignment", 313 | "using-final-decorator-in-unsupported-version", 314 | "wrong-exception-operation", 315 | ] 316 | enable = [ 317 | #"useless-suppression", # temporarily every now and then to clean them up 318 | "use-symbolic-message-instead", 319 | ] 320 | per-file-ignores = [ 321 | # redefined-outer-name: Tests reference fixtures in the test function 322 | # use-implicit-booleaness-not-comparison: Tests need to validate that a list 323 | # or a dict is returned 324 | "/tests/:redefined-outer-name,use-implicit-booleaness-not-comparison", 325 | ] 326 | 327 | [tool.pylint.REPORTS] 328 | score = false 329 | 330 | [tool.pylint.TYPECHECK] 331 | ignored-classes = [ 332 | "_CountingAttr", # for attrs 333 | ] 334 | mixin-class-rgx = ".*[Mm]ix[Ii]n" 335 | 336 | [tool.pylint.FORMAT] 337 | expected-line-ending-format = "LF" 338 | 339 | [tool.pylint.EXCEPTIONS] 340 | overgeneral-exceptions = [ 341 | "builtins.BaseException", 342 | "builtins.Exception", 343 | # "homeassistant.exceptions.HomeAssistantError", # too many issues 344 | ] 345 | 346 | [tool.pylint.TYPING] 347 | runtime-typing = false 348 | 349 | [tool.pylint.CODE_STYLE] 350 | max-line-length-suggestions = 72 351 | 352 | [tool.pytest.ini_options] 353 | testpaths = [ 354 | "tests", 355 | ] 356 | norecursedirs = [ 357 | ".git", 358 | "testing_config", 359 | ] 360 | log_format = "%(asctime)s.%(msecs)03d %(levelname)-8s %(threadName)s %(name)s:%(filename)s:%(lineno)s %(message)s" 361 | log_date_format = "%Y-%m-%d %H:%M:%S" 362 | asyncio_mode = "auto" 363 | asyncio_default_fixture_loop_scope = "function" 364 | 365 | [tool.ruff] 366 | required-version = ">=0.9.1" 367 | 368 | [tool.ruff.lint] 369 | select = [ 370 | "A001", # Variable {name} is shadowing a Python builtin 371 | "ASYNC210", # Async functions should not call blocking HTTP methods 372 | "ASYNC220", # Async functions should not create subprocesses with blocking methods 373 | "ASYNC221", # Async functions should not run processes with blocking methods 374 | "ASYNC222", # Async functions should not wait on processes with blocking methods 375 | "ASYNC230", # Async functions should not open files with blocking methods like open 376 | "ASYNC251", # Async functions should not call time.sleep 377 | "B002", # Python does not support the unary prefix increment 378 | "B005", # Using .strip() with multi-character strings is misleading 379 | "B007", # Loop control variable {name} not used within loop body 380 | "B014", # Exception handler with duplicate exception 381 | "B015", # Pointless comparison. Did you mean to assign a value? Otherwise, prepend assert or remove it. 382 | "B017", # pytest.raises(BaseException) should be considered evil 383 | "B018", # Found useless attribute access. Either assign it to a variable or remove it. 384 | "B023", # Function definition does not bind loop variable {name} 385 | "B024", # `{name}` is an abstract base class, but it has no abstract methods or properties 386 | "B026", # Star-arg unpacking after a keyword argument is strongly discouraged 387 | "B032", # Possible unintentional type annotation (using :). Did you mean to assign (using =)? 388 | "B035", # Dictionary comprehension uses static key 389 | "B904", # Use raise from to specify exception cause 390 | "B905", # zip() without an explicit strict= parameter 391 | "BLE", 392 | "C", # complexity 393 | "COM818", # Trailing comma on bare tuple prohibited 394 | "D", # docstrings 395 | "DTZ003", # Use datetime.now(tz=) instead of datetime.utcnow() 396 | "DTZ004", # Use datetime.fromtimestamp(ts, tz=) instead of datetime.utcfromtimestamp(ts) 397 | "E", # pycodestyle 398 | "F", # pyflakes/autoflake 399 | "F541", # f-string without any placeholders 400 | "FLY", # flynt 401 | "FURB", # refurb 402 | "G", # flake8-logging-format 403 | "I", # isort 404 | "INP", # flake8-no-pep420 405 | "ISC", # flake8-implicit-str-concat 406 | "ICN001", # import concentions; {name} should be imported as {asname} 407 | "LOG", # flake8-logging 408 | "N804", # First argument of a class method should be named cls 409 | "N805", # First argument of a method should be named self 410 | "N815", # Variable {name} in class scope should not be mixedCase 411 | "PERF", # Perflint 412 | "PGH", # pygrep-hooks 413 | "PIE", # flake8-pie 414 | "PL", # pylint 415 | "PT", # flake8-pytest-style 416 | "PTH", # flake8-pathlib 417 | "PYI", # flake8-pyi 418 | "RET", # flake8-return 419 | "RSE", # flake8-raise 420 | "RUF005", # Consider iterable unpacking instead of concatenation 421 | "RUF006", # Store a reference to the return value of asyncio.create_task 422 | "RUF007", # Prefer itertools.pairwise() over zip() when iterating over successive pairs 423 | "RUF008", # Do not use mutable default values for dataclass attributes 424 | "RUF010", # Use explicit conversion flag 425 | "RUF013", # PEP 484 prohibits implicit Optional 426 | "RUF016", # Slice in indexed access to type {value_type} uses type {index_type} instead of an integer 427 | "RUF017", # Avoid quadratic list summation 428 | "RUF018", # Avoid assignment expressions in assert statements 429 | "RUF019", # Unnecessary key check before dictionary access 430 | "RUF020", # {never_like} | T is equivalent to T 431 | "RUF021", # Parenthesize a and b expressions when chaining and and or together, to make the precedence clear 432 | "RUF022", # Sort __all__ 433 | "RUF023", # Sort __slots__ 434 | "RUF024", # Do not pass mutable objects as values to dict.fromkeys 435 | "RUF026", # default_factory is a positional-only argument to defaultdict 436 | "RUF030", # print() call in assert statement is likely unintentional 437 | "RUF032", # Decimal() called with float literal argument 438 | "RUF033", # __post_init__ method with argument defaults 439 | "RUF034", # Useless if-else condition 440 | "RUF100", # Unused `noqa` directive 441 | "RUF101", # noqa directives that use redirected rule codes 442 | "RUF200", # Failed to parse pyproject.toml: {message} 443 | "S102", # Use of exec detected 444 | "S103", # bad-file-permissions 445 | "S108", # hardcoded-temp-file 446 | "S306", # suspicious-mktemp-usage 447 | "S307", # suspicious-eval-usage 448 | "S313", # suspicious-xmlc-element-tree-usage 449 | "S314", # suspicious-xml-element-tree-usage 450 | "S315", # suspicious-xml-expat-reader-usage 451 | "S316", # suspicious-xml-expat-builder-usage 452 | "S317", # suspicious-xml-sax-usage 453 | "S318", # suspicious-xml-mini-dom-usage 454 | "S319", # suspicious-xml-pull-dom-usage 455 | "S601", # paramiko-call 456 | "S602", # subprocess-popen-with-shell-equals-true 457 | "S604", # call-with-shell-equals-true 458 | "S608", # hardcoded-sql-expression 459 | "S609", # unix-command-wildcard-injection 460 | "SIM", # flake8-simplify 461 | "SLF", # flake8-self 462 | "SLOT", # flake8-slots 463 | "T100", # Trace found: {name} used 464 | "T20", # flake8-print 465 | "TC", # flake8-type-checking 466 | "TID", # Tidy imports 467 | "TRY", # tryceratops 468 | "UP", # pyupgrade 469 | "UP031", # Use format specifiers instead of percent format 470 | "UP032", # Use f-string instead of `format` call 471 | "W", # pycodestyle 472 | ] 473 | 474 | ignore = [ 475 | "D202", # No blank lines allowed after function docstring 476 | "D203", # 1 blank line required before class docstring 477 | "D213", # Multi-line docstring summary should start at the second line 478 | "D406", # Section name should end with a newline 479 | "D407", # Section name underlining 480 | "E501", # line too long 481 | 482 | "PLC1901", # {existing} can be simplified to {replacement} as an empty string is falsey; too many false positives 483 | "PLR0911", # Too many return statements ({returns} > {max_returns}) 484 | "PLR0912", # Too many branches ({branches} > {max_branches}) 485 | "PLR0913", # Too many arguments to function call ({c_args} > {max_args}) 486 | "PLR0915", # Too many statements ({statements} > {max_statements}) 487 | "PLR2004", # Magic value used in comparison, consider replacing {value} with a constant variable 488 | "PLW2901", # Outer {outer_kind} variable {name} overwritten by inner {inner_kind} target 489 | "PT011", # pytest.raises({exception}) is too broad, set the `match` parameter or use a more specific exception 490 | "PT018", # Assertion should be broken down into multiple parts 491 | "RUF001", # String contains ambiguous unicode character. 492 | "RUF002", # Docstring contains ambiguous unicode character. 493 | "RUF003", # Comment contains ambiguous unicode character. 494 | "RUF015", # Prefer next(...) over single element slice 495 | "SIM102", # Use a single if statement instead of nested if statements 496 | "SIM103", # Return the condition {condition} directly 497 | "SIM108", # Use ternary operator {contents} instead of if-else-block 498 | "SIM115", # Use context handler for opening files 499 | 500 | # Moving imports into type-checking blocks can mess with pytest.patch() 501 | "TC001", # Move application import {} into a type-checking block 502 | "TC002", # Move third-party import {} into a type-checking block 503 | "TC003", # Move standard library import {} into a type-checking block 504 | 505 | "TRY003", # Avoid specifying long messages outside the exception class 506 | "TRY400", # Use `logging.exception` instead of `logging.error` 507 | # Ignored due to performance: https://github.com/charliermarsh/ruff/issues/2923 508 | "UP038", # Use `X | Y` in `isinstance` call instead of `(X, Y)` 509 | 510 | # May conflict with the formatter, https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules 511 | "W191", 512 | "E111", 513 | "E114", 514 | "E117", 515 | "D206", 516 | "D300", 517 | "Q", 518 | "COM812", 519 | "COM819", 520 | 521 | # Disabled because ruff does not understand type of __all__ generated by a function 522 | "PLE0605" 523 | ] 524 | 525 | [tool.ruff.lint.flake8-import-conventions.extend-aliases] 526 | voluptuous = "vol" 527 | "homeassistant.components.air_quality.PLATFORM_SCHEMA" = "AIR_QUALITY_PLATFORM_SCHEMA" 528 | "homeassistant.components.alarm_control_panel.PLATFORM_SCHEMA" = "ALARM_CONTROL_PANEL_PLATFORM_SCHEMA" 529 | "homeassistant.components.binary_sensor.PLATFORM_SCHEMA" = "BINARY_SENSOR_PLATFORM_SCHEMA" 530 | "homeassistant.components.button.PLATFORM_SCHEMA" = "BUTTON_PLATFORM_SCHEMA" 531 | "homeassistant.components.calendar.PLATFORM_SCHEMA" = "CALENDAR_PLATFORM_SCHEMA" 532 | "homeassistant.components.camera.PLATFORM_SCHEMA" = "CAMERA_PLATFORM_SCHEMA" 533 | "homeassistant.components.climate.PLATFORM_SCHEMA" = "CLIMATE_PLATFORM_SCHEMA" 534 | "homeassistant.components.conversation.PLATFORM_SCHEMA" = "CONVERSATION_PLATFORM_SCHEMA" 535 | "homeassistant.components.cover.PLATFORM_SCHEMA" = "COVER_PLATFORM_SCHEMA" 536 | "homeassistant.components.date.PLATFORM_SCHEMA" = "DATE_PLATFORM_SCHEMA" 537 | "homeassistant.components.datetime.PLATFORM_SCHEMA" = "DATETIME_PLATFORM_SCHEMA" 538 | "homeassistant.components.device_tracker.PLATFORM_SCHEMA" = "DEVICE_TRACKER_PLATFORM_SCHEMA" 539 | "homeassistant.components.event.PLATFORM_SCHEMA" = "EVENT_PLATFORM_SCHEMA" 540 | "homeassistant.components.fan.PLATFORM_SCHEMA" = "FAN_PLATFORM_SCHEMA" 541 | "homeassistant.components.geo_location.PLATFORM_SCHEMA" = "GEO_LOCATION_PLATFORM_SCHEMA" 542 | "homeassistant.components.humidifier.PLATFORM_SCHEMA" = "HUMIDIFIER_PLATFORM_SCHEMA" 543 | "homeassistant.components.image.PLATFORM_SCHEMA" = "IMAGE_PLATFORM_SCHEMA" 544 | "homeassistant.components.image_processing.PLATFORM_SCHEMA" = "IMAGE_PROCESSING_PLATFORM_SCHEMA" 545 | "homeassistant.components.lawn_mower.PLATFORM_SCHEMA" = "LAWN_MOWER_PLATFORM_SCHEMA" 546 | "homeassistant.components.light.PLATFORM_SCHEMA" = "LIGHT_PLATFORM_SCHEMA" 547 | "homeassistant.components.lock.PLATFORM_SCHEMA" = "LOCK_PLATFORM_SCHEMA" 548 | "homeassistant.components.media_player.PLATFORM_SCHEMA" = "MEDIA_PLAYER_PLATFORM_SCHEMA" 549 | "homeassistant.components.notify.PLATFORM_SCHEMA" = "NOTIFY_PLATFORM_SCHEMA" 550 | "homeassistant.components.number.PLATFORM_SCHEMA" = "NUMBER_PLATFORM_SCHEMA" 551 | "homeassistant.components.remote.PLATFORM_SCHEMA" = "REMOTE_PLATFORM_SCHEMA" 552 | "homeassistant.components.scene.PLATFORM_SCHEMA" = "SCENE_PLATFORM_SCHEMA" 553 | "homeassistant.components.select.PLATFORM_SCHEMA" = "SELECT_PLATFORM_SCHEMA" 554 | "homeassistant.components.sensor.PLATFORM_SCHEMA" = "SENSOR_PLATFORM_SCHEMA" 555 | "homeassistant.components.siren.PLATFORM_SCHEMA" = "SIREN_PLATFORM_SCHEMA" 556 | "homeassistant.components.stt.PLATFORM_SCHEMA" = "STT_PLATFORM_SCHEMA" 557 | "homeassistant.components.switch.PLATFORM_SCHEMA" = "SWITCH_PLATFORM_SCHEMA" 558 | "homeassistant.components.text.PLATFORM_SCHEMA" = "TEXT_PLATFORM_SCHEMA" 559 | "homeassistant.components.time.PLATFORM_SCHEMA" = "TIME_PLATFORM_SCHEMA" 560 | "homeassistant.components.todo.PLATFORM_SCHEMA" = "TODO_PLATFORM_SCHEMA" 561 | "homeassistant.components.tts.PLATFORM_SCHEMA" = "TTS_PLATFORM_SCHEMA" 562 | "homeassistant.components.vacuum.PLATFORM_SCHEMA" = "VACUUM_PLATFORM_SCHEMA" 563 | "homeassistant.components.valve.PLATFORM_SCHEMA" = "VALVE_PLATFORM_SCHEMA" 564 | "homeassistant.components.update.PLATFORM_SCHEMA" = "UPDATE_PLATFORM_SCHEMA" 565 | "homeassistant.components.wake_word.PLATFORM_SCHEMA" = "WAKE_WORD_PLATFORM_SCHEMA" 566 | "homeassistant.components.water_heater.PLATFORM_SCHEMA" = "WATER_HEATER_PLATFORM_SCHEMA" 567 | "homeassistant.components.weather.PLATFORM_SCHEMA" = "WEATHER_PLATFORM_SCHEMA" 568 | "homeassistant.core.DOMAIN" = "HOMEASSISTANT_DOMAIN" 569 | "homeassistant.helpers.area_registry" = "ar" 570 | "homeassistant.helpers.category_registry" = "cr" 571 | "homeassistant.helpers.config_validation" = "cv" 572 | "homeassistant.helpers.device_registry" = "dr" 573 | "homeassistant.helpers.entity_registry" = "er" 574 | "homeassistant.helpers.floor_registry" = "fr" 575 | "homeassistant.helpers.issue_registry" = "ir" 576 | "homeassistant.helpers.label_registry" = "lr" 577 | "homeassistant.util.color" = "color_util" 578 | "homeassistant.util.dt" = "dt_util" 579 | "homeassistant.util.json" = "json_util" 580 | "homeassistant.util.location" = "location_util" 581 | "homeassistant.util.logging" = "logging_util" 582 | "homeassistant.util.network" = "network_util" 583 | "homeassistant.util.ulid" = "ulid_util" 584 | "homeassistant.util.uuid" = "uuid_util" 585 | "homeassistant.util.yaml" = "yaml_util" 586 | 587 | [tool.ruff.lint.flake8-pytest-style] 588 | fixture-parentheses = false 589 | mark-parentheses = false 590 | 591 | [tool.ruff.lint.flake8-tidy-imports.banned-api] 592 | "async_timeout".msg = "use asyncio.timeout instead" 593 | "pytz".msg = "use zoneinfo instead" 594 | "tests".msg = "You should not import tests" 595 | 596 | [tool.ruff.lint.isort] 597 | force-sort-within-sections = true 598 | known-first-party = [ 599 | "homeassistant", 600 | ] 601 | combine-as-imports = true 602 | split-on-trailing-comma = false 603 | 604 | #[tool.ruff.lint.per-file-ignores] 605 | 606 | 607 | 608 | [tool.ruff.lint.mccabe] 609 | max-complexity = 25 610 | 611 | [tool.ruff.lint.pydocstyle] 612 | property-decorators = ["propcache.api.cached_property"] 613 | --------------------------------------------------------------------------------