feat: LED signals an unacknowledged fault instead of being permanently on - #5
Merged
Conversation
Button LED is switched on at boot and never off — no information, and visible from the house at night. Redefine it as "unacknowledged fault": pulses when a fault is unseen, clears when you read the DIAG screen, suppressed between sunset+30m and sunrise-30m. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
10 TDD tasks. Also amends the spec where the plan sharpened it: fault selectors move to their own src/faults.js to avoid a store<->led import cycle, push.js turns out to need no change, and the quiet-window membership test needs two candidate days rather than three. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Add state fields to track internal sensor status, push failures, and LED state including clock sanity and fault detection. Implements the reducer cases to set these fields and raises the clockWasInsane flag when invalid timestamps are encountered. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The adapted tests for insane timestamps (SANITY_EPOCH - 1 and NaN) were weakened by using targeted assertions instead of full-state equality checks. Strengthen to deepEqual against expected whole-state, matching the original proof that nothing else in the state was mutated. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The spec guessed ES5/CJS pre-install. suncalc 2.0.1 is type:module with
named exports only, so the import form is { getTimes }. Still single-file,
zero runtime deps, no native bindings — armv6 unaffected.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Boot dispatches its own buttonPress, so three manual presses reach DIAG, not four. Found during the Task 9 virtual-mode run. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
selectArmed compared the live fault key against seenFaultKey with string inequality, so ANY change to the set re-armed — including the set shrinking. Acknowledge "psh|ext", let push recover to "ext", and the LED pulsed at a situation that had strictly improved and was already read. With ext permanently live on this device that fired for every transient int/psh fault that cleared after being acknowledged. Armed now means: some LIVE fault is absent from the acknowledged set. seenFaultKey stays stored as the same "a|b" string — it is split back into a set only where the comparison happens, so the state shape and its serializability are unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Entering DIAG cleared led.clockWasInsane in the same reducer pass that set
mode: "DIAG". formatDiagLines derives line 1 from selectFaults on that same
post-clear state and renderDisplay only reaches it once the mode is already
DIAG, so the flag was gone before the one screen that reports it ever
rendered. With clk as the only fault the user walked out to a screen reading
as healthy, and the flag was then gone for good — no second chance.
Entering DIAG now captures seenFaultKey from the current state, clk
included, and leaves the flag raised. It is cleared when the display LEAVES
DIAG — both display/sleep and the buttonPress that cycles DIAG round to
DEFAULT — so a sticky flag cannot permanently displace the diagnose() text
on line 1. Only leaving DIAG clears it: sleeping from MINMAX would discard a
fault the user never saw.
The old clear-before-capture ordering existed to stop acknowledgement from
instantly re-arming. That hazard was a property of the string comparison in
selectArmed, now containment: live {ext} is a subset of acknowledged
{clk, ext}, so clearing the flag afterwards is inert.
The render test that asserted line 1 === "int psh clk" hand-built
clockWasInsane: true alongside mode: "DIAG" — a state no reducer path could
reach, which is why it passed against the bug. It now drives the real
reducer to DIAG from a faulted state.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The hardware-sync effect returned led.write()'s promise without awaiting it. RTK does await whatever an effect returns, inside its own try/catch, so this was not the uncaught exception it looked like — but every failed write cost a full listenerMiddleware/error stack trace, and the blink loop drives this pin up to 40 times a minute where the backlight is written twice a day. The effect is now async and awaits inside a catch that logs one line. The SIGINT handler awaited led.cleanup(), which unexports the pin, but never cancelled the blink loop's pending api.delay — so on a device shut down while armed, the loop woke mid-teardown and wrote to a closed fd for the rest of the shutdown. listener.clearListeners() now runs first, aborting every executing listener before any hardware is touched. It also cancels the pending backlight timeout, which is wanted on the way out. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
seenFaultKey was a monotonic high-water mark. Its only shrink path was the
post-pass reset at full health, which requires every fault to clear at once —
unreachable on this device, where the external probe is physically dead and
"ext" is permanently live. So the acknowledged set only ever grew: a genuine
second 15-minute push outage, or a returning internal-sensor error, arrived
already pre-acknowledged and the LED stayed dark for the remaining uptime of
the pm2 process.
The post-pass now intersects seenFaultKey with the live fault set on every
action instead of only nulling it at full health. An acknowledgement covers
only faults that are still live, so a fault that clears and returns is new
again and re-arms. Full health becomes the case where the intersection comes
out empty, not a separate branch.
seenFaultKey stays a serializable "a|b" string with null for "nothing
acknowledged"; ordering comes from selectFaults so the stored key keeps
FAULT_ORDER and remains a stable identity. Empty normalises to null — "" is
not a valid stored key and must not become observable. The reducer stays pure
and faults.js still imports nothing.
Containment in selectArmed is unaffected and still load-bearing: at the moment
push recovers, live {ext} against seen {psh,ext} must not arm, and does not.
One pre-existing assertion changes, intendedly: after leaving DIAG,
seenFaultKey is now "ext" rather than "clk|ext", because clk is no longer live
by then. clk is still captured in full on DIAG entry, so the screen the press
opens still reports it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The "recurrence re-arms rather than being silently pre-acknowledged" rule was written as a consequence of the fault key reaching "", which is unreachable on this device while the external probe is dead — so as stated it was self-contradictory. Restate it as the intersection: an acknowledgement covers only faults that are still live, and full health is the case where the intersection comes out empty. Records the failure it prevents (a second sustained push outage arriving pre-acknowledged, LED dark for the rest of the process), why containment alone is not enough on its own, and the clk ordering — captured on DIAG entry while still live, dropped when leaving DIAG clears the flag. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR changes the GPIO 17 button LED from a “power on forever” indicator into a meaningful fault signal: it now pulses only when there is at least one active, unacknowledged fault, and it is suppressed during a computed night “quiet period” (sunset+30min → sunrise−30min).
Changes:
- Add a pure fault-derivation module (
src/faults.js) with a stable ordered fault key and “armed” (unacknowledged) detection. - Implement acknowledgement semantics in the reducer (entering DIAG records the current fault set; acknowledgements shrink to live faults; clock-insanity is sticky until DIAG is read and exited).
- Add quiet-period calculation via
suncalc(src/solar.js) and wire a listener-driven LED blink loop (src/led.js) intoapp.js, plus config for site/timing and dev override.
Reviewed changes
Copilot reviewed 20 out of 21 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| src/store.test.js | Expands reducer tests for new sensor/LED state, acknowledgement narrowing, and clock fault behavior |
| src/store.js | Adds internal/push/LED state, sticky clock flag logic, DIAG acknowledgement capture, and post-pass acknowledgement narrowing |
| src/solar.test.js | Tests quiet-period logic across seasons, boundaries, and “bad clock” handling |
| src/solar.js | Implements isQuietPeriod() based on SunCalc sunset/sunrise windows with a margin |
| src/sensors.test.js | Adds coverage ensuring internal sensor status is dispatched on success/failure |
| src/sensors.js | Dispatches sensors/internal/status as "ok"/"error" from pollInternal |
| src/render.test.js | Adds DIAG rendering coverage for fault flags vs external diagnostic text |
| src/render.js | Shows non-ext fault flags on DIAG line 1 when present |
| src/led.test.js | Adds listener-middleware tests validating pulse behavior, quiet suppression, and disarm behavior |
| src/led.js | Implements the listener-driven blink loop gated by “armed” and injected quiet-check |
| src/faults.test.js | Adds unit tests for fault detection, key ordering, and armed semantics |
| src/faults.js | New pure selectors: selectFaults, selectFaultKey, selectArmed (+ constants) |
| src/config.test.js | Verifies new site/timing config and LED_IGNORE_QUIET override behavior |
| src/config.js | Adds site coords, quiet margin, blink timings, and dev override flag |
| package.json | Bumps version to 2.1.0 and adds suncalc dependency |
| package-lock.json | Locks suncalc@2.0.1 |
| docs/superpowers/specs/2026-07-27-led-fault-signal-design.md | Adds the detailed design spec for the new LED semantics and architecture |
| docs/superpowers/plans/2026-07-27-led-fault-signal.md | Adds the implementation plan and test expectations |
| CHANGELOG.md | Adds 2.1.0 release notes describing the LED behavior changes |
| app.js | Wires in blink + GPIO sync listeners, push outcome reporting, boot LED off, and shutdown listener cancellation |
| .env.example | Documents LED_IGNORE_QUIET dev override |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
The button LED (GPIO 17) was switched on at boot and never off. It carried no information — on when healthy, on when the external probe was dead, on at 3am. The polytunnel box is visible from the house, so it was light pollution with no payload.
This redefines it as "there is a fault and you have not looked at it yet."
Three conditions must hold simultaneously for the LED to be lit:
No state exists in which the LED is steadily on. Lit means a 750 ms pulse inside a 3 s cycle.
How it behaves
int(internal AHT20 read failed),psh(3 consecutive push failures = 15 min of sustained failure),clk(booted with an insane clock — sticky, so it can still report once NTP lands),ext(external DS18B20 not ok)."unknown"is the boot value and is deliberately not a fault.suncalc— no network call. At 50.77°N the LED is permitted roughly 04:30–21:50 local at midsummer and 07:40–16:35 at midwinter. The long winter blackout is intended.ext: <status>; line 1 now showsint/psh/clkflags when any are live, displacing thediagnose()text until they clear.seenFaultKeyis not persisted. With the external probe dead that means the LED pulses after a restart until someone visits — deliberate: an unexplained restart is worth knowing about.LED_IGNORE_QUIET=truebypasses night suppression for development, otherwise evening work on the laptop shows a permanently dark LED and reads as broken.Verification
103 tests passing.
app.jsis outside the test import graph, so the wiring was verified by running it: measured 751 ms on / 2255 ms off with a fault forced, silent when healthy, pulsing stopped the instant DIAG was reached, and a real-time run at 01:23 BST stayed dark with a fault active — the night gate works against the actual clock.Notes for review
suncalc2.0.1 is ESM with named exports only — no default export. The import form isimport { getTimes } from "suncalc". Single file, no runtime deps, no native bindings, no postinstall, so armv6 is unaffected. The device cannot run the suite (Node 16.14,node --testneeds 16.17+), so a load-time import error would only surface on deploy.selectArmed, intersection in the reducer's post-pass — and each is correct only because of the other. Both defects found in this area were caught by review rather than by test; a property test would serve better than more examples. Worth a follow-up ticket.app.jshas no test coverage and gained three listener registrations plus a shutdown ordering requirement here. Also worth a ticket.serializableCheck/immutableCheck: falseinconfigureStore, which would retire the device's dependence onNODE_ENV=productionliving only in pm2's saved dump. Left as an owner decision because it also removes the dev-mode safety net.Spec:
docs/superpowers/specs/2026-07-27-led-fault-signal-design.mdPlan:
docs/superpowers/plans/2026-07-27-led-fault-signal.md🤖 Generated with Claude Code