Ryan Malloy b412dc0f37 HA: discover programs over the wire + diagnostic sensor
Coordinator's _discover_programs is no longer a placeholder. It now
drives client.iter_programs() (v2 path) or the v1 adapter's forward
to OmniClientV1.iter_programs (v1 path), populating OmniData.programs
with decoded Program records keyed by slot. Errors are logged and
swallowed so partial enumeration doesn't break entry setup —
programs are non-critical telemetry.

OmniData.programs is now dict[int, Program] rather than the
ProgramProperties dict that was an empty placeholder. The
ProgramProperties dataclass remains in models.py for the Properties
opcode reply path; only the coordinator's value type changed.

New OmniProgramsSensor on the sensor platform: a single diagnostic
entity per panel whose state is the count of defined programs and
whose 'programs' attribute lists each program's slot, type name,
and schedule fields. Easy to consume from automations and the
developer-states UI.

Mock fixture seeds three programs (TIMED+TIMED+EVENT at slots 12 /
42 / 99). New integration test verifies the sensor enumerates them
in slot-ascending order with the expected per-record fields.

Full suite: 494 passed, 1 skipped (fixture-gated).
2026-05-12 19:10:32 -06:00

155 lines
5.0 KiB
Python

"""Fixtures for the HA-side integration tests.
Each test gets:
* a fresh ``MockPanel`` listening on a random localhost port,
* a HA config entry whose ``host``/``port``/``controller_key`` point at it,
* a fully booted HA instance with the integration loaded.
The HA harness blocks real sockets by default; we re-enable them here
so the in-process client can talk to the in-process mock.
"""
from __future__ import annotations
from collections.abc import AsyncIterator
from typing import Any
import pytest
from custom_components.omni_pca.const import CONF_CONTROLLER_KEY, DOMAIN
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.core import HomeAssistant
from pytest_homeassistant_custom_component.common import MockConfigEntry
from omni_pca.mock_panel import (
MockAreaState,
MockButtonState,
MockPanel,
MockState,
MockThermostatState,
MockUnitState,
MockZoneState,
)
CONTROLLER_KEY = bytes(range(16))
CONTROLLER_KEY_HEX = CONTROLLER_KEY.hex()
@pytest.fixture(autouse=True)
def auto_enable_custom_integrations(enable_custom_integrations: None) -> None:
"""Tell HA to load components from ``custom_components/`` for every test."""
return None
@pytest.fixture(autouse=True)
def expected_lingering_tasks() -> bool:
"""Allow the coordinator's background event-listener task to outlive the
test body — the integration cancels it on entry unload, but the harness's
default ``verify_cleanup`` flags any task still alive at teardown."""
return True
@pytest.fixture(autouse=True)
def _short_scan_interval(monkeypatch: pytest.MonkeyPatch) -> None:
"""Cut the 30s polling interval down so tests don't wait on it."""
from datetime import timedelta
from custom_components.omni_pca import const, coordinator
fast = timedelta(seconds=1)
monkeypatch.setattr(const, "SCAN_INTERVAL", fast)
monkeypatch.setattr(coordinator, "SCAN_INTERVAL", fast)
@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"),
2: MockZoneState(name="GARAGE_ENTRY"),
10: MockZoneState(name="LIVING_MOTION"),
},
units={
1: MockUnitState(name="LIVING_LAMP"),
2: MockUnitState(name="KITCHEN_OVERHEAD"),
},
areas={
1: MockAreaState(name="MAIN"),
},
thermostats={
1: MockThermostatState(name="LIVING_ROOM"),
},
buttons={
1: MockButtonState(name="GOOD_MORNING"),
},
user_codes={1: 1234},
programs=programs,
)
@pytest.fixture
async def panel(populated_state: MockState) -> AsyncIterator[tuple[MockPanel, str, int]]:
"""Spin up a MockPanel on a random localhost port for the test's lifetime."""
mock = MockPanel(controller_key=CONTROLLER_KEY, state=populated_state)
async with mock.serve(host="127.0.0.1") as (host, port):
yield mock, host, port
@pytest.fixture
def config_entry_data(panel: tuple[MockPanel, str, int]) -> dict[str, Any]:
_, host, port = panel
return {
CONF_HOST: host,
CONF_PORT: port,
CONF_CONTROLLER_KEY: CONTROLLER_KEY_HEX,
}
@pytest.fixture
async def configured_panel(
hass: HomeAssistant, config_entry_data: dict[str, Any]
) -> AsyncIterator[ConfigEntry]:
"""Add a config entry to HA, trigger setup, unload at teardown.
The unload step is important — it cancels the coordinator's background
event-listener task and closes the OmniClient socket. Without it, the
HA harness's ``verify_cleanup`` hangs waiting for the lingering reader
coroutine.
"""
entry = MockConfigEntry(
domain=DOMAIN,
data=config_entry_data,
title=f"Mock Omni at {config_entry_data[CONF_HOST]}:{config_entry_data[CONF_PORT]}",
unique_id=f"{config_entry_data[CONF_HOST]}:{config_entry_data[CONF_PORT]}",
)
entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
try:
yield entry
finally:
if entry.entry_id in hass.data.get(DOMAIN, {}):
await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()