Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion custom_components/givenergy_local/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
50 changes: 50 additions & 0 deletions custom_components/givenergy_local/time.py
Original file line number Diff line number Diff line change
Expand Up @@ -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] = []
Expand Down Expand Up @@ -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)


Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
3 changes: 3 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions tests/test_ems.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
# ---------------------------------------------------------------------------
Expand Down
50 changes: 49 additions & 1 deletion tests/test_time.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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}")
Expand All @@ -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
8 changes: 4 additions & 4 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading