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
Check · Review · Land done · Live — Landed; nothing deploys from this branch, so this is the end of its path.
vesper/arm:vesper/611-arm-server-grow-dosing-and-grow-light-gr → main 3 commits fork
11 files
changed +240
−27
Software/so101/README.md +15
| @@ -78,6 +78,21 @@ both cameras. The switch drivers: | ||
| 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 | 79 | |
| 80 | 80 | `/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`. | |
| 81 | 96 | `bench/fake-serve.ts` (what production and the previews run) runs the schedules too, on its simulated arm. |
| 82 | 97 | |
| 83 | 98 | ## 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"; | ||
| 31 | 31 | import { GrowLog } from "../src/grow-log.ts"; |
| 32 | 32 | import { GrowStills, parseEvery } from "../src/grow-stills.ts"; |
| 33 | 33 | import { startServer } from "../src/server.ts"; |
| 34 | import { loadGrowKey, parseGrowKey } from "../src/grow-key.ts"; | |
| 34 | 35 | |
| 35 | 36 | const fake = new FakeBus([1, 2, 3, 4, 5, 6]); |
| 36 | 37 | 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 | ||
| 43 | 44 | mkdirSync(dir, { recursive: true }); |
| 44 | 45 | const port = Number(process.env.PORT ?? process.argv[2] ?? 8102); |
| 45 | 46 | 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 }); | |
| 47 | 52 | let remote: Remote | null = null; |
| 48 | 53 | let problem: string | null = null; |
| 49 | 54 | try { |
| @@ -80,6 +85,6 @@ const facts = new Facts({ file: join(dir, "facts.sqlite") }); | ||
| 80 | 85 | const piSync = piSyncFromEnv(process.env, remote?.http ?? null, remote?.host ?? null, () => lobby.turns.snapshot().driver === null, dir); |
| 81 | 86 | 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) }; |
| 82 | 87 | 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") }); | |
| 84 | 89 | const { mode, host: armHost } = relay.link(); |
| 85 | 90 | 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 @@ | ||
| 1 | 1 | import { closeSync, openSync, readFileSync, renameSync, writeFileSync } from "node:fs"; |
| 2 | import { dirname } from "node:path"; | |
| 2 | import { dirname, resolve } from "node:path"; | |
| 3 | 3 | import { applyProfile, cameraDevice, loadProfile, parseUnit, profileFile } from "./camera-control.ts"; |
| 4 | 4 | import { grabStill } from "./camera-still.ts"; |
| 5 | 5 | import { defaultProfile, parseDevice } from "./camera-profile.ts"; |
| @@ -533,11 +533,12 @@ const COMMANDS: Record<string, Command> = { | ||
| 533 | 533 | const waiting = !values.port && !process.env.SO101_PORT; |
| 534 | 534 | if (waiting) await waitForArm(detectPort, (text) => console.log(text), (ms) => new Promise((r) => setTimeout(r, ms))); |
| 535 | 535 | 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 | |
| 537 | 537 | const cameraControl = (unit: number, profile: Parameters<typeof applyProfile>[1]) => applyProfile(cameraDevice(unit), profile); |
| 538 | 538 | const cameraStill = (unit: number) => grabStill(unit, cameraDevice(unit)); |
| 539 | 539 | 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 }); |
| 540 | 540 | 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`); | |
| 541 | 542 | console.log("ctrl-c to stop"); |
| 542 | 543 | await new Promise<void>((resolve) => process.once("SIGINT", () => resolve())); |
| 543 | 544 | await server.close(); |
Software/so101/src/dose-pumps.test.ts +6 −2
| @@ -17,6 +17,8 @@ import { renderPumps } from "./pumps-card.ts"; | ||
| 17 | 17 | import { startRelay, type RunningRelay } from "./relay.ts"; |
| 18 | 18 | import { startServer, type RunningServer } from "./server.ts"; |
| 19 | 19 | |
| 20 | const GROW_KEY = "dose-pumps-test-grow-key-0123456789abcdef"; | |
| 21 | ||
| 20 | 22 | /** Fake hardware: every switch by its GPIO line (or host), what it was told, and a float the test sets. */ |
| 21 | 23 | function rig(dir = mkdtempSync(join(tmpdir(), "so101-pumps-"))) { |
| 22 | 24 | 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", () => { | ||
| 229 | 231 | 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 () => { |
| 230 | 232 | const r = rig(); |
| 231 | 233 | 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 }); | |
| 233 | 235 | lobby = new Lobby({ identityKey: KEY, adminFile: join(dir, "admin.json"), countsFile: join(dir, "visitors.json") }); |
| 234 | 236 | const log = new CareLog(); |
| 235 | 237 | 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); | |
| 237 | 241 | const base = `http://127.0.0.1:${relay.port}`; |
| 238 | 242 | const setup = (body: unknown) => fetch(`${base}/care/dosing/pump`, { method: "POST", headers: owner(), body: JSON.stringify(body) }); |
| 239 | 243 |
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"; | ||
| 11 | 11 | import type { Switch } from "./light-switch.ts"; |
| 12 | 12 | import { startServer, type RunningServer } from "./server.ts"; |
| 13 | 13 | 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 }; | |
| 14 | 18 | |
| 15 | 19 | let server: RunningServer | undefined; |
| 16 | 20 | afterEach(() => server?.close()); |
| @@ -19,15 +23,15 @@ async function boot(lit: { on: boolean }) { | ||
| 19 | 23 | const dir = mkdtempSync(join(tmpdir(), "so101-light-")); |
| 20 | 24 | const fake: Switch = { on: async () => void (lit.on = true), off: async () => void (lit.on = false), state: async () => lit.on }; |
| 21 | 25 | 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") } }); | |
| 23 | 27 | return `http://127.0.0.1:${server.port}`; |
| 24 | 28 | } |
| 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) }); | |
| 26 | 30 | |
| 27 | 31 | test("GET /light says always on until a switch is set; POST sets it, and refuses nonsense with a sentence", async () => { |
| 28 | 32 | const lit = { on: false }; |
| 29 | 33 | 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)"); | |
| 31 | 35 | const bad = await post(base, { schedule: { onAt: "25:00", hours: 12 } }); |
| 32 | 36 | expect(bad.status).toBe(400); |
| 33 | 37 | 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 | ||
| 78 | 82 | const base = await boot(lit); |
| 79 | 83 | const put = await post(base, { switch: { driver: "gpio", line: 17 }, schedule: { onMinutes: 5, offMinutes: 25 } }, "pump"); |
| 80 | 84 | 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 } }; | |
| 82 | 86 | expect(grow.light.configured).toBe(false); |
| 83 | 87 | expect(grow.pump.text).toBe("On until 12:05"); |
| 84 | 88 | 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 | ||
| 101 | 105 | socket.on("error", () => {}); |
| 102 | 106 | socket.on("close", () => resolve({ answeredMs, reply })); |
| 103 | 107 | 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`); | |
| 105 | 109 | const pump = () => { |
| 106 | 110 | while (written < chunks * 100_000 && !socket.destroyed) { |
| 107 | 111 | written += 100_000; |
| @@ -121,15 +125,15 @@ test("POST /grow/<device> stops reading at the cap, whatever Content-Length says | ||
| 121 | 125 | expect(reply.toLowerCase()).toContain("connection: close"); // hung up after the answer |
| 122 | 126 | expect(answeredMs).toBeGreaterThanOrEqual(0); |
| 123 | 127 | 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 }>; | |
| 125 | 129 | expect(grow[device]!.configured).toBe(false); |
| 126 | 130 | } |
| 127 | 131 | }, 15_000); |
| 128 | 132 | |
| 129 | 133 | test("the body cap counts bytes: 600k three-byte characters (1.8 MB, under 1M characters) answer 413", async () => { |
| 130 | 134 | 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)}"}` }); | |
| 132 | 136 | 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 }>; | |
| 134 | 138 | expect(grow.light!.configured).toBe(false); |
| 135 | 139 | }, 15_000); |
Software/so101/src/relay.ts +18 −8
| @@ -124,6 +124,7 @@ import { LADDER } from "./video-fanout.ts"; | ||
| 124 | 124 | import { LOCAL as LOCAL_PERSON } from "./identity.ts"; |
| 125 | 125 | import { LeaderCalibrations } from "./leader-calibrations.ts"; |
| 126 | 126 | import { proxyFor, whyNot } from "./egress.ts"; |
| 127 | import { GROW_KEY_HEADER, isGrowRoute } from "./grow-key.ts"; | |
| 127 | 128 | import { CalibrationError } from "./leader-map.js"; |
| 128 | 129 | import type { Lobby } from "./lobby.ts"; |
| 129 | 130 | import { capBody, FrameCap } from "./speed-cap.ts"; |
| @@ -172,6 +173,8 @@ export interface RelayOptions { | ||
| 172 | 173 | probeEveryMs?: number; |
| 173 | 174 | /** 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. */ |
| 174 | 175 | 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; | |
| 175 | 178 | /** How often the arm's sensors are pulled into the care log book, ms; unset, never. */ |
| 176 | 179 | sensorsMs?: number; |
| 177 | 180 | /** A setting that could not be used (shown on /link while simulating). */ |
| @@ -246,6 +249,8 @@ const STATIC = /^\/(?:index\.html)?$|^\/(?:model|vendor|ui)\//; | ||
| 246 | 249 | const LEADER = "/leader/calibration"; |
| 247 | 250 | /** Admins only: clearing an emergency stop and changing the settings (the safety limits among them). */ |
| 248 | 251 | 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"]); | |
| 249 | 254 | /** Routes only the owner may reach; anyone else is told there is no such page. */ |
| 250 | 255 | 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"]); |
| 251 | 256 | /** The driver's, and any admin's at any time. */ |
| @@ -487,12 +492,13 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> { | ||
| 487 | 492 | req.on("error", reject); |
| 488 | 493 | }); |
| 489 | 494 | /** 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> = {}) => { | |
| 491 | 496 | const controller = new AbortController(); |
| 492 | 497 | const timer = timeoutMs === null ? undefined : setTimeout(() => controller.abort(new Error(`no answer in ${timeoutMs} ms`)), timeoutMs); |
| 493 | 498 | if (tracked) inFlight.add(controller); |
| 494 | 499 | const headers: Record<string, string> = {}; |
| 495 | 500 | 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); | |
| 496 | 502 | try { |
| 497 | 503 | const response = await fetch(url, { method: req.method, headers, body: body?.length ? body : undefined, signal: controller.signal, redirect: "manual", cache: "no-store" }); |
| 498 | 504 | return { response, controller }; |
| @@ -621,6 +627,8 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> { | ||
| 621 | 627 | }; |
| 622 | 628 | /** The grow light lives on the arm server (the Pi runs its schedule); this reads and changes it for the admin page. */ |
| 623 | 629 | 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 } : {}); | |
| 624 | 632 | /** The pH/EC probes live on the arm server beside the ADC (src/probes.ts); the care page reads and calibrates them through this. */ |
| 625 | 633 | const probeHooks: ProbeHooks = { |
| 626 | 634 | read: async (tempC) => { |
| @@ -647,7 +655,7 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> { | ||
| 647 | 655 | const lightHooks: LightHooks = { |
| 648 | 656 | read: async () => { |
| 649 | 657 | 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)) }); | |
| 651 | 659 | return answer.ok ? readGrow(await answer.json().catch(() => null)) : null; |
| 652 | 660 | } catch { |
| 653 | 661 | return null; |
| @@ -655,7 +663,7 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> { | ||
| 655 | 663 | }, |
| 656 | 664 | write: async (device, body) => { |
| 657 | 665 | 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)) }); | |
| 659 | 667 | if (answer.ok) return null; |
| 660 | 668 | const err = (await answer.json().catch(() => null)) as { error?: unknown } | null; |
| 661 | 669 | 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> { | ||
| 666 | 674 | }; |
| 667 | 675 | const pumpRead = cachedRead(async () => { |
| 668 | 676 | 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)) }); | |
| 670 | 678 | return answer.ok ? readDosing(await answer.json().catch(() => null)) : null; |
| 671 | 679 | } catch { |
| 672 | 680 | return null; |
| @@ -675,7 +683,7 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> { | ||
| 675 | 683 | /** One request to the Pi's pumps; the relay's own, never a browser's (a browser's /grow/dosing is refused below). */ |
| 676 | 684 | const pumpWrite = async (body: Record<string, unknown>, timeoutMs: number): Promise<import("./arm-pumps.ts").PumpAnswer> => { |
| 677 | 685 | 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) }); | |
| 679 | 687 | const parsed = (await answer.json().catch(() => null)) as { error?: unknown } | null; |
| 680 | 688 | if (answer.ok) return { ok: true, body: parsed }; |
| 681 | 689 | 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> { | ||
| 1108 | 1116 | const pullSensors = async () => { |
| 1109 | 1117 | if (!(remote && onArm())) return; |
| 1110 | 1118 | 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)) }); | |
| 1112 | 1120 | const body = (await answer.json()) as { readings?: unknown }; |
| 1113 | 1121 | if (!answer.ok || !Array.isArray(body.readings)) return; |
| 1114 | 1122 | for (const r of body.readings.slice(0, 16)) { |
| @@ -1191,6 +1199,8 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> { | ||
| 1191 | 1199 | const handled = await growRoutes(req, res, url); |
| 1192 | 1200 | if (handled) return; |
| 1193 | 1201 | } |
| 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() : {}; | |
| 1194 | 1204 | let by = "the arm's page"; |
| 1195 | 1205 | if (OWNER_READS.has(route) && !(lobby ? lobby.who(req)?.owner : false)) return json(res, 404, { error: "not found" }); |
| 1196 | 1206 | if (!reading && lobby) { |
| @@ -1269,7 +1279,7 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> { | ||
| 1269 | 1279 | const answered = (status: number) => logged && machine.note("received", route, { status }); |
| 1270 | 1280 | if (route === "POST /resume") return await resume(res, by); |
| 1271 | 1281 | 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); | |
| 1273 | 1283 | answered(answer.response.status); |
| 1274 | 1284 | return await pass(res, answer); |
| 1275 | 1285 | } |
| @@ -1279,7 +1289,7 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> { | ||
| 1279 | 1289 | } |
| 1280 | 1290 | try { |
| 1281 | 1291 | // 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); | |
| 1283 | 1293 | answered(answer.response.status); |
| 1284 | 1294 | return await pass(res, answer); |
| 1285 | 1295 | } 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)); | ||
| 18 | 18 | const shut = (s: { close(): unknown } | undefined) => Promise.race([Promise.resolve(s?.close()), sleep(500)]); |
| 19 | 19 | const profile = loadProfiles(BUILT_IN_DIR).find((p) => p.id === "spinach")!; |
| 20 | 20 | |
| 21 | const KEY = "sensors-relay-test-key-0123456789abcdef"; | |
| 21 | 22 | let servers: RunningServer[] = []; |
| 22 | 23 | let relay: RunningRelay | undefined; |
| 23 | 24 | afterEach(async () => { |
| @@ -28,7 +29,7 @@ afterEach(async () => { | ||
| 28 | 29 | }); |
| 29 | 30 | |
| 30 | 31 | 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 } : {}) }); | |
| 32 | 33 | servers.push(s); |
| 33 | 34 | return s; |
| 34 | 35 | }; |
| @@ -39,7 +40,7 @@ async function pulled(readings: unknown, fail = false): Promise<CareLog> { | ||
| 39 | 40 | const real = await arm([1, 2, 3], stub); |
| 40 | 41 | const sim = await arm([1, 2, 3, 4, 5, 6]); |
| 41 | 42 | 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 }); | |
| 43 | 44 | const base = `http://127.0.0.1:${relay.port}`; |
| 44 | 45 | for (let i = 0; i < 100 && ((await (await fetch(`${base}/link`)).json()) as { mode: string }).mode !== "live"; i++) await sleep(20); |
| 45 | 46 | if (fail) await shut(real); |
| @@ -113,6 +114,6 @@ test("GET /grow carries the sensors for the gated card", async () => { | ||
| 113 | 114 | const sensors = new Sensors([{ kind: "airTemp", label: "sht31 0x44", read: async () => 21.5 }]); |
| 114 | 115 | await sensors.tick(); |
| 115 | 116 | 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 }[] }; | |
| 117 | 118 | expect(body.sensors[0]!.value).toBe(21.5); |
| 118 | 119 | }); |
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" | ||
| 95 | 95 | const dir = mkdtempSync(join(tmpdir(), "so101-sensors-")); |
| 96 | 96 | const sensors = new Sensors([{ kind: "waterTemp", label: "water", read: async () => 18.25 }]); |
| 97 | 97 | 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" }); | |
| 99 | 99 | 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 }[] }; | |
| 101 | 101 | expect(body.sensors[0]!.value).toBe(18.25); |
| 102 | 102 | expect(body.readings[0]).toMatchObject({ kind: "waterTemp", source: "sensor" }); |
| 103 | 103 | } finally { |
Software/so101/src/server.ts +13
| @@ -48,6 +48,8 @@ | ||
| 48 | 48 | * ?hours=1-720 hourly rows (48 by default) |
| 49 | 49 | * 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, |
| 50 | 50 | * 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 | |
| 51 | 53 | * GET /grow the grow light and the pump (src/light.ts): schedule, switch driver, "On until 22:00", override and the last transitions |
| 52 | 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 |
| 53 | 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 |
| @@ -110,6 +112,7 @@ import { ads1115 } from "./ads1115.ts"; | ||
| 110 | 112 | import { CalError } from "./probe-math.ts"; |
| 111 | 113 | import { Probes } from "./probes.ts"; |
| 112 | 114 | import { loadSensors, Sensors } from "./sensors.ts"; |
| 115 | import { GROW_KEY_HEADER, growKeyMatches, isGrowRoute, loadGrowKey } from "./grow-key.ts"; | |
| 113 | 116 | import { DEVICES, Light, LightError, type DeviceName } from "./light.ts"; |
| 114 | 117 | import { DosePumps, PumpError } from "./dose-pumps.ts"; |
| 115 | 118 | 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 { | ||
| 158 | 161 | sensorsMs?: number; |
| 159 | 162 | /** The sensors, for tests that give them fake sources; by default those listed in sensors.json under the data dir. */ |
| 160 | 163 | 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; | |
| 161 | 166 | /** How long each sweep target is held, ms (1500); tests shorten it. */ |
| 162 | 167 | tuningHoldMs?: number; |
| 163 | 168 | /** Replaces the stream sweep of the P experiment; tests give it the numbers to decide on. */ |
| @@ -178,6 +183,9 @@ export interface ServerOptions { | ||
| 178 | 183 | cameraStill?: (unit: number) => Promise<Uint8Array>; |
| 179 | 184 | } |
| 180 | 185 | |
| 186 | /** Where the arm server keeps its grow key: beside the poses file. */ | |
| 187 | export const growKeyFile = (dataDir: string) => join(dataDir, "grow-key"); | |
| 188 | ||
| 181 | 189 | export interface RunningServer { |
| 182 | 190 | readonly port: number; |
| 183 | 191 | close(): Promise<void>; |
| @@ -263,6 +271,7 @@ export async function startServer(bus: FeetechBus, options: ServerOptions = {}): | ||
| 263 | 271 | const dosePumps = options.dosePumps ?? new DosePumps({ file: join(dataDir, "dose-pumps.json") }); |
| 264 | 272 | const probes = options.probes ?? new Probes(ads1115(), join(dataDir, "probes.json")); |
| 265 | 273 | const sensors = options.sensors ?? loadSensors(join(dataDir, "sensors.json")); |
| 274 | const growKey = options.growKey ?? loadGrowKey(growKeyFile(dataDir)); | |
| 266 | 275 | const precision = new PrecisionHistory({ file: join(dataDir, "precision.sqlite"), commit: options.commit }); |
| 267 | 276 | /** The stream's precision tracker while one drives; every Goal_Position write starts its bus-to-motion clock. */ |
| 268 | 277 | let tracking: Tracker | null = null; |
| @@ -838,6 +847,10 @@ export async function startServer(bus: FeetechBus, options: ServerOptions = {}): | ||
| 838 | 847 | } |
| 839 | 848 | }; |
| 840 | 849 | 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 | } | |
| 841 | 854 | if (req.method === "GET" && url.pathname.startsWith("/model/") && modelDir) { |
| 842 | 855 | const path = safeFile(resolve(modelDir), decode(url.pathname.slice("/model/".length))); |
| 843 | 856 | if (!path || ![".urdf", ".stl", ".dae", ".png", ".jpg"].includes(extname(path).toLowerCase())) return json(res, 404, { error: "no such model file" }); |