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

Migrated from github.com/Trevato/SO-ARM100

workbenchthis machine

Night mode: the saplings are sleeping (animation flowing into a multiplayer game with a leaderboard), and Wake up / Good night for the owner with a morning check-in (water, sprout count) #633

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/632-night-mode-the-saplings-are-sleeping-ani → main 9 commits fork 4 threads · 4 unresolved

  • Merged. smith/632-night-mode-the-saplings-are-sleeping-ani was merged into main as 7c15b9a.

Review

Sign in to review this change.
15 files changed +378 −15
Software/so101/README.md +4
@@ -36,6 +36,10 @@ Closed means: every turn ends (the owner's too) and the line is emptied, nobody
3636
3737 Closing never locks the owner out. Signing in and out keep working while closed, whoever drew the link: the forge's `/__forge/signin` and `/__forge/signout`, this app's `/signin`, `POST /signout` and `POST /join`, and the forge's signed-out chrome, whose `/_plat/login?rd=…` is a 303 to `/__forge/signin?to=…` (a path on this site only) and `/_plat/logout` a 303 to `/__forge/signout`. So do `/admin`, `/story`, `/status`, `/link` and `/deploy-ok` (one list, `RESTING_ALLOWED` in src/relay.ts); `/metrics`, `/version` and every command stay 503 `{"open": false}`; only turns, cameras, `/follow` and commands are refused. The card has a quiet **Owner? Sign in to open** link (sign-in, then `/admin`), and a signed-in owner sees **Open now** right on the card, which opens it and goes back to the arm (a same-origin form post to `/admin`; anyone else gets no button and is refused).
3838
39 **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.
40
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`.
42
3943 The switch also works outside the app:
4044
4145 - **The file.** Writing `{"open": true}` (or `{"open": false, "note": "…"}`) to `so101/open.json` under `FORGE_APP_DATA` counts within a second, no redeploy; this is the hook for the forge's app settings.
Software/so101/src/admin-page.ts +1 −1
@@ -350,7 +350,7 @@ function openSection(p: AdminPage): string {
350350 <label>Back around<small>optional; your local time; shown, it does not reopen by itself</small><input name="reopenAt" type="datetime-local"></label>
351351 </div>
352352 <div class="save"><button class="${s.open ? "danger" : ""}">${s.open ? "Close the arm" : "Update the note"}</button></div></form>`;
353 return `<section aria-labelledby="open"><h2 id="open">Open / Closed</h2>${now}${!s.open || r ? `<div class="actions">${open}</div>` : ""}${close}</section>`;
353 return `<section aria-labelledby="open"><h2 id="open">Open / Closed</h2>${now}<div class="actions">${!s.open || r ? open : ""}${!s.open ? action("wake", "Wake up the garden", "go") : r ? "" : action("goodnight", "Good night", "danger")}</div>${close}</section>`;
354354 }
355355
356356 /** The two commits side by side; a mismatch says so, with what the last attempt to fix it came to. */
Software/so101/src/admin-routes.ts +28 −3
@@ -5,6 +5,7 @@ import type { Person } from "./identity.ts";
55 import type { Lobby } from "./lobby.ts";
66 import { AdminError, changeFromForm } from "./admin.ts";
77 import { openChangeFromForm } from "./opening.ts";
8 import { nightSwitch, WAKE_LINES, type WakeCode } from "./night-switch.ts";
89 import { sameOrigin } from "./relay.ts";
910 import { adminCsp, DONE, renderAdmin, renderRefusal, type Done } from "./admin-page.ts";
1011 import type { Versions } from "./pi-deploy.ts";
@@ -109,10 +110,18 @@ export async function handleAdmin(lobby: Lobby, req: IncomingMessage, res: Serve
109110 res.setHeader("connection", "close");
110111 return show(413, null, "that form is too large");
111112 }
112 const form = new URLSearchParams(body.toString("utf8"));
113 // /admin/night is the terminal's door to Wake up / Good night: the same switch, a JSON body in and out.
114 const terminal = url.pathname === "/admin/night";
115 const form = terminal ? jsonForm(body.toString("utf8")) : new URLSearchParams(body.toString("utf8"));
113116 const act = form.get("action") ?? "";
114117 if (!canEdit && !REPORT_ACTIONS.has(act)) return session.refuse(403);
118 if (terminal && act !== "wake" && act !== "goodnight") return session.refuse(400);
119 const reply = (status: number, payload: unknown) => {
120 res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store" });
121 res.end(JSON.stringify(payload));
122 };
115123 let done: Done;
124 let wakeCode: WakeCode | null = null;
116125 try {
117126 switch (form.get("action")) {
118127 case "save":
@@ -137,6 +146,11 @@ export async function handleAdmin(lobby: Lobby, req: IncomingMessage, res: Serve
137146 lobby.setOpen(who, openChangeFromForm(form, act === "open", Date.now()));
138147 done = act === "open" ? "opened" : "closed";
139148 break;
149 case "wake":
150 case "goodnight":
151 wakeCode = await nightSwitch(lobby, who, act === "wake", form.get("note") ?? "", askLight);
152 done = act === "wake" ? "opened" : "closed";
153 break;
140154 case "end":
141155 lobby.endTurn(who);
142156 done = "ended";
@@ -185,12 +199,13 @@ export async function handleAdmin(lobby: Lobby, req: IncomingMessage, res: Serve
185199 throw new AdminError("no such action");
186200 }
187201 } catch (err) {
188 if (err instanceof AdminError) return show(400, null, err.message);
202 if (err instanceof AdminError) return terminal ? reply(400, { error: err.message }) : show(400, null, err.message);
189203 throw err;
190204 }
205 if (terminal) return reply(200, { ok: true, said: wakeCode ? WAKE_LINES[wakeCode] : "The room is closed; the grow light keeps its schedule." });
191206 // The resting card's Open now goes back to the arm itself; every other form comes back here.
192207 const home = done === "opened" && form.get("then") === "home";
193 res.writeHead(303, { location: home ? "/" : `/admin?done=${done}`, "cache-control": "no-store" });
208 res.writeHead(303, { location: wakeCode ? `/care/checkin?light=${wakeCode}` : home ? "/" : `/admin?done=${done}`, "cache-control": "no-store" });
194209 res.end();
195210 }
196211
@@ -235,3 +250,13 @@ export async function handleLift(lobby: Lobby, req: IncomingMessage, res: Server
235250 res.writeHead(303, { location: "/admin?done=lifted", "cache-control": "no-store" });
236251 res.end();
237252 }
253
254 /** A terminal's JSON object as the form fields the page would post (strings only). */
255 function jsonForm(text: string): URLSearchParams {
256 const out = new URLSearchParams();
257 try {
258 const v = JSON.parse(text || "null") as unknown;
259 if (v && typeof v === "object") for (const [k, x] of Object.entries(v)) if (typeof x === "string") out.set(k, x);
260 } catch {}
261 return out;
262 }
Software/so101/src/care-checkin.ts added +47
@@ -0,0 +1,47 @@
1 /** The morning check-in (#632): one screen, each tap one typed entry in the care log. */
2 import type { EntryBody } from "./care-log.ts";
3 import { STYLE } from "./care-page.ts";
4 import { WAKE_LINES, type WakeCode } from "./night-switch.ts";
5 import { esc } from "./story-markdown.ts";
6
7 const WATER = { moist: "cubes moist", topped: "topped up (bottom)", wet: "too wet" } as const;
8 const LID = { on: "on", cracked: "cracked", off: "off" } as const;
9 export const MAX_SPROUTS = 20;
10
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 {
13 const water = input.water;
14 if (typeof water === "string" && Object.hasOwn(WATER, water)) {
15 return water === "topped" ? { at, type: "action", what: "topoff", cause: "owner" } : { at, type: "observation", text: `Water: ${WATER[water as keyof typeof WATER]}` };
16 }
17 const lid = input.lid;
18 if (typeof lid === "string" && Object.hasOwn(LID, lid)) return { at, type: "observation", text: `Lid: ${LID[lid as keyof typeof LID]}` };
19 if (input.sprouts !== undefined && input.sprouts !== "") {
20 const n = Number(input.sprouts);
21 if (Number.isInteger(n) && n >= 0 && n <= MAX_SPROUTS) return { at, type: "measurement", kind: "sprouts", value: n, source: "manual" };
22 return null;
23 }
24 const note = typeof input.note === "string" ? input.note.trim() : "";
25 return note && note.length <= 500 ? { at, type: "observation", text: note } : null;
26 }
27
28 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>`;
29
30 /** 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 {
32 const line = light !== null && Object.hasOwn(WAKE_LINES, light) ? WAKE_LINES[light as WakeCode] : null;
33 return `<!doctype html>
34 <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
35 <title>Morning check-in</title>
36 <style>${STYLE}</style></head>
37 <body><main>
38 <h1>Good morning</h1>
39 ${line ? `<p class="dim">${esc(line)}</p>` : ""}${done ? `<p role="status">Logged.</p>` : ""}
40 <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>
41 <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>
42 <section class="panel" aria-label="Lid"><h2>Lid</h2>${one("lid", "on", "On")}${one("lid", "cracked", "Cracked")}${one("lid", "off", "Off")}</section>
43 <section class="panel" aria-label="Note"><h2>Photo note</h2><form method="post" action="/care/checkin"><input class="wide" type="text" name="note" maxlength="500" placeholder="optional" aria-label="Note"><button>Save note</button></form></section>

Add inputmode="numeric" to the sprouts field so a phone shows the number pad, and make sure the buttons keep a 44px minimum height on 360px screens.

44 <p><a href="/care">Open the grow card</a> · <a href="/">Back to the arm</a></p>
45 </main></body></html>
46 `;
47 }
Software/so101/src/care-log.ts +1 −1
@@ -45,7 +45,7 @@ export type EntryBody = Entry extends infer E ? (E extends unknown ? Omit<E, "id
4545 export class LogError extends Error {}
4646
4747 /** Plausible limits per measure: a typo like 71 for 7.1 is refused at the door. */
48 const LIMITS: Record<Measure, [number, number]> = { ph: [0, 14], ec: [0, 10], waterTemp: [0, 45], airTemp: [-10, 55], level: [0, 100], lightHours: [0, 24] };
48 const LIMITS: Record<Measure, [number, number]> = { ph: [0, 14], ec: [0, 10], waterTemp: [0, 45], airTemp: [-10, 55], level: [0, 100], lightHours: [0, 24], sprouts: [0, 20] };
4949
5050 const text = (v: unknown, what: string, max: number, required = true): string | undefined => {
5151 if (v === undefined || v === "") {
Software/so101/src/care-page.ts +2 −2
@@ -21,7 +21,7 @@ const LIVE = `if (window.EventSource) new EventSource("/care/events").addEventLi
2121 if (r.ok) document.querySelector("main").innerHTML = new DOMParser().parseFromString(await r.text(), "text/html").querySelector("main").innerHTML;
2222 });`;
2323
24 const STYLE = `
24 export const STYLE = `
2525 :root { color-scheme: dark light; --bg: #0b0d10; --panel: #11161d; --fg: #e8eaed; --dim: #9aa4ae; --line: #2a313a; --accent: #f0883e; --on-accent: #1a1206; --link: #79b8ff; --ok: #3fb950; --bad: #f85149; --band: #3fb95033; }
2626 @media (prefers-color-scheme: light) { :root { --bg: #fbfaf7; --panel: #fff; --fg: #1f2328; --dim: #59636e; --line: #d8dee4; --accent: #bc4c00; --on-accent: #fff; --link: #0969da; --ok: #1a7f37; --bad: #cf222e; --band: #1a7f3733; } }
2727 body { margin: 0; background: var(--bg); color: var(--fg); font: 16px/1.5 ui-sans-serif, system-ui, sans-serif; }
@@ -150,7 +150,7 @@ ${dosing.simulated ? `<p class="dim">No pumps are wired yet: a dose is only note
150150 })
151151 .join("");
152152
153 const kinds: Measure[] = ["ph", "ec", "waterTemp", "airTemp", "lightHours"];
153 const kinds: Measure[] = ["ph", "ec", "waterTemp", "airTemp", "lightHours", "sprouts"];
154154 const charts = kinds.map((k) => chart(k, snap, cur.stage.bands[k], now, cite(cur.stage.sources[k]))).join("");
155155
156156 let lastStage = "";
Software/so101/src/care-profile.ts +1 −1
@@ -6,7 +6,7 @@
66 import { existsSync, readdirSync, readFileSync } from "node:fs";
77 import { join } from "node:path";
88
9 export const MEASURES = ["ph", "ec", "waterTemp", "airTemp", "level", "lightHours"] as const;
9 export const MEASURES = ["ph", "ec", "waterTemp", "airTemp", "level", "lightHours", "sprouts"] as const;
1010 export type Measure = (typeof MEASURES)[number];
1111
1212 export type Band = readonly [number, number];
Software/so101/src/care-routes.ts +23
@@ -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 { checkinEntry, renderCheckin } from "./care-checkin.ts";
89 import { calibrateProbe, renderProbes, type ProbeCache } from "./care-probes.ts";
910 import type { Profile } from "./care-profile.ts";
1011 import type { Doser } from "./doser.ts";
@@ -102,6 +103,28 @@ export async function handleCare(req: IncomingMessage, res: ServerResponse, path
102103 return true;
103104 }
104105
106 if (pathname === "/care/checkin") {
107 if (!me.owner) return json(me.known ? 403 : 401, { error: "only the owner may check in" }), true;
108 const latest = ctx.log.entries().filter((e) => e.type === "measurement" && e.kind === "sprouts").at(-1);
109 const sprouts = latest && latest.type === "measurement" ? latest.value : null;
110 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;
112 if (req.method !== "POST") return json(405, { error: "GET or POST" }), true;
113 let raw: Buffer;
114 try {
115 raw = await ctx.readBody(req, 2048);
116 } catch {
117 return json(413, { error: "too long" }), true;
118 }
119 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;
122 const added = ctx.log.add(made);
123 if ((req.headers["content-type"] ?? "").includes("json")) json(200, added);
124 else send(303, "text/plain", "", { location: "/care/checkin?done=1" });
125 return true;
126 }
127
105128 const entry = /^\/care\/log(?:\/(\d+)(\/delete)?)?$/.exec(pathname);
106129 const reminder = pathname === "/care/reminder";
107130 const calibrate = pathname === "/care/probes/calibrate" && ctx.probes;
Software/so101/src/care-rules.ts +1
@@ -95,6 +95,7 @@ export const LABELS: Record<Measure, { name: string; unit: string }> = {
9595 airTemp: { name: "Air temp", unit: " °C" },
9696 level: { name: "Level", unit: " %" },
9797 lightHours: { name: "Light", unit: " h" },
98 sprouts: { name: "Sprouts", unit: "" },
9899 };
99100
100101 export const ago = (ms: number): string => {
Software/so101/src/care-terminal.ts +7 −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> <value> · /care did <action> [amount] · /care 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 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
@@ -47,6 +47,12 @@ export function careCommand(words: string[], tail: string): CareAsk | { print: s
4747 return post("/care/log", { type: "action", what: a, ...(b ? { amount: num(b) } : {}) });
4848 case "note":
4949 return tail ? post("/care/log", { type: "observation", text: tail }) : usage;
50 case "checkin": {
51 if (!tail.startsWith(a) || !tail.slice(a.length).trim()) return usage;
52 const value = tail.slice(a.length).trim();
53 if (a === "sprouts") return /^\d{1,2}$/.test(value) ? post("/care/checkin", { sprouts: Number(value) }) : usage;
54 return a === "water" || a === "lid" || a === "note" ? post("/care/checkin", { [a]: value }) : usage;
55 }
5056 case "problem": {
5157 const kind = PROBLEMS.find((p) => tail === p || tail.startsWith(`${p} `));
5258 return kind ? post("/care/log", { type: "problem", what: kind, ...(tail.length > kind.length ? { text: tail.slice(kind.length).trim() } : {}) }) : usage;
Software/so101/src/night-switch.test.ts added +201
@@ -0,0 +1,201 @@
1 import { afterEach, describe, 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 { CARE_USAGE, careCommand } from "./care-terminal.ts";
8 import { sign } from "./identity.ts";
9 import { Lobby } from "./lobby.ts";
10 import { startRelay, type RunningRelay } from "./relay.ts";
11 import { careReader, Door, emptyRoom, typed } from "./terminal.ts";
12
13 const KEY = "k".repeat(64);
14 const as = (name: string, role: "user" | "admin") => {
15 const identity = `id-${name}|${name}|${role}|${Date.now() + 600_000}`;
16 return { "x-forge-identity": identity, "x-forge-signature": sign(identity, KEY) };
17 };
18 const owner = as("trevato", "admin");
19 const form = (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 });
20
21 let dir = "";
22 let relay: RunningRelay | undefined;
23 let lobby: Lobby | undefined;
24 let arm: ReturnType<typeof Bun.serve> | undefined;
25 const lightCalls: unknown[] = [];
26 let growReads = 0;
27 afterEach(async () => {
28 await relay?.close();
29 lobby?.close();
30 arm?.stop(true);
31 if (dir) rmSync(dir, { recursive: true, force: true });
32 dir = "";
33 relay = lobby = arm = undefined;
34 lightCalls.length = 0;
35 growReads = 0;
36 });
37
38 /** The relay over a fake arm whose light switch is configured or not, and takes or refuses a write. */
39 async function boot(configured: boolean, refuse = false) {
40 dir = mkdtempSync(join(tmpdir(), "so101-wake-"));
41 arm = Bun.serve({
42 port: 0,
43 async fetch(req) {
44 const url = new URL(req.url);
45 if (url.pathname === "/grow/light" && req.method === "POST") {
46 lightCalls.push(await req.json());
47 return refuse ? Response.json({ error: "the relay is stuck" }, { status: 409 }) : Response.json({ ok: true });
48 }
49 if (url.pathname === "/grow") growReads++;
50 if (url.pathname === "/grow") return Response.json({ light: { configured, text: "On until 18:00", zone: "UTC", schedule: { onAt: "06:00", hours: 12 }, on: false, log: [] } });
51 return new Response("no", { status: 404 });
52 },
53 });
54 lobby = new Lobby({ identityKey: KEY, adminFile: join(dir, "admin.json"), openFile: join(dir, "open.json") });
55 const log = new CareLog(join(dir, "care.json"));
56 const profile = loadProfiles(BUILT_IN_DIR).find((p) => p.id === "spinach")!;
57 relay = await startRelay({ port: 0, simulation: `http://127.0.0.1:${arm.port}`, lobby, care: { log, profile } });
58 return { base: `http://127.0.0.1:${relay.port}`, port: relay.port, log };
59 }
60
61 describe("Good night and Wake up on the page", () => {
62 test("good night closes the room with the sleeping note and leaves the light on its schedule", async () => {
63 const { base } = await boot(true);
64 expect((await fetch(`${base}/admin`, form(owner, { action: "goodnight" }))).status).toBe(303);
65 expect(lightCalls).toEqual([]);
66 expect(lobby!.resting()?.note).toBe("The saplings are sleeping…");
67 const html = await (await fetch(`${base}/`)).text();
68 expect(html).toContain("The saplings are sleeping…");
69 expect(html).not.toContain("Wake up the garden");
70 expect(await (await fetch(`${base}/`)).text()).toContain("Back at sunrise: about <b>6:00</b>");
71 expect(await (await fetch(`${base}/`, { headers: owner })).text()).toContain("Wake up the garden");
72 });
73
74 test("many hits on the closed page make one /grow call, and the first view already shows the sunrise", async () => {
75 const { base } = await boot(true);
76 await fetch(`${base}/admin`, form(owner, { action: "goodnight" }));
77 const pages = await Promise.all(Array.from({ length: 25 }, () => fetch(`${base}/`).then((r) => r.text())));
78 for (let i = 0; i < 5; i++) pages.push(await (await fetch(`${base}/`)).text());
79 expect(pages.every((p) => p.includes("Back at sunrise: about <b>6:00</b>"))).toBe(true);
80 expect(growReads).toBe(1);
81 });
82
83 test("wake up turns the light on, then opens the room, and lands on the check-in with a fixed line", async () => {
84 const { base } = await boot(true);
85 await fetch(`${base}/admin`, form(owner, { action: "goodnight" }));
86 expect((await fetch(`${base}/admin`, form(as("bob", "user"), { action: "wake" }))).status).toBe(404);
87 expect(lightCalls).toEqual([]);
88 expect(lobby!.resting()).not.toBeNull();
89 const woke = await fetch(`${base}/admin`, form(owner, { action: "wake" }));
90 expect(woke.headers.get("location")).toBe("/care/checkin?light=on");
91 expect(lightCalls).toEqual([{ override: "on" }]);
92 expect(lobby!.resting()).toBeNull();
93 const page = await (await fetch(`${base}/care/checkin?light=on`, { headers: owner })).text();
94 expect(page).toContain("The light is on until its next scheduled change");
95 });
96
97 test("a refused light write leaves the room closed and says why", async () => {
98 const { base } = await boot(true, true);
99 await fetch(`${base}/admin`, form(owner, { action: "goodnight" }));
100 const woke = await fetch(`${base}/admin`, form(owner, { action: "wake" }));
101 expect(woke.status).toBe(400);
102 expect(await woke.text()).toContain("the relay is stuck");
103 expect(lobby!.resting()).not.toBeNull();
104 });
105
106 test("with no switch yet, wake up says to turn the light on by hand and writes nothing", async () => {
107 const { base } = await boot(false);
108 await fetch(`${base}/admin`, form(owner, { action: "goodnight" }));
109 const woke = await fetch(`${base}/admin`, form(owner, { action: "wake" }));
110 expect(woke.headers.get("location")).toBe("/care/checkin?light=manual");
111 expect(lightCalls).toEqual([]);
112 expect(lobby!.resting()).toBeNull();
113 });
114
115 test("a cross-origin post to wake, goodnight or the check-in is refused", async () => {
116 const { base, log } = await boot(true);
117 const evil = { ...owner, origin: "https://evil.example", "sec-fetch-site": "cross-site" };
118 expect((await fetch(`${base}/admin`, form(evil, { action: "goodnight" }))).status).toBe(403);
119 expect((await fetch(`${base}/admin/night`, { method: "POST", headers: { ...evil, "content-type": "application/json" }, body: JSON.stringify({ action: "goodnight" }) })).status).toBe(403);
120 expect((await fetch(`${base}/care/checkin`, form(evil, { sprouts: "3" }))).status).toBe(403);
121 expect(lobby!.resting()).toBeNull();
122 expect(log.entries()).toHaveLength(0);
123 });
124 });
125
126 describe("the morning check-in", () => {
127 test("is the owner's, writes typed entries, and never prints the query's words", async () => {
128 const { base, log } = await boot(true);
129 expect((await fetch(`${base}/care/checkin`)).status).toBe(401);
130 expect((await fetch(`${base}/care/checkin`, { headers: as("eve", "user") })).status).toBe(403);
131 const html = await (await fetch(`${base}/care/checkin?light=${encodeURIComponent("<b>hello</b> send money")}`, { headers: owner })).text();
132 expect(html).toContain("Cubes moist");
133 expect(html).not.toContain("send money");
134 for (const body of [{ water: "moist" }, { water: "topped" }, { lid: "cracked" }, { sprouts: "7" }, { note: "first leaves" }] as Record<string, string>[]) {
135 expect((await fetch(`${base}/care/checkin`, form(owner, body))).status).toBe(303);
136 }
137 expect((await fetch(`${base}/care/checkin`, form(owner, { sprouts: "21" }))).status).toBe(400);
138 expect((await fetch(`${base}/care/checkin`, form(owner, { water: "soaked" }))).status).toBe(400);
139 const entries = log.entries();
140 expect(entries.map((e) => e.type)).toEqual(["observation", "action", "observation", "measurement", "observation"]);
141 expect(entries[1]).toMatchObject({ what: "topoff", cause: "owner" });
142 expect(entries[3]).toMatchObject({ kind: "sprouts", value: 7 });
143 expect(await (await fetch(`${base}/care`)).text()).toContain("Sprouts: 7");
144 });
145 });
146
147 describe("the same at the terminal", () => {
148 const env = (port: number, name: string | null, role: "user" | "admin" = "user"): Record<string, string> =>
149 name ? { PORT: String(port), FORGE_APP_IDENTITY_KEY: KEY, FORGE_APP_USER_ID: `id-${name}`, FORGE_APP_USER: name, FORGE_APP_USER_ROLE: role } : { PORT: String(port) };
150 const run = async (e: Record<string, string>, line: string) => {
151 const printed: string[] = [];
152 new Door("ws://127.0.0.1:1/live", {}, (l) => printed.push(l), null, null, null, null, null, null, null, null, null, null, null, null, careReader(e)).input(line);
153 for (let i = 0; i < 100 && !printed.length; i++) await Bun.sleep(20);
154 return printed;
155 };
156
157 test("lines parse into the asks the page makes", () => {
158 expect(typed(emptyRoom(), "/care checkin water topped")).toEqual({ care: { method: "POST", path: "/care/checkin", body: { water: "topped" } } });
159 expect(typed(emptyRoom(), "/care checkin sprouts 7")).toEqual({ care: { method: "POST", path: "/care/checkin", body: { sprouts: 7 } } });
160 expect(typed(emptyRoom(), "/care checkin note first true leaves")).toEqual({ care: { method: "POST", path: "/care/checkin", body: { note: "first true leaves" } } });
161 for (const bad of ["/care checkin", "/care checkin water", "/care checkin sprouts x", "/care checkin mood good"]) expect(typed(emptyRoom(), bad)).toEqual({ print: CARE_USAGE });
162 expect(careCommand(["checkin", "lid", "off"], "lid off")).toMatchObject({ body: { lid: "off" } });
163 expect(typed(emptyRoom(), "/goodnight")).toMatchObject({ care: { path: "/admin/night", body: { action: "goodnight" } } });
164 expect(typed(emptyRoom(), "/goodnight see you at six")).toMatchObject({ care: { body: { action: "goodnight", note: "see you at six" } } });
165 expect(typed(emptyRoom(), "/wake")).toMatchObject({ care: { body: { action: "wake" } } });
166 expect(typed(emptyRoom(), "/wake now")).toEqual({ print: "usage: /wake" });
167 });
168
169 test("the owner checks in and web viewers hear it; a guest is refused", async () => {
170 const { port, log } = await boot(true);
171 let heard = 0;
172 log.onChange(() => heard++);
173 expect((await run(env(port, "eve"), "/care checkin sprouts 3"))[0]).toMatch(/^error: only the owner/);
174 expect(log.entries()).toHaveLength(0);
175 expect(await run(env(port, "trevato", "admin"), "/care checkin sprouts 5")).toEqual(["care saved"]);
176 expect(await run(env(port, "trevato", "admin"), "/care checkin water topped")).toEqual(["care saved"]);
177 expect(log.entries().map((e) => e.type)).toEqual(["measurement", "action"]);
178 expect(heard).toBe(2);
179 });
180
181 test("the owner says good night and wake; a refused light leaves the room closed; a guest is refused", async () => {
182 const { port } = await boot(true);
183 const o = env(port, "trevato", "admin");
184 expect((await run(env(port, "eve"), "/goodnight"))[0]).toMatch(/^error:/);
185 expect(lobby!.resting()).toBeNull();
186 expect((await run(o, "/goodnight"))[0]).toContain("grow light keeps its schedule");
187 expect(lobby!.resting()?.note).toBe("The saplings are sleeping…");
188 expect(lightCalls).toEqual([]);
189 expect((await run(o, "/wake"))[0]).toContain("The light is on");
190 expect(lightCalls).toEqual([{ override: "on" }]);
191 expect(lobby!.resting()).toBeNull();
192 });
193
194 test("a refused light at the terminal keeps the room closed", async () => {
195 const { port } = await boot(true, true);
196 const o = env(port, "trevato", "admin");
197 await run(o, "/goodnight");
198 expect((await run(o, "/wake"))[0]).toContain("the relay is stuck");
199 expect(lobby!.resting()).not.toBeNull();
200 });
201 });
Software/so101/src/night-switch.ts added +35
@@ -0,0 +1,35 @@
1 /**
2 * Wake up / Good night (#632): the owner's one tap on the page (/admin) and one line at the terminal (/wake, /goodnight).
3 * Wake up sets the light first and opens the room only once the light took, so a refused write leaves both as they were.
4 * 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.
6 */
7 import { AdminError } from "./admin.ts";
8 import type { LightHooks } from "./admin-routes.ts";
9 import type { Lobby } from "./lobby.ts";
10 import type { Person } from "./identity.ts";
11
12 export const SLEEPING_NOTE = "The saplings are sleeping…";
13
14 /** What the wake-up checklist says; the page is told a code, never a sentence. */
15 export const WAKE_LINES = {
16 on: "The light is on until its next scheduled change, and the cameras are streaming.",
17 manual: "Turn the light on (no switch yet). The cameras are streaming.",
18 } as const;
19 export type WakeCode = keyof typeof WAKE_LINES;
20
21 /** 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 if (!waking) {
24 lobby.setOpen(who, { open: false, note: note.trim().slice(0, 200) || SLEEPING_NOTE });
25 return null;
26 }
27 const light = askLight ? (await askLight.read())?.light : undefined;

A failed read (askLight.read() returns null/undefined) is treated as 'no switch', so the room opens and the owner is told to turn the light on by hand. Return a third code (e.g. 'unread') with its own line: 'Could not reach the light; check it.' Open the room or not, but do not claim there is no switch.

28 const switched = Boolean(light?.configured);
29 if (switched) {
30 const refused = await askLight!.write("light", { override: "on" });
31 if (refused) throw new AdminError(`the light did not turn on, so the room stays closed: ${refused}`);
32 }
33 lobby.setOpen(who, { open: true });
34 return switched ? "on" : "manual";
35 }
Software/so101/src/relay.ts +16 −3
@@ -552,10 +552,23 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> {
552552 /** Why the arm rests right now (src/opening.ts), or null while it is open; never resting without a lobby. */
553553 const restingNow = (): Resting | null => lobby?.resting() ?? null;
554554 /** The resting card, in place of the page. */

Stale doc comment: 'The resting card, in place of the page.' now sits above the sunrise cache. Move it down onto restingPage.

555 const restingPage = (req: IncomingMessage, res: ServerResponse, rest: Resting) => {
555 /** The light's sunrise for the closed card, from the arm: read at most once a minute, one read at a time; the first view waits for it. */
556 let sunrise: string | null = null;
557 let sunriseAt = 0;
558 let sunriseRead: Promise<void> | null = null;
559 const sunriseNow = async (): Promise<string | null> => {
560 if (!sunriseRead && Date.now() - sunriseAt > 60_000) {
561 sunriseAt = Date.now();
562 sunriseRead = lightHooks.read().then((g) => { const on = g?.light.schedule && "onAt" in g.light.schedule ? g.light.schedule.onAt : null; if (on) sunrise = on.replace(/^0/, ""); }).catch(() => {}).finally(() => { sunriseRead = null; });
563 }
564 if (sunrise === null && sunriseRead) await sunriseRead;
565 return sunrise;
566 };
567 const restingPage = async (req: IncomingMessage, res: ServerResponse, rest: Resting) => {
556568 const who = lobby?.who(req);
569 const sun = rest.reason === "closed" ? await sunriseNow() : null;
557570 res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", "content-security-policy": RESTING_CSP });
558 res.end(renderResting(rest, who ? (who.owner ? "owner" : "user") : "anonymous", lobby ? lobby.signedIn(req) : null));
571 res.end(renderResting(rest, who ? (who.owner ? "owner" : "user") : "anonymous", lobby ? lobby.signedIn(req) : null, sun));
559572 };
560573 /**
561574 * The arm's /status, saying which arm it is: from the link while it is live, the last one it had while reconnecting,
@@ -1283,7 +1296,7 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> {
12831296 if (cameraAdmin) return await administer(req, res, cameraAdmin[1] === undefined ? null : Number(cameraAdmin[1]), cameraAdmin[2] !== undefined);
12841297 if (CAMERA_PROFILE.test(url.pathname)) return json(res, 404, { error: "no such route" });
12851298 if (lobby && url.pathname === "/admin/lift") return await handleLift(lobby, req, res, readBody);
1286 if (lobby && (url.pathname === "/admin" || url.pathname === "/admin/health")) return await handleAdmin(lobby, req, res, url, readBody, askPrecision, askServos, videoCards, options.piSync ? () => options.piSync!.versions() : undefined, tuneP, lightHooks);
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);
12871300 if (serveStory(req, res, url.pathname, { cameras: () => cameras.list(), numbers: storyNumbers, signedIn: lobby && !lobby.local ? (r) => lobby.signedIn(r) : undefined })) return;
12881301 if (url.pathname === "/admin/versions") return versionsRead(req, res);
12891302 if (lobby && url.pathname === "/admin/data") return serveDataPage(lobby, req, res);
Software/so101/src/resting-page.ts +4 −2
@@ -103,7 +103,8 @@ export type Viewer = "anonymous" | "user" | "owner";
103103 function ownerRow(viewer: Viewer, r: Resting): string {
104104 if (viewer !== "owner") return "";
105105 const label = r.reason === "offline" ? "Open anyway" : "Open now";
106 return `<div class="owner"><form method="post" action="/admin"><input type="hidden" name="action" value="open"><input type="hidden" name="then" value="home"><button>${label}</button></form><a href="/admin">Admin</a></div>`;
106 const wake = r.reason === "offline" ? "" : `<form method="post" action="/admin"><input type="hidden" name="action" value="wake"><button>Wake up the garden</button></form>`;
107 return `<div class="owner">${wake}<form method="post" action="/admin"><input type="hidden" name="action" value="open"><input type="hidden" name="then" value="home"><button>${label}</button></form><a href="/admin">Admin</a></div>`;
107108 }
108109
109110 /**
@@ -114,10 +115,11 @@ const identityRow = (viewer: Viewer, signedIn: SignedIn | null): string =>
114115 viewer !== "anonymous" && !signedIn ? "" : renderSignedIn(viewer === "anonymous" ? null : signedIn, "/", `<span>Owner? <a href="${OWNER_SIGNIN}">Sign in to open</a></span>`);
115116
116117 /** The resting card as a whole page. */
117 export function renderResting(r: Resting, viewer: Viewer = "anonymous", signedIn: SignedIn | null = null): string {
118 export function renderResting(r: Resting, viewer: Viewer = "anonymous", signedIn: SignedIn | null = null, sunrise: string | null = null): string {
118119 const title = restingWords(r);
119120 const description = r.note || (r.reason === "offline" ? "The arm can't be reached from here just now; it comes back by itself." : "The owner has closed the arm for now.");
120121 const when = [
122 r.reason === "closed" && sunrise ? `<p>Back at sunrise: about <b>${esc(sunrise)}</b></p>` : "",
121123 r.reopenAt !== null ? `<p>Back around <b>${time(r.reopenAt)}</b></p>` : "",
122124 r.lastOpenAt !== null ? `<p>Last open ${time(r.lastOpenAt)}</p>` : "",
123125 ].join("");
Software/so101/src/terminal.ts +7 −1
@@ -36,6 +36,7 @@
3636 * /health owner or moderator: each open page's link as it last reported it (GET /admin/health, the page's Admin -> Health)
3737 * /stations [set <json>] the workshop's stations (GET /stations); the owner replaces them with a JSON list (PUT /admin/stations)
3838 * /probes [calibrate <ph7|ph4|ec1413> [tempC]] the pH/EC probes (GET /care/probes); anyone reads, the owner calibrates (POST /care/probes/calibrate)
39 * /wake, /goodnight [note] owner: Wake up (the light on, then the room open) and Good night (the room closed; the light keeps its schedule) (POST /admin/night)
3940 * /care [log|did|note|problem|done|snooze|delete …] the plant care app (GET /care/api); anyone reads, the owner writes (POST /care/log, /care/reminder)
4041 * /versions owner or moderator: the app's and the Pi's commits and the last sync (GET /admin/versions)
4142 * /watchdog owner: the grow watchdog's checks, the rows of the grow card's strip (GET /grow/watchdog); 404 to anyone else
@@ -232,6 +233,7 @@ export function careReader(env: Env, fetcher: typeof fetch = fetch): ReadCare {
232233 const body = (await r.json().catch(() => null)) as unknown;
233234 if (!r.ok) return { error: plain((body as { error?: unknown } | null)?.error ?? `status ${r.status}`), lines: [] };
234235 if (ask.path === "/care/probes/calibrate") return { error: null, lines: ["probe captured", ...probeLines({ view: body }).map(plain)] };
236 if (ask.path === "/admin/night") return { error: null, lines: [plain((body as { said?: unknown } | null)?.said ?? "done")] };
235237 if (write) return { error: null, lines: [plain(ask.path === "/care/dosing/pump" ? pumpSaid(body) : ask.path.startsWith("/care/dosing") ? "dosing: done" : "care saved")] };
236238 return { error: null, lines: (ask.path === "/care/learn.json" ? learnLines(body) : ask.path === "/care/dosing" ? dosingLines(body) : ask.path === "/care/probes" ? probeLines(body) : ask.path === "/care/dosing/pumps" ? pumpLines(body) : careLines(body)).map(plain) };
237239 } catch (err) {
@@ -587,7 +589,7 @@ export function whoami(room: Room): string {
587589 /** The terminal holds no session to end: the forge signs who is at the keyboard afresh on every connection. */
588590 export const SIGNOUT_NOTE = "the terminal keeps no session: you are whoever your SSH key signs in as, each time you connect; /quit to leave, and connect with another key to be someone else";
589591
590 export const HELP = "type to chat · /join /leave /ready /extend · /stop /resume (driver, owner) · /end [who] /table here|touch 1|2|3|<mm> /zero <joint> [deg] /align [joint] (owner) · /mute <who> /unmute <who> (owner, moderator) · /delete <who|#id> (owner, moderator) · /report <who|#id> · /reports /dismiss #<id> (owner, moderator) · /removed /restore #<id> /visitors (owner) · /close [note] /open (owner) · /health /precision /trace <session> /data [hour|day|week|all|#<turn>] /versions (owner, moderator) · /servos /tune [<joint> <P>] /grow [light|pump …] (owner; /grow confirm yes|no answers a uhubctl try-out) /grow log|planted <date>|note <text> · /care [log|did|note|problem|done|snooze|delete …] · /probes [calibrate <ph7|ph4|ec1413> [tempC]] (calibrate: owner) · /stations [set <json>] (set: owner) · /watchdog (owner) · /machine [off] · /who /whoami · /quit";
592 export const HELP = "type to chat · /join /leave /ready /extend · /stop /resume (driver, owner) · /end [who] /table here|touch 1|2|3|<mm> /zero <joint> [deg] /align [joint] (owner) · /mute <who> /unmute <who> (owner, moderator) · /delete <who|#id> (owner, moderator) · /report <who|#id> · /reports /dismiss #<id> (owner, moderator) · /removed /restore #<id> /visitors (owner) · /close [note] /open /goodnight [note] /wake (owner) · /health /precision /trace <session> /data [hour|day|week|all|#<turn>] /versions (owner, moderator) · /servos /tune [<joint> <P>] /grow [light|pump …] (owner; /grow confirm yes|no answers a uhubctl try-out) /grow log|planted <date>|note <text> · /care [log|did|note|checkin|problem|done|snooze|delete …] · /probes [calibrate <ph7|ph4|ec1413> [tempC]] (calibrate: owner) · /stations [set <json>] (set: owner) · /watchdog (owner) · /machine [off] · /who /whoami · /quit";
591593
592594 /** The Machine feed's socket beside the lobby's: same app, same port. */
593595 export const machineUrl = (live: string): string => live.replace(/\/live$/, "/machine");
@@ -760,6 +762,10 @@ export function typed(room: Room, input: string): Typed {
760762 const r = careCommand([arg, ...rest].filter(Boolean), line.replace(/^\/\S+\s+\S+\s*/, "").trim());
761763 return "method" in r ? { care: r } : r;
762764 }
765 if (/^(wake|goodnight)$/i.test(verb)) {
766 const note = line.slice(1 + verb.length).trim();
767 return /^wake$/i.test(verb) && note ? { print: "usage: /wake" } : { care: { method: "POST", path: "/admin/night", body: { action: verb.toLowerCase(), ...(note ? { note } : {}) }, timeoutMs: 12_000 } };
768 }
763769 if (/^probes$/i.test(verb)) {
764770 const r = probesCommand([arg, ...rest].filter(Boolean));
765771 return "method" in r ? { care: r } : r;

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs