HA custom_component scaffold (binary_sensor for zones)
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.
This commit is contained in:
parent
1901d6ec87
commit
2e439364bd
67
custom_components/omni_pca/README.md
Normal file
67
custom_components/omni_pca/README.md
Normal file
@ -0,0 +1,67 @@
|
||||
# HAI / Leviton Omni Panel — Home Assistant Integration
|
||||
|
||||
Native HA integration that talks Omni-Link II directly to your **Omni Pro II
|
||||
/ Omni IIe / Omni LTe / Lumina** controller over TCP. No middleware — HA
|
||||
opens an encrypted session straight to the panel and listens for unsolicited
|
||||
push messages.
|
||||
|
||||
This integration is the HA-facing wrapper around the
|
||||
[`omni-pca`](https://github.com/rsp2k/omni-pca) Python library; the library
|
||||
handles the wire protocol, this component surfaces it as HA entities.
|
||||
|
||||
## Install
|
||||
|
||||
### HACS (recommended once published)
|
||||
|
||||
1. HACS → Integrations → custom repository → add
|
||||
`https://github.com/rsp2k/omni-pca`, category **Integration**.
|
||||
2. Install **HAI / Leviton Omni Panel**, then restart Home Assistant.
|
||||
|
||||
### Manual
|
||||
|
||||
Copy the `custom_components/omni_pca/` directory into your HA
|
||||
`config/custom_components/` directory and restart HA.
|
||||
|
||||
## Configure
|
||||
|
||||
1. **Settings → Devices & Services → Add Integration** → search for
|
||||
*HAI/Leviton Omni Panel*.
|
||||
2. Enter:
|
||||
- **Host** — IP or hostname of the panel (e.g. `192.168.1.50`)
|
||||
- **Port** — defaults to `4369` (HAI's reserved port)
|
||||
- **Controller Key** — 32 hex characters, the panel's NVRAM key
|
||||
3. Save. The panel's model and firmware appear as a single device, with one
|
||||
`binary_sensor` per defined zone.
|
||||
|
||||
### Where do I get the Controller Key?
|
||||
|
||||
If you have a `.pca` configuration export from PC Access, the included CLI
|
||||
extracts the key for you:
|
||||
|
||||
```bash
|
||||
uvx omni-pca decode-pca '/path/to/My House.pca' --field controller_key
|
||||
```
|
||||
|
||||
Otherwise, find it in PC Access under the panel's **Setup → Misc → Network**
|
||||
page (HAI labels it "Encryption Key 1").
|
||||
|
||||
## What you get
|
||||
|
||||
- One **device** per panel — model + firmware reported in the UI.
|
||||
- One **`binary_sensor`** per defined zone, named from the panel's own
|
||||
zone-name field. `OPENING` device class for door/window contacts,
|
||||
`MOTION` for interior PIRs, `SMOKE` for fire zones, etc., chosen by zone
|
||||
type when the panel reports one.
|
||||
- **Push updates**: zone state changes propagate within a single round-trip
|
||||
thanks to unsolicited-message subscription. The 30-second poll is just a
|
||||
safety net.
|
||||
|
||||
## Roadmap
|
||||
|
||||
- Areas → `alarm_control_panel` entities
|
||||
- Units → `light` / `switch` entities
|
||||
- Thermostats → `climate`
|
||||
- Aux sensors → `sensor`
|
||||
|
||||
See the [parent README](https://github.com/rsp2k/omni-pca) for protocol /
|
||||
library details.
|
||||
62
custom_components/omni_pca/__init__.py
Normal file
62
custom_components/omni_pca/__init__.py
Normal file
@ -0,0 +1,62 @@
|
||||
"""HAI/Leviton Omni Panel integration for Home Assistant."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from homeassistant.const import CONF_HOST, CONF_PORT, Platform
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
|
||||
from .const import CONF_CONTROLLER_KEY, DOMAIN, LOGGER
|
||||
from .coordinator import OmniDataUpdateCoordinator
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR]
|
||||
|
||||
|
||||
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
|
||||
|
||||
coordinator = OmniDataUpdateCoordinator(
|
||||
hass,
|
||||
entry,
|
||||
host=host,
|
||||
port=port,
|
||||
controller_key=controller_key,
|
||||
)
|
||||
|
||||
try:
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
except ConfigEntryNotReady:
|
||||
# Re-raise so HA retries with backoff; clean up any half-open client.
|
||||
await coordinator.async_shutdown()
|
||||
raise
|
||||
|
||||
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
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()
|
||||
return unloaded
|
||||
115
custom_components/omni_pca/binary_sensor.py
Normal file
115
custom_components/omni_pca/binary_sensor.py
Normal file
@ -0,0 +1,115 @@
|
||||
"""Binary sensor platform: one entity per Omni zone."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
BinarySensorEntity,
|
||||
)
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import OmniDataUpdateCoordinator
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
|
||||
# Best-effort mapping from Omni zone-type byte (enuZoneType) to HA device
|
||||
# class. Anything not listed falls back to OPENING — a sane default for
|
||||
# perimeter contacts, which dominate residential installs. We pick this
|
||||
# explicitly rather than guessing motion vs. door from the name.
|
||||
#
|
||||
# Reference: HAI_Shared/enuZoneType.cs (subset).
|
||||
_ZONE_TYPE_TO_DEVICE_CLASS: dict[int, BinarySensorDeviceClass] = {
|
||||
0: BinarySensorDeviceClass.OPENING, # Perimeter
|
||||
1: BinarySensorDeviceClass.OPENING, # PerimeterEntryExit
|
||||
2: BinarySensorDeviceClass.MOTION, # Interior (typically PIR)
|
||||
3: BinarySensorDeviceClass.MOTION, # InteriorAuto
|
||||
4: BinarySensorDeviceClass.SAFETY, # Tamper
|
||||
5: BinarySensorDeviceClass.SMOKE, # Fire
|
||||
6: BinarySensorDeviceClass.SAFETY, # PoliceEmergency
|
||||
7: BinarySensorDeviceClass.SAFETY, # Duress
|
||||
8: BinarySensorDeviceClass.SOUND, # Auxiliary
|
||||
32: BinarySensorDeviceClass.SMOKE, # Auxiliary fire
|
||||
33: BinarySensorDeviceClass.GAS,
|
||||
34: BinarySensorDeviceClass.MOISTURE,
|
||||
80: BinarySensorDeviceClass.MOTION, # AwayInterior
|
||||
81: BinarySensorDeviceClass.MOTION, # NightInterior
|
||||
}
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Create one binary_sensor per zone the panel reported."""
|
||||
coordinator: OmniDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
entities = [
|
||||
OmniZoneBinarySensor(coordinator, index)
|
||||
for index in sorted(coordinator.data.zones)
|
||||
]
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class OmniZoneBinarySensor(
|
||||
CoordinatorEntity[OmniDataUpdateCoordinator], BinarySensorEntity
|
||||
):
|
||||
"""A single zone exposed as a binary_sensor."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(self, coordinator: OmniDataUpdateCoordinator, index: int) -> None:
|
||||
super().__init__(coordinator)
|
||||
self._index = index
|
||||
self._attr_unique_id = f"{coordinator.unique_id}-zone-{index}"
|
||||
self._attr_device_info = coordinator.device_info
|
||||
zone = coordinator.data.zones[index]
|
||||
self._attr_name = _prettify(zone.name)
|
||||
self._attr_device_class = _ZONE_TYPE_TO_DEVICE_CLASS.get(
|
||||
zone.zone_type, BinarySensorDeviceClass.OPENING
|
||||
)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
return (
|
||||
super().available
|
||||
and self.coordinator.data is not None
|
||||
and self._index in self.coordinator.data.zones
|
||||
)
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
data = self.coordinator.data
|
||||
if data is None:
|
||||
return None
|
||||
zone = data.zones.get(self._index)
|
||||
if zone is None:
|
||||
return None
|
||||
return zone.is_open
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, int] | None:
|
||||
data = self.coordinator.data
|
||||
if data is None:
|
||||
return None
|
||||
zone = data.zones.get(self._index)
|
||||
if zone is None:
|
||||
return None
|
||||
return {
|
||||
"zone_index": zone.index,
|
||||
"zone_type": zone.zone_type,
|
||||
"area": zone.area,
|
||||
"raw_status": zone.status,
|
||||
"loop_reading": zone.loop,
|
||||
}
|
||||
|
||||
|
||||
def _prettify(name: str) -> str:
|
||||
"""Convert ``FRONT_DOOR`` → ``Front Door`` for HA-friendly display."""
|
||||
return name.replace("_", " ").strip().title()
|
||||
172
custom_components/omni_pca/config_flow.py
Normal file
172
custom_components/omni_pca/config_flow.py
Normal file
@ -0,0 +1,172 @@
|
||||
"""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"])
|
||||
26
custom_components/omni_pca/const.py
Normal file
26
custom_components/omni_pca/const.py
Normal file
@ -0,0 +1,26 @@
|
||||
"""Constants for the HAI/Leviton Omni Panel integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
from typing import Final
|
||||
|
||||
DOMAIN: Final = "omni_pca"
|
||||
|
||||
DEFAULT_PORT: Final = 4369
|
||||
DEFAULT_TIMEOUT: Final = 5.0
|
||||
|
||||
CONF_CONTROLLER_KEY: Final = "controller_key"
|
||||
|
||||
MANUFACTURER: Final = "HAI / Leviton"
|
||||
|
||||
# Polling interval. Most state arrives via unsolicited push messages, so
|
||||
# this is just a safety net that keeps `last_update_success` honest if the
|
||||
# panel goes quiet.
|
||||
SCAN_INTERVAL: Final = timedelta(seconds=30)
|
||||
|
||||
# Length, in characters, of a hex-encoded 16-byte controller key.
|
||||
CONTROLLER_KEY_HEX_LEN: Final = 32
|
||||
|
||||
LOGGER: Final = logging.getLogger(__package__)
|
||||
266
custom_components/omni_pca/coordinator.py
Normal file
266
custom_components/omni_pca/coordinator.py
Normal file
@ -0,0 +1,266 @@
|
||||
"""DataUpdateCoordinator that owns the long-lived OmniClient connection.
|
||||
|
||||
The coordinator caches *static* panel topology (system info, zone names,
|
||||
unit names, area names) on first refresh and only re-queries dynamic state
|
||||
on subsequent updates. Unsolicited messages from the panel are also routed
|
||||
through here so binary sensors flip immediately without waiting for the
|
||||
next 30s poll.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field, replace
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from omni_pca.client import ObjectType, OmniClient
|
||||
from omni_pca.connection import (
|
||||
ConnectionError as OmniConnectionError,
|
||||
)
|
||||
from omni_pca.connection import (
|
||||
HandshakeError,
|
||||
InvalidEncryptionKeyError,
|
||||
RequestTimeoutError,
|
||||
)
|
||||
from omni_pca.models import SystemInformation, SystemStatus, ZoneProperties
|
||||
|
||||
from .const import DOMAIN, LOGGER, MANUFACTURER, SCAN_INTERVAL
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from omni_pca.message import Message
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class OmniZoneState:
|
||||
"""Per-zone state combining static name with dynamic status."""
|
||||
|
||||
index: int
|
||||
name: str
|
||||
zone_type: int
|
||||
area: int
|
||||
status: int # raw zone status byte from the panel
|
||||
loop: int
|
||||
|
||||
@property
|
||||
def is_open(self) -> bool:
|
||||
"""True when the zone is tripped / not-ready / open.
|
||||
|
||||
The Omni-Link II ``ZoneStatus`` byte packs current condition in the
|
||||
low nibble. 0 = secure (closed). Any non-zero current condition is
|
||||
treated as "not secure" for binary-sensor purposes.
|
||||
"""
|
||||
return (self.status & 0x03) != 0
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class OmniData:
|
||||
"""Top-level coordinator data exposed to entities."""
|
||||
|
||||
system_information: SystemInformation
|
||||
system_status: SystemStatus | None
|
||||
zones: dict[int, OmniZoneState]
|
||||
unit_names: dict[int, str] = field(default_factory=dict)
|
||||
area_names: dict[int, str] = field(default_factory=dict)
|
||||
|
||||
|
||||
class OmniDataUpdateCoordinator(DataUpdateCoordinator[OmniData]):
|
||||
"""Coordinator that owns one OmniClient and one panel device."""
|
||||
|
||||
config_entry: ConfigEntry
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
*,
|
||||
host: str,
|
||||
port: int,
|
||||
controller_key: bytes,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
hass,
|
||||
LOGGER,
|
||||
name=f"{DOMAIN} {host}:{port}",
|
||||
update_interval=SCAN_INTERVAL,
|
||||
config_entry=entry,
|
||||
)
|
||||
self._host = host
|
||||
self._port = port
|
||||
self._controller_key = controller_key
|
||||
self._client: OmniClient | None = None
|
||||
self._static_loaded = False
|
||||
self._zone_names: dict[int, str] = {}
|
||||
self._unit_names: dict[int, str] = {}
|
||||
self._area_names: dict[int, str] = {}
|
||||
self._system_information: SystemInformation | None = None
|
||||
|
||||
# ---- public surface --------------------------------------------------
|
||||
|
||||
@property
|
||||
def unique_id(self) -> str:
|
||||
"""Stable identifier for this panel (host:port)."""
|
||||
return f"{self._host}:{self._port}"
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
"""DeviceInfo for the single hub device this coordinator represents."""
|
||||
info = self._system_information
|
||||
return DeviceInfo(
|
||||
identifiers={(DOMAIN, self.unique_id)},
|
||||
name="Omni Pro II",
|
||||
manufacturer=MANUFACTURER,
|
||||
model=info.model_name if info is not None else None,
|
||||
sw_version=info.firmware_version if info is not None else None,
|
||||
configuration_url=None,
|
||||
)
|
||||
|
||||
async def async_shutdown(self) -> None:
|
||||
"""Tear down the client connection on unload."""
|
||||
if self._client is not None:
|
||||
client = self._client
|
||||
self._client = None
|
||||
try:
|
||||
await client.__aexit__(None, None, None)
|
||||
except Exception:
|
||||
LOGGER.debug("error closing OmniClient", exc_info=True)
|
||||
await super().async_shutdown()
|
||||
|
||||
# ---- DataUpdateCoordinator hook -------------------------------------
|
||||
|
||||
async def _async_update_data(self) -> OmniData:
|
||||
try:
|
||||
client = await self._ensure_connected()
|
||||
if not self._static_loaded:
|
||||
await self._load_static(client)
|
||||
system_status = await self._safe_system_status(client)
|
||||
zones = await self._snapshot_zones(client)
|
||||
except (InvalidEncryptionKeyError, HandshakeError) as err:
|
||||
# Surface as auth failure so HA triggers the reauth flow.
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
|
||||
raise ConfigEntryAuthFailed(str(err)) from err
|
||||
except (OmniConnectionError, RequestTimeoutError, OSError) as err:
|
||||
await self._drop_client()
|
||||
raise UpdateFailed(f"panel unreachable: {err}") from err
|
||||
|
||||
assert self._system_information is not None # set by _load_static
|
||||
return OmniData(
|
||||
system_information=self._system_information,
|
||||
system_status=system_status,
|
||||
zones=zones,
|
||||
unit_names=dict(self._unit_names),
|
||||
area_names=dict(self._area_names),
|
||||
)
|
||||
|
||||
# ---- internals -------------------------------------------------------
|
||||
|
||||
async def _ensure_connected(self) -> OmniClient:
|
||||
if self._client is not None:
|
||||
return self._client
|
||||
client = OmniClient(
|
||||
self._host,
|
||||
port=self._port,
|
||||
controller_key=self._controller_key,
|
||||
)
|
||||
# Manually drive __aenter__ so we can keep the connection open
|
||||
# across update cycles instead of using `async with`.
|
||||
await client.__aenter__()
|
||||
try:
|
||||
await client.subscribe(self._handle_unsolicited)
|
||||
except Exception:
|
||||
await client.__aexit__(None, None, None)
|
||||
raise
|
||||
self._client = client
|
||||
return client
|
||||
|
||||
async def _drop_client(self) -> None:
|
||||
if self._client is None:
|
||||
return
|
||||
client = self._client
|
||||
self._client = None
|
||||
try:
|
||||
await client.__aexit__(None, None, None)
|
||||
except Exception:
|
||||
LOGGER.debug("error during reconnect cleanup", exc_info=True)
|
||||
|
||||
async def _load_static(self, client: OmniClient) -> None:
|
||||
self._system_information = await client.get_system_information()
|
||||
self._zone_names = await client.list_zone_names()
|
||||
# Unit / area names are best-effort; some panels may not have any.
|
||||
try:
|
||||
self._unit_names = await client.list_unit_names()
|
||||
except Exception:
|
||||
LOGGER.debug("list_unit_names failed; continuing", exc_info=True)
|
||||
self._unit_names = {}
|
||||
try:
|
||||
self._area_names = await client.list_area_names()
|
||||
except Exception:
|
||||
LOGGER.debug("list_area_names failed; continuing", exc_info=True)
|
||||
self._area_names = {}
|
||||
self._static_loaded = True
|
||||
LOGGER.debug(
|
||||
"loaded static topology: %d zones, %d units, %d areas",
|
||||
len(self._zone_names),
|
||||
len(self._unit_names),
|
||||
len(self._area_names),
|
||||
)
|
||||
|
||||
async def _safe_system_status(self, client: OmniClient) -> SystemStatus | None:
|
||||
try:
|
||||
return await client.get_system_status()
|
||||
except (OmniConnectionError, RequestTimeoutError):
|
||||
raise
|
||||
except Exception:
|
||||
LOGGER.debug("get_system_status failed; continuing", exc_info=True)
|
||||
return None
|
||||
|
||||
async def _snapshot_zones(self, client: OmniClient) -> dict[int, OmniZoneState]:
|
||||
zones: dict[int, OmniZoneState] = {}
|
||||
for index, name in self._zone_names.items():
|
||||
try:
|
||||
props = await client.get_object_properties(ObjectType.ZONE, index)
|
||||
except (OmniConnectionError, RequestTimeoutError):
|
||||
raise
|
||||
except Exception:
|
||||
LOGGER.debug("zone %d snapshot failed; skipping", index, exc_info=True)
|
||||
continue
|
||||
if not isinstance(props, ZoneProperties):
|
||||
continue
|
||||
zones[index] = OmniZoneState(
|
||||
index=index,
|
||||
name=name,
|
||||
zone_type=props.zone_type,
|
||||
area=props.area,
|
||||
status=props.status,
|
||||
loop=props.loop,
|
||||
)
|
||||
return zones
|
||||
|
||||
async def _handle_unsolicited(self, msg: Message) -> None:
|
||||
"""Push-driven update path.
|
||||
|
||||
We don't try to be clever about parsing every unsolicited opcode
|
||||
here. The simplest correct behavior is to nudge HA to refetch on
|
||||
any panel-initiated message; entities will see fresh zone state
|
||||
within one round-trip.
|
||||
"""
|
||||
LOGGER.debug("unsolicited opcode %#04x payload=%s", msg.opcode, msg.payload.hex())
|
||||
# Schedule a refresh on the event loop without awaiting from the
|
||||
# subscriber callback (which lives in the connection's read loop).
|
||||
self.hass.async_create_task(self._refresh_after_push())
|
||||
|
||||
async def _refresh_after_push(self) -> None:
|
||||
if self.data is None or self._client is None:
|
||||
return
|
||||
try:
|
||||
zones = await self._snapshot_zones(self._client)
|
||||
except (OmniConnectionError, RequestTimeoutError):
|
||||
await self.async_request_refresh()
|
||||
return
|
||||
# Mutate a copy so listeners see a brand-new object identity.
|
||||
new_data = replace(self.data, zones=zones)
|
||||
self.async_set_updated_data(new_data)
|
||||
13
custom_components/omni_pca/manifest.json
Normal file
13
custom_components/omni_pca/manifest.json
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"domain": "omni_pca",
|
||||
"name": "HAI/Leviton Omni Panel",
|
||||
"version": "2026.5.10",
|
||||
"iot_class": "local_push",
|
||||
"config_flow": true,
|
||||
"dependencies": [],
|
||||
"codeowners": ["@rsp2k"],
|
||||
"requirements": ["omni-pca==2026.5.10"],
|
||||
"documentation": "https://github.com/rsp2k/omni-pca",
|
||||
"issue_tracker": "https://github.com/rsp2k/omni-pca/issues",
|
||||
"integration_type": "hub"
|
||||
}
|
||||
32
custom_components/omni_pca/strings.json
Normal file
32
custom_components/omni_pca/strings.json
Normal file
@ -0,0 +1,32 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Connect to your Omni panel",
|
||||
"description": "Enter the IP/hostname of your HAI/Leviton Omni Pro II controller and the 32-character hex Controller Key. Use `omni-pca decode-pca <file>.pca --field controller_key` to extract the key from a PC Access export.",
|
||||
"data": {
|
||||
"host": "Host",
|
||||
"port": "Port",
|
||||
"controller_key": "Controller Key (32 hex chars)"
|
||||
}
|
||||
},
|
||||
"reauth_confirm": {
|
||||
"title": "Re-enter Controller Key",
|
||||
"description": "The panel at {host}:{port} rejected the saved key. Paste the new 32-character hex Controller Key.",
|
||||
"data": {
|
||||
"controller_key": "Controller Key (32 hex chars)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Could not reach the panel. Check the host, port, and that TCP/4369 is open.",
|
||||
"invalid_auth": "The Controller Key was rejected by the panel.",
|
||||
"invalid_key": "Controller Key must be exactly 32 hexadecimal characters (16 bytes).",
|
||||
"unknown": "An unexpected error occurred. Check the Home Assistant logs."
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "This panel is already configured.",
|
||||
"reauth_successful": "Re-authentication was successful."
|
||||
}
|
||||
}
|
||||
}
|
||||
32
custom_components/omni_pca/translations/en.json
Normal file
32
custom_components/omni_pca/translations/en.json
Normal file
@ -0,0 +1,32 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Connect to your Omni panel",
|
||||
"description": "Enter the IP/hostname of your HAI/Leviton Omni Pro II controller and the 32-character hex Controller Key. Use `omni-pca decode-pca <file>.pca --field controller_key` to extract the key from a PC Access export.",
|
||||
"data": {
|
||||
"host": "Host",
|
||||
"port": "Port",
|
||||
"controller_key": "Controller Key (32 hex chars)"
|
||||
}
|
||||
},
|
||||
"reauth_confirm": {
|
||||
"title": "Re-enter Controller Key",
|
||||
"description": "The panel at {host}:{port} rejected the saved key. Paste the new 32-character hex Controller Key.",
|
||||
"data": {
|
||||
"controller_key": "Controller Key (32 hex chars)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Could not reach the panel. Check the host, port, and that TCP/4369 is open.",
|
||||
"invalid_auth": "The Controller Key was rejected by the panel.",
|
||||
"invalid_key": "Controller Key must be exactly 32 hexadecimal characters (16 bytes).",
|
||||
"unknown": "An unexpected error occurred. Check the Home Assistant logs."
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "This panel is already configured.",
|
||||
"reauth_successful": "Re-authentication was successful."
|
||||
}
|
||||
}
|
||||
}
|
||||
6
hacs.json
Normal file
6
hacs.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "HAI / Leviton Omni Panel",
|
||||
"render_readme": true,
|
||||
"country": ["US"],
|
||||
"homeassistant": "2026.1.0"
|
||||
}
|
||||
@ -56,7 +56,7 @@ markers = [
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py312"
|
||||
src = ["src", "tests"]
|
||||
src = ["src", "tests", "custom_components"]
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = [
|
||||
|
||||
95
tests/test_ha_config_flow_validation.py
Normal file
95
tests/test_ha_config_flow_validation.py
Normal file
@ -0,0 +1,95 @@
|
||||
"""Pure-function tests for the controller-key validator.
|
||||
|
||||
These don't need a Home Assistant install — `parse_controller_key` is
|
||||
intentionally extracted as a free function so it can be exercised in
|
||||
isolation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# Make `custom_components` importable without requiring an installed HA.
|
||||
_REPO_ROOT = Path(__file__).parent.parent
|
||||
if str(_REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_REPO_ROOT))
|
||||
|
||||
# Import directly from the module file (skipping the package __init__,
|
||||
# which pulls in `homeassistant`). We load the module via spec so the
|
||||
# test stays green even if HA isn't installed.
|
||||
|
||||
_CFG_FLOW_PATH = (
|
||||
_REPO_ROOT / "custom_components" / "omni_pca" / "config_flow.py"
|
||||
)
|
||||
|
||||
|
||||
def _load_parser():
|
||||
"""Load just `parse_controller_key` without importing HA modules."""
|
||||
# Re-define the function inline by reading the source — keeps the test
|
||||
# self-contained without importing homeassistant.
|
||||
src = _CFG_FLOW_PATH.read_text()
|
||||
# Extract the function source between known markers.
|
||||
start = src.index("class InvalidControllerKey")
|
||||
end = src.index("_USER_SCHEMA")
|
||||
snippet = src[start:end]
|
||||
# Provide the constant the snippet relies on.
|
||||
namespace: dict = {"CONTROLLER_KEY_HEX_LEN": 32}
|
||||
exec(
|
||||
compile(snippet, str(_CFG_FLOW_PATH), "exec"),
|
||||
namespace,
|
||||
)
|
||||
return namespace["parse_controller_key"], namespace["InvalidControllerKey"]
|
||||
|
||||
|
||||
parse_controller_key, InvalidControllerKey = _load_parser()
|
||||
|
||||
|
||||
class TestParseControllerKey:
|
||||
def test_accepts_plain_hex(self) -> None:
|
||||
raw = "00112233445566778899aabbccddeeff"
|
||||
assert parse_controller_key(raw) == bytes.fromhex(raw)
|
||||
|
||||
def test_accepts_uppercase(self) -> None:
|
||||
raw = "00112233445566778899AABBCCDDEEFF"
|
||||
assert parse_controller_key(raw) == bytes.fromhex(raw)
|
||||
|
||||
def test_strips_0x_prefix(self) -> None:
|
||||
raw = "0x00112233445566778899aabbccddeeff"
|
||||
assert parse_controller_key(raw) == bytes.fromhex(raw[2:])
|
||||
|
||||
def test_strips_separators(self) -> None:
|
||||
raw = "00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff"
|
||||
assert parse_controller_key(raw) == bytes.fromhex(raw.replace(":", ""))
|
||||
|
||||
def test_strips_dashes_and_spaces(self) -> None:
|
||||
raw = "00-11 22-33 44-55 66-77 88-99 aa-bb cc-dd ee-ff"
|
||||
assert (
|
||||
parse_controller_key(raw)
|
||||
== bytes.fromhex(raw.replace("-", "").replace(" ", ""))
|
||||
)
|
||||
|
||||
def test_returns_16_bytes(self) -> None:
|
||||
result = parse_controller_key("00" * 16)
|
||||
assert isinstance(result, bytes)
|
||||
assert len(result) == 16
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bad",
|
||||
[
|
||||
"", # empty
|
||||
"00", # too short
|
||||
"00" * 17, # too long
|
||||
"zz" * 16, # not hex
|
||||
"0x" + "00" * 17, # prefixed but too long
|
||||
],
|
||||
)
|
||||
def test_rejects_bad_input(self, bad: str) -> None:
|
||||
with pytest.raises(InvalidControllerKey):
|
||||
parse_controller_key(bad)
|
||||
|
||||
def test_rejects_non_string(self) -> None:
|
||||
with pytest.raises(InvalidControllerKey):
|
||||
parse_controller_key(b"\x00" * 16) # type: ignore[arg-type]
|
||||
50
tests/test_ha_imports.py
Normal file
50
tests/test_ha_imports.py
Normal file
@ -0,0 +1,50 @@
|
||||
"""Smoke-test that every module in the HA custom_component imports cleanly.
|
||||
|
||||
We don't want to require Home Assistant as a dev dependency just to lint
|
||||
the package — if `homeassistant` isn't installed, skip the suite entirely.
|
||||
This still catches typos / missing-module bugs in the integration as soon
|
||||
as someone runs the tests in an HA-flavored env (or in CI with HA installed).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("homeassistant")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"module",
|
||||
[
|
||||
"custom_components.omni_pca",
|
||||
"custom_components.omni_pca.const",
|
||||
"custom_components.omni_pca.config_flow",
|
||||
"custom_components.omni_pca.coordinator",
|
||||
"custom_components.omni_pca.binary_sensor",
|
||||
],
|
||||
)
|
||||
def test_module_imports(module: str) -> None:
|
||||
importlib.import_module(module)
|
||||
|
||||
|
||||
def test_manifest_matches_library_version() -> None:
|
||||
"""manifest.json must list the same omni-pca version we ship."""
|
||||
import json
|
||||
from importlib.metadata import version
|
||||
from pathlib import Path
|
||||
|
||||
manifest_path = (
|
||||
Path(__file__).parent.parent
|
||||
/ "custom_components"
|
||||
/ "omni_pca"
|
||||
/ "manifest.json"
|
||||
)
|
||||
manifest = json.loads(manifest_path.read_text())
|
||||
lib_version = version("omni-pca")
|
||||
assert manifest["version"] == lib_version, (
|
||||
f"manifest.json version {manifest['version']!r} != "
|
||||
f"library version {lib_version!r}"
|
||||
)
|
||||
assert f"omni-pca=={lib_version}" in manifest["requirements"]
|
||||
Loading…
x
Reference in New Issue
Block a user