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

Migrated from github.com/Trevato/SO-ARM100

workbenchthis machine

Grow box: a still every 10 min, a scrubbable timelapse, a grow log and a canopy chart #601

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/583-grow-box-a-still-every-10-min-a-scrubbab → main 2 commits fork

  • Merged. smith/583-grow-box-a-still-every-10-min-a-scrubbab was merged into main as d623239.

Review

Sign in to review this change.
12 files changed +950 −11
Software/so101/README.md +6
@@ -89,6 +89,12 @@ The app is a workshop: one Pi, several **stations**, each with its cameras and c
8989 - **The config** is `so101/stations.json` under `FORGE_APP_DATA`: a list of `{id, name, kind: "arm"|"grow", cameras: [camera ids], controls: [{label, href}]}` (src/stations.ts). Without a file it is the arm (camera 1) and the grow box (camera 2, from the camera list): edit it to match the cameras. With only an arm in it, `/` is the arm's page as before.
9090 - **Three doors.** The page `/admin/stations/edit` (owner; a raw JSON box that needs JavaScript, so it is awkward on a phone: prefer a laptop or the terminal), `GET /stations` (anyone: each station's `present`, why not, and its camera ids and names) and `PUT /admin/stations` (owner; at most 16 KB, 400 on bad JSON or a bad station), and in the terminal `/stations` to list and `/stations set <json list>` (owner) to replace them.
9191
92 ## The grow box's stills, timelapse and log (on the Grow box card)
93
94 The hosted app takes a still from the grow station's first camera every 10 minutes (`GROW_STILL_MINUTES`, 1 to 1440) and keeps it under `FORGE_APP_DATA/grow-stills`: 30 days, and no more than `GROW_STILLS_CAP_MB` (500) in all, the oldest going first. A still taken while the light is off is flagged dark and left out of the timelapse. The card plays today, the last 7 days or all of it, scrubbable, built in the browser from the stills (nothing is encoded). Under it: day N since the planted date, the owner's notes, light hours per day from the light's transitions, and "canopy (green area)" per day, the share of green pixels inside the box drawn in Tune. That is a measure of green area, not of health; the owner's browser works it out for stills that have none and sends it back.
95
96 `GET /grow/stills?from=&to=&after=&limit=&dark=1` pages stills (`{stills, next, count, bytes, capBytes}`), `GET /grow/stills/<ms>.jpg` one still, `GET /grow/log` the log and chart numbers; `POST /grow/log {planted?, note?}` and `POST /grow/canopy {readings}` are the owner's, and same-origin only. The chart is empty until the owner has opened the card once, since only that browser works out the readings. Without JavaScript the card shows the latest still and a link to `/grow/log`. From the terminal: `/grow log` reads the log, `/grow planted <YYYY-MM-DD>|none` and `/grow note <text>` (owner) write it through the same routes; the card shows a note on its next load.
97
9298 ## The story page: /story
9399
94100 `/story` on the hosted app is the arm explained for anyone: the hook, how it works (with a diagram of browser → forge → Pi → servos, and what latency, the queue, STOP and the safety zone mean), how to build your own (the [LeRobot SO-101 docs](https://huggingface.co/docs/lerobot/so101), this repository, `bun so101 setup`) and what the crew built. Anyone may read it; it is linked from the Chat tab's footer (hidden while you drive) and the Queue tab.
Software/so101/bench/fake-serve.ts +4 −1
@@ -26,6 +26,8 @@ import { Moderator, modelFromEnv } from "../src/moderator.ts";
2626 import { parseRemote, parseVideo, startRelay, type Remote } from "../src/relay.ts";
2727 import { piSyncFromEnv } from "../src/pi-deploy.ts";
2828 import { Facts } from "../src/facts.ts";
29 import { GrowLog } from "../src/grow-log.ts";
30 import { GrowStills, parseEvery } from "../src/grow-stills.ts";
2931 import { startServer } from "../src/server.ts";
3032
3133 const fake = new FakeBus([1, 2, 3, 4, 5, 6]);
@@ -74,6 +76,7 @@ try {
7476 }
7577 const facts = new Facts({ file: join(dir, "facts.sqlite") });
7678 const piSync = piSyncFromEnv(process.env, remote?.http ?? null, remote?.host ?? null, () => lobby.turns.snapshot().driver === null, dir);
77 const relay = await startRelay({ stations: new StationHub(join(dir, "stations.json")), facts, piSync: piSync ?? undefined, host, port, simulation: `http://127.0.0.1:${simulation.port}`, remote, problem, lobby, cameras, leaders, video, summaryEveryMs: 60_000, holdFile: join(dir, "hold.json"), baselineFile: join(dir, "servo-factory.json") });
79 const grow = { stills: new GrowStills(join(dir, "grow-stills"), { capBytes: Number(process.env.GROW_STILLS_CAP_MB) * 1e6 || undefined }), log: new GrowLog(join(dir, "grow-log.json")), everyMs: parseEvery(process.env.GROW_STILL_MINUTES) };
80 const relay = await startRelay({ grow, stations: new StationHub(join(dir, "stations.json")), facts, piSync: piSync ?? undefined, host, port, simulation: `http://127.0.0.1:${simulation.port}`, remote, problem, lobby, cameras, leaders, video, summaryEveryMs: 60_000, holdFile: join(dir, "hold.json"), baselineFile: join(dir, "servo-factory.json") });
7881 const { mode, host: armHost } = relay.link();
7982 console.log(`arm on http://${host}:${relay.port} (${mode === "simulated" ? "simulated" : `arm ${armHost}: ${mode}`}; data in ${dir}${local ? "; local: every visitor drives as the owner" : ""})`);
Software/so101/src/grow-card.test.ts added +148
@@ -0,0 +1,148 @@
1 import { afterEach, expect, test } from "bun:test";
2 import { mkdtempSync, rmSync } from "node:fs";
3 import { createServer, type Server } from "node:http";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { CameraHub } from "./cameras.ts";
7 import { GrowLog } from "./grow-log.ts";
8 import { GrowStills } from "./grow-stills.ts";
9 import { startRelay, type RunningRelay } from "./relay.ts";
10 import { StationHub } from "./stations.ts";
11
12 let relay: RunningRelay | undefined;
13 let camera: Server | undefined;
14 let dir = "";
15 afterEach(async () => {
16 await relay?.close();
17 camera?.close();
18 rmSync(dir, { recursive: true, force: true });
19 relay = camera = undefined;
20 });
21 const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
22
23 test("the relay takes stills, pages them, and the card carries the timelapse and the log", async () => {
24 dir = mkdtempSync(join(tmpdir(), "so101-growcard-"));
25 camera = createServer((req, res) => {
26 res.writeHead(200, { "content-type": "image/jpeg" });
27 res.end(Buffer.from([0xff, 0xd8, 1, 2, 3, 0xff, 0xd9]));
28 });
29 await new Promise<void>((r) => camera!.listen(0, "127.0.0.1", r));
30 const port = (camera.address() as { port: number }).port;
31 const stations = new StationHub();
32 stations.replace([{ id: "grow", name: "Grow box", kind: "grow", cameras: [1], controls: [] }]);
33 const stills = new GrowStills(join(dir, "stills"));
34 relay = await startRelay({ port: 0, simulation: "http://127.0.0.1:1", cameras: new CameraHub({ seed: [{ name: "Grow cam", source: `http://127.0.0.1:${port}/stream` }] }), stations, grow: { stills, log: new GrowLog(join(dir, "log.json")), everyMs: 40 }, env: {} });
35 const base = `http://127.0.0.1:${relay.port}`;
36 for (const end = Date.now() + 3000; stills.usage().count < 3 && Date.now() < end; ) await sleep(20);
37
38 const first = (await (await fetch(`${base}/grow/stills?limit=2`)).json()) as { stills: { at: number }[]; next: number | null; count: number };
39 expect(first.stills).toHaveLength(2);
40 expect(first.next).toBe(first.stills[1]!.at);
41 const rest = (await (await fetch(`${base}/grow/stills?limit=2&after=${first.next}`)).json()) as { stills: { at: number }[] };
42 expect(rest.stills.length).toBeGreaterThan(0);
43 expect(rest.stills[0]!.at).toBeGreaterThan(first.next!);
44 const one = await fetch(`${base}/grow/stills/${first.stills[0]!.at}.jpg`);
45 expect(one.headers.get("content-type")).toBe("image/jpeg");
46 expect((await one.arrayBuffer()).byteLength).toBe(7);
47 expect((await fetch(`${base}/grow/stills/1234567890.jpg`)).status).toBe(404);
48
49 expect((await fetch(`${base}/grow/log`).then((r) => r.json())) as { stills: { count: number } }).toMatchObject({ planted: null, notes: [], lightHours: [] });
50 const post = (path: string, body: unknown) => fetch(`${base}${path}`, { method: "POST", body: JSON.stringify(body) });
51 expect((await post("/grow/log", { planted: "2026-10-01", note: "thinned" })).status).toBe(200);
52 expect((await post("/grow/log", { planted: "soon" })).status).toBe(400);
53 expect((await post("/grow/canopy", { readings: [{ at: first.stills[0]!.at, green: 0.3 }] })).status).toBe(200);
54 const log = (await (await fetch(`${base}/grow/log`)).json()) as { planted: string; notes: { text: string }[]; canopy: { green: number }[] };
55 expect(log.notes[0]!.text).toBe("thinned");
56 expect(log.canopy[0]!.green).toBe(0.3);
57
58 const home = await (await fetch(`${base}/`)).text();
59 expect(home).toContain("data-grow");
60 expect(home).toMatch(/\/ui\/[0-9a-f]{12}\/grow-timelapse\.js/);
61 });
62
63 import { Lobby } from "./lobby.ts";
64 import { sign } from "./identity.ts";
65 import { Door, growLogLines, growLogReader } from "./terminal.ts";
66
67 /** A relay with a grow station whose camera answers `snapshot`, and an arm server that answers /grow as `light`. */
68 async function growRelay(snapshot: () => Response | Promise<Response>, light: unknown, extra: Record<string, unknown> = {}) {
69 dir = mkdtempSync(join(tmpdir(), "so101-growcard-"));
70 camera = createServer(async (req, res) => {
71 if (req.url?.startsWith("/grow")) {
72 res.writeHead(200, { "content-type": "application/json" });
73 return void res.end(JSON.stringify(light));
74 }
75 const r = await snapshot();
76 res.writeHead(r.status, { "content-type": "image/jpeg" });
77 res.end(Buffer.from(await r.arrayBuffer()));
78 });
79 await new Promise<void>((r) => camera!.listen(0, "127.0.0.1", r));
80 const port = (camera.address() as { port: number }).port;
81 const stations = new StationHub();
82 stations.replace([{ id: "grow", name: "Grow box", kind: "grow", cameras: [1], controls: [] }]);
83 const stills = new GrowStills(join(dir, "stills"));
84 relay = await startRelay({ port: 0, simulation: `http://127.0.0.1:${port}`, cameras: new CameraHub({ seed: [{ name: "Grow cam", source: `http://127.0.0.1:${port}/stream` }] }), stations, grow: { stills, log: new GrowLog(join(dir, "log.json")), everyMs: 40 }, env: {}, ...extra });
85 return { stills, base: `http://127.0.0.1:${relay.port}` };
86 }
87 const JPEG = () => new Response(Buffer.from([0xff, 0xd8, 1, 2, 3, 0xff, 0xd9]));
88 const lightView = (on: boolean) => ({ light: { configured: true, text: on ? "On until 22:00" : "Off until 06:00", on, zone: "UTC", schedule: { onAt: "06:00", hours: 16 }, log: [] }, pump: null });
89
90 test("a still taken while the light is off is flagged dark and left out of the timelapse", async () => {
91 const { stills, base } = await growRelay(JPEG, lightView(false));
92 for (const end = Date.now() + 3000; stills.usage().count < 2 && Date.now() < end; ) await sleep(20);
93 expect(stills.page({ dark: true }).stills.every((s) => s.dark)).toBe(true);
94 expect(((await (await fetch(`${base}/grow/stills`)).json()) as { stills: unknown[] }).stills).toEqual([]);
95 expect(((await (await fetch(`${base}/grow/stills?dark=1`)).json()) as { stills: unknown[] }).stills.length).toBeGreaterThan(0);
96 });
97
98 test("a snapshot that is not ok, or over 8 MB, is not kept", async () => {
99 let n = 0;
100 const { stills } = await growRelay(() => (n++ % 2 ? new Response(Buffer.alloc(8_000_001, 0xff), { status: 200 }) : new Response("nope", { status: 503 })), lightView(true));
101 await sleep(400);
102 expect(n).toBeGreaterThan(2);
103 expect(stills.usage().count).toBe(0);
104 });
105
106 const KEY = "k".repeat(32);
107 const as = (name: string, role: "admin" | "user") => {
108 const identity = `id-${name}|${name}|${role}|${Date.now() + 600_000}`;
109 return { "x-forge-identity": identity, "x-forge-signature": sign(identity, KEY) };
110 };
111
112 test("only the owner writes the grow log, and only from this page", async () => {
113 const lobby = new Lobby({ identityKey: KEY, adminFile: join(tmpdir(), `growcard-admin-${process.pid}.json`), openFile: join(tmpdir(), `growcard-open-${process.pid}.json`) });
114 const { base } = await growRelay(JPEG, lightView(true), { lobby });
115 const post = (path: string, headers: Record<string, string>, body: unknown) => fetch(`${base}${path}`, { method: "POST", headers, body: JSON.stringify(body) });
116 const guest = as("guest", "user");
117 expect((await post("/grow/log", guest, { note: "x" })).status).toBe(403);
118 expect((await post("/grow/canopy", guest, { readings: [] })).status).toBe(403);
119 expect((await post("/grow/log", { "sec-fetch-site": "cross-site", ...as("trevato", "admin") }, { note: "x" })).status).toBe(403);
120 expect((await post("/grow/canopy", { "sec-fetch-site": "cross-site", ...as("trevato", "admin") }, { readings: [] })).status).toBe(403);
121 expect((await post("/grow/log", as("trevato", "admin"), { note: "watered" })).status).toBe(200);
122 const read = await fetch(`${base}/grow/log`, { headers: guest });
123 expect(read.status).toBe(200);
124 const home = await (await fetch(`${base}/`, { headers: guest })).text();
125 expect(home).toContain('data-owner="0"');
126 });
127
128 test("the terminal reads the log and the owner writes it; the door prints refusals", async () => {
129 const seen: string[] = [];
130 const fetcher = (async (url: string, init?: RequestInit) => {
131 seen.push(`${init?.method ?? "GET"} ${url} ${init?.body ?? ""}`);
132 return init?.method === "POST" ? Response.json({ error: "only the owner may change the grow log" }, { status: 403 }) : Response.json({ planted: "2026-10-01", day: 5, stills: { count: 3, bytes: 3e6, capBytes: 5e8 }, everyMs: 600000, lightHours: [{ day: "2026-10-04", hours: 16 }], canopy: [{ day: "2026-10-04", green: 0.25 }], notes: [{ at: Date.UTC(2026, 9, 3), text: "thinned" }] });
133 }) as unknown as typeof fetch;
134 expect(growLogLines({ planted: null })[0]).toContain("Not planted yet");
135 const printed: string[] = [];
136 const door = new Door("ws://127.0.0.1:1/live", {}, (l) => printed.push(l), null, null, null, null, null, null, null, null, null, null, null, growLogReader({ PORT: "8102" }, fetcher));
137 door.input("/grow log");
138 door.input("/grow planted 2026-10-02");
139 door.input("/grow note watered the tray");
140 door.input("/grow planted soon");
141 await sleep(50);
142 expect(seen).toEqual(["GET http://127.0.0.1:8102/grow/log ", 'POST http://127.0.0.1:8102/grow/log {"planted":"2026-10-02"}', 'POST http://127.0.0.1:8102/grow/log {"note":"watered the tray"}']);
143 expect(printed).toContain("Day 5, planted 2026-10-01");
144 expect(printed).toContain("Canopy (green area): 10-04 25%");
145 expect(printed).toContain("2026-10-03 thinned");
146 expect(printed).toContain("error: only the owner may change the grow log");
147 expect(printed).toContain("usage: /grow log · /grow planted <YYYY-MM-DD>|none · /grow note <text>");
148 });
Software/so101/src/grow-log.ts added +128
@@ -0,0 +1,128 @@
1 /**
2 * The grow log: the planted date and the owner's notes, kept in one small file; and the figures that come from elsewhere,
3 * the light hours of each day from the light's transitions and the canopy of each day from the stills' readings.
4 */
5 import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
6 import { dirname } from "node:path";
7 import { DAY_MS } from "./grow-stills.ts";
8 import { offsetAt } from "./light-schedule.ts";
9
10 export class GrowLogError extends Error {}
11
12 export interface Note {
13 at: number;
14 text: string;
15 }
16 const MAX_NOTES = 200;
17 const MAX_NOTE = 280;
18
19 /** The zone's calendar day of an instant, "YYYY-MM-DD". */
20 export function dayOf(ms: number, zone: string): string {
21 return new Date(ms + offsetAt(ms, zone)).toISOString().slice(0, 10);
22 }
23
24 /** Day N of the grow: the planting day is day 1. Null before it is planted or when it is in the future. */
25 export function dayNumber(planted: string | null, now: number, zone: string): number | null {
26 if (!planted) return null;
27 const n = Math.round((Date.parse(`${dayOf(now, zone)}T00:00Z`) - Date.parse(`${planted}T00:00Z`)) / DAY_MS) + 1;
28 return n >= 1 ? n : null;
29 }
30
31 /** Hours of light on each calendar day of the last `days`, from the light's transitions. */
32 export function lightHours(log: { at: number; on: boolean }[], now: number, zone: string, days = 14): { day: string; hours: number }[] {
33 const sorted = [...log].sort((a, b) => a.at - b.at);
34 const first = now - days * DAY_MS;
35 const ms = new Map<string, number>();
36 const span = (from: number, to: number) => {
37 // Split at each local midnight so a window across one counts toward both days.
38 for (let t = Math.max(from, first); t < to; ) {
39 const day = dayOf(t, zone);
40 // Binary search the first instant of the next day.
41 let lo = t + 1;
42 let hi = t + 2 * DAY_MS;
43 while (lo < hi) {
44 const mid = Math.floor((lo + hi) / 2);
45 if (dayOf(mid, zone) === day) lo = mid + 1;
46 else hi = mid;
47 }
48 const end = Math.min(lo, to);
49 ms.set(day, (ms.get(day) ?? 0) + (end - t));
50 t = end;
51 }
52 };
53 let on: number | null = null;
54 for (const e of sorted) {
55 if (e.on && on === null) on = e.at;
56 else if (!e.on && on !== null) (span(on, e.at), (on = null));
57 }
58 if (on !== null) span(on, now);
59 // A day with no transition on it before the first logged one is unknown, so only days from the first transition on are listed.
60 const start = sorted[0] ? dayOf(sorted[0].at, zone) : null;
61 const out: { day: string; hours: number }[] = [];
62 for (let k = days; k >= 0; k--) {
63 const day = dayOf(now - k * DAY_MS, zone);
64 if (start && day >= start) out.push({ day, hours: Math.round(((ms.get(day) ?? 0) / 3_600_000) * 10) / 10 });
65 }
66 return out;
67 }
68
69 /** The mean canopy reading of each calendar day, oldest first. */
70 export function canopyByDay(readings: { at: number; green: number }[], zone: string): { day: string; green: number; n: number }[] {
71 const sums = new Map<string, { sum: number; n: number }>();
72 for (const r of readings) {
73 const day = dayOf(r.at, zone);
74 const s = sums.get(day) ?? { sum: 0, n: 0 };
75 sums.set(day, { sum: s.sum + r.green, n: s.n + 1 });
76 }
77 return [...sums].sort(([a], [b]) => (a < b ? -1 : 1)).map(([day, s]) => ({ day, green: Math.round((s.sum / s.n) * 10_000) / 10_000, n: s.n }));
78 }
79
80 export class GrowLog {
81 private planted: string | null = null;
82 private notes: Note[] = [];
83
84 private readonly file?: string;
85
86 constructor(file?: string) {
87 this.file = file;
88 if (!file) return;
89 try {
90 const raw = JSON.parse(readFileSync(file, "utf8")) as { planted?: unknown; notes?: unknown };
91 this.planted = typeof raw.planted === "string" && /^\d{4}-\d{2}-\d{2}$/.test(raw.planted) ? raw.planted : null;
92 this.notes = (Array.isArray(raw.notes) ? raw.notes : []).flatMap((n: Note) => (typeof n?.at === "number" && typeof n.text === "string" ? [{ at: n.at, text: n.text.slice(0, MAX_NOTE) }] : [])).slice(-MAX_NOTES);
93 } catch {
94 /* none yet */
95 }
96 }
97
98 view(): { planted: string | null; notes: Note[] } {
99 return { planted: this.planted, notes: this.notes };
100 }
101
102 /** The owner's change: a planted date (or null to clear it) and/or one note. Throws GrowLogError naming what is wrong. */
103 change(input: unknown, now: number): void {
104 if (typeof input !== "object" || input === null || Array.isArray(input)) throw new GrowLogError("send {planted?, note?}");
105 const { planted, note, ...rest } = input as Record<string, unknown>;
106 if (Object.keys(rest)[0]) throw new GrowLogError(`no field "${Object.keys(rest)[0]!.slice(0, 40)}"`);
107 let nextPlanted = this.planted;
108 if (planted !== undefined) {
109 if (planted !== null && (typeof planted !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(planted) || Number.isNaN(Date.parse(`${planted}T00:00Z`)) || new Date(`${planted}T00:00Z`).toISOString().slice(0, 10) !== planted)) throw new GrowLogError("planted is a date like 2026-10-12");
110 nextPlanted = planted;
111 }
112 let text = "";
113 if (note !== undefined) {
114 text = typeof note === "string" ? note.replace(/[\p{Cc}\p{Cf}]/gu, " ").trim() : "";
115 if (!text || text.length > MAX_NOTE) throw new GrowLogError(`a note is 1 to ${MAX_NOTE} characters`);
116 }
117 this.planted = nextPlanted;
118 if (text) this.notes = [...this.notes, { at: now, text }].slice(-MAX_NOTES);
119 this.save();
120 }
121
122 private save(): void {
123 if (!this.file) return;
124 mkdirSync(dirname(this.file), { recursive: true });
125 writeFileSync(`${this.file}.tmp`, JSON.stringify({ planted: this.planted, notes: this.notes }));
126 renameSync(`${this.file}.tmp`, this.file);
127 }
128 }
Software/so101/src/grow-stills.test.ts added +108
@@ -0,0 +1,108 @@
1 import { afterEach, describe, expect, test } from "bun:test";
2 import { mkdtempSync, readdirSync, rmSync } from "node:fs";
3 import { tmpdir } from "node:os";
4 import { join } from "node:path";
5 import { DAY_MS, GrowStills, parseEvery } from "./grow-stills.ts";
6 import { canopyByDay, dayNumber, GrowLog, GrowLogError, lightHours } from "./grow-log.ts";
7 import { greenFraction } from "./ui/grow-canopy.js";
8 import { chartPoints } from "./ui/grow-timelapse.js";
9
10 const dirs: string[] = [];
11 const tmp = () => {
12 const d = mkdtempSync(join(tmpdir(), "grow-"));
13 dirs.push(d);
14 return d;
15 };
16 afterEach(() => dirs.splice(0).forEach((d) => rmSync(d, { recursive: true, force: true })));
17 const jpeg = (n: number) => new Uint8Array([0xff, 0xd8, ...new Array(n - 4).fill(1), 0xff, 0xd9]);
18 const T0 = 1_760_000_000_000;
19
20 describe("retention", () => {
21 test("removes the oldest past the cap", () => {
22 const s = new GrowStills(tmp(), { capBytes: 350 });
23 for (let i = 0; i < 5; i++) s.add(jpeg(100), T0 + i * 600_000, false);
24 const left = s.page({}).stills.map((x) => x.at);
25 expect(left).toEqual([T0 + 2 * 600_000, T0 + 3 * 600_000, T0 + 4 * 600_000]);
26 expect(s.usage()).toEqual({ count: 3, bytes: 300, capBytes: 350 });
27 });
28 test("removes what is past 30 days, with its canopy reading", () => {
29 const s = new GrowStills(tmp());
30 s.add(jpeg(50), T0, false);
31 s.setGreen([{ at: T0, green: 0.4 }]);
32 s.add(jpeg(50), T0 + 31 * DAY_MS, false);
33 expect(s.page({}).stills.map((x) => x.at)).toEqual([T0 + 31 * DAY_MS]);
34 expect(s.readings(0)).toEqual([]);
35 });
36 test("refuses what is not a JPEG", () => {
37 expect(() => new GrowStills(tmp()).add(new Uint8Array([1, 2, 3, 4, 5]), T0, false)).toThrow();
38 });
39 });
40
41 describe("the timelapse endpoint's pages", () => {
42 test("pages oldest first, leaves dark ones out unless asked, and a green reading survives a restart", () => {
43 const dir = tmp();
44 const s = new GrowStills(dir);
45 for (let i = 0; i < 5; i++) s.add(jpeg(50), T0 + i * 600_000, i === 1);
46 const first = s.page({ limit: 2 });
47 expect(first.stills.map((x) => x.at)).toEqual([T0, T0 + 2 * 600_000]);
48 expect(first.next).toBe(T0 + 2 * 600_000);
49 const second = s.page({ limit: 2, after: first.next! });
50 expect(second.stills.map((x) => x.at)).toEqual([T0 + 3 * 600_000, T0 + 4 * 600_000]);
51 expect(second.next).toBeNull();
52 expect(s.page({ dark: true }).stills).toHaveLength(5);
53 expect(s.page({ from: T0 + 3 * 600_000, to: T0 + 4 * 600_000 }).stills).toHaveLength(1);
54 expect(s.setGreen([{ at: T0, green: 0.25 }, { at: 5, green: 0.5 }, { at: T0 + 600_000, green: 2 }])).toBe(1);
55 expect(new GrowStills(dir).page({}).stills[0]!.green).toBe(0.25);
56 expect(readdirSync(dir).filter((f) => f.endsWith(".tmp"))).toEqual([]);
57 });
58 });
59
60 describe("canopy", () => {
61 test("the green fraction of a fixture image, inside the region", () => {
62 // 4×2 frame: left half leaf green, right half soil brown.
63 const px = (r: number, g: number, b: number) => [r, g, b, 255];
64 const row = [...px(40, 160, 50), ...px(50, 170, 60), ...px(120, 80, 50), ...px(110, 70, 40)];
65 const data = new Uint8ClampedArray([...row, ...row]);
66 expect(greenFraction(data, 4, 2, null)).toBe(0.5);
67 expect(greenFraction(data, 4, 2, { x: 0, y: 0, w: 0.5, h: 1 })).toBe(1);
68 expect(greenFraction(data, 4, 2, { x: 0.5, y: 0, w: 0.5, h: 1 })).toBe(0);
69 });
70 test("grey and black are not green", () => {
71 expect(greenFraction(new Uint8ClampedArray([128, 128, 128, 255, 0, 8, 0, 255]), 2, 1, null)).toBe(0);
72 });
73 test("per day means and a chart", () => {
74 const days = canopyByDay([{ at: Date.UTC(2026, 9, 1, 8), green: 0.1 }, { at: Date.UTC(2026, 9, 1, 9), green: 0.3 }, { at: Date.UTC(2026, 9, 2, 8), green: 0.4 }], "UTC");
75 expect(days).toEqual([{ day: "2026-10-01", green: 0.2, n: 2 }, { day: "2026-10-02", green: 0.4, n: 1 }]);
76 expect(chartPoints(days, 100, 50, 0).split(" ")).toEqual(["0,25", "100,0"]);
77 expect(chartPoints([], 100, 50)).toBe("");
78 });
79 });
80
81 describe("the log", () => {
82 test("day N counts the planting day as 1", () => {
83 expect(dayNumber("2026-10-01", Date.UTC(2026, 9, 1, 12), "UTC")).toBe(1);
84 expect(dayNumber("2026-10-01", Date.UTC(2026, 9, 11, 12), "UTC")).toBe(11);
85 expect(dayNumber("2026-10-20", Date.UTC(2026, 9, 11, 12), "UTC")).toBeNull();
86 expect(dayNumber(null, T0, "UTC")).toBeNull();
87 });
88 test("light hours per day from transitions, across midnight", () => {
89 const at = (d: number, h: number) => Date.UTC(2026, 9, d, h);
90 const log = [{ at: at(1, 6), on: true }, { at: at(1, 22), on: false }, { at: at(2, 20), on: true }, { at: at(3, 4), on: false }];
91 const hours = lightHours(log, at(3, 12), "UTC", 3);
92 expect(hours).toEqual([{ day: "2026-10-01", hours: 16 }, { day: "2026-10-02", hours: 4 }, { day: "2026-10-03", hours: 4 }]);
93 });
94 test("keeps the planted date and notes, and refuses bad ones", () => {
95 const file = join(tmp(), "log.json");
96 const log = new GrowLog(file);
97 log.change({ planted: "2026-10-01", note: " watered " }, T0);
98 expect(new GrowLog(file).view()).toEqual({ planted: "2026-10-01", notes: [{ at: T0, text: "watered" }] });
99 for (const bad of [{ planted: "2026-13-40" }, { planted: "yesterday" }, { note: "" }, { note: "x".repeat(300) }, { other: 1 }, []]) expect(() => log.change(bad, T0)).toThrow(GrowLogError);
100 log.change({ planted: null }, T0);
101 expect(log.view().planted).toBeNull();
102 });
103 test("the interval setting is bounded", () => {
104 expect(parseEvery(undefined)).toBe(600_000);
105 expect(parseEvery("5")).toBe(300_000);
106 expect(parseEvery("0")).toBe(600_000);
107 });
108 });
Software/so101/src/grow-stills.ts added +165
@@ -0,0 +1,165 @@
1 /**
2 * The grow box's stills: one JPEG per capture, kept for 30 days under a byte cap (the oldest goes first), each flagged
3 * when the light was off. A still's name is its time, so the store is its own index and survives any restart.
4 * The canopy reading of each still (src/ui/grow-canopy.js, worked out in the browser) is kept beside them.
5 */
6 import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
7 import { join } from "node:path";
8
9 export const DAY_MS = 86_400_000;
10 export const KEEP_DAYS = 30;
11 /** A still every 10 minutes unless the owner says otherwise; never faster than every minute. */
12 export const DEFAULT_EVERY_MS = 600_000;
13 export const MIN_EVERY_MS = 60_000;
14 export const DEFAULT_CAP_BYTES = 500_000_000;
15 export const MAX_PAGE = 500;
16
17 export interface Still {
18 /** When it was taken, ms since the epoch. */
19 at: number;
20 /** The light was off: the picture is of a dark box. */
21 dark: boolean;
22 bytes: number;
23 /** Green-pixel fraction of the owner's region, 0 to 1, once the browser has worked it out. */
24 green?: number;
25 }
26
27 export interface Page {
28 stills: Still[];
29 /** Pass as `after` for the next page; null on the last. */
30 next: number | null;
31 }
32
33 const NAME = /^(\d{10,15})(d?)\.jpg$/;
34
35 /** "TEN_MIN" env text → ms between stills, within bounds; the default for anything else. */
36 export function parseEvery(text: string | undefined): number {
37 const minutes = Number(text);
38 return Number.isFinite(minutes) && minutes >= MIN_EVERY_MS / 60_000 && minutes <= 1440 ? Math.round(minutes * 60_000) : DEFAULT_EVERY_MS;
39 }
40
41 export class GrowStills {
42 private green = new Map<number, number>();
43
44 private readonly dir: string;
45 private readonly o: { capBytes?: number; keepMs?: number };
46
47 constructor(dir: string, o: { capBytes?: number; keepMs?: number } = {}) {
48 this.dir = dir;
49 this.o = o;
50 mkdirSync(dir, { recursive: true });
51 try {
52 for (const [at, g] of Object.entries(JSON.parse(readFileSync(this.greenFile, "utf8")) as Record<string, unknown>)) if (typeof g === "number" && g >= 0 && g <= 1) this.green.set(Number(at), g);
53 } catch {
54 /* none yet, or unreadable: the browser works them out again */
55 }
56 }
57
58 private get greenFile(): string {
59 return join(this.dir, "canopy.json");
60 }
61
62 get capBytes(): number {
63 return this.o.capBytes ?? DEFAULT_CAP_BYTES;
64 }
65
66 private files(): Still[] {
67 const out: Still[] = [];
68 for (const name of readdirSync(this.dir)) {
69 const m = NAME.exec(name);
70 if (!m) continue;
71 try {
72 const at = Number(m[1]);
73 const green = this.green.get(at);
74 out.push({ at, dark: m[2] === "d", bytes: statSync(join(this.dir, name)).size, ...(green === undefined ? {} : { green }) });
75 } catch {
76 /* removed while we looked */
77 }
78 }
79 return out.sort((a, b) => a.at - b.at);
80 }
81
82 private path(s: { at: number; dark: boolean }): string {
83 return join(this.dir, `${s.at}${s.dark ? "d" : ""}.jpg`);
84 }
85
86 /** Keep a still, then drop what is past its days and, oldest first, what is past the cap. */
87 add(jpeg: Uint8Array, at: number, dark: boolean): void {
88 if (!Number.isSafeInteger(at) || at < 1e9 || jpeg.length < 4 || jpeg[0] !== 0xff || jpeg[1] !== 0xd8) throw new Error("not a JPEG still");
89 writeFileSync(`${this.path({ at, dark })}.tmp`, jpeg);
90 renameSync(`${this.path({ at, dark })}.tmp`, this.path({ at, dark }));
91 this.prune(at);
92 }
93
94 /** Retention: nothing older than the keep window, and the total under the cap, the oldest going first. Returns what went. */
95 prune(now: number): Still[] {
96 const all = this.files();
97 const cutoff = now - (this.o.keepMs ?? KEEP_DAYS * DAY_MS);
98 let total = all.reduce((n, s) => n + s.bytes, 0);
99 const gone: Still[] = [];
100 for (const s of all) {
101 if (s.at >= cutoff && total <= this.capBytes) break;
102 try {
103 unlinkSync(this.path(s));
104 } catch {
105 /* already gone */
106 }
107 total -= s.bytes;
108 gone.push(s);
109 }
110 if (gone.length) {
111 for (const s of gone) this.green.delete(s.at);
112 this.saveGreen();
113 }
114 return gone;
115 }
116
117 /** Stills from `from` (inclusive) up to `to` (exclusive), oldest first, at most `limit`, starting after the still `after`. Dark ones only when asked. */
118 page(q: { from?: number; to?: number; after?: number; limit?: number; dark?: boolean }): Page {
119 const limit = Math.min(MAX_PAGE, Math.max(1, Math.floor(q.limit ?? 100)));
120 const match = this.files().filter((s) => (q.dark || !s.dark) && s.at >= (q.from ?? 0) && s.at < (q.to ?? Infinity) && s.at > (q.after ?? -1));
121 const stills = match.slice(0, limit);
122 return { stills, next: match.length > limit ? stills[stills.length - 1]!.at : null };
123 }
124
125 /** The newest still that is not dark, for the card before its script has run. */
126 latest(): Still | null {
127 return this.files().findLast((s) => !s.dark) ?? null;
128 }
129
130 /** Every still's time and canopy, without the cost of a page: for the daily chart. */
131 readings(from: number): { at: number; green: number }[] {
132 return this.files().flatMap((s) => (s.at >= from && !s.dark && s.green !== undefined ? [{ at: s.at, green: s.green }] : []));
133 }
134
135 usage(): { count: number; bytes: number; capBytes: number } {
136 const all = this.files();
137 return { count: all.length, bytes: all.reduce((n, s) => n + s.bytes, 0), capBytes: this.capBytes };
138 }
139
140 read(at: number): Uint8Array | null {
141 for (const dark of [false, true]) {
142 const file = this.path({ at, dark });
143 if (existsSync(file)) return readFileSync(file);
144 }
145 return null;
146 }
147
148 /** Remember the canopy readings of stills that are kept; others are ignored. Returns how many were taken. */
149 setGreen(readings: { at: number; green: number }[]): number {
150 const have = new Set(this.files().map((s) => s.at));
151 let n = 0;
152 for (const r of readings) {
153 if (!have.has(r.at) || !(r.green >= 0 && r.green <= 1)) continue;
154 this.green.set(r.at, Math.round(r.green * 10_000) / 10_000);
155 n++;
156 }
157 if (n) this.saveGreen();
158 return n;
159 }
160
161 private saveGreen(): void {
162 writeFileSync(`${this.greenFile}.tmp`, JSON.stringify(Object.fromEntries(this.green)));
163 renameSync(`${this.greenFile}.tmp`, this.greenFile);
164 }
165 }
Software/so101/src/relay.ts +84 −2
@@ -107,6 +107,8 @@ import { parseFps, type FeedOptions } from "./camera-feed.ts";
107107 import { CameraError, CameraHub } from "./cameras.ts";
108108 import { ARM_ONLY, StationError, StationHub } from "./stations.ts";
109109 import { renderWorkshop, type StationView } from "./workshop-page.ts";
110 import { DAY_MS, GrowStills, KEEP_DAYS } from "./grow-stills.ts";
111 import { canopyByDay, dayNumber, GrowLog, GrowLogError, lightHours } from "./grow-log.ts";
110112 import { parseProfile, ProfileError, type CameraProfile } from "./camera-profile.ts";
111113 import { VideoFeed, type VideoSources } from "./video-feed.ts";
112114 import type { VideoCard } from "./video-panel.ts";
@@ -156,6 +158,8 @@ export interface RelayOptions {
156158 cameras?: CameraHub;
157159 /** The workshop's stations; by default in memory, the arm alone. */
158160 stations?: StationHub;
161 /** The grow box's stills, log and how often a still is taken (src/grow-stills.ts); without it nothing is taken and the card shows no timelapse. */
162 grow?: { stills: GrowStills; log: GrowLog; everyMs: number };
159163 /** A setting that could not be used (shown on /link while simulating). */
160164 problem?: string | null;
161165 /** The link's retries and its patience (src/arm-link.ts). */
@@ -211,7 +215,8 @@ const STILL_FRAME = new URL("./ui/still-frame.js", import.meta.url);
211215 const STATIONS_PAGE = readFileSync(new URL("./stations-admin.html", import.meta.url), "utf8");
212216 const GROW_HINT = new URL("./ui/grow-hint.js", import.meta.url);
213217 /** The grow card's hint module, versioned by its own content and the module it imports. */
214 const SIGHT_VERSION = createHash("sha256").update(readFileSync(GROW_HINT)).update(readFileSync(STILL_FRAME)).digest("hex").slice(0, 12);
218 const GROW_MODULES = ["grow-timelapse.js", "grow-canopy.js"].map((f) => new URL(`./ui/${f}`, import.meta.url));
219 const SIGHT_VERSION = createHash("sha256").update(readFileSync(GROW_HINT)).update(readFileSync(STILL_FRAME)).update(Buffer.concat(GROW_MODULES.map((f) => readFileSync(f)))).digest("hex").slice(0, 12);
215220 const CAMERA_PAGE = readFileSync(new URL("./camera-list.html", import.meta.url), "utf8").replaceAll(
216221 "/ui/__VERSION__/",
217222 `/ui/${createHash("sha256").update(readFileSync(STILL_FRAME)).digest("hex").slice(0, 12)}/`,
@@ -947,6 +952,78 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> {
947952 return story.numbers;
948953 };
949954
955 /** The grow card's stills and log. Reading is public like the cameras; the planted date, notes and canopy readings are the owner's. */
956 const growRoutes = async (req: IncomingMessage, res: ServerResponse, url: URL): Promise<boolean> => {
957 const grow = options.grow!;
958 const isOwner = () => (lobby ? Boolean(lobby.who(req)?.owner) : true);
959 const num = (name: string) => {
960 const v = url.searchParams.get(name);
961 return v === null || !/^\d{1,15}$/.test(v) ? undefined : Number(v);
962 };
963 const still = /^\/grow\/stills\/(\d{10,15})\.jpg$/.exec(url.pathname);
964 if (req.method === "GET" && still) {
965 const bytes = grow.stills.read(Number(still[1]));
966 if (!bytes) return json(res, 404, { error: "no such still" }), true;
967 res.writeHead(200, { "content-type": "image/jpeg", "cache-control": "public, max-age=31536000, immutable", "content-length": String(bytes.length) });
968 res.end(bytes);
969 return true;
970 }
971 if (req.method === "GET" && url.pathname === "/grow/stills") {
972 json(res, 200, { ...grow.stills.page({ from: num("from"), to: num("to"), after: num("after"), limit: num("limit"), dark: url.searchParams.get("dark") === "1" }), ...grow.stills.usage() });
973 return true;
974 }
975 if (req.method === "GET" && url.pathname === "/grow/log") {
976 const light = (await lightHooks.read())?.light ?? null;
977 const zone = light?.zone || "UTC";
978 const now = Date.now();
979 const view = grow.log.view();
980 json(res, 200, { ...view, day: dayNumber(view.planted, now, zone), zone, lightHours: light ? lightHours(light.log, now, zone) : [], canopy: canopyByDay(grow.stills.readings(now - KEEP_DAYS * DAY_MS), zone), stills: grow.stills.usage(), everyMs: grow.everyMs, keepDays: KEEP_DAYS });
981 return true;
982 }
983 if (req.method === "POST" && (url.pathname === "/grow/log" || url.pathname === "/grow/canopy")) {
984 if (!isOwner()) return json(res, 403, { error: "only the owner may change the grow log" }), true;
985 let body: unknown;
986 try {
987 body = JSON.parse((await readBody(req, 32_768)).toString("utf8") || "null");
988 } catch {
989 return json(res, 400, { error: "send JSON" }), true;
990 }
991 if (url.pathname === "/grow/log") {
992 try {
993 grow.log.change(body, Date.now());
994 } catch (err) {
995 if (err instanceof GrowLogError) return json(res, 400, { error: err.message }), true;
996 throw err;
997 }
998 return json(res, 200, grow.log.view()), true;
999 }
1000 const list = (body as { readings?: unknown } | null)?.readings;
1001 if (!Array.isArray(list) || list.length > 100) return json(res, 400, { error: "send {readings: [{at, green}]}, at most 100" }), true;
1002 const readings = list.flatMap((r: { at?: unknown; green?: unknown }) => (typeof r?.at === "number" && typeof r.green === "number" ? [{ at: r.at, green: r.green }] : []));
1003 return json(res, 200, { taken: grow.stills.setGreen(readings) }), true;
1004 }
1005 return false;
1006 };
1007
1008 /** Take one still of the grow box's camera, flagged dark when the light is off. Quiet on failure: a missed still is a gap, not an error. */
1009 const takeStill = async () => {
1010 const grow = options.grow;
1011 const growStation = stations.list().find((st) => st.kind === "grow");
1012 const id = growStation?.cameras[0];
1013 const source = id === undefined ? null : cameras.source(id);
1014 if (!grow || source === null || restingNow()) return;
1015 try {
1016 const light = (await lightHooks.read())?.light;
1017 const answer = await fetch(source.replace(/\/[^/]*$/, "/snapshot"), { signal: AbortSignal.timeout(Math.min(readTimeoutMs, 5000)) });
1018 const bytes = new Uint8Array(await answer.arrayBuffer());
1019 if (answer.ok && bytes.length < 8_000_000) grow.stills.add(bytes, Date.now(), light?.configured === true && light.on === false);
1020 } catch {
1021 /* the camera or the Pi did not answer: no still this time */
1022 }
1023 };
1024 const stillTimer = options.grow ? setInterval(() => void takeStill(), options.grow.everyMs) : undefined;
1025 stillTimer?.unref?.();
1026
9501027 const http = createServer(async (req, res) => {
9511028 for (const [name, value] of Object.entries(FLOOR)) res.setHeader(name, value);
9521029 try {
@@ -995,6 +1072,10 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> {
9951072 res.writeHead(200, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
9961073 return res.end(text);
9971074 }
1075 if (url.pathname.startsWith("/grow/") && options.grow) {
1076 const handled = await growRoutes(req, res, url);
1077 if (handled) return;
1078 }
9981079 let by = "the arm's page";
9991080 if (OWNER_READS.has(route) && !(lobby ? lobby.who(req)?.owner : false)) return json(res, 404, { error: "not found" });
10001081 if (!reading && lobby) {
@@ -1045,7 +1126,7 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> {
10451126 if (req.method === "GET" && /^\/(?:index\.html)?$/.test(url.pathname) && stations.isWorkshop()) {
10461127 res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
10471128 const who = lobby?.who(req);
1048 return res.end(renderWorkshop(stationViews(), { signedIn: lobby && !lobby.local ? lobby.signedIn(req) : null, owner: Boolean(who?.owner) }).replaceAll("/ui/__VERSION__/", `/ui/${SIGHT_VERSION}/`));
1129 return res.end(renderWorkshop(stationViews(), { signedIn: lobby && !lobby.local ? lobby.signedIn(req) : null, owner: Boolean(who?.owner), grow: Boolean(options.grow), latest: options.grow?.stills.latest()?.at ?? null }).replaceAll("/ui/__VERSION__/", `/ui/${SIGHT_VERSION}/`));
10491130 }
10501131 if (req.method === "GET" && PAGE.test(url.pathname) && rest) return restingPage(req, res, rest);
10511132 if (req.method === "GET" && PAGE.test(url.pathname)) return await page(req, res, await ask(simulation + path.replace(/^\/arm(?=$|\?)/, "/"), req, undefined, null, false));
@@ -1433,6 +1514,7 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> {
14331514 turnFacts?.();
14341515 chatFacts?.();
14351516 clearInterval(sampleTimer);
1517 clearInterval(stillTimer);
14361518 readBacks?.();
14371519 arrived?.();
14381520 armLink?.close();
Software/so101/src/terminal.ts +54 −3
@@ -42,6 +42,8 @@
4242 * /precision owner or moderator: the precision scorecard /admin shows (GET /precision, src/precision-panel.ts)
4343 * /servos owner: each joint's servo control registers /admin shows (GET /servo-params, src/servo-panel.ts); 404 to anyone else
4444 * /tune <joint> <P> owner: the P experiment /admin's Servos "Try this P" runs (POST /tuning/p): sweep, write, sweep, keep or put back; 404 to anyone else
45 * /grow log the grow log (GET /grow/log): day N, the stills' store, light hours, canopy (green area), the latest notes
46 * /grow planted <YYYY-MM-DD>|none, /grow note <text> owner: the planted date, or a note (POST /grow/log); the card shows it at once on its next load
4547 * /grow owner: the light and the pump as /admin's Grow card shows them (GET /grow); 404 to anyone else
4648 * /grow [light|pump] on|off|clear owner: (no device word is the light) override until the next change, or give it back to the schedule (POST /grow/<device>)
4749 * /grow <light|pump> schedule 06:00 12 | 5 25 | off owner: a daily photoperiod (on at, hours) or an interval cycle (minutes on, off), or none
@@ -177,6 +179,37 @@ export function stationsReader(env: Env, fetcher: typeof fetch = fetch): ReadSta
177179 };
178180 }
179181
182 /** What the grow log answered; with a change, the owner's POST /grow/log. */
183 export type ReadGrowLog = (change?: Record<string, unknown>) => Promise<Seen>;
184
185 /** GET /grow/log as lines, or POST a planted date or a note (the owner's; the server decides who may). */
186 export function growLogReader(env: Env, fetcher: typeof fetch = fetch): ReadGrowLog {
187 return async (change) => {
188 try {
189 const r = await fetcher(`${appUrl(env)}/grow/log`, { method: change ? "POST" : "GET", headers: { ...terminalHeaders(env), ...(change ? { "content-type": "application/json" } : {}) }, ...(change ? { body: JSON.stringify(change) } : {}), signal: AbortSignal.timeout(5000) });
190 const body = (await r.json().catch(() => null)) as unknown;
191 if (!r.ok) return { error: plain((body as { error?: unknown } | null)?.error ?? `status ${r.status}`), lines: [] };
192 return { error: null, lines: change ? ["grow log saved"] : growLogLines(body) };
193 } catch (err) {
194 return { error: plain((err as Error).message), lines: [] };
195 }
196 };
197 }
198
199 /** The grow log as the card shows it: day N, the stills' store, light hours, canopy, the latest notes. */
200 export function growLogLines(body: unknown): string[] {
201 const b = (body ?? {}) as { planted?: unknown; day?: unknown; stills?: { count?: unknown; bytes?: unknown; capBytes?: unknown }; everyMs?: unknown; lightHours?: { day?: unknown; hours?: unknown }[]; canopy?: { day?: unknown; green?: unknown }[]; notes?: { at?: unknown; text?: unknown }[] };
202 const mb = (v: unknown) => (Number(v) / 1e6).toFixed(1);
203 const lines = [b.planted ? `Day ${b.day ?? "?"}, planted ${plain(b.planted)}` : "Not planted yet (/grow planted <YYYY-MM-DD>)"];
204 if (b.stills) lines.push(`Stills: ${Number(b.stills.count)}, ${mb(b.stills.bytes)} MB of ${mb(b.stills.capBytes)} MB, one every ${Math.round(Number(b.everyMs) / 60000)} min`);
205 const hours = (b.lightHours ?? []).slice(-7).map((d) => `${plain(d.day).slice(5)} ${Number(d.hours)}h`);
206 lines.push(hours.length ? `Light hours: ${hours.join(" · ")}` : "Light hours: no schedule log yet");
207 const canopy = (b.canopy ?? []).slice(-7).map((d) => `${plain(d.day).slice(5)} ${Math.round(Number(d.green) * 100)}%`);
208 lines.push(canopy.length ? `Canopy (green area): ${canopy.join(" · ")}` : "Canopy (green area): no readings yet; the owner's browser works them out when the card is opened");
209 for (const n of (b.notes ?? []).slice(-5)) lines.push(`${new Date(Number(n.at)).toISOString().slice(0, 10)} ${plain(n.text)}`);
210 return lines;
211 }
212
180213 /** One line a station: its name, whether it is there, and its cameras. */
181214 export function stationLines(body: unknown): string[] {
182215 if (!Array.isArray(body)) return [];
@@ -283,6 +316,7 @@ export function growReader(env: Env, fetcher: typeof fetch = fetch): ReadGrow {
283316 };
284317 }
285318
319 const GROW_LOG_USAGE = "usage: /grow log · /grow planted <YYYY-MM-DD>|none · /grow note <text>";
286320 const GROW_USAGE = "usage: /grow · /grow [light|pump] on|off|clear · schedule off | schedule 06:00 12 (light: on at, hours) | schedule 5 25 (pump: minutes on, off) · switch none | shelly <host> [1|2] | gpio <line> [low] | uhubctl <hub> <port> [vid:pid…]";
287321
288322 /** `/grow <device> …` as the body POST /grow/<device> takes; null when it does not parse. The arm server checks every field again. */
@@ -489,7 +523,7 @@ export function whoami(room: Room): string {
489523 /** The terminal holds no session to end: the forge signs who is at the keyboard afresh on every connection. */
490524 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";
491525
492 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) · /stations [set <json>] (set: owner) · /machine [off] · /who /whoami · /quit";
526 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 log|planted <date>|note <text> · /stations [set <json>] (set: owner) · /machine [off] · /who /whoami · /quit";
493527
494528 /** The Machine feed's socket beside the lobby's: same app, same port. */
495529 export const machineUrl = (live: string): string => live.replace(/\/live$/, "/machine");
@@ -645,7 +679,7 @@ function target(room: Room, word: string): string | null {
645679 return ID.test(word) ? word : null;
646680 }
647681
648 export type Typed = { send: Frame } | { post: "/stop" | "/resume" } | { table: "here" | number | { touch: 1 | 2 | 3 } } | { zero: { joint: string; referenceDeg: number } } | { align: string | null } | { print: string } | { lines: string[] } | { machine: boolean } | { health: true } | { precision: true } | { trace: number } | { data: DataAsk } | { servos: true } | { grow: { device: DeviceName; body: Record<string, unknown> } | null } | { tune: { joint: number; p: number } | null } | { versions: true } | { stations: string | null } | { quit: true } | null;
682 export type Typed = { send: Frame } | { post: "/stop" | "/resume" } | { table: "here" | number | { touch: 1 | 2 | 3 } } | { zero: { joint: string; referenceDeg: number } } | { align: string | null } | { print: string } | { lines: string[] } | { machine: boolean } | { health: true } | { precision: true } | { trace: number } | { data: DataAsk } | { servos: true } | { grow: { device: DeviceName; body: Record<string, unknown> } | null } | { tune: { joint: number; p: number } | null } | { versions: true } | { stations: string | null } | { growLog: Record<string, unknown> | null } | { quit: true } | null;
649683
650684 /** What a typed line asks for. Validation the server repeats anyway; this only saves a round trip on a typo. */
651685 export function typed(room: Room, input: string): Typed {
@@ -673,6 +707,13 @@ export function typed(room: Room, input: string): Typed {
673707 if (joint === undefined || rest.length !== 1 || !/^\d{1,3}$/.test(rest[0]!)) return { print: TUNE_USAGE };
674708 return { tune: { joint: Number(joint), p: Number(rest[0]) } };
675709 }
710 if (/^grow$/i.test(verb) && /^(log|planted|note)$/i.test(arg)) {
711 const word = arg.toLowerCase();
712 if (word === "log") return rest.length ? { print: GROW_LOG_USAGE } : { growLog: null };
713 const value = line.replace(/^\/\S+\s+\S+\s*/, "").trim();
714 if (word === "note") return value ? { growLog: { note: value } } : { print: GROW_LOG_USAGE };
715 return value === "none" ? { growLog: { planted: null } } : /^\d{4}-\d{2}-\d{2}$/.test(value) ? { growLog: { planted: value } } : { print: GROW_LOG_USAGE };
716 }
676717 if (/^grow$/i.test(verb)) {
677718 if (!arg) return { grow: null };
678719 // No device word is the light, as /grow on|off|schedule|switch was before the pump.
@@ -797,12 +838,14 @@ export class Door {
797838 private readonly readTrace: ReadTrace | null;
798839 private readonly readVersions: ReadVersions | null;
799840 private readonly readStations: ReadStations | null;
841 private readonly readGrowLog: ReadGrowLog | null;
800842 private readonly readAlign: ReadAlign | null;
801843 private readonly readData: ReadData | null;
802844 private readonly readGrow: ReadGrow | null;
803845
804846 /** `post` reaches the app's /stop and /resume, `read` its /admin/health, `readCard` its /precision; without them, the door says it cannot. */
805 constructor(url: string, headers: Record<string, string>, print: (line: string) => void, post: Post | null = null, read: Read | null = null, readCard: ReadCard | null = null, readServos: ReadServos | null = null, readVersions: ReadVersions | null = null, readAlign: ReadAlign | null = null, readTuning: ReadTuning | null = null, readTrace: ReadTrace | null = null, readData: ReadData | null = null, readGrow: ReadGrow | null = null, readStations: ReadStations | null = null) {
847 constructor(url: string, headers: Record<string, string>, print: (line: string) => void, post: Post | null = null, read: Read | null = null, readCard: ReadCard | null = null, readServos: ReadServos | null = null, readVersions: ReadVersions | null = null, readAlign: ReadAlign | null = null, readTuning: ReadTuning | null = null, readTrace: ReadTrace | null = null, readData: ReadData | null = null, readGrow: ReadGrow | null = null, readStations: ReadStations | null = null, readGrowLog: ReadGrowLog | null = null) {
848 this.readGrowLog = readGrowLog;
806849 this.readGrow = readGrow;
807850 this.readData = readData;
808851 this.readTrace = readTrace;
@@ -864,6 +907,7 @@ export class Door {
864907 else if ("tune" in t) void this.tune(t.tune);
865908 else if ("versions" in t) void this.versions();
866909 else if ("stations" in t) void this.stations(t.stations);
910 else if ("growLog" in t) void this.growLog(t.growLog);
867911 else if ("machine" in t) this.watchMachine(t.machine);
868912 else this.send(t.send);
869913 return true;
@@ -885,6 +929,13 @@ export class Door {
885929 for (const line of r.error ? [`error: ${r.error}`] : healthLines(r.rows)) this.print(line);
886930 }
887931
932 /** Anyone may read the log; the server lets only the owner write it, and the open page shows a note on its next load. */
933 private async growLog(change: Record<string, unknown> | null): Promise<void> {
934 if (!this.readGrowLog) return this.print("error: the grow log is not available here");
935 const r = await this.readGrowLog(change ?? undefined);
936 for (const line of r.error ? [`error: ${r.error}`] : r.lines) this.print(line);
937 }
938
888939 /** The server decides who may change them; anyone may list. */
889940 private async stations(set: string | null): Promise<void> {
890941 if (!this.readStations) return this.print("error: stations are not available here");
Software/so101/src/tty.ts +2 −2
@@ -4,7 +4,7 @@
44 */
55
66 import { createInterface, type Interface } from "node:readline";
7 import { alignReader, cardReader, dataReader, Door, growReader, HELP, liveUrl, poster, reader, servosReader, stationsReader, terminalHeaders, traceReader, tuningReader, versionsReader } from "./terminal.ts";
7 import { alignReader, cardReader, dataReader, Door, growLogReader, growReader, HELP, liveUrl, poster, reader, servosReader, stationsReader, terminalHeaders, traceReader, tuningReader, versionsReader } from "./terminal.ts";
88
99 const env = process.env;
1010 const where = env.FORGE_APP_ENV === "preview" ? `preview of #${env.FORGE_APP_PULL ?? "?"}` : (env.FORGE_APP_ENV ?? "local");
@@ -17,7 +17,7 @@ const print = (line: string) => {
1717 rl?.prompt(true);
1818 };
1919
20 const door = new Door(liveUrl(env), terminalHeaders(env), print, poster(env), reader(env), cardReader(env), servosReader(env), versionsReader(env), alignReader(env), tuningReader(env), traceReader(env), dataReader(env), growReader(env), stationsReader(env));
20 const door = new Door(liveUrl(env), terminalHeaders(env), print, poster(env), reader(env), cardReader(env), servosReader(env), versionsReader(env), alignReader(env), tuningReader(env), traceReader(env), dataReader(env), growReader(env), stationsReader(env), growLogReader(env));
2121 print(`\x1b[1mSO-101 arm\x1b[0m · ${where} · ${HELP}`);
2222 try {
2323 await door.open(() => {
Software/so101/src/ui/grow-canopy.js added +34
@@ -0,0 +1,34 @@
1 /**
2 * The cheap growth signal: the fraction of a still's pixels that are green, in the box the owner drew once.
3 * Plain ESM on RGBA pixels (a canvas's ImageData), served at /ui/ for the workshop page and imported by the tests.
4 * It measures green area, not health: a yellowing leaf may still count, a pale one may not.
5 */
6 import { cropRect } from "./still-frame.js";
7
8 /**
9 * A pixel is green when its green channel leads both others by a margin and it is not near black.
10 * @param {number} r
11 * @param {number} g
12 * @param {number} b
13 */
14 export function isGreen(r, g, b) {
15 return g >= 40 && g > r * 1.1 + 6 && g > b * 1.1 + 6;
16 }
17
18 /**
19 * Green-pixel fraction of the region of an RGBA frame, 0 to 1.
20 * @param {ArrayLike<number>} data
21 * @param {number} width
22 * @param {number} height
23 * @param {import("./still-frame.js").Rect | null} region fractions of the frame, null for all of it
24 */
25 export function greenFraction(data, width, height, region) {
26 const rect = cropRect(region, width, height);
27 let green = 0;
28 for (let row = rect.y; row < rect.y + rect.h; row++) {
29 for (let col = rect.x, i = (row * width + col) * 4; col < rect.x + rect.w; col++, i += 4) {
30 if (isGreen(data[i] ?? 0, data[i + 1] ?? 0, data[i + 2] ?? 0)) green++;
31 }
32 }
33 return Math.round((green / (rect.w * rect.h)) * 10_000) / 10_000;
34 }
Software/so101/src/ui/grow-timelapse.js added +206
@@ -0,0 +1,206 @@
1 // @ts-nocheck -- DOM code; the project has no DOM lib. The pure helpers are covered by grow-stills.test.ts.
2 /**
3 * The grow card's timelapse, log and canopy chart, built in the browser from the stills the relay keeps
4 * (GET /grow/stills, /grow/stills/<at>.jpg, /grow/log): no video is encoded anywhere. Plain ESM served at /ui/.
5 * The page has `[data-grow]` holding the camera's box as `data-region` JSON and `data-owner="1"` for the owner.
6 */
7 import { greenFraction } from "./grow-canopy.js";
8
9 const DAY = 86_400_000;
10 export const RANGES = { today: "Today", week: "7 days", all: "All" };
11
12 /** The start of a range, ms (0 for all); "today" is since local midnight. */
13 export function rangeStart(range, now = Date.now()) {
14 if (range === "week") return now - 7 * DAY;
15 if (range === "today") return new Date(now).setHours(0, 0, 0, 0);
16 return 0;
17 }
18
19 /**
20 * The canopy chart's polyline points for per-day readings, in a width×height box with a margin; empty for none.
21 * @param {{ day: string, green: number }[]} days
22 * @param {number} width
23 * @param {number} height
24 * @param {number} [pad]
25 */
26 export function chartPoints(days, width, height, pad = 6) {
27 if (!days.length) return "";
28 const top = Math.max(0.01, ...days.map((d) => d.green));
29 return days
30 .map((d, i) => {
31 const x = days.length === 1 ? width / 2 : pad + (i * (width - 2 * pad)) / (days.length - 1);
32 const y = height - pad - (d.green / top) * (height - 2 * pad);
33 return `${Math.round(x * 10) / 10},${Math.round(y * 10) / 10}`;
34 })
35 .join(" ");
36 }
37
38 /** @param {string} url @returns {Promise<any>} */
39 const get = async (url) => {
40 const res = await fetch(url, { cache: "no-store" });
41 if (!res.ok) throw new Error(String(res.status));
42 return res.json();
43 };
44 /** @param {string} url @param {unknown} body */
45 const send = async (url, body) => {
46 const res = await fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
47 if (!res.ok) throw new Error((await res.json().catch(() => null))?.error ?? `status ${res.status}`);
48 };
49
50 /** Every still from `from`, page by page (at most 2000). */
51 async function load(from) {
52 const out = [];
53 for (let after = -1, guard = 0; after !== null && guard < 4; guard++) {
54 const page = await get(`/grow/stills?from=${from}&after=${after}&limit=500`);
55 out.push(...page.stills);
56 after = page.next;
57 }
58 return out;
59 }
60
61 /** Work out the canopy of stills that have none yet (a few per visit, the owner's browser only) and send them. */
62 async function measure(stills, region) {
63 const todo = stills.filter((s) => s.green === undefined).slice(-40);
64 const found = [];
65 for (const s of todo) {
66 try {
67 const img = new Image();
68 img.src = `/grow/stills/${s.at}.jpg`;
69 await img.decode();
70 const canvas = document.createElement("canvas");
71 canvas.width = Math.min(img.naturalWidth, 640);
72 canvas.height = Math.round((canvas.width * img.naturalHeight) / img.naturalWidth);
73 const g = canvas.getContext("2d");
74 g.drawImage(img, 0, 0, canvas.width, canvas.height);
75 const { data } = g.getImageData(0, 0, canvas.width, canvas.height);
76 found.push({ at: s.at, green: greenFraction(data, canvas.width, canvas.height, region) });
77 } catch {
78 // A still that will not decode is skipped: it has no reading, which is the honest answer.
79 }
80 }
81 if (!found.length) return 0;
82 try {
83 await send("/grow/canopy", { readings: found });
84 } catch {
85 return 0; // the readings are worked out again on the next visit
86 }
87 return found.length;
88 }
89
90 const el = (tag, text, attrs = {}) => {
91 const e = document.createElement(tag);
92 if (text !== undefined) e.textContent = text;
93 for (const [k, v] of Object.entries(attrs)) e.setAttribute(k, v);
94 return e;
95 };
96
97 export async function growCard(root) {
98 root.replaceChildren();
99 const owner = root.dataset.owner === "1";
100 let region = null;
101 try {
102 region = JSON.parse(root.dataset.region ?? "null");
103 } catch {
104 region = null;
105 }
106 const view = el("img", undefined, { alt: "The grow box, from the timelapse", width: "640", height: "480" });
107 const slider = el("input", undefined, { type: "range", min: "0", max: "0", value: "0", "aria-label": "Scrub the timelapse" });
108 const when = el("output", "");
109 const play = el("button", "Play", { type: "button" });
110 const buttons = Object.entries(RANGES).map(([key, label]) => el("button", label, { type: "button", "data-range": key }));
111 const bar = el("div", undefined, { class: "controls" });
112 bar.append(...buttons, play);
113 const lapse = el("div", undefined, { class: "lapse" });
114 lapse.append(bar, view, slider, when);
115 const log = el("div", undefined, { class: "growlog" });
116 const problem = el("p", "", { class: "problem", role: "status", "aria-live": "polite" });
117 root.append(lapse, log, problem);
118
119 let stills = [];
120 let timer = null;
121 const show = (i) => {
122 const s = stills[i];
123 if (!s) return;
124 view.src = `/grow/stills/${s.at}.jpg`;
125 when.textContent = new Date(s.at).toLocaleString([], { dateStyle: "medium", timeStyle: "short" });
126 slider.value = String(i);
127 };
128 const stop = () => {
129 if (timer) clearInterval(timer);
130 timer = null;
131 play.textContent = "Play";
132 };
133 play.addEventListener("click", () => {
134 if (timer) return stop();
135 if (Number(slider.value) >= stills.length - 1) show(0);
136 play.textContent = "Pause";
137 timer = setInterval(() => (Number(slider.value) + 1 >= stills.length ? stop() : show(Number(slider.value) + 1)), 150);
138 });
139 slider.addEventListener("input", () => (stop(), show(Number(slider.value))));
140 const pick = async (range) => {
141 stop();
142 for (const b of buttons) b.setAttribute("aria-pressed", String(b.dataset.range === range));
143 stills = await load(rangeStart(range));
144 slider.max = String(Math.max(0, stills.length - 1));
145 if (!stills.length) {
146 view.removeAttribute("src");
147 when.textContent = "No stills in this range yet.";
148 return;
149 }
150 show(stills.length - 1);
151 if (owner && (await measure(stills, region))) await drawLog();
152 };
153 for (const b of buttons) b.addEventListener("click", () => void pick(b.dataset.range));
154
155 async function drawLog() {
156 const data = await get("/grow/log");
157 log.replaceChildren();
158 log.append(el("h3", data.planted ? `Day ${data.day ?? "?"} · planted ${data.planted}` : "Not planted yet"));
159 log.append(el("p", `Stills: ${data.stills.count}, ${(data.stills.bytes / 1e6).toFixed(1)} MB of ${(data.stills.capBytes / 1e6).toFixed(0)} MB, one every ${Math.round(data.everyMs / 60000)} min, kept ${data.keepDays} days.`));
160 const hours = data.lightHours.filter((d) => d.hours > 0 || data.lightHours.length < 4);
161 log.append(el("p", hours.length ? `Light hours per day: ${hours.slice(-7).map((d) => `${d.day.slice(5)} ${d.hours}`).join(" · ")}` : "Light hours: no schedule log yet."));
162 log.append(el("h3", "Canopy (green area)"));
163 if (data.canopy.length) {
164 const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
165 svg.setAttribute("viewBox", "0 0 300 80");
166 svg.setAttribute("role", "img");
167 svg.setAttribute("aria-label", `Canopy (green area) per day: ${data.canopy.map((d) => `${d.day} ${Math.round(d.green * 100)}%`).join(", ")}`);
168 const line = document.createElementNS("http://www.w3.org/2000/svg", "polyline");
169 line.setAttribute("points", chartPoints(data.canopy, 300, 80));
170 line.setAttribute("fill", "none");
171 line.setAttribute("stroke", "currentColor");
172 line.setAttribute("stroke-width", "2");
173 svg.append(line);
174 log.append(svg, el("p", `Share of the box that is green, per day: ${Math.round(data.canopy.at(-1).green * 100)}% on ${data.canopy.at(-1).day}. Green area only, not a health reading.`));
175 } else log.append(el("p", "No canopy readings yet" + (owner ? ": they appear as you look at the stills." : ".")));
176 const notes = el("ul");
177 for (const n of data.notes.slice(-10).reverse()) notes.append(el("li", `${new Date(n.at).toLocaleDateString()} ${n.text}`));
178 log.append(notes);
179 if (owner) {
180 const form = el("form");
181 const date = el("input", undefined, { type: "date", name: "planted", "aria-label": "Planted on", value: data.planted ?? "" });
182 const note = el("input", undefined, { type: "text", name: "note", maxlength: "280", placeholder: "Watered, thinned…", "aria-label": "Add a note" });
183 form.append(date, note, el("button", "Save", { type: "submit" }));
184 form.addEventListener("submit", async (e) => {
185 e.preventDefault();
186 const body = {};
187 if (date.value !== (data.planted ?? "")) body.planted = date.value || null;
188 if (note.value.trim()) body.note = note.value;
189 try {
190 if (Object.keys(body).length) await send("/grow/log", body);
191 problem.textContent = "";
192 } catch (err) {
193 problem.textContent = `Could not save: ${err.message}`;
194 }
195 await drawLog();
196 });
197 log.append(form);
198 }
199 }
200 await Promise.all([pick("today").then(() => (stills.length ? undefined : pick("week"))), drawLog()]);
201 }
202
203 /** @param {{ querySelectorAll: Function }} doc */
204 export function watchGrow(doc) {
205 for (const root of doc.querySelectorAll("[data-grow]")) void growCard(root).catch((err) => root.append(el("p", `The timelapse could not load: ${err.message}`, { role: "status" })));
206 }
Software/so101/src/workshop-page.ts +11 −3
@@ -29,6 +29,9 @@ h1 { margin: .5rem 0 1rem; } h2 { margin: .6rem 0; font-size: 1.15rem; }
2929 .sight:empty { display: none; }
3030 .cant-see { position: absolute; left: 0; right: 0; bottom: 0; margin: 0; padding: .4rem .6rem; border-radius: 0 0 6px 6px; background: #000d; color: #fff; font-size: .9rem; }
3131 .cant-see a { color: #9ecbff; }
32 .grow { min-height: 22rem; } .grow img { max-width: 100%; height: auto; } .lapse { aspect-ratio: 4/3; } .growlog { min-height: 12rem; } .grow input[type=date] { min-height: 44px; } .grow .problem { color: var(--accent); }
33 .grow h3 { margin: .8rem 0 .3rem; font-size: 1rem; } .grow input[type=range] { width: 100%; } .grow svg { width: 100%; max-height: 6rem; color: var(--accent); }
34 .grow button[aria-pressed=true] { outline: 2px solid var(--fg); } .grow form { display: flex; gap: .5rem; flex-wrap: wrap; } .grow input[type=text] { flex: 1; min-width: 8rem; min-height: 44px; }
3235 .card p { color: var(--dim); margin: .4rem 0; }
3336 .top { max-width: 60rem; margin: 0 auto; padding: .5rem 1rem 0; display: flex; justify-content: space-between; gap: 1rem; font-size: .9rem; color: var(--dim); }
3437 .top a, .card button { min-height: 44px; display: inline-flex; align-items: center; }
@@ -45,6 +48,10 @@ a:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
4548 export interface Looker {
4649 signedIn: SignedIn | null;
4750 owner: boolean;
51 /** The relay keeps the grow box's stills: its card gets the timelapse and the log. */
52 grow?: boolean;
53 /** When the newest lit still was taken: the card shows it without the script. */
54 latest?: number | null;
4855 }
4956
5057 function card({ station, present, resting, cameras }: StationView, looker: Looker): string {
@@ -67,11 +74,12 @@ function card({ station, present, resting, cameras }: StationView, looker: Looke
6774 })
6875 .join("");
6976 const controls = station.controls.map((c) => `<a href="${esc(c.href)}">${esc(c.label)}</a>`).join("");
70 return `<section class="card" data-station="${esc(station.id)}" data-present="true">${head}${thumbs}${controls ? `<div class="controls">${controls}</div>` : ""}</section>`;
77 const lapse = grow && looker.grow ? `<div class="grow" data-grow data-owner="${looker.owner ? 1 : 0}" data-region="${esc(JSON.stringify(cameras[0]?.region ?? null))}">${looker.latest ? `<img class="latest" src="/grow/stills/${Number(looker.latest)}.jpg" alt="The latest still of the grow box" width="640" height="480" loading="lazy">` : ""}<p><a href="/grow/log">The grow log</a></p><noscript><p>The timelapse and the canopy chart need JavaScript.</p></noscript></div>` : "";
78 return `<section class="card" data-station="${esc(station.id)}" data-present="true">${head}${thumbs}${lapse}${controls ? `<div class="controls">${controls}</div>` : ""}</section>`;
7179 }
7280
7381 /** The page's one script: the grow stills' check (src/ui/grow-hint.js). */
74 const SIGHT_SCRIPT = `import { watchStills } from "/ui/__VERSION__/grow-hint.js"; watchStills(document);`;
82 const SIGHT_SCRIPT = `import { watchStills } from "/ui/__VERSION__/grow-hint.js"; watchStills(document);__LAPSE__`;
7583
7684 export function renderWorkshop(views: readonly StationView[], looker: Looker = { signedIn: null, owner: false }): string {
7785 return `<!doctype html>
@@ -90,7 +98,7 @@ export function renderWorkshop(views: readonly StationView[], looker: Looker = {
9098 <h1>${WORKSHOP_TITLE}</h1>
9199 <div class="cards">${views.map((v) => card(v, looker)).join("\n")}</div>
92100 </main>
93 ${views.some((v) => v.station.kind === "grow" && v.present && v.cameras.length) ? `<script type="module">${SIGHT_SCRIPT}</script>` : ""}
101 ${views.some((v) => v.station.kind === "grow" && v.present && v.cameras.length) ? `<script type="module">${SIGHT_SCRIPT.replace("__LAPSE__", looker.grow ? ` import { watchGrow } from "/ui/__VERSION__/grow-timelapse.js"; watchGrow(document);` : "")}</script>` : ""}
94102 </body>
95103 </html>
96104 `;

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs