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

Migrated from github.com/Trevato/SO-ARM100

workbenchthis machine

Grow watchdog: while plants are in the box, the app checks cameras, the Pi, stills and the light every 5 min and files an issue for the crew when something breaks #630

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

quill/arm:quill/628-grow-watchdog-while-plants-are-in-the-bo → main 6 commits fork 2 threads · 2 unresolved

  • Merged. quill/628-grow-watchdog-while-plants-are-in-the-bo was merged into main as 79a6bf9.

Review

Sign in to review this change.
18 files changed +1068 −26
Software/so101/README.md +28
@@ -168,6 +168,34 @@ An ADS1115 breakout on the Pi's I²C bus (address 0x48; `sudo apt install i2c-to
168168
169169 The page paints from the last answer (a few seconds old, shown with its age) and refreshes it in the background, so a Pi that is down or slow never delays `/care`. The terminal has the same two doors: `/probes` prints the readings, calibration dates and the next step (anyone), and `/probes calibrate <ph7|ph4|ec1413> [solution tempC]` captures a step (owner). Both go through the same code as the page's buttons, and a calibration made in either shows live for everyone watching `/care`.
170170
171 ## The grow watchdog (on the Grow box card)
172
173 While there are plants in the box, the hosted app checks the grow station itself every 5 minutes. When something breaks it files a forge issue for the crew, so the owner doesn't have to find it. "Plants in the box" means a `sow` in the care log with no `end` after it. To end a grow, log the action **end** on `/care` (or `/care did end`); the next `sow` starts the watchdog again. Before the first sow it watches nothing (src/grow-watchdog.ts).
174
175 | check | broken when | issue |
176 |---|---|---|
177 | Cameras | a grow camera's snapshot fails for 10 min | "Grow box: camera <name> dark since <time>" |
178 | Pi server | the Pi's `/status` or `/version` gets no answer for 10 min (only when `ARM_SERVER` names a Pi) | "Grow box: the Pi isn't answering", saying whether the arm was unplugged |
179 | Timelapse | no new still for 30 min (or two still intervals, if that is longer) | "Grow box: timelapse stopped" |
180 | Light | with a switch, its state differs from the schedule (or from an override still in force) for 30 min. With no switch, the newest still's mean brightness (above 60 of 255 counts as lit; stills older than 15 min are ignored) disagrees with the schedule for 30 min | "Grow box: light <on/off> outside its schedule" |
181 | Care | a reminder is overdue more than 12 h | no issue. It goes in the owner's log on `/admin`, because only the owner can do it |
182
183 - **One issue per check.** Each issue's body has a line `Watchdog key: \`grow-watchdog:<check>\``. Before filing, the watchdog looks for an open issue with that line, page after page (up to 10 pages of 100 open issues), so a restart never files twice. When the check recovers, the watchdog comments with the downtime ("Recovered at … after 1 h 20 min down") and closes the issue. Ending the grow closes them too. State is kept in `FORGE_APP_DATA/so101/watchdog.json`.
184 - **The forge client** (src/forge-issues.ts) files with the app's own token. Each issue is labelled `grow-watchdog` and assigned to a builder. Settings: `GROW_WATCHDOG_TOKEN` (a secret; without it nothing is filed and the strip says why), `GROW_WATCHDOG_REPO` (`trevato/arm`), `GROW_WATCHDOG_ASSIGNEE` (`quill`; empty for no assignee), and `GROW_WATCHDOG_FORGE` (else `FORGE_URL`, else `https://forge.open-platform.sh`). The app reaches the forge through the cell's egress proxy, and `forge.json` allows `forge.open-platform.sh:443` there.
185 - **The forge's routes.** These were tried against forge.open-platform.sh on 2026-10-27 with one marked issue (#634):
186 - `POST /api/v1/repos/<owner>/<repo>/issues {title, body}` opens an issue and answers it with its `number`. Labels and an assignee sent here are ignored.
187 - `GET …/issues?state=open&limit=100` answers `{items, next_cursor}`. Each item has `number`, `kind`, `state` and `body`; the watchdog finds its key line in `body`. `&cursor=<next_cursor>` asks for the next page; `next_cursor` is null on the last one.
188 - `POST …/issues/<n>/comments {body}` comments.
189 - `PATCH …/issues/<n> {state: "closed"}` closes. Any state but `open` or `closed` is `invalid_state`.
190 - `PUT …/issues/<n>/assignee {assignee: <username>}` assigns. An unknown name is `no_such_user`.
191 - `POST …/labels {name, color}` makes the label, once. If that is refused, `GET …/labels` says whether it exists already; if it does not, the strip says so ("#12 label not made (…)") and the next issue tries again. `PUT …/issues/<n>/labels {labels: [names]}` sets it.
192
193 The token needs scope to close, assign and label, which is more than opening and commenting need. A crew agent's token is refused those three. Of all the request bodies, only the labels body could not be confirmed past that scope check. Labelling and assigning are best-effort: an issue that was filed but not labelled is still found again by its key line, and the strip says what did not take ("#12 not labelled (the forge answered 403 …)").
194 - **The strip.** Only the owner sees it, on the grow card on `/`. It has one line per check: a dot (green fine, amber watching, red broken, grey not checked), the state spelled out, the time it went bad, what was seen, and the issue's number as a link. Under it: when the watchdog last looked, and why nothing is filed, if nothing can be. The strip is drawn on the server and needs no script.
195 - **In the terminal.** `/watchdog` (the owner's) prints the same rows from `GET /grow/watchdog`. That route answers the owner alone; anyone else gets 404, so a guest sees nothing there, as on the page. `/care did end` ends a grow from the terminal.
196 - **The camera check asks for what viewers get.** `CameraHub.snapshot(id)` (src/cameras.ts) works out a camera's still-frame URL for viewers' `/camera/<id>/snapshot`, for the grow stills and for the watchdog alike.
197 - **It only reports.** The watchdog reads cameras, the Pi, the stills, the light and the care log. It never switches the light, runs a pump or doses.
198
171199 ## The story page: /story
172200
173201 `/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 +6 −1
@@ -32,6 +32,8 @@ import { GrowLog } from "../src/grow-log.ts";
3232 import { GrowStills, parseEvery } from "../src/grow-stills.ts";
3333 import { startServer } from "../src/server.ts";
3434 import { loadGrowKey, parseGrowKey } from "../src/grow-key.ts";
35 import { EVERY_MS, GrowWatchdog } from "../src/grow-watchdog.ts";
36 import { forgeIssuesFromEnv } from "../src/forge-issues.ts";
3537
3638 const fake = new FakeBus([1, 2, 3, 4, 5, 6]);
3739 const ranges: Record<number, [number, number]> = { 1: [818, 3487], 2: [813, 3202], 3: [900, 3100], 4: [700, 3300], 5: [600, 3400], 6: [1995, 3525] };
@@ -85,6 +87,9 @@ const facts = new Facts({ file: join(dir, "facts.sqlite") });
8587 const piSync = piSyncFromEnv(process.env, remote?.http ?? null, remote?.host ?? null, () => lobby.turns.snapshot().driver === null, dir);
8688 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) };
8789 const careProfile = loadProfiles(BUILT_IN_DIR, join(dir, "care-profiles")).find((p) => p.id === (process.env.CARE_PROFILE ?? "spinach"));
88 const relay = await startRelay({ growKey, probeEveryMs: 300_000, sensorsMs: 600_000, care: careProfile ? { log: new CareLog(join(dir, "care.json")), profile: careProfile, dosingFile: join(dir, "dosing.json") } : undefined, 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") });
90 // The grow watchdog: files forge issues for the crew while plants are in the box; a reminder only the owner can do goes to /admin's log.
91 const forge = forgeIssuesFromEnv(process.env);
92 const watchdog = new GrowWatchdog({ file: join(dir, "watchdog.json"), desk: forge.desk, why: forge.why, notify: (text) => lobby.admin.record({ id: "grow-watchdog", name: "Grow watchdog", owner: false }, text) });
93 const relay = await startRelay({ watchdog: { dog: watchdog, everyMs: EVERY_MS }, growKey, probeEveryMs: 300_000, sensorsMs: 600_000, care: careProfile ? { log: new CareLog(join(dir, "care.json")), profile: careProfile, dosingFile: join(dir, "dosing.json") } : undefined, 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") });
8994 const { mode, host: armHost } = relay.link();
9095 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/cameras.ts +9
@@ -148,6 +148,15 @@ export class CameraHub {
148148 return this.specs.find((c) => c.id === id)?.source ?? null;
149149 }
150150
151 /**
152 * Where a listed camera's still frame is, or null when unknown: its source with the last path part made "snapshot"
153 * (ustreamer's /stream beside /snapshot). The one place it is worked out: viewers' /camera/<id>/snapshot, the grow
154 * stills and the grow watchdog all ask here, so the watchdog checks the frame viewers get.
155 */
156 snapshot(id: number): string | null {
157 return this.source(id)?.replace(/\/[^/]*$/, "/snapshot") ?? null;
158 }
159
151160 /** End every viewer's stream (the arm is resting); each feed lets go of its source once nobody watches, and may open again. */
152161 endViewers(): void {
153162 for (const feed of this.feeds.values()) feed.endViewers();
Software/so101/src/care-log.ts +1 −1
@@ -9,7 +9,7 @@ import { MEASURES, type Measure } from "./care-profile.ts";
99
1010 export const MAX_ENTRIES = 5000;
1111 export const SENSOR_EVERY_MS = 3_600_000;
12 export const ACTIONS = ["sow", "water", "topoff", "change", "nutrients", "phdown", "thin", "harvest", "airstone", "pumpcheck", "transplant", "done"] as const;
12 export const ACTIONS = ["sow", "water", "topoff", "change", "nutrients", "phdown", "thin", "harvest", "airstone", "pumpcheck", "transplant", "done", "end"] as const;
1313 export const PROBLEMS = ["bolting", "tip burn", "root rot", "algae", "yellowing", "leggy"] as const;
1414 export const SYSTEMS = ["vertical tower", "DWC", "NFT", "Kratky", "ebb-and-flow"] as const;
1515 export type Action = (typeof ACTIONS)[number];
Software/so101/src/care-terminal.test.ts +2
@@ -41,6 +41,8 @@ test("a /care line parses into the ask the page's buttons make", () => {
4141 expect(typed(emptyRoom(), "/care")).toEqual({ care: { method: "GET", path: "/care/api" } });
4242 expect(typed(emptyRoom(), "/care log ph 6.1")).toEqual({ care: { method: "POST", path: "/care/log", body: { type: "measurement", kind: "ph", value: 6.1 } } });
4343 expect(typed(emptyRoom(), "/care did harvest 40")).toEqual({ care: { method: "POST", path: "/care/log", body: { type: "action", what: "harvest", amount: 40 } } });
44 // Ending a grow from the terminal: what stops the grow watchdog (src/grow-watchdog.ts).
45 expect(typed(emptyRoom(), "/care did end")).toEqual({ care: { method: "POST", path: "/care/log", body: { type: "action", what: "end" } } });
4446 expect(typed(emptyRoom(), "/care problem tip burn on the top row")).toEqual({ care: { method: "POST", path: "/care/log", body: { type: "problem", what: "tip burn", text: "on the top row" } } });
4547 expect(typed(emptyRoom(), "/care snooze roots 48")).toEqual({ care: { method: "POST", path: "/care/reminder", body: { id: "roots", action: "snooze", hours: 48 } } });
4648 expect(typed(emptyRoom(), "/care delete 3")).toMatchObject({ care: { method: "DELETE", path: "/care/log/3" } });
Software/so101/src/forge-issues.ts added +151
@@ -0,0 +1,151 @@
1 /**
2 * The forge's issues, as the grow watchdog (src/grow-watchdog.ts) needs them: find the open one carrying a key, open
3 * one, comment on one, close one. Every request carries the app's own token and nothing a visitor typed; the token is
4 * never logged, and a failed request says only its status.
5 *
6 * The forge's routes, as tried against forge.open-platform.sh on 2026-10-27 (pull #630):
7 * GET /api/v1/repos/<o>/<r>/issues?state=open&limit=100[&cursor=<next_cursor>] → {items: [{number, kind, state, body, …}], next_cursor}
8 * (next_cursor is null on the last page; find follows it, at most 10 pages)
9 * POST /api/v1/repos/<o>/<r>/issues {title, body} → the issue, with its number (labels and assignee here are ignored)
10 * POST /api/v1/repos/<o>/<r>/issues/<n>/comments {body} → the comment
11 * PATCH /api/v1/repos/<o>/<r>/issues/<n> {state: "closed"} ("open" or "closed"; any other state is invalid_state)
12 * PUT /api/v1/repos/<o>/<r>/issues/<n>/assignee {assignee: <username>} (no_such_user for an unknown name)
13 * GET /api/v1/repos/<o>/<r>/labels → [{id, name, color}] · POST /api/v1/repos/<o>/<r>/labels {name, color} · PUT /api/v1/repos/<o>/<r>/issues/<n>/labels {labels: [names]}
14 * Closing, assigning and labelling need a token with more scope than reading and opening; the label's body shape is the
15 * one route not confirmed past the scope check. Labelling and assigning are best-effort: an issue that is filed but not
16 * labelled is still found again by its key line, and the strip says what did not take.
17 *
18 * GROW_WATCHDOG_TOKEN the app's forge token (a secret); unset, nothing is filed and the strip says so
19 * GROW_WATCHDOG_FORGE the forge's address (else FORGE_URL, else https://forge.open-platform.sh)
20 * GROW_WATCHDOG_REPO owner/name the issues go to (trevato/arm)
21 * GROW_WATCHDOG_ASSIGNEE the crew builder each issue is assigned to (quill)
22 */
23
24 export const LABEL = "grow-watchdog";
25 /** How many pages of open issues `find` reads, 100 each. */
26 const PAGES = 10;
27
28 export interface IssueDesk {
29 /** The number of the open issue whose body carries `key`, or null. */
30 find(key: string): Promise<number | null>;
31 /** Open an issue: its number, and what did not take (the label, the assignee), if anything. */
32 open(title: string, body: string): Promise<{ number: number; warning: string | null }>;
33 comment(number: number, body: string): Promise<void>;
34 close(number: number): Promise<void>;
35 /** Where an issue is read, for the strip's link. */
36 link(number: number): string;
37 }
38
39 /** The line in an issue's body the watchdog finds it again by. */
40 export const keyLine = (key: string): string => `Watchdog key: \`${LABEL}:${key}\``;
41
42 const REPO = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}\/[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
43 const NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
44 /** Visible text only, and not too much of it. */
45 const clean = (s: string, max: number): string => s.replace(/[\p{Cc}\p{Cf}]/gu, (c) => (c === "\n" ? c : "")).slice(0, max);
46
47 export interface ForgeOptions {
48 base: string;
49 token: string;
50 repo: string;
51 assignee: string | null;
52 fetch?: typeof fetch;
53 }
54
55 export class ForgeIssues implements IssueDesk {
56 private readonly api: string;
57 private readonly repoApi: string;
58 private labelled = false;
59 private readonly web: string;
60 private readonly o: ForgeOptions;
61 constructor(o: ForgeOptions) {
62 this.o = o;
63 const base = new URL(o.base);
64 if (base.protocol !== "https:" && base.protocol !== "http:") throw new Error("the forge's address is http(s)");
65 if (!REPO.test(o.repo)) throw new Error("the repository is owner/name");
66 if (o.assignee !== null && !NAME.test(o.assignee)) throw new Error("the assignee is a forge name");
67 this.repoApi = `${base.origin}/api/v1/repos/${o.repo}`;
68 this.api = `${this.repoApi}/issues`;
69 this.web = `${base.origin}/${o.repo}/issues`;
70 }
71
72 private async ask(path: string, method: string, body?: unknown, root = this.api): Promise<unknown> {
73 const answer = await (this.o.fetch ?? fetch)(`${root}${path}`, {
74 method,
75 headers: { authorization: `Bearer ${this.o.token}`, accept: "application/json", ...(body === undefined ? {} : { "content-type": "application/json" }) },
76 body: body === undefined ? undefined : JSON.stringify(body),
77 signal: AbortSignal.timeout(10_000),
78 });
79 if (!answer.ok) throw new Error(`the forge answered ${answer.status} to ${method} ${root === this.api ? "issues" : ""}${path.replace(/\?.*$/, "")}`);
80 return answer.json().catch(() => null);
outdated

Only page one is read. Follow next_cursor (bounded) so the key line is found past 100 open issues; add a test with the key on page two.

81 }
82
83 async find(key: string): Promise<number | null> {
84 const line = keyLine(key);
85 let cursor: string | null = null;
86 // Page after page until the forge says there is no next one, or PAGES of them: past 1,000 open issues the key is lost.
87 for (let page = 0; page < PAGES; page++) {
88 const got = (await this.ask(`?state=open&limit=100${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`, "GET")) as { items?: unknown; next_cursor?: unknown } | unknown[] | null;
89 const items = Array.isArray(got) ? got : Array.isArray((got as { items?: unknown })?.items) ? ((got as { items: unknown[] }).items) : [];
90 for (const i of items as { number?: unknown; body?: unknown; kind?: unknown; state?: unknown }[]) {
91 if (typeof i?.number === "number" && typeof i.body === "string" && i.body.includes(line) && i.kind !== "pull" && i.state !== "closed") return i.number;
92 }
93 const next = Array.isArray(got) ? null : (got as { next_cursor?: unknown } | null)?.next_cursor;
outdated

A failed label creation is swallowed and labelled is still set to true. Set it only on success (or when the forge says it exists), and report the failure in the warning.

94 if (typeof next !== "string" && typeof next !== "number") return null;
95 if (String(next) === cursor) return null;
96 cursor = String(next);
97 }
98 return null;
99 }
100
101 async open(title: string, body: string): Promise<{ number: number; warning: string | null }> {
102 const got = (await this.ask("", "POST", { title: clean(title, 200), body: clean(body, 8000) })) as { number?: unknown } | null;
103 if (typeof got?.number !== "number" || !Number.isInteger(got.number)) throw new Error("the forge did not say the new issue's number");
104 const n = got.number;
105 const missed: string[] = [];
106 if (!this.labelled) {
107 // Made once. A refusal may only mean it exists already, so the forge's own list decides; failing both, the next issue tries again.
108 this.labelled = await this.ask("/labels", "POST", { name: LABEL, color: "2da44e" }, this.repoApi).then(
109 () => true,
110 async (err: Error) => {
111 const names = await this.ask("/labels", "GET", undefined, this.repoApi).catch(() => null);
112 const there = Array.isArray(names) && names.some((l: { name?: unknown } | null) => l?.name === LABEL);
113 if (!there) missed.push(`label not made (${err.message})`);
114 return there;
115 },
116 );
117 }
118 await this.ask(`/${n}/labels`, "PUT", { labels: [LABEL] }).catch((err: Error) => missed.push(`not labelled (${err.message})`));
119 if (this.o.assignee) await this.ask(`/${n}/assignee`, "PUT", { assignee: this.o.assignee }).catch((err: Error) => missed.push(`not assigned (${err.message})`));
120 return { number: n, warning: missed.length ? `#${n} ${missed.join("; ")}` : null };
121 }
122
123 async comment(number: number, body: string): Promise<void> {
124 await this.ask(`/${number}/comments`, "POST", { body: clean(body, 8000) });
125 }
126
127 async close(number: number): Promise<void> {
128 await this.ask(`/${number}`, "PATCH", { state: "closed" });
129 }
130
131 link(number: number): string {
132 return `${this.web}/${number}`;
133 }
134 }
135
136 /** The desk the environment names, or null with why (no token, or a setting that is not one). */
137 export function forgeIssuesFromEnv(env: Record<string, string | undefined>): { desk: ForgeIssues | null; why: string | null } {
138 const token = env.GROW_WATCHDOG_TOKEN?.trim();
139 if (!token) return { desk: null, why: "not filed: GROW_WATCHDOG_TOKEN is not set" };
140 try {
141 const desk = new ForgeIssues({
142 base: env.GROW_WATCHDOG_FORGE?.trim() || env.FORGE_URL?.trim() || "https://forge.open-platform.sh",
143 token,
144 repo: env.GROW_WATCHDOG_REPO?.trim() || "trevato/arm",
145 assignee: env.GROW_WATCHDOG_ASSIGNEE === undefined ? "quill" : env.GROW_WATCHDOG_ASSIGNEE.trim() || null,
146 });
147 return { desk, why: null };
148 } catch (err) {
149 return { desk: null, why: `not filed: ${(err as Error).message}` };
150 }
151 }
Software/so101/src/grow-frame-worker.ts +4 −4
@@ -1,10 +1,10 @@
1 /** The worker src/grow-frame.ts runs `finishStill` in, so a 1080p decode never holds up the relay's pages. */
1 /** The worker src/grow-frame.ts runs `finishStill` (or `meanLevel`) in, so a 1080p decode never holds up the relay's pages. */
22 import { parentPort } from "node:worker_threads";
3 import { finishStill, type StillLook } from "./grow-frame.ts";
3 import { finishStill, meanLevel, type StillLook } from "./grow-frame.ts";
44
5 parentPort?.once("message", ({ bytes, look }: { bytes: Uint8Array; look: StillLook }) => {
5 parentPort?.once("message", ({ bytes, look, level }: { bytes: Uint8Array; look?: StillLook; level?: true }) => {
66 try {
7 parentPort!.postMessage({ ok: true, out: finishStill(bytes, look) });
7 parentPort!.postMessage({ ok: true, out: level ? meanLevel(bytes) : finishStill(bytes, look!) });
88 } catch (err) {
99 parentPort!.postMessage({ ok: false, error: (err as Error).message });
1010 }
Software/so101/src/grow-frame.test.ts +8 −1
@@ -8,7 +8,7 @@ import { FeetechBus } from "./bus.ts";
88 import { CameraHub } from "./cameras.ts";
99 import { grabStill, STILL_HEIGHT, STILL_WIDTH, StillError, stillCommands, type Run } from "./camera-still.ts";
1010 import { FakeBus } from "./fake-bus.ts";
11 import { finishStill, finishStillApart, jpegSize } from "./grow-frame.ts";
11 import { finishStill, finishStillApart, jpegSize, meanLevelApart } from "./grow-frame.ts";
1212 import { GrowLog } from "./grow-log.ts";
1313 import { GrowStills } from "./grow-stills.ts";
1414 import { startRelay, type RunningRelay } from "./relay.ts";
@@ -233,3 +233,10 @@ test("the relay keeps each still cropped to the camera's region, with a correcte
233233 expect(corrected.r / raw.r).toBeCloseTo(2, 1);
234234 expect((await fetch(`${base}/grow/stills/1234567890-corrected.jpg`)).status).toBe(404);
235235 });
236
237 test("meanLevelApart tells a lit still from a dark one, in a worker", async () => {
238 const flat = (v: number) => new Uint8Array(jpeg.encode({ data: Buffer.alloc(64 * 48 * 4, v), width: 64, height: 48 }, 90).data);
239 expect(await meanLevelApart(flat(200))).toBeGreaterThan(150);
240 expect(await meanLevelApart(flat(10))).toBeLessThan(30);
241 await expect(meanLevelApart(new Uint8Array([1, 2, 3]))).rejects.toThrow();
242 });
Software/so101/src/grow-frame.ts +19 −3
@@ -72,6 +72,15 @@ export function finishStill(bytes: Uint8Array, look: StillLook): Finished {
7272 return { raw, corrected, cropped: !whole, width, height };
7373 }
7474
75 /** The mean brightness (Rec. 601 luma, 0 to 255) of a JPEG, every 4th pixel: how lit the box looks. Throws on a JPEG it cannot read. */
76 export function meanLevel(bytes: Uint8Array): number {
77 const { data } = jpeg.decode(bytes, { useTArray: true, formatAsRGBA: true, maxResolutionInMP: MAX_MP, maxMemoryUsageInMB: 256 });
78 let sum = 0;
79 let n = 0;
80 for (let i = 0; i + 2 < data.length; i += 16, n++) sum += 0.299 * data[i]! + 0.587 * data[i + 1]! + 0.114 * data[i + 2]!;
81 return n ? sum / n : 0;
82 }
83
7584 /** The rows of `rect` out of an RGBA frame `width` pixels wide. */
7685 function crop(data: Uint8Array, width: number, rect: Rect): Uint8Array {
7786 const out = new Uint8Array(rect.w * rect.h * 4);
@@ -85,11 +94,18 @@ function crop(data: Uint8Array, width: number, rect: Rect): Uint8Array {
8594 /** `finishStill` in a worker of its own, ended when it answers or after `timeoutMs`. */
8695 export function finishStillApart(bytes: Uint8Array, look: StillLook, timeoutMs = 20_000): Promise<Finished> {
8796 if (!look.region && !look.gains) return Promise.resolve(finishStill(bytes, look));
97 return apart<Finished>({ bytes, look }, timeoutMs);
98 }
99
100 /** `meanLevel` in a worker of its own, so the grow watchdog's look at a still holds up no page. */
101 export const meanLevelApart = (bytes: Uint8Array, timeoutMs = 20_000): Promise<number> => apart<number>({ bytes, level: true }, timeoutMs);
102
103 function apart<T>(message: { bytes: Uint8Array; look?: StillLook; level?: true }, timeoutMs: number): Promise<T> {
88104 return new Promise((resolve, reject) => {
89105 const worker = new Worker(new URL("./grow-frame-worker.ts", import.meta.url));
90106 const timer = setTimeout(() => done(new Error("finishing the still took too long")), timeoutMs);
91107 let settled = false;
92 const done = (err: Error | null, out?: Finished) => {
108 const done = (err: Error | null, out?: T) => {
93109 if (settled) return;
94110 settled = true;
95111 clearTimeout(timer);
@@ -97,8 +113,8 @@ export function finishStillApart(bytes: Uint8Array, look: StillLook, timeoutMs =
97113 if (err) reject(err);
98114 else resolve(out!);
99115 };
100 worker.once("message", (m: { ok: true; out: Finished } | { ok: false; error: string }) => (m.ok ? done(null, m.out) : done(new Error(m.error))));
116 worker.once("message", (m: { ok: true; out: T } | { ok: false; error: string }) => (m.ok ? done(null, m.out) : done(new Error(m.error))));
101117 worker.once("error", (err: unknown) => done(err instanceof Error ? err : new Error(String(err))));
102 worker.postMessage({ bytes, look });
118 worker.postMessage(message);
103119 });
104120 }
Software/so101/src/grow-watchdog.test.ts added +344
@@ -0,0 +1,344 @@
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 { CareLog, type Entry } from "./care-log.ts";
8 import { ForgeIssues, keyLine, type IssueDesk } from "./forge-issues.ts";
9 import { growLive, GrowWatchdog, type Readings } from "./grow-watchdog.ts";
10 import { sign } from "./identity.ts";
11 import { Lobby } from "./lobby.ts";
12 import { startRelay, type RunningRelay } from "./relay.ts";
13 import { StationHub } from "./stations.ts";
14 import { renderWatch } from "./workshop-page.ts";
15 import { Door, emptyRoom, typed, watchdogLines, watchdogReader } from "./terminal.ts";
16
17 const MIN = 60_000;
18 const T0 = Date.UTC(2026, 9, 27, 12, 0);
19
20 /** A forge that keeps its issues in memory and remembers every call. */
21 function fakeDesk(existing: { number: number; key: string }[] = []) {
22 const calls: string[] = [];
23 const open = new Map(existing.map((e) => [e.number, keyLine(e.key)]));
24 let next = 100;
25 const desk: IssueDesk = {
26 find: async (key) => {
27 calls.push(`find ${key}`);
28 for (const [n, body] of open) if (body.includes(keyLine(key))) return n;
29 return null;
30 },
31 open: async (title, body) => {
32 calls.push(`open ${title}`);
33 open.set(next, body);
34 return { number: next++, warning: null };
35 },
36 comment: async (n, body) => void calls.push(`comment #${n} ${body}`),
37 close: async (n) => {
38 calls.push(`close #${n}`);
39 open.delete(n);
40 },
41 link: (n) => `https://forge.example/trevato/arm/issues/${n}`,
42 };
43 return { desk, calls, open };
44 }
45
46 const quiet: Readings = { live: true, cameras: [{ id: 2, name: "Grow cam", ok: true }], pi: { reached: true, arm: "ready" }, stills: { latestAt: T0, everyMs: 10 * MIN }, light: { expected: true, actual: true, by: "switch" }, overdue: [], zone: "UTC" };
47
48 /** A watchdog on a clock the test moves. */
49 function dog(desk: IssueDesk | null, o: { file?: string } = {}) {
50 let now = T0;
51 const told: string[] = [];
52 const w = new GrowWatchdog({ desk, why: desk ? null : "not filed: GROW_WATCHDOG_TOKEN is not set", notify: (t) => told.push(t), now: () => now, ...o });
53 return { w, told, at: (ms: number) => (now = T0 + ms) };
54 }
55 const opens = (calls: string[]) => calls.filter((c) => c.startsWith("open "));
56
57 test("a camera down for 10 min files one issue; recovery comments the downtime and closes it", async () => {
58 const { desk, calls } = fakeDesk();
59 const { w, at } = dog(desk);
60 const dark = { ...quiet, cameras: [{ id: 2, name: "Grow cam", ok: false }] };
61 await w.tick(dark);
62 expect(w.status().rows.find((r) => r.check === "cameras")).toMatchObject({ state: "amber", since: T0 });
63 at(9 * MIN);
64 await w.tick({ ...dark, stills: { latestAt: T0 + 9 * MIN, everyMs: 10 * MIN } });
65 expect(opens(calls)).toEqual([]);
66 at(10 * MIN);
67 await w.tick({ ...dark, stills: { latestAt: T0 + 9 * MIN, everyMs: 10 * MIN } });
68 expect(opens(calls)).toEqual(["open Grow box: camera Grow cam dark since 2026-10-27 12:00"]);
69 expect(w.status().rows.find((r) => r.check === "cameras")).toMatchObject({ state: "red", issue: { number: 100, href: "https://forge.example/trevato/arm/issues/100" } });
70 at(15 * MIN);
71 await w.tick({ ...dark, stills: { latestAt: T0 + 14 * MIN, everyMs: 10 * MIN } });
72 expect(opens(calls)).toHaveLength(1);
73 at(80 * MIN);
74 await w.tick({ ...quiet, stills: { latestAt: T0 + 79 * MIN, everyMs: 10 * MIN } });
75 expect(calls).toContain("comment #100 Recovered at 2026-10-27 13:20 after 1 h 20 min down.");
76 expect(calls[calls.length - 1]).toBe("close #100");
77 expect(w.status().rows.every((r) => r.state === "ok")).toBe(true);
78 });
79
80 test("de-duplicated: an open issue with the key is reused, and a restart with the state file files nothing new", async () => {
81 const dir = mkdtempSync(join(tmpdir(), "so101-watchdog-"));
82 try {
83 const { desk, calls } = fakeDesk([{ number: 42, key: "pi" }]);
84 const file = join(dir, "watchdog.json");
85 const first = dog(desk, { file });
86 const down = { ...quiet, pi: { reached: false, arm: null } };
87 await first.w.tick(down);
88 first.at(11 * MIN);
89 await first.w.tick({ ...down, stills: { latestAt: T0 + 11 * MIN, everyMs: 10 * MIN } });
90 expect(opens(calls)).toEqual([]);
91 expect(first.w.status().rows.find((r) => r.check === "pi")?.issue?.number).toBe(42);
92 const again = dog(desk, { file });
93 again.at(20 * MIN);
94 await again.w.tick({ ...down, stills: { latestAt: T0 + 20 * MIN, everyMs: 10 * MIN } });
95 expect(calls.filter((c) => c === "find pi")).toHaveLength(1);
96 expect(opens(calls)).toEqual([]);
97 } finally {
98 rmSync(dir, { recursive: true, force: true });
99 }
100 });
101
102 test("the Pi, the stills and the light each file their own issue, saying what they saw", async () => {
103 const { desk, calls, open } = fakeDesk();
104 const { w, at } = dog(desk);
105 const bad: Readings = { ...quiet, pi: { reached: false, arm: "not connected" }, stills: { latestAt: T0 - 40 * MIN, everyMs: 10 * MIN }, light: { expected: false, actual: true, by: "switch" } };
106 await w.tick(bad);
107 expect(opens(calls)).toEqual(["open Grow box: timelapse stopped"]);
108 at(31 * MIN);
109 await w.tick(bad);
110 expect(opens(calls).sort()).toEqual(["open Grow box: light on outside its schedule", "open Grow box: the Pi isn't answering", "open Grow box: timelapse stopped"]);
111 expect([...open.values()].some((b) => b.includes("The arm is unplugged"))).toBe(true);
112 expect([...open.values()].every((b) => b.includes("Watchdog key: `grow-watchdog:"))).toBe(true);
113 // Not knowing (the light could not be read) keeps the issue open; it is not a recovery.
114 at(40 * MIN);
115 await w.tick({ ...bad, light: null });
116 expect(calls.filter((c) => c.startsWith("close"))).toEqual([]);
117 });
118
119 test("a light read from the stills: lit during the dark period for 30 min files; a stale still says nothing", async () => {
120 const { desk, calls } = fakeDesk();
121 const { w, at } = dog(desk);
122 await w.tick({ ...quiet, light: { expected: false, actual: true, by: "stills" } });
123 at(20 * MIN);
124 await w.tick({ ...quiet, stills: { latestAt: T0 + 20 * MIN, everyMs: 10 * MIN }, light: { expected: false, actual: null, by: "stills" } });
125 expect(w.status().rows.find((r) => r.check === "light")).toMatchObject({ state: "amber", text: "could not tell this time" });
126 at(30 * MIN);
127 await w.tick({ ...quiet, stills: { latestAt: T0 + 30 * MIN, everyMs: 10 * MIN }, light: { expected: false, actual: true, by: "stills" } });
128 expect(opens(calls)).toEqual(["open Grow box: light on outside its schedule"]);
129 });
130
131 test("a reminder overdue more than 12 h tells the owner once and files nothing", async () => {
132 const { desk, calls } = fakeDesk();
133 const { w, told, at } = dog(desk);
134 const due = { ...quiet, overdue: [{ id: "topoff", label: "Top off the reservoir", dueAt: T0 - 11 * 60 * MIN }] };
135 await w.tick(due);
136 expect(told).toEqual([]);
137 expect(w.status().rows.find((r) => r.check === "care")?.state).toBe("amber");
138 at(61 * MIN);
139 await w.tick({ ...due, stills: { latestAt: T0 + 61 * MIN, everyMs: 10 * MIN } });
140 at(66 * MIN);
141 await w.tick({ ...due, stills: { latestAt: T0 + 66 * MIN, everyMs: 10 * MIN } });
142 expect(told).toEqual(["Grow box: Top off the reservoir: due 12 h 1 min ago. It's yours to do: the crew can't."]);
143 expect(w.status().rows.find((r) => r.check === "care")?.state).toBe("red");
144 expect(calls.filter((c) => !c.startsWith("find"))).toEqual([]);
145 });
146
147 test("no plants, no watching: nothing is filed, and an open issue closes when the grow ends", async () => {
148 const { desk, calls } = fakeDesk();
149 const { w, at } = dog(desk);
150 const dark = { ...quiet, cameras: [{ id: 2, name: "Grow cam", ok: false }] };
151 await w.tick({ ...dark, live: false });
152 at(20 * MIN);
153 await w.tick({ ...dark, live: false });
154 expect(calls).toEqual([]);
155 expect(w.status().rows.every((r) => r.state === "off")).toBe(true);
156 await w.tick({ ...dark, stills: { latestAt: T0 + 20 * MIN, everyMs: 10 * MIN } });
157 at(31 * MIN);
158 await w.tick({ ...dark, stills: { latestAt: T0 + 31 * MIN, everyMs: 10 * MIN } });
159 expect(opens(calls)).toHaveLength(1);
160 await w.tick({ ...dark, live: false });
161 expect(calls.slice(-2)).toEqual(["comment #100 The grow ended at 2026-10-27 12:31; closing.", "close #100"]);
162 });
163
164 test("without a token the strip still shows, and says nothing is filed", async () => {
165 const { w, at } = dog(null);
166 await w.tick({ ...quiet, cameras: [{ id: 2, name: "Grow cam", ok: false }] });
167 at(11 * MIN);
168 await w.tick({ ...quiet, cameras: [{ id: 2, name: "Grow cam", ok: false }], stills: { latestAt: T0 + 11 * MIN, everyMs: 10 * MIN } });
169 const s = w.status();
170 expect(s.filing).toBe("not filed: GROW_WATCHDOG_TOKEN is not set");
171 expect(s.rows.find((r) => r.check === "cameras")).toMatchObject({ state: "red", issue: null });
172 const html = renderWatch(s);
173 expect(html).toContain('data-check="cameras" data-state="red"');
174 expect(html).toContain("broken since 2026-10-27 12:00");
175 expect(html).toContain("GROW_WATCHDOG_TOKEN is not set");
176 });
177
178 test("the strip escapes what it shows", () => {
179 const html = renderWatch({ live: true, at: T0, zone: "UTC", filing: null, rows: [{ check: "cameras", name: "Cameras", state: "red", since: T0, text: "<img onerror=x>: no snapshot", issue: { number: 7, href: 'https://f/"x' } }] });
180 expect(html).not.toContain("<img");
181 expect(html).toContain("&lt;img onerror=x&gt;");
182 expect(html).toContain('href="https://f/&quot;x"');
183 });
184
185 test("a grow is live from a sow until an end", () => {
186 const e = (what: "sow" | "end" | "harvest", at: number) => ({ id: at, at, type: "action", what }) as Entry;
187 expect(growLive([])).toBe(false);
188 expect(growLive([e("sow", 1)])).toBe(true);
189 expect(growLive([e("sow", 1), e("harvest", 2)])).toBe(true);
190 expect(growLive([e("sow", 1), e("end", 2)])).toBe(false);
191 expect(growLive([e("sow", 1), e("end", 2), e("sow", 3)])).toBe(true);
192 });
193
194 test("the forge client sends the app's token, the label and the assignee, and finds an issue by its key line", async () => {
195 const seen: { method: string; url: string; auth: string | null; body: unknown }[] = [];
196 const fetcher = (async (url: string, init: RequestInit) => {
197 seen.push({ method: init.method!, url, auth: new Headers(init.headers).get("authorization"), body: init.body ? JSON.parse(String(init.body)) : null });
198 if (init.method === "GET") return Response.json({ items: [{ number: 3, kind: "issue", state: "open", body: `x\n${keyLine("stills")}` }, { number: 4, kind: "pull", state: "open", body: keyLine("pi") }] });
199 if (init.method === "POST" && url.endsWith("/issues")) return Response.json({ number: 9 });
200 if (url.endsWith("/assignee")) return Response.json({ error: "forbidden" }, { status: 403 });
201 return Response.json({});
202 }) as unknown as typeof fetch;
203 const forge = new ForgeIssues({ base: "https://forge.example/x", token: "tok", repo: "trevato/arm", assignee: "quill", fetch: fetcher });
204 expect(await forge.find("stills")).toBe(3);
205 expect(await forge.find("pi")).toBeNull();
206 expect(await forge.open("Grow box: timelapse stopped", "b")).toEqual({ number: 9, warning: "#9 not assigned (the forge answered 403 to PUT issues/9/assignee)" });
207 await forge.comment(9, "Recovered");
208 await forge.close(9);
209 expect(seen.map((s) => `${s.method} ${s.url}`)).toEqual([
210 "GET https://forge.example/api/v1/repos/trevato/arm/issues?state=open&limit=100",
211 "GET https://forge.example/api/v1/repos/trevato/arm/issues?state=open&limit=100",
212 "POST https://forge.example/api/v1/repos/trevato/arm/issues",
213 "POST https://forge.example/api/v1/repos/trevato/arm/labels",
214 "PUT https://forge.example/api/v1/repos/trevato/arm/issues/9/labels",
215 "PUT https://forge.example/api/v1/repos/trevato/arm/issues/9/assignee",
216 "POST https://forge.example/api/v1/repos/trevato/arm/issues/9/comments",
217 "PATCH https://forge.example/api/v1/repos/trevato/arm/issues/9",
218 ]);
219 expect(seen.every((s) => s.auth === "Bearer tok")).toBe(true);
220 expect(seen[2]!.body).toEqual({ title: "Grow box: timelapse stopped", body: "b" });
221 expect(seen[3]!.body).toEqual({ name: "grow-watchdog", color: "2da44e" });
222 expect(seen[4]!.body).toEqual({ labels: ["grow-watchdog"] });
223 expect(seen[5]!.body).toEqual({ assignee: "quill" });
224 expect(seen[7]!.body).toEqual({ state: "closed" });
225 // The label is made once, not per issue.
226 await forge.open("again", "b");
227 expect(seen.filter((s) => s.url.endsWith("/arm/labels"))).toHaveLength(1);
228 expect(() => new ForgeIssues({ base: "https://f", token: "t", repo: "../etc", assignee: null })).toThrow();
229 });
230
231 test("find follows next_cursor: a key only on the second page is found, and the pages are bounded", async () => {
232 const asked: string[] = [];
233 const page = (n: number) => Array.from({ length: 100 }, (_, i) => ({ number: n * 100 + i, kind: "issue", state: "open", body: "other" }));
234 const fetcher = (async (url: string) => {
235 asked.push(url.slice(url.indexOf("?")));
236 const cursor = new URL(url).searchParams.get("cursor");
237 if (!cursor) return Response.json({ items: page(1), next_cursor: "c 2" });
238 if (cursor === "c 2") return Response.json({ items: [...page(2), { number: 7, kind: "issue", state: "open", body: keyLine("pi") }], next_cursor: null });
239 return Response.json({ error: "no such cursor" }, { status: 400 });
240 }) as unknown as typeof fetch;
241 const forge = new ForgeIssues({ base: "https://forge.example", token: "t", repo: "trevato/arm", assignee: null, fetch: fetcher });
242 expect(await forge.find("pi")).toBe(7);
243 expect(asked).toEqual(["?state=open&limit=100", "?state=open&limit=100&cursor=c%202"]);
244 asked.length = 0;
245 expect(await forge.find("light")).toBeNull();
246 expect(asked).toHaveLength(2);
247
248 // A forge whose cursor never ends is read 10 pages deep, no more.
249 let pages = 0;
250 const endless = (async () => Response.json({ items: page(++pages), next_cursor: `p${pages}` })) as unknown as typeof fetch;
251 expect(await new ForgeIssues({ base: "https://forge.example", token: "t", repo: "trevato/arm", assignee: null, fetch: endless }).find("pi")).toBeNull();
252 expect(pages).toBe(10);
253 });
254
255 test("a label that could not be made is said, and tried again with the next issue; one that exists already counts", async () => {
256 let posts = 0;
257 let exists = false;
258 const fetcher = (async (url: string, init: RequestInit) => {
259 if (url.endsWith("/arm/labels") && init.method === "POST") return posts++, Response.json({ error: "busy" }, { status: 503 });
260 if (url.endsWith("/arm/labels")) return Response.json(exists ? [{ id: "1", name: "grow-watchdog", color: "2da44e" }] : []);
261 if (url.endsWith("/issues")) return Response.json({ number: 5 });
262 return Response.json({});
263 }) as unknown as typeof fetch;
264 const forge = new ForgeIssues({ base: "https://forge.example", token: "t", repo: "trevato/arm", assignee: null, fetch: fetcher });
265 expect((await forge.open("a", "b")).warning).toBe("#5 label not made (the forge answered 503 to POST /labels)");
266 exists = true;
267 expect((await forge.open("a", "b")).warning).toBeNull();
268 expect(posts).toBe(2);
269 await forge.open("a", "b");
270 expect(posts).toBe(2);
271 });
272
273 // ---------------------------------------------------------------- the relay
274
275 let relay: RunningRelay | undefined;
276 let camera: Server | undefined;
277 afterEach(async () => {
278 await relay?.close();
279 camera?.close();
280 relay = camera = undefined;
281 });
282 const KEY = "k".repeat(32);
283 const as = (name: string, role: "admin" | "user") => {
284 const identity = `id-${name}|${name}|${role}|${Date.now() + 600_000}`;
285 return { "x-forge-identity": identity, "x-forge-signature": sign(identity, KEY) };
286 };
287
288 test("the relay looks at the grow camera, files when it stays dark, and the owner's grow card shows the strip", async () => {
289 const asked: string[] = [];
290 camera = createServer((req, res) => {
291 asked.push(req.url ?? "");
292 res.writeHead(503);
293 res.end("no frame");
294 });
295 await new Promise<void>((r) => camera!.listen(0, "127.0.0.1", r));
296 const port = (camera.address() as { port: number }).port;
297 const stations = new StationHub();
298 stations.replace([{ id: "grow", name: "Grow box", kind: "grow", cameras: [1], controls: [] }]);
299 const log = new CareLog();
300 log.add({ type: "action", what: "sow" });
301 const { desk, calls } = fakeDesk();
302 let now = T0;
303 const watchdog = new GrowWatchdog({ desk, notify: () => {}, now: () => (now += 6 * MIN) });
304 const lobby = new Lobby({ identityKey: KEY, adminFile: join(tmpdir(), `watchdog-admin-${process.pid}.json`), openFile: join(tmpdir(), `watchdog-open-${process.pid}.json`) });
305 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, care: { log, profile: (await import("./care-profile.ts")).loadProfiles((await import("./care-profile.ts")).BUILT_IN_DIR).find((p) => p.id === "spinach")! }, watchdog: { dog: watchdog, everyMs: 40 }, lobby, env: {} });
306 const base = `http://127.0.0.1:${relay.port}`;
307 for (const end = Date.now() + 3000; !opens(calls).length && Date.now() < end; ) await new Promise((r) => setTimeout(r, 20));
308 expect(opens(calls)).toEqual([expect.stringMatching(/^open Grow box: camera Grow cam dark since 2026-10-27 12:06$/)]);
309 const owner = await (await fetch(`${base}/`, { headers: as("trevato", "admin") })).text();
310 expect(owner).toContain('data-check="cameras" data-state="red"');
311 expect(owner).toContain('href="https://forge.example/trevato/arm/issues/100"');
312 const guest = await (await fetch(`${base}/`, { headers: as("guest", "user") })).text();
313 expect(guest).not.toContain("data-check=");
314 // It checks the frame viewers get: the same /snapshot their /camera/1/snapshot reaches.
315 expect(asked.length).toBeGreaterThan(0);
316 expect(asked.every((u) => u === "/snapshot")).toBe(true);
317 expect(await new CameraHub({ seed: [{ name: "c", source: "http://pi:8080/stream" }] }).snapshot(1)).toBe("http://pi:8080/snapshot");
318
319 // The terminal's door: the same rows, the owner's alone.
320 const rows = (await (await fetch(`${base}/grow/watchdog`, { headers: as("trevato", "admin") })).json()) as { live: boolean; rows: { check: string; state: string }[] };
321 expect(rows.live).toBe(true);
322 expect(rows.rows.find((r) => r.check === "cameras")?.state).toBe("red");
323 expect((await fetch(`${base}/grow/watchdog`, { headers: as("guest", "user") })).status).toBe(404);
324 expect((await fetch(`${base}/grow/watchdog`)).status).toBe(404);
325 expect((await fetch(`${base}/grow/watchdog`, { method: "POST", headers: as("trevato", "admin") })).status).toBe(405);
326 const owner_ = await watchdogReader({ PORT: String(relay.port), FORGE_APP_IDENTITY_KEY: KEY, FORGE_APP_USER: "trevato", FORGE_APP_USER_ID: "id-trevato", FORGE_APP_USER_ROLE: "admin" })();
327 expect(owner_.error).toBeNull();
328 expect(owner_.lines.find((l) => l.startsWith("Cameras"))).toMatch(/^Cameras +BROKEN since 2026-10-27 12:06 · Grow cam: no snapshot · #100 https:\/\/forge\.example\/trevato\/arm\/issues\/100$/);
329 const guest_ = await watchdogReader({ PORT: String(relay.port), FORGE_APP_IDENTITY_KEY: KEY, FORGE_APP_USER: "guest", FORGE_APP_USER_ID: "id-guest", FORGE_APP_USER_ROLE: "user" })();
330 expect(guest_).toEqual({ error: "the watchdog is the owner's", lines: [] });
331 });
332
333 test("/watchdog in the terminal prints the strip's rows, and what it cannot read", async () => {
334 expect(typed(emptyRoom(), "/watchdog")).toEqual({ watchdog: true });
335 expect(watchdogLines({ at: null })).toEqual(["Watchdog: its first look is a few seconds after start."]);
336 expect(watchdogLines({ at: T0, live: false })[0]).toContain("no plants in the box");
337 const lines = watchdogLines({ live: true, at: T0, zone: "UTC", filing: "not filed: GROW_WATCHDOG_TOKEN is not set", rows: [{ name: "Light", state: "amber", since: T0, text: "the switch says on\u001b[2J", issue: null }, { name: "Care", state: "ok", since: null, text: "nothing overdue", issue: null }] });
338 expect(lines).toEqual(["Light watching since 2026-10-27 12:00 · the switch says on[2J", "Care fine · nothing overdue", "Watchdog looked 2026-10-27 12:00 (UTC) · not filed: GROW_WATCHDOG_TOKEN is not set"]);
339 const printed: string[] = [];
340 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, null, null, async () => ({ error: "the watchdog is the owner's", lines: [] }));
341 door.input("/watchdog");
342 await new Promise((r) => setTimeout(r, 20));
343 expect(printed).toContain("error: the watchdog is the owner's");
344 });
Software/so101/src/grow-watchdog.ts added +306
@@ -0,0 +1,306 @@
1 /**
2 * The grow watchdog: while there are plants in the box (a sow in the care log and no "end" after it), the relay looks
3 * at the station every 5 minutes and hands what it saw here as plain readings. This turns them into checks:
4 *
5 * cameras a station camera's snapshot fails for 10 min → an issue "Grow box: camera <name> dark since <time>"
6 * pi the Pi's /status or /version unreachable for 10 min → "Grow box: the Pi isn't answering" (says if the arm is unplugged)
7 * stills no new timelapse still for 30 min → "Grow box: timelapse stopped"
8 * light the light disagrees with its schedule for 30 min → "Grow box: light <on/off> outside its schedule"
9 * care a reminder overdue more than 12 h → no issue: the owner is told (it is their hands)
10 *
11 * One open issue per check key at a time, found again by a key line in its body (src/forge-issues.ts), so a restart or
12 * a lost state file never files twice. When the check recovers the issue gets a comment with the downtime and is
13 * closed. It only reports: nothing here, or in what the relay does for it, switches or doses anything.
14 *
15 * The state (when each key went bad, its issue, whether the owner was told) is a small JSON file under the data dir.
16 */
17
18 import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
19 import { dirname } from "node:path";
20 import type { Entry } from "./care-log.ts";
21 import { keyLine, type IssueDesk } from "./forge-issues.ts";
22
23 export const EVERY_MS = 300_000;
24 const MIN = 60_000;
25 const HOUR = 3_600_000;
26 export const GRACE = { cameras: 10 * MIN, pi: 10 * MIN, stills: 30 * MIN, light: 30 * MIN, care: 12 * HOUR } as const;
27 /** Mean brightness (0 to 255) of a still above which the light is taken to be on, when there is no switch to ask. */
28 export const LIT_LEVEL = 60;
29
30 export type CheckId = keyof typeof GRACE;
31 export const CHECKS: readonly { id: CheckId; name: string }[] = [
32 { id: "cameras", name: "Cameras" },
33 { id: "pi", name: "Pi server" },
34 { id: "stills", name: "Timelapse" },
35 { id: "light", name: "Light" },
36 { id: "care", name: "Care" },
37 ];
38
39 /** What the relay saw on one look; I/O happens there, judgement here. */
40 export interface Readings {
41 /** Plants are in the box: everything below matters. */
42 live: boolean;
43 /** The grow station's cameras and whether a snapshot came back. */
44 cameras: { id: number; name: string; ok: boolean }[];
45 /** The Pi's arm server: whether /status and /version both answered, and what /status said of the arm; null when no Pi is named. */
46 pi: { reached: boolean; arm: string | null } | null;
47 /** The newest still and how often they are taken; null when the relay keeps none. */
48 stills: { latestAt: number | null; everyMs: number } | null;
49 /**
50 * The light against its schedule. `expected` null: no schedule, nothing to compare. `actual` null: could not tell
51 * this time (the Pi did not answer, or no fresh still), so the check keeps what it had. null: nothing read at all.
52 */
53 light: { expected: boolean | null; actual: boolean | null; by: "switch" | "stills" } | null;
54 /** Reminders that are overdue and not snoozed. */
55 overdue: { id: string; label: string; dueAt: number }[];
56 /** The Pi's time zone, for the times in titles. */
57 zone: string;
58 }
59
60 /** One thing that may be wrong. `bad` null: unknown this time, keep the last state. */
61 export interface Observation {
62 key: string;
63 check: CheckId;
64 bad: boolean | null;
65 /** When it went bad, if the readings know better than "now". */
66 since?: number;
67 /** The issue's title, from when it went bad. */
68 title: (since: string) => string;
69 /** One line for the strip and the issue body. */
70 detail: string;
71 }
72
73 /** The grow is on: the last sow is after the last "end". */
74 export function growLive(entries: readonly Entry[]): boolean {
75 let sow = -Infinity;
76 let end = -Infinity;
77 for (const e of entries) {
78 if (e.type !== "action") continue;
79 if (e.what === "sow") sow = Math.max(sow, e.at);
80 if (e.what === "end") end = Math.max(end, e.at);
81 }
82 return sow > end;
83 }
84
85 /** "2026-10-27 14:05" in the zone. */
86 export function when(ms: number, zone: string): string {
87 const parts = Object.fromEntries(
88 new Intl.DateTimeFormat("en-CA", { timeZone: zone || "UTC", year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hourCycle: "h23" })
89 .formatToParts(new Date(ms))
90 .map((p) => [p.type, p.value]),
91 );
92 return `${parts.year}-${parts.month}-${parts.day} ${parts.hour}:${parts.minute}`;
93 }
94
95 /** "1 h 20 min", "35 min", "2 d 3 h". */
96 export function span(ms: number): string {
97 const m = Math.max(0, Math.round(ms / MIN));
98 if (m < 60) return `${m} min`;
99 const h = Math.floor(m / 60);
100 if (h < 48) return `${h} h${m % 60 ? ` ${m % 60} min` : ""}`;
101 return `${Math.floor(h / 24)} d${h % 24 ? ` ${h % 24} h` : ""}`;
102 }
103
104 /** The readings as checks, each with its key. */
105 export function observe(r: Readings, now: number): Observation[] {
106 const out: Observation[] = [];
107 for (const c of r.cameras) {
108 out.push({ key: `camera:${c.id}`, check: "cameras", bad: !c.ok, title: (t) => `Grow box: camera ${c.name} dark since ${t}`, detail: c.ok ? `${c.name} answers` : `${c.name}: no snapshot` });
109 }
110 if (r.pi) {
111 const arm = r.pi.arm === "not connected" ? " The arm is unplugged (its /status said so last)." : "";
112 out.push({ key: "pi", check: "pi", bad: !r.pi.reached, title: () => "Grow box: the Pi isn't answering", detail: r.pi.reached ? `answers${r.pi.arm === "not connected" ? "; the arm is unplugged" : ""}` : `/status or /version unreachable.${arm}` });
113 }
114 if (r.stills) {
115 const late = Math.max(1.5 * r.stills.everyMs, 15 * MIN);
116 const last = r.stills.latestAt;
117 const bad = last === null || now - last > late;
118 out.push({ key: "stills", check: "stills", bad, ...(last !== null ? { since: last } : {}), title: () => "Grow box: timelapse stopped", detail: last === null ? "no still taken yet" : `last still ${span(now - last)} ago` });
119 }
120 if (r.light === null) out.push({ key: "light", check: "light", bad: null, title: () => "", detail: "could not read the light" });
121 else if (r.light.expected !== null) {
122 const { expected, actual, by } = r.light;
123 const word = (on: boolean) => (on ? "on" : "off");
124 out.push({
125 key: "light",
126 check: "light",
127 bad: actual === null ? null : actual !== expected,
128 title: () => `Grow box: light ${word(!expected)} outside its schedule`,
129 detail: actual === null ? "could not tell this time" : `${by === "switch" ? "the switch says" : "the stills look"} ${word(actual)}; the schedule says ${word(expected)}`,
130 });
131 }
132 for (const d of r.overdue) out.push({ key: `care:${d.id}`, check: "care", bad: true, since: d.dueAt, title: () => `Care: ${d.label} overdue`, detail: `${d.label}: due ${span(now - d.dueAt)} ago` });
133 return out;
134 }
135
136 type State = "ok" | "amber" | "red" | "off";
137 interface Kept {
138 check: CheckId;
139 badSince: number;
140 title?: string;
141 issue?: number;
142 notified?: boolean;
143 }
144
145 export interface CheckRow {
146 check: CheckId;
147 name: string;
148 state: State;
149 /** When the worst key of this check went bad. */
150 since: number | null;
151 text: string;
152 issue: { number: number; href: string } | null;
153 }
154
155 export interface WatchStatus {
156 live: boolean;
157 /** When it last looked; null before the first look. */
158 at: number | null;
159 zone: string;
160 rows: CheckRow[];
161 /** Why nothing is filed, when nothing can be (no token); else the last filing error, or null. */
162 filing: string | null;
163 }
164
165 export interface WatchdogOptions {
166 /** Where the state is kept; none keeps it in memory. */
167 file?: string;
168 /** The forge's issues; null files nothing (the strip still shows), with `why` saying so. */
169 desk: IssueDesk | null;
170 why?: string | null;
171 /** Tell the owner something only their hands can fix. */
172 notify: (text: string) => void;
173 now?: () => number;
174 }
175
176 export class GrowWatchdog {
177 private kept: Record<string, Kept> = {};
178 private last: { at: number; live: boolean; zone: string; seen: Observation[] } | null = null;
179 private filing: string | null;
180 private busy = false;
181 private readonly o: WatchdogOptions;
182
183 constructor(o: WatchdogOptions) {
184 this.o = o;
185 this.filing = o.desk ? null : (o.why ?? "not filed: no forge token");
186 if (o.file && existsSync(o.file)) {
187 try {
188 const saved = JSON.parse(readFileSync(o.file, "utf8")) as { kept?: Record<string, Kept> };
189 for (const [k, v] of Object.entries(saved.kept ?? {})) if (typeof v?.badSince === "number" && CHECKS.some((c) => c.id === v.check)) this.kept[k] = v;
190 } catch (err) {
191 console.error(`watchdog: ${o.file} unreadable (${(err as Error).message}); starting afresh`);
192 }
193 }
194 }
195
196 private now(): number {
197 return (this.o.now ?? Date.now)();
198 }
199
200 private save(): void {
201 if (!this.o.file) return;
202 mkdirSync(dirname(this.o.file), { recursive: true });
203 writeFileSync(`${this.o.file}.tmp`, JSON.stringify({ kept: this.kept }));
204 renameSync(`${this.o.file}.tmp`, this.o.file);
205 }
206
207 /** One look: judge the readings, file, notify and close as needed. Never throws; a forge error is kept for the strip. */
208 async tick(r: Readings): Promise<void> {
209 if (this.busy) return;
210 this.busy = true;
211 try {
212 const now = this.now();
213 const seen = r.live ? observe(r, now) : [];
214 this.last = { at: now, live: r.live, zone: r.zone, seen };
215 const present = new Set(seen.map((s) => s.key));
216 for (const s of seen) {
217 const k = this.kept[s.key];
218 if (s.bad === null) continue;
219 if (!s.bad) {
220 if (k) await this.recover(s.key, k, `Recovered at ${when(now, r.zone)} after ${span(now - k.badSince)} down.`);
221 continue;
222 }
223 const kept = k ?? (this.kept[s.key] = { check: s.check, badSince: Math.min(s.since ?? now, now) });
224 if (now - kept.badSince < GRACE[s.check]) continue;
225 kept.title ??= s.title(when(kept.badSince, r.zone));
226 if (s.check === "care") {
227 if (!kept.notified) {
228 this.o.notify(`Grow box: ${s.detail}. It's yours to do: the crew can't.`);
229 kept.notified = true;
230 }
231 } else if (kept.issue === undefined) await this.file(s, kept, r.zone);
232 }
233 // A key that is no longer looked at (the grow ended, a camera left the station) is over.
234 for (const [key, k] of Object.entries(this.kept)) {
235 if (!present.has(key)) await this.recover(key, k, r.live ? `No longer checked at ${when(now, r.zone)}; it was down ${span(now - k.badSince)}.` : `The grow ended at ${when(now, r.zone)}; closing.`);
236 }
237 this.save();
238 } catch (err) {
239 console.error(`watchdog: ${(err as Error).message}`);
240 } finally {
241 this.busy = false;
242 }
243 }
244
245 private async file(s: Observation, k: Kept, zone: string): Promise<void> {
246 const desk = this.o.desk;
247 if (!desk) return;
248 try {
249 const found = await desk.find(s.key);
250 const opened =
251 found !== null
252 ? { number: found, warning: null }
253 : await desk.open(
254 k.title!,
255 [
256 `The grow watchdog found this while there are plants in the box, and it is still so.`,
257 ``,
258 `- **What:** ${s.detail}`,
259 `- **Since:** ${when(k.badSince, zone)} (${zone || "UTC"})`,
260 `- **Check:** ${CHECKS.find((c) => c.id === s.check)!.name}, every 5 min; this issue closes itself when it recovers.`,
261 ``,
262 `The watchdog only reports: it never switches the light or doses.`,
263 ``,
264 keyLine(s.key),
265 ].join("\n"),
266 );
267 k.issue = opened.number;
268 this.filing = opened.warning;
269 } catch (err) {
270 this.filing = `could not file: ${(err as Error).message}`;
271 }
272 }
273
274 private async recover(key: string, k: Kept, words: string): Promise<void> {
275 if (k.issue !== undefined && this.o.desk) {
276 try {
277 await this.o.desk.comment(k.issue, words);
278 await this.o.desk.close(k.issue);
279 } catch (err) {
280 this.filing = `could not close #${k.issue}: ${(err as Error).message}`;
281 return; // kept, so the next look tries again
282 }
283 }
284 delete this.kept[key];
285 }
286
287 /** The strip: one row per check, the worst of its keys. */
288 status(): WatchStatus {
289 const last = this.last;
290 const rows = CHECKS.map(({ id, name }): CheckRow => {
291 const seen = last?.live ? last.seen.filter((s) => s.check === id) : [];
292 if (!seen.length && id === "care" && last?.live) return { check: id, name, state: "ok", since: null, text: "nothing overdue", issue: null };
293 if (!seen.length) return { check: id, name, state: "off", since: null, text: "", issue: null };
294 let worst: CheckRow | null = null;
295 const rank: Record<State, number> = { off: 0, ok: 1, amber: 2, red: 3 };
296 for (const s of seen) {
297 const k = this.kept[s.key];
298 const state: State = !k ? (s.bad === null ? "off" : "ok") : last!.at - k.badSince >= GRACE[id] ? "red" : "amber";
299 const row: CheckRow = { check: id, name, state, since: k?.badSince ?? null, text: s.detail, issue: k?.issue !== undefined && this.o.desk ? { number: k.issue, href: this.o.desk.link(k.issue) } : null };
300 if (!worst || rank[state] > rank[worst.state]) worst = row;
301 }
302 return worst!;
303 });
304 return { live: Boolean(last?.live), at: last?.at ?? null, zone: last?.zone ?? "UTC", rows, filing: this.filing };
305 }
306 }
Software/so101/src/manifest.test.ts +13 −1
@@ -1,8 +1,11 @@
11 import { expect, test } from "bun:test";
22 import { parseRemote } from "./relay.ts";
3 import { forgeIssuesFromEnv } from "./forge-issues.ts";
34
45 const manifest = await Bun.file(new URL("../../../forge.json", import.meta.url)).json();
56 const egress: string[] = manifest.egress;
7 // The grow watchdog files issues to the forge; the cell's proxy lets nothing else out.
8 const FORGE = "forge.open-platform.sh:443";
69
710 test("every egress entry is a well-formed host:port", () => {
811 expect(egress.length).toBeGreaterThan(0);
@@ -15,10 +18,19 @@ test("every egress entry is a well-formed host:port", () => {
1518
1619 test("egress covers the arm and the first camera the relay derives from one host", () => {
1720 const [host] = egress[0]!.split(":");
21 const pi = egress.filter((e) => e !== FORGE);
1822 const remote = parseRemote({ ARM_SERVER: `ws://${host}:8101` })!;
1923 const needed = [remote.host, ...remote.cameras.slice(0, 1).map((c) => new URL(c).host)];
2024 for (const target of needed) expect(egress).toContain(target);
21 expect(egress.every((e) => e.startsWith(`${host}:`))).toBe(true);
25 expect(pi.every((e) => e.startsWith(`${host}:`))).toBe(true);
26 });
27
28 test("egress covers the forge the grow watchdog files to by default, and nothing else off the Pi", () => {
29 expect(egress).toContain(FORGE);
30 const { desk } = forgeIssuesFromEnv({ GROW_WATCHDOG_TOKEN: "t" });
31 expect(new URL(desk!.link(1)).host).toBe(FORGE.replace(/:443$/, ""));
32 const [host] = egress[0]!.split(":");
33 expect(egress.filter((e) => !e.startsWith(`${host}:`))).toEqual([FORGE]);
2234 });
2335
2436 test("the terminal the forge opens on ssh is this package's tty, run from the repository root", async () => {
Software/so101/src/relay.test.ts +3 −2
@@ -385,8 +385,9 @@ describe("relay", () => {
385385 test("an arm server that goes away: reconnecting with the last pose, driving suspended, then live again on the same arm", async () => {
386386 remote = await arm([1, 2, 6]);
387387 const port = remote.port;
388 // A stopped server closes the watch at once; the patience for a quiet one is long here, so a move's busy bus is no drop.
389 relay = await startRelay({ port: 0, simulation: simBase, remote: remoteAt(port), link: { ...FAST, staleMs: 1000 } });
388 // A stopped server closes the watch at once; the patience for a quiet one is long here, so a move's busy bus is no drop,
389 // and going offline (to the simulation) waits 10 s, so a loaded machine cannot end "reconnecting" before the checks below.
390 relay = await startRelay({ port: 0, simulation: simBase, remote: remoteAt(port), link: { ...FAST, staleMs: 1000, offlineAfterMs: 10_000 } });
390391 const base = `http://127.0.0.1:${relay.port}`;
391392 expect(await joints(base)).toBe(3);
392393 await shut(remote);
Software/so101/src/relay.ts +91 −5
@@ -107,7 +107,7 @@ 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 { CareLog, LogError } from "./care-log.ts";
110 import { growing, lastWatered, nextDue, snapshot, sownAt } from "./care-rules.ts";
110 import { growing, lastWatered, nextDue, overdue, reminders, snapshot, sownAt } from "./care-rules.ts";
111111 import { BUILT_IN_DIR, loadProfiles, type Profile } from "./care-profile.ts";
112112 import { ProbeCache, readProbeView, sampleProbes, waterTemp, type ProbeHooks } from "./care-probes.ts";
113113 import { handleCare } from "./care-routes.ts";
@@ -116,7 +116,9 @@ import type { Doser } from "./doser.ts";
116116 import { armPumps, cachedRead, readDosing, type PumpHooks } from "./arm-pumps.ts";
117117 import { renderWorkshop, type GrowNow, type StationView } from "./workshop-page.ts";
118118 import { DAY_MS, GrowStills, KEEP_DAYS } from "./grow-stills.ts";
119 import { finishStillApart } from "./grow-frame.ts";
119 import { finishStillApart, meanLevelApart } from "./grow-frame.ts";
120 import { growLive, LIT_LEVEL, type GrowWatchdog, type Readings } from "./grow-watchdog.ts";
121 import { phaseAt } from "./light-schedule.ts";
120122 import { canopyByDay, dayNumber, dayOf, GrowLog, GrowLogError, lightHours } from "./grow-log.ts";
121123 import { parseProfile, ProfileError, type CameraProfile } from "./camera-profile.ts";
122124 import { VideoFeed, type VideoSources } from "./video-feed.ts";
@@ -180,6 +182,8 @@ export interface RelayOptions {
180182 growKey?: string | null;
181183 /** How often the arm's sensors are pulled into the care log book, ms; unset, never. */
182184 sensorsMs?: number;
185 /** The grow watchdog (src/grow-watchdog.ts) and how often it looks; unset, nothing watches and the grow card has no strip. */
186 watchdog?: { dog: GrowWatchdog; everyMs: number };
183187 /** A setting that could not be used (shown on /link while simulating). */
184188 problem?: string | null;
185189 /** The link's retries and its patience (src/arm-link.ts). */
@@ -1127,7 +1131,7 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> {
11271131 .then(read)
11281132 .catch(() => null);
11291133 }
1130 bytes ??= await read(await fetch(source.replace(/\/[^/]*$/, "/snapshot"), { signal: AbortSignal.timeout(Math.min(readTimeoutMs, 5000)) }));
1134 bytes ??= await read(await fetch(cameras.snapshot(id)!, { signal: AbortSignal.timeout(Math.min(readTimeoutMs, 5000)) }));
11311135 if (!bytes) return;
11321136 const done = await finishStillApart(bytes, { region: look?.region ?? null, gains: look?.gains ?? null }).catch(() => null);
11331137 // A still that will not decode is kept whole and uncorrected: a picture is still better than a gap.
@@ -1137,6 +1141,80 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> {
11371141 /* the camera or the Pi did not answer: no still this time */
11381142 }
11391143 };
1144 /**
1145 * What the grow watchdog sees on one look: the grow station's camera snapshots, the Pi's /status and /version, the
1146 * newest still, the light against its schedule (by its switch, or by how bright the newest still is when it has
1147 * none) and the overdue reminders. Reads only: nothing here switches or doses.
1148 */
1149 const levels = new Map<number, number>();
1150 const watchReadings = async (): Promise<Readings> => {
1151 const now = Date.now();
1152 const live = growLive(care.log.entries());
1153 const growStation = stations.list().find((st) => st.kind === "grow");
1154 const listed = new Map(cameras.list().map((c) => [c.id, c.name]));
1155 const answers = async (url: string, ms: number) => {
1156 try {
1157 const answer = await fetch(url, { cache: "no-store", signal: AbortSignal.timeout(ms) });
1158 const bytes = await answer.arrayBuffer();
1159 return answer.ok ? bytes.byteLength : 0;
1160 } catch {
1161 return 0;
1162 }
1163 };
1164 const shots = (growStation?.cameras ?? []).filter((id) => listed.has(id)).map(async (id) => ({ id, name: listed.get(id)!, ok: (await answers(cameras.snapshot(id)!, Math.min(readTimeoutMs, 5000))) > 0 }));
1165 let pi: Readings["pi"] = null;
1166 if (remote) {
1167 const look = async (path: string) => {
1168 try {
1169 const answer = await fetch(`${remote!.http}${path}`, { cache: "no-store", signal: AbortSignal.timeout(Math.min(readTimeoutMs, 5000)) });
1170 const body = (await answer.json().catch(() => null)) as { arm?: unknown } | null;
1171 return answer.status < 500 && !answer.headers.has("x-forge-egress") ? { arm: typeof body?.arm === "string" ? body.arm.slice(0, 40) : null } : null;
1172 } catch {
1173 return null;
1174 }
1175 };
1176 const [status, version] = await Promise.all([look("/status"), look("/version")]);
1177 pi = { reached: status !== null && version !== null, arm: status?.arm ?? null };
1178 }
1179 const latest = options.grow?.stills.latest() ?? null;
1180 let light: Readings["light"] = null;
1181 const grow = remote && !onArm() ? null : await lightHooks.read();
1182 if (grow) {
1183 const l = grow.light;
1184 const expected = l.schedule ? (l.override && l.override.until > now ? l.override.on : phaseAt(l.schedule, now, l.zone).on) : null;
1185 if (l.configured) light = { expected, actual: l.on, by: "switch" };
1186 else {
1187 // No switch: how bright the newest still is says whether the light is on; a still over 15 min old says nothing.
1188 let actual: boolean | null = null;
1189 if (expected !== null && latest && now - latest.at < 900_000 && options.grow) {
1190 let level = levels.get(latest.at);
1191 if (level === undefined) {
1192 const bytes = options.grow.stills.read(latest.at);
1193 level = bytes ? await meanLevelApart(bytes).catch(() => undefined) : undefined;
1194 if (level !== undefined) levels.clear(), levels.set(latest.at, level);
1195 }
1196 if (level !== undefined) actual = level > LIT_LEVEL;
1197 }
1198 light = { expected, actual, by: "stills" };
1199 }
1200 }
1201 return {
1202 live,
1203 cameras: await Promise.all(shots),
1204 pi,
1205 stills: options.grow ? { latestAt: latest?.at ?? null, everyMs: options.grow.everyMs } : null,
1206 light,
1207 overdue: overdue(reminders(care.profile, snapshot(care.log), now)).map((d) => ({ id: d.reminder.id, label: d.reminder.label, dueAt: d.dueAt })),
1208 zone: grow?.light.zone || "UTC",
1209 };
1210 };
1211 const watch = options.watchdog;
1212 const look = () => void watchReadings().then((r) => watch!.dog.tick(r)).catch((err) => console.error(`watchdog: ${reason(err)}`));
1213 const watchTimer = watch ? setInterval(look, watch.everyMs) : undefined;
1214 watchTimer?.unref?.();
1215 // The first look soon after start, so the strip is not blank for five minutes.
1216 const firstLook = watch ? setTimeout(look, Math.min(watch.everyMs, 10_000)) : undefined;
1217 firstLook?.unref?.();
11401218 /** Pull the arm's sensor Readings (src/sensors.ts) into the log book, an hour apart per kind (CareLog.addReading). Quiet when the arm does not answer. */
11411219 const pullSensors = async () => {
11421220 if (!(remote && onArm())) return;
@@ -1220,6 +1298,12 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> {
12201298 res.writeHead(200, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
12211299 return res.end(text);
12221300 }
1301 // The grow watchdog's rows, the strip's own (the terminal's /watchdog reads them): the owner's, like the strip; to anyone else there is no such route.
1302 if (url.pathname === "/grow/watchdog") {
1303 if (!watch || !lobby?.who(req)?.owner) return json(res, 404, { error: "no such route" });
1304 if (req.method !== "GET") return res.setHeader("allow", "GET"), json(res, 405, { error: "GET" });
1305 return json(res, 200, watch.dog.status());
1306 }
12231307 // The Pi's pumps answer the relay's controller alone: a browser that reached them would skip every dosing limit.
12241308 if (url.pathname === "/grow/dosing" || url.pathname.startsWith("/grow/dosing/")) return json(res, 404, { error: "not found" });
12251309 if (url.pathname.startsWith("/grow/") && options.grow) {
@@ -1266,7 +1350,7 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> {
12661350 if (source === null) return json(res, 404, { error: "no such camera" });
12671351 if (camera[2] === "stream") return cameras.feed(id)!.serve(req, res, parseFps(url.searchParams.get("fps")));
12681352 if (camera[2] === "state") return json(res, 200, { ok: cameras.list().find((c) => c.id === id)?.online ?? null });
1269 const target = source.replace(/\/[^/]*$/, "/snapshot") + url.search;
1353 const target = cameras.snapshot(id)! + url.search;
12701354 try {
12711355 return await pass(res, await ask(target, req, undefined, readTimeoutMs, true));
12721356 } catch (err) {
@@ -1277,7 +1361,7 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> {
12771361 if (req.method === "GET" && /^\/(?:index\.html)?$/.test(url.pathname) && stations.isWorkshop()) {
12781362 res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
12791363 const who = lobby?.who(req);
1280 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, everyMs: options.grow?.everyMs, growNow: growNow() }).replaceAll("/ui/__VERSION__/", `/ui/${SIGHT_VERSION}/`));
1364 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, everyMs: options.grow?.everyMs, growNow: growNow(), watch: who?.owner && watch ? watch.dog.status() : null }).replaceAll("/ui/__VERSION__/", `/ui/${SIGHT_VERSION}/`));
12811365 }
12821366 if (req.method === "GET" && PAGE.test(url.pathname) && rest) return restingPage(req, res, rest);
12831367 if (req.method === "GET" && PAGE.test(url.pathname)) return await page(req, res, await ask(simulation + path.replace(/^\/arm(?=$|\?)/, "/"), req, undefined, null, false));
@@ -1667,6 +1751,8 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> {
16671751 chatFacts?.();
16681752 clearInterval(sampleTimer);
16691753 clearInterval(stillTimer);
1754 clearInterval(watchTimer);
1755 clearTimeout(firstLook);
16701756 clearInterval(probeTimer);
16711757 clearInterval(doseTimer);
16721758 clearInterval(sensorTimer);
Software/so101/src/terminal.ts +50 −3
@@ -38,6 +38,7 @@
3838 * /probes [calibrate <ph7|ph4|ec1413> [tempC]] the pH/EC probes (GET /care/probes); anyone reads, the owner calibrates (POST /care/probes/calibrate)
3939 * /care [log|did|note|problem|done|snooze|delete …] the plant care app (GET /care/api); anyone reads, the owner writes (POST /care/log, /care/reminder)
4040 * /versions owner or moderator: the app's and the Pi's commits and the last sync (GET /admin/versions)
41 * /watchdog owner: the grow watchdog's checks, the rows of the grow card's strip (GET /grow/watchdog); 404 to anyone else
4142 * /trace <session> owner or moderator: a kept session's 5 Hz trace, per joint (GET /precision/trace)
4243 * /data [hour|day|week|all] owner or moderator: the Data view's overview and worst sessions (GET /data, src/data-lines.ts); default day
4344 * /data #<turn> owner or moderator: one turn's numbers and the arm's events around it (GET /data?turn=<id>)
@@ -78,6 +79,7 @@ import { probeLines } from "./care-probes.ts";
7879 import { careCommand, careLines, dosingLines, pumpLines, pumpSaid, probesCommand, type CareAsk } from "./care-terminal.ts";
7980 import { presenceText } from "./ui/lobby-panel.js";
8081 import { entryLine, jointRows, linkRows, stateRows } from "./ui/machine.js";
82 import { when } from "./grow-watchdog.ts";
8183
8284 export type Env = Record<string, string | undefined>;
8385
@@ -166,6 +168,39 @@ export function versionsReader(env: Env, fetcher: typeof fetch = fetch): ReadVer
166168 };
167169 }
168170
171 /** GET the grow watchdog's rows (src/grow-watchdog.ts) as the strip shows them; the server lets only the owner. */
172 export function watchdogReader(env: Env, fetcher: typeof fetch = fetch): ReadVersions {
173 return async () => {
174 try {
175 const r = await fetcher(`${appUrl(env)}/grow/watchdog`, { headers: terminalHeaders(env), signal: AbortSignal.timeout(5000) });
176 const body = (await r.json().catch(() => null)) as Record<string, unknown> | null;
177 if (r.ok) return { error: null, lines: watchdogLines(body) };
178 return { error: r.status === 404 ? "the watchdog is the owner's" : plain(body?.error ?? `status ${r.status}`), lines: [] };
179 } catch (err) {
180 return { error: plain((err as Error).message), lines: [] };
181 }
182 };
183 }
184
185 const WATCH_WORDS: Record<string, string> = { ok: "fine", amber: "watching", red: "BROKEN", off: "not checked" };
186
187 /** The strip as lines: one per check, its state in words, since when, what was seen and its issue. */
188 export function watchdogLines(body: unknown): string[] {
189 const b = (body ?? {}) as { live?: unknown; at?: unknown; zone?: unknown; filing?: unknown; rows?: { name?: unknown; state?: unknown; since?: unknown; text?: unknown; issue?: { number?: unknown; href?: unknown } | null }[] };
190 const zone = plain(b.zone) || "UTC";
191 if (typeof b.at !== "number") return ["Watchdog: its first look is a few seconds after start."];
192 if (b.live !== true) return ["Watchdog: no plants in the box, nothing is watched (a sow on /care starts it; /care did end stops it)."];
193 const lines = (Array.isArray(b.rows) ? b.rows : []).slice(0, 10).map((r) => {
194 const state = WATCH_WORDS[plain(r.state)] ?? "?";
195 const since = typeof r.since === "number" && r.state !== "ok" ? ` since ${when(r.since, zone)}` : "";
196 const text = plain(r.text);
197 const issue = typeof r.issue?.number === "number" ? ` · #${r.issue.number} ${plain(r.issue.href)}` : "";
198 return `${plain(r.name).padEnd(10)} ${state}${since}${text ? ` · ${text}` : ""}${issue}`;
199 });
200 lines.push(`Watchdog looked ${when(b.at, zone)} (${zone})${b.filing ? ` · ${plain(b.filing)}` : ""}`);
201 return lines;
202 }
203
169204 /** What a station read or change answered. */
170205 export type ReadStations = (set?: string) => Promise<Seen>;
171206
@@ -552,7 +587,7 @@ export function whoami(room: Room): string {
552587 /** The terminal holds no session to end: the forge signs who is at the keyboard afresh on every connection. */
553588 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";
554589
555 export const HELP = "type to chat · /join /leave /ready /extend · /stop /resume (driver, owner) · /end [who] /table here|touch 1|2|3|<mm> /zero <joint> [deg] /align [joint] (owner) · /mute <who> /unmute <who> (owner, moderator) · /delete <who|#id> (owner, moderator) · /report <who|#id> · /reports /dismiss #<id> (owner, moderator) · /removed /restore #<id> /visitors (owner) · /close [note] /open (owner) · /health /precision /trace <session> /data [hour|day|week|all|#<turn>] /versions (owner, moderator) · /servos /tune [<joint> <P>] /grow [light|pump …] (owner; /grow confirm yes|no answers a uhubctl try-out) /grow log|planted <date>|note <text> · /care [log|did|note|problem|done|snooze|delete …] · /probes [calibrate <ph7|ph4|ec1413> [tempC]] (calibrate: owner) · /stations [set <json>] (set: owner) · /machine [off] · /who /whoami · /quit";
590 export const HELP = "type to chat · /join /leave /ready /extend · /stop /resume (driver, owner) · /end [who] /table here|touch 1|2|3|<mm> /zero <joint> [deg] /align [joint] (owner) · /mute <who> /unmute <who> (owner, moderator) · /delete <who|#id> (owner, moderator) · /report <who|#id> · /reports /dismiss #<id> (owner, moderator) · /removed /restore #<id> /visitors (owner) · /close [note] /open (owner) · /health /precision /trace <session> /data [hour|day|week|all|#<turn>] /versions (owner, moderator) · /servos /tune [<joint> <P>] /grow [light|pump …] (owner; /grow confirm yes|no answers a uhubctl try-out) /grow log|planted <date>|note <text> · /care [log|did|note|problem|done|snooze|delete …] · /probes [calibrate <ph7|ph4|ec1413> [tempC]] (calibrate: owner) · /stations [set <json>] (set: owner) · /watchdog (owner) · /machine [off] · /who /whoami · /quit";
556591
557592 /** The Machine feed's socket beside the lobby's: same app, same port. */
558593 export const machineUrl = (live: string): string => live.replace(/\/live$/, "/machine");
@@ -708,7 +743,7 @@ function target(room: Room, word: string): string | null {
708743 return ID.test(word) ? word : null;
709744 }
710745
711 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 } | { care: CareAsk } | { quit: true } | null;
746 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 } | { watchdog: true } | { stations: string | null } | { growLog: Record<string, unknown> | null } | { care: CareAsk } | { quit: true } | null;
712747
713748 /** What a typed line asks for. Validation the server repeats anyway; this only saves a round trip on a typo. */
714749 export function typed(room: Room, input: string): Typed {
@@ -839,6 +874,8 @@ export function typed(room: Room, input: string): Typed {
839874 return { servos: true };
840875 case "versions":
841876 return { versions: true };
877 case "watchdog":
878 return { watchdog: true };
842879 case "machine":
843880 if (arg && arg.toLowerCase() !== "off" && arg.toLowerCase() !== "on") return { print: "usage: /machine [off]" };
844881 return { machine: arg.toLowerCase() !== "off" };
@@ -874,6 +911,7 @@ export class Door {
874911 private readonly readTuning: ReadTuning | null;
875912 private readonly readTrace: ReadTrace | null;
876913 private readonly readVersions: ReadVersions | null;
914 private readonly readWatchdog: ReadVersions | null;
877915 private readonly readStations: ReadStations | null;
878916 private readonly readGrowLog: ReadGrowLog | null;
879917 private readonly readCare: ReadCare | null;
@@ -882,7 +920,8 @@ export class Door {
882920 private readonly readGrow: ReadGrow | null;
883921
884922 /** `post` reaches the app's /stop and /resume, `read` its /admin/health, `readCard` its /precision; without them, the door says it cannot. */
885 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, readCare: ReadCare | null = null) {
923 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, readCare: ReadCare | null = null, readWatchdog: ReadVersions | null = null) {
924 this.readWatchdog = readWatchdog;
886925 this.readCare = readCare;
887926 this.readGrowLog = readGrowLog;
888927 this.readGrow = readGrow;
@@ -945,6 +984,7 @@ export class Door {
945984 else if ("grow" in t) void this.grow(t.grow);
946985 else if ("tune" in t) void this.tune(t.tune);
947986 else if ("versions" in t) void this.versions();
987 else if ("watchdog" in t) void this.watchdog();
948988 else if ("stations" in t) void this.stations(t.stations);
949989 else if ("growLog" in t) void this.growLog(t.growLog);
950990 else if ("care" in t) void this.care(t.care);
@@ -990,6 +1030,13 @@ export class Door {
9901030 for (const line of r.error ? [`error: ${r.error}`] : r.lines) this.print(line);
9911031 }
9921032
1033 /** The server lets only the owner; a refusal is printed, else a line per check. */
1034 private async watchdog(): Promise<void> {
1035 if (!this.readWatchdog) return this.print("error: the watchdog is not available here");
1036 const r = await this.readWatchdog();
1037 for (const line of r.error ? [`error: ${r.error}`] : r.lines) this.print(line);
1038 }
1039
9931040 /** The server decides who may; a refusal is printed, else the commits and the last sync outcome. */
9941041 private async versions(): Promise<void> {
9951042 if (!this.readVersions) return this.print("error: versions 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, careReader, dataReader, Door, growLogReader, growReader, HELP, liveUrl, poster, reader, servosReader, stationsReader, terminalHeaders, traceReader, tuningReader, versionsReader } from "./terminal.ts";
7 import { alignReader, cardReader, careReader, dataReader, Door, growLogReader, growReader, HELP, liveUrl, poster, reader, servosReader, stationsReader, terminalHeaders, traceReader, tuningReader, versionsReader, watchdogReader } 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), growLogReader(env), careReader(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), careReader(env), watchdogReader(env));
2121 print(`\x1b[1mSO-101 arm\x1b[0m · ${where} · ${HELP}`);
2222 try {
2323 await door.open(() => {
Software/so101/src/workshop-page.ts +29 −2
@@ -4,6 +4,7 @@ import type { Station } from "./stations.ts";
44 import { renderSignedIn, SIGNED_IN_STYLE, type SignedIn } from "./signed-in.ts";
55 import { esc } from "./story-markdown.ts";
66 import type { Rect } from "./ui/still-frame.js";
7 import { when, type WatchStatus } from "./grow-watchdog.ts";
78
89 export const WORKSHOP_TITLE = "Workshop";
910
@@ -61,6 +62,12 @@ h1 { margin: .5rem 0 1rem; } h2 { margin: .6rem 0; font-size: 1.15rem; }
6162 .grow h3 { margin: .8rem 0 .3rem; font-size: 1rem; } .grow input[type=range] { width: 100%; min-height: 32px; } .grow svg { width: 100%; max-height: 6rem; color: var(--accent); }
6263 .grow form { display: flex; gap: .5rem; flex-wrap: wrap; } .grow input[type=text] { flex: 1; min-width: 8rem; min-height: 44px; }
6364 .card p { color: var(--dim); margin: .4rem 0; }
65 .watch { list-style: none; margin: .6rem 0 0; padding: 0; display: grid; gap: .25rem; font-size: .9rem; }
66 .watch li { display: grid; grid-template-columns: .7rem 6.5rem 1fr; gap: .5rem; align-items: baseline; }
67 .watch li::before { content: ""; width: .7rem; height: .7rem; border-radius: 50%; background: var(--line); align-self: center; }
68 .watch [data-state=ok]::before { background: #2da44e; } .watch [data-state=amber]::before { background: #d4a72c; } .watch [data-state=red]::before { background: #cf222e; }
69 .watch b { font-weight: 600; } .watch span { color: var(--dim); }
70 .watch-note { font-size: .85rem; }
6471 .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); }
6572 .top a, .card button { min-height: 44px; display: inline-flex; align-items: center; }
6673 .card button { font: inherit; padding: 0 1rem; border-radius: 999px; border: 0; background: var(--accent); color: var(--on-accent); font-weight: 650; }
@@ -95,6 +102,8 @@ export interface Looker {
95102 grow?: boolean;
96103 /** When the newest lit still was taken: the card shows it without the script. */
97104 latest?: number | null;
105 /** The grow watchdog's checks, for the owner's strip on the grow card (src/grow-watchdog.ts); null for anyone else. */
106 watch?: WatchStatus | null;
98107 /** How often a still is taken, ms: the empty timelapse says when the first one comes. */
99108 everyMs?: number;
100109 /** The plant's day, the last watering and the next reminder. */
@@ -155,10 +164,11 @@ function card({ station, present, resting, cameras }: StationView, looker: Looke
155164 }
156165 const grow = station.kind === "grow";
157166 const status = grow ? growing(looker.growNow, looker.owner) : "";
167 const watch = grow && looker.watch ? renderWatch(looker.watch) : "";
158168 if (!cameras.length) {
159169 const setup = looker.owner ? ` <a href="/admin/camera-list">Cameras</a> · <a href="/admin/stations/edit">Stations</a>` : "";
160170 const controls = station.controls.map((c) => `<a href="${esc(c.href)}">${esc(c.label)}</a>`).join("");
161 return `<section class="card" data-station="${esc(station.id)}" data-present="true" data-cameras="none" ${kind}>${head}<p>No camera set up yet.${setup}</p>${status}${controls ? `<div class="controls">${controls}</div>` : ""}</section>`;
171 return `<section class="card" data-station="${esc(station.id)}" data-present="true" data-cameras="none" ${kind}>${head}<p>No camera set up yet.${setup}</p>${status}${watch}${controls ? `<div class="controls">${controls}</div>` : ""}</section>`;
162172 }
163173 const controls = station.controls.map((c) => `<a href="${esc(c.href)}">${esc(c.label)}</a>`).join("");
164174 const minutes = Math.max(1, Math.round((looker.everyMs ?? 600_000) / 60_000));
@@ -167,7 +177,24 @@ function card({ station, present, resting, cameras }: StationView, looker: Looke
167177 ? `<img class="latest" src="/grow/stills/${Number(looker.latest)}.jpg" alt="The latest still of the grow box" width="1280" height="720" loading="lazy">`
168178 : `<p class="quiet">The first stills arrive within ${minutes} minutes.</p>`;
169179 const lapse = grow && looker.grow ? `<div class="grow" data-grow data-owner="${looker.owner ? 1 : 0}" data-every="${minutes}"${looker.latest ? " data-stills" : ""} data-region="${esc(JSON.stringify(cameras[0]?.region ?? null))}">${first}<noscript><p>The timelapse and the canopy chart need JavaScript. <a href="/grow/log">The grow log</a></p></noscript></div>` : "";
170 return `<section class="card" data-station="${esc(station.id)}" data-present="true" ${kind}>${head}${cams(station, cameras, grow)}${status}${lapse}${controls ? `<div class="controls">${controls}</div>` : ""}</section>`;
180 return `<section class="card" data-station="${esc(station.id)}" data-present="true" ${kind}>${head}${cams(station, cameras, grow)}${status}${watch}${lapse}${controls ? `<div class="controls">${controls}</div>` : ""}</section>`;
181 }
182
183 const WORDS = { ok: "fine", amber: "watching", red: "broken", off: "not checked" } as const;
184
185 /** The owner's status strip: one line per check, its colour, its state in words, since when, and its issue. */
186 export function renderWatch(w: WatchStatus): string {
187 if (w.at === null) return `<p class="watch-note">Watchdog: its first look is a few seconds after start.</p>`;
188 if (!w.live) return `<p class="watch-note">Watchdog: no plants in the box, nothing is watched. A sow in <a href="/care">Care</a> starts it; the action "end" stops it.</p>`;
189 const rows = w.rows
190 .map((r) => {
191 const since = r.since !== null && r.state !== "ok" ? ` since ${esc(when(r.since, w.zone))}` : "";
192 const issue = r.issue ? ` · <a href="${esc(r.issue.href)}">#${r.issue.number}</a>` : "";
193 return `<li data-check="${r.check}" data-state="${r.state}"><b>${esc(r.name)}</b><span>${WORDS[r.state]}${since}${r.text ? ` · ${esc(r.text)}` : ""}${issue}</span></li>`;
194 })
195 .join("");
196 const looked = `looked ${esc(when(w.at, w.zone))}`;
197 return `<ul class="watch" aria-label="Grow watchdog">${rows}</ul><p class="watch-note">Watchdog, ${looked}${w.filing ? ` · ${esc(w.filing)}` : ""}</p>`;
171198 }
172199
173200 /** The page's script: the cameras' swipe, pick and full screen (src/ui/station-cams.js); for the grow box, the stills' check (src/ui/grow-hint.js) and the timelapse. */
forge.json +2 −1
@@ -18,6 +18,7 @@
1818 "100.89.95.68:8084",
1919 "100.89.95.68:8091",
2020 "100.89.95.68:8092",
21 "100.89.95.68:8101"
21 "100.89.95.68:8101",
22 "forge.open-platform.sh:443"
2223 ]
2324 }

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs