omni-pca/tests/ha_integration/test_pca_source.py
Ryan Malloy 8141599b4e pca_file: extract per-zone Area assignment from SetupData
Walks the OMNI_PRO_II installer section past ZoneType, DCM stuff,
thermostat config, and the X10/VoltOut/FlagOut/ExpEnc area-group
arrays to land on the 176-byte Zones[].Area block at offset 3106.

The path from instSetupStart (2560) to zone area:

  ZoneType[176] → DCM phones/accounts/type/test(5-byte clsWhen) →
  DCMAlarmCode[176] → 8 DCM bytes → TempFormat..NumAreasUsed (29 bytes
  of misc config including 25-byte CallBackNumber) → X10 area groups
  (16) → VoltOut (8) → FlagOut (15) → ExpEnc (32) → Zones[].Area (176).

Total preamble within installer section = 546 bytes. Verified against
the live fixture: 176 zones all assigned to area 1 (single-area
install), matches expectation.

PcaAccount.zone_areas now carries {slot: area_number}; MockState.from_pca
threads it through MockZoneState.area; mock _build_zone_properties already
serves it. End-to-end test verifies the area flows through to
coordinator.data.zones[*].area.

This was the largest single-RE jump in SetupData decoding so far — got
us past the variable-length DCM block by counting fixed-width fields
out from the known ZoneType end. The clsWhen=5-byte struct was the
last unknown; derived from clsHardwareArray.ReadWhen (clsHardwareArray
.cs:456-468).

Full suite: 499 passed, 1 skipped.
2026-05-12 22:26:25 -06:00

200 lines
7.7 KiB
Python

