3 Commits

Author SHA1 Message Date
269d0e897d program_engine: Phase 4 — EVENT programs + event taxonomy
EVENT programs (ProgType=2) now fire in response to panel events.
Unlike TIMED/YEARLY there's no per-program asyncio worker — EVENT
programs sit in an {event_id → [Program]} dispatch table built at
engine.start(). External code calls engine.emit_event(id) to dispatch.

Event-ID encoding mirrors clsText.GetEventCategory (clsText.cs:1585-...).
The 16-bit ID's high bits select a category; low bits encode the
specific object number / state. Phase 4 ships helpers for the three
practically-common categories plus the hand-rolled fixed-ID events:

  event_id_user_macro_button(b)   — 0x0000..0x00FF
  event_id_zone_state(z, st)      — 0x0400 | (((z-1)*4 + st) & 0x3FF)
  event_id_unit_state(u, on)      — 0x0800 | (((u-1)*2 + on) & 0x3FF)
  EVENT_PHONE_DEAD / _RINGING / _OFF_HOOK / _ON_HOOK = 768..771
  EVENT_AC_POWER_OFF / _ON = 772 / 773

emit_event(id) returns the count of programs that fired; convenience
methods emit_user_macro_button / emit_zone_state / emit_unit_state
wrap the encoders. emit_event before start() is a no-op (no event
table built); after stop() the table clears, so another start()
rebuilds from the current panel.state.programs.

13 new tests covering the helpers' bit layouts, range checks, single-
program dispatch, no-match silent no-op, before-start no-op, multiple
programs on the same event, zone-state state-filtering, and a smoke
test against AC_POWER_OFF as a sample fixed-ID event.

Full suite: 549 passed, 1 skipped (up from 539).
2026-05-14 01:30:23 -06:00
d6205cd330 program_engine: Phase 3 — YEARLY + sunrise/sunset
YEARLY programs (month/day match at hour:minute, regardless of weekday)
now fire autonomously. Each YEARLY slot gets its own asyncio worker
running sleep-until-next-fire / fire / loop, just like TIMED.

Sunrise/sunset support lands in the same phase. New PanelLocation
dataclass wraps a geographic position; PanelLocation.from_account(acct)
builds one from the .pca's latitude/longitude/time_zone, flipping the
longitude sign to match astral's east-positive convention.

The engine's __init__ accepts an optional location=PanelLocation.
TIMED programs whose hour byte hits the AT_SUNRISE (25) or AT_SUNSET
(26) sentinels resolve against astral's computed sunrise/sunset on
each candidate day, applying the program's signed minute offset
(time_offset_minutes). Without a location supplied, sun-relative
programs are silently skipped — the same effect as an empty Days mask.

_next_yearly_fire validates the (month, day) combination — Feb 30 etc.
return None and the program never fires (matches real-panel range
checks).

12 new tests covering:
* PanelLocation longitude sign-flip and timezone derivation
* _next_yearly_fire for today / next-year rollover / disabled / invalid
* YEARLY worker end-to-end + multi-year loop
* _next_sun_relative_fire AT_SUNSET / sunrise-with-offset / no-days
* engine sun-relative skip without location
* engine sun-relative fires correctly with location

Full suite: 539 passed, 1 skipped (up from 527).
2026-05-14 01:27:54 -06:00
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