From 9c190e25312d425525343b348e43c5ea33fe5e2e Mon Sep 17 00:00:00 2001 From: Dewet Diener Date: Wed, 13 May 2026 18:42:25 +0100 Subject: [PATCH 1/7] chore: bump givenergy-modbus to >=2.0.0,<3.0.0 for v1.0.0 development Points uv at the local givenergy-modbus checkout while v2.0.0 is in development and not yet published to PyPI. Co-Authored-By: Claude Sonnet 4.6 --- .../givenergy_local/manifest.json | 2 +- pyproject.toml | 5 ++- uv.lock | 40 ++++++++++++++++--- 3 files changed, 40 insertions(+), 7 deletions(-) diff --git a/custom_components/givenergy_local/manifest.json b/custom_components/givenergy_local/manifest.json index 10da558..7599675 100644 --- a/custom_components/givenergy_local/manifest.json +++ b/custom_components/givenergy_local/manifest.json @@ -9,7 +9,7 @@ "iot_class": "local_polling", "issue_tracker": "https://github.com/dewet22/givenergy-hass/issues", "requirements": [ - "givenergy-modbus>=1.3.0,<2.0.0a1" + "givenergy-modbus>=2.0.0,<3.0.0" ], "version": "0.0.0" } diff --git a/pyproject.toml b/pyproject.toml index 7472d17..7c48636 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ name = "givenergy-local" description = "Home Assistant custom component for GivEnergy inverters via local Modbus TCP" requires-python = ">=3.13" dependencies = [ - "givenergy-modbus>=1.3.0,<2.0.0a1", + "givenergy-modbus>=2.0.0,<3.0.0", ] # Version is not declared here. The integration is distributed via HACS, # which reads `custom_components/givenergy_local/manifest.json` as the @@ -23,6 +23,9 @@ dev = [ [tool.uv] package = false +[tool.uv.sources] +givenergy-modbus = { path = "../givenergy-modbus", editable = true } + [tool.pytest.ini_options] asyncio_mode = "auto" diff --git a/uv.lock b/uv.lock index 8cdc842..91f7253 100644 --- a/uv.lock +++ b/uv.lock @@ -1930,7 +1930,7 @@ dev = [ ] [package.metadata] -requires-dist = [{ name = "givenergy-modbus", specifier = ">=1.3.0,<2.0.0a1" }] +requires-dist = [{ name = "givenergy-modbus", editable = "../givenergy-modbus" }] [package.metadata.requires-dev] dev = [ @@ -1944,16 +1944,46 @@ dev = [ [[package]] name = "givenergy-modbus" version = "1.3.0" -source = { registry = "https://pypi.org/simple" } +source = { editable = "../givenergy-modbus" } dependencies = [ { name = "crccheck" }, { name = "pydantic", version = "2.10.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13.2'" }, { name = "pydantic", version = "2.12.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13.2' and python_full_version < '3.14.2'" }, { name = "pydantic", version = "2.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14.2'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/99/8f/929743420244f5364b497e66424db622b2ab61dd422ff495380ff977c032/givenergy_modbus-1.3.0.tar.gz", hash = "sha256:9ea1481c9a44a489c0881d45658350a809476704d848521b647483a477b60e79", size = 59793, upload-time = "2026-05-13T08:12:19.955Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/94/12/c725b05ed2cb963ba443d65b4b9bb76948ccd89bac789cc8dcda210885aa/givenergy_modbus-1.3.0-py3-none-any.whl", hash = "sha256:bedfcad399b42265f398edf0cf7fca37a5996e664a5f9c18ca8c3cd5393e0aa1", size = 39285, upload-time = "2026-05-13T08:12:18.618Z" }, + +[package.metadata] +requires-dist = [ + { name = "crccheck", specifier = ">=1.1,<2" }, + { name = "pydantic", specifier = ">=2.0,<3" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "ipython", specifier = ">=9.0.0,<10" }, + { name = "pip", specifier = ">=26.0.0,<27" }, + { name = "pyyaml", specifier = ">=6.0.2" }, + { name = "twine", specifier = ">=6.0.0,<7" }, + { name = "virtualenv", specifier = ">=21.0.0,<22" }, +] +docs = [ + { name = "mkdocs", specifier = ">=1.6.0,<2" }, + { name = "mkdocs-autorefs", specifier = ">=1.0.0,<2" }, + { name = "mkdocs-include-markdown-plugin", specifier = ">=7.0.0,<8" }, + { name = "mkdocs-material", specifier = ">=9.5.0,<10" }, + { name = "mkdocs-material-extensions", specifier = ">=1.3.0,<2" }, + { name = "mkdocstrings", extras = ["python"], specifier = ">=1.0.0,<2" }, +] +test = [ + { name = "bandit", specifier = ">=1.7.5,<2" }, + { name = "mypy", specifier = ">=2.0.0,<3" }, + { name = "pytest", specifier = ">=9.0.0,<10" }, + { name = "pytest-asyncio", specifier = ">=1.0.0,<2" }, + { name = "pytest-cov", specifier = ">=7.0.0,<8" }, + { name = "pytest-timeout", specifier = ">=2.4.0,<3" }, + { name = "ruff", specifier = ">=0.15.0,<1" }, + { name = "tox", specifier = ">=4.4.8,<5" }, + { name = "tox-uv", specifier = ">=1.0.0" }, ] [[package]] From a57df738696e330a0813ee3bcd0d47cf2c6b8b1c Mon Sep 17 00:00:00 2001 From: Dewet Diener Date: Thu, 14 May 2026 07:57:44 +0100 Subject: [PATCH 2/7] chore: point givenergy-modbus source at GitHub HEAD (main branch) Switches from a local editable path to tracking the upstream git repo so the next branch always reflects the latest givenergy-modbus main. Co-Authored-By: Claude Sonnet 4.6 --- pyproject.toml | 2 +- uv.lock | 38 ++------------------------------------ 2 files changed, 3 insertions(+), 37 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 7c48636..7cbe941 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,7 @@ dev = [ package = false [tool.uv.sources] -givenergy-modbus = { path = "../givenergy-modbus", editable = true } +givenergy-modbus = { git = "https://github.com/dewet22/givenergy-modbus", branch = "main" } [tool.pytest.ini_options] asyncio_mode = "auto" diff --git a/uv.lock b/uv.lock index 91f7253..9117119 100644 --- a/uv.lock +++ b/uv.lock @@ -1930,7 +1930,7 @@ dev = [ ] [package.metadata] -requires-dist = [{ name = "givenergy-modbus", editable = "../givenergy-modbus" }] +requires-dist = [{ name = "givenergy-modbus", git = "https://github.com/dewet22/givenergy-modbus?branch=main" }] [package.metadata.requires-dev] dev = [ @@ -1944,7 +1944,7 @@ dev = [ [[package]] name = "givenergy-modbus" version = "1.3.0" -source = { editable = "../givenergy-modbus" } +source = { git = "https://github.com/dewet22/givenergy-modbus?branch=main#7ca3acde5d363b9b0a73956c41a11850ebb61350" } dependencies = [ { name = "crccheck" }, { name = "pydantic", version = "2.10.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13.2'" }, @@ -1952,40 +1952,6 @@ dependencies = [ { name = "pydantic", version = "2.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14.2'" }, ] -[package.metadata] -requires-dist = [ - { name = "crccheck", specifier = ">=1.1,<2" }, - { name = "pydantic", specifier = ">=2.0,<3" }, -] - -[package.metadata.requires-dev] -dev = [ - { name = "ipython", specifier = ">=9.0.0,<10" }, - { name = "pip", specifier = ">=26.0.0,<27" }, - { name = "pyyaml", specifier = ">=6.0.2" }, - { name = "twine", specifier = ">=6.0.0,<7" }, - { name = "virtualenv", specifier = ">=21.0.0,<22" }, -] -docs = [ - { name = "mkdocs", specifier = ">=1.6.0,<2" }, - { name = "mkdocs-autorefs", specifier = ">=1.0.0,<2" }, - { name = "mkdocs-include-markdown-plugin", specifier = ">=7.0.0,<8" }, - { name = "mkdocs-material", specifier = ">=9.5.0,<10" }, - { name = "mkdocs-material-extensions", specifier = ">=1.3.0,<2" }, - { name = "mkdocstrings", extras = ["python"], specifier = ">=1.0.0,<2" }, -] -test = [ - { name = "bandit", specifier = ">=1.7.5,<2" }, - { name = "mypy", specifier = ">=2.0.0,<3" }, - { name = "pytest", specifier = ">=9.0.0,<10" }, - { name = "pytest-asyncio", specifier = ">=1.0.0,<2" }, - { name = "pytest-cov", specifier = ">=7.0.0,<8" }, - { name = "pytest-timeout", specifier = ">=2.4.0,<3" }, - { name = "ruff", specifier = ">=0.15.0,<1" }, - { name = "tox", specifier = ">=4.4.8,<5" }, - { name = "tox-uv", specifier = ">=1.0.0" }, -] - [[package]] name = "greenlet" version = "3.5.0" From a21effe0c9330e3f1361e0c43217f089d9333a8b Mon Sep 17 00:00:00 2001 From: Dewet Diener Date: Thu, 14 May 2026 07:57:51 +0100 Subject: [PATCH 3/7] fix: replace deprecated Inverter alias with SinglePhaseInverter givenergy-modbus renamed Inverter to SinglePhaseInverter; the alias still works but emits a deprecation warning and will be removed. Co-Authored-By: Claude Sonnet 4.6 --- custom_components/givenergy_local/number.py | 4 ++-- custom_components/givenergy_local/select.py | 4 ++-- custom_components/givenergy_local/sensor.py | 4 ++-- custom_components/givenergy_local/switch.py | 4 ++-- custom_components/givenergy_local/time.py | 4 ++-- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/custom_components/givenergy_local/number.py b/custom_components/givenergy_local/number.py index feb230c..89f2fdd 100644 --- a/custom_components/givenergy_local/number.py +++ b/custom_components/givenergy_local/number.py @@ -4,7 +4,7 @@ from dataclasses import dataclass, field from givenergy_modbus.client import commands -from givenergy_modbus.model.inverter import Inverter +from givenergy_modbus.model.inverter import SinglePhaseInverter from homeassistant.components.number import NumberEntity, NumberEntityDescription, NumberMode from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE @@ -19,7 +19,7 @@ @dataclass(frozen=True, kw_only=True) class GivEnergyNumberEntityDescription(NumberEntityDescription): - value_fn: Callable[[Inverter], float | None] = field(default=lambda _: None) + value_fn: Callable[[SinglePhaseInverter], float | None] = field(default=lambda _: None) set_value_cmd: Callable[[float], list] = field(default=lambda _: []) diff --git a/custom_components/givenergy_local/select.py b/custom_components/givenergy_local/select.py index 25fb249..4075d7c 100644 --- a/custom_components/givenergy_local/select.py +++ b/custom_components/givenergy_local/select.py @@ -4,7 +4,7 @@ from dataclasses import dataclass, field from givenergy_modbus.client import commands -from givenergy_modbus.model.inverter import BatteryPowerMode, Inverter +from givenergy_modbus.model.inverter import BatteryPowerMode, SinglePhaseInverter from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -18,7 +18,7 @@ @dataclass(frozen=True, kw_only=True) class GivEnergySelectEntityDescription(SelectEntityDescription): - current_option_fn: Callable[[Inverter], str | None] = field(default=lambda _: None) + current_option_fn: Callable[[SinglePhaseInverter], str | None] = field(default=lambda _: None) select_option_cmd: Callable[[str], list] = field(default=lambda _: []) diff --git a/custom_components/givenergy_local/sensor.py b/custom_components/givenergy_local/sensor.py index f093d1f..8d29075 100644 --- a/custom_components/givenergy_local/sensor.py +++ b/custom_components/givenergy_local/sensor.py @@ -5,7 +5,7 @@ from typing import Any from givenergy_modbus.model.battery import Battery -from givenergy_modbus.model.inverter import BatteryType, Inverter, MeterType, Model, Status +from givenergy_modbus.model.inverter import BatteryType, MeterType, Model, SinglePhaseInverter, Status from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -34,7 +34,7 @@ @dataclass(frozen=True, kw_only=True) class GivEnergyInverterSensorDescription(SensorEntityDescription): - value_fn: Callable[[Inverter], Any] = field(default=lambda _: None) + value_fn: Callable[[SinglePhaseInverter], Any] = field(default=lambda _: None) @dataclass(frozen=True, kw_only=True) diff --git a/custom_components/givenergy_local/switch.py b/custom_components/givenergy_local/switch.py index 256f64e..493d74a 100644 --- a/custom_components/givenergy_local/switch.py +++ b/custom_components/givenergy_local/switch.py @@ -5,7 +5,7 @@ from typing import Any from givenergy_modbus.client import commands -from givenergy_modbus.model.inverter import Inverter +from givenergy_modbus.model.inverter import SinglePhaseInverter from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -19,7 +19,7 @@ @dataclass(frozen=True, kw_only=True) class GivEnergySwitchEntityDescription(SwitchEntityDescription): - is_on_fn: Callable[[Inverter], bool] = field(default=lambda _: False) + is_on_fn: Callable[[SinglePhaseInverter], bool] = field(default=lambda _: False) turn_on_cmd: Callable[[], list] = field(default=list) turn_off_cmd: Callable[[], list] = field(default=list) diff --git a/custom_components/givenergy_local/time.py b/custom_components/givenergy_local/time.py index ded8a25..60f6c65 100644 --- a/custom_components/givenergy_local/time.py +++ b/custom_components/givenergy_local/time.py @@ -6,7 +6,7 @@ from givenergy_modbus.client import commands from givenergy_modbus.model import TimeSlot -from givenergy_modbus.model.inverter import Inverter +from givenergy_modbus.model.inverter import SinglePhaseInverter from homeassistant.components.time import TimeEntity, TimeEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -20,7 +20,7 @@ @dataclass(frozen=True, kw_only=True) class GivEnergyTimeEntityDescription(TimeEntityDescription): - slot_fn: Callable[[Inverter], TimeSlot | None] = field(default=lambda _: None) + slot_fn: Callable[[SinglePhaseInverter], TimeSlot | None] = field(default=lambda _: None) is_start: bool = True set_slot_cmd: Callable[[TimeSlot], list] = field(default=lambda _: []) From d11a8fd8c633194fe9f48902a1a16d059feb4db1 Mon Sep 17 00:00:00 2001 From: Dewet Diener Date: Thu, 14 May 2026 08:15:29 +0100 Subject: [PATCH 4/7] feat: expose reboot and calibrate battery SOC as HA services Dangerous one-shot commands are registered as domain services rather than button entities so they only appear in Developer Tools and cannot be accidentally triggered from a dashboard. Co-Authored-By: Claude Sonnet 4.6 --- custom_components/givenergy_local/__init__.py | 30 ++++++++++++++++++- custom_components/givenergy_local/const.py | 3 ++ .../givenergy_local/services.yaml | 12 ++++++++ 3 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 custom_components/givenergy_local/services.yaml diff --git a/custom_components/givenergy_local/__init__.py b/custom_components/givenergy_local/__init__.py index c10965d..4ac7205 100644 --- a/custom_components/givenergy_local/__init__.py +++ b/custom_components/givenergy_local/__init__.py @@ -1,8 +1,9 @@ from __future__ import annotations +from givenergy_modbus.client import commands from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceCall from .const import ( CONF_MAX_BATTERIES, @@ -15,10 +16,16 @@ DEFAULT_TIMEOUT_TOLERANCE, DOMAIN, PLATFORMS, + SERVICE_CALIBRATE_BATTERY_SOC, + SERVICE_REBOOT_INVERTER, ) from .coordinator import GivEnergyUpdateCoordinator +def _coordinators(hass: HomeAssistant) -> list[GivEnergyUpdateCoordinator]: + return list(hass.data.get(DOMAIN, {}).values()) + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator = GivEnergyUpdateCoordinator( hass=hass, @@ -35,6 +42,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + if not hass.services.has_service(DOMAIN, SERVICE_REBOOT_INVERTER): + + async def handle_reboot_inverter(_call: ServiceCall) -> None: + for c in _coordinators(hass): + if c._client and c._client.connected: + await c._client.one_shot_command(commands.set_inverter_reboot()) + + async def handle_calibrate_battery_soc(_call: ServiceCall) -> None: + for c in _coordinators(hass): + if c._client and c._client.connected: + await c._client.one_shot_command(commands.set_calibrate_battery_soc()) + + hass.services.async_register(DOMAIN, SERVICE_REBOOT_INVERTER, handle_reboot_inverter) + hass.services.async_register(DOMAIN, SERVICE_CALIBRATE_BATTERY_SOC, handle_calibrate_battery_soc) + return True @@ -43,4 +66,9 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if unload_ok: coordinator: GivEnergyUpdateCoordinator = hass.data[DOMAIN].pop(entry.entry_id) await coordinator.async_close() + + if not hass.data.get(DOMAIN): + hass.services.async_remove(DOMAIN, SERVICE_REBOOT_INVERTER) + hass.services.async_remove(DOMAIN, SERVICE_CALIBRATE_BATTERY_SOC) + return unload_ok diff --git a/custom_components/givenergy_local/const.py b/custom_components/givenergy_local/const.py index e623a3f..8e1056f 100644 --- a/custom_components/givenergy_local/const.py +++ b/custom_components/givenergy_local/const.py @@ -13,3 +13,6 @@ DEFAULT_TIMEOUT_TOLERANCE = 5 PLATFORMS = ["sensor", "switch", "number", "select", "time"] + +SERVICE_REBOOT_INVERTER = "reboot_inverter" +SERVICE_CALIBRATE_BATTERY_SOC = "calibrate_battery_soc" diff --git a/custom_components/givenergy_local/services.yaml b/custom_components/givenergy_local/services.yaml new file mode 100644 index 0000000..7423ec3 --- /dev/null +++ b/custom_components/givenergy_local/services.yaml @@ -0,0 +1,12 @@ +reboot_inverter: + name: Reboot inverter + description: > + Restarts the GivEnergy inverter. The inverter will be offline for approximately + 30 seconds. Only use this if the inverter is in an unresponsive state. + +calibrate_battery_soc: + name: Calibrate battery state of charge + description: > + Instructs the inverter to recalibrate its battery state-of-charge estimation. + This triggers a full charge/discharge cycle and should only be run when advised + by GivEnergy support or as part of routine battery maintenance. From 9eec629ee6a355235cbf8025c8bbb8631a716161 Mon Sep 17 00:00:00 2001 From: Dewet Diener Date: Thu, 14 May 2026 08:19:44 +0100 Subject: [PATCH 5/7] fix: require explicit device_id on reboot and calibrate services Prevents accidental multi-inverter firing by resolving a required device_id field to a single coordinator via the device registry. Services.yaml gains a device selector filtered to this integration. Co-Authored-By: Claude Sonnet 4.6 --- custom_components/givenergy_local/__init__.py | 46 +++++++++++++------ .../givenergy_local/services.yaml | 16 +++++++ 2 files changed, 48 insertions(+), 14 deletions(-) diff --git a/custom_components/givenergy_local/__init__.py b/custom_components/givenergy_local/__init__.py index 4ac7205..9d70f68 100644 --- a/custom_components/givenergy_local/__init__.py +++ b/custom_components/givenergy_local/__init__.py @@ -1,9 +1,11 @@ from __future__ import annotations +import voluptuous as vol from givenergy_modbus.client import commands from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.helpers import config_validation as cv, device_registry as dr from .const import ( CONF_MAX_BATTERIES, @@ -22,8 +24,20 @@ from .coordinator import GivEnergyUpdateCoordinator -def _coordinators(hass: HomeAssistant) -> list[GivEnergyUpdateCoordinator]: - return list(hass.data.get(DOMAIN, {}).values()) +SERVICE_DEVICE_SCHEMA = vol.Schema({vol.Required("device_id"): cv.string}) + + +def _coordinator_for_device( + hass: HomeAssistant, device_id: str +) -> GivEnergyUpdateCoordinator | None: + device = dr.async_get(hass).async_get(device_id) + if device is None: + return None + for entry_id in device.config_entries: + coordinator = hass.data.get(DOMAIN, {}).get(entry_id) + if coordinator is not None: + return coordinator + return None async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -45,18 +59,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if not hass.services.has_service(DOMAIN, SERVICE_REBOOT_INVERTER): - async def handle_reboot_inverter(_call: ServiceCall) -> None: - for c in _coordinators(hass): - if c._client and c._client.connected: - await c._client.one_shot_command(commands.set_inverter_reboot()) - - async def handle_calibrate_battery_soc(_call: ServiceCall) -> None: - for c in _coordinators(hass): - if c._client and c._client.connected: - await c._client.one_shot_command(commands.set_calibrate_battery_soc()) - - hass.services.async_register(DOMAIN, SERVICE_REBOOT_INVERTER, handle_reboot_inverter) - hass.services.async_register(DOMAIN, SERVICE_CALIBRATE_BATTERY_SOC, handle_calibrate_battery_soc) + async def handle_reboot_inverter(call: ServiceCall) -> None: + c = _coordinator_for_device(hass, call.data["device_id"]) + if c and c._client and c._client.connected: + await c._client.one_shot_command(commands.set_inverter_reboot()) + + async def handle_calibrate_battery_soc(call: ServiceCall) -> None: + c = _coordinator_for_device(hass, call.data["device_id"]) + if c and c._client and c._client.connected: + await c._client.one_shot_command(commands.set_calibrate_battery_soc()) + + hass.services.async_register( + DOMAIN, SERVICE_REBOOT_INVERTER, handle_reboot_inverter, SERVICE_DEVICE_SCHEMA + ) + hass.services.async_register( + DOMAIN, SERVICE_CALIBRATE_BATTERY_SOC, handle_calibrate_battery_soc, SERVICE_DEVICE_SCHEMA + ) return True diff --git a/custom_components/givenergy_local/services.yaml b/custom_components/givenergy_local/services.yaml index 7423ec3..2b55796 100644 --- a/custom_components/givenergy_local/services.yaml +++ b/custom_components/givenergy_local/services.yaml @@ -3,6 +3,14 @@ reboot_inverter: description: > Restarts the GivEnergy inverter. The inverter will be offline for approximately 30 seconds. Only use this if the inverter is in an unresponsive state. + fields: + device_id: + name: Device + description: The GivEnergy inverter to reboot. + required: true + selector: + device: + integration: givenergy_local calibrate_battery_soc: name: Calibrate battery state of charge @@ -10,3 +18,11 @@ calibrate_battery_soc: Instructs the inverter to recalibrate its battery state-of-charge estimation. This triggers a full charge/discharge cycle and should only be run when advised by GivEnergy support or as part of routine battery maintenance. + fields: + device_id: + name: Device + description: The GivEnergy inverter to calibrate. + required: true + selector: + device: + integration: givenergy_local From e68adb9298161522bd03dd0b416198d70dcbbe17 Mon Sep 17 00:00:00 2001 From: Dewet Diener Date: Thu, 14 May 2026 23:09:58 +0100 Subject: [PATCH 6/7] refactor: adopt givenergy-modbus 2.x APIs; drop max_batteries setting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Call client.detect() once on connect (coordinator + config flow) so refresh_plant() dispatches via the new model-aware load_config()/refresh() path. This unlocks support for three-phase, AIO-HV, EMS and other non-default topologies. - Replace the deprecated Inverter import with a shared InverterModel = SinglePhaseInverter | ThreePhaseInverter alias defined in coordinator.py. - Rebuild the time platform around commands.set_charge_slot / set_discharge_slot using plant.inverter.slot_map, replacing the eight deprecated set_charge_slot_N / set_discharge_slot_N wrappers. - Remove the max_batteries config option entirely — capabilities.lv_battery_addresses drives battery enumeration now, so the user-facing field, schema entry, coordinator parameter and translations are all dead weight. --- custom_components/givenergy_local/__init__.py | 12 ++-- .../givenergy_local/config_flow.py | 9 +-- custom_components/givenergy_local/const.py | 2 - .../givenergy_local/coordinator.py | 22 ++++---- custom_components/givenergy_local/number.py | 5 +- custom_components/givenergy_local/select.py | 6 +- custom_components/givenergy_local/sensor.py | 6 +- .../givenergy_local/strings.json | 2 - custom_components/givenergy_local/switch.py | 5 +- custom_components/givenergy_local/time.py | 44 ++++++++++----- .../givenergy_local/translations/en.json | 2 - tests/conftest.py | 10 +++- tests/test_config_flow.py | 12 ++-- tests/test_coordinator.py | 56 +++++++++---------- uv.lock | 2 +- 15 files changed, 101 insertions(+), 94 deletions(-) diff --git a/custom_components/givenergy_local/__init__.py b/custom_components/givenergy_local/__init__.py index 9d70f68..6df0089 100644 --- a/custom_components/givenergy_local/__init__.py +++ b/custom_components/givenergy_local/__init__.py @@ -5,14 +5,13 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.helpers import config_validation as cv, device_registry as dr +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import device_registry as dr from .const import ( - CONF_MAX_BATTERIES, CONF_PASSIVE, CONF_SCAN_INTERVAL, CONF_TIMEOUT_TOLERANCE, - DEFAULT_MAX_BATTERIES, DEFAULT_PASSIVE, DEFAULT_SCAN_INTERVAL, DEFAULT_TIMEOUT_TOLERANCE, @@ -23,7 +22,6 @@ ) from .coordinator import GivEnergyUpdateCoordinator - SERVICE_DEVICE_SCHEMA = vol.Schema({vol.Required("device_id"): cv.string}) @@ -46,7 +44,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: host=entry.data[CONF_HOST], port=entry.data[CONF_PORT], scan_interval=entry.data.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL), - max_batteries=entry.data.get(CONF_MAX_BATTERIES, DEFAULT_MAX_BATTERIES), passive=entry.data.get(CONF_PASSIVE, DEFAULT_PASSIVE), timeout_tolerance=entry.data.get(CONF_TIMEOUT_TOLERANCE, DEFAULT_TIMEOUT_TOLERANCE), ) @@ -73,7 +70,10 @@ async def handle_calibrate_battery_soc(call: ServiceCall) -> None: DOMAIN, SERVICE_REBOOT_INVERTER, handle_reboot_inverter, SERVICE_DEVICE_SCHEMA ) hass.services.async_register( - DOMAIN, SERVICE_CALIBRATE_BATTERY_SOC, handle_calibrate_battery_soc, SERVICE_DEVICE_SCHEMA + DOMAIN, + SERVICE_CALIBRATE_BATTERY_SOC, + handle_calibrate_battery_soc, + SERVICE_DEVICE_SCHEMA, ) return True diff --git a/custom_components/givenergy_local/config_flow.py b/custom_components/givenergy_local/config_flow.py index ba84fbe..8fd00eb 100644 --- a/custom_components/givenergy_local/config_flow.py +++ b/custom_components/givenergy_local/config_flow.py @@ -9,11 +9,9 @@ from homeassistant.const import CONF_HOST, CONF_PORT from .const import ( - CONF_MAX_BATTERIES, CONF_PASSIVE, CONF_SCAN_INTERVAL, CONF_TIMEOUT_TOLERANCE, - DEFAULT_MAX_BATTERIES, DEFAULT_PASSIVE, DEFAULT_PORT, DEFAULT_SCAN_INTERVAL, @@ -28,7 +26,6 @@ vol.Required(CONF_HOST): str, vol.Required(CONF_PORT, default=DEFAULT_PORT): int, vol.Required(CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL): int, - vol.Required(CONF_MAX_BATTERIES, default=DEFAULT_MAX_BATTERIES): int, vol.Required(CONF_PASSIVE, default=DEFAULT_PASSIVE): bool, vol.Required(CONF_TIMEOUT_TOLERANCE, default=DEFAULT_TIMEOUT_TOLERANCE): int, } @@ -99,7 +96,11 @@ async def _test_connection(self, host: str, port: int) -> tuple[str, str | None] client = Client(host=host, port=port) try: await client.connect() - plant = await client.refresh_plant(full_refresh=False, max_batteries=0) + # detect() resolves the device model and topology before any reads + # so refresh_plant() picks the right register layout (single vs. + # three-phase) from the first request. + await client.detect() + plant = await client.refresh_plant(full_refresh=False) return plant.inverter_serial_number, None except Exception: _LOGGER.exception("Connection test failed for %s:%s", host, port) diff --git a/custom_components/givenergy_local/const.py b/custom_components/givenergy_local/const.py index 8e1056f..7fd53d5 100644 --- a/custom_components/givenergy_local/const.py +++ b/custom_components/givenergy_local/const.py @@ -2,9 +2,7 @@ DEFAULT_PORT = 8899 DEFAULT_SCAN_INTERVAL = 30 -DEFAULT_MAX_BATTERIES = 1 -CONF_MAX_BATTERIES = "max_batteries" CONF_SCAN_INTERVAL = "scan_interval" CONF_PASSIVE = "passive" CONF_TIMEOUT_TOLERANCE = "timeout_tolerance" diff --git a/custom_components/givenergy_local/coordinator.py b/custom_components/givenergy_local/coordinator.py index c41d969..0b761a4 100644 --- a/custom_components/givenergy_local/coordinator.py +++ b/custom_components/givenergy_local/coordinator.py @@ -4,6 +4,8 @@ from datetime import datetime, timedelta from givenergy_modbus.client.client import Client +from givenergy_modbus.model.inverter import SinglePhaseInverter +from givenergy_modbus.model.inverter_threephase import ThreePhaseInverter from givenergy_modbus.model.plant import Plant from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -11,6 +13,8 @@ from .const import DOMAIN +InverterModel = SinglePhaseInverter | ThreePhaseInverter + _LOGGER = logging.getLogger(__name__) # Target interval between full holding-register refreshes in active mode. @@ -26,7 +30,6 @@ def __init__( host: str, port: int, scan_interval: int, - max_batteries: int, passive: bool = False, timeout_tolerance: int = 5, ) -> None: @@ -38,7 +41,6 @@ def __init__( ) self.host = host self.port = port - self.max_batteries = max_batteries self.passive = passive self.timeout_tolerance = timeout_tolerance self._client: Client | None = None @@ -113,10 +115,7 @@ async def _active_update(self) -> Plant: assert self._client is not None # _async_update_data ensures this full_refresh = self._active_tick % self._full_refresh_every == 0 self._active_tick += 1 - await self._client.refresh_plant( - full_refresh=full_refresh, - max_batteries=self.max_batteries, - ) + await self._client.refresh_plant(full_refresh=full_refresh) return self._client.plant async def _passive_update(self, reconnecting: bool) -> Plant: @@ -127,10 +126,7 @@ async def _passive_update(self, reconnecting: bool) -> Plant: """ assert self._client is not None # _async_update_data ensures this if reconnecting: - await self._client.refresh_plant( - full_refresh=True, - max_batteries=self.max_batteries, - ) + await self._client.refresh_plant(full_refresh=True) return self._client.plant plant = self._client.plant @@ -163,9 +159,13 @@ def _check_cache_freshness(self, plant: Plant) -> None: # ------------------------------------------------------------------ async def _connect(self) -> None: - """Open a fresh TCP connection and reset all staleness tracking.""" + """Open a fresh TCP connection, discover topology, and reset staleness tracking.""" self._client = Client(host=self.host, port=self.port) await self._client.connect() + # detect() populates plant.capabilities, which makes subsequent + # refresh_plant() calls dispatch via model-aware load_config()/refresh() + # — required for three-phase, AIO-HV, EMS and other non-default topologies. + await self._client.detect() self._last_inverter_time = None self._unchanged_ticks = 0 self._active_tick = 0 diff --git a/custom_components/givenergy_local/number.py b/custom_components/givenergy_local/number.py index 89f2fdd..4455855 100644 --- a/custom_components/givenergy_local/number.py +++ b/custom_components/givenergy_local/number.py @@ -4,7 +4,6 @@ from dataclasses import dataclass, field from givenergy_modbus.client import commands -from givenergy_modbus.model.inverter import SinglePhaseInverter from homeassistant.components.number import NumberEntity, NumberEntityDescription, NumberMode from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE @@ -14,12 +13,12 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import GivEnergyUpdateCoordinator +from .coordinator import GivEnergyUpdateCoordinator, InverterModel @dataclass(frozen=True, kw_only=True) class GivEnergyNumberEntityDescription(NumberEntityDescription): - value_fn: Callable[[SinglePhaseInverter], float | None] = field(default=lambda _: None) + value_fn: Callable[[InverterModel], float | None] = field(default=lambda _: None) set_value_cmd: Callable[[float], list] = field(default=lambda _: []) diff --git a/custom_components/givenergy_local/select.py b/custom_components/givenergy_local/select.py index 4075d7c..ee867cd 100644 --- a/custom_components/givenergy_local/select.py +++ b/custom_components/givenergy_local/select.py @@ -4,7 +4,7 @@ from dataclasses import dataclass, field from givenergy_modbus.client import commands -from givenergy_modbus.model.inverter import BatteryPowerMode, SinglePhaseInverter +from givenergy_modbus.model.inverter import BatteryPowerMode from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -13,12 +13,12 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import GivEnergyUpdateCoordinator +from .coordinator import GivEnergyUpdateCoordinator, InverterModel @dataclass(frozen=True, kw_only=True) class GivEnergySelectEntityDescription(SelectEntityDescription): - current_option_fn: Callable[[SinglePhaseInverter], str | None] = field(default=lambda _: None) + current_option_fn: Callable[[InverterModel], str | None] = field(default=lambda _: None) select_option_cmd: Callable[[str], list] = field(default=lambda _: []) diff --git a/custom_components/givenergy_local/sensor.py b/custom_components/givenergy_local/sensor.py index 8d29075..f3bce2d 100644 --- a/custom_components/givenergy_local/sensor.py +++ b/custom_components/givenergy_local/sensor.py @@ -5,7 +5,7 @@ from typing import Any from givenergy_modbus.model.battery import Battery -from givenergy_modbus.model.inverter import BatteryType, MeterType, Model, SinglePhaseInverter, Status +from givenergy_modbus.model.inverter import BatteryType, MeterType, Model, Status from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -29,12 +29,12 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import GivEnergyUpdateCoordinator +from .coordinator import GivEnergyUpdateCoordinator, InverterModel @dataclass(frozen=True, kw_only=True) class GivEnergyInverterSensorDescription(SensorEntityDescription): - value_fn: Callable[[SinglePhaseInverter], Any] = field(default=lambda _: None) + value_fn: Callable[[InverterModel], Any] = field(default=lambda _: None) @dataclass(frozen=True, kw_only=True) diff --git a/custom_components/givenergy_local/strings.json b/custom_components/givenergy_local/strings.json index 337198a..eb2a2ed 100644 --- a/custom_components/givenergy_local/strings.json +++ b/custom_components/givenergy_local/strings.json @@ -8,7 +8,6 @@ "host": "Inverter IP Address", "port": "Modbus Port", "scan_interval": "Scan Interval (seconds)", - "max_batteries": "Number of Batteries", "passive": "Passive mode (listen only — another client drives refreshes)", "timeout_tolerance": "Timeout Tolerance (consecutive failures before unavailable)" } @@ -20,7 +19,6 @@ "host": "Inverter IP Address", "port": "Modbus Port", "scan_interval": "Scan Interval (seconds)", - "max_batteries": "Number of Batteries", "passive": "Passive mode (listen only — another client drives refreshes)", "timeout_tolerance": "Timeout Tolerance (consecutive failures before unavailable)" } diff --git a/custom_components/givenergy_local/switch.py b/custom_components/givenergy_local/switch.py index 493d74a..9950707 100644 --- a/custom_components/givenergy_local/switch.py +++ b/custom_components/givenergy_local/switch.py @@ -5,7 +5,6 @@ from typing import Any from givenergy_modbus.client import commands -from givenergy_modbus.model.inverter import SinglePhaseInverter from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -14,12 +13,12 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import GivEnergyUpdateCoordinator +from .coordinator import GivEnergyUpdateCoordinator, InverterModel @dataclass(frozen=True, kw_only=True) class GivEnergySwitchEntityDescription(SwitchEntityDescription): - is_on_fn: Callable[[SinglePhaseInverter], bool] = field(default=lambda _: False) + is_on_fn: Callable[[InverterModel], bool] = field(default=lambda _: False) turn_on_cmd: Callable[[], list] = field(default=list) turn_off_cmd: Callable[[], list] = field(default=list) diff --git a/custom_components/givenergy_local/time.py b/custom_components/givenergy_local/time.py index 60f6c65..586a184 100644 --- a/custom_components/givenergy_local/time.py +++ b/custom_components/givenergy_local/time.py @@ -6,7 +6,6 @@ from givenergy_modbus.client import commands from givenergy_modbus.model import TimeSlot -from givenergy_modbus.model.inverter import SinglePhaseInverter from homeassistant.components.time import TimeEntity, TimeEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -15,14 +14,15 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import GivEnergyUpdateCoordinator +from .coordinator import GivEnergyUpdateCoordinator, InverterModel @dataclass(frozen=True, kw_only=True) class GivEnergyTimeEntityDescription(TimeEntityDescription): - slot_fn: Callable[[SinglePhaseInverter], TimeSlot | None] = field(default=lambda _: None) + slot_fn: Callable[[InverterModel], TimeSlot | None] = field(default=lambda _: None) is_start: bool = True - set_slot_cmd: Callable[[TimeSlot], list] = field(default=lambda _: []) + is_charge: bool = True + slot_index: int = 1 TIME_DESCRIPTIONS: tuple[GivEnergyTimeEntityDescription, ...] = ( @@ -31,7 +31,8 @@ class GivEnergyTimeEntityDescription(TimeEntityDescription): name="Charge Slot 1 Start", slot_fn=lambda inv: inv.charge_slot_1, is_start=True, - set_slot_cmd=lambda ts: commands.set_charge_slot_1(ts), + is_charge=True, + slot_index=1, entity_category=EntityCategory.CONFIG, ), GivEnergyTimeEntityDescription( @@ -39,7 +40,8 @@ class GivEnergyTimeEntityDescription(TimeEntityDescription): name="Charge Slot 1 End", slot_fn=lambda inv: inv.charge_slot_1, is_start=False, - set_slot_cmd=lambda ts: commands.set_charge_slot_1(ts), + is_charge=True, + slot_index=1, entity_category=EntityCategory.CONFIG, ), GivEnergyTimeEntityDescription( @@ -47,7 +49,8 @@ class GivEnergyTimeEntityDescription(TimeEntityDescription): name="Charge Slot 2 Start", slot_fn=lambda inv: inv.charge_slot_2, is_start=True, - set_slot_cmd=lambda ts: commands.set_charge_slot_2(ts), + is_charge=True, + slot_index=2, entity_category=EntityCategory.CONFIG, ), GivEnergyTimeEntityDescription( @@ -55,7 +58,8 @@ class GivEnergyTimeEntityDescription(TimeEntityDescription): name="Charge Slot 2 End", slot_fn=lambda inv: inv.charge_slot_2, is_start=False, - set_slot_cmd=lambda ts: commands.set_charge_slot_2(ts), + is_charge=True, + slot_index=2, entity_category=EntityCategory.CONFIG, ), GivEnergyTimeEntityDescription( @@ -63,7 +67,8 @@ class GivEnergyTimeEntityDescription(TimeEntityDescription): name="Discharge Slot 1 Start", slot_fn=lambda inv: inv.discharge_slot_1, is_start=True, - set_slot_cmd=lambda ts: commands.set_discharge_slot_1(ts), + is_charge=False, + slot_index=1, entity_category=EntityCategory.CONFIG, ), GivEnergyTimeEntityDescription( @@ -71,7 +76,8 @@ class GivEnergyTimeEntityDescription(TimeEntityDescription): name="Discharge Slot 1 End", slot_fn=lambda inv: inv.discharge_slot_1, is_start=False, - set_slot_cmd=lambda ts: commands.set_discharge_slot_1(ts), + is_charge=False, + slot_index=1, entity_category=EntityCategory.CONFIG, ), GivEnergyTimeEntityDescription( @@ -79,7 +85,8 @@ class GivEnergyTimeEntityDescription(TimeEntityDescription): name="Discharge Slot 2 Start", slot_fn=lambda inv: inv.discharge_slot_2, is_start=True, - set_slot_cmd=lambda ts: commands.set_discharge_slot_2(ts), + is_charge=False, + slot_index=2, entity_category=EntityCategory.CONFIG, ), GivEnergyTimeEntityDescription( @@ -87,7 +94,8 @@ class GivEnergyTimeEntityDescription(TimeEntityDescription): name="Discharge Slot 2 End", slot_fn=lambda inv: inv.discharge_slot_2, is_start=False, - set_slot_cmd=lambda ts: commands.set_discharge_slot_2(ts), + is_charge=False, + slot_index=2, entity_category=EntityCategory.CONFIG, ), ) @@ -132,12 +140,20 @@ async def async_set_value(self, value: dt_time) -> None: client = self.coordinator._client if client is None or not client.connected: return - current_slot = self.entity_description.slot_fn(self.coordinator.data.inverter) + inverter = self.coordinator.data.inverter + current_slot = self.entity_description.slot_fn(inverter) if current_slot is None: current_slot = TimeSlot(start=dt_time(0, 0), end=dt_time(0, 0)) if self.entity_description.is_start: new_slot = TimeSlot(start=value, end=current_slot.end) else: new_slot = TimeSlot(start=current_slot.start, end=value) - await client.one_shot_command(self.entity_description.set_slot_cmd(new_slot)) + setter = ( + commands.set_charge_slot + if self.entity_description.is_charge + else commands.set_discharge_slot + ) + await client.one_shot_command( + setter(self.entity_description.slot_index, new_slot, inverter.slot_map) + ) await self.coordinator.async_request_refresh() diff --git a/custom_components/givenergy_local/translations/en.json b/custom_components/givenergy_local/translations/en.json index 337198a..eb2a2ed 100644 --- a/custom_components/givenergy_local/translations/en.json +++ b/custom_components/givenergy_local/translations/en.json @@ -8,7 +8,6 @@ "host": "Inverter IP Address", "port": "Modbus Port", "scan_interval": "Scan Interval (seconds)", - "max_batteries": "Number of Batteries", "passive": "Passive mode (listen only — another client drives refreshes)", "timeout_tolerance": "Timeout Tolerance (consecutive failures before unavailable)" } @@ -20,7 +19,6 @@ "host": "Inverter IP Address", "port": "Modbus Port", "scan_interval": "Scan Interval (seconds)", - "max_batteries": "Number of Batteries", "passive": "Passive mode (listen only — another client drives refreshes)", "timeout_tolerance": "Timeout Tolerance (consecutive failures before unavailable)" } diff --git a/tests/conftest.py b/tests/conftest.py index 5d46438..a6d224b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,7 +5,12 @@ import pytest from givenergy_modbus.model import TimeSlot -from givenergy_modbus.model.inverter import BatteryPowerMode, BatteryType, MeterType +from givenergy_modbus.model.inverter import ( + SINGLE_PHASE_SLOTS, + BatteryPowerMode, + BatteryType, + MeterType, +) from pytest_homeassistant_custom_component.common import MockConfigEntry from custom_components.givenergy_local.const import DOMAIN @@ -67,6 +72,7 @@ def mock_inverter() -> MagicMock: inv.battery_discharge_min_power_reserve = 4 inv.battery_power_mode = BatteryPowerMode.SELF_CONSUMPTION inv.system_time = datetime(2026, 5, 10, 12, 0, 0) + inv.slot_map = SINGLE_PHASE_SLOTS inv.charge_slot_1 = TimeSlot(start=time(0, 30), end=time(4, 30)) inv.charge_slot_2 = TimeSlot(start=time(0, 0), end=time(0, 0)) inv.discharge_slot_1 = TimeSlot(start=time(17, 0), end=time(22, 0)) @@ -148,6 +154,7 @@ def mock_client(mock_plant) -> AsyncMock: client.plant = mock_plant client.refresh_plant = AsyncMock(return_value=mock_plant) client.connect = AsyncMock() + client.detect = AsyncMock() client.close = AsyncMock() client.one_shot_command = AsyncMock() with ( @@ -165,7 +172,6 @@ def mock_config_entry() -> MockConfigEntry: "host": "192.168.1.100", "port": 8899, "scan_interval": 30, - "max_batteries": 1, "passive": False, }, unique_id="SA1234G123", diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index e71507b..1e3dfb1 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -4,7 +4,6 @@ from homeassistant.const import CONF_HOST, CONF_PORT from custom_components.givenergy_local.const import ( - CONF_MAX_BATTERIES, CONF_PASSIVE, CONF_SCAN_INTERVAL, CONF_TIMEOUT_TOLERANCE, @@ -16,7 +15,6 @@ CONF_HOST: "192.168.1.100", CONF_PORT: 8899, CONF_SCAN_INTERVAL: 30, - CONF_MAX_BATTERIES: 1, CONF_PASSIVE: False, CONF_TIMEOUT_TOLERANCE: DEFAULT_TIMEOUT_TOLERANCE, } @@ -99,14 +97,12 @@ async def test_reconfigure_updates_settings_without_retesting_connection( assert setup_integration.data[CONF_SCAN_INTERVAL] == 60 assert setup_integration.data[CONF_PASSIVE] is True - # The post-reload coordinator calls refresh_plant(full_refresh=True, max_batteries=1). + # The post-reload coordinator calls refresh_plant(full_refresh=True). # _test_connection (used only when host/port changes) calls - # refresh_plant(full_refresh=False, max_batteries=0) — neither set of args - # should appear if it wasn't invoked. + # refresh_plant(full_refresh=False) — the latter should not appear if + # the host didn't change. test_connection_calls = [ - c - for c in mock_client.refresh_plant.call_args_list - if c.kwargs == {"full_refresh": False, "max_batteries": 0} + c for c in mock_client.refresh_plant.call_args_list if c.kwargs == {"full_refresh": False} ] assert test_connection_calls == [] diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py index fcba281..ad7b278 100644 --- a/tests/test_coordinator.py +++ b/tests/test_coordinator.py @@ -11,7 +11,7 @@ async def test_first_refresh_connects_and_fetches(hass, mock_client, setup_integration): mock_client.connect.assert_called_once() - mock_client.refresh_plant.assert_called_once_with(full_refresh=True, max_batteries=1) + mock_client.refresh_plant.assert_called_once_with(full_refresh=True) async def test_reconnects_when_disconnected(hass, mock_client, mock_config_entry): @@ -25,7 +25,7 @@ async def test_reconnects_when_disconnected(hass, mock_client, mock_config_entry async def test_update_failed_clears_client(hass, mock_plant): - coordinator = GivEnergyUpdateCoordinator(hass, "192.168.1.1", 8899, 30, 1) + coordinator = GivEnergyUpdateCoordinator(hass, "192.168.1.1", 8899, 30) with patch("custom_components.givenergy_local.coordinator.Client") as mock_cls: client = AsyncMock() @@ -51,7 +51,7 @@ async def test_async_close_closes_client(hass, mock_client, setup_integration): async def test_timeout_raises_update_failed(hass): - coordinator = GivEnergyUpdateCoordinator(hass, "192.168.1.1", 8899, 30, 1) + coordinator = GivEnergyUpdateCoordinator(hass, "192.168.1.1", 8899, 30) with patch("custom_components.givenergy_local.coordinator.Client") as mock_cls: client = AsyncMock() @@ -66,9 +66,7 @@ async def test_timeout_raises_update_failed(hass): async def test_timeout_within_tolerance_preserves_client(hass, mock_plant): """TimeoutError within tolerance keeps the TCP connection open and serves stale data.""" - coordinator = GivEnergyUpdateCoordinator( - hass, "192.168.1.1", 8899, 30, 1, timeout_tolerance=3 - ) + coordinator = GivEnergyUpdateCoordinator(hass, "192.168.1.1", 8899, 30, timeout_tolerance=3) with patch("custom_components.givenergy_local.coordinator.Client") as mock_cls: client = AsyncMock() @@ -87,10 +85,8 @@ async def test_timeout_within_tolerance_preserves_client(hass, mock_plant): async def test_timeout_exceeding_tolerance_resets_client(hass, mock_plant): - """Once consecutive failures exceed tolerance the client is reset so the next tick reconnects.""" - coordinator = GivEnergyUpdateCoordinator( - hass, "192.168.1.1", 8899, 30, 1, timeout_tolerance=2 - ) + """Once consecutive failures exceed tolerance the client is reset for the next tick.""" + coordinator = GivEnergyUpdateCoordinator(hass, "192.168.1.1", 8899, 30, timeout_tolerance=2) with patch("custom_components.givenergy_local.coordinator.Client") as mock_cls: client = AsyncMock() @@ -111,7 +107,7 @@ async def test_timeout_exceeding_tolerance_resets_client(hass, mock_plant): async def test_timeout_increments_consecutive_failures(hass): - coordinator = GivEnergyUpdateCoordinator(hass, "192.168.1.1", 8899, 30, 1) + coordinator = GivEnergyUpdateCoordinator(hass, "192.168.1.1", 8899, 30) with patch("custom_components.givenergy_local.coordinator.Client") as mock_cls: client = AsyncMock() @@ -127,7 +123,7 @@ async def test_timeout_increments_consecutive_failures(hass): async def test_successful_refresh_resets_failure_count(hass, mock_plant): - coordinator = GivEnergyUpdateCoordinator(hass, "192.168.1.1", 8899, 30, 1) + coordinator = GivEnergyUpdateCoordinator(hass, "192.168.1.1", 8899, 30) with patch("custom_components.givenergy_local.coordinator.Client") as mock_cls: client = AsyncMock() @@ -158,7 +154,7 @@ async def test_successful_refresh_resets_failure_count(hass, mock_plant): async def test_total_failures_increments_on_every_failure_type(hass, mock_plant): """Each of the three failure paths (UpdateFailed, TimeoutError, generic Exception) must increment total_failures.""" - coordinator = GivEnergyUpdateCoordinator(hass, "192.168.1.1", 8899, 30, 1) + coordinator = GivEnergyUpdateCoordinator(hass, "192.168.1.1", 8899, 30) with patch("custom_components.givenergy_local.coordinator.Client") as mock_cls: client = AsyncMock() @@ -189,7 +185,7 @@ async def test_total_failures_increments_on_every_failure_type(hass, mock_plant) async def test_non_timeout_error_closes_client(hass): """Non-timeout errors (e.g. connection drop) should reset the client.""" - coordinator = GivEnergyUpdateCoordinator(hass, "192.168.1.1", 8899, 30, 1) + coordinator = GivEnergyUpdateCoordinator(hass, "192.168.1.1", 8899, 30) with patch("custom_components.givenergy_local.coordinator.Client") as mock_cls: client = AsyncMock() @@ -207,7 +203,7 @@ async def test_non_timeout_error_closes_client(hass): async def test_passive_mode_initial_connect_does_full_refresh(hass, mock_plant): """Even in passive mode the first connect must seed the cache with a full refresh.""" - coordinator = GivEnergyUpdateCoordinator(hass, "192.168.1.1", 8899, 30, 1, passive=True) + coordinator = GivEnergyUpdateCoordinator(hass, "192.168.1.1", 8899, 30, passive=True) with patch("custom_components.givenergy_local.coordinator.Client") as mock_cls: client = AsyncMock() @@ -218,12 +214,12 @@ async def test_passive_mode_initial_connect_does_full_refresh(hass, mock_plant): await coordinator._async_update_data() - client.refresh_plant.assert_called_once_with(full_refresh=True, max_batteries=1) + client.refresh_plant.assert_called_once_with(full_refresh=True) async def test_passive_mode_skips_refresh_on_subsequent_ticks(hass, mock_plant): """After the initial connect, passive mode must not send any Modbus requests.""" - coordinator = GivEnergyUpdateCoordinator(hass, "192.168.1.1", 8899, 30, 1, passive=True) + coordinator = GivEnergyUpdateCoordinator(hass, "192.168.1.1", 8899, 30, passive=True) with patch("custom_components.givenergy_local.coordinator.Client") as mock_cls: client = AsyncMock() @@ -246,7 +242,7 @@ async def test_passive_mode_skips_refresh_on_subsequent_ticks(hass, mock_plant): async def test_passive_mode_reconnect_does_full_refresh(hass, mock_plant): """If the connection drops in passive mode, reconnecting must re-seed the cache.""" - coordinator = GivEnergyUpdateCoordinator(hass, "192.168.1.1", 8899, 30, 1, passive=True) + coordinator = GivEnergyUpdateCoordinator(hass, "192.168.1.1", 8899, 30, passive=True) with patch("custom_components.givenergy_local.coordinator.Client") as mock_cls: client = AsyncMock() @@ -257,12 +253,12 @@ async def test_passive_mode_reconnect_does_full_refresh(hass, mock_plant): await coordinator._async_update_data() - client.refresh_plant.assert_called_once_with(full_refresh=True, max_batteries=1) + client.refresh_plant.assert_called_once_with(full_refresh=True) async def test_active_mode_always_refreshes(hass, mock_plant): """In active (default) mode every tick issues a refresh_plant request.""" - coordinator = GivEnergyUpdateCoordinator(hass, "192.168.1.1", 8899, 30, 1, passive=False) + coordinator = GivEnergyUpdateCoordinator(hass, "192.168.1.1", 8899, 30, passive=False) with patch("custom_components.givenergy_local.coordinator.Client") as mock_cls: client = AsyncMock() @@ -280,7 +276,7 @@ async def test_active_mode_always_refreshes(hass, mock_plant): async def test_active_mode_first_tick_is_full_refresh(hass, mock_plant): """Tick 0 must always be a full refresh regardless of interval.""" - coordinator = GivEnergyUpdateCoordinator(hass, "192.168.1.1", 8899, 30, 1, passive=False) + coordinator = GivEnergyUpdateCoordinator(hass, "192.168.1.1", 8899, 30, passive=False) with patch("custom_components.givenergy_local.coordinator.Client") as mock_cls: client = AsyncMock() @@ -292,13 +288,13 @@ async def test_active_mode_first_tick_is_full_refresh(hass, mock_plant): await coordinator._async_update_data() - client.refresh_plant.assert_called_once_with(full_refresh=True, max_batteries=1) + client.refresh_plant.assert_called_once_with(full_refresh=True) async def test_active_mode_intermediate_ticks_are_partial(hass, mock_plant): """Ticks 1 … (n-1) must use full_refresh=False.""" # scan_interval=30 → _full_refresh_every = round(300/30) = 10 - coordinator = GivEnergyUpdateCoordinator(hass, "192.168.1.1", 8899, 30, 1, passive=False) + coordinator = GivEnergyUpdateCoordinator(hass, "192.168.1.1", 8899, 30, passive=False) with patch("custom_components.givenergy_local.coordinator.Client") as mock_cls: client = AsyncMock() @@ -320,7 +316,7 @@ async def test_active_mode_intermediate_ticks_are_partial(hass, mock_plant): async def test_active_mode_nth_tick_is_full_refresh(hass, mock_plant): """Every _full_refresh_every ticks a full refresh must be issued again.""" # scan_interval=30 → _full_refresh_every = 10; tick 10 is the next full refresh - coordinator = GivEnergyUpdateCoordinator(hass, "192.168.1.1", 8899, 30, 1, passive=False) + coordinator = GivEnergyUpdateCoordinator(hass, "192.168.1.1", 8899, 30, passive=False) with patch("custom_components.givenergy_local.coordinator.Client") as mock_cls: client = AsyncMock() @@ -342,7 +338,7 @@ async def test_active_mode_nth_tick_is_full_refresh(hass, mock_plant): async def test_active_mode_reconnect_resets_refresh_cycle(hass, mock_plant): """After a reconnect the full-refresh cycle must restart from tick 0.""" - coordinator = GivEnergyUpdateCoordinator(hass, "192.168.1.1", 8899, 30, 1, passive=False) + coordinator = GivEnergyUpdateCoordinator(hass, "192.168.1.1", 8899, 30, passive=False) with patch("custom_components.givenergy_local.coordinator.Client") as mock_cls: client = AsyncMock() @@ -368,7 +364,7 @@ async def test_active_mode_reconnect_resets_refresh_cycle(hass, mock_plant): async def test_passive_stale_cache_raises_after_two_unchanged_ticks(hass, mock_plant): """Cache is only considered stale after two consecutive unchanged ticks.""" - coordinator = GivEnergyUpdateCoordinator(hass, "192.168.1.1", 8899, 30, 1, passive=True) + coordinator = GivEnergyUpdateCoordinator(hass, "192.168.1.1", 8899, 30, passive=True) fixed_time = datetime(2026, 5, 10, 12, 0, 0) mock_plant.inverter.system_time = fixed_time @@ -389,7 +385,7 @@ async def test_passive_stale_cache_raises_after_two_unchanged_ticks(hass, mock_p async def test_passive_one_unchanged_tick_is_tolerated(hass, mock_plant): """A single unchanged tick is allowed before the stale error is raised.""" - coordinator = GivEnergyUpdateCoordinator(hass, "192.168.1.1", 8899, 30, 1, passive=True) + coordinator = GivEnergyUpdateCoordinator(hass, "192.168.1.1", 8899, 30, passive=True) fixed_time = datetime(2026, 5, 10, 12, 0, 0) mock_plant.inverter.system_time = fixed_time @@ -408,7 +404,7 @@ async def test_passive_one_unchanged_tick_is_tolerated(hass, mock_plant): async def test_passive_advancing_system_time_succeeds(hass, mock_plant): """If system_time advances the cache is live and no error is raised.""" - coordinator = GivEnergyUpdateCoordinator(hass, "192.168.1.1", 8899, 30, 1, passive=True) + coordinator = GivEnergyUpdateCoordinator(hass, "192.168.1.1", 8899, 30, passive=True) with patch("custom_components.givenergy_local.coordinator.Client") as mock_cls: client = AsyncMock() @@ -428,7 +424,7 @@ async def test_passive_advancing_system_time_succeeds(hass, mock_plant): async def test_passive_reconnect_resets_stale_detection(hass, mock_plant): """Reconnecting clears _last_inverter_time so the first passive tick never fires stale.""" - coordinator = GivEnergyUpdateCoordinator(hass, "192.168.1.1", 8899, 30, 1, passive=True) + coordinator = GivEnergyUpdateCoordinator(hass, "192.168.1.1", 8899, 30, passive=True) fixed_time = datetime(2026, 5, 10, 12, 0, 0) mock_plant.inverter.system_time = fixed_time coordinator._last_inverter_time = fixed_time # same as what the plant will return @@ -447,7 +443,7 @@ async def test_passive_reconnect_resets_stale_detection(hass, mock_plant): async def test_passive_none_system_time_skips_stale_check(hass, mock_plant): """If system_time is None (register not yet populated) the stale check is skipped.""" - coordinator = GivEnergyUpdateCoordinator(hass, "192.168.1.1", 8899, 30, 1, passive=True) + coordinator = GivEnergyUpdateCoordinator(hass, "192.168.1.1", 8899, 30, passive=True) mock_plant.inverter.system_time = None with patch("custom_components.givenergy_local.coordinator.Client") as mock_cls: diff --git a/uv.lock b/uv.lock index 9117119..1fda153 100644 --- a/uv.lock +++ b/uv.lock @@ -1944,7 +1944,7 @@ dev = [ [[package]] name = "givenergy-modbus" version = "1.3.0" -source = { git = "https://github.com/dewet22/givenergy-modbus?branch=main#7ca3acde5d363b9b0a73956c41a11850ebb61350" } +source = { git = "https://github.com/dewet22/givenergy-modbus?branch=main#04ef415d2bb3f019aa436c86324803c0f7109713" } dependencies = [ { name = "crccheck" }, { name = "pydantic", version = "2.10.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13.2'" }, From 80a2e4058b5b82815a055c7b37b4d43e4ff10ab5 Mon Sep 17 00:00:00 2001 From: Dewet Diener Date: Thu, 14 May 2026 23:33:20 +0100 Subject: [PATCH 7/7] fix: raise HomeAssistantError when service target is unavailable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both reboot_inverter and calibrate_battery_soc previously silently no-op'd if the device_id was invalid, the coordinator was missing, or the Modbus client was disconnected — the call appeared to succeed while no command was actually sent. Also add a detect() assertion to the first-refresh test so the topology-discovery step doesn't quietly regress. --- custom_components/givenergy_local/__init__.py | 17 +++++++++++++---- tests/test_coordinator.py | 1 + 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/custom_components/givenergy_local/__init__.py b/custom_components/givenergy_local/__init__.py index 6df0089..a78e612 100644 --- a/custom_components/givenergy_local/__init__.py +++ b/custom_components/givenergy_local/__init__.py @@ -5,6 +5,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv from homeassistant.helpers import device_registry as dr @@ -58,13 +59,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def handle_reboot_inverter(call: ServiceCall) -> None: c = _coordinator_for_device(hass, call.data["device_id"]) - if c and c._client and c._client.connected: - await c._client.one_shot_command(commands.set_inverter_reboot()) + if c is None or c._client is None or not c._client.connected: + raise HomeAssistantError( + f"GivEnergy inverter for device {call.data['device_id']!r} " + "is not currently connected" + ) + await c._client.one_shot_command(commands.set_inverter_reboot()) async def handle_calibrate_battery_soc(call: ServiceCall) -> None: c = _coordinator_for_device(hass, call.data["device_id"]) - if c and c._client and c._client.connected: - await c._client.one_shot_command(commands.set_calibrate_battery_soc()) + if c is None or c._client is None or not c._client.connected: + raise HomeAssistantError( + f"GivEnergy inverter for device {call.data['device_id']!r} " + "is not currently connected" + ) + await c._client.one_shot_command(commands.set_calibrate_battery_soc()) hass.services.async_register( DOMAIN, SERVICE_REBOOT_INVERTER, handle_reboot_inverter, SERVICE_DEVICE_SCHEMA diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py index ad7b278..21bfa15 100644 --- a/tests/test_coordinator.py +++ b/tests/test_coordinator.py @@ -11,6 +11,7 @@ async def test_first_refresh_connects_and_fetches(hass, mock_client, setup_integration): mock_client.connect.assert_called_once() + mock_client.detect.assert_called_once() mock_client.refresh_plant.assert_called_once_with(full_refresh=True)