"""HA-side integration: optional .pca file source for panel programs.
When ``CONF_PCA_PATH`` is set in the entry data, the coordinator should
parse the .pca file at that path (with ``CONF_PCA_KEY`` as the per-install
key) and use those programs *instead* of streaming them over the wire.
The wire-based discovery for everything else (zones, units, etc.) is
unaffected.
"""
from __future__ import annotations
from collections.abc import AsyncIterator
from pathlib import Path
from typing import Any
import pytest
from custom_components.omni_pca.const import (
CONF_CONTROLLER_KEY,
CONF_PCA_KEY,
CONF_PCA_PATH,
DOMAIN,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.core import HomeAssistant
from pytest_homeassistant_custom_component.common import MockConfigEntry
from .conftest import CONTROLLER_KEY_HEX
LIVE_FIXTURE_PLAIN = Path(
"/home/kdm/home-auto/HAI/pca-re/extracted/Our_House.pca.plain"
)
def _materialize_encrypted_fixture(tmp_path: Path) -> tuple[Path, int]:
"""Re-encrypt the plain fixture so parse_pca_file can decrypt it.
parse_pca_file always runs the XOR keystream. The plain dump bypasses
that, so we re-apply the keystream with KEY_EXPORT and write the
result to tmp_path. Returns (file_path, key) the coordinator should use.
"""
from omni_pca.pca_file import KEY_EXPORT, decrypt_pca_bytes
plain = LIVE_FIXTURE_PLAIN.read_bytes()
# XOR is symmetric — "decrypt" of plain bytes with the export key
# produces a valid encrypted .pca that parse_pca_file can read back.
encrypted = decrypt_pca_bytes(plain, KEY_EXPORT)
fixture = tmp_path / "Test_House.pca"
fixture.write_bytes(encrypted)
return fixture, KEY_EXPORT
@pytest.fixture
async def configured_with_pca(
hass: HomeAssistant, panel: tuple[Any, str, int], tmp_path: Path
) -> AsyncIterator[ConfigEntry]:
"""Config entry pointing at a .pca file fixture for programs."""
if not LIVE_FIXTURE_PLAIN.is_file():
pytest.skip(f"live .pca fixture missing: {LIVE_FIXTURE_PLAIN}")
fixture_path, pca_key = _materialize_encrypted_fixture(tmp_path)
_, host, port = panel
entry = MockConfigEntry(
domain=DOMAIN,
data={
CONF_HOST: host,
CONF_PORT: port,
CONF_CONTROLLER_KEY: CONTROLLER_KEY_HEX,
CONF_PCA_PATH: str(fixture_path),
CONF_PCA_KEY: pca_key,
},
title=f"Mock Omni @ {host}:{port} (with .pca)",
unique_id=f"{host}:{port}",
)
entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
try:
yield entry
finally:
if entry.entry_id in hass.data.get(DOMAIN, {}):
await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
async def test_pca_source_overrides_wire_programs(
hass: HomeAssistant, configured_with_pca: ConfigEntry
) -> None:
"""The fixture .pca has 330 defined programs (Phase 1 recon). The mock
panel only seeded 3 in conftest. When pca_path is set, the .pca count
wins — proving the coordinator routed through _discover_programs_from_pca,
not iter_programs."""
coordinator = hass.data[DOMAIN][configured_with_pca.entry_id]
assert len(coordinator.data.programs) == 330
# Sanity: the diagnostic sensor reflects the .pca count, not the mock seed.
sensors = [
s for s in hass.states.async_all("sensor")
if "panel_programs" in s.entity_id
]
assert len(sensors) == 1
assert int(sensors[0].state) == 330
async def test_mockpanel_from_pca_drives_full_ha_discovery(
hass: HomeAssistant, tmp_path: Path
) -> None:
"""End-to-end: build a MockPanel state straight from the live .pca,
then point HA at that mock with no other configuration. The
integration should discover *every* named zone / unit / button /
thermostat from the .pca via the normal wire path — no .pca config
needed, because the mock is now serving real data.
"""
if not LIVE_FIXTURE_PLAIN.is_file():
pytest.skip(f"live .pca fixture missing: {LIVE_FIXTURE_PLAIN}")
from custom_components.omni_pca.const import CONF_CONTROLLER_KEY
from omni_pca.mock_panel import MockPanel, MockState
from omni_pca.pca_file import KEY_EXPORT, decrypt_pca_bytes
encrypted = decrypt_pca_bytes(LIVE_FIXTURE_PLAIN.read_bytes(), KEY_EXPORT)
state = MockState.from_pca(encrypted, key=KEY_EXPORT)
# Sanity — the from_pca seeding matches the live fixture's names.
assert len(state.zones) == 16
assert len(state.units) == 44
panel = MockPanel(
controller_key=bytes(range(16)), # matches CONTROLLER_KEY_HEX
state=state,
)
async with panel.serve(host="127.0.0.1") as (host, port):
entry = MockConfigEntry(
domain=DOMAIN,
data={
CONF_HOST: host,
CONF_PORT: port,
CONF_CONTROLLER_KEY: CONTROLLER_KEY_HEX,
},
title=f"Mock Omni @ {host}:{port} (from .pca)",
unique_id=f"{host}:{port}",
)
entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
try:
coordinator = hass.data[DOMAIN][entry.entry_id]
# All 16 zones surfaced through normal wire discovery.
assert len(coordinator.data.zones) == 16
# Units, buttons, thermostats too.
assert len(coordinator.data.units) == 44
assert len(coordinator.data.buttons) == 16
assert len(coordinator.data.thermostats) == 2
# And the programs sensor reflects 330 from wire iter_programs.
assert len(coordinator.data.programs) == 330
# Zone types flowed from SetupData → mock → wire Properties
# reply → HA's ZoneProperties parser.
zone_types_by_slot = {
idx: z.zone_type for idx, z in coordinator.data.zones.items()
}
assert zone_types_by_slot[1] == 0x00 # GARAGE ENTRY → EntryExit
assert zone_types_by_slot[3] == 0x01 # BACK DOOR → Perimeter
assert zone_types_by_slot[7] == 0x03 # LIVINGROOM MOT → AwayInt
assert zone_types_by_slot[11] == 0x55 # OUTSIDE TEMP → outdoor temp
# Per-zone area assignments — single-area install, every
# zone surfaces as area=1 through the wire Properties reply.
for idx, z in coordinator.data.zones.items():
assert z.area == 1, f"zone {idx} expected area=1 got {z.area}"
finally:
await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
async def test_pca_path_validation_rejects_missing_file(
hass: HomeAssistant, tmp_path: Path
) -> None:
"""The config-flow validator returns ``pca_not_found`` for an absent
file. We exercise the helper directly to avoid spinning a full mock
panel just for the validation branch."""
from custom_components.omni_pca.config_flow import OmniConfigFlow
flow = OmniConfigFlow()
flow.hass = hass
err = await flow._validate_pca(str(tmp_path / "does-not-exist.pca"), 0)
assert err == "pca_not_found"
async def test_pca_path_validation_rejects_garbage(
hass: HomeAssistant, tmp_path: Path
) -> None:
"""A file that doesn't decode as a .pca returns ``pca_decode_failed``."""
from custom_components.omni_pca.config_flow import OmniConfigFlow
garbage = tmp_path / "garbage.pca"
garbage.write_bytes(b"not a real pca file" * 1000)
flow = OmniConfigFlow()
flow.hass = hass
err = await flow._validate_pca(str(garbage), 0)
assert err == "pca_decode_failed"