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).
125 lines
4.2 KiB
Python
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
|