HA: optional .pca file as alternate source for panel programs
Adds CONF_PCA_PATH + CONF_PCA_KEY config-flow fields. When set, the coordinator parses programs from the .pca file at that path instead of streaming them over the wire on every entry refresh. Useful for: * deployments where wire enumeration is slow (1500-slot iteration) * offline snapshots when the panel is unreachable * deterministic test setups against a known fixture The config-flow validates the file is readable and decrypts cleanly, surfacing pca_not_found / pca_decode_failed errors via the strings/ en.json translations. The .pca path is checked first in _discover_programs; if absent the wire path runs as before. So existing deployments are unaffected. Tests cover the success path (live fixture, 330 programs) and the two validation failures (missing file, garbage bytes).
This commit is contained in:
parent
b412dc0f37
commit
e57fbc41e3
@ -16,6 +16,8 @@ from homeassistant.exceptions import ConfigEntryNotReady
|
|||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
CONF_CONTROLLER_KEY,
|
CONF_CONTROLLER_KEY,
|
||||||
|
CONF_PCA_KEY,
|
||||||
|
CONF_PCA_PATH,
|
||||||
CONF_TRANSPORT,
|
CONF_TRANSPORT,
|
||||||
DEFAULT_TRANSPORT,
|
DEFAULT_TRANSPORT,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
@ -57,6 +59,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
transport: str = entry.data.get(CONF_TRANSPORT, DEFAULT_TRANSPORT)
|
transport: str = entry.data.get(CONF_TRANSPORT, DEFAULT_TRANSPORT)
|
||||||
|
pca_path: str = entry.data.get(CONF_PCA_PATH, "") or ""
|
||||||
|
pca_key: int = entry.data.get(CONF_PCA_KEY, 0)
|
||||||
coordinator = OmniDataUpdateCoordinator(
|
coordinator = OmniDataUpdateCoordinator(
|
||||||
hass,
|
hass,
|
||||||
entry,
|
entry,
|
||||||
@ -64,6 +68,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
port=port,
|
port=port,
|
||||||
controller_key=controller_key,
|
controller_key=controller_key,
|
||||||
transport=transport,
|
transport=transport,
|
||||||
|
pca_path=pca_path or None,
|
||||||
|
pca_key=pca_key,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@ -20,6 +20,8 @@ from omni_pca.connection import (
|
|||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
CONF_CONTROLLER_KEY,
|
CONF_CONTROLLER_KEY,
|
||||||
|
CONF_PCA_KEY,
|
||||||
|
CONF_PCA_PATH,
|
||||||
CONF_TRANSPORT,
|
CONF_TRANSPORT,
|
||||||
CONTROLLER_KEY_HEX_LEN,
|
CONTROLLER_KEY_HEX_LEN,
|
||||||
DEFAULT_PORT,
|
DEFAULT_PORT,
|
||||||
@ -70,6 +72,15 @@ _USER_SCHEMA = vol.Schema(
|
|||||||
vol.Required(CONF_TRANSPORT, default=DEFAULT_TRANSPORT): vol.In(
|
vol.Required(CONF_TRANSPORT, default=DEFAULT_TRANSPORT): vol.In(
|
||||||
[TRANSPORT_TCP, TRANSPORT_UDP]
|
[TRANSPORT_TCP, TRANSPORT_UDP]
|
||||||
),
|
),
|
||||||
|
# Optional: load panel programs from a saved .pca file on the HA
|
||||||
|
# filesystem (e.g. /config/pca/My_House.pca) instead of streaming
|
||||||
|
# them from the panel on every restart. Useful when wire
|
||||||
|
# enumeration is slow or unreliable. CONF_PCA_KEY is the
|
||||||
|
# per-install key from PCA01.CFG (a uint32, 0 for plain-text).
|
||||||
|
vol.Optional(CONF_PCA_PATH, default=""): str,
|
||||||
|
vol.Optional(CONF_PCA_KEY, default=0): vol.All(
|
||||||
|
vol.Coerce(int), vol.Range(min=0, max=0xFFFFFFFF)
|
||||||
|
),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -90,6 +101,8 @@ class OmniConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
host: str = user_input[CONF_HOST].strip()
|
host: str = user_input[CONF_HOST].strip()
|
||||||
port: int = user_input[CONF_PORT]
|
port: int = user_input[CONF_PORT]
|
||||||
transport: str = user_input.get(CONF_TRANSPORT, DEFAULT_TRANSPORT)
|
transport: str = user_input.get(CONF_TRANSPORT, DEFAULT_TRANSPORT)
|
||||||
|
pca_path: str = (user_input.get(CONF_PCA_PATH) or "").strip()
|
||||||
|
pca_key: int = user_input.get(CONF_PCA_KEY, 0)
|
||||||
unique_id = f"{host}:{port}"
|
unique_id = f"{host}:{port}"
|
||||||
|
|
||||||
await self.async_set_unique_id(unique_id)
|
await self.async_set_unique_id(unique_id)
|
||||||
@ -101,19 +114,24 @@ class OmniConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
LOGGER.debug("controller key rejected: %s", err)
|
LOGGER.debug("controller key rejected: %s", err)
|
||||||
errors[CONF_CONTROLLER_KEY] = "invalid_key"
|
errors[CONF_CONTROLLER_KEY] = "invalid_key"
|
||||||
else:
|
else:
|
||||||
title, error = await self._probe(host, port, key, transport)
|
if pca_path and (pca_err := await self._validate_pca(pca_path, pca_key)):
|
||||||
if error is not None:
|
errors[CONF_PCA_PATH] = pca_err
|
||||||
errors["base"] = error
|
if not errors:
|
||||||
else:
|
title, error = await self._probe(host, port, key, transport)
|
||||||
return self.async_create_entry(
|
if error is not None:
|
||||||
title=title or f"Omni Panel ({host})",
|
errors["base"] = error
|
||||||
data={
|
else:
|
||||||
CONF_HOST: host,
|
return self.async_create_entry(
|
||||||
CONF_PORT: port,
|
title=title or f"Omni Panel ({host})",
|
||||||
CONF_CONTROLLER_KEY: key.hex(),
|
data={
|
||||||
CONF_TRANSPORT: transport,
|
CONF_HOST: host,
|
||||||
},
|
CONF_PORT: port,
|
||||||
)
|
CONF_CONTROLLER_KEY: key.hex(),
|
||||||
|
CONF_TRANSPORT: transport,
|
||||||
|
CONF_PCA_PATH: pca_path,
|
||||||
|
CONF_PCA_KEY: pca_key,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
step_id="user",
|
step_id="user",
|
||||||
@ -121,6 +139,33 @@ class OmniConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
errors=errors,
|
errors=errors,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def _validate_pca(self, path: str, key: int) -> str | None:
|
||||||
|
"""Validate the user-supplied .pca path is readable and decrypts.
|
||||||
|
|
||||||
|
Returns ``None`` on success or an error code string on failure
|
||||||
|
(matches the {code: message} keys in strings.json).
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from omni_pca.pca_file import parse_pca_file
|
||||||
|
|
||||||
|
p = Path(path)
|
||||||
|
if not p.is_file():
|
||||||
|
return "pca_not_found"
|
||||||
|
try:
|
||||||
|
data = await self.hass.async_add_executor_job(p.read_bytes)
|
||||||
|
acct = await self.hass.async_add_executor_job(
|
||||||
|
lambda: parse_pca_file(data, key=key)
|
||||||
|
)
|
||||||
|
except Exception as err:
|
||||||
|
LOGGER.debug("pca file rejected: %s", err)
|
||||||
|
return "pca_decode_failed"
|
||||||
|
# Sanity: programs block decoded cleanly. Empty is allowed
|
||||||
|
# (legitimate brand-new install with no programs).
|
||||||
|
if not isinstance(acct.programs, tuple):
|
||||||
|
return "pca_decode_failed"
|
||||||
|
return None
|
||||||
|
|
||||||
async def async_step_reauth(
|
async def async_step_reauth(
|
||||||
self, entry_data: Mapping[str, Any]
|
self, entry_data: Mapping[str, Any]
|
||||||
) -> ConfigFlowResult:
|
) -> ConfigFlowResult:
|
||||||
|
|||||||
@ -14,6 +14,13 @@ DEFAULT_TIMEOUT: Final = 5.0
|
|||||||
CONF_CONTROLLER_KEY: Final = "controller_key"
|
CONF_CONTROLLER_KEY: Final = "controller_key"
|
||||||
CONF_TRANSPORT: Final = "transport"
|
CONF_TRANSPORT: Final = "transport"
|
||||||
|
|
||||||
|
# Optional: when set, load panel programs from a .pca file at this path
|
||||||
|
# instead of enumerating them over the wire on every entry refresh. The
|
||||||
|
# .pca file is decrypted with CONF_PCA_KEY (the per-install key from
|
||||||
|
# PCA01.CFG, or 0 for a plain-text dump). Both must be set together.
|
||||||
|
CONF_PCA_PATH: Final = "pca_path"
|
||||||
|
CONF_PCA_KEY: Final = "pca_key"
|
||||||
|
|
||||||
TRANSPORT_TCP: Final = "tcp"
|
TRANSPORT_TCP: Final = "tcp"
|
||||||
TRANSPORT_UDP: Final = "udp"
|
TRANSPORT_UDP: Final = "udp"
|
||||||
DEFAULT_TRANSPORT: Final = TRANSPORT_TCP
|
DEFAULT_TRANSPORT: Final = TRANSPORT_TCP
|
||||||
|
|||||||
@ -138,6 +138,8 @@ class OmniDataUpdateCoordinator(DataUpdateCoordinator[OmniData]):
|
|||||||
port: int,
|
port: int,
|
||||||
controller_key: bytes,
|
controller_key: bytes,
|
||||||
transport: str = "tcp",
|
transport: str = "tcp",
|
||||||
|
pca_path: str | None = None,
|
||||||
|
pca_key: int = 0,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(
|
super().__init__(
|
||||||
hass,
|
hass,
|
||||||
@ -150,6 +152,8 @@ class OmniDataUpdateCoordinator(DataUpdateCoordinator[OmniData]):
|
|||||||
self._port = port
|
self._port = port
|
||||||
self._controller_key = controller_key
|
self._controller_key = controller_key
|
||||||
self._transport = transport
|
self._transport = transport
|
||||||
|
self._pca_path = pca_path
|
||||||
|
self._pca_key = pca_key
|
||||||
self._client: OmniClient | None = None
|
self._client: OmniClient | None = None
|
||||||
self._discovery_done = False
|
self._discovery_done = False
|
||||||
self._discovered: OmniData | None = None
|
self._discovered: OmniData | None = None
|
||||||
@ -383,19 +387,25 @@ class OmniDataUpdateCoordinator(DataUpdateCoordinator[OmniData]):
|
|||||||
async def _discover_programs(
|
async def _discover_programs(
|
||||||
self, client: OmniClient
|
self, client: OmniClient
|
||||||
) -> dict[int, Program]:
|
) -> dict[int, Program]:
|
||||||
"""Enumerate defined panel programs via the appropriate wire path.
|
"""Enumerate defined panel programs.
|
||||||
|
|
||||||
* v2 (TCP): ``client.iter_programs()`` drives UploadProgram with
|
Two sources, in order of preference:
|
||||||
request_reason=1 ("next defined after slot"), stopping at EOD.
|
|
||||||
* v1 (UDP): the adapter forwards to OmniClientV1.iter_programs(),
|
|
||||||
which is a bare UploadPrograms stream ack-walked to EOD.
|
|
||||||
|
|
||||||
Both surfaces yield :class:`omni_pca.programs.Program` and skip
|
1. ``CONF_PCA_PATH`` is configured → parse the .pca file and
|
||||||
empty slots, so the resulting dict only carries defined programs.
|
extract the programs block. Avoids streaming 1500 records on
|
||||||
Errors during enumeration are logged and swallowed — programs
|
every entry refresh and works against an offline snapshot.
|
||||||
are a non-critical part of discovery, so a partial list is better
|
2. Otherwise → enumerate over the wire:
|
||||||
than blocking the entry setup.
|
* v2 (TCP): ``client.iter_programs()`` drives UploadProgram
|
||||||
|
with request_reason=1 ("next defined after slot").
|
||||||
|
* v1 (UDP): adapter forwards to OmniClientV1.iter_programs(),
|
||||||
|
a bare UploadPrograms stream ack-walked to EOD.
|
||||||
|
|
||||||
|
Both paths yield :class:`omni_pca.programs.Program` and skip
|
||||||
|
empty slots. Errors are logged and swallowed — programs are
|
||||||
|
non-critical discovery, so a partial list beats blocking setup.
|
||||||
"""
|
"""
|
||||||
|
if self._pca_path:
|
||||||
|
return await self._discover_programs_from_pca()
|
||||||
out: dict[int, Program] = {}
|
out: dict[int, Program] = {}
|
||||||
try:
|
try:
|
||||||
async for prog in client.iter_programs():
|
async for prog in client.iter_programs():
|
||||||
@ -409,6 +419,38 @@ class OmniDataUpdateCoordinator(DataUpdateCoordinator[OmniData]):
|
|||||||
)
|
)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
async def _discover_programs_from_pca(self) -> dict[int, Program]:
|
||||||
|
"""Parse the configured .pca file and pull out its programs block.
|
||||||
|
|
||||||
|
Runs the disk I/O on the executor since :mod:`pca_file` does
|
||||||
|
sync reads. Any failure (missing file, bad key, malformed block)
|
||||||
|
is logged and downgraded to an empty dict — the rest of
|
||||||
|
discovery still works.
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from omni_pca.pca_file import parse_pca_file
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = await self.hass.async_add_executor_job(
|
||||||
|
Path(self._pca_path).read_bytes
|
||||||
|
)
|
||||||
|
acct = await self.hass.async_add_executor_job(
|
||||||
|
lambda: parse_pca_file(data, key=self._pca_key)
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
LOGGER.warning(
|
||||||
|
"failed to load programs from %s — falling back to empty list",
|
||||||
|
self._pca_path, exc_info=True,
|
||||||
|
)
|
||||||
|
return {}
|
||||||
|
out: dict[int, Program] = {}
|
||||||
|
for prog in acct.programs:
|
||||||
|
if prog.slot is None or prog.is_empty():
|
||||||
|
continue
|
||||||
|
out[prog.slot] = prog
|
||||||
|
return out
|
||||||
|
|
||||||
async def _walk_properties(
|
async def _walk_properties(
|
||||||
self,
|
self,
|
||||||
client: OmniClient,
|
client: OmniClient,
|
||||||
|
|||||||
@ -3,11 +3,14 @@
|
|||||||
"step": {
|
"step": {
|
||||||
"user": {
|
"user": {
|
||||||
"title": "Connect to your Omni panel",
|
"title": "Connect to your Omni panel",
|
||||||
"description": "Enter the IP/hostname of your HAI/Leviton Omni Pro II controller and the 32-character hex Controller Key. Use `omni-pca decode-pca <file>.pca --field controller_key` to extract the key from a PC Access export.",
|
"description": "Enter the IP/hostname of your HAI/Leviton Omni Pro II controller and the 32-character hex Controller Key. Use `omni-pca decode-pca <file>.pca --field controller_key` to extract the key from a PC Access export. Optionally provide a path to a saved `.pca` file to load panel programs from disk instead of streaming them from the controller.",
|
||||||
"data": {
|
"data": {
|
||||||
"host": "Host",
|
"host": "Host",
|
||||||
"port": "Port",
|
"port": "Port",
|
||||||
"controller_key": "Controller Key (32 hex chars)"
|
"controller_key": "Controller Key (32 hex chars)",
|
||||||
|
"transport": "Transport",
|
||||||
|
"pca_path": ".pca file path (optional)",
|
||||||
|
"pca_key": ".pca per-install key (integer; 0 if plain-text)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"reauth_confirm": {
|
"reauth_confirm": {
|
||||||
@ -22,6 +25,8 @@
|
|||||||
"cannot_connect": "Could not reach the panel. Check the host, port, and that TCP/4369 is open.",
|
"cannot_connect": "Could not reach the panel. Check the host, port, and that TCP/4369 is open.",
|
||||||
"invalid_auth": "The Controller Key was rejected by the panel.",
|
"invalid_auth": "The Controller Key was rejected by the panel.",
|
||||||
"invalid_key": "Controller Key must be exactly 32 hexadecimal characters (16 bytes).",
|
"invalid_key": "Controller Key must be exactly 32 hexadecimal characters (16 bytes).",
|
||||||
|
"pca_not_found": "No file at the supplied .pca path.",
|
||||||
|
"pca_decode_failed": "Could not decode the .pca file. Check the per-install key.",
|
||||||
"unknown": "An unexpected error occurred. Check the Home Assistant logs."
|
"unknown": "An unexpected error occurred. Check the Home Assistant logs."
|
||||||
},
|
},
|
||||||
"abort": {
|
"abort": {
|
||||||
|
|||||||
@ -3,11 +3,14 @@
|
|||||||
"step": {
|
"step": {
|
||||||
"user": {
|
"user": {
|
||||||
"title": "Connect to your Omni panel",
|
"title": "Connect to your Omni panel",
|
||||||
"description": "Enter the IP/hostname of your HAI/Leviton Omni Pro II controller and the 32-character hex Controller Key. Use `omni-pca decode-pca <file>.pca --field controller_key` to extract the key from a PC Access export.",
|
"description": "Enter the IP/hostname of your HAI/Leviton Omni Pro II controller and the 32-character hex Controller Key. Use `omni-pca decode-pca <file>.pca --field controller_key` to extract the key from a PC Access export. Optionally provide a path to a saved `.pca` file to load panel programs from disk instead of streaming them from the controller.",
|
||||||
"data": {
|
"data": {
|
||||||
"host": "Host",
|
"host": "Host",
|
||||||
"port": "Port",
|
"port": "Port",
|
||||||
"controller_key": "Controller Key (32 hex chars)"
|
"controller_key": "Controller Key (32 hex chars)",
|
||||||
|
"transport": "Transport",
|
||||||
|
"pca_path": ".pca file path (optional)",
|
||||||
|
"pca_key": ".pca per-install key (integer; 0 if plain-text)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"reauth_confirm": {
|
"reauth_confirm": {
|
||||||
@ -22,6 +25,8 @@
|
|||||||
"cannot_connect": "Could not reach the panel. Check the host, port, and that TCP/4369 is open.",
|
"cannot_connect": "Could not reach the panel. Check the host, port, and that TCP/4369 is open.",
|
||||||
"invalid_auth": "The Controller Key was rejected by the panel.",
|
"invalid_auth": "The Controller Key was rejected by the panel.",
|
||||||
"invalid_key": "Controller Key must be exactly 32 hexadecimal characters (16 bytes).",
|
"invalid_key": "Controller Key must be exactly 32 hexadecimal characters (16 bytes).",
|
||||||
|
"pca_not_found": "No file at the supplied .pca path.",
|
||||||
|
"pca_decode_failed": "Could not decode the .pca file. Check the per-install key.",
|
||||||
"unknown": "An unexpected error occurred. Check the Home Assistant logs."
|
"unknown": "An unexpected error occurred. Check the Home Assistant logs."
|
||||||
},
|
},
|
||||||
"abort": {
|
"abort": {
|
||||||
|
|||||||
131
tests/ha_integration/test_pca_source.py
Normal file
131
tests/ha_integration/test_pca_source.py
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
"""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_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"
|
||||||
Loading…
x
Reference in New Issue
Block a user