Public: anyone can read this; the crew can change it.
Migrated from github.com/Trevato/SO-ARM100
workbenchthis machine
The workshop: one Pi, several stations (the arm and the grow box), each with cameras and controls; works with the arm unplugged #613
smith/arm:smith/581-the-workshop-one-pi-several-stations-the → main 5 commits fork
16 files
changed +611
−13
Software/so101/README.md +8
| @@ -45,6 +45,14 @@ The arm closes itself too: a real arm (ARM_SERVER) that has not answered for mor | ||
| 45 | 45 | |
| 46 | 46 | `GET /status` says so for everyone: `{"open": false, "reason": "closed"|"offline", "note": "…"|null, "reopenAt": ISO|null, "lastOpenAt": ISO|null, "driver": null, "source": "The arm is resting"}`, answered without asking the arm; while open, the arm's status as before with `"open": true`. |
| 47 | 47 | |
| 48 | ## The workshop: stations on / | |
| 49 | ||
| 50 | The app is a workshop: one Pi, several **stations**, each with its cameras and controls. Today there are two, **the arm** and **the grow box** (spinach under a UV light). `/` is the workshop home, a card per station with its live camera snapshot first and its controls after, and the arm's own page is at `/arm` (its URL is unchanged for anyone who has it). The page and its title say "Workshop"; it shows who is looking ("Signed in as …", or "Watching as a guest · Sign in"), has a skip link, and keeps working while the arm is closed. | |
| 51 | ||
| 52 | - **Any subset present.** The grow box has nothing to enumerate (the light is USB power only), so it is always there. The arm's card is there only while the link to the Pi's `so101 serve` is live; unplugged, closed by the owner or unreachable it says why ("The arm: not connected", "The arm is resting") and offers no controls, and the owner sees **Open now** on it. Plugging the arm back in brings its card back by itself, with no restart. `so101 serve` with no servo board waits for one ("arm not connected; waiting for its servo board") instead of exiting, so the Pi's service stays up for the cameras. | |
| 53 | - **The config** is `so101/stations.json` under `FORGE_APP_DATA`: a list of `{id, name, kind: "arm"|"grow", cameras: [camera ids], controls: [{label, href}]}` (src/stations.ts). Without a file it is the arm (camera 1) and the grow box (camera 2, from the camera list): edit it to match the cameras. With only an arm in it, `/` is the arm's page as before. | |
| 54 | - **Three doors.** The page `/admin/stations/edit` (owner; a raw JSON box that needs JavaScript, so it is awkward on a phone: prefer a laptop or the terminal), `GET /stations` (anyone: each station's `present`, why not, and its camera ids and names) and `PUT /admin/stations` (owner; at most 16 KB, 400 on bad JSON or a bad station), and in the terminal `/stations` to list and `/stations set <json list>` (owner) to replace them. | |
| 55 | ||
| 48 | 56 | ## The story page: /story |
| 49 | 57 | |
| 50 | 58 | `/story` on the hosted app is the arm explained for anyone: the hook, how it works (with a diagram of browser → forge → Pi → servos, and what latency, the queue, STOP and the safety zone mean), how to build your own (the [](https://huggingface.co/docs/lerobot/so101), this repository, `bun so101 setup`) and what the crew built. Anyone may read it; it is linked from the Chat tab's footer (hidden while you drive) and the Queue tab. |
Software/so101/bench/fake-serve.ts +2 −1
| @@ -19,6 +19,7 @@ import { Accounts } from "../src/accounts.ts"; | ||
| 19 | 19 | import { FeetechBus } from "../src/bus.ts"; |
| 20 | 20 | import { FakeBus } from "../src/fake-bus.ts"; |
| 21 | 21 | import { CameraHub } from "../src/cameras.ts"; |
| 22 | import { StationHub } from "../src/stations.ts"; | |
| 22 | 23 | import { LeaderCalibrations } from "../src/leader-calibrations.ts"; |
| 23 | 24 | import { Lobby } from "../src/lobby.ts"; |
| 24 | 25 | import { Moderator, modelFromEnv } from "../src/moderator.ts"; |
| @@ -73,6 +74,6 @@ try { | ||
| 73 | 74 | } |
| 74 | 75 | const facts = new Facts({ file: join(dir, "facts.sqlite") }); |
| 75 | 76 | const piSync = piSyncFromEnv(process.env, remote?.http ?? null, remote?.host ?? null, () => lobby.turns.snapshot().driver === null, dir); |
| 76 | const relay = await startRelay({ 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") }); | |
| 77 | const relay = await startRelay({ 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") }); | |
| 77 | 78 | const { mode, host: armHost } = relay.link(); |
| 78 | 79 | 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/admin-page.ts +1 −1
| @@ -358,7 +358,7 @@ export function renderAdmin(p: AdminPage): string { | ||
| 358 | 358 | "Admin · SO-101", |
| 359 | 359 | `<header> |
| 360 | 360 | <h1>Admin</h1> |
| 361 | <div class="state"><span class="pill${s.paused || p.resting ? " paused" : ""}">${p.resting ? (p.resting.reason === "offline" ? "Offline" : "Closed") : s.paused ? "Paused" : "Open"}</span><span>${d ? `<b>${esc(d.name)}</b> has control` : "Nobody has control"}</span><span><b>${p.snapshot.queue.length}</b> waiting</span><a href="/admin/data">Data</a><a href="/admin/camera-list">Cameras</a><a href="/">Back to the arm</a></div> | |
| 361 | <div class="state"><span class="pill${s.paused || p.resting ? " paused" : ""}">${p.resting ? (p.resting.reason === "offline" ? "Offline" : "Closed") : s.paused ? "Paused" : "Open"}</span><span>${d ? `<b>${esc(d.name)}</b> has control` : "Nobody has control"}</span><span><b>${p.snapshot.queue.length}</b> waiting</span><a href="/admin/data">Data</a><a href="/admin/camera-list">Cameras</a><a href="/admin/stations/edit">Stations</a><a href="/">Back to the arm</a></div> | |
| 362 | 362 | ${p.signedIn ? renderSignedIn(p.signedIn, "/admin") : ""} |
| 363 | 363 | </header> |
| 364 | 364 | ${flash} |
Software/so101/src/arm-wait.ts added +18
| @@ -0,0 +1,18 @@ | ||
| 1 | /** `so101 serve` with no arm plugged in waits for one instead of exiting, so the service stays up for the rest of the workshop. */ | |
| 2 | ||
| 3 | export const NO_ADAPTER = /no USB serial adapter found/; | |
| 4 | ||
| 5 | /** Returns once `detect` finds a port; any other failure (several adapters) is thrown. Says once that it is waiting. */ | |
| 6 | export async function waitForArm(detect: () => Promise<string>, say: (text: string) => void, sleep: (ms: number) => Promise<void>, everyMs = 2000): Promise<string> { | |
| 7 | let told = false; | |
| 8 | for (;;) { | |
| 9 | try { | |
| 10 | return await detect(); | |
| 11 | } catch (err) { | |
| 12 | if (!NO_ADAPTER.test((err as Error).message)) throw err; | |
| 13 | if (!told) say("arm not connected; waiting for its servo board"); | |
| 14 | told = true; | |
| 15 | await sleep(everyMs); | |
| 16 | } | |
| 17 | } | |
| 18 | } |
Software/so101/src/cli-node.test.ts +7
| @@ -46,3 +46,10 @@ test.skipIf(!strips)("setup --dry-run runs under Node and touches no servo", () | ||
| 46 | 46 | expect(dry.stdout).toContain("so101 setup (dry run)"); |
| 47 | 47 | expect(dry.status).toBe(0); |
| 48 | 48 | }); |
| 49 | ||
| 50 | test("cli.ts parses: `await` in `so101 serve` needs the handler to be async", () => { | |
| 51 | const text = readFileSync(new URL("./cli.ts", import.meta.url), "utf8"); | |
| 52 | const transpiler = new Bun.Transpiler({ loader: "ts" }); | |
| 53 | expect(() => transpiler.transformSync(text)).not.toThrow(); | |
| 54 | expect(() => transpiler.transformSync(text.replace("serve: async (values) =>", "serve: (values) =>"))).toThrow(); | |
| 55 | }); |
Software/so101/src/cli.ts +4 −1
| @@ -8,6 +8,7 @@ import { FeetechBus, range } from "./bus.ts"; | ||
| 8 | 8 | import { describeError, hex } from "./protocol.ts"; |
| 9 | 9 | import { MAX_FRAME_STEP, MotionError, moveToPose, name as jointName, playTrajectory, recordTrajectory, sleep } from "./motion.ts"; |
| 10 | 10 | import { streamLeader } from "./leader.ts"; |
| 11 | import { waitForArm } from "./arm-wait.ts"; | |
| 11 | 12 | import { assertPoseName, parsePoseFile, serializePoseFile, type PoseFile } from "./poses.ts"; |
| 12 | 13 | import { durationSeconds, largestStep, parseTrajectory, serializeTrajectory } from "./trajectory.ts"; |
| 13 | 14 | import { |
| @@ -525,9 +526,11 @@ const COMMANDS: Record<string, Command> = { | ||
| 525 | 526 | } |
| 526 | 527 | }, |
| 527 | 528 | |
| 528 | serve: (values) => { | |
| 529 | serve: async (values) => { | |
| 529 | 530 | const [host, portText] = (values.listen ?? "0.0.0.0:8101").split(":"); |
| 530 | 531 | const port = bounded(integer(portText ?? "8101", "--listen port"), 1, 65535, "--listen port"); |
| 532 | const waiting = !values.port && !process.env.SO101_PORT; | |
| 533 | if (waiting) await waitForArm(detectPort, (text) => console.log(text), (ms) => new Promise((r) => setTimeout(r, ms))); | |
| 531 | 534 | return withBus(values, async (bus) => { |
| 532 | 535 | const { startServer } = await import("./server.ts"); // lazily: it reaches bun:sqlite, which Node lacks |
| 533 | 536 | const cameraControl = (unit: number, profile: Parameters<typeof applyProfile>[1]) => applyProfile(cameraDevice(unit), profile); |
Software/so101/src/relay.test.ts +1 −1
| @@ -149,7 +149,7 @@ describe("relay", () => { | ||
| 149 | 149 | expect(await source(base)).toBe("simulated: ARM_SERVER is not set"); |
| 150 | 150 | const page = await fetch(`${base}/`); |
| 151 | 151 | expect(page.status).toBe(200); |
| 152 | expect(await page.text()).toContain("<title>SO-101</title>"); | |
| 152 | expect(await page.text()).toContain("<title>Workshop · the arm</title>"); | |
| 153 | 153 | expect(page.headers.get("x-content-type-options")).toBe("nosniff"); |
| 154 | 154 | expect(page.headers.get("content-security-policy")).toContain("frame-ancestors 'none'"); |
| 155 | 155 | // No camera is listed while the arm is simulated: the owner adds them on /admin, until then there is nothing to show. |
Software/so101/src/relay.ts +60 −3
| @@ -104,6 +104,8 @@ import { readTuning } from "./tuning-panel.ts"; | ||
| 104 | 104 | import { readServos } from "./servo-panel.ts"; |
| 105 | 105 | import { parseFps, type FeedOptions } from "./camera-feed.ts"; |
| 106 | 106 | import { CameraError, CameraHub } from "./cameras.ts"; |
| 107 | import { ARM_ONLY, StationError, StationHub } from "./stations.ts"; | |
| 108 | import { renderWorkshop, type StationView } from "./workshop-page.ts"; | |
| 107 | 109 | import { parseProfile, ProfileError, type CameraProfile } from "./camera-profile.ts"; |
| 108 | 110 | import { VideoFeed, type VideoSources } from "./video-feed.ts"; |
| 109 | 111 | import type { VideoCard } from "./video-panel.ts"; |
| @@ -151,6 +153,8 @@ export interface RelayOptions { | ||
| 151 | 153 | remote?: Remote | null; |
| 152 | 154 | /** The camera list; by default in memory, seeded with the first camera of `remote`. */ |
| 153 | 155 | cameras?: CameraHub; |
| 156 | /** The workshop's stations; by default in memory, the arm alone. */ | |
| 157 | stations?: StationHub; | |
| 154 | 158 | /** A setting that could not be used (shown on /link while simulating). */ |
| 155 | 159 | problem?: string | null; |
| 156 | 160 | /** The link's retries and its patience (src/arm-link.ts). */ |
| @@ -195,12 +199,15 @@ export interface RunningRelay { | ||
| 195 | 199 | |
| 196 | 200 | const SCHEMES: Record<string, "http:" | "https:"> = { "http:": "http:", "https:": "https:", "ws:": "http:", "wss:": "https:" }; |
| 197 | 201 | const MAX_BODY = 1_000_000; |
| 202 | const MAX_STATIONS_BODY = 16_384; | |
| 203 | const STATION_GRACE_MS = 10_000; | |
| 198 | 204 | const VIDEO = /^\/camera\/(\d{1,9})\/video$/; |
| 199 | 205 | const CAMERA_WS = /^\/camera\/(\d{1,9})\/ws$/; |
| 200 | 206 | const CAMERA = /^\/camera\/(\d{1,9})\/(stream|state|snapshot)$/; |
| 201 | 207 | const ADMIN_CAMERA = /^\/admin\/cameras(?:\/(\d{1,9})(\/try)?)?$/; |
| 202 | 208 | /** The cameras page, with the still-frame module under a name that changes with its content (the arm server's /ui/ takes any). */ |
| 203 | 209 | const STILL_FRAME = new URL("./ui/still-frame.js", import.meta.url); |
| 210 | const STATIONS_PAGE = readFileSync(new URL("./stations-admin.html", import.meta.url), "utf8"); | |
| 204 | 211 | const CAMERA_PAGE = readFileSync(new URL("./camera-list.html", import.meta.url), "utf8").replaceAll( |
| 205 | 212 | "/ui/__VERSION__/", |
| 206 | 213 | `/ui/${createHash("sha256").update(readFileSync(STILL_FRAME)).digest("hex").slice(0, 12)}/`, |
| @@ -208,9 +215,9 @@ const CAMERA_PAGE = readFileSync(new URL("./camera-list.html", import.meta.url), | ||
| 208 | 215 | /** The arm server's camera profiles: the relay asks for them itself, for the owner, and passes nobody's request on. */ |
| 209 | 216 | const CAMERA_PROFILE = /^\/camera-profile(?:\/|$)/; |
| 210 | 217 | /** Served from this process whichever arm is live: they describe the app, not the arm. */ |
| 211 | const LOCAL = /^\/(?:index\.html|leader-map\.js)?$|^\/(?:model|vendor|printables|ui|app)(?:\/|$)/; | |
| 218 | const LOCAL = /^\/(?:index\.html|leader-map\.js|arm)?$|^\/(?:model|vendor|printables|ui|app)(?:\/|$)/; | |
| 212 | 219 | /** The page itself, which the relay writes the first view's facts into. */ |
| 213 | const PAGE = /^\/(?:index\.html)?$/; | |
| 220 | const PAGE = /^\/(?:index\.html|arm)?$/; | |
| 214 | 221 | /** The page's static files: what a first visit downloads, and what gzip is for. */ |
| 215 | 222 | const STATIC = /^\/(?:index\.html)?$|^\/(?:model|vendor|ui)\//; |
| 216 | 223 | const LEADER = "/leader/calibration"; |
| @@ -301,6 +308,7 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> { | ||
| 301 | 308 | const facts = options.facts ?? null; |
| 302 | 309 | const edges = new ArmEdges(); |
| 303 | 310 | const metrics = new LiveMetrics(); |
| 311 | const stations = options.stations ?? new StationHub(undefined, ARM_ONLY); | |
| 304 | 312 | const cameras = options.cameras ?? new CameraHub({ feed: { ...options.camera, metrics }, seed: remote ? [{ name: "Camera 1", source: remote.cameras[0]! }] : [] }); |
| 305 | 313 | const leaders = options.leaders ?? new LeaderCalibrations(); |
| 306 | 314 | const videos = new Map<number, VideoFeed>(Object.entries(options.video ?? {}).map(([id, sources]) => [Number(id), new VideoFeed(sources, {}, options.env ?? process.env)])); |
| @@ -356,6 +364,7 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> { | ||
| 356 | 364 | if (!baseline.recorded() && !tried) void recordBaseline(); |
| 357 | 365 | }, |
| 358 | 366 | onChange: (state, previous) => { |
| 367 | if (previous === "live") leftLiveAt = Date.now(); | |
| 359 | 368 | if (state === "live") { |
| 360 | 369 | console.log(`arm server ${host}: live`); |
| 361 | 370 | if (previous === "reconnecting") facts?.event("reconnect"); |
| @@ -378,6 +387,8 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> { | ||
| 378 | 387 | for (const c of inFlight) c.abort(new Error("the arm server was changed")); |
| 379 | 388 | connect(); |
| 380 | 389 | }; |
| 390 | /** When the link last stopped being live: a blip shorter than STATION_GRACE_MS does not drop the arm's card. */ | |
| 391 | let leftLiveAt = 0; | |
| 381 | 392 | const mode = (): Link["mode"] => (remote && armLink ? armLink.state : "simulated"); |
| 382 | 393 | /** Whether the arm server, not the simulation, is the arm right now: live, or only briefly gone. */ |
| 383 | 394 | const onArm = () => mode() === "live" || mode() === "reconnecting"; |
| @@ -713,6 +724,41 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> { | ||
| 713 | 724 | res.end(out); |
| 714 | 725 | }; |
| 715 | 726 | |
| 727 | /** Each station as the home shows it: the arm is present only while the link reaches a real one (so it comes back by itself). */ | |
| 728 | const stationViews = (): StationView[] => { | |
| 729 | const listed = new Map(cameras.list().map((c) => [c.id, c.name])); | |
| 730 | const rest = restingNow(); | |
| 731 | return stations.list().map((station) => { | |
| 732 | const present = station.kind === "arm" ? (mode() === "live" || (mode() === "reconnecting" && Date.now() - leftLiveAt < STATION_GRACE_MS)) && !rest : true; | |
| 733 | return { | |
| 734 | station, | |
| 735 | present, | |
| 736 | ...(station.kind === "arm" && rest ? { resting: restingWords(rest) } : {}), | |
| 737 | cameras: present ? station.cameras.filter((id) => listed.has(id)).map((id) => ({ id, name: listed.get(id)! })) : [], | |
| 738 | }; | |
| 739 | }); | |
| 740 | }; | |
| 741 | ||
| 742 | const administerStations = async (req: IncomingMessage, res: ServerResponse) => { | |
| 743 | const who = lobby ? lobby.who(req) : null; | |
| 744 | if (lobby && !who?.owner) return json(res, who ? 403 : 401, { error: "only the owner may change the stations" }); | |
| 745 | if (req.method === "GET") return json(res, 200, stations.list()); | |
| 746 | if (req.method !== "PUT") return json(res, 405, { error: "GET or PUT /admin/stations" }); | |
| 747 | let body: Buffer; | |
| 748 | try { | |
| 749 | body = await readBody(req, MAX_STATIONS_BODY); | |
| 750 | } catch { | |
| 751 | return json(res, 413, { error: "the stations are too long" }); | |
| 752 | } | |
| 753 | try { | |
| 754 | return json(res, 200, stations.replace(JSON.parse(body.toString("utf8") || "null"))); | |
| 755 | } catch (err) { | |
| 756 | if (err instanceof StationError) return json(res, 400, { error: err.message }); | |
| 757 | if (err instanceof SyntaxError) return json(res, 400, { error: "send the stations as JSON" }); | |
| 758 | throw err; | |
| 759 | } | |
| 760 | }; | |
| 761 | ||
| 716 | 762 | /** |
| 717 | 763 | * Put a profile on so101-camera@<unit>'s camera through the arm server, kept there for the camera's next start unless only |
| 718 | 764 | * tried: what it set and what the camera refused, or why it could not. |
| @@ -887,6 +933,12 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> { | ||
| 887 | 933 | res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" }); |
| 888 | 934 | return res.end(CAMERA_PAGE); |
| 889 | 935 | } |
| 936 | if (req.method === "GET" && url.pathname === "/admin/stations/edit") { | |
| 937 | res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" }); | |
| 938 | return res.end(STATIONS_PAGE); | |
| 939 | } | |
| 940 | if (url.pathname === "/admin/stations") return await administerStations(req, res); | |
| 941 | if (reading && url.pathname === "/stations") return json(res, 200, stationViews().map(({ station, present, resting, cameras: c }) => ({ id: station.id, name: station.name, kind: station.kind, present, ...(resting ? { resting } : {}), cameras: c }))); | |
| 890 | 942 | const cameraAdmin = ADMIN_CAMERA.exec(url.pathname); |
| 891 | 943 | if (cameraAdmin) return await administer(req, res, cameraAdmin[1] === undefined ? null : Number(cameraAdmin[1]), cameraAdmin[2] !== undefined); |
| 892 | 944 | if (CAMERA_PROFILE.test(url.pathname)) return json(res, 404, { error: "no such route" }); |
| @@ -964,8 +1016,13 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> { | ||
| 964 | 1016 | } |
| 965 | 1017 | } |
| 966 | 1018 | |
| 1019 | if (req.method === "GET" && /^\/(?:index\.html)?$/.test(url.pathname) && stations.isWorkshop()) { | |
| 1020 | res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" }); | |
| 1021 | const who = lobby?.who(req); | |
| 1022 | return res.end(renderWorkshop(stationViews(), { signedIn: lobby && !lobby.local ? lobby.signedIn(req) : null, owner: Boolean(who?.owner) })); | |
| 1023 | } | |
| 967 | 1024 | if (req.method === "GET" && PAGE.test(url.pathname) && rest) return restingPage(req, res, rest); |
| 968 | if (req.method === "GET" && PAGE.test(url.pathname)) return await page(req, res, await ask(simulation + path, req, undefined, null, false)); | |
| 1025 | if (req.method === "GET" && PAGE.test(url.pathname)) return await page(req, res, await ask(simulation + path.replace(/^\/arm(?=$|\?)/, "/"), req, undefined, null, false)); | |
| 969 | 1026 | if (req.method === "GET" && STATIC.test(url.pathname) && acceptsGzip(req.headers)) return await packed(res, await ask(simulation + path, req, undefined, null, false), path); |
| 970 | 1027 | // Resting, nothing reaches an arm but a STOP: not a command, not a read. |
| 971 | 1028 | // The forge's own paths (sign-in among them) are never the arm's: resting never refuses them, so the owner can get back in. |
Software/so101/src/stations-admin.html added +32
| @@ -0,0 +1,32 @@ | ||
| 1 | ||
| 2 | <html lang="en"> | |
| 3 | <head> | |
| 4 | <meta charset="utf-8"> | |
| 5 | <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| 6 | <title>Admin · stations</title> | |
| 7 | <style> | |
| 8 | :root { color-scheme: light dark; } | |
| 9 | body { font: 15px/1.5 system-ui, sans-serif; max-width: 42rem; margin: 2rem auto; padding: 0 1rem; } | |
| 10 | textarea { width: 100%; min-height: 20rem; font: 13px/1.4 ui-monospace, monospace; box-sizing: border-box; } | |
| 11 | button { font: inherit; padding: .5rem .9rem; } #msg { min-height: 1.5em; } | |
| 12 | </style> | |
| 13 | </head> | |
| 14 | <body> | |
| 15 | <p><a href="/">← the workshop</a> · <a href="/admin">Admin</a></p> | |
| 16 | <h1>Stations</h1> | |
| 17 | <p>One entry per station: <code>id</code>, <code>name</code>, <code>kind</code> (<code>arm</code> or <code>grow</code>), <code>cameras</code> (ids from the <a href="/admin/camera-list">camera list</a>) and <code>controls</code> (<code>label</code> and a <code>href</code> path). The home shows a card for each.</p> | |
| 18 | <noscript><p>This page needs JavaScript.</p></noscript> | |
| 19 | <textarea id="text" spellcheck="false" aria-label="Stations as JSON"></textarea> | |
| 20 | <p><button id="save">Save</button></p> | |
| 21 | <p id="msg" role="status"></p> | |
| 22 | <script> | |
| 23 | const text = document.getElementById("text"), msg = document.getElementById("msg"); | |
| 24 | fetch("/admin/stations").then((r) => r.json()).then((s) => { text.value = JSON.stringify(s, null, 2); }, () => { msg.textContent = "could not load"; }); | |
| 25 | document.getElementById("save").onclick = async () => { | |
| 26 | const r = await fetch("/admin/stations", { method: "PUT", headers: { "content-type": "application/json" }, body: text.value }); | |
| 27 | const body = await r.json(); | |
| 28 | msg.textContent = r.ok ? "Saved." : body.error; | |
| 29 | }; | |
| 30 | </script> | |
| 31 | </body> | |
| 32 | </html> |
Software/so101/src/stations.test.ts added +240
| @@ -0,0 +1,240 @@ | ||
| 1 | import { afterEach, expect, test } from "bun:test"; | |
| 2 | import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; | |
| 3 | import { createServer, type Server } from "node:http"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { waitForArm } from "./arm-wait.ts"; | |
| 7 | import { FeetechBus } from "./bus.ts"; | |
| 8 | import { CameraHub } from "./cameras.ts"; | |
| 9 | import { FakeBus } from "./fake-bus.ts"; | |
| 10 | import { sign } from "./identity.ts"; | |
| 11 | import { Lobby } from "./lobby.ts"; | |
| 12 | import { parseRemote, startRelay, type RunningRelay } from "./relay.ts"; | |
| 13 | import { startServer, type RunningServer } from "./server.ts"; | |
| 14 | import { ARM_ONLY, parseStations, StationError, StationHub, WORKSHOP } from "./stations.ts"; | |
| 15 | import { renderWorkshop } from "./workshop-page.ts"; | |
| 16 | import { Door, stationLines, stationsReader } from "./terminal.ts"; | |
| 17 | ||
| 18 | const FAST = { backoffMs: [5, 10, 20, 40], staleMs: 60, offlineAfterMs: 600, connectTimeoutMs: 50, pollEveryMs: 10 }; | |
| 19 | const KEY = "k".repeat(64); | |
| 20 | const as = (name: string, role: "admin" | "user") => { | |
| 21 | const identity = `id-${name}|${name}|${role}|${Date.now() + 600_000}`; | |
| 22 | return { "x-forge-identity": identity, "x-forge-signature": sign(identity, KEY) }; | |
| 23 | }; | |
| 24 | const owner = as("trevato", "admin"); | |
| 25 | const visitor = as("guest1", "user"); | |
| 26 | ||
| 27 | const GROW = { id: "grow", name: "Grow box", kind: "grow", cameras: [1], controls: [{ label: "Light", href: "/grow/light" }] }; | |
| 28 | ||
| 29 | test("parseStations validates every field", () => { | |
| 30 | expect(parseStations([GROW])[0]!.name).toBe("Grow box"); | |
| 31 | expect(() => parseStations({})).toThrow(StationError); | |
| 32 | expect(() => parseStations([{ ...GROW, kind: "pond" }])).toThrow(/kind/); | |
| 33 | expect(() => parseStations([{ ...GROW, id: "Bad Id" }])).toThrow(/id/); | |
| 34 | expect(() => parseStations([GROW, GROW])).toThrow(/two stations/); | |
| 35 | expect(() => parseStations([{ ...GROW, controls: [{ label: "x", href: "//evil.example" }] }])).toThrow(/path/); | |
| 36 | expect(() => parseStations([{ ...GROW, cameras: ["1"] }])).toThrow(/cameras/); | |
| 37 | }); | |
| 38 | ||
| 39 | test("the hub keeps its list in a file and starts as the arm and the grow box", () => { | |
| 40 | const dir = mkdtempSync(join(tmpdir(), "so101-stations-")); | |
| 41 | try { | |
| 42 | const file = join(dir, "stations.json"); | |
| 43 | const hub = new StationHub(file); | |
| 44 | expect(hub.list().map((x) => x.id)).toEqual(WORKSHOP.map((x) => x.id)); | |
| 45 | expect(new StationHub(undefined, ARM_ONLY).isWorkshop()).toBe(false); | |
| 46 | hub.replace([GROW]); | |
| 47 | expect(() => hub.replace("nope")).toThrow(StationError); | |
| 48 | expect(new StationHub(file).list()[0]!.id).toBe("grow"); | |
| 49 | } finally { | |
| 50 | rmSync(dir, { recursive: true, force: true }); | |
| 51 | } | |
| 52 | }); | |
| 53 | ||
| 54 | test("waitForArm waits through 'no adapter' and returns the port once it is plugged in", async () => { | |
| 55 | let calls = 0; | |
| 56 | const said: string[] = []; | |
| 57 | const port = await waitForArm(async () => { | |
| 58 | if (++calls < 3) throw new Error("no USB serial adapter found — plug in"); | |
| 59 | return "/dev/ttyACM0"; | |
| 60 | }, (t) => said.push(t), async () => {}); | |
| 61 | expect(port).toBe("/dev/ttyACM0"); | |
| 62 | expect(said).toHaveLength(1); | |
| 63 | await expect(waitForArm(async () => { throw new Error("several adapters found"); }, () => {}, async () => {})).rejects.toThrow(/several/); | |
| 64 | }); | |
| 65 | ||
| 66 | let relay: RunningRelay | undefined; | |
| 67 | let servers: Server[] = []; | |
| 68 | afterEach(async () => { | |
| 69 | await Promise.race([relay?.close(), new Promise((r) => setTimeout(r, 500))]); | |
| 70 | for (const s of servers) { | |
| 71 | s.closeAllConnections(); | |
| 72 | s.close(); | |
| 73 | } | |
| 74 | relay = undefined; | |
| 75 | servers = []; | |
| 76 | }); | |
| 77 | ||
| 78 | const listen = async (handler: Parameters<typeof createServer>[1]) => { | |
| 79 | const server = createServer(handler); | |
| 80 | servers.push(server); | |
| 81 | await new Promise<void>((r) => server.listen(0, "127.0.0.1", r)); | |
| 82 | return (server.address() as { port: number }).port; | |
| 83 | }; | |
| 84 | ||
| 85 | test("with no arm the workshop shows the grow box and serves its camera; plugging the arm in brings its card", async () => { | |
| 86 | const camera = await listen((req, res) => { | |
| 87 | res.writeHead(200, { "content-type": "image/jpeg" }); | |
| 88 | res.end("jpeg"); | |
| 89 | }); | |
| 90 | const sim = await listen((req, res) => void res.writeHead(200, { "content-type": "text/html" }).end("<title>viewer</title>")); | |
| 91 | const free = await listen(() => {}); // a port nothing answers on once its server is closed | |
| 92 | servers.pop()!.close(); | |
| 93 | const cameras = new CameraHub({ seed: [{ name: "Grow cam", source: `http://127.0.0.1:${camera}/stream` }] }); | |
| 94 | const stations = new StationHub(); | |
| 95 | stations.replace([{ id: "arm", name: "The arm", kind: "arm", cameras: [], controls: [{ label: "Drive", href: "/arm" }] }, GROW]); | |
| 96 | const remote = parseRemote({ ARM_SERVER: `ws://127.0.0.1:${free}` })!; | |
| 97 | relay = await startRelay({ port: 0, simulation: `http://127.0.0.1:${sim}`, remote, cameras, stations, link: FAST, env: {} }); | |
| 98 | const base = `http://127.0.0.1:${relay.port}`; | |
| 99 | ||
| 100 | const home = await (await fetch(`${base}/`)).text(); | |
| 101 | expect(home).toContain("<title>Workshop</title>"); | |
| 102 | expect(home).toContain('data-station="grow" data-present="true"'); | |
| 103 | expect(home).toContain("/camera/1/snapshot"); | |
| 104 | expect(home).toContain("The arm: not connected"); | |
| 105 | expect(home).not.toContain('href="/arm"'); | |
| 106 | const snap = await fetch(`${base}/camera/1/snapshot`); | |
| 107 | expect(snap.status).toBe(200); | |
| 108 | expect(await snap.text()).toBe("jpeg"); | |
| 109 | ||
| 110 | const fake = new FakeBus([1, 2, 3]); | |
| 111 | const plugged = await startServer(new FeetechBus(fake), { host: "127.0.0.1", port: free, ids: [1, 2, 3], posesFile: join(tmpdir(), `so101-stations-${process.pid}.json`) }); | |
| 112 | try { | |
| 113 | let page = ""; | |
| 114 | for (let i = 0; i < 100 && !page.includes('data-station="arm" data-present="true"'); i++) { | |
| 115 | await new Promise((r) => setTimeout(r, 50)); | |
| 116 | page = await (await fetch(`${base}/`)).text(); | |
| 117 | } | |
| 118 | expect(page).toContain('data-station="arm" data-present="true"'); | |
| 119 | expect(page).toContain('href="/arm"'); | |
| 120 | expect((await (await fetch(`${base}/arm`)).text())).toContain("viewer"); | |
| 121 | } finally { | |
| 122 | await Promise.race([relay.close(), new Promise((r) => setTimeout(r, 500))]); | |
| 123 | await Promise.race([plugged.close(), new Promise((r) => setTimeout(r, 500))]); | |
| 124 | } | |
| 125 | }); | |
| 126 | ||
| 127 | /** A relay with a live fake arm, a grow camera and a lobby; `closed` writes the owner's switch first. */ | |
| 128 | async function bootWithArm(closed = false) { | |
| 129 | const dir = mkdtempSync(join(tmpdir(), "so101-stations-")); | |
| 130 | dirs.push(dir); | |
| 131 | const camera = await listen((req, res) => void res.writeHead(200, { "content-type": "image/jpeg" }).end("jpeg")); | |
| 132 | const sim = await listen((req, res) => void res.writeHead(200, { "content-type": "text/html" }).end("<title>viewer</title>")); | |
| 133 | const free = await listen(() => {}); | |
| 134 | servers.pop()!.close(); | |
| 135 | arms.push(await startServer(new FeetechBus(new FakeBus([1, 2, 3])), { host: "127.0.0.1", port: free, ids: [1, 2, 3], posesFile: join(dir, "poses.json") })); | |
| 136 | if (closed) writeFileSync(join(dir, "open.json"), JSON.stringify({ open: false, note: "Back soon", changedAt: 1 })); | |
| 137 | const lobby = new Lobby({ identityKey: KEY, adminFile: join(dir, "admin.json"), openFile: join(dir, "open.json") }); | |
| 138 | lobbies.push(lobby); | |
| 139 | const cameras = new CameraHub({ seed: [{ name: "Arm cam", source: `http://127.0.0.1:${camera}/stream` }, { name: "Grow cam", source: `http://127.0.0.1:${camera}/stream` }] }); | |
| 140 | const stations = new StationHub(join(dir, "stations.json")); | |
| 141 | relay = await startRelay({ port: 0, simulation: `http://127.0.0.1:${sim}`, remote: parseRemote({ ARM_SERVER: `ws://127.0.0.1:${free}` }), cameras, stations, lobby, link: FAST, env: {} }); | |
| 142 | const base = `http://127.0.0.1:${relay.port}`; | |
| 143 | for (let i = 0; i < 100 && relay.link().mode !== "live"; i++) await new Promise((r) => setTimeout(r, 20)); | |
| 144 | return { base, dir }; | |
| 145 | } | |
| 146 | const dirs: string[] = []; | |
| 147 | const arms: RunningServer[] = []; | |
| 148 | const lobbies: Lobby[] = []; | |
| 149 | afterEach(async () => { | |
| 150 | await Promise.race([Promise.all(arms.map((a) => a.close())), new Promise((r) => setTimeout(r, 500))]); | |
| 151 | for (const l of lobbies) l.close(); | |
| 152 | for (const d of dirs) rmSync(d, { recursive: true, force: true }); | |
| 153 | arms.length = lobbies.length = dirs.length = 0; | |
| 154 | }); | |
| 155 | ||
| 156 | test("/admin/stations: a guest gets 401, a visitor 403, bad JSON 400, a huge body 413; the owner's PUT persists", async () => { | |
| 157 | const { base, dir } = await bootWithArm(); | |
| 158 | const put = (headers: Record<string, string>, body: string) => fetch(`${base}/admin/stations`, { method: "PUT", headers: { ...headers, "content-type": "application/json" }, body }); | |
| 159 | expect((await put({}, "[]")).status).toBe(401); | |
| 160 | expect((await fetch(`${base}/admin/stations`)).status).toBe(401); | |
| 161 | expect((await put(visitor, "[]")).status).toBe(403); | |
| 162 | expect((await put(owner, "{nope")).status).toBe(400); | |
| 163 | expect((await put(owner, JSON.stringify([{ id: "x", name: "X", kind: "pond" }]))).status).toBe(400); | |
| 164 | expect((await put(owner, JSON.stringify([{ ...GROW, name: "x".repeat(40_000) }]))).status).toBe(413); | |
| 165 | expect((await fetch(`${base}/admin/stations`, { method: "POST", headers: owner })).status).toBe(405); | |
| 166 | const saved = await put(owner, JSON.stringify([GROW])); | |
| 167 | expect(saved.status).toBe(200); | |
| 168 | expect(new StationHub(join(dir, "stations.json")).list().map((s) => s.id)).toEqual(["grow"]); | |
| 169 | expect(((await (await fetch(`${base}/admin/stations`, { headers: owner })).json()) as { id: string }[]).map((s) => s.id)).toEqual(["grow"]); | |
| 170 | }); | |
| 171 | ||
| 172 | test("a closed arm is not present on /stations or the home, which still offers sign-in or Open now", async () => { | |
| 173 | const { base } = await bootWithArm(true); | |
| 174 | const list = (await (await fetch(`${base}/stations`)).json()) as { id: string; present: boolean; resting?: string; cameras: unknown[] }[]; | |
| 175 | expect(list.find((s) => s.id === "arm")).toMatchObject({ present: false, cameras: [] }); | |
| 176 | expect(list.find((s) => s.id === "arm")!.resting).toBeTruthy(); | |
| 177 | expect(list.find((s) => s.id === "grow")).toMatchObject({ present: true }); | |
| 178 | const guest = await (await fetch(`${base}/`)).text(); | |
| 179 | expect(guest).toContain('data-station="arm" data-present="false"'); | |
| 180 | expect(guest).toContain("Watching as a guest"); | |
| 181 | expect(guest).toContain("/__forge/signin"); | |
| 182 | expect(guest).toContain('href="#main"'); | |
| 183 | expect(guest).not.toContain("Open now"); | |
| 184 | const boss = await (await fetch(`${base}/`, { headers: owner })).text(); | |
| 185 | expect(boss).toContain("Signed in as <b>trevato</b>"); | |
| 186 | expect(boss).toContain('action="/signout"'); | |
| 187 | expect(boss).toContain("Open now"); | |
| 188 | expect(await (await fetch(`${base}/`, { headers: visitor })).text()).not.toContain("Open now"); | |
| 189 | }); | |
| 190 | ||
| 191 | test("an open arm with the default list: both cards, the arm's camera and the grow box's", async () => { | |
| 192 | const { base } = await bootWithArm(); | |
| 193 | const home = await (await fetch(`${base}/`)).text(); | |
| 194 | expect(home).toContain('data-station="arm" data-present="true"'); | |
| 195 | expect(home).toContain('data-station="grow" data-present="true"'); | |
| 196 | expect(home).toContain("/camera/1/snapshot"); | |
| 197 | expect(home).toContain("/camera/2/snapshot"); | |
| 198 | }); | |
| 199 | ||
| 200 | test("the terminal lists stations and the owner sets them; the door prints refusals", async () => { | |
| 201 | const seen: { url: string; method?: string; body?: unknown }[] = []; | |
| 202 | const fetcher = (async (url: string, init?: RequestInit) => { | |
| 203 | seen.push({ url, method: init?.method, body: init?.body }); | |
| 204 | return init?.method === "PUT" ? Response.json({ error: "only the owner may change the stations" }, { status: 403 }) : Response.json([{ name: "Grow box", kind: "grow", present: true, cameras: [{}] }]); | |
| 205 | }) as unknown as typeof fetch; | |
| 206 | expect(stationLines([{ name: "The arm", kind: "arm", present: false, cameras: [] }])).toEqual(["The arm (arm): not connected, 0 camera(s)"]); | |
| 207 | const printed: string[] = []; | |
| 208 | const door = new Door("ws://127.0.0.1:1/live", {}, (l) => printed.push(l), null, null, null, null, null, null, null, null, null, stationsReader({ PORT: "8102" }, fetcher)); | |
| 209 | door.input("/stations"); | |
| 210 | door.input('/stations set [{"id":"a"}]'); | |
| 211 | door.input("/stations nonsense"); | |
| 212 | await Bun.sleep(50); | |
| 213 | expect(seen.map((s) => `${s.method} ${s.url}`)).toEqual(["GET http://127.0.0.1:8102/stations", "PUT http://127.0.0.1:8102/admin/stations"]); | |
| 214 | expect(seen[1]!.body).toBe('[{"id":"a"}]'); | |
| 215 | expect(printed).toContain("Grow box (grow): present, 1 camera(s)"); | |
| 216 | expect(printed).toContain("error: only the owner may change the stations"); | |
| 217 | expect(printed).toContain("usage: /stations [set <json list>]"); | |
| 218 | }); | |
| 219 | ||
| 220 | test("a present station with no listed camera says so, and points the owner at the setup pages", async () => { | |
| 221 | const bare = { station: { id: "grow", name: "Grow box", kind: "grow" as const, cameras: [9], controls: [] }, present: true, cameras: [] }; | |
| 222 | const guest = renderWorkshop([bare]); | |
| 223 | expect(guest).toContain("No camera set up yet."); | |
| 224 | expect(guest).not.toContain("/admin/camera-list"); | |
| 225 | const boss = renderWorkshop([bare], { signedIn: null, owner: true }); | |
| 226 | expect(boss).toContain('href="/admin/camera-list"'); | |
| 227 | expect(boss).toContain('href="/admin/stations/edit"'); | |
| 228 | }); | |
| 229 | ||
| 230 | test("the buttons use dark text on the accent in dark mode", () => { | |
| 231 | expect(renderWorkshop([])).toContain("--on-accent: #1a1206"); | |
| 232 | }); | |
| 233 | ||
| 234 | test("the arm's card survives a short reconnect", async () => { | |
| 235 | const { base } = await bootWithArm(); | |
| 236 | await Promise.race([arms[0]!.close(), new Promise((r) => setTimeout(r, 500))]); | |
| 237 | for (let i = 0; i < 100 && relay!.link().mode === "live"; i++) await new Promise((r) => setTimeout(r, 20)); | |
| 238 | expect(relay!.link().mode).not.toBe("live"); | |
| 239 | expect(await (await fetch(`${base}/`)).text()).toContain('data-station="arm" data-present="true"'); | |
| 240 | }); |
Software/so101/src/stations.ts added +107
| @@ -0,0 +1,107 @@ | ||
| 1 | /** | |
| 2 | * The workshop's stations: one Pi, several places to look at and act on, each with its cameras and its controls. | |
| 3 | * The list is one file (stations.json under the data dir) the owner edits on /admin/stations. An `arm` station is | |
| 4 | * present only while the arm is (the relay's link tells); a `grow` station has no hardware to enumerate, so it always is. | |
| 5 | */ | |
| 6 | ||
| 7 | import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; | |
| 8 | import { dirname } from "node:path"; | |
| 9 | ||
| 10 | export const KINDS = ["arm", "grow"] as const; | |
| 11 | export type StationKind = (typeof KINDS)[number]; | |
| 12 | export const MAX_STATIONS = 8; | |
| 13 | ||
| 14 | export interface Control { | |
| 15 | label: string; | |
| 16 | /** A path on this app, like /arm. */ | |
| 17 | href: string; | |
| 18 | } | |
| 19 | ||
| 20 | export interface Station { | |
| 21 | id: string; | |
| 22 | name: string; | |
| 23 | kind: StationKind; | |
| 24 | /** Ids from the camera list (src/cameras.ts); the first is the thumbnail. */ | |
| 25 | cameras: number[]; | |
| 26 | controls: Control[]; | |
| 27 | } | |
| 28 | ||
| 29 | export class StationError extends Error {} | |
| 30 | ||
| 31 | const text = (value: unknown, what: string, max: number): string => { | |
| 32 | const s = typeof value === "string" ? value.trim() : ""; | |
| 33 | if (!s || s.length > max || /[\u0000-\u001f\u007f]/.test(s)) throw new StationError(`${what} is 1 to ${max} characters`); | |
| 34 | return s; | |
| 35 | }; | |
| 36 | ||
| 37 | /** The arm alone: the home is then the arm's own page. */ | |
| 38 | export const ARM_ONLY: readonly Station[] = [{ id: "arm", name: "The arm", kind: "arm", cameras: [1], controls: [{ label: "Drive the arm", href: "/arm" }] }]; | |
| 39 | ||
| 40 | /** What the file holds when there is none: the arm and the grow box (its camera is the second one listed; the owner edits both on /admin/stations/edit). */ | |
| 41 | export const WORKSHOP: readonly Station[] = [...ARM_ONLY, { id: "grow", name: "Grow box", kind: "grow", cameras: [2], controls: [] }]; | |
| 42 | ||
| 43 | /** Any JSON → the stations it describes; throws a StationError saying what is wrong. */ | |
| 44 | export function parseStations(input: unknown): Station[] { | |
| 45 | if (!Array.isArray(input)) throw new StationError("stations are a JSON list"); | |
| 46 | if (input.length > MAX_STATIONS) throw new StationError(`at most ${MAX_STATIONS} stations`); | |
| 47 | const seen = new Set<string>(); | |
| 48 | return input.map((raw: unknown) => { | |
| 49 | const s = (raw ?? {}) as Record<string, unknown>; | |
| 50 | const id = typeof s.id === "string" ? s.id.trim() : ""; | |
| 51 | if (!/^[a-z0-9][a-z0-9-]{0,31}$/.test(id)) throw new StationError("a station's id is lowercase letters, digits and dashes, up to 32"); | |
| 52 | if (seen.has(id)) throw new StationError(`two stations are called "${id}"`); | |
| 53 | seen.add(id); | |
| 54 | if (!KINDS.includes(s.kind as StationKind)) throw new StationError(`a station's kind is ${KINDS.join(" or ")}`); | |
| 55 | const cameras = s.cameras ?? []; | |
| 56 | if (!Array.isArray(cameras) || cameras.length > 4 || !cameras.every((c) => Number.isSafeInteger(c) && c > 0)) throw new StationError("a station's cameras are up to 4 camera ids"); | |
| 57 | const controls = s.controls ?? []; | |
| 58 | if (!Array.isArray(controls) || controls.length > 8) throw new StationError("a station has up to 8 controls"); | |
| 59 | return { | |
| 60 | id, | |
| 61 | name: text(s.name, "a station's name", 40), | |
| 62 | kind: s.kind as StationKind, | |
| 63 | cameras: cameras as number[], | |
| 64 | controls: controls.map((c: unknown) => { | |
| 65 | const control = (c ?? {}) as Record<string, unknown>; | |
| 66 | const href = text(control.href, "a control's href", 200); | |
| 67 | if (!/^\/(?!\/)[\w\-./?=&%]*$/.test(href)) throw new StationError("a control's href is a path on this app, like /arm"); | |
| 68 | return { label: text(control.label, "a control's label", 40), href }; | |
| 69 | }), | |
| 70 | }; | |
| 71 | }); | |
| 72 | } | |
| 73 | ||
| 74 | export class StationHub { | |
| 75 | private stations: Station[]; | |
| 76 | ||
| 77 | constructor(private readonly file?: string, seed: readonly Station[] = WORKSHOP) { | |
| 78 | this.stations = [...seed]; | |
| 79 | if (file && existsSync(file)) { | |
| 80 | try { | |
| 81 | this.stations = parseStations(JSON.parse(readFileSync(file, "utf8"))); | |
| 82 | } catch (err) { | |
| 83 | console.error(`stations: ${file} unreadable (${(err as Error).message}); using the default`); | |
| 84 | } | |
| 85 | } | |
| 86 | } | |
| 87 | ||
| 88 | list(): Station[] { | |
| 89 | return this.stations; | |
| 90 | } | |
| 91 | ||
| 92 | /** Replace the whole list; nothing changes when it is invalid. */ | |
| 93 | replace(input: unknown): Station[] { | |
| 94 | this.stations = parseStations(input); | |
| 95 | if (this.file) { | |
| 96 | mkdirSync(dirname(this.file), { recursive: true }); | |
| 97 | writeFileSync(`${this.file}.tmp`, JSON.stringify(this.stations, null, 2)); | |
| 98 | renameSync(`${this.file}.tmp`, this.file); | |
| 99 | } | |
| 100 | return this.stations; | |
| 101 | } | |
| 102 | ||
| 103 | /** The workshop is the home only once there is more than the arm to show. */ | |
| 104 | isWorkshop(): boolean { | |
| 105 | return this.stations.some((s) => s.kind !== "arm"); | |
| 106 | } | |
| 107 | } |
Software/so101/src/terminal.ts +45 −3
| @@ -34,6 +34,7 @@ | ||
| 34 | 34 | * /zero <joint> [deg] admin: make the model read deg (default 0) for that joint at the pose it is in now (POST /settings/joint-zero) |
| 35 | 35 | * /align [joint] admin: list each joint's reference pose and offset; with a joint, record it at its reference (POST /settings/joint-zero) and say Done or record again |
| 36 | 36 | * /health owner or moderator: each open page's link as it last reported it (GET /admin/health, the page's Admin -> Health) |
| 37 | * /stations [set <json>] the workshop's stations (GET /stations); the owner replaces them with a JSON list (PUT /admin/stations) | |
| 37 | 38 | * /versions owner or moderator: the app's and the Pi's commits and the last sync (GET /admin/versions) |
| 38 | 39 | * /trace <session> owner or moderator: a kept session's 5 Hz trace, per joint (GET /precision/trace) |
| 39 | 40 | * /data [hour|day|week|all] owner or moderator: the Data view's overview and worst sessions (GET /data, src/data-lines.ts); default day |
| @@ -150,6 +151,32 @@ export function versionsReader(env: Env, fetcher: typeof fetch = fetch): ReadVer | ||
| 150 | 151 | }; |
| 151 | 152 | } |
| 152 | 153 | |
| 154 | /** What a station read or change answered. */ | |
| 155 | export type ReadStations = (set?: string) => Promise<Seen>; | |
| 156 | ||
| 157 | /** GET the stations, or PUT a new list (the owner's); the server decides who may. */ | |
| 158 | export function stationsReader(env: Env, fetcher: typeof fetch = fetch): ReadStations { | |
| 159 | return async (set) => { | |
| 160 | try { | |
| 161 | const change = set !== undefined; | |
| 162 | const r = await fetcher(`${appUrl(env)}${change ? "/admin/stations" : "/stations"}`, { method: change ? "PUT" : "GET", headers: { ...terminalHeaders(env), ...(change ? { "content-type": "application/json" } : {}) }, ...(change ? { body: set } : {}), signal: AbortSignal.timeout(5000) }); | |
| 163 | const body = (await r.json().catch(() => null)) as unknown; | |
| 164 | if (!r.ok) return { error: plain((body as { error?: unknown } | null)?.error ?? `status ${r.status}`), lines: [] }; | |
| 165 | if (change) return { error: null, lines: ["stations saved"] }; | |
| 166 | return { error: null, lines: stationLines(body) }; | |
| 167 | } catch (err) { | |
| 168 | return { error: plain((err as Error).message), lines: [] }; | |
| 169 | } | |
| 170 | }; | |
| 171 | } | |
| 172 | ||
| 173 | /** One line a station: its name, whether it is there, and its cameras. */ | |
| 174 | export function stationLines(body: unknown): string[] { | |
| 175 | if (!Array.isArray(body)) return []; | |
| 176 | return body.map((s: { name?: unknown; kind?: unknown; present?: unknown; resting?: unknown; cameras?: unknown[] }) => | |
| 177 | plain(`${s.name} (${s.kind}): ${s.present ? "present" : s.resting ?? "not connected"}, ${Array.isArray(s.cameras) ? s.cameras.length : 0} camera(s)`)); | |
| 178 | } | |
| 179 | ||
| 153 | 180 | /** The offsets GET /settings holds and the angles GET /status says the model reads, or why not. */ |
| 154 | 181 | export type Aligned = { error: string | null; offsets: Record<string, number>; modelDeg: Record<string, number | null> }; |
| 155 | 182 | export type ReadAlign = () => Promise<Aligned>; |
| @@ -398,7 +425,7 @@ export function whoami(room: Room): string { | ||
| 398 | 425 | /** The terminal holds no session to end: the forge signs who is at the keyboard afresh on every connection. */ |
| 399 | 426 | 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"; |
| 400 | 427 | |
| 401 | 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>] (owner) · /machine [off] · /who /whoami · /quit"; | |
| 428 | 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>] (owner) · /stations [set <json>] (set: owner) · /machine [off] · /who /whoami · /quit"; | |
| 402 | 429 | |
| 403 | 430 | /** The Machine feed's socket beside the lobby's: same app, same port. */ |
| 404 | 431 | export const machineUrl = (live: string): string => live.replace(/\/live$/, "/machine"); |
| @@ -554,7 +581,7 @@ function target(room: Room, word: string): string | null { | ||
| 554 | 581 | return ID.test(word) ? word : null; |
| 555 | 582 | } |
| 556 | 583 | |
| 557 | export type Typed = { send: Frame } | { post: "/stop" | "/resume" } | { table: "here" | number | { touch: 1 | 2 | 3 } } | { zero: { joint: string; referenceDeg: number } } | { align: string | null } | { print: string } | { lines: string[] } | { machine: boolean } | { health: true } | { precision: true } | { trace: number } | { data: DataAsk } | { servos: true } | { tune: { joint: number; p: number } | null } | { versions: true } | { quit: true } | null; | |
| 584 | export type Typed = { send: Frame } | { post: "/stop" | "/resume" } | { table: "here" | number | { touch: 1 | 2 | 3 } } | { zero: { joint: string; referenceDeg: number } } | { align: string | null } | { print: string } | { lines: string[] } | { machine: boolean } | { health: true } | { precision: true } | { trace: number } | { data: DataAsk } | { servos: true } | { tune: { joint: number; p: number } | null } | { versions: true } | { stations: string | null } | { quit: true } | null; | |
| 558 | 585 | |
| 559 | 586 | /** What a typed line asks for. Validation the server repeats anyway; this only saves a round trip on a typo. */ |
| 560 | 587 | export function typed(room: Room, input: string): Typed { |
| @@ -562,6 +589,11 @@ export function typed(room: Room, input: string): Typed { | ||
| 562 | 589 | if (!line) return null; |
| 563 | 590 | if (!line.startsWith("/")) return { send: { type: "post", text: line } }; |
| 564 | 591 | const [verb = "", arg = "", ...rest] = line.slice(1).split(/\s+/); |
| 592 | if (/^stations$/i.test(verb)) { | |
| 593 | if (!arg) return { stations: null }; | |
| 594 | const set = /^\/stations\s+set\s+(\S[\s\S]*)$/i.exec(line); | |
| 595 | return set ? { stations: set[1]! } : { print: "usage: /stations [set <json list>]" }; | |
| 596 | } | |
| 565 | 597 | if (/^align$/i.test(verb)) { |
| 566 | 598 | if (rest.length || (arg && !REFERENCES.some((r) => r.joint === arg))) return { print: `usage: /align [${REFERENCES.map((r) => r.joint).join("|")}]` }; |
| 567 | 599 | return { align: arg || null }; |
| @@ -692,16 +724,18 @@ export class Door { | ||
| 692 | 724 | private readonly readTuning: ReadTuning | null; |
| 693 | 725 | private readonly readTrace: ReadTrace | null; |
| 694 | 726 | private readonly readVersions: ReadVersions | null; |
| 727 | private readonly readStations: ReadStations | null; | |
| 695 | 728 | private readonly readAlign: ReadAlign | null; |
| 696 | 729 | private readonly readData: ReadData | null; |
| 697 | 730 | |
| 698 | 731 | /** `post` reaches the app's /stop and /resume, `read` its /admin/health, `readCard` its /precision; without them, the door says it cannot. */ |
| 699 | constructor(url: string, headers: Record<string, string>, print: (line: string) => void, post: Post | null = null, read: Read | null = null, readCard: ReadCard | null = null, readServos: ReadServos | null = null, readVersions: ReadVersions | null = null, readAlign: ReadAlign | null = null, readTuning: ReadTuning | null = null, readTrace: ReadTrace | null = null, readData: ReadData | null = null) { | |
| 732 | constructor(url: string, headers: Record<string, string>, print: (line: string) => void, post: Post | null = null, read: Read | null = null, readCard: ReadCard | null = null, readServos: ReadServos | null = null, readVersions: ReadVersions | null = null, readAlign: ReadAlign | null = null, readTuning: ReadTuning | null = null, readTrace: ReadTrace | null = null, readData: ReadData | null = null, readStations: ReadStations | null = null) { | |
| 700 | 733 | this.readData = readData; |
| 701 | 734 | this.readTrace = readTrace; |
| 702 | 735 | this.readAlign = readAlign; |
| 703 | 736 | this.readTuning = readTuning; |
| 704 | 737 | this.readVersions = readVersions; |
| 738 | this.readStations = readStations; | |
| 705 | 739 | this.read = read; |
| 706 | 740 | this.readCard = readCard; |
| 707 | 741 | this.readServos = readServos; |
| @@ -754,6 +788,7 @@ export class Door { | ||
| 754 | 788 | else if ("servos" in t) void this.servos(); |
| 755 | 789 | else if ("tune" in t) void this.tune(t.tune); |
| 756 | 790 | else if ("versions" in t) void this.versions(); |
| 791 | else if ("stations" in t) void this.stations(t.stations); | |
| 757 | 792 | else if ("machine" in t) this.watchMachine(t.machine); |
| 758 | 793 | else this.send(t.send); |
| 759 | 794 | return true; |
| @@ -775,6 +810,13 @@ export class Door { | ||
| 775 | 810 | for (const line of r.error ? [`error: ${r.error}`] : healthLines(r.rows)) this.print(line); |
| 776 | 811 | } |
| 777 | 812 | |
| 813 | /** The server decides who may change them; anyone may list. */ | |
| 814 | private async stations(set: string | null): Promise<void> { | |
| 815 | if (!this.readStations) return this.print("error: stations are not available here"); | |
| 816 | const r = await this.readStations(set ?? undefined); | |
| 817 | for (const line of r.error ? [`error: ${r.error}`] : r.lines) this.print(line); | |
| 818 | } | |
| 819 | ||
| 778 | 820 | /** The server decides who may; a refusal is printed, else the commits and the last sync outcome. */ |
| 779 | 821 | private async versions(): Promise<void> { |
| 780 | 822 | if (!this.readVersions) return this.print("error: versions are not available here"); |
Software/so101/src/tty.ts +2 −2
| @@ -4,7 +4,7 @@ | ||
| 4 | 4 | */ |
| 5 | 5 | |
| 6 | 6 | import { createInterface, type Interface } from "node:readline"; |
| 7 | import { alignReader, cardReader, dataReader, Door, HELP, liveUrl, poster, reader, servosReader, terminalHeaders, traceReader, tuningReader, versionsReader } from "./terminal.ts"; | |
| 7 | import { alignReader, cardReader, dataReader, Door, HELP, liveUrl, poster, reader, servosReader, stationsReader, terminalHeaders, traceReader, tuningReader, versionsReader } from "./terminal.ts"; | |
| 8 | 8 | |
| 9 | 9 | const env = process.env; |
| 10 | 10 | const where = env.FORGE_APP_ENV === "preview" ? `preview of #${env.FORGE_APP_PULL ?? "?"}` : (env.FORGE_APP_ENV ?? "local"); |
| @@ -17,7 +17,7 @@ const print = (line: string) => { | ||
| 17 | 17 | rl?.prompt(true); |
| 18 | 18 | }; |
| 19 | 19 | |
| 20 | const door = new Door(liveUrl(env), terminalHeaders(env), print, poster(env), reader(env), cardReader(env), servosReader(env), versionsReader(env), alignReader(env), tuningReader(env), traceReader(env), dataReader(env)); | |
| 20 | const door = new Door(liveUrl(env), terminalHeaders(env), print, poster(env), reader(env), cardReader(env), servosReader(env), versionsReader(env), alignReader(env), tuningReader(env), traceReader(env), dataReader(env), stationsReader(env)); | |
| 21 | 21 | print(`\x1b[1mSO-101 arm\x1b[0m · ${where} · ${HELP}`); |
| 22 | 22 | try { |
| 23 | 23 | await door.open(() => { |
Software/so101/src/viewer.html +1 −1
| @@ -9,7 +9,7 @@ | ||
| 9 | 9 | <meta name="mobile-web-app-capable" content="yes"> |
| 10 | 10 | <meta name="apple-mobile-web-app-capable" content="yes"> |
| 11 | 11 | <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> |
| 12 | <title>SO-101</title> | |
| 12 | <title>Workshop · the arm</title> | |
| 13 | 13 | <script type="importmap"> |
| 14 | 14 | { "imports": { |
| 15 | 15 | "three": "/vendor/three/build/three.module.js", |
Software/so101/src/workshop-page.ts added +81
| @@ -0,0 +1,81 @@ | ||
| 1 | /** The workshop's home: a card per station, its live camera thumbnail first, then its controls. */ | |
| 2 | ||
| 3 | import type { Station } from "./stations.ts"; | |
| 4 | import { renderSignedIn, SIGNED_IN_STYLE, type SignedIn } from "./signed-in.ts"; | |
| 5 | import { esc } from "./story-markdown.ts"; | |
| 6 | ||
| 7 | export const WORKSHOP_TITLE = "Workshop"; | |
| 8 | ||
| 9 | export interface StationView { | |
| 10 | station: Station; | |
| 11 | present: boolean; | |
| 12 | /** Why a present-less arm rests, when it is closed on purpose or cannot be reached (src/resting-page.ts). */ | |
| 13 | resting?: string; | |
| 14 | /** Cameras of the station that are listed. */ | |
| 15 | cameras: { id: number; name: string }[]; | |
| 16 | } | |
| 17 | ||
| 18 | const STYLE = ` | |
| 19 | :root { color-scheme: dark light; --bg: #0b0d10; --panel: #11161d; --fg: #e8eaed; --dim: #9aa4ae; --line: #2a313a; --accent: #f0883e; --on-accent: #1a1206; --link: #79b8ff; } | |
| 20 | @media (prefers-color-scheme: light) { :root { --bg: #fbfaf7; --panel: #fff; --fg: #1f2328; --dim: #59636e; --line: #d8dee4; --accent: #bc4c00; --on-accent: #fff; --link: #0969da; } } | |
| 21 | body { margin: 0; background: var(--bg); color: var(--fg); font: 16px/1.5 ui-sans-serif, system-ui, sans-serif; } | |
| 22 | main { max-width: 60rem; margin: 0 auto; padding: 1rem; } | |
| 23 | h1 { margin: .5rem 0 1rem; } h2 { margin: .6rem 0; font-size: 1.15rem; } | |
| 24 | .cards { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr)); } | |
| 25 | .card { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: .8rem; } | |
| 26 | .card img { width: 100%; aspect-ratio: 4/3; object-fit: cover; border-radius: 6px; background: #000; display: block; } | |
| 27 | .card p { color: var(--dim); margin: .4rem 0; } | |
| 28 | .top { max-width: 60rem; margin: 0 auto; padding: .5rem 1rem 0; display: flex; justify-content: space-between; gap: 1rem; font-size: .9rem; color: var(--dim); } | |
| 29 | .top a, .card button { min-height: 44px; display: inline-flex; align-items: center; } | |
| 30 | .card button { font: inherit; padding: 0 1rem; border-radius: 999px; border: 0; background: var(--accent); color: var(--on-accent); font-weight: 650; } | |
| 31 | .skip { position: absolute; left: -999px; } .skip:focus { left: .5rem; top: .5rem; background: var(--panel); padding: .5rem; z-index: 1; } | |
| 32 | .controls { display: flex; gap: .5rem; flex-wrap: wrap; margin-top: .6rem; } | |
| 33 | .controls a { display: inline-flex; align-items: center; min-height: 44px; padding: 0 1rem; border-radius: 999px; background: var(--accent); color: var(--on-accent); font-weight: 650; text-decoration: none; } | |
| 34 | a { color: var(--link); } | |
| 35 | .controls a { color: var(--on-accent); } | |
| 36 | a:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } | |
| 37 | `; | |
| 38 | ||
| 39 | /** Who is looking: the identity line's reading (src/signed-in.ts), null for a guest, and whether they own the place. */ | |
| 40 | export interface Looker { | |
| 41 | signedIn: SignedIn | null; | |
| 42 | owner: boolean; | |
| 43 | } | |
| 44 | ||
| 45 | function card({ station, present, resting, cameras }: StationView, looker: Looker): string { | |
| 46 | const head = `<h2>${esc(station.name)}</h2>`; | |
| 47 | if (!present) { | |
| 48 | const reopen = resting && looker.owner ? `<form method="post" action="/admin"><input type="hidden" name="action" value="open"><input type="hidden" name="then" value="home"><button>Open now</button></form>` : ""; | |
| 49 | return `<section class="card" data-station="${esc(station.id)}" data-present="false">${head}<p>${esc(resting ?? `${station.name}: not connected`)}</p>${reopen}</section>`; | |
| 50 | } | |
| 51 | if (!cameras.length) { | |
| 52 | const setup = looker.owner ? ` <a href="/admin/camera-list">Cameras</a> · <a href="/admin/stations/edit">Stations</a>` : ""; | |
| 53 | const controls = station.controls.map((c) => `<a href="${esc(c.href)}">${esc(c.label)}</a>`).join(""); | |
| 54 | return `<section class="card" data-station="${esc(station.id)}" data-present="true" data-cameras="none">${head}<p>No camera set up yet.${setup}</p>${controls ? `<div class="controls">${controls}</div>` : ""}</section>`; | |
| 55 | } | |
| 56 | const thumbs = cameras.map((c, i) => `<img src="/camera/${c.id}/snapshot" alt="${esc(c.name)}"${i ? ' loading="lazy"' : ""} width="640" height="480">`).join(""); | |
| 57 | const controls = station.controls.map((c) => `<a href="${esc(c.href)}">${esc(c.label)}</a>`).join(""); | |
| 58 | return `<section class="card" data-station="${esc(station.id)}" data-present="true">${head}${thumbs}${controls ? `<div class="controls">${controls}</div>` : ""}</section>`; | |
| 59 | } | |
| 60 | ||
| 61 | export function renderWorkshop(views: readonly StationView[], looker: Looker = { signedIn: null, owner: false }): string { | |
| 62 | return `<!doctype html> | |
| 63 | <html lang="en"> | |
| 64 | <head> | |
| 65 | <meta charset="utf-8"> | |
| 66 | <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> | |
| 67 | <title>${WORKSHOP_TITLE}</title> | |
| 68 | <meta name="description" content="One Pi, several stations: the robot arm and the grow box, each with its cameras and controls."> | |
| 69 | <style>${STYLE}${SIGNED_IN_STYLE}</style> | |
| 70 | </head> | |
| 71 | <body> | |
| 72 | <a class="skip" href="#main">Skip to the stations</a> | |
| 73 | <nav class="top" aria-label="Workshop"><span>${renderSignedIn(looker.signedIn, "/")}</span><span><a href="/story">The story</a>${looker.owner ? ' · <a href="/admin">Admin</a>' : ""}</span></nav> | |
| 74 | <main id="main"> | |
| 75 | <h1>${WORKSHOP_TITLE}</h1> | |
| 76 | <div class="cards">${views.map((v) => card(v, looker)).join("\n")}</div> | |
| 77 | </main> | |
| 78 | </body> | |
| 79 | </html> | |
| 80 | `; | |
| 81 | } |
docs/story.md +2
| @@ -6,6 +6,8 @@ It could be you. Open [the arm's page](https://arm--forge-trevato-apps.open-plat | ||
| 6 | 6 | |
| 7 | 7 | <!-- camera --> |
| 8 | 8 | |
| 9 | The arm is one station in a larger workshop: one Raspberry Pi, several stations, each with its own cameras and controls. Next to it sits a grow box of spinach seedlings under a UV light. The workshop's home shows a card for each station that is plugged in, and the arm's card comes back by itself when the arm does. | |
| 10 | ||
| 9 | 11 | This is the story of how that works, how you can build one of your own, and how a crew of AI agents keeps making it better. |
| 10 | 12 | |
| 11 | 13 | ## How it works |