First half of the autonomous program-execution engine. Two phases land
together because Phase 1 was pure scaffolding (Clock + classifier)
and made little sense in isolation.
Phase 1 — engine foundation:
* Clock protocol with RealClock (wall time + asyncio.sleep) and
FakeClock (manual advance, no real waiting; sleepers wake in
chronological order on advance_to).
* classify(programs) splits a Program tuple into timed / event /
yearly / clausal-head buckets, dropping FREE / REMARK / unknown
records and the AND/OR/THEN clausal continuations (those are
reached by walking forward from each WHEN/AT/EVERY head, not by
classification).
* ProgramEngine class with start / stop lifecycle (idempotent +
context-manager), per-program asyncio task list, _EngineMetrics
counters.
Phase 2 — TIMED programs actually run:
* _next_absolute_fire(now, program) computes the next datetime at
which a TIMED program with TimeKind.ABSOLUTE should fire, given
its hour/minute/days mask. Walks forward up to 8 days; returns
None for empty Days mask (program is effectively disabled).
* Each TIMED program gets its own asyncio task running
sleep-until-next-fire / fire / loop. Firing dispatches the
4-byte Command wire payload (cmd / par / pr2) through
MockPanel._handle_command — same code path the v2 Command opcode
uses, so a TIMED program turning on a unit produces identical
state to a client sending the equivalent Command.
* astral added as an [engine] optional dependency, pinned to 2.2
for HA compat (HA itself pins astral==2.2). Library wired up but
not yet consumed — sunrise/sunset support lands in Phase 3.
Tests (28 new):
* RealClock and FakeClock behaviour incl. chronological wake order.
* classify against each ProgramType, unknown values, empty input.
* Engine lifecycle (idempotent start/stop, context manager,
malformed-record tolerance).
* End-to-end: TIMED UNIT_ON program fires at the right Monday 06:00,
loops correctly across weeks, never fires outside its Days mask,
ignores programs with empty Days mask.
Full suite: 527 passed, 1 skipped (up from 499).
Final cross-reference round, covering the remaining files where wire
bytes have a user- or installer-facing counterpart:
v1/messages.py
New Cross-references block: SETUP ZONES + SETUP TEMPERATURES for the
fields the parsers' raw bytes ultimately come from, and APPENDIX C
for what each synthesized index means on hardware (unit 257+ =
expansion-enclosure outputs, 393+ = panel flags).
models.ZoneStatus
Status-byte bit-layout doc now also points at the Owner's Manual
CONTROL chapter's "View Zone Status" keypad screen -- same Secure /
Not Ready / Trouble / Tamper labels.
models.UnitStatus
State-byte semantics doc references the Owner's Manual CONTROL
chapter for the user-side actions (All On/All Off/Scene/Bright/Dim)
that drive units into each of these states.
mock_panel.py
Notes that the mock's plausible-but-arbitrary RequestProperties /
RequestStatus responses correspond on real hardware to what an
installer typed into INSTALLER SETUP. Production fixtures should
pre-seed MockPanel state to match a known SETUP configuration.
uv.lock
Catches up the project's own entry to omni-pca 2026.5.11 (was
pinned to 2026.5.10 from the previous lock generation).
No code changes; 387 tests still pass.
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.