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

Closed opened this
Closed without merging

smith/arm:smith/581-the-workshop-one-pi-several-stations-the → main 5 commits fork

  • Closed without merging. Nothing was applied to main.

Review

Sign in to review this change.
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
4545
4646 `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`.
4747
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
4856 ## The story page: /story
4957
5058 `/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 [LeRobot SO-101 docs](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";
1919 import { FeetechBus } from "../src/bus.ts";
2020 import { FakeBus } from "../src/fake-bus.ts";
2121 import { CameraHub } from "../src/cameras.ts";
22 import { StationHub } from "../src/stations.ts";
2223 import { LeaderCalibrations } from "../src/leader-calibrations.ts";
2324 import { Lobby } from "../src/lobby.ts";
2425 import { Moderator, modelFromEnv } from "../src/moderator.ts";
@@ -73,6 +74,6 @@ try {
7374 }
7475 const facts = new Facts({ file: join(dir, "facts.sqlite") });
7576 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") });
7778 const { mode, host: armHost } = relay.link();
7879 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 {
358358 "Admin · SO-101",
359359 `<header>
360360 <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>
362362 ${p.signedIn ? renderSignedIn(p.signedIn, "/admin") : ""}
363363 </header>
364364 ${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", ()
4646 expect(dry.stdout).toContain("so101 setup (dry run)");
4747 expect(dry.status).toBe(0);
4848 });
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";
88 import { describeError, hex } from "./protocol.ts";
99 import { MAX_FRAME_STEP, MotionError, moveToPose, name as jointName, playTrajectory, recordTrajectory, sleep } from "./motion.ts";
1010 import { streamLeader } from "./leader.ts";
11 import { waitForArm } from "./arm-wait.ts";
1112 import { assertPoseName, parsePoseFile, serializePoseFile, type PoseFile } from "./poses.ts";
1213 import { durationSeconds, largestStep, parseTrajectory, serializeTrajectory } from "./trajectory.ts";
1314 import {
@@ -525,9 +526,11 @@ const COMMANDS: Record<string, Command> = {
525526 }
526527 },
527528
528 serve: (values) => {
529 serve: async (values) => {
529530 const [host, portText] = (values.listen ?? "0.0.0.0:8101").split(":");
530531 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)));
531534 return withBus(values, async (bus) => {
532535 const { startServer } = await import("./server.ts"); // lazily: it reaches bun:sqlite, which Node lacks
533536 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", () => {
149149 expect(await source(base)).toBe("simulated: ARM_SERVER is not set");
150150 const page = await fetch(`${base}/`);
151151 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>");
153153 expect(page.headers.get("x-content-type-options")).toBe("nosniff");
154154 expect(page.headers.get("content-security-policy")).toContain("frame-ancestors 'none'");
155155 // 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";
104104 import { readServos } from "./servo-panel.ts";
105105 import { parseFps, type FeedOptions } from "./camera-feed.ts";
106106 import { CameraError, CameraHub } from "./cameras.ts";
107 import { ARM_ONLY, StationError, StationHub } from "./stations.ts";
108 import { renderWorkshop, type StationView } from "./workshop-page.ts";
107109 import { parseProfile, ProfileError, type CameraProfile } from "./camera-profile.ts";
108110 import { VideoFeed, type VideoSources } from "./video-feed.ts";
109111 import type { VideoCard } from "./video-panel.ts";
@@ -151,6 +153,8 @@ export interface RelayOptions {
151153 remote?: Remote | null;
152154 /** The camera list; by default in memory, seeded with the first camera of `remote`. */
153155 cameras?: CameraHub;
156 /** The workshop's stations; by default in memory, the arm alone. */
157 stations?: StationHub;
154158 /** A setting that could not be used (shown on /link while simulating). */
155159 problem?: string | null;
156160 /** The link's retries and its patience (src/arm-link.ts). */
@@ -195,12 +199,15 @@ export interface RunningRelay {
195199
196200 const SCHEMES: Record<string, "http:" | "https:"> = { "http:": "http:", "https:": "https:", "ws:": "http:", "wss:": "https:" };
197201 const MAX_BODY = 1_000_000;
202 const MAX_STATIONS_BODY = 16_384;
203 const STATION_GRACE_MS = 10_000;
198204 const VIDEO = /^\/camera\/(\d{1,9})\/video$/;
199205 const CAMERA_WS = /^\/camera\/(\d{1,9})\/ws$/;
200206 const CAMERA = /^\/camera\/(\d{1,9})\/(stream|state|snapshot)$/;
201207 const ADMIN_CAMERA = /^\/admin\/cameras(?:\/(\d{1,9})(\/try)?)?$/;
202208 /** The cameras page, with the still-frame module under a name that changes with its content (the arm server's /ui/ takes any). */
203209 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");
204211 const CAMERA_PAGE = readFileSync(new URL("./camera-list.html", import.meta.url), "utf8").replaceAll(
205212 "/ui/__VERSION__/",
206213 `/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),
208215 /** The arm server's camera profiles: the relay asks for them itself, for the owner, and passes nobody's request on. */
209216 const CAMERA_PROFILE = /^\/camera-profile(?:\/|$)/;
210217 /** 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)(?:\/|$)/;
212219 /** The page itself, which the relay writes the first view's facts into. */
213 const PAGE = /^\/(?:index\.html)?$/;
220 const PAGE = /^\/(?:index\.html|arm)?$/;
214221 /** The page's static files: what a first visit downloads, and what gzip is for. */
215222 const STATIC = /^\/(?:index\.html)?$|^\/(?:model|vendor|ui)\//;
216223 const LEADER = "/leader/calibration";
@@ -301,6 +308,7 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> {
301308 const facts = options.facts ?? null;
302309 const edges = new ArmEdges();
303310 const metrics = new LiveMetrics();
311 const stations = options.stations ?? new StationHub(undefined, ARM_ONLY);
304312 const cameras = options.cameras ?? new CameraHub({ feed: { ...options.camera, metrics }, seed: remote ? [{ name: "Camera 1", source: remote.cameras[0]! }] : [] });
305313 const leaders = options.leaders ?? new LeaderCalibrations();
306314 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> {
356364 if (!baseline.recorded() && !tried) void recordBaseline();
357365 },
358366 onChange: (state, previous) => {
367 if (previous === "live") leftLiveAt = Date.now();
359368 if (state === "live") {
360369 console.log(`arm server ${host}: live`);
361370 if (previous === "reconnecting") facts?.event("reconnect");
@@ -378,6 +387,8 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> {
378387 for (const c of inFlight) c.abort(new Error("the arm server was changed"));
379388 connect();
380389 };
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;
381392 const mode = (): Link["mode"] => (remote && armLink ? armLink.state : "simulated");
382393 /** Whether the arm server, not the simulation, is the arm right now: live, or only briefly gone. */
383394 const onArm = () => mode() === "live" || mode() === "reconnecting";
@@ -713,6 +724,41 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> {
713724 res.end(out);
714725 };
715726
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
716762 /**
717763 * Put a profile on so101-camera@<unit>'s camera through the arm server, kept there for the camera's next start unless only
718764 * 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> {
887933 res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
888934 return res.end(CAMERA_PAGE);
889935 }
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 })));
890942 const cameraAdmin = ADMIN_CAMERA.exec(url.pathname);
891943 if (cameraAdmin) return await administer(req, res, cameraAdmin[1] === undefined ? null : Number(cameraAdmin[1]), cameraAdmin[2] !== undefined);
892944 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> {
9641016 }
9651017 }
9661018
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 }
9671024 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));
9691026 if (req.method === "GET" && STATIC.test(url.pathname) && acceptsGzip(req.headers)) return await packed(res, await ask(simulation + path, req, undefined, null, false), path);
9701027 // Resting, nothing reaches an arm but a STOP: not a command, not a read.
9711028 // 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 <!doctype html>
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 @@
3434 * /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)
3535 * /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
3636 * /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)
3738 * /versions owner or moderator: the app's and the Pi's commits and the last sync (GET /admin/versions)
3839 * /trace <session> owner or moderator: a kept session's 5 Hz trace, per joint (GET /precision/trace)
3940 * /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
150151 };
151152 }
152153
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
153180 /** The offsets GET /settings holds and the angles GET /status says the model reads, or why not. */
154181 export type Aligned = { error: string | null; offsets: Record<string, number>; modelDeg: Record<string, number | null> };
155182 export type ReadAlign = () => Promise<Aligned>;
@@ -398,7 +425,7 @@ export function whoami(room: Room): string {
398425 /** The terminal holds no session to end: the forge signs who is at the keyboard afresh on every connection. */
399426 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";
400427
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";
402429
403430 /** The Machine feed's socket beside the lobby's: same app, same port. */
404431 export const machineUrl = (live: string): string => live.replace(/\/live$/, "/machine");
@@ -554,7 +581,7 @@ function target(room: Room, word: string): string | null {
554581 return ID.test(word) ? word : null;
555582 }
556583
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;
558585
559586 /** What a typed line asks for. Validation the server repeats anyway; this only saves a round trip on a typo. */
560587 export function typed(room: Room, input: string): Typed {
@@ -562,6 +589,11 @@ export function typed(room: Room, input: string): Typed {
562589 if (!line) return null;
563590 if (!line.startsWith("/")) return { send: { type: "post", text: line } };
564591 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 }
565597 if (/^align$/i.test(verb)) {
566598 if (rest.length || (arg && !REFERENCES.some((r) => r.joint === arg))) return { print: `usage: /align [${REFERENCES.map((r) => r.joint).join("|")}]` };
567599 return { align: arg || null };
@@ -692,16 +724,18 @@ export class Door {
692724 private readonly readTuning: ReadTuning | null;
693725 private readonly readTrace: ReadTrace | null;
694726 private readonly readVersions: ReadVersions | null;
727 private readonly readStations: ReadStations | null;
695728 private readonly readAlign: ReadAlign | null;
696729 private readonly readData: ReadData | null;
697730
698731 /** `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) {
700733 this.readData = readData;
701734 this.readTrace = readTrace;
702735 this.readAlign = readAlign;
703736 this.readTuning = readTuning;
704737 this.readVersions = readVersions;
738 this.readStations = readStations;
705739 this.read = read;
706740 this.readCard = readCard;
707741 this.readServos = readServos;
@@ -754,6 +788,7 @@ export class Door {
754788 else if ("servos" in t) void this.servos();
755789 else if ("tune" in t) void this.tune(t.tune);
756790 else if ("versions" in t) void this.versions();
791 else if ("stations" in t) void this.stations(t.stations);
757792 else if ("machine" in t) this.watchMachine(t.machine);
758793 else this.send(t.send);
759794 return true;
@@ -775,6 +810,13 @@ export class Door {
775810 for (const line of r.error ? [`error: ${r.error}`] : healthLines(r.rows)) this.print(line);
776811 }
777812
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
778820 /** The server decides who may; a refusal is printed, else the commits and the last sync outcome. */
779821 private async versions(): Promise<void> {
780822 if (!this.readVersions) return this.print("error: versions are not available here");
Software/so101/src/tty.ts +2 −2
@@ -4,7 +4,7 @@
44 */
55
66 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";
88
99 const env = process.env;
1010 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) => {
1717 rl?.prompt(true);
1818 };
1919
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));
2121 print(`\x1b[1mSO-101 arm\x1b[0m · ${where} · ${HELP}`);
2222 try {
2323 await door.open(() => {
Software/so101/src/viewer.html +1 −1
@@ -9,7 +9,7 @@
99 <meta name="mobile-web-app-capable" content="yes">
1010 <meta name="apple-mobile-web-app-capable" content="yes">
1111 <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
12 <title>SO-101</title>
12 <title>Workshop · the arm</title>
1313 <script type="importmap">
1414 { "imports": {
1515 "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
66
77 <!-- camera -->
88
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
911 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.
1012
1113 ## How it works

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs