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
Check · Review · Land done · Live — Landed; nothing deploys from this branch, so this is the end of its path.
quill/arm:quill/628-grow-watchdog-while-plants-are-in-the-bo → main 6 commits fork 2 threads · 2 unresolved
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 | ||
| 168 | 168 | |
| 169 | 169 | 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`. |
| 170 | 170 | |
| 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 | ||
| 171 | 199 | ## The story page: /story |
| 172 | 200 | |
| 173 | 201 | `/story` on the hosted app is the arm explained for anyone: the hook, how it works (with a diagram of browser → forge → Pi → servos, and what latency, the queue, STOP and the safety zone mean), how to build your own (the [](https://huggingface.co/docs/lerobot/so101), this repository, `bun so101 setup`) and what the crew built. Anyone may read it; it is linked from the Chat tab's footer (hidden while you drive) and the Queue tab. |
Software/so101/bench/fake-serve.ts +6 −1
| @@ -32,6 +32,8 @@ import { GrowLog } from "../src/grow-log.ts"; | ||
| 32 | 32 | import { GrowStills, parseEvery } from "../src/grow-stills.ts"; |
| 33 | 33 | import { startServer } from "../src/server.ts"; |
| 34 | 34 | 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"; | |
| 35 | 37 | |
| 36 | 38 | const fake = new FakeBus([1, 2, 3, 4, 5, 6]); |
| 37 | 39 | 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") }); | ||
| 85 | 87 | const piSync = piSyncFromEnv(process.env, remote?.http ?? null, remote?.host ?? null, () => lobby.turns.snapshot().driver === null, dir); |
| 86 | 88 | 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) }; |
| 87 | 89 | 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") }); | |
| 89 | 94 | const { mode, host: armHost } = relay.link(); |
| 90 | 95 | 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 { | ||
| 148 | 148 | return this.specs.find((c) => c.id === id)?.source ?? null; |
| 149 | 149 | } |
| 150 | 150 | |
| 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 | ||
| 151 | 160 | /** End every viewer's stream (the arm is resting); each feed lets go of its source once nobody watches, and may open again. */ |
| 152 | 161 | endViewers(): void { |
| 153 | 162 | 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"; | ||
| 9 | 9 | |
| 10 | 10 | export const MAX_ENTRIES = 5000; |
| 11 | 11 | 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; | |
| 13 | 13 | export const PROBLEMS = ["bolting", "tip burn", "root rot", "algae", "yellowing", "leggy"] as const; |
| 14 | 14 | export const SYSTEMS = ["vertical tower", "DWC", "NFT", "Kratky", "ebb-and-flow"] as const; |
| 15 | 15 | 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", () => { | ||
| 41 | 41 | expect(typed(emptyRoom(), "/care")).toEqual({ care: { method: "GET", path: "/care/api" } }); |
| 42 | 42 | expect(typed(emptyRoom(), "/care log ph 6.1")).toEqual({ care: { method: "POST", path: "/care/log", body: { type: "measurement", kind: "ph", value: 6.1 } } }); |
| 43 | 43 | 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" } } }); | |
| 44 | 46 | 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" } } }); |
| 45 | 47 | expect(typed(emptyRoom(), "/care snooze roots 48")).toEqual({ care: { method: "POST", path: "/care/reminder", body: { id: "roots", action: "snooze", hours: 48 } } }); |
| 46 | 48 | 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); | |
| 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 |
||
| 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. */ | |
| 2 | 2 | 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"; | |
| 4 | 4 | |
| 5 | parentPort?.once("message", ({ bytes, look }: { bytes: Uint8Array; look: StillLook }) => { | |
| 5 | parentPort?.once("message", ({ bytes, look, level }: { bytes: Uint8Array; look?: StillLook; level?: true }) => { | |
| 6 | 6 | try { |
| 7 | parentPort!.postMessage({ ok: true, out: finishStill(bytes, look) }); | |
| 7 | parentPort!.postMessage({ ok: true, out: level ? meanLevel(bytes) : finishStill(bytes, look!) }); | |
| 8 | 8 | } catch (err) { |
| 9 | 9 | parentPort!.postMessage({ ok: false, error: (err as Error).message }); |
| 10 | 10 | } |
Software/so101/src/grow-frame.test.ts +8 −1
| @@ -8,7 +8,7 @@ import { FeetechBus } from "./bus.ts"; | ||
| 8 | 8 | import { CameraHub } from "./cameras.ts"; |
| 9 | 9 | import { grabStill, STILL_HEIGHT, STILL_WIDTH, StillError, stillCommands, type Run } from "./camera-still.ts"; |
| 10 | 10 | 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"; | |
| 12 | 12 | import { GrowLog } from "./grow-log.ts"; |
| 13 | 13 | import { GrowStills } from "./grow-stills.ts"; |
| 14 | 14 | 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 | ||
| 233 | 233 | expect(corrected.r / raw.r).toBeCloseTo(2, 1); |
| 234 | 234 | expect((await fetch(`${base}/grow/stills/1234567890-corrected.jpg`)).status).toBe(404); |
| 235 | 235 | }); |
| 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 { | ||
| 72 | 72 | return { raw, corrected, cropped: !whole, width, height }; |
| 73 | 73 | } |
| 74 | 74 | |
| 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 | ||
| 75 | 84 | /** The rows of `rect` out of an RGBA frame `width` pixels wide. */ |
| 76 | 85 | function crop(data: Uint8Array, width: number, rect: Rect): Uint8Array { |
| 77 | 86 | const out = new Uint8Array(rect.w * rect.h * 4); |
| @@ -85,11 +94,18 @@ function crop(data: Uint8Array, width: number, rect: Rect): Uint8Array { | ||
| 85 | 94 | /** `finishStill` in a worker of its own, ended when it answers or after `timeoutMs`. */ |
| 86 | 95 | export function finishStillApart(bytes: Uint8Array, look: StillLook, timeoutMs = 20_000): Promise<Finished> { |
| 87 | 96 | 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> { | |
| 88 | 104 | return new Promise((resolve, reject) => { |
| 89 | 105 | const worker = new Worker(new URL("./grow-frame-worker.ts", import.meta.url)); |
| 90 | 106 | const timer = setTimeout(() => done(new Error("finishing the still took too long")), timeoutMs); |
| 91 | 107 | let settled = false; |
| 92 | const done = (err: Error | null, out?: Finished) => { | |
| 108 | const done = (err: Error | null, out?: T) => { | |
| 93 | 109 | if (settled) return; |
| 94 | 110 | settled = true; |
| 95 | 111 | clearTimeout(timer); |
| @@ -97,8 +113,8 @@ export function finishStillApart(bytes: Uint8Array, look: StillLook, timeoutMs = | ||
| 97 | 113 | if (err) reject(err); |
| 98 | 114 | else resolve(out!); |
| 99 | 115 | }; |
| 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)))); | |
| 101 | 117 | worker.once("error", (err: unknown) => done(err instanceof Error ? err : new Error(String(err)))); |
| 102 | worker.postMessage({ bytes, look }); | |
| 118 | worker.postMessage(message); | |
| 103 | 119 | }); |
| 104 | 120 | } |
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("<img onerror=x>"); | |
| 182 | expect(html).toContain('href="https://f/"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 @@ | ||
| 1 | 1 | import { expect, test } from "bun:test"; |
| 2 | 2 | import { parseRemote } from "./relay.ts"; |
| 3 | import { forgeIssuesFromEnv } from "./forge-issues.ts"; | |
| 3 | 4 | |
| 4 | 5 | const manifest = await Bun.file(new URL("../../../forge.json", import.meta.url)).json(); |
| 5 | 6 | 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"; | |
| 6 | 9 | |
| 7 | 10 | test("every egress entry is a well-formed host:port", () => { |
| 8 | 11 | expect(egress.length).toBeGreaterThan(0); |
| @@ -15,10 +18,19 @@ test("every egress entry is a well-formed host:port", () => { | ||
| 15 | 18 | |
| 16 | 19 | test("egress covers the arm and the first camera the relay derives from one host", () => { |
| 17 | 20 | const [host] = egress[0]!.split(":"); |
| 21 | const pi = egress.filter((e) => e !== FORGE); | |
| 18 | 22 | const remote = parseRemote({ ARM_SERVER: `ws://${host}:8101` })!; |
| 19 | 23 | const needed = [remote.host, ...remote.cameras.slice(0, 1).map((c) => new URL(c).host)]; |
| 20 | 24 | 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]); | |
| 22 | 34 | }); |
| 23 | 35 | |
| 24 | 36 | 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", () => { | ||
| 385 | 385 | test("an arm server that goes away: reconnecting with the last pose, driving suspended, then live again on the same arm", async () => { |
| 386 | 386 | remote = await arm([1, 2, 6]); |
| 387 | 387 | 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 } }); | |
| 390 | 391 | const base = `http://127.0.0.1:${relay.port}`; |
| 391 | 392 | expect(await joints(base)).toBe(3); |
| 392 | 393 | await shut(remote); |
Software/so101/src/relay.ts +91 −5
| @@ -107,7 +107,7 @@ import { parseFps, type FeedOptions } from "./camera-feed.ts"; | ||
| 107 | 107 | import { CameraError, CameraHub } from "./cameras.ts"; |
| 108 | 108 | import { ARM_ONLY, StationError, StationHub } from "./stations.ts"; |
| 109 | 109 | import { 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"; | |
| 111 | 111 | import { BUILT_IN_DIR, loadProfiles, type Profile } from "./care-profile.ts"; |
| 112 | 112 | import { ProbeCache, readProbeView, sampleProbes, waterTemp, type ProbeHooks } from "./care-probes.ts"; |
| 113 | 113 | import { handleCare } from "./care-routes.ts"; |
| @@ -116,7 +116,9 @@ import type { Doser } from "./doser.ts"; | ||
| 116 | 116 | import { armPumps, cachedRead, readDosing, type PumpHooks } from "./arm-pumps.ts"; |
| 117 | 117 | import { renderWorkshop, type GrowNow, type StationView } from "./workshop-page.ts"; |
| 118 | 118 | 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"; | |
| 120 | 122 | import { canopyByDay, dayNumber, dayOf, GrowLog, GrowLogError, lightHours } from "./grow-log.ts"; |
| 121 | 123 | import { parseProfile, ProfileError, type CameraProfile } from "./camera-profile.ts"; |
| 122 | 124 | import { VideoFeed, type VideoSources } from "./video-feed.ts"; |
| @@ -180,6 +182,8 @@ export interface RelayOptions { | ||
| 180 | 182 | growKey?: string | null; |
| 181 | 183 | /** How often the arm's sensors are pulled into the care log book, ms; unset, never. */ |
| 182 | 184 | 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 }; | |
| 183 | 187 | /** A setting that could not be used (shown on /link while simulating). */ |
| 184 | 188 | problem?: string | null; |
| 185 | 189 | /** The link's retries and its patience (src/arm-link.ts). */ |
| @@ -1127,7 +1131,7 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> { | ||
| 1127 | 1131 | .then(read) |
| 1128 | 1132 | .catch(() => null); |
| 1129 | 1133 | } |
| 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)) })); | |
| 1131 | 1135 | if (!bytes) return; |
| 1132 | 1136 | const done = await finishStillApart(bytes, { region: look?.region ?? null, gains: look?.gains ?? null }).catch(() => null); |
| 1133 | 1137 | // 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> { | ||
| 1137 | 1141 | /* the camera or the Pi did not answer: no still this time */ |
| 1138 | 1142 | } |
| 1139 | 1143 | }; |
| 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?.(); | |
| 1140 | 1218 | /** 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. */ |
| 1141 | 1219 | const pullSensors = async () => { |
| 1142 | 1220 | if (!(remote && onArm())) return; |
| @@ -1220,6 +1298,12 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> { | ||
| 1220 | 1298 | res.writeHead(200, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" }); |
| 1221 | 1299 | return res.end(text); |
| 1222 | 1300 | } |
| 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 | } | |
| 1223 | 1307 | // The Pi's pumps answer the relay's controller alone: a browser that reached them would skip every dosing limit. |
| 1224 | 1308 | if (url.pathname === "/grow/dosing" || url.pathname.startsWith("/grow/dosing/")) return json(res, 404, { error: "not found" }); |
| 1225 | 1309 | if (url.pathname.startsWith("/grow/") && options.grow) { |
| @@ -1266,7 +1350,7 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> { | ||
| 1266 | 1350 | if (source === null) return json(res, 404, { error: "no such camera" }); |
| 1267 | 1351 | if (camera[2] === "stream") return cameras.feed(id)!.serve(req, res, parseFps(url.searchParams.get("fps"))); |
| 1268 | 1352 | 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; | |
| 1270 | 1354 | try { |
| 1271 | 1355 | return await pass(res, await ask(target, req, undefined, readTimeoutMs, true)); |
| 1272 | 1356 | } catch (err) { |
| @@ -1277,7 +1361,7 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> { | ||
| 1277 | 1361 | if (req.method === "GET" && /^\/(?:index\.html)?$/.test(url.pathname) && stations.isWorkshop()) { |
| 1278 | 1362 | res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" }); |
| 1279 | 1363 | 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}/`)); | |
| 1281 | 1365 | } |
| 1282 | 1366 | if (req.method === "GET" && PAGE.test(url.pathname) && rest) return restingPage(req, res, rest); |
| 1283 | 1367 | 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> { | ||
| 1667 | 1751 | chatFacts?.(); |
| 1668 | 1752 | clearInterval(sampleTimer); |
| 1669 | 1753 | clearInterval(stillTimer); |
| 1754 | clearInterval(watchTimer); | |
| 1755 | clearTimeout(firstLook); | |
| 1670 | 1756 | clearInterval(probeTimer); |
| 1671 | 1757 | clearInterval(doseTimer); |
| 1672 | 1758 | clearInterval(sensorTimer); |
Software/so101/src/terminal.ts +50 −3
| @@ -38,6 +38,7 @@ | ||
| 38 | 38 | * /probes [calibrate <ph7|ph4|ec1413> [tempC]] the pH/EC probes (GET /care/probes); anyone reads, the owner calibrates (POST /care/probes/calibrate) |
| 39 | 39 | * /care [log|did|note|problem|done|snooze|delete …] the plant care app (GET /care/api); anyone reads, the owner writes (POST /care/log, /care/reminder) |
| 40 | 40 | * /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 | |
| 41 | 42 | * /trace <session> owner or moderator: a kept session's 5 Hz trace, per joint (GET /precision/trace) |
| 42 | 43 | * /data [hour|day|week|all] owner or moderator: the Data view's overview and worst sessions (GET /data, src/data-lines.ts); default day |
| 43 | 44 | * /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"; | ||
| 78 | 79 | import { careCommand, careLines, dosingLines, pumpLines, pumpSaid, probesCommand, type CareAsk } from "./care-terminal.ts"; |
| 79 | 80 | import { presenceText } from "./ui/lobby-panel.js"; |
| 80 | 81 | import { entryLine, jointRows, linkRows, stateRows } from "./ui/machine.js"; |
| 82 | import { when } from "./grow-watchdog.ts"; | |
| 81 | 83 | |
| 82 | 84 | export type Env = Record<string, string | undefined>; |
| 83 | 85 | |
| @@ -166,6 +168,39 @@ export function versionsReader(env: Env, fetcher: typeof fetch = fetch): ReadVer | ||
| 166 | 168 | }; |
| 167 | 169 | } |
| 168 | 170 | |
| 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 | ||
| 169 | 204 | /** What a station read or change answered. */ |
| 170 | 205 | export type ReadStations = (set?: string) => Promise<Seen>; |
| 171 | 206 | |
| @@ -552,7 +587,7 @@ export function whoami(room: Room): string { | ||
| 552 | 587 | /** The terminal holds no session to end: the forge signs who is at the keyboard afresh on every connection. */ |
| 553 | 588 | 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"; |
| 554 | 589 | |
| 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"; | |
| 556 | 591 | |
| 557 | 592 | /** The Machine feed's socket beside the lobby's: same app, same port. */ |
| 558 | 593 | export const machineUrl = (live: string): string => live.replace(/\/live$/, "/machine"); |
| @@ -708,7 +743,7 @@ function target(room: Room, word: string): string | null { | ||
| 708 | 743 | return ID.test(word) ? word : null; |
| 709 | 744 | } |
| 710 | 745 | |
| 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; | |
| 712 | 747 | |
| 713 | 748 | /** What a typed line asks for. Validation the server repeats anyway; this only saves a round trip on a typo. */ |
| 714 | 749 | export function typed(room: Room, input: string): Typed { |
| @@ -839,6 +874,8 @@ export function typed(room: Room, input: string): Typed { | ||
| 839 | 874 | return { servos: true }; |
| 840 | 875 | case "versions": |
| 841 | 876 | return { versions: true }; |
| 877 | case "watchdog": | |
| 878 | return { watchdog: true }; | |
| 842 | 879 | case "machine": |
| 843 | 880 | if (arg && arg.toLowerCase() !== "off" && arg.toLowerCase() !== "on") return { print: "usage: /machine [off]" }; |
| 844 | 881 | return { machine: arg.toLowerCase() !== "off" }; |
| @@ -874,6 +911,7 @@ export class Door { | ||
| 874 | 911 | private readonly readTuning: ReadTuning | null; |
| 875 | 912 | private readonly readTrace: ReadTrace | null; |
| 876 | 913 | private readonly readVersions: ReadVersions | null; |
| 914 | private readonly readWatchdog: ReadVersions | null; | |
| 877 | 915 | private readonly readStations: ReadStations | null; |
| 878 | 916 | private readonly readGrowLog: ReadGrowLog | null; |
| 879 | 917 | private readonly readCare: ReadCare | null; |
| @@ -882,7 +920,8 @@ export class Door { | ||
| 882 | 920 | private readonly readGrow: ReadGrow | null; |
| 883 | 921 | |
| 884 | 922 | /** `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; | |
| 886 | 925 | this.readCare = readCare; |
| 887 | 926 | this.readGrowLog = readGrowLog; |
| 888 | 927 | this.readGrow = readGrow; |
| @@ -945,6 +984,7 @@ export class Door { | ||
| 945 | 984 | else if ("grow" in t) void this.grow(t.grow); |
| 946 | 985 | else if ("tune" in t) void this.tune(t.tune); |
| 947 | 986 | else if ("versions" in t) void this.versions(); |
| 987 | else if ("watchdog" in t) void this.watchdog(); | |
| 948 | 988 | else if ("stations" in t) void this.stations(t.stations); |
| 949 | 989 | else if ("growLog" in t) void this.growLog(t.growLog); |
| 950 | 990 | else if ("care" in t) void this.care(t.care); |
| @@ -990,6 +1030,13 @@ export class Door { | ||
| 990 | 1030 | for (const line of r.error ? [`error: ${r.error}`] : r.lines) this.print(line); |
| 991 | 1031 | } |
| 992 | 1032 | |
| 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 | ||
| 993 | 1040 | /** The server decides who may; a refusal is printed, else the commits and the last sync outcome. */ |
| 994 | 1041 | private async versions(): Promise<void> { |
| 995 | 1042 | if (!this.readVersions) return this.print("error: versions are not available here"); |
Software/so101/src/tty.ts +2 −2
| @@ -4,7 +4,7 @@ | ||
| 4 | 4 | */ |
| 5 | 5 | |
| 6 | 6 | import { createInterface, type Interface } from "node:readline"; |
| 7 | import { alignReader, cardReader, 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"; | |
| 8 | 8 | |
| 9 | 9 | const env = process.env; |
| 10 | 10 | const where = env.FORGE_APP_ENV === "preview" ? `preview of #${env.FORGE_APP_PULL ?? "?"}` : (env.FORGE_APP_ENV ?? "local"); |
| @@ -17,7 +17,7 @@ const print = (line: string) => { | ||
| 17 | 17 | rl?.prompt(true); |
| 18 | 18 | }; |
| 19 | 19 | |
| 20 | const door = new Door(liveUrl(env), terminalHeaders(env), print, poster(env), reader(env), cardReader(env), servosReader(env), versionsReader(env), alignReader(env), tuningReader(env), traceReader(env), dataReader(env), growReader(env), stationsReader(env), 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)); | |
| 21 | 21 | print(`\x1b[1mSO-101 arm\x1b[0m · ${where} · ${HELP}`); |
| 22 | 22 | try { |
| 23 | 23 | await door.open(() => { |
Software/so101/src/workshop-page.ts +29 −2
| @@ -4,6 +4,7 @@ import type { Station } from "./stations.ts"; | ||
| 4 | 4 | import { renderSignedIn, SIGNED_IN_STYLE, type SignedIn } from "./signed-in.ts"; |
| 5 | 5 | import { esc } from "./story-markdown.ts"; |
| 6 | 6 | import type { Rect } from "./ui/still-frame.js"; |
| 7 | import { when, type WatchStatus } from "./grow-watchdog.ts"; | |
| 7 | 8 | |
| 8 | 9 | export const WORKSHOP_TITLE = "Workshop"; |
| 9 | 10 | |
| @@ -61,6 +62,12 @@ h1 { margin: .5rem 0 1rem; } h2 { margin: .6rem 0; font-size: 1.15rem; } | ||
| 61 | 62 | .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); } |
| 62 | 63 | .grow form { display: flex; gap: .5rem; flex-wrap: wrap; } .grow input[type=text] { flex: 1; min-width: 8rem; min-height: 44px; } |
| 63 | 64 | .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; } | |
| 64 | 71 | .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); } |
| 65 | 72 | .top a, .card button { min-height: 44px; display: inline-flex; align-items: center; } |
| 66 | 73 | .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 { | ||
| 95 | 102 | grow?: boolean; |
| 96 | 103 | /** When the newest lit still was taken: the card shows it without the script. */ |
| 97 | 104 | 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; | |
| 98 | 107 | /** How often a still is taken, ms: the empty timelapse says when the first one comes. */ |
| 99 | 108 | everyMs?: number; |
| 100 | 109 | /** The plant's day, the last watering and the next reminder. */ |
| @@ -155,10 +164,11 @@ function card({ station, present, resting, cameras }: StationView, looker: Looke | ||
| 155 | 164 | } |
| 156 | 165 | const grow = station.kind === "grow"; |
| 157 | 166 | const status = grow ? growing(looker.growNow, looker.owner) : ""; |
| 167 | const watch = grow && looker.watch ? renderWatch(looker.watch) : ""; | |
| 158 | 168 | if (!cameras.length) { |
| 159 | 169 | const setup = looker.owner ? ` <a href="/admin/camera-list">Cameras</a> · <a href="/admin/stations/edit">Stations</a>` : ""; |
| 160 | 170 | 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>`; | |
| 162 | 172 | } |
| 163 | 173 | const controls = station.controls.map((c) => `<a href="${esc(c.href)}">${esc(c.label)}</a>`).join(""); |
| 164 | 174 | 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 | ||
| 167 | 177 | ? `<img class="latest" src="/grow/stills/${Number(looker.latest)}.jpg" alt="The latest still of the grow box" width="1280" height="720" loading="lazy">` |
| 168 | 178 | : `<p class="quiet">The first stills arrive within ${minutes} minutes.</p>`; |
| 169 | 179 | 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>`; | |
| 171 | 198 | } |
| 172 | 199 | |
| 173 | 200 | /** 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 @@ | ||
| 18 | 18 | "100.89.95.68:8084", |
| 19 | 19 | "100.89.95.68:8091", |
| 20 | 20 | "100.89.95.68:8092", |
| 21 | "100.89.95.68:8101" | |
| 21 | "100.89.95.68:8101", | |
| 22 | "forge.open-platform.sh:443" | |
| 22 | 23 | ] |
| 23 | 24 | } |
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.