diff --git a/custom_components/omni_pca/README.md b/custom_components/omni_pca/README.md new file mode 100644 index 0000000..310e89e --- /dev/null +++ b/custom_components/omni_pca/README.md @@ -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. diff --git a/custom_components/omni_pca/__init__.py b/custom_components/omni_pca/__init__.py new file mode 100644 index 0000000..de1cdfa --- /dev/null +++ b/custom_components/omni_pca/__init__.py @@ -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 diff --git a/custom_components/omni_pca/binary_sensor.py b/custom_components/omni_pca/binary_sensor.py new file mode 100644 index 0000000..9865c56 --- /dev/null +++ b/custom_components/omni_pca/binary_sensor.py @@ -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() diff --git a/custom_components/omni_pca/config_flow.py b/custom_components/omni_pca/config_flow.py new file mode 100644 index 0000000..1c8aa6d --- /dev/null +++ b/custom_components/omni_pca/config_flow.py @@ -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"]) diff --git a/custom_components/omni_pca/const.py b/custom_components/omni_pca/const.py new file mode 100644 index 0000000..7fc3737 --- /dev/null +++ b/custom_components/omni_pca/const.py @@ -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__) diff --git a/custom_components/omni_pca/coordinator.py b/custom_components/omni_pca/coordinator.py new file mode 100644 index 0000000..6f9f66b --- /dev/null +++ b/custom_components/omni_pca/coordinator.py @@ -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) diff --git a/custom_components/omni_pca/manifest.json b/custom_components/omni_pca/manifest.json new file mode 100644 index 0000000..2735b6d --- /dev/null +++ b/custom_components/omni_pca/manifest.json @@ -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" +} diff --git a/custom_components/omni_pca/strings.json b/custom_components/omni_pca/strings.json new file mode 100644 index 0000000..d2234b5 --- /dev/null +++ b/custom_components/omni_pca/strings.json @@ -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 .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." + } + } +} diff --git a/custom_components/omni_pca/translations/en.json b/custom_components/omni_pca/translations/en.json new file mode 100644 index 0000000..d2234b5 --- /dev/null +++ b/custom_components/omni_pca/translations/en.json @@ -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 .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." + } + } +} diff --git a/hacs.json b/hacs.json new file mode 100644 index 0000000..84a8014 --- /dev/null +++ b/hacs.json @@ -0,0 +1,6 @@ +{ + "name": "HAI / Leviton Omni Panel", + "render_readme": true, + "country": ["US"], + "homeassistant": "2026.1.0" +} diff --git a/pyproject.toml b/pyproject.toml index 96d1866..638a6f5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 = [ diff --git a/tests/test_ha_config_flow_validation.py b/tests/test_ha_config_flow_validation.py new file mode 100644 index 0000000..53e13ef --- /dev/null +++ b/tests/test_ha_config_flow_validation.py @@ -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] diff --git a/tests/test_ha_imports.py b/tests/test_ha_imports.py new file mode 100644 index 0000000..7ed0344 --- /dev/null +++ b/tests/test_ha_imports.py @@ -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"]