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:
parent
93b7e1f604
commit
df8b6128ea
1
.gitignore
vendored
1
.gitignore
vendored
@ -39,3 +39,4 @@ panel_key*
|
||||
|
||||
# Wine artifacts (if used for testing)
|
||||
.wine-pca/
|
||||
ha-config/
|
||||
|
||||
@ -1 +1 @@
|
||||
3.12
|
||||
3.14
|
||||
|
||||
@ -15,7 +15,7 @@ from __future__ import annotations
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
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.helpers import config_validation as cv
|
||||
|
||||
|
||||
26
dev/Makefile
Normal file
26
dev/Makefile
Normal 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
54
dev/README.md
Normal 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
46
dev/docker-compose.yml
Normal 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
112
dev/run_mock_panel.py
Normal 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())
|
||||
@ -5,7 +5,7 @@ description = "Async Python client for HAI/Leviton Omni-Link II home automation
|
||||
readme = "README.md"
|
||||
license = { text = "MIT" }
|
||||
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"]
|
||||
classifiers = [
|
||||
"Development Status :: 3 - Alpha",
|
||||
@ -43,6 +43,13 @@ dev = [
|
||||
"ruff>=0.13.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]
|
||||
asyncio_mode = "auto"
|
||||
|
||||
26
tests/conftest.py
Normal file
26
tests/conftest.py
Normal 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
|
||||
7
tests/ha_integration/__init__.py
Normal file
7
tests/ha_integration/__init__.py
Normal 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.
|
||||
"""
|
||||
133
tests/ha_integration/conftest.py
Normal file
133
tests/ha_integration/conftest.py
Normal 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()
|
||||
151
tests/ha_integration/test_setup.py
Normal file
151
tests/ha_integration/test_setup.py
Normal 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
|
||||
Loading…
x
Reference in New Issue
Block a user