Ryan Malloy 30b482a8cb HA integration: wire v1+UDP into the coordinator + config flow
OmniClientV1Adapter (src/omni_pca/v1/adapter.py)
  V2-shape facade over OmniClientV1. Exposes the OmniClient surface the
  HA coordinator was written against — get_system_information,
  list_*_names, get_object_properties (synthesized from streamed names),
  get_extended_status (chunked, routed to v1 typed status opcodes),
  get_object_status(AREA, ...) (derived from SystemStatus.area_alarms),
  events() (EventStream on v1 SystemEvents opcode 35), plus all the
  write-method shims.

  Chunks unit/zone/thermostat/aux polls per-type because firmware 2.12
  NAKs Request*Status with >~62 records in one shot (verified live).
  Falls back to "Area 1".."Area 8" when the UploadNames stream returns
  zero areas — common on panels where the installer didn't name them.

custom_components/omni_pca/coordinator.py
  _ensure_connected picks OmniClientV1Adapter for transport=udp. New
  _walk_properties_v1 replaces the v2 RequestProperties walk with a
  name-stream + synthesized-Properties pass.

custom_components/omni_pca/config_flow.py
  _probe routes to OmniClientV1Adapter for transport=udp instead of
  trying to drive v2 OmniClient over UDP (which silently dropped after
  handshake, per the earlier diagnosis).

src/omni_pca/events.py
  parse_events / _ensure_system_events / EventStream now take an
  expected_opcode arg (default v2 SystemEvents=55, v1 callers pass 35).
  Word format is byte-identical between v1 and v2, so the typed-event
  decoder is unchanged.

src/omni_pca/v1/client.py
  _range_status supports the long-form RequestUnitStatus (BE u16
  start/end) so panels with unit indices > 255 (sprinklers, flags) work.

