Repository: mletenay/home-assistant-goodwe-inverter Branch: master Commit: 40e29983fe38 Files: 29 Total size: 105.5 KB Directory structure: gitextract_ho4ycwf4/ ├── .github/ │ └── workflows/ │ ├── hassfest.yaml │ └── validate.yaml ├── .gitignore ├── LICENSE ├── README.md ├── custom_components/ │ └── goodwe/ │ ├── __init__.py │ ├── button.py │ ├── config_flow.py │ ├── const.py │ ├── coordinator.py │ ├── diagnostics.py │ ├── icons.json │ ├── manifest.json │ ├── number.py │ ├── select.py │ ├── sensor.py │ ├── services.py │ ├── services.yaml │ ├── strings.json │ ├── switch.py │ └── translations/ │ ├── cs.json │ ├── de.json │ ├── en.json │ ├── es.json │ └── sk.json ├── hacs.json ├── info.md ├── inverter_scan.py └── inverter_test.py ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/workflows/hassfest.yaml ================================================ name: Validate with hassfest on: push: pull_request: schedule: - cron: "0 0 * * *" jobs: validate: runs-on: "ubuntu-latest" steps: - uses: "actions/checkout@v3" - uses: home-assistant/actions/hassfest@master ================================================ FILE: .github/workflows/validate.yaml ================================================ name: Validate on: push: pull_request: schedule: - cron: "0 0 * * *" workflow_dispatch: permissions: {} jobs: validate-hacs: runs-on: "ubuntu-latest" steps: - name: HACS validation uses: "hacs/action@main" with: category: "integration" ignore: "brands" ================================================ FILE: .gitignore ================================================ # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ pip-wheel-metadata/ share/python-wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover *.py,cover .hypothesis/ .pytest_cache/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 db.sqlite3-journal # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder target/ # Jupyter Notebook .ipynb_checkpoints # IPython profile_default/ ipython_config.py # pyenv .python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # However, in case of collaboration, if having platform-specific dependencies or dependencies # having no cross-platform support, pipenv may install dependencies that don't work, or not # install all needed dependencies. #Pipfile.lock # PEP 582; used by e.g. github.com/David-OConnor/pyflow __pypackages__/ # Celery stuff celerybeat-schedule celerybeat.pid # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ .dmypy.json dmypy.json # Pyre type checker .pyre/ .idea ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2020 mletenay Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ [!["Buy Me A Coffee"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://www.buymeacoffee.com/mletenay) [![hacs_badge](https://img.shields.io/badge/HACS-Default-orange.svg)](https://github.com/hacs/integration) [![Build Status](https://github.com/mletenay/home-assistant-goodwe-inverter/actions/workflows/hassfest.yaml/badge.svg)](https://github.com/mletenay/home-assistant-goodwe-inverter/actions/workflows/hassfest.yaml) ![GitHub Release](https://img.shields.io/github/v/release/mletenay/home-assistant-goodwe-inverter) ## GoodWe solar inverter for Home Assistant (experimental) Support for Goodwe solar inverters is present as native integration of [Home Assistant](https://www.home-assistant.io/integrations/goodwe/) since its release 2022.2 and is recommended for most users. This custom component is experimental version with features not (yet) present in standard HA's integration and is intended for users with specific needs and early adopters of new features. Use at own risk. ### Differences between this HACS and native HA integration - EMS modes - Special work modes `Eco charge mode` and `Eco discharge mode` (24/7 with defined power and SoC). - Network configuration parameters `Port`, `Modbus id`, `Scan iterval`, `Network retry attempts`, `Network request timeout`. - Input `SoC upper limit`, `DoD (backup)` - Switch `DOD holding`, `Export Limit`. `Load Control`, `Backup supply` - Switch and SoC/Power inputs for `Fast Charging` functionality. - `Start inverter` and `Stop inverter` buttons for grid-only inverters. - Services for getting/setting inverter configuration parameters ### Migration from HACS to HA If you have been using this custom component and want to migrate to standard HA integration, the migration is straightforward. Just remove the integration from HACS (press Ignore and force uninstall despite the warning the integration is still configured). Afrer restart of Home Assistant, the standard Goodwe integration will start and all your existing settings, entity names, history and statistics should be preserved. (If you uninstall the integration first, then uninstall HACS component and install integration back again, it will also work, but you will probably loose some history and settings since HA integration uses slightly different default entity names.) ## EMS modes The integration exposes inverter's EMS mode and EMS power (limit) settings. The following list should explain individual modes and their behavior. (The `Xset`/`Xmax` variables below are values of the `EMS power` setting. ) - **Auto** - _Scenario:_ Self-use. - `PBattery = PInv - Pmeter - Ppv` (Discharge/Charge) - The battery power is controlled by the meter power when the meter communication is normal. - **Charge PV** - _Scenario:_ Control the battery to keep charging. - `PBattery = Xmax + PV` (Charge) - Xmax is to allow the power to be taken from the grid, and PV power is preferred. When set to 0, only PV power is used. Charging power will be limited by charging current limit. - _Interpretation:_ Charge Battery from PV (high priority) or Grid (low priority); EmsPowerSet = negative ESS ActivePower (if possible because of PV). - Grid: low priority, PV: high priority, Battery: Charge Mode, The control object is 'Grid' - **Discharge PV** - _Scenario:_ Control the battery to keep discharging. - `PBattery = Xmax` (Discharge) - Xmax is the allowable discharge power of the battery. When the power fed into the grid is limited, PV power will be used first. - _Interpretation:_ ESS ActivePower = PV power + EmsPowerSet (i.e. battery discharge); useful for surplus feed-to-grid. - PV: high priority, Battery: low priority, Grid: Energy Out Mode, The control object is 'Battery' - **Import AC** - _Scenario:_ The inverter is used as a unit for power grid energy scheduling. - `PBattery = Xset + PV` (Charge) - Xset refers to the power purchased from the power grid. The power purchased from the grid is preferred. If the PV power is too large, the MPPT power will be limited. (grid side load is not considered) - _Interpretation:_ Charge Battery from Grid (high priority) or PV (low priority); EmsPowerSet = negative ESS ActivePower; as long as BMS_CHARGE_MAX_CURRENT is > 0, no AC-Power is exported; when BMS_CHARGE_MAX_CURRENT == 0, PV surplus feed in starts! - Grid: high priority, PV: low priority, Battery: Charge Mode, The control object is 'Grid' - **Export AC** - _Scenario:_ The inverter is used as a unit for power grid energy scheduling. - `PBattery = Xset` (Discharge) - Xset is to sell power to the grid. PV power is preferred. When PV energy is insufficient, the battery will discharge. PV power will be limited by x. (grid side load is not considered) - _Interpretation:_ EmsPowerSet = positive ESS ActivePower. But PV will be limited, i.e. remaining power is not used to charge battery. - PV: high priority, Battery: low priority, Grid: Energy Out Mode, The control object is 'Grid' - **Conserve** - _Scenario:_ Off-grid reservation mode. - `PBattery = PV` (Charge) - In on-grid mode, the battery is continuously charged, and only PV power (AC Couple model takes 10% of the rated power of the power grid) is used. The battery can only discharge in off-grid mode. - **Off-Grid** - _Scenario:_ Off-Grid Mode. - `PBattery = Pbackup - Ppv` (Charge/Discharge) - Forced off-grid operation. - **Battery Standby** - _Scenario:_ The inverter is used as a unit for power grid energy scheduling. - `PBattery = 0` (Standby) - The battery does not charge and discharge - **Buy Power** - _Scenario:_ Regional energy management. - `PBattery = PInv - (Pmeter + Xset) - Ppv` (Charge/Discharge) - When the meter communication is normal, the power purchased from the power grid is controlled as Xset. When the PV power is too large, the MPPT power will be limited. When the load is too large, the battery will discharge. - _Interpretation:_ Control power at the point of common coupling. - Grid: high priority, PV: low priority, Battery: Energy In and Out Mode, The control object is 'Grid' - **Sell Power** - _Scenario:_ Regional energy management. - `PBattery = PInv - (Pmeter - Xset) - Ppv` (Charge/Discharge) - When the communication of electricity meter is normal, the power sold from the power grid is controlled as Xset, PV power is preferred, and the battery discharges when PV energy is insufficient.PV power will be limited by Xset. - _Interpretation:_ Control power at the point of common coupling. - PV: high priority, Battery: low priority, Grid: Energy Out Mode, The control object is 'Grid' - **Charge Battery** - _Scenario:_ Force the battery to work at set power value. - `PBattery = Xset` (Charge) - Xset is the charging power of the battery. PV power is preferred. When PV power is insufficient, it will buy power from the power grid. The charging power is also affected by the charging current limit. - _Interpretation:_ Charge Battery from PV (high priority) or Grid (low priority); priorities are inverted compared to IMPORT_AC. - PV: high priority, Grid: low priority, Battery: Energy In Mode, The control object is 'Battery' - **Discharge Battery** - _Scenario:_ Force the battery to work at set power value. - `PBattery = Xset` (Discharge) - Xset is the discharge power of the battery, and the battery discharge has priority. If the PV power is too large, MPPT will be limited. Discharge power is also affected by discharge current limit. - _Interpretation:_ ??? - PV: low priority, Battery: high priority, Grid: Energy In Mode, The control object is 'Battery' - **Stopped** - _Scenario:_ System shutdown. - Stop working and turn to wait mode ## Home Assistant Energy Dashboard The integration provides several values suitable for the energy dashboard introduced to HA in v2021.8. The best supported are the inverters of ET/EH families, where the sensors `meter_e_total_exp`, `meter_e_total_imp`, `e_total`, `e_bat_charge_total` and `e_bat_discharge_total` are the most suitable for the dashboard measurements and statistics. For the other inverter families, if such sensors are not directly available from the inverter, they can be calculated, see paragraph below. ## Cumulative energy values The sensor values reported by the inverter are instant measurements. To report summary (energy) values like daily/monthly sell or buy (in kWh), these values have to be aggregated over time. [Riemann Sum](https://www.home-assistant.io/integrations/integration/) integration can be used to convert these instant (W) values into cumulative values (Wh). [Utility Meter](https://www.home-assistant.io/integrations/utility_meter) can report these values as human readable statistical values. [Template Sensor](https://www.home-assistant.io/integrations/template/) can be used to separate buy and sell values. ```YAML sensor: - platform: template sensors: # Template sensor for values of energy bought (active_power < 0) energy_buy: device_class: power friendly_name: "Energy Buy" unit_of_measurement: 'W' value_template: >- {% if states('sensor.goodwe_active_power')|float < 0 %} {{ states('sensor.goodwe_active_power')|float * -1 }} {% else %} {{ 0 }} {% endif %} # Template sensor for values of energy sold (active_power > 0) energy_sell: device_class: power friendly_name: "Energy Sell" unit_of_measurement: 'W' value_template: >- {% if states('sensor.goodwe_active_power')|float > 0 %} {{ states('sensor.goodwe_active_power')|float }} {% else %} {{ 0 }} {% endif %} # Sensor for Riemann sum of energy bought (W -> kWh) - platform: integration source: sensor.energy_buy name: energy_buy_sum unit_prefix: k round: 1 method: left # Sensor for Riemann sum of energy sold (W -> kWh) - platform: integration source: sensor.energy_sell name: energy_sell_sum unit_prefix: k round: 1 method: left utility_meter: energy_buy_daily: source: sensor.energy_buy_sum cycle: daily energy_buy_monthly: source: sensor.energy_buy_sum cycle: monthly energy_sell_daily: source: sensor.energy_sell_sum cycle: daily energy_sell_monthly: source: sensor.energy_sell_sum cycle: monthly house_consumption_daily: source: sensor.house_consumption_sum cycle: daily house_consumption_monthly: source: sensor.house_consumption_sum cycle: monthly ``` ## Troubleshooting If you observe any problems or cannot make it work with your inverter at all, try to increase logging level of the component and check the log files. ```YAML logger: default: warning logs: custom_components.goodwe: debug goodwe: debug ``` ## Source code The source code implementing the actual communication with GoodWe inverters (which was originally part of this plugin) was extracted and moved to standalone [PyPI library](https://pypi.org/project/goodwe/). This repository now contains only the HomeAssistant specific code. ## Inverter discovery and communication testing To test whether the inverter properly responds to UDP request, just execute the `inverter_test.py` script in your python (3.8+) environment. The `inverter_scan.py` script can be used to discover inverter(s) on your local network. ## References and inspiration - https://github.com/marcelblijleven/goodwe - https://www.photovoltaikforum.com/core/attachment/342066-bluetooth-firmware-update-string-storage-de-v002-pdf/ - https://github.com/robbinjanssen/home-assistant-omnik-inverter ================================================ FILE: custom_components/goodwe/__init__.py ================================================ """The Goodwe inverter component.""" from goodwe import Inverter, InverterError, connect from goodwe.const import GOODWE_TCP_PORT, GOODWE_UDP_PORT from homeassistant.const import CONF_HOST, CONF_PORT, CONF_PROTOCOL, CONF_SCAN_INTERVAL from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.device_registry import DeviceInfo from .config_flow import GoodweFlowHandler from .const import ( CONF_KEEP_ALIVE, CONF_MODBUS_ID, CONF_MODEL_FAMILY, CONF_NETWORK_RETRIES, CONF_NETWORK_TIMEOUT, DEFAULT_MODBUS_ID, DEFAULT_NETWORK_RETRIES, DEFAULT_NETWORK_TIMEOUT, DOMAIN, PLATFORMS, ) from .coordinator import GoodweConfigEntry, GoodweRuntimeData, GoodweUpdateCoordinator from .services import async_setup_services, async_unload_services async def async_setup_entry(hass: HomeAssistant, entry: GoodweConfigEntry) -> bool: """Set up the Goodwe components from a config entry.""" hass.data.setdefault(DOMAIN, {}) host = entry.options.get(CONF_HOST, entry.data[CONF_HOST]) protocol = entry.options.get(CONF_PROTOCOL, entry.data.get(CONF_PROTOCOL, "UDP")) port = entry.options.get( CONF_PORT, entry.data.get( CONF_PORT, GOODWE_TCP_PORT if protocol == "TCP" else GOODWE_UDP_PORT ), ) keep_alive = entry.options.get(CONF_KEEP_ALIVE, False) model_family = entry.options.get(CONF_MODEL_FAMILY, entry.data[CONF_MODEL_FAMILY]) network_retries = entry.options.get(CONF_NETWORK_RETRIES, DEFAULT_NETWORK_RETRIES) network_timeout = entry.options.get(CONF_NETWORK_TIMEOUT, DEFAULT_NETWORK_TIMEOUT) modbus_id = entry.options.get(CONF_MODBUS_ID, DEFAULT_MODBUS_ID) # Connect to Goodwe inverter try: inverter = await connect( host=host, port=port, family=model_family, comm_addr=modbus_id, timeout=network_timeout, retries=network_retries, ) inverter.set_keep_alive(keep_alive) except InverterError as err: try: inverter = await async_check_port(hass, entry, host) except InverterError: raise ConfigEntryNotReady from err device_info = DeviceInfo( configuration_url="https://semsplus.goodwe.com/", identifiers={(DOMAIN, inverter.serial_number)}, name=entry.title, manufacturer="GoodWe", model=inverter.model_name, sw_version=f"{inverter.firmware} / {inverter.arm_firmware}", hw_version=f"{inverter.serial_number[5:8]} {inverter.serial_number[0:5]}", ) # Create update coordinator coordinator = GoodweUpdateCoordinator(hass, entry, inverter) # Fetch initial data so we have data when entities subscribe await coordinator.async_config_entry_first_refresh() entry.runtime_data = GoodweRuntimeData( inverter=inverter, coordinator=coordinator, device_info=device_info, ) hass.data[DOMAIN][entry.entry_id] = entry.runtime_data entry.async_on_unload(entry.add_update_listener(update_listener)) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await async_setup_services(hass) return True async def async_check_port( hass: HomeAssistant, entry: GoodweConfigEntry, host: str ) -> Inverter: """Check the communication port of the inverter, it may have changed after a firmware update.""" inverter, port = await GoodweFlowHandler.async_detect_inverter_port(host=host) family = type(inverter).__name__ hass.config_entries.async_update_entry( entry, data={ CONF_HOST: host, CONF_PORT: port, CONF_MODEL_FAMILY: family, }, ) return inverter async def async_unload_entry( hass: HomeAssistant, config_entry: GoodweConfigEntry ) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms( config_entry, PLATFORMS ) if unload_ok: hass.data[DOMAIN].pop(config_entry.entry_id) if not hass.data[DOMAIN]: await async_unload_services(hass) return unload_ok async def update_listener(hass: HomeAssistant, config_entry: GoodweConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(config_entry.entry_id) async def async_migrate_entry( hass: HomeAssistant, config_entry: GoodweConfigEntry ) -> bool: """Migrate old config entries.""" if config_entry.version > 2: # This means the user has downgraded from a future version return False if config_entry.version == 1: # Update from version 1 to version 2 adding the PROTOCOL to the config entry host = config_entry.data[CONF_HOST] port = config_entry.data.get( CONF_PORT, config_entry.data.get( CONF_PORT, ( GOODWE_TCP_PORT if config_entry.data.get(CONF_PROTOCOL) == "TCP" else GOODWE_UDP_PORT ), ), ) if not port: try: _, port = await GoodweFlowHandler.async_detect_inverter_port(host=host) except InverterError as err: raise ConfigEntryNotReady from err new_data = { CONF_HOST: host, CONF_PORT: port, CONF_PROTOCOL: config_entry.data.get(CONF_PROTOCOL), CONF_KEEP_ALIVE: config_entry.data.get(CONF_KEEP_ALIVE), CONF_MODEL_FAMILY: config_entry.data.get(CONF_MODEL_FAMILY), CONF_SCAN_INTERVAL: config_entry.data.get(CONF_SCAN_INTERVAL), CONF_NETWORK_RETRIES: config_entry.data.get(CONF_NETWORK_RETRIES), CONF_NETWORK_TIMEOUT: config_entry.data.get(CONF_NETWORK_TIMEOUT), CONF_MODBUS_ID: config_entry.data.get(CONF_MODBUS_ID), } hass.config_entries.async_update_entry(config_entry, data=new_data, version=2) return True ================================================ FILE: custom_components/goodwe/button.py ================================================ """GoodWe PV inverter selection settings entities.""" from collections.abc import Awaitable, Callable from dataclasses import dataclass from datetime import datetime import logging from goodwe import Inverter, InverterError from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import GoodweConfigEntry _LOGGER = logging.getLogger(__name__) @dataclass(frozen=True, kw_only=True) class GoodweButtonEntityDescription(ButtonEntityDescription): """Class describing Goodwe button entities.""" setting: str action: Callable[[Inverter], Awaitable[None]] BUTTONS = ( GoodweButtonEntityDescription( key="synchronize_clock", translation_key="synchronize_clock", entity_category=EntityCategory.CONFIG, setting="time", action=lambda inv: inv.write_setting("time", datetime.now()), ), GoodweButtonEntityDescription( key="start_inverter", translation_key="start_inverter", setting="start", action=lambda inv: inv.write_setting("start", 0), ), GoodweButtonEntityDescription( key="stop_inverter", translation_key="stop_inverter", setting="stop", action=lambda inv: inv.write_setting("stop", 0), ), ) async def async_setup_entry( hass: HomeAssistant, config_entry: GoodweConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the inverter button entities from a config entry.""" inverter = config_entry.runtime_data.inverter device_info = config_entry.runtime_data.device_info entities = [] for description in BUTTONS: try: await inverter.read_setting(description.setting) except (InverterError, ValueError): # Inverter model does not support this feature _LOGGER.debug("Could not read %s value", description.setting) else: entities.append( GoodweButtonEntity( device_info, description, inverter, ) ) async_add_entities(entities) class GoodweButtonEntity(ButtonEntity): """Entity representing the inverter clock synchronization button.""" _attr_should_poll = False _attr_has_entity_name = True entity_description: GoodweButtonEntityDescription def __init__( self, device_info: DeviceInfo, description: GoodweButtonEntityDescription, inverter: Inverter, ) -> None: """Initialize the inverter operation mode setting entity.""" self.entity_description = description self._attr_unique_id = f"{DOMAIN}-{description.key}-{inverter.serial_number}" self._attr_device_info = device_info self._inverter: Inverter = inverter async def async_press(self) -> None: """Triggers the button press service.""" await self.entity_description.action(self._inverter) ================================================ FILE: custom_components/goodwe/config_flow.py ================================================ """Config flow to configure Goodwe inverters using their local API.""" from __future__ import annotations import logging from typing import Any import voluptuous as vol from goodwe import Inverter, InverterError, connect from goodwe.const import GOODWE_TCP_PORT, GOODWE_UDP_PORT from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, OptionsFlow, ) from homeassistant.const import CONF_HOST, CONF_PORT, CONF_PROTOCOL, CONF_SCAN_INTERVAL from homeassistant.core import callback from homeassistant.helpers import config_validation as cv from .const import ( CONF_KEEP_ALIVE, CONF_MODBUS_ID, CONF_MODEL_FAMILY, CONF_NETWORK_RETRIES, CONF_NETWORK_TIMEOUT, DEFAULT_MODBUS_ID, DEFAULT_NAME, DEFAULT_NETWORK_RETRIES, DEFAULT_NETWORK_TIMEOUT, DEFAULT_SCAN_INTERVAL, DOMAIN, ) PROTOCOL_CHOICES = ["UDP", "TCP"] CONFIG_SCHEMA = vol.Schema( { vol.Required(CONF_HOST): str, vol.Required(CONF_PROTOCOL, default="UDP"): vol.In(PROTOCOL_CHOICES), vol.Required(CONF_MODEL_FAMILY, default="none"): str, } ) OPTIONS_SCHEMA = vol.Schema( { vol.Required(CONF_HOST): str, vol.Optional(CONF_PORT): int, vol.Required(CONF_PROTOCOL): vol.In(PROTOCOL_CHOICES), vol.Required(CONF_KEEP_ALIVE): cv.boolean, vol.Required(CONF_MODEL_FAMILY): str, vol.Optional(CONF_SCAN_INTERVAL): int, vol.Optional(CONF_MODBUS_ID): int, vol.Optional(CONF_NETWORK_RETRIES): cv.positive_int, vol.Optional(CONF_NETWORK_TIMEOUT): cv.positive_int, } ) _LOGGER = logging.getLogger(__name__) class OptionsFlowHandler(OptionsFlow): """Options for the component.""" def __init__(self, config_entry: ConfigEntry) -> None: """Init object.""" self.entry = config_entry async def async_step_init(self, user_input: dict | None = None) -> ConfigFlowResult: """Manage the options.""" if user_input is not None: return self.async_create_entry(title="", data=user_input) host = self.entry.options.get(CONF_HOST, self.entry.data[CONF_HOST]) port = self.entry.options.get(CONF_PORT, self.entry.data.get(CONF_PORT)) protocol = self.entry.options.get( CONF_PROTOCOL, self.entry.data.get(CONF_PROTOCOL, "UDP") ) keep_alive = self.entry.options.get(CONF_KEEP_ALIVE, False) model_family = self.entry.options.get( CONF_MODEL_FAMILY, self.entry.data[CONF_MODEL_FAMILY] ) network_retries = self.entry.options.get( CONF_NETWORK_RETRIES, DEFAULT_NETWORK_RETRIES ) network_timeout = self.entry.options.get( CONF_NETWORK_TIMEOUT, DEFAULT_NETWORK_TIMEOUT ) modbus_id = self.entry.options.get(CONF_MODBUS_ID, DEFAULT_MODBUS_ID) return self.async_show_form( step_id="init", data_schema=self.add_suggested_values_to_schema( OPTIONS_SCHEMA, { CONF_HOST: host, CONF_PORT: port, CONF_PROTOCOL: protocol, CONF_KEEP_ALIVE: keep_alive, CONF_MODEL_FAMILY: model_family, CONF_SCAN_INTERVAL: self.entry.options.get( CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL ), CONF_NETWORK_RETRIES: network_retries, CONF_NETWORK_TIMEOUT: network_timeout, CONF_MODBUS_ID: modbus_id, }, ), ) class GoodweFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a Goodwe config flow.""" MINOR_VERSION = 2 @staticmethod @callback def async_get_options_flow( config_entry: ConfigEntry, ) -> OptionsFlowHandler: """Get the options flow.""" return OptionsFlowHandler(config_entry) async def async_handle_successful_connection( self, inverter: Inverter, host: str, port: int, protocol: str, ) -> ConfigFlowResult: """Handle a successful connection storing it's values on the entry data.""" await self.async_set_unique_id(inverter.serial_number) self._abort_if_unique_id_configured() return self.async_create_entry( title=DEFAULT_NAME, data={ CONF_HOST: host, CONF_PORT: port, CONF_PROTOCOL: protocol, CONF_MODEL_FAMILY: type(inverter).__name__, }, ) async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle a flow initialized by the user.""" errors = {} if user_input is not None: host = user_input[CONF_HOST] protocol = user_input[CONF_PROTOCOL] model_family = user_input[CONF_MODEL_FAMILY] port = user_input.get( CONF_PORT, GOODWE_UDP_PORT if protocol == "UDP" else GOODWE_TCP_PORT ) try: _LOGGER.debug( "Goodwe connecting to %s:%s protocol=%s family=%s", host, port, protocol, model_family, ) inverter = await connect( host=host, port=port, family=model_family, retries=10 ) except InverterError: errors[CONF_HOST] = "connection_error" else: return await self.async_handle_successful_connection( inverter, host, port, protocol ) return self.async_show_form( step_id="user", data_schema=CONFIG_SCHEMA, errors=errors ) @staticmethod async def async_detect_inverter_port( host: str, ) -> tuple[Inverter, int]: """Detects the port of the Inverter.""" port = GOODWE_UDP_PORT try: inverter = await connect(host=host, port=port, retries=10) except InverterError: port = GOODWE_TCP_PORT inverter = await connect(host=host, port=port, retries=10) return inverter, port ================================================ FILE: custom_components/goodwe/const.py ================================================ """Constants for the Goodwe component.""" from datetime import timedelta from homeassistant.const import Platform DOMAIN = "goodwe" PLATFORMS = [ Platform.BUTTON, Platform.NUMBER, Platform.SELECT, Platform.SENSOR, Platform.SWITCH, ] DEFAULT_NAME = "GoodWe" SCAN_INTERVAL = timedelta(seconds=10) DEFAULT_SCAN_INTERVAL = 5 DEFAULT_NETWORK_RETRIES = 10 DEFAULT_NETWORK_TIMEOUT = 1 DEFAULT_MODBUS_ID = 0 CONF_KEEP_ALIVE = "keep_alive" CONF_MODEL_FAMILY = "model_family" CONF_NETWORK_RETRIES = "network_retries" CONF_NETWORK_TIMEOUT = "network_timeout" CONF_MODBUS_ID = "modbus_id" SERVICE_GET_PARAMETER = "get_parameter" SERVICE_SET_PARAMETER = "set_parameter" ATTR_DEVICE_ID = "device_id" ATTR_ENTITY_ID = "entity_id" ATTR_PARAMETER = "parameter" ATTR_VALUE = "value" ================================================ FILE: custom_components/goodwe/coordinator.py ================================================ """Update coordinator for Goodwe.""" from __future__ import annotations from dataclasses import dataclass from datetime import datetime, timedelta import logging from typing import Any from goodwe import Inverter, InverterError, RequestFailedException from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_SCAN_INTERVAL from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import ( BaseCoordinatorEntity, DataUpdateCoordinator, UpdateFailed, ) from .const import DEFAULT_SCAN_INTERVAL _LOGGER = logging.getLogger(__name__) type GoodweConfigEntry = ConfigEntry[GoodweRuntimeData] @dataclass class GoodweRuntimeData: """Data class for runtime data.""" inverter: Inverter coordinator: GoodweUpdateCoordinator device_info: DeviceInfo class GoodweUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Gather data for the energy device.""" config_entry: GoodweConfigEntry def __init__( self, hass: HomeAssistant, entry: GoodweConfigEntry, inverter: Inverter, ) -> None: """Initialize update coordinator.""" super().__init__( hass, _LOGGER, config_entry=entry, name=entry.title, update_interval=timedelta( seconds=entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) ), ) self.inverter: Inverter = inverter self._last_data: dict[str, Any] = {} self._polled_entities: dict[BaseCoordinatorEntity, datetime] = {} async def _async_update_data(self) -> dict[str, Any]: """Fetch data from the inverter.""" await self._update_polled_entities() try: self._last_data = self.data or {} return await self.inverter.read_runtime_data() except RequestFailedException as ex: # UDP communication with inverter is by definition unreliable. # It is rather normal in many environments to fail to receive # proper response in usual time, so we intentionally ignore isolated # failures and report problem with availability only after # consecutive streak of 3 of failed requests. if ex.consecutive_failures_count < 3: _LOGGER.debug( "No response received (streak of %d)", ex.consecutive_failures_count ) # return last known data return self._last_data # Inverter does not respond anymore (e.g. it went to sleep mode) _LOGGER.debug( "Inverter not responding (streak of %d)", ex.consecutive_failures_count ) raise UpdateFailed(ex) from ex except InverterError as ex: raise UpdateFailed(ex) from ex async def _update_polled_entities(self) -> None: for entity, interval in list(self._polled_entities.items()): if interval: try: await entity.async_update() except InverterError: _LOGGER.debug("Failed to update entity %s", entity.name) def sensor_value(self, sensor: str) -> Any: """Answer current (or last known) value of the sensor.""" val = self.data.get(sensor) return val if val is not None else self._last_data.get(sensor) def total_sensor_value(self, sensor: str) -> Any: """Answer current value of the 'total' (never 0) sensor.""" val = self.data.get(sensor) return val or self._last_data.get(sensor) def reset_sensor(self, sensor: str) -> None: """Reset sensor value to 0. Intended for "daily" cumulative sensors (e.g. PV energy produced today), which should be explicitly reset to 0 at midnight if inverter is suspended. """ self._last_data[sensor] = 0 self.data[sensor] = 0 def entity_state_polling( self, entity: BaseCoordinatorEntity, interval: int ) -> None: """Enable/disable polling of entity state.""" if interval: self._polled_entities[entity] = interval else: self._polled_entities.pop(entity, None) ================================================ FILE: custom_components/goodwe/diagnostics.py ================================================ """Diagnostics support for Goodwe.""" from __future__ import annotations from typing import Any from goodwe import Inverter, InverterError from homeassistant.core import HomeAssistant from .coordinator import GoodweConfigEntry async def async_get_config_entry_diagnostics( hass: HomeAssistant, config_entry: GoodweConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" inverter = config_entry.runtime_data.inverter return { "config_entry": config_entry.as_dict(), "inverter": { "model_name": inverter.model_name, "rated_power": inverter.rated_power, "firmware": inverter.firmware, "arm_firmware": inverter.arm_firmware, "dsp1_version": inverter.dsp1_version, "dsp2_version": inverter.dsp2_version, "dsp_svn_version": inverter.dsp_svn_version, "arm_version": inverter.arm_version, "arm_svn_version": inverter.arm_svn_version, "modbus_address": await _read_register(inverter, 45127), "modbus_baudrate": await _read_register(inverter, 45132), "log_data_enable": await _read_register(inverter, 47005), "data_send_interval": await _read_register(inverter, 47006), "wifi_or_lan": await _read_register(inverter, 47009), "modbus_tcp_wo_internet": await _read_register(inverter, 47017), "wifi_modbus_tcp_enable": await _read_register(inverter, 47040), }, } async def _read_register(inverter: Inverter, register: int) -> Any: try: return await inverter.read_setting(f"modbus-{register}") except InverterError: return None ================================================ FILE: custom_components/goodwe/icons.json ================================================ { "entity": { "button": { "synchronize_clock": { "default": "mdi:clock-check-outline" }, "start_inverter": { "default": "mdi:power" }, "stop_inverter": { "default": "mdi:power-off" } }, "number": { "battery_discharge_depth": { "default": "mdi:battery-arrow-down" }, "battery_discharge_depth_offline": { "default": "mdi:battery-arrow-down" }, "eco_mode_power": { "default": "mdi:battery-charging-low" }, "eco_mode_soc": { "default": "mdi:battery-charging-low" }, "fast_charging_power": { "default": "mdi:battery-arrow-up" }, "fast_charging_soc": { "default": "mdi:battery-arrow-up" }, "grid_export_limit": { "default": "mdi:transmission-tower" }, "soc_upper_limit": { "default": "mdi:battery-heart-outline" } }, "select": { "operation_mode": { "default": "mdi:solar-power" } }, "switch": { "grid_export_limit_switch": { "default": "mdi:transmission-tower-import", "state": { "on": "mdi:transmission-tower", "off": "mdi:transmission-tower-import" } }, "load_control": { "default": "mdi:electric-switch", "state": { "on": "mdi:electric-switch-closed", "off": "mdi:electric-switch" } }, "fast_charging_switch": { "default": "mdi:battery-medium", "state": { "on": "mdi:battery-arrow-up", "off": "mdi:battery-medium" } }, "backup_supply_switch": { "default": "mdi:battery-medium", "state": { "on": "mdi:battery-medium", "off": "mdi:battery-medium" } }, "dod_holding_switch": { "default": "mdi:battery-arrow-down", "state": { "on": "mdi:battery-arrow-down", "off": "mdi:battery-arrow-down" } } } } } ================================================ FILE: custom_components/goodwe/manifest.json ================================================ { "domain": "goodwe", "name": "GoodWe Inverter", "codeowners": [ "@mletenay", "@starkillerOG", "@fizcris" ], "config_flow": true, "documentation": "https://github.com/mletenay/home-assistant-goodwe-inverter", "integration_type": "device", "iot_class": "local_polling", "issue_tracker": "https://github.com/mletenay/home-assistant-goodwe-inverter/issues", "loggers": ["goodwe"], "requirements": ["goodwe==0.4.10"], "version": "0.9.9.30" } ================================================ FILE: custom_components/goodwe/number.py ================================================ """GoodWe PV inverter numeric settings entities.""" from __future__ import annotations from collections.abc import Awaitable, Callable from dataclasses import dataclass import logging from goodwe import Inverter, InverterError from homeassistant.components.number import ( NumberDeviceClass, NumberEntity, NumberEntityDescription, ) from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfPower from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import GoodweConfigEntry _LOGGER = logging.getLogger(__name__) @dataclass(frozen=True, kw_only=True) class GoodweNumberEntityDescription(NumberEntityDescription): """Class describing Goodwe number entities.""" getter: Callable[[Inverter], Awaitable[any]] mapper: Callable[[any], int] setter: Callable[[Inverter, int], Awaitable[None]] filter: Callable[[Inverter], bool] def _get_setting_unit(inverter: Inverter, setting: str) -> str: """Return the unit of an inverter setting.""" return next((s.unit for s in inverter.settings() if s.id_ == setting), "") async def set_offline_battery_dod(inverter: Inverter, dod: int) -> None: """Sets offline battery dod - dod for backup output.""" if 10 <= dod <= 100: await inverter.write_setting("battery_discharge_depth_offline", 100 - dod) async def get_offline_battery_dod(inverter: Inverter) -> int: """Returns offline battery dod - dod for backup output.""" return 100 - (await inverter.read_setting("battery_discharge_depth_offline")) NUMBERS = ( # Only one of the export limits are added. # Availability is checked in the filter method. # Export limit in W GoodweNumberEntityDescription( key="grid_export_limit", translation_key="grid_export_limit", entity_category=EntityCategory.CONFIG, device_class=NumberDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.WATT, native_step=100, native_min_value=0, getter=lambda inv: inv.get_grid_export_limit(), mapper=lambda v: v, setter=lambda inv, val: inv.set_grid_export_limit(val), filter=lambda inv: _get_setting_unit(inv, "grid_export_limit") != "%", ), # Export limit in % GoodweNumberEntityDescription( key="grid_export_limit", translation_key="grid_export_limit", entity_category=EntityCategory.CONFIG, native_unit_of_measurement=PERCENTAGE, native_step=1, native_min_value=0, native_max_value=200, getter=lambda inv: inv.get_grid_export_limit(), mapper=lambda v: v, setter=lambda inv, val: inv.set_grid_export_limit(val), filter=lambda inv: _get_setting_unit(inv, "grid_export_limit") == "%", ), GoodweNumberEntityDescription( key="battery_discharge_depth", translation_key="battery_discharge_depth", icon="mdi:battery-arrow-down", entity_category=EntityCategory.CONFIG, native_unit_of_measurement=PERCENTAGE, native_step=1, native_min_value=0, native_max_value=99, getter=lambda inv: inv.get_ongrid_battery_dod(), mapper=lambda v: v, setter=lambda inv, val: inv.set_ongrid_battery_dod(val), filter=lambda inv: True, ), GoodweNumberEntityDescription( key="soc_upper_limit", translation_key="soc_upper_limit", native_unit_of_measurement=PERCENTAGE, native_step=1, native_min_value=0, native_max_value=100, getter=lambda inv: inv.read_setting("soc_upper_limit"), mapper=lambda v: v, setter=lambda inv, val: inv.write_setting("soc_upper_limit", val), filter=lambda inv: True, ), GoodweNumberEntityDescription( key="battery_discharge_depth_offline", translation_key="battery_discharge_depth_offline", entity_category=EntityCategory.CONFIG, native_unit_of_measurement=PERCENTAGE, native_step=1, native_min_value=0, native_max_value=99, getter=lambda inv: get_offline_battery_dod(inv), mapper=lambda v: v, setter=lambda inv, val: set_offline_battery_dod(inv, val), filter=lambda inv: True, ), GoodweNumberEntityDescription( key="eco_mode_power", translation_key="eco_mode_power", entity_category=EntityCategory.CONFIG, native_unit_of_measurement=PERCENTAGE, native_step=1, native_min_value=0, native_max_value=100, getter=lambda inv: inv.read_setting("eco_mode_1"), mapper=lambda v: abs(v.get_power()) if v.get_power() else 0, setter=None, filter=lambda inv: True, ), GoodweNumberEntityDescription( key="eco_mode_soc", translation_key="eco_mode_soc", entity_category=EntityCategory.CONFIG, native_unit_of_measurement=PERCENTAGE, native_step=1, native_min_value=0, native_max_value=100, getter=lambda inv: inv.read_setting("eco_mode_1"), mapper=lambda v: v.soc or 0, setter=None, filter=lambda inv: True, ), GoodweNumberEntityDescription( key="fast_charging_power", translation_key="fast_charging_power", native_unit_of_measurement=PERCENTAGE, native_step=1, native_min_value=0, native_max_value=100, getter=lambda inv: inv.read_setting("fast_charging_power"), mapper=lambda v: v, setter=lambda inv, val: inv.write_setting("fast_charging_power", val), filter=lambda inv: True, ), GoodweNumberEntityDescription( key="fast_charging_soc", translation_key="fast_charging_soc", native_unit_of_measurement=PERCENTAGE, native_step=1, native_min_value=0, native_max_value=100, getter=lambda inv: inv.read_setting("fast_charging_soc"), mapper=lambda v: v, setter=lambda inv, val: inv.write_setting("fast_charging_soc", val), filter=lambda inv: True, ), GoodweNumberEntityDescription( key="ems_power_limit", translation_key="ems_power_limit", entity_category=EntityCategory.CONFIG, device_class=NumberDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.WATT, native_step=100, native_min_value=0, getter=lambda inv: inv.read_setting("ems_power_limit"), mapper=lambda v: v, setter=lambda inv, val: inv.write_setting("ems_power_limit", val), filter=lambda inv: True, ), GoodweNumberEntityDescription( key="battery_soc_protection", translation_key="battery_soc_protection", icon="mdi:battery-arrow-down-outline", entity_category=EntityCategory.CONFIG, native_unit_of_measurement=PERCENTAGE, native_step=1, native_min_value=0, native_max_value=100, getter=lambda inv: inv.read_setting("battery_soc_protection"), mapper=lambda v: v, setter=lambda inv, val: inv.write_setting("battery_soc_protection", val), filter=lambda inv: True, ), ) async def async_setup_entry( hass: HomeAssistant, config_entry: GoodweConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the inverter select entities from a config entry.""" inverter = config_entry.runtime_data.inverter device_info = config_entry.runtime_data.device_info entities = [] for description in filter(lambda dsc: dsc.filter(inverter), NUMBERS): try: current_value = description.mapper(await description.getter(inverter)) except (InverterError, ValueError): # Inverter model does not support this setting _LOGGER.debug("Could not read inverter setting %s", description.key) continue entity = InverterNumberEntity(device_info, description, inverter, current_value) # Set the max value of grid_export_limit and ems_power_limit (W version) if ( description.key in ("grid_export_limit", "ems_power_limit") and description.native_unit_of_measurement == UnitOfPower.WATT ): entity.native_max_value = ( inverter.rated_power * 2 if inverter.rated_power else 10000 ) entities.append(entity) async_add_entities(entities) class InverterNumberEntity(NumberEntity): """Inverter numeric setting entity.""" _attr_should_poll = False _attr_has_entity_name = True entity_description: GoodweNumberEntityDescription def __init__( self, device_info: DeviceInfo, description: GoodweNumberEntityDescription, inverter: Inverter, current_value: int, ) -> None: """Initialize the number inverter setting entity.""" self.entity_description = description self._attr_unique_id = f"{DOMAIN}-{description.key}-{inverter.serial_number}" self._attr_device_info = device_info self._attr_native_value = float(current_value) self._inverter: Inverter = inverter async def async_update(self) -> None: """Get the current value from inverter.""" value = await self.entity_description.getter(self._inverter) self._attr_native_value = float(value) async def async_set_native_value(self, value: float) -> None: """Set new value to inverter.""" if self.entity_description.setter: await self.entity_description.setter(self._inverter, int(value)) self._attr_native_value = value self.async_write_ha_state() ================================================ FILE: custom_components/goodwe/select.py ================================================ """GoodWe PV inverter selection settings entities.""" from dataclasses import dataclass import logging from goodwe import Inverter, InverterError, OperationMode from goodwe.inverter import EMSMode from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.const import ( STATE_UNAVAILABLE, STATE_UNKNOWN, EntityCategory, Platform, ) from homeassistant.core import Event, HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import async_track_state_change_event from .const import DOMAIN from .coordinator import GoodweConfigEntry _LOGGER = logging.getLogger(__name__) _MODE_TO_OPTION: dict[OperationMode, str] = { OperationMode.GENERAL: "general", OperationMode.OFF_GRID: "off_grid", OperationMode.BACKUP: "backup", OperationMode.ECO: "eco", OperationMode.PEAK_SHAVING: "peak_shaving", OperationMode.SELF_USE: "self_use", OperationMode.ECO_CHARGE: "eco_charge", OperationMode.ECO_DISCHARGE: "eco_discharge", } _OPTION_TO_MODE: dict[str, OperationMode] = { value: key for key, value in _MODE_TO_OPTION.items() } @dataclass(frozen=True, kw_only=True) class GoodweSelectEntityDescription(SelectEntityDescription): """Class describing Goodwe number entities.""" options: dict[str, EMSMode] OPERATION_MODE = SelectEntityDescription( key="operation_mode", entity_category=EntityCategory.CONFIG, translation_key="operation_mode", ) EMS_MODE = GoodweSelectEntityDescription( key="ems_mode", entity_category=EntityCategory.CONFIG, translation_key="ems_mode", options={e.name.lower(): e for e in list(EMSMode)}, ) async def async_setup_entry( hass: HomeAssistant, config_entry: GoodweConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the inverter select entities from a config entry.""" inverter = config_entry.runtime_data.inverter device_info = config_entry.runtime_data.device_info supported_modes = await inverter.get_operation_modes(True) # read current operating mode from the inverter try: active_mode = await inverter.get_operation_mode() eco_mode = await inverter.read_setting("eco_mode_1") current_eco_power = abs(eco_mode.power) if eco_mode.power else 0 current_eco_soc = eco_mode.soc or 0 except (InverterError, ValueError): # Inverter model does not support this setting _LOGGER.debug("Could not read inverter operation mode", exc_info=True) else: active_mode_option = _MODE_TO_OPTION.get(active_mode) if active_mode_option is not None: entity = InverterOperationModeEntity( device_info, OPERATION_MODE, inverter, [v for k, v in _MODE_TO_OPTION.items() if k in supported_modes], active_mode_option, current_eco_power, current_eco_soc, ) async_add_entities([entity]) else: _LOGGER.warning( "Active mode %s not found in Goodwe Inverter Operation Mode Entity. Skipping entity creation", active_mode, ) eco_mode_power_entity_id = er.async_get(hass).async_get_entity_id( Platform.NUMBER, DOMAIN, f"{DOMAIN}-eco_mode_power-{inverter.serial_number}", ) if eco_mode_power_entity_id: async_track_state_change_event( hass, eco_mode_power_entity_id, entity.update_eco_mode_power, ) eco_mode_soc_entity_id = er.async_get(hass).async_get_entity_id( Platform.NUMBER, DOMAIN, f"{DOMAIN}-eco_mode_soc-{inverter.serial_number}", ) if eco_mode_soc_entity_id: async_track_state_change_event( hass, eco_mode_soc_entity_id, entity.update_eco_mode_soc, ) # read current EMS mode from the inverter try: ems_mode = await inverter.get_ems_mode() except (InverterError, ValueError): # Inverter model does not support EMS modes _LOGGER.debug("Could not read inverter EMS mode", exc_info=True) else: entity = InverterEMSModeEntity( device_info, EMS_MODE, inverter, ems_mode, ) async_add_entities([entity]) class InverterOperationModeEntity(SelectEntity): """Entity representing the inverter operation mode.""" _attr_should_poll = False _attr_has_entity_name = True def __init__( self, device_info: DeviceInfo, description: SelectEntityDescription, inverter: Inverter, supported_options: list[str], current_mode: str, current_eco_power: int, current_eco_soc: int, ) -> None: """Initialize the inverter operation mode setting entity.""" self.entity_description = description self._attr_unique_id = f"{DOMAIN}-{description.key}-{inverter.serial_number}" self._attr_device_info = device_info self._attr_options = supported_options self._attr_current_option = current_mode self._inverter: Inverter = inverter self._eco_mode_power = current_eco_power self._eco_mode_soc = current_eco_soc async def async_select_option(self, option: str) -> None: """Change the selected option.""" _LOGGER.debug( "Setting operation mode to %s, power %d, max SoC %d", option, self._eco_mode_power, self._eco_mode_soc, ) try: await self._inverter.set_operation_mode( _OPTION_TO_MODE[option], self._eco_mode_power, self._eco_mode_soc ) except InverterError as err: _LOGGER.warning( "Failed to set operation mode to %s: %s", option, err ) return self._attr_current_option = option self.async_write_ha_state() async def async_update(self) -> None: """Get the current value from inverter.""" value = await self._inverter.get_operation_mode() self._attr_current_option = _MODE_TO_OPTION[value] async def update_eco_mode_power(self, event: Event) -> None: """Update eco mode power value in inverter (when in eco mode).""" state = event.data.get("new_state") if state is None or state.state in (STATE_UNKNOWN, "", STATE_UNAVAILABLE): return self._eco_mode_power = int(float(state.state)) if event.data.get("old_state"): operation_mode = _OPTION_TO_MODE[self.current_option] if operation_mode in ( OperationMode.ECO_CHARGE, OperationMode.ECO_DISCHARGE, ): _LOGGER.debug("Setting eco mode power to %d", self._eco_mode_power) try: await self._inverter.set_operation_mode( operation_mode, self._eco_mode_power, self._eco_mode_soc ) except InverterError as err: _LOGGER.warning( "Failed to update eco mode power to %d: %s", self._eco_mode_power, err, ) async def update_eco_mode_soc(self, event: Event) -> None: """Update eco mode SoC value in inverter (when in eco mode).""" state = event.data.get("new_state") if state is None or state.state in (STATE_UNKNOWN, "", STATE_UNAVAILABLE): return self._eco_mode_soc = int(float(state.state)) if event.data.get("old_state"): operation_mode = _OPTION_TO_MODE[self.current_option] if operation_mode in ( OperationMode.ECO_CHARGE, OperationMode.ECO_DISCHARGE, ): _LOGGER.debug("Setting eco mode SoC to %d", self._eco_mode_soc) try: await self._inverter.set_operation_mode( operation_mode, self._eco_mode_power, self._eco_mode_soc ) except InverterError as err: _LOGGER.warning( "Failed to update eco mode SoC to %d: %s", self._eco_mode_soc, err, ) class InverterEMSModeEntity(SelectEntity): """Entity representing the inverter EMS mode.""" _attr_should_poll = False _attr_has_entity_name = True entity_description: GoodweSelectEntityDescription def __init__( self, device_info: DeviceInfo, description: GoodweSelectEntityDescription, inverter: Inverter, current_mode: EMSMode, ) -> None: """Initialize the inverter operation mode setting entity.""" self.entity_description = description self._attr_unique_id = f"{DOMAIN}-{description.key}-{inverter.serial_number}" self._attr_device_info = device_info self._attr_options = list(description.options.keys()) self._attr_current_option = current_mode.name.lower() self._inverter: Inverter = inverter async def async_select_option(self, option: str) -> None: """Change the EMS mode.""" _LOGGER.debug("Setting EMS mode to %s", option) try: await self._inverter.set_ems_mode(self.entity_description.options[option]) except InverterError as err: _LOGGER.warning("Failed to set EMS mode to %s: %s", option, err) return self._attr_current_option = option self.async_write_ha_state() async def async_update(self) -> None: """Get the current EMS mode from inverter.""" value = await self._inverter.get_ems_mode() self._attr_current_option = value.name.lower() ================================================ FILE: custom_components/goodwe/sensor.py ================================================ """Support for GoodWe inverter via UDP.""" from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass from datetime import date, datetime, timedelta from decimal import Decimal import logging from typing import Any from goodwe import Inverter, Sensor, SensorKind from goodwe.sensor import ( Enum, Enum2, EnumBitmap4, EnumBitmap22, EnumCalculated, EnumH, EnumL, ) from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, SensorStateClass, ) from homeassistant.const import ( PERCENTAGE, EntityCategory, UnitOfApparentPower, UnitOfElectricCurrent, UnitOfElectricPotential, UnitOfEnergy, UnitOfFrequency, UnitOfPower, UnitOfReactivePower, UnitOfTemperature, UnitOfTime, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import async_track_point_in_time from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util from .const import DOMAIN from .coordinator import GoodweConfigEntry, GoodweUpdateCoordinator _LOGGER = logging.getLogger(__name__) # Sensor name of battery SoC BATTERY_SOC = "battery_soc" # Sensors that are reset to 0 at midnight. # The inverter is only powered by the solar panels and not mains power, so it goes dead when the sun goes down. # The "_day" sensors are reset to 0 when the inverter wakes up in the morning when the sun comes up and power to the inverter is restored. # This makes sure daily values are reset at midnight instead of at sunrise. # When the inverter has a battery connected, HomeAssistant will not reset the values but let the inverter reset them by looking at the unavailable state of the inverter. DAILY_RESET = ["e_day", "e_load_day"] _MAIN_SENSORS = ( "ppv", "house_consumption", "active_power", "battery_soc", "e_day", "e_total", "meter_e_total_exp", "meter_e_total_imp", "e_bat_charge_total", "e_bat_discharge_total", ) _ICONS: dict[SensorKind, str] = { SensorKind.PV: "mdi:solar-power", SensorKind.AC: "mdi:power-plug-outline", SensorKind.UPS: "mdi:power-plug-off-outline", SensorKind.BAT: "mdi:battery-high", SensorKind.GRID: "mdi:transmission-tower", } @dataclass(frozen=True) class GoodweSensorEntityDescription(SensorEntityDescription): """Class describing Goodwe sensor entities.""" value: Callable[[GoodweUpdateCoordinator, str], Any] = lambda coordinator, sensor: ( coordinator.sensor_value(sensor) ) available: Callable[[GoodweUpdateCoordinator], bool] = lambda coordinator: ( coordinator.last_update_success ) _DESCRIPTIONS: dict[str, GoodweSensorEntityDescription] = { "A": GoodweSensorEntityDescription( key="A", device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, ), "V": GoodweSensorEntityDescription( key="V", device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricPotential.VOLT, ), "W": GoodweSensorEntityDescription( key="W", device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, ), "kWh": GoodweSensorEntityDescription( key="kWh", device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value=lambda coordinator, sensor: coordinator.total_sensor_value(sensor), available=lambda coordinator: coordinator.data is not None, ), "VA": GoodweSensorEntityDescription( key="VA", device_class=SensorDeviceClass.APPARENT_POWER, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, entity_registry_enabled_default=False, ), "var": GoodweSensorEntityDescription( key="var", device_class=SensorDeviceClass.REACTIVE_POWER, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfReactivePower.VOLT_AMPERE_REACTIVE, entity_registry_enabled_default=False, ), "C": GoodweSensorEntityDescription( key="C", device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, ), "Hz": GoodweSensorEntityDescription( key="Hz", device_class=SensorDeviceClass.FREQUENCY, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfFrequency.HERTZ, ), "h": GoodweSensorEntityDescription( key="h", device_class=SensorDeviceClass.DURATION, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTime.HOURS, entity_registry_enabled_default=False, ), "%": GoodweSensorEntityDescription( key="%", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, ), } DIAG_SENSOR = GoodweSensorEntityDescription( key="_", state_class=SensorStateClass.MEASUREMENT, ) TEXT_SENSOR = GoodweSensorEntityDescription( key="text", ) ENUM_SENSOR = GoodweSensorEntityDescription( key="enum", device_class=SensorDeviceClass.ENUM, ) async def async_setup_entry( hass: HomeAssistant, config_entry: GoodweConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the GoodWe inverter from a config entry.""" entities: list[InverterSensor] = [] inverter = config_entry.runtime_data.inverter coordinator = config_entry.runtime_data.coordinator device_info = config_entry.runtime_data.device_info # Individual inverter sensors entities entities.extend( InverterSensor(coordinator, device_info, inverter, sensor) for sensor in inverter.sensors() ) async_add_entities(entities) class InverterSensor(CoordinatorEntity[GoodweUpdateCoordinator], SensorEntity): """Entity representing individual inverter sensor.""" _attr_has_entity_name = True entity_description: GoodweSensorEntityDescription def __init__( self, coordinator: GoodweUpdateCoordinator, device_info: DeviceInfo, inverter: Inverter, sensor: Sensor, ) -> None: """Initialize an inverter sensor.""" super().__init__(coordinator) self._attr_name = sensor.name.strip() self._attr_unique_id = f"{DOMAIN}-{sensor.id_}-{inverter.serial_number}" self._attr_device_info = device_info self._attr_entity_category = ( EntityCategory.DIAGNOSTIC if sensor.id_ not in _MAIN_SENSORS else None ) try: self.entity_description = _DESCRIPTIONS[sensor.unit] except KeyError: if isinstance(sensor, (Enum, EnumH, EnumL, Enum2, EnumCalculated)): self.entity_description = ENUM_SENSOR self._attr_options = list(sensor._labels.values()) elif ( isinstance(sensor, (EnumBitmap4, EnumBitmap22)) or sensor.id_ == "timestamp" ): self.entity_description = TEXT_SENSOR else: self.entity_description = DIAG_SENSOR self._attr_native_unit_of_measurement = sensor.unit self._attr_icon = _ICONS.get(sensor.kind) # Set the inverter SoC as main device battery sensor if sensor.id_ == BATTERY_SOC: self._attr_device_class = SensorDeviceClass.BATTERY self._sensor = sensor self._stop_reset: Callable[[], None] | None = None @property def native_value(self) -> StateType | date | datetime | Decimal: """Return the value reported by the sensor.""" return self.entity_description.value(self.coordinator, self._sensor.id_) @property def available(self) -> bool: """Return if entity is available. We delegate the behavior to entity description lambda, since some sensors (like energy produced today) should report themselves as available even when the (non-battery) pv inverter is off-line during night and most of the sensors are actually unavailable. """ return self.entity_description.available(self.coordinator) @callback def async_reset(self, now): """Reset the value back to 0 at midnight. Some sensors values like daily produced energy are kept available, even when the inverter is in sleep mode and no longer responds to request. In contrast to "total" sensors, these "daily" sensors need to be reset to 0 on midnight. """ if not self.coordinator.last_update_success: self.coordinator.reset_sensor(self._sensor.id_) self.async_write_ha_state() _LOGGER.debug("Goodwe reset %s to 0", self.name) next_midnight = dt_util.start_of_local_day( dt_util.now() + timedelta(days=1, minutes=1) ) self._stop_reset = async_track_point_in_time( self.hass, self.async_reset, next_midnight ) async def async_added_to_hass(self) -> None: """Schedule reset task at midnight.""" if self._sensor.id_ in DAILY_RESET: next_midnight = dt_util.start_of_local_day( dt_util.now() + timedelta(days=1) ) self._stop_reset = async_track_point_in_time( self.hass, self.async_reset, next_midnight ) await super().async_added_to_hass() async def async_will_remove_from_hass(self) -> None: """Remove reset task at midnight.""" if self._sensor.id_ in DAILY_RESET and self._stop_reset is not None: self._stop_reset() await super().async_will_remove_from_hass() ================================================ FILE: custom_components/goodwe/services.py ================================================ """Services for Goodwe integration.""" from __future__ import annotations import logging import voluptuous as vol from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from .const import ( ATTR_DEVICE_ID, ATTR_ENTITY_ID, ATTR_PARAMETER, ATTR_VALUE, DOMAIN, SERVICE_GET_PARAMETER, SERVICE_SET_PARAMETER, ) _LOGGER = logging.getLogger(__name__) SERVICE_GET_PARAMETER_SCHEMA = vol.Schema( { vol.Required(ATTR_DEVICE_ID): str, vol.Required(ATTR_PARAMETER): str, vol.Required(ATTR_ENTITY_ID): str, } ) SERVICE_SET_PARAMETER_SCHEMA = vol.Schema( { vol.Required(ATTR_DEVICE_ID): str, vol.Required(ATTR_PARAMETER): str, vol.Required(ATTR_VALUE): vol.Any(str, int, bool), } ) async def async_setup_services(hass: HomeAssistant) -> None: """Set up services for Goodwe integration.""" if hass.services.has_service(DOMAIN, SERVICE_GET_PARAMETER): return async def _get_inverter_by_device_id(hass: HomeAssistant, device_id: str): """Return a inverter instance given a device_id.""" device = dr.async_get(hass).async_get(device_id) for runtime_data in hass.data[DOMAIN].values(): if device.identifiers == runtime_data.device_info.get("identifiers"): return runtime_data.inverter raise ValueError(f"Inverter for device id {device_id} not found") async def async_get_parameter(call): """Service for setting inverter parameter.""" device_id = call.data[ATTR_DEVICE_ID] parameter = call.data[ATTR_PARAMETER] entity_id = call.data[ATTR_ENTITY_ID] _LOGGER.debug("Reading inverter parameter '%s'", parameter) inverter = await _get_inverter_by_device_id(hass, device_id) value = await inverter.read_setting(parameter) entity = er.async_get(hass).async_get(entity_id) await hass.services.async_call( entity.domain, "set_value", {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: value}, blocking=True, ) async def async_set_parameter(call): """Service for setting inverter parameter.""" device_id = call.data[ATTR_DEVICE_ID] parameter = call.data[ATTR_PARAMETER] value = call.data[ATTR_VALUE] _LOGGER.info("Setting inverter parameter '%s' to '%s'", parameter, value) inverter = await _get_inverter_by_device_id(hass, device_id) await inverter.write_setting(parameter, value) hass.services.async_register( DOMAIN, SERVICE_GET_PARAMETER, async_get_parameter, schema=SERVICE_GET_PARAMETER_SCHEMA, ) hass.services.async_register( DOMAIN, SERVICE_SET_PARAMETER, async_set_parameter, schema=SERVICE_SET_PARAMETER_SCHEMA, ) async def async_unload_services(hass: HomeAssistant) -> None: """Unload services for Goodwe integration.""" if hass.services.has_service(DOMAIN, SERVICE_GET_PARAMETER): hass.services.async_remove(DOMAIN, SERVICE_GET_PARAMETER) if hass.services.has_service(DOMAIN, SERVICE_SET_PARAMETER): hass.services.async_remove(DOMAIN, SERVICE_SET_PARAMETER) ================================================ FILE: custom_components/goodwe/services.yaml ================================================ get_parameter: name: Get inverter configuration parameter description: Read inverter configuration parameter and store it in helper entity fields: device_id: name: Inverter device description: ID of the inverter device required: true selector: device: integration: goodwe parameter: name: Parameter description: Name of the inverter parameter required: true selector: text: example: 'battery_charge_current' entity_id: name: Helper entity description: Entity where to store the parameter value required: true selector: entity: domain: - input_number - input_text set_parameter: name: Set inverter configuration parameter - EXPERIMENTAL description: BEWARE !!! Improper use may cause damage ! fields: device_id: name: Inverter device description: ID of the inverter device required: true selector: device: integration: goodwe parameter: name: Parameter description: Name of the inverter parameter required: true selector: text: example: 'battery_charge_current' value: name: Value description: Value of the parameter to set example: '20' required: true selector: object: ================================================ FILE: custom_components/goodwe/strings.json ================================================ { "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]" }, "error": { "connection_error": "[%key:common::config_flow::error::cannot_connect%]" }, "step": { "user": { "data": { "host": "[%key:common::config_flow::data::ip%]", "protocol": "Protocol", "model_family": "Inverter Family (optional)" }, "description": "Connect to inverter", "title": "GoodWe inverter" } } }, "entity": { "button": { "synchronize_clock": { "name": "Synchronize inverter clock" } }, "number": { "battery_discharge_depth": { "name": "Depth of discharge (on-grid)" }, "battery_discharge_depth_offline": { "name": "Depth of discharge (backup)" }, "battery_soc_protection": { "name": "Battery SoC protection" }, "eco_mode_power": { "name": "Eco mode power" }, "eco_mode_soc": { "name": "Eco mode SoC" }, "ems_power_limit": { "name": "EMS power limit" }, "fast_charging_power": { "name": "Fast charging power" }, "fast_charging_soc": { "name": "Fast charging SoC" }, "grid_export_limit": { "name": "Grid export limit" }, "soc_upper_limit": { "name": "SoC upper limit" } }, "select": { "ems_mode": { "name": "EMS mode", "state": { "auto": "Auto", "charge_pv": "Charge PV", "discharge_pv": "Discharge PV", "import_ac": "Import AC", "export_ac": "Export AC", "conserve": "Conserve", "off_grid": "Off-grid", "battery_standby": "Battery Standby", "buy_power": "Buy Power", "sell_power": "Sell Power", "charge_battery": "Charge Battery", "discharge_battery": "Discharge Battery" } }, "operation_mode": { "name": "Inverter operation mode", "state": { "backup": "Backup mode", "eco": "Eco mode", "eco_charge": "Eco charge mode", "eco_discharge": "Eco discharge mode", "general": "General mode", "off_grid": "Off-grid mode", "peak_shaving": "Peak shaving mode" } } }, "switch": { "backup_supply_switch": { "name": "Backup supply switch" }, "dod_holding_switch": { "name": "DOD holding" }, "fast_charging_switch": { "name": "Fast charging switch" }, "grid_export_limit_switch": { "name": "Grid export limit switch" }, "load_control": { "name": "Load control" } } }, "options": { "step": { "init": { "title": "GoodWe optional settings", "description": "Specify optional (network) settings", "data": { "host": "[%key:common::config_flow::data::ip%]", "port": "Port (8899/UDP | 502/TCP)", "protocol": "Protocol", "keep_alive": "TCP Keep alive", "model_family": "Protocol Family [ET|DT|ES] (optional)", "scan_interval": "Scan interval (s)", "network_retries": "Network retry attempts", "network_timeout": "Network request timeout (s)" } } } } } ================================================ FILE: custom_components/goodwe/switch.py ================================================ """GoodWe PV inverter switch entities.""" from dataclasses import dataclass import logging from typing import Any from goodwe import Inverter, InverterError from homeassistant.components.switch import ( SwitchDeviceClass, SwitchEntity, SwitchEntityDescription, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo, EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import BaseCoordinatorEntity from .coordinator import GoodweUpdateCoordinator _LOGGER = logging.getLogger(__name__) @dataclass(frozen=True, kw_only=True) class GoodweSwitchEntityDescription(SwitchEntityDescription): """Class describing Goodwe switch entities.""" setting: str polling_interval: int = 0 SWITCHES = ( GoodweSwitchEntityDescription( key="load_control", translation_key="load_control", device_class=SwitchDeviceClass.OUTLET, setting="load_control_switch", ), GoodweSwitchEntityDescription( key="grid_export_limit_switch", translation_key="grid_export_limit_switch", entity_category=EntityCategory.CONFIG, device_class=SwitchDeviceClass.SWITCH, setting="grid_export", ), GoodweSwitchEntityDescription( key="fast_charging_switch", translation_key="fast_charging_switch", device_class=SwitchDeviceClass.SWITCH, setting="fast_charging", polling_interval=30, ), GoodweSwitchEntityDescription( key="backup_supply_switch", translation_key="backup_supply_switch", entity_category=EntityCategory.CONFIG, device_class=SwitchDeviceClass.SWITCH, setting="backup_supply", ), GoodweSwitchEntityDescription( key="dod_holding_switch", translation_key="dod_holding_switch", entity_category=EntityCategory.CONFIG, device_class=SwitchDeviceClass.SWITCH, setting="dod_holding", ), ) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the inverter switch entities from a config entry.""" inverter = config_entry.runtime_data.inverter coordinator = config_entry.runtime_data.coordinator device_info = config_entry.runtime_data.device_info entities = [] for description in SWITCHES: try: current_state = await inverter.read_setting(description.setting) except (InverterError, ValueError): # Inverter model does not support this feature _LOGGER.debug("Could not read %s value", description.setting) else: entities.append( InverterSwitchEntity( coordinator, device_info, description, inverter, current_state == 1, ) ) async_add_entities(entities) class InverterSwitchEntity( BaseCoordinatorEntity[GoodweUpdateCoordinator], SwitchEntity ): """Switch representation of inverter's 'Load Control' relay.""" _attr_should_poll = False _attr_has_entity_name = True entity_description: GoodweSwitchEntityDescription def __init__( self, coordinator: GoodweUpdateCoordinator, device_info: DeviceInfo, description: GoodweSwitchEntityDescription, inverter: Inverter, current_is_on: bool, ) -> None: """Initialize the inverter operation mode setting entity.""" super().__init__(coordinator) self.entity_description = description self._attr_unique_id = f"{description.key}-{inverter.serial_number}" self._attr_device_info = device_info self._attr_is_on = current_is_on self._inverter: Inverter = inverter self._notify_coordinator() async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" await self._inverter.write_setting(self.entity_description.setting, 1) self._attr_is_on = True self._notify_coordinator() self.async_write_ha_state() async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" await self._inverter.write_setting(self.entity_description.setting, 0) self._attr_is_on = False self._notify_coordinator() self.async_write_ha_state() async def async_update(self) -> None: """Get the current value from inverter.""" value = await self._inverter.read_setting(self.entity_description.setting) self._attr_is_on = value == 1 self._notify_coordinator() def _notify_coordinator(self) -> None: if self.entity_description.polling_interval: self.coordinator.entity_state_polling( self, self.entity_description.polling_interval if self._attr_is_on else 0, ) ================================================ FILE: custom_components/goodwe/translations/cs.json ================================================ { "config": { "abort": { "already_configured": "Zařízení je již nastaveno", "already_in_progress": "Nastavení již probíhá" }, "error": { "connection_error": "Nepodařilo se připojit" }, "flow_title": "GoodWe", "step": { "user": { "data": { "host": "IP adresa", "port": "Port (8899/UDP | 502/TCP)", "protocol": "Protokol", "model_family": "Typ střídače (volitelné)" }, "description": "Připojí se ke střídači", "title": "Střídač GoodWe" } } }, "entity": { "button": { "synchronize_clock": { "name": "Synchronizovat hodiny střídače" }, "start_inverter": { "name": "Start střídače" }, "stop_inverter": { "name": "Stop střídače" } }, "number": { "battery_discharge_depth": { "name": "Maximum vybití (v síti)" }, "battery_discharge_depth_offline": { "name": "Maximum vybití (backup)" }, "eco_mode_power": { "name": "Výkon v ekonomickém režimu" }, "eco_mode_soc": { "name": "Stav nabítí baterie ekonomickém režimu" }, "grid_export_limit": { "name": "Limit dodávky do sítě" }, "fast_charging_power": { "name": "Rychlé nabíjení výkon" }, "fast_charging_soc": { "name": "Rychlé nabíjení stav baterie" } }, "select": { "operation_mode": { "name": "Provozní režim střídače", "state": { "backup": "Režim zálohy", "eco": "Ekonomický režim", "eco_charge": "Režim ekonomického nabíjení", "eco_discharge": "Režim ekonomického vybíjení", "general": "Obecný režim", "off_grid": "Režim vypnuté sítě", "peak_shaving": "Režim pokrývání špiček", "self_use": "Vlastní spotřeba" } } }, "sensor": { "grid_in_out_label": { "state": { "Idle": "Nečinnost", "Exporting": "Export", "Importing": "Import" } } }, "switch": { "grid_export_limit_switch": { "name": "Řízení dodávky do sítě" }, "fast_charging_switch": { "name": "Rychlé nabíjení" }, "load_control": { "name": "Řízení zátěže" }, "backup_supply_switch": { "name": "Záloha" }, "dod_holding_switch": { "name": "Udržovat DOD baterie" } } }, "options": { "step": { "init": { "data": { "host": "IP adresa", "port": "Port (8899/UDP | 502/TCP)", "protocol": "Protokol", "keep_alive": "TCP Keep alive", "model_family": "Typ protokolu [ET|DT|ES]", "scan_interval": "Interval skenování (s)", "network_retries": "Počet opakování síťového požadavku", "network_timeout": "Časový limit síťového požadavku (s)" }, "description": "Nastaví volitelné (síťové) volby", "title": "Volitelné volby GoodWe" } } } } ================================================ FILE: custom_components/goodwe/translations/de.json ================================================ { "config": { "abort": { "already_configured": "Gerät ist bereits konfiguriert", "already_in_progress": "Die Konfiguration wird bereits bearbeitet" }, "error": { "connection_error": "Verbindung fehlgeschlagen" }, "flow_title": "GoodWe", "step": { "user": { "data": { "host": "Hostname / IP-Adresse", "port": "Port (8899/UDP | 502/TCP)", "protocol": "Protokoll", "model_family": "Wechselrichterfamilie (optional)" }, "description": "Verbinden zum Wechselrichter", "title": "GoodWe Wechselrichter" } } }, "entity": { "button": { "synchronize_clock": { "name": "Wechselrichter-Uhr synchronisieren" }, "start_inverter": { "name": "Wechselrichter starten" }, "stop_inverter": { "name": "Wechselrichter stoppen" } }, "number": { "battery_discharge_depth": { "name": "Entladungstiefe (Netzbetrieb)" }, "eco_mode_power": { "name": "Öko-Modus Leistung" }, "eco_mode_soc": { "name": "Öko-Modus SoC" }, "grid_export_limit": { "name": "Netzeinspeisung Limit" }, "fast_charging_power": { "name": "Schnellladeleistung" }, "fast_charging_soc": { "name": "Schnelllade SoC" } }, "select": { "operation_mode": { "name": "Wechselrichter-Betriebsart", "state": { "backup": "Backup Modus", "eco": "Öko Modus", "eco_charge": "Öko Lademodus", "eco_discharge": "Öko Entlademodus", "general": "Allgemeiner Modus", "off_grid": "Netzunabhängiger Modus", "peak_shaving": "Spitzenlastreduzierungs-Modus", "self_use": "Selbstnutzung-Modus" } } }, "sensor": { "grid_in_out_label": { "state": { "0": "Untätig", "1": "Exportieren", "2": "Importieren" } } }, "switch": { "grid_export_limit_switch": { "name": "Netz-Export Begrenzungsschalter" }, "fast_charging_switch": { "name": "Schnellladungs-Schalter" }, "load_control": { "name": "Lastkontrolle" } } }, "options": { "step": { "init": { "data": { "host": "Hostname / IP-Adresse", "port": "Port (8899/UDP | 502/TCP)", "protocol": "Protokoll", "keep_alive": "TCP Aufrechterhaltung", "model_family": "Protokoll Familie [ET|DT|ES]", "scan_interval": "Scan-Intervall (s)", "network_retries": "Netzwiederholungsversuche", "network_timeout": "Zeitüberschreitung bei Netzanfragen(s)" }, "description": "Optionale (Netzwerk-)Einstellungen", "title": "GoodWe optionale Einstellungen" } } } } ================================================ FILE: custom_components/goodwe/translations/en.json ================================================ { "config": { "abort": { "already_configured": "Device is already configured", "already_in_progress": "Configuration flow is already in progress" }, "error": { "connection_error": "Failed to connect" }, "flow_title": "GoodWe", "step": { "user": { "data": { "host": "Hostname / IP address", "port": "Port (8899/UDP | 502/TCP)", "protocol": "Protocol", "model_family": "Inverter Family (optional)" }, "description": "Connect to inverter", "title": "GoodWe inverter" } } }, "entity": { "button": { "synchronize_clock": { "name": "Synchronize inverter clock" }, "start_inverter": { "name": "Start inverter" }, "stop_inverter": { "name": "Stop inverter" } }, "number": { "battery_discharge_depth": { "name": "Depth of discharge (on-grid)" }, "battery_discharge_depth_offline": { "name": "Depth of discharge (backup)" }, "battery_soc_protection": { "name": "Battery SoC protection" }, "eco_mode_power": { "name": "Eco mode power" }, "eco_mode_soc": { "name": "Eco mode SoC" }, "ems_power_limit": { "name": "EMS power limit" }, "grid_export_limit": { "name": "Grid export limit" }, "fast_charging_power": { "name": "Fast charging power" }, "fast_charging_soc": { "name": "Fast charging SoC" } }, "select": { "ems_mode": { "name": "EMS mode", "state": { "auto": "Auto", "charge_pv": "Charge PV", "discharge_pv": "Discharge PV", "import_ac": "Import AC", "export_ac": "Export AC", "conserve": "Conserve", "off_grid": "Off-grid", "battery_standby": "Battery Standby", "buy_power": "Buy Power", "sell_power": "Sell Power", "charge_battery": "Charge Battery", "discharge_battery": "Discharge Battery" } }, "operation_mode": { "name": "Inverter operation mode", "state": { "backup": "Backup mode", "eco": "Eco mode", "eco_charge": "Eco charge mode", "eco_discharge": "Eco discharge mode", "general": "General mode", "off_grid": "Off-grid mode", "peak_shaving": "Peak shaving mode", "self_use": "Self Use mode" } } }, "sensor": { "grid_in_out_label": { "state": { "0": "Idle", "1": "Exporting", "2": "Importing" } } }, "switch": { "grid_export_limit_switch": { "name": "Grid export limit switch" }, "fast_charging_switch": { "name": "Fast charging switch" }, "load_control": { "name": "Load control" }, "backup_supply_switch": { "name": "Backup supply" }, "dod_holding_switch": { "name": "DOD holding" } } }, "options": { "step": { "init": { "data": { "host": "Hostname / IP Address", "port": "Port (8899/UDP | 502/TCP)", "protocol": "Protocol", "keep_alive": "TCP Keep alive", "model_family": "Protocol Family [ET|DT|ES]", "scan_interval": "Scan interval (s)", "network_retries": "Network retry attempts", "network_timeout": "Network request timeout (s)" }, "description": "Specify optional (network) settings", "title": "GoodWe optional settings" } } } } ================================================ FILE: custom_components/goodwe/translations/es.json ================================================ { "config": { "abort": { "already_configured": "El dispositivo ya está configurado", "already_in_progress": "El proceso de configuración ya está en curso" }, "error": { "connection_error": "No se pudo conectar" }, "flow_title": "GoodWe", "step": { "user": { "data": { "host": "Nombre de equipo / Dirección IP", "port": "Port (8899/UDP | 502/TCP)", "protocol": "Protocolo", "model_family": "Familia de inversores (opcional)" }, "description": "Conectar al inversor", "title": "Inversor GoodWe" } } }, "entity": { "button": { "synchronize_clock": { "name": "Sincronizar reloj del inversor" }, "start_inverter": { "name": "Iniciar inversor" }, "stop_inverter": { "name": "Detener inversor" } }, "number": { "battery_discharge_depth": { "name": "Profundidad de descarga (en red)" }, "battery_discharge_depth_offline": { "name": "Profundidad de descarga (respaldo)" }, "eco_mode_power": { "name": "Potencia en modo ecológico" }, "eco_mode_soc": { "name": "Estado de carga en modo ecológico" }, "grid_export_limit": { "name": "Límite de exportación a la red" }, "fast_charging_power": { "name": "Potencia de carga rápida" }, "fast_charging_soc": { "name": "Estado de carga para carga rápida" } }, "select": { "operation_mode": { "name": "Modo de funcionamiento del inversor", "state": { "backup": "Modo de respaldo", "eco": "Modo ecológico", "eco_charge": "Modo de carga ecológica", "eco_discharge": "Modo de descarga ecológica", "general": "Modo general", "off_grid": "Modo aislado", "peak_shaving": "Modo de recorte de picos", "self_use": "Modo de autoconsumo" } } }, "sensor": { "grid_in_out_label": { "state": { "0": "En reposo", "1": "Exportando", "2": "Importando" } } }, "switch": { "grid_export_limit_switch": { "name": "Interruptor de límite de exportación a red" }, "fast_charging_switch": { "name": "Interruptor de carga rápida" }, "load_control": { "name": "Control de carga" }, "backup_supply_switch": { "name": "Suministro de respaldo" }, "dod_holding_switch": { "name": "Retención de profundidad de descarga" } } }, "options": { "step": { "init": { "data": { "host": "Nombre de equipo / Dirección IP", "port": "Port (8899/UDP | 502/TCP)", "protocol": "Protocolo", "keep_alive": "Mantenimiento de conexión TCP", "model_family": "Familia de protocolos [ET|DT|ES]", "scan_interval": "Intervalo de escaneo (s)", "network_retries": "Reintentos de red", "network_timeout": "Tiempo de espera de solicitud de red (s)" }, "description": "Especificar configuraciones opcionales (de red)", "title": "Configuraciones opcionales de GoodWe" } } } } ================================================ FILE: custom_components/goodwe/translations/sk.json ================================================ { "config": { "abort": { "already_configured": "Zariadenie je už nastavené", "already_in_progress": "Konfigurácia už prebieha" }, "error": { "connection_error": "Nepodarilo sa pripojiť" }, "flow_title": "GoodWe", "step": { "user": { "data": { "host": "IP adresa", "port": "Port (8899/UDP | 502/TCP)", "protocol": "Protokol", "model_family": "Typ meniča (voliteľné)" }, "description": "Pripojiť sa k meniča", "title": "Menič GoodWe" } } }, "entity": { "button": { "synchronize_clock": { "name": "Synchronizácia hodín meniča" }, "start_inverter": { "name": "Zapnúť menič" }, "stop_inverter": { "name": "Zastaviť menič" } }, "number": { "battery_discharge_depth": { "name": "Maximum vybitia (v sieti)" }, "eco_mode_power": { "name": "Výkon v ekonomickom režime" }, "eco_mode_soc": { "name": "Stav nabitia batérie v ekonomickom režime" }, "grid_export_limit": { "name": "Limit dodávky do siete" }, "fast_charging_power": { "name": "Rýchle nabíjanie výkon" }, "fast_charging_soc": { "name": "Rýchle nabíjanie stav batérie" } }, "select": { "operation_mode": { "name": "Prevádzkový režim meniča", "state": { "backup": "Režim zálohovania", "eco": "Ekonomický režim", "eco_charge": "Režim ekonomického nabíjania", "eco_discharge": "Režim ekonomického vybíjania", "general": "Všeobecný režim", "off_grid": "Režim mimo siete", "peak_shaving": "Režim šetrenia v špičke", "self_use": "Vlastná spotreba" } } }, "sensor": { "grid_in_out_label": { "state": { "Idle": "Nečinnosť", "Exporting": "Export", "Importing": "Import" } } }, "switch": { "grid_export_limit_switch": { "name": "Riadenie dodávky do siete" }, "fast_charging_switch": { "name": "Rýchle nabíjanie" }, "load_control": { "name": "Riadenie záťaže" }, "backup_supply_switch": { "name": "Záloha" } } }, "options": { "step": { "init": { "data": { "host": "IP adresa", "port": "Port (8899/UDP | 502/TCP)", "protocol": "Protokol", "keep_alive": "TCP Keep alive", "model_family": "Typ protokolu [ET|DT|ES]", "scan_interval": "Interval skenovania (s)", "network_retries": "Počet opakovaní sieťových dopytov", "network_timeout": "Časový limit sieťových dopytov (s)" }, "description": "Nastaví voliteľné (sieťové) parametre", "title": "Voliteľné parametre GoodWe" } } } } ================================================ FILE: hacs.json ================================================ { "name": "GoodWe Inverter (experimental)", "homeassistant": "2025.12.0" } ================================================ FILE: info.md ================================================ [!["Buy Me A Coffee"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://www.buymeacoffee.com/mletenay) [![hacs_badge](https://img.shields.io/badge/HACS-Default-orange.svg)](https://github.com/hacs/integration) ![GitHub Release](https://img.shields.io/github/v/release/mletenay/home-assistant-goodwe-inverter) ## GoodWe solar inverter for Home Assistant (experimental) Support for Goodwe solar inverters is present as native integration of [Home Assistant](https://www.home-assistant.io/integrations/goodwe/) since its release 2022.2 and is recommended for most users. This custom component is experimental version with features not (yet) present in standard HA's integration and is intended for users with specific needs and early adopters of new features. Use at own risk. ### Differences between this HACS and native HA integration - EMS modes - Special work modes `Eco charge mode` and `Eco discharge mode` (24/7 with defined power and SoC). - Network configuration parameters `Scan iterval`, `Network retry attempts`, `Network request timeout`. - Switch `Export Limit Switch`. - Switch `Load Control` (for ET+ inverters). - Switch and SoC/Power inputs for `Fast Charging` functionality. - `Start inverter` and `Stop inverter` buttons for grid-only inverters. - Services for getting/setting inverter configuration parameters ### Migration from HACS to HA If you have been using this custom component and want to migrate to standard HA integration, the migration is straightforward. Just remove the integration from HACS (press Ignore and force uninstall despite the warning the integration is still configured). Afrer restart of Home Assistant, the standard Goodwe integration will start and all your existing settings, entity names, history and statistics should be preserved. (If you uninstall the integration first, then uninstall HACS component and install integration back again, it will also work, but you will probably loose some history and settings since HA integration uses slightly different default entity names.) ### Documentation Find the full documentation [here](https://github.com/mletenay/home-assistant-goodwe-inverter). ================================================ FILE: inverter_scan.py ================================================ """Simple test script to scan inverter present on local network""" import asyncio import goodwe import logging import sys logging.basicConfig( format="%(asctime)-15s %(funcName)s(%(lineno)d) - %(levelname)s: %(message)s", stream=sys.stderr, level=getattr(logging, "ERROR", None), ) result = asyncio.run(goodwe.search_inverters()).decode("utf-8").split(",") print(f"Located inverter at IP: {result[0]}, mac: {result[1]}, name: {result[2]}") inverter = asyncio.run(goodwe.discover(result[0], 8899)) print( f"Identified inverter model: {inverter.model_name}, serialNr: {inverter.serial_number}" ) ================================================ FILE: inverter_test.py ================================================ """Simple test script to check inverter UDP protocol communication""" import asyncio import goodwe import logging import sys logging.basicConfig( format="%(asctime)-15s %(funcName)s(%(lineno)d) - %(levelname)s: %(message)s", stream=sys.stderr, level=getattr(logging, "ERROR", None), ) # Set the appropriate IP address IP_ADDRESS = "192.168.2.14" PORT = 8899 FAMILY = "ET" # One of ET, ES, DT or None to detect inverter family automatically COMM_ADDR = None # Usually 0xf7 for ET/ES or 0x7f for DT, or None for default value TIMEOUT = 1 RETRIES = 3 inverter = asyncio.run( goodwe.connect(host=IP_ADDRESS, family=FAMILY, timeout=TIMEOUT, retries=RETRIES) ) print( f"Identified inverter:\n" f"\tModel: {inverter.model_name}\n" f"\tSerialNr: {inverter.serial_number}\n" f"\tFirmware: {inverter.firmware}" ) response = asyncio.run(inverter.read_runtime_data()) print("\nSensors values:") for sensor in inverter.sensors(): if sensor.id_ in response: print( f"\t{sensor.id_:30}:\t{sensor.name} = {response[sensor.id_]} {sensor.unit}" )