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