diff --git a/custom_components/givenergy_local/manifest.json b/custom_components/givenergy_local/manifest.json index 189d8f9..a8de978 100644 --- a/custom_components/givenergy_local/manifest.json +++ b/custom_components/givenergy_local/manifest.json @@ -13,7 +13,7 @@ "iot_class": "local_polling", "issue_tracker": "https://github.com/dewet22/givenergy-hass/issues", "requirements": [ - "givenergy-modbus>=2.1.0b14,<3.0.0" + "givenergy-modbus>=2.1.0b15,<3.0.0" ], "version": "1.1.0" } diff --git a/custom_components/givenergy_local/time.py b/custom_components/givenergy_local/time.py index 2ada9bc..8d9e990 100644 --- a/custom_components/givenergy_local/time.py +++ b/custom_components/givenergy_local/time.py @@ -132,6 +132,47 @@ def _endpoint_setter(cmd: Callable[[int, dt_time], list], idx: int) -> Callable[ return lambda value: cmd(idx, value) +def _smart_load_slot_getter(idx: int) -> Callable[[InverterModel], TimeSlot | None]: + # Both single- and three-phase models define smart_load_slot_* as optional + # pydantic fields (default None), so direct access is safe today. The getattr + # default is cheap insurance: these entities are created unconditionally, so a + # future model that drops the field reads as None (entity unavailable) instead + # of raising AttributeError. + return lambda inv: getattr(inv, f"smart_load_slot_{idx}", None) + + +def _smart_load_slot_setter( + cmd: Callable[[int, dt_time | None], list], idx: int +) -> Callable[[dt_time, InverterModel], list]: + return lambda value, _inv: cmd(idx, value) + + +def _smart_load_time_descriptions() -> tuple[GivEnergyTimeEntityDescription, ...]: + """Start/end time entities for Smart Load slots 1–10 (HR 554–573).""" + descriptions: list[GivEnergyTimeEntityDescription] = [] + for idx in range(1, 11): + for endpoint, is_start, cmd in ( + ("start", True, commands.set_smart_load_slot_start), + ("end", False, commands.set_smart_load_slot_end), + ): + descriptions.append( + GivEnergyTimeEntityDescription( + key=f"smart_load_slot_{idx}_{endpoint}", + name=f"Smart Load Slot {idx} {endpoint.title()}", + slot_fn=_smart_load_slot_getter(idx), + is_start=is_start, + setter_fn=_smart_load_slot_setter(cmd, idx), + entity_category=EntityCategory.CONFIG, + ) + ) + return tuple(descriptions) + + +SMART_LOAD_TIME_DESCRIPTIONS: tuple[GivEnergyTimeEntityDescription, ...] = ( + _smart_load_time_descriptions() +) + + def _ems_time_descriptions() -> tuple[GivEnergyEmsTimeEntityDescription, ...]: """Start/end time entities for EMS charge, discharge & export slots 1-3.""" descriptions: list[GivEnergyEmsTimeEntityDescription] = [] @@ -168,10 +209,19 @@ async def async_setup_entry( GivEnergyTimeEntity(coordinator, description) for description in TIME_DESCRIPTIONS ] if coordinator.data.ems is not None: + # EMS plant: the controller owns scheduling. Expose its slots and skip the + # inverter-level Smart Load slots — the library only populates HR(554-573) + # on non-EMS inverters, so creating them here would leave a block of + # permanently-unavailable config entities with silent-no-op writes. entities.extend( GivEnergyEmsTimeEntity(coordinator, description) for description in EMS_TIME_DESCRIPTIONS ) + else: + entities.extend( + GivEnergyTimeEntity(coordinator, description) + for description in SMART_LOAD_TIME_DESCRIPTIONS + ) async_add_entities(entities) diff --git a/pyproject.toml b/pyproject.toml index 7471bda..58268da 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ version = "0.0.0" description = "Home Assistant custom component for GivEnergy inverters via local Modbus TCP" requires-python = ">=3.14.2" dependencies = [ - "givenergy-modbus>=2.1.0b14,<3.0.0", + "givenergy-modbus>=2.1.0b15,<3.0.0", ] [dependency-groups] diff --git a/tests/conftest.py b/tests/conftest.py index d10ff12..fa43a57 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -95,6 +95,9 @@ def mock_inverter() -> MagicMock: inv.system_mode = 1 inv.battery_pause_mode = 0 inv.battery_pause_slot_1 = TimeSlot(start=time(0, 0), end=time(0, 0)) + inv.smart_load_slot_1 = TimeSlot(start=time(6, 0), end=time(7, 0)) + for i in range(2, 11): + setattr(inv, f"smart_load_slot_{i}", TimeSlot(start=time(0, 0), end=time(0, 0))) # AC output + power quality inv.v_ac1_output = 240.3 inv.f_ac1_output = 50.01 diff --git a/tests/test_ems.py b/tests/test_ems.py index e0658a8..db2a59c 100644 --- a/tests/test_ems.py +++ b/tests/test_ems.py @@ -80,6 +80,18 @@ async def test_no_ems_entities_for_non_ems_plant(hass, setup_integration): assert _entity_id(hass, "number", "SA1234G123_ems_charge_target_soc_1") is None +async def test_no_smart_load_entities_for_ems_plant(hass, ems_setup): + """Smart Load slots are inverter-level and superseded by the EMS controller. + + The library only populates HR(554-573) on non-EMS inverters, so an EMS plant + must not register them (else a block of unavailable config entities appears). + """ + registry = er.async_get(hass) + entries = er.async_entries_for_config_entry(registry, ems_setup.entry_id) + smart_load = [e for e in entries if e.domain == "time" and "_smart_load_slot_" in e.unique_id] + assert smart_load == [] + + # --------------------------------------------------------------------------- # Initial values (read from coordinator.data.ems) # --------------------------------------------------------------------------- diff --git a/tests/test_time.py b/tests/test_time.py index 1517408..a4d341f 100644 --- a/tests/test_time.py +++ b/tests/test_time.py @@ -1,4 +1,4 @@ -"""Tests for the GivEnergy Local time platform (charge/discharge slots).""" +"""Tests for the GivEnergy Local time platform (charge/discharge + smart load slots).""" from homeassistant.helpers import entity_registry as er @@ -68,6 +68,7 @@ async def test_all_time_slot_entities_created(hass, setup_integration): "discharge_slot_2_end", "battery_pause_slot_start", "battery_pause_slot_end", + *[f"smart_load_slot_{i}_{ep}" for i in range(1, 11) for ep in ("start", "end")], ] for key in expected_keys: entity_id = _entity_id(hass, f"SA1234G123_{key}") @@ -89,3 +90,50 @@ async def test_set_battery_pause_slot_end_sends_command(hass, mock_client, setup "time", "set_value", {"entity_id": entity_id, "time": "15:00:00"}, blocking=True ) mock_client.one_shot_command.assert_called_once() + + +async def test_smart_load_slot_1_start_initial_value(hass, setup_integration): + state = hass.states.get(_entity_id(hass, "SA1234G123_smart_load_slot_1_start")) + assert state.state == "06:00:00" + + +async def test_smart_load_slot_1_end_initial_value(hass, setup_integration): + state = hass.states.get(_entity_id(hass, "SA1234G123_smart_load_slot_1_end")) + assert state.state == "07:00:00" + + +async def test_set_smart_load_slot_1_start_sends_command(hass, mock_client, setup_integration): + entity_id = _entity_id(hass, "SA1234G123_smart_load_slot_1_start") + await hass.services.async_call( + "time", "set_value", {"entity_id": entity_id, "time": "08:30:00"}, blocking=True + ) + mock_client.one_shot_command.assert_called_once() + cmd_arg = mock_client.one_shot_command.call_args[0][0] + assert isinstance(cmd_arg, list) + assert len(cmd_arg) > 0 + + +async def test_set_smart_load_slot_5_end_sends_command(hass, mock_client, setup_integration): + """Spot-check mid-range slot to confirm idx capture is correct across all 10.""" + entity_id = _entity_id(hass, "SA1234G123_smart_load_slot_5_end") + await hass.services.async_call( + "time", "set_value", {"entity_id": entity_id, "time": "09:00:00"}, blocking=True + ) + mock_client.one_shot_command.assert_called_once() + + +def test_smart_load_slot_getter_returns_none_when_field_absent(): + """The getter must read None, not raise, when the field is missing entirely. + + Both current inverter models define smart_load_slot_* as optional pydantic fields, + so direct access is safe today. This guards the defensive getattr contract against a + future model that drops the field: since these entities are created unconditionally, + a missing field must surface as None (entity unavailable) rather than AttributeError. + """ + from custom_components.givenergy_local.time import _smart_load_slot_getter + + class _ModelWithoutSmartLoad: + """Stand-in for an inverter model lacking smart_load_slot_* attributes.""" + + getter = _smart_load_slot_getter(1) + assert getter(_ModelWithoutSmartLoad()) is None diff --git a/uv.lock b/uv.lock index 8c7024d..60863a2 100644 --- a/uv.lock +++ b/uv.lock @@ -947,7 +947,7 @@ dev = [ ] [package.metadata] -requires-dist = [{ name = "givenergy-modbus", specifier = ">=2.1.0b14,<3.0.0" }] +requires-dist = [{ name = "givenergy-modbus", specifier = ">=2.1.0b15,<3.0.0" }] [package.metadata.requires-dev] dev = [ @@ -960,15 +960,15 @@ dev = [ [[package]] name = "givenergy-modbus" -version = "2.1.0b14" +version = "2.1.0b15" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "crccheck" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/78/37/f9e3105ad2b6d9f6ff9242b85ae5d427e954978989278e99e547d778129f/givenergy_modbus-2.1.0b14.tar.gz", hash = "sha256:acc8234800e3b3cbaf066fb814afa45e850c277e3ca5b9ff14546fd5affe7a59", size = 290383, upload-time = "2026-06-02T13:47:55.059Z" } +sdist = { url = "https://files.pythonhosted.org/packages/54/0a/bb111b797b67ded19147a45cfc0997a724f21c11093eb248e7c79bb15416/givenergy_modbus-2.1.0b15.tar.gz", hash = "sha256:47b5975aa5de6bfb20f48b3d04dfc64c60a015b5b3ef48b797883754ccfd830a", size = 296678, upload-time = "2026-06-02T14:41:10.831Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/44/5415b8e276c2c83195594b60c4a1f4de1001dfa8944b11bbc72bbe510225/givenergy_modbus-2.1.0b14-py3-none-any.whl", hash = "sha256:010ed270d1ffde0c4b64dc0709419f9d2c33275c9b2737c1f1fb03b26025aa5c", size = 113569, upload-time = "2026-06-02T13:47:53.76Z" }, + { url = "https://files.pythonhosted.org/packages/70/3d/da0d01d16661f7f150bb60b224daf0fc1d2dda8a3614d3ae4f99915a4ab0/givenergy_modbus-2.1.0b15-py3-none-any.whl", hash = "sha256:db370b4bb4ed7cfdb1ecd79869ddd68b41518467da4401ec0b99bf97c92bbe04", size = 117830, upload-time = "2026-06-02T14:41:09.318Z" }, ] [[package]]