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.
116 lines
3.9 KiB
Python
116 lines
3.9 KiB
Python
"""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()
|