5 Commits

Author SHA1 Message Date
d4c4e530f6 program_engine: real AND/OR condition evaluator
Some checks are pending
Validate / HACS validation (push) Waiting to run
Validate / Hassfest (push) Waiting to run
StateEvaluator decodes AND/OR records against MockState. ProgramEngine
.use_state_evaluator() installs one bound to the engine's panel + clock
+ location. Replaces the stub that always-passes-AND-always-fails-OR.

Traditional (OP=0) decode follows clsConditionLine.Cond synthesis
(clsConditionLine.cs:17-33): disk byte 1 (= and_family) carries the
compact GetConditionalText family byte, disk bytes 3-4 (and_instance
from cond2>>8) carry the object index. Family decoding mirrors
clsText.GetConditionalText (clsText.cs:2224-2274):

  family & 0xFC == 0x00 → OTHER  (low 4 bits = MiscConditional)
  family & 0xFC == 0x04 → ZONE   (bit 0x02 = NOT_READY, else SECURE)
  family & 0xFC == 0x08 → CTRL   (bit 0x02 = ON,         else OFF)
  family & 0xFC == 0x0C → TIME   (no MockState model → False)
  family >= 0x10        → SEC    (high nibble = mode, low = area)

Structured (OP > 0) decode uses Arg1 OP Arg2 with both sides resolved
via _resolve_arg(argtype, ix, field). MockState-backed resolution:

  ZONE        → loop / current_state / arming_state / latched_state
  UNIT        → on/off byte / time_remaining / dim level
  THERMOSTAT  → temp / setpoints / modes / humidity
  AREA        → mode
  TIME_DATE   → (clock-derived) year / month / day / DoW / hour /
                minute / time-of-day-in-minutes

CondOP supported: EQ / NE / LT / GT / ODD / EVEN / MULTIPLE / IN /
NOT_IN. Unknown argtypes or fields raise _UnsupportedCondition
internally — the evaluator swallows it and returns False, keeping the
chain *guarded* rather than firing too eagerly.

LIGHT/DARK MiscConditional uses astral via the engine's PanelLocation
when set. When location is missing, returns False either way (don't
fire if we can't determine).

15 new tests covering each evaluator branch (Traditional ZONE secure/
not-ready/undefined, CTRL on/off/dimmed, SEC mode-match, OTHER NEVER/
DARK; Structured Zone.CurrentState EQ/NE, Thermostat.Temp GT/LT,
TimeDate.Hour/DOW EQ, TimeDate-without-clock) plus end-to-end engine
integration showing use_state_evaluator() gates a real WHEN+AND chain
and the OR-alternative path works against real state.

Full suite: 581 passed, 1 skipped (up from 563, 82 engine tests total).
2026-05-14 02:39:41 -06:00
cc32081caf program_engine: Phase 5 — clausal chains (WHEN/AT/EVERY + AND/OR/THEN)
Final phase of the autonomous program-execution engine. Multi-record
clausal programs (firmware ≥3.0.0) now run end-to-end:

* WHEN-headed chains dispatch through emit_event() — same code path as
  raw EVENT programs, but with optional AND/OR condition guards.
* AT-headed chains schedule like TIMED (absolute or sun-relative).
* EVERY-headed chains fire on a recurring interval (every_interval
  seconds — the unit derivation matches the existing programs.py
  decode).

New types:

* ClausalChain dataclass — (head, conditions, actions). Built once at
  engine construction; engine.chains exposes the list.
* build_chains(programs) walks a slot-ordered Program tuple,
  grouping adjacent multi-record records into chains. Stops at the
  next clausal head, a non-clausal record, or an empty slot. Drops
  chains with no THEN action (they have no effect).
* evaluate_conditions(cs, is_satisfied=fn) — AND-of-OR-groups
  evaluator. Empty conditions tuple is True; OR records start a new
  group; within a group all ANDs must satisfy; overall True iff any
  group satisfies. The detailed semantic decode of each AND/OR record
  (zone-state checks, structured TEMP>70-style ops, …) is deferred
  to a follow-up — for now ``is_satisfied`` is the integration hook
  callers supply.

ProgramEngine.set_condition_evaluator(fn) lets tests / HA plug in a
state-aware evaluator. The default is a stub that passes ANDs and
fails ORs — a usable smoke-test default, deliberately not a real one.

14 new tests covering chain construction (single chain, with conditions,
with multiple THENs, adjacent chains, missing-THEN drop, non-clausal
boundary), the condition evaluator (empty/all-AND/AND-fail/OR-group
separation), and end-to-end execution (WHEN chain dispatch, condition
blocking, custom evaluator, AT chain schedule, EVERY chain interval).

With this the engine implements every program type the panel firmware
exposes — TIMED / EVENT / YEARLY compact-form plus WHEN / AT / EVERY +
AND / OR / THEN clausal. MockPanel + ProgramEngine + .pca decode +
MockState.from_pca composes into a complete "run any panel's programs
autonomously" sandbox.

Full suite: 563 passed, 1 skipped (up from 549, 64 engine tests total).
2026-05-14 01:34:19 -06:00
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