diff --git a/custom_components/givenergy_local/__init__.py b/custom_components/givenergy_local/__init__.py index c10965d..a78e612 100644 --- a/custom_components/givenergy_local/__init__.py +++ b/custom_components/givenergy_local/__init__.py @@ -1,23 +1,43 @@ 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 +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 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, DOMAIN, PLATFORMS, + SERVICE_CALIBRATE_BATTERY_SOC, + SERVICE_REBOOT_INVERTER, ) from .coordinator import GivEnergyUpdateCoordinator +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: coordinator = GivEnergyUpdateCoordinator( @@ -25,7 +45,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), ) @@ -35,6 +54,37 @@ 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: + c = _coordinator_for_device(hass, call.data["device_id"]) + 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 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 + ) + hass.services.async_register( + DOMAIN, + SERVICE_CALIBRATE_BATTERY_SOC, + handle_calibrate_battery_soc, + SERVICE_DEVICE_SCHEMA, + ) + return True @@ -43,4 +93,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/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 e623a3f..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" @@ -13,3 +11,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/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/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/custom_components/givenergy_local/number.py b/custom_components/givenergy_local/number.py index feb230c..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 Inverter 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[[Inverter], 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 25fb249..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, Inverter +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[[Inverter], 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 f093d1f..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, Inverter, MeterType, Model, 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[[Inverter], 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/services.yaml b/custom_components/givenergy_local/services.yaml new file mode 100644 index 0000000..2b55796 --- /dev/null +++ b/custom_components/givenergy_local/services.yaml @@ -0,0 +1,28 @@ +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. + 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 + 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. + fields: + device_id: + name: Device + description: The GivEnergy inverter to calibrate. + required: true + selector: + device: + integration: givenergy_local 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 256f64e..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 Inverter 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[[Inverter], 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 ded8a25..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 Inverter 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[[Inverter], 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/pyproject.toml b/pyproject.toml index 7472d17..7cbe941 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 = { git = "https://github.com/dewet22/givenergy-modbus", branch = "main" } + [tool.pytest.ini_options] asyncio_mode = "auto" 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..21bfa15 100644 --- a/tests/test_coordinator.py +++ b/tests/test_coordinator.py @@ -11,7 +11,8 @@ 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.detect.assert_called_once() + 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 +26,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 +52,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 +67,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 +86,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 +108,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 +124,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 +155,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 +186,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 +204,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 +215,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 +243,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 +254,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 +277,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 +289,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 +317,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 +339,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 +365,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 +386,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 +405,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 +425,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 +444,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 8cdc842..1fda153 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", git = "https://github.com/dewet22/givenergy-modbus?branch=main" }] [package.metadata.requires-dev] dev = [ @@ -1944,17 +1944,13 @@ dev = [ [[package]] name = "givenergy-modbus" version = "1.3.0" -source = { registry = "https://pypi.org/simple" } +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'" }, { 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]] name = "greenlet"