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:
Ryan Malloy 2026-05-10 13:09:27 -06:00
parent 1901d6ec87
commit 2e439364bd
13 changed files with 937 additions and 1 deletions

View 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.

View 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

View 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()

View 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"])

View 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__)

View 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)

View 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"
}

View 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."
}
}
}

View 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
View File

@ -0,0 +1,6 @@
{
"name": "HAI / Leviton Omni Panel",
"render_readme": true,
"country": ["US"],
"homeassistant": "2026.1.0"
}

View File

@ -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 = [

View 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
View 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"]