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

Merged opened this

Check · Review · Land done · Live — Landed; nothing deploys from this branch, so this is the end of its path.

Merged

smith/arm:smith/582-grow-light-schedule-a-switch-driver-smar → main 14 commits fork 2 threads · 2 unresolved

  • Merged. smith/582-grow-light-schedule-a-switch-driver-smar was merged into main as 26776eb.

Review

Sign in to review this change.
13 files changed +248 −18
Software/so101/README.md +2 −1
@@ -66,7 +66,7 @@ both cameras. The switch drivers:
6666
6767 - `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.
6868 - `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.
7070 - Tapo: not included; its local protocol is encrypted and needs an account login.
7171
7272 **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:
7676 - `/grow [light|pump] on|off|clear`: an override until the next change, or back to the schedule.
7777 - `/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).
7878 - `/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.
7980
8081 `/grow`, `POST /grow/light` and `POST /grow/pump` through the app are the owner's: to anyone else they answer 404.
8182
Software/so101/src/admin-page.ts +1 −1
@@ -210,7 +210,7 @@ export const adminCsp = (canEdit: boolean): string =>
210210 `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'`;
211211
212212 /** 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;
214214 export type Done = keyof typeof DONE;
215215
216216 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
205205 expect(await answer.text()).toContain("the arm server did not answer");
206206 expect((await fetch(`${base}/admin`, form(owner, { ...action, device: "toaster" }))).status).toBe(400);
207207 });
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";
99 import { adminCsp, DONE, renderAdmin, renderRefusal, type Done } from "./admin-page.ts";
1010 import type { Versions } from "./pi-deploy.ts";
1111 import type { PrecisionPanel } from "./precision-panel.ts";
12 import { lightRequest } from "./light-panel.ts";
12 import { lightRequest, triedFirst } from "./light-panel.ts";
1313 import type { DeviceName, LightView } from "./light.ts";
1414 import type { ServoPanel } from "./servo-panel.ts";
1515 import type { VideoCard } from "./video-panel.ts";
@@ -164,12 +164,13 @@ export async function handleAdmin(lobby: Lobby, req: IncomingMessage, res: Serve
164164 }
165165 case "light-schedule":
166166 case "light-switch":
167 case "light-override": {
167 case "light-override":
168 case "light-confirm": {
168169 const request = lightRequest(form);
169170 if (!request) throw new AdminError("no such device");
170171 const refused = askLight ? await askLight.write(request.device, request.body) : "the arm is not reachable from here";
171172 if (refused) throw new AdminError(refused);
172 done = "lightSaved";
173 done = triedFirst(request.body) ? "lightTried" : "lightSaved";

On a real Pi this line is rarely reached for uhubctl. askLight.write aborts at min(readTimeoutMs, 8000) = 2.5 s, and trial() sleeps 2 s on top of about 5 uhubctl/lsusb calls. Filed as #637: give light writes their own, longer timeout.

173174 break;
174175 }
175176 case "tune-p": {
Software/so101/src/light-endpoint.test.ts +45 −1
@@ -6,7 +6,7 @@ import { join } from "node:path";
66 import { FeetechBus } from "./bus.ts";
77 import { FakeBus } from "./fake-bus.ts";
88 import { Light, type DeviceName } from "./light.ts";
9 import { lightRequest, lightSections } from "./light-panel.ts";
9 import { lightRequest, lightSections, triedFirst } from "./light-panel.ts";
1010 import { readGrow, readLight } from "./light-read.ts";
1111 import type { Switch } from "./light-switch.ts";
1212 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
137137 const grow = (await (await fetch(`${base}/grow`, { headers: keyed })).json()) as Record<string, { configured: boolean }>;
138138 expect(grow.light!.configured).toBe(false);
139139 }, 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:
2323 return device && body ? { device, body } : null;
2424 }
2525
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
2632 function requestBody(form: URLSearchParams): Record<string, unknown> | null {
2733 const get = (k: string) => (form.get(k) ?? "").trim();
2834 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 {
3036 case "light-schedule":
3137 if (form.get("clear")) return { schedule: null };
3238 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" };
3341 case "light-override":
3442 return { override: get("value") };
3543 case "light-switch":
@@ -68,7 +76,7 @@ export function lightSections(grow: { light: LightView; pump: LightView | null;
6876
6977 /** 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. */
7078 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>`;
7280 }
7381
7482 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
8391 const photo = view.schedule && !isCycle(view.schedule) ? view.schedule : null;
8492 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>` : "";
8593 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 : "";
8697 const over = view.configured && canEdit
8798 ? `<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>`
8899 : "";
89100 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}`;
91102 if (!canEdit) return body;
92103 const driver = view.driver ?? "none";
93104 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
111122 ${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>
112123 <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>
113124 <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>
115126 <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>
116127 <label>USB port<small>1–32</small><input name="port" type="number" required min="1" max="32" step="1" inputmode="numeric"></label>
117128 <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 {
4848 on: typeof b.on === "boolean" ? b.on : null,
4949 text: text(b.text, 100),
5050 override: typeof o.on === "boolean" && typeof o.until === "number" ? { on: o.on, until: o.until } : null,
51 pending: text(b.pending, 80) || null,
5152 dark: b.dark === true,
5253 error: text(b.error, 300) || null,
5354 log,
Software/so101/src/light-switch.ts +46 −2
@@ -16,6 +16,11 @@ export interface Switch {
1616 state(): Promise<boolean>;
1717 /** Setup check; throws SwitchError when this switch must not be used. */
1818 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>;
1924 }
2025
2126 export class SwitchError extends Error {}
@@ -80,6 +85,8 @@ export function parseDriver(input: unknown): DriverConfig {
8085 export interface Deps {
8186 run?: Run;
8287 fetch?: typeof fetch;
88 /** Waits ms; tests pass a no-op. */
89 sleep?: (ms: number) => Promise<void>;
8390 }
8491
8592 export function makeSwitch(config: DriverConfig, deps: Deps = {}): Switch {
@@ -89,7 +96,7 @@ export function makeSwitch(config: DriverConfig, deps: Deps = {}): Switch {
8996 case "gpio":
9097 return new GpioSwitch(config, deps.run ?? run);
9198 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))));
93100 }
94101 }
95102
@@ -177,12 +184,21 @@ export function dropped(hub: HubListing, port: number, cameraIds: readonly strin
177184 return { devices, cameras };
178185 }
179186
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
180194 class UhubctlSwitch implements Switch {
181195 private readonly c: Extract<DriverConfig, { driver: "uhubctl" }>;
182196 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>) {
184199 this.c = c;
185200 this.exec = exec;
201 this.sleep = sleep;
186202 }
187203 private async list(): Promise<HubListing> {
188204 const r = await this.exec("uhubctl", ["-l", this.c.hub]);
@@ -195,6 +211,34 @@ class UhubctlSwitch implements Switch {
195211 const hit = dropped(await this.list(), this.c.port, this.c.cameras);
196212 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`);
197213 }
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 }
198242 private async set(on: boolean) {
199243 await this.verify(); // every time: a camera plugged in later must not be cut by the next transition
200244 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", () => {
255255 await expect(light.configure({ switch: uhub })).rejects.toThrow(/camera/);
256256 expect(light.view().configured).toBe(false);
257257 });
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 });
258318 test("parseHub", () => {
259319 const h = parseHub(GANGED);
260320 expect(h.perPort).toBe(false);
Software/so101/src/light.ts +30 −2
@@ -39,12 +39,16 @@ export interface LightView {
3939 /** "On until 22:00", "Off until 06:00", or why there is nothing to say. */
4040 text: string;
4141 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;
4244 /** The photoperiod is in its off hours: the card tells people to keep room lights off. */
4345 dark: boolean;
4446 error: string | null;
4547 log: Transition[];
4648 }
4749
50 const describe = (d: DriverConfig) => (d.driver === "uhubctl" ? `USB hub ${d.hub}, port ${d.port}` : d.driver);
51
4852 export class LightError extends Error {}
4953
5054 const KEEP = 48;
@@ -63,6 +67,7 @@ export class Light {
6367 private s: Stored = { schedule: null, driver: null, override: null, applied: null };
6468 private sw: Switch | null = null;
6569 private error: string | null = null;
70 private pending: { driver: DriverConfig; sw: Switch } | null = null;
6671 private log: Transition[] = [];
6772 private busy: Promise<void> = Promise.resolve();
6873 readonly zone: string;
@@ -133,23 +138,46 @@ export class Light {
133138 let schedule = this.s.schedule;
134139 let driver = this.s.driver;
135140 let sw = this.sw;
141 let tried: { driver: DriverConfig; sw: Switch } | null = null;
136142 try {
137143 if (change.schedule !== undefined) schedule = change.schedule === null ? null : parseSchedule(change.schedule);
138144 if (change.switch !== undefined) {
139145 driver = change.switch === null ? null : parseDriver(change.switch);
140146 sw = driver ? this.make(driver) : null;
141147 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: this.want() reads the stored schedule. If one request carries both schedule and switch, the port is restored per the old schedule. The forms and tty send them separately, so only a raw API caller hits this. Use the parsed schedule local when it's set.

151 tried = { driver, sw };
152 driver = this.s.driver;
153 sw = this.sw;
154 }
142155 }
143156 } catch (err) {
144157 if (err instanceof ScheduleError || err instanceof SwitchError) throw new LightError(err.message);
145158 throw err;
146159 }
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 };
148163 this.sw = sw;
149164 this.save();
150165 await this.tick();
151166 }
152167
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
153181 /** Hold the light on or off until the next schedule transition; "clear" gives it back to the schedule. */
154182 async override(value: unknown): Promise<void> {
155183 if (value !== "on" && value !== "off" && value !== "clear") throw new LightError("override is on, off or clear");
@@ -174,7 +202,7 @@ export class Light {
174202 const word = want ? (want.on ? "On" : "Off") : "Unscheduled";
175203 text = until === null ? `${word} (no schedule set)` : `${word} until ${clockIn(until, this.zone)}${live ? " (override)" : ""}`;
176204 }
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) };
178206 }
179207
180208 private record(e: Transition): void {
Software/so101/src/server.ts +5 −3
@@ -52,7 +52,7 @@
5252 * grow-key (src/grow-key.ts): only the hosted app, which checks the owner first, has it
5353 * GET /grow the grow light and the pump (src/light.ts): schedule, switch driver, "On until 22:00", override and the last transitions
5454 * 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
5656 * GET /probes the pH and EC probes on the ADS1115 (src/probes.ts): values (null until calibrated), ?temp= compensates, and the next calibration step
5757 * POST /probes/calibrate {"step": "ph7"|"ph4"|"ec1413", "tempC"?: number}; 400 says what to do
5858 * 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
10341034 case "POST /grow/light":
10351035 case "POST /grow/pump": {
10361036 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);
10381038 const extra = Object.keys(rest)[0];
10391039 try {
10401040 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");
10411042 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);
10431045 else await device.configure({ schedule, switch: sw });
10441046 } catch (err) {
10451047 if (err instanceof LightError) throw new HttpError(400, err.message);
Software/so101/src/terminal.test.ts +7
@@ -832,6 +832,12 @@ describe("/grow", () => {
832832 expect(growBody(["switch", "gpio", "17", "low"])).toEqual({ switch: { driver: "gpio", line: 17, activeLow: true } });
833833 expect(growBody(["switch", "uhubctl", "1-1", "2", "046d:0825"])).toEqual({ switch: { driver: "uhubctl", hub: "1-1", port: 2, cameras: ["046d:0825"] } });
834834 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 } } });
835841 expect(growBody(["schedule", "x", "y"])).toBeNull();
836842 expect(growBody(["on", "extra"])).toBeNull();
837843 expect(typed(emptyRoom(), "/grow toaster on")).toHaveProperty("print");
@@ -851,6 +857,7 @@ describe("/grow", () => {
851857 expect(printed.join("\n")).toContain("On until 18:00 · on at 06:00 for 12 h (UTC) · switch: shelly");
852858 expect(printed.join("\n")).toContain("Dark period: keep room lights off");
853859 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");
854861 expect(asked).toEqual([["/grow/pump", { schedule: { onMinutes: 10, offMinutes: 20 } }]]);
855862 const guest = await growReader({ PORT: "8102" }, (async () => Response.json({ error: "not found" }, { status: 404 })) as unknown as typeof fetch)();
856863 expect(guest).toEqual({ error: "the grow light is for the owner", lines: [] });
Software/so101/src/terminal.ts +5 −2
@@ -49,6 +49,7 @@
4949 * /grow owner: the light and the pump as /admin's Grow card shows them (GET /grow); 404 to anyone else
5050 * /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>)
5151 * /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)
5253 * /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
5354 * /tune owner: the run's progress and the last result (GET /tuning, src/tuning-panel.ts)
5455 * /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
319320 const s = v.schedule;
320321 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})`;
321322 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)`);
322324 if (v.dark) lines.push(` Dark period: keep room lights off`);
323325 if (v.error) lines.push(` the switch failed: ${plain(v.error)}`);
324326 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 {
343345 }
344346
345347 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";
347349
348350 /** `/grow <device> …` as the body POST /grow/<device> takes; null when it does not parse. The arm server checks every field again. */
349351 export function growBody(words: string[]): Record<string, unknown> | null {
350352 const [what = "", ...a] = words;
351353 const n = (w: string | undefined) => (w !== undefined && /^\d{1,4}$/.test(w) ? Number(w) : null);
352354 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;
353356 if (what === "schedule") {
354357 if (a.length === 1 && a[0] === "off") return { schedule: null };
355358 if (a.length !== 2) return null;
@@ -549,7 +552,7 @@ export function whoami(room: Room): string {
549552 /** The terminal holds no session to end: the forge signs who is at the keyboard afresh on every connection. */
550553 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";
551554
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";
553556
554557 /** The Machine feed's socket beside the lobby's: same app, same port. */
555558 export const machineUrl = (live: string): string => live.replace(/\/live$/, "/machine");

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs