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.
222 lines
8.0 KiB
Python
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"])
|