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.
This commit is contained in:
Ryan Malloy 2026-05-10 15:37:48 -06:00
parent 93b7e1f604
commit df8b6128ea
13 changed files with 3075 additions and 191 deletions

1
.gitignore vendored
View File

@ -39,3 +39,4 @@ panel_key*
# Wine artifacts (if used for testing) # Wine artifacts (if used for testing)
.wine-pca/ .wine-pca/
ha-config/

View File

@ -1 +1 @@
3.12 3.14

View File

@ -15,7 +15,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
import voluptuous as vol import voluptuous as vol
from homeassistant.const import CONF_ENTRY_ID from homeassistant.const import ATTR_CONFIG_ENTRY_ID as CONF_ENTRY_ID
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import config_validation as cv from homeassistant.helpers import config_validation as cv

26
dev/Makefile Normal file
View File

@ -0,0 +1,26 @@
.PHONY: dev-up dev-down dev-logs dev-mock dev-reset
# Boot HA + MockPanel stack
dev-up:
docker compose -f docker-compose.yml up -d
@echo
@echo "HA → http://localhost:8123 (first-run onboarding required)"
@echo "Mock panel → host.docker.internal:14369"
@echo "Controller key → 000102030405060708090a0b0c0d0e0f"
dev-down:
docker compose -f docker-compose.yml down
dev-logs:
docker compose -f docker-compose.yml logs -f
# Run only the mock on the host (no docker), useful when you want to
# point a host-side OmniClient at it.
dev-mock:
cd .. && uv run python dev/run_mock_panel.py --host 127.0.0.1 --port 14369
# Wipe the HA config dir (clears onboarding + entities).
dev-reset:
docker compose -f docker-compose.yml down
rm -rf ha-config
mkdir -p ha-config

54
dev/README.md Normal file
View File

@ -0,0 +1,54 @@
# Dev stack
Local Home Assistant + MockPanel for clicking around the integration without a
real Omni controller. Useful for screenshots, manual smoke tests, and seeing
what the entity layout looks like.
## Quick start
```bash
cd dev/
make dev-up # docker compose up -d
# wait ~30s for HA to boot
open http://localhost:8123
```
First time: HA onboarding wizard (any name / location works). Then:
1. **Settings → Devices & Services → Add Integration**
2. Search for **HAI/Leviton Omni Panel**
3. Fill in:
- host: `host.docker.internal`
- port: `14369`
- controller key: `000102030405060708090a0b0c0d0e0f`
4. Submit. Within a few seconds you should see the Omni Pro II device with
~25 entities (binary sensors, lights, alarm panel, climate, sensors,
buttons, switches, the events entity).
## What the mock simulates
Five named zones, four units, two areas, two thermostats, three button
macros. User codes `1234` (master, code index 1) and `5678` (code index 2).
Arming the alarm with code `1234` will succeed and the
`alarm_control_panel` entity transitions through ARMING → ARMED_AWAY in
real time via the panel's push-event simulation. Wrong code → HA error
toast, panel stays disarmed.
## Other targets
```bash
make dev-logs # tail HA + mock logs
make dev-mock # run only the mock on the host (no docker)
make dev-down # stop the stack
make dev-reset # wipe HA config and start fresh
```
## Notes
- The HA container mounts `../custom_components/omni_pca/` read-only, so
edits to the integration need a restart (`docker compose restart
homeassistant`) to take effect.
- The mock panel binds `0.0.0.0:14369` inside the container. If you
prefer to talk to it from the host directly (e.g. with `omni-pca`
CLI), use `make dev-mock` to run it natively.

46
dev/docker-compose.yml Normal file
View File

