Public: anyone can read this; the crew can change it.
Migrated from github.com/Trevato/SO-ARM100
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
Check · Review · Land done · Live — Landed; nothing deploys from this branch, so this is the end of its path.
smith/arm:smith/632-night-mode-the-saplings-are-sleeping-ani → main 9 commits fork 4 threads · 4 unresolved
Software/so101/README.md +4
| @@ -36,6 +36,10 @@ Closed means: every turn ends (the owner's too) and the line is emptied, nobody | ||
| 36 | 36 | |
| 37 | 37 | 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). |
| 38 | 38 | |
| 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 | ||
| 39 | 43 | The switch also works outside the app: |
| 40 | 44 | |
| 41 | 45 | - **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 { | ||
| 350 | 350 | <label>Back around<small>optional; your local time; shown, it does not reopen by itself</small><input name="reopenAt" type="datetime-local"></label> |
| 351 | 351 | </div> |
| 352 | 352 | <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>`; | |
| 354 | 354 | } |
| 355 | 355 | |
| 356 | 356 | /** 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"; | ||
| 5 | 5 | import type { Lobby } from "./lobby.ts"; |
| 6 | 6 | import { AdminError, changeFromForm } from "./admin.ts"; |
| 7 | 7 | import { openChangeFromForm } from "./opening.ts"; |
| 8 | import { nightSwitch, WAKE_LINES, type WakeCode } from "./night-switch.ts"; | |
| 8 | 9 | import { sameOrigin } from "./relay.ts"; |
| 9 | 10 | import { adminCsp, DONE, renderAdmin, renderRefusal, type Done } from "./admin-page.ts"; |
| 10 | 11 | import type { Versions } from "./pi-deploy.ts"; |
| @@ -109,10 +110,18 @@ export async function handleAdmin(lobby: Lobby, req: IncomingMessage, res: Serve | ||
| 109 | 110 | res.setHeader("connection", "close"); |
| 110 | 111 | return show(413, null, "that form is too large"); |
| 111 | 112 | } |
| 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")); | |
| 113 | 116 | const act = form.get("action") ?? ""; |
| 114 | 117 | 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 | }; | |
| 115 | 123 | let done: Done; |
| 124 | let wakeCode: WakeCode | null = null; | |
| 116 | 125 | try { |
| 117 | 126 | switch (form.get("action")) { |
| 118 | 127 | case "save": |
| @@ -137,6 +146,11 @@ export async function handleAdmin(lobby: Lobby, req: IncomingMessage, res: Serve | ||
| 137 | 146 | lobby.setOpen(who, openChangeFromForm(form, act === "open", Date.now())); |
| 138 | 147 | done = act === "open" ? "opened" : "closed"; |
| 139 | 148 | 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; | |
| 140 | 154 | case "end": |
| 141 | 155 | lobby.endTurn(who); |
| 142 | 156 | done = "ended"; |
| @@ -185,12 +199,13 @@ export async function handleAdmin(lobby: Lobby, req: IncomingMessage, res: Serve | ||
| 185 | 199 | throw new AdminError("no such action"); |
| 186 | 200 | } |
| 187 | 201 | } 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); | |
| 189 | 203 | throw err; |
| 190 | 204 | } |
| 205 | if (terminal) return reply(200, { ok: true, said: wakeCode ? WAKE_LINES[wakeCode] : "The room is closed; the grow light keeps its schedule." }); | |
| 191 | 206 | // The resting card's Open now goes back to the arm itself; every other form comes back here. |
| 192 | 207 | 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" }); | |
| 194 | 209 | res.end(); |
| 195 | 210 | } |
| 196 | 211 | |
| @@ -235,3 +250,13 @@ export async function handleLift(lobby: Lobby, req: IncomingMessage, res: Server | ||
| 235 | 250 | res.writeHead(303, { location: "/admin?done=lifted", "cache-control": "no-store" }); |
| 236 | 251 | res.end(); |
| 237 | 252 | } |
| 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> | |
| 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 | ||
| 45 | 45 | export class LogError extends Error {} |
| 46 | 46 | |
| 47 | 47 | /** 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] }; | |
| 49 | 49 | |
| 50 | 50 | const text = (v: unknown, what: string, max: number, required = true): string | undefined => { |
| 51 | 51 | 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 | ||
| 21 | 21 | if (r.ok) document.querySelector("main").innerHTML = new DOMParser().parseFromString(await r.text(), "text/html").querySelector("main").innerHTML; |
| 22 | 22 | });`; |
| 23 | 23 | |
| 24 | const STYLE = ` | |
| 24 | export const STYLE = ` | |
| 25 | 25 | :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; } |
| 26 | 26 | @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; } } |
| 27 | 27 | 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 | ||
| 150 | 150 | }) |
| 151 | 151 | .join(""); |
| 152 | 152 | |
| 153 | const kinds: Measure[] = ["ph", "ec", "waterTemp", "airTemp", "lightHours"]; | |
| 153 | const kinds: Measure[] = ["ph", "ec", "waterTemp", "airTemp", "lightHours", "sprouts"]; | |
| 154 | 154 | const charts = kinds.map((k) => chart(k, snap, cur.stage.bands[k], now, cite(cur.stage.sources[k]))).join(""); |
| 155 | 155 | |
| 156 | 156 | let lastStage = ""; |
Software/so101/src/care-profile.ts +1 −1
| @@ -6,7 +6,7 @@ | ||
| 6 | 6 | import { existsSync, readdirSync, readFileSync } from "node:fs"; |
| 7 | 7 | import { join } from "node:path"; |
| 8 | 8 | |
| 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; | |
| 10 | 10 | export type Measure = (typeof MEASURES)[number]; |
| 11 | 11 | |
| 12 | 12 | export type Band = readonly [number, number]; |
Software/so101/src/care-routes.ts +23
| @@ -5,6 +5,7 @@ import { boltingWatch, currentStage, health, reminders, snapshot, stageHistory, | ||
| 5 | 5 | import { LogError, type CareLog } from "./care-log.ts"; |
| 6 | 6 | import { learnData, renderLearn } from "./care-sources.ts"; |
| 7 | 7 | import { renderCare } from "./care-page.ts"; |
| 8 | import { checkinEntry, renderCheckin } from "./care-checkin.ts"; | |
| 8 | 9 | import { calibrateProbe, renderProbes, type ProbeCache } from "./care-probes.ts"; |
| 9 | 10 | import type { Profile } from "./care-profile.ts"; |
| 10 | 11 | import type { Doser } from "./doser.ts"; |
| @@ -102,6 +103,28 @@ export async function handleCare(req: IncomingMessage, res: ServerResponse, path | ||
| 102 | 103 | return true; |
| 103 | 104 | } |
| 104 | 105 | |
| 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 | ||
| 105 | 128 | const entry = /^\/care\/log(?:\/(\d+)(\/delete)?)?$/.exec(pathname); |
| 106 | 129 | const reminder = pathname === "/care/reminder"; |
| 107 | 130 | 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 }> = { | ||
| 95 | 95 | airTemp: { name: "Air temp", unit: " °C" }, |
| 96 | 96 | level: { name: "Level", unit: " %" }, |
| 97 | 97 | lightHours: { name: "Light", unit: " h" }, |
| 98 | sprouts: { name: "Sprouts", unit: "" }, | |
| 98 | 99 | }; |
| 99 | 100 | |
| 100 | 101 | export const ago = (ms: number): string => { |
Software/so101/src/care-terminal.ts +7 −1
| @@ -17,7 +17,7 @@ export interface CareAsk { | ||
| 17 | 17 | timeoutMs?: number; |
| 18 | 18 | } |
| 19 | 19 | |
| 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)"; | |
| 21 | 21 | |
| 22 | 22 | export const PROBES_USAGE = "usage: /probes · /probes calibrate <ph7|ph4|ec1413> [solution tempC] (owner)"; |
| 23 | 23 | |
| @@ -47,6 +47,12 @@ export function careCommand(words: string[], tail: string): CareAsk | { print: s | ||
| 47 | 47 | return post("/care/log", { type: "action", what: a, ...(b ? { amount: num(b) } : {}) }); |
| 48 | 48 | case "note": |
| 49 | 49 | 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 | } | |
| 50 | 56 | case "problem": { |
| 51 | 57 | const kind = PROBLEMS.find((p) => tail === p || tail.startsWith(`${p} `)); |
| 52 | 58 | 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> { | ||
| 552 | 552 | /** Why the arm rests right now (src/opening.ts), or null while it is open; never resting without a lobby. */ |
| 553 | 553 | const restingNow = (): Resting | null => lobby?.resting() ?? null; |
| 554 | 554 | /** 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) => { | |
| 556 | 568 | const who = lobby?.who(req); |
| 569 | const sun = rest.reason === "closed" ? await sunriseNow() : null; | |
| 557 | 570 | 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)); | |
| 559 | 572 | }; |
| 560 | 573 | /** |
| 561 | 574 | * 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> { | ||
| 1283 | 1296 | if (cameraAdmin) return await administer(req, res, cameraAdmin[1] === undefined ? null : Number(cameraAdmin[1]), cameraAdmin[2] !== undefined); |
| 1284 | 1297 | if (CAMERA_PROFILE.test(url.pathname)) return json(res, 404, { error: "no such route" }); |
| 1285 | 1298 | 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); | |
| 1287 | 1300 | if (serveStory(req, res, url.pathname, { cameras: () => cameras.list(), numbers: storyNumbers, signedIn: lobby && !lobby.local ? (r) => lobby.signedIn(r) : undefined })) return; |
| 1288 | 1301 | if (url.pathname === "/admin/versions") return versionsRead(req, res); |
| 1289 | 1302 | 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"; | ||
| 103 | 103 | function ownerRow(viewer: Viewer, r: Resting): string { |
| 104 | 104 | if (viewer !== "owner") return ""; |
| 105 | 105 | 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>`; | |
| 107 | 108 | } |
| 108 | 109 | |
| 109 | 110 | /** |
| @@ -114,10 +115,11 @@ const identityRow = (viewer: Viewer, signedIn: SignedIn | null): string => | ||
| 114 | 115 | viewer !== "anonymous" && !signedIn ? "" : renderSignedIn(viewer === "anonymous" ? null : signedIn, "/", `<span>Owner? <a href="${OWNER_SIGNIN}">Sign in to open</a></span>`); |
| 115 | 116 | |
| 116 | 117 | /** 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 { | |
| 118 | 119 | const title = restingWords(r); |
| 119 | 120 | 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."); |
| 120 | 121 | const when = [ |
| 122 | r.reason === "closed" && sunrise ? `<p>Back at sunrise: about <b>${esc(sunrise)}</b></p>` : "", | |
| 121 | 123 | r.reopenAt !== null ? `<p>Back around <b>${time(r.reopenAt)}</b></p>` : "", |
| 122 | 124 | r.lastOpenAt !== null ? `<p>Last open ${time(r.lastOpenAt)}</p>` : "", |
| 123 | 125 | ].join(""); |
Software/so101/src/terminal.ts +7 −1
| @@ -36,6 +36,7 @@ | ||
| 36 | 36 | * /health owner or moderator: each open page's link as it last reported it (GET /admin/health, the page's Admin -> Health) |
| 37 | 37 | * /stations [set <json>] the workshop's stations (GET /stations); the owner replaces them with a JSON list (PUT /admin/stations) |
| 38 | 38 | * /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) | |
| 39 | 40 | * /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) |
| 40 | 41 | * /versions owner or moderator: the app's and the Pi's commits and the last sync (GET /admin/versions) |
| 41 | 42 | * /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 { | ||
| 232 | 233 | const body = (await r.json().catch(() => null)) as unknown; |
| 233 | 234 | if (!r.ok) return { error: plain((body as { error?: unknown } | null)?.error ?? `status ${r.status}`), lines: [] }; |
| 234 | 235 | 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")] }; | |
| 235 | 237 | if (write) return { error: null, lines: [plain(ask.path === "/care/dosing/pump" ? pumpSaid(body) : ask.path.startsWith("/care/dosing") ? "dosing: done" : "care saved")] }; |
| 236 | 238 | 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) }; |
| 237 | 239 | } catch (err) { |
| @@ -587,7 +589,7 @@ export function whoami(room: Room): string { | ||
| 587 | 589 | /** The terminal holds no session to end: the forge signs who is at the keyboard afresh on every connection. */ |
| 588 | 590 | 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"; |
| 589 | 591 | |
| 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"; | |
| 591 | 593 | |
| 592 | 594 | /** The Machine feed's socket beside the lobby's: same app, same port. */ |
| 593 | 595 | export const machineUrl = (live: string): string => live.replace(/\/live$/, "/machine"); |
| @@ -760,6 +762,10 @@ export function typed(room: Room, input: string): Typed { | ||
| 760 | 762 | const r = careCommand([arg, ...rest].filter(Boolean), line.replace(/^\/\S+\s+\S+\s*/, "").trim()); |
| 761 | 763 | return "method" in r ? { care: r } : r; |
| 762 | 764 | } |
| 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 | } | |
| 763 | 769 | if (/^probes$/i.test(verb)) { |
| 764 | 770 | const r = probesCommand([arg, ...rest].filter(Boolean)); |
| 765 | 771 | return "method" in r ? { care: r } : r; |
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.