One CycleEngine per thermostat; 60s tick. The scheduler's _sync_engines creates/removes engines as thermostats appear in room configs; main_tick runs every 60s (max_instances=1, coalesce=True) and also fires on HA state changes. Each engine serialises its work behind an internal asyncio.Lock. | Zones are independent failure domains; one thermostat's outage must not stall another's control loop (#286 hardened the per-engine exception isolation). | scheduler.py lines ~53, 130–137, 470–481; cycle_engine.py tick() line 176. |
Mode is locked at cycle start (_cycle_mode, cycle_engine line 100). Monitoring, at-target checks, and the setpoint all use the locked mode, not live hvac_action. | During HVAC idle phases the naively inferred mode can flip tick-to-tick → heat/cool oscillation. A room that now needs the opposite direction is dropped by the mode filter; if no compatible rooms remain the cycle ends cleanly and a new one may start opposite on the next tick. | docs/cycle-engine.md §"Why the mode is locked"; cycle_engine.py lines ~404, 546, 566. |
Mid-cycle trigger changes are applied in place, never by teardown (#215). A changed source/target updates the running cycle and re-derives the setpoint; the cycle log stays open with a trigger updated in place setpoint-history entry. | The old teardown was itself a compressor stop/start — and with the #208/#214 off-time lockout enabled, the rebuild was then blocked for the lockout window, leaving a room unconditioned (back-to-back schedule blocks at 08:00 caused a full furnace stop→start for a target that only moved up). | cycle_engine.py lines ~886–911, 972; docs/safety.md §"In-place cycle updates". |
The engine never converts display units. It receives °F from the DB and commands °F; ha_client.py translates to HA-native at the wire. The only conversion helpers it touches are the HA-ingest normalisers (_climate_temp_to_f, to_f on °C sensors). | Conversion in two layers is how #231 happened; the engine staying unit-blind keeps the write boundary the single converter. | cycle_engine.py _climate_temp_to_f docstring (line 3589). |
The scheduler owns _active_unit. Resolution order: TEMPERATURE_UNIT env override → HA /api/config on startup → last-known system_settings.temperature_unit. Engine and routes read it via scheduler.get_temperature_unit(), never from the DB directly. | One synchronous, cached source; a mid-request DB read could race a unit change. Unit changes set unit_change_ack_required and surface a banner rather than silently re-labelling data. | scheduler.py lines ~61, 102–105, 293–295, 366–374. |
EntityState.numeric is always °F. POST /api/ha/states converts °C entity values to °F (and rewrites the unit label) before returning. | The frontend must apply exactly one conversion (fmtTemp); if raw HA values leaked through, °C sensors would double-convert on display. | routes.py lines ~1378–1387. |
MCP dispatches via loopback HTTP — one write boundary (#372, shipped 0.22.0/0.22.1). Tools are generated from the OpenAPI spec; every call becomes session.request(...) against http://127.0.0.1:8099, hitting the same handlers, validation, unit conversion, and logging as the UI. The MCP server is a separate ASGI (Starlette/uvicorn) stack on port 9099 because aiohttp is not ASGI; /mcp returns 503 unless the mcp_enabled toggle is on (off by default). | "No duplicated business logic, no #231 double-conversion" — the module docstring says it outright. A second code path into the DB would eventually drift from the write boundary. | mcp_http.py (whole file, esp. docstring and dispatch_tool); main.py _start_mcp_server; docs/mcp.md. |
System-off means no HA writes. With system_enabled false (and dev mode off), the engine tick returns before any control logic; the sole exception is a one-time abort of a cycle that was in flight when the system was disabled (which restores setpoint/vents). Idle-state vent reconciliation is also gated on enabled. Dev mode goes further: ha_client intercepts every write and logs [DEV] Would …. | An operator disabling the system must be able to trust that Plenum stops touching their equipment, without leaving a half-run cycle holding the thermostat at an overshot setpoint. | cycle_engine.py lines 315–322, 376; ha_client.py dev_mode branches (lines ~287, 317, 335). |
SQLite, single file app.db in DATA_DIR, WAL mode; system_settings is a key-value table (flags: temperature_unit, unit_change_ack_required, system_enabled, developer_mode, mcp_enabled, sensor_stale_after_min, migration sentinels, …). No settings table, no ORM. | Zero-dependency persistence fitting the add-on model; KV avoids a schema migration per flag. DATA_DIR is /config in the add-on (config.yaml environment:), /data by default elsewhere. | db.py SCHEMA (line 38, system_settings at 189); main.py lines 42–43. |
| WebSocket events carry raw °F. Zone-status payloads are built from engine state (already °F); the frontend converts for display. | Same single-conversion rule as REST GETs. | scheduler.py _zone_status_dict; api/ws_handler.py is payload-agnostic. |
All schedule times are local wall-clock in the configured timezone; storage timestamps are UTC. tz.py is the only legitimate source of "local now" — it reads TZ (from the timezone add-on option via run.sh). Schedule start_time/end_time/expires_at are naive local; cycle/event timestamps are datetime.now(UTC). | Mixing the two produced real bugs: #65 (holdovers stored local, compared as UTC), #294 (vacation modal min computed in UTC), #301 (naive-UTC rendered as local). | tz.py module docstring; db.py schedules table comment (line 89) and _migrate_holdover_timestamps_to_utc. |
Safety monitoring must survive degraded states. The thermostat-availability check runs before the enabled/vacation guards; a sustained outage with a cycle in flight aborts it after unavailable_abort_after_min (default 5). The #367 envelope check runs even with zero active rooms. | #267: an unavailable thermostat used to suspend all per-tick safety monitors while the physical HVAC kept running at the last commanded setpoint with vents closed. #367: with no active rooms, nothing enforced max_setpoint. | cycle_engine.py lines 263–300 (with the #267 comment), 358–381. |