@ -0,0 +1,46 @@
# Local dev stack: real Home Assistant talking to a MockPanel running on
# the host. Lets you click around the UI and grab screenshots without a
# physical Omni controller.
#
# make dev-up # start
# make dev-logs # tail HA logs
# make dev-down # stop and clean
#
# Once running, open http://localhost:8123 and:
# 1. Onboard with any name / location.
# 2. Settings -> Devices & Services -> Add Integration ->
# "HAI/Leviton Omni Panel".
# 3. Use:
# host host.docker.internal
# port 14369
# controller_key 000102030405060708090a0b0c0d0e0f
# (matches scripts/run_mock_panel.py defaults)
services:
mock-panel:
image: ghcr.io/astral-sh/uv:python3.14-bookworm-slim
working_dir: /app
volumes:
- ..:/app:ro
command: >
uv run --project /app
python /app/dev/run_mock_panel.py
--host 0.0.0.0
--port 14369
ports:
- "14369:14369"
homeassistant:
image: ghcr.io/home-assistant/home-assistant:2026.5
container_name: omni-pca-dev-ha
depends_on:
- mock-panel
volumes:
- ./ha-config:/config
- ../custom_components/omni_pca:/config/custom_components/omni_pca:ro
ports:
- "8123:8123"
extra_hosts:
- "host.docker.internal:host-gateway"
environment:
- TZ=America/Boise

112
dev/run_mock_panel.py Normal file
View File

@ -0,0 +1,112 @@
#!/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())

View File

