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

413 lines
15 KiB
Python

"""Pure helper functions for the omni_pca integration.
Anything in this module is deliberately decoupled from Home Assistant and
the live OmniClient so it can be unit-tested without either dependency.
The HA-side code (binary_sensor, etc.) imports these and converts the
returned strings to ``BinarySensorDeviceClass`` enum members.
"""
from __future__ import annotations
from typing import Final
# String values that correspond 1:1 to HA's BinarySensorDeviceClass enum
# members. We return strings here (instead of importing the enum) so this
# module stays importable without Home Assistant in the venv.
DEVICE_CLASS_OPENING: Final = "opening"
DEVICE_CLASS_DOOR: Final = "door"
DEVICE_CLASS_WINDOW: Final = "window"
DEVICE_CLASS_MOTION: Final = "motion"
DEVICE_CLASS_SMOKE: Final = "smoke"
DEVICE_CLASS_GAS: Final = "gas"
DEVICE_CLASS_MOISTURE: Final = "moisture"
DEVICE_CLASS_TAMPER: Final = "tamper"
DEVICE_CLASS_SAFETY: Final = "safety"
DEVICE_CLASS_PROBLEM: Final = "problem"
DEVICE_CLASS_SOUND: Final = "sound"
DEVICE_CLASS_HEAT: Final = "heat"
DEVICE_CLASS_COLD: Final = "cold"
# Maps the Omni ``enuZoneType`` byte (see ``omni_pca.models.ZoneType``) to
# a HA ``BinarySensorDeviceClass`` string. The mapping is a judgement
# call — Omni's zone-type taxonomy is finer-grained than HA's binary
# sensor classes, so we collapse a few buckets:
#
# * Perimeter / entry-exit / latching variants → opening
# (most installs use these for door/window contacts)
# * Interior / night / away interior → motion (PIRs)
# * Fire family (FIRE/FIRE_EMERGENCY/FIRE_TAMPER) → smoke
# * Water / freeze → moisture / cold
# * Gas → gas
# * Tamper / latching tamper → tamper
# * Panic / police / silent duress / aux-emerg → safety
# * Temperature / humidity / aux → not a binary sensor
# (callers should skip — see ``is_binary_zone_type``)
#
# The default for any unmapped value is "opening", which matches the
# dominant residential install (perimeter contact).
_ZONE_TYPE_TO_DEVICE_CLASS: dict[int, str] = {
# Burglary / contact zones
0: DEVICE_CLASS_OPENING, # ENTRY_EXIT
1: DEVICE_CLASS_OPENING, # PERIMETER
4: DEVICE_CLASS_OPENING, # DOUBLE_ENTRY_DELAY
5: DEVICE_CLASS_OPENING, # QUAD_ENTRY_DELAY
6: DEVICE_CLASS_OPENING, # LATCHING_PERIMETER
67: DEVICE_CLASS_OPENING, # EXIT_TERMINATOR
# Motion zones
2: DEVICE_CLASS_MOTION, # NIGHT_INTERIOR
3: DEVICE_CLASS_MOTION, # AWAY_INTERIOR
7: DEVICE_CLASS_MOTION, # LATCHING_NIGHT_INTERIOR
8: DEVICE_CLASS_MOTION, # LATCHING_AWAY_INTERIOR
# Panic / duress / police family
16: DEVICE_CLASS_SAFETY, # PANIC
17: DEVICE_CLASS_SAFETY, # POLICE_EMERGENCY
18: DEVICE_CLASS_SAFETY, # SILENT_DURESS
48: DEVICE_CLASS_SAFETY, # AUX_EMERGENCY
# Tamper
19: DEVICE_CLASS_TAMPER, # TAMPER
20: DEVICE_CLASS_TAMPER, # LATCHING_TAMPER
56: DEVICE_CLASS_TAMPER, # FIRE_TAMPER (treat as tamper, not smoke)
# Fire family
32: DEVICE_CLASS_SMOKE, # FIRE
33: DEVICE_CLASS_SMOKE, # FIRE_EMERGENCY
# Other safety / environmental
34: DEVICE_CLASS_GAS, # GAS
49: DEVICE_CLASS_PROBLEM, # TROUBLE
54: DEVICE_CLASS_COLD, # FREEZE
55: DEVICE_CLASS_MOISTURE, # WATER
# Sound / aux
64: DEVICE_CLASS_SOUND, # AUXILIARY (loose mapping; use sound)
65: DEVICE_CLASS_OPENING, # KEYSWITCH
66: DEVICE_CLASS_OPENING, # SHUNT_LOCK
}
# Zone-type bytes that don't map to a binary sensor at all — they're
# numeric readings (temperature, humidity, energy) and should be exposed
# via the sensor platform in Phase B instead. We skip these in
# binary_sensor setup.
_ANALOG_ZONE_TYPES: frozenset[int] = frozenset({
80, # ENERGY_SAVER
81, # OUTDOOR_TEMP
82, # TEMPERATURE
83, # TEMP_ALARM
84, # HUMIDITY
})
def device_class_for_zone_type(zone_type: int) -> str:
"""Return the HA ``BinarySensorDeviceClass`` value for an Omni zone type.
Defaults to ``"opening"`` — the most common contact-sensor case — for
any zone-type byte we don't have an explicit mapping for. Callers
should check :func:`is_binary_zone_type` first to decide whether the
zone makes sense as a binary sensor at all.
"""
return _ZONE_TYPE_TO_DEVICE_CLASS.get(zone_type, DEVICE_CLASS_OPENING)
def is_binary_zone_type(zone_type: int) -> bool:
"""True iff this zone type belongs on the binary_sensor platform.
Analog/numeric zone types (temperature, humidity, energy savers) are
sensor-platform candidates, not binary sensors, so we filter them out
here so the coordinator's discovery doesn't have to know.
"""
return zone_type not in _ANALOG_ZONE_TYPES
# Zone types whose live ``is_on`` semantics should be derived from the
# *latched* alarm bit (alarm tripped) rather than the current condition
# bit (open/closed). Smoke/fire/gas/water/freeze/panic are latching by
# nature — a smoke detector that flashed for one second still wants to
# read "on" until the user clears the alarm.
_LATCHED_ALARM_ZONE_TYPES: frozenset[int] = frozenset({
16, 17, 18, # panic family
19, 20, 56, # tamper family
32, 33, # fire family
34, # gas
48, # aux emergency
54, 55, # freeze, water
})
def use_latched_alarm_for_zone(zone_type: int) -> bool:
"""True if this zone's ``is_on`` should track the latched alarm bit.
For door/window/motion zones we use the *current condition* bit (live
open/closed). For latching alarm zones (smoke, water, panic, …) we
instead use the latched-tripped bit so a brief sensor blip stays
visible to the user until the alarm is cleared.
"""
return zone_type in _LATCHED_ALARM_ZONE_TYPES
def prettify_name(name: str) -> str:
"""Convert the panel's ``FRONT_DOOR`` style name into ``Front Door``.
Returns an empty string unchanged so callers can use truthiness to
detect "no name configured on this index".
"""
return name.replace("_", " ").strip().title()
# --------------------------------------------------------------------------
# Alarm panel state translation
# --------------------------------------------------------------------------
# String values matching HA's AlarmControlPanelState enum so this module
# stays importable without Home Assistant in the venv.
ALARM_STATE_DISARMED: Final = "disarmed"
ALARM_STATE_ARMED_HOME: Final = "armed_home"
ALARM_STATE_ARMED_AWAY: Final = "armed_away"
ALARM_STATE_ARMED_NIGHT: Final = "armed_night"
ALARM_STATE_ARMED_VACATION: Final = "armed_vacation"
ALARM_STATE_ARMED_CUSTOM_BYPASS: Final = "armed_custom_bypass"
ALARM_STATE_ARMING: Final = "arming"
ALARM_STATE_PENDING: Final = "pending"
ALARM_STATE_TRIGGERED: Final = "triggered"
# Maps SecurityMode (steady-state values) to HA alarm states. Arming-in-
# progress modes (9..14) get mapped via _ARMING_MODE_TO_FINAL — an arming
# area is always reported as ARMING regardless of the destination mode.
_SECURITY_MODE_TO_ALARM_STATE: dict[int, str] = {
0: ALARM_STATE_DISARMED,
1: ALARM_STATE_ARMED_HOME, # DAY
2: ALARM_STATE_ARMED_NIGHT, # NIGHT
3: ALARM_STATE_ARMED_AWAY, # AWAY
4: ALARM_STATE_ARMED_VACATION, # VACATION
5: ALARM_STATE_ARMED_CUSTOM_BYPASS, # DAY_INSTANT
6: ALARM_STATE_ARMED_NIGHT, # NIGHT_DELAYED
}
_ARMING_MODES: frozenset[int] = frozenset({9, 10, 11, 12, 13, 14})
def security_mode_to_alarm_state(
mode: int,
alarm_active: bool = False,
entry_timer: int = 0,
exit_timer: int = 0,
) -> str:
"""Map an Omni SecurityMode to a HA alarm_control_panel state string.
Priority order:
1. ``alarm_active`` → triggered
2. ``entry_timer > 0`` → pending
3. arming-in-progress modes or ``exit_timer > 0`` → arming
4. steady-state mapping
"""
if alarm_active:
return ALARM_STATE_TRIGGERED
if entry_timer > 0:
return ALARM_STATE_PENDING
if mode in _ARMING_MODES or exit_timer > 0:
return ALARM_STATE_ARMING
return _SECURITY_MODE_TO_ALARM_STATE.get(mode, ALARM_STATE_DISARMED)
# Inverse for the four standard arm services HA exposes. Returned ints are
# the SecurityMode values to send via execute_security_command.
ARM_SERVICE_TO_SECURITY_MODE: dict[str, int] = {
"arm_home": 1, # DAY
"arm_away": 3, # AWAY
"arm_night": 2, # NIGHT
"arm_vacation": 4, # VACATION
"arm_custom_bypass": 5, # DAY_INSTANT
"disarm": 0, # OFF
}
# --------------------------------------------------------------------------
# Light brightness conversion (Omni 0..100 ↔ HA 0..255)
# --------------------------------------------------------------------------
def omni_state_to_ha_brightness(state: int) -> int | None:
"""Decode a UnitStatus.state byte into HA brightness (1..255) or None.
Returns None when the unit is off (state == 0). For state == 1 (plain
"on", non-dimmable) returns 255. For state in 100..200 returns
``round((state - 100) * 255 / 100)`` clamped to 1..255.
"""
if state == 0:
return None
if state == 1:
return 255
if 100 <= state <= 200:
percent = state - 100
return max(1, min(255, round(percent * 255 / 100)))
# Scene levels (2..13) and ramping codes (17..25): treat as on, full.
return 255
def ha_brightness_to_omni_percent(brightness: int) -> int:
"""Convert HA's 1..255 brightness to Omni's 0..100 percent.
Brightness 0 is invalid here (use turn_off); 1 maps to 1%, 255 to 100%.
"""
if brightness <= 0:
return 0
if brightness >= 255:
return 100
return max(1, min(100, round(brightness * 100 / 255)))
# --------------------------------------------------------------------------
# HVAC mode translation
# --------------------------------------------------------------------------
HVAC_MODE_OFF: Final = "off"
HVAC_MODE_HEAT: Final = "heat"
HVAC_MODE_COOL: Final = "cool"
HVAC_MODE_HEAT_COOL: Final = "heat_cool"
HVAC_MODE_AUX_HEAT: Final = "heat" # HA collapses emergency-heat into heat + preset
_OMNI_HVAC_TO_HA: dict[int, str] = {
0: HVAC_MODE_OFF,
1: HVAC_MODE_HEAT,
2: HVAC_MODE_COOL,
3: HVAC_MODE_HEAT_COOL,
4: HVAC_MODE_HEAT, # EMERGENCY_HEAT — HA treats as heat + a preset
}
_HA_HVAC_TO_OMNI: dict[str, int] = {
HVAC_MODE_OFF: 0,
HVAC_MODE_HEAT: 1,
HVAC_MODE_COOL: 2,
HVAC_MODE_HEAT_COOL: 3,
}
def omni_hvac_to_ha(mode: int) -> str:
return _OMNI_HVAC_TO_HA.get(mode, HVAC_MODE_OFF)
def ha_hvac_to_omni(mode: str) -> int:
return _HA_HVAC_TO_OMNI.get(mode, 0)
_OMNI_FAN_TO_HA: dict[int, str] = {0: "auto", 1: "on", 2: "diffuse"}
_HA_FAN_TO_OMNI: dict[str, int] = {"auto": 0, "on": 1, "diffuse": 2, "cycle": 2}
def omni_fan_to_ha(mode: int) -> str:
return _OMNI_FAN_TO_HA.get(mode, "auto")
def ha_fan_to_omni(mode: str) -> int:
return _HA_FAN_TO_OMNI.get(mode, 0)
_OMNI_HOLD_TO_HA: dict[int, str] = {0: "none", 1: "hold", 2: "vacation", 0xFF: "hold"}
_HA_HOLD_TO_OMNI: dict[str, int] = {"none": 0, "hold": 1, "vacation": 2}
def omni_hold_to_ha(mode: int) -> str:
return _OMNI_HOLD_TO_HA.get(mode, "none")
def ha_hold_to_omni(mode: str) -> int:
return _HA_HOLD_TO_OMNI.get(mode, 0)
# --------------------------------------------------------------------------
# Temperature: HA °F → Omni raw byte
# --------------------------------------------------------------------------
#
# Omni encodes temperature linearly. Per clsText.cs (DecodeTempRaw):
# °F = round(raw * 9 / 10) - 40
# °C = raw / 2 - 40
# Inverse:
# raw = round((°F + 40) * 10 / 9)
def fahrenheit_to_omni_raw(f: float) -> int:
"""Inverse of omni_temp_to_fahrenheit. Clamps to the valid 0..255 byte."""
raw = round((f + 40) * 10 / 9)
return max(0, min(255, raw))
def celsius_to_omni_raw(c: float) -> int:
"""Inverse of omni_temp_to_celsius. Clamps to the valid 0..255 byte."""
raw = round((c + 40) * 2)
return max(0, min(255, raw))
# --------------------------------------------------------------------------
# Analog zone → sensor device class
# --------------------------------------------------------------------------
SENSOR_DEVICE_CLASS_TEMPERATURE: Final = "temperature"
SENSOR_DEVICE_CLASS_HUMIDITY: Final = "humidity"
SENSOR_DEVICE_CLASS_POWER: Final = "power"
_ANALOG_ZONE_TYPE_TO_DEVICE_CLASS: dict[int, str] = {
80: SENSOR_DEVICE_CLASS_POWER, # ENERGY_SAVER
81: SENSOR_DEVICE_CLASS_TEMPERATURE, # OUTDOOR_TEMP
82: SENSOR_DEVICE_CLASS_TEMPERATURE, # TEMPERATURE
83: SENSOR_DEVICE_CLASS_TEMPERATURE, # TEMP_ALARM
84: SENSOR_DEVICE_CLASS_HUMIDITY, # HUMIDITY
}
def analog_zone_device_class(zone_type: int) -> str | None:
"""Return the HA SensorDeviceClass string for an analog zone, or None."""
return _ANALOG_ZONE_TYPE_TO_DEVICE_CLASS.get(zone_type)
# --------------------------------------------------------------------------
# Event surfacing
# --------------------------------------------------------------------------
# Snake_case event-type strings exposed by the EventEntity.
EVENT_TYPE_ZONE_STATE_CHANGED: Final = "zone_state_changed"
EVENT_TYPE_UNIT_STATE_CHANGED: Final = "unit_state_changed"
EVENT_TYPE_ARMING_CHANGED: Final = "arming_changed"
EVENT_TYPE_ALARM_ACTIVATED: Final = "alarm_activated"
EVENT_TYPE_ALARM_CLEARED: Final = "alarm_cleared"
EVENT_TYPE_AC_LOST: Final = "ac_lost"
EVENT_TYPE_AC_RESTORED: Final = "ac_restored"
EVENT_TYPE_BATTERY_LOW: Final = "battery_low"
EVENT_TYPE_BATTERY_RESTORED: Final = "battery_restored"
EVENT_TYPE_USER_MACRO_BUTTON: Final = "user_macro_button"
EVENT_TYPE_PHONE_LINE_DEAD: Final = "phone_line_dead"
EVENT_TYPE_PHONE_LINE_RESTORED: Final = "phone_line_restored"
EVENT_TYPE_UNKNOWN: Final = "unknown"
EVENT_TYPES: tuple[str, ...] = (
EVENT_TYPE_ZONE_STATE_CHANGED,
EVENT_TYPE_UNIT_STATE_CHANGED,
EVENT_TYPE_ARMING_CHANGED,
EVENT_TYPE_ALARM_ACTIVATED,
EVENT_TYPE_ALARM_CLEARED,
EVENT_TYPE_AC_LOST,
EVENT_TYPE_AC_RESTORED,
EVENT_TYPE_BATTERY_LOW,
EVENT_TYPE_BATTERY_RESTORED,
EVENT_TYPE_USER_MACRO_BUTTON,
EVENT_TYPE_PHONE_LINE_DEAD,
EVENT_TYPE_PHONE_LINE_RESTORED,
EVENT_TYPE_UNKNOWN,
)
def event_type_for(class_name: str) -> str:
"""Map a SystemEvent subclass name to its snake_case event type."""
mapping = {
"ZoneStateChanged": EVENT_TYPE_ZONE_STATE_CHANGED,
"UnitStateChanged": EVENT_TYPE_UNIT_STATE_CHANGED,
"ArmingChanged": EVENT_TYPE_ARMING_CHANGED,
"AlarmActivated": EVENT_TYPE_ALARM_ACTIVATED,
"AlarmCleared": EVENT_TYPE_ALARM_CLEARED,
"AcLost": EVENT_TYPE_AC_LOST,
"AcRestored": EVENT_TYPE_AC_RESTORED,
"BatteryLow": EVENT_TYPE_BATTERY_LOW,
"BatteryRestored": EVENT_TYPE_BATTERY_RESTORED,
"UserMacroButton": EVENT_TYPE_USER_MACRO_BUTTON,
"PhoneLineDead": EVENT_TYPE_PHONE_LINE_DEAD,
"PhoneLineRestored": EVENT_TYPE_PHONE_LINE_RESTORED,
}
return mapping.get(class_name, EVENT_TYPE_UNKNOWN)