Public: anyone can read this; the crew can change it.
Migrated from github.com/Trevato/SO-ARM100
workbenchthis machine
Grow light schedule: a switch driver (smart plug, GPIO relay, or uhubctl only when it switches the light alone) with a photoperiod that runs on the Pi #631
Check · Review · Land done · Live — Landed; nothing deploys from this branch, so this is the end of its path.
smith/arm:smith/582-grow-light-schedule-a-switch-driver-smar → main 14 commits fork 2 threads · 2 unresolved
13 files
changed +248
−18
Software/so101/README.md +2 −1
| @@ -66,7 +66,7 @@ both cameras. The switch drivers: | ||
| 66 | 66 | |
| 67 | 67 | - `shelly`: a Shelly smart plug on the LAN, local HTTP, no cloud account (Gen1 `/relay/0`, Gen2 `/rpc/Switch.Set`). The recommended path, about $15. |
| 68 | 68 | - `gpio`: a relay on a GPIO line, through libgpiod's `gpioset` / `gpioget` (needs `gpiod` v2 on the Pi), for a 5 V relay module. |
| 69 | - `uhubctl`: only for a port that switches alone. Saving it lists the hub; it **refuses** when the hub is ganged and a camera (named in the form by USB id, or with "cam" / "video" / "UVC" in its name) is on it, and checks again before every switch. | |
| 69 | - `uhubctl`: only for a port that switches alone. Saving it lists the hub; it **refuses** when the hub is ganged and a camera (named in the form by USB id, or with "cam" / "video" / "UVC" in its name) is on it, and checks again before every switch. Then it **tries the port**: off for 2 s, `lsusb` before and during (a camera that disappears fails it), back on; the switch is kept only when the owner answers "Did the light go off?" **Yes** on the grow card (`POST /grow/light {"confirm": true}`; a port that reports "off" is not proof on a ganged hub, and on this Pi's own `1-1` the light stayed on). The port is put back as the schedule says (off in a dark period), not blindly on. The waiting try-out lives in memory only: a restart of the arm server forgets it and the owner starts again (the card says so). A hub with real per-port power, like the UUGear MEGA4, passes. | |
| 70 | 70 | - Tapo: not included; its local protocol is encrypted and needs an account login. |
| 71 | 71 | |
| 72 | 72 | **From the terminal** (owner only; anyone else is told it is the owner's, exactly as for `/servos`), the same arm-server |
| @@ -76,6 +76,7 @@ both cameras. The switch drivers: | ||
| 76 | 76 | - `/grow [light|pump] on|off|clear`: an override until the next change, or back to the schedule. |
| 77 | 77 | - `/grow light schedule 06:00 12` · `/grow pump schedule 5 25` · `schedule off`: a photoperiod (on at, hours) or a cycle (minutes on, minutes off). |
| 78 | 78 | - `/grow pump switch shelly 192.168.1.40 [1|2]` · `switch gpio 17 [low]` · `switch uhubctl 1-1 2 [vid:pid…]` · `switch none`: the arm server refuses a `uhubctl` port a camera shares, and says so. |
| 79 | - `/grow [light|pump] confirm yes|no`: the answer to "Did the light go off?" after a `switch uhubctl …` try-out (the card's two buttons; `POST /grow/<device> {"confirm": …}`). `/grow` shows a waiting try-out and this command; yes keeps the switch, no drops it. Pending state is memory only. A terminal confirm is not pushed to a card already open: a viewer sees it on the next load. There is one waiting try-out per device, so a second `switch uhubctl …` replaces the first; the card and `/grow` both name the switch a yes applies to. | |
| 79 | 80 | |
| 80 | 81 | `/grow`, `POST /grow/light` and `POST /grow/pump` through the app are the owner's: to anyone else they answer 404. |
| 81 | 82 |
Software/so101/src/admin-page.ts +1 −1
| @@ -210,7 +210,7 @@ export const adminCsp = (canEdit: boolean): string => | ||
| 210 | 210 | `default-src 'none'; style-src 'sha256-${sha(STYLE)}'; script-src 'sha256-${sha(scriptFor(canEdit))}'; connect-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'`; |
| 211 | 211 | |
| 212 | 212 | /** The fixed lines a redirect back may ask for; nothing from the request is echoed. */ |
| 213 | export const DONE = { opened: "Open: people can take turns again.", closed: "Closed: visitors see the resting card.", saved: "Saved.", paused: "Paused: nobody new gets a turn.", resumed: "Open again: the next in line has control.", ended: "Turn ended.", taken: "You have control.", restored: "Line restored.", lifted: "Lifted.", reportRemoved: "Line removed; report closed.", reportMuted: "Author muted; report closed.", reportDismissed: "Report dismissed.", lightSaved: "Saved: the Pi applies it within 15 seconds.", tuning: "The P experiment is running on the arm: reload to follow it." } as const; | |
| 213 | export const DONE = { opened: "Open: people can take turns again.", closed: "Closed: visitors see the resting card.", saved: "Saved.", paused: "Paused: nobody new gets a turn.", resumed: "Open again: the next in line has control.", ended: "Turn ended.", taken: "You have control.", restored: "Line restored.", lifted: "Lifted.", reportRemoved: "Line removed; report closed.", reportMuted: "Author muted; report closed.", reportDismissed: "Report dismissed.", lightSaved: "Saved: the Pi applies it within 15 seconds.", lightTried: "The port was switched off for 2 s and back on. Say below whether the light went off.", tuning: "The P experiment is running on the arm: reload to follow it." } as const; | |
| 214 | 214 | export type Done = keyof typeof DONE; |
| 215 | 215 | |
| 216 | 216 | export interface Target { id: string; name: string } |
Software/so101/src/admin-relay.test.ts +28
| @@ -205,3 +205,31 @@ test("the grow light: /grow is the owner's; light-* actions on /admin pass the o | ||
| 205 | 205 | expect(await answer.text()).toContain("the arm server did not answer"); |
| 206 | 206 | expect((await fetch(`${base}/admin`, form(owner, { ...action, device: "toaster" }))).status).toBe(400); |
| 207 | 207 | }); |
| 208 | ||
| 209 | test("/admin: a uhubctl light-switch flashes lightTried, a light-confirm posts {confirm} and flashes lightSaved; a guest's terminal confirm reaches nothing", async () => { | |
| 210 | const posted: unknown[] = []; | |
| 211 | const arm = Bun.serve({ port: 0, fetch: async (req) => (req.method === "POST" ? (posted.push(await req.json()), Response.json({})) : Response.json({ light: { name: "light", configured: false, text: "Light: always on (no switch set up)", zone: "UTC", log: [] } })) }); | |
| 212 | try { | |
| 213 | dir = mkdtempSync(join(tmpdir(), "so101-admin-relay-")); | |
| 214 | lobby = new Lobby({ identityKey: KEY, adminFile: join(dir, "admin.json") }); | |
| 215 | relay = await startRelay({ port: 0, simulation: `http://127.0.0.1:${arm.port}`, lobby, growKey: "grow-key" }); | |
| 216 | const base = `http://127.0.0.1:${relay.port}`; | |
| 217 | const owner = as("trevato", "admin"); | |
| 218 | const tried = await fetch(`${base}/admin`, form(owner, { action: "light-switch", device: "light", driver: "uhubctl", hub: "1-1", port: "2" })); | |
| 219 | expect(tried.status).toBe(303); | |
| 220 | expect(posted.at(-1)).toMatchObject({ switch: { driver: "uhubctl", hub: "1-1", port: 2 } }); | |
| 221 | const triedPage = await fetch(new URL(tried.headers.get("location")!, base), { headers: owner }); | |
| 222 | expect(await triedPage.text()).toContain("switched off for 2 s and back on"); | |
| 223 | const saved = await fetch(`${base}/admin`, form(owner, { action: "light-confirm", device: "light", answer: "yes" })); | |
| 224 | expect(saved.status).toBe(303); | |
| 225 | expect(posted.at(-1)).toEqual({ confirm: true }); | |
| 226 | expect(await (await fetch(new URL(saved.headers.get("location")!, base), { headers: owner })).text()).toContain("Saved: the Pi applies it"); | |
| 227 | // the terminal's /grow confirm yes is that same POST /grow/light: a guest and a moderator get 404 and the arm hears nothing | |
| 228 | const before = posted.length; | |
| 229 | await fetch(`${base}/admin`, form(owner, { action: "mod-add", name: "bob" })); | |
| 230 | for (const headers of [{}, as("bob", "user")]) expect((await fetch(`${base}/grow/light`, { method: "POST", headers, body: JSON.stringify({ confirm: true }) })).status).toBe(404); | |
| 231 | expect(posted.length).toBe(before); | |
| 232 | } finally { | |
| 233 | arm.stop(true); | |
| 234 | } | |
| 235 | }); |
Software/so101/src/admin-routes.ts +4 −3
| @@ -9,7 +9,7 @@ import { sameOrigin } from "./relay.ts"; | ||
| 9 | 9 | import { adminCsp, DONE, renderAdmin, renderRefusal, type Done } from "./admin-page.ts"; |
| 10 | 10 | import type { Versions } from "./pi-deploy.ts"; |
| 11 | 11 | import type { PrecisionPanel } from "./precision-panel.ts"; |
| 12 | import { lightRequest } from "./light-panel.ts"; | |
| 12 | import { lightRequest, triedFirst } from "./light-panel.ts"; | |
| 13 | 13 | import type { DeviceName, LightView } from "./light.ts"; |
| 14 | 14 | import type { ServoPanel } from "./servo-panel.ts"; |
| 15 | 15 | import type { VideoCard } from "./video-panel.ts"; |
| @@ -164,12 +164,13 @@ export async function handleAdmin(lobby: Lobby, req: IncomingMessage, res: Serve | ||
| 164 | 164 | } |
| 165 | 165 | case "light-schedule": |
| 166 | 166 | case "light-switch": |
| 167 | case "light-override": { | |
| 167 | case "light-override": | |
| 168 | case "light-confirm": { | |
| 168 | 169 | const request = lightRequest(form); |
| 169 | 170 | if (!request) throw new AdminError("no such device"); |
| 170 | 171 | const refused = askLight ? await askLight.write(request.device, request.body) : "the arm is not reachable from here"; |
| 171 | 172 | if (refused) throw new AdminError(refused); |
| 172 | done = "lightSaved"; | |
| 173 | done = triedFirst(request.body) ? "lightTried" : "lightSaved"; | |
| 173 | 174 | break; |
| 174 | 175 | } |
| 175 | 176 | case "tune-p": { |
Software/so101/src/light-endpoint.test.ts +45 −1
| @@ -6,7 +6,7 @@ import { join } from "node:path"; | ||
| 6 | 6 | import { FeetechBus } from "./bus.ts"; |
| 7 | 7 | import { FakeBus } from "./fake-bus.ts"; |
| 8 | 8 | import { Light, type DeviceName } from "./light.ts"; |
| 9 | import { lightRequest, lightSections } from "./light-panel.ts"; | |
| 9 | import { lightRequest, lightSections, triedFirst } from "./light-panel.ts"; | |
| 10 | 10 | import { readGrow, readLight } from "./light-read.ts"; |
| 11 | 11 | import type { Switch } from "./light-switch.ts"; |
| 12 | 12 | import { startServer, type RunningServer } from "./server.ts"; |
| @@ -137,3 +137,47 @@ test("the body cap counts bytes: 600k three-byte characters (1.8 MB, under 1M ch | ||
| 137 | 137 | const grow = (await (await fetch(`${base}/grow`, { headers: keyed })).json()) as Record<string, { configured: boolean }>; |
| 138 | 138 | expect(grow.light!.configured).toBe(false); |
| 139 | 139 | }, 15_000); |
| 140 | ||
| 141 | test("a tried switch waits for POST /grow/light {confirm}; a bad confirm and a mixed request are 400", async () => { | |
| 142 | const lit = { on: false }; | |
| 143 | const dir = mkdtempSync(join(tmpdir(), "so101-light-")); | |
| 144 | const tried: Switch = { on: async () => void (lit.on = true), off: async () => void (lit.on = false), state: async () => lit.on, trial: async () => {} }; | |
| 145 | const make = (name: DeviceName) => new Light({ name, file: join(dir, `${name}.json`), zone: "UTC", now: () => Date.parse("2026-06-10T12:00:00Z"), make: () => tried }); | |
| 146 | server = await startServer(new FeetechBus(new FakeBus([1, 2])), { host: "127.0.0.1", port: 0, ids: [1, 2], posesFile: join(dir, "poses.json"), growKey: KEY, lights: { light: make("light"), pump: make("pump") } }); | |
| 147 | const base = `http://127.0.0.1:${server.port}`; | |
| 148 | const uhub = { driver: "gpio", line: 17 }; | |
| 149 | expect((await post(base, { confirm: true })).status).toBe(400); // nothing waits yet | |
| 150 | const set = (await (await post(base, { switch: uhub })).json()) as { configured: boolean; pending: string | null }; | |
| 151 | expect(set.configured).toBe(false); | |
| 152 | expect(set.pending).toBe("gpio"); | |
| 153 | for (const mixed of [{ confirm: true, override: "on" }, { confirm: true, schedule: null }, { confirm: true, switch: null }]) { | |
| 154 | const r = await post(base, mixed); | |
| 155 | expect(r.status).toBe(400); | |
| 156 | expect(((await r.json()) as { error: string }).error).toMatch(/own request/); | |
| 157 | } | |
| 158 | expect((await post(base, { confirm: "yes" })).status).toBe(400); | |
| 159 | const kept = (await (await post(base, { confirm: true })).json()) as { configured: boolean; pending: string | null }; | |
| 160 | expect(kept).toMatchObject({ configured: true, pending: null }); | |
| 161 | await post(base, { switch: uhub }); | |
| 162 | expect(((await (await post(base, { confirm: false })).json()) as { pending: string | null }).pending).toBeNull(); | |
| 163 | }); | |
| 164 | ||
| 165 | test("the pending card asks, in forms that work without JavaScript, and says it is memory only; light-confirm becomes {confirm}", () => { | |
| 166 | const view = readLight({ configured: false, text: "Light: always on (no switch set up)", zone: "UTC", pending: "USB hub 1-1, port 2", log: [] }, "light")!; | |
| 167 | const html = lightSections({ light: view, pump: null }, true, esc); | |
| 168 | expect(html).toContain("Did the light go off?"); | |
| 169 | expect(html).toContain("USB hub 1-1, port 2"); | |
| 170 | expect(html).toContain("memory only"); | |
| 171 | expect(html).toContain('value="light-confirm"'); | |
| 172 | expect(lightSections({ light: view, pump: null }, false, esc)).not.toContain("Did the light go off?"); | |
| 173 | const req = (q: string) => lightRequest(new URLSearchParams(q)); | |
| 174 | expect(req("action=light-confirm&device=light&answer=yes")).toEqual({ device: "light", body: { confirm: true } }); | |
| 175 | expect(req("action=light-confirm&device=light&answer=no")).toEqual({ device: "light", body: { confirm: false } }); | |
| 176 | }); | |
| 177 | ||
| 178 | test("triedFirst: only a uhubctl switch is tried before it is saved", () => { | |
| 179 | expect(triedFirst({ switch: { driver: "uhubctl", hub: "1-1", port: 2 } })).toBe(true); | |
| 180 | expect(triedFirst({ switch: { driver: "gpio", line: 17 } })).toBe(false); | |
| 181 | expect(triedFirst({ switch: null })).toBe(false); | |
| 182 | expect(triedFirst({ confirm: true })).toBe(false); | |
| 183 | }); |
Software/so101/src/light-panel.ts +14 −3
| @@ -23,6 +23,12 @@ export function lightRequest(form: URLSearchParams): { device: DeviceName; body: | ||
| 23 | 23 | return device && body ? { device, body } : null; |
| 24 | 24 | } |
| 25 | 25 | |
| 26 | /** True when the request sets a uhubctl switch: that one is tried first and waits for the owner's answer instead of being saved. */ | |
| 27 | export function triedFirst(body: Record<string, unknown>): boolean { | |
| 28 | const sw = body.switch; | |
| 29 | return typeof sw === "object" && sw !== null && (sw as { driver?: unknown }).driver === "uhubctl"; | |
| 30 | } | |
| 31 | ||
| 26 | 32 | function requestBody(form: URLSearchParams): Record<string, unknown> | null { |
| 27 | 33 | const get = (k: string) => (form.get(k) ?? "").trim(); |
| 28 | 34 | const num = (k: string) => (/^\d{1,4}$/.test(get(k)) ? Number(get(k)) : null); |
| @@ -30,6 +36,8 @@ function requestBody(form: URLSearchParams): Record<string, unknown> | null { | ||
| 30 | 36 | case "light-schedule": |
| 31 | 37 | if (form.get("clear")) return { schedule: null }; |
| 32 | 38 | return { schedule: get("kind") === "cycle" ? { onMinutes: num("onMinutes"), offMinutes: num("offMinutes") } : { onAt: get("onAt"), hours: num("hours") } }; |
| 39 | case "light-confirm": | |
| 40 | return { confirm: get("answer") === "yes" }; | |
| 33 | 41 | case "light-override": |
| 34 | 42 | return { override: get("value") }; |
| 35 | 43 | case "light-switch": |
| @@ -68,7 +76,7 @@ export function lightSections(grow: { light: LightView; pump: LightView | null; | ||
| 68 | 76 | |
| 69 | 77 | /** One driver's own form in a details element (open when it is the one in use), so a phone shows only the fields it needs and no script is wanted. */ |
| 70 | 78 | function driverForm(device: DeviceName, driver: string, title: string, current: string, fields: string): string { |
| 71 | return `<details${current === driver ? " open" : ""}><summary>${title}${current === driver ? " · in use" : ""}</summary><form method="post" action="/admin"><input type="hidden" name="action" value="light-switch"><input type="hidden" name="device" value="${device}"><input type="hidden" name="driver" value="${driver}">${fields}<div class="save"><button class="go">Use this switch</button></div></form></details>`; | |
| 79 | return `<details${current === driver ? " open" : ""}><summary>${title}${current === driver ? " · in use" : ""}</summary><form method="post" action="/admin"><input type="hidden" name="action" value="light-switch"><input type="hidden" name="device" value="${device}"><input type="hidden" name="driver" value="${driver}">${fields}<div class="save"><button class="go">${driver === "uhubctl" ? "Test and use this switch" : "Use this switch"}</button></div></form></details>`; | |
| 72 | 80 | } |
| 73 | 81 | |
| 74 | 82 | function deviceCard(view: LightView, canEdit: boolean, esc: (s: string) => string): string { |
| @@ -83,11 +91,14 @@ function deviceCard(view: LightView, canEdit: boolean, esc: (s: string) => strin | ||
| 83 | 91 | const photo = view.schedule && !isCycle(view.schedule) ? view.schedule : null; |
| 84 | 92 | const what = view.schedule ? `<p class="empty">${cycle ? `Every cycle: ${cycle.onMinutes} min on, ${cycle.offMinutes} min off.` : `Daily: on at ${esc(photo!.onAt)} for ${photo!.hours} h (${esc(view.zone)}).`}</p>` : ""; |
| 85 | 93 | const long = d === "light" && photo && photo.hours > SPINACH_MAX_HOURS ? `<p class="flash bad" role="note">${photo.hours} hours is long for spinach: it bolts on long days (modern lines at about 16 h, older ones from 14 h). Keep it at ${DEFAULT_SCHEDULE.hours}–${SPINACH_MAX_HOURS} h for a leaf crop.</p>` : ""; |
| 94 | const ask = view.pending && canEdit | |
| 95 | ? `<div class="flash bad" role="alert"><p><b>Did the light go off?</b> ${esc(view.pending)} was switched off for 2 s and back on, and no camera dropped. A port that reports "off" is not proof: on a ganged hub it stays powered. Save the switch only if you saw the light go off. This waits in memory only: a restart of the arm server forgets it.</p><div class="actions">${post(d, "light-confirm", `<input type="hidden" name="answer" value="yes">`, "Yes, it went off: save", "go")}${post(d, "light-confirm", `<input type="hidden" name="answer" value="no">`, "No, it stayed on")}</div></div>` | |
| 96 | : ""; | |
| 86 | 97 | const over = view.configured && canEdit |
| 87 | 98 | ? `<div class="actions">${view.override ? post(d, "light-override", `<input type="hidden" name="value" value="clear">`, "Back to the schedule") : ""}${post(d, "light-override", `<input type="hidden" name="value" value="on">`, "On until the next change")}${post(d, "light-override", `<input type="hidden" name="value" value="off">`, "Off until the next change")}</div>` |
| 88 | 99 | : ""; |
| 89 | 100 | const dark = view.dark ? `<p class="flash bad" role="note"><b>Dark period: keep room lights off.</b> Spinach counts light in its dark hours as day, and that can push it to bolt.</p>` : ""; |
| 90 | const body = `${head}<p><b>${text}</b></p>${dark}${what}${err}${long}${over}${strip}`; | |
| 101 | const body = `${head}<p><b>${text}</b></p>${dark}${what}${err}${ask}${long}${over}${strip}`; | |
| 91 | 102 | if (!canEdit) return body; |
| 92 | 103 | const driver = view.driver ?? "none"; |
| 93 | 104 | const opt = (v: string, t: string) => `<option value="${v}"${driver === v ? " selected" : ""}>${t}</option>`; |
| @@ -111,7 +122,7 @@ ${driverForm(d, "shelly", "Shelly plug (LAN)", driver, `<label>Plug address<smal | ||
| 111 | 122 | ${driverForm(d, "gpio", "GPIO relay", driver, `<label>GPIO line<small>the pin's line number, 0–63</small><input name="line" type="number" required min="0" max="63" step="1" inputmode="numeric" placeholder="17"></label> |
| 112 | 123 | <label>Chip<small>optional, default gpiochip0</small><input name="chip" maxlength="12" autocapitalize="none" autocomplete="off" pattern="gpiochip[0-9]{1,2}" placeholder="gpiochip0"></label> |
| 113 | 124 | <label class="check"><input name="activeLow" type="checkbox"> Relay closes on 0 (active low)</label>`)} |
| 114 | ${driverForm(d, "uhubctl", "USB port (uhubctl)", driver, `<p class="empty">Refused if a camera shares the port: a Pi 4 gangs its USB ports.</p> | |
| 125 | ${driverForm(d, "uhubctl", "USB port (uhubctl)", driver, `<p class="empty">Needs a hub with per-port power (a Pi 4's own ports are ganged; a UUGear MEGA4 switches each). Saving switches the port off for 2 s: refused if a camera drops, kept only when you confirm the light went off.</p> | |
| 115 | 126 | <label>USB hub<small>from <code>uhubctl</code>, like 1-1</small><input name="hub" required maxlength="20" autocapitalize="none" autocomplete="off" pattern="[0-9]+(-[0-9]+(\\.[0-9]+)*)?" placeholder="1-1"></label> |
| 116 | 127 | <label>USB port<small>1–32</small><input name="port" type="number" required min="1" max="32" step="1" inputmode="numeric"></label> |
| 117 | 128 | <label>Camera USB ids<small>optional, like 046d:0825, separated by commas; cameras are also found by name</small><input name="cameras" maxlength="200" autocapitalize="none" autocomplete="off" pattern="([0-9A-Fa-f]{4}:[0-9A-Fa-f]{4}[ ,]*)*" placeholder="046d:0825"></label>`)} |
Software/so101/src/light-read.ts +1
| @@ -48,6 +48,7 @@ export function readLight(body: unknown, name: DeviceName): LightView | null { | ||
| 48 | 48 | on: typeof b.on === "boolean" ? b.on : null, |
| 49 | 49 | text: text(b.text, 100), |
| 50 | 50 | override: typeof o.on === "boolean" && typeof o.until === "number" ? { on: o.on, until: o.until } : null, |
| 51 | pending: text(b.pending, 80) || null, | |
| 51 | 52 | dark: b.dark === true, |
| 52 | 53 | error: text(b.error, 300) || null, |
| 53 | 54 | log, |
Software/so101/src/light-switch.ts +46 −2
| @@ -16,6 +16,11 @@ export interface Switch { | ||
| 16 | 16 | state(): Promise<boolean>; |
| 17 | 17 | /** Setup check; throws SwitchError when this switch must not be used. */ |
| 18 | 18 | verify?(): Promise<void>; |
| 19 | /** | |
| 20 | * A physical try-out that cannot be undone by reading: switch off for a moment, watch for damage, switch back to what the schedule says (on when it says nothing). | |
| 21 | * Throws SwitchError when something that must stay powered dropped. A switch with one needs the owner's word that the light really went off. | |
| 22 | */ | |
| 23 | trial?(restoreOn?: boolean): Promise<void>; | |
| 19 | 24 | } |
| 20 | 25 | |
| 21 | 26 | export class SwitchError extends Error {} |
| @@ -80,6 +85,8 @@ export function parseDriver(input: unknown): DriverConfig { | ||
| 80 | 85 | export interface Deps { |
| 81 | 86 | run?: Run; |
| 82 | 87 | fetch?: typeof fetch; |
| 88 | /** Waits ms; tests pass a no-op. */ | |
| 89 | sleep?: (ms: number) => Promise<void>; | |
| 83 | 90 | } |
| 84 | 91 | |
| 85 | 92 | export function makeSwitch(config: DriverConfig, deps: Deps = {}): Switch { |
| @@ -89,7 +96,7 @@ export function makeSwitch(config: DriverConfig, deps: Deps = {}): Switch { | ||
| 89 | 96 | case "gpio": |
| 90 | 97 | return new GpioSwitch(config, deps.run ?? run); |
| 91 | 98 | case "uhubctl": |
| 92 | return new UhubctlSwitch(config, deps.run ?? run); | |
| 99 | return new UhubctlSwitch(config, deps.run ?? run, deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)))); | |
| 93 | 100 | } |
| 94 | 101 | } |
| 95 | 102 | |
| @@ -177,12 +184,21 @@ export function dropped(hub: HubListing, port: number, cameraIds: readonly strin | ||
| 177 | 184 | return { devices, cameras }; |
| 178 | 185 | } |
| 179 | 186 | |
| 187 | /** `lsusb` output as id -> name. */ | |
| 188 | export function usbDevices(out: string): Map<string, string> { | |
| 189 | const devices = new Map<string, string>(); | |
| 190 | for (const m of out.matchAll(/^Bus \d+ Device \d+: ID ([0-9a-f]{4}:[0-9a-f]{4})\s*(.*)$/gim)) devices.set(m[1]!.toLowerCase(), m[2]!.trim()); | |
| 191 | return devices; | |
| 192 | } | |
| 193 | ||
| 180 | 194 | class UhubctlSwitch implements Switch { |
| 181 | 195 | private readonly c: Extract<DriverConfig, { driver: "uhubctl" }>; |
| 182 | 196 | private readonly exec: Run; |
| 183 | constructor(c: Extract<DriverConfig, { driver: "uhubctl" }>, exec: Run) { | |
| 197 | private readonly sleep: (ms: number) => Promise<void>; | |
| 198 | constructor(c: Extract<DriverConfig, { driver: "uhubctl" }>, exec: Run, sleep: (ms: number) => Promise<void>) { | |
| 184 | 199 | this.c = c; |
| 185 | 200 | this.exec = exec; |
| 201 | this.sleep = sleep; | |
| 186 | 202 | } |
| 187 | 203 | private async list(): Promise<HubListing> { |
| 188 | 204 | const r = await this.exec("uhubctl", ["-l", this.c.hub]); |
| @@ -195,6 +211,34 @@ class UhubctlSwitch implements Switch { | ||
| 195 | 211 | const hit = dropped(await this.list(), this.c.port, this.c.cameras); |
| 196 | 212 | if (hit.cameras.length) throw new SwitchError(`switching port ${this.c.port} of hub ${this.c.hub} would also cut a camera (${hit.cameras.join("; ")}): a Pi 4 cannot switch one USB port alone. Use a smart plug or a relay`); |
| 197 | 213 | } |
| 214 | /** Off for 2 s, then back to `restoreOn` (the schedule's state, so a dark period stays dark): no camera may drop off the bus meanwhile. A hub that reports "off" while still powering the port (ganged) is why the owner must see the light go. */ | |
| 215 | async trial(restoreOn = true) { | |
| 216 | await this.verify(); | |
| 217 | const seen = async () => { | |
| 218 | const r = await this.exec("lsusb", []); | |
| 219 | if (r.code !== 0) throw new SwitchError(`lsusb failed: ${r.out.trim().slice(0, 200)}`); | |
| 220 | return usbDevices(r.out); | |
| 221 | }; | |
| 222 | const before = await seen(); | |
| 223 | let after: Map<string, string> | undefined; | |
| 224 | let failure: unknown; | |
| 225 | try { | |
| 226 | await this.set(false); // inside the try: a hub that cut the port and then failed must still be put back | |
| 227 | await this.sleep(2000); | |
| 228 | after = await seen(); | |
| 229 | } catch (err) { | |
| 230 | failure = err; | |
| 231 | } | |
| 232 | const lost = after ? [...before].filter(([id, name]) => !after.has(id) && (this.c.cameras.includes(id) || CAMERA_WORDS.test(name))) : []; | |
| 233 | if (!failure && lost.length) failure = new SwitchError(`the test cut a camera (${lost.map(([id, name]) => `${id} ${name}`.trim()).join("; ")}): this port is ganged with it. Use a hub with per-port power`); | |
| 234 | try { | |
| 235 | await this.set(restoreOn); | |
| 236 | } catch (err) { | |
| 237 | const said = err instanceof Error ? err.message : String(err); | |
| 238 | throw new SwitchError(failure ? `${failure instanceof Error ? failure.message : String(failure)}; and putting the port back failed too: ${said}` : `the test could not put the port back: ${said}`); | |
| 239 | } | |
| 240 | if (failure) throw failure; | |
| 241 | } | |
| 198 | 242 | private async set(on: boolean) { |
| 199 | 243 | await this.verify(); // every time: a camera plugged in later must not be cut by the next transition |
| 200 | 244 | const r = await this.exec("uhubctl", ["-l", this.c.hub, "-p", String(this.c.port), "-a", on ? "on" : "off"]); |
Software/so101/src/light.test.ts +60
| @@ -255,6 +255,66 @@ describe("drivers", () => { | ||
| 255 | 255 | await expect(light.configure({ switch: uhub })).rejects.toThrow(/camera/); |
| 256 | 256 | expect(light.view().configured).toBe(false); |
| 257 | 257 | }); |
| 258 | const LSUSB = "Bus 001 Device 004: ID 046d:0825 Logitech, Inc. Webcam C270\nBus 001 Device 003: ID 1bcf:2c99 Sunplus USB Camera\n"; | |
| 259 | function trialRun(afterOff: string) { | |
| 260 | const calls: string[] = []; | |
| 261 | let off = false; | |
| 262 | const run: Run = async (cmd, args) => { | |
| 263 | calls.push(cmd === "lsusb" ? "lsusb" : args.includes("-a") ? `${args.at(-1)}` : "list"); | |
| 264 | if (cmd === "lsusb") return { code: 0, out: off ? afterOff : LSUSB }; | |
| 265 | if (args.includes("-a")) off = args.at(-1) === "off"; | |
| 266 | return { code: 0, out: PPPS }; | |
| 267 | }; | |
| 268 | return { run, calls }; | |
| 269 | } | |
| 270 | test("uhubctl trial: off, look at the bus, back on; a dropped camera fails it", async () => { | |
| 271 | const ok = trialRun(LSUSB); | |
| 272 | await makeSwitch(parseDriver(uhub), { run: ok.run, sleep: async () => {} }).trial!(); | |
| 273 | expect(ok.calls.filter((c) => c === "off" || c === "on" || c === "lsusb")).toEqual(["lsusb", "off", "lsusb", "on"]); | |
| 274 | const bad = trialRun("Bus 001 Device 003: ID 1bcf:2c99 Sunplus USB Camera\n"); | |
| 275 | await expect(makeSwitch(parseDriver(uhub), { run: bad.run, sleep: async () => {} }).trial!()).rejects.toThrow(/046d:0825/); | |
| 276 | expect(bad.calls.at(-1)).toBe("on"); | |
| 277 | }); | |
| 278 | test("trial: a hub that cut the port then failed is still put back; a restore that fails too is reported with the first error", async () => { | |
| 279 | const calls: string[] = []; | |
| 280 | const failing = (failOn: (n: number) => boolean): Run => { | |
| 281 | let sets = 0; | |
| 282 | return async (cmd, args) => { | |
| 283 | if (cmd === "lsusb") return { code: 0, out: LSUSB }; | |
| 284 | if (!args.includes("-a")) return { code: 0, out: PPPS }; | |
| 285 | calls.push(args.at(-1)!); | |
| 286 | return failOn(++sets) ? { code: 1, out: "timed out" } : { code: 0, out: PPPS }; | |
| 287 | }; | |
| 288 | }; | |
| 289 | await expect(makeSwitch(parseDriver(uhub), { run: failing((n) => n === 1), sleep: async () => {} }).trial!()).rejects.toThrow(/uhubctl failed/); | |
| 290 | expect(calls).toEqual(["off", "on"]); // the failed cut was followed by a restore | |
| 291 | await expect(makeSwitch(parseDriver(uhub), { run: failing(() => true), sleep: async () => {} }).trial!()).rejects.toThrow(/uhubctl failed.*putting the port back failed too/); | |
| 292 | }); | |
| 293 | test("the trial puts the port back as the schedule says: dark stays dark", async () => { | |
| 294 | const dir = mkdtempSync(join(tmpdir(), "light-")); | |
| 295 | const run = trialRun(LSUSB); | |
| 296 | const noon = Date.parse("2026-06-10T12:00:00Z"); | |
| 297 | const light = new Light({ file: join(dir, "l.json"), zone: "UTC", now: () => noon, deps: { run: run.run, sleep: async () => {} } }); | |
| 298 | await light.configure({ schedule: { onAt: "20:00", hours: 8 } }); // off at noon | |
| 299 | await light.configure({ switch: uhub }); | |
| 300 | const moves = run.calls.filter((c) => c === "off" || c === "on"); | |
| 301 | expect(moves).toEqual(["off", "off"]); // off for the look, then back to off, not on | |
| 302 | }); | |
| 303 | test("a uhubctl switch is kept only when the owner says the light went off", async () => { | |
| 304 | const dir = mkdtempSync(join(tmpdir(), "light-")); | |
| 305 | const light = new Light({ file: join(dir, "l.json"), zone: "UTC", deps: { run: trialRun(LSUSB).run, sleep: async () => {} } }); | |
| 306 | await light.configure({ switch: uhub }); | |
| 307 | expect(light.view().configured).toBe(false); | |
| 308 | expect(light.view().pending).toBe("USB hub 1-1, port 2"); | |
| 309 | await light.confirm(false); | |
| 310 | expect(light.view().pending).toBeNull(); | |
| 311 | expect(light.view().configured).toBe(false); | |
| 312 | await light.configure({ switch: uhub }); | |
| 313 | await light.confirm(true); | |
| 314 | expect(light.view().configured).toBe(true); | |
| 315 | expect(light.view().driver).toBe("uhubctl"); | |
| 316 | await expect(light.confirm(true)).rejects.toThrow(/waiting/); | |
| 317 | }); | |
| 258 | 318 | test("parseHub", () => { |
| 259 | 319 | const h = parseHub(GANGED); |
| 260 | 320 | expect(h.perPort).toBe(false); |
Software/so101/src/light.ts +30 −2
| @@ -39,12 +39,16 @@ export interface LightView { | ||
| 39 | 39 | /** "On until 22:00", "Off until 06:00", or why there is nothing to say. */ |
| 40 | 40 | text: string; |
| 41 | 41 | override: { on: boolean; until: number } | null; |
| 42 | /** A switch that passed its try-out and waits for the owner to say the light really went off. */ | |
| 43 | pending: string | null; | |
| 42 | 44 | /** The photoperiod is in its off hours: the card tells people to keep room lights off. */ |
| 43 | 45 | dark: boolean; |
| 44 | 46 | error: string | null; |
| 45 | 47 | log: Transition[]; |
| 46 | 48 | } |
| 47 | 49 | |
| 50 | const describe = (d: DriverConfig) => (d.driver === "uhubctl" ? `USB hub ${d.hub}, port ${d.port}` : d.driver); | |
| 51 | ||
| 48 | 52 | export class LightError extends Error {} |
| 49 | 53 | |
| 50 | 54 | const KEEP = 48; |
| @@ -63,6 +67,7 @@ export class Light { | ||
| 63 | 67 | private s: Stored = { schedule: null, driver: null, override: null, applied: null }; |
| 64 | 68 | private sw: Switch | null = null; |
| 65 | 69 | private error: string | null = null; |
| 70 | private pending: { driver: DriverConfig; sw: Switch } | null = null; | |
| 66 | 71 | private log: Transition[] = []; |
| 67 | 72 | private busy: Promise<void> = Promise.resolve(); |
| 68 | 73 | readonly zone: string; |
| @@ -133,23 +138,46 @@ export class Light { | ||
| 133 | 138 | let schedule = this.s.schedule; |
| 134 | 139 | let driver = this.s.driver; |
| 135 | 140 | let sw = this.sw; |
| 141 | let tried: { driver: DriverConfig; sw: Switch } | null = null; | |
| 136 | 142 | try { |
| 137 | 143 | if (change.schedule !== undefined) schedule = change.schedule === null ? null : parseSchedule(change.schedule); |
| 138 | 144 | if (change.switch !== undefined) { |
| 139 | 145 | driver = change.switch === null ? null : parseDriver(change.switch); |
| 140 | 146 | sw = driver ? this.make(driver) : null; |
| 141 | 147 | await sw?.verify?.(); |
| 148 | if (driver && sw?.trial) { | |
| 149 | // Not kept until the owner confirms the light went off: the hub's own report is not proof. | |
| 150 | await this.serial(() => sw!.trial!(this.want()?.on ?? true)); | |
Minor: |
||
| 151 | tried = { driver, sw }; | |
| 152 | driver = this.s.driver; | |
| 153 | sw = this.sw; | |
| 154 | } | |
| 142 | 155 | } |
| 143 | 156 | } catch (err) { |
| 144 | 157 | if (err instanceof ScheduleError || err instanceof SwitchError) throw new LightError(err.message); |
| 145 | 158 | throw err; |
| 146 | 159 | } |
| 147 | this.s = { ...this.s, schedule, driver, override: change.switch !== undefined ? null : this.s.override, applied: change.switch !== undefined ? null : this.s.applied }; | |
| 160 | if (change.switch !== undefined) this.pending = tried; | |
| 161 | const swapped = change.switch !== undefined && !tried; | |
| 162 | this.s = { ...this.s, schedule, driver, override: swapped ? null : this.s.override, applied: swapped ? null : this.s.applied }; | |
| 148 | 163 | this.sw = sw; |
| 149 | 164 | this.save(); |
| 150 | 165 | await this.tick(); |
| 151 | 166 | } |
| 152 | 167 | |
| 168 | /** The owner's answer to "did the light go off?": yes keeps the tried switch, no drops it. */ | |
| 169 | async confirm(yes: unknown): Promise<void> { | |
| 170 | if (typeof yes !== "boolean") throw new LightError("confirm is true or false"); | |
| 171 | const p = this.pending; | |
| 172 | if (!p) throw new LightError("no switch is waiting to be confirmed"); | |
| 173 | this.pending = null; | |
| 174 | if (!yes) return; | |
| 175 | this.s = { ...this.s, driver: p.driver, override: null, applied: null }; | |
| 176 | this.sw = p.sw; | |
| 177 | this.save(); | |
| 178 | await this.tick(); | |
| 179 | } | |
| 180 | ||
| 153 | 181 | /** Hold the light on or off until the next schedule transition; "clear" gives it back to the schedule. */ |
| 154 | 182 | async override(value: unknown): Promise<void> { |
| 155 | 183 | if (value !== "on" && value !== "off" && value !== "clear") throw new LightError("override is on, off or clear"); |
| @@ -174,7 +202,7 @@ export class Light { | ||
| 174 | 202 | const word = want ? (want.on ? "On" : "Off") : "Unscheduled"; |
| 175 | 203 | text = until === null ? `${word} (no schedule set)` : `${word} until ${clockIn(until, this.zone)}${live ? " (override)" : ""}`; |
| 176 | 204 | } |
| 177 | return { name: this.name, configured: this.sw !== null, driver: this.s.driver?.driver ?? null, schedule: this.s.schedule, zone: this.zone, on: this.sw ? this.s.applied : null, text, override: live, dark: this.name === "light" && this.sw !== null && this.s.schedule !== null && !isCycle(this.s.schedule) && !phaseAt(this.s.schedule, t, this.zone).on, error: this.error, log: this.log.slice(-KEEP) }; | |
| 205 | return { name: this.name, configured: this.sw !== null, driver: this.s.driver?.driver ?? null, schedule: this.s.schedule, zone: this.zone, on: this.sw ? this.s.applied : null, text, override: live, pending: this.pending ? describe(this.pending.driver) : null, dark: this.name === "light" && this.sw !== null && this.s.schedule !== null && !isCycle(this.s.schedule) && !phaseAt(this.s.schedule, t, this.zone).on, error: this.error, log: this.log.slice(-KEEP) }; | |
| 178 | 206 | } |
| 179 | 207 | |
| 180 | 208 | private record(e: Transition): void { |
Software/so101/src/server.ts +5 −3
| @@ -52,7 +52,7 @@ | ||
| 52 | 52 | * grow-key (src/grow-key.ts): only the hosted app, which checks the owner first, has it |
| 53 | 53 | * GET /grow the grow light and the pump (src/light.ts): schedule, switch driver, "On until 22:00", override and the last transitions |
| 54 | 54 | * GET /grow/sensors the sensors listed in sensors.json (src/sensors.ts: ds18b20, sht31, bme280, float): each one's last value, when, and why it failed; and their Readings |
| 55 | * POST /grow/light {"schedule"?: {"onAt": "06:00", "hours": 12}|null, "switch"?: {"driver": "shelly"|"gpio"|"uhubctl", ...}|null} or {"override": "on"|"off"|"clear"}; 400 says why not | |
| 55 | * POST /grow/light {"schedule"?: {"onAt": "06:00", "hours": 12}|null, "switch"?: {"driver": "shelly"|"gpio"|"uhubctl", ...}|null} or {"override": "on"|"off"|"clear"}, or {"confirm": true|false} (a uhubctl switch is tried first, off for 2 s, and kept only once the owner says the light went off); 400 says why not | |
| 56 | 56 | * GET /probes the pH and EC probes on the ADS1115 (src/probes.ts): values (null until calibrated), ?temp= compensates, and the next calibration step |
| 57 | 57 | * POST /probes/calibrate {"step": "ph7"|"ph4"|"ec1413", "tempC"?: number}; 400 says what to do |
| 58 | 58 | * POST /grow/pump the same, with an interval schedule {"onMinutes": 5, "offMinutes": 25} |
| @@ -1034,12 +1034,14 @@ export async function startServer(arm: FeetechBus | null, options: ServerOptions | ||
| 1034 | 1034 | case "POST /grow/light": |
| 1035 | 1035 | case "POST /grow/pump": { |
| 1036 | 1036 | const device = lights[route.slice("POST /grow/".length) as DeviceName]; |
| 1037 | const { schedule, switch: sw, override, ...rest } = await readBody(req); | |
| 1037 | const { schedule, switch: sw, override, confirm, ...rest } = await readBody(req); | |
| 1038 | 1038 | const extra = Object.keys(rest)[0]; |
| 1039 | 1039 | try { |
| 1040 | 1040 | if (extra) throw new LightError(`no field "${extra.slice(0, 40)}"`); |
| 1041 | if (confirm !== undefined && (schedule !== undefined || sw !== undefined || override !== undefined)) throw new LightError("a confirmation is its own request"); | |
| 1041 | 1042 | if (override !== undefined && (schedule !== undefined || sw !== undefined)) throw new LightError("an override is its own request"); |
| 1042 | if (override !== undefined) await device.override(override); | |
| 1043 | if (confirm !== undefined) await device.confirm(confirm); | |
| 1044 | else if (override !== undefined) await device.override(override); | |
| 1043 | 1045 | else await device.configure({ schedule, switch: sw }); |
| 1044 | 1046 | } catch (err) { |
| 1045 | 1047 | if (err instanceof LightError) throw new HttpError(400, err.message); |
Software/so101/src/terminal.test.ts +7
| @@ -832,6 +832,12 @@ describe("/grow", () => { | ||
| 832 | 832 | expect(growBody(["switch", "gpio", "17", "low"])).toEqual({ switch: { driver: "gpio", line: 17, activeLow: true } }); |
| 833 | 833 | expect(growBody(["switch", "uhubctl", "1-1", "2", "046d:0825"])).toEqual({ switch: { driver: "uhubctl", hub: "1-1", port: 2, cameras: ["046d:0825"] } }); |
| 834 | 834 | expect(growBody(["switch", "none"])).toEqual({ switch: null }); |
| 835 | expect(growBody(["confirm", "yes"])).toEqual({ confirm: true }); | |
| 836 | expect(growBody(["confirm", "no"])).toEqual({ confirm: false }); | |
| 837 | expect(growBody(["confirm"])).toBeNull(); | |
| 838 | expect(growBody(["confirm", "maybe"])).toBeNull(); | |
| 839 | expect(typed(emptyRoom(), "/grow confirm yes")).toEqual({ grow: { device: "light", body: { confirm: true } } }); | |
| 840 | expect(typed(emptyRoom(), "/grow pump confirm no")).toEqual({ grow: { device: "pump", body: { confirm: false } } }); | |
| 835 | 841 | expect(growBody(["schedule", "x", "y"])).toBeNull(); |
| 836 | 842 | expect(growBody(["on", "extra"])).toBeNull(); |
| 837 | 843 | expect(typed(emptyRoom(), "/grow toaster on")).toHaveProperty("print"); |
| @@ -851,6 +857,7 @@ describe("/grow", () => { | ||
| 851 | 857 | expect(printed.join("\n")).toContain("On until 18:00 · on at 06:00 for 12 h (UTC) · switch: shelly"); |
| 852 | 858 | expect(printed.join("\n")).toContain("Dark period: keep room lights off"); |
| 853 | 859 | expect(printed.join("\n")).toContain("5 min on, 25 min off"); |
| 860 | expect(growLines({ light: view("light", { pending: "USB hub 1-1, port 2" }) as never, pump: null }).join("\n")).toContain("/grow light confirm yes|no"); | |
| 854 | 861 | expect(asked).toEqual([["/grow/pump", { schedule: { onMinutes: 10, offMinutes: 20 } }]]); |
| 855 | 862 | const guest = await growReader({ PORT: "8102" }, (async () => Response.json({ error: "not found" }, { status: 404 })) as unknown as typeof fetch)(); |
| 856 | 863 | expect(guest).toEqual({ error: "the grow light is for the owner", lines: [] }); |
Software/so101/src/terminal.ts +5 −2
| @@ -49,6 +49,7 @@ | ||
| 49 | 49 | * /grow owner: the light and the pump as /admin's Grow card shows them (GET /grow); 404 to anyone else |
| 50 | 50 | * /grow [light|pump] on|off|clear owner: (no device word is the light) override until the next change, or give it back to the schedule (POST /grow/<device>) |
| 51 | 51 | * /grow <light|pump> schedule 06:00 12 | 5 25 | off owner: a daily photoperiod (on at, hours) or an interval cycle (minutes on, off), or none |
| 52 | * /grow [light|pump] confirm yes|no owner: the answer to "did the light go off?" after a uhubctl switch was tried (it is kept only on yes) | |
| 52 | 53 | * /grow <light|pump> switch none | shelly <host> [1|2] | gpio <line> [low] | uhubctl <hub> <port> [vid:pid…] owner: the switch; uhubctl is refused when a camera shares it |
| 53 | 54 | * /tune owner: the run's progress and the last result (GET /tuning, src/tuning-panel.ts) |
| 54 | 55 | * /machine [off] the Machine panel (/machine, src/machine-feed.ts): the joints, the link and the server as they are, |
| @@ -319,6 +320,7 @@ export function growLines(grow: { light: LightView; pump: LightView | null; sens | ||
| 319 | 320 | const s = v.schedule; |
| 320 | 321 | const sched = !s ? "no schedule" : isCycle(s) ? `${s.onMinutes} min on, ${s.offMinutes} min off` : `on at ${s.onAt} for ${s.hours} h (${v.zone})`; |
| 321 | 322 | lines.push(`${plain(v.text)} · ${sched} · switch: ${v.driver ?? "none"}`); |
| 323 | if (v.pending) lines.push(` Did the light go off? ${plain(v.pending)} was tried, not saved yet: /grow ${v.name} confirm yes|no (kept in memory only)`); | |
| 322 | 324 | if (v.dark) lines.push(` Dark period: keep room lights off`); |
| 323 | 325 | if (v.error) lines.push(` the switch failed: ${plain(v.error)}`); |
| 324 | 326 | for (const e of v.log.slice(-3)) lines.push(` ${new Date(e.at).toISOString().slice(5, 16).replace("T", " ")}Z ${e.on ? "on" : "off"} (${e.cause})`); |
| @@ -343,13 +345,14 @@ export function growReader(env: Env, fetcher: typeof fetch = fetch): ReadGrow { | ||
| 343 | 345 | } |
| 344 | 346 | |
| 345 | 347 | const GROW_LOG_USAGE = "usage: /grow log · /grow planted <YYYY-MM-DD>|none · /grow note <text>"; |
| 346 | const GROW_USAGE = "usage: /grow · /grow [light|pump] on|off|clear · schedule off | schedule 06:00 12 (light: on at, hours) | schedule 5 25 (pump: minutes on, off) · switch none | shelly <host> [1|2] | gpio <line> [low] | uhubctl <hub> <port> [vid:pid…]"; | |
| 348 | const GROW_USAGE = "usage: /grow · /grow [light|pump] on|off|clear · schedule off | schedule 06:00 12 (light: on at, hours) | schedule 5 25 (pump: minutes on, off) · switch none | shelly <host> [1|2] | gpio <line> [low] | uhubctl <hub> <port> [vid:pid…] · confirm yes|no"; | |
| 347 | 349 | |
| 348 | 350 | /** `/grow <device> …` as the body POST /grow/<device> takes; null when it does not parse. The arm server checks every field again. */ |
| 349 | 351 | export function growBody(words: string[]): Record<string, unknown> | null { |
| 350 | 352 | const [what = "", ...a] = words; |
| 351 | 353 | const n = (w: string | undefined) => (w !== undefined && /^\d{1,4}$/.test(w) ? Number(w) : null); |
| 352 | 354 | if (what === "on" || what === "off" || what === "clear") return a.length ? null : { override: what }; |
| 355 | if (what === "confirm") return a.length === 1 && (a[0] === "yes" || a[0] === "no") ? { confirm: a[0] === "yes" } : null; | |
| 353 | 356 | if (what === "schedule") { |
| 354 | 357 | if (a.length === 1 && a[0] === "off") return { schedule: null }; |
| 355 | 358 | if (a.length !== 2) return null; |
| @@ -549,7 +552,7 @@ export function whoami(room: Room): string { | ||
| 549 | 552 | /** The terminal holds no session to end: the forge signs who is at the keyboard afresh on every connection. */ |
| 550 | 553 | 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"; |
| 551 | 554 | |
| 552 | export const HELP = "type to chat · /join /leave /ready /extend · /stop /resume (driver, owner) · /end [who] /table here|touch 1|2|3|<mm> /zero <joint> [deg] /align [joint] (owner) · /mute <who> /unmute <who> (owner, moderator) · /delete <who|#id> (owner, moderator) · /report <who|#id> · /reports /dismiss #<id> (owner, moderator) · /removed /restore #<id> /visitors (owner) · /close [note] /open (owner) · /health /precision /trace <session> /data [hour|day|week|all|#<turn>] /versions (owner, moderator) · /servos /tune [<joint> <P>] /grow [light|pump …] (owner) /grow log|planted <date>|note <text> · /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"; | |
| 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"; | |
| 553 | 556 | |
| 554 | 557 | /** The Machine feed's socket beside the lobby's: same app, same port. */ |
| 555 | 558 | export const machineUrl = (live: string): string => live.replace(/\/live$/, "/machine"); |
On a real Pi this line is rarely reached for uhubctl.
askLight.writeaborts atmin(readTimeoutMs, 8000)= 2.5 s, andtrial()sleeps 2 s on top of about 5 uhubctl/lsusb calls. Filed as #637: give light writes their own, longer timeout.