Controller watchdog: notice when the lights aren't showing what Iris pushed, and put them back #148
Labels
No labels
area/ai
area/backend
area/frontend
area/infra
area/scheduler
area/wled
good-first-issue
priority/high
priority/low
priority/medium
security
type/bug
type/chore
type/ci-cd
type/docs
type/feature
type/qa
v1.0.0
v1.1.0
v1.2.0
v2.0.0
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
rbrooks/Iris-WLED#148
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Problem
Iris pushes state at transitions (on, dim, off, ramp steps, the morning window) and then assumes the controller keeps it. Nothing checks. When a controller loses power or reboots, which is routine for a roofline controller on an outdoor circuit, a GFCI trip or a storm, it comes back in its own boot preset, not in what Iris last pushed. Iris doesn't find out until the next scheduled transition, which can be hours later. The same applies after a firmware update, or after someone pulls the plug to move an inflatable.
For a set-and-forget tool, this is the most likely way the lights will be wrong on a given night, and today nothing reports it.
What exists (verified against
main):run_dim_jobandrun_off_ramp_jobcallget_state()only to skip whenonis false (services/schedule_service.py).services/wled_push.pypush_with_fallback). A reboot after a successful push is invisible to them.last_known_goodpushes the wrong state. Thelast_known_goodpolicy's reconnect watcher re-pushes the state that failed, not the module's stored_last_known_good, which is set on success but only exposed viaget_last_known_good(). The name promises something the code doesn't do; worth fixing or renaming here.info.uptime. It is the cheapest reboot signal WLED offers.Design
A single "what should be showing now" resolver comes first. Today that knowledge is spread across
_push_today_scheme,get_active_times,_ramp_ceiling,quick_push_manager.get_status(),resolve_no_event_stateand the envelope builder, and it is expressed by pushing, not by returning a state. Refactor it intoexpected_state(now) -> ExpectedState | Indeterminate, then make both the scheduler's pushes and the watchdog call it.The watchdog job runs every
WATCHDOG_INTERVAL_MINminutes (default 5). It is an interval job, registered likeaurora_poll, and on by default. On each tick it:/json/info(uptime) and/json/state. If the controller is unreachable, it records that and leaves recovery to the existing reconnect path, so there is no double handling.uptimeis lower than the last observed value. That is unambiguous, and it is the only case the watchdog corrects without question.on,bri, and each segment'scol,fx,sx,ix,pal), with a small tolerance onbri.ramp.pysteps legitimately movebri), a preview is active (preview_manager), orexpected_stateis indeterminate./remoteoverride is the expected state until it reverts, so compare against that.Don't fight a human. The hard part is someone using the WLED app on purpose. If the watchdog reverted every manual change within 5 minutes, it would be infuriating. So there is a policy setting (runtime-settable):
reboot_only(default): correct only after a detected reboot, or a reachable-again transition. Report drift, but don't correct it.enforce: correct any drift. Right for installs where nobody touches the controller.off.Report every correction:
state_correctedwebhook event,{reason: "reboot"|"drift", expected, observed_summary}.iris_watchdog_corrections_total{reason}andiris_watchdog_last_check_timestamp_secondsmetrics.schedule_logrow.Drift that isn't corrected (
reboot_only) is logged and sent asstate_drift, once per episode, not every tick.Edge cases
DemoControllernever reboots. The watchdog must still run against it for coverage. Add a test hook that simulates an uptime reset.Acceptance
expected_state(now)resolver exists, and the scheduler's own pushes use it. There is a test proving the scheduler's push andexpected_stateagree across the envelope: on, dim, ramp, morning window, off, quick push and no-event.reboot_onlymode is reported once and not corrected. Inenforcemode it is corrected.docs/setup.mdalert rule:increase(iris_watchdog_corrections_total[1d]) > 3suggests a flaky circuit) and a CHANGELOG entry.Picking this up now that #149 (
day_plan) and #150 (the guard in the plan) are merged.expected_state(now)is built on top ofplan_for_date. The plan answers "what for this night"; the new layer adds "what at this moment": the envelope (on, dim, overnight-off, morning-on, off), ramps, quick-push and/remoteoverrides, and previews. The on, dim and off jobs will push whatexpected_statesays at their fire time, with a parity test, so the watchdog and the scheduler can't disagree.Default policy is
reboot_only, as the issue body says. Thelast_known_goodnaming mismatch gets fixed here too.Done in #175 (squash-merged, CI green).
Delivered:
services/expected_state.py:expected_state(now), built on the day plan, anddifferences(), the comparator.DemoControlleracross 8 configurations × approved, unapproved and no-event nights, and asserts that the controller then shows exactlyexpected_state.last_known_goodnow means "retry when the controller is back", and on reconnect it pushes what should be showing now. The unused global is gone.info.uptime, persisted per host, so an Iris restart isn't mistaken for a reboot but a shared power cut is caught.on,bri(±2) and each segment'scol/fx/sx/ix/pal.reboot_only(the default),enforceandoff.state_correctedandstate_driftwebhooks and theiris_watchdog_*metrics.Still open, needs hardware: pull the roofline controller's power mid-evening and confirm it's corrected within one interval.
Noted, filed or already tracked: