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