Ryan Malloy b412dc0f37 HA: discover programs over the wire + diagnostic sensor
Coordinator's _discover_programs is no longer a placeholder. It now
drives client.iter_programs() (v2 path) or the v1 adapter's forward
to OmniClientV1.iter_programs (v1 path), populating OmniData.programs
with decoded Program records keyed by slot. Errors are logged and
swallowed so partial enumeration doesn't break entry setup —
programs are non-critical telemetry.

OmniData.programs is now dict[int, Program] rather than the
ProgramProperties dict that was an empty placeholder. The
ProgramProperties dataclass remains in models.py for the Properties
opcode reply path; only the coordinator's value type changed.

New OmniProgramsSensor on the sensor platform: a single diagnostic
entity per panel whose state is the count of defined programs and
whose 'programs' attribute lists each program's slot, type name,
and schedule fields. Easy to consume from automations and the
developer-states UI.

Mock fixture seeds three programs (TIMED+TIMED+EVENT at slots 12 /
42 / 99). New integration test verifies the sensor enumerates them
in slot-ascending order with the expected per-record fields.

Full suite: 494 passed, 1 skipped (fixture-gated).
2026-05-12 19:10:32 -06:00

317 lines
11 KiB
Python

"""Sensor platform — analog zones, thermostat readings, panel telemetry.
We deliberately re-expose thermostat current_temperature / humidity as
diagnostic sensors (in addition to the climate entity) so users can
plot history. The climate entity remains the canonical control surface.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorStateClass,
)
from homeassistant.const import PERCENTAGE, UnitOfTemperature
from homeassistant.helpers.entity import EntityCategory
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import OmniDataUpdateCoordinator
from .helpers import (
SENSOR_DEVICE_CLASS_HUMIDITY,
SENSOR_DEVICE_CLASS_TEMPERATURE,
analog_zone_device_class,
is_binary_zone_type,
prettify_name,
)
if TYPE_CHECKING:
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
_DEVICE_CLASS_STR_TO_ENUM: dict[str, SensorDeviceClass] = {
SENSOR_DEVICE_CLASS_TEMPERATURE: SensorDeviceClass.TEMPERATURE,
SENSOR_DEVICE_CLASS_HUMIDITY: SensorDeviceClass.HUMIDITY,
"power": SensorDeviceClass.POWER,
}
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
coordinator: OmniDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
entities: list[SensorEntity] = []
# Analog zones (temperature / humidity / energy)
for index in sorted(coordinator.data.zones):
props = coordinator.data.zones[index]
if is_binary_zone_type(props.zone_type):
continue
device_class_str = analog_zone_device_class(props.zone_type)
if device_class_str is None:
continue
entities.append(
OmniAnalogZoneSensor(coordinator, index, device_class_str)
)
# Per-thermostat diagnostic sensors
for index in sorted(coordinator.data.thermostats):
entities.append(OmniThermostatTempSensor(coordinator, index))
entities.append(OmniThermostatHumiditySensor(coordinator, index))
entities.append(OmniThermostatOutdoorTempSensor(coordinator, index))
entities.append(OmniSystemModelSensor(coordinator))
entities.append(OmniLastEventSensor(coordinator))
entities.append(OmniProgramsSensor(coordinator))
async_add_entities(entities)
# --------------------------------------------------------------------------
# Analog zones
# --------------------------------------------------------------------------
class OmniAnalogZoneSensor(
CoordinatorEntity[OmniDataUpdateCoordinator], SensorEntity
):
_attr_has_entity_name = True
_attr_state_class = SensorStateClass.MEASUREMENT
def __init__(
self,
coordinator: OmniDataUpdateCoordinator,
index: int,
device_class_str: str,
) -> None:
super().__init__(coordinator)
self._index = index
self._attr_unique_id = f"{coordinator.unique_id}-zone-{index}-analog"
props = coordinator.data.zones[index]
self._attr_name = prettify_name(props.name) or f"Zone {index}"
self._attr_device_info = coordinator.device_info
self._attr_device_class = _DEVICE_CLASS_STR_TO_ENUM.get(device_class_str)
if device_class_str == SENSOR_DEVICE_CLASS_TEMPERATURE:
self._attr_native_unit_of_measurement = UnitOfTemperature.FAHRENHEIT
elif device_class_str == SENSOR_DEVICE_CLASS_HUMIDITY:
self._attr_native_unit_of_measurement = PERCENTAGE
@property
def native_value(self) -> float | int | None:
status = self.coordinator.data.zone_status.get(self._index)
if status is None:
return None
# Reuse the linear temp formula for temperature zones; humidity
# zones report the loop byte as the percentage directly.
if self._attr_device_class == SensorDeviceClass.TEMPERATURE:
return round(status.loop * 9 / 10) - 40
return status.loop
# --------------------------------------------------------------------------
# Thermostat diagnostic sensors
# --------------------------------------------------------------------------
class _ThermostatBase(CoordinatorEntity[OmniDataUpdateCoordinator], SensorEntity):
_attr_has_entity_name = True
_attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_state_class = SensorStateClass.MEASUREMENT
def __init__(
self, coordinator: OmniDataUpdateCoordinator, index: int
) -> None:
super().__init__(coordinator)
self._index = index
self._attr_device_info = coordinator.device_info
@property
def _status(self): # type: ignore[no-untyped-def]
return self.coordinator.data.thermostat_status.get(self._index)
class OmniThermostatTempSensor(_ThermostatBase):
_attr_device_class = SensorDeviceClass.TEMPERATURE
_attr_native_unit_of_measurement = UnitOfTemperature.FAHRENHEIT
def __init__(
self, coordinator: OmniDataUpdateCoordinator, index: int
) -> None:
super().__init__(coordinator, index)
self._attr_unique_id = f"{coordinator.unique_id}-thermostat-{index}-temp"
props = coordinator.data.thermostats[index]
base = prettify_name(props.name) or f"Thermostat {index}"
self._attr_name = f"{base} Temperature"
@property
def native_value(self) -> float | None:
s = self._status
if s is None or s.temperature_raw == 0:
return None
return round(s.temperature_f, 1)
class OmniThermostatHumiditySensor(_ThermostatBase):
_attr_device_class = SensorDeviceClass.HUMIDITY
_attr_native_unit_of_measurement = PERCENTAGE
def __init__(
self, coordinator: OmniDataUpdateCoordinator, index: int
) -> None:
super().__init__(coordinator, index)
self._attr_unique_id = f"{coordinator.unique_id}-thermostat-{index}-humidity"
props = coordinator.data.thermostats[index]
base = prettify_name(props.name) or f"Thermostat {index}"
self._attr_name = f"{base} Humidity"
@property
def native_value(self) -> int | None:
s = self._status
if s is None or s.humidity_raw == 0:
return None
return int(s.humidity_raw)
class OmniThermostatOutdoorTempSensor(_ThermostatBase):
_attr_device_class = SensorDeviceClass.TEMPERATURE
_attr_native_unit_of_measurement = UnitOfTemperature.FAHRENHEIT
def __init__(
self, coordinator: OmniDataUpdateCoordinator, index: int
) -> None:
super().__init__(coordinator, index)
self._attr_unique_id = f"{coordinator.unique_id}-thermostat-{index}-outdoor"
props = coordinator.data.thermostats[index]
base = prettify_name(props.name) or f"Thermostat {index}"
self._attr_name = f"{base} Outdoor Temperature"
@property
def native_value(self) -> float | None:
s = self._status
if s is None or s.outdoor_temperature_raw == 0:
return None
return round(s.outdoor_temperature_f, 1)
# --------------------------------------------------------------------------
# Panel telemetry
# --------------------------------------------------------------------------
class OmniSystemModelSensor(
CoordinatorEntity[OmniDataUpdateCoordinator], SensorEntity
):
"""Static text sensor: model + firmware. Helps confirm the integration
talked to the panel without needing to dig into Devices & Services."""
_attr_has_entity_name = True
_attr_entity_category = EntityCategory.DIAGNOSTIC
def __init__(self, coordinator: OmniDataUpdateCoordinator) -> None:
super().__init__(coordinator)
self._attr_unique_id = f"{coordinator.unique_id}-system-model"
self._attr_name = "Panel Model"
self._attr_device_info = coordinator.device_info
@property
def native_value(self) -> str | None:
info = self.coordinator.data.system_info
if info is None:
return None
return f"{info.model_name} {info.firmware_version}"
class OmniLastEventSensor(
CoordinatorEntity[OmniDataUpdateCoordinator], SensorEntity
):
"""Diagnostic text sensor showing the most recent push event class name."""
_attr_has_entity_name = True
_attr_entity_category = EntityCategory.DIAGNOSTIC
def __init__(self, coordinator: OmniDataUpdateCoordinator) -> None:
super().__init__(coordinator)
self._attr_unique_id = f"{coordinator.unique_id}-last-event"
self._attr_name = "Last Panel Event"
self._attr_device_info = coordinator.device_info
@property
def native_value(self) -> str | None:
ev = self.coordinator.data.last_event
if ev is None:
return None
return type(ev).__name__
@property
def extra_state_attributes(self) -> dict[str, Any] | None:
ev = self.coordinator.data.last_event
if ev is None:
return None
result: dict[str, Any] = {"event_class": type(ev).__name__}
for key in (
"zone_index", "unit_index", "area_index", "user_index",
"new_state", "new_mode", "alarm_type",
):
if hasattr(ev, key):
result[key] = getattr(ev, key)
return result
class OmniProgramsSensor(
CoordinatorEntity[OmniDataUpdateCoordinator], SensorEntity
):
"""Diagnostic sensor exposing the panel's automation programs.
State value is the count of defined programs. The ``programs``
attribute carries a list of per-program summaries — a stable,
JSON-serializable view automations and template sensors can read.
"""
_attr_has_entity_name = True
_attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_state_class = SensorStateClass.MEASUREMENT
def __init__(self, coordinator: OmniDataUpdateCoordinator) -> None:
super().__init__(coordinator)
self._attr_unique_id = f"{coordinator.unique_id}-programs"
self._attr_name = "Panel Programs"
self._attr_device_info = coordinator.device_info
@property
def native_value(self) -> int:
return len(self.coordinator.data.programs)
@property
def extra_state_attributes(self) -> dict[str, Any]:
from omni_pca.programs import ProgramType
summaries: list[dict[str, Any]] = []
for slot in sorted(self.coordinator.data.programs):
p = self.coordinator.data.programs[slot]
try:
type_name = ProgramType(p.prog_type).name
except ValueError:
type_name = f"UNKNOWN({p.prog_type})"
summaries.append(
{
"slot": slot,
"type": type_name,
"cmd": p.cmd,
"par": p.par,
"pr2": p.pr2,
"month": p.month,
"day": p.day,
"days": p.days,
"hour": p.hour,
"minute": p.minute,
}
)
return {"programs": summaries}