Verified end-to-end against firmware 2.12 panel at 192.168.1.9:
  config entries:
    state=loaded  Omni Pro II (host.docker.internal)  (mock)
    state=loaded  Omni Pro II (192.168.1.9)           (real, v1+UDP)
  real-panel entities created in HA: 96 (30 binary_sensor, 26 light,
  15 switch, 13 button, 9 sensor, 3 climate)
  cross-check: light.omni_pro_ii_front_porch_2 = on  (matches live
  probe: unit #2 'FRONT PORCH' state=0x01 brightness=100)

dev/probe_v1_coordinator.py
  Coordinator-shaped end-to-end smoke test against the real panel
  without HA — drives the full discovery + poll cycle through the
  adapter. Useful for regression-checking the v1 wire path.

dev/add_real_panel.py
  Programmatically adds the real-panel config entry to the dev HA
  stack via the REST config-flow endpoints. Idempotent.
2026-05-11 01:30:49 -06:00

222 lines
8.0 KiB
Python

"""Config flow for the HAI/Leviton Omni Panel integration."""
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_PORT
from omni_pca.client import OmniClient
from omni_pca.connection import (
ConnectionError as OmniConnectionError,
)
from omni_pca.connection import (
HandshakeError,
InvalidEncryptionKeyError,
)
from .const import (
CONF_CONTROLLER_KEY,
CONF_TRANSPORT,
CONTROLLER_KEY_HEX_LEN,
DEFAULT_PORT,
DEFAULT_TRANSPORT,
DOMAIN,
LOGGER,
TRANSPORT_TCP,
TRANSPORT_UDP,
)
class InvalidControllerKey(ValueError): # noqa: N818 - public surface, predates rule
"""The supplied controller key is not 32 hex characters."""
def parse_controller_key(raw: str) -> bytes:
"""Validate and decode a 32-char hex controller key into 16 raw bytes.
Pure function so it can be unit-tested without a HA harness.
Whitespace and a leading ``0x`` prefix are tolerated; case-insensitive.
"""
if not isinstance(raw, str):
raise InvalidControllerKey("controller key must be a string")
cleaned = raw.strip().replace(" ", "").replace(":", "").replace("-", "")
if cleaned.lower().startswith("0x"):
cleaned = cleaned[2:]
if len(cleaned) != CONTROLLER_KEY_HEX_LEN:
raise InvalidControllerKey(
f"controller key must be {CONTROLLER_KEY_HEX_LEN} hex characters "
f"(got {len(cleaned)})"
)
try:
return bytes.fromhex(cleaned)
except ValueError as err:
raise InvalidControllerKey(f"controller key is not valid hex: {err}") from err
_USER_SCHEMA = vol.Schema(
{
vol.Required(CONF_HOST): str,
vol.Required(CONF_PORT, default=DEFAULT_PORT): vol.All(
vol.Coerce(int), vol.Range(min=1, max=65535)
),
vol.Required(CONF_CONTROLLER_KEY): str,
# Most modern firmware uses TCP; some installers configure
# Network_UDP. PC Access stores the choice as
# enuPreferredNetworkProtocol in the .pca config.
vol.Required(CONF_TRANSPORT, default=DEFAULT_TRANSPORT): vol.In(
[TRANSPORT_TCP, TRANSPORT_UDP]
),
}
)
class OmniConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for omni_pca."""
VERSION = 1
def __init__(self) -> None:
self._reauth_entry_data: Mapping[str, Any] | None = None
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
errors: dict[str, str] = {}
if user_input is not None:
host: str = user_input[CONF_HOST].strip()
port: int = user_input[CONF_PORT]
transport: str = user_input.get(CONF_TRANSPORT, DEFAULT_TRANSPORT)
unique_id = f"{host}:{port}"
await self.async_set_unique_id(unique_id)
self._abort_if_unique_id_configured()
try:
key = parse_controller_key(user_input[CONF_CONTROLLER_KEY])
except InvalidControllerKey as err:
LOGGER.debug("controller key rejected: %s", err)
errors[CONF_CONTROLLER_KEY] = "invalid_key"
else:
title, error = await self._probe(host, port, key, transport)
if error is not None:
errors["base"] = error
else:
return self.async_create_entry(
title=title or f"Omni Panel ({host})",
data={
CONF_HOST: host,
CONF_PORT: port,
CONF_CONTROLLER_KEY: key.hex(),
CONF_TRANSPORT: transport,
},
)
return self.async_show_form(
step_id="user",
data_schema=_USER_SCHEMA,
errors=errors,
)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
self._reauth_entry_data = entry_data
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
assert self._reauth_entry_data is not None
host: str = self._reauth_entry_data[CONF_HOST]
port: int = self._reauth_entry_data[CONF_PORT]
transport: str = self._reauth_entry_data.get(
CONF_TRANSPORT, DEFAULT_TRANSPORT
)
errors: dict[str, str] = {}
if user_input is not None:
try:
key = parse_controller_key(user_input[CONF_CONTROLLER_KEY])
except InvalidControllerKey:
errors[CONF_CONTROLLER_KEY] = "invalid_key"
else:
_, error = await self._probe(host, port, key, transport)
if error is not None:
errors["base"] = error
else:
entry = self._get_reauth_entry()
new_data = {**entry.data, CONF_CONTROLLER_KEY: key.hex()}
return self.async_update_reload_and_abort(entry, data=new_data)
return self.async_show_form(
step_id="reauth_confirm",
data_schema=vol.Schema({vol.Required(CONF_CONTROLLER_KEY): str}),
description_placeholders={"host": host, "port": str(port)},
errors=errors,
)
# ---- helpers ---------------------------------------------------------
async def _probe(
self,
host: str,
port: int,
key: bytes,
transport: str = DEFAULT_TRANSPORT,
) -> tuple[str | None, str | None]:
"""Try to connect once. Returns (title, error_code).
TCP uses :class:`OmniClient` (v2 wire protocol). UDP uses the v1
adapter — UDP-listening panels speak the legacy wire protocol,
not OmniLink2 — see :mod:`omni_pca.v1.adapter` for the bridge.
"""
try:
if transport == TRANSPORT_UDP:
from omni_pca.v1 import (
HandshakeError as V1HandshakeError,
)
from omni_pca.v1 import (
InvalidEncryptionKeyError as V1InvalidEncryptionKeyError,
)
from omni_pca.v1 import OmniClientV1Adapter
from omni_pca.v1.connection import (
ConnectionError as V1ConnectionError,
)
try:
async with OmniClientV1Adapter(
host, port=port, controller_key=key,
) as client:
info = await client.get_system_information()
except (V1HandshakeError, V1InvalidEncryptionKeyError):
return None, "invalid_auth"
except (V1ConnectionError, OSError, TimeoutError) as err:
LOGGER.debug("v1 probe failed: %s", err)
return None, "cannot_connect"
else:
async with OmniClient(
host, port=port, controller_key=key, transport=transport, # type: ignore[arg-type]
) as client:
info = await client.get_system_information()
except (HandshakeError, InvalidEncryptionKeyError):
return None, "invalid_auth"
except (OmniConnectionError, OSError, TimeoutError) as err:
LOGGER.debug("probe connect failed: %s", err)
return None, "cannot_connect"
except Exception:
LOGGER.exception("unexpected probe failure")
return None, "unknown"
return f"{info.model_name} ({host})", None
def _get_reauth_entry(self): # type: ignore[no-untyped-def]
"""Resolve the entry being reauthenticated.
Wrapped in a method so tests / older HA versions that lack the
helper can monkeypatch this single accessor.
"""
return self.hass.config_entries.async_get_entry(self.context["entry_id"])