custom_components/omni_pca/ — six new platform modules wrapping the
v1.0 client surface. Every command method catches CommandFailedError
and re-raises HomeAssistantError so panel rejections (bad code, etc.)
become user-friendly HA errors instead of silent failures.
alarm_control_panel.py — OmniAreaAlarmPanel per discovered area.
Supports ARM_HOME (Day) / ARM_NIGHT / ARM_AWAY / ARM_VACATION /
ARM_CUSTOM_BYPASS (Day-Instant). State derives from area_status via
pure helpers.security_mode_to_alarm_state which handles arming-in-
progress, entry/exit timers, and active-alarm overrides.
light.py — OmniUnitLight per discovered unit (every unit; non-dimmable
units silently ignore brightness, no harm done). Brightness conversion
via helpers.omni_state_to_ha_brightness / ha_brightness_to_omni_percent
(Omni state byte: 0=off, 1=on, 100..200=brightness percent).
switch.py — OmniZoneBypassSwitch per binary zone. CONFIG entity_category;
pairs with the existing diagnostic 'zone bypassed' binary_sensor.
climate.py — OmniThermostatClimate per discovered thermostat.
Supports OFF / HEAT / COOL / HEAT_COOL hvac_modes; auto / on / diffuse
fan_modes; none / hold / vacation preset_modes. Single-setpoint and
range setpoint via TARGET_TEMPERATURE_RANGE. Fahrenheit native (Omni
panels are F-native; HA handles unit conversion downstream).
sensor.py — analog zones (temperature/humidity/power) + per-thermostat
diagnostic temp/humidity/outdoor sensors + OmniSystemModelSensor
+ OmniLastEventSensor (event_class + parsed event fields as attrs).
button.py — OmniPanelButton per discovered button macro. Programs not
yet exposed because the library lacks RequestProperties for Programs.
event.py — single OmniPanelEvent per panel relaying typed SystemEvents
via _trigger_event. event_types: zone_state_changed, unit_state_changed,
arming_changed, alarm_activated/cleared, ac_lost/restored,
battery_low/restored, user_macro_button, phone_line_dead/restored.
Automations key off platform: event + event_type filter.
helpers.py — extended with security_mode_to_alarm_state,
ARM_SERVICE_TO_SECURITY_MODE, omni_state_to_ha_brightness +
ha_brightness_to_omni_percent, omni/ha_{hvac,fan,hold} round-trips,
fahrenheit_to_omni_raw / celsius_to_omni_raw, analog_zone_device_class,
EVENT_TYPES tuple, event_type_for(class_name).
__init__.py — PLATFORMS extended to all 8 entity types.
scene.py intentionally NOT created — Omni 'scenes' are user-defined
button macros, already covered by the button platform. Documented in
README; revisit if/when the library gains scene-discovery opcodes.
tests/test_ha_helpers.py: +67 unit tests covering every new helper.
331 tests pass (was 264). Ruff clean across src/ tests/ custom_components/.
264 lines
9.2 KiB
Python
264 lines
9.2 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))
|
|
|
|
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
|