Public: anyone can read this; the crew can change it.

Migrated from github.com/Trevato/SO-ARM100

workbenchthis machine

Track the light's real day: lightOn/lightOff in the care log, off-due from the actual on-time (today: on 8:30 → off 20:30) #640

Merged opened this in answer to

Check · Review · Land done · Live — Landed; nothing deploys from this branch, so this is the end of its path.

Merged

smith/arm:smith/639-track-the-light-s-real-day-lighton-light → main 3 commits fork 6 threads · 6 unresolved

  • Merged. smith/639-track-the-light-s-real-day-lighton-light was merged into main as b506101.

Review

Sign in to review this change.
14 files changed +360 −20
Software/so101/README.md +3 −3
@@ -38,7 +38,7 @@ Closing never locks the owner out. Signing in and out keep working while closed,
3838
3939 **Good night and Wake up (owner).** Two taps (or two terminal lines) wrap the same switch (src/night-switch.ts). **Good night** (`/admin` → Open / Closed, or `/goodnight [note]`) closes the room with the note "The saplings are sleeping…" and touches nothing else: the grow light keeps running on its schedule, because the arm holds a manual override until the schedule's next change and a forgotten "off" would darken the spinach. **Wake up the garden** (the same section, the card's owner row, or `/wake`) first sets the light's override to on (`POST /grow/light`), and opens the room only when that took; a refused or unreachable light write leaves the room closed and says why, so the room and the light never disagree. The override on the arm ends at the schedule's next transition, then the schedule resumes (with no schedule it holds, a week at most). With no light switch set up it opens the room and says to turn the light on by hand. The terminal door is `POST /admin/night {"action": "wake"|"goodnight", "note"?}`, owner only, same-origin like every other post. While closed, the card shows "Back at sunrise: about 6:00" from the light's schedule.
4040
41 **Morning check-in (owner).** Wake up lands on `/care/checkin`: one screen of taps, each writing one typed entry in the care log (water: cubes moist / topped up / too wet; lid: on / cracked / off; a sprout count 0 to 20, the `sprouts` measurement charted on `/care`; a photo note). The line at the top comes from a short code (`?light=on|manual`), never from the query's words. In the terminal: `/care checkin water <moist|topped|wet>`, `lid <on|cracked|off>`, `sprouts <0-20>`, `note <text>`. All doors use one function (`checkinEntry`, src/care-checkin.ts), and a write reaches open `/care` pages live through `/care/events`.
41 **Morning check-in (owner).** Wake up lands on `/care/checkin`: one screen of taps, each writing one typed entry in the care log (water: cubes moist / topped up / too wet; lid: on / cracked / off; a sprout count 0 to 20, the `sprouts` measurement charted on `/care`; a photo note; a **Light on** / **Light off** button, which reads the opposite of the last light action, with an "at…" time to log a late one). The line at the top comes from a short code (`?light=on|manual`), never from the query's words. In the terminal: `/care checkin water <moist|topped|wet>`, `lid <on|cracked|off>`, `sprouts <0-20>`, `note <text>`, and `light <on|off> [HH:MM]` (the time is today in the Pi's zone, or yesterday's if still ahead; `/care did lightOn` always logs now). All doors use one function (`checkinEntry`, src/care-checkin.ts), and a write reaches open `/care` pages live through `/care/events`.
4242
4343 The switch also works outside the app:
4444
@@ -133,7 +133,7 @@ ignored and said once on the arm server's stderr. The arm server reads it at sta
133133 The app is a workshop: one Pi, several **stations**, each with its cameras and controls. Today there are two, **the arm** and **the grow box** (spinach under a UV light). `/` is the workshop home, a card per station with its live camera snapshot first and its controls after, and the arm's own page is at `/arm` (its URL is unchanged for anyone who has it). The page and its title say "Workshop"; it shows who is looking ("Signed in as …", or "Watching as a guest · Sign in"), has a skip link, and keeps working while the arm is closed.
134134
135135 - **Any subset present.** The grow box has nothing to enumerate (the light is USB power only), so it is always there. The arm's card is there only while the link to the Pi's `so101 serve` is live; unplugged, closed by the owner or unreachable it says why ("The arm: not connected", "The arm is resting") and offers no controls (its cameras still show), and the owner sees **Open now** on it. Plugging the arm back in brings its card back by itself, with no restart. `so101 serve` runs with the arm unplugged: when the port is missing or cannot be opened it logs `arm not connected (<path>: <reason>); trying again every 5 s` and keeps serving, so the cameras, `/version` and the grow box stay up and the Pi can still update itself. `GET /status` answers 200 with `arm: "not connected"`; every route that needs the servos (moves, poses, torque, calibration, the drive stream) answers 503 `{"error":"arm not connected"}`, and STOP answers 200. The port is tried again every 5 s: when the arm answers, the real bus takes over without a restart (the cameras never blink) and the server logs `arm connected on <path>`. If the port fails mid-run, whatever drives stops (failed, never resumed), open drive streams close with 1013, `/status` goes back to "not connected" and the retries start again (the log line is `arm not connected (its port failed); trying again every 5 s`). A recording under way when the port fails ends there: stopping it saves what was sampled.
136 - **On a phone.** Each camera is shown whole at 16:9 (`object-fit: contain`), full width; a tap opens it full screen (on an iPhone, the still itself, to pinch). A station with several cameras shows them as a row to swipe, with a segmented control naming each, and side by side when the card is wide; the last one picked is remembered in the browser (src/ui/station-cams.js). A camera with a box drawn on Tune offers **Zoom to the box**. The grow card says where the plant is from the care log's latest `sow` (one source of truth: "Germinating: day 0 · Sprouts expected in about 3–7 days"), the last watering and the next reminder, links the log book, and says "Grow box offline: the Pi isn't answering" while it is down. Not planted yet, the owner gets **Set the sow date** (a date picker, today by default) that logs a sow and lands back on `/`. The page keeps room at its foot so the forge's floating button never sits on a control.
136 - **On a phone.** Each camera is shown whole at 16:9 (`object-fit: contain`), full width; a tap opens it full screen (on an iPhone, the still itself, to pinch). A station with several cameras shows them as a row to swipe, with a segmented control naming each, and side by side when the card is wide; the last one picked is remembered in the browser (src/ui/station-cams.js). A camera with a box drawn on Tune offers **Zoom to the box**. The grow card says where the plant is from the care log's latest `sow` (one source of truth: "Germinating: day 0 · Sprouts expected in about 3–7 days"), the last watering and the next reminder, links the log book, shows the light's real day from the care log's `lightOn` / `lightOff` ("Light on 08:30 → off due 20:30 (12 h) · started 1 h 30 m late · 1 h 30 m of light so far"; off due is the on-time plus the schedule's hours, held to 10–14 h; "light still on?" 30 min past due with no `lightOff`), and says "Grow box offline: the Pi isn't answering" while it is down. Not planted yet, the owner gets **Set the sow date** (a date picker, today by default) that logs a sow and lands back on `/`. The page keeps room at its foot so the forge's floating button never sits on a control.
137137 - **"Camera can't see the plants."** On the grow box's card, each still is checked in the browser once it has loaded: the part inside the camera's box (set on Tune, `/admin/camera-list`) is counted for blown-out and black pixels (`visibility` in src/ui/still-frame.js), and when the plants cannot be seen the card says so over the foot of the still, with a link to Tune (src/ui/grow-hint.js). The relay never decodes a JPEG, so the terminal and the JSON doors cannot show this hint; they have no pixels to count.
138138 - **The config** is `so101/stations.json` under `FORGE_APP_DATA`: a list of `{id, name, kind: "arm"|"grow", cameras: [camera ids], controls: [{label, href}]}` (src/stations.ts). Without a file it is the arm (camera 1) and the grow box (camera 2 from above, then camera 1 from the side): edit it to match the cameras. With only an arm in it, `/` is the arm's page as before.
139139 - **Live.** `GET /care/events` is a server-sent stream that says `care` after every change to the book, from any door; the page listens with a few lines of script and refreshes itself (not while a field has focus). Without the script the page works the same, on reload. A refused form returns to `/care?error=…` with the message in an alert line.
@@ -163,7 +163,7 @@ The grow box's care app: a plant profile, reminders worked out from it and the l
163163 - **Reminders** fall due a set time after the last log entry that counts for them (a top-off resets "top off", a watering resets the moisture check), or after the stage began. Done, Snooze and a different cadence (`POST /care/reminder {id, action: "done"|"snooze"|"every", hours}`) are the owner's.
164164 - **Profiles are files.** `src/care-profiles/spinach.json` ships with the app; put another plant (lettuce, basil) as `FORGE_APP_DATA/so101/care-profiles/<name>.json` in the same shape (stages, `bands`, `reminders` with `everyHours`, optional `tower`, `needs`, `resets`; see src/care-profile.ts, which names what is wrong with a bad file and skips it). `CARE_PROFILE=<id>` picks which one the app runs (default `spinach`).
165165 - **Sources.** Every target, reminder and stage cites a reference. A value shows a footnote marker; tapping it opens a card with the reference, what it supports and a link. A value with no source says "grower consensus (unsourced)". `GET /care/learn` is the Learn page: the references by topic, extension and peer-reviewed first, then a Fundamentals reading path (week one, then week two) and a list of items named but not checked. `GET /care/learn.json` and the terminal's `/care learn` give the same with URLs. In a profile, `references` is a list of `{id, title, publisher, url, kind, accessed, topics, covers}`: `kind` is `extension`, `peer-reviewed`, `grower` or `vendor`, `url` is `https`, and ids are unique. `sources` (a list of `{id, claim}`, each id naming a reference) sits on each band (under the stage's `sources`, keyed by measure) and on each reminder, with an optional `why` on a reminder; `light.sources`, `light.warnSources` and `timeline.sources` do the same for the light hours, the bolting warning and the timeline. A source naming no reference makes the profile load fail. `/care/api` and `/care` list each reminder's `why` and source ids.
166 - **The log book** is `FORGE_APP_DATA/so101/care.json`. Entries are `measurement` (ph, ec, waterTemp, airTemp, level, lightHours), `action` (sow, water, topoff, change, nutrients, thin, harvest, airstone, pumpcheck, transplant), `observation` and `problem`, each with a time (now unless given). A measurement is a `Reading {kind, value, at, source: manual|sensor}`, so a sensor can write the same later. Photos on observations and the pump schedule are not part of it yet.
166 - **The log book** is `FORGE_APP_DATA/so101/care.json`. Entries are `measurement` (ph, ec, waterTemp, airTemp, level, lightHours), `action` (sow, water, topoff, change, nutrients, thin, harvest, airstone, pumpcheck, transplant, lightOn, lightOff), `observation` and `problem`, each with a time (now unless given). A measurement is a `Reading {kind, value, at, source: manual|sensor}`, so a sensor can write the same later. Photos on observations and the pump schedule are not part of it yet.
167167 - **Live.** `GET /care/events` is a server-sent stream that says `care` after every change to the book, from any door; the page listens with a few lines of script and refreshes itself (not while a field has focus). Without the script the page works the same, on reload. A refused form returns to `/care?error=…` with the message in an alert line.
168168 - **Three doors.** The page; JSON: `GET /care/api` (anyone), `POST /care/log`, `PUT` or `DELETE /care/log/<id>` and `POST /care/reminder` (owner, same-origin only, 8 KB at most); and in the terminal `/care` (stage, health, what is due, the last entries with their ids) for anyone, with `/care log ph 6.1`, `/care did topoff`, `/care did harvest 40`, `/care note <text>`, `/care problem algae [detail]`, `/care done <reminder>`, `/care snooze <reminder> [hours]` and `/care delete <id>` for the owner. All three act on the same log, so a write in one shows in the others at once.
169169
Software/so101/src/admin-routes.ts +2 −2
@@ -93,7 +93,7 @@ const REPORT_ACTIONS = new Set(["report-remove", "report-mute", "report-dismiss"
9393 type ReadBody = (req: IncomingMessage, limit?: number) => Promise<Buffer>;
9494
9595 /** The owner's page. Anyone else is told so; a change is a form post, answered with a redirect back. */
96 export async function handleAdmin(lobby: Lobby, req: IncomingMessage, res: ServerResponse, url: URL, readBody: ReadBody, askPrecision?: AskPrecision, askServos?: AskServos, askVideo?: AskVideo, versions?: () => Versions, tune?: TuneP, askLight?: LightHooks): Promise<void> {
96 export async function handleAdmin(lobby: Lobby, req: IncomingMessage, res: ServerResponse, url: URL, readBody: ReadBody, askPrecision?: AskPrecision, askServos?: AskServos, askVideo?: AskVideo, versions?: () => Versions, tune?: TuneP, askLight?: LightHooks, recordLight?: (what: "lightOn" | "lightOff") => void): Promise<void> {
9797 const session = await serveAdminPage(lobby, req, res, url, askPrecision, askServos, askVideo, versions, askLight);
9898 if (!session) return;
9999 const { who, canEdit, show } = session;
@@ -148,7 +148,7 @@ export async function handleAdmin(lobby: Lobby, req: IncomingMessage, res: Serve
148148 break;
149149 case "wake":
150150 case "goodnight":
151 wakeCode = await nightSwitch(lobby, who, act === "wake", form.get("note") ?? "", askLight);
151 wakeCode = await nightSwitch(lobby, who, act === "wake", form.get("note") ?? "", askLight, recordLight);
152152 done = act === "wake" ? "opened" : "closed";
153153 break;
154154 case "end":
Software/so101/src/care-checkin.ts +18 −3
@@ -1,6 +1,7 @@
11 /** The morning check-in (#632): one screen, each tap one typed entry in the care log. */
22 import type { EntryBody } from "./care-log.ts";
33 import { STYLE } from "./care-page.ts";
4 import { wallToUtc, offsetAt } from "./light-schedule.ts";
outdated

lastLight is imported but unused in this file. Remove the import.

45 import { WAKE_LINES, type WakeCode } from "./night-switch.ts";
56 import { esc } from "./story-markdown.ts";
67
@@ -8,8 +9,21 @@ const WATER = { moist: "cubes moist", topped: "topped up (bottom)", wet: "too we
89 const LID = { on: "on", cracked: "cracked", off: "off" } as const;
910 export const MAX_SPROUTS = 20;
1011
11 /** What one check-in tap writes: water → an action (topped up) or an observation, lid and photo note → observations, sprouts → a measurement. Null when nothing valid was sent. */
12 export function checkinEntry(input: Record<string, unknown>, at: number): EntryBody | null {
12 /** "HH:MM" today in the zone as an instant; a time still ahead of `now` is yesterday's (a late log after midnight). Null when it is not a time. */
13 function clockToday(hhmm: string, now: number, zone: string): number | null {
14 const m = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(hhmm);
15 if (!m) return null;
16 const local = new Date(now + offsetAt(now, zone));
17 const at = wallToUtc(local.getUTCFullYear(), local.getUTCMonth() + 1, local.getUTCDate(), Number(m[1]), Number(m[2]), zone);
18 return at > now + 60_000 ? at - 24 * 3_600_000 : at;
19 }
20
21 /** What one check-in tap writes: light → lightOn or lightOff (at "HH:MM" today in the zone, else now), water → an action (topped up) or an observation, lid and photo note → observations, sprouts → a measurement. Null when nothing valid was sent. */
22 export function checkinEntry(input: Record<string, unknown>, at: number, zone = "UTC"): EntryBody | null {
23 if (input.light === "on" || input.light === "off") {
24 const when = typeof input.lightAt === "string" && input.lightAt !== "" ? clockToday(input.lightAt, at, zone) : at;
25 return when === null ? null : { at: when, type: "action", what: input.light === "on" ? "lightOn" : "lightOff", cause: "owner" };
26 }
1327 const water = input.water;
1428 if (typeof water === "string" && Object.hasOwn(WATER, water)) {
1529 return water === "topped" ? { at, type: "action", what: "topoff", cause: "owner" } : { at, type: "observation", text: `Water: ${WATER[water as keyof typeof WATER]}` };
@@ -28,7 +42,7 @@ export function checkinEntry(input: Record<string, unknown>, at: number): EntryB
2842 const one = (name: string, value: string, label: string) => `<form method="post" action="/care/checkin"><input type="hidden" name="${name}" value="${value}"><button>${esc(label)}</button></form>`;
2943
3044 /** The check-in page; `sprouts` is the newest count, `done` a line about the last tap, `light` the wake-up checklist line (fixed text, never the query's). */
31 export function renderCheckin(sprouts: number | null, done: boolean, light: string | null): string {
45 export function renderCheckin(sprouts: number | null, done: boolean, light: string | null, lit: "on" | "off" | null = null): string {
3246 const line = light !== null && Object.hasOwn(WAKE_LINES, light) ? WAKE_LINES[light as WakeCode] : null;
3347 return `<!doctype html>
3448 <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
@@ -37,6 +51,7 @@ export function renderCheckin(sprouts: number | null, done: boolean, light: stri
3751 <body><main>
3852 <h1>Good morning</h1>
3953 ${line ? `<p class="dim">${esc(line)}</p>` : ""}${done ? `<p role="status">Logged.</p>` : ""}
54 <section class="panel" aria-label="Light"><h2>Light${lit === null ? "" : ` (last: ${lit})`}</h2><form method="post" action="/care/checkin"><input type="hidden" name="light" value="${lit === "on" ? "off" : "on"}"><button>${lit === "on" ? "Light off" : "Light on"}</button><label>at… <input type="time" name="lightAt" aria-label="Time, if not now"></label></form></section>
4055 <section class="panel" aria-label="Water"><h2>Water</h2>${one("water", "moist", "Cubes moist")}${one("water", "topped", "Topped up (bottom)")}${one("water", "wet", "Too wet")}</section>
4156 <section class="panel" aria-label="Sprouts"><h2>Sprouts${sprouts === null ? "" : ` (last: ${sprouts})`}</h2><form method="post" action="/care/checkin"><input type="number" name="sprouts" min="0" max="${MAX_SPROUTS}" step="1" value="${sprouts ?? 0}" aria-label="Sprout count"><button>Log sprouts</button></form></section>
4257 <section class="panel" aria-label="Lid"><h2>Lid</h2>${one("lid", "on", "On")}${one("lid", "cracked", "Cracked")}${one("lid", "off", "Off")}</section>
Software/so101/src/care-log.ts +1 −1
@@ -9,7 +9,7 @@ import { MEASURES, type Measure } from "./care-profile.ts";
99
1010 export const MAX_ENTRIES = 5000;
1111 export const SENSOR_EVERY_MS = 3_600_000;
12 export const ACTIONS = ["sow", "water", "topoff", "change", "nutrients", "phdown", "thin", "harvest", "airstone", "pumpcheck", "transplant", "done", "end"] as const;
12 export const ACTIONS = ["sow", "water", "topoff", "change", "nutrients", "phdown", "thin", "harvest", "airstone", "pumpcheck", "transplant", "done", "end", "lightOn", "lightOff"] as const;
1313 export const PROBLEMS = ["bolting", "tip burn", "root rot", "algae", "yellowing", "leggy"] as const;
1414 export const SYSTEMS = ["vertical tower", "DWC", "NFT", "Kratky", "ebb-and-flow"] as const;
1515 export type Action = (typeof ACTIONS)[number];
Software/so101/src/care-routes.ts +9 −3
@@ -5,6 +5,7 @@ import { boltingWatch, currentStage, health, reminders, snapshot, stageHistory,
55 import { LogError, type CareLog } from "./care-log.ts";
66 import { learnData, renderLearn } from "./care-sources.ts";
77 import { renderCare } from "./care-page.ts";
8 import { lastLight } from "./light-day.ts";
89 import { checkinEntry, renderCheckin } from "./care-checkin.ts";
910 import { calibrateProbe, renderProbes, type ProbeCache } from "./care-probes.ts";
1011 import type { Profile } from "./care-profile.ts";
@@ -23,6 +24,8 @@ export interface CareContext {
2324 who: (req: IncomingMessage) => { owner: boolean; signedIn: SignedIn | null; known: boolean };
2425 readBody: (req: IncomingMessage, limit?: number) => Promise<Buffer>;
2526 now?: () => number;
27 /** The Pi's time zone, asked of the Pi when not yet known; null when it does not answer. Without it, UTC. */
28 zone?: () => Promise<string | null>;
2629 /** The pH/EC probes on the Pi; without them the page has no probe panel. */
2730 probes?: ProbeCache;
2831 }
@@ -108,7 +111,7 @@ export async function handleCare(req: IncomingMessage, res: ServerResponse, path
108111 const latest = ctx.log.entries().filter((e) => e.type === "measurement" && e.kind === "sprouts").at(-1);
109112 const sprouts = latest && latest.type === "measurement" ? latest.value : null;
110113 const query = new URL(req.url ?? "/", "http://x").searchParams;
111 if (req.method === "GET") return send(200, "text/html; charset=utf-8", renderCheckin(sprouts, query.has("done"), query.get("light"))), true;
114 if (req.method === "GET") return send(200, "text/html; charset=utf-8", renderCheckin(sprouts, query.has("done"), query.get("light"), lastLight(ctx.log.entries(), now))), true;
112115 if (req.method !== "POST") return json(405, { error: "GET or POST" }), true;
113116 let raw: Buffer;
114117 try {
@@ -117,8 +120,11 @@ export async function handleCare(req: IncomingMessage, res: ServerResponse, path
117120 return json(413, { error: "too long" }), true;
118121 }
119122 const body = fields(req, raw.toString("utf8"));
120 const made = checkinEntry(body, now);
121 if (!made) return json(400, { error: "send water, lid, sprouts (0 to 20) or a note" }), true;
123 // A back-dated time is the Pi's wall clock: refuse it rather than guess the zone.
124 const zone = body.lightAt ? ((await ctx.zone?.()) ?? (ctx.zone ? null : "UTC")) : "UTC";
125 if (zone === null) return json(409, { error: "the Pi's time zone is not known yet (the arm server did not answer); log the light now, or try again" }), true;
126 const made = checkinEntry(body, now, zone);

A form post that gets 409 or 400 shows a person raw JSON. Redirect back to /care/checkin with a short error code and word it on the page.

127 if (!made) return json(400, { error: "send light, water, lid, sprouts (0 to 20) or a note" }), true;
122128 const added = ctx.log.add(made);
123129 if ((req.headers["content-type"] ?? "").includes("json")) json(200, added);
124130 else send(303, "text/plain", "", { location: "/care/checkin?done=1" });
Software/so101/src/care-terminal.ts +5 −1
@@ -17,7 +17,7 @@ export interface CareAsk {
1717 timeoutMs?: number;
1818 }
1919
20 export const CARE_USAGE = "usage: /care · /care log <ph|ec|waterTemp|airTemp|level|lightHours|sprouts> <value> · /care did <action> [amount] · /care note <text> · /care checkin water <moist|topped|wet> | lid <on|cracked|off> | sprouts <0-20> | note <text> · /care problem <bolting|tip burn|root rot|algae|yellowing|leggy> [detail] · /care done <reminder> · /care snooze <reminder> [hours] · /care delete <entry id> · /care learn · /care dosing · /care dose <nutrientA|nutrientB|phDown> [ml] · /care mode <loop> <off|suggest|auto> · /care resume · /care pumps · /care wire <pump> gpio <line> [low] | shelly <host> | none · /care prime <pump> [seconds] · /care calibrate <pump> <ml> · /care float <line|none> [low] [cap seconds] · /care topoff (dosing is the owner's; a pump is nutrientA, nutrientB, phDown or topOff)";
20 export const CARE_USAGE = "usage: /care · /care log <ph|ec|waterTemp|airTemp|level|lightHours|sprouts> <value> · /care did <action> [amount] · /care note <text> · /care checkin light <on|off> [HH:MM] | water <moist|topped|wet> | lid <on|cracked|off> | sprouts <0-20> | note <text> · /care problem <bolting|tip burn|root rot|algae|yellowing|leggy> [detail] · /care done <reminder> · /care snooze <reminder> [hours] · /care delete <entry id> · /care learn · /care dosing · /care dose <nutrientA|nutrientB|phDown> [ml] · /care mode <loop> <off|suggest|auto> · /care resume · /care pumps · /care wire <pump> gpio <line> [low] | shelly <host> | none · /care prime <pump> [seconds] · /care calibrate <pump> <ml> · /care float <line|none> [low] [cap seconds] · /care topoff (dosing is the owner's; a pump is nutrientA, nutrientB, phDown or topOff)";
2121
2222 export const PROBES_USAGE = "usage: /probes · /probes calibrate <ph7|ph4|ec1413> [solution tempC] (owner)";
2323
@@ -50,6 +50,10 @@ export function careCommand(words: string[], tail: string): CareAsk | { print: s
5050 case "checkin": {
5151 if (!tail.startsWith(a) || !tail.slice(a.length).trim()) return usage;
5252 const value = tail.slice(a.length).trim();
53 if (a === "light") {
54 const m = /^(on|off)(?: ([01]\d:[0-5]\d))?$/.exec(value);
55 return m ? post("/care/checkin", { light: m[1], ...(m[2] ? { lightAt: m[2] } : {}) }) : usage;
56 }
5357 if (a === "sprouts") return /^\d{1,2}$/.test(value) ? post("/care/checkin", { sprouts: Number(value) }) : usage;
5458 return a === "water" || a === "lid" || a === "note" ? post("/care/checkin", { [a]: value }) : usage;
5559 }
Software/so101/src/light-checkin.test.ts added +89
@@ -0,0 +1,89 @@
1 import { afterEach, expect, test } from "bun:test";
2 import { mkdtempSync, rmSync } from "node:fs";
3 import { tmpdir } from "node:os";
4 import { join } from "node:path";
5 import { CareLog } from "./care-log.ts";
6 import { BUILT_IN_DIR, loadProfiles } from "./care-profile.ts";
7 import { sign } from "./identity.ts";
8 import { GrowLog } from "./grow-log.ts";
9 import { GrowStills } from "./grow-stills.ts";
10 import { StationHub } from "./stations.ts";
11 import { Lobby } from "./lobby.ts";
12 import { startRelay, type RunningRelay } from "./relay.ts";
13
14 const KEY = "k".repeat(64);
15 const as = (name: string, role: "user" | "admin") => {
16 const identity = `id-${name}|${name}|${role}|${Date.now() + 600_000}`;
17 return { "x-forge-identity": identity, "x-forge-signature": sign(identity, KEY) };
18 };
19 const owner = as("trevato", "admin");
20 const post = (headers: Record<string, string>, body: Record<string, string>) => ({ method: "POST", headers: { ...headers, "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams(body), redirect: "manual" as const });
21
22 let dir = "";
23 let relay: RunningRelay | undefined;
24 let lobby: Lobby | undefined;
25 let arm: ReturnType<typeof Bun.serve> | undefined;
26 afterEach(async () => {
27 await relay?.close();
28 lobby?.close();
29 arm?.stop(true);
30 if (dir) rmSync(dir, { recursive: true, force: true });
31 dir = "";
32 relay = lobby = arm = undefined;
33 });
34
35 /** A relay over a fake arm in Tokyo (UTC+9), which answers /grow only while `up`. */
36 async function boot(up = true) {
37 dir = mkdtempSync(join(tmpdir(), "so101-lightday-"));
38 const state = { up };
39 arm = Bun.serve({
40 port: 0,
41 fetch(req) {
42 if (new URL(req.url).pathname === "/grow" && state.up) return Response.json({ light: { configured: false, text: "On", zone: "Asia/Tokyo", schedule: { onAt: "06:00", hours: 12 }, on: false, log: [] } });
43 return new Response("no", { status: 404 });
44 },
45 });
46 lobby = new Lobby({ identityKey: KEY, adminFile: join(dir, "admin.json"), openFile: join(dir, "open.json") });
47 const log = new CareLog(join(dir, "care.json"));
48 const stations = new StationHub();
49 stations.replace([{ id: "grow", name: "Grow box", kind: "grow", cameras: [], controls: [] }]);
50 relay = await startRelay({ port: 0, simulation: `http://127.0.0.1:${arm.port}`, lobby, stations, grow: { stills: new GrowStills(join(dir, "stills")), log: new GrowLog(join(dir, "log.json")), everyMs: 600_000 }, env: {}, care: { log, profile: loadProfiles(BUILT_IN_DIR).find((p) => p.id === "spinach")! } });
51 return { base: `http://127.0.0.1:${relay.port}`, log, state };
52 }
53
54 test("the light tap is the owner's: a guest is refused and writes nothing; the owner gets a 303", async () => {
55 const { base, log } = await boot();
56 expect((await fetch(`${base}/care/checkin`, post({}, { light: "on" }))).status).toBeGreaterThanOrEqual(401);
57 expect((await fetch(`${base}/care/checkin`, post(as("bob", "user"), { light: "on" }))).status).toBe(403);
58 expect(log.entries()).toEqual([]);
59 const r = await fetch(`${base}/care/checkin`, post(owner, { light: "on" }));
60 expect(r.status).toBe(303);
61 expect(log.entries().map((e) => e.type === "action" && e.what)).toEqual(["lightOn"]);
62 });
63
64 test("an 'at' time is the Pi's wall clock, not UTC: 08:30 in Tokyo is 23:30 UTC the day before", async () => {
65 const { base, log } = await boot();
66 const r = await fetch(`${base}/care/checkin`, post(owner, { light: "on", lightAt: "08:30" }));
67 expect(r.status).toBe(303);
68 const at = log.entries()[0]!.at;
69 const tokyo = new Date(at + 9 * 3_600_000);
70 expect([tokyo.getUTCHours(), tokyo.getUTCMinutes()]).toEqual([8, 30]);
71 });
72
73 test("until the Pi's zone is known, an 'at' time is refused and says why; logging now still works", async () => {
74 const { base, log, state } = await boot(false);
75 const r = await fetch(`${base}/care/checkin`, post(owner, { light: "on", lightAt: "08:30" }));
76 expect(r.status).toBe(409);
77 expect(await r.text()).toContain("time zone is not known");
78 expect(log.entries()).toEqual([]);
79 expect((await fetch(`${base}/care/checkin`, post(owner, { light: "on" }))).status).toBe(303);
80 state.up = true;
81 expect((await fetch(`${base}/care/checkin`, post(owner, { light: "off", lightAt: "23:59" }))).status).toBe(303);
82 });
83
84 test("the card words the day in the Pi's zone once it is known, and says nothing before", async () => {
85 const { base } = await boot();
86 await fetch(`${base}/care/checkin`, post(owner, { light: "on", lightAt: "08:30" }));
87 const home = await (await fetch(`${base}/`, { headers: owner })).text();
88 expect(home).toContain("data-light>Light on 08:30 → off due 20:30 (12 h)");
89 });
Software/so101/src/light-day.test.ts added +92
@@ -0,0 +1,92 @@
1 import { describe, expect, test } from "bun:test";
2 import { CareLog } from "./care-log.ts";
3 import { checkinEntry, renderCheckin } from "./care-checkin.ts";
4 import { lastLight, lightDay, lightLines } from "./light-day.ts";
5
6 const H = 3_600_000;
7 const M = 60_000;
8 const DAY0 = Date.UTC(2026, 9, 27);
9 const SCHEDULE = { onAt: "07:00", hours: 12 };
10 const logWith = (...events: [string, number][]) => {
11 let now = DAY0;
12 const log = new CareLog(undefined, () => now);
13 for (const [what, at] of events) {
14 now = at;
15 log.add({ type: "action", what, at });
16 }
17 return log;
18 };
19
20 describe("the light's real day", () => {
21 test("an 8:30 lightOn puts the off due at 20:30, 1 h 30 m late", () => {
22 const log = logWith(["lightOn", DAY0 + 8.5 * H]);
23 const now = DAY0 + 10 * H;
24 const day = lightDay(log.entries(), now, "UTC", SCHEDULE)!;
25 expect(day.offDue).toBe(DAY0 + 20.5 * H);
26 const lines = lightLines(day, "UTC");
27 expect(lines[0]).toBe("Light on 08:30 → off due 20:30 (12 h)");
28 expect(lines).toContain("started 1 h 30 m late");
29 expect(lines).toContain("1 h 30 m of light so far");
30 });
31
32 test("a 06:00 lightOn with a 12 h schedule puts the off due at 18:00, on time", () => {
33 const log = logWith(["lightOn", DAY0 + 6 * H]);
34 const day = lightDay(log.entries(), DAY0 + 7 * H, "UTC", { onAt: "06:00", hours: 12 })!;
35 expect(lightLines(day, "UTC")[0]).toBe("Light on 06:00 → off due 18:00 (12 h)");
36 expect(lightLines(day, "UTC").join()).not.toContain("late");
37 });
38
39 test("a schedule's hours are held between 10 h and the spinach maximum", () => {
40 const on = logWith(["lightOn", DAY0 + 8 * H]).entries();
41 expect(lightDay(on, DAY0 + 9 * H, "UTC", { onAt: "07:00", hours: 8 })!.offDue).toBe(DAY0 + 18 * H);
42 expect(lightDay(on, DAY0 + 9 * H, "UTC", { onAt: "07:00", hours: 18 })!.offDue).toBe(DAY0 + 22 * H);
43 });
44
45 test("a missing lightOff 30 min past due asks whether the light is still on", () => {
46 const entries = logWith(["lightOn", DAY0 + 8.5 * H]).entries();
47 expect(lightLines(lightDay(entries, DAY0 + 20.5 * H + 29 * M, "UTC", SCHEDULE)!, "UTC")).not.toContain("light still on?");
48 expect(lightLines(lightDay(entries, DAY0 + 20.5 * H + 30 * M, "UTC", SCHEDULE)!, "UTC")).toContain("light still on?");
49 });
50
51 test("a lightOff closes the day and sums the hours from the actions", () => {
52 const entries = logWith(["lightOn", DAY0 + 8.5 * H], ["lightOff", DAY0 + 20.5 * H]).entries();
53 const day = lightDay(entries, DAY0 + 22 * H, "UTC", SCHEDULE)!;
54 expect(day.stillOn).toBe(false);
55 expect(lightLines(day, "UTC")).toEqual(["Light 08:30 → 20:30", "started 1 h 30 m late", "12 h of light"]);
56 });
57
58 test("nothing logged, or an on from days ago, shows nothing", () => {
59 expect(lightDay([], DAY0, "UTC", SCHEDULE)).toBeNull();
60 expect(lightDay(logWith(["lightOn", DAY0]).entries(), DAY0 + 3 * 24 * H, "UTC", SCHEDULE)).toBeNull();
61 });
62 });
63
64 describe("the check-in's light button", () => {
65 test("the button reads the opposite of the last action, with an at… time", () => {
66 expect(renderCheckin(null, false, null, null)).toContain("<button>Light on</button>");
67 expect(renderCheckin(null, false, null, "on")).toContain("<button>Light off</button>");
68 expect(renderCheckin(null, false, null, "on")).toContain('name="lightAt"');
69 });
70
71 test("a tap writes lightOn now; at 08:30 it writes that time today, or yesterday's when still ahead", () => {
72 const now = DAY0 + 10 * H;
73 expect(checkinEntry({ light: "on" }, now)).toEqual({ at: now, type: "action", what: "lightOn", cause: "owner" });
74 expect(checkinEntry({ light: "on", lightAt: "08:30" }, now)).toMatchObject({ at: DAY0 + 8.5 * H });
75 expect(checkinEntry({ light: "off", lightAt: "23:00" }, now)).toMatchObject({ what: "lightOff", at: DAY0 - H });
76 expect(checkinEntry({ light: "on", lightAt: "soon" }, now)).toBeNull();
77 });
78 });
79
80 describe("by time, not by the order logged", () => {
81 test("a late log of an earlier lightOn after a lightOff does not flip the day or the button", () => {
82 let now = DAY0 + 21 * H;
83 const log = new CareLog(undefined, () => now);
84 log.add({ type: "action", what: "lightOn", at: DAY0 + 8.5 * H });
85 log.add({ type: "action", what: "lightOff", at: DAY0 + 20.5 * H });
86 log.add({ type: "action", what: "lightOn", at: DAY0 + 7.5 * H }); // forgotten earlier tap, logged last
87 expect(lastLight(log.entries(), now)).toBe("off");
88 const day = lightDay(log.entries(), now, "UTC", SCHEDULE)!;
89 expect(day.onAt).toBe(DAY0 + 8.5 * H);
90 expect(day.offAt).toBe(DAY0 + 20.5 * H);
91 });
92 });
Software/so101/src/light-day.ts added +88
@@ -0,0 +1,88 @@
1 /**
2 * The light's real day (#639): the lightOn and lightOff actions in the care log say when the light really ran, so the
3 * off-time follows the actual on-time instead of the alarm. Pure: (log, now, zone, schedule) in, words out.
4 */
5 import type { Entry } from "./care-log.ts";
6 import { clockIn, offsetAt, wallToUtc } from "./light-schedule.ts";
7 import { SPINACH_MAX_HOURS } from "./light-panel.ts";
8
9 const HOUR = 3_600_000;
10 const MIN = 60_000;
11 /** A day shorter than this starves spinach; the schedule's own hours never push below it. */
12 export const MIN_DAY_HOURS = 10;
13 /** How long past the off-due time before the card asks whether the light was left on. */
14 export const STILL_ON_AFTER_MS = 30 * MIN;
15 /** A start this much after the schedule counts as late. */
16 const LATE_MS = 15 * MIN;
17 /** An on older than this is yesterday's news: the card shows nothing. */
18 const STALE_MS = 36 * HOUR;
19
20 export interface LightDay {
21 onAt: number;
22 offDue: number;
23 /** Hours of light planned: the schedule's, held between 10 h and the spinach maximum. */
24 hours: number;
25 /** How far after the scheduled start the light came on; 0 when on time or early. */
26 lateMs: number;
27 /** When it was switched off; null while it is on. */
28 offAt: number | null;
29 /** Light so far (to now while on, else the whole day). */
30 lightMs: number;
31 /** On past due + 30 min with no lightOff logged. */
32 stillOn: boolean;
33 }
34
35 export interface Photo {
36 onAt: string;
37 hours: number;
38 }
39
40 /** The hours the day gets: the schedule's, never above the spinach maximum or below 10 h. */
41 export const dayHours = (scheduled: number): number => Math.min(SPINACH_MAX_HOURS, Math.max(MIN_DAY_HOURS, scheduled));
42
43 /** By time, not by the order they were logged: a late log of an earlier light must not win. */
44 const latest = (list: Entry[]): Entry | undefined => list.reduce<Entry | undefined>((a, e) => (!a || e.at > a.at || (e.at === a.at && e.id > a.id) ? e : a), undefined);
45 const earliest = (list: Entry[]): Entry | undefined => list.reduce<Entry | undefined>((a, e) => (!a || e.at < a.at ? e : a), undefined);
outdated

.pop() takes the last inserted, not the latest at. A back-dated entry added later wins wrongly. Pick the max at.

46
47 /** The last light action logged by `now`: "on" or "off", null when none is. */
48 export function lastLight(entries: readonly Entry[], now: number): "on" | "off" | null {
49 const e = latest(entries.filter((x) => x.type === "action" && (x.what === "lightOn" || x.what === "lightOff") && x.at <= now));
50 return e && e.type === "action" ? (e.what === "lightOn" ? "on" : "off") : null;
51 }
outdated

Same: choose the latest lightOn by at, and the first lightOff after it by at, not by insertion order.

52
53 /** The current (or just-finished) day from the log; null when the light was never logged on, or not within a day and a half. */
54 export function lightDay(entries: readonly Entry[], now: number, zone: string, schedule: Photo): LightDay | null {
55 const on = latest(entries.filter((x) => x.type === "action" && x.what === "lightOn" && x.at <= now));
56 if (!on || now - on.at > STALE_MS) return null;
57 const off = earliest(entries.filter((x) => x.type === "action" && x.what === "lightOff" && x.at > on.at && x.at <= now));
58 const hours = dayHours(schedule.hours);
59 const local = new Date(on.at + offsetAt(on.at, zone));
60 const [h, m] = schedule.onAt.split(":").map(Number) as [number, number];
61 const planned = wallToUtc(local.getUTCFullYear(), local.getUTCMonth() + 1, local.getUTCDate(), h, m, zone);
62 const offDue = on.at + hours * HOUR;
63 return {
64 onAt: on.at,
65 offDue,
66 hours,
67 lateMs: Math.max(0, on.at - planned),
68 offAt: off?.at ?? null,
69 lightMs: (off?.at ?? now) - on.at,
70 stillOn: !off && now >= offDue + STILL_ON_AFTER_MS,
71 };
72 }
73
74 /** "1 h 30 m", "45 m", "12 h". */
75 export function span(ms: number): string {
76 const total = Math.max(0, Math.round(ms / MIN));
77 const [h, m] = [Math.floor(total / 60), total % 60];
78 return [h ? `${h} h` : "", m || !h ? `${m} m` : ""].filter(Boolean).join(" ");
79 }
80
81 /** The grow card's lines for the day: on → off due, a late start, the hours so far, and the nag. */
82 export function lightLines(day: LightDay, zone: string): string[] {
83 const lines = [day.offAt === null ? `Light on ${clockIn(day.onAt, zone)} → off due ${clockIn(day.offDue, zone)} (${day.hours} h)` : `Light ${clockIn(day.onAt, zone)} → ${clockIn(day.offAt, zone)}`];
84 if (day.lateMs >= LATE_MS) lines.push(`started ${span(day.lateMs)} late`);
85 lines.push(day.offAt === null ? `${span(day.lightMs)} of light so far` : `${span(day.lightMs)} of light`);
86 if (day.stillOn) lines.push("light still on?");
87 return lines;
88 }
Software/so101/src/night-switch.test.ts +23
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test";
22 import { mkdtempSync, rmSync } from "node:fs";
33 import { tmpdir } from "node:os";
44 import { join } from "node:path";
5 import { lastLight } from "./light-day.ts";
56 import { CareLog } from "./care-log.ts";
67 import { BUILT_IN_DIR, loadProfiles } from "./care-profile.ts";
78 import { CARE_USAGE, careCommand } from "./care-terminal.ts";
@@ -199,3 +200,25 @@ describe("the same at the terminal", () => {
199200 expect(lobby!.resting()).not.toBeNull();
200201 });
201202 });
203
204 describe("the light's real day in the care log (#639)", () => {
205 test("wake up writes one lightOn; good night writes nothing and leaves an open day open", async () => {
206 const { base, log } = await boot(false);
207 await fetch(`${base}/admin`, form(owner, { action: "wake" }));
208 expect(log.entries().map((e) => e.type === "action" && e.what)).toEqual(["lightOn"]);
209 await fetch(`${base}/admin`, form(owner, { action: "goodnight" }));
210 expect(log.entries().map((e) => e.type === "action" && e.what)).toEqual(["lightOn"]);
211 expect(lastLight(log.entries(), Date.now())).toBe("on");
212 });
213
214 test("a refused wake up writes nothing", async () => {
215 const { base, log } = await boot(true, true);
216 await fetch(`${base}/admin`, form(owner, { action: "goodnight" }));
217 await fetch(`${base}/admin`, form(owner, { action: "wake" }));
218 expect(log.entries().filter((e) => e.type === "action" && e.what === "lightOn")).toEqual([]);
219 });
220 });
221
222 test("the terminal can back-date the light through the check-in", () => {
223 expect(careCommand(["checkin", "light", "on", "08:30"], "light on 08:30")).toMatchObject({ path: "/care/checkin", body: { light: "on", lightAt: "08:30" } });
224 });
Software/so101/src/night-switch.ts +4 −2
@@ -2,7 +2,8 @@
22 * Wake up / Good night (#632): the owner's one tap on the page (/admin) and one line at the terminal (/wake, /goodnight).
33 * Wake up sets the light first and opens the room only once the light took, so a refused write leaves both as they were.
44 * Good night only closes the room: the grow light stays on its schedule, because the arm holds an override until the next
5 * schedule transition and a forgotten "off" would darken the plants.
5 * schedule transition and a forgotten "off" would darken the plants. Wake up also writes one care-log lightOn (#639) once
6 * the light write took; Good night writes nothing, because nothing turned the light off: the owner logs lightOff on the check-in.
67 */
78 import { AdminError } from "./admin.ts";
89 import type { LightHooks } from "./admin-routes.ts";
@@ -19,7 +20,7 @@ export const WAKE_LINES = {
1920 export type WakeCode = keyof typeof WAKE_LINES;
2021
2122 /** Wake up (light on, then open) or Good night (close with a note). Throws AdminError when the light refuses or the caller may not. */
22 export async function nightSwitch(lobby: Lobby, who: Person, waking: boolean, note: string, askLight?: LightHooks): Promise<WakeCode | null> {
23 export async function nightSwitch(lobby: Lobby, who: Person, waking: boolean, note: string, askLight?: LightHooks, record?: (what: "lightOn" | "lightOff") => void): Promise<WakeCode | null> {
2324 if (!waking) {
2425 lobby.setOpen(who, { open: false, note: note.trim().slice(0, 200) || SLEEPING_NOTE });
2526 return null;
outdated

Good night does not switch the light off (see the header comment), so recording lightOff here logs something that did not happen. Remove this line.

@@ -31,5 +32,6 @@ export async function nightSwitch(lobby: Lobby, who: Person, waking: boolean, no
3132 if (refused) throw new AdminError(`the light did not turn on, so the room stays closed: ${refused}`);
3233 }
3334 lobby.setOpen(who, { open: true });
35 record?.("lightOn");
3436 return switched ? "on" : "manual";
3537 }
Software/so101/src/relay.ts +15 −4
@@ -112,6 +112,8 @@ import { CareLog, LogError } from "./care-log.ts";
112112 import { growing, lastWatered, nextDue, overdue, reminders, snapshot, sownAt } from "./care-rules.ts";
113113 import { BUILT_IN_DIR, loadProfiles, type Profile } from "./care-profile.ts";
114114 import { ProbeCache, readProbeView, sampleProbes, waterTemp, type ProbeHooks } from "./care-probes.ts";
115 import { lightDay, lightLines } from "./light-day.ts";
116 import { DEFAULT_SCHEDULE } from "./light-panel.ts";
115117 import { handleCare } from "./care-routes.ts";
116118 import { handleDosing, makeDoser } from "./care-dosing.ts";
117119 import type { Doser } from "./doser.ts";
@@ -683,11 +685,16 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> {
683685 },
684686 };
685687 const probeCache = new ProbeCache(probeHooks, () => waterTemp(care.log, Date.now()), () => care.log.notify());

Defaults to UTC until the Pi has been read once; the card and back-dated log are wrong by the zone offset until then. Load it at start or on first use.

688 /** The Pi's zone and photoperiod as last heard, so the care log's light day can be worded without waiting on the Pi. */
689 let lightSeen: { zone: string; onAt: string; hours: number } | null = null;
686690 const lightHooks: LightHooks = {
687691 read: async () => {
688692 try {
689693 const answer = await fetch(armGrow(), { cache: "no-store", headers: growAuth(), signal: AbortSignal.timeout(Math.min(readTimeoutMs, 1000)) });
690 return answer.ok ? readGrow(await answer.json().catch(() => null)) : null;
694 const view = answer.ok ? readGrow(await answer.json().catch(() => null)) : null;
695 const s = view?.light.schedule;
696 if (view) lightSeen = { zone: view.light.zone || "UTC", onAt: s && "onAt" in s ? s.onAt : DEFAULT_SCHEDULE.onAt, hours: s && "onAt" in s ? s.hours : DEFAULT_SCHEDULE.hours };
697 return view;
691698 } catch {
692699 return null;
693700 }
@@ -864,7 +871,11 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> {
864871 const snap = snapshot(care.log);
865872 const plant = growing(care.profile, snap, now);
866873 const next = nextDue(care.profile, snap, now);
867 return { now, sownAt: plant.sownAt, title: plant.title, hint: plant.hint, lastWatered: lastWatered(snap.entries, now), next: next ? { label: next.reminder.label, dueAt: next.dueAt } : null, pi: piState() };
874 // Until the Pi has answered once its zone is unknown, and a light day worded in UTC would be wrong: say nothing and ask.
875 const seen = lightSeen;
876 if (!seen) void lightHooks.read();
877 const day = seen ? lightDay(snap.entries, now, seen.zone, seen) : null;
878 return { now, sownAt: plant.sownAt, title: plant.title, hint: plant.hint, lastWatered: lastWatered(snap.entries, now), next: next ? { label: next.reminder.label, dueAt: next.dueAt } : null, pi: piState(), light: seen && day ? lightLines(day, seen.zone) : null };
868879 };
869880
870881 /** Each station as the home shows it: the arm is present only while the link reaches a real one (so it comes back by itself). */
@@ -1289,14 +1300,14 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> {
12891300 return { owner: Boolean(person?.owner), known: Boolean(person), id: person?.id, signedIn: lobby && !lobby.local ? lobby.signedIn(r) : null };
12901301 };
12911302 if (await handleDosing(req, res, url.pathname, { doser, log: care.log, who, readBody, sameOrigin, pumps: pumpHooks })) return;
1292 if (await handleCare(req, res, url.pathname, { profile: care.profile, log: care.log, probes: probeCache, doser, pumps: () => pumpRead.read(), who, readBody })) return;
1303 if (await handleCare(req, res, url.pathname, { profile: care.profile, log: care.log, probes: probeCache, doser, pumps: () => pumpRead.read(), who, readBody, zone: async () => (lightSeen ?? ((await lightHooks.read()) && lightSeen))?.zone ?? null })) return;
12931304 }
12941305 if (reading && url.pathname === "/stations") return json(res, 200, stationViews().map(({ station, present, resting, cameras: c }) => ({ id: station.id, name: station.name, kind: station.kind, present, ...(resting ? { resting } : {}), cameras: c })));
12951306 const cameraAdmin = ADMIN_CAMERA.exec(url.pathname);
12961307 if (cameraAdmin) return await administer(req, res, cameraAdmin[1] === undefined ? null : Number(cameraAdmin[1]), cameraAdmin[2] !== undefined);
12971308 if (CAMERA_PROFILE.test(url.pathname)) return json(res, 404, { error: "no such route" });
12981309 if (lobby && url.pathname === "/admin/lift") return await handleLift(lobby, req, res, readBody);
1299 if (lobby && (url.pathname === "/admin" || url.pathname === "/admin/health" || url.pathname === "/admin/night")) return await handleAdmin(lobby, req, res, url, readBody, askPrecision, askServos, videoCards, options.piSync ? () => options.piSync!.versions() : undefined, tuneP, lightHooks);
1310 if (lobby && (url.pathname === "/admin" || url.pathname === "/admin/health" || url.pathname === "/admin/night")) return await handleAdmin(lobby, req, res, url, readBody, askPrecision, askServos, videoCards, options.piSync ? () => options.piSync!.versions() : undefined, tuneP, lightHooks, (what) => void care.log.add({ type: "action", what, cause: "owner" }));
13001311 if (serveStory(req, res, url.pathname, { cameras: () => cameras.list(), numbers: storyNumbers, signedIn: lobby && !lobby.local ? (r) => lobby.signedIn(r) : undefined })) return;
13011312 if (url.pathname === "/admin/versions") return versionsRead(req, res);
13021313 if (lobby && url.pathname === "/admin/data") return serveDataPage(lobby, req, res);
Software/so101/src/workshop-page.ts +4 −1
@@ -92,6 +92,8 @@ export interface GrowNow {
9292 next: { label: string; dueAt: number } | null;
9393 /** Whether the Pi's server answers; null when there is no Pi behind this app. */
9494 pi: "online" | "offline" | null;
95 /** The light's real day from the care log (src/light-day.ts), line by line; null when it was not logged lately. */
96 light?: string[] | null;
9597 }
9698
9799 /** Who is looking: the identity line's reading (src/signed-in.ts), null for a guest, and whether they own the place. */
@@ -135,7 +137,8 @@ function growing(g: GrowNow | undefined, owner: boolean): string {
135137 ? `<h3>${esc(g.title)}</h3><p>${g.hint ? `${esc(g.hint)}. ` : ""}Sown ${day(g.sownAt)}.</p>${owner ? `<details><summary>Change the sow date</summary>${sowForm(g.now, "Save the sow date")}</details>` : ""}`
136138 : `<h3>Not planted yet</h3>${owner ? sowForm(g.now, "Set the sow date") : ""}`;
137139 const care = [g.lastWatered !== null ? `Last watered: ${ago(g.now - g.lastWatered)}` : "Not watered yet", g.next ? `Next: ${esc(g.next.label)}, ${due(g.next.dueAt - g.now)}` : ""].filter(Boolean).join(" · ");
138 return `<div class="growing" data-growing>${offline}${plant}<p>${care}. <a href="/care">The log book</a></p></div>`;
140 const light = g.light?.length ? `<p data-light>${g.light.map(esc).join(" · ")}</p>` : "";
141 return `<div class="growing" data-growing>${offline}${plant}${light}<p>${care}. <a href="/care">The log book</a></p></div>`;
139142 }
140143
141144 /** A station's cameras, each at its own 16:9 shape: a row to swipe on a phone, side by side when the card is wide. Tap one for full screen. */
Software/so101/src/workshop-phone.test.ts +7
@@ -291,3 +291,10 @@ test("the planted date is the sow: the day counts from it past a transplant, and
291291 rmSync(dir, { recursive: true, force: true });
292292 }
293293 });
294
295 test("the grow card words the light's real day, escaped, and says nothing without a logged day", () => {
296 const html = home({ light: ["Light on 08:30 → off due 20:30 (12 h)", "started 1 h 30 m late", "light still on?", "<b>x</b>"] });
297 expect(html).toContain('<p data-light>Light on 08:30 → off due 20:30 (12 h) · started 1 h 30 m late · light still on? · &lt;b&gt;x&lt;/b&gt;</p>');
298 expect(home({ light: null })).not.toContain("data-light");
299 expect(home()).not.toContain("data-light");
300 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs