The Names block (between SetupData and Voices) was previously walked
as opaque bytes. It's actually a sequence of seven object-family
tables, each storing N × String8(L) per the
clsAbstractNamedItem.ReadName / clsPcaCryptFileStream.ReadString8(out S, byte L)
pattern. Per-slot layout is [1 byte actual length][L bytes name],
with length 0 meaning "unused".
New PcaAccount fields:
* zone_names, unit_names, button_names, code_names,
thermostat_names, area_names, message_names
— each is {1-based slot: name}, only non-empty slots.
Object *properties* (zone_type, area_membership, etc.) aren't
extracted yet — those live in SetupData, which remains opaque.
Names alone unlock the biggest win: meaningful entity labels in
HA from a .pca snapshot.
MockState.from_pca now seeds zones/units/areas/thermostats/buttons
with MockZoneState/MockUnitState/etc. instances carrying just the
name. Defaults handle everything else. A connected client sees the
real panel's names through normal wire discovery (UploadNames
streams them back, properties synth fills the rest).
New end-to-end test verifies the HA integration discovers all 16
zones, 44 units, 16 buttons, 2 thermostats from the live fixture
when the MockPanel is built via MockState.from_pca — proving the
full file → mock → wire → HA pipeline.
Live fixture: 16 zones, 44 units, 16 buttons, 8 codes, 2 thermostats,
0 areas, 8 messages, 330 programs. (Areas in this v1 install have
no user-assigned names — expected.)
Full suite: 499 passed, 1 skipped (fixture-gated).
187 lines
6.9 KiB
Python
187 lines
6.9 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
|
|
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"
|