├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── images │ │ ├── raw-logs.png │ │ ├── download-diagnostics.png │ │ └── enable-debug-logging.png │ ├── config.yml │ ├── feature_request.yml │ └── bug_report.yml ├── workflows │ ├── hacs.yml │ └── stale.yml └── verify_translation_strings.py ├── images ├── services.png ├── usb-device.png ├── energy-config.png ├── battery_sensors.png ├── inverter_sensors.png ├── network-settings.jpeg ├── optimizer_sensors.png ├── battery_configuration.png ├── network-configuration.png ├── power_meter_sensors.png ├── select-connection-type.png └── inverter_configuration_diagnostics.png ├── crowdin.yml ├── hacs.json ├── manifest.json ├── translations ├── pt-BR.json ├── es_ES.json └── ca_ES.json ├── icons.json ├── types.py ├── const.py ├── .gitignore ├── diagnostics.py ├── services.yaml ├── update_coordinator.py ├── README.md ├── select.py ├── switch.py ├── __init__.py ├── number.py └── config_flow.py /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: wlcrs 2 | -------------------------------------------------------------------------------- /images/services.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wlcrs/huawei_solar/HEAD/images/services.png -------------------------------------------------------------------------------- /images/usb-device.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wlcrs/huawei_solar/HEAD/images/usb-device.png -------------------------------------------------------------------------------- /images/energy-config.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wlcrs/huawei_solar/HEAD/images/energy-config.png -------------------------------------------------------------------------------- /crowdin.yml: -------------------------------------------------------------------------------- 1 | files: 2 | - source: /strings.json 3 | translation: /translations/%two_letters_code%.json -------------------------------------------------------------------------------- /images/battery_sensors.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wlcrs/huawei_solar/HEAD/images/battery_sensors.png -------------------------------------------------------------------------------- /images/inverter_sensors.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wlcrs/huawei_solar/HEAD/images/inverter_sensors.png -------------------------------------------------------------------------------- /images/network-settings.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wlcrs/huawei_solar/HEAD/images/network-settings.jpeg -------------------------------------------------------------------------------- /images/optimizer_sensors.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wlcrs/huawei_solar/HEAD/images/optimizer_sensors.png -------------------------------------------------------------------------------- /images/battery_configuration.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wlcrs/huawei_solar/HEAD/images/battery_configuration.png -------------------------------------------------------------------------------- /images/network-configuration.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wlcrs/huawei_solar/HEAD/images/network-configuration.png -------------------------------------------------------------------------------- /images/power_meter_sensors.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wlcrs/huawei_solar/HEAD/images/power_meter_sensors.png -------------------------------------------------------------------------------- /images/select-connection-type.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wlcrs/huawei_solar/HEAD/images/select-connection-type.png -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/images/raw-logs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wlcrs/huawei_solar/HEAD/.github/ISSUE_TEMPLATE/images/raw-logs.png -------------------------------------------------------------------------------- /images/inverter_configuration_diagnostics.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wlcrs/huawei_solar/HEAD/images/inverter_configuration_diagnostics.png -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Huawei Solar", 3 | "content_in_root": true, 4 | "render_readme": true, 5 | "homeassistant": "2025.9.0" 6 | } 7 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/images/download-diagnostics.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wlcrs/huawei_solar/HEAD/.github/ISSUE_TEMPLATE/images/download-diagnostics.png -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/images/enable-debug-logging.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wlcrs/huawei_solar/HEAD/.github/ISSUE_TEMPLATE/images/enable-debug-logging.png -------------------------------------------------------------------------------- /.github/workflows/hacs.yml: -------------------------------------------------------------------------------- 1 | name: hacs 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: "0 0 * * *" 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | validate: 14 | runs-on: "ubuntu-latest" 15 | steps: 16 | - uses: "actions/checkout@v3" 17 | - name: HACS validation 18 | uses: "hacs/action@main" 19 | with: 20 | category: "integration" 21 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "huawei_solar", 3 | "name": "Huawei Solar", 4 | "after_dependencies": [ 5 | "usb" 6 | ], 7 | "config_flow": true, 8 | "documentation": "https://github.com/wlcrs/huawei_solar/wiki", 9 | "issue_tracker": "https://github.com/wlcrs/huawei_solar/issues", 10 | "requirements": [ 11 | "huawei-solar>=3.0.0b1", 12 | "tmodbus>=0.2.3" 13 | ], 14 | "codeowners": [ 15 | "@wlcrs" 16 | ], 17 | "iot_class": "local_polling", 18 | "version": "2.0.0b2", 19 | "loggers": [ 20 | "huawei_solar", 21 | "tmodbus" 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Connectivity issues 4 | url: https://github.com/wlcrs/huawei_solar/discussions/categories/connecting-to-the-inverter 5 | about: Please do not use the issue tracker if you are experiencing connectivity issues. Use the 'Connecting to the inverter' Discussions category instead. 6 | - name: Configuration problems 7 | url: https://github.com/wlcrs/huawei_solar/discussions/categories/setting-up-the-integration 8 | about: Please do not use the issue tracker if you have issues to correctly configure and use this integration. Use the 'Setting up the integration' Discussions category instead. 9 | -------------------------------------------------------------------------------- /translations/pt-BR.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "already_configured": "O dispositivo já está configurado" 5 | }, 6 | "error": { 7 | "cannot_connect": "Falhou ao conectar", 8 | "invalid_auth": "Autenticação inválida", 9 | "invalid_slave_ids": "Os IDs escravos devem ser uma lista de inteiros separada por vírgulas", 10 | "read_error": "Falha na leitura do inversor.", 11 | "slave_cannot_connect": "Falha ao conectar ao escravo adicional", 12 | "unknown": "Erro inesperado" 13 | }, 14 | "step": { 15 | "login": { 16 | "data": { 17 | "password": "Senha", 18 | "username": "Nome de usuário" 19 | }, 20 | "description": "Por favor, insira as credenciais do instalador" 21 | }, 22 | "user": { 23 | "data": { 24 | "enable_parameter_configuration": "Avançado: habilitar configuração de parâmetros", 25 | "host": "Host", 26 | "port": "Porta", 27 | "slave_ids": "IDs de escravos (separados por vírgulas)" 28 | } 29 | } 30 | } 31 | } 32 | } -------------------------------------------------------------------------------- /icons.json: -------------------------------------------------------------------------------- 1 | { 2 | "services": { 3 | "forcible_charge": { 4 | "service": "mdi:battery-arrow-up" 5 | }, 6 | "forcible_discharge": { 7 | "service": "mdi:battery-arrow-down" 8 | }, 9 | "forcible_charge_soc": { 10 | "service": "mdi:battery-arrow-up" 11 | }, 12 | "forcible_discharge_soc": { 13 | "service": "mdi:battery-arrow-down" 14 | }, 15 | "stop_forcible_charge": { 16 | "service": "mdi:battery-off" 17 | }, 18 | "reset_maximum_feed_grid_power": { 19 | "service": "mdi:transmission-tower" 20 | }, 21 | "set_di_active_power_scheduling": { 22 | "service": "mdi:calendar-clock" 23 | }, 24 | "set_zero_power_grid_connection": { 25 | "service": "mdi:transmission-tower" 26 | }, 27 | "set_maximum_feed_grid_power": { 28 | "service": "mdi:transmission-tower-export" 29 | }, 30 | "set_maximum_feed_grid_power_percent": { 31 | "service": "mdi:transmission-tower-export" 32 | }, 33 | "set_tou_periods": { 34 | "service": "mdi:battery-clock" 35 | }, 36 | "set_capacity_control_periods": { 37 | "service": "mdi:battery-clock" 38 | }, 39 | "set_fixed_charge_periods": { 40 | "service": "mdi:battery-clock" 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: Feature request 2 | description: Suggest an idea for this project 3 | title: "[Feature Request]: " 4 | labels: ["enhancement"] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | > [!CAUTION] 10 | > Please do not abuse this form to submit a problem that you are experiencing with installing, configuring or using this integration. 11 | > 12 | > * For connectivity problems: use the [Connecting to the inverter](https://github.com/wlcrs/huawei_solar/discussions/categories/connecting-to-the-inverter) discussion category 13 | > * For configuration questions: use the [Setting up the integration](https://github.com/wlcrs/huawei_solar/discussions/categories/setting-up-the-integration) discussion category 14 | > * For crashes: fill out the complete [Bug report](https://github.com/wlcrs/huawei_solar/issues/new?template=bug_report.yml) issue template 15 | > 16 | > Your issue will be closed immediately without any reaction if you abuse this form. 17 | 18 | ### Scope of this integration 19 | 20 | This integration aims to expose the information and functions made available by Huawei Solar inverters directly **over Modbus** in Home Assistant. 21 | 22 | It does **NOT** do any interpretation of - or calculations with - this data. It does **NOT** interact with FusionSolar. 23 | 24 | - type: textarea 25 | attributes: 26 | label: "Describe your feature request" 27 | placeholder: Please mention the relevant Modbus registers from the Huawei "Solar Inverter Modbus Interface Definitions" PDF in your request. 28 | validations: 29 | required: true 30 | 31 | - type: checkboxes 32 | id: no-bugreport 33 | attributes: 34 | label: "Proper usage" 35 | options: 36 | - label: I confirm that this is not a bug report or support request 37 | required: true 38 | - label: I confirm that this feature request is within the stated scope of the integration 39 | required: true 40 | -------------------------------------------------------------------------------- /types.py: -------------------------------------------------------------------------------- 1 | """Typing for the Huawei Solar integration.""" 2 | 3 | from dataclasses import dataclass 4 | from typing import TypedDict, cast 5 | 6 | from huawei_solar import HuaweiSolarDevice, RegisterName, SUN2000Device 7 | 8 | from homeassistant.config_entries import ConfigEntry 9 | from homeassistant.helpers.device_registry import DeviceInfo 10 | from homeassistant.helpers.entity import Entity, EntityDescription 11 | 12 | from .update_coordinator import ( 13 | HuaweiSolarOptimizerUpdateCoordinator, 14 | HuaweiSolarUpdateCoordinator, 15 | ) 16 | 17 | 18 | @dataclass 19 | class HuaweiSolarDeviceData: 20 | """Runtime data for the Huawei Solar integration.""" 21 | 22 | device: HuaweiSolarDevice 23 | device_info: DeviceInfo 24 | update_coordinator: HuaweiSolarUpdateCoordinator 25 | configuration_update_coordinator: HuaweiSolarUpdateCoordinator | None 26 | 27 | 28 | @dataclass 29 | class HuaweiSolarInverterData(HuaweiSolarDeviceData): 30 | """Runtime data for the Huawei Solar integration for SUN2000 inverter devices.""" 31 | 32 | device: SUN2000Device 33 | 34 | power_meter: DeviceInfo | None 35 | connected_energy_storage: DeviceInfo | None 36 | battery_1: DeviceInfo | None 37 | battery_2: DeviceInfo | None 38 | optimizer_device_infos: dict[int, DeviceInfo] | None 39 | 40 | power_meter_update_coordinator: HuaweiSolarUpdateCoordinator | None 41 | energy_storage_update_coordinator: HuaweiSolarUpdateCoordinator | None 42 | optimizer_update_coordinator: HuaweiSolarOptimizerUpdateCoordinator | None 43 | 44 | 45 | type HuaweiSolarConfigEntry = ConfigEntry[HuaweiSolarData] 46 | 47 | 48 | class HuaweiSolarData(TypedDict): 49 | """Data for each Huawei Solar config entry.""" 50 | 51 | device_datas: list[HuaweiSolarDeviceData] 52 | 53 | 54 | class HuaweiSolarEntity(Entity): 55 | """Huawei Solar Entity.""" 56 | 57 | _attr_has_entity_name = True 58 | 59 | 60 | class HuaweiSolarEntityDescription(EntityDescription): 61 | """Huawei Solar Entity Description.""" 62 | 63 | @property 64 | def register_name(self) -> RegisterName: 65 | """Return the register name.""" 66 | return cast("RegisterName", self.key) 67 | 68 | 69 | class HuaweiSolarEntityContext(TypedDict): 70 | """Context for Huawei Solar Entities.""" 71 | 72 | register_names: list[RegisterName] 73 | -------------------------------------------------------------------------------- /const.py: -------------------------------------------------------------------------------- 1 | """Constants for the Huawei Solar integration.""" 2 | 3 | from datetime import timedelta 4 | 5 | DOMAIN = "huawei_solar" 6 | DEFAULT_PORT = 502 7 | DEFAULT_SLAVE_ID = 0 8 | DEFAULT_SERIAL_SLAVE_ID = 1 9 | DEFAULT_USERNAME = "installer" 10 | DEFAULT_PASSWORD = "00000a" 11 | 12 | CONF_SLAVE_IDS = "slave_ids" 13 | CONF_ENABLE_PARAMETER_CONFIGURATION = "enable_parameter_configuration" 14 | 15 | DATA_DEVICE_DATAS = "device_datas" 16 | DATA_UPDATE_COORDINATORS = "update_coordinators" 17 | 18 | INVERTER_UPDATE_INTERVAL = timedelta(seconds=30) 19 | POWER_METER_UPDATE_INTERVAL = timedelta(seconds=30) 20 | ENERGY_STORAGE_UPDATE_INTERVAL = timedelta(seconds=30) 21 | UPDATE_TIMEOUT = timedelta(seconds=29) 22 | # configuration can only change when edited through FusionSolar web or app 23 | CONFIGURATION_UPDATE_INTERVAL = timedelta(minutes=15) 24 | CONFIGURATION_UPDATE_TIMEOUT = timedelta(minutes=1) 25 | # optimizer data is only refreshed every 5 minutes by the inverter. 26 | OPTIMIZER_UPDATE_INTERVAL = timedelta(minutes=5) 27 | OPTIMIZER_UPDATE_TIMEOUT = timedelta(minutes=1) 28 | 29 | SERVICE_FORCIBLE_CHARGE = "forcible_charge" 30 | SERVICE_FORCIBLE_DISCHARGE = "forcible_discharge" 31 | SERVICE_FORCIBLE_CHARGE_SOC = "forcible_charge_soc" 32 | SERVICE_FORCIBLE_DISCHARGE_SOC = "forcible_discharge_soc" 33 | SERVICE_STOP_FORCIBLE_CHARGE = "stop_forcible_charge" 34 | 35 | SERVICE_RESET_MAXIMUM_FEED_GRID_POWER = "reset_maximum_feed_grid_power" 36 | SERVICE_SET_DI_ACTIVE_POWER_SCHEDULING = "set_di_active_power_scheduling" 37 | SERVICE_SET_ZERO_POWER_GRID_CONNECTION = "set_zero_power_grid_connection" 38 | SERVICE_SET_MAXIMUM_FEED_GRID_POWER = "set_maximum_feed_grid_power" 39 | SERVICE_SET_MAXIMUM_FEED_GRID_POWER_PERCENT = "set_maximum_feed_grid_power_percent" 40 | SERVICE_SET_TOU_PERIODS = "set_tou_periods" 41 | SERVICE_SET_CAPACITY_CONTROL_PERIODS = "set_capacity_control_periods" 42 | SERVICE_SET_FIXED_CHARGE_PERIODS = "set_fixed_charge_periods" 43 | 44 | SERVICES = ( 45 | SERVICE_FORCIBLE_CHARGE, 46 | SERVICE_FORCIBLE_DISCHARGE, 47 | SERVICE_FORCIBLE_CHARGE_SOC, 48 | SERVICE_FORCIBLE_DISCHARGE_SOC, 49 | SERVICE_STOP_FORCIBLE_CHARGE, 50 | SERVICE_RESET_MAXIMUM_FEED_GRID_POWER, 51 | SERVICE_SET_DI_ACTIVE_POWER_SCHEDULING, 52 | SERVICE_SET_ZERO_POWER_GRID_CONNECTION, 53 | SERVICE_SET_MAXIMUM_FEED_GRID_POWER, 54 | SERVICE_SET_TOU_PERIODS, 55 | SERVICE_SET_CAPACITY_CONTROL_PERIODS, 56 | SERVICE_SET_FIXED_CHARGE_PERIODS, 57 | ) 58 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: "Mark or close stale issues and PRs" 2 | permissions: 3 | contents: read 4 | issues: write 5 | pull-requests: write 6 | on: 7 | schedule: 8 | - cron: "0 0 * * *" 9 | 10 | jobs: 11 | stale: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/stale@1160a2240286f5da8ec72b1c0816ce2481aabf84 # v8.0.0 15 | with: 16 | repo-token: ${{ secrets.GITHUB_TOKEN }} 17 | # Staling issues and PR's 18 | days-before-stale: 30 19 | stale-issue-label: stale 20 | stale-pr-label: stale 21 | stale-issue-message: | 22 | This issue has been automatically marked as stale because it has been open 30 days 23 | with no activity. Remove stale label or comment or this issue will be closed in 10 days 24 | stale-pr-message: | 25 | This PR has been automatically marked as stale because it has been open 30 days 26 | with no activity. Remove stale label or comment or this PR will be closed in 10 days 27 | # Not stale if have this labels or part of milestone 28 | exempt-issue-labels: wip,on-hold 29 | exempt-pr-labels: wip,on-hold 30 | exempt-all-milestones: true 31 | # Close issue operations 32 | # Label will be automatically removed if the issues are no longer closed nor locked. 33 | days-before-close: 10 34 | delete-branch: true 35 | close-issue-message: This issue was automatically closed because of stale in 10 days 36 | close-pr-message: This PR was automatically closed because of stale in 10 days 37 | - name: "Manage 'incomplete' issues" 38 | uses: actions/stale@1160a2240286f5da8ec72b1c0816ce2481aabf84 # v8.0.0 39 | with: 40 | repo-token: ${{ secrets.GITHUB_TOKEN }} 41 | 42 | # Configuration for 'incomplete' issues 43 | days-before-stale: 4 44 | days-before-close: 3 45 | 46 | # KEY SETTING: Limit this step ONLY to issues with this label 47 | only-labels: incomplete 48 | 49 | # Reuse the existing stale label or use a unique one 50 | stale-issue-label: stale 51 | 52 | # Disable PR processing for this step (since you only mentioned Issues) 53 | days-before-pr-stale: -1 54 | days-before-pr-close: -1 55 | 56 | # Custom messages for this specific rule 57 | stale-issue-message: | 58 | This issue is marked 'incomplete' and has been inactive for 4 days. 59 | Please provide the missing information or this will be closed in 3 days. 60 | close-issue-message: | 61 | This incomplete issue was closed due to lack of activity. 62 | -------------------------------------------------------------------------------- /diagnostics.py: -------------------------------------------------------------------------------- 1 | """Diagnostics support for Huawei Solar.""" 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.const import CONF_PASSWORD 9 | from homeassistant.core import HomeAssistant 10 | 11 | from .const import DATA_DEVICE_DATAS 12 | from .types import ( 13 | HuaweiSolarConfigEntry, 14 | HuaweiSolarDeviceData, 15 | HuaweiSolarInverterData, 16 | ) 17 | 18 | TO_REDACT = {CONF_PASSWORD} 19 | 20 | 21 | async def async_get_config_entry_diagnostics( 22 | hass: HomeAssistant, entry: HuaweiSolarConfigEntry 23 | ) -> dict[str, Any]: 24 | """Return diagnostics for a config entry.""" 25 | device_datas: list[HuaweiSolarDeviceData] = entry.runtime_data[DATA_DEVICE_DATAS] 26 | 27 | diagnostics_data = { 28 | "config_entry_data": async_redact_data(dict(entry.data), TO_REDACT), 29 | } 30 | for dd in device_datas: 31 | if isinstance(dd, HuaweiSolarInverterData): 32 | diagnostics_data[f"device_{dd.device.client.unit_id}"] = { 33 | "_type": "SUN2000", 34 | "model_name": dd.device.model_name, 35 | "firmware_version": dd.device.firmware_version, 36 | "software_version": dd.device.software_version, 37 | "pv_string_count": dd.device.pv_string_count, 38 | "has_optimizers": dd.device.has_optimizers, 39 | "battery_type": dd.device.battery_type, 40 | "battery_1_type": dd.device.battery_1_type, 41 | "battery_2_type": dd.device.battery_2_type, 42 | "power_meter_type": dd.device.power_meter_type, 43 | "supports_capacity_control": dd.device.supports_capacity_control, 44 | } 45 | 46 | if dd.power_meter_update_coordinator: 47 | diagnostics_data[ 48 | f"device_{dd.device.client.unit_id}_power_meter_data" 49 | ] = dd.power_meter_update_coordinator.data 50 | 51 | if dd.energy_storage_update_coordinator: 52 | diagnostics_data[f"device_{dd.device.client.unit_id}_battery_data"] = ( 53 | dd.energy_storage_update_coordinator.data 54 | ) 55 | 56 | if dd.optimizer_update_coordinator: 57 | diagnostics_data[ 58 | f"device_{dd.device.client.unit_id}_optimizer_data" 59 | ] = dd.optimizer_update_coordinator.data 60 | else: 61 | diagnostics_data[f"device_{dd.device.client.unit_id}"] = { 62 | "_type": type(dd.device).__name__, 63 | "model_name": dd.device.model_name, 64 | "serial_number": dd.device.serial_number, 65 | } 66 | 67 | diagnostics_data[f"device_{dd.device.client.unit_id}_data"] = ( 68 | dd.update_coordinator.data 69 | ) 70 | 71 | if dd.configuration_update_coordinator: 72 | diagnostics_data[f"device_{dd.device.client.unit_id}_config_data"] = ( 73 | dd.configuration_update_coordinator.data 74 | ) 75 | 76 | return diagnostics_data 77 | -------------------------------------------------------------------------------- /services.yaml: -------------------------------------------------------------------------------- 1 | forcible_charge: 2 | fields: 3 | device_id: 4 | required: true 5 | selector: 6 | device: 7 | integration: huawei_solar 8 | model: "Batteries" 9 | duration: 10 | required: true 11 | default: 60 12 | selector: 13 | number: 14 | min: 1 15 | max: 1440 16 | unit_of_measurement: "minutes" 17 | mode: box 18 | power: 19 | required: true 20 | default: 1000 21 | selector: 22 | text: 23 | 24 | forcible_discharge: 25 | fields: 26 | device_id: 27 | required: true 28 | selector: 29 | device: 30 | integration: huawei_solar 31 | model: "Batteries" 32 | duration: 33 | required: true 34 | default: 60 35 | selector: 36 | number: 37 | min: 1 38 | max: 1440 39 | unit_of_measurement: "minutes" 40 | mode: box 41 | power: 42 | required: true 43 | default: 1000 44 | selector: 45 | text: 46 | 47 | forcible_charge_soc: 48 | fields: 49 | device_id: 50 | required: true 51 | selector: 52 | device: 53 | integration: huawei_solar 54 | model: "Batteries" 55 | target_soc: 56 | required: true 57 | default: 50 58 | selector: 59 | number: 60 | min: 12 61 | max: 100 62 | unit_of_measurement: "%" 63 | power: 64 | required: true 65 | default: 1000 66 | selector: 67 | text: 68 | 69 | forcible_discharge_soc: 70 | fields: 71 | device_id: 72 | required: true 73 | selector: 74 | device: 75 | integration: huawei_solar 76 | model: "Batteries" 77 | target_soc: 78 | required: true 79 | default: 15 80 | selector: 81 | number: 82 | min: 12 83 | max: 100 84 | unit_of_measurement: "%" 85 | power: 86 | required: true 87 | default: 1000 88 | selector: 89 | text: 90 | 91 | stop_forcible_charge: 92 | fields: 93 | device_id: 94 | required: true 95 | selector: 96 | device: 97 | integration: huawei_solar 98 | model: "Batteries" 99 | 100 | reset_maximum_feed_grid_power: 101 | fields: 102 | device_id: 103 | required: true 104 | selector: 105 | device: 106 | integration: huawei_solar 107 | 108 | set_di_active_power_scheduling: 109 | fields: 110 | device_id: 111 | required: true 112 | selector: 113 | device: 114 | integration: huawei_solar 115 | 116 | set_zero_power_grid_connection: 117 | fields: 118 | device_id: 119 | required: true 120 | selector: 121 | device: 122 | integration: huawei_solar 123 | 124 | set_maximum_feed_grid_power: 125 | fields: 126 | device_id: 127 | required: true 128 | selector: 129 | device: 130 | integration: huawei_solar 131 | power: 132 | required: true 133 | default: 0 134 | selector: 135 | text: 136 | 137 | set_maximum_feed_grid_power_percent: 138 | fields: 139 | device_id: 140 | required: true 141 | selector: 142 | device: 143 | integration: huawei_solar 144 | power_percentage: 145 | required: true 146 | default: 0 147 | selector: 148 | number: 149 | min: 0 150 | max: 100 151 | unit_of_measurement: "%" 152 | 153 | set_tou_periods: 154 | fields: 155 | device_id: 156 | required: true 157 | selector: 158 | device: 159 | integration: huawei_solar 160 | periods: 161 | required: true 162 | selector: 163 | text: 164 | multiline: true 165 | 166 | set_capacity_control_periods: 167 | fields: 168 | device_id: 169 | required: true 170 | selector: 171 | device: 172 | integration: huawei_solar 173 | periods: 174 | required: true 175 | selector: 176 | text: 177 | multiline: true 178 | 179 | set_fixed_charge_periods: 180 | fields: 181 | device_id: 182 | required: true 183 | selector: 184 | device: 185 | integration: huawei_solar 186 | periods: 187 | required: true 188 | selector: 189 | text: 190 | multiline: true 191 | -------------------------------------------------------------------------------- /update_coordinator.py: -------------------------------------------------------------------------------- 1 | """Specialized DataUpdateCoordinators for Huawei Solar entities.""" 2 | 3 | import asyncio 4 | from collections.abc import Awaitable, Callable 5 | from datetime import timedelta 6 | from itertools import chain 7 | import logging 8 | from typing import Any 9 | 10 | from huawei_solar import HuaweiSolarException, RegisterName, Result, SUN2000Device 11 | from huawei_solar.device.base import HuaweiSolarDevice 12 | from huawei_solar.files import OptimizerRealTimeData 13 | 14 | from homeassistant.core import HomeAssistant 15 | from homeassistant.helpers.debounce import Debouncer 16 | from homeassistant.helpers.device_registry import DeviceInfo 17 | from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed 18 | 19 | from .const import OPTIMIZER_UPDATE_TIMEOUT, UPDATE_TIMEOUT 20 | 21 | _LOGGER = logging.getLogger(__name__) 22 | 23 | 24 | class HuaweiSolarUpdateCoordinator( 25 | DataUpdateCoordinator[dict[RegisterName, Result[Any]]] 26 | ): 27 | """A specialised DataUpdateCoordinator for Huawei Solar entities.""" 28 | 29 | device: HuaweiSolarDevice 30 | 31 | def __init__( 32 | self, 33 | hass: HomeAssistant, 34 | logger: logging.Logger, 35 | device: HuaweiSolarDevice, 36 | name: str, 37 | update_interval: timedelta | None = None, 38 | update_method: Callable[[], Awaitable[dict[RegisterName, Result[Any]]]] 39 | | None = None, 40 | request_refresh_debouncer: Debouncer | None = None, 41 | update_timeout: timedelta = UPDATE_TIMEOUT, 42 | ) -> None: 43 | """Create a HuaweiSolarUpdateCoordinator.""" 44 | super().__init__( 45 | hass, 46 | logger, 47 | name=name, 48 | update_interval=update_interval, 49 | update_method=update_method, 50 | request_refresh_debouncer=request_refresh_debouncer, 51 | ) 52 | self.device = device 53 | self.update_timeout = update_timeout 54 | 55 | async def _async_update_data(self) -> dict[RegisterName, Result[Any]]: 56 | register_names_set = set( 57 | chain.from_iterable(ctx["register_names"] for ctx in self.async_contexts()) 58 | ) 59 | try: 60 | async with asyncio.timeout(self.update_timeout.total_seconds()): 61 | return await self.device.batch_update(list(register_names_set)) 62 | except HuaweiSolarException as err: 63 | raise UpdateFailed( 64 | f"Could not update {self.device.serial_number} values: {err}" 65 | ) from err 66 | 67 | 68 | class HuaweiSolarOptimizerUpdateCoordinator( 69 | DataUpdateCoordinator[dict[int, OptimizerRealTimeData]] 70 | ): 71 | """A specialised DataUpdateCoordinator for Huawei Solar optimizers.""" 72 | 73 | def __init__( 74 | self, 75 | hass: HomeAssistant, 76 | logger: logging.Logger, 77 | device: SUN2000Device, 78 | optimizer_device_infos: dict[int, DeviceInfo], 79 | name: str, 80 | update_interval: timedelta | None = None, 81 | request_refresh_debouncer: Debouncer | None = None, 82 | ) -> None: 83 | """Create a HuaweiSolarRegisterUpdateCoordinator.""" 84 | super().__init__( 85 | hass, 86 | logger, 87 | name=name, 88 | update_interval=update_interval, 89 | request_refresh_debouncer=request_refresh_debouncer, 90 | ) 91 | self.device = device 92 | self.optimizer_device_infos = optimizer_device_infos 93 | 94 | async def _async_update_data(self) -> dict[int, OptimizerRealTimeData]: 95 | """Retrieve the latest values from the optimizers.""" 96 | try: 97 | async with asyncio.timeout(OPTIMIZER_UPDATE_TIMEOUT.total_seconds()): 98 | return await self.device.get_latest_optimizer_history_data() 99 | except HuaweiSolarException as err: 100 | raise UpdateFailed( 101 | f"Could not update {self.device.serial_number} optimizer values: {err}" 102 | ) from err 103 | 104 | 105 | async def create_optimizer_update_coordinator( 106 | hass: HomeAssistant, 107 | device: SUN2000Device, 108 | optimizer_device_infos: dict[int, DeviceInfo], 109 | update_interval: timedelta | None, 110 | ) -> HuaweiSolarOptimizerUpdateCoordinator: 111 | """Create and refresh entities of an HuaweiSolarOptimizerUpdateCoordinator.""" 112 | 113 | coordinator = HuaweiSolarOptimizerUpdateCoordinator( 114 | hass, 115 | _LOGGER, 116 | device=device, 117 | optimizer_device_infos=optimizer_device_infos, 118 | name=f"{device.serial_number}_optimizer_data_update_coordinator", 119 | update_interval=update_interval, 120 | ) 121 | 122 | await coordinator.async_config_entry_first_refresh() 123 | 124 | return coordinator 125 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug report 2 | description: Is the integration crashing, or behaving incorrectly? Create a report to help us improve. Do not use for connectivity problems. 3 | title: "[Bug]: " 4 | labels: ["bug"] 5 | body: 6 | 7 | - type: markdown 8 | attributes: 9 | value: | 10 | 11 | > [!CAUTION] 12 | > Provide **all** the requested information. 13 | > 14 | > Failing to do so will result in your bug report being closed as incomplete. 15 | 16 | 17 | - type: textarea 18 | attributes: 19 | label: "Describe the issue" 20 | description: "A clear and concise description of what the issue is." 21 | validations: 22 | required: true 23 | 24 | - type: textarea 25 | attributes: 26 | label: "Describe your Huawei Solar Setup" 27 | description: "Please be thorough when describing your setup" 28 | value: | 29 | Inverter Type: 30 | Inverter Firmware version: 31 | sDongle present: Yes / No 32 | sDongle Type: sDongleA-05 (WiFi / Ethernet) / SDongleA-03 (4G) 33 | sDongle Connectivitiy: WiFi / Ethernet / 4G 34 | sDongle Firmware: 35 | Power meter present: three phase / single phase / no 36 | Optimizers Present: Yes / No 37 | Battery: LUNA2000-SO xxkWh / LUNA2000-S1 xxkWh (Released 2024) / LG RESU xxkWh / None 38 | Battery Firmware version: 39 | Huawei Solar integration version: 40 | validations: 41 | required: true 42 | 43 | - type: dropdown 44 | id: connection_method 45 | attributes: 46 | label: How do you connect to the inverter? 47 | options: 48 | - Please select your connection method 49 | - Over serial, with a serial-to-USB stick 50 | - Over serial, with a serial-to-WiFi stick 51 | - Via the `SUN2000-` WiFi 52 | - Via the SDongle, wireless connection 53 | - Via the SDongle, wired connection 54 | - Via EMMA, wireless connection 55 | - Via EMMA, wired connection 56 | - Via SmartLogger 57 | validations: 58 | required: true 59 | 60 | - type: textarea 61 | attributes: 62 | label: "Upload your Diagnostics File" 63 | description: | 64 | ![Download diagnostics menu entry](https://raw.githubusercontent.com/wlcrs/huawei_solar/main/.github/ISSUE_TEMPLATE/images/download-diagnostics.png) 65 | 1. Go to the 'Huawei Solar' integration settings page [![Open your Home Assistant instance and show an integration.](https://my.home-assistant.io/badges/integration.svg)](https://my.home-assistant.io/redirect/integration/?domain=huawei-solar) 66 | 2. Click on the three dots next to the integration entry 67 | 3. Select 'Download diagnostis' 68 | 4. Click on the textarea below, drag & drop the file into the textarea to upload it. 69 | value: Drag & Drop your Diagnostics File here. 70 | validations: 71 | required: true 72 | 73 | - type: markdown 74 | attributes: 75 | value: | 76 | ## Relevant debug logs 77 | 78 | > [!IMPORTANT] 79 | > Just copy&pasting the error from the HA `System → Logs` page is insufficient in 95% of the cases. 80 | > 81 | > **You MUST gather debugging logs.** 82 | 83 | 1. Enable debug logging 84 | ![Enable Debug Logging menu entry](https://raw.githubusercontent.com/wlcrs/huawei_solar/main/.github/ISSUE_TEMPLATE/images/enable-debug-logging.png) 85 | 2. Reload the integration 86 | 3. Perform the action that triggers the error (if applicable) 87 | 4. Stop debug logging: a text file will be downloaded. 88 | 89 | Full instructions here: ['Debug Logs and Diagnostics' from the HA documentation](https://www.home-assistant.io/docs/configuration/troubleshooting/#debug-logs-and-diagnostics) 90 | 91 | If you are having problems during initial setup of this integration, you'll need to manually gather these logs. To do this, add the following lines to your `/config/configuration.yaml` and restart HA: 92 | 93 | ```yaml 94 | logger: 95 | default: debug 96 | logs: 97 | custom_components.huawei_solar: debug 98 | huawei_solar: debug 99 | tmodbus: debug # only include this if you're having connectivity issues 100 | ``` 101 | 102 | Make sure to check for relevant log lines in the **Full logs** on the Logs settings page [![Open your Home Assistant instance and show your Home Assistant logs.](https://my.home-assistant.io/badges/logs.svg)](https://my.home-assistant.io/redirect/logs/?) 103 | 104 | > [!IMPORTANT] 105 | > Do NOT copy&paste info from the summary that you see when going to `System → Logs`, always use "Show raw logs" and look there. 106 | > ![Raw logs](https://raw.githubusercontent.com/wlcrs/huawei_solar/main/.github/ISSUE_TEMPLATE/images/raw-logs.png) 107 | 108 | - type: textarea 109 | attributes: 110 | label: Upload your relevant debug logs 111 | render: text 112 | validations: 113 | required: true 114 | 115 | - type: checkboxes 116 | id: checks 117 | attributes: 118 | label: "Please confirm the following:" 119 | options: 120 | - label: I'm running the latest release of Home Assistant. 121 | - label: I'm running the latest release of this integration. 122 | - label: I did not find an existing issue describing this problem. 123 | - label: I did upload the diagnostics-file that I could retrieve from the 'Devices & Services Page' 124 | - label: I did upload the relevant debug logs (via 'Enable Debug Logging'-feature or by manually configuring HA logging) 125 | - label: I understand that skipping any of the above fields can result in my issue being closed as incomplete 126 | 127 | 128 | 129 | -------------------------------------------------------------------------------- /.github/verify_translation_strings.py: -------------------------------------------------------------------------------- 1 | """Verify that all entity keys have corresponding translation strings.""" 2 | 3 | from __future__ import annotations 4 | 5 | import ast 6 | from collections.abc import Iterator 7 | import json 8 | from pathlib import Path 9 | import re 10 | import sys 11 | 12 | 13 | def normalize_translation_key(raw_key: str) -> str: 14 | """Normalise the key to the expected translation key format.""" 15 | 16 | return ( 17 | raw_key.replace("#", "_") 18 | .replace("-", "_") 19 | .replace(" ", "_") 20 | .replace("/", "_") 21 | .lower() 22 | ) 23 | 24 | 25 | def get_call_name(func: ast.AST) -> str | None: 26 | """Return the textual name for a call node.""" 27 | 28 | if isinstance(func, ast.Name): 29 | return func.id 30 | if isinstance(func, ast.Attribute): 31 | return func.attr 32 | return None 33 | 34 | 35 | def dotted_path(node: ast.AST) -> list[str] | None: 36 | """Return the dotted path represented by a node.""" 37 | 38 | parts: list[str] = [] 39 | current: ast.AST = node 40 | 41 | while isinstance(current, ast.Attribute): 42 | parts.append(current.attr) 43 | current = current.value 44 | 45 | if isinstance(current, ast.Name): 46 | parts.append(current.id) 47 | else: 48 | return None 49 | 50 | parts.reverse() 51 | return parts 52 | 53 | 54 | def attribute_to_key(node: ast.Attribute, register_aliases: set[str]) -> str | None: 55 | """Extract attribute name if it originates from register_names alias.""" 56 | 57 | names = dotted_path(node) 58 | if not names: 59 | return None 60 | 61 | base, *rest = names 62 | if base not in register_aliases or not rest: 63 | return None 64 | 65 | for attr in reversed(rest): 66 | if attr != "value": 67 | return attr 68 | return None 69 | 70 | 71 | class EntityKeyCollector(ast.NodeVisitor): 72 | """Collect translation keys referenced in entity descriptions within a module.""" 73 | 74 | def __init__(self, register_aliases: set[str]) -> None: 75 | """Initialise an empty collector.""" 76 | 77 | self.entity_keys: set[str] = set() 78 | self.register_aliases = register_aliases 79 | self._binding_stack: list[dict[str, str]] = [{}] 80 | 81 | def _push_scope(self) -> None: 82 | self._binding_stack.append({}) 83 | 84 | def _pop_scope(self) -> None: 85 | self._binding_stack.pop() 86 | 87 | def _add_binding(self, name: str, raw_key: str) -> None: 88 | self._binding_stack[-1][name] = raw_key 89 | 90 | def _lookup_binding(self, name: str) -> str | None: 91 | for scope in reversed(self._binding_stack): 92 | if name in scope: 93 | return scope[name] 94 | return None 95 | 96 | def _resolve_node(self, node: ast.AST) -> str | None: 97 | if isinstance(node, ast.Attribute): 98 | return attribute_to_key(node, self.register_aliases) 99 | if isinstance(node, ast.Constant) and isinstance(node.value, str): 100 | return node.value 101 | if isinstance(node, ast.Name): 102 | return self._lookup_binding(node.id) 103 | return None 104 | 105 | def _bind_function_defaults( 106 | self, node: ast.FunctionDef | ast.AsyncFunctionDef 107 | ) -> None: 108 | positional_args = list(node.args.posonlyargs) + list(node.args.args) 109 | defaults = node.args.defaults 110 | if defaults: 111 | for arg, default in zip( 112 | positional_args[-len(defaults) :], defaults, strict=False 113 | ): 114 | if key := self._resolve_node(default): 115 | self._add_binding(arg.arg, key) 116 | 117 | for kw_arg, default in zip( 118 | node.args.kwonlyargs, node.args.kw_defaults, strict=False 119 | ): 120 | if default is None: 121 | continue 122 | if key := self._resolve_node(default): 123 | self._add_binding(kw_arg.arg, key) 124 | 125 | def visit_Call(self, node: ast.Call) -> None: 126 | """Inspect call nodes for entity descriptions and record their keys.""" 127 | 128 | call_name = get_call_name(node.func) 129 | if call_name and call_name.endswith("EntityDescription"): 130 | # Skip 'key' if 'translation_key' is explicitly provided 131 | has_translation_key = any( 132 | kw.arg == "translation_key" for kw in node.keywords 133 | ) 134 | for keyword in node.keywords: 135 | if keyword.arg == "key": 136 | if has_translation_key: 137 | continue 138 | elif keyword.arg != "translation_key": 139 | continue 140 | 141 | if raw_key := self._resolve_node(keyword.value): 142 | self.entity_keys.add(normalize_translation_key(raw_key)) 143 | # Continue traversal to handle nested calls 144 | self.generic_visit(node) 145 | 146 | def visit_FunctionDef(self, node: ast.FunctionDef) -> None: 147 | """Track bindings within a synchronous function scope.""" 148 | 149 | self._push_scope() 150 | self._bind_function_defaults(node) 151 | self.generic_visit(node) 152 | self._pop_scope() 153 | 154 | def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None: 155 | """Track bindings within an asynchronous function scope.""" 156 | 157 | self._push_scope() 158 | self._bind_function_defaults(node) 159 | self.generic_visit(node) 160 | self._pop_scope() 161 | 162 | def visit_ClassDef(self, node: ast.ClassDef) -> None: 163 | """Track bindings defined on a class body.""" 164 | 165 | self._push_scope() 166 | self.generic_visit(node) 167 | self._pop_scope() 168 | 169 | def visit_Assign(self, node: ast.Assign) -> None: 170 | """Capture bindings from simple assignments.""" 171 | 172 | raw_key = self._resolve_node(node.value) 173 | if raw_key is not None: 174 | for target in node.targets: 175 | if isinstance(target, ast.Name): 176 | self._add_binding(target.id, raw_key) 177 | self.generic_visit(node) 178 | 179 | def visit_AnnAssign(self, node: ast.AnnAssign) -> None: 180 | """Capture bindings from annotated assignments.""" 181 | 182 | if node.value is not None and isinstance(node.target, ast.Name): 183 | if raw_key := self._resolve_node(node.value): 184 | self._add_binding(node.target.id, raw_key) 185 | self.generic_visit(node) 186 | 187 | 188 | def find_register_aliases(tree: ast.AST) -> set[str]: 189 | """Return the alias names used for huawei_solar.register_names.""" 190 | 191 | aliases: set[str] = set() 192 | 193 | for node in ast.walk(tree): 194 | if isinstance(node, ast.ImportFrom) and node.module == "huawei_solar": 195 | for alias in node.names: 196 | if alias.name == "register_names": 197 | aliases.add(alias.asname or alias.name) 198 | elif isinstance(node, ast.Import): 199 | for alias in node.names: 200 | if alias.name == "huawei_solar.register_names": 201 | aliases.add(alias.asname or alias.name.split(".")[-1]) 202 | 203 | return aliases 204 | 205 | 206 | def iter_entity_keys(file_path: Path) -> Iterator[str]: 207 | """Yield normalised entity keys referenced in the given Python module.""" 208 | 209 | try: 210 | source = file_path.read_text(encoding="utf-8") 211 | except OSError as err: 212 | sys.stderr.write(f"Error reading {file_path}: {err}\n") 213 | return iter(()) 214 | 215 | try: 216 | tree = ast.parse(source) 217 | except SyntaxError as err: 218 | sys.stderr.write(f"Error parsing {file_path}: {err}\n") 219 | return iter(()) 220 | 221 | register_aliases = find_register_aliases(tree) 222 | if not register_aliases: 223 | return iter(()) 224 | 225 | collector = EntityKeyCollector(register_aliases) 226 | collector.visit(tree) 227 | return iter(collector.entity_keys) 228 | 229 | 230 | def get_translation_keys(strings_file: Path) -> dict[str, set[str]]: 231 | """Extract all translation keys from strings.json grouped by platform.""" 232 | 233 | try: 234 | strings_data = json.loads(strings_file.read_text(encoding="utf-8")) 235 | except (OSError, json.JSONDecodeError) as err: 236 | sys.stderr.write(f"Error loading {strings_file}: {err}\n") 237 | return {} 238 | 239 | translation_keys: dict[str, set[str]] = {} 240 | 241 | for platform, entities in strings_data.get("entity", {}).items(): 242 | translation_keys[platform] = set(entities.keys()) 243 | 244 | return translation_keys 245 | 246 | 247 | def collect_entity_keys(component_path: Path) -> dict[str, set[str]]: 248 | """Collect entity keys defined in each platform module.""" 249 | 250 | entity_keys_by_platform: dict[str, set[str]] = {} 251 | 252 | for platform_file in component_path.glob("*.py"): 253 | platform = platform_file.stem 254 | entity_keys_by_platform[platform] = set(iter_entity_keys(platform_file)) 255 | 256 | return entity_keys_by_platform 257 | 258 | 259 | def verify_translations(component_path: Path) -> int: 260 | """Verify that all entity keys have translations and no orphan strings.""" 261 | 262 | strings_file = component_path / "strings.json" 263 | if not strings_file.exists(): 264 | sys.stderr.write(f"Error: {strings_file} not found\n") 265 | return 1 266 | 267 | translation_keys = get_translation_keys(strings_file) 268 | entity_keys_by_platform = collect_entity_keys(component_path) 269 | 270 | missing_translations: dict[str, set[str]] = {} 271 | orphan_translations: dict[str, set[str]] = {} 272 | 273 | for platform, entity_keys in entity_keys_by_platform.items(): 274 | if not entity_keys: 275 | continue 276 | 277 | translations = translation_keys.get(platform, set()) 278 | missing = entity_keys - translations 279 | if missing: 280 | missing_translations[platform] = missing 281 | 282 | for platform, translations in translation_keys.items(): 283 | entity_keys = entity_keys_by_platform.get(platform, set()) 284 | unused = translations - entity_keys 285 | 286 | unused = { 287 | key 288 | for key in unused 289 | if not (re.match(r"^pv_\d\d", key) or re.match(r"^state_\d_\d", key)) 290 | } 291 | if unused: 292 | orphan_translations[platform] = unused 293 | 294 | if missing_translations or orphan_translations: 295 | if missing_translations: 296 | sys.stderr.write("❌ Missing translation keys found:\n\n") 297 | for platform, keys in sorted(missing_translations.items()): 298 | sys.stderr.write(f" {platform}:\n") 299 | for key in sorted(keys): 300 | sys.stderr.write(f" - {key}\n") 301 | sys.stderr.write( 302 | "\nPlease add the missing keys to strings.json under the " 303 | 'appropriate "entity" section.\n\n' 304 | ) 305 | 306 | if orphan_translations: 307 | sys.stderr.write("❌ Unused translation keys found:\n\n") 308 | for platform, keys in sorted(orphan_translations.items()): 309 | sys.stderr.write(f" {platform}:\n") 310 | for key in sorted(keys): 311 | sys.stderr.write(f" - {key}\n") 312 | sys.stderr.write( 313 | "\nPlease remove these keys or update the corresponding entity descriptions.\n" 314 | ) 315 | 316 | return 1 317 | 318 | sys.stdout.write( 319 | "✅ All entity keys have corresponding translation strings and no unused entries\n" 320 | ) 321 | return 0 322 | 323 | 324 | def main() -> int: 325 | """Main entry point.""" 326 | 327 | script_dir = Path(__file__).parent 328 | component_path = script_dir.parent 329 | 330 | if not (component_path / "manifest.json").exists(): 331 | sys.stderr.write(f"Error: manifest.json not found in {component_path}\n") 332 | sys.stderr.write("Please run this script from the component directory\n") 333 | return 1 334 | 335 | return verify_translations(component_path) 336 | 337 | 338 | if __name__ == "__main__": 339 | sys.exit(main()) 340 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Huawei Solar Integration 2 | 3 | [![hacs_badge](https://img.shields.io/badge/HACS-Default-orange.svg)](https://github.com/hacs/integration) 4 | [![GitHub release](https://img.shields.io/github/release/wlcrs/huawei_solar.svg)](https://GitHub.com/wlcrs/huawei_solar/releases/) 5 | [![Documentation](https://img.shields.io/badge/Documentation-2D963D?logo=read-the-docs&logoColor=white)](https://github.com/wlcrs/huawei_solar/wiki) 6 | ![](https://img.shields.io/badge/dynamic/json?color=41BDF5&logo=home-assistant&label=integration%20usage&suffix=%20installs&cacheSeconds=15600&url=https://analytics.home-assistant.io/custom_integrations.json&query=$.huawei_solar.total) 7 | 8 | This integration exposes the information and functions made available by Huawei Solar installations over Modbus to Home Assistant. 9 | 10 | ## Table of Contents 11 | 12 | - [Screenshots](#screenshots) 13 | - [Prerequisites](#prerequisites) 14 | - [Installation](#installation) 15 | - [Inverter polling frequency](#inverter-polling-frequency) 16 | - [FAQ - Troubleshooting](#faq---troubleshooting) 17 | 18 | Looking for more information? The [Wiki](https://github.com/wlcrs/huawei_solar/wiki) contains in-depth documentation and support materials. 19 | 20 | ## Screenshots 21 | 22 | | **Inverter** | **Battery** | 23 | |:----------------------------------------------------------------------:|:------------------------------------------------------------:| 24 | |![Inverter Sensors](images/inverter_sensors.png) | ![Battery Sensors](images/battery_sensors.png) | 25 | |![Inverter Diagnostics](images/inverter_configuration_diagnostics.png) | ![Battery Configuration](images/battery_configuration.png) | 26 | 27 | 28 | |**Power Meter** | **Optimizer** | 29 | |:-------------------------------------------------------:|:---------------------------------------------------:| 30 | |![Power Meter Sensors](images/power_meter_sensors.png) | ![Optimizer Sensors](images/optimizer_sensors.png) | 31 | 32 | 33 | 34 | **HA Energy Dashboard** 35 | 36 | ![energy-config](images/energy-config.png) 37 | 38 | **Services** 39 | 40 | This integration exposes multiple services, allowing you to [actively control the amount of electricity exported to the grid](https://github.com/wlcrs/huawei_solar/wiki/Changing-Active-Power-Control) and [forcibly charge/discharge your battery](https://github.com/wlcrs/huawei_solar/wiki/Force-charge-discharge-battery). 41 | 42 | ![services](images/services.png) 43 | 44 | To enable these advanced features, you need to select 'Elevate permissions' during the setup of this integration. 45 | 46 | ## Prerequisites 47 | 48 | **Connection** 49 | 50 | This integration supports two connection modes to Huawei solar devices: 51 | - direct serial connection to the RS485A1 and RS485B1 pins of the COM port of SUN2000 inverters 52 | - network connection 53 | 54 | Detailed information can be found on the ['Connecting to the inverter' Wiki-page](https://github.com/wlcrs/huawei_solar/wiki/Connecting-to-the-inverter) 55 | 56 | > [!NOTE] 57 | > Modbus devices only support **one connection at a time**. 58 | > 59 | > Make sure that nothing else is trying to connect to your Huawei solar installation. 60 | > Otherwise the connection from this integration to your installation will constantly be interrupted. 61 | 62 | **Firmware** 63 | 64 | This integration supports inverters running firmware versions released in 2023 and later. Older firmware versions don't have support for all registers, which can result in the integration failing to work properly. 65 | 66 | ## Installation 67 | 68 | 1. Install this integration with HACS, or copy the contents of this 69 | repository into the `custom_components/huawei_solar` directory 70 | [![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=wlcrs&repository=huawei_solar&category=integration) 71 | 72 | 3. Restart HA 73 | 4. Start the configuration flow: 74 | - [![Start Config Flow](https://my.home-assistant.io/badges/config_flow_start.svg)](https://my.home-assistant.io/redirect/config_flow_start?domain=huawei_solar) 75 | - Or: Go to `Configuration` -> `Integrations` and click the `+ Add Integration`. Select `Huawei Solar` from the list 76 | 77 | 5. Choose whether you want to connect via serial or network connection 78 | 79 | 80 | ![](images/select-connection-type.png) 81 | 82 | 83 | ### Serial configuration 84 | 85 | 5. Select the "USB to RS485 converter" that you connected to the RS485A1 and RS485B1 pins of your inverter. The Slave ID should be identical to the *Com address* set in the *RS485_1* settings. 86 | 87 | ![](images/usb-device.png) 88 | 89 | ### Network configuration 90 | 91 | 5. Enter the IP address and port on which the Modbus-TCP interface is available. Some pointers: 92 | - The port is either `502` or `6607`. 93 | - When connecting to the inverter AP the host IP is typically `192.168.200.1` and the slave id is typically `0`. 94 | - When connecting to an SDongle, the slave id is typically `1`. Make sure to give this device a fixed IP! 95 | 96 | Checking the `Advanced: elevate permissions` checkbox will: 97 | - give you access to optimizer data 98 | - enable you to dynamically change your inverter and battery settings 99 | 100 | ![](images/network-configuration.png) 101 | 102 | 6. When using the `elevate permissions` feature in combination with certain connection methods (most TCP-connections, not for serial connections), 103 | you will be asked to enter the credentials to the `installer` account in a next step. These are the 104 | credentials used to connect to the inverter in the "Device Commissioning" section of 105 | the FusionSolar App. The default password is either `00000a` or `0000000a`. If necessary, you can [perform a password reset](https://support.huawei.com/enterprise/en/doc/EDOC1100136173/8aa1f88a/resetting-password). This will not reset other parameters like the FusionSolar cloud connection or other changes made by the firm which did your solar installation. 106 | 107 | 108 | ## Inverter polling frequency 109 | 110 | The integration will poll the inverter for new values every 30 seconds. If you wish to receive fresh inverter data less (or more) frequently, you can disable the automatic refresh in the integration's system options (Enable polling for updates) and create your own automations with your desired polling frequency. If your installation contains a power meter and/or battery, then you need to create a separate data polling automation for these devices. This allows for fine grained control of which entities must be updated more quickly. 111 | 112 | ```yaml 113 | - alias: "Huawei Solar inverter data polling" 114 | trigger: 115 | - platform: time_pattern 116 | hours: "*" 117 | minutes: "*" 118 | seconds: "/20" 119 | action: 120 | - service: homeassistant.update_entity 121 | target: 122 | entity_id: sensor.inverter_daily_yield 123 | - alias: "Huawei Solar power meter data polling" 124 | trigger: 125 | - platform: time_pattern 126 | hours: "*" 127 | minutes: "*" 128 | seconds: "/5" 129 | action: 130 | - service: homeassistant.update_entity 131 | target: 132 | entity_id: sensor.power_meter_active_power 133 | - alias: "Huawei Solar battery data polling" 134 | trigger: 135 | - platform: time_pattern 136 | hours: "*" 137 | minutes: "*" 138 | seconds: "/20" 139 | action: 140 | - service: homeassistant.update_entity 141 | target: 142 | entity_id: sensor.battery_state_of_capacity 143 | ``` 144 | 145 | **Note:** optimizer data is refreshed only every 5 minutes, which matches how frequently the inverter refreshes this data. Increasing the update frequency of those entities will thus not result in a higher resolution. 146 | 147 | ## FAQ - Troubleshooting 148 | 149 | **Q**: The Daily Yield/Total Yield is incorrect: it also goes up when the battery is discharging. 150 | 151 | **A**: Huawei does not provide a Modbus register that represents the *output* of the inverter produced by energy coming only from the solar panels. It does provide a register that represents the *input* of the solar panels, but that does not take into account the conversion losses of the inverter. cfr. the Wiki page '[Daily Solar Yield](https://github.com/wlcrs/huawei_solar/wiki/Daily-Solar-Yield)' for some possible workarounds. cfr. [#1](https://github.com/wlcrs/huawei_solar/issues/1) for more context. 152 | 153 | --- 154 | 155 | **Q**: Why do I get the error "Connection succeeded, but failed to read from inverter." while setting up this integration? 156 | 157 | **A**: While the integration was able to setup the initial connection to the Huawei Inverter, it did not respond to any queries in time. This is either caused by using an invalid slave ID (typically 0 or 1, try both or ask your installer if unsure), or because an other device established a connection with the inverter, causing the integration to lose it's connection 158 | 159 | --- 160 | 161 | **Q**: Will the FusionSolar App still work when using this integration? 162 | 163 | **A**: The inverter will still send it's data to the Huawei cloud, and you will still be able to see live statistics from your installation in the FusionSolar App. However, if you are using this integration via the network, and you (or your installer) need to use the 'Device commissioning' feature of the app, you will need to disable this integration. 164 | 165 | --- 166 | 167 | **Q**: I want to connect multiple systems simultaniously to the Huawei Solar inverter. For example: 2 HA installations, EVCC, ... Is this possible? 168 | 169 | **A**: This integration connects to the inverter over Modbus. This protocol only supports one "server" (confusingly named, but this is the party sending queries to the inverter). It is therefore not possible to connect multiple systems directly to the inverter. However, you can use a [Modbus Proxy](https://github.com/Akulatraxas/ha-modbusproxy) to multiplex the connection to the inverter. 170 | 171 | --- 172 | 173 | **Q**: How do I change the connection parameters (IP, port, USB device, installer password) of this integration? 174 | 175 | **A**: Changing connection parameters is not supported. You need to delete the integration and install it again. You typically do not lose any history attached to your entities in Home Assistant. 176 | 177 | --- 178 | 179 | 180 | **Q**: The "Daily Yield" value reported does not match with the value from FusionSolar? 181 | 182 | **A**: The "Daily Yield" reported by the inverter is the *output* yield of the inverter, and not the *input* from your solar panels. It therefore includes the yield from discharging the battery, but misses the yield used to charge the battery. FusionSolar computes the "Yield" by combining the values from "Daily Yield", "Battery Day Charge" and "Battery Day Discharge". [More information on the Wiki ...](https://github.com/wlcrs/huawei_solar/wiki/Daily-Solar-Yield) 183 | 184 | --- 185 | 186 | 187 | 188 | **Q**: I can't get this integration to work. What am I doing wrong? 189 | 190 | **A**: First make sure that ['Modbus TCP' access is enabled in the settings of your inverter](https://forum.huawei.com/enterprise/en/modbus-tcp-guide/thread/789585-100027). Next, check if the port is correct. Some inverters use port 6607 instead of 502 (this can change for you after a firmware update!). If that doesn't work for you, and you intend to write an issue, make sure you have the relevant logs included. For this integration, you can enable all relevant logs by including the following lines in your `configuration.yaml`: 191 | 192 | ```yaml 193 | logger: 194 | logs: 195 | tmodbus: debug # only include this if you're having connectivity issues 196 | huawei_solar: debug 197 | homeassistant.components.huawei_solar: debug 198 | ``` 199 | 200 | By providing logs directly when creating the issue, you will likely get help much faster. 201 | 202 | --- 203 | 204 | 205 | 206 | **Q**: I didn't check 'Advanced: Elevate permissions' during the initial setup of this integration and changed my mind. How do I change this? 207 | 208 | **A**: 'Reconfigure' the integration. This action is available from the dropdown menu on the [integration settings page](https://my.home-assistant.io/redirect/integration/?domain=huawei_solar). 209 | 210 | 211 | ## Translations 212 | 213 | Do you want to help out by translating this integration? This project uses Crowdin to make it easy to contribute translations. Use [this invite link to get started](https://crowdin.com/project/huawei-solar/invite?h=4cc071611aab39bd38409ea013f224d12239065). 214 | -------------------------------------------------------------------------------- /select.py: -------------------------------------------------------------------------------- 1 | """Switch entities for Huawei Solar.""" 2 | 3 | from collections.abc import Callable 4 | from dataclasses import dataclass 5 | from enum import IntEnum 6 | import logging 7 | from typing import Any, TypeVar, cast 8 | 9 | from huawei_solar import ( 10 | EMMADevice, 11 | HuaweiSolarDevice, 12 | SUN2000Device, 13 | register_names as rn, 14 | register_values as rv, 15 | ) 16 | from huawei_solar.register_definitions.number import NumberRegister 17 | from huawei_solar.registers import REGISTERS 18 | 19 | from homeassistant.components.select import SelectEntity, SelectEntityDescription 20 | from homeassistant.const import EntityCategory 21 | from homeassistant.core import HomeAssistant, callback 22 | from homeassistant.helpers.device_registry import DeviceInfo 23 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 24 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 25 | 26 | from .const import CONF_ENABLE_PARAMETER_CONFIGURATION, DATA_DEVICE_DATAS 27 | from .types import ( 28 | HuaweiSolarConfigEntry, 29 | HuaweiSolarDeviceData, 30 | HuaweiSolarEntity, 31 | HuaweiSolarEntityContext, 32 | HuaweiSolarEntityDescription, 33 | HuaweiSolarInverterData, 34 | ) 35 | from .update_coordinator import HuaweiSolarUpdateCoordinator 36 | 37 | _LOGGER = logging.getLogger(__name__) 38 | 39 | 40 | T = TypeVar("T") 41 | 42 | 43 | @dataclass(frozen=True) 44 | class HuaweiSolarSelectEntityDescription[T]( 45 | HuaweiSolarEntityDescription, SelectEntityDescription 46 | ): 47 | """Huawei Solar Select Entity Description.""" 48 | 49 | is_available_key: rn.RegisterName | None = None 50 | check_is_available_func: Callable[[Any], bool] | None = None 51 | 52 | def __post_init__(self) -> None: 53 | """Defaults the translation_key to the select key.""" 54 | 55 | # We use this special setter to be able to set/update the translation_key 56 | # in this frozen dataclass. 57 | # cfr. https://docs.python.org/3/library/dataclasses.html#frozen-instances 58 | object.__setattr__( 59 | self, 60 | "translation_key", 61 | self.translation_key or self.key.replace("#", "_").lower(), 62 | ) 63 | 64 | @property 65 | def context(self) -> HuaweiSolarEntityContext: 66 | """Context used by DataUpdateCoordinator.""" 67 | registers = [self.register_name] 68 | if self.is_available_key: 69 | registers.append(self.is_available_key) 70 | 71 | return {"register_names": registers} 72 | 73 | 74 | ENERGY_STORAGE_SWITCH_DESCRIPTIONS: tuple[HuaweiSolarSelectEntityDescription, ...] = ( 75 | HuaweiSolarSelectEntityDescription( 76 | key=rn.STORAGE_EXCESS_PV_ENERGY_USE_IN_TOU, 77 | icon="mdi:battery-charging-medium", 78 | entity_category=EntityCategory.CONFIG, 79 | ), 80 | ) 81 | 82 | CAPACITY_CONTROL_SWITCH_DESCRIPTIONS: tuple[HuaweiSolarSelectEntityDescription, ...] = ( 83 | HuaweiSolarSelectEntityDescription( 84 | key=rn.STORAGE_CAPACITY_CONTROL_MODE, 85 | icon="mdi:battery-arrow-up", 86 | entity_category=EntityCategory.CONFIG, 87 | # Active capacity control is only available is 'Charge from grid' is enabled 88 | is_available_key=rn.STORAGE_CHARGE_FROM_GRID_FUNCTION, 89 | check_is_available_func=lambda charge_from_grid: charge_from_grid, 90 | ), 91 | ) 92 | 93 | EMMA_SELECT_DESCRIPTIONS: tuple[HuaweiSolarSelectEntityDescription, ...] = ( 94 | HuaweiSolarSelectEntityDescription( 95 | key=rn.EMMA_ESS_CONTROL_MODE, 96 | entity_category=EntityCategory.CONFIG, 97 | ), 98 | HuaweiSolarSelectEntityDescription( 99 | key=rn.EMMA_TOU_PREFERRED_USE_OF_SURPLUS_PV_POWER, 100 | entity_category=EntityCategory.CONFIG, 101 | ), 102 | ) 103 | 104 | 105 | async def async_setup_entry( 106 | hass: HomeAssistant, 107 | entry: HuaweiSolarConfigEntry, 108 | async_add_entities: AddEntitiesCallback, 109 | ) -> None: 110 | """Huawei Solar Select Entities Setup.""" 111 | if not entry.data.get(CONF_ENABLE_PARAMETER_CONFIGURATION, False): 112 | _LOGGER.info("Skipping select setup, as parameter configuration is not enabled") 113 | return 114 | 115 | device_datas: list[HuaweiSolarDeviceData] = entry.runtime_data[DATA_DEVICE_DATAS] 116 | 117 | entities_to_add: list[SelectEntity] = [] 118 | for ucs in device_datas: 119 | if not ucs.configuration_update_coordinator: 120 | continue 121 | 122 | slave_entities: list[HuaweiSolarSelectEntity | StorageModeSelectEntity] = [] 123 | if isinstance(ucs.device, EMMADevice): 124 | for entity_description in EMMA_SELECT_DESCRIPTIONS: 125 | slave_entities.append( # noqa: PERF401 126 | HuaweiSolarSelectEntity( 127 | ucs.configuration_update_coordinator, 128 | ucs.device, 129 | entity_description, 130 | ucs.device_info, 131 | ) 132 | ) 133 | 134 | if isinstance(ucs, HuaweiSolarInverterData) and ucs.connected_energy_storage: 135 | slave_entities.extend( 136 | HuaweiSolarSelectEntity( 137 | ucs.configuration_update_coordinator, 138 | ucs.device, 139 | entity_description, 140 | ucs.connected_energy_storage, 141 | ) 142 | for entity_description in ENERGY_STORAGE_SWITCH_DESCRIPTIONS 143 | ) 144 | slave_entities.append( 145 | StorageModeSelectEntity( 146 | ucs.configuration_update_coordinator, 147 | ucs.device, 148 | ucs.connected_energy_storage, 149 | ) 150 | ) 151 | if ucs.device.supports_capacity_control: 152 | _LOGGER.debug( 153 | "Adding capacity control switch entities for %s", 154 | ucs.device.serial_number, 155 | ) 156 | slave_entities.extend( 157 | HuaweiSolarSelectEntity( 158 | ucs.configuration_update_coordinator, 159 | ucs.device, 160 | entity_description, 161 | ucs.connected_energy_storage, 162 | ) 163 | for entity_description in CAPACITY_CONTROL_SWITCH_DESCRIPTIONS 164 | ) 165 | else: 166 | _LOGGER.debug( 167 | "Storage capacity control is not supported by inverter %s", 168 | ucs.device.serial_number, 169 | ) 170 | 171 | entities_to_add.extend(slave_entities) 172 | 173 | async_add_entities(entities_to_add) 174 | 175 | 176 | class HuaweiSolarSelectEntity( 177 | CoordinatorEntity[HuaweiSolarUpdateCoordinator], HuaweiSolarEntity, SelectEntity 178 | ): 179 | """Huawei Solar Select Entity.""" 180 | 181 | entity_description: HuaweiSolarSelectEntityDescription 182 | 183 | def _friendly_format(self, value: IntEnum) -> str: 184 | return value.name.lower() 185 | 186 | def _to_enum(self, value: str) -> IntEnum: 187 | return cast(IntEnum, getattr(self._register_unit, value.upper())) 188 | 189 | def __init__( 190 | self, 191 | coordinator: HuaweiSolarUpdateCoordinator, 192 | device: HuaweiSolarDevice, 193 | description: HuaweiSolarSelectEntityDescription, 194 | device_info: DeviceInfo, 195 | ) -> None: 196 | """Huawei Solar Select Entity constructor.""" 197 | super().__init__(coordinator, description.context) 198 | self.coordinator = coordinator 199 | 200 | self.device = device 201 | self.entity_description = description 202 | 203 | self._attr_device_info = device_info 204 | self._attr_unique_id = f"{device.serial_number}_{description.key}" 205 | 206 | register = REGISTERS[description.register_name] 207 | 208 | assert isinstance(register, NumberRegister) 209 | assert isinstance(register.unit, type) and issubclass(register.unit, IntEnum) 210 | 211 | self._register_unit: type[IntEnum] = register.unit 212 | 213 | self._attr_current_option = None 214 | self._attr_options = [ 215 | self._friendly_format(value) for value in self._register_unit 216 | ] 217 | 218 | @callback 219 | def _handle_coordinator_update(self) -> None: 220 | """Handle updated data from the coordinator.""" 221 | if ( 222 | self.coordinator.data 223 | and self.entity_description.key in self.coordinator.data 224 | ): 225 | self._attr_current_option = self._friendly_format( 226 | self.coordinator.data[self.entity_description.register_name].value 227 | ) 228 | 229 | if self.entity_description.check_is_available_func: 230 | assert self.entity_description.is_available_key 231 | is_available_register = self.coordinator.data[ 232 | self.entity_description.is_available_key 233 | ] 234 | self._attr_available = self.entity_description.check_is_available_func( 235 | is_available_register.value if is_available_register else None 236 | ) 237 | else: 238 | self._attr_available = True 239 | else: 240 | self._attr_current_option = None 241 | self._attr_available = False 242 | 243 | self.async_write_ha_state() 244 | 245 | async def async_select_option(self, option: str) -> None: 246 | """Change the selected option.""" 247 | await self.device.set( 248 | self.entity_description.register_name, self._to_enum(option) 249 | ) 250 | self._attr_current_option = option 251 | 252 | await self.coordinator.async_request_refresh() 253 | 254 | @property 255 | def available(self) -> bool: 256 | """Is the entity available. 257 | 258 | Override available property (from CoordinatorEntity) to take into 259 | account the custom check_is_available_func result. 260 | """ 261 | available = super().available 262 | 263 | if self.entity_description.check_is_available_func and available: 264 | return self._attr_available 265 | 266 | return available 267 | 268 | 269 | class StorageModeSelectEntity( 270 | CoordinatorEntity[HuaweiSolarUpdateCoordinator], HuaweiSolarEntity, SelectEntity 271 | ): 272 | """Huawei Solar Storage Mode Select Entity. 273 | 274 | The available options depend on the type of battery used, so it needs 275 | separate logic. 276 | """ 277 | 278 | entity_description: HuaweiSolarSelectEntityDescription 279 | 280 | def __init__( 281 | self, 282 | coordinator: HuaweiSolarUpdateCoordinator, 283 | device: SUN2000Device, 284 | device_info: DeviceInfo, 285 | ) -> None: 286 | """Huawei Solar Storage Mode Select Entity constructor. 287 | 288 | Do not use directly. Use `.create` instead! 289 | """ 290 | super().__init__( 291 | coordinator, {"register_names": [rn.STORAGE_WORKING_MODE_SETTINGS]} 292 | ) 293 | self.coordinator = coordinator 294 | 295 | self.device = device 296 | self.entity_description = HuaweiSolarSelectEntityDescription( 297 | key=rn.STORAGE_WORKING_MODE_SETTINGS, 298 | entity_category=EntityCategory.CONFIG, 299 | ) 300 | self._attr_device_info = device_info 301 | self._attr_unique_id = f"{device.serial_number}_{self.entity_description.key}" 302 | 303 | self._attr_current_option = None 304 | # The options depend on the type of battery 305 | available_options = [swm.name for swm in rv.StorageWorkingModesC] 306 | if device.battery_type == rv.StorageProductModel.HUAWEI_LUNA2000: 307 | available_options.remove(rv.StorageWorkingModesC.TIME_OF_USE_LG.name) 308 | elif device.battery_type == rv.StorageProductModel.LG_RESU: 309 | available_options.remove(rv.StorageWorkingModesC.TIME_OF_USE_LUNA2000.name) 310 | self._attr_options = [option.lower() for option in available_options] 311 | 312 | @callback 313 | def _handle_coordinator_update(self) -> None: 314 | """Handle updated data from the coordinator.""" 315 | if ( 316 | self.coordinator.data 317 | and self.entity_description.key in self.coordinator.data 318 | ): 319 | self._attr_current_option = self.coordinator.data[ 320 | self.entity_description.register_name 321 | ].value.name.lower() 322 | self._attr_available = True 323 | else: 324 | self._attr_current_option = None 325 | self._attr_available = False 326 | self.async_write_ha_state() 327 | 328 | async def async_select_option(self, option: str) -> None: 329 | """Change the selected option.""" 330 | await self.device.set( 331 | rn.STORAGE_WORKING_MODE_SETTINGS, 332 | getattr(rv.StorageWorkingModesC, option.upper()), 333 | ) 334 | self._attr_current_option = option 335 | 336 | await self.coordinator.async_request_refresh() 337 | -------------------------------------------------------------------------------- /switch.py: -------------------------------------------------------------------------------- 1 | """Switch entities for Huawei Solar.""" 2 | 3 | from __future__ import annotations 4 | 5 | import asyncio 6 | from collections.abc import Callable 7 | from dataclasses import dataclass 8 | import logging 9 | from typing import Any, TypeVar 10 | 11 | from huawei_solar import HuaweiSolarDevice, register_names as rn, register_values as rv 12 | 13 | from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription 14 | from homeassistant.const import EntityCategory 15 | from homeassistant.core import HomeAssistant, callback 16 | from homeassistant.helpers.device_registry import DeviceInfo 17 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 18 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 19 | 20 | from .const import CONF_ENABLE_PARAMETER_CONFIGURATION, DATA_DEVICE_DATAS 21 | from .types import ( 22 | HuaweiSolarConfigEntry, 23 | HuaweiSolarDeviceData, 24 | HuaweiSolarEntity, 25 | HuaweiSolarEntityContext, 26 | HuaweiSolarEntityDescription, 27 | HuaweiSolarInverterData, 28 | ) 29 | from .update_coordinator import HuaweiSolarUpdateCoordinator 30 | 31 | _LOGGER = logging.getLogger(__name__) 32 | 33 | 34 | T = TypeVar("T") 35 | 36 | 37 | @dataclass(frozen=True) 38 | class HuaweiSolarSwitchEntityDescription[T]( 39 | HuaweiSolarEntityDescription, SwitchEntityDescription 40 | ): 41 | """Huawei Solar Switch Entity Description.""" 42 | 43 | is_available_key: rn.RegisterName | None = None 44 | check_is_available_func: Callable[[Any], bool] | None = None 45 | 46 | def __post_init__(self) -> None: 47 | """Defaults the translation_key to the switch key.""" 48 | 49 | # We use this special setter to be able to set/update the translation_key 50 | # in this frozen dataclass. 51 | # cfr. https://docs.python.org/3/library/dataclasses.html#frozen-instances 52 | object.__setattr__( 53 | self, 54 | "translation_key", 55 | self.translation_key or self.key.replace("#", "_").lower(), 56 | ) 57 | 58 | @property 59 | def context(self) -> HuaweiSolarEntityContext: 60 | """Context used by DataUpdateCoordinator.""" 61 | registers = [self.register_name] 62 | if self.is_available_key: 63 | registers.append(self.is_available_key) 64 | 65 | return {"register_names": registers} 66 | 67 | 68 | ENERGY_STORAGE_WITH_CAPACITY_CONTROL_SWITCH_DESCRIPTIONS: tuple[ 69 | HuaweiSolarSwitchEntityDescription, ... 70 | ] = ( 71 | HuaweiSolarSwitchEntityDescription( 72 | key=rn.STORAGE_CHARGE_FROM_GRID_FUNCTION, 73 | icon="mdi:battery-charging-50", 74 | entity_category=EntityCategory.CONFIG, 75 | is_available_key=rn.STORAGE_CAPACITY_CONTROL_MODE, 76 | check_is_available_func=( 77 | lambda ccm: ccm != rv.StorageCapacityControlMode.ACTIVE_CAPACITY_CONTROL 78 | ), 79 | ), 80 | ) 81 | 82 | ENERGY_STORAGE_WITHOUT_CAPACITY_CONTROL_SWITCH_DESCRIPTIONS: tuple[ 83 | HuaweiSolarSwitchEntityDescription, ... 84 | ] = ( 85 | HuaweiSolarSwitchEntityDescription( 86 | key=rn.STORAGE_CHARGE_FROM_GRID_FUNCTION, 87 | icon="mdi:battery-charging-50", 88 | entity_category=EntityCategory.CONFIG, 89 | ), 90 | ) 91 | 92 | 93 | INVERTER_SWITCH_DESCRIPTIONS: tuple[HuaweiSolarSwitchEntityDescription, ...] = ( 94 | HuaweiSolarSwitchEntityDescription( 95 | key=rn.MPPT_MULTIMODAL_SCANNING, 96 | icon="mdi:magnify-scan", 97 | entity_category=EntityCategory.CONFIG, 98 | entity_registry_enabled_default=False, 99 | ), 100 | ) 101 | 102 | 103 | async def async_setup_entry( 104 | hass: HomeAssistant, 105 | entry: HuaweiSolarConfigEntry, 106 | async_add_entities: AddEntitiesCallback, 107 | ) -> None: 108 | """Huawei Solar Switch Entities Setup.""" 109 | if not entry.data.get(CONF_ENABLE_PARAMETER_CONFIGURATION, False): 110 | _LOGGER.info("Skipping switch setup, as parameter configuration is not enabled") 111 | return 112 | 113 | device_data: list[HuaweiSolarDeviceData] = entry.runtime_data[DATA_DEVICE_DATAS] 114 | 115 | entities_to_add: list[SwitchEntity] = [] 116 | for ucs in device_data: 117 | if not ucs.configuration_update_coordinator: 118 | continue 119 | 120 | slave_entities: list[ 121 | HuaweiSolarSwitchEntity | HuaweiSolarOnOffSwitchEntity 122 | ] = [] 123 | 124 | if isinstance(ucs, HuaweiSolarInverterData): 125 | # This entity dependens on DEVICE_STATUS which is already read by the inverter_update_coordinator 126 | slave_entities.append( 127 | HuaweiSolarOnOffSwitchEntity( 128 | ucs.update_coordinator, ucs.device, ucs.device_info 129 | ) 130 | ) 131 | 132 | slave_entities.extend( 133 | [ 134 | HuaweiSolarSwitchEntity( 135 | ucs.update_coordinator, 136 | ucs.device, 137 | entity_description, 138 | ucs.device_info, 139 | ) 140 | for entity_description in INVERTER_SWITCH_DESCRIPTIONS 141 | ] 142 | ) 143 | 144 | if ucs.connected_energy_storage: 145 | if ucs.device.supports_capacity_control: 146 | slave_entities.extend( 147 | HuaweiSolarSwitchEntity( 148 | ucs.configuration_update_coordinator, 149 | ucs.device, 150 | entity_description, 151 | ucs.connected_energy_storage, 152 | ) 153 | for entity_description in ENERGY_STORAGE_WITH_CAPACITY_CONTROL_SWITCH_DESCRIPTIONS 154 | ) 155 | else: 156 | slave_entities.extend( 157 | HuaweiSolarSwitchEntity( 158 | ucs.configuration_update_coordinator, 159 | ucs.device, 160 | entity_description, 161 | ucs.connected_energy_storage, 162 | ) 163 | for entity_description in ENERGY_STORAGE_WITHOUT_CAPACITY_CONTROL_SWITCH_DESCRIPTIONS 164 | ) 165 | 166 | entities_to_add.extend(slave_entities) 167 | 168 | async_add_entities(entities_to_add) 169 | 170 | 171 | DEVICE_STATUS_OFF_RANGE_START = 0x3000 172 | DEVICE_STATUS_OFF_RANGE_END = 0x3FFF 173 | 174 | 175 | class HuaweiSolarSwitchEntity( 176 | CoordinatorEntity[HuaweiSolarUpdateCoordinator], HuaweiSolarEntity, SwitchEntity 177 | ): 178 | """Huawei Solar Switch Entity.""" 179 | 180 | entity_description: HuaweiSolarSwitchEntityDescription 181 | 182 | def __init__( 183 | self, 184 | coordinator: HuaweiSolarUpdateCoordinator, 185 | device: HuaweiSolarDevice, 186 | description: HuaweiSolarSwitchEntityDescription, 187 | device_info: DeviceInfo, 188 | ) -> None: 189 | """Huawei Solar Switch Entity constructor. 190 | 191 | Do not use directly. Use `.create` instead! 192 | """ 193 | super().__init__(coordinator, description.context) 194 | self.coordinator = coordinator 195 | 196 | self.device = device 197 | self.entity_description = description 198 | 199 | self._attr_device_info = device_info 200 | self._attr_unique_id = f"{device.serial_number}_{description.key}" 201 | 202 | @callback 203 | def _handle_coordinator_update(self) -> None: 204 | """Handle updated data from the coordinator.""" 205 | if ( 206 | self.coordinator.data 207 | and self.entity_description.key in self.coordinator.data 208 | ): 209 | self._attr_is_on = self.coordinator.data[ 210 | self.entity_description.register_name 211 | ].value 212 | 213 | if self.entity_description.check_is_available_func: 214 | assert self.entity_description.is_available_key 215 | is_available_register = self.coordinator.data.get( 216 | self.entity_description.is_available_key 217 | ) 218 | self._attr_available = self.entity_description.check_is_available_func( 219 | is_available_register.value if is_available_register else None 220 | ) 221 | else: 222 | self._attr_available = True 223 | else: 224 | self._attr_is_on = None 225 | self._attr_available = False 226 | 227 | self.async_write_ha_state() 228 | 229 | async def async_turn_on(self, **kwargs: Any) -> None: 230 | """Turn the setting on.""" 231 | if await self.device.client.set(self.entity_description.register_name, True): 232 | self._attr_is_on = True 233 | 234 | await self.coordinator.async_request_refresh() 235 | 236 | async def async_turn_off(self, **kwargs: Any) -> None: 237 | """Turn the setting off.""" 238 | if await self.device.client.set(self.entity_description.register_name, False): 239 | self._attr_is_on = False 240 | 241 | await self.coordinator.async_request_refresh() 242 | 243 | @property 244 | def available(self) -> bool: 245 | """Is the entity available. 246 | 247 | Override available property (from CoordinatorEntity) to 248 | take into account the custom check_is_available_func result. 249 | """ 250 | available = super().available 251 | 252 | if self.entity_description.check_is_available_func and available: 253 | return self._attr_available 254 | 255 | return available 256 | 257 | 258 | class HuaweiSolarOnOffSwitchEntity( 259 | CoordinatorEntity[HuaweiSolarUpdateCoordinator], HuaweiSolarEntity, SwitchEntity 260 | ): 261 | """Huawei Solar Switch Entity.""" 262 | 263 | POLL_FREQUENCY_SECONDS = 15 264 | MAX_STATUS_CHANGE_TIME_SECONDS = 3000 # Maximum status change time is 5 minutes 265 | 266 | def __init__( 267 | self, 268 | # not the HuaweiSolarConfigurationUpdateCoordinator as 269 | # this entity depends on the 'Device Status' register 270 | coordinator: HuaweiSolarUpdateCoordinator, 271 | device: HuaweiSolarDevice, 272 | device_info: DeviceInfo, 273 | ) -> None: 274 | """Huawei Solar Switch Entity constructor. 275 | 276 | Do not use directly. Use `.create` instead! 277 | """ 278 | super().__init__(coordinator, {"register_names": [rn.DEVICE_STATUS]}) 279 | self.coordinator = coordinator 280 | 281 | self.device = device 282 | self.entity_description = SwitchEntityDescription( 283 | key=rn.STARTUP, 284 | icon="mdi:power-standby", 285 | entity_category=EntityCategory.CONFIG, 286 | ) 287 | 288 | self._attr_device_info = device_info 289 | self._attr_unique_id = f"{device.serial_number}_{self.entity_description.key}" 290 | 291 | self._change_lock = asyncio.Lock() 292 | 293 | @staticmethod 294 | def _is_off(device_status: str) -> bool: 295 | return device_status.startswith("Shutdown") 296 | 297 | @callback 298 | def _handle_coordinator_update(self) -> None: 299 | """Handle updated data from the coordinator.""" 300 | if self._change_lock.locked(): 301 | return # Don't do status updates if async_turn_on or async_turn_off is running 302 | 303 | if self.coordinator.data and rn.DEVICE_STATUS in self.coordinator.data: 304 | device_status = self.coordinator.data[rn.DEVICE_STATUS].value 305 | 306 | self._attr_is_on = not self._is_off(device_status) 307 | self._attr_available = True 308 | else: 309 | self._attr_available = False 310 | 311 | self.async_write_ha_state() 312 | 313 | async def async_turn_on(self, **kwargs: Any) -> None: 314 | """Turn the setting on.""" 315 | async with self._change_lock: 316 | await self.device.set(rn.STARTUP, 0) 317 | 318 | # Turning on can take up to 5 minutes... We'll poll every 15 seconds 319 | for _ in range( 320 | self.MAX_STATUS_CHANGE_TIME_SECONDS // self.POLL_FREQUENCY_SECONDS 321 | ): 322 | await asyncio.sleep(self.POLL_FREQUENCY_SECONDS) 323 | device_status = (await self.device.client.get(rn.DEVICE_STATUS)).value 324 | if not self._is_off(device_status): 325 | self._attr_is_on = True 326 | break 327 | 328 | await self.coordinator.async_request_refresh() 329 | 330 | async def async_turn_off(self, **kwargs: Any) -> None: 331 | """Turn the setting off.""" 332 | async with self._change_lock: 333 | await self.device.set(rn.SHUTDOWN, 0) 334 | 335 | # Turning on can take up to 5 minutes... We'll poll every 15 seconds 336 | for _ in range( 337 | self.MAX_STATUS_CHANGE_TIME_SECONDS // self.POLL_FREQUENCY_SECONDS 338 | ): 339 | await asyncio.sleep(self.POLL_FREQUENCY_SECONDS) 340 | device_status = (await self.device.client.get(rn.DEVICE_STATUS)).value 341 | if self._is_off(device_status): 342 | self._attr_is_on = False 343 | break 344 | 345 | await self.coordinator.async_request_refresh() 346 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- 1 | """The Huawei Solar integration.""" 2 | 3 | import logging 4 | 5 | from huawei_solar import ( 6 | EMMADevice, 7 | HuaweiSolarException, 8 | InvalidCredentials, 9 | SChargerDevice, 10 | SDongleDevice, 11 | SmartLoggerDevice, 12 | SUN2000Device, 13 | create_device_instance, 14 | create_rtu_client, 15 | create_sub_device_instance, 16 | create_tcp_client, 17 | register_values as rv, 18 | ) 19 | from huawei_solar.device.base import HuaweiSolarDevice, HuaweiSolarDeviceWithLogin 20 | from huawei_solar.modbus_pdu import PermissionDeniedError 21 | 22 | from homeassistant.config_entries import ConfigEntry 23 | from homeassistant.const import ( 24 | CONF_HOST, 25 | CONF_PASSWORD, 26 | CONF_PORT, 27 | CONF_USERNAME, 28 | Platform, 29 | ) 30 | from homeassistant.core import HomeAssistant 31 | from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady 32 | from homeassistant.helpers import device_registry as dr 33 | from homeassistant.helpers.device_registry import DeviceInfo 34 | 35 | from .const import ( 36 | CONF_ENABLE_PARAMETER_CONFIGURATION, 37 | CONF_SLAVE_IDS, 38 | CONFIGURATION_UPDATE_INTERVAL, 39 | DATA_DEVICE_DATAS, 40 | DOMAIN, 41 | ENERGY_STORAGE_UPDATE_INTERVAL, 42 | INVERTER_UPDATE_INTERVAL, 43 | OPTIMIZER_UPDATE_INTERVAL, 44 | POWER_METER_UPDATE_INTERVAL, 45 | ) 46 | from .services import async_setup_services 47 | from .types import ( 48 | HuaweiSolarConfigEntry, 49 | HuaweiSolarDeviceData, 50 | HuaweiSolarInverterData, 51 | ) 52 | from .update_coordinator import ( 53 | HuaweiSolarUpdateCoordinator, 54 | create_optimizer_update_coordinator, 55 | ) 56 | 57 | _LOGGER = logging.getLogger(__name__) 58 | 59 | PLATFORMS: list[Platform] = [ 60 | Platform.NUMBER, 61 | Platform.SELECT, 62 | Platform.SENSOR, 63 | Platform.SWITCH, 64 | ] 65 | 66 | 67 | async def async_setup_entry(hass: HomeAssistant, entry: HuaweiSolarConfigEntry) -> bool: 68 | """Set up Huawei Solar from a config entry.""" 69 | 70 | primary_device = None 71 | try: 72 | # Multiple inverters can be connected to each other via a daisy chain, 73 | # via an internal modbus-network (ie. not the same modbus network that we are 74 | # using to talk to the inverter). 75 | # 76 | # Each inverter receives it's own 'slave id' in that case. 77 | # The inverter that we use as 'gateway' will then forward the request to 78 | # the proper inverter. 79 | 80 | # ┌─────────────┐ 81 | # │ EXTERNAL │ 82 | # │ APPLICATION │ 83 | # └──────┬──────┘ 84 | # │ 85 | # ┌────┴────┐ 86 | # │PRIMARY │ 87 | # │INVERTER │ 88 | # └────┬────┘ 89 | # ┌──────────────┼───────────────┐ 90 | # │ │ │ 91 | # ┌────┴────┐ ┌───┴─────┐ ┌────┴────┐ 92 | # │ SLAVE X │ │ SLAVE Y │ │SLAVE ...│ 93 | # └─────────┘ └─────────┘ └─────────┘ 94 | 95 | if entry.data[CONF_HOST] is None: 96 | client = create_rtu_client( 97 | port=entry.data[CONF_PORT], unit_id=entry.data[CONF_SLAVE_IDS][0] 98 | ) 99 | else: 100 | client = create_tcp_client( 101 | host=entry.data[CONF_HOST], 102 | port=entry.data[CONF_PORT], 103 | unit_id=entry.data[CONF_SLAVE_IDS][0], 104 | ) 105 | 106 | primary_device = await create_device_instance(client) 107 | 108 | if entry.data.get(CONF_ENABLE_PARAMETER_CONFIGURATION): 109 | if ( 110 | isinstance(primary_device, HuaweiSolarDeviceWithLogin) 111 | and entry.data.get(CONF_USERNAME) 112 | and entry.data.get(CONF_PASSWORD) 113 | ): 114 | try: 115 | await primary_device.login( 116 | entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD] 117 | ) 118 | except InvalidCredentials as err: 119 | raise ConfigEntryAuthFailed from err 120 | 121 | primary_device_data = await _setup_device_data( 122 | hass, 123 | entry, 124 | primary_device, 125 | ) 126 | 127 | device_datas: list[HuaweiSolarDeviceData] = [primary_device_data] 128 | 129 | for extra_unit_id in entry.data[CONF_SLAVE_IDS][1:]: 130 | sub_device = await create_sub_device_instance(primary_device, extra_unit_id) 131 | sub_device_data = await _setup_device_data(hass, entry, sub_device) 132 | 133 | device_datas.append(sub_device_data) 134 | 135 | entry.runtime_data = { 136 | DATA_DEVICE_DATAS: device_datas, 137 | } 138 | except (HuaweiSolarException, TimeoutError) as err: 139 | if primary_device is not None: 140 | await primary_device.stop() 141 | 142 | raise ConfigEntryNotReady from err 143 | 144 | except Exception: 145 | # always try to stop the bridge, as it will keep retrying 146 | # in the background otherwise! 147 | if primary_device is not None: 148 | await primary_device.stop() 149 | raise 150 | 151 | await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) 152 | await async_setup_services(hass, entry) 153 | 154 | return True 155 | 156 | 157 | async def async_unload_entry( 158 | hass: HomeAssistant, entry: HuaweiSolarConfigEntry 159 | ) -> bool: 160 | """Unload a config entry.""" 161 | if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): 162 | device_datas: list[HuaweiSolarDeviceData] = entry.runtime_data["device_datas"] 163 | primary_device = device_datas[0].device 164 | await primary_device.client.disconnect() 165 | 166 | return unload_ok 167 | 168 | 169 | def _battery_product_model_to_manufacturer(spm: rv.StorageProductModel) -> str | None: 170 | if spm == rv.StorageProductModel.HUAWEI_LUNA2000: 171 | return "Huawei" 172 | if spm == rv.StorageProductModel.LG_RESU: 173 | return "LG Chem" 174 | return None 175 | 176 | 177 | def _battery_product_model_to_model(spm: rv.StorageProductModel) -> str | None: 178 | if spm == rv.StorageProductModel.HUAWEI_LUNA2000: 179 | return "LUNA 2000" 180 | if spm == rv.StorageProductModel.LG_RESU: 181 | return "RESU" 182 | return None 183 | 184 | 185 | async def _setup_inverter_device_data( 186 | hass: HomeAssistant, 187 | entry: ConfigEntry, 188 | device: SUN2000Device, 189 | connecting_inverter_device_id: tuple[str, str] | None, 190 | ) -> HuaweiSolarInverterData: 191 | device_registry = dr.async_get(hass) 192 | 193 | inverter_device_info = DeviceInfo( 194 | identifiers={(DOMAIN, device.serial_number)}, 195 | translation_key="inverter", 196 | manufacturer="Huawei", 197 | model=device.model_name, 198 | serial_number=device.serial_number, 199 | sw_version=device.software_version, 200 | via_device=connecting_inverter_device_id, # type: ignore[typeddict-item] 201 | ) 202 | 203 | # Add inverter device to device registery 204 | device_registry.async_get_or_create( 205 | config_entry_id=entry.entry_id, 206 | identifiers={(DOMAIN, device.serial_number)}, 207 | manufacturer="Huawei", 208 | name=device.model_name, 209 | model=device.model_name, 210 | sw_version=device.software_version, 211 | ) 212 | 213 | update_coordinator = HuaweiSolarUpdateCoordinator( 214 | hass, 215 | _LOGGER, 216 | device=device, 217 | name=f"{device.serial_number}_data_update_coordinator", 218 | update_interval=INVERTER_UPDATE_INTERVAL, 219 | ) 220 | 221 | # Add power meter device if a power meter is detected 222 | if device.power_meter_type is not None: 223 | power_meter_device_info = DeviceInfo( 224 | identifiers={ 225 | (DOMAIN, f"{device.serial_number}/power_meter"), 226 | }, 227 | translation_key="power_meter", 228 | via_device=(DOMAIN, device.serial_number), 229 | ) 230 | power_meter_update_coordinator = HuaweiSolarUpdateCoordinator( 231 | hass, 232 | _LOGGER, 233 | device=device, 234 | name=f"{device.serial_number}_power_meter_data_update_coordinator", 235 | update_interval=POWER_METER_UPDATE_INTERVAL, 236 | ) 237 | else: 238 | power_meter_device_info = None 239 | power_meter_update_coordinator = None 240 | 241 | # Add battery device if a battery is detected 242 | if device.battery_type != rv.StorageProductModel.NONE: 243 | battery_device_info = DeviceInfo( 244 | identifiers={ 245 | (DOMAIN, f"{device.serial_number}/connected_energy_storage"), 246 | }, 247 | translation_key="connected_energy_storage", 248 | model="Batteries", 249 | manufacturer=inverter_device_info.get("manufacturer"), 250 | via_device=(DOMAIN, device.serial_number), 251 | ) 252 | 253 | energy_storage_update_coordinator = HuaweiSolarUpdateCoordinator( 254 | hass, 255 | _LOGGER, 256 | device=device, 257 | name=f"{device.serial_number}_battery_data_update_coordinator", 258 | update_interval=ENERGY_STORAGE_UPDATE_INTERVAL, 259 | ) 260 | else: 261 | battery_device_info = None 262 | energy_storage_update_coordinator = None 263 | 264 | if device.battery_1_type != rv.StorageProductModel.NONE: 265 | battery_1_device_info = DeviceInfo( 266 | identifiers={ 267 | (DOMAIN, f"{device.serial_number}/battery_1"), 268 | }, 269 | translation_key="battery_1", 270 | manufacturer=_battery_product_model_to_manufacturer(device.battery_1_type), 271 | model=_battery_product_model_to_model(device.battery_1_type), 272 | via_device=(DOMAIN, device.serial_number), 273 | ) 274 | else: 275 | battery_1_device_info = None 276 | 277 | if device.battery_2_type != rv.StorageProductModel.NONE: 278 | battery_2_device_info = DeviceInfo( 279 | identifiers={ 280 | (DOMAIN, f"{device.serial_number}/battery_2"), 281 | }, 282 | translation_key="battery_2", 283 | manufacturer=_battery_product_model_to_manufacturer(device.battery_2_type), 284 | model=_battery_product_model_to_model(device.battery_2_type), 285 | via_device=(DOMAIN, device.serial_number), 286 | ) 287 | else: 288 | battery_2_device_info = None 289 | 290 | optimizers_device_infos = {} 291 | optimizer_update_coordinator = None 292 | 293 | # Add optimizer devices if optimizers are detected 294 | if device.has_optimizers and ( 295 | # Optimizers are not accessible when connected through a SmartLogger 296 | not isinstance(device.primary_device, SmartLoggerDevice) 297 | ): 298 | try: 299 | optimizer_system_infos = ( 300 | await device.get_optimizer_system_information_data() 301 | ) 302 | 303 | optimizers_device_infos = { 304 | optimizer_id: DeviceInfo( 305 | identifiers={(DOMAIN, optimizer.sn)}, 306 | name=optimizer.sn, 307 | manufacturer="Huawei", 308 | model=optimizer.model, 309 | sw_version=optimizer.software_version, 310 | via_device=(DOMAIN, device.serial_number), 311 | ) 312 | for optimizer_id, optimizer in optimizer_system_infos.items() 313 | } 314 | 315 | optimizer_update_coordinator = await create_optimizer_update_coordinator( 316 | hass, 317 | device, 318 | optimizers_device_infos, 319 | OPTIMIZER_UPDATE_INTERVAL, 320 | ) 321 | except PermissionDeniedError as exception: 322 | _LOGGER.info( 323 | "Cannot create optimizer sensor entities as the integration has insufficient permissions. " 324 | "Consider enabling elevated permissions to get more optimizer data", 325 | exc_info=exception, 326 | ) 327 | except Exception as exc: # pylint: disable=broad-except 328 | _LOGGER.exception( 329 | "Cannot create optimizer sensor entities due to an unexpected error", 330 | exc_info=exc, 331 | ) 332 | 333 | if entry.data.get(CONF_ENABLE_PARAMETER_CONFIGURATION, False): 334 | configuration_update_coordinator = HuaweiSolarUpdateCoordinator( 335 | hass, 336 | _LOGGER, 337 | device=device, 338 | name=f"{device.serial_number}_config_data_update_coordinator", 339 | update_interval=CONFIGURATION_UPDATE_INTERVAL, 340 | ) 341 | else: 342 | configuration_update_coordinator = None 343 | 344 | return HuaweiSolarInverterData( 345 | device=device, 346 | device_info=inverter_device_info, 347 | update_coordinator=update_coordinator, 348 | power_meter=power_meter_device_info, 349 | power_meter_update_coordinator=power_meter_update_coordinator, 350 | connected_energy_storage=battery_device_info, 351 | energy_storage_update_coordinator=energy_storage_update_coordinator, 352 | optimizer_device_infos=optimizers_device_infos, 353 | optimizer_update_coordinator=optimizer_update_coordinator, 354 | battery_1=battery_1_device_info, 355 | battery_2=battery_2_device_info, 356 | configuration_update_coordinator=configuration_update_coordinator, 357 | ) 358 | 359 | 360 | DEVICE_CLASS_TO_TRANSLATION_KEY: dict[type[HuaweiSolarDevice], str] = { 361 | EMMADevice: "emma", 362 | SChargerDevice: "charger", 363 | SDongleDevice: "sdongle", 364 | SmartLoggerDevice: "smartlogger", 365 | } 366 | 367 | 368 | async def _setup_device_data( 369 | hass: HomeAssistant, 370 | entry: ConfigEntry, 371 | device: HuaweiSolarDevice, 372 | ) -> HuaweiSolarDeviceData: 373 | """Create the correct DeviceInfo-objects, which can be used to correctly assign to entities in this integration.""" 374 | if isinstance(device, SUN2000Device): 375 | return await _setup_inverter_device_data(hass, entry, device, None) 376 | 377 | device_registry = dr.async_get(hass) 378 | 379 | if hasattr(device, "software_version"): 380 | sw_version = device.software_version 381 | 382 | device_info = DeviceInfo( 383 | identifiers={(DOMAIN, device.serial_number)}, 384 | translation_key=DEVICE_CLASS_TO_TRANSLATION_KEY[type(device)], 385 | manufacturer="Huawei", 386 | model=device.model_name, 387 | serial_number=device.serial_number, 388 | sw_version=sw_version, 389 | ) 390 | 391 | # Add device to device registery 392 | device_registry.async_get_or_create( 393 | config_entry_id=entry.entry_id, 394 | identifiers={(DOMAIN, device.serial_number)}, 395 | manufacturer="Huawei", 396 | name=device.model_name, 397 | model=device.model_name, 398 | sw_version=sw_version, 399 | ) 400 | 401 | update_coordinator = HuaweiSolarUpdateCoordinator( 402 | hass, 403 | _LOGGER, 404 | device=device, 405 | name=f"{device.serial_number}_data_update_coordinator", 406 | update_interval=INVERTER_UPDATE_INTERVAL, 407 | ) 408 | 409 | if entry.data.get(CONF_ENABLE_PARAMETER_CONFIGURATION, False): 410 | configuration_update_coordinator = HuaweiSolarUpdateCoordinator( 411 | hass, 412 | _LOGGER, 413 | device=device, 414 | name=f"{device.serial_number}_config_data_update_coordinator", 415 | update_interval=CONFIGURATION_UPDATE_INTERVAL, 416 | ) 417 | else: 418 | configuration_update_coordinator = None 419 | 420 | return HuaweiSolarDeviceData( 421 | device=device, 422 | device_info=device_info, 423 | update_coordinator=update_coordinator, 424 | configuration_update_coordinator=configuration_update_coordinator, 425 | ) 426 | -------------------------------------------------------------------------------- /number.py: -------------------------------------------------------------------------------- 1 | """Number entities for Huawei Solar.""" 2 | 3 | from dataclasses import dataclass 4 | import logging 5 | 6 | from huawei_solar import ( 7 | EMMADevice, 8 | HuaweiSolarDevice, 9 | RegisterName, 10 | register_names as rn, 11 | ) 12 | 13 | from homeassistant.components.number import ( 14 | NumberEntity, 15 | NumberEntityDescription, 16 | NumberMode, 17 | ) 18 | from homeassistant.components.number.const import DEFAULT_MAX_VALUE, DEFAULT_MIN_VALUE 19 | from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfPower 20 | from homeassistant.core import HomeAssistant, callback 21 | from homeassistant.helpers.device_registry import DeviceInfo 22 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 23 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 24 | 25 | from .const import CONF_ENABLE_PARAMETER_CONFIGURATION, DATA_DEVICE_DATAS 26 | from .types import ( 27 | HuaweiSolarConfigEntry, 28 | HuaweiSolarDeviceData, 29 | HuaweiSolarEntity, 30 | HuaweiSolarEntityContext, 31 | HuaweiSolarEntityDescription, 32 | HuaweiSolarInverterData, 33 | ) 34 | from .update_coordinator import HuaweiSolarUpdateCoordinator 35 | 36 | _LOGGER = logging.getLogger(__name__) 37 | 38 | 39 | @dataclass(frozen=True) 40 | class HuaweiSolarNumberEntityDescription( 41 | HuaweiSolarEntityDescription, NumberEntityDescription 42 | ): 43 | """Huawei Solar Number Entity Description.""" 44 | 45 | # Used when the min/max cannot dynamically change 46 | static_minimum_key: RegisterName | None = None 47 | static_maximum_key: RegisterName | None = None 48 | 49 | # Used when the min/max is influenced by other parameters 50 | dynamic_minimum_key: RegisterName | None = None 51 | dynamic_maximum_key: RegisterName | None = None 52 | 53 | def __post_init__(self) -> None: 54 | """Defaults the translation_key to the number key.""" 55 | # We use this special setter to be able to set/update the translation_key 56 | # in this frozen dataclass. 57 | # cfr. https://docs.python.org/3/library/dataclasses.html#frozen-instances 58 | object.__setattr__( 59 | self, 60 | "translation_key", 61 | self.translation_key or self.key.replace("#", "_").lower(), 62 | ) 63 | 64 | @property 65 | def context(self) -> HuaweiSolarEntityContext: 66 | """Context used by DataUpdateCoordinator.""" 67 | 68 | registers = [self.register_name] 69 | if self.dynamic_minimum_key: 70 | registers.append(self.dynamic_minimum_key) 71 | if self.dynamic_maximum_key: 72 | registers.append(self.dynamic_maximum_key) 73 | return HuaweiSolarEntityContext(register_names=registers) 74 | 75 | 76 | INVERTER_NUMBER_DESCRIPTIONS: tuple[HuaweiSolarNumberEntityDescription, ...] = ( 77 | HuaweiSolarNumberEntityDescription( 78 | key=rn.ACTIVE_POWER_PERCENTAGE_DERATING, 79 | native_max_value=100, 80 | native_step=0.1, 81 | native_min_value=-100, 82 | icon="mdi:transmission-tower-off", 83 | native_unit_of_measurement=PERCENTAGE, 84 | entity_category=EntityCategory.CONFIG, 85 | entity_registry_enabled_default=False, 86 | ), 87 | HuaweiSolarNumberEntityDescription( 88 | key=rn.ACTIVE_POWER_FIXED_VALUE_DERATING, 89 | static_maximum_key=rn.P_MAX, 90 | native_step=1, 91 | native_min_value=0, 92 | icon="mdi:transmission-tower-off", 93 | native_unit_of_measurement=UnitOfPower.WATT, 94 | entity_category=EntityCategory.CONFIG, 95 | ), 96 | HuaweiSolarNumberEntityDescription( 97 | key=rn.MPPT_SCANNING_INTERVAL, 98 | native_max_value=30, 99 | native_step=1, 100 | native_min_value=5, 101 | icon="mdi:sun-clock", 102 | native_unit_of_measurement="minutes", 103 | entity_category=EntityCategory.CONFIG, 104 | entity_registry_enabled_default=False, 105 | ), 106 | ) 107 | 108 | EMMA_NUMBER_DESCRIPTIONS: tuple[HuaweiSolarNumberEntityDescription, ...] = ( 109 | HuaweiSolarNumberEntityDescription( 110 | key=rn.EMMA_MAXIMUM_FEED_GRID_POWER_PERCENT, 111 | native_max_value=100, 112 | native_step=0.1, 113 | native_min_value=-100, 114 | icon="mdi:transmission-tower-off", 115 | native_unit_of_measurement=PERCENTAGE, 116 | entity_category=EntityCategory.CONFIG, 117 | entity_registry_enabled_default=False, 118 | ), 119 | HuaweiSolarNumberEntityDescription( 120 | key=rn.EMMA_MAXIMUM_FEED_GRID_POWER_WATT, 121 | static_maximum_key=rn.INVERTER_RATED_POWER, 122 | native_step=1, 123 | native_min_value=-1000, 124 | icon="mdi:transmission-tower-off", 125 | native_unit_of_measurement=UnitOfPower.WATT, 126 | entity_category=EntityCategory.CONFIG, 127 | ), 128 | HuaweiSolarNumberEntityDescription( 129 | key=rn.EMMA_TOU_MAXIMUM_POWER_FOR_CHARGING_BATTERIES_FROM_GRID, 130 | native_min_value=0, 131 | native_max_value=50000, 132 | icon="mdi:battery-positive", 133 | native_unit_of_measurement=UnitOfPower.WATT, 134 | entity_category=EntityCategory.CONFIG, 135 | ), 136 | ) 137 | 138 | ENERGY_STORAGE_NUMBER_DESCRIPTIONS: tuple[HuaweiSolarNumberEntityDescription, ...] = ( 139 | HuaweiSolarNumberEntityDescription( 140 | key=rn.STORAGE_MAXIMUM_CHARGING_POWER, 141 | native_min_value=0, 142 | static_maximum_key=rn.STORAGE_MAXIMUM_CHARGE_POWER, 143 | icon="mdi:battery-positive", 144 | native_unit_of_measurement=UnitOfPower.WATT, 145 | entity_category=EntityCategory.CONFIG, 146 | ), 147 | HuaweiSolarNumberEntityDescription( 148 | key=rn.STORAGE_MAXIMUM_DISCHARGING_POWER, 149 | native_min_value=0, 150 | static_maximum_key=rn.STORAGE_MAXIMUM_DISCHARGE_POWER, 151 | icon="mdi:battery-negative", 152 | native_unit_of_measurement=UnitOfPower.WATT, 153 | entity_category=EntityCategory.CONFIG, 154 | ), 155 | HuaweiSolarNumberEntityDescription( 156 | key=rn.STORAGE_CHARGING_CUTOFF_CAPACITY, 157 | native_min_value=90, 158 | native_max_value=100, 159 | native_step=0.1, 160 | icon="mdi:battery-positive", 161 | native_unit_of_measurement=PERCENTAGE, 162 | entity_category=EntityCategory.CONFIG, 163 | ), 164 | HuaweiSolarNumberEntityDescription( 165 | key=rn.STORAGE_BACKUP_POWER_STATE_OF_CHARGE, 166 | native_min_value=0, 167 | native_max_value=100, 168 | native_step=0.1, 169 | icon="mdi:battery-negative", 170 | native_unit_of_measurement=PERCENTAGE, 171 | entity_category=EntityCategory.CONFIG, 172 | entity_registry_enabled_default=False, 173 | ), 174 | HuaweiSolarNumberEntityDescription( 175 | key=rn.STORAGE_GRID_CHARGE_CUTOFF_STATE_OF_CHARGE, 176 | native_min_value=20, 177 | native_max_value=100, 178 | native_step=0.1, 179 | icon="mdi:battery-charging-50", 180 | native_unit_of_measurement=PERCENTAGE, 181 | entity_category=EntityCategory.CONFIG, 182 | ), 183 | HuaweiSolarNumberEntityDescription( 184 | key=rn.STORAGE_POWER_OF_CHARGE_FROM_GRID, 185 | native_min_value=0, 186 | dynamic_maximum_key=rn.STORAGE_MAXIMUM_POWER_OF_CHARGE_FROM_GRID, 187 | icon="mdi:battery-negative", 188 | native_unit_of_measurement=UnitOfPower.WATT, 189 | entity_category=EntityCategory.CONFIG, 190 | ), 191 | ) 192 | 193 | CAPACITY_CONTROL_NUMBER_DESCRIPTIONS: tuple[HuaweiSolarNumberEntityDescription, ...] = ( 194 | HuaweiSolarNumberEntityDescription( 195 | key=rn.STORAGE_CAPACITY_CONTROL_SOC_PEAK_SHAVING, 196 | dynamic_minimum_key=rn.STORAGE_DISCHARGING_CUTOFF_CAPACITY, 197 | native_max_value=100, 198 | native_step=0.1, 199 | icon="mdi:battery-arrow-up", 200 | native_unit_of_measurement=PERCENTAGE, 201 | entity_category=EntityCategory.CONFIG, 202 | ), 203 | # this entity has a dynamic maximum value which is only available when capacity control is supported 204 | HuaweiSolarNumberEntityDescription( 205 | key=rn.STORAGE_DISCHARGING_CUTOFF_CAPACITY, 206 | native_min_value=0, 207 | native_max_value=20, 208 | dynamic_maximum_key=rn.STORAGE_CAPACITY_CONTROL_SOC_PEAK_SHAVING, 209 | native_step=0.1, 210 | icon="mdi:battery-negative", 211 | native_unit_of_measurement=PERCENTAGE, 212 | entity_category=EntityCategory.CONFIG, 213 | ), 214 | ) 215 | 216 | 217 | NON_CAPACITY_CONTROL_NUMBER_DESCRIPTIONS: tuple[ 218 | HuaweiSolarNumberEntityDescription, ... 219 | ] = ( 220 | # this entity is identical to the one above, but without dynamic maximum. 221 | HuaweiSolarNumberEntityDescription( 222 | key=rn.STORAGE_DISCHARGING_CUTOFF_CAPACITY, 223 | native_min_value=0, 224 | native_max_value=20, 225 | native_step=0.1, 226 | icon="mdi:battery-negative", 227 | native_unit_of_measurement=PERCENTAGE, 228 | entity_category=EntityCategory.CONFIG, 229 | ), 230 | ) 231 | 232 | 233 | async def async_setup_entry( 234 | hass: HomeAssistant, 235 | entry: HuaweiSolarConfigEntry, 236 | async_add_entities: AddEntitiesCallback, 237 | ) -> None: 238 | """Huawei Solar Number entities Setup.""" 239 | if not entry.data.get(CONF_ENABLE_PARAMETER_CONFIGURATION): 240 | _LOGGER.info("Skipping number setup, as parameter configuration is not enabled") 241 | return 242 | 243 | device_datas: list[HuaweiSolarDeviceData] = entry.runtime_data[DATA_DEVICE_DATAS] 244 | 245 | entities_to_add: list[NumberEntity] = [] 246 | for ucs in device_datas: 247 | if not ucs.configuration_update_coordinator: 248 | continue 249 | slave_entities: list[HuaweiSolarNumberEntity] = [] 250 | if isinstance(ucs.device, EMMADevice): 251 | for entity_description in EMMA_NUMBER_DESCRIPTIONS: 252 | slave_entities.append( # noqa: PERF401 253 | await HuaweiSolarNumberEntity.create( 254 | ucs.configuration_update_coordinator, 255 | ucs.device, 256 | entity_description, 257 | ucs.device_info, 258 | ) 259 | ) 260 | 261 | if isinstance(ucs, HuaweiSolarInverterData): 262 | for entity_description in INVERTER_NUMBER_DESCRIPTIONS: 263 | slave_entities.append( # noqa: PERF401 264 | await HuaweiSolarNumberEntity.create( 265 | ucs.configuration_update_coordinator, 266 | ucs.device, 267 | entity_description, 268 | ucs.device_info, 269 | ) 270 | ) 271 | 272 | if ucs.connected_energy_storage: 273 | for entity_description in ENERGY_STORAGE_NUMBER_DESCRIPTIONS: 274 | slave_entities.append( # noqa: PERF401 275 | await HuaweiSolarNumberEntity.create( 276 | ucs.configuration_update_coordinator, 277 | ucs.device, 278 | entity_description, 279 | ucs.device_info, 280 | ) 281 | ) 282 | 283 | if ucs.device.supports_capacity_control: 284 | _LOGGER.debug( 285 | "Adding capacity control number entities on device %s", 286 | ucs.device.serial_number, 287 | ) 288 | for entity_description in CAPACITY_CONTROL_NUMBER_DESCRIPTIONS: 289 | slave_entities.append( # noqa: PERF401 290 | await HuaweiSolarNumberEntity.create( 291 | ucs.configuration_update_coordinator, 292 | ucs.device, 293 | entity_description, 294 | ucs.connected_energy_storage, 295 | ) 296 | ) 297 | 298 | else: 299 | _LOGGER.debug( 300 | "Capacity control not supported on slave %s. Skipping capacity control number entities", 301 | ucs.device.serial_number, 302 | ) 303 | for entity_description in NON_CAPACITY_CONTROL_NUMBER_DESCRIPTIONS: 304 | slave_entities.append( # noqa: PERF401 305 | await HuaweiSolarNumberEntity.create( 306 | ucs.configuration_update_coordinator, 307 | ucs.device, 308 | entity_description, 309 | ucs.connected_energy_storage, 310 | ) 311 | ) 312 | 313 | else: 314 | _LOGGER.debug( 315 | "No battery detected on slave %s. Skipping energy storage number entities", 316 | ucs.device.client.unit_id, 317 | ) 318 | 319 | entities_to_add.extend(slave_entities) 320 | 321 | async_add_entities(entities_to_add) 322 | 323 | 324 | class HuaweiSolarNumberEntity( 325 | CoordinatorEntity[HuaweiSolarUpdateCoordinator], HuaweiSolarEntity, NumberEntity 326 | ): 327 | """Huawei Solar Number Entity.""" 328 | 329 | entity_description: HuaweiSolarNumberEntityDescription 330 | _attr_mode = NumberMode.BOX # Always allow a precise number 331 | 332 | _static_min_value: float | None = None 333 | _static_max_value: float | None = None 334 | 335 | _dynamic_min_value: float | None = None 336 | _dynamic_max_value: float | None = None 337 | 338 | def __init__( 339 | self, 340 | coordinator: HuaweiSolarUpdateCoordinator, 341 | device: HuaweiSolarDevice, 342 | description: HuaweiSolarNumberEntityDescription, 343 | device_info: DeviceInfo, 344 | static_max_value: float | None = None, 345 | static_min_value: float | None = None, 346 | ) -> None: 347 | """Huawei Solar Number Entity constructor. 348 | 349 | Do not use directly. Use `.create` instead! 350 | """ 351 | super().__init__(coordinator, description.context) 352 | self.coordinator = coordinator 353 | 354 | self.device = device 355 | self.entity_description = description 356 | 357 | self._attr_device_info = device_info 358 | self._attr_unique_id = f"{device.serial_number}_{description.key}" 359 | 360 | self._static_max_value = static_max_value 361 | self._static_min_value = static_min_value 362 | 363 | @classmethod 364 | async def create( 365 | cls, 366 | coordinator: HuaweiSolarUpdateCoordinator, 367 | device: HuaweiSolarDevice, 368 | description: HuaweiSolarNumberEntityDescription, 369 | device_info: DeviceInfo, 370 | ) -> "HuaweiSolarNumberEntity": 371 | """Huawei Solar Number Entity constructor. 372 | 373 | This async constructor fills in the necessary min/max values 374 | """ 375 | 376 | static_max_value = None 377 | if description.static_maximum_key: 378 | static_max_value = ( 379 | await device.client.get(description.static_maximum_key) 380 | ).value 381 | 382 | static_min_value = None 383 | if description.static_minimum_key: 384 | static_min_value = ( 385 | await device.client.get(description.static_minimum_key) 386 | ).value 387 | 388 | return cls( 389 | coordinator, 390 | device, 391 | description, 392 | device_info, 393 | static_max_value, 394 | static_min_value, 395 | ) 396 | 397 | @callback 398 | def _handle_coordinator_update(self) -> None: 399 | """Handle updated data from the coordinator.""" 400 | if ( 401 | self.coordinator.data 402 | and self.entity_description.key in self.coordinator.data 403 | ): 404 | self._attr_native_value = self.coordinator.data[ 405 | self.entity_description.register_name 406 | ].value 407 | 408 | if self.entity_description.dynamic_minimum_key: 409 | min_register = self.coordinator.data.get( 410 | self.entity_description.dynamic_minimum_key 411 | ) 412 | 413 | if min_register: 414 | self._dynamic_min_value = min_register.value 415 | 416 | if self.entity_description.dynamic_maximum_key: 417 | max_register = self.coordinator.data.get( 418 | self.entity_description.dynamic_maximum_key 419 | ) 420 | 421 | if max_register: 422 | self._dynamic_max_value = max_register.value 423 | else: 424 | self._attr_available = False 425 | self._attr_native_value = None 426 | 427 | self.async_write_ha_state() 428 | 429 | async def async_set_native_value(self, value: float) -> None: 430 | """Set a new value.""" 431 | if await self.device.set(self.entity_description.register_name, float(value)): 432 | self._attr_native_value = float(value) 433 | 434 | await self.coordinator.async_request_refresh() 435 | 436 | @property 437 | def native_max_value(self) -> float: 438 | """Maximum value, possibly determined dynamically using _dynamic_max_value.""" 439 | native_max_value = ( 440 | self._static_max_value or self.entity_description.native_max_value 441 | ) 442 | 443 | if self._dynamic_max_value: 444 | if native_max_value: 445 | return min(self._dynamic_max_value, native_max_value) 446 | return self._dynamic_max_value 447 | 448 | if native_max_value: 449 | return native_max_value 450 | return DEFAULT_MAX_VALUE 451 | 452 | @property 453 | def native_min_value(self) -> float: 454 | """Minimum value, possibly determined dynamically using _dynamic_min_value.""" 455 | native_min_value = ( 456 | self._static_min_value or self.entity_description.native_min_value 457 | ) 458 | 459 | if self._dynamic_min_value: 460 | if native_min_value: 461 | return max(self._dynamic_min_value, native_min_value) 462 | return self._dynamic_min_value 463 | 464 | if native_min_value: 465 | return native_min_value 466 | return DEFAULT_MIN_VALUE 467 | -------------------------------------------------------------------------------- /config_flow.py: -------------------------------------------------------------------------------- 1 | """Config flow for Huawei Solar integration.""" 2 | 3 | from __future__ import annotations 4 | 5 | import contextlib 6 | import logging 7 | from typing import Any 8 | 9 | from huawei_solar import ( 10 | ConnectionException, 11 | HuaweiSolarException, 12 | InvalidCredentials, 13 | ReadException, 14 | create_device_instance, 15 | create_rtu_client, 16 | create_sub_device_instance, 17 | create_tcp_client, 18 | get_device_infos, 19 | ) 20 | from huawei_solar.device.base import HuaweiSolarDeviceWithLogin 21 | import serial.tools.list_ports 22 | from tmodbus.exceptions import ModbusConnectionError 23 | import voluptuous as vol 24 | 25 | from homeassistant import config_entries 26 | from homeassistant.components import usb 27 | from homeassistant.config_entries import ConfigFlowResult 28 | from homeassistant.const import ( 29 | CONF_HOST, 30 | CONF_PASSWORD, 31 | CONF_PORT, 32 | CONF_TYPE, 33 | CONF_USERNAME, 34 | ) 35 | import homeassistant.helpers.config_validation as cv 36 | 37 | from .const import ( 38 | CONF_ENABLE_PARAMETER_CONFIGURATION, 39 | CONF_SLAVE_IDS, 40 | DEFAULT_PORT, 41 | DEFAULT_SERIAL_SLAVE_ID, 42 | DEFAULT_USERNAME, 43 | DOMAIN, 44 | ) 45 | 46 | _LOGGER = logging.getLogger(__name__) 47 | 48 | CONF_MANUAL_PATH = "Enter Manually" 49 | 50 | 51 | async def validate_serial_setup(port: str, unit_ids: list[int]) -> dict[str, Any]: 52 | """Validate the serial device that was passed by the user.""" 53 | client = create_rtu_client( 54 | port=port, 55 | unit_id=unit_ids[0], 56 | ) 57 | try: 58 | await client.connect() 59 | device = await create_device_instance(client) 60 | 61 | _LOGGER.info( 62 | "Successfully connected to device %s %s with SN %s", 63 | type(device).__name__, 64 | device.model_name, 65 | device.serial_number, 66 | ) 67 | 68 | result = { 69 | "model_name": device.model_name, 70 | "serial_number": device.serial_number, 71 | } 72 | 73 | # Also validate the other slave-ids 74 | for slave_id in unit_ids[1:]: 75 | try: 76 | slave_bridge = await create_sub_device_instance(device, slave_id) 77 | 78 | _LOGGER.info( 79 | "Successfully connected to sub device %s with ID %s: %s with SN %s", 80 | type(slave_bridge).__name__, 81 | slave_id, 82 | slave_bridge.model_name, 83 | slave_bridge.serial_number, 84 | ) 85 | except HuaweiSolarException as err: 86 | _LOGGER.error("Could not connect to slave %s", slave_id) 87 | raise DeviceException(f"Could not connect to slave {slave_id}") from err 88 | 89 | # Return info that you want to store in the config entry. 90 | return result 91 | finally: 92 | # Cleanup this device object explicitly to prevent it from trying to maintain a modbus connection 93 | with contextlib.suppress(Exception): 94 | await client.disconnect() 95 | 96 | 97 | async def validate_network_setup_auto_slave_discovery( 98 | *, 99 | host: str, 100 | port: int, 101 | elevated_permissions: bool, 102 | ) -> dict[str, Any]: 103 | """Validate that we can connect to the device via the provided host and port. Try to autodiscover the slave ids.""" 104 | 105 | client = create_tcp_client( 106 | host=host, 107 | port=port, 108 | unit_id=0, 109 | ) 110 | try: 111 | await client.connect() 112 | device_infos = await get_device_infos(client) 113 | _LOGGER.info("Received %d device infos", len(device_infos)) 114 | 115 | if not device_infos: 116 | raise DeviceException("No devices found") 117 | 118 | device_info = device_infos[0] 119 | _LOGGER.info( 120 | "Device %s was auto-discovered of type %s with model %s and software version %s", 121 | device_info.device_id, 122 | device_info.product_type, 123 | device_info.model, 124 | device_info.software_version, 125 | ) 126 | 127 | if device_info.device_id is None: 128 | raise DeviceException("Primary device has no device_id") 129 | 130 | # we assume the first device is the primary device 131 | device = await create_device_instance(client.for_unit_id(device_info.device_id)) 132 | 133 | _LOGGER.info( 134 | "Successfully connected to device with ID %s: %s %s with SN %s", 135 | device_info.device_id, 136 | type(device).__name__, 137 | device.model_name, 138 | device.serial_number, 139 | ) 140 | 141 | # Check if we have write access. If this is not the case, we will 142 | # need to login (and request the username/password from the user to be 143 | # able to do this). 144 | 145 | has_write_permission = elevated_permissions and ( 146 | not isinstance(device, HuaweiSolarDeviceWithLogin) 147 | or await device.has_write_permission() 148 | ) 149 | 150 | unit_ids = [device_infos[0].device_id] 151 | for device_info in device_infos[1:]: 152 | if device_info.device_id is None: 153 | _LOGGER.warning( 154 | "Device with no device_id found. Skipping. Product type: %s, model: %s, software version: %s", 155 | device_info.product_type, 156 | device_info.model, 157 | device_info.software_version, 158 | ) 159 | continue 160 | 161 | _LOGGER.info( 162 | "Device %s was auto-discovered of type %s with model %s and software version %s", 163 | device_info.device_id, 164 | device_info.product_type, 165 | device_info.model, 166 | device_info.software_version, 167 | ) 168 | try: 169 | sub_device = await create_sub_device_instance( 170 | device, device_info.device_id 171 | ) 172 | 173 | _LOGGER.info( 174 | "Successfully connected to sub device with ID %s. %s: %s with SN %s", 175 | device_info.device_id, 176 | type(sub_device).__name__, 177 | sub_device.model_name, 178 | sub_device.serial_number, 179 | ) 180 | 181 | unit_ids.append(device_info.device_id) 182 | 183 | except HuaweiSolarException: 184 | _LOGGER.exception( 185 | "Error while processing sub device with ID %s. Skipping", 186 | device_info.device_id, 187 | ) 188 | 189 | # Return info that you want to store in the config entry. 190 | return { 191 | "slave_ids": unit_ids, 192 | "model_name": device.model_name, 193 | "serial_number": device.serial_number, 194 | "has_write_permission": has_write_permission, 195 | } 196 | finally: 197 | with contextlib.suppress(Exception): 198 | await client.disconnect() 199 | 200 | 201 | async def validate_network_setup( 202 | *, 203 | host: str, 204 | port: int, 205 | unit_ids: list[int], 206 | elevated_permissions: bool, 207 | ) -> dict[str, Any]: 208 | """Validate the user input allows us to connect. 209 | 210 | Data has the keys from STEP_SETUP_NETWORK_DATA_SCHEMA with values provided by the user. 211 | """ 212 | client = create_tcp_client( 213 | host=host, 214 | port=port, 215 | unit_id=unit_ids[0], 216 | ) 217 | try: 218 | await client.connect() 219 | device = await create_device_instance(client) 220 | 221 | _LOGGER.info( 222 | "Successfully connected to device %s %s with SN %s", 223 | (type(device).__name__), 224 | device.model_name, 225 | device.serial_number, 226 | ) 227 | 228 | # Check if we have write access. If this is not the case, we will 229 | # need to login (and request the username/password from the user to be 230 | # able to do this). 231 | has_write_permission = elevated_permissions and ( 232 | not isinstance(device, HuaweiSolarDeviceWithLogin) 233 | or await device.has_write_permission() 234 | ) 235 | # Also validate the other slave-ids 236 | for unit_id in unit_ids[1:]: 237 | try: 238 | sub_device = await create_sub_device_instance(device, unit_id) 239 | 240 | _LOGGER.info( 241 | "Successfully connected to sub device %s %s: %s with SN %s", 242 | type(sub_device).__name__, 243 | unit_id, 244 | sub_device.model_name, 245 | sub_device.serial_number, 246 | ) 247 | except HuaweiSolarException as err: 248 | _LOGGER.error("Could not connect to sub device %s", unit_id) 249 | raise DeviceException( 250 | f"Could not connect to sub device {unit_id}" 251 | ) from err 252 | 253 | return { 254 | "model_name": device.model_name, 255 | "serial_number": device.serial_number, 256 | "has_write_permission": has_write_permission, 257 | } 258 | finally: 259 | # Cleanup this inverter object explicitly to prevent it from trying to maintain a modbus connection 260 | with contextlib.suppress(Exception): 261 | await client.disconnect() 262 | 263 | 264 | async def validate_network_setup_login( 265 | *, 266 | host: str, 267 | port: int, 268 | unit_id: int, 269 | username: str, 270 | password: str, 271 | ) -> bool: 272 | """Verify the installer username/password and test if it can perform a write-operation.""" 273 | client = create_tcp_client( 274 | host=host, 275 | port=port, 276 | unit_id=unit_id, 277 | ) 278 | try: 279 | # these parameters have already been tested in validate_input, so this should work fine! 280 | await client.connect() 281 | bridge = await create_device_instance(client) 282 | 283 | assert isinstance(bridge, HuaweiSolarDeviceWithLogin) 284 | 285 | await bridge.login(username, password) 286 | 287 | # verify that we have write-permission now 288 | 289 | return await bridge.has_write_permission() 290 | except InvalidCredentials: 291 | return False 292 | finally: 293 | if bridge is not None: 294 | # Cleanup this inverter object explicitly to prevent it from trying to maintain a modbus connection 295 | await bridge.stop() 296 | 297 | 298 | def parse_unit_ids(unit_ids: str) -> list[int]: 299 | """Parse unit ids string into list of ints.""" 300 | try: 301 | return list(map(int, unit_ids.split(","))) 302 | except ValueError as err: 303 | raise UnitIdsParseException from err 304 | 305 | 306 | class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 307 | """Handle a config flow for Huawei Solar.""" 308 | 309 | # Values entered by user in config flow 310 | _host: str | None = None 311 | _port: int | None = None 312 | 313 | _serial_port: str | None = None 314 | _slave_ids: list[int] | None = None 315 | 316 | _username: str | None = None 317 | _password: str | None = None 318 | 319 | _elevated_permissions = False 320 | 321 | # Only used in reauth flows: 322 | _reauth_entry: config_entries.ConfigEntry | None = None 323 | # Only used in reconfigure flows: 324 | _reconfigure_entry: config_entries.ConfigEntry | None = None 325 | 326 | # Only used for async_step_network_login 327 | _inverter_info: dict[str, Any] | None = None 328 | 329 | VERSION = 1 330 | MINOR_VERSION = 1 331 | 332 | async def async_step_user( 333 | self, user_input: dict[str, Any] | None = None 334 | ) -> ConfigFlowResult: 335 | """Step when user initializes a integration.""" 336 | return await self.async_step_setup_connection_type() 337 | 338 | def _update_config_data_from_entry_data(self, entry_data: dict[str, Any]) -> None: 339 | self._host = entry_data.get(CONF_HOST) 340 | if self._host is None: 341 | self._serial_port = entry_data.get(CONF_PORT) 342 | else: 343 | self._port = entry_data.get(CONF_PORT) 344 | 345 | slave_ids = entry_data.get(CONF_SLAVE_IDS) 346 | if not isinstance(slave_ids, list): 347 | assert isinstance(slave_ids, int) 348 | slave_ids = [slave_ids] 349 | self._slave_ids = slave_ids 350 | 351 | self._username = entry_data.get(CONF_USERNAME) 352 | self._password = entry_data.get(CONF_PASSWORD) 353 | 354 | self._elevated_permissions = entry_data.get( 355 | CONF_ENABLE_PARAMETER_CONFIGURATION, False 356 | ) 357 | 358 | async def async_step_reconfigure( 359 | self, user_input: dict[str, Any] | None = None 360 | ) -> ConfigFlowResult: 361 | """Step when user reconfigures the integration.""" 362 | assert "entry_id" in self.context 363 | self._reconfigure_entry = self.hass.config_entries.async_get_known_entry( 364 | self.context["entry_id"] 365 | ) 366 | self._update_config_data_from_entry_data(self._reconfigure_entry.data) # type: ignore[arg-type] 367 | await self.hass.config_entries.async_unload(self.context["entry_id"]) 368 | return await self.async_step_setup_connection_type() 369 | 370 | async def async_step_reauth( 371 | self, config: dict[str, Any] | None = None 372 | ) -> ConfigFlowResult: 373 | """Perform reauth upon an login error.""" 374 | assert config is not None 375 | assert "entry_id" in self.context 376 | self._reauth_entry = self.hass.config_entries.async_get_known_entry( 377 | self.context["entry_id"] 378 | ) 379 | self._update_config_data_from_entry_data(config) 380 | return await self.async_step_network_login() 381 | 382 | async def async_step_setup_connection_type( 383 | self, user_input: dict[str, Any] | None = None 384 | ) -> ConfigFlowResult: 385 | """Step to let the user choose the connection type.""" 386 | if user_input is not None: 387 | user_selection = user_input[CONF_TYPE] 388 | if user_selection == "Serial": 389 | return await self.async_step_setup_serial() 390 | 391 | return await self.async_step_setup_network() 392 | 393 | list_of_types = ["Serial", "Network"] 394 | 395 | # In case of a reconfigure flow, we already know the current choice. 396 | current_conn_type = None 397 | if self._host: 398 | current_conn_type = "Network" 399 | elif self._port: 400 | current_conn_type = "Serial" 401 | schema = vol.Schema( 402 | {vol.Required(CONF_TYPE, default=current_conn_type): vol.In(list_of_types)} 403 | ) 404 | return self.async_show_form(step_id="setup_connection_type", data_schema=schema) 405 | 406 | async def async_step_setup_serial( 407 | self, user_input: dict[str, Any] | None = None 408 | ) -> ConfigFlowResult: 409 | """Handle connection parameters when using ModbusRTU.""" 410 | # You always have elevated permissions when connecting over serial 411 | self._elevated_permissions = True 412 | 413 | errors = {} 414 | 415 | if user_input is not None: 416 | self._host = None 417 | try: 418 | self._slave_ids = parse_unit_ids(user_input[CONF_SLAVE_IDS]) 419 | except UnitIdsParseException: 420 | errors["base"] = "invalid_slave_ids" 421 | else: 422 | if user_input[CONF_PORT] == CONF_MANUAL_PATH: 423 | return await self.async_step_setup_serial_manual_path() 424 | 425 | self._serial_port = await self.hass.async_add_executor_job( 426 | usb.get_serial_by_id, user_input[CONF_PORT] 427 | ) 428 | 429 | try: 430 | assert isinstance(self._serial_port, str) 431 | info = await validate_serial_setup( 432 | self._serial_port, self._slave_ids 433 | ) 434 | 435 | except (ConnectionException, ModbusConnectionError): 436 | errors["base"] = "cannot_connect" 437 | except DeviceException: 438 | errors["base"] = "slave_cannot_connect" 439 | except ReadException: 440 | errors["base"] = "read_error" 441 | except Exception: # allowed in config flow 442 | _LOGGER.exception( 443 | "Unexpected exception while connecting over serial" 444 | ) 445 | errors["base"] = "unknown" 446 | else: 447 | return await self._create_or_update_entry(info) 448 | 449 | ports = await self.hass.async_add_executor_job(serial.tools.list_ports.comports) 450 | list_of_ports = { 451 | port.device: usb.human_readable_device_name( 452 | port.device, 453 | port.serial_number, 454 | port.manufacturer, 455 | port.description, 456 | port.vid, 457 | port.pid, 458 | ) 459 | for port in ports 460 | } 461 | 462 | list_of_ports[CONF_MANUAL_PATH] = CONF_MANUAL_PATH 463 | 464 | schema = vol.Schema( 465 | { 466 | vol.Required(CONF_PORT, default=self._port): vol.In(list_of_ports), 467 | vol.Required( 468 | CONF_SLAVE_IDS, 469 | default=",".join(map(str, self._slave_ids)) 470 | if self._slave_ids 471 | else str(DEFAULT_SERIAL_SLAVE_ID), 472 | ): str, 473 | } 474 | ) 475 | return self.async_show_form( 476 | step_id="setup_serial", 477 | data_schema=schema, 478 | errors=errors, 479 | ) 480 | 481 | async def async_step_setup_serial_manual_path( 482 | self, user_input: dict[str, Any] | None = None 483 | ) -> ConfigFlowResult: 484 | """Select path manually.""" 485 | errors = {} 486 | 487 | if user_input is not None: 488 | self._serial_port = user_input[CONF_PORT] 489 | assert isinstance(self._serial_port, str) 490 | 491 | try: 492 | self._slave_ids = list(map(int, user_input[CONF_SLAVE_IDS].split(","))) 493 | info = await validate_serial_setup(self._serial_port, self._slave_ids) 494 | except UnitIdsParseException: 495 | errors["base"] = "invalid_slave_ids" 496 | except (ConnectionException, ModbusConnectionError): 497 | errors["base"] = "cannot_connect" 498 | except DeviceException: 499 | errors["base"] = "slave_cannot_connect" 500 | except ReadException: 501 | errors["base"] = "read_error" 502 | except Exception: # allowed in config flow 503 | _LOGGER.exception("Unexpected exception while connecting over serial") 504 | errors["base"] = "unknown" 505 | else: 506 | return await self._create_or_update_entry(info) 507 | 508 | schema = vol.Schema( 509 | { 510 | vol.Required(CONF_PORT, default=self._port): str, 511 | vol.Required( 512 | CONF_SLAVE_IDS, 513 | default=",".join(map(str, self._slave_ids)) 514 | if self._slave_ids 515 | else str(DEFAULT_SERIAL_SLAVE_ID), 516 | ): str, 517 | } 518 | ) 519 | return self.async_show_form( 520 | step_id="setup_serial_manual_path", data_schema=schema, errors=errors 521 | ) 522 | 523 | async def async_step_setup_network( 524 | self, user_input: dict[str, Any] | None = None 525 | ) -> ConfigFlowResult: 526 | """Handle connection parameters when using ModbusTCP.""" 527 | errors = {} 528 | 529 | if user_input is not None: 530 | self._host = user_input[CONF_HOST] 531 | assert self._host is not None 532 | self._port = user_input[CONF_PORT] 533 | assert self._port is not None 534 | self._elevated_permissions = user_input[CONF_ENABLE_PARAMETER_CONFIGURATION] 535 | 536 | info = None 537 | if user_input[CONF_SLAVE_IDS].lower() == "auto": 538 | try: 539 | info = await validate_network_setup_auto_slave_discovery( 540 | host=self._host, 541 | port=self._port, 542 | elevated_permissions=self._elevated_permissions, 543 | ) 544 | self._slave_ids = info.pop("slave_ids") 545 | 546 | except (ConnectionException, ModbusConnectionError): 547 | errors["base"] = "cannot_connect" 548 | except DeviceException: 549 | errors["base"] = "slave_cannot_connect" 550 | except ReadException: 551 | _LOGGER.exception("Read exception while connecting via TCP") 552 | errors["base"] = "read_error" 553 | except Exception: # allowed in config flow 554 | _LOGGER.exception("Unexpected exception while connecting via TCP") 555 | errors["base"] = "unknown" 556 | else: 557 | try: 558 | self._slave_ids = list( 559 | map(int, user_input[CONF_SLAVE_IDS].split(",")) 560 | ) 561 | except ValueError: 562 | errors["base"] = "invalid_slave_ids" 563 | else: 564 | try: 565 | info = await validate_network_setup( 566 | host=self._host, 567 | port=self._port, 568 | unit_ids=self._slave_ids, 569 | elevated_permissions=self._elevated_permissions, 570 | ) 571 | 572 | except (ConnectionException, ModbusConnectionError): 573 | errors["base"] = "cannot_connect" 574 | except DeviceException: 575 | errors["base"] = "slave_cannot_connect" 576 | except ReadException: 577 | _LOGGER.exception("Read exception while connecting via TCP") 578 | errors["base"] = "read_error" 579 | except Exception: # allowed in config flow 580 | _LOGGER.exception( 581 | "Unexpected exception while connecting via TCP" 582 | ) 583 | errors["base"] = "unknown" 584 | 585 | # info will be set when we successfully connected to the inverter 586 | if info: 587 | # Check if we need to ask for the login details 588 | if self._elevated_permissions and info["has_write_permission"] is False: 589 | self.context["title_placeholders"] = {"name": info["model_name"]} 590 | self._inverter_info = info 591 | return await self.async_step_network_login() 592 | 593 | # In case of a reconfigure, the user can have unchecked the elevated permissions checkbox 594 | self._username = None 595 | self._password = None 596 | 597 | # Otherwise, we can directly create the device entry! 598 | return await self._create_or_update_entry(info) 599 | 600 | return self.async_show_form( 601 | step_id="setup_network", 602 | data_schema=vol.Schema( 603 | { 604 | vol.Required(CONF_HOST, default=self._host): str, 605 | vol.Required( 606 | CONF_PORT, default=self._port or DEFAULT_PORT 607 | ): cv.port, 608 | vol.Required( 609 | CONF_SLAVE_IDS, 610 | default=",".join(map(str, self._slave_ids)) 611 | if self._slave_ids 612 | else "AUTO", 613 | ): str, 614 | vol.Required( 615 | CONF_ENABLE_PARAMETER_CONFIGURATION, 616 | default=self._elevated_permissions, 617 | ): bool, 618 | } 619 | ), 620 | errors=errors, 621 | ) 622 | 623 | async def async_step_network_login( 624 | self, user_input: dict[str, Any] | None = None 625 | ) -> ConfigFlowResult: 626 | """Handle username/password input.""" 627 | assert self._host is not None 628 | assert self._port is not None 629 | assert self._slave_ids is not None 630 | assert self._inverter_info is not None 631 | 632 | errors = {} 633 | 634 | if user_input is not None: 635 | self._username = user_input[CONF_USERNAME] 636 | self._password = user_input[CONF_PASSWORD] 637 | 638 | assert self._username is not None 639 | assert self._password is not None 640 | 641 | try: 642 | login_success = await validate_network_setup_login( 643 | host=self._host, 644 | port=self._port, 645 | unit_id=self._slave_ids[0], 646 | username=self._username, 647 | password=self._password, 648 | ) 649 | if login_success: 650 | return await self._create_or_update_entry(self._inverter_info) 651 | 652 | errors["base"] = "invalid_auth" 653 | except (ConnectionException, ModbusConnectionError): 654 | errors["base"] = "cannot_connect" 655 | except DeviceException: 656 | errors["base"] = "slave_cannot_connect" 657 | except ReadException: 658 | _LOGGER.exception( 659 | "Could not read from device while validating login parameter" 660 | ) 661 | errors["base"] = "read_error" 662 | except Exception: # allowed in config flow 663 | _LOGGER.exception( 664 | "Unexpected exception while validating login parameters" 665 | ) 666 | errors["base"] = "unknown" 667 | 668 | return self.async_show_form( 669 | step_id="network_login", 670 | data_schema=vol.Schema( 671 | { 672 | vol.Required( 673 | CONF_USERNAME, default=self._username or DEFAULT_USERNAME 674 | ): str, 675 | vol.Required(CONF_PASSWORD, default=self._password): str, 676 | } 677 | ), 678 | errors=errors, 679 | ) 680 | 681 | async def _create_or_update_entry( 682 | self, inverter_info: dict[str, Any] | None 683 | ) -> ConfigFlowResult: 684 | """Create the entry, or update the existing one if present.""" 685 | 686 | data = { 687 | CONF_HOST: self._host, 688 | CONF_PORT: self._serial_port 689 | if self._serial_port is not None 690 | else self._port, 691 | CONF_SLAVE_IDS: self._slave_ids, 692 | CONF_ENABLE_PARAMETER_CONFIGURATION: self._elevated_permissions, 693 | CONF_USERNAME: self._username, 694 | CONF_PASSWORD: self._password, 695 | } 696 | 697 | if self._reauth_entry: 698 | self.hass.config_entries.async_update_entry(self._reauth_entry, data=data) 699 | await self.hass.config_entries.async_reload(self._reauth_entry.entry_id) 700 | return self.async_abort(reason="reauth_successful") 701 | 702 | assert inverter_info 703 | self.context["title_placeholders"] = {"name": inverter_info["model_name"]} 704 | if self._reconfigure_entry: 705 | self.hass.config_entries.async_update_entry( 706 | self._reconfigure_entry, data=data 707 | ) 708 | await self.hass.config_entries.async_reload( 709 | self._reconfigure_entry.entry_id 710 | ) 711 | return self.async_abort(reason="reconfigure_successful") 712 | 713 | await self.async_set_unique_id(inverter_info["serial_number"]) 714 | self._abort_if_unique_id_configured(updates=data) 715 | 716 | return self.async_create_entry(title=inverter_info["model_name"], data=data) 717 | 718 | 719 | class UnitIdsParseException(Exception): 720 | """Error while parsing the unit id's.""" 721 | 722 | 723 | class DeviceException(Exception): 724 | """Error while testing communication with a device.""" 725 | -------------------------------------------------------------------------------- /translations/es_ES.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "already_configured": "Dispositivo ya configurado" 5 | }, 6 | "error": { 7 | "cannot_connect": "Error de conexión", 8 | "invalid_auth": "Credenciales inválidas", 9 | "invalid_slave_ids": "Los IDs esclavos deben estar separados por comas", 10 | "read_error": "Lectura inválida", 11 | "slave_cannot_connect": "Error de conexión al esclavo", 12 | "unknown": "Error inesperado" 13 | }, 14 | "step": { 15 | "network_login": { 16 | "data": { 17 | "password": "Contraseña", 18 | "username": "Nombre de usuario" 19 | }, 20 | "description": "Introduce las credenciales de Instalador" 21 | }, 22 | "setup_network": { 23 | "data": { 24 | "enable_parameter_configuration": "Avanzado: elevar permisos", 25 | "host": "Host", 26 | "port": "Puerto", 27 | "slave_ids": "IDs esclavos (separados por comas)" 28 | } 29 | }, 30 | "setup_serial": { 31 | "data": { 32 | "port": "Selecciona un dispositivo", 33 | "slave_ids": "IDs esclavos (separados por comas)" 34 | }, 35 | "title": "Dispositivo" 36 | }, 37 | "setup_serial_manual_path": { 38 | "data": { 39 | "port": "Ruta al dispositivo USB" 40 | }, 41 | "title": "Ruta" 42 | }, 43 | "user": { 44 | "data": { 45 | "type": "Tipo de conexión" 46 | }, 47 | "title": "Selecciona el tipo de conexión" 48 | } 49 | } 50 | }, 51 | "entity": { 52 | "number": { 53 | "storage_backup_power_state_of_charge": { 54 | "name": "Carga de reserva SoC" 55 | }, 56 | "storage_capacity_control_soc_peak_shaving": { 57 | "name": "Afeitado de pico SoC" 58 | }, 59 | "storage_charging_cutoff_capacity": { 60 | "name": "Fin de carga SoC" 61 | }, 62 | "storage_discharging_cutoff_capacity": { 63 | "name": "Fin de descarga SoC" 64 | }, 65 | "storage_grid_charge_cutoff_state_of_charge": { 66 | "name": "Corte de carga de red SoC" 67 | }, 68 | "storage_maximum_charging_power": { 69 | "name": "Potencia máxima de carga" 70 | }, 71 | "storage_maximum_discharging_power": { 72 | "name": "Potencia máxima de descarga" 73 | }, 74 | "storage_power_of_charge_from_grid": { 75 | "name": "Potencia máxima de carga desde la red" 76 | }, 77 | "mppt_scanning_interval": { 78 | "name": "MPPT scan interval" 79 | } 80 | }, 81 | "select": { 82 | "storage_capacity_control_mode": { 83 | "name": "Modo de control de capacidad", 84 | "state": { 85 | "active_capacity_control": "Control de capacidad activo", 86 | "apparent_power_limit": "Límite de potencia aparente", 87 | "disable": "Desactivar" 88 | } 89 | }, 90 | "storage_excess_pv_energy_use_in_tou": { 91 | "name": "Uso de energía FV excedente en TOU", 92 | "state": { 93 | "charge": "Carga", 94 | "fed_to_grid": "Vertir a la red" 95 | } 96 | }, 97 | "storage_working_mode_settings": { 98 | "name": "Modo de funcionamiento", 99 | "state": { 100 | "adaptive": "Adaptativo", 101 | "fixed_charge_discharge": "Carga y descarga fija", 102 | "fully_fed_to_grid": "Vertido completo a la red", 103 | "maximise_self_consumption": "Maximizar el autoconsumo", 104 | "time_of_use_lg": "Horario de uso", 105 | "time_of_use_luna2000": "Horario de uso" 106 | } 107 | } 108 | }, 109 | "sensor": { 110 | "accumulated_energy_yield": { 111 | "name": "Rendimiento acumulado de energía" 112 | }, 113 | "accumulated_yield_energy": { 114 | "name": "Rendimiento total" 115 | }, 116 | "rated_power": { 117 | "name": "Potencia nominal" 118 | }, 119 | "p_max": { 120 | "name": "Potencia activa máxima" 121 | }, 122 | "active_grid_a_b_voltage": { 123 | "name": "Tensión de línea A-B" 124 | }, 125 | "active_grid_a_power": { 126 | "name": "Potencia activa de fase A" 127 | }, 128 | "active_grid_b_c_voltage": { 129 | "name": "Tensión de línea B-C" 130 | }, 131 | "active_grid_b_power": { 132 | "name": "Potencia activa de fase B" 133 | }, 134 | "active_grid_c_a_voltage": { 135 | "name": "Tensión de línea C-A" 136 | }, 137 | "active_grid_c_power": { 138 | "name": "Potencia activa de fase C" 139 | }, 140 | "active_grid_frequency": { 141 | "name": "Frecuencia" 142 | }, 143 | "active_grid_power_factor": { 144 | "name": "Factor de potencia" 145 | }, 146 | "active_power": { 147 | "name": "Potencia activa" 148 | }, 149 | "alarm": { 150 | "name": "Alarma" 151 | }, 152 | "alarms": { 153 | "name": "Alarmas" 154 | }, 155 | "daily_yield_energy": { 156 | "name": "Rendimiento diario" 157 | }, 158 | "day_active_power_peak": { 159 | "name": "Pico de potencia diurna" 160 | }, 161 | "device_status": { 162 | "name": "Estado del dispositivo" 163 | }, 164 | "efficiency": { 165 | "name": "Eficiencia" 166 | }, 167 | "grid_a_current": { 168 | "name": "Corriente de fase A" 169 | }, 170 | "active_grid_a_current": { 171 | "name": "Corriente de fase A" 172 | }, 173 | "active_grid_b_current": { 174 | "name": "Corriente de fase B" 175 | }, 176 | "active_grid_c_current": { 177 | "name": "Corriente de fase C" 178 | }, 179 | "grid_a_voltage": { 180 | "name": "Tensión de fase A" 181 | }, 182 | "grid_accumulated_energy": { 183 | "name": "Consumo" 184 | }, 185 | "grid_accumulated_reactive_power": { 186 | "name": "Energía reactiva acumulada" 187 | }, 188 | "grid_b_current": { 189 | "name": "Corriente de fase B" 190 | }, 191 | "grid_b_voltage": { 192 | "name": "Tensión de fase B" 193 | }, 194 | "grid_c_current": { 195 | "name": "Corriente de fase C" 196 | }, 197 | "grid_c_voltage": { 198 | "name": "Tensión de fase C" 199 | }, 200 | "grid_exported_energy": { 201 | "name": "Energía exportada" 202 | }, 203 | "input_current": { 204 | "name": "Corriente de entrada" 205 | }, 206 | "input_power": { 207 | "name": "Potencia de entrada" 208 | }, 209 | "input_voltage": { 210 | "name": "Tensión de entrada" 211 | }, 212 | "internal_temperature": { 213 | "name": "Temperatura interna" 214 | }, 215 | "line_voltage_a_b": { 216 | "name": "Tensión de línea A-B" 217 | }, 218 | "line_voltage_b_c": { 219 | "name": "Tensión de línea B-C" 220 | }, 221 | "line_voltage_c_a": { 222 | "name": "Tensión de línea C-A" 223 | }, 224 | "meter_status": { 225 | "name": "Estado del medidor" 226 | }, 227 | "nb_online_optimizers": { 228 | "name": "Optimizadores en línea" 229 | }, 230 | "output_current": { 231 | "name": "Corriente de salida" 232 | }, 233 | "output_power": { 234 | "name": "Potencia de salida" 235 | }, 236 | "output_voltage": { 237 | "name": "Tensión de salida" 238 | }, 239 | "phase_a_current": { 240 | "name": "Corriente de fase A" 241 | }, 242 | "phase_a_voltage": { 243 | "name": "Tensión de fase A" 244 | }, 245 | "phase_b_current": { 246 | "name": "Corriente de fase B" 247 | }, 248 | "phase_b_voltage": { 249 | "name": "Tensión de fase B" 250 | }, 251 | "phase_c_current": { 252 | "name": "Corriente de fase C" 253 | }, 254 | "phase_c_voltage": { 255 | "name": "Tensión de fase C" 256 | }, 257 | "power_factor": { 258 | "name": "Factor de potencia" 259 | }, 260 | "power_meter_active_power": { 261 | "name": "Potencia activa" 262 | }, 263 | "power_meter_reactive_power": { 264 | "name": "Potencia reactiva" 265 | }, 266 | "pv_01_current": { 267 | "name": "Corriente FV 1" 268 | }, 269 | "pv_01_voltage": { 270 | "name": "Tensión FV 1" 271 | }, 272 | "pv_02_current": { 273 | "name": "Corriente FV 2" 274 | }, 275 | "pv_02_voltage": { 276 | "name": "Tensión FV 2" 277 | }, 278 | "pv_03_current": { 279 | "name": "Corriente FV 3" 280 | }, 281 | "pv_03_voltage": { 282 | "name": "Tensión FV 3" 283 | }, 284 | "pv_04_current": { 285 | "name": "Corriente FV 4" 286 | }, 287 | "pv_04_voltage": { 288 | "name": "Tensión FV 4" 289 | }, 290 | "pv_05_current": { 291 | "name": "Corriente FV 5" 292 | }, 293 | "pv_05_voltage": { 294 | "name": "Tensión FV 5" 295 | }, 296 | "pv_06_current": { 297 | "name": "Corriente FV 6" 298 | }, 299 | "pv_06_voltage": { 300 | "name": "Tensión FV 6" 301 | }, 302 | "pv_07_current": { 303 | "name": "Corriente FV 7" 304 | }, 305 | "pv_07_voltage": { 306 | "name": "Tensión FV 7" 307 | }, 308 | "pv_08_current": { 309 | "name": "Corriente FV 8" 310 | }, 311 | "pv_08_voltage": { 312 | "name": "Tensión FV 8" 313 | }, 314 | "pv_09_current": { 315 | "name": "Corriente FV 9" 316 | }, 317 | "pv_09_voltage": { 318 | "name": "Tensión FV 9" 319 | }, 320 | "pv_10_current": { 321 | "name": "Corriente FV 10" 322 | }, 323 | "pv_10_voltage": { 324 | "name": "Tensión FV 10" 325 | }, 326 | "pv_11_current": { 327 | "name": "Corriente FV 11" 328 | }, 329 | "pv_11_voltage": { 330 | "name": "Tensión FV 11" 331 | }, 332 | "pv_12_current": { 333 | "name": "Corriente FV 12" 334 | }, 335 | "pv_12_voltage": { 336 | "name": "Tensión FV 12" 337 | }, 338 | "pv_13_current": { 339 | "name": "Corriente FV 13" 340 | }, 341 | "pv_13_voltage": { 342 | "name": "Tensión FV 13" 343 | }, 344 | "pv_14_current": { 345 | "name": "Corriente FV 14" 346 | }, 347 | "pv_14_voltage": { 348 | "name": "Tensión FV 14" 349 | }, 350 | "pv_15_current": { 351 | "name": "Corriente FV 15" 352 | }, 353 | "pv_15_voltage": { 354 | "name": "Tensión FV 15" 355 | }, 356 | "pv_16_current": { 357 | "name": "Corriente FV 16" 358 | }, 359 | "pv_16_voltage": { 360 | "name": "Tensión FV 16" 361 | }, 362 | "pv_17_current": { 363 | "name": "Corriente FV 17" 364 | }, 365 | "pv_17_voltage": { 366 | "name": "Tensión FV 17" 367 | }, 368 | "pv_18_current": { 369 | "name": "Corriente FV 18" 370 | }, 371 | "pv_18_voltage": { 372 | "name": "Tensión FV 18" 373 | }, 374 | "pv_19_current": { 375 | "name": "Corriente FV 19" 376 | }, 377 | "pv_19_voltage": { 378 | "name": "Tensión FV 19" 379 | }, 380 | "pv_20_current": { 381 | "name": "Corriente FV 20" 382 | }, 383 | "pv_20_voltage": { 384 | "name": "Tensión FV 20" 385 | }, 386 | "pv_21_current": { 387 | "name": "Corriente FV 21" 388 | }, 389 | "pv_21_voltage": { 390 | "name": "Tensión FV 21" 391 | }, 392 | "pv_22_current": { 393 | "name": "Corriente FV 22" 394 | }, 395 | "pv_22_voltage": { 396 | "name": "Tensión FV 22" 397 | }, 398 | "pv_23_current": { 399 | "name": "Corriente FV 23" 400 | }, 401 | "pv_23_voltage": { 402 | "name": "Tensión FV 23" 403 | }, 404 | "pv_24_current": { 405 | "name": "Corriente FV 24" 406 | }, 407 | "pv_24_voltage": { 408 | "name": "Tensión FV 24" 409 | }, 410 | "reactive_power": { 411 | "name": "Potencia reactiva" 412 | }, 413 | "running_status": { 414 | "name": "Estado de funcionamiento" 415 | }, 416 | "shutdown_time": { 417 | "name": "Hora de apagado" 418 | }, 419 | "single_phase_meter_current": { 420 | "name": "Corriente" 421 | }, 422 | "single_phase_meter_voltage": { 423 | "name": "Tensión" 424 | }, 425 | "startup_time": { 426 | "name": "Hora de arranque" 427 | }, 428 | "state_1": { 429 | "name": "Estado del inversor" 430 | }, 431 | "state_2_0": { 432 | "name": "Estado de bloqueo" 433 | }, 434 | "state_2_1": { 435 | "name": "Estado de conexión FV" 436 | }, 437 | "state_2_2": { 438 | "name": "Recopilación de datos DSP" 439 | }, 440 | "state_3_0": { 441 | "name": "Estado off-grid" 442 | }, 443 | "state_3_1": { 444 | "name": "Interruptor off-grid" 445 | }, 446 | "storage_bus_current": { 447 | "name": "Corriente del bus" 448 | }, 449 | "storage_bus_voltage": { 450 | "name": "Tensión del bus" 451 | }, 452 | "storage_capacity_control_periods": { 453 | "name": "Períodos de control de capacidad" 454 | }, 455 | "storage_charge_discharge_power": { 456 | "name": "Potencia de carga/descarga" 457 | }, 458 | "storage_current_day_charge_capacity": { 459 | "name": "Carga diaria" 460 | }, 461 | "storage_current_day_discharge_capacity": { 462 | "name": "Descarga diaria" 463 | }, 464 | "storage_fixed_charging_and_discharging_periods": { 465 | "name": "Períodos de carga y descarga fija" 466 | }, 467 | "storage_running_status": { 468 | "name": "Estado" 469 | }, 470 | "storage_maximum_charge_power": { 471 | "name": "Potencia máxima de carga" 472 | }, 473 | "storage_maximum_discharge_power": { 474 | "name": "Potencia máxima de descarga" 475 | }, 476 | "storage_rated_capacity": { 477 | "name": "Capacidad nominal" 478 | }, 479 | "storage_state_of_capacity": { 480 | "name": "Estado de la capacidad" 481 | }, 482 | "storage_time_of_use_charging_and_discharging_periods": { 483 | "name": "Períodos de horario de uso" 484 | }, 485 | "storage_total_charge": { 486 | "name": "Carga total" 487 | }, 488 | "storage_total_discharge": { 489 | "name": "Descarga total" 490 | }, 491 | "temperature": { 492 | "name": "Temperatura" 493 | }, 494 | "voltage_to_ground": { 495 | "name": "Tensión a tierra" 496 | }, 497 | "storage_lg_resu_time_of_use_price_periods": { 498 | "name": "TOU price periods" 499 | }, 500 | "storage_huawei_luna2000_time_of_use_price_periods": { 501 | "name": "TOU price periods" 502 | }, 503 | "storage_huawei_luna2000_time_of_use_charging_and_discharging_periods": { 504 | "name": "TOU charging and discharging periods" 505 | }, 506 | "storage_lg_resu_time_of_use_charging_and_discharging_periods": { 507 | "name": "TOU charging and discharging periods" 508 | }, 509 | "inverter_total_absorbed_energy": { 510 | "name": "Total absorbed energy" 511 | }, 512 | "energy_charged_today": { 513 | "name": "Energy charged today" 514 | }, 515 | "total_charged_energy": { 516 | "name": "Total charged energy" 517 | }, 518 | "energy_discharged_today": { 519 | "name": "Energy discharged today" 520 | }, 521 | "total_discharged_energy": { 522 | "name": "Total discharged energy" 523 | }, 524 | "ess_chargeable_energy": { 525 | "name": "ESS Chargeable energy" 526 | }, 527 | "ess_dischargeable_energy": { 528 | "name": "ESS Dischargeable energy" 529 | }, 530 | "rated_ess_capacity": { 531 | "name": "Rated ESS capacity" 532 | }, 533 | "consumption_today": { 534 | "name": "Consumption today" 535 | }, 536 | "total_energy_consumption": { 537 | "name": "Total energy consumption" 538 | }, 539 | "soh_calibration_status": { 540 | "name": "SOH calibration status" 541 | }, 542 | "pack_1_max_temperature": { 543 | "name": "Pack 1 max temperature" 544 | }, 545 | "pack_1_min_temperature": { 546 | "name": "Pack 1 min temperature" 547 | }, 548 | "pack_2_max_temperature": { 549 | "name": "Pack 2 max temperature" 550 | }, 551 | "pack_2_min_temperature": { 552 | "name": "Pack 2 min temperature" 553 | }, 554 | "pack_3_max_temperature": { 555 | "name": "Pack 3 max temperature" 556 | }, 557 | "pack_3_min_temperature": { 558 | "name": "Pack 3 min temperature" 559 | }, 560 | "pack_1_working_status": { 561 | "name": "Pack 1 working status" 562 | }, 563 | "pack_2_working_status": { 564 | "name": "Pack 2 working status" 565 | }, 566 | "pack_3_working_status": { 567 | "name": "Pack 3 working status" 568 | }, 569 | "pack_1_firmware_version": { 570 | "name": "Pack 1 firmware version" 571 | }, 572 | "pack_1_serial_number": { 573 | "name": "Pack 1 serial number" 574 | }, 575 | "pack_1_state_of_capacity": { 576 | "name": "Pack 1 state of capacity" 577 | }, 578 | "pack_1_charge_discharge_power": { 579 | "name": "Pack 1 charge discharge power" 580 | }, 581 | "pack_1_voltage": { 582 | "name": "Pack 1 voltage" 583 | }, 584 | "pack_1_current": { 585 | "name": "Pack 1 current" 586 | }, 587 | "pack_1_soh_calibration_status": { 588 | "name": "Pack 1 SOH calibration status" 589 | }, 590 | "pack_1_total_charge": { 591 | "name": "Pack 1 total charge" 592 | }, 593 | "pack_1_total_discharge": { 594 | "name": "Pack 1 total discharge" 595 | }, 596 | "pack_2_firmware_version": { 597 | "name": "Pack 2 firmware version" 598 | }, 599 | "pack_2_serial_number": { 600 | "name": "Pack 2 serial number" 601 | }, 602 | "pack_2_state_of_capacity": { 603 | "name": "Pack 2 state of capacity" 604 | }, 605 | "pack_2_charge_discharge_power": { 606 | "name": "Pack 2 charge discharge power" 607 | }, 608 | "pack_2_voltage": { 609 | "name": "Pack 2 voltage" 610 | }, 611 | "pack_2_current": { 612 | "name": "Pack 2 current" 613 | }, 614 | "pack_2_soh_calibration_status": { 615 | "name": "Pack 2 SOH calibration status" 616 | }, 617 | "pack_2_total_charge": { 618 | "name": "Pack 2 total charge" 619 | }, 620 | "pack_2_total_discharge": { 621 | "name": "Pack 2 total discharge" 622 | }, 623 | "pack_3_firmware_version": { 624 | "name": "Pack 3 firmware version" 625 | }, 626 | "pack_3_serial_number": { 627 | "name": "Pack 3 serial number" 628 | }, 629 | "pack_3_state_of_capacity": { 630 | "name": "Pack 3 state of capacity" 631 | }, 632 | "pack_3_charge_discharge_power": { 633 | "name": "Pack 3 charge discharge power" 634 | }, 635 | "pack_3_voltage": { 636 | "name": "Pack 3 voltage" 637 | }, 638 | "pack_3_current": { 639 | "name": "Pack 3 current" 640 | }, 641 | "pack_3_soh_calibration_status": { 642 | "name": "Pack 3 SOH calibration status" 643 | }, 644 | "pack_3_total_charge": { 645 | "name": "Pack 3 total charge" 646 | }, 647 | "pack_3_total_discharge": { 648 | "name": "Pack 3 total discharge" 649 | }, 650 | "bms_temperature": { 651 | "name": "BMS temperature" 652 | } 653 | }, 654 | "switch": { 655 | "startup": { 656 | "name": "Encendido/Apagado del inversor" 657 | }, 658 | "storage_charge_from_grid_function": { 659 | "name": "Carga desde la red" 660 | }, 661 | "mppt_multimodal_scanning": { 662 | "name": "MPPT scanning" 663 | } 664 | } 665 | }, 666 | "services": { 667 | "forcible_charge": { 668 | "description": "Carga forzada de la batería durante un cierto tiempo", 669 | "fields": { 670 | "device_id": { 671 | "description": "Debe ser un dispositivo 'Batería'", 672 | "name": "Batería" 673 | }, 674 | "duration": { 675 | "description": "Duración de la carga", 676 | "name": "Duración" 677 | }, 678 | "power": { 679 | "description": "Potencia utilizada para la carga", 680 | "name": "Potencia" 681 | } 682 | }, 683 | "name": "Carga Forzada" 684 | }, 685 | "forcible_charge_soc": { 686 | "description": "Carga forzada de la batería hasta cierto nivel de SoC", 687 | "fields": { 688 | "device_id": { 689 | "description": "Debe ser un dispositivo 'Batería'", 690 | "name": "Batería" 691 | }, 692 | "power": { 693 | "description": "Potencia utilizada para la carga", 694 | "name": "Potencia" 695 | }, 696 | "target_soc": { 697 | "description": "SoC que debe alcanzarse", 698 | "name": "SoC Objetivo" 699 | } 700 | }, 701 | "name": "Carga Forzada a un Nivel de SoC" 702 | }, 703 | "forcible_discharge": { 704 | "description": "Descarga forzada de la batería durante un cierto tiempo", 705 | "fields": { 706 | "device_id": { 707 | "description": "Debe ser un dispositivo 'Batería'", 708 | "name": "Batería" 709 | }, 710 | "duration": { 711 | "description": "Duración de la descarga", 712 | "name": "Duración" 713 | }, 714 | "power": { 715 | "description": "Potencia utilizada para la descarga", 716 | "name": "Potencia" 717 | } 718 | }, 719 | "name": "Descarga Forzada" 720 | }, 721 | "forcible_discharge_soc": { 722 | "description": "Descarga forzada de la batería hasta cierto nivel de SoC", 723 | "fields": { 724 | "device_id": { 725 | "description": "Debe ser un dispositivo 'Batería'", 726 | "name": "Batería" 727 | }, 728 | "power": { 729 | "description": "Potencia utilizada para la descarga", 730 | "name": "Potencia" 731 | }, 732 | "target_soc": { 733 | "description": "SoC que debe alcanzarse", 734 | "name": "SoC objetivo" 735 | } 736 | }, 737 | "name": "Descarga forzada a un nivel de SoC" 738 | }, 739 | "reset_maximum_feed_grid_power": { 740 | "description": "Restablece el control de potencia activa al modo ilimitado por defecto", 741 | "fields": { 742 | "device_id": { 743 | "description": "Debe ser un dispositivo 'Inversor'", 744 | "name": "Inversor" 745 | } 746 | }, 747 | "name": "Restablecer control de potencia activa a máxima alimentación a la red" 748 | }, 749 | "set_capacity_control_periods": { 750 | "description": "Establece los períodos de control de capacidad", 751 | "fields": { 752 | "device_id": { 753 | "description": "Debe ser un dispositivo 'Batería'", 754 | "name": "Batería" 755 | }, 756 | "periods": { 757 | "description": "Un período por línea. Formato: '[hora inicio]-[hora fin]/[días efectivos]/[potencia]W', con 1=lunes, 7=domingo. Ejemplo: '00:00-23:59/1234567/2500W'.", 758 | "name": "Períodos" 759 | } 760 | }, 761 | "name": "Establecer períodos de control de capacidad" 762 | }, 763 | "set_di_active_power_scheduling": { 764 | "description": "Establece el control de potencia activa a 'Programación Activa DI'", 765 | "fields": { 766 | "device_id": { 767 | "description": "Debe ser un dispositivo 'Inversor'", 768 | "name": "Inversor" 769 | } 770 | }, 771 | "name": "Establecer control de potencia activa a 'Programación Activa DI'" 772 | }, 773 | "set_fixed_charge_periods": { 774 | "description": "Establece los períodos de carga y descarga fija", 775 | "fields": { 776 | "device_id": { 777 | "description": "Debe ser un dispositivo 'Batería'", 778 | "name": "Batería" 779 | }, 780 | "periods": { 781 | "description": "Un período por línea. Formato: '[hora inicio]-[hora fin]/[potencia]W'. Ejemplo: '12:00-15:59/-1000W'.", 782 | "name": "Períodos" 783 | } 784 | }, 785 | "name": "Establecer períodos de carga y descarga fija" 786 | }, 787 | "set_maximum_feed_grid_power": { 788 | "description": "Establece el Control de Potencia Activa a 'Conexión a la red Limitada por Potencia' a la potencia en vatios especificada", 789 | "fields": { 790 | "device_id": { 791 | "description": "Debe ser un dispositivo 'Inversor'", 792 | "name": "Inversor" 793 | }, 794 | "power": { 795 | "description": "Potencia máxima en Vatios", 796 | "name": "Potencia" 797 | } 798 | }, 799 | "name": "Limitar la potencia alimentada a la red" 800 | }, 801 | "set_maximum_feed_grid_power_percent": { 802 | "description": "Establece el Control de Potencia Activa a 'Conexión a la red limitada por Potencia (%)' al porcentaje especificado", 803 | "fields": { 804 | "device_id": { 805 | "description": "Debe ser un dispositivo 'Inversor'", 806 | "name": "Inversor" 807 | }, 808 | "power_percentage": { 809 | "description": "Porcentaje máximo", 810 | "name": "Porcentaje de Potencia" 811 | } 812 | }, 813 | "name": "Limitar la potencia alimentada a la red a un porcentaje" 814 | }, 815 | "set_tou_periods": { 816 | "description": "Establece los períodos de uso", 817 | "fields": { 818 | "device_id": { 819 | "description": "Debe ser un dispositivo 'Batería'", 820 | "name": "Batería" 821 | }, 822 | "periods": { 823 | "description": "Un período por línea. Para baterías Huawei LUNA2000: '[hora inicio]-[hora fin]/[días efectivos]/[carga]', con 1=lunes, 7=domingo (ejemplo: '12:00-14:00/1234567/-'). Para baterías LG RESU: '[hora inicio]-[hora fin]/[precio de la electricidad]' (ejemplo: 13:00-14:00/0.50)", 824 | "name": "Períodos" 825 | } 826 | }, 827 | "name": "Establecer los períodos de uso" 828 | }, 829 | "set_zero_power_grid_connection": { 830 | "description": "Establece el Control de Potencia Activa a 'Conexión a la red con inyección cero'", 831 | "fields": { 832 | "device_id": { 833 | "description": "Debe ser un dispositivo 'Inversor'", 834 | "name": "Inversor" 835 | } 836 | }, 837 | "name": "Establecer Control de Potencia Activa a 'Conexión a la red con inyección cero'" 838 | }, 839 | "stop_forcible_charge": { 840 | "description": "Cancelar el comando de carga forzada en ejecución", 841 | "fields": { 842 | "device_id": { 843 | "description": "Debe ser un dispositivo 'Batería'", 844 | "name": "Batería" 845 | } 846 | }, 847 | "name": "Detener la carga o descarga forzada" 848 | } 849 | } 850 | } 851 | -------------------------------------------------------------------------------- /translations/ca_ES.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "already_configured": "Dispositiu ja configurat" 5 | }, 6 | "error": { 7 | "cannot_connect": "Error de connexió", 8 | "invalid_auth": "Credencials no vàlides", 9 | "invalid_slave_ids": "Els IDs esclaus han d'estar separats per comes", 10 | "read_error": "Error de lectura", 11 | "slave_cannot_connect": "Error de connexió al dispositiu esclau", 12 | "unknown": "Error inesperat" 13 | }, 14 | "step": { 15 | "network_login": { 16 | "data": { 17 | "password": "Contrasenya", 18 | "username": "Nom d'usuari" 19 | }, 20 | "description": "Introdueix les credencials d'instal·lador" 21 | }, 22 | "setup_network": { 23 | "data": { 24 | "enable_parameter_configuration": "Avançat: elevar permisos", 25 | "host": "Host", 26 | "port": "Port", 27 | "slave_ids": "IDs d'esclaus (separats per comes)" 28 | } 29 | }, 30 | "setup_serial": { 31 | "data": { 32 | "port": "Selecciona un dispositiu", 33 | "slave_ids": "IDs d'esclaus (separats per comes)" 34 | }, 35 | "title": "Dispositiu" 36 | }, 37 | "setup_serial_manual_path": { 38 | "data": { 39 | "port": "Ruta al dispositiu USB" 40 | }, 41 | "title": "Ruta" 42 | }, 43 | "user": { 44 | "data": { 45 | "type": "Tipus de connexió" 46 | }, 47 | "title": "Selecciona el tipus de connexió" 48 | } 49 | } 50 | }, 51 | "entity": { 52 | "number": { 53 | "storage_backup_power_state_of_charge": { 54 | "name": "Càrrega de reserva SoC" 55 | }, 56 | "storage_capacity_control_soc_peak_shaving": { 57 | "name": "Raspat de pic SoC" 58 | }, 59 | "storage_charging_cutoff_capacity": { 60 | "name": "Fi de càrrega SoC" 61 | }, 62 | "storage_discharging_cutoff_capacity": { 63 | "name": "Fi de descàrrega SoC" 64 | }, 65 | "storage_grid_charge_cutoff_state_of_charge": { 66 | "name": "Tall de càrrega de xarxa SoC" 67 | }, 68 | "storage_maximum_charging_power": { 69 | "name": "Potència màxima de càrrega" 70 | }, 71 | "storage_maximum_discharging_power": { 72 | "name": "Potència màxima de descàrrega" 73 | }, 74 | "storage_power_of_charge_from_grid": { 75 | "name": "Potència màxima de càrrega des de la xarxa" 76 | }, 77 | "mppt_scanning_interval": { 78 | "name": "MPPT scan interval" 79 | } 80 | }, 81 | "select": { 82 | "storage_capacity_control_mode": { 83 | "name": "Mode de control de capacitat", 84 | "state": { 85 | "active_capacity_control": "Control de capacitat actiu", 86 | "apparent_power_limit": "Límit de potència aparent", 87 | "disable": "Desactivar" 88 | } 89 | }, 90 | "storage_excess_pv_energy_use_in_tou": { 91 | "name": "Ús d'energia FV excedent en TOU", 92 | "state": { 93 | "charge": "Càrrega", 94 | "fed_to_grid": "Vertir a la xarxa" 95 | } 96 | }, 97 | "storage_working_mode_settings": { 98 | "name": "Mode de funcionament", 99 | "state": { 100 | "adaptive": "Adaptatiu", 101 | "fixed_charge_discharge": "Càrrega i descàrrega fixa", 102 | "fully_fed_to_grid": "Vertit complet a la xarxa", 103 | "maximise_self_consumption": "Maximitzar l'autoconsum", 104 | "time_of_use_lg": "Horari d'ús", 105 | "time_of_use_luna2000": "Horari d'ús" 106 | } 107 | } 108 | }, 109 | "sensor": { 110 | "accumulated_energy_yield": { 111 | "name": "Rendiment acumulat d'energia" 112 | }, 113 | "accumulated_yield_energy": { 114 | "name": "Rendiment total" 115 | }, 116 | "rated_power": { 117 | "name": "Potència nominal" 118 | }, 119 | "p_max": { 120 | "name": "Potència activa màxima" 121 | }, 122 | "active_grid_a_b_voltage": { 123 | "name": "Tensió de línia A-B" 124 | }, 125 | "active_grid_a_power": { 126 | "name": "Potència activa de fase A" 127 | }, 128 | "active_grid_b_c_voltage": { 129 | "name": "Tensió de línia B-C" 130 | }, 131 | "active_grid_b_power": { 132 | "name": "Potència activa de fase B" 133 | }, 134 | "active_grid_c_a_voltage": { 135 | "name": "Tensió de línia C-A" 136 | }, 137 | "active_grid_c_power": { 138 | "name": "Potència activa de fase C" 139 | }, 140 | "active_grid_frequency": { 141 | "name": "Frequència" 142 | }, 143 | "active_grid_power_factor": { 144 | "name": "Factor de potència" 145 | }, 146 | "active_power": { 147 | "name": "Potència activa" 148 | }, 149 | "alarm": { 150 | "name": "Alarma" 151 | }, 152 | "alarms": { 153 | "name": "Alarmes" 154 | }, 155 | "daily_yield_energy": { 156 | "name": "Rendiment diari" 157 | }, 158 | "day_active_power_peak": { 159 | "name": "Pic de potència diürna" 160 | }, 161 | "device_status": { 162 | "name": "Estat del dispositiu" 163 | }, 164 | "efficiency": { 165 | "name": "Eficiència" 166 | }, 167 | "grid_a_current": { 168 | "name": "Corrent de fase A" 169 | }, 170 | "grid_a_voltage": { 171 | "name": "Tensió de fase A" 172 | }, 173 | "grid_accumulated_energy": { 174 | "name": "Consum" 175 | }, 176 | "grid_accumulated_reactive_power": { 177 | "name": "Energia reactiva acumulada" 178 | }, 179 | "grid_b_current": { 180 | "name": "Corrent de fase B" 181 | }, 182 | "grid_b_voltage": { 183 | "name": "Tensió de fase B" 184 | }, 185 | "grid_c_current": { 186 | "name": "Corrent de fase C" 187 | }, 188 | "grid_c_voltage": { 189 | "name": "Tensió de fase C" 190 | }, 191 | "grid_exported_energy": { 192 | "name": "Energia exportada" 193 | }, 194 | "input_current": { 195 | "name": "Corrent d'entrada" 196 | }, 197 | "input_power": { 198 | "name": "Potència d'entrada" 199 | }, 200 | "input_voltage": { 201 | "name": "Tensió d'entrada" 202 | }, 203 | "internal_temperature": { 204 | "name": "Temperatura interna" 205 | }, 206 | "line_voltage_a_b": { 207 | "name": "Tensió de línia A-B" 208 | }, 209 | "line_voltage_b_c": { 210 | "name": "Tensió de línia B-C" 211 | }, 212 | "line_voltage_c_a": { 213 | "name": "Tensió de línia C-A" 214 | }, 215 | "meter_status": { 216 | "name": "Estat del comptador" 217 | }, 218 | "nb_online_optimizers": { 219 | "name": "Optimitzadors en línia" 220 | }, 221 | "output_current": { 222 | "name": "Corrent de sortida" 223 | }, 224 | "output_power": { 225 | "name": "Potència de sortida" 226 | }, 227 | "output_voltage": { 228 | "name": "Tensió de sortida" 229 | }, 230 | "phase_a_current": { 231 | "name": "Corrent de fase A" 232 | }, 233 | "phase_a_voltage": { 234 | "name": "Tensió de fase A" 235 | }, 236 | "phase_b_current": { 237 | "name": "Corrent de fase B" 238 | }, 239 | "phase_b_voltage": { 240 | "name": "Tensió de fase B" 241 | }, 242 | "phase_c_current": { 243 | "name": "Corrent de fase C" 244 | }, 245 | "phase_c_voltage": { 246 | "name": "Tensió de fase C" 247 | }, 248 | "power_factor": { 249 | "name": "Factor de potència" 250 | }, 251 | "power_meter_active_power": { 252 | "name": "Potència activa" 253 | }, 254 | "power_meter_reactive_power": { 255 | "name": "Potència reactiva" 256 | }, 257 | "pv_01_current": { 258 | "name": "Corrent FV 1" 259 | }, 260 | "pv_01_voltage": { 261 | "name": "Tensió FV 1" 262 | }, 263 | "pv_02_current": { 264 | "name": "Corrent FV 2" 265 | }, 266 | "pv_02_voltage": { 267 | "name": "Tensió FV 2" 268 | }, 269 | "pv_03_current": { 270 | "name": "Corrent FV 3" 271 | }, 272 | "pv_03_voltage": { 273 | "name": "Tensió FV 3" 274 | }, 275 | "pv_04_current": { 276 | "name": "Corrent FV 4" 277 | }, 278 | "pv_04_voltage": { 279 | "name": "Tensió FV 4" 280 | }, 281 | "pv_05_current": { 282 | "name": "Corrent FV 5" 283 | }, 284 | "pv_05_voltage": { 285 | "name": "Tensió FV 5" 286 | }, 287 | "pv_06_current": { 288 | "name": "Corrent FV 6" 289 | }, 290 | "pv_06_voltage": { 291 | "name": "Tensió FV 6" 292 | }, 293 | "pv_07_current": { 294 | "name": "Corrent FV 7" 295 | }, 296 | "pv_07_voltage": { 297 | "name": "Tensió FV 7" 298 | }, 299 | "pv_08_current": { 300 | "name": "Corrent FV 8" 301 | }, 302 | "pv_08_voltage": { 303 | "name": "Tensió FV 8" 304 | }, 305 | "pv_09_current": { 306 | "name": "Corrent FV 9" 307 | }, 308 | "pv_09_voltage": { 309 | "name": "Tensió FV 9" 310 | }, 311 | "pv_10_current": { 312 | "name": "Corrent FV 10" 313 | }, 314 | "pv_10_voltage": { 315 | "name": "Tensió FV 10" 316 | }, 317 | "pv_11_current": { 318 | "name": "Corrent FV 11" 319 | }, 320 | "pv_11_voltage": { 321 | "name": "Tensió FV 11" 322 | }, 323 | "pv_12_current": { 324 | "name": "Corrent FV 12" 325 | }, 326 | "pv_12_voltage": { 327 | "name": "Tensió FV 12" 328 | }, 329 | "pv_13_current": { 330 | "name": "Corrent FV 13" 331 | }, 332 | "pv_13_voltage": { 333 | "name": "Tensió FV 13" 334 | }, 335 | "pv_14_current": { 336 | "name": "Corrent FV 14" 337 | }, 338 | "pv_14_voltage": { 339 | "name": "Tensió FV 14" 340 | }, 341 | "pv_15_current": { 342 | "name": "Corrent FV 15" 343 | }, 344 | "pv_15_voltage": { 345 | "name": "Tensió FV 15" 346 | }, 347 | "pv_16_current": { 348 | "name": "Corrent FV 16" 349 | }, 350 | "pv_16_voltage": { 351 | "name": "Tensió FV 16" 352 | }, 353 | "pv_17_current": { 354 | "name": "Corrent FV 17" 355 | }, 356 | "pv_17_voltage": { 357 | "name": "Tensió FV 17" 358 | }, 359 | "pv_18_current": { 360 | "name": "Corrent FV 18" 361 | }, 362 | "pv_18_voltage": { 363 | "name": "Tensió FV 18" 364 | }, 365 | "pv_19_current": { 366 | "name": "Corrent FV 19" 367 | }, 368 | "pv_19_voltage": { 369 | "name": "Tensió FV 19" 370 | }, 371 | "pv_20_current": { 372 | "name": "Corrent FV 20" 373 | }, 374 | "pv_20_voltage": { 375 | "name": "Tensió FV 20" 376 | }, 377 | "pv_21_current": { 378 | "name": "Corrent FV 21" 379 | }, 380 | "pv_21_voltage": { 381 | "name": "Tensió FV 21" 382 | }, 383 | "pv_22_current": { 384 | "name": "Corrent FV 22" 385 | }, 386 | "pv_22_voltage": { 387 | "name": "Tensió FV 22" 388 | }, 389 | "pv_23_current": { 390 | "name": "Corrent FV 23" 391 | }, 392 | "pv_23_voltage": { 393 | "name": "Tensió FV 23" 394 | }, 395 | "pv_24_current": { 396 | "name": "Corrent FV 24" 397 | }, 398 | "pv_24_voltage": { 399 | "name": "Tensió FV 24" 400 | }, 401 | "reactive_power": { 402 | "name": "Potència reactiva" 403 | }, 404 | "running_status": { 405 | "name": "Estat de funcionament" 406 | }, 407 | "shutdown_time": { 408 | "name": "Hora d'apagat" 409 | }, 410 | "single_phase_meter_current": { 411 | "name": "Corrent" 412 | }, 413 | "single_phase_meter_voltage": { 414 | "name": "Tensió" 415 | }, 416 | "startup_time": { 417 | "name": "Hora d'inici" 418 | }, 419 | "state_1": { 420 | "name": "Estat de l'inversor" 421 | }, 422 | "state_2_0": { 423 | "name": "Estat de bloqueig" 424 | }, 425 | "state_2_1": { 426 | "name": "Estat de connexió FV" 427 | }, 428 | "state_2_2": { 429 | "name": "Recopilació de dades DSP" 430 | }, 431 | "state_3_0": { 432 | "name": "Estat off-grid" 433 | }, 434 | "state_3_1": { 435 | "name": "Interruptor off-grid" 436 | }, 437 | "storage_bus_current": { 438 | "name": "Corrent del bus" 439 | }, 440 | "storage_bus_voltage": { 441 | "name": "Tensió del bus" 442 | }, 443 | "storage_capacity_control_periods": { 444 | "name": "Períodes de control de capacitat" 445 | }, 446 | "storage_charge_discharge_power": { 447 | "name": "Potència de càrrega/descàrrega" 448 | }, 449 | "storage_current_day_charge_capacity": { 450 | "name": "Càrrega diària" 451 | }, 452 | "storage_current_day_discharge_capacity": { 453 | "name": "Descàrrega diària" 454 | }, 455 | "storage_fixed_charging_and_discharging_periods": { 456 | "name": "Períodes de càrrega i descàrrega fixa" 457 | }, 458 | "storage_running_status": { 459 | "name": "Estat" 460 | }, 461 | "storage_maximum_charge_power": { 462 | "name": "Potència de càrrega màxima" 463 | }, 464 | "storage_maximum_discharge_power": { 465 | "name": "Potència de descàrrega màxima" 466 | }, 467 | "storage_rated_capacity": { 468 | "name": "Capacitat nominal" 469 | }, 470 | "storage_state_of_capacity": { 471 | "name": "Estat de la capacitat" 472 | }, 473 | "storage_time_of_use_charging_and_discharging_periods": { 474 | "name": "Períodes d'horari d'ús" 475 | }, 476 | "storage_total_charge": { 477 | "name": "Càrrega total" 478 | }, 479 | "storage_total_discharge": { 480 | "name": "Descàrrega total" 481 | }, 482 | "temperature": { 483 | "name": "Temperatura" 484 | }, 485 | "voltage_to_ground": { 486 | "name": "Tensió a terra" 487 | }, 488 | "storage_lg_resu_time_of_use_price_periods": { 489 | "name": "TOU price periods" 490 | }, 491 | "storage_huawei_luna2000_time_of_use_price_periods": { 492 | "name": "TOU price periods" 493 | }, 494 | "storage_huawei_luna2000_time_of_use_charging_and_discharging_periods": { 495 | "name": "TOU charging and discharging periods" 496 | }, 497 | "storage_lg_resu_time_of_use_charging_and_discharging_periods": { 498 | "name": "TOU charging and discharging periods" 499 | }, 500 | "storage_fixed_charging_and_discharging_periods": { 501 | "name": "Fixed charging periods" 502 | }, 503 | "inverter_total_absorbed_energy": { 504 | "name": "Total absorbed energy" 505 | }, 506 | "energy_charged_today": { 507 | "name": "Energy charged today" 508 | }, 509 | "total_charged_energy": { 510 | "name": "Total charged energy" 511 | }, 512 | "energy_discharged_today": { 513 | "name": "Energy discharged today" 514 | }, 515 | "total_discharged_energy": { 516 | "name": "Total discharged energy" 517 | }, 518 | "ess_chargeable_energy": { 519 | "name": "ESS Chargeable energy" 520 | }, 521 | "ess_dischargeable_energy": { 522 | "name": "ESS Dischargeable energy" 523 | }, 524 | "rated_ess_capacity": { 525 | "name": "Rated ESS capacity" 526 | }, 527 | "consumption_today": { 528 | "name": "Consumption today" 529 | }, 530 | "total_energy_consumption": { 531 | "name": "Total energy consumption" 532 | }, 533 | "soh_calibration_status": { 534 | "name": "SOH calibration status" 535 | }, 536 | "pack_1_max_temperature": { 537 | "name": "Pack 1 max temperature" 538 | }, 539 | "pack_1_min_temperature": { 540 | "name": "Pack 1 min temperature" 541 | }, 542 | "pack_2_max_temperature": { 543 | "name": "Pack 2 max temperature" 544 | }, 545 | "pack_2_min_temperature": { 546 | "name": "Pack 2 min temperature" 547 | }, 548 | "pack_3_max_temperature": { 549 | "name": "Pack 3 max temperature" 550 | }, 551 | "pack_3_min_temperature": { 552 | "name": "Pack 3 min temperature" 553 | }, 554 | "pack_1_working_status": { 555 | "name": "Pack 1 working status" 556 | }, 557 | "pack_2_working_status": { 558 | "name": "Pack 2 working status" 559 | }, 560 | "pack_3_working_status": { 561 | "name": "Pack 3 working status" 562 | }, 563 | "pack_1_firmware_version": { 564 | "name": "Pack 1 firmware version" 565 | }, 566 | "pack_1_serial_number": { 567 | "name": "Pack 1 serial number" 568 | }, 569 | "pack_1_state_of_capacity": { 570 | "name": "Pack 1 state of capacity" 571 | }, 572 | "pack_1_charge_discharge_power": { 573 | "name": "Pack 1 charge discharge power" 574 | }, 575 | "pack_1_voltage": { 576 | "name": "Pack 1 voltage" 577 | }, 578 | "pack_1_current": { 579 | "name": "Pack 1 current" 580 | }, 581 | "pack_1_soh_calibration_status": { 582 | "name": "Pack 1 SOH calibration status" 583 | }, 584 | "pack_1_total_charge": { 585 | "name": "Pack 1 total charge" 586 | }, 587 | "pack_1_total_discharge": { 588 | "name": "Pack 1 total discharge" 589 | }, 590 | "pack_2_firmware_version": { 591 | "name": "Pack 2 firmware version" 592 | }, 593 | "pack_2_serial_number": { 594 | "name": "Pack 2 serial number" 595 | }, 596 | "pack_2_state_of_capacity": { 597 | "name": "Pack 2 state of capacity" 598 | }, 599 | "pack_2_charge_discharge_power": { 600 | "name": "Pack 2 charge discharge power" 601 | }, 602 | "pack_2_voltage": { 603 | "name": "Pack 2 voltage" 604 | }, 605 | "pack_2_current": { 606 | "name": "Pack 2 current" 607 | }, 608 | "pack_2_soh_calibration_status": { 609 | "name": "Pack 2 SOH calibration status" 610 | }, 611 | "pack_2_total_charge": { 612 | "name": "Pack 2 total charge" 613 | }, 614 | "pack_2_total_discharge": { 615 | "name": "Pack 2 total discharge" 616 | }, 617 | "pack_3_firmware_version": { 618 | "name": "Pack 3 firmware version" 619 | }, 620 | "pack_3_serial_number": { 621 | "name": "Pack 3 serial number" 622 | }, 623 | "pack_3_state_of_capacity": { 624 | "name": "Pack 3 state of capacity" 625 | }, 626 | "pack_3_charge_discharge_power": { 627 | "name": "Pack 3 charge discharge power" 628 | }, 629 | "pack_3_voltage": { 630 | "name": "Pack 3 voltage" 631 | }, 632 | "pack_3_current": { 633 | "name": "Pack 3 current" 634 | }, 635 | "pack_3_soh_calibration_status": { 636 | "name": "Pack 3 SOH calibration status" 637 | }, 638 | "pack_3_total_charge": { 639 | "name": "Pack 3 total charge" 640 | }, 641 | "pack_3_total_discharge": { 642 | "name": "Pack 3 total discharge" 643 | }, 644 | "bms_temperature": { 645 | "name": "BMS temperature" 646 | }, 647 | "active_grid_a_current": { 648 | "name": "Corrent de fase A" 649 | }, 650 | "active_grid_b_current": { 651 | "name": "Corrent de fase B" 652 | }, 653 | "active_grid_c_current": { 654 | "name": "Corrent de fase C" 655 | } 656 | }, 657 | "switch": { 658 | "startup": { 659 | "name": "Engegat/Apagat de l'inversor" 660 | }, 661 | "storage_charge_from_grid_function": { 662 | "name": "Càrrega des de la xarxa" 663 | }, 664 | "mppt_multimodal_scanning": { 665 | "name": "MPPT scanning" 666 | } 667 | } 668 | }, 669 | "services": { 670 | "forcible_charge": { 671 | "description": "Càrrega forçada de la bateria durant un cert temps", 672 | "fields": { 673 | "device_id": { 674 | "description": "Ha de ser un dispositiu 'Bateria'", 675 | "name": "Bateria" 676 | }, 677 | "duration": { 678 | "description": "Durada de la càrrega", 679 | "name": "Durada" 680 | }, 681 | "power": { 682 | "description": "Potència utilitzada per a la càrrega", 683 | "name": "Potència" 684 | } 685 | }, 686 | "name": "Càrrega Forçada" 687 | }, 688 | "forcible_charge_soc": { 689 | "description": "Càrrega forçada de la bateria fins a un cert nivell de SoC", 690 | "fields": { 691 | "device_id": { 692 | "description": "Ha de ser un dispositiu 'Bateria'", 693 | "name": "Bateria" 694 | }, 695 | "power": { 696 | "description": "Potència utilitzada per a la càrrega", 697 | "name": "Potència" 698 | }, 699 | "target_soc": { 700 | "description": "SoC que s'ha d'assolir", 701 | "name": "SoC Objectiu" 702 | } 703 | }, 704 | "name": "Càrrega Forçada a un Nivell de SoC" 705 | }, 706 | "forcible_discharge": { 707 | "description": "Descàrrega forçada de la bateria durant un cert temps", 708 | "fields": { 709 | "device_id": { 710 | "description": "Ha de ser un dispositiu 'Bateria'", 711 | "name": "Bateria" 712 | }, 713 | "duration": { 714 | "description": "Durada de la descàrrega", 715 | "name": "Durada" 716 | }, 717 | "power": { 718 | "description": "Potència utilitzada per a la descàrrega", 719 | "name": "Potència" 720 | } 721 | }, 722 | "name": "Descàrrega Forçada" 723 | }, 724 | "forcible_discharge_soc": { 725 | "description": "Descàrrega forçada de la bateria fins a un cert nivell de SoC", 726 | "fields": { 727 | "device_id": { 728 | "description": "Ha de ser un dispositiu 'Bateria'", 729 | "name": "Bateria" 730 | }, 731 | "power": { 732 | "description": "Potència utilitzada per a la descàrrega", 733 | "name": "Potència" 734 | }, 735 | "target_soc": { 736 | "description": "SoC que s'ha d'assolir", 737 | "name": "SoC objectiu" 738 | } 739 | }, 740 | "name": "Descàrrega Forçada a un Nivell de SoC" 741 | }, 742 | "reset_maximum_feed_grid_power": { 743 | "description": "Restableix el control de potència activa al mode il·limitat per defecte", 744 | "fields": { 745 | "device_id": { 746 | "description": "Ha de ser un dispositiu 'Inversor'", 747 | "name": "Inversor" 748 | } 749 | }, 750 | "name": "Restablir control de potència activa a màxima alimentació a la xarxa" 751 | }, 752 | "set_capacity_control_periods": { 753 | "description": "Estableix els períodes de control de capacitat", 754 | "fields": { 755 | "device_id": { 756 | "description": "Ha de ser un dispositiu 'Bateria'", 757 | "name": "Bateria" 758 | }, 759 | "periods": { 760 | "description": "Un període per línia. Format: '[hora inici]-[hora fi]/[dies efectius]/[potència]W', amb 1=dilluns, 7=diumenge. Exemple: '00:00-23:59/1234567/2500W'.", 761 | "name": "Períodes" 762 | } 763 | }, 764 | "name": "Establir períodes de control de capacitat" 765 | }, 766 | "set_di_active_power_scheduling": { 767 | "description": "Estableix el control de potència activa a 'Programació Activa DI'", 768 | "fields": { 769 | "device_id": { 770 | "description": "Ha de ser un dispositiu 'Inversor'", 771 | "name": "Inversor" 772 | } 773 | }, 774 | "name": "Establir control de potència activa a 'Programació Activa DI'" 775 | }, 776 | "set_fixed_charge_periods": { 777 | "description": "Estableix els períodes de càrrega i descàrrega fixa", 778 | "fields": { 779 | "device_id": { 780 | "description": "Ha de ser un dispositiu 'Bateria'", 781 | "name": "Bateria" 782 | }, 783 | "periods": { 784 | "description": "Un període per línia. Format: '[hora inici]-[hora fi]/[potència]W'. Exemple: '12:00-15:59/-1000W'.", 785 | "name": "Períodes" 786 | } 787 | }, 788 | "name": "Establir períodes de càrrega i descàrrega fixa" 789 | }, 790 | "set_maximum_feed_grid_power": { 791 | "description": "Estableix el Control de Potència Activa a 'Connexió a la xarxa Limitada per Potència' a la potència en watts especificada", 792 | "fields": { 793 | "device_id": { 794 | "description": "Ha de ser un dispositiu 'Inversor'", 795 | "name": "Inversor" 796 | }, 797 | "power": { 798 | "description": "Potència màxima en Watts", 799 | "name": "Potència" 800 | } 801 | }, 802 | "name": "Limitar la potència alimentada a la xarxa" 803 | }, 804 | "set_maximum_feed_grid_power_percent": { 805 | "description": "Estableix el Control de Potència Activa a 'Connexió a la xarxa limitada per Potència (%)' al percentatge especificat", 806 | "fields": { 807 | "device_id": { 808 | "description": "Ha de ser un dispositiu 'Inversor'", 809 | "name": "Inversor" 810 | }, 811 | "power_percentage": { 812 | "description": "Percentatge màxim", 813 | "name": "Percentatge de Potència" 814 | } 815 | }, 816 | "name": "Limitar la potència alimentada a la xarxa a un percentatge" 817 | }, 818 | "set_tou_periods": { 819 | "description": "Estableix els períodes d'ús", 820 | "fields": { 821 | "device_id": { 822 | "description": "Ha de ser un dispositiu 'Bateria'", 823 | "name": "Bateria" 824 | }, 825 | "periods": { 826 | "description": "Un període per línia. Per a bateries Huawei LUNA2000: '[hora inici]-[hora fi]/[dies efectius]/[càrrega]', amb 1=dilluns, 7=diumenge (exemple: '12:00-14:00/1234567/-'). Per a bateries LG RESU: '[hora inici]-[hora fi]/[preu de l'electricitat]' (exemple: 13:00-14:00/0.50)", 827 | "name": "Períodes" 828 | } 829 | }, 830 | "name": "Establir els períodes d'ús" 831 | }, 832 | "set_zero_power_grid_connection": { 833 | "description": "Estableix el Control de Potència Activa a 'Connexió a la xarxa amb injecció zero'", 834 | "fields": { 835 | "device_id": { 836 | "description": "Ha de ser un dispositiu 'Inversor'", 837 | "name": "Inversor" 838 | } 839 | }, 840 | "name": "Establir Control de Potència Activa a 'Connexió a la xarxa amb injecció zero'" 841 | }, 842 | "stop_forcible_charge": { 843 | "description": "Cancelar el comandament de càrrega forçada en execució", 844 | "fields": { 845 | "device_id": { 846 | "description": "Ha de ser un dispositiu 'Bateria'", 847 | "name": "Bateria" 848 | } 849 | }, 850 | "name": "Aturar la càrrega o descàrrega forçada" 851 | } 852 | } 853 | } 854 | --------------------------------------------------------------------------------