1 Commits

Author SHA1 Message Date
2cc28b0e50 program_engine: Phases 1+2 — Clock abstraction + TIMED execution
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).
2026-05-14 01:25:14 -06:00