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.
152 lines
5.4 KiB
Python
152 lines
5.4 KiB
Python
"""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
|