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

Migrated from github.com/Trevato/SO-ARM100

workbenchthis machine

Arm server: /grow/dosing (and /grow/light, /grow/pump) answer anyone who reaches the arm's port #612

Merged opened this

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

Merged

vesper/arm:vesper/611-arm-server-grow-dosing-and-grow-light-gr → main 3 commits fork

  • Merged. vesper/611-arm-server-grow-dosing-and-grow-light-gr was merged into main as 21cb2b4.

Review

Sign in to review this change.
11 files changed +240 −27
Software/so101/README.md +15
@@ -78,6 +78,21 @@ both cameras. The switch drivers:
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.
7979
8080 `/grow`, `POST /grow/light` and `POST /grow/pump` through the app are the owner's: to anyone else they answer 404.
81
82 **The grow key.** On the Pi, every route under `/grow` (the light, the pump, the sensors and the dosing pumps)
83 answers 401 unless its `x-grow-key` header carries the arm server's key, compared in constant time (src/grow-key.ts).
84 Anything that reaches the arm server's port directly (the LAN, the tailnet) is refused; only the hosted app, which
85 checks the owner first, sends the key, and only on `GET /grow`, `POST /grow/light` and `POST /grow/pump`, the admin
86 page's light actions, the sensor pull and the dosing controller's requests to the pumps (src/arm-pumps.ts). A browser's own `x-grow-key` is never passed on, and any other `/grow` route
87 of the arm server answers 404 through the app. The key is never in the repository or a page:
88
89 - On the Pi, `so101 serve` writes a random key to `grow-key` beside its poses file (mode 0600) at first start and keeps it; start-up prints the path.
90 - The hosted app takes `ARM_GROW_KEY` (a secret), else `FORGE_APP_DATA/so101/grow-key`, made at first start; its simulated arm uses the same.
91
92 Set it up once: on the Pi, `cat <data dir>/grow-key`, and put that in the app's `ARM_GROW_KEY` secret. Until they
93 match, the Pi runs its schedules as before but the app cannot read or change the light (a light action on /admin → Grow says why), and a
94 preview without the secret cannot touch the real grow box at all. To keep the port off the LAN, bind it to the
95 tailnet: `so101 serve --listen <tailnet address>:8101`.
8196 `bench/fake-serve.ts` (what production and the previews run) runs the schedules too, on its simulated arm.
8297
8398 ## Sensors (`sensors.json`)
Software/so101/bench/fake-serve.ts +7 −2
@@ -31,6 +31,7 @@ import { BUILT_IN_DIR, loadProfiles } from "../src/care-profile.ts";
3131 import { GrowLog } from "../src/grow-log.ts";
3232 import { GrowStills, parseEvery } from "../src/grow-stills.ts";
3333 import { startServer } from "../src/server.ts";
34 import { loadGrowKey, parseGrowKey } from "../src/grow-key.ts";
3435
3536 const fake = new FakeBus([1, 2, 3, 4, 5, 6]);
3637 const ranges: Record<number, [number, number]> = { 1: [818, 3487], 2: [813, 3202], 3: [900, 3100], 4: [700, 3300], 5: [600, 3400], 6: [1995, 3525] };
@@ -43,7 +44,11 @@ const dir = appData ? join(appData, "so101") : mkdtempSync(join(tmpdir(), "so101
4344 mkdirSync(dir, { recursive: true });
4445 const port = Number(process.env.PORT ?? process.argv[2] ?? 8102);
4546 const host = process.env.HOST ?? "127.0.0.1";
46 const simulation = await startServer(new FeetechBus(fake), { host: "127.0.0.1", port: 0, posesFile: join(dir, "poses.json"), modelDir: new URL("../../../Simulation/SO101", import.meta.url).pathname, lightMs: 15_000 });
47 // The grow key the Pi's grow routes need: ARM_GROW_KEY (the Pi's own grow-key), else one kept here, which the simulation shares.
48 const envKey = parseGrowKey(process.env.ARM_GROW_KEY);
49 if (process.env.ARM_GROW_KEY && !envKey) console.error("ARM_GROW_KEY is not a grow key (32 to 256 visible characters); using the one under the data dir");
50 const growKey = envKey ?? loadGrowKey(join(dir, "grow-key"));
51 const simulation = await startServer(new FeetechBus(fake), { growKey, host: "127.0.0.1", port: 0, posesFile: join(dir, "poses.json"), modelDir: new URL("../../../Simulation/SO101", import.meta.url).pathname, lightMs: 15_000 });
4752 let remote: Remote | null = null;
4853 let problem: string | null = null;
4954 try {
@@ -80,6 +85,6 @@ const facts = new Facts({ file: join(dir, "facts.sqlite") });
8085 const piSync = piSyncFromEnv(process.env, remote?.http ?? null, remote?.host ?? null, () => lobby.turns.snapshot().driver === null, dir);
8186 const grow = { stills: new GrowStills(join(dir, "grow-stills"), { capBytes: Number(process.env.GROW_STILLS_CAP_MB) * 1e6 || undefined }), log: new GrowLog(join(dir, "grow-log.json")), everyMs: parseEvery(process.env.GROW_STILL_MINUTES) };
8287 const careProfile = loadProfiles(BUILT_IN_DIR, join(dir, "care-profiles")).find((p) => p.id === (process.env.CARE_PROFILE ?? "spinach"));
83 const relay = await startRelay({ probeEveryMs: 300_000, sensorsMs: 600_000, care: careProfile ? { log: new CareLog(join(dir, "care.json")), profile: careProfile, dosingFile: join(dir, "dosing.json") } : undefined, grow, stations: new StationHub(join(dir, "stations.json")), facts, piSync: piSync ?? undefined, host, port, simulation: `http://127.0.0.1:${simulation.port}`, remote, problem, lobby, cameras, leaders, video, summaryEveryMs: 60_000, holdFile: join(dir, "hold.json"), baselineFile: join(dir, "servo-factory.json") });
88 const relay = await startRelay({ growKey, probeEveryMs: 300_000, sensorsMs: 600_000, care: careProfile ? { log: new CareLog(join(dir, "care.json")), profile: careProfile, dosingFile: join(dir, "dosing.json") } : undefined, grow, stations: new StationHub(join(dir, "stations.json")), facts, piSync: piSync ?? undefined, host, port, simulation: `http://127.0.0.1:${simulation.port}`, remote, problem, lobby, cameras, leaders, video, summaryEveryMs: 60_000, holdFile: join(dir, "hold.json"), baselineFile: join(dir, "servo-factory.json") });
8489 const { mode, host: armHost } = relay.link();
8590 console.log(`arm on http://${host}:${relay.port} (${mode === "simulated" ? "simulated" : `arm ${armHost}: ${mode}`}; data in ${dir}${local ? "; local: every visitor drives as the owner" : ""})`);
Software/so101/src/cli.ts +3 −2
@@ -1,5 +1,5 @@
11 import { closeSync, openSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2 import { dirname } from "node:path";
2 import { dirname, resolve } from "node:path";
33 import { applyProfile, cameraDevice, loadProfile, parseUnit, profileFile } from "./camera-control.ts";
44 import { grabStill } from "./camera-still.ts";
55 import { defaultProfile, parseDevice } from "./camera-profile.ts";
@@ -533,11 +533,12 @@ const COMMANDS: Record<string, Command> = {
533533 const waiting = !values.port && !process.env.SO101_PORT;
534534 if (waiting) await waitForArm(detectPort, (text) => console.log(text), (ms) => new Promise((r) => setTimeout(r, ms)));
535535 return withBus(values, async (bus) => {
536 const { startServer } = await import("./server.ts"); // lazily: it reaches bun:sqlite, which Node lacks
536 const { growKeyFile, startServer } = await import("./server.ts"); // lazily: it reaches bun:sqlite, which Node lacks
537537 const cameraControl = (unit: number, profile: Parameters<typeof applyProfile>[1]) => applyProfile(cameraDevice(unit), profile);
538538 const cameraStill = (unit: number) => grabStill(unit, cameraDevice(unit));
539539 const server = await startServer(bus, { host, port, posesFile: values.file ?? "poses.json", modelDir: values.model, gainSyncMs: 2000, cameraControl, cameraStill, lightMs: 15_000, sensorsMs: 60_000 });
540540 console.log(`serving ${bus.path} on http://${host}:${server.port} — open it in a browser; the API is listed in src/server.ts`);
541 console.log(`the grow routes need the key in ${growKeyFile(dirname(resolve(values.file ?? "poses.json")))}: give the hosted app the same in ARM_GROW_KEY`);
541542 console.log("ctrl-c to stop");
542543 await new Promise<void>((resolve) => process.once("SIGINT", () => resolve()));
543544 await server.close();
Software/so101/src/dose-pumps.test.ts +6 −2
@@ -17,6 +17,8 @@ import { renderPumps } from "./pumps-card.ts";
1717 import { startRelay, type RunningRelay } from "./relay.ts";
1818 import { startServer, type RunningServer } from "./server.ts";
1919
20 const GROW_KEY = "dose-pumps-test-grow-key-0123456789abcdef";
21
2022 /** Fake hardware: every switch by its GPIO line (or host), what it was told, and a float the test sets. */
2123 function rig(dir = mkdtempSync(join(tmpdir(), "so101-pumps-"))) {
2224 let t = 1_800_000_000_000;
@@ -229,11 +231,13 @@ describe("end to end: the page's relay, the arm server and a pump", () => {
229231 test("the owner wires and calibrates a pump from /care; a dose goes through the controller to the Pi; a browser cannot reach the Pi's pumps", async () => {
230232 const r = rig();
231233 dir = r.dir;
232 server = await startServer(new FeetechBus(new FakeBus([1, 2])), { host: "127.0.0.1", port: 0, ids: [1, 2], posesFile: join(dir, "poses.json"), dosePumps: r.pumps });
234 server = await startServer(new FeetechBus(new FakeBus([1, 2])), { host: "127.0.0.1", port: 0, ids: [1, 2], posesFile: join(dir, "poses.json"), dosePumps: r.pumps, growKey: GROW_KEY });
233235 lobby = new Lobby({ identityKey: KEY, adminFile: join(dir, "admin.json"), countsFile: join(dir, "visitors.json") });
234236 const log = new CareLog();
235237 const profile = loadProfiles(BUILT_IN_DIR).find((p) => p.id === "spinach")!;
236 relay = await startRelay({ port: 0, simulation: `http://127.0.0.1:${server.port}`, lobby, care: { log, profile } });
238 relay = await startRelay({ port: 0, simulation: `http://127.0.0.1:${server.port}`, lobby, care: { log, profile }, growKey: GROW_KEY });
239 // the Pi's pumps refuse anyone without the grow key, whoever reaches its port (#611)
240 for (const method of ["GET", "POST"]) expect((await fetch(`http://127.0.0.1:${server.port}/grow/dosing`, { method, ...(method === "POST" ? { body: JSON.stringify({ action: "fill" }) } : {}) })).status).toBe(401);
237241 const base = `http://127.0.0.1:${relay.port}`;
238242 const setup = (body: unknown) => fetch(`${base}/care/dosing/pump`, { method: "POST", headers: owner(), body: JSON.stringify(body) });
239243
Software/so101/src/grow-key.test.ts added +115
@@ -0,0 +1,115 @@
1 import { afterEach, expect, test } from "bun:test";
2 import { mkdtempSync, readFileSync, statSync, writeFileSync } from "node:fs";
3 import { tmpdir } from "node:os";
4 import { join } from "node:path";
5 import { FeetechBus } from "./bus.ts";
6 import { FakeBus } from "./fake-bus.ts";
7 import { GROW_KEY_HEADER, growKeyMatches, isGrowRoute, loadGrowKey, parseGrowKey } from "./grow-key.ts";
8 import { sign } from "./identity.ts";
9 import { Light, type DeviceName } from "./light.ts";
10 import { Lobby } from "./lobby.ts";
11 import type { Switch } from "./light-switch.ts";
12 import { startRelay, type RunningRelay } from "./relay.ts";
13 import { growKeyFile, startServer, type RunningServer } from "./server.ts";
14
15 const KEY = "grow-key-test-0123456789abcdef0123456789";
16 let server: RunningServer | undefined;
17 let relay: RunningRelay | undefined;
18 let lobby: Lobby | undefined;
19 afterEach(async () => {
20 await relay?.close();
21 await server?.close();
22 lobby?.close();
23 relay = server = lobby = undefined;
24 });
25 const IDENTITY = "i".repeat(64);
26 const as = (name: string, role: "user" | "admin") => {
27 const identity = `id-${name}|${name}|${role}|${Date.now() + 600_000}`;
28 return { "x-forge-identity": identity, "x-forge-signature": sign(identity, IDENTITY) };
29 };
30
31 /** An arm server whose light switch only records; `growKey` unset, it makes its own in the data dir. */
32 async function arm(growKey?: string) {
33 const dir = mkdtempSync(join(tmpdir(), "so101-growkey-"));
34 const lit = { on: false };
35 const fake: Switch = { on: async () => void (lit.on = true), off: async () => void (lit.on = false), state: async () => lit.on };
36 const make = (name: DeviceName) => new Light({ name, file: join(dir, `${name}.json`), zone: "UTC", now: () => Date.parse("2026-06-10T12:00:00Z"), make: () => fake });
37 server = await startServer(new FeetechBus(new FakeBus([1, 2])), { host: "127.0.0.1", port: 0, ids: [1, 2], posesFile: join(dir, "poses.json"), lights: { light: make("light"), pump: make("pump") }, ...(growKey ? { growKey } : {}) });
38 return { base: `http://127.0.0.1:${server.port}`, dir, lit };
39 }
40
41 test("a key is 32 to 256 visible characters; a file without one is made at mode 0600 and kept, one with junk is refused", () => {
42 expect(parseGrowKey(` ${KEY}\n`)).toBe(KEY);
43 expect(parseGrowKey("short")).toBeNull();
44 expect(parseGrowKey(`${"a".repeat(20)} ${"b".repeat(20)}`)).toBeNull();
45 expect(parseGrowKey(undefined)).toBeNull();
46 const dir = mkdtempSync(join(tmpdir(), "so101-growkey-"));
47 const file = join(dir, "grow-key");
48 const made = loadGrowKey(file);
49 expect(made).toMatch(/^[0-9a-f]{64}$/);
50 expect(statSync(file).mode & 0o777).toBe(0o600);
51 expect(loadGrowKey(file)).toBe(made);
52 writeFileSync(join(dir, "bad"), "nope");
53 expect(() => loadGrowKey(join(dir, "bad"))).toThrow(/no grow key/);
54 expect(readFileSync(join(dir, "bad"), "utf8")).toBe("nope");
55 });
56
57 test("the compare takes only the exact key, as one header", () => {
58 expect(growKeyMatches(KEY, KEY)).toBe(true);
59 expect(growKeyMatches(KEY, `${KEY}x`)).toBe(false);
60 expect(growKeyMatches(KEY, KEY.slice(1))).toBe(false);
61 expect(growKeyMatches(KEY, "")).toBe(false);
62 expect(growKeyMatches(KEY, undefined)).toBe(false);
63 expect(growKeyMatches(KEY, [KEY, KEY])).toBe(false);
64 expect(isGrowRoute("/grow")).toBe(true);
65 expect(isGrowRoute("/grow/dosing")).toBe(true);
66 expect(isGrowRoute("/growth")).toBe(false);
67 });
68
69 test("the arm server answers no /grow route, read or write, without its key, and switches nothing", async () => {
70 const { base, lit } = await arm(KEY);
71 const on = JSON.stringify({ switch: { driver: "shelly", host: "192.168.1.40" }, schedule: { onAt: "06:00", hours: 12 } });
72 for (const headers of [{}, { [GROW_KEY_HEADER]: "wrong-key-0123456789abcdef0123456789" }] as Record<string, string>[]) {
73 expect((await fetch(`${base}/grow`, { headers })).status).toBe(401);
74 expect((await fetch(`${base}/grow/sensors`, { headers })).status).toBe(401);
75 expect((await fetch(`${base}/grow/dosing`, { headers })).status).toBe(401);
76 for (const device of ["light", "pump"]) expect((await fetch(`${base}/grow/${device}`, { method: "POST", headers, body: on })).status).toBe(401);
77 }
78 expect(lit.on).toBe(false);
79 expect((await fetch(`${base}/status`)).status).toBe(200); // the rest of the arm server is as it was
80 const keyed = await fetch(`${base}/grow/light`, { method: "POST", headers: { [GROW_KEY_HEADER]: KEY }, body: on });
81 expect(keyed.status).toBe(200);
82 expect(lit.on).toBe(true);
83 });
84
85 test("with no key given, the arm server makes one beside its poses file and holds to it", async () => {
86 const { base, dir } = await arm();
87 const key = readFileSync(growKeyFile(dir), "utf8").trim();
88 expect(key).toMatch(/^[0-9a-f]{64}$/);
89 expect((await fetch(`${base}/grow`)).status).toBe(401);
90 expect((await fetch(`${base}/grow`, { headers: { [GROW_KEY_HEADER]: key } })).status).toBe(200);
91 });
92
93 test("the relay sends the key on the owner's grow routes and the admin page's light actions, never a browser's, and refuses the arm's other /grow routes", async () => {
94 const { base: sim, lit } = await arm(KEY);
95 const dir = mkdtempSync(join(tmpdir(), "so101-growkey-lobby-"));
96 lobby = new Lobby({ identityKey: IDENTITY, adminFile: join(dir, "admin.json") });
97 relay = await startRelay({ port: 0, simulation: sim, growKey: KEY, lobby, env: {} });
98 const base = `http://127.0.0.1:${relay.port}`;
99 const owner = as("trevato", "admin");
100 expect((await fetch(`${base}/grow`, { headers: owner })).status).toBe(200);
101 const on = JSON.stringify({ switch: { driver: "shelly", host: "192.168.1.40" }, schedule: { onAt: "06:00", hours: 12 } });
102 expect((await fetch(`${base}/grow/light`, { method: "POST", headers: { ...owner, "content-type": "application/json" }, body: on })).status).toBe(200);
103 expect(lit.on).toBe(true);
104 const off = await fetch(`${base}/admin`, { method: "POST", headers: { ...owner, "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ action: "light-override", device: "light", value: "off" }), redirect: "manual" });
105 expect(off.status).toBeLessThan(400);
106 expect(lit.on).toBe(false);
107 for (const path of ["/grow/sensors", "/grow/dosing", "/grow/anything"]) {
108 expect((await fetch(`${base}${path}`, { headers: { ...owner, [GROW_KEY_HEADER]: KEY } })).status).toBe(404);
109 expect((await fetch(`${base}${path}`, { method: "POST", headers: { ...owner, [GROW_KEY_HEADER]: KEY }, body: "{}" })).status).toBe(404);
110 }
111 await relay.close();
112 // A relay without the key passes nothing a browser sends: the arm server refuses even a browser that knows it.
113 relay = await startRelay({ port: 0, simulation: sim, lobby, env: {} });
114 expect((await fetch(`http://127.0.0.1:${relay.port}/grow`, { headers: { ...owner, [GROW_KEY_HEADER]: KEY } })).status).toBe(401);
115 });
Software/so101/src/grow-key.ts added +45
@@ -0,0 +1,45 @@
1 /**
2 * The grow key: a secret the hosted app (src/relay.ts) and the arm server (src/server.ts) share, so that only the app,
3 * which has already checked the owner, reaches the grow routes on the Pi. Everything under /grow there (the light,
4 * the pump, the sensors, the dosing pumps) answers 401 without it, whoever reaches the arm server's port.
5 *
6 * The arm server keeps it in `grow-key` beside its poses file, written at first start (mode 0600); the hosted app takes
7 * ARM_GROW_KEY, else its own `grow-key` under FORGE_APP_DATA. It is never in the repository and never in a page.
8 */
9 import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
10 import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs";
11
12 /** The request header the key rides on. */
13 export const GROW_KEY_HEADER = "x-grow-key";
14
15 /** A usable key: 32 to 256 visible ASCII characters, nothing else. */
16 const KEY = /^[\x21-\x7e]{32,256}$/;
17
18 /** The key in `text` (an env var or a file's content, trimmed); null when it is not one. */
19 export function parseGrowKey(text: string | undefined | null): string | null {
20 const key = (text ?? "").trim();
21 return KEY.test(key) ? key : null;
22 }
23
24 /** The key kept in `file`, made (64 hex characters, mode 0600) when there is none. A file that holds no key is an error, not overwritten. */
25 export function loadGrowKey(file: string): string {
26 if (existsSync(file)) {
27 const key = parseGrowKey(readFileSync(file, "utf8"));
28 if (!key) throw new Error(`${file} holds no grow key (32 to 256 visible characters); fix or remove it`);
29 return key;
30 }
31 const key = randomBytes(32).toString("hex");
32 writeFileSync(file, `${key}\n`, { mode: 0o600, flag: "wx" });
33 chmodSync(file, 0o600);
34 return key;
35 }
36
37 /** Whether the header `given` carries `expected`, compared in constant time (both are hashed first, so length leaks nothing either). */
38 export function growKeyMatches(expected: string, given: string | string[] | undefined): boolean {
39 if (typeof given !== "string" || !given) return false;
40 const digest = (s: string) => createHash("sha256").update(s).digest();
41 return timingSafeEqual(digest(expected), digest(given));
42 }
43
44 /** Whether a path is one of the arm server's grow routes. */
45 export const isGrowRoute = (pathname: string) => pathname === "/grow" || pathname.startsWith("/grow/");
Software/so101/src/light-endpoint.test.ts +12 −8
@@ -11,6 +11,10 @@ import { readGrow, readLight } from "./light-read.ts";
1111 import type { Switch } from "./light-switch.ts";
1212 import { startServer, type RunningServer } from "./server.ts";
1313 import { esc } from "./admin-page.ts";
14 import { GROW_KEY_HEADER } from "./grow-key.ts";
15
16 const KEY = "light-endpoint-test-key-0123456789abcdef";
17 const keyed = { [GROW_KEY_HEADER]: KEY };
1418
1519 let server: RunningServer | undefined;
1620 afterEach(() => server?.close());
@@ -19,15 +23,15 @@ async function boot(lit: { on: boolean }) {
1923 const dir = mkdtempSync(join(tmpdir(), "so101-light-"));
2024 const fake: Switch = { on: async () => void (lit.on = true), off: async () => void (lit.on = false), state: async () => lit.on };
2125 const make = (name: DeviceName) => new Light({ name, file: join(dir, `${name}.json`), zone: "UTC", now: () => Date.parse("2026-06-10T12:00:00Z"), make: () => fake });
22 server = await startServer(new FeetechBus(new FakeBus([1, 2])), { host: "127.0.0.1", port: 0, ids: [1, 2], posesFile: join(dir, "poses.json"), lights: { light: make("light"), pump: make("pump") } });
26 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") } });
2327 return `http://127.0.0.1:${server.port}`;
2428 }
25 const post = (base: string, body: unknown, device = "light") => fetch(`${base}/grow/${device}`, { method: "POST", body: JSON.stringify(body) });
29 const post = (base: string, body: unknown, device = "light") => fetch(`${base}/grow/${device}`, { method: "POST", headers: keyed, body: JSON.stringify(body) });
2630
2731 test("GET /light says always on until a switch is set; POST sets it, and refuses nonsense with a sentence", async () => {
2832 const lit = { on: false };
2933 const base = await boot(lit);
30 expect(((await (await fetch(`${base}/grow`)).json()) as { light: { text: string } }).light.text).toBe("Light: always on (no switch set up)");
34 expect(((await (await fetch(`${base}/grow`, { headers: keyed })).json()) as { light: { text: string } }).light.text).toBe("Light: always on (no switch set up)");
3135 const bad = await post(base, { schedule: { onAt: "25:00", hours: 12 } });
3236 expect(bad.status).toBe(400);
3337 expect(((await bad.json()) as { error: string }).error).toMatch(/on at/);
@@ -78,7 +82,7 @@ test("the pump runs on its own switch and an interval cycle, apart from the ligh
7882 const base = await boot(lit);
7983 const put = await post(base, { switch: { driver: "gpio", line: 17 }, schedule: { onMinutes: 5, offMinutes: 25 } }, "pump");
8084 expect(put.status).toBe(200);
81 const grow = (await (await fetch(`${base}/grow`)).json()) as { light: { configured: boolean }; pump: { text: string } };
85 const grow = (await (await fetch(`${base}/grow`, { headers: keyed })).json()) as { light: { configured: boolean }; pump: { text: string } };
8286 expect(grow.light.configured).toBe(false);
8387 expect(grow.pump.text).toBe("On until 12:05");
8488 expect((await post(base, { schedule: { onMinutes: 0, offMinutes: 5 } }, "pump")).status).toBe(400);
@@ -101,7 +105,7 @@ function flood(port: number, path: string, chunks: number): Promise<{ answeredMs
101105 socket.on("error", () => {});
102106 socket.on("close", () => resolve({ answeredMs, reply }));
103107 setTimeout(() => socket.destroy(), 2000); // a server that never answers
104 socket.write(`POST ${path} HTTP/1.1\r\nhost: x\r\ntransfer-encoding: chunked\r\n\r\n`);
108 socket.write(`POST ${path} HTTP/1.1\r\nhost: x\r\n${GROW_KEY_HEADER}: ${KEY}\r\ntransfer-encoding: chunked\r\n\r\n`);
105109 const pump = () => {
106110 while (written < chunks * 100_000 && !socket.destroyed) {
107111 written += 100_000;
@@ -121,15 +125,15 @@ test("POST /grow/<device> stops reading at the cap, whatever Content-Length says
121125 expect(reply.toLowerCase()).toContain("connection: close"); // hung up after the answer
122126 expect(answeredMs).toBeGreaterThanOrEqual(0);
123127 expect(answeredMs).toBeLessThan(1000); // answered at the cap, not after the whole body
124 const grow = (await (await fetch(`${base}/grow`)).json()) as Record<string, { configured: boolean }>;
128 const grow = (await (await fetch(`${base}/grow`, { headers: keyed })).json()) as Record<string, { configured: boolean }>;
125129 expect(grow[device]!.configured).toBe(false);
126130 }
127131 }, 15_000);
128132
129133 test("the body cap counts bytes: 600k three-byte characters (1.8 MB, under 1M characters) answer 413", async () => {
130134 const base = await boot({ on: false });
131 const res = await fetch(`${base}/grow/light`, { method: "POST", body: `{"x":"${"€".repeat(600_000)}"}` });
135 const res = await fetch(`${base}/grow/light`, { method: "POST", headers: keyed, body: `{"x":"${"€".repeat(600_000)}"}` });
132136 expect(res.status).toBe(413);
133 const grow = (await (await fetch(`${base}/grow`)).json()) as Record<string, { configured: boolean }>;
137 const grow = (await (await fetch(`${base}/grow`, { headers: keyed })).json()) as Record<string, { configured: boolean }>;
134138 expect(grow.light!.configured).toBe(false);
135139 }, 15_000);
Software/so101/src/relay.ts +18 −8
@@ -124,6 +124,7 @@ import { LADDER } from "./video-fanout.ts";
124124 import { LOCAL as LOCAL_PERSON } from "./identity.ts";
125125 import { LeaderCalibrations } from "./leader-calibrations.ts";
126126 import { proxyFor, whyNot } from "./egress.ts";
127 import { GROW_KEY_HEADER, isGrowRoute } from "./grow-key.ts";
127128 import { CalibrationError } from "./leader-map.js";
128129 import type { Lobby } from "./lobby.ts";
129130 import { capBody, FrameCap } from "./speed-cap.ts";
@@ -172,6 +173,8 @@ export interface RelayOptions {
172173 probeEveryMs?: number;
173174 /** The grow box's stills, log and how often a still is taken (src/grow-stills.ts); without it nothing is taken and the card shows no timelapse. */
174175 grow?: { stills: GrowStills; log: GrowLog; everyMs: number };
176 /** The key the arm server's /grow routes need (src/grow-key.ts), sent only on the grow requests this relay has already gated; without it the arm server refuses them. */
177 growKey?: string | null;
175178 /** How often the arm's sensors are pulled into the care log book, ms; unset, never. */
176179 sensorsMs?: number;
177180 /** A setting that could not be used (shown on /link while simulating). */
@@ -246,6 +249,8 @@ const STATIC = /^\/(?:index\.html)?$|^\/(?:model|vendor|ui)\//;
246249 const LEADER = "/leader/calibration";
247250 /** Admins only: clearing an emergency stop and changing the settings (the safety limits among them). */
248251 const ADMIN_ONLY = new Set(["POST /estop/clear", "PUT /settings", "POST /settings/table-here", "POST /settings/table-touch", "POST /settings/joint-zero", "PUT /servo-params", "POST /servo-params/factory", "POST /tuning/p", "POST /grow/light", "POST /grow/pump", "POST /probes/calibrate"]);
252 /** The arm server's grow routes the relay passes on, owner only (ADMIN_ONLY, OWNER_READS) and with the grow key; any other /grow route of the arm server is refused here. */
253 const GROW_KEYED = new Set(["GET /grow", "POST /grow/light", "POST /grow/pump"]);
249254 /** Routes only the owner may reach; anyone else is told there is no such page. */
250255 const OWNER_READS = new Set(["GET /servo-params", "PUT /servo-params", "POST /servo-params/factory", "GET /tuning", "POST /tuning/p", "GET /grow", "POST /grow/light", "POST /grow/pump", "GET /probes", "POST /probes/calibrate"]);
251256 /** The driver's, and any admin's at any time. */
@@ -487,12 +492,13 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> {
487492 req.on("error", reject);
488493 });
489494 /** Ask one upstream; resolves once its headers are in. The controller stays live for the body. */
490 const ask = async (url: string, req: IncomingMessage, body: Buffer | undefined, timeoutMs: number | null, tracked: boolean) => {
495 const ask = async (url: string, req: IncomingMessage, body: Buffer | undefined, timeoutMs: number | null, tracked: boolean, extra: Record<string, string> = {}) => {
491496 const controller = new AbortController();
492497 const timer = timeoutMs === null ? undefined : setTimeout(() => controller.abort(new Error(`no answer in ${timeoutMs} ms`)), timeoutMs);
493498 if (tracked) inFlight.add(controller);
494499 const headers: Record<string, string> = {};
495500 for (const name of ["content-type", "accept", "range", "if-none-match"]) if (typeof req.headers[name] === "string") headers[name] = req.headers[name];
501 Object.assign(headers, extra);
496502 try {
497503 const response = await fetch(url, { method: req.method, headers, body: body?.length ? body : undefined, signal: controller.signal, redirect: "manual", cache: "no-store" });
498504 return { response, controller };
@@ -621,6 +627,8 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> {
621627 };
622628 /** The grow light lives on the arm server (the Pi runs its schedule); this reads and changes it for the admin page. */
623629 const armGrow = (path = "") => `${remote && onArm() ? remote.http : simulation}/grow${path}`;
630 /** The grow key's header, for the relay's own requests to the arm server's /grow routes; never for anything a browser chose. */
631 const growAuth = (): Record<string, string> => (options.growKey ? { [GROW_KEY_HEADER]: options.growKey } : {});
624632 /** The pH/EC probes live on the arm server beside the ADC (src/probes.ts); the care page reads and calibrates them through this. */
625633 const probeHooks: ProbeHooks = {
626634 read: async (tempC) => {
@@ -647,7 +655,7 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> {
647655 const lightHooks: LightHooks = {
648656 read: async () => {
649657 try {
650 const answer = await fetch(armGrow(), { cache: "no-store", signal: AbortSignal.timeout(Math.min(readTimeoutMs, 1000)) });
658 const answer = await fetch(armGrow(), { cache: "no-store", headers: growAuth(), signal: AbortSignal.timeout(Math.min(readTimeoutMs, 1000)) });
651659 return answer.ok ? readGrow(await answer.json().catch(() => null)) : null;
652660 } catch {
653661 return null;
@@ -655,7 +663,7 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> {
655663 },
656664 write: async (device, body) => {
657665 try {
658 const answer = await fetch(armGrow(`/${device}`), { method: "POST", body: JSON.stringify(body), signal: AbortSignal.timeout(Math.min(readTimeoutMs, 8000)) });
666 const answer = await fetch(armGrow(`/${device}`), { method: "POST", headers: growAuth(), body: JSON.stringify(body), signal: AbortSignal.timeout(Math.min(readTimeoutMs, 8000)) });
659667 if (answer.ok) return null;
660668 const err = (await answer.json().catch(() => null)) as { error?: unknown } | null;
661669 return typeof err?.error === "string" ? err.error.replace(/[\p{Cc}\p{Cf}]/gu, "").slice(0, 300) : `the arm server answered ${answer.status}`;
@@ -666,7 +674,7 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> {
666674 };
667675 const pumpRead = cachedRead(async () => {
668676 try {
669 const answer = await fetch(armGrow("/dosing"), { cache: "no-store", signal: AbortSignal.timeout(Math.min(readTimeoutMs, 1000)) });
677 const answer = await fetch(armGrow("/dosing"), { cache: "no-store", headers: growAuth(), signal: AbortSignal.timeout(Math.min(readTimeoutMs, 1000)) });
670678 return answer.ok ? readDosing(await answer.json().catch(() => null)) : null;
671679 } catch {
672680 return null;
@@ -675,7 +683,7 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> {
675683 /** One request to the Pi's pumps; the relay's own, never a browser's (a browser's /grow/dosing is refused below). */
676684 const pumpWrite = async (body: Record<string, unknown>, timeoutMs: number): Promise<import("./arm-pumps.ts").PumpAnswer> => {
677685 try {
678 const answer = await fetch(armGrow("/dosing"), { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), signal: AbortSignal.timeout(timeoutMs) });
686 const answer = await fetch(armGrow("/dosing"), { method: "POST", headers: { "content-type": "application/json", ...growAuth() }, body: JSON.stringify(body), signal: AbortSignal.timeout(timeoutMs) });
679687 const parsed = (await answer.json().catch(() => null)) as { error?: unknown } | null;
680688 if (answer.ok) return { ok: true, body: parsed };
681689 return { ok: false, status: answer.status, error: typeof parsed?.error === "string" ? parsed.error.replace(/[\p{Cc}\p{Cf}]/gu, "").slice(0, 300) : `the arm server answered ${answer.status}` };
@@ -1108,7 +1116,7 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> {
11081116 const pullSensors = async () => {
11091117 if (!(remote && onArm())) return;
11101118 try {
1111 const answer = await fetch(armGrow("/sensors"), { cache: "no-store", signal: AbortSignal.timeout(Math.min(readTimeoutMs, 2000)) });
1119 const answer = await fetch(armGrow("/sensors"), { cache: "no-store", headers: growAuth(), signal: AbortSignal.timeout(Math.min(readTimeoutMs, 2000)) });
11121120 const body = (await answer.json()) as { readings?: unknown };
11131121 if (!answer.ok || !Array.isArray(body.readings)) return;
11141122 for (const r of body.readings.slice(0, 16)) {
@@ -1191,6 +1199,8 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> {
11911199 const handled = await growRoutes(req, res, url);
11921200 if (handled) return;
11931201 }
1202 if (isGrowRoute(url.pathname) && !GROW_KEYED.has(route)) return json(res, 404, { error: "no such route" });
1203 const keyed = GROW_KEYED.has(route) ? growAuth() : {};
11941204 let by = "the arm's page";
11951205 if (OWNER_READS.has(route) && !(lobby ? lobby.who(req)?.owner : false)) return json(res, 404, { error: "not found" });
11961206 if (!reading && lobby) {
@@ -1269,7 +1279,7 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> {
12691279 const answered = (status: number) => logged && machine.note("received", route, { status });
12701280 if (route === "POST /resume") return await resume(res, by);
12711281 if (LOCAL.test(url.pathname) || !onArm() || !remote) {
1272 const answer = await ask(simulation + path, req, body, null, false);
1282 const answer = await ask(simulation + path, req, body, null, false, keyed);
12731283 answered(answer.response.status);
12741284 return await pass(res, answer);
12751285 }
@@ -1279,7 +1289,7 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> {
12791289 }
12801290 try {
12811291 // A read must answer fast; a move or a playback answers when the arm arrives, and is only cut when the link drops.
1282 const answer = await ask(remote.http + path, req, body, reading ? readTimeoutMs : null, true);
1292 const answer = await ask(remote.http + path, req, body, reading ? readTimeoutMs : null, true, keyed);
12831293 answered(answer.response.status);
12841294 return await pass(res, answer);
12851295 } catch (err) {
Software/so101/src/sensors-relay.test.ts +4 −3
@@ -18,6 +18,7 @@ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
1818 const shut = (s: { close(): unknown } | undefined) => Promise.race([Promise.resolve(s?.close()), sleep(500)]);
1919 const profile = loadProfiles(BUILT_IN_DIR).find((p) => p.id === "spinach")!;
2020
21 const KEY = "sensors-relay-test-key-0123456789abcdef";
2122 let servers: RunningServer[] = [];
2223 let relay: RunningRelay | undefined;
2324 afterEach(async () => {
@@ -28,7 +29,7 @@ afterEach(async () => {
2829 });
2930
3031 const arm = async (ids: number[], sensors?: unknown) => {
31 const s = await startServer(new FeetechBus(new FakeBus(ids)), { host: "127.0.0.1", port: 0, ids, posesFile: join(mkdtempSync(join(tmpdir(), "so101-sr-")), "poses.json"), watchdogMs: 60, watchEveryMs: 10, watchQuietMs: 20, ...(sensors ? { sensors: sensors as Sensors } : {}) });
32 const s = await startServer(new FeetechBus(new FakeBus(ids)), { host: "127.0.0.1", port: 0, ids, posesFile: join(mkdtempSync(join(tmpdir(), "so101-sr-")), "poses.json"), growKey: KEY, watchdogMs: 60, watchEveryMs: 10, watchQuietMs: 20, ...(sensors ? { sensors: sensors as Sensors } : {}) });
3233 servers.push(s);
3334 return s;
3435 };
@@ -39,7 +40,7 @@ async function pulled(readings: unknown, fail = false): Promise<CareLog> {
3940 const real = await arm([1, 2, 3], stub);
4041 const sim = await arm([1, 2, 3, 4, 5, 6]);
4142 const log = new CareLog();
42 relay = await startRelay({ port: 0, simulation: `http://127.0.0.1:${sim.port}`, remote: parseRemote({ ARM_SERVER: `ws://127.0.0.1:${real.port}` })!, care: { log, profile }, sensorsMs: 30 });
43 relay = await startRelay({ port: 0, simulation: `http://127.0.0.1:${sim.port}`, remote: parseRemote({ ARM_SERVER: `ws://127.0.0.1:${real.port}` })!, care: { log, profile }, sensorsMs: 30, growKey: KEY });
4344 const base = `http://127.0.0.1:${relay.port}`;
4445 for (let i = 0; i < 100 && ((await (await fetch(`${base}/link`)).json()) as { mode: string }).mode !== "live"; i++) await sleep(20);
4546 if (fail) await shut(real);
@@ -113,6 +114,6 @@ test("GET /grow carries the sensors for the gated card", async () => {
113114 const sensors = new Sensors([{ kind: "airTemp", label: "sht31 0x44", read: async () => 21.5 }]);
114115 await sensors.tick();
115116 const s = await arm([1, 2], sensors);
116 const body = (await (await fetch(`http://127.0.0.1:${s.port}/grow`)).json()) as { sensors: { value: number }[] };
117 const body = (await (await fetch(`http://127.0.0.1:${s.port}/grow`, { headers: { "x-grow-key": KEY } })).json()) as { sensors: { value: number }[] };
117118 expect(body.sensors[0]!.value).toBe(21.5);
118119 });
Software/so101/src/sensors.test.ts +2 −2
@@ -95,9 +95,9 @@ test("GET /grow/sensors on the arm server answers with the sensors' last values"
9595 const dir = mkdtempSync(join(tmpdir(), "so101-sensors-"));
9696 const sensors = new Sensors([{ kind: "waterTemp", label: "water", read: async () => 18.25 }]);
9797 await sensors.tick();
98 const server = await startServer(new FeetechBus(new FakeBus([1, 2])), { host: "127.0.0.1", port: 0, ids: [1, 2], posesFile: join(dir, "poses.json"), sensors });
98 const server = await startServer(new FeetechBus(new FakeBus([1, 2])), { host: "127.0.0.1", port: 0, ids: [1, 2], posesFile: join(dir, "poses.json"), sensors, growKey: "sensors-test-key-0123456789abcdef" });
9999 try {
100 const body = (await (await fetch(`http://127.0.0.1:${server.port}/grow/sensors`)).json()) as { sensors: { value: number }[]; readings: { kind: string; source: string }[] };
100 const body = (await (await fetch(`http://127.0.0.1:${server.port}/grow/sensors`, { headers: { "x-grow-key": "sensors-test-key-0123456789abcdef" } })).json()) as { sensors: { value: number }[]; readings: { kind: string; source: string }[] };
101101 expect(body.sensors[0]!.value).toBe(18.25);
102102 expect(body.readings[0]).toMatchObject({ kind: "waterTemp", source: "sensor" });
103103 } finally {
Software/so101/src/server.ts +13
@@ -48,6 +48,8 @@
4848 * ?hours=1-720 hourly rows (48 by default)
4949 * POST /tuning/p {"joint": id, "p": 1-64} run the P experiment (src/tuning-run.ts) on that joint while nobody drives: sweep, write, sweep, keep or put back; 202 at once,
5050 * 409 while a run is under way or anything drives, 423 on E-stop or hold; the result joins GET /precision's tunings
51 * Every route under /grow answers 401 unless its x-grow-key header carries the key in the data dir's
52 * grow-key (src/grow-key.ts): only the hosted app, which checks the owner first, has it
5153 * GET /grow the grow light and the pump (src/light.ts): schedule, switch driver, "On until 22:00", override and the last transitions
5254 * 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
5355 * 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
@@ -110,6 +112,7 @@ import { ads1115 } from "./ads1115.ts";
110112 import { CalError } from "./probe-math.ts";
111113 import { Probes } from "./probes.ts";
112114 import { loadSensors, Sensors } from "./sensors.ts";
115 import { GROW_KEY_HEADER, growKeyMatches, isGrowRoute, loadGrowKey } from "./grow-key.ts";
113116 import { DEVICES, Light, LightError, type DeviceName } from "./light.ts";
114117 import { DosePumps, PumpError } from "./dose-pumps.ts";
115118 import { ARRIVE_TICKS, LINK_HOPS, parseSnapBack, READ_BACK_HOP, RELAY_HOP, type Cause, type Tracker } from "./precision.ts";
@@ -158,6 +161,8 @@ export interface ServerOptions {
158161 sensorsMs?: number;
159162 /** The sensors, for tests that give them fake sources; by default those listed in sensors.json under the data dir. */
160163 sensors?: Sensors;
164 /** The key every /grow route needs in its x-grow-key header (src/grow-key.ts); by default the data dir's grow-key, made at first start. */
165 growKey?: string;
161166 /** How long each sweep target is held, ms (1500); tests shorten it. */
162167 tuningHoldMs?: number;
163168 /** Replaces the stream sweep of the P experiment; tests give it the numbers to decide on. */
@@ -178,6 +183,9 @@ export interface ServerOptions {
178183 cameraStill?: (unit: number) => Promise<Uint8Array>;
179184 }
180185
186 /** Where the arm server keeps its grow key: beside the poses file. */
187 export const growKeyFile = (dataDir: string) => join(dataDir, "grow-key");
188
181189 export interface RunningServer {
182190 readonly port: number;
183191 close(): Promise<void>;
@@ -263,6 +271,7 @@ export async function startServer(bus: FeetechBus, options: ServerOptions = {}):
263271 const dosePumps = options.dosePumps ?? new DosePumps({ file: join(dataDir, "dose-pumps.json") });
264272 const probes = options.probes ?? new Probes(ads1115(), join(dataDir, "probes.json"));
265273 const sensors = options.sensors ?? loadSensors(join(dataDir, "sensors.json"));
274 const growKey = options.growKey ?? loadGrowKey(growKeyFile(dataDir));
266275 const precision = new PrecisionHistory({ file: join(dataDir, "precision.sqlite"), commit: options.commit });
267276 /** The stream's precision tracker while one drives; every Goal_Position write starts its bus-to-motion clock. */
268277 let tracking: Tracker | null = null;
@@ -838,6 +847,10 @@ export async function startServer(bus: FeetechBus, options: ServerOptions = {}):
838847 }
839848 };
840849 if (req.method === "GET" && url.pathname === "/health/pi") return json(res, 200, readPiHealth());
850 if (isGrowRoute(url.pathname) && !growKeyMatches(growKey, req.headers[GROW_KEY_HEADER])) {
851 res.setHeader("connection", "close"); // an unread body is not waited for
852 return json(res, 401, { error: "no grow key, or the wrong one: the hosted app's ARM_GROW_KEY must be this arm server's grow-key (src/grow-key.ts)" });
853 }
841854 if (req.method === "GET" && url.pathname.startsWith("/model/") && modelDir) {
842855 const path = safeFile(resolve(modelDir), decode(url.pathname.slice("/model/".length)));
843856 if (!path || ![".urdf", ".stl", ".dae", ".png", ".jpg"].includes(extname(path).toLowerCase())) return json(res, 404, { error: "no such model file" });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs