Ryan Malloy ce87ebcb13
Some checks are pending
Validate / HACS validation (push) Waiting to run
Validate / Hassfest (push) Waiting to run
HA: websocket commands + side-panel registration
Phase B of the program viewer. Three websocket commands and a stub
side-panel registration wire the HA integration to consume the
program_renderer library.

Websocket commands (all namespaced ``omni_pca/programs/``):

* ``list``  — paginated, filterable summaries. Filters: trigger_types
  (TIMED / EVENT / YEARLY / WHEN / AT / EVERY), references_entity
  (e.g. ``"unit:7"``), case-insensitive substring search. Each row
  carries summary tokens + a flat ``references`` list for filter UI.
* ``get``   — full structured-English detail for a slot. Clausal
  chains return as one logical unit even when the user clicked an
  interior slot.
* ``fire``  — sends ``Command.EXECUTE_PROGRAM`` over the wire so the
  panel runs the program now. Returns ``{slot, fired: true}`` on
  success or a structured error.

Token serialisation uses short keys (k/t/ek/ei/s) for compact wire
format — the panel's 1500-slot table on a busy install fits in a few
hundred KB of JSON.

Coordinator-backed resolvers:

* ``_CoordinatorNameResolver`` — pulls names from data.zones / units /
  areas / thermostats / buttons (HA-side ZoneProperties etc.)
* ``_CoordinatorStateResolver`` — pulls live state from *_status maps
  so every websocket call sees the freshest available overlay without
  round-tripping the panel. SECURE / NOT READY / BYPASSED for zones,
  OFF / ON / ON 60% for units, Day / Night / Away for areas,
  °F for thermostats.

Side-panel registration: ``async_register_side_panel`` registers a
custom panel under ``Omni Programs`` in HA's sidebar with a
``mdi:script-text-outline`` icon. Bundle is served at
``/api/omni_pca/panel.js`` via a static-path registration. A
working stub panel.js ships now so the wiring is exercisable;
Phase C will drop the real Lit/TS bundle into the same path.

Panel registration is wrapped in a try/except + a once-per-HA-boot
guard so test environments without ``hass_frontend`` installed don't
break the rest of the integration. The manifest only lists ``http``
and ``websocket_api`` as hard dependencies for the same reason —
panel_custom is opportunistic.

10 new HA-integration tests cover list/get/fire end-to-end plus
filters, pagination, search, live-state overlay, and structured-error
returns for bad entry_id / missing slot.

Full suite: 634 passed, 1 skipped (up from 624).
2026-05-14 03:07:00 -06:00

125 lines
4.2 KiB
Python

"""HAI/Leviton Omni Panel integration for Home Assistant.
Forwards every config entry to the full set of platforms wrapping the
omni-pca library: alarm_control_panel (areas), binary_sensor (zones +
system flags), button (panel button macros), climate (thermostats),
event (typed push events), light (units), sensor (analog zones,
thermostat readings, panel telemetry), switch (zone bypass).
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from homeassistant.const import CONF_HOST, CONF_PORT, Platform
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
from .const import (
CONF_CONTROLLER_KEY,
CONF_PCA_KEY,
CONF_PCA_PATH,
CONF_TRANSPORT,
DEFAULT_TRANSPORT,
DOMAIN,
LOGGER,
)
from .coordinator import OmniDataUpdateCoordinator
from .services import async_setup_services, async_unload_services
from .websocket import (
async_register_side_panel,
async_register_websocket_commands,
)
_PANEL_REGISTERED_KEY = "_panel_registered"
if TYPE_CHECKING:
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
PLATFORMS: list[Platform] = [
Platform.ALARM_CONTROL_PANEL,
Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.CLIMATE,
Platform.EVENT,
Platform.LIGHT,
Platform.SENSOR,
Platform.SWITCH,
]
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
async def async_setup(hass: HomeAssistant, config: dict) -> bool:
"""No YAML support; everything is config-flow driven."""
hass.data.setdefault(DOMAIN, {})
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up an Omni panel from a config entry."""
host: str = entry.data[CONF_HOST]
port: int = entry.data[CONF_PORT]
try:
controller_key = bytes.fromhex(entry.data[CONF_CONTROLLER_KEY])
except ValueError as err:
LOGGER.error("stored controller key for %s is corrupt: %s", entry.title, err)
return False
transport: str = entry.data.get(CONF_TRANSPORT, DEFAULT_TRANSPORT)
pca_path: str = entry.data.get(CONF_PCA_PATH, "") or ""
pca_key: int = entry.data.get(CONF_PCA_KEY, 0)
coordinator = OmniDataUpdateCoordinator(
hass,
entry,
host=host,
port=port,
controller_key=controller_key,
transport=transport,
pca_path=pca_path or None,
pca_key=pca_key,
)
try:
await coordinator.async_config_entry_first_refresh()
except ConfigEntryNotReady:
# Re-raise so HA retries with backoff; clean up any half-open client
# *and* the background event task spawned by the first refresh.
await coordinator.async_shutdown()
raise
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
await async_setup_services(hass)
# Websocket commands are global (not per-entry) but the registration
# helper is idempotent, so calling it for each entry is harmless.
async_register_websocket_commands(hass)
# Panel registration must happen exactly once — guard with a flag in
# hass.data. The flag survives entry reloads; only a full HA restart
# clears it (matching when HA itself would need to re-register).
if not hass.data[DOMAIN].get(_PANEL_REGISTERED_KEY):
try:
await async_register_side_panel(hass)
except Exception:
LOGGER.warning(
"omni_pca: side panel registration failed", exc_info=True,
)
hass.data[DOMAIN][_PANEL_REGISTERED_KEY] = True
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry.
``coordinator.async_shutdown()`` cancels the long-lived event-listener
task and closes the ``OmniClient`` socket, so HA's reload doesn't
leak a background coroutine or a half-open TCP connection.
"""
unloaded = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unloaded:
coordinator: OmniDataUpdateCoordinator = hass.data[DOMAIN].pop(entry.entry_id)
await coordinator.async_shutdown()
await async_unload_services(hass)
return unloaded