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

Migrated from github.com/Trevato/SO-ARM100

workbenchthis machine

so101 serve boots with the arm unplugged: /status says "arm: not connected", cameras and the grow box keep serving #617

Merged opened this

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

Merged

smith/arm:smith/615-so101-serve-boots-with-the-arm-unplugged → main 1 commit fork 1 thread · 1 unresolved

  • Merged. smith/615-so101-serve-boots-with-the-arm-unplugged was merged into main as 71dc2a8.

Review

Sign in to review this change.
5 files changed +206 −15
Software/so101/README.md +1 −1
@@ -127,7 +127,7 @@ ignored and said once on the arm server's stderr. The arm server reads it at sta
127127
128128 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.
129129
130 - **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.
130 - **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` runs with the arm unplugged: when the port is missing or cannot be opened it logs `arm not connected (<path>: <reason>)` and keeps serving, so the cameras, `/version` and the grow box stay up and the Pi can still update itself. `GET /status` answers 200 with `arm: "not connected"`; every route that needs the servos (moves, poses, torque, calibration, the drive stream) answers 503 `{"error":"arm not connected"}`, and STOP answers 200. Plugging the arm in needs a restart of the service for now.
131131 - **"Camera can't see the plants."** On the grow box's card, each still is checked in the browser once it has loaded: the part inside the camera's box (set on Tune, `/admin/camera-list`) is counted for blown-out and black pixels (`visibility` in src/ui/still-frame.js), and when the plants cannot be seen the card says so over the foot of the still, with a link to Tune (src/ui/grow-hint.js). The relay never decodes a JPEG, so the terminal and the JSON doors cannot show this hint; they have no pixels to count.
132132 - **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.
133133 - **Live.** `GET /care/events` is a server-sent stream that says `care` after every change to the book, from any door; the page listens with a few lines of script and refreshes itself (not while a field has focus). Without the script the page works the same, on reload. A refused form returns to `/care?error=…` with the message in an alert line.
Software/so101/src/absent-arm.test.ts added +67
@@ -0,0 +1,67 @@
1 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2 import { mkdtempSync, readFileSync } from "node:fs";
3 import { tmpdir } from "node:os";
4 import { join } from "node:path";
5 import WebSocket from "ws";
6 import { ARM_ROUTES, FREE_ROUTES } from "./absent-bus.ts";
7 import { FeetechBus } from "./bus.ts";
8 import { startServer, type RunningServer } from "./server.ts";
9
10 let server: RunningServer;
11 let base: string;
12
13 beforeAll(async () => {
14 const dir = mkdtempSync(join(tmpdir(), "so101-absent-"));
15 server = await startServer(null, { host: "127.0.0.1", port: 0, posesFile: join(dir, "poses.json"), growKey: "k" });
16 base = `http://127.0.0.1:${server.port}`;
17 });
18 afterAll(() => void server.close()); // not awaited: Bun never finishes http.close after a server-closed WebSocket (an E-stop close does the same)
19
20 describe("serve without an arm", () => {
21 test("a missing port is an open error, which the CLI turns into no bus", async () => {
22 await expect(FeetechBus.open("/dev/does-not-exist-ttyACM9")).rejects.toThrow();
23 });
24
25 test("/status is 200 and says the arm is not connected", async () => {
26 const res = await fetch(`${base}/status`);
27 expect(res.status).toBe(200);
28 const body = (await res.json()) as { arm: string; joints: unknown[] };
29 expect(body.arm).toBe("not connected");
30 expect(body.joints).toEqual([]);
31 });
32
33 test("/version and the camera profile answer", async () => {
34 expect((await fetch(`${base}/version`)).status).toBe(200);
35 expect((await fetch(`${base}/camera-profile/1`)).status).toBe(200);
36 expect((await fetch(`${base}/grow/dosing`, { headers: { "x-grow-key": "k" } })).status).toBe(200);
37 });
38
39 test("a move answers 503 and STOP answers 200", async () => {
40 const move = await fetch(`${base}/move`, { method: "POST", body: JSON.stringify({ targets: { "1": 2048 } }) });
41 expect(move.status).toBe(503);
42 expect(await move.json()).toEqual({ error: "arm not connected" });
43 expect((await fetch(`${base}/stop`, { method: "POST", body: "{}" })).status).toBe(200);
44 });
45
46 test("every route in server.ts is listed as an arm route or a free one, and each arm route answers 503", async () => {
47 const source = readFileSync(join(import.meta.dir, "server.ts"), "utf8");
48 const routes = [...source.matchAll(/^ {8}case "((?:GET|POST|PUT) [^"]+)":/gm)].map((m) => m[1]!);
49 expect(routes.length).toBeGreaterThan(40);
50 for (const route of routes) expect(ARM_ROUTES.has(route) !== FREE_ROUTES.has(route), `${route} must be in exactly one list`).toBe(true);
51 for (const route of ARM_ROUTES) {
52 const [method, path] = route.split(" ") as [string, string];
53 const res = await fetch(`${base}${path}`, { method, body: method === "GET" ? undefined : "{}" });
54 expect(res.status, route).toBe(503);
55 expect(await res.json(), route).toEqual({ error: "arm not connected" });
56 }
57 });
58
59 test("the drive stream is closed at once with the reason, and drives nothing", async () => {
60 const closed = await new Promise<{ code: number; reason: string }>((resolve) => {
61 const ws = new WebSocket(`ws://127.0.0.1:${server.port}/follow`);
62 ws.on("close", (code, reason) => (resolve({ code, reason: String(reason) })));
63 ws.on("error", () => {});
64 });
65 expect(closed).toEqual({ code: 1013, reason: "arm not connected" });
66 });
67 });
Software/so101/src/absent-bus.ts added +74
@@ -0,0 +1,74 @@
1 import type { FeetechBus } from "./bus.ts";
2
3 /** What every arm route answers while no arm is plugged in. */
4 export const ARM_ABSENT = "arm not connected";
5
6 /** Thrown by the absent bus: the server answers it 503, and nothing reached a wire. */
7 export class ArmAbsentError extends Error {
8 constructor() {
9 super(ARM_ABSENT);
10 }
11 }
12
13 /** The routes that need the servos. A route in src/server.ts is in this list or in FREE_ROUTES (a test holds the line). */
14 export const ARM_ROUTES: ReadonlySet<string> = new Set([
15 "GET /limits",
16 "GET /servo-params",
17 "PUT /servo-params",
18 "POST /servo-params/factory",
19 "POST /tuning/p",
20 "POST /settings/table-here",
21 "POST /settings/table-touch",
22 "POST /settings/joint-zero",
23 "POST /pose/save",
24 "POST /pose/go",
25 "POST /move",
26 "POST /grip",
27 "POST /torque",
28 "POST /record/start",
29 "POST /record/stop",
30 "POST /play",
31 ]);
32
33 /** The routes that work without an arm: pages, cameras' neighbours, the grow box, files on disk, and the stops (nothing to stop). */
34 export const FREE_ROUTES: ReadonlySet<string> = new Set([
35 "GET /",
36 "GET /index.html",
37 "GET /leader-map.js",
38 "GET /status",
39 "GET /version",
40 "GET /grow",
41 "GET /grow/sensors",
42 "POST /grow/light",
43 "POST /grow/pump",
44 "GET /grow/dosing",
45 "POST /grow/dosing",
46 "GET /probes",
47 "POST /probes/calibrate",
48 "GET /tuning",
49 "GET /poses",
50 "GET /recordings",
51 "GET /calibration",
52 "GET /metrics",
53 "GET /precision",
54 "GET /precision/trace",
55 "POST /precision/snap-back",
56 "GET /settings",
57 "PUT /settings",
58 "GET /model.json",
59 "POST /pose/delete",
60 "POST /recording/delete",
61 "POST /stop",
62 "POST /resume",
63 "POST /estop",
64 "POST /estop/clear",
65 ]);
66
67 /** A bus that is not there: reads and writes fail with ArmAbsentError before anything is sent. */
68 export function absentBus(path: string, baudRate = 1_000_000): FeetechBus {
69 const state: Record<string | symbol, unknown> = { path, baudRate, echo: false, onSyncWrite: null };
70 return new Proxy(state, {
71 get: (target, key) => (key in target ? target[key] : () => Promise.reject(new ArmAbsentError())),
72 set: (target, key, value) => ((target[key] = value), true),

The get trap answers every missing key with a rejecting function, including then. await bus would call it and never settle. Return undefined for then and for symbol keys.

73 }) as unknown as FeetechBus;
74 }
Software/so101/src/cli.ts +25 −11
@@ -9,7 +9,6 @@ import { FeetechBus, range } from "./bus.ts";
99 import { describeError, hex } from "./protocol.ts";
1010 import { MAX_FRAME_STEP, MotionError, moveToPose, name as jointName, playTrajectory, recordTrajectory, sleep } from "./motion.ts";
1111 import { streamLeader } from "./leader.ts";
12 import { waitForArm } from "./arm-wait.ts";
1312 import { assertPoseName, parsePoseFile, serializePoseFile, type PoseFile } from "./poses.ts";
1413 import { durationSeconds, largestStep, parseTrajectory, serializeTrajectory } from "./trajectory.ts";
1514 import {
@@ -530,21 +529,24 @@ const COMMANDS: Record<string, Command> = {
530529 serve: async (values) => {
531530 const [host, portText] = (values.listen ?? "0.0.0.0:8101").split(":");
532531 const port = bounded(integer(portText ?? "8101", "--listen port"), 1, 65535, "--listen port");
533 const waiting = !values.port && !process.env.SO101_PORT;
534 if (waiting) await waitForArm(detectPort, (text) => console.log(text), (ms) => new Promise((r) => setTimeout(r, ms)));
535 return withBus(values, async (bus) => {
536 const { growKeyFile, startServer } = await import("./server.ts"); // lazily: it reaches bun:sqlite, which Node lacks
537 const cameraControl = (unit: number, profile: Parameters<typeof applyProfile>[1]) => applyProfile(cameraDevice(unit), profile);
538 const cameraStill = (unit: number) => grabStill(unit, cameraDevice(unit));
532 const { growKeyFile, startServer } = await import("./server.ts"); // lazily: it reaches bun:sqlite, which Node lacks
533 const bus = await openArm(values);
534 const cameraControl = (unit: number, profile: Parameters<typeof applyProfile>[1]) => applyProfile(cameraDevice(unit), profile);
535 const cameraStill = (unit: number) => grabStill(unit, cameraDevice(unit));
536 try {
539537 const server = await startServer(bus, { host, port, posesFile: values.file ?? "poses.json", modelDir: values.model, gainSyncMs: 2000, cameraControl, cameraStill, lightMs: 15_000, sensorsMs: 60_000 });
540 console.log(`serving ${bus.path} on http://${host}:${server.port} — open it in a browser; the API is listed in src/server.ts`);
538 console.log(`serving ${bus?.path ?? "no arm"} on http://${host}:${server.port} — open it in a browser; the API is listed in src/server.ts`);
541539 console.log(`the grow routes need the key in ${growKeyFile(dirname(resolve(values.file ?? "poses.json")))}: give the hosted app the same in ARM_GROW_KEY`);
542540 console.log("ctrl-c to stop");
543541 await new Promise<void>((resolve) => process.once("SIGINT", () => resolve()));
544542 await server.close();
545 await bus.syncWrite(STS3215.Torque_Enable, [1, 2, 3, 4, 5, 6].map((id) => [id, 0] as const));
546 console.log("stopped; torque released");
547 });
543 if (bus) {
544 await bus.syncWrite(STS3215.Torque_Enable, [1, 2, 3, 4, 5, 6].map((id) => [id, 0] as const));
545 console.log("stopped; torque released");
546 } else console.log("stopped");
547 } finally {
548 await bus?.close();
549 }
548550 },
549551
550552 "camera-profile": async (values, [unitText]) => {
@@ -632,6 +634,18 @@ async function withBus<T>(values: Values, fn: (bus: FeetechBus) => Promise<T>):
632634 }
633635 }
634636
637 /** The arm's bus for `serve`, or null (one logged line) when the port is missing or cannot be opened: the cameras and the grow box serve on. */
638 async function openArm(values: Values): Promise<FeetechBus | null> {
639 let path = values.port ?? process.env.SO101_PORT;
640 try {
641 path ??= await detectPort();
642 return await FeetechBus.open(path, { baudRate: values.baud ? integer(values.baud, "--baud") : 1_000_000 });
643 } catch (err) {
644 console.log(`arm not connected (${path ?? "no port"}: ${message(err)})`);
645 return null;
646 }
647 }
648
635649 async function detectPort(): Promise<string> {
636650 const candidates = (await SerialPort.list())
637651 .map((port) => port.path)
Software/so101/src/server.ts +39 −3
@@ -101,6 +101,7 @@ import { StillError } from "./camera-still.ts";
101101 import { Clock, CommandGate, HELD_CLOSE, isRelease, sentAtOf } from "./command-gate.ts";
102102 import { FLOOD_CLOSE, FrameRate, frameBytes, MAX_FRAME_BYTES, TOO_BIG_CLOSE } from "./frame-limit.ts";
103103 import type { FeetechBus } from "./bus.ts";
104 import { ARM_ABSENT, ARM_ROUTES, ArmAbsentError, absentBus } from "./absent-bus.ts";
104105 import { ArmModel, gripperTicksToRadians, ticksToRadians, type Vec3, type Violation } from "./kinematics.ts";
105106 import { fitTablePlane, MAX_TILT_DEG } from "./table-plane.ts";
106107 import { clamp, type Frame } from "./mapping.ts";
@@ -226,6 +227,8 @@ export function actor(body: Record<string, unknown>): string {
226227 const CLEAN_CLOSE = new Set([1000, 1005]);
227228 /** The close code for a stream ended by an emergency stop. */
228229 export const ESTOP_CLOSE = 4423;
230 /** The close code for a stream opened with no arm plugged in: try again later. */
231 const NO_ARM_CLOSE = 1013;
229232
230233 class HttpError extends Error {
231234 readonly status: number;
@@ -248,7 +251,10 @@ const VENDORED = new Set(["three", "urdf-loader"]);
248251 const isUiModule = (name: string) => /^[a-z][a-z0-9-]*\.(js|css)$/.test(name);
249252 const MIME: Record<string, string> = { ".js": "text/javascript", ".css": "text/css", ".mjs": "text/javascript", ".json": "application/json", ".urdf": "application/xml", ".stl": "model/stl", ".dae": "model/vnd.collada+xml", ".png": "image/png", ".jpg": "image/jpeg" };
250253
251 export async function startServer(bus: FeetechBus, options: ServerOptions = {}): Promise<RunningServer> {
254 /** `arm` is null when the serial port could not be opened: the server still serves everything that is not the arm. */
255 export async function startServer(arm: FeetechBus | null, options: ServerOptions = {}): Promise<RunningServer> {
256 const hasArm = arm !== null;
257 const bus = arm ?? absentBus("(no arm)");
252258 const ids = options.ids ?? [1, 2, 3, 4, 5, 6];
253259 const posesFile = resolve(options.posesFile ?? "poses.json");
254260 const dataDir = dirname(posesFile);
@@ -291,7 +297,7 @@ export async function startServer(bus: FeetechBus, options: ServerOptions = {}):
291297 let held = false;
292298
293299 const limits = new Map<number, { min: number; max: number }>();
294 for (const id of ids) {
300 for (const id of hasArm ? ids : []) {
295301 limits.set(id, {
296302 min: await bus.read(id, STS3215.Min_Position_Limit),
297303 max: await bus.read(id, STS3215.Max_Position_Limit),
@@ -462,6 +468,7 @@ export async function startServer(bus: FeetechBus, options: ServerOptions = {}):
462468 const stopped = (e: EStop) => `emergency stop (${e.by}, ${e.at.slice(0, 19).replace("T", " ")} UTC): the arm holds until an admin clears it`;
463469 /** Torque on, goal where each joint is: the arm stays put and resists. */
464470 const holdHere = async () => {
471 if (!hasArm) return;
465472 const positions = await bus.syncRead(ids, STS3215.Present_Position);
466473 const here = ids.filter((id) => positions.has(id));
467474 await bus.syncWrite(STS3215.Goal_Position, here.map((id) => [id, positions.get(id)!] as const));
@@ -706,6 +713,7 @@ export async function startServer(bus: FeetechBus, options: ServerOptions = {}):
706713 }
707714
708715 const status = async () => {
716 if (!hasArm) return absentStatus();
709717 // Present_Position (56) through Present_Temperature (63) is one block: one round trip, not one per register.
710718 const block = await bus.syncReadBlock(ids, PRESENT);
711719 const positions = new Map([...block].map(([id, [p]]) => [id, p!]));
@@ -763,6 +771,28 @@ export async function startServer(bus: FeetechBus, options: ServerOptions = {}):
763771 };
764772 };
765773
774 /** The status without an arm: no joints, and everything the server itself knows. */
775 const absentStatus = () => ({
776 arm: "not connected",
777 joints: [],
778 busy: driver !== null,
779 driving: driver,
780 recording: recording?.name ?? null,
781 zone: model ? settings.zone : "off",
782 violation: null,
783 lowestMm: null,
784 tableZ: model ? model.tableZ(zoneSettings()) : null,
785 blocked: null,
786 jammed: [],
787 jams: [],
788 stream: streamStats,
789 deadman: held,
790 estop,
791 held: gate.held,
792 machine: { run, seq: machine.seq },
793 motion: speedLimits(),
794 });
795
766796 /** A supervised move to explicit targets; the one way joints move over HTTP. */
767797 const move = async (targetIds: number[], requested: number[], body: Record<string, unknown>, source: string) => {
768798 const controller = claim("move");
@@ -949,6 +979,7 @@ export async function startServer(bus: FeetechBus, options: ServerOptions = {}):
949979 writeAtomic(join(calibrationDir, `${kind}.json`), JSON.stringify({ ...body, saved: new Date().toISOString() }, null, 1));
950980 return json(res, 200, { stored: kind });
951981 }
982 if (!hasArm && ARM_ROUTES.has(route)) throw new HttpError(503, ARM_ABSENT);
952983 switch (route) {
953984 case "GET /":
954985 case "GET /index.html":
@@ -1388,6 +1419,7 @@ export async function startServer(bus: FeetechBus, options: ServerOptions = {}):
13881419 if (err.status === 413) res.setHeader("connection", "close"); // the rest of the body is unread: end the connection after the answer
13891420 return json(res, err.status, { error: err.message });
13901421 }
1422 if (err instanceof ArmAbsentError) return json(res, 503, { error: ARM_ABSENT });
13911423 json(res, 500, { error: err instanceof Error ? err.message : String(err) });
13921424 }
13931425 });
@@ -1417,6 +1449,10 @@ export async function startServer(bus: FeetechBus, options: ServerOptions = {}):
14171449 return c;
14181450 };
14191451 wss.on("connection", (ws: WebSocket, req?: IncomingMessage) => {
1452 if (!hasArm) {
1453 ws.close(NO_ARM_CLOSE, ARM_ABSENT); // 1013, try again later: nothing is driven
1454 return;
1455 }
14201456 if (estop) {
14211457 ws.close(ESTOP_CLOSE, stopped(estop));
14221458 return;
@@ -1837,7 +1873,7 @@ export async function startServer(bus: FeetechBus, options: ServerOptions = {}):
18371873 });
18381874
18391875 await new Promise<void>((resolve) => http.listen(options.port ?? 8101, options.host ?? "0.0.0.0", resolve));
1840 const gainTimer = options.gainSyncMs ? setInterval(() => void gainSync.tick(), options.gainSyncMs) : undefined;
1876 const gainTimer = hasArm && options.gainSyncMs ? setInterval(() => void gainSync.tick(), options.gainSyncMs) : undefined;
18411877 if (gainTimer) void gainSync.tick();
18421878 const lightTimer = options.lightMs ? setInterval(() => DEVICES.forEach((d) => void lights[d].tick()), options.lightMs) : undefined;
18431879 if (options.lightMs) DEVICES.forEach((d) => void lights[d].boot());

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs