Ryan Malloy 57b8aa4b04 HA Phase B: alarm + light + switch + climate + sensor + button + event
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/.
2026-05-10 14:59:18 -06:00

85 lines
2.8 KiB
Python

"""HAI/Leviton Omni Panel integration for Home Assistant.
Forwards every config entry to the full set of platforms wrapping the
omni-pca library: alarm_control_panel (areas), binary_sensor (zones +
system flags), button (panel button macros), climate (thermostats),
event (typed push events), light (units), sensor (analog zones,
thermostat readings, panel telemetry), switch (zone bypass).
"""
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.ALARM_CONTROL_PANEL,
Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.CLIMATE,
Platform.EVENT,
Platform.LIGHT,
Platform.SENSOR,
Platform.SWITCH,
]
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
# *and* the background event task spawned by the first refresh.
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.
``coordinator.async_shutdown()`` cancels the long-lived event-listener
task and closes the ``OmniClient`` socket, so HA's reload doesn't
leak a background coroutine or a half-open TCP connection.
"""
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