custom_components/omni_pca/ — drop-in HA integration: - manifest.json (HA 2026.x, iot_class=local_push, requires omni-pca lib) - config_flow.py — host/port/controller_key with auth + reauth steps, parse_controller_key() extracted as pure testable function - coordinator.py — OmniDataUpdateCoordinator with long-lived OmniClient, unsolicited push wiring, ConfigEntryAuthFailed on bad key, reconnect on err - binary_sensor.py — one entity per named zone, zone_type -> device_class map (OPENING/MOTION/SMOKE/etc), is_on derived from ZoneProperties.status - const.py, strings.json, translations/en.json, README.md - hacs.json at root for HACS distribution tests: 97 pass + 2 skip (HA harness not installed; importorskip in test_ha_imports.py). 12 cases for parse_controller_key validation. Ruff clean across src/ tests/ custom_components/. Status of HA component itself NOT validated against a running HA — needs that next.
173 lines
6.0 KiB
Python
173 lines
6.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,
|
|
CONTROLLER_KEY_HEX_LEN,
|
|
DEFAULT_PORT,
|
|
DOMAIN,
|
|
LOGGER,
|
|
)
|
|
|
|
|
|
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,
|
|
}
|
|
)
|
|
|
|
|
|
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]
|
|
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)
|
|
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(),
|
|
},
|
|
)
|
|
|
|
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]
|
|
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)
|
|
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
|
|
) -> tuple[str | None, str | None]:
|
|
"""Try to connect once. Returns (title, error_code)."""
|
|
try:
|
|
async with OmniClient(host, port=port, controller_key=key) 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"])
|