diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a1058a2 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,12 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- LCD backlight sleeps after 30 s; button press wakes it, presses while lit cycle display modes and reset the timeout diff --git a/app.js b/app.js index 5a99c9f..304ee9e 100644 --- a/app.js +++ b/app.js @@ -6,7 +6,8 @@ import { } from "piteknix"; import { loadConfig } from "./src/config.js"; -import { appReducer, nextMode } from "./src/store.js"; +import { appReducer, buttonPress } from "./src/store.js"; +import { registerBacklightTimeout } from "./src/backlight.js"; import { renderDisplay } from "./src/render.js"; import { pollInternal, pollExternal } from "./src/sensors.js"; import { pushData } from "./src/push.js"; @@ -28,14 +29,26 @@ listener.startListening({ predicate: (action) => action.type.startsWith("data/") || action.type.startsWith("sensors/") || - action.type === "display/next", + action.type.startsWith("display/"), effect: (_action, api) => renderDisplay(api.getState(), display), }); + +// Backlight hardware sync — fires only when isBacklit actually changes +listener.startListening({ + predicate: (_action, curr, prev) => curr.display.isBacklit !== prev.display.isBacklit, + effect: (_action, api) => display.setBacklight(api.getState().display.isBacklit), +}); + +registerBacklightTimeout(listener, { timeoutMs: cfg.backlightTimeoutMs }); const store = configureStore({ reducer: appReducer, middleware: (getDefault) => getDefault().prepend(listener.middleware), }); +// boot behaves like a first press: light up, arm the sleep timer. Dispatched +// before the initial poll so a slow 1-wire read can't delay arming it. +store.dispatch(buttonPress()); + await display.clear(); display.printLine(0, "starting up..."); await led.on(); @@ -73,8 +86,8 @@ const push = setInterval(async () => { if (isVirtualMode()) { setupKeyboardListener({ onDebug: () => console.log(JSON.stringify(store.getState(), null, 2)) }); } -// Button (GPIO 27 / key "1") cycles display modes: DEFAULT <-> DIAG -button.watch(() => store.dispatch(nextMode())); +// Button (GPIO 27 / key "1") is context-sensitive: wakes backlight when dark, cycles DEFAULT <-> DIAG when lit +button.watch(() => store.dispatch(buttonPress())); process.on("SIGINT", async () => { clearInterval(poll); diff --git a/docs/superpowers/plans/2026-07-25-backlight-timeout.md b/docs/superpowers/plans/2026-07-25-backlight-timeout.md new file mode 100644 index 0000000..0813545 --- /dev/null +++ b/docs/superpowers/plans/2026-07-25-backlight-timeout.md @@ -0,0 +1,332 @@ +# Backlight Timeout + Button Wake Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** LCD backlight sleeps after 30 s; first button press wakes it, presses while lit cycle the display mode and reset the timeout. + +**Architecture:** Reducer owns press semantics (a single `display/buttonPress` action means "wake" when dark, "cycle mode" when lit; `display/sleep` darkens and resets mode to DEFAULT). An RTK listener-middleware entry owns the 30 s timer via `cancelActiveListeners()` + `delay()`. A second edge-triggered listener syncs `isBacklit` to the display hardware. Boot dispatches one `buttonPress` so startup behaves exactly like a first press. + +**Tech Stack:** Node.js ESM, `@reduxjs/toolkit` v2 (`configureStore`, `createListenerMiddleware`), `node --test` + `node:assert/strict`. No new dependencies. + +**Spec:** `docs/superpowers/specs/2026-07-25-backlight-timeout-design.md` + +## Global Constraints + +- Node >= 18, ESM (`"type": "module"`), test runner is `node --test 'src/**/*.test.js'` via `npm test`. +- Working dir: `~/Sites/worldofpi/polyteknix`, branch `feature/backlight-timeout`. +- Action naming follows REDUX-SPEC `slice/property` convention. +- Reducer must stay pure — timers and hardware calls live in listener middleware only. +- `display/next` and the `nextMode()` action creator are retired; `getNextMode` (the pure helper) stays. +- No piteknix changes — both display drivers already implement `setBacklight(on)`. +- After Task 1 and until Task 3 completes, `app.js` still references the removed `nextMode` — tests stay green throughout (app.js is not in the test import graph), but do not `npm run dev` between those tasks. + +--- + +### Task 1: Reducer press/sleep semantics + +**Files:** +- Modify: `src/store.js` +- Test: `src/store.test.js` + +**Interfaces:** +- Consumes: existing `initialState`, `getNextMode` in `src/store.js`. +- Produces: `buttonPress()` → `{ type: "display/buttonPress" }` and `sleep()` → `{ type: "display/sleep" }` action creators, exported from `src/store.js`. Reducer honours both. `nextMode` export and `display/next` case removed. Tasks 2 and 3 import `buttonPress`/`sleep` by these exact names. + +- [ ] **Step 1: Write the failing tests** + +In `src/store.test.js`, change the import line and replace the `display/next` test with the four new tests: + +```js +import { appReducer, initialState, setInternalTemp, setExternalStatus, buttonPress, sleep } from "./store.js"; +``` + +Delete the `test("display/next cycles DEFAULT -> DIAG -> DEFAULT", ...)` block. Append: + +```js +test("buttonPress when dark wakes backlight without cycling mode", () => { + const s = appReducer(initialState, buttonPress()); + assert.equal(s.display.isBacklit, true); + assert.equal(s.display.mode, "DEFAULT"); +}); + +test("buttonPress when lit cycles mode and keeps backlight on", () => { + const lit = appReducer(initialState, buttonPress()); + const s1 = appReducer(lit, buttonPress()); + assert.equal(s1.display.mode, "DIAG"); + assert.equal(s1.display.isBacklit, true); + const s2 = appReducer(s1, buttonPress()); + assert.equal(s2.display.mode, "DEFAULT"); +}); + +test("sleep turns backlight off and resets mode to DEFAULT", () => { + const lit = appReducer(initialState, buttonPress()); + const onDiag = appReducer(lit, buttonPress()); + const s = appReducer(onDiag, sleep()); + assert.equal(s.display.isBacklit, false); + assert.equal(s.display.mode, "DEFAULT"); +}); + +test("sleep when already dark is a no-op shape-wise", () => { + const s = appReducer(initialState, sleep()); + assert.equal(s.display.isBacklit, false); + assert.equal(s.display.mode, "DEFAULT"); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd ~/Sites/worldofpi/polyteknix && npm test` +Expected: FAIL — `buttonPress` is not exported (SyntaxError on import in `store.test.js`). + +- [ ] **Step 3: Implement in `src/store.js`** + +Replace the `nextMode` creator with the two new creators (leave the other creators untouched): + +```js +export const buttonPress = () => ({ type: "display/buttonPress" }); +export const sleep = () => ({ type: "display/sleep" }); +``` + +In `appReducer`, replace the `case "display/next":` block with: + +```js + case "display/buttonPress": + if (!state.display.isBacklit) { + return { ...state, display: { ...state.display, isBacklit: true } }; + } + return { ...state, display: { ...state.display, mode: getNextMode(state.display.mode) } }; + case "display/sleep": + return { ...state, display: { ...state.display, isBacklit: false, mode: "DEFAULT" } }; +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npm test` +Expected: PASS — 28 tests, 0 fail (25 before this task, minus the deleted display/next test, plus these 4). + +- [ ] **Step 5: Commit** + +```bash +git add src/store.js src/store.test.js +git commit -m "feat: buttonPress/sleep reducer semantics for backlight wake" +``` + +--- + +### Task 2: Backlight timeout listener module + +**Files:** +- Create: `src/backlight.js` +- Test: `src/backlight.test.js` + +**Interfaces:** +- Consumes: `sleep` creator from `src/store.js` (Task 1); RTK `listenerMiddleware.startListening` API. +- Produces: `registerBacklightTimeout(listener, { timeoutMs })` exported from `src/backlight.js` — `listener` is the object returned by `createListenerMiddleware()`. Task 3 calls it with `{ timeoutMs: cfg.backlightTimeoutMs }`. + +- [ ] **Step 1: Write the failing tests** + +Create `src/backlight.test.js`: + +```js +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { configureStore, createListenerMiddleware } from "@reduxjs/toolkit"; +import { appReducer, buttonPress } from "./store.js"; +import { registerBacklightTimeout } from "./backlight.js"; + +const wait = (ms) => new Promise((r) => setTimeout(r, ms)); + +const makeStore = (timeoutMs) => { + const listener = createListenerMiddleware(); + registerBacklightTimeout(listener, { timeoutMs }); + return configureStore({ + reducer: appReducer, + middleware: (getDefault) => getDefault().prepend(listener.middleware), + }); +}; + +test("backlight sleeps after the timeout", async () => { + const store = makeStore(25); + store.dispatch(buttonPress()); + assert.equal(store.getState().display.isBacklit, true); + await wait(60); + assert.equal(store.getState().display.isBacklit, false); + assert.equal(store.getState().display.mode, "DEFAULT"); +}); + +test("a press mid-window resets the timeout", async () => { + const store = makeStore(50); + store.dispatch(buttonPress()); // lit, timer armed + await wait(30); + store.dispatch(buttonPress()); // lit -> cycles mode, re-arms timer + await wait(30); // 60ms after first press, 30ms after second + assert.equal(store.getState().display.isBacklit, true, "reset should have kept it lit"); + assert.equal(store.getState().display.mode, "DIAG"); + await wait(40); // 70ms after second press — past its window + assert.equal(store.getState().display.isBacklit, false); +}); + +test("press while lit cycles the mode", async () => { + const store = makeStore(200); // short enough not to hold the event loop long after the suite + + store.dispatch(buttonPress()); + store.dispatch(buttonPress()); + assert.equal(store.getState().display.mode, "DIAG"); + assert.equal(store.getState().display.isBacklit, true); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npm test` +Expected: FAIL — cannot find module `./backlight.js`. + +- [ ] **Step 3: Implement `src/backlight.js`** + +```js +import { sleep } from "./store.js"; + +/** + * Arms a sleep timer on every button press. A new press cancels the + * pending timer (cancelActiveListeners) and starts a fresh one, so the + * backlight goes dark timeoutMs after the LAST press. + */ +export const registerBacklightTimeout = (listener, { timeoutMs }) => { + listener.startListening({ + type: "display/buttonPress", + effect: async (_action, api) => { + api.cancelActiveListeners(); + await api.delay(timeoutMs); + api.dispatch(sleep()); + }, + }); +}; +``` + +Note: `api.delay()` rejects with `TaskAbortError` when cancelled; RTK's listener runtime catches that internally — do NOT wrap in try/catch. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npm test` +Expected: PASS, 0 fail. If the reset test flakes on a loaded machine, widen the windows proportionally (50→100, waits ×2) — keep ratios, don't tighten. + +- [ ] **Step 5: Commit** + +```bash +git add src/backlight.js src/backlight.test.js +git commit -m "feat: backlight sleep timer as RTK listener (cancel-on-press)" +``` + +--- + +### Task 3: Config + app wiring + +**Files:** +- Modify: `src/config.js` +- Modify: `src/config.test.js` +- Modify: `app.js` + +**Interfaces:** +- Consumes: `buttonPress` from `src/store.js` (Task 1); `registerBacklightTimeout` from `src/backlight.js` (Task 2). +- Produces: `loadConfig()` result gains `backlightTimeoutMs: 30000`. Running app has the full behaviour. + +- [ ] **Step 1: Write the failing config test** + +In `src/config.test.js`, add to the first test (`loadConfig reads key from env`): + +```js + assert.equal(cfg.backlightTimeoutMs, 30000); +``` + +- [ ] **Step 2: Run tests to verify it fails** + +Run: `npm test` +Expected: FAIL — `undefined !== 30000`. + +- [ ] **Step 3: Add the constant to `src/config.js`** + +In the returned object, after `pollMs`: + +```js + backlightTimeoutMs: 30000, +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npm test` +Expected: PASS, 0 fail. + +- [ ] **Step 5: Wire `app.js`** + +Four edits: + +1. Import line — replace `nextMode` with `buttonPress`, add the backlight module: + +```js +import { appReducer, buttonPress } from "./src/store.js"; +import { registerBacklightTimeout } from "./src/backlight.js"; +``` + +2. Listener block — widen the render predicate from `action.type === "display/next"` to `action.type.startsWith("display/")`, and register the two new listeners right after the existing `listener.startListening({...})` render block (before `configureStore`): + +```js +listener.startListening({ + predicate: (action) => + action.type.startsWith("data/") || + action.type.startsWith("sensors/") || + action.type.startsWith("display/"), + effect: (_action, api) => renderDisplay(api.getState(), display), +}); + +// Backlight hardware sync — fires only when isBacklit actually changes +listener.startListening({ + predicate: (_action, curr, prev) => curr.display.isBacklit !== prev.display.isBacklit, + effect: (_action, api) => display.setBacklight(api.getState().display.isBacklit), +}); + +registerBacklightTimeout(listener, { timeoutMs: cfg.backlightTimeoutMs }); +``` + +3. Button handler — replace `button.watch(() => store.dispatch(nextMode()));` with: + +```js +button.watch(() => store.dispatch(buttonPress())); +``` + +4. Boot press — immediately after `await pollAll();` (before the `setInterval` lines): + +```js +store.dispatch(buttonPress()); // boot behaves like a first press: light up, arm the sleep timer +``` + +- [ ] **Step 6: Run full suite** + +Run: `npm test` +Expected: PASS, 0 fail. + +- [ ] **Step 7: Virtual-mode smoke test (scripted, ~50 s)** + +```bash +S=$(mktemp -d); mkfifo $S/in +cd ~/Sites/worldofpi/polyteknix +(sleep 120 > $S/in &) +VIRTUAL_MODE=true IOTPLOTTER_KEY= node app.js < $S/in > $S/out.log 2>&1 & +sleep 2 +grep -c $'\x1B\[44m' $S/out.log # blue-bg frames => backlight ON at boot; expect >= 1 +sleep 33 +tail -c 300 $S/out.log | grep -c $'\x1B\[40m\x1B\[90m' # dim frame => slept; expect >= 1 +printf '1' > $S/in; sleep 1 +tail -c 300 $S/out.log | grep -c $'\x1B\[44m' # wake press => blue again; expect >= 1 +printf '1' > $S/in; sleep 1 +tail -c 400 $S/out.log | grep -c 'ext:' # second press => DIAG frame ("ext:" on line 0) +printf 'q' > $S/in +``` + +Expected: each grep count >= 1 at its step. (`IOTPLOTTER_KEY=` override prevents pushing synthetic data to the real feed.) + +- [ ] **Step 8: Commit** + +```bash +git add src/config.js src/config.test.js app.js +git commit -m "feat: wire backlight timeout + context-sensitive button into app" +``` diff --git a/docs/superpowers/specs/2026-07-25-backlight-timeout-design.md b/docs/superpowers/specs/2026-07-25-backlight-timeout-design.md new file mode 100644 index 0000000..b8a89c7 --- /dev/null +++ b/docs/superpowers/specs/2026-07-25-backlight-timeout-design.md @@ -0,0 +1,114 @@ +# Backlight timeout + button wake — design + +**Date:** 2026-07-25 +**Status:** approved (brainstormed with user) +**Repo:** polyteknix (builds on the piteknix refactor, PR #1, merged) + +## What + +The LCD backlight turns off after a timeout. Button behaviour becomes +context-sensitive: + +- **Backlight off + press** → backlight on. Display mode untouched. +- **Backlight on + press** → cycle display mode (DEFAULT ↔ DIAG) **and** + reset the timeout. +- **No press for 30 s** → backlight off, display mode resets to DEFAULT + (next wake always shows the main readings). +- **Boot** → backlight on with the timeout armed ("starting up..." is + visible; goes dark 30 s after boot if untouched). + +Sensor polling, rendering, and iotplotter push are unaffected. Content +keeps rendering while dark — the sleep affects backlight only (plus the +mode reset). LCD text remains readable in daylight without backlight. + +Decisions taken with the user: 30 s timeout; mode resets to DEFAULT on +sleep; boot starts lit with the timer running. + +## How + +Approach: **reducer owns press semantics, RTK listener middleware owns +the timer** (chosen over an app-shell `setTimeout`, which would put the +behaviour in the untested app.js, and over a timestamp-in-state poll +check, which is useless at the 5-minute hardware poll cadence). + +### State + actions (`src/store.js`) + +`initialState.display.isBacklit` stays `false` — boot wakes it via the +first press (see Boot below). Action changes: + +- New `display/buttonPress` (`buttonPress()` creator). Reducer: + - `isBacklit === false` → `{ isBacklit: true }`, mode untouched. + - `isBacklit === true` → `{ mode: getNextMode(mode) }`. +- New `display/sleep` (`sleep()` creator). Reducer: + `{ isBacklit: false, mode: "DEFAULT" }`. +- `display/next` is **retired** — `buttonPress` replaces it. The + GPIO/keyboard button is its only caller. + +### Timeout listener (`src/backlight.js`, wired in `app.js`) + +Factored into its own module so it is testable without hardware: + +```js +export const registerBacklightTimeout = (listener, { timeoutMs }) => { + listener.startListening({ + type: "display/buttonPress", + effect: async (_action, api) => { + api.cancelActiveListeners(); // a new press cancels the pending sleep + await api.delay(timeoutMs); + api.dispatch(sleep()); + }, + }); +}; +``` + +`api.delay()` rejects on cancellation; RTK treats that as listener +completion — no timer leak, no unhandled rejection. + +### Backlight hardware sync (`app.js`) + +```js +listener.startListening({ + predicate: (_a, curr, prev) => curr.display.isBacklit !== prev.display.isBacklit, + effect: (_a, api) => display.setBacklight(api.getState().display.isBacklit), +}); +``` + +Fires only on the edge — no repeated I2C backlight writes per render. +Both display drivers already implement `setBacklight(on)`: hardware LCD +via `backlightSync()/noBacklightSync()`, virtual terminal via blue-bg vs +dim rendering. **No piteknix change needed.** + +The existing render listener predicate widens from `display/next` to the +`display/` prefix so the sleep's mode reset triggers a redraw. + +### Boot + config + +- `src/config.js`: `backlightTimeoutMs: 30_000` constant (not + env-driven — YAGNI). Tests don't need a config override — they pass a + short `timeoutMs` straight into `registerBacklightTimeout`. +- `app.js` immediately after store creation (before the startup message + and initial poll): `store.dispatch(buttonPress())` — boot literally *is* + the first press: lights the backlight, arms the timer promptly even if + the first 1-wire poll is slow. No special-cased boot path. + +### Error handling + +No new failure modes: reducer is pure; the only side effects are +`setBacklight` (already-handled display driver call) and a dispatched +action. A press during the sleep dispatch is ordered by the store — +worst case the screen sleeps and the same press wakes it. + +## Testing + +- **Reducer** (`src/store.test.js`): press-when-dark wakes without + cycling; press-when-lit cycles without touching backlight; sleep + resets both mode and backlight; boot-press from initialState lights up + on DEFAULT. +- **Integration** (`src/backlight.test.js`, real store + listener + middleware, `timeoutMs` ~25 ms injected — no fake timers): + - press → wait past timeout → state slept (isBacklit false, DEFAULT); + - press → second press mid-window → wait the original window out → + still lit (reset actually reset); + - press-while-lit cycled the mode. +- **Eyeball** (`npm run dev`): virtual LCD renders dim vs blue — + the whole feature is verifiable on the laptop before deploy. diff --git a/src/backlight.js b/src/backlight.js new file mode 100644 index 0000000..98927a7 --- /dev/null +++ b/src/backlight.js @@ -0,0 +1,17 @@ +import { sleep } from "./store.js"; + +/** + * Arms a sleep timer on every button press. A new press cancels the + * pending timer (cancelActiveListeners) and starts a fresh one, so the + * backlight goes dark timeoutMs after the LAST press. + */ +export const registerBacklightTimeout = (listener, { timeoutMs }) => { + listener.startListening({ + type: "display/buttonPress", + effect: async (_action, api) => { + api.cancelActiveListeners(); + await api.delay(timeoutMs); + api.dispatch(sleep()); + }, + }); +}; diff --git a/src/backlight.test.js b/src/backlight.test.js new file mode 100644 index 0000000..30d9bd7 --- /dev/null +++ b/src/backlight.test.js @@ -0,0 +1,47 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { configureStore, createListenerMiddleware } from "@reduxjs/toolkit"; +import { appReducer, buttonPress } from "./store.js"; +import { registerBacklightTimeout } from "./backlight.js"; + +const wait = (ms) => new Promise((r) => setTimeout(r, ms)); + +const makeStore = (timeoutMs) => { + const listener = createListenerMiddleware(); + registerBacklightTimeout(listener, { timeoutMs }); + return configureStore({ + reducer: appReducer, + middleware: (getDefault) => getDefault().prepend(listener.middleware), + }); +}; + +test("backlight sleeps after the timeout", async () => { + const store = makeStore(100); + store.dispatch(buttonPress()); + assert.equal(store.getState().display.isBacklit, true); + await wait(240); + assert.equal(store.getState().display.isBacklit, false); + assert.equal(store.getState().display.mode, "DEFAULT"); +}); + +test("a press mid-window resets the timeout", async () => { + const store = makeStore(200); + store.dispatch(buttonPress()); // lit, timer armed + await wait(120); + store.dispatch(buttonPress()); // lit -> cycles mode, re-arms timer + await wait(120); // 240ms after first press, 120ms after second + assert.equal(store.getState().display.isBacklit, true, "reset should have kept it lit"); + assert.equal(store.getState().display.mode, "DIAG"); + await wait(160); // 280ms after second press — past its window + assert.equal(store.getState().display.isBacklit, false); +}); + +test("press while lit cycles the mode", async () => { + const store = makeStore(30); + + store.dispatch(buttonPress()); + store.dispatch(buttonPress()); + assert.equal(store.getState().display.mode, "DIAG"); + assert.equal(store.getState().display.isBacklit, true); + await wait(50); // let the armed timer fire so no pending listener outlives the test +}); diff --git a/src/config.js b/src/config.js index ecd3b77..2deeefe 100644 --- a/src/config.js +++ b/src/config.js @@ -8,6 +8,7 @@ export const loadConfig = (env = process.env) => { iotplotterKey, feedId: "408491097864656092", pollMs: isVirtual ? 3000 : 1000 * 60 * 5, + backlightTimeoutMs: 30000, tempCalibrationOffset: 1.2, externalSensorId: "28-0301a279e8e6", }; diff --git a/src/config.test.js b/src/config.test.js index 3d00f99..6639408 100644 --- a/src/config.test.js +++ b/src/config.test.js @@ -6,6 +6,7 @@ test("loadConfig reads key from env", () => { const cfg = loadConfig({ IOTPLOTTER_KEY: "abc", VIRTUAL_MODE: "false" }); assert.equal(cfg.iotplotterKey, "abc"); assert.equal(cfg.feedId, "408491097864656092"); + assert.equal(cfg.backlightTimeoutMs, 30000); }); test("loadConfig throws on missing key in hardware mode", () => { diff --git a/src/store.js b/src/store.js index 044b60e..b6ff9e0 100644 --- a/src/store.js +++ b/src/store.js @@ -17,7 +17,8 @@ export const setInternalTemp = (v) => ({ type: "data/temperature/internal", payl export const setExternalTemp = (v) => ({ type: "data/temperature/external", payload: v }); export const setInternalHumidity = (v) => ({ type: "data/humidity/internal", payload: v }); export const setExternalStatus = (payload) => ({ type: "sensors/external/status", payload }); -export const nextMode = () => ({ type: "display/next" }); +export const buttonPress = () => ({ type: "display/buttonPress" }); +export const sleep = () => ({ type: "display/sleep" }); export function appReducer(state = initialState, action) { switch (action.type) { @@ -36,8 +37,13 @@ export function appReducer(state = initialState, action) { external_diagnostic: action.payload.detail, }, }; - case "display/next": + case "display/buttonPress": + if (!state.display.isBacklit) { + return { ...state, display: { ...state.display, isBacklit: true } }; + } return { ...state, display: { ...state.display, mode: getNextMode(state.display.mode) } }; + case "display/sleep": + return { ...state, display: { ...state.display, isBacklit: false, mode: "DEFAULT" } }; default: return state; } diff --git a/src/store.test.js b/src/store.test.js index 3dcf6dd..681dabb 100644 --- a/src/store.test.js +++ b/src/store.test.js @@ -1,6 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { appReducer, initialState, setInternalTemp, setExternalStatus, nextMode } from "./store.js"; +import { appReducer, initialState, setInternalTemp, setExternalStatus, buttonPress, sleep } from "./store.js"; test("reducer sets internal temperature", () => { const s = appReducer(initialState, setInternalTemp(21.4)); @@ -17,9 +17,31 @@ test("unknown action returns state unchanged", () => { assert.equal(appReducer(initialState, { type: "nope" }), initialState); }); -test("display/next cycles DEFAULT -> DIAG -> DEFAULT", () => { - const s1 = appReducer(initialState, nextMode()); +test("buttonPress when dark wakes backlight without cycling mode", () => { + const s = appReducer(initialState, buttonPress()); + assert.equal(s.display.isBacklit, true); + assert.equal(s.display.mode, "DEFAULT"); +}); + +test("buttonPress when lit cycles mode and keeps backlight on", () => { + const lit = appReducer(initialState, buttonPress()); + const s1 = appReducer(lit, buttonPress()); assert.equal(s1.display.mode, "DIAG"); - const s2 = appReducer(s1, nextMode()); + assert.equal(s1.display.isBacklit, true); + const s2 = appReducer(s1, buttonPress()); assert.equal(s2.display.mode, "DEFAULT"); }); + +test("sleep turns backlight off and resets mode to DEFAULT", () => { + const lit = appReducer(initialState, buttonPress()); + const onDiag = appReducer(lit, buttonPress()); + const s = appReducer(onDiag, sleep()); + assert.equal(s.display.isBacklit, false); + assert.equal(s.display.mode, "DEFAULT"); +}); + +test("sleep when already dark is a no-op shape-wise", () => { + const s = appReducer(initialState, sleep()); + assert.equal(s.display.isBacklit, false); + assert.equal(s.display.mode, "DEFAULT"); +});