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]
|
[tool.ruff]
|
||||||
line-length = 100
|
line-length = 100
|
||||||
target-version = "py312"
|
target-version = "py312"
|
||||||
src = ["src", "tests"]
|
src = ["src", "tests", "custom_components"]
|
||||||
|
|
||||||
[tool.ruff.lint]
|
[tool.ruff.lint]
|
||||||
select = [
|
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