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
Check · Review · Land done · Live — Landed; nothing deploys from this branch, so this is the end of its path.
smith/arm:smith/583-grow-box-a-still-every-10-min-a-scrubbab → main 2 commits fork
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 | ||
| 89 | 89 | - **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. |
| 90 | 90 | - **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. |
| 91 | 91 | |
| 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 | ||
| 92 | 98 | ## The story page: /story |
| 93 | 99 | |
| 94 | 100 | `/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 [](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"; | ||
| 26 | 26 | import { parseRemote, parseVideo, startRelay, type Remote } from "../src/relay.ts"; |
| 27 | 27 | import { piSyncFromEnv } from "../src/pi-deploy.ts"; |
| 28 | 28 | import { Facts } from "../src/facts.ts"; |
| 29 | import { GrowLog } from "../src/grow-log.ts"; | |
| 30 | import { GrowStills, parseEvery } from "../src/grow-stills.ts"; | |
| 29 | 31 | import { startServer } from "../src/server.ts"; |
| 30 | 32 | |
| 31 | 33 | const fake = new FakeBus([1, 2, 3, 4, 5, 6]); |
| @@ -74,6 +76,7 @@ try { | ||
| 74 | 76 | } |
| 75 | 77 | const facts = new Facts({ file: join(dir, "facts.sqlite") }); |
| 76 | 78 | 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") }); | |
| 78 | 81 | const { mode, host: armHost } = relay.link(); |
| 79 | 82 | 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"; | ||
| 107 | 107 | import { CameraError, CameraHub } from "./cameras.ts"; |
| 108 | 108 | import { ARM_ONLY, StationError, StationHub } from "./stations.ts"; |
| 109 | 109 | 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"; | |
| 110 | 112 | import { parseProfile, ProfileError, type CameraProfile } from "./camera-profile.ts"; |
| 111 | 113 | import { VideoFeed, type VideoSources } from "./video-feed.ts"; |
| 112 | 114 | import type { VideoCard } from "./video-panel.ts"; |
| @@ -156,6 +158,8 @@ export interface RelayOptions { | ||
| 156 | 158 | cameras?: CameraHub; |
| 157 | 159 | /** The workshop's stations; by default in memory, the arm alone. */ |
| 158 | 160 | 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 }; | |
| 159 | 163 | /** A setting that could not be used (shown on /link while simulating). */ |
| 160 | 164 | problem?: string | null; |
| 161 | 165 | /** 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); | ||
| 211 | 215 | const STATIONS_PAGE = readFileSync(new URL("./stations-admin.html", import.meta.url), "utf8"); |
| 212 | 216 | const GROW_HINT = new URL("./ui/grow-hint.js", import.meta.url); |
| 213 | 217 | /** 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); | |
| 215 | 220 | const CAMERA_PAGE = readFileSync(new URL("./camera-list.html", import.meta.url), "utf8").replaceAll( |
| 216 | 221 | "/ui/__VERSION__/", |
| 217 | 222 | `/ui/${createHash("sha256").update(readFileSync(STILL_FRAME)).digest("hex").slice(0, 12)}/`, |
| @@ -947,6 +952,78 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> { | ||
| 947 | 952 | return story.numbers; |
| 948 | 953 | }; |
| 949 | 954 | |
| 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 | ||
| 950 | 1027 | const http = createServer(async (req, res) => { |
| 951 | 1028 | for (const [name, value] of Object.entries(FLOOR)) res.setHeader(name, value); |
| 952 | 1029 | try { |
| @@ -995,6 +1072,10 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> { | ||
| 995 | 1072 | res.writeHead(200, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" }); |
| 996 | 1073 | return res.end(text); |
| 997 | 1074 | } |
| 1075 | if (url.pathname.startsWith("/grow/") && options.grow) { | |
| 1076 | const handled = await growRoutes(req, res, url); | |
| 1077 | if (handled) return; | |
| 1078 | } | |
| 998 | 1079 | let by = "the arm's page"; |
| 999 | 1080 | if (OWNER_READS.has(route) && !(lobby ? lobby.who(req)?.owner : false)) return json(res, 404, { error: "not found" }); |
| 1000 | 1081 | if (!reading && lobby) { |
| @@ -1045,7 +1126,7 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> { | ||
| 1045 | 1126 | if (req.method === "GET" && /^\/(?:index\.html)?$/.test(url.pathname) && stations.isWorkshop()) { |
| 1046 | 1127 | res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" }); |
| 1047 | 1128 | 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}/`)); | |
| 1049 | 1130 | } |
| 1050 | 1131 | if (req.method === "GET" && PAGE.test(url.pathname) && rest) return restingPage(req, res, rest); |
| 1051 | 1132 | 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> { | ||
| 1433 | 1514 | turnFacts?.(); |
| 1434 | 1515 | chatFacts?.(); |
| 1435 | 1516 | clearInterval(sampleTimer); |
| 1517 | clearInterval(stillTimer); | |
| 1436 | 1518 | readBacks?.(); |
| 1437 | 1519 | arrived?.(); |
| 1438 | 1520 | armLink?.close(); |
Software/so101/src/terminal.ts +54 −3
| @@ -42,6 +42,8 @@ | ||
| 42 | 42 | * /precision owner or moderator: the precision scorecard /admin shows (GET /precision, src/precision-panel.ts) |
| 43 | 43 | * /servos owner: each joint's servo control registers /admin shows (GET /servo-params, src/servo-panel.ts); 404 to anyone else |
| 44 | 44 | * /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 | |
| 45 | 47 | * /grow owner: the light and the pump as /admin's Grow card shows them (GET /grow); 404 to anyone else |
| 46 | 48 | * /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>) |
| 47 | 49 | * /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 | ||
| 177 | 179 | }; |
| 178 | 180 | } |
| 179 | 181 | |
| 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 | ||
| 180 | 213 | /** One line a station: its name, whether it is there, and its cameras. */ |
| 181 | 214 | export function stationLines(body: unknown): string[] { |
| 182 | 215 | if (!Array.isArray(body)) return []; |
| @@ -283,6 +316,7 @@ export function growReader(env: Env, fetcher: typeof fetch = fetch): ReadGrow { | ||
| 283 | 316 | }; |
| 284 | 317 | } |
| 285 | 318 | |
| 319 | const GROW_LOG_USAGE = "usage: /grow log · /grow planted <YYYY-MM-DD>|none · /grow note <text>"; | |
| 286 | 320 | 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…]"; |
| 287 | 321 | |
| 288 | 322 | /** `/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 { | ||
| 489 | 523 | /** The terminal holds no session to end: the forge signs who is at the keyboard afresh on every connection. */ |
| 490 | 524 | 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"; |
| 491 | 525 | |
| 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"; | |
| 493 | 527 | |
| 494 | 528 | /** The Machine feed's socket beside the lobby's: same app, same port. */ |
| 495 | 529 | export const machineUrl = (live: string): string => live.replace(/\/live$/, "/machine"); |
| @@ -645,7 +679,7 @@ function target(room: Room, word: string): string | null { | ||
| 645 | 679 | return ID.test(word) ? word : null; |
| 646 | 680 | } |
| 647 | 681 | |
| 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; | |
| 649 | 683 | |
| 650 | 684 | /** What a typed line asks for. Validation the server repeats anyway; this only saves a round trip on a typo. */ |
| 651 | 685 | export function typed(room: Room, input: string): Typed { |
| @@ -673,6 +707,13 @@ export function typed(room: Room, input: string): Typed { | ||
| 673 | 707 | if (joint === undefined || rest.length !== 1 || !/^\d{1,3}$/.test(rest[0]!)) return { print: TUNE_USAGE }; |
| 674 | 708 | return { tune: { joint: Number(joint), p: Number(rest[0]) } }; |
| 675 | 709 | } |
| 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 | } | |
| 676 | 717 | if (/^grow$/i.test(verb)) { |
| 677 | 718 | if (!arg) return { grow: null }; |
| 678 | 719 | // No device word is the light, as /grow on|off|schedule|switch was before the pump. |
| @@ -797,12 +838,14 @@ export class Door { | ||
| 797 | 838 | private readonly readTrace: ReadTrace | null; |
| 798 | 839 | private readonly readVersions: ReadVersions | null; |
| 799 | 840 | private readonly readStations: ReadStations | null; |
| 841 | private readonly readGrowLog: ReadGrowLog | null; | |
| 800 | 842 | private readonly readAlign: ReadAlign | null; |
| 801 | 843 | private readonly readData: ReadData | null; |
| 802 | 844 | private readonly readGrow: ReadGrow | null; |
| 803 | 845 | |
| 804 | 846 | /** `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; | |
| 806 | 849 | this.readGrow = readGrow; |
| 807 | 850 | this.readData = readData; |
| 808 | 851 | this.readTrace = readTrace; |
| @@ -864,6 +907,7 @@ export class Door { | ||
| 864 | 907 | else if ("tune" in t) void this.tune(t.tune); |
| 865 | 908 | else if ("versions" in t) void this.versions(); |
| 866 | 909 | else if ("stations" in t) void this.stations(t.stations); |
| 910 | else if ("growLog" in t) void this.growLog(t.growLog); | |
| 867 | 911 | else if ("machine" in t) this.watchMachine(t.machine); |
| 868 | 912 | else this.send(t.send); |
| 869 | 913 | return true; |
| @@ -885,6 +929,13 @@ export class Door { | ||
| 885 | 929 | for (const line of r.error ? [`error: ${r.error}`] : healthLines(r.rows)) this.print(line); |
| 886 | 930 | } |
| 887 | 931 | |
| 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 | ||
| 888 | 939 | /** The server decides who may change them; anyone may list. */ |
| 889 | 940 | private async stations(set: string | null): Promise<void> { |
| 890 | 941 | if (!this.readStations) return this.print("error: stations are not available here"); |
Software/so101/src/tty.ts +2 −2
| @@ -4,7 +4,7 @@ | ||
| 4 | 4 | */ |
| 5 | 5 | |
| 6 | 6 | 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"; | |
| 8 | 8 | |
| 9 | 9 | const env = process.env; |
| 10 | 10 | 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) => { | ||
| 17 | 17 | rl?.prompt(true); |
| 18 | 18 | }; |
| 19 | 19 | |
| 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)); | |
| 21 | 21 | print(`\x1b[1mSO-101 arm\x1b[0m · ${where} · ${HELP}`); |
| 22 | 22 | try { |
| 23 | 23 | 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; } | ||
| 29 | 29 | .sight:empty { display: none; } |
| 30 | 30 | .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; } |
| 31 | 31 | .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; } | |
| 32 | 35 | .card p { color: var(--dim); margin: .4rem 0; } |
| 33 | 36 | .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); } |
| 34 | 37 | .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; } | ||
| 45 | 48 | export interface Looker { |
| 46 | 49 | signedIn: SignedIn | null; |
| 47 | 50 | 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; | |
| 48 | 55 | } |
| 49 | 56 | |
| 50 | 57 | function card({ station, present, resting, cameras }: StationView, looker: Looker): string { |
| @@ -67,11 +74,12 @@ function card({ station, present, resting, cameras }: StationView, looker: Looke | ||
| 67 | 74 | }) |
| 68 | 75 | .join(""); |
| 69 | 76 | 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>`; | |
| 71 | 79 | } |
| 72 | 80 | |
| 73 | 81 | /** 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__`; | |
| 75 | 83 | |
| 76 | 84 | export function renderWorkshop(views: readonly StationView[], looker: Looker = { signedIn: null, owner: false }): string { |
| 77 | 85 | return `<!doctype html> |
| @@ -90,7 +98,7 @@ export function renderWorkshop(views: readonly StationView[], looker: Looker = { | ||
| 90 | 98 | <h1>${WORKSHOP_TITLE}</h1> |
| 91 | 99 | <div class="cards">${views.map((v) => card(v, looker)).join("\n")}</div> |
| 92 | 100 | </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>` : ""} | |
| 94 | 102 | </body> |
| 95 | 103 | </html> |
| 96 | 104 | `; |