@ -5,7 +5,7 @@ description = "Async Python client for HAI/Leviton Omni-Link II home automation
readme = "README.md" readme = "README.md"
license = { text = "MIT" } license = { text = "MIT" }
authors = [{ name = "Ryan Malloy", email = "ryan@supported.systems" }] authors = [{ name = "Ryan Malloy", email = "ryan@supported.systems" }]
requires-python = ">=3.12" requires-python = ">=3.14.2"
keywords = ["hai", "leviton", "omni", "home-automation", "omni-link", "home-assistant"] keywords = ["hai", "leviton", "omni", "home-automation", "omni-link", "home-assistant"]
classifiers = [ classifiers = [
"Development Status :: 3 - Alpha", "Development Status :: 3 - Alpha",
@ -43,6 +43,13 @@ dev = [
"ruff>=0.13.0", "ruff>=0.13.0",
"mypy>=1.18.0", "mypy>=1.18.0",
] ]
# Optional group for testing the HA custom_component end-to-end. Pulls in
# the full Home Assistant test harness; requires Python 3.14.2+. The repo's
# .python-version pins to 3.14 for development; install with:
# uv sync --group ha
ha = [
"pytest-homeassistant-custom-component>=0.13.330",
]
[tool.pytest.ini_options] [tool.pytest.ini_options]
asyncio_mode = "auto" asyncio_mode = "auto"

26
tests/conftest.py Normal file
View File

@ -0,0 +1,26 @@
"""Pytest configuration shared across the test suite.
The HA test harness (``pytest-homeassistant-custom-component``) installs
``pytest_socket`` globally, which disables real socket use to keep HA
unit tests hermetic. Our library has its own e2e tests that legitimately
need to talk to a localhost ``MockPanel`` over a real TCP socket, so we
re-enable sockets by default and let the HA integration tests opt back
into the strict policy via the harness fixtures.
"""
from __future__ import annotations
import pytest
@pytest.fixture(autouse=True)
def _enable_localhost_sockets(socket_enabled: pytest.FixtureRequest) -> None: # type: ignore[valid-type]
"""Re-enable sockets for every test by default.
``socket_enabled`` is the standard fixture exported by ``pytest_socket``
(and re-exported by the HA harness); requesting it via autouse undoes
the harness's default ``disable_socket()`` for tests that need real
networking. HA-side tests can override by explicitly using the
``socket_disabled`` fixture if they want hermetic behaviour.
"""
return None

View File

@ -0,0 +1,7 @@
"""HA-side integration tests.
These spin up a real Home Assistant instance in-process via
``pytest-homeassistant-custom-component``, point the omni_pca config
entry at a live ``MockPanel`` running on a localhost port, and assert
that the entity layer materializes correctly.
"""

View File

@ -0,0 +1,133 @@
"""Fixtures for the HA-side integration tests.
Each test gets:
* a fresh ``MockPanel`` listening on a random localhost port,
* a HA config entry whose ``host``/``port``/``controller_key`` point at it,
* a fully booted HA instance with the integration loaded.
The HA harness blocks real sockets by default; we re-enable them here
so the in-process client can talk to the in-process mock.
"""
from __future__ import annotations
from collections.abc import AsyncIterator
from typing import Any
import pytest
from custom_components.omni_pca.const import CONF_CONTROLLER_KEY, 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 omni_pca.mock_panel import (
MockAreaState,
MockButtonState,
MockPanel,
MockState,
MockThermostatState,
MockUnitState,
MockZoneState,
)
CONTROLLER_KEY = bytes(range(16))
CONTROLLER_KEY_HEX = CONTROLLER_KEY.hex()
@pytest.fixture(autouse=True)
def auto_enable_custom_integrations(enable_custom_integrations: None) -> None:
"""Tell HA to load components from ``custom_components/`` for every test."""
return None
@pytest.fixture(autouse=True)
def expected_lingering_tasks() -> bool:
"""Allow the coordinator's background event-listener task to outlive the
test body the integration cancels it on entry unload, but the harness's
default ``verify_cleanup`` flags any task still alive at teardown."""
return True
@pytest.fixture(autouse=True)
def _short_scan_interval(monkeypatch: pytest.MonkeyPatch) -> None:
"""Cut the 30s polling interval down so tests don't wait on it."""
from datetime import timedelta
from custom_components.omni_pca import const, coordinator
fast = timedelta(seconds=1)
monkeypatch.setattr(const, "SCAN_INTERVAL", fast)
monkeypatch.setattr(coordinator, "SCAN_INTERVAL", fast)
@pytest.fixture
def populated_state() -> MockState:
"""A lightly-populated mock state covering every entity platform."""
return MockState(
zones={
1: MockZoneState(name="FRONT_DOOR"),
2: MockZoneState(name="GARAGE_ENTRY"),
10: MockZoneState(name="LIVING_MOTION"),
},
units={
1: MockUnitState(name="LIVING_LAMP"),
2: MockUnitState(name="KITCHEN_OVERHEAD"),
},
areas={
1: MockAreaState(name="MAIN"),
},
thermostats={
1: MockThermostatState(name="LIVING_ROOM"),
},
buttons={
1: MockButtonState(name="GOOD_MORNING"),
},
user_codes={1: 1234},
)
@pytest.fixture
async def panel(populated_state: MockState) -> AsyncIterator[tuple[MockPanel, str, int]]:
"""Spin up a MockPanel on a random localhost port for the test's lifetime."""
mock = MockPanel(controller_key=CONTROLLER_KEY, state=populated_state)
async with mock.serve(host="127.0.0.1") as (host, port):
yield mock, host, port
@pytest.fixture
def config_entry_data(panel: tuple[MockPanel, str, int]) -> dict[str, Any]:
_, host, port = panel
return {
CONF_HOST: host,
CONF_PORT: port,
CONF_CONTROLLER_KEY: CONTROLLER_KEY_HEX,
}
@pytest.fixture
async def configured_panel(
hass: HomeAssistant, config_entry_data: dict[str, Any]
) -> AsyncIterator[ConfigEntry]:
"""Add a config entry to HA, trigger setup, unload at teardown.
The unload step is important it cancels the coordinator's background
event-listener task and closes the OmniClient socket. Without it, the
HA harness's ``verify_cleanup`` hangs waiting for the lingering reader
coroutine.
"""
entry = MockConfigEntry(
domain=DOMAIN,
data=config_entry_data,
title=f"Mock Omni at {config_entry_data[CONF_HOST]}:{config_entry_data[CONF_PORT]}",
unique_id=f"{config_entry_data[CONF_HOST]}:{config_entry_data[CONF_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()

View File

@ -0,0 +1,151 @@
"""HA-side integration: integration loads, entities materialize."""
from __future__ import annotations
from custom_components.omni_pca.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import STATE_OFF, STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant
async def test_integration_loads_against_mock_panel(
hass: HomeAssistant, configured_panel
) -> None:
"""End-to-end: HA discovers our integration, completes the secure
session against the mock, populates the coordinator, and lands in
LOADED state with no errors."""
assert configured_panel.state is ConfigEntryState.LOADED
coordinator = hass.data[DOMAIN][configured_panel.entry_id]
assert coordinator.data is not None
assert coordinator.data.system_info is not None
assert coordinator.data.system_info.model_name == "Omni Pro II"
async def test_zone_entities_created(
hass: HomeAssistant, configured_panel
) -> None:
"""Every named zone in MockState lands as a binary_sensor entity."""
states = hass.states.async_all("binary_sensor")
zone_entity_ids = [s.entity_id for s in states if "front_door" in s.entity_id.lower()
or "garage_entry" in s.entity_id.lower()
or "living_motion" in s.entity_id.lower()]
# Each zone gets a primary + bypassed entity, so at least 3 names x 2 = 6
# plus the system-level AC / battery / trouble entities.
assert len(zone_entity_ids) >= 3, (
f"expected zone entities, got {[s.entity_id for s in states]}"
)
async def test_alarm_panel_entity_created(
hass: HomeAssistant, configured_panel
) -> None:
"""One alarm_control_panel per discovered area."""
states = hass.states.async_all("alarm_control_panel")
assert len(states) == 1
assert states[0].state != STATE_UNAVAILABLE
async def test_light_entities_for_units(
hass: HomeAssistant, configured_panel
) -> None:
"""One light entity per discovered unit."""
states = hass.states.async_all("light")
assert len(states) == 2
# Both units default to off in the mock.
for s in states:
assert s.state in (STATE_OFF, STATE_UNAVAILABLE)
async def test_switch_entities_for_zone_bypass(
hass: HomeAssistant, configured_panel
) -> None:
"""One bypass switch per binary zone."""
states = hass.states.async_all("switch")
assert len(states) == 3 # one per binary zone
async def test_climate_entity_for_thermostat(
hass: HomeAssistant, configured_panel
) -> None:
states = hass.states.async_all("climate")
assert len(states) == 1
async def test_button_entity_for_panel_button(
hass: HomeAssistant, configured_panel
) -> None:
states = hass.states.async_all("button")
assert len(states) == 1
async def test_event_entity_per_panel(
hass: HomeAssistant, configured_panel
) -> None:
states = hass.states.async_all("event")
assert len(states) == 1
async def test_unload_entry(
hass: HomeAssistant, configured_panel
) -> None:
"""Unloading the config entry tears everything down cleanly."""
assert await hass.config_entries.async_unload(configured_panel.entry_id)
await hass.async_block_till_done()
assert configured_panel.state is ConfigEntryState.NOT_LOADED
# Coordinator removed from hass.data
assert configured_panel.entry_id not in hass.data.get(DOMAIN, {})
async def test_turn_unit_on_via_light_service(
hass: HomeAssistant, configured_panel, panel
) -> None:
"""Drive a HA service call; verify it reaches the mock and updates state."""
mock, _, _ = panel
light_states = hass.states.async_all("light")
assert light_states, "expected at least one light entity"
target = light_states[0].entity_id
await hass.services.async_call(
"light", "turn_on", {"entity_id": target}, blocking=True
)
await hass.async_block_till_done()
# The mock's state updated for whichever unit was first in sorted order.
on_units = [u for u in mock.state.units.values() if u.state == 1]
assert on_units, "expected the mock to record the unit as ON"
async def test_arm_panel_via_alarm_service(
hass: HomeAssistant, configured_panel, panel
) -> None:
"""Arm the panel from HA; verify the mock area transitions."""
mock, _, _ = panel
alarm_states = hass.states.async_all("alarm_control_panel")
assert alarm_states, "expected one alarm_control_panel entity"
target = alarm_states[0].entity_id
await hass.services.async_call(
"alarm_control_panel",
"alarm_arm_away",
{"entity_id": target, "code": "1234"},
blocking=True,
)
await hass.async_block_till_done()
assert mock.state.areas[1].mode == 3 # SecurityMode.AWAY
async def test_arm_panel_with_wrong_code_keeps_disarmed(
hass: HomeAssistant, configured_panel, panel
) -> None:
"""Wrong code: panel stays disarmed and HA surfaces the error."""
mock, _, _ = panel
alarm_states = hass.states.async_all("alarm_control_panel")
target = alarm_states[0].entity_id
# The service should raise; we don't assert the exception class because
# HA wraps it. We just assert the panel mode didn't change.
import contextlib
with contextlib.suppress(Exception):
await hass.services.async_call(
"alarm_control_panel",
"alarm_arm_away",
{"entity_id": target, "code": "9999"},
blocking=True,
)
assert mock.state.areas[1].mode == 0 # still disarmed

2697
uv.lock generated

File diff suppressed because it is too large Load Diff