omni-pca/dev/run_mock_panel.py
Ryan Malloy df8b6128ea HA test harness + docker dev stack — both proven green
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.
2026-05-10 15:37:48 -06:00

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())