Symptom-to-triage playbook for Plenum (smart-thermostat-with-vents). Load when something is behaving wrong at runtime and you need to find the cause fast — temperatures off by ~×1.8 or ~30°, cycles that never end or end early, vents thrashing or stuck open/closed, a zone the engine silently stopped controlling, timestamps shifted by hours, duplicated/zombie live-feed rows, HA entity or cover-service failures, wrong heat/cool mode or oscillation, or one of the three classic CI failures (golden...
Scanned 9/11/2026
Install to Claude Code
npx -y skills add dhruvb14/smart-thermostat-with-vents --skill plenum-debugging-playbook --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Plenum Debugging Playbook?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/dhruvb14-plenum-debugging-playbook)More formats (shields.io, HTML) on the badges page.
---
name: plenum-debugging-playbook
description: Symptom-to-triage playbook for Plenum (smart-thermostat-with-vents). Load when something is behaving wrong at runtime and you need to find the cause fast — temperatures off by ~×1.8 or ~30°, cycles that never end or end early, vents thrashing or stuck open/closed, a zone the engine silently stopped controlling, timestamps shifted by hours, duplicated/zombie live-feed rows, HA entity or cover-service failures, wrong heat/cool mode or oscillation, or one of the three classic CI failures (golden-screenshot diff, TEMPERATURE_FIELDS parity, coverage gate). Gives ranked causes, the exact discriminating command/query/log line, and the issue precedent for each.
---
# Plenum Debugging Playbook
Symptom → ranked causes → one discriminating experiment → precedent. Built from
the 48 closed `label:bug` issues plus the engine-audit issues, originally
verified against the repo 2026-07-04 (v0.22.1); spot-checked and refreshed
2026-09-01 against v0.35.0.
**When NOT to use this skill**
- You need to *measure* something (log-mining scripts, metrics interpretation,
DB query recipes, WS capture) → `plenum-diagnostics-and-tooling`.
- You want the full settled history of an incident (root cause → fix → status)
→ `plenum-failure-archaeology`.
- You are about to *change* code to fix what you found → `plenum-change-control`
first (gates, evidence, non-negotiables).
- You don't understand a domain term (deadband, overshoot, short-cycling,
dead-heading, hvac_action vs hvac_mode) → `hvac-zoning-reference`.
- CI failure beyond the three classics in family 8 → `plenum-ci-and-release`.
Jargon used below, once: **deadband** = tolerance band around a target inside
which a room is "close enough" (a *delta*, not an absolute temp). **overshoot
(delta)** = how far past the target the commanded setpoint is pushed so the
HVAC actually fires. **tick** = the engine's 60 s evaluation pass. **golden** =
committed reference screenshot for visual-regression E2E.
---
## Where to look — the diagnostic anatomy
All paths relative to repo root; runtime paths are inside the container.
| Surface | What it is | How to read it |
|---|---|---|
| Backend log | Python logging → container stdout. Every event-log write is mirrored here as `[CATEGORY] message` (`smart_vent/backend/event_logger.py:71`) | Docker: `docker logs <container>`. HAOS: add-on Log tab (container name carries the repo-ID prefix, e.g. `..._plenum` — see #92) |
| `event_log` DB table | Persisted structured events: `id, timestamp, level, category, message, details` (`db.py`). Categories in use: `system`, `api`, `auth`, `engine`, `presence`, `ha`, `reconcile`, `dev` — the routes-layer `auth` category (#373 login/OIDC/MCP-token events) is not yet in the Logs.tsx filter list, see `plenum-diagnostics-and-tooling`. Capped ~5000 rows (trim had an off-by-one, #299) | `sqlite3 "$DATA_DIR/app.db" "SELECT timestamp,level,category,message FROM event_log ORDER BY id DESC LIMIT 50;"` (code default `/data/app.db` — `backend/main.py:52-53`; add-on default `/config/app.db` — see `plenum-run-and-operate`) |
| Logs page → Live Feed | UI over `GET /api/logs/events` + WS pushes | Filter by category/level; beware #302 dup rows, #283 zombie sockets |
| Logs page → Cycle History | UI over `GET /api/logs` (cycle_logs rows), per-cycle drill-down `GET /api/logs/{cycle_id}/detail` and `/temp-samples` | A cycle with `ended_at IS NULL` shows "Active/running" |
| Cycle tables | `cycle_logs`, `room_cycle_states` (per-room `target_temp, reached_at, vent_closed_at, role, joined_at`), `cycle_temp_samples`, `cycle_setpoint_history`, `cycle_vent_events` | `sqlite3 "$DATA_DIR/app.db" "SELECT id,mode,started_at,ended_at,ended_reason,in_min_runtime_hold FROM cycle_logs ORDER BY started_at DESC LIMIT 10;"` |
| HA states as Plenum sees them | `POST /api/ha/states` with `{"entity_ids": [...]}` — sensor values already normalized to °F | `curl -s -X POST localhost:8099/api/ha/states -H 'Content-Type: application/json' -d '{"entity_ids":["sensor.x"]}'` |
| Zone status | `GET /api/status` (per-zone cycle_state/hvac_mode/current_temp/setpoint), `GET /api/system/status`, `GET /api/healthz` | The #367 evidence format |
| Health endpoints | `GET /api/sensor-health` (per-sensor freshness vs staleness threshold), `GET /api/thermostat-health` | First stop for "engine seems blind" |
| Settings | `GET /api/settings`; `system_settings` table keys include `temperature_unit`, `unit_change_ack_required`, `unit_change_acked_unit`, `system_enabled`, `developer_mode`, `sensor_stale_after_min`, `vacation_mode_enabled`, `vacation_mode_return_at`, `outside_temperature_entity_id`, `mcp_enabled`, `mqtt_enabled`, `theme` (not exhaustive — full catalog in `plenum-config-and-flags`) | `sqlite3 "$DATA_DIR/app.db" "SELECT * FROM system_settings;"` |
| Live WS | `GET /ws` (`main.py:364`), broadcasts zone status + log events, temperatures raw °F | |
---
## Master triage table
| # | Symptom (as an operator states it) | Most likely causes, ranked | Discriminating experiment | Precedent |
|---|---|---|---|---|
| 1 | "Shows 158°F / -16°C / values off by ×1.8 or ~30°" | (a) absolute-vs-delta conversion misuse; (b) double conversion (frontend converted before POST); (c) a write path bypassing `to_f` (MCP, new endpoint); (d) HA fixture/instance in the wrong unit | Read the stored value: `sqlite3 "$DATA_DIR/app.db" "SELECT min_setpoint,deadband FROM thermostat_configs;"` — sane °F (60–85, deadband ~0.5–2) ⇒ display bug; insane (141.44, negative) ⇒ write-boundary bug | #231 #280 #281 #284 #291 #292 |
| 2 | "Cycle runs for hours / never ends" or "ends the instant it starts" | (a) post-closure drift blocking termination; (b) large `temp_offset` / stale sensor lying about room temp; (c) deadband in the completion check; (d) schedule window restarting cycles | `curl -s localhost:8099/api/logs/<cycle_id>/detail` — compare `vent_closed_at` per room vs cycle `ended_at`; then `GET /api/sensor-health` for staleness | #86 #70 #211 #38 |
| 3 | "Vents open/close repeatedly" or "stay open/closed when they shouldn't" | (a) min-runtime hold not gated (thrash); (b) close path bypassing `control_method` dispatcher (silently no-op close); (c) idle-room vents left over from previous cycle; (d) airflow floor (`min_open_vents_fraction`) refusing a close | `sqlite3 "$DATA_DIR/app.db" "SELECT timestamp,entity_id,action,reason FROM cycle_vent_events WHERE cycle_id='<id>' ORDER BY timestamp;"` — alternating open/close on the same entity ⇒ thrash; "Closed" logged but HA shows open ⇒ dispatcher bypass | #237 #57 #82 #67 #244 #210 |
| 4 | "The engine just stopped doing anything for a zone" | (a) exception before `engine.tick()` swallowed; (b) engine deleted mid-cycle (last room removed); (c) startup task garbage-collected; (d) `active_rooms=0` early-exit (no safety checks run) — now backstopped by `_add_safety_rooms`, #367; (e) thermostat unavailable mid-cycle | Backend log: is the 60 s `Reconcile <climate.x>: engine=... active_rooms=N` line still appearing for that zone? Absent ⇒ (a)/(b)/(c). Present with `active_rooms=0` while rooms bake ⇒ (d) | #286 #285 #304 #367 #267 |
| 5 | "Times are hours off / picker rejects valid times" | (a) `datetime.now()` (naive local) mixed into a UTC pipeline; (b) frontend printing raw naive-UTC string without `+ "Z"` conversion; (c) UTC used to build a `datetime-local` bound; (d) wrong `timezone` add-on option / `TZ` env | Offset direction test: shifted by exactly the browser's UTC offset ⇒ display bug (b); shifted the *other* way ⇒ backend wrote local time (a). Check `echo $TZ` in the container vs the `timezone` option | #65 #26 #294 #301 |
| 6 | "Live feed shows duplicates / keeps loading after I leave / Clear doesn't work" | (a) WS reconnect after intentional close (zombie sockets, stale handlers); (b) offset pagination sliding under new rows; (c) local-only Clear repopulated by polling | Browser devtools → Network → WS: more than one open `/ws` connection after navigating away and back ⇒ (a). Duplicated rows only after "Load older" ⇒ (b) | #283 #302 #303 #297 (backend twin) |
| 7 | "Vent didn't move / sensor shows unavailable / HA connection flaky" | (a) vent's `control_method` doesn't match what the integration supports (`cover.open_cover` unsupported); (b) sensor stale-but-numeric; (c) HA WS busy-loop or hung handshake during HA restart | Test the vent directly: `POST /api/vents/test` (the Rooms-page Test Open/Close buttons use it); `GET /api/sensor-health` for (b); backend log connect/close spam for (c) | #57 #82 #211 #297 |
| 8 | "CI is red" (the 3 classics) | (a) golden screenshot diff; (b) `TEMPERATURE_FIELDS` parity test; (c) coverage gate | (a) look at the changed PNGs in the PR diff — visual-regression legs now live in `container-ci.yml` (not a standalone e2e.yml) and a `commit-goldens` fan-in pushes regenerated goldens; (b) run `python -m pytest backend/tests/test_temperature_field_parity.py -v` from `smart_vent/`; (c) coverage below the ratchets (backend `fail_under` 96.7 as of 2026-09; thresholds table → `plenum-validation-and-qa` §6) | #182 #369 #329; → `plenum-ci-and-release` |
| 9 | "It's heating when it should cool" / "heat and cool alternate every few minutes" | (a) mode inferred from live `hvac_action` at cycle boundary (heat_cool oscillation); (b) `_read_hvac_mode` fallback defaulting to heat on `idle`; (c) big `temp_offset` or stale sensor flipping the room-temp vote; (d) restored stale cycle mode after restart | `sqlite3 "$DATA_DIR/app.db" "SELECT started_at,mode,ended_reason FROM cycle_logs ORDER BY started_at DESC LIMIT 10;"` — strict cool/heat alternation with minutes-long gaps ⇒ (a); one wrong-direction cycle after restart ⇒ (d) | #29 #38 #48 #26 |
---
## Family detail and time-sink stories
### 1. Temperatures wrong by ~×1.8 or ~30° (unit conversion)
The conversion contract is owned by `plenum-architecture-contract` / CLAUDE.md;
one line here: **storage is always °F; the backend converts on write
(`units.to_f` / `units.delta_to_f`), the frontend converts on display only.**
The helpers live in `smart_vent/backend/units.py` (re-exported into
`routes.py`; CLAUDE.md corrected 2026-07-05 to match).
Discriminate the four signatures:
| Signature | Meaning | Precedent |
|---|---|---|
| Stored value ≈ user's °C × 1.8² + offsets (16 → 141.44) | **double conversion** — frontend called `toStorage` before POST *and* backend `to_f` converted again | #231 |
| Stored value = raw user °C (21 stored as 21 °F) | write path **bypassed** `to_f` entirely — check MCP tools and any new endpoint against `TEMPERATURE_FIELDS` (`routes.py:425`, 19 entries as of v0.35.0 + `e2e/tests/temperature-fields.ts`) | #284 |
| Display shows a **negative °C** for a small positive delta (2 °F deadband → −16.7 °C) | **absolute conversion applied to a delta** — someone used `toDisplay`/`from_f` (subtracts 32) where `toDisplayDelta`/`from_f_delta` (scale only) was required | #291; magnitude-only ×1.8 error with no sign flip = delta never converted at all, #292 |
| Everything is °F even though the home is metric | auto-detect broken: HA `unit_system` is an *object* (compare `unit_system["temperature"] == "°C"`, not `== "metric"`), and blank `temperature_unit` in `run.sh` used to default-lock to `F` | #281; engine reading raw °C climate attributes as °F = #280 (runaway-HVAC class, not display) |
Story in one line (#231, the costliest — full account in
`plenum-failure-archaeology`): both sides converted, 16 °C stored as
141.44 °F, and each side's tests passed. If you see family-1 symptoms, your
first question is always "which side converted, and how many times?" —
answered by reading the stored °F value, never by reading the UI.
### 2. Cycle never terminates / terminates early
- **Never terminates (#86):** after a room's vent closes, its temperature
drifts back across the target on the very next tick; if the completion check
counts closed-vent rooms as "not at target", termination is blocked forever.
Rule that came out of it: *a room whose `vent_closed_at` is set has been
served; its later drift is irrelevant.* Check `room_cycle_states`: all rooms
have `vent_closed_at` set but the cycle's `ended_at` is NULL ⇒ this class.
- **Terminates/marks-at-target too early (#70):** deadband belongs to the
*start* decision, not the completion check. If rooms are declared done at
`target + deadband`, look at `_is_at_target` usage.
- **Stale sensors (#211):** a battery Zigbee sensor that dropped off the mesh
keeps its last numeric value in HA — engine happily controls on 3-hour-old
data. Guard exists now: `get_numeric_state(entity_id, max_age_min=...)`
treats readings older than the threshold as `None`
(`ha_client.py:180`); threshold is the `sensor_stale_after_min` system
setting (default `SENSOR_STALE_AFTER_MIN = 30.0`, `cycle_engine.py:55`),
tunable via `PUT /api/settings/sensor-staleness`, visible via
`GET /api/sensor-health`. A stale episode emits a one-per-episode warning
in the Live Feed.
- **`temp_offset` (per-room, delta, rooms table):** a large offset can hold a
room artificially far from target. `SELECT name,temp_offset,deadband_override
FROM rooms;`
- Also remember `cycle_timeout_hours` (default 3.0) is the backstop — a cycle
that ends with `ended_reason` mentioning timeout was probably stuck in one of
the above.
### 3. Vents thrash or stay open/closed wrongly
- **Thrash during min-runtime hold (#237):** after all active rooms hit target
but before `min_cycle_runtime_min` elapses, the engine re-opens vents so the
compressor has somewhere to blow — then the next monitor tick tried to
re-close them. Fix persisted `cycle_logs.in_min_runtime_hold` as a gate;
a room re-opened this way is recorded as a `reopened_min_runtime_hold`
vent event and gets `role='overflow'` in `room_cycle_states`. If you see
open↔close alternation in `cycle_vent_events` during a hold, the gate is
being bypassed.
- **"Closed" in logs but open in HA (#57/#82):** vents have a per-vent
`control_method` (`open_close` default, `set_position`,
`set_tilt_position`, `toggle` — `room_vents.control_method`). Any close
path that calls `ha.close_cover()` directly instead of
`VentController._invoke_close` silently no-ops on position/tilt vents.
Story: live logs proudly said "Closed idle room Bathroom vents" while every
Flair vent stayed wide open — the engine and reality disagreed for entire
cycles because the raw call sites were the two *idle-room* close paths,
which no test exercised. Discriminator: `POST /api/vents/test` (or the
Rooms-page Test buttons) against the same entity.
- **Idle-room vents wrong at cycle boundaries (#67/#244):** `_terminate_cycle`
reopens all zone vents; a new cycle must then *close* non-participating
rooms' vents (#67), and termination must reopen idle rooms it closed at
start (#244 — pre-fix, the once-per-cycle `Drift (idle)` WARNING was just
the reconciler cleaning this up, not a real fault). Current idle-reconciler
message: `Vent <entity> found closed while zone is idle — re-opened.` plus
"closed externally" guidance (`cycle_engine.py:3178`) — if you see it more
than ~once per cycle, suspect an external actor (manual HA control,
another automation), not the engine.
- **Airflow floor:** the count-based `min_open_vents` was replaced by
`min_open_vents_fraction` (default 0.333) + `total_vents_count` +
`has_bypass_damper` on `thermostat_configs` (#213 wave, dead-head
protection #210). A vent that "refuses" to close may be the floor working
as designed — check the event log reason before filing a bug.
- **A single `reopened_drift` event is not thrash (#503):** a *served* room
(`vent_closed_at` already set) whose temp drifts back past its deadband
mid-cycle now gets its vent deliberately reopened rather than left served-
and-ignored. One `reopened_drift` row per room per cycle is expected
behavior, not the alternating-thrash pattern this family is about —
only treat it as a bug if the same entity oscillates open/close
repeatedly in `cycle_vent_events`.
### 4. Engine silently stops controlling a zone
The nastiest family because the failure is *absence of logs*.
- **#286:** `_tick_engine` had DB calls *outside* its try block; a
`database is locked` there propagated into a `gather(...,
return_exceptions=True)` whose result was discarded — zero output, zone
dead. Discriminator: the per-tick `Reconcile <entity>: engine=...` line
(category `reconcile`, `cycle_engine.py:2967`) stops appearing for that
zone while other zones keep logging.
- **#285:** deleting the last room of a thermostat deleted its engine mid-cycle
with no abort — HA kept the overshoot setpoint indefinitely, cycle row stuck
`ended_at IS NULL`. If a zone vanished from the UI but HA is still
heating/cooling toward a weird setpoint, check for an orphan open cycle:
`SELECT id,thermostat_entity_id,started_at FROM cycle_logs WHERE ended_at IS NULL;`
- **#304:** fire-and-forget startup tasks held only by weak refs could be
GC'd — unit resolution silently never ran. Suspect this only right after
startup.
- **#367:** `active_rooms=0` early-return skipped *all* safety checks —
`max_setpoint` was only evaluated on the active-room path, so an upstairs
zone sat at 81 °F with a 77 °F "hard cap" for 2+ hours, every tick calmly
logging `active_rooms=0`. Lesson: a safety cap that lives inside the comfort
path is not a safety cap. Fixed by `_add_safety_rooms` (`cycle_engine.py:3640`),
which pulls any room that breached its envelope into the active set with
`source="safety"` even with no other demand. Reconcile lines *present* +
rooms out of bounds ⇒ this class.
- **#267:** thermostat `unavailable` mid-cycle used to suspend every safety
monitor forever. Now bounded by `thermostat_configs.unavailable_abort_after_min`
(default 5); check `GET /api/thermostat-health`.
### 5. Timezone-shifted times
Backend rule (see `backend/tz.py` docstring): **store UTC, convert to local
only at display/aggregation**, local zone comes from the `timezone` add-on
option exported as `TZ` by `run.sh` (line 40/108; option default
`America/New_York` in `config.yaml`). Naive-UTC ISO strings cross the API; the
frontend appends `"Z"` before `new Date(...)`.
Ranked causes, with direction as the discriminator (browser at UTC−5):
1. Time shown 5 h *early* → backend wrote **local** naive time and frontend
added "Z" (double shift) — the #65 `datetime.now()`-in-`room_manager`
pattern.
2. Time shown 5 h *late* (i.e. raw UTC) → frontend printed the naive-UTC
string verbatim without the `+ "Z"` conversion — #26 (Dev Mode), #301
(vent timeline).
3. A `datetime-local` picker rejecting valid near-future times → its
`min`/bound built with `toISOString()` (UTC) instead of local components —
#294 (vacation modal).
4. Schedules firing at wrong wall-clock times → wrong `TZ` in the container:
`docker exec <c> printenv TZ` and compare with the add-on option; all local
time must flow through `tz.get_timezone()`.
### 6. WS / live-feed weirdness
- **#283 (frontend):** `connectWS`'s close listener unconditionally scheduled a
reconnect, so an *intentional* dispose resurrected the socket with the old
handler — N filter changes ⇒ each event rendered N+1 times, and every
Dashboard visit left a zombie re-fetching 4 endpoints per broadcast forever.
Current `api.ts` `connectWS` (~line 863) tracks a `closed` flag; if dup rows
return, check that flag logic first, then devtools → count open `/ws`
connections.
- **#302:** offset pagination + newest-first feed + live inserts ⇒ "Load older"
refetches rows you already have (React duplicate-key warnings in console are
the tell).
- **#303:** Dev Mode "Clear" only cleared local state; the 3 s poll of
`/api/logs/events?category=dev` repopulated it.
- **#297 (backend twin):** HA WS client busy-looped on clean close (no backoff
reset guard) and could hang forever in an unguarded handshake `receive_json`
— symptom: connect/auth/subscribe log spam during an HA restart window, or
an add-on that never reconnects until restarted.
### 7. HA connection / entity problems
- Unsupported cover service → family 3 (#57/#82): fix is per-vent
`control_method`, verified with `POST /api/vents/test`; a vent failure is
logged as an `error` event (category `engine`) and never aborts the cycle.
- Sensor `unavailable`/`unknown` → `get_numeric_state` returns `None` (room
skipped / thermostat-ambient fallback); sensor *stale but numeric* → family
2 (#211), check `GET /api/sensor-health`.
- Thermostat entity unavailable → #267 abort-after-N-minutes; transient blips
are tolerated by design.
- HA restart window → #297 reconnect pathologies; also #38 failure mode 1:
right after (re)connect the state cache can be empty, so mode inference sees
no readings — the engine falls back to thermostat ambient rather than
returning "off".
- `POST /api/ha/states` (`routes.py:2069`) is the "what does Plenum think HA
says" oracle — sensor values in the response are already normalized to °F,
so comparing it with HA's own Developer Tools (which shows native units) is
itself a unit-conversion check.
### 8. CI failures — the three classics (detail lives in `plenum-ci-and-release`)
1. **Golden diff.** Any rendered-UI change fails the visual-regression legs
(inside `.github/workflows/container-ci.yml`; the standalone `e2e.yml`
is gone — CLAUDE.md corrected 2026-07-05). The matrix legs regenerate goldens and a
`commit-goldens` fan-in job pushes them back (`git push origin
HEAD:"$BRANCH"` — the detached-HEAD form, #369). Time-varying UI must be
wrapped in `<Frozen>` (`frontend/src/ci.tsx`) or goldens never stabilize
(#182). Review changed PNGs like code.
2. **Parity test.** `smart_vent/backend/tests/test_temperature_field_parity.py`
fails if `TEMPERATURE_FIELDS` in `routes.py` and
`e2e/tests/temperature-fields.ts` diverge, a `kind` mismatches, or a
`ui: true` field lacks a `// @covers:` tag in
`e2e/tests/temperature-units.spec.ts`. It is telling you a #231-class bug
is possible — add the missing entry, don't weaken the test.
3. **Coverage gate.** Backend `fail_under` ratchet in
`smart_vent/pyproject.toml`; frontend vitest thresholds in
`smart_vent/frontend/vite.config.ts` (values: `plenum-validation-and-qa`
§6). Pre-2026-07-05 CLAUDE.md copies quoted older numbers; corrected in
PR #388 — if docs and repo disagree again, the repo wins.
Also common but flake-class: the E2E global-setup room-creation click race
(#329) — a red Round-trip leg on an unrelated PR is usually this; re-run
before digging.
### 9. Wrong HVAC mode / oscillation
- **#29 (the classic):** on `heat_cool` thermostats, deriving each new cycle's
direction from live `hvac_action` at the cycle *boundary* oscillates: cool
to 68, terminate, thermostat now below its own heat setpoint reports
`heating`, engine heats to 72, repeat — HVAC fighting itself. Fix direction
from **room temps vs targets** (`_infer_mode_from_room_temps`), and if all
rooms are within deadband, start nothing.
- **#26 sub-bug 1a:** `hvac_action` goes `idle` *exactly when* the room reaches
target (it satisfied its own setpoint); a fallback that maps idle-on-
heat_cool to "heating" inverts the at-target comparison at precisely the
moment it matters, so vents never close. Any mode-fallback that "defaults to
heat" (#48 bug 2) is a red flag in review.
- **#38:** no ambient cross-check — a `temp_offset = -4` room or a stale sensor
can vote "heat" while the thermostat itself reads 74 °F against a 72 °F
target. Also post-restart: `restore_from_db` used to resurrect a stale cycle
mode unvalidated.
- **#48 bug 1:** correct mode but HVAC never fires — setpoint computed only
from room targets can land on the satisfied side of the *thermostat's* own
ambient (hallway probe cooler than bedrooms). The setpoint must be clamped
strictly beyond thermostat ambient.
- Mode is **locked per cycle** (`_cycle_mode`) — mid-cycle flips are corrected
by reconciliation (watch for `Drift: thermostat ... mode changed` events).
Oscillation therefore always lives at cycle *boundaries*; check
`cycle_logs.mode` alternation, not mid-cycle state.
---
## Provenance and maintenance
Date-stamped facts originally verified against the repo on 2026-07-04,
v0.22.1; spot-checked and refreshed 2026-09-01 against v0.35.0 (line numbers,
`fail_under`, `TEMPERATURE_FIELDS` size, event categories, and the new
`reopened_drift` vent action were the drifted items found). How to re-verify:
- Table names / schemas: `grep -n "CREATE TABLE" smart_vent/backend/db.py`
- Endpoints: `grep -n "@routes\." smart_vent/backend/api/routes.py`
- Unit helpers in `backend/units.py` (not routes.py): `grep -n "def " smart_vent/backend/units.py`
- `TEMPERATURE_FIELDS` at `routes.py:425` (19 entries as of v0.35.0) and `e2e/tests/temperature-fields.ts` (line numbers drift; grep the name)
- Staleness default 30.0 min: `grep -n "SENSOR_STALE_AFTER_MIN" smart_vent/backend/engine/cycle_engine.py` (now line 56)
- `unavailable_abort_after_min` default 5, `min_open_vents_fraction` default 0.333: thermostat_configs DDL in `db.py`
- Coverage gates: `grep fail_under smart_vent/pyproject.toml`; thresholds block in `smart_vent/frontend/vite.config.ts`
- Visual regression + round-trip legs inside `container-ci.yml`: `grep -n "visual\|Round-trip\|commit-goldens" .github/workflows/container-ci.yml`
- WS route `/ws`: `grep -n add_get smart_vent/backend/main.py`
- `TZ` from `timezone` option: `grep -n TZ smart_vent/run.sh`
- Event-log cap ~5000 rows and categories: `smart_vent/backend/db.py` (`_EVENT_LOG_MAX`) and `event_logger.py`
- Issue bodies quoted from the GitHub issue tracker (`dhruvb14/smart-thermostat-with-vents`), mined 2026-07-04; issue numbers are stable, quoted line numbers inside them are not.
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!