omni-pca/tests/test_ha_helpers.py
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

305 lines
11 KiB
Python

"""Pure-function tests for ``custom_components.omni_pca.helpers``.
These never import anything from ``homeassistant.*``, so they run in the
same venv as the rest of the library tests. The HA-bound modules
(coordinator, binary_sensor, __init__) are covered separately by
``test_ha_imports.py`` which uses ``pytest.importorskip("homeassistant")``.
"""
from __future__ import annotations
import importlib.util
import sys
from pathlib import Path
import pytest
# Load the helpers module by file path so we don't have to drag in the
# rest of the package (which imports `homeassistant.*` at module scope).
_REPO_ROOT = Path(__file__).parent.parent
_HELPERS_PATH = _REPO_ROOT / "custom_components" / "omni_pca" / "helpers.py"
def _load_helpers():
spec = importlib.util.spec_from_file_location(
"_omni_pca_helpers_under_test", _HELPERS_PATH
)
assert spec is not None
assert spec.loader is not None
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
helpers = _load_helpers()
class TestDeviceClassForZoneType:
@pytest.mark.parametrize(
("zone_type", "expected"),
[
(0, "opening"), # ENTRY_EXIT
(1, "opening"), # PERIMETER
(2, "motion"), # NIGHT_INTERIOR
(3, "motion"), # AWAY_INTERIOR
(16, "safety"), # PANIC
(17, "safety"), # POLICE_EMERGENCY
(18, "safety"), # SILENT_DURESS
(19, "tamper"), # TAMPER
(20, "tamper"), # LATCHING_TAMPER
(32, "smoke"), # FIRE
(33, "smoke"), # FIRE_EMERGENCY
(34, "gas"), # GAS
(54, "cold"), # FREEZE
(55, "moisture"), # WATER
(56, "tamper"), # FIRE_TAMPER
],
)
def test_known_zone_types(self, zone_type: int, expected: str) -> None:
assert helpers.device_class_for_zone_type(zone_type) == expected
def test_unknown_zone_type_defaults_to_opening(self) -> None:
assert helpers.device_class_for_zone_type(199) == "opening"
def test_zero_is_opening(self) -> None:
assert helpers.device_class_for_zone_type(0) == "opening"
class TestIsBinaryZoneType:
@pytest.mark.parametrize("analog_type", [80, 81, 82, 83, 84])
def test_analog_types_excluded(self, analog_type: int) -> None:
assert helpers.is_binary_zone_type(analog_type) is False
@pytest.mark.parametrize(
"binary_type", [0, 1, 2, 3, 16, 19, 32, 34, 54, 55, 56, 64]
)
def test_binary_types_included(self, binary_type: int) -> None:
assert helpers.is_binary_zone_type(binary_type) is True
class TestUseLatchedAlarmForZone:
@pytest.mark.parametrize(
"latching_type",
[16, 17, 18, 19, 20, 32, 33, 34, 48, 54, 55, 56],
)
def test_latching_types(self, latching_type: int) -> None:
assert helpers.use_latched_alarm_for_zone(latching_type) is True
@pytest.mark.parametrize("contact_type", [0, 1, 2, 3, 4, 5, 6, 7, 8])
def test_contact_and_motion_types_use_current_condition(
self, contact_type: int
) -> None:
assert helpers.use_latched_alarm_for_zone(contact_type) is False
class TestPrettifyName:
@pytest.mark.parametrize(
("raw", "expected"),
[
("FRONT_DOOR", "Front Door"),
("front_door", "Front Door"),
("KITCHEN", "Kitchen"),
(" Trimmed ", "Trimmed"),
("MOTION_KIDS_ROOM", "Motion Kids Room"),
("", ""),
],
)
def test_round_trip(self, raw: str, expected: str) -> None:
assert helpers.prettify_name(raw) == expected
# ----------------------------------------------------------------------------
# Phase B helpers — pure functions used by the new entity platforms
# ----------------------------------------------------------------------------
class TestSecurityModeToAlarmState:
@pytest.mark.parametrize(
("mode", "expected"),
[
(0, "disarmed"),
(1, "armed_home"), # DAY
(2, "armed_night"), # NIGHT
(3, "armed_away"), # AWAY
(4, "armed_vacation"), # VACATION
(5, "armed_custom_bypass"), # DAY_INSTANT
(6, "armed_night"), # NIGHT_DELAYED
],
)
def test_steady_state(self, mode: int, expected: str) -> None:
assert helpers.security_mode_to_alarm_state(mode) == expected
def test_alarm_active_overrides(self) -> None:
assert helpers.security_mode_to_alarm_state(3, alarm_active=True) == "triggered"
def test_entry_timer_pending(self) -> None:
assert helpers.security_mode_to_alarm_state(3, entry_timer=15) == "pending"
def test_exit_timer_arming(self) -> None:
assert helpers.security_mode_to_alarm_state(3, exit_timer=30) == "arming"
@pytest.mark.parametrize("arming_mode", [9, 10, 11, 12, 13, 14])
def test_arming_in_progress_modes(self, arming_mode: int) -> None:
assert helpers.security_mode_to_alarm_state(arming_mode) == "arming"
def test_unknown_mode_falls_back_to_disarmed(self) -> None:
assert helpers.security_mode_to_alarm_state(99) == "disarmed"
class TestArmServiceMapping:
def test_all_services_present(self) -> None:
for svc in ("arm_home", "arm_away", "arm_night", "arm_vacation",
"arm_custom_bypass", "disarm"):
assert svc in helpers.ARM_SERVICE_TO_SECURITY_MODE
def test_round_trip_through_alarm_state(self) -> None:
# Arm with each service, decode the resulting mode back to the HA
# state, and verify the names are sensible.
for svc, expected_state in [
("disarm", "disarmed"),
("arm_home", "armed_home"),
("arm_away", "armed_away"),
("arm_night", "armed_night"),
("arm_vacation", "armed_vacation"),
]:
mode = helpers.ARM_SERVICE_TO_SECURITY_MODE[svc]
assert helpers.security_mode_to_alarm_state(mode) == expected_state
class TestBrightnessConversions:
@pytest.mark.parametrize(
("state", "expected"),
[
(0, None), # off
(1, 255), # plain on, non-dimmable
(100, 1), # 0% via Omni's overlap (level 100 = 0%, but we floor at 1)
(150, 128), # 50%
(200, 255), # 100%
],
)
def test_state_to_ha_brightness(self, state: int, expected: int | None) -> None:
result = helpers.omni_state_to_ha_brightness(state)
assert result == expected
@pytest.mark.parametrize(
("brightness", "expected_percent"),
[
(1, 1),
(128, 50),
(255, 100),
],
)
def test_ha_brightness_to_omni_percent(
self, brightness: int, expected_percent: int
) -> None:
assert helpers.ha_brightness_to_omni_percent(brightness) == expected_percent
def test_zero_brightness(self) -> None:
assert helpers.ha_brightness_to_omni_percent(0) == 0
class TestHvacFanHoldRoundTrip:
@pytest.mark.parametrize(
("omni_mode", "expected_ha"),
[(0, "off"), (1, "heat"), (2, "cool"), (3, "heat_cool"), (4, "heat")],
)
def test_hvac_mapping(self, omni_mode: int, expected_ha: str) -> None:
assert helpers.omni_hvac_to_ha(omni_mode) == expected_ha
@pytest.mark.parametrize(
("ha_mode", "expected_omni"),
[("off", 0), ("heat", 1), ("cool", 2), ("heat_cool", 3)],
)
def test_hvac_inverse(self, ha_mode: str, expected_omni: int) -> None:
assert helpers.ha_hvac_to_omni(ha_mode) == expected_omni
def test_fan_round_trip(self) -> None:
for omni in (0, 1, 2):
ha = helpers.omni_fan_to_ha(omni)
back = helpers.ha_fan_to_omni(ha)
assert back == omni
def test_hold_round_trip(self) -> None:
for omni in (0, 1, 2):
ha = helpers.omni_hold_to_ha(omni)
back = helpers.ha_hold_to_omni(ha)
assert back == omni
def test_legacy_old_on_hold_value(self) -> None:
# Old firmware sentinel 0xFF should map to the same HA preset as 1.
assert helpers.omni_hold_to_ha(0xFF) == helpers.omni_hold_to_ha(1)
class TestTemperatureInverse:
@pytest.mark.parametrize(
("fahrenheit", "expected_raw"),
[
(-40, 0), # bottom of the scale
(0, 44), # ~0°F
(32, 80), # freezing
(72, 124), # room temp
(212, 280), # boiling — above byte range, gets clamped
],
)
def test_fahrenheit_to_raw(self, fahrenheit: float, expected_raw: int) -> None:
result = helpers.fahrenheit_to_omni_raw(fahrenheit)
# We clamp to 0..255 so 212°F (would compute 280) becomes 255.
if expected_raw > 255:
assert result == 255
else:
assert result == expected_raw
def test_inverse_round_trip_at_typical_setpoints(self) -> None:
# Take a few raw values, decode to °F via the linear formula, encode
# back, and verify we get the same byte (within ±1 due to rounding).
for raw in (80, 100, 124, 144, 168, 184):
fahrenheit = round(raw * 9 / 10) - 40
back = helpers.fahrenheit_to_omni_raw(fahrenheit)
assert abs(back - raw) <= 1
class TestAnalogZoneDeviceClass:
@pytest.mark.parametrize(
("zone_type", "expected"),
[
(80, "power"),
(81, "temperature"),
(82, "temperature"),
(83, "temperature"),
(84, "humidity"),
(1, None), # binary zone — not analog
(255, None), # unknown
],
)
def test_mapping(self, zone_type: int, expected: str | None) -> None:
assert helpers.analog_zone_device_class(zone_type) == expected
class TestEventTypeFor:
@pytest.mark.parametrize(
("class_name", "expected"),
[
("ZoneStateChanged", "zone_state_changed"),
("UnitStateChanged", "unit_state_changed"),
("ArmingChanged", "arming_changed"),
("AlarmActivated", "alarm_activated"),
("AlarmCleared", "alarm_cleared"),
("AcLost", "ac_lost"),
("AcRestored", "ac_restored"),
("BatteryLow", "battery_low"),
("BatteryRestored", "battery_restored"),
("UserMacroButton", "user_macro_button"),
("PhoneLineDead", "phone_line_dead"),
("PhoneLineRestored", "phone_line_restored"),
],
)
def test_known_events(self, class_name: str, expected: str) -> None:
assert helpers.event_type_for(class_name) == expected
def test_unknown_event_class(self) -> None:
assert helpers.event_type_for("SomeRandomThing") == "unknown"
def test_event_types_tuple_includes_unknown(self) -> None:
assert "unknown" in helpers.EVENT_TYPES