Pytest harness (in-process HA + MockPanel)
==========================================
pyproject.toml — bumps requires-python to 3.14.2 to align with HA 2026.5.x
which is what pytest-homeassistant-custom-component pins. Dev group 'ha'
pulls the harness; .python-version updated to 3.14.
src/omni_pca/mock_panel.py — Thermostat (6) and Button (3) RequestProperties
handlers added (previous commit). Without these the HA coordinator's
discovery walk produced empty thermostat/button dicts.
custom_components/omni_pca/services.py — fix CONF_ENTRY_ID import: HA
exports it as ATTR_CONFIG_ENTRY_ID, not CONF_ENTRY_ID. Aliased on import.
tests/conftest.py — re-enables sockets globally (the HA harness installs
pytest_socket which otherwise blocks our network e2e tests).
tests/ha_integration/ — new directory with full HA boot harness:
conftest.py:
- autouse enable_custom_integrations so HA loads our component
- autouse expected_lingering_tasks=True (background event listener)
- autouse _short_scan_interval (1s instead of 30s for fast tests)
- panel fixture: MockPanel on a random localhost port for each test
- configured_panel fixture: builds a MockConfigEntry, runs setup,
yields, then unloads on teardown so the coordinator's reader task
and OmniClient socket close cleanly (otherwise verify_cleanup hangs)
test_setup.py — 12 tests:
- integration loads + system_info populated
- alarm_control_panel/light/switch/climate/button/event/binary_sensor
entities materialise per platform
- unload_entry tears down cleanly
- turning a light on via HA service updates the mock state
- arming via HA service with the right code transitions the area
- arming with wrong code keeps the area disarmed and surfaces error
Total: 351 passed, 1 skipped (PCA fixture). Ruff clean across src/ tests/
custom_components/. The 12 HA integration tests run in <1s end-to-end —
they boot HA in-process, drive the config flow, exercise services, and
verify state mutations on the mock side.
Docker dev stack (manual smoke / screenshots)
=============================================
dev/docker-compose.yml — HA 2026.5 container + MockPanel sidecar.
dev/run_mock_panel.py — long-running mock with a populated state
(5 zones, 4 units, 2 areas, 2 thermostats, 3 buttons, codes 1234/5678).
dev/Makefile — make dev-up / dev-logs / dev-down / dev-mock / dev-reset.
dev/README.md — onboarding walkthrough (host=host.docker.internal,
port=14369, controller_key=000102030405060708090a0b0c0d0e0f).
.gitignore — adds ha-config/ so the persisted HA state from the dev
stack doesn't get committed.
113 lines
3.3 KiB
Python
113 lines
3.3 KiB
Python
#!/usr/bin/env python3
|
|
"""Launch a long-running MockPanel suitable for the docker-compose dev stack.
|
|
|
|
Reuses the mock fixture from the test suite so the behaviour matches what
|
|
the HA integration tests prove out. Defaults match dev/docker-compose.yml.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import asyncio
|
|
import logging
|
|
import signal
|
|
import sys
|
|
|
|
from omni_pca.mock_panel import (
|
|
MockAreaState,
|
|
MockButtonState,
|
|
MockPanel,
|
|
MockState,
|
|
MockThermostatState,
|
|
MockUnitState,
|
|
MockZoneState,
|
|
)
|
|
|
|
DEFAULT_KEY_HEX = "000102030405060708090a0b0c0d0e0f"
|
|
|
|
|
|
def _populated_state() -> MockState:
|
|
"""A small but representative set of objects so HA shows real entities."""
|
|
return MockState(
|
|
zones={
|
|
1: MockZoneState(name="FRONT_DOOR"),
|
|
2: MockZoneState(name="GARAGE_ENTRY"),
|
|
3: MockZoneState(name="BACK_DOOR"),
|
|
10: MockZoneState(name="LIVING_MOTION"),
|
|
11: MockZoneState(name="HALL_MOTION"),
|
|
},
|
|
units={
|
|
1: MockUnitState(name="LIVING_LAMP"),
|
|
2: MockUnitState(name="KITCHEN_OVERHEAD"),
|
|
3: MockUnitState(name="FRONT_PORCH"),
|
|
4: MockUnitState(name="BEDROOM_FAN"),
|
|
},
|
|
areas={
|
|
1: MockAreaState(name="MAIN"),
|
|
2: MockAreaState(name="GUEST"),
|
|
},
|
|
thermostats={
|
|
1: MockThermostatState(name="LIVING_ROOM"),
|
|
2: MockThermostatState(name="MASTER_BEDROOM"),
|
|
},
|
|
buttons={
|
|
1: MockButtonState(name="GOOD_MORNING"),
|
|
2: MockButtonState(name="MOVIE_MODE"),
|
|
3: MockButtonState(name="GOODNIGHT"),
|
|
},
|
|
user_codes={1: 1234, 2: 5678},
|
|
)
|
|
|
|
|
|
async def _serve(host: str, port: int, key: bytes) -> None:
|
|
panel = MockPanel(controller_key=key, state=_populated_state())
|
|
async with panel.serve(host=host, port=port) as (bound_host, bound_port):
|
|
logging.info("MockPanel listening on %s:%d", bound_host, bound_port)
|
|
logging.info("Use this controller key in HA: %s", key.hex())
|
|
stop = asyncio.Event()
|
|
|
|
def _on_signal() -> None:
|
|
logging.info("shutdown signal received")
|
|
stop.set()
|
|
|
|
loop = asyncio.get_running_loop()
|
|
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
loop.add_signal_handler(sig, _on_signal)
|
|
|
|
await stop.wait()
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--host", default="0.0.0.0")
|
|
parser.add_argument("--port", type=int, default=14369)
|
|
parser.add_argument(
|
|
"--controller-key",
|
|
default=DEFAULT_KEY_HEX,
|
|
help="32 hex chars; default is the docker-compose value",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
|
)
|
|
|
|
try:
|
|
key = bytes.fromhex(args.controller_key)
|
|
except ValueError:
|
|
print(f"controller-key must be 32 hex chars: {args.controller_key!r}",
|
|
file=sys.stderr)
|
|
return 2
|
|
if len(key) != 16:
|
|
print(f"controller-key must decode to exactly 16 bytes (got {len(key)})",
|
|
file=sys.stderr)
|
|
return 2
|
|
|
|
asyncio.run(_serve(args.host, args.port, key))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|