diff --git a/custom_components/omni_pca/coordinator.py b/custom_components/omni_pca/coordinator.py index 03db36b..4b90898 100644 --- a/custom_components/omni_pca/coordinator.py +++ b/custom_components/omni_pca/coordinator.py @@ -62,7 +62,6 @@ from omni_pca.models import ( AreaStatus, ButtonProperties, ObjectType, - ProgramProperties, SystemInformation, SystemStatus, ThermostatProperties, @@ -73,6 +72,7 @@ from omni_pca.models import ( ZoneStatus, ) from omni_pca.opcodes import OmniLink2MessageType +from omni_pca.programs import Program from .const import ( DOMAIN, @@ -108,7 +108,7 @@ class OmniData: areas: dict[int, AreaProperties] = field(default_factory=dict) thermostats: dict[int, ThermostatProperties] = field(default_factory=dict) buttons: dict[int, ButtonProperties] = field(default_factory=dict) - programs: dict[int, ProgramProperties] = field(default_factory=dict) + programs: dict[int, Program] = field(default_factory=dict) zone_status: dict[int, ZoneStatus] = field(default_factory=dict) unit_status: dict[int, UnitStatus] = field(default_factory=dict) @@ -382,15 +382,32 @@ class OmniDataUpdateCoordinator(DataUpdateCoordinator[OmniData]): async def _discover_programs( self, client: OmniClient - ) -> dict[int, ProgramProperties]: - # Programs aren't reachable via the Properties opcode (the C# side - # uses a separate request/reply pair), so we just return an empty - # dict. We keep the field on OmniData so Phase B can plug in real - # discovery the moment the library exposes it. AMBIGUITY: the spec - # asks for "named programs" — there's no on-the-wire path for that - # in v1.0 of omni_pca, so an empty mapping is the honest answer. - _ = client, ProgramProperties - return {} + ) -> dict[int, Program]: + """Enumerate defined panel programs via the appropriate wire path. + + * v2 (TCP): ``client.iter_programs()`` drives UploadProgram with + request_reason=1 ("next defined after slot"), stopping at EOD. + * v1 (UDP): the adapter forwards to OmniClientV1.iter_programs(), + which is a bare UploadPrograms stream ack-walked to EOD. + + Both surfaces yield :class:`omni_pca.programs.Program` and skip + empty slots, so the resulting dict only carries defined programs. + Errors during enumeration are logged and swallowed — programs + are a non-critical part of discovery, so a partial list is better + than blocking the entry setup. + """ + out: dict[int, Program] = {} + try: + async for prog in client.iter_programs(): + if prog.slot is not None: + out[prog.slot] = prog + except (OmniConnectionError, RequestTimeoutError): + raise + except Exception: + LOGGER.debug( + "program enumeration interrupted (kept %d)", len(out), exc_info=True + ) + return out async def _walk_properties( self, diff --git a/custom_components/omni_pca/sensor.py b/custom_components/omni_pca/sensor.py index d99da4b..deffde2 100644 --- a/custom_components/omni_pca/sensor.py +++ b/custom_components/omni_pca/sensor.py @@ -69,6 +69,7 @@ async def async_setup_entry( entities.append(OmniSystemModelSensor(coordinator)) entities.append(OmniLastEventSensor(coordinator)) + entities.append(OmniProgramsSensor(coordinator)) async_add_entities(entities) @@ -261,3 +262,55 @@ class OmniLastEventSensor( if hasattr(ev, key): result[key] = getattr(ev, key) return result + + +class OmniProgramsSensor( + CoordinatorEntity[OmniDataUpdateCoordinator], SensorEntity +): + """Diagnostic sensor exposing the panel's automation programs. + + State value is the count of defined programs. The ``programs`` + attribute carries a list of per-program summaries — a stable, + JSON-serializable view automations and template sensors can read. + """ + + _attr_has_entity_name = True + _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_state_class = SensorStateClass.MEASUREMENT + + def __init__(self, coordinator: OmniDataUpdateCoordinator) -> None: + super().__init__(coordinator) + self._attr_unique_id = f"{coordinator.unique_id}-programs" + self._attr_name = "Panel Programs" + self._attr_device_info = coordinator.device_info + + @property + def native_value(self) -> int: + return len(self.coordinator.data.programs) + + @property + def extra_state_attributes(self) -> dict[str, Any]: + from omni_pca.programs import ProgramType + + summaries: list[dict[str, Any]] = [] + for slot in sorted(self.coordinator.data.programs): + p = self.coordinator.data.programs[slot] + try: + type_name = ProgramType(p.prog_type).name + except ValueError: + type_name = f"UNKNOWN({p.prog_type})" + summaries.append( + { + "slot": slot, + "type": type_name, + "cmd": p.cmd, + "par": p.par, + "pr2": p.pr2, + "month": p.month, + "day": p.day, + "days": p.days, + "hour": p.hour, + "minute": p.minute, + } + ) + return {"programs": summaries} diff --git a/src/omni_pca/v1/adapter.py b/src/omni_pca/v1/adapter.py index 6502db7..5fc3135 100644 --- a/src/omni_pca/v1/adapter.py +++ b/src/omni_pca/v1/adapter.py @@ -172,6 +172,16 @@ class OmniClientV1Adapter: async def list_message_names(self) -> dict[int, str]: return (await self._ensure_names()).get(7, {}) # NameType.MESSAGE + # ---- programs ------------------------------------------------------ + + def iter_programs(self): + """Forward to OmniClientV1.iter_programs (streaming UploadPrograms). + + Same async-iterator shape as :meth:`OmniClient.iter_programs` so the + coordinator does not need a transport branch. + """ + return self._client.iter_programs() + # ---- properties synthesis ------------------------------------------ async def get_object_properties( diff --git a/tests/ha_integration/conftest.py b/tests/ha_integration/conftest.py index c2c386b..c88b777 100644 --- a/tests/ha_integration/conftest.py +++ b/tests/ha_integration/conftest.py @@ -64,6 +64,26 @@ def _short_scan_interval(monkeypatch: pytest.MonkeyPatch) -> None: @pytest.fixture def populated_state() -> MockState: """A lightly-populated mock state covering every entity platform.""" + from omni_pca.programs import Days, Program, ProgramType + programs = { + slot: prog.encode_wire_bytes() + for slot, prog in { + 12: Program( + slot=12, prog_type=int(ProgramType.TIMED), + cmd=3, hour=6, minute=0, + days=int(Days.MONDAY | Days.FRIDAY), + ), + 42: Program( + slot=42, prog_type=int(ProgramType.TIMED), + cmd=4, hour=22, minute=30, + days=int(Days.SUNDAY), + ), + 99: Program( + slot=99, prog_type=int(ProgramType.EVENT), + cmd=5, month=5, day=12, + ), + }.items() + } return MockState( zones={ 1: MockZoneState(name="FRONT_DOOR"), @@ -84,6 +104,7 @@ def populated_state() -> MockState: 1: MockButtonState(name="GOOD_MORNING"), }, user_codes={1: 1234}, + programs=programs, ) diff --git a/tests/ha_integration/test_setup.py b/tests/ha_integration/test_setup.py index bd12a50..84dc8e1 100644 --- a/tests/ha_integration/test_setup.py +++ b/tests/ha_integration/test_setup.py @@ -78,6 +78,35 @@ async def test_button_entity_for_panel_button( assert len(states) == 1 +async def test_programs_sensor_reflects_seeded_panel( + hass: HomeAssistant, configured_panel +) -> None: + """The diagnostic Panel Programs sensor enumerates discovered programs. + + Mock seed has 3 programs at slots 12 / 42 / 99 (see + :func:`populated_state`); after discovery the coordinator stashes them + on ``OmniData.programs`` and the sensor surfaces a count + per-slot + summary attribute. + """ + programs_states = [ + s for s in hass.states.async_all("sensor") + if s.entity_id.endswith("_panel_programs") or "panel_programs" in s.entity_id + ] + assert len(programs_states) == 1, ( + f"expected one panel_programs sensor, got {[s.entity_id for s in programs_states]}" + ) + s = programs_states[0] + assert int(s.state) == 3 + summaries = s.attributes["programs"] + assert [p["slot"] for p in summaries] == [12, 42, 99] + # Spot-check the per-record fields landed in summary form. + by_slot = {p["slot"]: p for p in summaries} + assert by_slot[12]["type"] == "TIMED" + assert by_slot[12]["hour"] == 6 and by_slot[12]["minute"] == 0 + assert by_slot[99]["type"] == "EVENT" + assert by_slot[99]["month"] == 5 and by_slot[99]["day"] == 12 + + async def test_event_entity_per_panel( hass: HomeAssistant, configured_panel ) -> None: