Skip to content

feat: LED signals an unacknowledged fault instead of being permanently on - #5

Merged
varffs merged 21 commits into
masterfrom
led-fault-signal
Jul 28, 2026
Merged

feat: LED signals an unacknowledged fault instead of being permanently on#5
varffs merged 21 commits into
masterfrom
led-fault-signal

Conversation

@varffs

@varffs varffs commented Jul 28, 2026

Copy link
Copy Markdown
Owner

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:

  1. at least one fault is active, and
  2. at least one active fault is not in the set last acknowledged, and
  3. it is not the quiet period (sunset + 30 min → sunrise − 30 min)

No state exists in which the LED is steadily on. Lit means a 750 ms pulse inside a 3 s cycle.

How it behaves

  • Faults: 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.
  • Acknowledgement is physical: cycling the display to DIAG requires standing at the device, so looking is acknowledging. The acknowledgement covers only faults still live, so a fault that clears and returns is new again.
  • Night suppression is computed locally from the site coordinates via 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.
  • DIAG screen: line 0 still shows ext: <status>; line 1 now shows int / psh / clk flags when any are live, displacing the diagnose() text until they clear.
  • Restart re-arms, because seenFaultKey is 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=true bypasses night suppression for development, otherwise evening work on the laptop shows a permanently dark LED and reads as broken.

Verification

103 tests passing. app.js is 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

  • suncalc 2.0.1 is ESM with named exports only — no default export. The import form is import { 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 --test needs 16.17+), so a load-time import error would only surface on deploy.
  • The acknowledgement rule is two interacting halves — containment in 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.js has no test coverage and gained three listener registrations plus a shutdown ordering requirement here. Also worth a ticket.
  • Not done, deliberately: explicit serializableCheck/immutableCheck: false in configureStore, which would retire the device's dependence on NODE_ENV=production living 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.md
Plan: docs/superpowers/plans/2026-07-27-led-fault-signal.md

🤖 Generated with Claude Code

varffs and others added 21 commits July 27, 2026 23:38
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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) into app.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.

@varffs
varffs merged commit 8a0a649 into master Jul 28, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants