Ryan Malloy 2e439364bd 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.
2026-05-10 13:09:27 -06:00

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