agentskills.codes
PL

plenum-architecture-contract

>-

Install

mkdir -p .claude/skills/plenum-architecture-contract && curl -L -o skill.zip "https://agentskills.codes/api/skills/download/16699" && unzip -o skill.zip -d .claude/skills/plenum-architecture-contract && rm skill.zip

Installs to .claude/skills/plenum-architecture-contract

Activation

This is the description your AI agent reads to decide when to run this skill — the better it matches your request, the more reliably it fires.

The load-bearing design decisions of Plenum (smart-thermostat-with-vents), the invariants that must hold, WHY each exists, and the known-weak points. Load when you need the canonical temperature-unit contract (°F storage, single write boundary, #231), the component map with real file paths, the cycle-engine invariants (mode lock, in-place trigger updates, 60s tick), the MCP loopback design, or a "before you change X, understand Y" dependency check. Also load when CLAUDE.md prose seems to disagree with the repo — this skill flags the known drift.
551 chars✓ has a “when” triggerlonger than Claude Code's old 250-char listing cap (fine on current versions)

About this skill

Plenum architecture contract

The design decisions this system stands on, stated as invariants with their rationale. Every claim below was verified against the repo at v0.22.1 (2026-07-04). Where DESIGN.md prose has drifted behind the code (or CLAUDE.md had, before the 2026-07-05 corrections in PR #388), the drift is called out — the repo files win.

When NOT to use this skill:

  • Domain theory (why short-cycling kills compressors, what a deadband is, HA entity semantics) → hvac-zoning-reference.
  • The blow-by-blow story of a past incident → plenum-failure-archaeology.
  • Which CI gates a change must clear, PR/release mechanics → plenum-change-control.
  • Diagnosing a live symptom → plenum-debugging-playbook.
  • Adding/locating a config knob → plenum-config-and-flags.

Jargon (once): write boundary — the POST/PUT/PATCH handlers in smart_vent/backend/api/routes.py, the only place display-unit input becomes storage °F. Delta — a temperature difference (deadband, offset): °C↔°F scales by 9/5 with no 32 offset. Ingress — Home Assistant's authenticated reverse proxy that serves the add-on UI. Zone — one thermostat plus its rooms.


1. Component map (verified file paths and responsibilities)

All backend paths are under smart_vent/backend/.

HA Core ◄─ WebSocket + REST ─► ha_client.py ◄── engine/ ◄── scheduler.py ◄── api/routes.py ◄── frontend / MCP
                                                    │              │                │
                                                    └──────────────┴── db.py ── app.db (SQLite, DATA_DIR)
FileResponsibility (verified)
main.pyEntry point. Binds aiohttp on PORT (default 8099), starts the HA client task, the Scheduler, and the HTTP MCP server (uvicorn) on MCP_PORT (default 9099). One-shot flair.db → app.db rename (#89). Security-headers middleware on every response.
ha_client.pyRaw HA WebSocket client + state cache. Normalises sensor readings to °F via per-entity unit_of_measurement (get_numeric_state, line ~172), resolves HA's system unit into ha_temp_unit from /api/config (#281), and converts stored-°F setpoints back to HA-native units on write via _setpoint_to_native (#280). Dev mode intercepts all writes here and logs [DEV] Would … instead.
scheduler.pyOrchestrator. Owns _engines: dict[thermostat_id → CycleEngine], the 60s APScheduler main_tick, log purge (24h), schedule-expiry sweep (60s, #359), daily/monthly metrics rollups (#85). Owns _active_unit and the system_enabled / developer_mode flags (persisted in system_settings).
engine/cycle_engine.pyOne instance per thermostat. The IDLE → RUNNING → TERMINATING → IDLE state machine; all safety guards (short-cycle lockout, cooling lockout, staleness filter, cycle timeout, airflow floor, #367 envelope enforcement) live in its tick. ~3600 lines.
engine/room_manager.pyResolves each room's active trigger: override > schedule > presence holdover.
engine/vent_controller.pyIssues cover open/close commands per the room-vent control_method.
api/routes.pyAll REST endpoints. The write boundary: every temperature field is converted via the units.py helpers here, then bounds-checked on the °F value. Also TEMPERATURE_FIELDS (Python side of the parity system).
api/ws_handler.pyThin WebSocket broadcaster (WSManager). Event payloads are built in scheduler.py / the engine — temperatures in them are raw °F.
units.pyThe four converters: to_f / delta_to_f / from_f / from_f_delta. Single source of truth shared by routes.py, ha_client.py, and the engine's HA-ingest helper.
db.pyaiosqlite layer. SCHEMA script (WAL mode, FK on) + append-only _MIGRATIONS list of ALTER TABLE statements + two one-shot data migrations gated by system_settings sentinels.
tz.pySingle source of local-time truth: TZ env (from the timezone add-on option via run.sh). Schedule matching and metrics bucketing use it; storage timestamps are UTC.
mcp_http.py / mcp_openapi.pyHTTP (Streamable) MCP server on port 9099; tools generated from the OpenAPI spec, each call dispatched over loopback HTTP to the REST API.
frontend/src/contexts.tsbuildUnitContext(unit) — the frontend display-conversion layer (toDisplay, toDisplayDelta, fmtTemp).

DESIGN.md is the original design doc and is stale in places (it still describes a stdio/SSE MCP server, min_open_vents as a count, a 30s monitor tick). Treat it as historical intent; this skill and the code are current.


2. The temperature-unit contract (canonical statement — owned here)

Invariant: every temperature at rest or in flight inside the system is °F. SQLite rows, engine arithmetic, scheduler state, WebSocket event payloads, API GET responses — all °F, always (Issue #123). Exactly three places convert, and nothing else may:

  1. Backend write boundary (api/routes.py): display-unit input → °F via to_f / delta_to_f (imported from units.py as _to_f / _delta_to_f), using the active unit from scheduler.get_temperature_unit(). Bounds (40–90 °F for targets, 40–100 °F for setpoints, 0–10 °F for deadband_override) are checked after conversion, on the °F value — checking the raw input is meaningless when the unit varies (.jules/sentinel.md, 2026-05-05).
  2. Frontend display layer (contexts.ts): °F → display via toDisplay / toDisplayDelta / fmtTemp, for rendering and form initialisation only. Forms hold display-unit values and submit them raw.
  3. HA I/O boundary (ha_client.py + _climate_temp_to_f in cycle_engine.py): HA-native values ↔ °F. Sensor entities carry a per-entity unit_of_measurement; °C readings are normalised through the shared to_f (#251). Climate entities report in HA's system unit (ha_temp_unit, resolved from /api/config — #281 fixed the detection), so climate reads pass through _climate_temp_to_f and setpoint writes through _setpoint_to_native (#280 — sending raw °F to a metric HA would be interpreted as °C and drive the HVAC wildly wrong).

The frontend NEVER calls toStorage / toStorageDelta on an outgoing payload. That was #231: the Thermostats form converted 16 °C → 60.8 °F via toStorage, then the backend's _to_f converted again → 141.44 °F stored. Each side's unit tests passed because each asserted a different contract. The conversion belongs to exactly one side — the backend. The toStorage helpers still exist in contexts.ts but are reserved for local validation-bound math only.

Absolute vs delta is a type distinction, not a detail. Absolute uses (f−32)×5/9; a delta uses only ×5/9. Feeding a delta (deadband, temp_offset, overshoot_delta) through the absolute converter renders a 2 °F deadband as a negative °C number (#291 was exactly this in a chart subtitle). units.py keeps all four converters side by side to make the axis visible.

The parity system keeps the contract enforced: every write-boundary temperature field is registered in TEMPERATURE_FIELDS in both routes.py (12 fields as of v0.22.1) and e2e/tests/temperature-fields.ts, with // @covers: round-trip tests, and backend/tests/test_temperature_field_parity.py fails CI on any drift. The add-a-field procedure and gates are owned by plenum-change-control; the parity-rule details by plenum-validation-and-qa.

History note: older CLAUDE.md copies placed _to_f/_delta_to_f/_from_f in backend/api/routes.py; CLAUDE.md was corrected 2026-07-05 (PR #388). They live in backend/units.py and are imported into routes.py under those aliases (routes.py lines 35–38). Same semantics; trust units.py.


3. Engine and scheduler invariants

InvariantWhyWhere verified
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-

Content truncated.

Search skills

Search the agent skills registry