├── requirements.txt ├── .github ├── FUNDING.yml ├── dependabot.yaml ├── workflows │ ├── hassfest.yaml │ ├── hacs.yaml │ ├── linter.yml │ └── codeql-analysis.yml └── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature_request.md │ ├── change_request.md │ └── bug_report.yml ├── .gitignore ├── doc ├── example_controls.png ├── sunspec-common-models-1_6.pdf ├── sunspec-multiple-mppt-1_2.pdf ├── feed-in_limitation_application_note.pdf ├── sunspec-implementation-technical-note.pdf ├── SunSpec-Specification-Common-Models-A12031-1.6.pdf ├── storedge_charge_discharge_profile_programming.pdf ├── Power-Control-Open-Protocol-for-SolarEdge-Inverters.pdf └── battery_specs.txt ├── hacs.json ├── setup.cfg ├── custom_components └── solaredge_modbus_multi │ ├── manifest.json │ ├── diagnostics.py │ ├── binary_sensor.py │ ├── helpers.py │ ├── repairs.py │ ├── button.py │ ├── translations │ ├── nb.json │ ├── en.json │ ├── nl.json │ ├── pl.json │ ├── de.json │ ├── fr.json │ └── it.json │ ├── strings.json │ ├── switch.py │ ├── const.py │ ├── __init__.py │ ├── config_flow.py │ ├── select.py │ └── number.py ├── README.md └── LICENSE /requirements.txt: -------------------------------------------------------------------------------- 1 | pymodbus>=3.8.3 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: WillCodeForCats 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | solaredge-modbus-multi.bbprojectd 3 | /megalinter-reports 4 | -------------------------------------------------------------------------------- /doc/example_controls.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WillCodeForCats/solaredge-modbus-multi/HEAD/doc/example_controls.png -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "SolarEdge Modbus Multi", 3 | "content_in_root": false, 4 | "homeassistant": "2025.2.0" 5 | } 6 | -------------------------------------------------------------------------------- /doc/sunspec-common-models-1_6.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WillCodeForCats/solaredge-modbus-multi/HEAD/doc/sunspec-common-models-1_6.pdf -------------------------------------------------------------------------------- /doc/sunspec-multiple-mppt-1_2.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WillCodeForCats/solaredge-modbus-multi/HEAD/doc/sunspec-multiple-mppt-1_2.pdf -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 80 3 | extend-select = B950 4 | extend-ignore = E203,E501,E701 5 | 6 | [isort] 7 | profile = black 8 | -------------------------------------------------------------------------------- /doc/feed-in_limitation_application_note.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WillCodeForCats/solaredge-modbus-multi/HEAD/doc/feed-in_limitation_application_note.pdf -------------------------------------------------------------------------------- /doc/sunspec-implementation-technical-note.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WillCodeForCats/solaredge-modbus-multi/HEAD/doc/sunspec-implementation-technical-note.pdf -------------------------------------------------------------------------------- /doc/SunSpec-Specification-Common-Models-A12031-1.6.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WillCodeForCats/solaredge-modbus-multi/HEAD/doc/SunSpec-Specification-Common-Models-A12031-1.6.pdf -------------------------------------------------------------------------------- /doc/storedge_charge_discharge_profile_programming.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WillCodeForCats/solaredge-modbus-multi/HEAD/doc/storedge_charge_discharge_profile_programming.pdf -------------------------------------------------------------------------------- /doc/Power-Control-Open-Protocol-for-SolarEdge-Inverters.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WillCodeForCats/solaredge-modbus-multi/HEAD/doc/Power-Control-Open-Protocol-for-SolarEdge-Inverters.pdf -------------------------------------------------------------------------------- /.github/dependabot.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: weekly 7 | time: "06:00" 8 | - package-ecosystem: "pip" 9 | directory: "/" 10 | schedule: 11 | interval: daily 12 | time: "00:00" 13 | groups: 14 | python-packages: 15 | patterns: 16 | - "*" 17 | -------------------------------------------------------------------------------- /.github/workflows/hassfest.yaml: -------------------------------------------------------------------------------- 1 | name: Validate with Hassfest 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | schedule: 11 | - cron: "0 0 * * *" 12 | permissions: read-all 13 | jobs: 14 | validate: 15 | runs-on: "ubuntu-latest" 16 | steps: 17 | - uses: "actions/checkout@v6" 18 | - uses: "home-assistant/actions/hassfest@master" 19 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: GitHub Community Support 4 | url: https://github.com/WillCodeForCats/solaredge-modbus-multi/discussions 5 | about: Please ask and answer questions here. 6 | - name: Home Assistant Community Support 7 | url: https://community.home-assistant.io/t/custom-component-solaredge-modbus-multi/ 8 | about: Please ask and answer questions here. 9 | -------------------------------------------------------------------------------- /.github/workflows/hacs.yaml: -------------------------------------------------------------------------------- 1 | name: HACS Action 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | schedule: 11 | - cron: "0 0 * * *" 12 | permissions: read-all 13 | jobs: 14 | hacs: 15 | name: HACS Action 16 | runs-on: "ubuntu-latest" 17 | steps: 18 | - uses: "actions/checkout@v6" 19 | - name: HACS Action 20 | uses: "hacs/action@main" 21 | with: 22 | category: "integration" 23 | -------------------------------------------------------------------------------- /custom_components/solaredge_modbus_multi/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "solaredge_modbus_multi", 3 | "name": "SolarEdge Modbus Multi", 4 | "codeowners": ["@WillCodeForCats"], 5 | "config_flow": true, 6 | "dependencies": [], 7 | "documentation": "https://github.com/WillCodeForCats/solaredge-modbus-multi/wiki", 8 | "integration_type": "hub", 9 | "iot_class": "local_polling", 10 | "issue_tracker": "https://github.com/WillCodeForCats/solaredge-modbus-multi/issues", 11 | "loggers": ["custom_components.solaredge_modbus_multi"], 12 | "requirements": ["pymodbus>=3.8.3"], 13 | "version": "3.2.1" 14 | } 15 | -------------------------------------------------------------------------------- /.github/workflows/linter.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Lint Code Base 3 | on: 4 | push: 5 | pull_request: 6 | branches: 7 | - main 8 | permissions: read-all 9 | jobs: 10 | build: 11 | name: Lint Code Base 12 | runs-on: ubuntu-latest 13 | permissions: 14 | contents: read 15 | packages: read 16 | statuses: write 17 | steps: 18 | - name: Checkout Code 19 | uses: actions/checkout@v6 20 | with: 21 | fetch-depth: 0 22 | - name: Lint Code Base 23 | uses: github/super-linter@v7 24 | env: 25 | VALIDATE_ALL_CODEBASE: false 26 | VALIDATE_JSCPD: false 27 | VALIDATE_PYTHON_MYPY: false 28 | VALIDATE_PYTHON_PYLINT: false 29 | VALIDATE_PYTHON_PYINK: false 30 | DEFAULT_BRANCH: main 31 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 32 | -------------------------------------------------------------------------------- /doc/battery_specs.txt: -------------------------------------------------------------------------------- 1 | 2 | SE5K-RWS / SE7K-RWS / SE8K-RWS / SE10K-RWS 3 | SolarEdge Home Battery – Low Voltage BAT-05K48M0B-01 4 | LG Chem RESU3.3, RESU6.5, RESU10, RESU12, RESU13 5 | BYD Battery-Box LV 3.5, 7.0, 10.5, 14.0 6 | BYD Battery-Box Premium LVS 4.0, 8.0, 12.0, 16.0, 20.0, 24.0 7 | 40-62 V DC 8 | 130 A DC 9 | 10 | Note: in issue #370 a SolarEdge Home Battery with SE8K-RWS inverter reported battery 11 | voltages as high as 825 VDC when it's supposed to be a 48VDC nominal system. 12 | 13 | 14 | 15 | SE5000-XXS / SE6000-XXS 16 | LG Chem RESU7H 17 | LG Chem RESU10H 18 | 500 V DC max 19 | 17.5 A DC max 20 | 21 | 22 | SEXXXXH-RWSACBXXXX 23 | LG Chem RESU7H 24 | LG Chem RESU10H 25 | 480 V DC 26 | 13.5 A DC 27 | 28 | 29 | SE2200H, SE3000H, SE3500H, SE3680H, SE4000H, SE5000H, SE6000H 30 | LG Chem RESU7H, LG Chem RESU10H, LG Chem RESU10H Prime, LG Chem RESU16H Prime 31 | 480 V DC max 32 | 14 A DC max 33 | -------------------------------------------------------------------------------- /.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 | 18 | 19 | **Describe the feature** 20 | A clear and concise description of what the feature should be. 21 | 22 | **Screenshots** 23 | If applicable, add screenshots or edits of the proposed feature that help describe it. 24 | 25 | **Home Assistant (please complete the following information):** 26 | 27 | - Home Assistant Core Version: 28 | - solaredge-modbus-multi Version: 29 | 30 | **Additional context** 31 | Add any other context about the request here. 32 | -------------------------------------------------------------------------------- /.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 | 18 | 19 | **Describe the current behavior** 20 | A clear and concise description of what the feature should be. 21 | 22 | **What should be updated or changed?** 23 | A clear and concise description of the proposed new or updated behavior. 24 | 25 | **Home Assistant (please complete the following information):** 26 | 27 | - Home Assistant Core Version: 28 | - solaredge-modbus-multi Version: 29 | 30 | **Additional context** 31 | Add any other context about the request here. 32 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [main] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [main] 20 | schedule: 21 | - cron: "31 7 * * 1" 22 | permissions: read-all 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: ["python"] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v6 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v4 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | 52 | # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 53 | # queries: security-extended,security-and-quality 54 | 55 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 56 | # If this step fails, then you should remove it and run the build manually (see below) 57 | - name: Autobuild 58 | uses: github/codeql-action/autobuild@v4 59 | 60 | # ℹ️ Command-line programs to run using the OS shell. 61 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 62 | 63 | # If the Autobuild fails above, remove it and uncomment the following three lines. 64 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 65 | 66 | # - run: | 67 | # echo "Run, Build Application using script" 68 | # ./location_of_script_within_repo/buildscript.sh 69 | 70 | - name: Perform CodeQL Analysis 71 | uses: github/codeql-action/analyze@v4 72 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SolarEdge Modbus Multi 2 | 3 | [![hacs_badge](https://img.shields.io/badge/HACS-Default-41BDF5.svg?style=for-the-badge)](https://github.com/hacs/integration) 4 | 5 | This integration provides Modbus/TCP local polling to one or more SolarEdge inverters for Home Assistant. Each inverter can support three meters and three batteries over Modbus/TCP. It works with single inverters, multiple inverters, meters, and batteries. It has significant improvements over similar integrations, and `solaredge_modbus_multi` is actively maintained. 6 | 7 | ## Features 8 | 9 | - Inverter support for 1 to 32 SolarEdge inverters. 10 | - Meter support for 1 to 3 meters per inverter. 11 | - Battery support for 1 to 3 batteries per inverter. 12 | - Supports site limit and storage controls. 13 | - Automatically detects meters and batteries. 14 | - Supports Three Phase Inverters with Synergy Technology. 15 | - Polling frequency configuration option (1 to 86400 seconds). 16 | - Configurable starting inverter device ID. 17 | - Connects locally using Modbus/TCP - no cloud dependencies. 18 | - Informational sensor for device and its attributes 19 | - Supports status and error reporting sensors. 20 | - User friendly: Config Flow, Options, Repair Issues, and Reconfiguration. 21 | 22 | Read about more features on the wiki: [WillCodeForCats/solaredge-modbus-multi/wiki](https://github.com/WillCodeForCats/solaredge-modbus-multi/wiki) 23 | 24 | ## Installation 25 | 26 | Install with [HACS](https://hacs.xyz): Search for "SolarEdge Modbus Multi" in the default repository, 27 | 28 | [![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=WillCodeForCats&repository=solaredge-modbus-multi&category=integration) 29 | 30 | OR 31 | 32 | Download the [latest release](https://github.com/WillCodeForCats/solaredge-modbus-multi/releases) and copy the `solaredge_modbus_multi` folder into to your Home Assistant `config/custom_components` folder. 33 | 34 | After rebooting Home Assistant, this integration can be configured through the integration setup UI. It also supports options, repair issues, and reconfiguration through the user interface. 35 | 36 | ### Configuration 37 | 38 | [WillCodeForCats/solaredge-modbus-multi/wiki/Configuration](https://github.com/WillCodeForCats/solaredge-modbus-multi/wiki/Configuration) 39 | 40 | ### Documentation 41 | 42 | [WillCodeForCats/solaredge-modbus-multi/wiki](https://github.com/WillCodeForCats/solaredge-modbus-multi/wiki) 43 | 44 | ### Minimum Required Versions 45 | 46 | - Home Assistant 2025.2.0 (HA=>2025.9.0 requires release v3.1.7 or newer) 47 | - pymodbus 3.8.3 (pymodbus>=3.10.0 requires release v3.1.6 or newer) 48 | 49 | ## Specifications 50 | 51 | [WillCodeForCats/solaredge-modbus-multi/tree/main/doc](https://github.com/WillCodeForCats/solaredge-modbus-multi/tree/main/doc) 52 | 53 | ## Project Sponsors 54 | 55 | - [@bertybuttface](https://github.com/bertybuttface) 56 | - [@dominikamann](https://github.com/dominikamann) 57 | - [@maksyms](https://github.com/maksyms) 58 | - [@pwo108](https://github.com/pwo108) 59 | - [@barrown](https://github.com/barrown) 60 | -------------------------------------------------------------------------------- /custom_components/solaredge_modbus_multi/diagnostics.py: -------------------------------------------------------------------------------- 1 | """Diagnostics support for SolarEdge Modbus Multi Device.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import Any 6 | 7 | from homeassistant.components.diagnostics import async_redact_data 8 | from homeassistant.config_entries import ConfigEntry 9 | from homeassistant.core import HomeAssistant 10 | 11 | from .const import DOMAIN 12 | from .helpers import float_to_hex 13 | 14 | REDACT_CONFIG = {"unique_id", "host"} 15 | REDACT_INVERTER = {"identifiers", "C_SerialNumber", "serial_number"} 16 | REDACT_METER = {"identifiers", "C_SerialNumber", "serial_number", "via_device"} 17 | REDACT_BATTERY = {"identifiers", "B_SerialNumber", "serial_number", "via_device"} 18 | 19 | 20 | def format_values(format_input) -> Any: 21 | if isinstance(format_input, dict): 22 | formatted_dict = {} 23 | for name, value in iter(format_input.items()): 24 | if isinstance(value, dict): 25 | display_value = format_values(value) 26 | elif isinstance(value, float): 27 | display_value = float_to_hex(value) 28 | else: 29 | display_value = hex(value) if isinstance(value, int) else value 30 | 31 | formatted_dict[name] = display_value 32 | 33 | return formatted_dict 34 | 35 | return format_input 36 | 37 | 38 | async def async_get_config_entry_diagnostics( 39 | hass: HomeAssistant, config_entry: ConfigEntry 40 | ) -> dict[str, Any]: 41 | """Return diagnostics for a config entry.""" 42 | hub = hass.data[DOMAIN][config_entry.entry_id]["hub"] 43 | 44 | data: dict[str, Any] = { 45 | "pymodbus_version": hub.pymodbus_version, 46 | "config_entry": async_redact_data(config_entry.as_dict(), REDACT_CONFIG), 47 | "yaml": async_redact_data(hass.data[DOMAIN]["yaml"], REDACT_CONFIG), 48 | } 49 | 50 | for inverter in hub.inverters: 51 | inverter: dict[str, Any] = { 52 | f"inverter_unit_id_{inverter.inverter_unit_id}": { 53 | "device_info": inverter.device_info, 54 | "global_power_control": inverter.global_power_control, 55 | "advanced_power_control": inverter.advanced_power_control, 56 | "site_limit_control": inverter.site_limit_control, 57 | "common": inverter.decoded_common, 58 | "model": format_values(inverter.decoded_model), 59 | "is_mmppt": inverter.is_mmppt, 60 | "mmppt": format_values(inverter.decoded_mmppt), 61 | "has_battery": inverter.has_battery, 62 | "storage_control": format_values(inverter.decoded_storage_control), 63 | } 64 | } 65 | 66 | data.update(async_redact_data(inverter, REDACT_INVERTER)) 67 | 68 | for meter in hub.meters: 69 | meter: dict[str, Any] = { 70 | f"meter_id_{meter.meter_id}": { 71 | "device_info": meter.device_info, 72 | "inverter_unit_id": meter.inverter_unit_id, 73 | "common": meter.decoded_common, 74 | "model": format_values(meter.decoded_model), 75 | } 76 | } 77 | data.update(async_redact_data(meter, REDACT_METER)) 78 | 79 | for battery in hub.batteries: 80 | battery: dict[str, Any] = { 81 | f"battery_id_{battery.battery_id}": { 82 | "device_info": battery.device_info, 83 | "inverter_unit_id": battery.inverter_unit_id, 84 | "common": battery.decoded_common, 85 | "model": format_values(battery.decoded_model), 86 | } 87 | } 88 | data.update(async_redact_data(battery, REDACT_BATTERY)) 89 | 90 | return data 91 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: Create a report to help us improve or fix a problem. 3 | labels: [] 4 | assignees: 5 | - octocat 6 | body: 7 | - type: markdown 8 | attributes: 9 | value: | 10 | Thanks for taking the time to fill out this bug report! 11 | Do not use this form to ask how to set up or configure the integration. 12 | Make sure you have read the "Known Issues" in the wiki before submitting this form. 13 | - type: textarea 14 | id: what-happened 15 | attributes: 16 | label: Describe the bug 17 | description: A clear and concise description of what the bug is. 18 | validations: 19 | required: true 20 | - type: textarea 21 | id: expected-behavior 22 | attributes: 23 | label: Expected behavior 24 | description: A clear and concise description of what you expected to happen. 25 | validations: 26 | required: true 27 | - type: textarea 28 | id: screenshots 29 | attributes: 30 | label: Screenshots 31 | description: If applicable, add screenshots of the problem and/or configuration. 32 | - type: textarea 33 | id: diagnostic-file 34 | attributes: 35 | label: Diagnostic File 36 | description: REQUIRED. Drag and drop to attach your diagnostic file. The diagnostic file is a snapshot. Make sure it's taken at the exact time the issue is happening. Diagnostic file is not required when reporting an issue that prevents the integration from starting. https://github.com/WillCodeForCats/solaredge-modbus-multi/wiki/Integration-Diagnostics 37 | validations: 38 | required: true 39 | - type: textarea 40 | id: log-file 41 | attributes: 42 | label: Debug logs 43 | description: Optional. Debug logs are not always necessary, but may be required for more complex issues. You can enable debug logs in Home Assistant. Debug logging will generate a large amount of data. Turn on debug only to observe the isseue then turn off debug during normal operation. Debug logs are required when reporting an issue that prevents the integration from starting. 44 | render: shell 45 | - type: input 46 | id: ha-version 47 | attributes: 48 | label: Home Assistant Version 49 | validations: 50 | required: true 51 | - type: input 52 | id: version 53 | attributes: 54 | description: If you're not running the latest release you should try upgrading first. Any diagnostics or fixes will be based on the latest release. 55 | label: solaredge-modbus-multi Version 56 | validations: 57 | required: true 58 | - type: dropdown 59 | id: installation 60 | attributes: 61 | label: Installation Type 62 | description: Your issue must be duplicated in an HAOS VM. If your issue is related to dependencies in a Supervised or Core installation you will need to do your own debugging. 63 | multiple: false 64 | options: 65 | - HAOS 66 | - Container 67 | - Supervised 68 | - Core 69 | validations: 70 | required: true 71 | - type: checkboxes 72 | id: terms 73 | attributes: 74 | label: Read the Instructions 75 | description: Make sure your configuration is correct, your LAN is working, and you have read about "Known Issues" in the wiki. Do not open an issue to ask how to set up or configure the integration. 76 | options: 77 | - label: My configuration follows https://github.com/WillCodeForCats/solaredge-modbus-multi/wiki/Configuration 78 | required: true 79 | - label: I have read the Known Issues https://github.com/WillCodeForCats/solaredge-modbus-multi/wiki/Known-Issues 80 | required: true 81 | - label: This issue is not about a template error (do not report template errors, read https://github.com/WillCodeForCats/solaredge-modbus-multi/wiki/Template-Design-Notes) 82 | required: true 83 | - type: textarea 84 | id: additional-context 85 | attributes: 86 | label: Additional Context 87 | description: Add any other context about the problem here. 88 | -------------------------------------------------------------------------------- /custom_components/solaredge_modbus_multi/binary_sensor.py: -------------------------------------------------------------------------------- 1 | """Component to interface with binary sensors.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | 7 | from homeassistant.components.binary_sensor import ( 8 | BinarySensorDeviceClass, 9 | BinarySensorEntity, 10 | ) 11 | from homeassistant.config_entries import ConfigEntry 12 | from homeassistant.core import HomeAssistant, callback 13 | from homeassistant.helpers.entity import EntityCategory 14 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 15 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 16 | 17 | from .const import DOMAIN 18 | 19 | _LOGGER = logging.getLogger(__name__) 20 | 21 | 22 | async def async_setup_entry( 23 | hass: HomeAssistant, 24 | config_entry: ConfigEntry, 25 | async_add_entities: AddEntitiesCallback, 26 | ) -> None: 27 | hub = hass.data[DOMAIN][config_entry.entry_id]["hub"] 28 | coordinator = hass.data[DOMAIN][config_entry.entry_id]["coordinator"] 29 | 30 | entities = [] 31 | 32 | for inverter in hub.inverters: 33 | if hub.option_detect_extras and inverter.advanced_power_control: 34 | entities.append(AdvPowerControlEnabled(inverter, config_entry, coordinator)) 35 | 36 | entities.append(GridStatusOnOff(inverter, config_entry, coordinator)) 37 | 38 | if entities: 39 | async_add_entities(entities) 40 | 41 | 42 | class SolarEdgeBinarySensorBase(CoordinatorEntity, BinarySensorEntity): 43 | """Base class for SolarEdge binary sensor entities.""" 44 | 45 | should_poll = False 46 | _attr_has_entity_name = True 47 | 48 | def __init__(self, platform, config_entry, coordinator): 49 | """Pass coordinator to CoordinatorEntity.""" 50 | super().__init__(coordinator) 51 | """Initialize the sensor.""" 52 | self._platform = platform 53 | self._config_entry = config_entry 54 | 55 | @property 56 | def device_info(self): 57 | return self._platform.device_info 58 | 59 | @property 60 | def config_entry_id(self): 61 | return self._config_entry.entry_id 62 | 63 | @property 64 | def config_entry_name(self): 65 | return self._config_entry.data["name"] 66 | 67 | @property 68 | def available(self) -> bool: 69 | return super().available and self._platform.online 70 | 71 | @callback 72 | def _handle_coordinator_update(self) -> None: 73 | self.async_write_ha_state() 74 | 75 | 76 | class AdvPowerControlEnabled(SolarEdgeBinarySensorBase): 77 | """Grid Control boolean status. This is "AdvancedPwrControlEn" in specs.""" 78 | 79 | entity_category = EntityCategory.DIAGNOSTIC 80 | 81 | @property 82 | def available(self) -> bool: 83 | return ( 84 | super().available 85 | and self._platform.advanced_power_control is True 86 | and "AdvPwrCtrlEn" in self._platform.decoded_model.keys() 87 | ) 88 | 89 | @property 90 | def unique_id(self) -> str: 91 | return f"{self._platform.uid_base}_adv_pwr_ctrl_en" 92 | 93 | @property 94 | def name(self) -> str: 95 | return "Advanced Power Control" 96 | 97 | @property 98 | def is_on(self) -> bool: 99 | return self._platform.decoded_model["AdvPwrCtrlEn"] == 0x1 100 | 101 | 102 | class GridStatusOnOff(SolarEdgeBinarySensorBase): 103 | """Grid Status On Off. This is undocumented from discussions.""" 104 | 105 | device_class = BinarySensorDeviceClass.POWER 106 | icon = "mdi:transmission-tower" 107 | 108 | @property 109 | def available(self) -> bool: 110 | return ( 111 | super().available and "I_Grid_Status" in self._platform.decoded_model.keys() 112 | ) 113 | 114 | @property 115 | def unique_id(self) -> str: 116 | return f"{self._platform.uid_base}_grid_status_on_off" 117 | 118 | @property 119 | def name(self) -> str: 120 | return "Grid Status" 121 | 122 | @property 123 | def entity_registry_enabled_default(self) -> bool: 124 | return "I_Grid_Status" in self._platform.decoded_model.keys() 125 | 126 | @property 127 | def is_on(self) -> bool: 128 | return not self._platform.decoded_model["I_Grid_Status"] 129 | -------------------------------------------------------------------------------- /custom_components/solaredge_modbus_multi/helpers.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import ipaddress 4 | import struct 5 | 6 | from homeassistant.exceptions import HomeAssistantError 7 | 8 | from .const import DOMAIN_REGEX 9 | 10 | 11 | def float_to_hex(f: float) -> str: 12 | """Convert a float number to a hex string for display.""" 13 | if not isinstance(f, (float, int)): 14 | raise TypeError(f"Expected float or int, got {type(f).__name__}") 15 | 16 | try: 17 | return hex(struct.unpack(" str: 23 | """Convert a list of 16-bit unsigned integers into a string. Each int is 2 bytes. 24 | 25 | This method exists because pymodbus ModbusClientMixin.convert_from_registers with 26 | data_type=DATATYPE.STRING needs errors="ignore" added to handle SolarEdge strings. 27 | 28 | Ref: https://github.com/pymodbus-dev/pymodbus/blob/ 29 | 7fc8d3e02d9d9011c25c80149eb88318e7f50d0e/pymodbus/client/mixin.py#L719 30 | """ 31 | byte_data = b"".join(i.to_bytes(2, "big") for i in int_list) 32 | return byte_data.decode("utf-8", errors="ignore").replace("\x00", "").rstrip() 33 | 34 | 35 | def update_accum(self, accum_value: int) -> None: 36 | if self.last is None: 37 | self.last = 0 38 | 39 | if not accum_value > 0: 40 | raise ValueError("update_accum must be non-zero value.") 41 | 42 | if accum_value >= self.last: 43 | # doesn't check accumulator rollover, but it would probably take 44 | # several decades to roll over to 0 so we'll worry about it later 45 | self.last = accum_value 46 | return accum_value 47 | else: 48 | raise ValueError("update_accum must be an increasing value.") 49 | 50 | 51 | def host_valid(host): 52 | """Return True if hostname or IP address is valid.""" 53 | try: 54 | if ipaddress.ip_address(host).version == (4 or 6): 55 | return True 56 | 57 | except ValueError: 58 | return DOMAIN_REGEX.match(host) 59 | 60 | 61 | def device_list_from_string(value: str) -> list[int]: 62 | """The function `device_list_from_string` takes a string input and returns a list of 63 | device IDs, where the input can be a single ID or a range of IDs separated by commas 64 | 65 | Parameters 66 | ---------- 67 | value 68 | The `value` parameter is a string that represents a list of device IDs. The 69 | device IDs can be specified as individual IDs or as ranges separated by a hyphen 70 | For example, the string "1,3-5,7" represents the device IDs 1, 3, 4, 5 and 7 71 | 72 | Returns 73 | ------- 74 | The function `device_list_from_string` returns a list of device IDs. 75 | 76 | Credit: https://github.com/thargy/modbus-scanner/blob/main/scan.py 77 | """ 78 | 79 | parts = [p.strip() for p in value.split(",")] 80 | ids = [] 81 | for p in parts: 82 | r = [i.strip() for i in p.split("-")] 83 | if len(r) < 2: 84 | # We have a single id 85 | ids.append(check_device_id(r[0])) 86 | 87 | elif len(r) > 2: 88 | # Invalid range, multiple '-'s 89 | raise HomeAssistantError("invalid_range_format") 90 | 91 | else: 92 | # Looks like a range 93 | start = check_device_id(r[0]) 94 | end = check_device_id(r[1]) 95 | if end < start: 96 | raise HomeAssistantError("invalid_range_lte") 97 | 98 | ids.extend(range(start, end + 1)) 99 | 100 | return sorted(set(ids)) 101 | 102 | 103 | def check_device_id(value: str | int) -> int: 104 | """The `check_device_id` function takes a value and checks if it is a valid device 105 | ID between 1 and 247, raising an error if it is not. 106 | 107 | Parameters 108 | ---------- 109 | value 110 | The value parameter is the input value that is 111 | being checked for validity as a device ID. 112 | 113 | Returns 114 | ------- 115 | the device ID as an integer. 116 | 117 | Credit: https://github.com/thargy/modbus-scanner/blob/main/scan.py 118 | """ 119 | 120 | if len(value) == 0: 121 | raise HomeAssistantError("empty_device_id") 122 | 123 | try: 124 | id = int(value) 125 | 126 | if (id < 1) or id > 247: 127 | raise HomeAssistantError("invalid_device_id") 128 | 129 | except ValueError: 130 | raise HomeAssistantError("invalid_device_id") 131 | 132 | return id 133 | -------------------------------------------------------------------------------- /custom_components/solaredge_modbus_multi/repairs.py: -------------------------------------------------------------------------------- 1 | """Repairs for SolarEdge Modbus Multi Device.""" 2 | 3 | from __future__ import annotations 4 | 5 | import re 6 | from typing import cast 7 | 8 | from homeassistant import data_entry_flow 9 | from homeassistant.components.repairs import RepairsFlow 10 | from homeassistant.config_entries import ConfigEntry 11 | from homeassistant.const import CONF_HOST, CONF_PORT 12 | from homeassistant.core import HomeAssistant 13 | from homeassistant.exceptions import HomeAssistantError 14 | 15 | from .config_flow import generate_config_schema 16 | from .const import DOMAIN, ConfDefaultStr, ConfName 17 | from .helpers import device_list_from_string, host_valid 18 | 19 | 20 | class CheckConfigurationRepairFlow(RepairsFlow): 21 | """Handler for an issue fixing flow.""" 22 | 23 | _entry: ConfigEntry 24 | 25 | def __init__(self, entry: ConfigEntry) -> None: 26 | """Create flow.""" 27 | 28 | self._entry = entry 29 | super().__init__() 30 | 31 | async def async_step_init( 32 | self, user_input: dict[str, str] | None = None 33 | ) -> data_entry_flow.FlowResult: 34 | """Handle the first step of a fix flow.""" 35 | return await self.async_step_confirm() 36 | 37 | async def async_step_confirm( 38 | self, user_input: dict[str, str] | None = None 39 | ) -> data_entry_flow.FlowResult: 40 | """Handle the confirm step of a fix flow.""" 41 | errors = {} 42 | 43 | if user_input is not None: 44 | user_input[CONF_HOST] = user_input[CONF_HOST].lower() 45 | user_input[ConfName.DEVICE_LIST] = re.sub( 46 | r"\s+", "", user_input[ConfName.DEVICE_LIST], flags=re.UNICODE 47 | ) 48 | 49 | try: 50 | inverter_count = len( 51 | device_list_from_string(user_input[ConfName.DEVICE_LIST]) 52 | ) 53 | except HomeAssistantError as e: 54 | errors[ConfName.DEVICE_LIST] = f"{e}" 55 | 56 | else: 57 | if not host_valid(user_input[CONF_HOST]): 58 | errors[CONF_HOST] = "invalid_host" 59 | elif not 1 <= user_input[CONF_PORT] <= 65535: 60 | errors[CONF_PORT] = "invalid_tcp_port" 61 | elif not 1 <= inverter_count <= 32: 62 | errors[ConfName.DEVICE_LIST] = "invalid_inverter_count" 63 | else: 64 | user_input[ConfName.DEVICE_LIST] = device_list_from_string( 65 | user_input[ConfName.DEVICE_LIST] 66 | ) 67 | this_unique_id = f"{user_input[CONF_HOST]}:{user_input[CONF_PORT]}" 68 | existing_entry = ( 69 | self.hass.config_entries.async_entry_for_domain_unique_id( 70 | DOMAIN, this_unique_id 71 | ) 72 | ) 73 | 74 | if ( 75 | existing_entry is not None 76 | and self._entry.unique_id != this_unique_id 77 | ): 78 | errors[CONF_HOST] = "already_configured" 79 | errors[CONF_PORT] = "already_configured" 80 | 81 | else: 82 | self.hass.config_entries.async_update_entry( 83 | self._entry, 84 | unique_id=this_unique_id, 85 | data={**self._entry.data, **user_input}, 86 | ) 87 | 88 | return self.async_create_entry(title="", data={}) 89 | 90 | else: 91 | reconfig_device_list = ",".join( 92 | str(device) 93 | for device in self._entry.data.get( 94 | ConfName.DEVICE_LIST, ConfDefaultStr.DEVICE_LIST 95 | ) 96 | ) 97 | 98 | user_input = { 99 | CONF_HOST: self._entry.data[CONF_HOST], 100 | CONF_PORT: self._entry.data[CONF_PORT], 101 | ConfName.DEVICE_LIST: reconfig_device_list, 102 | } 103 | 104 | return self.async_show_form( 105 | step_id="confirm", 106 | data_schema=generate_config_schema("confirm", user_input), 107 | errors=errors, 108 | ) 109 | 110 | 111 | async def async_create_fix_flow( 112 | hass: HomeAssistant, 113 | issue_id: str, 114 | data: dict[str, str | int | float | None] | None, 115 | ) -> RepairsFlow: 116 | """Create flow.""" 117 | 118 | entry_id = cast(str, data["entry_id"]) 119 | 120 | if (entry := hass.config_entries.async_get_entry(entry_id)) is not None: 121 | if issue_id == "check_configuration": 122 | return CheckConfigurationRepairFlow(entry) 123 | -------------------------------------------------------------------------------- /custom_components/solaredge_modbus_multi/button.py: -------------------------------------------------------------------------------- 1 | """Component to interface with binary sensors.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | 7 | from homeassistant.components.button import ButtonEntity 8 | from homeassistant.config_entries import ConfigEntry 9 | from homeassistant.core import HomeAssistant, callback 10 | from homeassistant.helpers.entity import EntityCategory 11 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 12 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 13 | from pymodbus.client.mixin import ModbusClientMixin 14 | 15 | from .const import DOMAIN 16 | 17 | _LOGGER = logging.getLogger(__name__) 18 | 19 | 20 | async def async_setup_entry( 21 | hass: HomeAssistant, 22 | config_entry: ConfigEntry, 23 | async_add_entities: AddEntitiesCallback, 24 | ) -> None: 25 | hub = hass.data[DOMAIN][config_entry.entry_id]["hub"] 26 | coordinator = hass.data[DOMAIN][config_entry.entry_id]["coordinator"] 27 | 28 | entities = [] 29 | 30 | for inverter in hub.inverters: 31 | entities.append(SolarEdgeRefreshButton(inverter, config_entry, coordinator)) 32 | 33 | """ Power Control Block """ 34 | if hub.option_detect_extras and inverter.advanced_power_control: 35 | entities.append( 36 | SolarEdgeCommitControlSettings(inverter, config_entry, coordinator) 37 | ) 38 | entities.append( 39 | SolarEdgeDefaultControlSettings(inverter, config_entry, coordinator) 40 | ) 41 | 42 | if entities: 43 | async_add_entities(entities) 44 | 45 | 46 | class SolarEdgeButtonBase(CoordinatorEntity, ButtonEntity): 47 | """Base class for SolarEdge button entities.""" 48 | 49 | _attr_has_entity_name = True 50 | 51 | def __init__(self, platform, config_entry, coordinator): 52 | """Pass coordinator to CoordinatorEntity.""" 53 | super().__init__(coordinator) 54 | """Initialize the sensor.""" 55 | self._platform = platform 56 | self._config_entry = config_entry 57 | 58 | @property 59 | def device_info(self): 60 | return self._platform.device_info 61 | 62 | @property 63 | def config_entry_id(self): 64 | return self._config_entry.entry_id 65 | 66 | @property 67 | def config_entry_name(self): 68 | return self._config_entry.data["name"] 69 | 70 | @property 71 | def available(self) -> bool: 72 | return super().available and self._platform.online 73 | 74 | @callback 75 | def _handle_coordinator_update(self) -> None: 76 | self.async_write_ha_state() 77 | 78 | 79 | class SolarEdgeRefreshButton(SolarEdgeButtonBase): 80 | """Button to request an immediate device data update.""" 81 | 82 | entity_category = EntityCategory.CONFIG 83 | icon = "mdi:refresh" 84 | 85 | @property 86 | def unique_id(self) -> str: 87 | return f"{self._platform.uid_base}_refresh" 88 | 89 | @property 90 | def name(self) -> str: 91 | return "Refresh" 92 | 93 | @property 94 | def available(self) -> bool: 95 | return True 96 | 97 | async def async_press(self) -> None: 98 | await self.async_update() 99 | 100 | 101 | class SolarEdgeCommitControlSettings(SolarEdgeButtonBase): 102 | """Button to Commit Power Control Settings.""" 103 | 104 | entity_category = EntityCategory.CONFIG 105 | icon = "mdi:content-save-cog-outline" 106 | 107 | @property 108 | def unique_id(self) -> str: 109 | return f"{self._platform.uid_base}bt_commit_pwr_settings" 110 | 111 | @property 112 | def name(self) -> str: 113 | return "Commit Power Settings" 114 | 115 | async def async_press(self) -> None: 116 | _LOGGER.debug(f"set {self.unique_id} to 1") 117 | 118 | await self._platform.write_registers( 119 | address=61696, 120 | payload=ModbusClientMixin.convert_to_registers( 121 | 1, data_type=ModbusClientMixin.DATATYPE.UINT16, word_order="little" 122 | ), 123 | ) 124 | await self.async_update() 125 | 126 | 127 | class SolarEdgeDefaultControlSettings(SolarEdgeButtonBase): 128 | """Button to Restore Power Control Default Settings.""" 129 | 130 | entity_category = EntityCategory.CONFIG 131 | icon = "mdi:restore-alert" 132 | 133 | @property 134 | def unique_id(self) -> str: 135 | return f"{self._platform.uid_base}bt_default_pwr_settings" 136 | 137 | @property 138 | def name(self) -> str: 139 | return "Default Power Settings" 140 | 141 | @property 142 | def entity_registry_enabled_default(self) -> bool: 143 | return False 144 | 145 | async def async_press(self) -> None: 146 | _LOGGER.debug(f"set {self.unique_id} to 1") 147 | 148 | await self._platform.write_registers( 149 | address=61697, 150 | payload=ModbusClientMixin.convert_to_registers( 151 | 1, data_type=ModbusClientMixin.DATATYPE.UINT16, word_order="little" 152 | ), 153 | ) 154 | await self.async_update() 155 | -------------------------------------------------------------------------------- /custom_components/solaredge_modbus_multi/translations/nb.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "title": "SolarEdge Modbus-konfigurasjon", 6 | "data": { 7 | "name": "Sensorvoorvoegsel", 8 | "host": "IP-adres van omvormer", 9 | "port": "Modbus/TCP-poort", 10 | "device_list": "Inverter-enhetsliste" 11 | } 12 | }, 13 | "reconfigure": { 14 | "title": "SolarEdge Modbus-konfigurasjon", 15 | "data": { 16 | "host": "IP-adres van omvormer", 17 | "port": "Modbus/TCP-poort", 18 | "device_list": "Inverter-enhetsliste" 19 | } 20 | } 21 | }, 22 | "error": { 23 | "already_configured": "Enheten er allerede konfigurert", 24 | "invalid_device_id": "Enhets-ID må være mellom 1 og 247.", 25 | "invalid_inverter_count": "Må være mellom 1 og 32 omformere.", 26 | "invalid_host": "Ugyldig IP-adresse.", 27 | "invalid_tcp_port": "Gyldig portområde er 1 til 65535.", 28 | "invalid_range_format": "Oppføring ser ut som et område, men bare én '-' per område er tillatt.", 29 | "invalid_range_lte": "Start-ID i et område må være mindre enn eller lik slutt-ID.", 30 | "empty_device_id": "ID-listen inneholder en tom eller udefinert verdi." 31 | }, 32 | "abort": { 33 | "already_configured": "Vert og port er allerede konfigurert i en annen hub.", 34 | "reconfigure_successful": "Omkonfigureringen var vellykket" 35 | } 36 | }, 37 | "options": { 38 | "step": { 39 | "init": { 40 | "title": "SolarEdge Modbus-alternativer", 41 | "data": { 42 | "scan_interval": "Avstemningsfrekvens (sekunder)", 43 | "keep_modbus_open": "Hold Modbus-tilkoblingen åpen", 44 | "detect_meters": "Automatisk oppdagelse av målere", 45 | "detect_batteries": "Automatisk gjenkjenning av batterier", 46 | "detect_extras": "Automatisk oppdage flere enheter", 47 | "advanced_power_control": "Strømkontrollalternativer", 48 | "sleep_after_write": "Inverter Command Delay (sekunder)" 49 | } 50 | }, 51 | "adv_pwr_ctl": { 52 | "title": "Strømkontrollalternativer", 53 | "data": { 54 | "adv_storage_control": "Aktiver lagringskontroll", 55 | "adv_site_limit_control": "Aktiver Site Limit Control" 56 | }, 57 | "description": "Advarsel: Disse alternativene kan bryte forsyningsavtaler, endre forbruksfaktureringen, kan kreve spesialutstyr og overskrive klargjøring av SolarEdge eller installatøren. Bruk på eget ansvar! Justerbare parametere i Modbus-registre er beregnet for langtidslagring. Periodiske endringer kan skade flashminnet." 58 | }, 59 | "battery_options": { 60 | "title": "Batterialternativer", 61 | "data": { 62 | "allow_battery_energy_reset": "La batterienergien tilbakestilles", 63 | "battery_energy_reset_cycles": "Oppdater sykluser for å tilbakestille batterienergi", 64 | "battery_rating_adjust": "Justering av batterikapasitet (prosent)" 65 | } 66 | } 67 | }, 68 | "error": { 69 | "invalid_scan_interval": "Gyldig intervall er 1 til 86400 sekunder.", 70 | "invalid_sleep_interval": "Gyldig intervall er 0 til 60 sekunder.", 71 | "invalid_percent": "Gyldig område er 0 til 100 prosent." 72 | } 73 | }, 74 | "issues": { 75 | "check_configuration": { 76 | "title": "Sjekk Modbus-konfigurasjon", 77 | "fix_flow": { 78 | "step": { 79 | "confirm": { 80 | "title": "Sjekk Modbus-konfigurasjon", 81 | "description": "Det oppstod en feil under forsøk på å åpne en Modbus/TCP-tilkobling.\n\nBekreft konfigurasjonen.", 82 | "data": { 83 | "host": "IP-adres van omvormer", 84 | "port": "Modbus/TCP-poort", 85 | "device_id": "Inverter Modbus-adresse (enhets-ID)", 86 | "number_of_inverters": "Antall omformere koblet sammen" 87 | } 88 | } 89 | }, 90 | "error": { 91 | "invalid_device_id": "Enhets-ID må være mellom 1 og 247.", 92 | "invalid_inverter_count": "Må være mellom 1 og 32 omformere.", 93 | "invalid_host": "Ugyldig IP-adresse.", 94 | "invalid_tcp_port": "Gyldig portområde er 1 til 65535.", 95 | "invalid_range_format": "Oppføring ser ut som et område, men bare én '-' per område er tillatt.", 96 | "invalid_range_lte": "Start-ID i et område må være mindre enn eller lik slutt-ID.", 97 | "empty_device_id": "ID-listen inneholder en tom eller udefinert verdi.", 98 | "already_configured": "Vert og port er allerede konfigurert i en annen hub." 99 | } 100 | } 101 | }, 102 | "detect_timeout_gpc": { 103 | "title": "Global dynamisk kraftkontroll timeout", 104 | "description": "Omformeren svarte ikke mens du leste data for global dynamisk kraftkontroll." 105 | }, 106 | "detect_timeout_apc": { 107 | "title": "Avansert timeout for strømkontroll", 108 | "description": "Omformeren svarte ikke mens du leste data for avanserte strømkontroller." 109 | } 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /custom_components/solaredge_modbus_multi/strings.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "title": "SolarEdge Modbus Configuration", 6 | "data": { 7 | "name": "Sensor Prefix", 8 | "host": "Inverter IP Address", 9 | "port": "Modbus/TCP Port", 10 | "device_list": "Inverter Device ID List" 11 | } 12 | }, 13 | "reconfigure": { 14 | "title": "SolarEdge Modbus Configuration", 15 | "data": { 16 | "host": "Inverter IP Address", 17 | "port": "Modbus/TCP Port", 18 | "device_list": "Inverter Device ID List" 19 | } 20 | } 21 | }, 22 | "error": { 23 | "already_configured": "Device is already configured!", 24 | "invalid_device_id": "Device ID must be between 1 to 247.", 25 | "invalid_inverter_count": "Must be between 1 to 32 inverters.", 26 | "invalid_host": "Invalid IP address.", 27 | "invalid_tcp_port": "Valid port range is 1 to 65535.", 28 | "invalid_range_format": "Entry looks like a range but only one '-' per range is allowed.", 29 | "invalid_range_lte": "Starting ID in a range must be less than or equal to the end ID.", 30 | "empty_device_id": "The ID list contains an empty or undefined value." 31 | }, 32 | "abort": { 33 | "already_configured": "Host and port is already configured in another hub.", 34 | "reconfigure_successful": "Re-configuration was successful" 35 | } 36 | }, 37 | "options": { 38 | "step": { 39 | "init": { 40 | "title": "SolarEdge Modbus Options", 41 | "data": { 42 | "scan_interval": "Polling Frequency (seconds)", 43 | "keep_modbus_open": "Keep Modbus Connection Open", 44 | "detect_meters": "Auto-Detect Meters", 45 | "detect_batteries": "Auto-Detect Batteries", 46 | "detect_extras": "Auto-Detect Additional Entities", 47 | "advanced_power_control": "Power Control Options", 48 | "sleep_after_write": "Inverter Command Delay (seconds)" 49 | } 50 | }, 51 | "adv_pwr_ctl": { 52 | "title": "Power Control Options", 53 | "data": { 54 | "adv_storage_control": "Enable Storage Control", 55 | "adv_site_limit_control": "Enable Site Limit Control" 56 | }, 57 | "description": "Warning: These options can violate utility agreements, alter your utility billing, may require special equipment, and overwrite provisioning by SolarEdge or your installer. Use at your own risk! Adjustable parameters in Modbus registers are intended for long-term storage. Periodic changes may damage the flash memory." 58 | }, 59 | "battery_options": { 60 | "title": "Battery Options", 61 | "data": { 62 | "allow_battery_energy_reset": "Allow Battery Energy to Reset", 63 | "battery_energy_reset_cycles": "Update Cycles to Reset Battery Energy", 64 | "battery_rating_adjust": "Battery Rating Adjustment (percent)" 65 | } 66 | } 67 | }, 68 | "error": { 69 | "invalid_scan_interval": "Valid interval is 1 to 86400 seconds.", 70 | "invalid_sleep_interval": "Valid interval is 0 to 60 seconds.", 71 | "invalid_percent": "Valid range is 0 to 100 percent." 72 | } 73 | }, 74 | "issues": { 75 | "check_configuration": { 76 | "title": "Check Modbus Configuration", 77 | "fix_flow": { 78 | "step": { 79 | "confirm": { 80 | "title": "Check Modbus Configuration", 81 | "description": "An error occurred while trying to open a Modbus/TCP connection.\n\nPlease confirm your configuration.", 82 | "data": { 83 | "host": "Inverter IP Address", 84 | "port": "Modbus/TCP Port", 85 | "device_list": "Inverter Device ID List" 86 | } 87 | } 88 | }, 89 | "error": { 90 | "invalid_device_id": "Device ID must be between 1 to 247.", 91 | "invalid_inverter_count": "Must be between 1 to 32 inverters.", 92 | "invalid_host": "Invalid IP address.", 93 | "invalid_tcp_port": "Valid port range is 1 to 65535.", 94 | "invalid_range_format": "Entry looks like a range but only one '-' per range is allowed.", 95 | "invalid_range_lte": "Starting ID in a range must be less than or equal to the end ID.", 96 | "empty_device_id": "The ID list contains an empty or undefined value.", 97 | "already_configured": "Host and port is already configured in another hub." 98 | } 99 | } 100 | }, 101 | "detect_timeout_gpc": { 102 | "title": "Global Dynamic Power Control Timeout", 103 | "description": "The inverter did not respond while reading data for Global Dynamic Power Controls. These entities will be unavailable. Disable the Auto-Detect Additional Entities option if the inverter has trouble trying to read these sensors." 104 | }, 105 | "detect_timeout_apc": { 106 | "title": "Advanced Power Control Timeout", 107 | "description": "The inverter did not respond while reading data for Advanced Power Controls. These entities will be unavailable. Disable the Auto-Detect Additional Entities option if the inverter has trouble trying to read these sensors." 108 | } 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /custom_components/solaredge_modbus_multi/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "title": "SolarEdge Modbus Configuration", 6 | "data": { 7 | "name": "Sensor Prefix", 8 | "host": "Inverter IP Address", 9 | "port": "Modbus/TCP Port", 10 | "device_list": "Inverter Device ID List" 11 | } 12 | }, 13 | "reconfigure": { 14 | "title": "SolarEdge Modbus Configuration", 15 | "data": { 16 | "host": "Inverter IP Address", 17 | "port": "Modbus/TCP Port", 18 | "device_list": "Inverter Device ID List" 19 | } 20 | } 21 | }, 22 | "error": { 23 | "already_configured": "Device is already configured!", 24 | "invalid_device_id": "Device ID must be between 1 to 247.", 25 | "invalid_inverter_count": "Must be between 1 to 32 inverters.", 26 | "invalid_host": "Invalid IP address.", 27 | "invalid_tcp_port": "Valid port range is 1 to 65535.", 28 | "invalid_range_format": "Entry looks like a range but only one '-' per range is allowed.", 29 | "invalid_range_lte": "Starting ID in a range must be less than or equal to the end ID.", 30 | "empty_device_id": "The ID list contains an empty or undefined value." 31 | }, 32 | "abort": { 33 | "already_configured": "Host and port is already configured in another hub.", 34 | "reconfigure_successful": "Re-configuration was successful" 35 | } 36 | }, 37 | "options": { 38 | "step": { 39 | "init": { 40 | "title": "SolarEdge Modbus Options", 41 | "data": { 42 | "scan_interval": "Polling Frequency (seconds)", 43 | "keep_modbus_open": "Keep Modbus Connection Open", 44 | "detect_meters": "Auto-Detect Meters", 45 | "detect_batteries": "Auto-Detect Batteries", 46 | "detect_extras": "Auto-Detect Additional Entities", 47 | "advanced_power_control": "Power Control Options", 48 | "sleep_after_write": "Inverter Command Delay (seconds)" 49 | } 50 | }, 51 | "adv_pwr_ctl": { 52 | "title": "Power Control Options", 53 | "data": { 54 | "adv_storage_control": "Enable Storage Control", 55 | "adv_site_limit_control": "Enable Site Limit Control" 56 | }, 57 | "description": "Warning: These options can violate utility agreements, alter your utility billing, may require special equipment, and overwrite provisioning by SolarEdge or your installer. Use at your own risk! Adjustable parameters in Modbus registers are intended for long-term storage. Periodic changes may damage the flash memory." 58 | }, 59 | "battery_options": { 60 | "title": "Battery Options", 61 | "data": { 62 | "allow_battery_energy_reset": "Allow Battery Energy to Reset", 63 | "battery_energy_reset_cycles": "Update Cycles to Reset Battery Energy", 64 | "battery_rating_adjust": "Battery Rating Adjustment (percent)" 65 | } 66 | } 67 | }, 68 | "error": { 69 | "invalid_scan_interval": "Valid interval is 1 to 86400 seconds.", 70 | "invalid_sleep_interval": "Valid interval is 0 to 60 seconds.", 71 | "invalid_percent": "Valid range is 0 to 100 percent." 72 | } 73 | }, 74 | "issues": { 75 | "check_configuration": { 76 | "title": "Check Modbus Configuration", 77 | "fix_flow": { 78 | "step": { 79 | "confirm": { 80 | "title": "Check Modbus Configuration", 81 | "description": "An error occurred while trying to open a Modbus/TCP connection.\n\nPlease confirm your configuration.", 82 | "data": { 83 | "host": "Inverter IP Address", 84 | "port": "Modbus/TCP Port", 85 | "device_list": "Inverter Device ID List" 86 | } 87 | } 88 | }, 89 | "error": { 90 | "invalid_device_id": "Device ID must be between 1 to 247.", 91 | "invalid_inverter_count": "Must be between 1 to 32 inverters.", 92 | "invalid_host": "Invalid IP address.", 93 | "invalid_tcp_port": "Valid port range is 1 to 65535.", 94 | "invalid_range_format": "Entry looks like a range but only one '-' per range is allowed.", 95 | "invalid_range_lte": "Starting ID in a range must be less than or equal to the end ID.", 96 | "empty_device_id": "The ID list contains an empty or undefined value.", 97 | "already_configured": "Host and port is already configured in another hub." 98 | } 99 | } 100 | }, 101 | "detect_timeout_gpc": { 102 | "title": "Global Dynamic Power Control Timeout", 103 | "description": "The inverter did not respond while reading data for Global Dynamic Power Controls. These entities will be unavailable. Disable the Auto-Detect Additional Entities option if the inverter has trouble trying to read these sensors." 104 | }, 105 | "detect_timeout_apc": { 106 | "title": "Advanced Power Control Timeout", 107 | "description": "The inverter did not respond while reading data for Advanced Power Controls. These entities will be unavailable. Disable the Auto-Detect Additional Entities option if the inverter has trouble trying to read these sensors." 108 | } 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /custom_components/solaredge_modbus_multi/translations/nl.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "title": "SolarEdge Modbus-configuratie", 6 | "data": { 7 | "name": "Sensor prefix", 8 | "host": "omvormer IP-adres", 9 | "port": "Modbus/TCP Port", 10 | "device_list": "Omvormerapparaat-ID-lijst" 11 | } 12 | }, 13 | "reconfigure": { 14 | "title": "SolarEdge Modbus-configuratie", 15 | "data": { 16 | "host": "omvormer IP-adres", 17 | "port": "Modbus/TCP Port", 18 | "device_list": "Omvormerapparaat-ID-lijst" 19 | } 20 | } 21 | }, 22 | "error": { 23 | "already_configured": "Apparaat is al geconfigureerd", 24 | "invalid_device_id": "Apparaat-ID moet tussen 1 en 247 liggen.", 25 | "invalid_inverter_count": "Moet tussen 1 en 32 omvormers zijn.", 26 | "invalid_host": "Ongeldig IP-adres.", 27 | "invalid_tcp_port": "Geldig poortbereik is 1 tot 65535.", 28 | "invalid_range_format": "Invoer ziet eruit als een bereik, maar er is slechts één '-' per bereik toegestaan.", 29 | "invalid_range_lte": "De start-ID in een bereik moet kleiner zijn dan of gelijk zijn aan de eind-ID.", 30 | "empty_device_id": "De ID-lijst bevat een lege of ongedefinieerde waarde." 31 | }, 32 | "abort": { 33 | "already_configured": "Host en poort zijn al geconfigureerd in een andere hub.", 34 | "reconfigure_successful": "De herconfiguratie was succesvol" 35 | } 36 | }, 37 | "options": { 38 | "step": { 39 | "init": { 40 | "title": "SolarEdge Modbus Instellingen", 41 | "data": { 42 | "scan_interval": "Oproepfrequentie (seconden)", 43 | "keep_modbus_open": "Houd Modbus-verbinding open", 44 | "detect_meters": "Meters automatisch detecteren", 45 | "detect_batteries": "Batterijen automatisch detecteren", 46 | "detect_extras": "Automatische detectie van aanvullende entiteiten", 47 | "advanced_power_control": "Opties voor vermogensregeling", 48 | "sleep_after_write": "Omvormer commando vertraging (seconden)" 49 | } 50 | }, 51 | "adv_pwr_ctl": { 52 | "title": "Opties voor stroomregeling", 53 | "data": { 54 | "adv_storage_control": "Opslagbeheer inschakelen", 55 | "adv_site_limit_control": "Beheer van sitelimiet inschakelen" 56 | }, 57 | "description": "Waarschuwing: deze opties kunnen in strijd zijn met nutsvoorzieningen, de facturering van uw nutsbedrijf wijzigen, mogelijk speciale apparatuur vereisen en de voorzieningen door SolarEdge of uw installateur overschrijven. Gebruik op eigen risico! Instelbare parameters in Modbus-registers zijn bedoeld voor langdurige opslag. Periodieke wijzigingen kunnen het flashgeheugen beschadigen." 58 | }, 59 | "battery_options": { 60 | "title": "Batterij opties", 61 | "data": { 62 | "allow_battery_energy_reset": "Batterij-energie laten resetten", 63 | "battery_energy_reset_cycles": "Update cycli om de batterij-energie te resetten", 64 | "battery_rating_adjust": "Aanpassing batterijvermogen (%)" 65 | } 66 | } 67 | }, 68 | "error": { 69 | "invalid_scan_interval": "Geldig interval is 1 tot 86400 seconden.", 70 | "invalid_sleep_interval": "Geldig interval is 0 tot 60 seconden.", 71 | "invalid_percent": "Het geldige bereik is 0 tot 100 procent." 72 | } 73 | }, 74 | "issues": { 75 | "check_configuration": { 76 | "title": "Controleer de Modbus-configuratie", 77 | "fix_flow": { 78 | "step": { 79 | "confirm": { 80 | "title": "Controleer de Modbus-configuratie", 81 | "description": "Er is een fout opgetreden bij het openen van een Modbus/TCP-verbinding.\n\nBevestig uw configuratie.", 82 | "data": { 83 | "host": "omvormer IP-adres", 84 | "port": "Modbus/TCP Port", 85 | "device_id": "Omvormer Modbus-adres (apparaat-ID)", 86 | "number_of_inverters": "Aantal aangesloten omvormers" 87 | } 88 | } 89 | }, 90 | "error": { 91 | "invalid_device_id": "Apparaat-ID moet tussen 1 en 247 liggen.", 92 | "invalid_inverter_count": "Moet tussen 1 en 32 omvormers zijn.", 93 | "invalid_host": "Ongeldig IP-adres.", 94 | "invalid_tcp_port": "Geldig poortbereik is 1 tot 65535.", 95 | "invalid_range_format": "Invoer ziet eruit als een bereik, maar er is slechts één '-' per bereik toegestaan.", 96 | "invalid_range_lte": "De start-ID in een bereik moet kleiner zijn dan of gelijk zijn aan de eind-ID.", 97 | "empty_device_id": "De ID-lijst bevat een lege of ongedefinieerde waarde.", 98 | "already_configured": "Host en poort zijn al geconfigureerd in een andere hub." 99 | } 100 | } 101 | }, 102 | "detect_timeout_gpc": { 103 | "title": "Global Dynamic Power Control Time -out", 104 | "description": "De omvormer reageerde niet tijdens het lezen van gegevens voor Global Dynamic Power Controls." 105 | }, 106 | "detect_timeout_apc": { 107 | "title": "Geavanceerde time -out voor stroomregeling", 108 | "description": "De omvormer reageerde niet tijdens het lezen van gegevens voor geavanceerde stroomregeling." 109 | } 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /custom_components/solaredge_modbus_multi/translations/pl.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "title": "Konfiguracja SolarEdge Modbus", 6 | "data": { 7 | "name": "Prefix sensora", 8 | "host": "Adres IP inwertera", 9 | "port": "Modbus/TCP Port", 10 | "device_list": "Lista identyfikatorów urządzeń falownika" 11 | } 12 | }, 13 | "reconfigure": { 14 | "title": "Konfiguracja SolarEdge Modbus", 15 | "data": { 16 | "host": "Adres IP inwertera", 17 | "port": "Modbus/TCP Port", 18 | "device_list": "Lista identyfikatorów urządzeń falownika" 19 | } 20 | } 21 | }, 22 | "error": { 23 | "already_configured": "Urządzenie jest już skonfigurowane!", 24 | "invalid_device_id": "Device ID musi być pomiędzy 1 i 247.", 25 | "invalid_inverter_count": "Dopuszczalna liczba inwerterów to od 1 do 32.", 26 | "invalid_host": "Błędny adres IP.", 27 | "invalid_tcp_port": "Dozwolony zakres portów to od 1 do 65535.", 28 | "invalid_range_format": "Wpis wygląda jak zakres, ale dozwolony jest tylko jeden znak „-” na zakres.", 29 | "invalid_range_lte": "Początkowy identyfikator w zakresie musi być mniejszy lub równy identyfikatorowi końcowemu.", 30 | "empty_device_id": "Lista identyfikatorów zawiera pustą lub niezdefiniowaną wartość." 31 | }, 32 | "abort": { 33 | "already_configured": "Host i port są już skonfigurowane w innym koncentratorze.", 34 | "reconfigure_successful": "Ponowna konfiguracja przebiegła pomyślnie" 35 | } 36 | }, 37 | "options": { 38 | "step": { 39 | "init": { 40 | "title": "Opcje SolarEdge Modbus", 41 | "data": { 42 | "scan_interval": "Częstotliwość odczytu (sekundy)", 43 | "keep_modbus_open": "Pozostaw połączenie Modbus otwarte", 44 | "detect_meters": "Automatycznie wykryj liczniki", 45 | "detect_batteries": "Automatycznie wykryj baterie", 46 | "detect_extras": "Automatycznie wykrywaj dodatkowe elementy", 47 | "advanced_power_control": "Opcje kontroli mocy", 48 | "sleep_after_write": "Opóźnienie polecenia falownika (sekundy)" 49 | } 50 | }, 51 | "adv_pwr_ctl": { 52 | "title": "Opcje sterowania zasilaniem", 53 | "data": { 54 | "adv_storage_control": "Włącz kontrolę pamięci", 55 | "adv_site_limit_control": "Włącz kontrolę limitu witryny" 56 | }, 57 | "description": "Ostrzeżenie: opcje te mogą naruszać umowy za media, zmieniać rozliczenia za media, mogą wymagać specjalnego sprzętu i nadpisać udostępnianie przez SolarEdge lub instalatora. Używaj na własne ryzyko! Parametry regulowane w rejestrach Modbus przeznaczone są do długotrwałego przechowywania. Okresowe zmiany mogą uszkodzić pamięć flash." 58 | }, 59 | "battery_options": { 60 | "title": "Opcje baterii", 61 | "data": { 62 | "allow_battery_energy_reset": "Zezwól na zresetowanie energii baterii", 63 | "battery_energy_reset_cycles": "Zaktualizuj cykle, aby zresetować energię baterii", 64 | "battery_rating_adjust": "Regulacja oceny baterii (w procentach)" 65 | } 66 | } 67 | }, 68 | "error": { 69 | "invalid_scan_interval": "Próbkowanie musi być w zakresie od 1 do 86400 sekund.", 70 | "invalid_sleep_interval": "Próbkowanie musi być w zakresie od 0 do 60 sekund.", 71 | "invalid_percent": "Prawidłowy zakres wynosi od 0 do 100 procent." 72 | } 73 | }, 74 | "issues": { 75 | "check_configuration": { 76 | "title": "Sprawdź konfigurację Modbus", 77 | "fix_flow": { 78 | "step": { 79 | "confirm": { 80 | "title": "Sprawdź konfigurację Modbus", 81 | "description": "Wystąpił błąd podczas próby otwarcia połączenia Modbus/TCP.\n\nPotwierdź konfigurację.", 82 | "data": { 83 | "host": "Adres IP inwertera", 84 | "port": "Modbus/TCP Port", 85 | "device_id": "Adres Modbus Inwertera (Device ID)", 86 | "number_of_inverters": "Ilość inwerterów" 87 | } 88 | } 89 | }, 90 | "error": { 91 | "invalid_device_id": "Device ID musi być pomiędzy 1 i 247.", 92 | "invalid_inverter_count": "Dopuszczalna liczba inwerterów to od 1 do 32.", 93 | "invalid_host": "Błędny adres IP.", 94 | "invalid_tcp_port": "Dozwolony zakres portów to od 1 do 65535.", 95 | "invalid_range_format": "Wpis wygląda jak zakres, ale dozwolony jest tylko jeden znak „-” na zakres.", 96 | "invalid_range_lte": "Początkowy identyfikator w zakresie musi być mniejszy lub równy identyfikatorowi końcowemu.", 97 | "empty_device_id": "Lista identyfikatorów zawiera pustą lub niezdefiniowaną wartość.", 98 | "already_configured": "Host i port są już skonfigurowane w innym koncentratorze." 99 | } 100 | } 101 | }, 102 | "detect_timeout_gpc": { 103 | "title": "Globalny limit dynamicznej kontroli mocy", 104 | "description": "Falownik nie zareagował podczas czytania danych dla globalnej dynamicznej kontroli mocy." 105 | }, 106 | "detect_timeout_apc": { 107 | "title": "Zaawansowany limit czasu kontroli mocy", 108 | "description": "Falownik nie zareagował podczas czytania danych pod kątem zaawansowanych kontroli mocy." 109 | } 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /custom_components/solaredge_modbus_multi/translations/de.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "title": "SolarEdge Modbus-Konfiguration", 6 | "data": { 7 | "name": "Sensorpräfix", 8 | "host": "Wechselrichter-IP-Adresse", 9 | "port": "Modbus/TCP-Port", 10 | "device_list": "Liste der Wechselrichter-Geräte-IDs" 11 | } 12 | }, 13 | "reconfigure": { 14 | "title": "SolarEdge Modbus-Konfiguration", 15 | "data": { 16 | "host": "Wechselrichter-IP-Adresse", 17 | "port": "Modbus/TCP-Port", 18 | "device_list": "Liste der Wechselrichter-Geräte-IDs" 19 | } 20 | } 21 | }, 22 | "error": { 23 | "already_configured": "Der Wechselrichter ist bereits konfiguriert.", 24 | "invalid_device_id": "Die Geräte-ID muss zwischen 1 und 247 liegen.", 25 | "invalid_inverter_count": "Muss zwischen 1 und 32 Wechselrichtern liegen.", 26 | "invalid_host": "Ungültige IP-Adresse.", 27 | "invalid_tcp_port": "Der gültige Portbereich ist 1 bis 65535.", 28 | "invalid_range_format": "Der Eintrag sieht aus wie ein Bereich, es ist jedoch nur ein „-“ pro Bereich zulässig.", 29 | "invalid_range_lte": "Die Start-ID in einem Bereich muss kleiner oder gleich der End-ID sein.", 30 | "empty_device_id": "Die ID-Liste enthält einen leeren oder undefinierten Wert." 31 | }, 32 | "abort": { 33 | "already_configured": "Host und Port sind bereits in einem anderen Hub konfiguriert.", 34 | "reconfigure_successful": "Die Neukonfiguration war erfolgreich" 35 | } 36 | }, 37 | "options": { 38 | "step": { 39 | "init": { 40 | "title": "SolarEdge Modbus Optionen", 41 | "data": { 42 | "scan_interval": "Abfragehäufigkeit (Sekunden)", 43 | "keep_modbus_open": "Modbus-Verbindung geöffnet lassen", 44 | "detect_meters": "Messgeräte automatisch erkennen", 45 | "detect_batteries": "Batterien automatisch erkennen", 46 | "detect_extras": "Zusätzliche Entitäten automatisch erkennen", 47 | "advanced_power_control": "Optionen zur Leistungssteuerung", 48 | "sleep_after_write": "Befehlsverzögerung des Wechselrichters (Sekunden)" 49 | } 50 | }, 51 | "adv_pwr_ctl": { 52 | "title": "Energiesteuerungsoptionen", 53 | "data": { 54 | "adv_storage_control": "Speichersteuerung aktivieren", 55 | "adv_site_limit_control": "Site-Limit-Kontrolle aktivieren" 56 | }, 57 | "description": "Warnung: Diese Optionen können gegen Stromverträge verstoßen, Ihre Stromabrechnung ändern, möglicherweise spezielle Geräte erfordern und die Bereitstellung durch SolarEdge oder Ihren Installateur überschreiben. Benutzung auf eigene Gefahr! Einstellbare Parameter in Modbus-Registern sind für die Langzeitspeicherung vorgesehen. Regelmäßige Änderungen können den Flash-Speicher beschädigen." 58 | }, 59 | "battery_options": { 60 | "title": "Batterieoptionen", 61 | "data": { 62 | "allow_battery_energy_reset": "Batterieenergie zurücksetzen lassen", 63 | "battery_energy_reset_cycles": "Aktualisierungszyklen zum Zurücksetzen der Batterieenergie", 64 | "battery_rating_adjust": "Anpassung der Batterieleistung (Prozent)" 65 | } 66 | } 67 | }, 68 | "error": { 69 | "invalid_scan_interval": "Gültiges Intervall ist 1 bis 86400 Sekunden.", 70 | "invalid_sleep_interval": "Gültiges Intervall ist 0 bis 60 Sekunden.", 71 | "invalid_percent": "Gültiges Bereich ist 0 bis 100 Prozent." 72 | } 73 | }, 74 | "issues": { 75 | "check_configuration": { 76 | "title": "Überprüfen Sie die Modbus-Konfiguration", 77 | "fix_flow": { 78 | "step": { 79 | "confirm": { 80 | "title": "Überprüfen Sie die Modbus-Konfiguration", 81 | "description": "Beim Versuch, eine Modbus/TCP-Verbindung zu öffnen, ist ein Fehler aufgetreten.\n\nBitte bestätigen Sie Ihre Konfiguration.", 82 | "data": { 83 | "host": "Wechselrichter-IP-Adresse", 84 | "port": "Modbus/TCP-Port", 85 | "device_id": "Wechselrichter-Modbus-Adresse (Geräte-ID)", 86 | "number_of_inverters": "Anzahl Wechselrichter" 87 | } 88 | } 89 | }, 90 | "error": { 91 | "invalid_device_id": "Die Geräte-ID muss zwischen 1 und 247 liegen.", 92 | "invalid_inverter_count": "Muss zwischen 1 und 32 Wechselrichtern liegen.", 93 | "invalid_host": "Ungültige IP-Adresse.", 94 | "invalid_tcp_port": "Der gültige Portbereich ist 1 bis 65535.", 95 | "invalid_range_format": "Der Eintrag sieht aus wie ein Bereich, es ist jedoch nur ein „-“ pro Bereich zulässig.", 96 | "invalid_range_lte": "Die Start-ID in einem Bereich muss kleiner oder gleich der End-ID sein.", 97 | "empty_device_id": "Die ID-Liste enthält einen leeren oder undefinierten Wert.", 98 | "already_configured": "Host und Port sind bereits in einem anderen Hub konfiguriert." 99 | } 100 | } 101 | }, 102 | "detect_timeout_gpc": { 103 | "title": "Global Dynamic Power Control Timeout", 104 | "description": "Der Wechselrichter reagierte nicht beim Lesen von Daten für globale dynamische Leistungssteuerung." 105 | }, 106 | "detect_timeout_apc": { 107 | "title": "Fortgeschrittene Leistungssteuerung Zeitlimit", 108 | "description": "Der Wechselrichter reagierte nicht beim Lesen von Daten für fortgeschrittene Stromversorgungssteuerungen." 109 | } 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /custom_components/solaredge_modbus_multi/translations/fr.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "title": "Configuration SolarEdge Modbus", 6 | "data": { 7 | "name": "Prefix du capteur", 8 | "host": "Adresse IP de l'onduleur", 9 | "port": "Port Modbus/TCP", 10 | "device_list": "Liste des ID des appareils de l'onduleur" 11 | } 12 | }, 13 | "reconfigure": { 14 | "title": "Configuration SolarEdge Modbus", 15 | "data": { 16 | "host": "Adresse IP de l'onduleur", 17 | "port": "Port Modbus/TCP", 18 | "device_list": "Liste des ID des appareils de l'onduleur" 19 | } 20 | } 21 | }, 22 | "error": { 23 | "already_configured": "L'appareil est déjà configuré!", 24 | "invalid_device_id": "L'adresse Modbus doit être entre 1 et 247.", 25 | "invalid_inverter_count": "Doit être entre 1 et 32 onduleurs.", 26 | "invalid_host": "Adresse IP invalide.", 27 | "invalid_tcp_port": "La plage de ports valide est comprise entre 1 et 65535.", 28 | "invalid_range_format": "L'entrée ressemble à une plage mais un seul « - » par plage est autorisé.", 29 | "invalid_range_lte": "L’ID de début d’une plage doit être inférieur ou égal à l’ID de fin.", 30 | "empty_device_id": "La liste d'ID contient une valeur vide ou non définie." 31 | }, 32 | "abort": { 33 | "already_configured": "L'hôte et le port sont déjà configurés dans un autre hub.", 34 | "reconfigure_successful": "La reconfiguration a réussi" 35 | } 36 | }, 37 | "options": { 38 | "step": { 39 | "init": { 40 | "title": "Options SolarEdge Modbus", 41 | "data": { 42 | "scan_interval": "Fréquence de rafraichissement (en secondes)", 43 | "keep_modbus_open": "Garder la connection Modbus ouverte", 44 | "detect_meters": "Auto-détecter les capteurs", 45 | "detect_batteries": "Auto-détecter les batteries", 46 | "detect_extras": "Détection automatique des entités supplémentaires", 47 | "advanced_power_control": "Options de contrôle de l'alimentation", 48 | "sleep_after_write": "Délai de commande de l'onduleur (en secondes)" 49 | } 50 | }, 51 | "adv_pwr_ctl": { 52 | "title": "Options de contrôle de l'alimentation", 53 | "data": { 54 | "adv_storage_control": "Activer le contrôle du stockage", 55 | "adv_site_limit_control": "Activer le contrôle des limites du site" 56 | }, 57 | "description": "Avertissement : Ces options peuvent enfreindre l'accord d'utilisation, modifier la facturation de vos services, nécessiter un équipement spécial et écraser le provisionnement par SolarEdge ou votre installateur. À utiliser à vos risques et périls! Les paramètres réglables dans les registres Modbus sont destinés au stockage à long terme. Des modifications périodiques peuvent endommager la mémoire flash." 58 | }, 59 | "battery_options": { 60 | "title": "Options de batterie", 61 | "data": { 62 | "allow_battery_energy_reset": "Autoriser la réinitialisation de la batterie", 63 | "battery_energy_reset_cycles": "Cycles de mise à jour pour réinitialiser l'énergie de la batterie", 64 | "battery_rating_adjust": "Ajustement de la capacité de la batterie (pourcentage)" 65 | } 66 | } 67 | }, 68 | "error": { 69 | "invalid_scan_interval": "L'intervalle valide est de 1 à 86 400 secondes.", 70 | "invalid_sleep_interval": "L'intervalle valide est de 0 à 60 secondes.", 71 | "invalid_percent": "La plage valide est de 0 à 100 %." 72 | } 73 | }, 74 | "issues": { 75 | "check_configuration": { 76 | "title": "Vérifier la configuration Modbus", 77 | "fix_flow": { 78 | "step": { 79 | "confirm": { 80 | "title": "Vérifier la configuration Modbus", 81 | "description": "Une erreur s'est produite lors de la tentative d'ouverture d'une connexion Modbus/TCP.\n\nVeuillez confirmer votre configuration.", 82 | "data": { 83 | "host": "Adresse IP de l'onduleur", 84 | "port": "Port Modbus/TCP", 85 | "device_id": "L'adresse Modbus de l'onduleur (Device ID)", 86 | "number_of_inverters": "Nombre d'onduleurs" 87 | } 88 | } 89 | }, 90 | "error": { 91 | "invalid_device_id": "L'adresse Modbus doit être entre 1 et 247.", 92 | "invalid_inverter_count": "Doit être entre 1 et 32 onduleurs.", 93 | "invalid_host": "Adresse IP invalide.", 94 | "invalid_tcp_port": "La plage de ports valide est comprise entre 1 et 65535.", 95 | "invalid_range_format": "L'entrée ressemble à une plage mais un seul « - » par plage est autorisé.", 96 | "invalid_range_lte": "L’ID de début d’une plage doit être inférieur ou égal à l’ID de fin.", 97 | "empty_device_id": "La liste d'ID contient une valeur vide ou non définie.", 98 | "already_configured": "L'hôte et le port sont déjà configurés dans un autre hub." 99 | } 100 | } 101 | }, 102 | "detect_timeout_gpc": { 103 | "title": "Tempsion mondial de contrôle de la puissance dynamique", 104 | "description": "L'onduleur n'a pas répondu lors de la lecture des données pour les contrôles de puissance dynamique globaux." 105 | }, 106 | "detect_timeout_apc": { 107 | "title": "Timeout de contrôle de puissance avancé", 108 | "description": "L'onduleur n'a pas répondu lors de la lecture des données pour les contrôles de puissance avancés." 109 | } 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /custom_components/solaredge_modbus_multi/translations/it.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "title": "Configurazione Modbus SolarEdge", 6 | "data": { 7 | "name": "Prefisso sensore", 8 | "host": "Indirizzo IP dell'inverter", 9 | "port": "Porta Modbus/TCP", 10 | "device_list": "Elenco ID dispositivi inverter" 11 | } 12 | }, 13 | "reconfigure": { 14 | "title": "Configurazione Modbus SolarEdge", 15 | "data": { 16 | "host": "Indirizzo IP dell'inverter", 17 | "port": "Porta Modbus/TCP", 18 | "device_list": "Elenco ID dispositivi inverter" 19 | } 20 | } 21 | }, 22 | "error": { 23 | "already_configured": "Il dispositivo è già configurato!", 24 | "invalid_device_id": "L'ID del dispositivo deve essere compreso tra 1 e 247.", 25 | "invalid_inverter_count": "Deve essere compreso tra 1 e 32 inverter.", 26 | "invalid_host": "Indirizzo IP non valido.", 27 | "invalid_tcp_port": "L'intervallo di porte valido è compreso tra 1 e 65535.", 28 | "invalid_range_format": "L'immissione sembra un intervallo ma è consentito solo un '-' per intervallo.", 29 | "invalid_range_lte": "L'ID iniziale in un intervallo deve essere inferiore o uguale all'ID finale.", 30 | "empty_device_id": "L'elenco ID contiene un valore vuoto o non definito." 31 | }, 32 | "abort": { 33 | "already_configured": "L'host e la porta sono già configurati in un altro hub.", 34 | "reconfigure_successful": "La riconfigurazione ha avuto successo" 35 | } 36 | }, 37 | "options": { 38 | "step": { 39 | "init": { 40 | "title": "Opzioni Modbus SolarEdge", 41 | "data": { 42 | "scan_interval": "Frequenza di polling (secondi)", 43 | "keep_modbus_open": "Mantieni aperta la connessione Modbus", 44 | "detect_meters": "Misuratori di rilevamento automatico", 45 | "detect_batteries": "Rilevamento automatico delle batterie", 46 | "detect_extras": "Rileva automaticamente entità aggiuntive", 47 | "advanced_power_control": "Opzioni di controllo della potenza", 48 | "sleep_after_write": "Ritardo comando inverter (secondi)" 49 | } 50 | }, 51 | "adv_pwr_ctl": { 52 | "title": "Opzioni di controllo della potenza", 53 | "data": { 54 | "adv_storage_control": "Abilita il controllo dell'archiviazione", 55 | "adv_site_limit_control": "Abilita il controllo dei limiti del sito" 56 | }, 57 | "description": "Avvertenza: queste opzioni possono violare i contratti dei servizi pubblici, alterare la fatturazione dei servizi pubblici, potrebbero richiedere apparecchiature speciali e sovrascrivere la fornitura da parte di SolarEdge o dell'installatore. Utilizzare a proprio rischio! I parametri regolabili nei registri Modbus sono destinati alla memorizzazione a lungo termine. Modifiche periodiche potrebbero danneggiare la memoria flash." 58 | }, 59 | "battery_options": { 60 | "title": "Opzioni batteria", 61 | "data": { 62 | "allow_battery_energy_reset": "Consenti il ripristino dell'energia della batteria", 63 | "battery_energy_reset_cycles": "Cicli di aggiornamento per ripristinare l'energia della batteria", 64 | "battery_rating_adjust": "Regolazione della potenza della batteria (percentuale)" 65 | } 66 | } 67 | }, 68 | "error": { 69 | "invalid_scan_interval": "L'intervallo valido è compreso tra 1 e 86400 secondi.", 70 | "invalid_sleep_interval": "L'intervallo valido è compreso tra 0 e 60 secondi.", 71 | "invalid_percent": "L'intervallo valido è compreso tra 0 e 100%." 72 | } 73 | }, 74 | "issues": { 75 | "check_configuration": { 76 | "title": "Controllare la configurazione Modbus", 77 | "fix_flow": { 78 | "step": { 79 | "confirm": { 80 | "title": "Controllare la configurazione Modbus", 81 | "description": "Si è verificato un errore durante il tentativo di aprire una connessione Modbus/TCP.\n\nPer favore conferma la tua configurazione", 82 | "data": { 83 | "host": "Indirizzo IP dell'inverter", 84 | "port": "Porta Modbus/TCP", 85 | "device_id": "Indirizzo Modbus dell'inverter (ID dispositivo)", 86 | "number_of_inverters": "Numero di inverter" 87 | } 88 | } 89 | }, 90 | "error": { 91 | "invalid_device_id": "L'ID del dispositivo deve essere compreso tra 1 e 247.", 92 | "invalid_inverter_count": "Deve essere compreso tra 1 e 32 inverter.", 93 | "invalid_host": "Indirizzo IP non valido.", 94 | "invalid_tcp_port": "L'intervallo di porte valido è compreso tra 1 e 65535.", 95 | "invalid_range_format": "L'immissione sembra un intervallo ma è consentito solo un '-' per intervallo.", 96 | "invalid_range_lte": "L'ID iniziale in un intervallo deve essere inferiore o uguale all'ID finale.", 97 | "empty_device_id": "L'elenco ID contiene un valore vuoto o non definito.", 98 | "already_configured": "L'host e la porta sono già configurati in un altro hub." 99 | } 100 | } 101 | }, 102 | "detect_timeout_gpc": { 103 | "title": "Timeout di controllo del potere dinamico globale", 104 | "description": "L'inverter non ha risposto durante la lettura dei dati per i controlli di potenza dinamica globali." 105 | }, 106 | "detect_timeout_apc": { 107 | "title": "Timeout di controllo del potere avanzato", 108 | "description": "L'inverter non ha risposto durante la lettura dei dati per i controlli di potenza avanzati." 109 | } 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /custom_components/solaredge_modbus_multi/switch.py: -------------------------------------------------------------------------------- 1 | """Switch platform for SolarEdge Modbus Multi.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from typing import Any 7 | 8 | from homeassistant.components.switch import SwitchEntity 9 | from homeassistant.config_entries import ConfigEntry 10 | from homeassistant.core import HomeAssistant, callback 11 | from homeassistant.helpers.entity import EntityCategory 12 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 13 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 14 | from pymodbus.client.mixin import ModbusClientMixin 15 | 16 | from .const import DOMAIN, SunSpecNotImpl 17 | 18 | _LOGGER = logging.getLogger(__name__) 19 | 20 | 21 | async def async_setup_entry( 22 | hass: HomeAssistant, 23 | config_entry: ConfigEntry, 24 | async_add_entities: AddEntitiesCallback, 25 | ) -> None: 26 | hub = hass.data[DOMAIN][config_entry.entry_id]["hub"] 27 | coordinator = hass.data[DOMAIN][config_entry.entry_id]["coordinator"] 28 | 29 | entities = [] 30 | 31 | """ Power Control Options: Site Limit Control """ 32 | for inverter in hub.inverters: 33 | if hub.option_site_limit_control is True: 34 | entities.append( 35 | SolarEdgeExternalProduction(inverter, config_entry, coordinator) 36 | ) 37 | entities.append( 38 | SolarEdgeNegativeSiteLimit(inverter, config_entry, coordinator) 39 | ) 40 | 41 | if hub.option_detect_extras and inverter.advanced_power_control: 42 | entities.append(SolarEdgeGridControl(inverter, config_entry, coordinator)) 43 | 44 | if entities: 45 | async_add_entities(entities) 46 | 47 | 48 | class SolarEdgeSwitchBase(CoordinatorEntity, SwitchEntity): 49 | should_poll = False 50 | _attr_has_entity_name = True 51 | 52 | def __init__(self, platform, config_entry, coordinator) -> None: 53 | """Pass coordinator to CoordinatorEntity.""" 54 | super().__init__(coordinator) 55 | """Initialize the sensor.""" 56 | self._platform = platform 57 | self._config_entry = config_entry 58 | 59 | @property 60 | def device_info(self): 61 | return self._platform.device_info 62 | 63 | @property 64 | def config_entry_id(self): 65 | return self._config_entry.entry_id 66 | 67 | @property 68 | def config_entry_name(self): 69 | return self._config_entry.data["name"] 70 | 71 | @property 72 | def available(self) -> bool: 73 | return super().available and self._platform.online 74 | 75 | @callback 76 | def _handle_coordinator_update(self) -> None: 77 | self.async_write_ha_state() 78 | 79 | 80 | class SolarEdgeExternalProduction(SolarEdgeSwitchBase): 81 | """External Production switch. Indicates a non-SolarEdge power sorce in system.""" 82 | 83 | entity_category = EntityCategory.CONFIG 84 | 85 | @property 86 | def available(self) -> bool: 87 | try: 88 | if self._platform.decoded_model["E_Lim_Ctl_Mode"] == SunSpecNotImpl.UINT16: 89 | return False 90 | 91 | return super().available 92 | 93 | except KeyError: 94 | return False 95 | 96 | @property 97 | def unique_id(self) -> str: 98 | return f"{self._platform.uid_base}_external_production" 99 | 100 | @property 101 | def name(self) -> str: 102 | return "External Production" 103 | 104 | @property 105 | def entity_registry_enabled_default(self) -> bool: 106 | return False 107 | 108 | @property 109 | def is_on(self) -> bool: 110 | return (int(self._platform.decoded_model["E_Lim_Ctl_Mode"]) >> 10) & 1 111 | 112 | async def async_turn_on(self, **kwargs: Any) -> None: 113 | """Turn the entity on.""" 114 | set_bits = int(self._platform.decoded_model["E_Lim_Ctl_Mode"]) 115 | set_bits = set_bits | (1 << 10) 116 | 117 | _LOGGER.debug(f"set {self.unique_id} bits {set_bits:016b}") 118 | 119 | await self._platform.write_registers( 120 | address=57344, 121 | payload=ModbusClientMixin.convert_to_registers( 122 | set_bits, 123 | data_type=ModbusClientMixin.DATATYPE.UINT16, 124 | word_order="little", 125 | ), 126 | ) 127 | await self.async_update() 128 | 129 | async def async_turn_off(self, **kwargs: Any) -> None: 130 | """Turn the entity off.""" 131 | set_bits = int(self._platform.decoded_model["E_Lim_Ctl_Mode"]) 132 | set_bits = set_bits & ~(1 << 10) 133 | 134 | _LOGGER.debug(f"set {self.unique_id} bits {set_bits:016b}") 135 | 136 | await self._platform.write_registers( 137 | address=57344, 138 | payload=ModbusClientMixin.convert_to_registers( 139 | set_bits, 140 | data_type=ModbusClientMixin.DATATYPE.UINT16, 141 | word_order="little", 142 | ), 143 | ) 144 | await self.async_update() 145 | 146 | 147 | class SolarEdgeNegativeSiteLimit(SolarEdgeSwitchBase): 148 | """Negative Site Limit switch. Sets minimum import power when enabled.""" 149 | 150 | entity_category = EntityCategory.CONFIG 151 | 152 | @property 153 | def available(self) -> bool: 154 | try: 155 | if self._platform.decoded_model["E_Lim_Ctl_Mode"] == SunSpecNotImpl.UINT16: 156 | return False 157 | 158 | return super().available 159 | 160 | except KeyError: 161 | return False 162 | 163 | @property 164 | def unique_id(self) -> str: 165 | return f"{self._platform.uid_base}_negative_site_limit" 166 | 167 | @property 168 | def name(self) -> str: 169 | return "Negative Site Limit" 170 | 171 | @property 172 | def is_on(self) -> bool: 173 | return (int(self._platform.decoded_model["E_Lim_Ctl_Mode"]) >> 11) & 1 174 | 175 | async def async_turn_on(self, **kwargs: Any) -> None: 176 | """Turn the entity on.""" 177 | set_bits = int(self._platform.decoded_model["E_Lim_Ctl_Mode"]) 178 | set_bits = set_bits | (1 << 11) 179 | 180 | _LOGGER.debug(f"set {self.unique_id} bits {set_bits:016b}") 181 | 182 | await self._platform.write_registers( 183 | address=57344, 184 | payload=ModbusClientMixin.convert_to_registers( 185 | set_bits, 186 | data_type=ModbusClientMixin.DATATYPE.UINT16, 187 | word_order="little", 188 | ), 189 | ) 190 | await self.async_update() 191 | 192 | async def async_turn_off(self, **kwargs: Any) -> None: 193 | """Turn the entity off.""" 194 | set_bits = int(self._platform.decoded_model["E_Lim_Ctl_Mode"]) 195 | set_bits = set_bits & ~(1 << 11) 196 | 197 | _LOGGER.debug(f"set {self.unique_id} bits {set_bits:016b}") 198 | 199 | await self._platform.write_registers( 200 | address=57344, 201 | payload=ModbusClientMixin.convert_to_registers( 202 | set_bits, 203 | data_type=ModbusClientMixin.DATATYPE.UINT16, 204 | word_order="little", 205 | ), 206 | ) 207 | await self.async_update() 208 | 209 | 210 | class SolarEdgeGridControl(SolarEdgeSwitchBase): 211 | """Grid Control boolean switch. This is "AdvancedPwrControlEn" in specs.""" 212 | 213 | entity_category = EntityCategory.CONFIG 214 | 215 | @property 216 | def available(self) -> bool: 217 | return ( 218 | super().available 219 | and self._platform.advanced_power_control 220 | and "AdvPwrCtrlEn" in self._platform.decoded_model.keys() 221 | ) 222 | 223 | @property 224 | def unique_id(self) -> str: 225 | return f"{self._platform.uid_base}_adv_pwr_ctrl" 226 | 227 | @property 228 | def name(self) -> str: 229 | return "Advanced Power Control" 230 | 231 | @property 232 | def is_on(self) -> bool: 233 | return self._platform.decoded_model["AdvPwrCtrlEn"] == 0x1 234 | 235 | async def async_turn_on(self, **kwargs: Any) -> None: 236 | _LOGGER.debug(f"set {self.unique_id} to 0x1") 237 | 238 | await self._platform.write_registers( 239 | address=61762, 240 | payload=ModbusClientMixin.convert_to_registers( 241 | 0x1, data_type=ModbusClientMixin.DATATYPE.INT32, word_order="little" 242 | ), 243 | ) 244 | await self.async_update() 245 | 246 | async def async_turn_off(self, **kwargs: Any) -> None: 247 | _LOGGER.debug(f"set {self.unique_id} to 0x0") 248 | 249 | await self._platform.write_registers( 250 | address=61762, 251 | payload=ModbusClientMixin.convert_to_registers( 252 | 0x0, data_type=ModbusClientMixin.DATATYPE.INT32, word_order="little" 253 | ), 254 | ) 255 | await self.async_update() 256 | -------------------------------------------------------------------------------- /custom_components/solaredge_modbus_multi/const.py: -------------------------------------------------------------------------------- 1 | """Constants used by SolarEdge Modbus Multi components.""" 2 | 3 | from __future__ import annotations 4 | 5 | import re 6 | from enum import IntEnum, StrEnum 7 | from typing import Final 8 | 9 | DOMAIN = "solaredge_modbus_multi" 10 | DEFAULT_NAME = "SolarEdge" 11 | 12 | # raise a startup exception if pymodbus version is less than this 13 | PYMODBUS_REQUIRED_VERSION = "3.8.3" 14 | 15 | # units missing in homeassistant core 16 | ENERGY_VOLT_AMPERE_HOUR: Final = "VAh" 17 | ENERGY_VOLT_AMPERE_REACTIVE_HOUR: Final = "varh" 18 | 19 | # from voluptuous/validators.py 20 | DOMAIN_REGEX = re.compile( 21 | # start anchor, because fullmatch is not available in python 2.7 22 | "(?:" 23 | # domain 24 | r"(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+" 25 | r"(?:[A-Z]{2,6}\.?|[A-Z0-9-]{2,}\.?$)" 26 | # host name only 27 | r"|(?:^[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?)" 28 | # end anchor, because fullmatch is not available in python 2.7 29 | r")\Z", 30 | re.IGNORECASE, 31 | ) 32 | 33 | 34 | class ModbusExceptions: 35 | """An enumeration of the valid modbus exceptions.""" 36 | 37 | """ 38 | Copied from pymodbus source: 39 | https://github.com/pymodbus-dev/pymodbus/blob/a1c14c7a8fbea52618ba1cbc9933c1dd24c3339d/pymodbus/pdu/pdu.py#L72 40 | """ 41 | 42 | IllegalFunction = 0x01 43 | IllegalAddress = 0x02 44 | IllegalValue = 0x03 45 | DeviceFailure = 0x04 46 | Acknowledge = 0x05 47 | DeviceBusy = 0x06 48 | NegativeAcknowledge = 0x07 49 | MemoryParityError = 0x08 50 | GatewayPathUnavailable = 0x0A 51 | GatewayNoResponse = 0x0B 52 | 53 | 54 | class RetrySettings(IntEnum): 55 | """Retry settings when opening a connection to the inverter fails.""" 56 | 57 | Time = 800 # first attempt in milliseconds 58 | Ratio = 3 # time multiplier between each attempt 59 | Limit = 5 # number of attempts before failing 60 | 61 | 62 | class ModbusDefaults(IntEnum): 63 | """Values to pass to pymodbus""" 64 | 65 | """ 66 | ReconnectDelay doubles automatically with each unsuccessful connect, from 67 | ReconnectDelay to ReconnectDelayMax. 68 | Set `ReconnectDelay = 0` to avoid automatic reconnection. 69 | Disabled because it didn't work properly with HA Async in PR#360. 70 | 71 | ReconnectDelay and ReconnectDelayMax can be set to seconds.milliseconds 72 | values using the advanced YAML configuration option. 73 | """ 74 | 75 | Timeout = 3 # Timeout for a request, in seconds. 76 | Retries = 3 # Max number of retries per request. 77 | ReconnectDelay = 0 # Minimum in seconds before reconnecting. 78 | ReconnectDelayMax = 3 # Maximum in seconds before reconnecting. 79 | 80 | 81 | class SolarEdgeTimeouts(IntEnum): 82 | """Timeouts in milliseconds.""" 83 | 84 | Inverter = 8400 85 | Device = 1200 86 | Init = 1200 87 | Read = 6000 88 | 89 | 90 | class BatteryLimit(IntEnum): 91 | """Configure battery limits for input and display validation.""" 92 | 93 | Vmin = 0 # volts 94 | Vmax = 1000 # volts 95 | Amin = -200 # amps 96 | Amax = 200 # amps 97 | Tmax = 100 # degrees C 98 | Tmin = -30 # degrees C 99 | ChargeMax = 1000000 # watts 100 | DischargeMax = 1000000 # watts 101 | 102 | 103 | class ConfDefaultInt(IntEnum): 104 | """Defaults for options that are integers.""" 105 | 106 | SCAN_INTERVAL = 300 107 | PORT = 1502 108 | SLEEP_AFTER_WRITE = 0 109 | BATTERY_RATING_ADJUST = 0 110 | BATTERY_ENERGY_RESET_CYCLES = 0 111 | 112 | 113 | class ConfDefaultFlag(IntEnum): 114 | """Defaults for options that are booleans.""" 115 | 116 | DETECT_METERS = 1 117 | DETECT_BATTERIES = 0 118 | DETECT_EXTRAS = 0 119 | KEEP_MODBUS_OPEN = 0 120 | ADV_PWR_CONTROL = 0 121 | ADV_STORAGE_CONTROL = 0 122 | ADV_SITE_LIMIT_CONTROL = 0 123 | ALLOW_BATTERY_ENERGY_RESET = 0 124 | 125 | 126 | class ConfDefaultStr(StrEnum): 127 | """Defaults for options that are strings.""" 128 | 129 | DEVICE_LIST = "1" 130 | 131 | 132 | class ConfName(StrEnum): 133 | DEVICE_LIST = "device_list" 134 | DETECT_METERS = "detect_meters" 135 | DETECT_BATTERIES = "detect_batteries" 136 | DETECT_EXTRAS = "detect_extras" 137 | KEEP_MODBUS_OPEN = "keep_modbus_open" 138 | ADV_PWR_CONTROL = "advanced_power_control" 139 | ADV_STORAGE_CONTROL = "adv_storage_control" 140 | ADV_SITE_LIMIT_CONTROL = "adv_site_limit_control" 141 | ALLOW_BATTERY_ENERGY_RESET = "allow_battery_energy_reset" 142 | SLEEP_AFTER_WRITE = "sleep_after_write" 143 | BATTERY_RATING_ADJUST = "battery_rating_adjust" 144 | BATTERY_ENERGY_RESET_CYCLES = "battery_energy_reset_cycles" 145 | 146 | # Old config entry names for migration 147 | NUMBER_INVERTERS = "number_of_inverters" 148 | DEVICE_ID = "device_id" 149 | 150 | 151 | class SunSpecAccum(IntEnum): 152 | NA16 = 0x0000 153 | NA32 = 0x00000000 154 | LIMIT16 = 0xFFFF 155 | LIMIT32 = 0xFFFFFFFF 156 | 157 | 158 | class SunSpecNotImpl(IntEnum): 159 | INT16 = 0x8000 160 | UINT16 = 0xFFFF 161 | INT32 = 0x80000000 162 | UINT32 = 0xFFFFFFFF 163 | FLOAT32 = 0x7FC00000 164 | 165 | 166 | # Battery ID and modbus starting address 167 | BATTERY_REG_BASE = { 168 | 1: 57600, 169 | 2: 57856, 170 | 3: 58368, 171 | } 172 | 173 | # Meter ID and modbus starting address 174 | METER_REG_BASE = { 175 | 1: 40121, 176 | 2: 40295, 177 | 3: 40469, 178 | } 179 | 180 | SUNSPEC_SF_RANGE = [ 181 | -10, 182 | -9, 183 | -8, 184 | -7, 185 | -6, 186 | -5, 187 | -4, 188 | -3, 189 | -2, 190 | -1, 191 | 0, 192 | 1, 193 | 2, 194 | 3, 195 | 4, 196 | 5, 197 | 6, 198 | 7, 199 | 8, 200 | 9, 201 | 10, 202 | ] 203 | 204 | # parameter names per sunspec 205 | DEVICE_STATUS = { 206 | 1: "I_STATUS_OFF", 207 | 2: "I_STATUS_SLEEPING", 208 | 3: "I_STATUS_STARTING", 209 | 4: "I_STATUS_MPPT", 210 | 5: "I_STATUS_THROTTLED", 211 | 6: "I_STATUS_SHUTTING_DOWN", 212 | 7: "I_STATUS_FAULT", 213 | 8: "I_STATUS_STANDBY", 214 | } 215 | 216 | # English descriptions of parameter names 217 | DEVICE_STATUS_TEXT = { 218 | 1: "Off", 219 | 2: "Sleeping (Auto-Shutdown)", 220 | 3: "Grid Monitoring", 221 | 4: "Production", 222 | 5: "Production (Curtailed)", 223 | 6: "Shutting Down", 224 | 7: "Fault", 225 | 8: "Maintenance", 226 | } 227 | 228 | VENDOR_STATUS = { 229 | SunSpecNotImpl.INT16: None, 230 | 0: "No Error", 231 | 17: "Temperature Too High", 232 | 25: "Isolation Faults", 233 | 27: "Hardware Error", 234 | 31: "AC Voltage Too High", 235 | 33: "AC Voltage Too High", 236 | 32: "AC Voltage Too Low", 237 | 34: "AC Frequency Too High", 238 | 35: "AC Frequency Too Low", 239 | 41: "AC Voltage Too Low", 240 | 44: "No Country Selected", 241 | 61: "AC Voltage Too Low", 242 | 62: "AC Voltage Too Low", 243 | 63: "AC Voltage Too Low", 244 | 64: "AC Voltage Too High", 245 | 65: "AC Voltage Too High", 246 | 66: "AC Voltage Too High", 247 | 67: "AC Voltage Too Low", 248 | 68: "AC Voltage Too Low", 249 | 69: "AC Voltage Too Low", 250 | 79: "AC Frequency Too High", 251 | 80: "AC Frequency Too High", 252 | 81: "AC Frequency Too High", 253 | 82: "AC Frequency Too Low", 254 | 83: "AC Frequency Too Low", 255 | 84: "AC Frequency Too Low", 256 | 95: "Hardware Error", 257 | 97: "Vin Buck Max", 258 | 104: "Temperature Too High", 259 | 106: "Hardware Error", 260 | 107: "Battery Communication Error", 261 | 110: "Meter Communication Error", 262 | 120: "Hardware Error", 263 | 121: "Isolation Faults", 264 | 125: "Hardware Error", 265 | 126: "Hardware Error", 266 | 150: "Arc Fault Detected", 267 | 151: "Arc Fault Detected", 268 | 153: "Hardware Error", 269 | 256: "Arc Detected", 270 | } 271 | 272 | SUNSPEC_DID = { 273 | 101: "Single Phase Inverter", 274 | 102: "Split Phase Inverter", 275 | 103: "Three Phase Inverter", 276 | 160: "Multiple MPPT Inverter Extension", 277 | 201: "Single Phase Meter", 278 | 202: "Split Phase Meter", 279 | 203: "Three Phase Wye Meter", 280 | 204: "Three Phase Delta Meter", 281 | } 282 | 283 | METER_EVENTS = { 284 | 2: "POWER_FAILURE", 285 | 3: "UNDER_VOLTAGE", 286 | 4: "LOW_PF", 287 | 5: "OVER_CURRENT", 288 | 6: "OVER_VOLTAGE", 289 | 7: "MISSING_SENSOR", 290 | 8: "RESERVED1", 291 | 9: "RESERVED2", 292 | 10: "RESERVED3", 293 | 11: "RESERVED4", 294 | 12: "RESERVED5", 295 | 13: "RESERVED6", 296 | 14: "RESERVED7", 297 | 15: "RESERVED8", 298 | 16: "OEM1", 299 | 17: "OEM2", 300 | 18: "OEM3", 301 | 19: "OEM4", 302 | 20: "OEM5", 303 | 21: "OEM6", 304 | 22: "OEM7", 305 | 23: "OEM8", 306 | 24: "OEM9", 307 | 25: "OEM10", 308 | 26: "OEM11", 309 | 27: "OEM12", 310 | 28: "OEM13", 311 | 29: "OEM14", 312 | 30: "OEM15", 313 | } 314 | 315 | BATTERY_STATUS = { 316 | 0: "B_STATUS_OFF", 317 | 1: "B_STATUS_STANDBY", 318 | 2: "B_STATUS_INIT", 319 | 3: "B_STATUS_CHARGE", 320 | 4: "B_STATUS_DISCHARGE", 321 | 5: "B_STATUS_FAULT", 322 | 6: "B_STATUS_PRESERVE_CHARGE", 323 | 7: "B_STATUS_IDLE", 324 | 10: "B_STATUS_POWER_SAVING", 325 | } 326 | 327 | BATTERY_STATUS_TEXT = { 328 | 0: "Off", 329 | 1: "Standby", 330 | 2: "Initializing", 331 | 3: "Charge", 332 | 4: "Discharge", 333 | 5: "Fault", 334 | 6: "Preserve Charge", 335 | 7: "Idle", 336 | 10: "Power Saving", 337 | } 338 | 339 | RRCR_STATUS = { 340 | 3: "L1", 341 | 2: "L2", 342 | 1: "L3", 343 | 0: "L4", 344 | } 345 | 346 | MMPPT_EVENTS = { 347 | 0: "GROUND_FAULT", 348 | 1: "INPUT_OVER_VOLTAGE", 349 | 3: "DC_DISCONNECT", 350 | 5: "CABINET_OPEN", 351 | 6: "MANUAL_SHUTDOWN", 352 | 7: "OVER_TEMP", 353 | 12: "BLOWN_FUSE", 354 | 13: "UNDER_TEMP", 355 | 14: "MEMORY_LOSS", 356 | 15: "ARC_DETECTION", 357 | 19: "RESERVED", 358 | 20: "TEST_FAILED", 359 | 21: "INPUT_UNDER_VOLTAGE", 360 | 22: "INPUT_OVER_CURRENT", 361 | } 362 | 363 | REACTIVE_POWER_CONFIG = { 364 | 0: "Fixed CosPhi", 365 | 1: "Fixed Q", 366 | 2: "CosPhi(P)", 367 | 3: "Q(U) + Q(P)", 368 | 4: "RRCR", 369 | } 370 | 371 | STORAGE_CONTROL_MODE = { 372 | 0: "Disabled", 373 | 1: "Maximize Self Consumption", 374 | 2: "Time of Use", 375 | 3: "Backup Only", 376 | 4: "Remote Control", 377 | } 378 | 379 | STORAGE_AC_CHARGE_POLICY = { 380 | 0: "Disabled", 381 | 1: "Always Allowed", 382 | 2: "Fixed Energy Limit", 383 | 3: "Percent of Production", 384 | } 385 | 386 | STORAGE_MODE = { 387 | 0: "Solar Power Only (Off)", 388 | 1: "Charge from Clipped Solar Power", 389 | 2: "Charge from Solar Power", 390 | 3: "Charge from Solar Power and Grid", 391 | 4: "Discharge to Maximize Export", 392 | 5: "Discharge to Minimize Import", 393 | 7: "Maximize Self Consumption", 394 | } 395 | 396 | LIMIT_CONTROL_MODE = { 397 | None: "Disabled", 398 | 0: "Export Control (Export/Import Meter)", 399 | 1: "Export Control (Consumption Meter)", 400 | 2: "Production Control", 401 | } 402 | 403 | LIMIT_CONTROL = {0: "Total", 1: "Per Phase"} 404 | -------------------------------------------------------------------------------- /custom_components/solaredge_modbus_multi/__init__.py: -------------------------------------------------------------------------------- 1 | """The SolarEdge Modbus Multi Integration.""" 2 | 3 | from __future__ import annotations 4 | 5 | import asyncio 6 | import logging 7 | from datetime import timedelta 8 | 9 | import voluptuous as vol 10 | from homeassistant.config_entries import ConfigEntry 11 | from homeassistant.const import CONF_HOST, CONF_PORT, CONF_SCAN_INTERVAL, Platform 12 | from homeassistant.core import HomeAssistant 13 | from homeassistant.helpers.device_registry import DeviceEntry 14 | from homeassistant.helpers.typing import ConfigType 15 | from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed 16 | 17 | from .const import DOMAIN, ConfDefaultInt, ConfName, RetrySettings 18 | from .hub import DataUpdateFailed, HubInitFailed, SolarEdgeModbusMultiHub 19 | 20 | _LOGGER = logging.getLogger(__name__) 21 | 22 | PLATFORMS: list[str] = [ 23 | Platform.BINARY_SENSOR, 24 | Platform.BUTTON, 25 | Platform.NUMBER, 26 | Platform.SELECT, 27 | Platform.SENSOR, 28 | Platform.SWITCH, 29 | ] 30 | 31 | # This is probably not allowed per ADR-0010, but I need a way to 32 | # set advanced config that shouldn't appear in any UI dialogs. 33 | CONFIG_SCHEMA = vol.Schema( 34 | { 35 | DOMAIN: vol.Schema( 36 | { 37 | "retry": vol.Schema( 38 | { 39 | vol.Optional("time"): vol.Coerce(int), 40 | vol.Optional("ratio"): vol.Coerce(int), 41 | vol.Optional("limit"): vol.Coerce(int), 42 | } 43 | ), 44 | "modbus": vol.Schema( 45 | { 46 | vol.Optional("timeout"): vol.Coerce(int), 47 | vol.Optional("retries"): vol.Coerce(int), 48 | vol.Optional("reconnect_delay"): vol.Coerce(float), 49 | vol.Optional("reconnect_delay_max"): vol.Coerce(float), 50 | } 51 | ), 52 | } 53 | ) 54 | }, 55 | extra=vol.ALLOW_EXTRA, 56 | ) 57 | 58 | 59 | async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: 60 | """Set up SolarEdge Modbus Muti advanced YAML config.""" 61 | hass.data.setdefault(DOMAIN, {}) 62 | hass.data[DOMAIN]["yaml"] = config.get(DOMAIN, {}) 63 | 64 | return True 65 | 66 | 67 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 68 | """Set up SolarEdge Modbus Muti from a config entry.""" 69 | 70 | solaredge_hub = SolarEdgeModbusMultiHub( 71 | hass, entry.entry_id, entry.data, entry.options 72 | ) 73 | 74 | coordinator = SolarEdgeCoordinator( 75 | hass, 76 | solaredge_hub, 77 | entry.options.get(CONF_SCAN_INTERVAL, ConfDefaultInt.SCAN_INTERVAL), 78 | ) 79 | 80 | hass.data[DOMAIN][entry.entry_id] = { 81 | "hub": solaredge_hub, 82 | "coordinator": coordinator, 83 | } 84 | 85 | await coordinator.async_config_entry_first_refresh() 86 | 87 | await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) 88 | 89 | entry.async_on_unload(entry.add_update_listener(async_reload_entry)) 90 | 91 | return True 92 | 93 | 94 | async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 95 | """Unload a config entry.""" 96 | solaredge_hub = hass.data[DOMAIN][entry.entry_id]["hub"] 97 | await solaredge_hub.shutdown() 98 | 99 | unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) 100 | if unload_ok: 101 | hass.data[DOMAIN].pop(entry.entry_id) 102 | 103 | return unload_ok 104 | 105 | 106 | async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: 107 | """Handle an options update.""" 108 | await hass.config_entries.async_reload(entry.entry_id) 109 | 110 | 111 | async def async_remove_config_entry_device( 112 | hass: HomeAssistant, config_entry: ConfigEntry, device_entry: DeviceEntry 113 | ) -> bool: 114 | """Remove a config entry from a device.""" 115 | solaredge_hub = hass.data[DOMAIN][config_entry.entry_id]["hub"] 116 | 117 | known_devices = [] 118 | 119 | for inverter in solaredge_hub.inverters: 120 | inverter_device_ids = { 121 | dev_id[1] 122 | for dev_id in inverter.device_info["identifiers"] 123 | if dev_id[0] == DOMAIN 124 | } 125 | for dev_id in inverter_device_ids: 126 | known_devices.append(dev_id) 127 | 128 | for meter in solaredge_hub.meters: 129 | meter_device_ids = { 130 | dev_id[1] 131 | for dev_id in meter.device_info["identifiers"] 132 | if dev_id[0] == DOMAIN 133 | } 134 | for dev_id in meter_device_ids: 135 | known_devices.append(dev_id) 136 | 137 | for battery in solaredge_hub.batteries: 138 | battery_device_ids = { 139 | dev_id[1] 140 | for dev_id in battery.device_info["identifiers"] 141 | if dev_id[0] == DOMAIN 142 | } 143 | for dev_id in battery_device_ids: 144 | known_devices.append(dev_id) 145 | 146 | this_device_ids = { 147 | dev_id[1] for dev_id in device_entry.identifiers if dev_id[0] == DOMAIN 148 | } 149 | 150 | for device_id in this_device_ids: 151 | if device_id in known_devices: 152 | _LOGGER.error(f"Unable to remove entry: device {device_id} is in use") 153 | return False 154 | 155 | return True 156 | 157 | 158 | async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: 159 | """Migrate old entry.""" 160 | _LOGGER.debug( 161 | "Migrating from config version " 162 | f"{config_entry.version}.{config_entry.minor_version}" 163 | ) 164 | 165 | if config_entry.version > 2: 166 | return False 167 | 168 | if config_entry.version == 1: 169 | _LOGGER.debug("Migrating from version 1") 170 | 171 | update_data = {**config_entry.data} 172 | update_options = {**config_entry.options} 173 | 174 | if CONF_SCAN_INTERVAL in update_data: 175 | update_options = { 176 | **update_options, 177 | CONF_SCAN_INTERVAL: update_data.pop(CONF_SCAN_INTERVAL), 178 | } 179 | 180 | start_device_id = update_data.pop(ConfName.DEVICE_ID) 181 | number_of_inverters = update_data.pop(ConfName.NUMBER_INVERTERS) 182 | 183 | inverter_list = [] 184 | for inverter_index in range(number_of_inverters): 185 | inverter_unit_id = inverter_index + start_device_id 186 | inverter_list.append(inverter_unit_id) 187 | 188 | update_data = { 189 | **update_data, 190 | ConfName.DEVICE_LIST: inverter_list, 191 | } 192 | 193 | hass.config_entries.async_update_entry( 194 | config_entry, 195 | data=update_data, 196 | options=update_options, 197 | version=2, 198 | minor_version=0, 199 | ) 200 | 201 | if config_entry.version == 2 and config_entry.minor_version < 1: 202 | _LOGGER.debug("Migrating from version 2.0") 203 | 204 | config_entry_data = {**config_entry.data} 205 | 206 | # Use host:port address string as the config entry unique ID. 207 | # This is technically not a valid HA unique ID, but with modbus 208 | # we can't know anything like a serial number per IP since a 209 | # single SE modbus IP could have up to 32 different serial numbers 210 | # and the "leader" modbus unit id can't be known programmatically. 211 | 212 | old_unique_id = config_entry.unique_id 213 | new_unique_id = f"{config_entry_data[CONF_HOST]}:{config_entry_data[CONF_PORT]}" 214 | 215 | _LOGGER.warning( 216 | "Migrating config entry unique ID from %s to %s", 217 | old_unique_id, 218 | new_unique_id, 219 | ) 220 | 221 | hass.config_entries.async_update_entry( 222 | config_entry, unique_id=new_unique_id, version=2, minor_version=1 223 | ) 224 | 225 | _LOGGER.warning( 226 | "Migrated to config version " 227 | f"{config_entry.version}.{config_entry.minor_version}" 228 | ) 229 | 230 | return True 231 | 232 | 233 | class SolarEdgeCoordinator(DataUpdateCoordinator): 234 | def __init__( 235 | self, hass: HomeAssistant, hub: SolarEdgeModbusMultiHub, scan_interval: int 236 | ): 237 | super().__init__( 238 | hass, 239 | _LOGGER, 240 | name="SolarEdge Coordinator", 241 | update_interval=timedelta(seconds=scan_interval), 242 | ) 243 | self._hub = hub 244 | self._yaml_config = hass.data[DOMAIN]["yaml"] 245 | 246 | async def _async_update_data(self) -> bool: 247 | try: 248 | while self._hub.has_write: 249 | _LOGGER.debug(f"Waiting for write {self._hub.has_write}") 250 | await asyncio.sleep(1) 251 | 252 | return await self._refresh_modbus_data_with_retry( 253 | ex_type=DataUpdateFailed, 254 | limit=self._yaml_config.get("retry", {}).get( 255 | "limit", RetrySettings.Limit 256 | ), 257 | wait_ms=self._yaml_config.get("retry", {}).get( 258 | "time", RetrySettings.Time 259 | ), 260 | wait_ratio=self._yaml_config.get("retry", {}).get( 261 | "ratio", RetrySettings.Ratio 262 | ), 263 | ) 264 | 265 | except HubInitFailed as e: 266 | raise UpdateFailed(f"{e}") 267 | 268 | except DataUpdateFailed as e: 269 | raise UpdateFailed(f"{e}") 270 | 271 | async def _refresh_modbus_data_with_retry( 272 | self, 273 | ex_type=Exception, 274 | limit: int = 0, 275 | wait_ms: int = 100, 276 | wait_ratio: int = 2, 277 | ) -> bool: 278 | """ 279 | Retry refresh until no exception occurs or retries exhaust 280 | :param ex_type: retry only if exception is subclass of this type 281 | :param limit: maximum number of invocation attempts 282 | :param wait_ms: initial wait time after each attempt in milliseconds. 283 | :param wait_ratio: increase wait by multiplying by this after each try. 284 | :return: result of first successful invocation 285 | :raises: last invocation exception if attempts exhausted 286 | or exception is not an instance of ex_type 287 | Credit: https://gist.github.com/davidohana/c0518ff6a6b95139e905c8a8caef9995 288 | """ 289 | _LOGGER.debug(f"Retry limit={limit} time={wait_ms} ratio={wait_ratio}") 290 | attempt = 1 291 | while True: 292 | try: 293 | return await self._hub.async_refresh_modbus_data() 294 | except Exception as ex: 295 | if not isinstance(ex, ex_type): 296 | raise ex 297 | if 0 < limit <= attempt: 298 | _LOGGER.debug(f"No more data refresh attempts (maximum {limit})") 299 | raise ex 300 | 301 | _LOGGER.debug(f"Failed data refresh attempt {attempt}") 302 | 303 | attempt += 1 304 | _LOGGER.debug( 305 | f"Waiting {wait_ms} ms before data refresh attempt {attempt}" 306 | ) 307 | await asyncio.sleep(wait_ms / 1000) 308 | wait_ms *= wait_ratio 309 | -------------------------------------------------------------------------------- /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 2021 Seth Mattinen 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/solaredge_modbus_multi/config_flow.py: -------------------------------------------------------------------------------- 1 | """Config flow for the SolarEdge Modbus Multi integration.""" 2 | 3 | from __future__ import annotations 4 | 5 | import re 6 | from typing import Any 7 | 8 | import homeassistant.helpers.config_validation as cv 9 | import voluptuous as vol 10 | from homeassistant import config_entries 11 | from homeassistant.config_entries import ConfigEntry, OptionsFlow 12 | from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, CONF_SCAN_INTERVAL 13 | from homeassistant.core import callback 14 | from homeassistant.data_entry_flow import FlowResult 15 | from homeassistant.exceptions import HomeAssistantError 16 | 17 | from .const import ( 18 | DEFAULT_NAME, 19 | DOMAIN, 20 | ConfDefaultFlag, 21 | ConfDefaultInt, 22 | ConfDefaultStr, 23 | ConfName, 24 | ) 25 | from .helpers import device_list_from_string, host_valid 26 | 27 | 28 | def generate_config_schema(step_id: str, user_input: dict[str, Any]) -> vol.Schema: 29 | """Generate config flow or repair schema.""" 30 | schema: dict[vol.Marker, Any] = {} 31 | 32 | if step_id == "user": 33 | schema |= {vol.Required(CONF_NAME, default=user_input[CONF_NAME]): cv.string} 34 | 35 | if step_id in ["reconfigure", "confirm", "user"]: 36 | schema |= { 37 | vol.Required(CONF_HOST, default=user_input[CONF_HOST]): cv.string, 38 | vol.Required(CONF_PORT, default=user_input[CONF_PORT]): vol.Coerce(int), 39 | vol.Required( 40 | f"{ConfName.DEVICE_LIST}", 41 | default=user_input[ConfName.DEVICE_LIST], 42 | ): cv.string, 43 | } 44 | 45 | return vol.Schema(schema) 46 | 47 | 48 | class SolaredgeModbusMultiConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 49 | """Handle a config flow for SolarEdge Modbus Multi.""" 50 | 51 | VERSION = 2 52 | MINOR_VERSION = 1 53 | 54 | @staticmethod 55 | @callback 56 | def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: 57 | """Create the options flow for SolarEdge Modbus Multi.""" 58 | return SolaredgeModbusMultiOptionsFlowHandler() 59 | 60 | async def async_step_user( 61 | self, user_input: dict[str, Any] | None = None 62 | ) -> FlowResult: 63 | """Handle the initial config flow step.""" 64 | errors = {} 65 | 66 | if user_input is not None: 67 | user_input[CONF_HOST] = user_input[CONF_HOST].lower() 68 | user_input[ConfName.DEVICE_LIST] = re.sub( 69 | r"\s+", "", user_input[ConfName.DEVICE_LIST], flags=re.UNICODE 70 | ) 71 | 72 | try: 73 | inverter_count = len( 74 | device_list_from_string(user_input[ConfName.DEVICE_LIST]) 75 | ) 76 | except HomeAssistantError as e: 77 | errors[ConfName.DEVICE_LIST] = f"{e}" 78 | 79 | else: 80 | if not host_valid(user_input[CONF_HOST]): 81 | errors[CONF_HOST] = "invalid_host" 82 | elif not 1 <= user_input[CONF_PORT] <= 65535: 83 | errors[CONF_PORT] = "invalid_tcp_port" 84 | elif not 1 <= inverter_count <= 32: 85 | errors[ConfName.DEVICE_LIST] = "invalid_inverter_count" 86 | else: 87 | new_unique_id = f"{user_input[CONF_HOST]}:{user_input[CONF_PORT]}" 88 | await self.async_set_unique_id(new_unique_id) 89 | 90 | self._abort_if_unique_id_configured() 91 | 92 | user_input[ConfName.DEVICE_LIST] = device_list_from_string( 93 | user_input[ConfName.DEVICE_LIST] 94 | ) 95 | 96 | return self.async_create_entry( 97 | title=user_input[CONF_NAME], data=user_input 98 | ) 99 | else: 100 | user_input = { 101 | CONF_NAME: DEFAULT_NAME, 102 | CONF_HOST: "", 103 | CONF_PORT: ConfDefaultInt.PORT, 104 | ConfName.DEVICE_LIST: ConfDefaultStr.DEVICE_LIST, 105 | } 106 | 107 | return self.async_show_form( 108 | step_id="user", 109 | data_schema=generate_config_schema("user", user_input), 110 | errors=errors, 111 | ) 112 | 113 | async def async_step_reconfigure( 114 | self, user_input: dict[str, Any] | None = None 115 | ) -> FlowResult: 116 | """Handle the reconfigure flow step.""" 117 | errors = {} 118 | config_entry = self.hass.config_entries.async_get_entry( 119 | self.context["entry_id"] 120 | ) 121 | 122 | if user_input is not None: 123 | user_input[CONF_HOST] = user_input[CONF_HOST].lower() 124 | user_input[ConfName.DEVICE_LIST] = re.sub( 125 | r"\s+", "", user_input[ConfName.DEVICE_LIST], flags=re.UNICODE 126 | ) 127 | 128 | try: 129 | inverter_count = len( 130 | device_list_from_string(user_input[ConfName.DEVICE_LIST]) 131 | ) 132 | except HomeAssistantError as e: 133 | errors[ConfName.DEVICE_LIST] = f"{e}" 134 | 135 | else: 136 | if not host_valid(user_input[CONF_HOST]): 137 | errors[CONF_HOST] = "invalid_host" 138 | elif not 1 <= user_input[CONF_PORT] <= 65535: 139 | errors[CONF_PORT] = "invalid_tcp_port" 140 | elif not 1 <= inverter_count <= 32: 141 | errors[ConfName.DEVICE_LIST] = "invalid_inverter_count" 142 | else: 143 | user_input[ConfName.DEVICE_LIST] = device_list_from_string( 144 | user_input[ConfName.DEVICE_LIST] 145 | ) 146 | this_unique_id = f"{user_input[CONF_HOST]}:{user_input[CONF_PORT]}" 147 | 148 | if this_unique_id != config_entry.unique_id: 149 | self._async_abort_entries_match( 150 | { 151 | "host": user_input[CONF_HOST], 152 | "port": user_input[CONF_PORT], 153 | } 154 | ) 155 | 156 | return self.async_update_reload_and_abort( 157 | config_entry, 158 | unique_id=this_unique_id, 159 | data={**config_entry.data, **user_input}, 160 | reason="reconfigure_successful", 161 | ) 162 | else: 163 | reconfig_device_list = ",".join( 164 | str(device) 165 | for device in config_entry.data.get( 166 | ConfName.DEVICE_LIST, ConfDefaultStr.DEVICE_LIST 167 | ) 168 | ) 169 | 170 | user_input = { 171 | CONF_HOST: config_entry.data.get(CONF_HOST), 172 | CONF_PORT: config_entry.data.get(CONF_PORT, ConfDefaultInt.PORT), 173 | ConfName.DEVICE_LIST: reconfig_device_list, 174 | } 175 | 176 | return self.async_show_form( 177 | step_id="reconfigure", 178 | data_schema=generate_config_schema("reconfigure", user_input), 179 | errors=errors, 180 | ) 181 | 182 | 183 | class SolaredgeModbusMultiOptionsFlowHandler(OptionsFlow): 184 | """Handle an options flow for SolarEdge Modbus Multi.""" 185 | 186 | async def async_step_init( 187 | self, user_input: dict[str, Any] | None = None 188 | ) -> FlowResult: 189 | """Handle the initial options flow step.""" 190 | errors = {} 191 | 192 | if user_input is not None: 193 | if user_input[CONF_SCAN_INTERVAL] < 1: 194 | errors[CONF_SCAN_INTERVAL] = "invalid_scan_interval" 195 | elif user_input[CONF_SCAN_INTERVAL] > 86400: 196 | errors[CONF_SCAN_INTERVAL] = "invalid_scan_interval" 197 | elif user_input[ConfName.SLEEP_AFTER_WRITE] < 0: 198 | errors[ConfName.SLEEP_AFTER_WRITE] = "invalid_sleep_interval" 199 | elif user_input[ConfName.SLEEP_AFTER_WRITE] > 60: 200 | errors[ConfName.SLEEP_AFTER_WRITE] = "invalid_sleep_interval" 201 | else: 202 | if user_input[ConfName.DETECT_BATTERIES] is True: 203 | self.init_info = user_input 204 | return await self.async_step_battery_options() 205 | else: 206 | if user_input[ConfName.ADV_PWR_CONTROL] is True: 207 | self.init_info = user_input 208 | return await self.async_step_adv_pwr_ctl() 209 | 210 | else: 211 | return self.async_create_entry(title="", data=user_input) 212 | 213 | else: 214 | user_input = { 215 | CONF_SCAN_INTERVAL: self.config_entry.options.get( 216 | CONF_SCAN_INTERVAL, ConfDefaultInt.SCAN_INTERVAL 217 | ), 218 | ConfName.KEEP_MODBUS_OPEN: self.config_entry.options.get( 219 | ConfName.KEEP_MODBUS_OPEN, bool(ConfDefaultFlag.KEEP_MODBUS_OPEN) 220 | ), 221 | ConfName.DETECT_METERS: self.config_entry.options.get( 222 | ConfName.DETECT_METERS, bool(ConfDefaultFlag.DETECT_METERS) 223 | ), 224 | ConfName.DETECT_BATTERIES: self.config_entry.options.get( 225 | ConfName.DETECT_BATTERIES, bool(ConfDefaultFlag.DETECT_BATTERIES) 226 | ), 227 | ConfName.DETECT_EXTRAS: self.config_entry.options.get( 228 | ConfName.DETECT_EXTRAS, bool(ConfDefaultFlag.DETECT_EXTRAS) 229 | ), 230 | ConfName.ADV_PWR_CONTROL: self.config_entry.options.get( 231 | ConfName.ADV_PWR_CONTROL, bool(ConfDefaultFlag.ADV_PWR_CONTROL) 232 | ), 233 | ConfName.SLEEP_AFTER_WRITE: self.config_entry.options.get( 234 | ConfName.SLEEP_AFTER_WRITE, ConfDefaultInt.SLEEP_AFTER_WRITE 235 | ), 236 | } 237 | 238 | return self.async_show_form( 239 | step_id="init", 240 | data_schema=vol.Schema( 241 | { 242 | vol.Optional( 243 | CONF_SCAN_INTERVAL, 244 | default=user_input[CONF_SCAN_INTERVAL], 245 | ): vol.Coerce(int), 246 | vol.Optional( 247 | f"{ConfName.KEEP_MODBUS_OPEN}", 248 | default=user_input[ConfName.KEEP_MODBUS_OPEN], 249 | ): cv.boolean, 250 | vol.Optional( 251 | f"{ConfName.DETECT_METERS}", 252 | default=user_input[ConfName.DETECT_METERS], 253 | ): cv.boolean, 254 | vol.Optional( 255 | f"{ConfName.DETECT_BATTERIES}", 256 | default=user_input[ConfName.DETECT_BATTERIES], 257 | ): cv.boolean, 258 | vol.Optional( 259 | f"{ConfName.DETECT_EXTRAS}", 260 | default=user_input[ConfName.DETECT_EXTRAS], 261 | ): cv.boolean, 262 | vol.Optional( 263 | f"{ConfName.ADV_PWR_CONTROL}", 264 | default=user_input[ConfName.ADV_PWR_CONTROL], 265 | ): cv.boolean, 266 | vol.Optional( 267 | f"{ConfName.SLEEP_AFTER_WRITE}", 268 | default=user_input[ConfName.SLEEP_AFTER_WRITE], 269 | ): vol.Coerce(int), 270 | }, 271 | ), 272 | errors=errors, 273 | ) 274 | 275 | async def async_step_battery_options( 276 | self, user_input: dict[str, Any] | None = None 277 | ) -> FlowResult: 278 | """Battery Options""" 279 | errors = {} 280 | 281 | if user_input is not None: 282 | if user_input[ConfName.BATTERY_RATING_ADJUST] < 0: 283 | errors[ConfName.BATTERY_RATING_ADJUST] = "invalid_percent" 284 | elif user_input[ConfName.BATTERY_RATING_ADJUST] > 100: 285 | errors[ConfName.BATTERY_RATING_ADJUST] = "invalid_percent" 286 | else: 287 | if self.init_info[ConfName.ADV_PWR_CONTROL] is True: 288 | self.init_info = {**self.init_info, **user_input} 289 | return await self.async_step_adv_pwr_ctl() 290 | 291 | return self.async_create_entry( 292 | title="", data={**self.init_info, **user_input} 293 | ) 294 | 295 | else: 296 | user_input = { 297 | ConfName.ALLOW_BATTERY_ENERGY_RESET: self.config_entry.options.get( 298 | ConfName.ALLOW_BATTERY_ENERGY_RESET, 299 | bool(ConfDefaultFlag.ALLOW_BATTERY_ENERGY_RESET), 300 | ), 301 | ConfName.BATTERY_ENERGY_RESET_CYCLES: self.config_entry.options.get( 302 | ConfName.BATTERY_ENERGY_RESET_CYCLES, 303 | ConfDefaultInt.BATTERY_ENERGY_RESET_CYCLES, 304 | ), 305 | ConfName.BATTERY_RATING_ADJUST: self.config_entry.options.get( 306 | ConfName.BATTERY_RATING_ADJUST, 307 | ConfDefaultInt.BATTERY_RATING_ADJUST, 308 | ), 309 | } 310 | 311 | return self.async_show_form( 312 | step_id="battery_options", 313 | data_schema=vol.Schema( 314 | { 315 | vol.Optional( 316 | f"{ConfName.ALLOW_BATTERY_ENERGY_RESET}", 317 | default=user_input[ConfName.ALLOW_BATTERY_ENERGY_RESET], 318 | ): cv.boolean, 319 | vol.Optional( 320 | f"{ConfName.BATTERY_ENERGY_RESET_CYCLES}", 321 | default=user_input[ConfName.BATTERY_ENERGY_RESET_CYCLES], 322 | ): vol.Coerce(int), 323 | vol.Optional( 324 | f"{ConfName.BATTERY_RATING_ADJUST}", 325 | default=user_input[ConfName.BATTERY_RATING_ADJUST], 326 | ): vol.Coerce(int), 327 | } 328 | ), 329 | errors=errors, 330 | ) 331 | 332 | async def async_step_adv_pwr_ctl( 333 | self, user_input: dict[str, Any] | None = None 334 | ) -> FlowResult: 335 | """Power Control Options""" 336 | errors = {} 337 | 338 | if user_input is not None: 339 | return self.async_create_entry( 340 | title="", data={**self.init_info, **user_input} 341 | ) 342 | 343 | else: 344 | user_input = { 345 | ConfName.ADV_STORAGE_CONTROL: self.config_entry.options.get( 346 | ConfName.ADV_STORAGE_CONTROL, 347 | bool(ConfDefaultFlag.ADV_STORAGE_CONTROL), 348 | ), 349 | ConfName.ADV_SITE_LIMIT_CONTROL: self.config_entry.options.get( 350 | ConfName.ADV_SITE_LIMIT_CONTROL, 351 | bool(ConfDefaultFlag.ADV_SITE_LIMIT_CONTROL), 352 | ), 353 | } 354 | 355 | return self.async_show_form( 356 | step_id="adv_pwr_ctl", 357 | data_schema=vol.Schema( 358 | { 359 | vol.Required( 360 | f"{ConfName.ADV_STORAGE_CONTROL}", 361 | default=user_input[ConfName.ADV_STORAGE_CONTROL], 362 | ): cv.boolean, 363 | vol.Required( 364 | f"{ConfName.ADV_SITE_LIMIT_CONTROL}", 365 | default=user_input[ConfName.ADV_SITE_LIMIT_CONTROL], 366 | ): cv.boolean, 367 | } 368 | ), 369 | errors=errors, 370 | ) 371 | -------------------------------------------------------------------------------- /custom_components/solaredge_modbus_multi/select.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | 5 | from homeassistant.components.select import SelectEntity 6 | from homeassistant.config_entries import ConfigEntry 7 | from homeassistant.core import HomeAssistant, callback 8 | from homeassistant.helpers.entity import EntityCategory 9 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 10 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 11 | from pymodbus.client.mixin import ModbusClientMixin 12 | 13 | from .const import ( 14 | DOMAIN, 15 | LIMIT_CONTROL, 16 | LIMIT_CONTROL_MODE, 17 | REACTIVE_POWER_CONFIG, 18 | STORAGE_AC_CHARGE_POLICY, 19 | STORAGE_CONTROL_MODE, 20 | STORAGE_MODE, 21 | SunSpecNotImpl, 22 | ) 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 | hub = hass.data[DOMAIN][config_entry.entry_id]["hub"] 33 | coordinator = hass.data[DOMAIN][config_entry.entry_id]["coordinator"] 34 | 35 | entities = [] 36 | 37 | for inverter in hub.inverters: 38 | """Power Control Options: Storage Control""" 39 | if hub.option_storage_control and inverter.decoded_storage_control: 40 | entities.append(StorageControlMode(inverter, config_entry, coordinator)) 41 | entities.append(StorageACChargePolicy(inverter, config_entry, coordinator)) 42 | entities.append(StorageDefaultMode(inverter, config_entry, coordinator)) 43 | entities.append(StorageCommandMode(inverter, config_entry, coordinator)) 44 | 45 | """ Power Control Options: Site Limit Control """ 46 | if hub.option_site_limit_control: 47 | entities.append( 48 | SolaredgeLimitControlMode(inverter, config_entry, coordinator) 49 | ) 50 | entities.append(SolaredgeLimitControl(inverter, config_entry, coordinator)) 51 | 52 | """ Power Control Block """ 53 | if hub.option_detect_extras and inverter.advanced_power_control: 54 | entities.append( 55 | SolarEdgeReactivePowerMode(inverter, config_entry, coordinator) 56 | ) 57 | 58 | if entities: 59 | async_add_entities(entities) 60 | 61 | 62 | def get_key(d, search): 63 | for k, v in d.items(): 64 | if v == search: 65 | return k 66 | return None 67 | 68 | 69 | class SolarEdgeSelectBase(CoordinatorEntity, SelectEntity): 70 | should_poll = False 71 | _attr_has_entity_name = True 72 | entity_category = EntityCategory.CONFIG 73 | 74 | def __init__(self, platform, config_entry, coordinator): 75 | """Pass coordinator to CoordinatorEntity.""" 76 | super().__init__(coordinator) 77 | """Initialize the sensor.""" 78 | self._platform = platform 79 | self._config_entry = config_entry 80 | 81 | @property 82 | def device_info(self): 83 | return self._platform.device_info 84 | 85 | @property 86 | def config_entry_id(self): 87 | return self._config_entry.entry_id 88 | 89 | @property 90 | def config_entry_name(self): 91 | return self._config_entry.data["name"] 92 | 93 | @property 94 | def available(self) -> bool: 95 | return super().available and self._platform.online 96 | 97 | @callback 98 | def _handle_coordinator_update(self) -> None: 99 | self.async_write_ha_state() 100 | 101 | 102 | class StorageControlMode(SolarEdgeSelectBase): 103 | def __init__(self, platform, config_entry, coordinator): 104 | super().__init__(platform, config_entry, coordinator) 105 | self._options = STORAGE_CONTROL_MODE 106 | self._attr_options = list(self._options.values()) 107 | 108 | @property 109 | def unique_id(self) -> str: 110 | return f"{self._platform.uid_base}_storage_control_mode" 111 | 112 | @property 113 | def name(self) -> str: 114 | return "Storage Control Mode" 115 | 116 | @property 117 | def entity_registry_enabled_default(self) -> bool: 118 | return self._platform.has_battery is True 119 | 120 | @property 121 | def available(self) -> bool: 122 | try: 123 | if ( 124 | self._platform.decoded_storage_control is False 125 | or self._platform.decoded_storage_control["control_mode"] 126 | == SunSpecNotImpl.UINT16 127 | or self._platform.decoded_storage_control["control_mode"] 128 | not in self._options 129 | ): 130 | return False 131 | 132 | return super().available 133 | 134 | except KeyError: 135 | return False 136 | 137 | @property 138 | def current_option(self) -> str: 139 | return self._options[self._platform.decoded_storage_control["control_mode"]] 140 | 141 | async def async_select_option(self, option: str) -> None: 142 | _LOGGER.debug(f"set {self.unique_id} to {option}") 143 | new_mode = get_key(self._options, option) 144 | await self._platform.write_registers( 145 | address=57348, 146 | payload=ModbusClientMixin.convert_to_registers( 147 | new_mode, 148 | data_type=ModbusClientMixin.DATATYPE.UINT16, 149 | word_order="little", 150 | ), 151 | ) 152 | await self.async_update() 153 | 154 | 155 | class StorageACChargePolicy(SolarEdgeSelectBase): 156 | def __init__(self, platform, config_entry, coordinator): 157 | super().__init__(platform, config_entry, coordinator) 158 | self._options = STORAGE_AC_CHARGE_POLICY 159 | self._attr_options = list(self._options.values()) 160 | 161 | @property 162 | def unique_id(self) -> str: 163 | return f"{self._platform.uid_base}_ac_charge_policy" 164 | 165 | @property 166 | def name(self) -> str: 167 | return "AC Charge Policy" 168 | 169 | @property 170 | def entity_registry_enabled_default(self) -> bool: 171 | return self._platform.has_battery is True 172 | 173 | @property 174 | def available(self) -> bool: 175 | try: 176 | if ( 177 | self._platform.decoded_storage_control is False 178 | or self._platform.decoded_storage_control["ac_charge_policy"] 179 | == SunSpecNotImpl.UINT16 180 | or self._platform.decoded_storage_control["ac_charge_policy"] 181 | not in self._options 182 | ): 183 | return False 184 | 185 | return super().available 186 | 187 | except KeyError: 188 | return False 189 | 190 | @property 191 | def current_option(self) -> str: 192 | return self._options[self._platform.decoded_storage_control["ac_charge_policy"]] 193 | 194 | async def async_select_option(self, option: str) -> None: 195 | _LOGGER.debug(f"set {self.unique_id} to {option}") 196 | new_mode = get_key(self._options, option) 197 | await self._platform.write_registers( 198 | address=57349, 199 | payload=ModbusClientMixin.convert_to_registers( 200 | new_mode, 201 | data_type=ModbusClientMixin.DATATYPE.UINT16, 202 | word_order="little", 203 | ), 204 | ) 205 | await self.async_update() 206 | 207 | 208 | class StorageDefaultMode(SolarEdgeSelectBase): 209 | def __init__(self, platform, config_entry, coordinator): 210 | super().__init__(platform, config_entry, coordinator) 211 | self._options = STORAGE_MODE 212 | self._attr_options = list(self._options.values()) 213 | 214 | @property 215 | def unique_id(self) -> str: 216 | return f"{self._platform.uid_base}_storage_default_mode" 217 | 218 | @property 219 | def name(self) -> str: 220 | return "Storage Default Mode" 221 | 222 | @property 223 | def entity_registry_enabled_default(self) -> bool: 224 | return self._platform.has_battery is True 225 | 226 | @property 227 | def available(self) -> bool: 228 | try: 229 | if ( 230 | self._platform.decoded_storage_control is False 231 | or self._platform.decoded_storage_control["default_mode"] 232 | == SunSpecNotImpl.UINT16 233 | or self._platform.decoded_storage_control["default_mode"] 234 | not in self._options 235 | ): 236 | return False 237 | 238 | # Available only in remote control mode 239 | return ( 240 | super().available 241 | and self._platform.decoded_storage_control["control_mode"] == 4 242 | ) 243 | 244 | except KeyError: 245 | return False 246 | 247 | @property 248 | def current_option(self) -> str: 249 | return self._options[self._platform.decoded_storage_control["default_mode"]] 250 | 251 | async def async_select_option(self, option: str) -> None: 252 | _LOGGER.debug(f"set {self.unique_id} to {option}") 253 | new_mode = get_key(self._options, option) 254 | await self._platform.write_registers( 255 | address=57354, 256 | payload=ModbusClientMixin.convert_to_registers( 257 | new_mode, 258 | data_type=ModbusClientMixin.DATATYPE.UINT16, 259 | word_order="little", 260 | ), 261 | ) 262 | await self.async_update() 263 | 264 | 265 | class StorageCommandMode(SolarEdgeSelectBase): 266 | def __init__(self, platform, config_entry, coordinator): 267 | super().__init__(platform, config_entry, coordinator) 268 | self._options = STORAGE_MODE 269 | self._attr_options = list(self._options.values()) 270 | 271 | @property 272 | def unique_id(self) -> str: 273 | return f"{self._platform.uid_base}_storage_command_mode" 274 | 275 | @property 276 | def name(self) -> str: 277 | return "Storage Command Mode" 278 | 279 | @property 280 | def entity_registry_enabled_default(self) -> bool: 281 | return self._platform.has_battery is True 282 | 283 | @property 284 | def available(self) -> bool: 285 | try: 286 | if ( 287 | self._platform.decoded_storage_control is False 288 | or self._platform.decoded_storage_control["command_mode"] 289 | == SunSpecNotImpl.UINT16 290 | or self._platform.decoded_storage_control["command_mode"] 291 | not in self._options 292 | ): 293 | return False 294 | 295 | # Available only in remote control mode 296 | return ( 297 | super().available 298 | and self._platform.decoded_storage_control["control_mode"] == 4 299 | ) 300 | 301 | except KeyError: 302 | return False 303 | 304 | @property 305 | def current_option(self) -> str: 306 | return self._options[self._platform.decoded_storage_control["command_mode"]] 307 | 308 | async def async_select_option(self, option: str) -> None: 309 | _LOGGER.debug(f"set {self.unique_id} to {option}") 310 | new_mode = get_key(self._options, option) 311 | await self._platform.write_registers( 312 | address=57357, 313 | payload=ModbusClientMixin.convert_to_registers( 314 | new_mode, 315 | data_type=ModbusClientMixin.DATATYPE.UINT16, 316 | word_order="little", 317 | ), 318 | ) 319 | await self.async_update() 320 | 321 | 322 | class SolaredgeLimitControlMode(SolarEdgeSelectBase): 323 | def __init__(self, platform, config_entry, coordinator): 324 | super().__init__(platform, config_entry, coordinator) 325 | self._options = LIMIT_CONTROL_MODE 326 | self._attr_options = list(self._options.values()) 327 | 328 | @property 329 | def available(self) -> bool: 330 | try: 331 | if self._platform.decoded_model["E_Lim_Ctl_Mode"] == SunSpecNotImpl.UINT16: 332 | return None 333 | 334 | return super().available 335 | 336 | except KeyError: 337 | return False 338 | 339 | @property 340 | def unique_id(self) -> str: 341 | return f"{self._platform.uid_base}_limit_control_mode" 342 | 343 | @property 344 | def name(self) -> str: 345 | return "Limit Control Mode" 346 | 347 | @property 348 | def current_option(self) -> str: 349 | if (int(self._platform.decoded_model["E_Lim_Ctl_Mode"]) >> 0) & 1: 350 | return self._options[0] 351 | 352 | elif (int(self._platform.decoded_model["E_Lim_Ctl_Mode"]) >> 1) & 1: 353 | return self._options[1] 354 | 355 | elif (int(self._platform.decoded_model["E_Lim_Ctl_Mode"]) >> 2) & 1: 356 | return self._options[2] 357 | 358 | else: 359 | return self._options[None] 360 | 361 | async def async_select_option(self, option: str) -> None: 362 | set_bits = int(self._platform.decoded_model["E_Lim_Ctl_Mode"]) 363 | new_mode = get_key(self._options, option) 364 | 365 | set_bits = set_bits & ~(1 << 0) 366 | set_bits = set_bits & ~(1 << 1) 367 | set_bits = set_bits & ~(1 << 2) 368 | 369 | if new_mode is not None: 370 | set_bits = set_bits | (1 << int(new_mode)) 371 | 372 | _LOGGER.debug(f"set {self.unique_id} bits {set_bits:016b}") 373 | await self._platform.write_registers( 374 | address=57344, 375 | payload=ModbusClientMixin.convert_to_registers( 376 | set_bits, 377 | data_type=ModbusClientMixin.DATATYPE.UINT16, 378 | word_order="little", 379 | ), 380 | ) 381 | await self.async_update() 382 | 383 | 384 | class SolaredgeLimitControl(SolarEdgeSelectBase): 385 | def __init__(self, platform, config_entry, coordinator): 386 | super().__init__(platform, config_entry, coordinator) 387 | self._options = LIMIT_CONTROL 388 | self._attr_options = list(self._options.values()) 389 | 390 | @property 391 | def available(self) -> bool: 392 | try: 393 | if self._platform.decoded_model["E_Lim_Ctl"] == SunSpecNotImpl.UINT16: 394 | return False 395 | 396 | return super().available 397 | 398 | except KeyError: 399 | return False 400 | 401 | @property 402 | def unique_id(self) -> str: 403 | return f"{self._platform.uid_base}_limit_control" 404 | 405 | @property 406 | def name(self) -> str: 407 | return "Limit Control" 408 | 409 | @property 410 | def current_option(self) -> str: 411 | return self._options[self._platform.decoded_model["E_Lim_Ctl"]] 412 | 413 | async def async_select_option(self, option: str) -> None: 414 | _LOGGER.debug(f"set {self.unique_id} to {option}") 415 | new_mode = get_key(self._options, option) 416 | await self._platform.write_registers( 417 | address=57345, 418 | payload=ModbusClientMixin.convert_to_registers( 419 | new_mode, 420 | data_type=ModbusClientMixin.DATATYPE.UINT16, 421 | word_order="little", 422 | ), 423 | ) 424 | await self.async_update() 425 | 426 | 427 | class SolarEdgeReactivePowerMode(SolarEdgeSelectBase): 428 | def __init__(self, platform, config_entry, coordinator): 429 | super().__init__(platform, config_entry, coordinator) 430 | self._options = REACTIVE_POWER_CONFIG 431 | self._attr_options = list(self._options.values()) 432 | 433 | @property 434 | def available(self) -> bool: 435 | try: 436 | if ( 437 | self._platform.decoded_model["ReactivePwrConfig"] 438 | == SunSpecNotImpl.INT32 439 | or self._platform.decoded_model["ReactivePwrConfig"] 440 | not in self._options 441 | ): 442 | return False 443 | 444 | return super().available 445 | 446 | except KeyError: 447 | return False 448 | 449 | @property 450 | def unique_id(self) -> str: 451 | return f"{self._platform.uid_base}_reactive_power_mode" 452 | 453 | @property 454 | def name(self) -> str: 455 | return "Reactive Power Mode" 456 | 457 | @property 458 | def current_option(self) -> str: 459 | return self._options[self._platform.decoded_model["ReactivePwrConfig"]] 460 | 461 | async def async_select_option(self, option: str) -> None: 462 | _LOGGER.debug(f"set {self.unique_id} to {option}") 463 | new_mode = get_key(self._options, option) 464 | await self._platform.write_registers( 465 | address=61700, 466 | payload=ModbusClientMixin.convert_to_registers( 467 | new_mode, 468 | data_type=ModbusClientMixin.DATATYPE.INT32, 469 | word_order="little", 470 | ), 471 | ) 472 | await self.async_update() 473 | -------------------------------------------------------------------------------- /custom_components/solaredge_modbus_multi/number.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | 5 | from homeassistant.components.number import NumberEntity 6 | from homeassistant.config_entries import ConfigEntry 7 | from homeassistant.const import ( 8 | PERCENTAGE, 9 | UnitOfElectricCurrent, 10 | UnitOfEnergy, 11 | UnitOfPower, 12 | UnitOfTime, 13 | ) 14 | from homeassistant.core import HomeAssistant, callback 15 | from homeassistant.helpers.entity import EntityCategory 16 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 17 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 18 | from pymodbus.client.mixin import ModbusClientMixin 19 | 20 | from .const import DOMAIN, BatteryLimit, SunSpecNotImpl 21 | from .helpers import float_to_hex 22 | 23 | _LOGGER = logging.getLogger(__name__) 24 | 25 | 26 | async def async_setup_entry( 27 | hass: HomeAssistant, 28 | config_entry: ConfigEntry, 29 | async_add_entities: AddEntitiesCallback, 30 | ) -> None: 31 | hub = hass.data[DOMAIN][config_entry.entry_id]["hub"] 32 | coordinator = hass.data[DOMAIN][config_entry.entry_id]["coordinator"] 33 | 34 | entities = [] 35 | 36 | """ Dynamic Power Control """ 37 | if hub.option_detect_extras: 38 | for inverter in hub.inverters: 39 | entities.append( 40 | SolarEdgeActivePowerLimitSet(inverter, config_entry, coordinator) 41 | ) 42 | entities.append(SolarEdgeCosPhiSet(inverter, config_entry, coordinator)) 43 | 44 | """ Power Control Options: Storage Control """ 45 | if hub.option_storage_control is True: 46 | for inverter in hub.inverters: 47 | if inverter.decoded_storage_control is False: 48 | continue 49 | entities.append(StorageACChargeLimit(inverter, config_entry, coordinator)) 50 | entities.append(StorageBackupReserve(inverter, config_entry, coordinator)) 51 | entities.append(StorageCommandTimeout(inverter, config_entry, coordinator)) 52 | if inverter.has_battery is True: 53 | entities.append(StorageChargeLimit(inverter, config_entry, coordinator)) 54 | entities.append( 55 | StorageDischargeLimit(inverter, config_entry, coordinator) 56 | ) 57 | 58 | """ Power Control Options: Site Limit Control """ 59 | if hub.option_site_limit_control is True: 60 | for inverter in hub.inverters: 61 | entities.append(SolarEdgeSiteLimit(inverter, config_entry, coordinator)) 62 | entities.append( 63 | SolarEdgeExternalProductionMax(inverter, config_entry, coordinator) 64 | ) 65 | 66 | """ Power Control Block """ 67 | if hub.option_detect_extras and inverter.advanced_power_control: 68 | for inverter in hub.inverters: 69 | entities.append(SolarEdgePowerReduce(inverter, config_entry, coordinator)) 70 | entities.append(SolarEdgeCurrentLimit(inverter, config_entry, coordinator)) 71 | 72 | if entities: 73 | async_add_entities(entities) 74 | 75 | 76 | def get_key(d, search): 77 | for k, v in d.items(): 78 | if v == search: 79 | return k 80 | return None 81 | 82 | 83 | class SolarEdgeNumberBase(CoordinatorEntity, NumberEntity): 84 | should_poll = False 85 | _attr_has_entity_name = True 86 | entity_category = EntityCategory.CONFIG 87 | 88 | def __init__(self, platform, config_entry, coordinator): 89 | """Pass coordinator to CoordinatorEntity.""" 90 | super().__init__(coordinator) 91 | """Initialize the number.""" 92 | self._platform = platform 93 | self._config_entry = config_entry 94 | 95 | @property 96 | def device_info(self): 97 | return self._platform.device_info 98 | 99 | @property 100 | def config_entry_id(self): 101 | return self._config_entry.entry_id 102 | 103 | @property 104 | def config_entry_name(self): 105 | return self._config_entry.data["name"] 106 | 107 | @property 108 | def available(self) -> bool: 109 | return super().available and self._platform.online 110 | 111 | @callback 112 | def _handle_coordinator_update(self) -> None: 113 | self.async_write_ha_state() 114 | 115 | 116 | class StorageACChargeLimit(SolarEdgeNumberBase): 117 | icon = "mdi:lightning-bolt" 118 | 119 | @property 120 | def unique_id(self) -> str: 121 | return f"{self._platform.uid_base}_storage_ac_charge_limit" 122 | 123 | @property 124 | def name(self) -> str: 125 | return "AC Charge Limit" 126 | 127 | @property 128 | def entity_registry_enabled_default(self) -> bool: 129 | return self._platform.has_battery is True 130 | 131 | @property 132 | def available(self) -> bool: 133 | try: 134 | if ( 135 | self._platform.decoded_storage_control is False 136 | or float_to_hex( 137 | self._platform.decoded_storage_control["ac_charge_limit"] 138 | ) 139 | == hex(SunSpecNotImpl.FLOAT32) 140 | or self._platform.decoded_storage_control["ac_charge_limit"] < 0 141 | ): 142 | return False 143 | 144 | # Available for AC charge policies 2 & 3 145 | return super().available and self._platform.decoded_storage_control[ 146 | "ac_charge_policy" 147 | ] in [2, 3] 148 | 149 | except (TypeError, KeyError): 150 | return False 151 | 152 | @property 153 | def native_unit_of_measurement(self) -> str | None: 154 | # kWh in AC policy "Fixed Energy Limit", % in AC policy "Percent of Production" 155 | if self._platform.decoded_storage_control["ac_charge_policy"] == 2: 156 | return UnitOfEnergy.KILO_WATT_HOUR 157 | elif self._platform.decoded_storage_control["ac_charge_policy"] == 3: 158 | return PERCENTAGE 159 | else: 160 | return None 161 | 162 | @property 163 | def native_min_value(self) -> int: 164 | return 0 165 | 166 | @property 167 | def native_max_value(self) -> int: 168 | # 100MWh in AC policy "Fixed Energy Limit" 169 | if self._platform.decoded_storage_control["ac_charge_policy"] == 2: 170 | return 100000000 171 | elif self._platform.decoded_storage_control["ac_charge_policy"] == 3: 172 | return 100 173 | else: 174 | return 0 175 | 176 | @property 177 | def native_value(self) -> int: 178 | return int(self._platform.decoded_storage_control["ac_charge_limit"]) 179 | 180 | async def async_set_native_value(self, value: float) -> None: 181 | _LOGGER.debug(f"set {self.unique_id} to {value}") 182 | await self._platform.write_registers( 183 | address=57350, 184 | payload=ModbusClientMixin.convert_to_registers( 185 | float(value), 186 | data_type=ModbusClientMixin.DATATYPE.FLOAT32, 187 | word_order="little", 188 | ), 189 | ) 190 | await self.async_update() 191 | 192 | 193 | class StorageBackupReserve(SolarEdgeNumberBase): 194 | native_unit_of_measurement = PERCENTAGE 195 | native_min_value = 0 196 | native_max_value = 100 197 | icon = "mdi:battery-positive" 198 | 199 | @property 200 | def unique_id(self) -> str: 201 | return f"{self._platform.uid_base}_storage_backup_reserve" 202 | 203 | @property 204 | def name(self) -> str: 205 | return "Backup Reserve" 206 | 207 | @property 208 | def entity_registry_enabled_default(self) -> bool: 209 | return self._platform.has_battery is True 210 | 211 | @property 212 | def available(self) -> bool: 213 | try: 214 | if ( 215 | self._platform.decoded_storage_control is False 216 | or float_to_hex( 217 | self._platform.decoded_storage_control["backup_reserve"] 218 | ) 219 | == hex(SunSpecNotImpl.FLOAT32) 220 | or self._platform.decoded_storage_control["backup_reserve"] < 0 221 | or self._platform.decoded_storage_control["backup_reserve"] > 100 222 | ): 223 | return False 224 | 225 | return super().available 226 | 227 | except (TypeError, KeyError): 228 | return False 229 | 230 | @property 231 | def native_value(self) -> int: 232 | return int(self._platform.decoded_storage_control["backup_reserve"]) 233 | 234 | async def async_set_native_value(self, value: int) -> None: 235 | _LOGGER.debug(f"set {self.unique_id} to {value}") 236 | await self._platform.write_registers( 237 | address=57352, 238 | payload=ModbusClientMixin.convert_to_registers( 239 | int(value), 240 | data_type=ModbusClientMixin.DATATYPE.FLOAT32, 241 | word_order="little", 242 | ), 243 | ) 244 | await self.async_update() 245 | 246 | 247 | class StorageCommandTimeout(SolarEdgeNumberBase): 248 | native_min_value = 0 249 | native_max_value = 86400 # 24h 250 | native_unit_of_measurement = UnitOfTime.SECONDS 251 | icon = "mdi:clock-end" 252 | 253 | @property 254 | def unique_id(self) -> str: 255 | return f"{self._platform.uid_base}_storage_command_timeout" 256 | 257 | @property 258 | def name(self) -> str: 259 | return "Storage Command Timeout" 260 | 261 | @property 262 | def entity_registry_enabled_default(self) -> bool: 263 | return self._platform.has_battery is True 264 | 265 | @property 266 | def available(self) -> bool: 267 | try: 268 | if ( 269 | self._platform.decoded_storage_control is False 270 | or self._platform.decoded_storage_control["command_timeout"] 271 | == SunSpecNotImpl.UINT32 272 | or self._platform.decoded_storage_control["command_timeout"] > 86400 273 | ): 274 | return False 275 | 276 | # Available only in remote control mode 277 | return ( 278 | super().available 279 | and self._platform.decoded_storage_control["control_mode"] == 4 280 | ) 281 | 282 | except (TypeError, KeyError): 283 | return False 284 | 285 | @property 286 | def native_value(self) -> int: 287 | return int(self._platform.decoded_storage_control["command_timeout"]) 288 | 289 | async def async_set_native_value(self, value: int) -> None: 290 | _LOGGER.debug(f"set {self.unique_id} to {value}") 291 | await self._platform.write_registers( 292 | address=57355, 293 | payload=ModbusClientMixin.convert_to_registers( 294 | int(value), 295 | data_type=ModbusClientMixin.DATATYPE.UINT32, 296 | word_order="little", 297 | ), 298 | ) 299 | await self.async_update() 300 | 301 | 302 | class StorageChargeLimit(SolarEdgeNumberBase): 303 | native_min_value = 0 304 | native_step = 1.0 305 | native_unit_of_measurement = UnitOfPower.WATT 306 | icon = "mdi:lightning-bolt" 307 | 308 | @property 309 | def unique_id(self) -> str: 310 | return f"{self._platform.uid_base}_storage_charge_limit" 311 | 312 | @property 313 | def name(self) -> str: 314 | return "Storage Charge Limit" 315 | 316 | @property 317 | def available(self) -> bool: 318 | try: 319 | if ( 320 | self._platform.decoded_storage_control is False 321 | or float_to_hex(self._platform.decoded_storage_control["charge_limit"]) 322 | == hex(SunSpecNotImpl.FLOAT32) 323 | or self._platform.decoded_storage_control["charge_limit"] < 0 324 | ): 325 | return False 326 | 327 | # Available only in remote control mode 328 | return ( 329 | super().available 330 | and self._platform.decoded_storage_control["control_mode"] == 4 331 | ) 332 | 333 | except (TypeError, KeyError): 334 | return False 335 | 336 | @property 337 | def native_max_value(self) -> int: 338 | return BatteryLimit.ChargeMax 339 | 340 | @property 341 | def native_value(self) -> int: 342 | return int(self._platform.decoded_storage_control["charge_limit"]) 343 | 344 | async def async_set_native_value(self, value: int) -> None: 345 | _LOGGER.debug(f"set {self.unique_id} to {value}") 346 | await self._platform.write_registers( 347 | address=57358, 348 | payload=ModbusClientMixin.convert_to_registers( 349 | int(value), 350 | data_type=ModbusClientMixin.DATATYPE.FLOAT32, 351 | word_order="little", 352 | ), 353 | ) 354 | await self.async_update() 355 | 356 | 357 | class StorageDischargeLimit(SolarEdgeNumberBase): 358 | native_min_value = 0 359 | native_step = 1.0 360 | native_unit_of_measurement = UnitOfPower.WATT 361 | icon = "mdi:lightning-bolt" 362 | 363 | @property 364 | def unique_id(self) -> str: 365 | return f"{self._platform.uid_base}_storage_discharge_limit" 366 | 367 | @property 368 | def name(self) -> str: 369 | return "Storage Discharge Limit" 370 | 371 | @property 372 | def available(self) -> bool: 373 | try: 374 | if ( 375 | self._platform.decoded_storage_control is False 376 | or float_to_hex( 377 | self._platform.decoded_storage_control["discharge_limit"] 378 | ) 379 | == hex(SunSpecNotImpl.FLOAT32) 380 | or self._platform.decoded_storage_control["discharge_limit"] < 0 381 | ): 382 | return False 383 | 384 | # Available only in remote control mode 385 | return ( 386 | super().available 387 | and self._platform.decoded_storage_control["control_mode"] == 4 388 | ) 389 | 390 | except (TypeError, KeyError): 391 | return False 392 | 393 | @property 394 | def native_max_value(self) -> int: 395 | return BatteryLimit.DischargeMax 396 | 397 | @property 398 | def native_value(self) -> int: 399 | return int(self._platform.decoded_storage_control["discharge_limit"]) 400 | 401 | async def async_set_native_value(self, value: int) -> None: 402 | _LOGGER.debug(f"set {self.unique_id} to {value}") 403 | await self._platform.write_registers( 404 | address=57360, 405 | payload=ModbusClientMixin.convert_to_registers( 406 | int(value), 407 | data_type=ModbusClientMixin.DATATYPE.FLOAT32, 408 | word_order="little", 409 | ), 410 | ) 411 | await self.async_update() 412 | 413 | 414 | class SolarEdgeSiteLimit(SolarEdgeNumberBase): 415 | native_min_value = 0 416 | native_max_value = 1000000 417 | native_unit_of_measurement = UnitOfPower.WATT 418 | icon = "mdi:lightning-bolt" 419 | 420 | @property 421 | def unique_id(self) -> str: 422 | return f"{self._platform.uid_base}_site_limit" 423 | 424 | @property 425 | def name(self) -> str: 426 | return "Site Limit" 427 | 428 | @property 429 | def available(self) -> bool: 430 | try: 431 | if float_to_hex(self._platform.decoded_model["E_Site_Limit"]) == hex( 432 | SunSpecNotImpl.FLOAT32 433 | ): 434 | return False 435 | 436 | return super().available and ( 437 | (int(self._platform.decoded_model["E_Lim_Ctl_Mode"]) >> 0) & 1 438 | or (int(self._platform.decoded_model["E_Lim_Ctl_Mode"]) >> 1) & 1 439 | or (int(self._platform.decoded_model["E_Lim_Ctl_Mode"]) >> 2) & 1 440 | ) 441 | 442 | except (TypeError, KeyError): 443 | return False 444 | 445 | @property 446 | def native_value(self) -> int: 447 | if self._platform.decoded_model["E_Site_Limit"] < 0: 448 | return 0 449 | 450 | return int(self._platform.decoded_model["E_Site_Limit"]) 451 | 452 | async def async_set_native_value(self, value: int) -> None: 453 | _LOGGER.debug(f"set {self.unique_id} to {value}") 454 | await self._platform.write_registers( 455 | address=57346, 456 | payload=ModbusClientMixin.convert_to_registers( 457 | int(value), 458 | data_type=ModbusClientMixin.DATATYPE.FLOAT32, 459 | word_order="little", 460 | ), 461 | ) 462 | await self.async_update() 463 | 464 | 465 | class SolarEdgeExternalProductionMax(SolarEdgeNumberBase): 466 | native_min_value = 0 467 | native_max_value = 1000000 468 | native_unit_of_measurement = UnitOfPower.WATT 469 | icon = "mdi:lightning-bolt" 470 | 471 | @property 472 | def unique_id(self) -> str: 473 | return f"{self._platform.uid_base}_external_production_max" 474 | 475 | @property 476 | def name(self) -> str: 477 | return "External Production Max" 478 | 479 | @property 480 | def available(self) -> bool: 481 | try: 482 | if ( 483 | float_to_hex(self._platform.decoded_model["Ext_Prod_Max"]) 484 | == hex(SunSpecNotImpl.FLOAT32) 485 | or self._platform.decoded_model["Ext_Prod_Max"] < 0 486 | ): 487 | return False 488 | 489 | return ( 490 | super().available 491 | and (int(self._platform.decoded_model["E_Lim_Ctl_Mode"]) >> 10) & 1 492 | ) 493 | 494 | except (TypeError, KeyError): 495 | return False 496 | 497 | @property 498 | def entity_registry_enabled_default(self) -> bool: 499 | return False 500 | 501 | @property 502 | def native_value(self) -> int: 503 | return int(self._platform.decoded_model["Ext_Prod_Max"]) 504 | 505 | async def async_set_native_value(self, value: int) -> None: 506 | _LOGGER.debug(f"set {self.unique_id} to {value}") 507 | await self._platform.write_registers( 508 | address=57362, 509 | payload=ModbusClientMixin.convert_to_registers( 510 | int(value), 511 | data_type=ModbusClientMixin.DATATYPE.FLOAT32, 512 | word_order="little", 513 | ), 514 | ) 515 | await self.async_update() 516 | 517 | 518 | class SolarEdgeActivePowerLimitSet(SolarEdgeNumberBase): 519 | """Global Dynamic Power Control: Set Inverter Active Power Limit""" 520 | 521 | native_unit_of_measurement = PERCENTAGE 522 | native_min_value = 0 523 | native_max_value = 100 524 | mode = "slider" 525 | icon = "mdi:percent" 526 | 527 | @property 528 | def unique_id(self) -> str: 529 | return f"{self._platform.uid_base}_active_power_limit_set" 530 | 531 | @property 532 | def name(self) -> str: 533 | return "Active Power Limit" 534 | 535 | @property 536 | def entity_registry_enabled_default(self) -> bool: 537 | return self._platform.global_power_control 538 | 539 | @property 540 | def available(self) -> bool: 541 | try: 542 | if ( 543 | self._platform.decoded_model["I_Power_Limit"] == SunSpecNotImpl.UINT16 544 | or self._platform.decoded_model["I_Power_Limit"] > 100 545 | or self._platform.decoded_model["I_Power_Limit"] < 0 546 | ): 547 | return False 548 | 549 | return super().available 550 | 551 | except (TypeError, KeyError): 552 | return False 553 | 554 | @property 555 | def native_value(self) -> int: 556 | return self._platform.decoded_model["I_Power_Limit"] 557 | 558 | async def async_set_native_value(self, value: int) -> None: 559 | _LOGGER.debug(f"set {self.unique_id} to {value}") 560 | await self._platform.write_registers( 561 | address=61441, 562 | payload=ModbusClientMixin.convert_to_registers( 563 | int(value), 564 | data_type=ModbusClientMixin.DATATYPE.UINT16, 565 | word_order="little", 566 | ), 567 | ) 568 | await self.async_update() 569 | 570 | 571 | class SolarEdgeCosPhiSet(SolarEdgeNumberBase): 572 | """Global Dynamic Power Control: Set Inverter CosPhi""" 573 | 574 | native_min_value = -1.0 575 | native_max_value = 1.0 576 | native_step = 0.1 577 | mode = "slider" 578 | icon = "mdi:angle-acute" 579 | 580 | @property 581 | def unique_id(self) -> str: 582 | return f"{self._platform.uid_base}_cosphi_set" 583 | 584 | @property 585 | def name(self) -> str: 586 | return "CosPhi" 587 | 588 | @property 589 | def entity_registry_enabled_default(self) -> bool: 590 | return False 591 | 592 | @property 593 | def available(self) -> bool: 594 | try: 595 | if ( 596 | float_to_hex(self._platform.decoded_model["I_CosPhi"]) 597 | == hex(SunSpecNotImpl.FLOAT32) 598 | or self._platform.decoded_model["I_CosPhi"] > 1.0 599 | or self._platform.decoded_model["I_CosPhi"] < -1.0 600 | ): 601 | return False 602 | 603 | return super().available 604 | 605 | except (TypeError, KeyError): 606 | return False 607 | 608 | @property 609 | def native_value(self) -> float: 610 | return round(self._platform.decoded_model["I_CosPhi"], 1) 611 | 612 | async def async_set_native_value(self, value: float) -> None: 613 | _LOGGER.debug(f"set {self.unique_id} to {value}") 614 | await self._platform.write_registers( 615 | address=61442, 616 | payload=ModbusClientMixin.convert_to_registers( 617 | float(value), 618 | data_type=ModbusClientMixin.DATATYPE.FLOAT32, 619 | word_order="little", 620 | ), 621 | ) 622 | await self.async_update() 623 | 624 | 625 | class SolarEdgePowerReduce(SolarEdgeNumberBase): 626 | """Limits the inverter's maximum output power from 0-100%""" 627 | 628 | native_unit_of_measurement = PERCENTAGE 629 | native_min_value = 0 630 | native_max_value = 100 631 | mode = "slider" 632 | icon = "mdi:percent" 633 | 634 | @property 635 | def unique_id(self) -> str: 636 | return f"{self._platform.uid_base}_power_reduce" 637 | 638 | @property 639 | def name(self) -> str: 640 | return "Power Reduce" 641 | 642 | @property 643 | def entity_registry_enabled_default(self) -> bool: 644 | return False 645 | 646 | @property 647 | def available(self) -> bool: 648 | try: 649 | if ( 650 | float_to_hex(self._platform.decoded_model["PowerReduce"]) 651 | == hex(SunSpecNotImpl.FLOAT32) 652 | or self._platform.decoded_model["PowerReduce"] > 100 653 | or self._platform.decoded_model["PowerReduce"] < 0 654 | ): 655 | return False 656 | 657 | return super().available 658 | 659 | except (TypeError, KeyError): 660 | return False 661 | 662 | @property 663 | def native_value(self) -> int: 664 | return round(self._platform.decoded_model["PowerReduce"], 0) 665 | 666 | async def async_set_native_value(self, value: float) -> None: 667 | _LOGGER.debug(f"set {self.unique_id} to {value}") 668 | await self._platform.write_registers( 669 | address=61760, 670 | payload=ModbusClientMixin.convert_to_registers( 671 | float(value), 672 | data_type=ModbusClientMixin.DATATYPE.FLOAT32, 673 | word_order="little", 674 | ), 675 | ) 676 | await self.async_update() 677 | 678 | 679 | class SolarEdgeCurrentLimit(SolarEdgeNumberBase): 680 | """Limits the inverter's maximum output current.""" 681 | 682 | native_unit_of_measurement = UnitOfElectricCurrent.AMPERE 683 | native_min_value = 0 684 | native_max_value = 256 685 | icon = "mdi:current-ac" 686 | 687 | @property 688 | def unique_id(self) -> str: 689 | return f"{self._platform.uid_base}_max_current" 690 | 691 | @property 692 | def name(self) -> str: 693 | return "Current Limit" 694 | 695 | @property 696 | def entity_registry_enabled_default(self) -> bool: 697 | return False 698 | 699 | @property 700 | def available(self) -> bool: 701 | try: 702 | if ( 703 | float_to_hex(self._platform.decoded_model["MaxCurrent"]) 704 | == hex(SunSpecNotImpl.FLOAT32) 705 | or self._platform.decoded_model["MaxCurrent"] > 256 706 | or self._platform.decoded_model["MaxCurrent"] < 0 707 | ): 708 | return False 709 | 710 | return super().available 711 | 712 | except (TypeError, KeyError): 713 | return False 714 | 715 | @property 716 | def native_value(self) -> int: 717 | return round(self._platform.decoded_model["MaxCurrent"], 0) 718 | 719 | async def async_set_native_value(self, value: float) -> None: 720 | _LOGGER.debug(f"set {self.unique_id} to {value}") 721 | await self._platform.write_registers( 722 | address=61838, 723 | payload=ModbusClientMixin.convert_to_registers( 724 | float(value), 725 | data_type=ModbusClientMixin.DATATYPE.FLOAT32, 726 | word_order="little", 727 | ), 728 | ) 729 | await self.async_update() 730 | --------------------------------------------------------------------------------