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

Migrated from github.com/Trevato/SO-ARM100

workbenchthis machine

so101 serve picks the arm up when it is plugged back in, and lets it go when it is unplugged, without a restart #622

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/616-so101-serve-picks-the-arm-up-when-it-is → main 5 commits fork 4 threads · 4 unresolved

  • Merged. smith/616-so101-serve-picks-the-arm-up-when-it-is was merged into main as fbdacf3.

Review

Sign in to review this change.
13 files changed +339 −128
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 (its cameras still show), 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>); trying again every 5 s` 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. The port is tried again every 5 s (src/arm-plug.ts): when the arm answers, the server starts over with it and logs `arm connected: serving <path>`, with no restart of the service.
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 (its cameras still show), 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>); trying again every 5 s` 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. The port is tried again every 5 s: when the arm answers, the real bus takes over without a restart (the cameras never blink) and the server logs `arm connected on <path>`. If the port fails mid-run, whatever drives stops (failed, never resumed), open drive streams close with 1013, `/status` goes back to "not connected" and the retries start again (the log line is `arm not connected (its port failed); trying again every 5 s`). A recording under way when the port fails ends there: stopping it saves what was sampled.
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/bench/real-probe.ts +4 −1
@@ -63,6 +63,7 @@ async function run(url: string, ws: WebSocket, joint: Joint, target: number, ms:
6363 ws.on("close", (code) => (closed = code));
6464 ws.send(JSON.stringify({ [joint.id]: target }));
6565 const t0 = performance.now();
66 let near = 0;
6667 while (performance.now() - t0 < ms) {
6768 await sleep(SAMPLE_MS);
6869 const s = await status(url);
@@ -71,7 +72,9 @@ async function run(url: string, ws: WebSocket, joint: Joint, target: number, ms:
7172 const pos = s.joints.find((j) => j.id === joint.id)?.position;
7273 if (typeof pos !== "number") throw new Error(`joint ${joint.id} has no position`);
7374 onSample?.(pos, performance.now() - t0);
74 if (!onSample && Math.abs(pos - target) <= ARRIVE_TICKS) return;
75 // Two samples in reach, not one: a joint that was moving out when the target changed is still in reach as it turns.
76 near = Math.abs(pos - target) <= ARRIVE_TICKS ? near + 1 : 0;
77 if (!onSample && near >= 2) return;
7578 }
7679 }
7780
Software/so101/src/arm-plug.test.ts +45 −34
@@ -3,56 +3,67 @@ import { mkdtempSync, rmSync } from "node:fs";
33 import { tmpdir } from "node:os";
44 import { createServer } from "node:net";
55 import { join } from "node:path";
6 import { retryArm } from "./arm-plug.ts";
7
8 const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
9
10 test("retryArm tries again until the arm answers, hands it over once, and stops trying", async () => {
11 let tries = 0;
12 const got: string[] = [];
13 retryArm(async () => (++tries < 3 ? null : "bus"), (bus) => void got.push(bus), 10);
14 await sleep(100);
15 expect(got).toEqual(["bus"]);
16 expect(tries).toBe(3);
17 });
18
19 test("retryArm counts a thrown open as not there, and its stop stops it", async () => {
20 let tries = 0;
21 const stop = retryArm(async () => (++tries, Promise.reject(new Error("ENOENT"))), () => {}, 10);
22 await sleep(45);
23 stop();
24 const seen = tries;
25 expect(seen).toBeGreaterThan(1);
26 await sleep(40);
27 expect(tries).toBe(seen);
28 });
6 import { releaseTorque } from "./cli.ts";
7 import { FeetechBus } from "./bus.ts";
8 import { FakeBus } from "./fake-bus.ts";
299
3010 const dir = mkdtempSync(join(tmpdir(), "so101-plug-"));
3111 afterAll(() => rmSync(dir, { recursive: true, force: true }));
3212
33 test("`so101 serve` with SO101_PORT at a missing path serves /status 200, arm: not connected, and says it will try again", async () => {
13 /** `so101 serve` with SO101_PORT at a missing path, until it says where it listens. */
14 async function serveWithoutArm() {
3415 const free = createServer();
3516 await new Promise<void>((r) => free.listen(0, "127.0.0.1", r));
3617 const listen = (free.address() as { port: number }).port;
3718 await new Promise((r) => free.close(r));
3819 const script = `import { main } from ${JSON.stringify(join(import.meta.dir, "cli.ts"))}; await main(["serve", "--listen", "127.0.0.1:${listen}", "--file", ${JSON.stringify(join(dir, "poses.json"))}]);`;
3920 const child = Bun.spawn(["bun", "-e", script], { env: { ...process.env, SO101_PORT: "/dev/does-not-exist-ttyACM9" }, stdout: "pipe", stderr: "pipe" });
40 try {
41 const reader = child.stdout.getReader();
42 let out = "";
43 while (!/on http:\/\/127\.0\.0\.1:(\d+)/.test(out)) {
21 const reader = child.stdout.getReader();
22 let out = "";
23 const read = async (until: RegExp) => {
24 while (!until.test(out)) {
4425 const { value, done } = await reader.read();
4526 if (done) throw new Error(`serve exited: ${out}${await new Response(child.stderr).text()}`);
4627 out += new TextDecoder().decode(value);
4728 }
48 expect(out).toContain("arm not connected (/dev/does-not-exist-ttyACM9");
49 expect(out).toContain("trying again every 5 s");
50 const port = /on http:\/\/127\.0\.0\.1:(\d+)/.exec(out)![1];
51 const res = await fetch(`http://127.0.0.1:${port}/status`);
29 return out;
30 };
31 await read(/on http:\/\/127\.0\.0\.1:(\d+)/);
32 return { child, read, port: /on http:\/\/127\.0\.0\.1:(\d+)/.exec(out)![1]!, out: () => out };
33 }
34
35 test("`so101 serve` with SO101_PORT at a missing path serves /status 200, arm: not connected, and says it will try again", async () => {
36 const s = await serveWithoutArm();
37 try {
38 expect(s.out()).toContain("arm not connected (/dev/does-not-exist-ttyACM9");
39 expect(s.out()).toContain("trying again every 5 s");
40 const res = await fetch(`http://127.0.0.1:${s.port}/status`);
5241 expect(res.status).toBe(200);
5342 expect(((await res.json()) as { arm: string }).arm).toBe("not connected");
5443 } finally {
55 child.kill();
56 await child.exited;
44 s.child.kill();
45 await s.child.exited;
5746 }
5847 }, 20_000);
48
49 test("`so101 serve` with no arm stops on ctrl-c with a plain `stopped`", async () => {
50 const s = await serveWithoutArm();
51 try {
52 await s.read(/ctrl-c to stop/);
53 s.child.kill("SIGINT");
54 await s.read(/stopped\n/);
55 expect(await s.child.exited).toBe(0);
56 expect(s.out()).not.toContain("torque released");
57 } finally {
58 s.child.kill();
59 }
60 }, 20_000);
61
62 test("the shutdown line says torque was released, or that the arm's port was not answering", async () => {
63 const fake = new FakeBus([1, 2, 3, 4, 5, 6]);
64 const bus = new FeetechBus(fake);
65 expect(await releaseTorque(bus)).toBe("stopped; torque released");
66 expect(fake.writes.filter((w) => w.address === 40 && w.bytes[0] === 0).length).toBe(6);
67 fake.fail(new Error("device unplugged"));
68 expect(await releaseTorque(bus)).toBe("stopped; the arm's port was not answering");
69 });
Software/so101/src/arm-plug.ts deleted −25
@@ -1,25 +0,0 @@
1 /** `so101 serve` with the arm unplugged serves on without it and tries the port again every few seconds (hot-plug). */
2
3 /** How often an unplugged arm's port is tried again, ms. */
4 export const REPLUG_MS = 5000;
5
6 /**
7 * Tries `open` every `everyMs` until it gives a bus, then hands it to `plugged` once and stops.
8 * `open` answers null while the arm is not there; tries never overlap. Returns the stop.
9 */
10 export function retryArm<B>(open: () => Promise<B | null>, plugged: (bus: B) => void | Promise<void>, everyMs = REPLUG_MS): () => void {
11 let stopped = false;
12 let timer: ReturnType<typeof setTimeout> | undefined;
13 const attempt = async () => {
14 const bus = await open().catch(() => null);
15 if (stopped) return;
16 if (bus === null) return void (timer = setTimeout(attempt, everyMs));
17 stopped = true;
18 await plugged(bus);
19 };
20 timer = setTimeout(attempt, everyMs);
21 return () => {
22 stopped = true;
23 clearTimeout(timer);
24 };
25 }
Software/so101/src/arm-replug.test.ts added +136
@@ -0,0 +1,136 @@
1 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2 import WebSocket from "ws";
3 import { mkdtempSync } from "node:fs";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { FeetechBus } from "./bus.ts";
7 import { FakeBus } from "./fake-bus.ts";
8 import { startServer, type RunningServer } from "./server.ts";
9
10 let server: RunningServer;
11 let base: string;
12 let plugged: FakeBus | null = null;
13 let opened = 0;
14
15 beforeAll(async () => {
16 const dir = mkdtempSync(join(tmpdir(), "so101-replug-"));
17 const reopen = async () => {
18 opened++;
19 return plugged ? new FeetechBus(plugged) : null;
20 };
21 server = await startServer(null, { host: "127.0.0.1", port: 0, posesFile: join(dir, "poses.json"), reopen, retryMs: 20 });
22 base = `http://127.0.0.1:${server.port}`;
23 });
24 afterAll(() => void server.close());
25
26 const arm = async () => ((await (await fetch(`${base}/status`)).json()) as { arm: string }).arm;
27 const until = async (want: (arm: string) => boolean) => {
28 for (let i = 0; i < 100; i++) {
29 if (want(await arm())) return;
30 await Bun.sleep(20);
31 }
32 throw new Error(`arm still ${await arm()}`);
33 };
34 const move = () => fetch(`${base}/move`, { method: "POST", body: JSON.stringify({ targets: { "1": 2048 } }) });
35
36 test("booted without the arm, plugging it in brings it back; losing its port lets it go again; plugging in again returns it", async () => {
37 expect(await arm()).toBe("not connected");
38 expect((await move()).status).toBe(503);
39 await Bun.sleep(60);
40 expect(opened).toBeGreaterThan(1); // retried while absent
41
42 plugged = new FakeBus([1, 2, 3, 4, 5, 6]);
43 await until((a) => a !== "not connected");
44 expect((await move()).status).toBe(200);
45 const first = plugged;
46
47 plugged = null;
48 first.fail(new Error("device unplugged"));
49 await until((a) => a === "not connected");
50 expect((await move()).status).toBe(503);
51 expect((await fetch(`${base}/version`)).status).toBe(200);
52 expect((await fetch(`${base}/stop`, { method: "POST", body: "{}" })).status).toBe(200);
53
54 plugged = new FakeBus([1, 2, 3, 4, 5, 6]);
55 await until((a) => a !== "not connected");
56 await fetch(`${base}/resume`, { method: "POST", body: "{}" }); // the stop above holds the arm
57 expect((await move()).status).toBe(200);
58 });
59
60 describe("a port that fails with the arm in use", () => {
61 let server: RunningServer;
62 let base: string;
63 let live: FakeBus;
64 let next: FakeBus | null = null;
65
66 beforeAll(async () => {
67 const dir = mkdtempSync(join(tmpdir(), "so101-replug-live-"));
68 live = new FakeBus([1, 2, 3, 4, 5, 6], { speedTicksS: 50 }); // slow: a long move is still under way when the cable goes
69 server = await startServer(new FeetechBus(live), { host: "127.0.0.1", port: 0, posesFile: join(dir, "poses.json"), reopen: async () => (next ? new FeetechBus(next) : null), retryMs: 20 });
70 base = `http://127.0.0.1:${server.port}`;
71 });
72 afterAll(() => void server.close());
73
74 const status = async () => (await (await fetch(`${base}/status`)).json()) as { arm: string; driving: string | null };
75 const settle = async (want: (s: { arm: string; driving: string | null }) => boolean) => {
76 for (let i = 0; i < 100; i++) {
77 if (want(await status())) return;
78 await Bun.sleep(20);
79 }
80 throw new Error(`never settled: ${JSON.stringify(await status())}`);
81 };
82
83 test("the move in flight fails and the drive stream closes with 1013; an E-stop made while the arm is gone holds it on return", async () => {
84 const started = Date.now();
85 const moving = fetch(`${base}/move`, { method: "POST", body: JSON.stringify({ targets: { "1": 3500 }, speed: 100 }) });
86 await settle((s) => s.driving === "move");
87 live.fail(new Error("device unplugged"));
88 const res = await moving;
89 expect(res.status).not.toBe(200); // failed, never resumed
90 expect(Date.now() - started).toBeLessThan(4000); // not the ~25 s the move would have taken
91 await settle((s) => s.arm === "not connected" && s.driving === null);
92
93 expect((await fetch(`${base}/estop`, { method: "POST", body: "{}" })).status).toBe(200);
94 next = new FakeBus([1, 2, 3, 4, 5, 6]);
95 await settle((s) => s.arm !== "not connected");
96 for (let i = 0; i < 50 && !next.writes.some((w) => w.address === 40 && w.bytes[0] === 1); i++) await Bun.sleep(20);
97 expect(next.writes.some((w) => w.address === 40 && w.bytes[0] === 1)).toBe(true); // torque on: it holds where it stands
98 expect(next.writes.some((w) => w.address === 42)).toBe(true); // goal set to the present position
99 await fetch(`${base}/estop/clear`, { method: "POST", body: "{}" });
100
101 const ws = new WebSocket(`ws://127.0.0.1:${server.port}/follow`);
102 await new Promise((r) => ws.once("open", r));
103 const closed = new Promise<number>((resolve) => ws.once("close", resolve));
104 ws.send(JSON.stringify({ "1": 2100 }));
105 await settle((s) => s.driving === "stream");
106 next.fail(new Error("device unplugged"));
107 expect(await closed).toBe(1013);
108 await settle((s) => s.arm === "not connected" && s.driving === null);
109 });
110 });
111
112 describe("a recording when the cable goes", () => {
113 test("is ended by the loss itself, not by the next read failing: what was sampled saves, and the arm is free again", async () => {
114 const dir = mkdtempSync(join(tmpdir(), "so101-replug-rec-"));
115 const live = new FakeBus([1, 2, 3, 4, 5, 6]);
116 let back: FakeBus | null = null;
117 const server = await startServer(new FeetechBus(live), { host: "127.0.0.1", port: 0, posesFile: join(dir, "poses.json"), reopen: async () => (back ? new FeetechBus(back) : null), retryMs: 20 });
118 const base = `http://127.0.0.1:${server.port}`;
119 const post = (path: string, body: unknown) => fetch(`${base}${path}`, { method: "POST", body: JSON.stringify(body) });
120 try {
121 // 1 Hz: the loop sleeps nearly all the time, so the cable goes while it waits and no read is in flight.
122 expect((await post("/record/start", { name: "cut", hz: 1 })).status).toBe(200);
123 await Bun.sleep(300);
124 live.fail(new Error("device unplugged"));
125 for (let i = 0; i < 100 && ((await (await fetch(`${base}/status`)).json()) as { arm: string }).arm !== "not connected"; i++) await Bun.sleep(20);
126 await Bun.sleep(1200); // past the next sample: only the abort stops the loop before it reads the dead bus
127 back = new FakeBus([1, 2, 3, 4, 5, 6]); // routes that need the arm wait for it to come back
128 for (let i = 0; i < 100 && ((await (await fetch(`${base}/status`)).json()) as { arm: string }).arm === "not connected"; i++) await Bun.sleep(20);
129 const stopped = await post("/record/stop", {});
130 expect(await stopped.json()).toMatchObject({ saved: "cut", frames: 1 });
131 expect(((await (await fetch(`${base}/status`)).json()) as { driving: string | null }).driving).toBeNull();
132 } finally {
133 await server.close();
134 }
135 });
136 });
Software/so101/src/arm-wait.ts deleted −18
@@ -1,18 +0,0 @@
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/bus.test.ts +13
@@ -131,3 +131,16 @@ describe("FeetechBus syncWrite ordering for playback", () => {
131131 ]);
132132 });
133133 });
134
135 describe("a failing port", () => {
136 test("onFail fires once with the transport's error, and the bus answers nothing after it", async () => {
137 const fake = new FakeBus([1]);
138 const bus = new FeetechBus(fake);
139 const told: Error[] = [];
140 bus.onFail = (error) => told.push(error);
141 expect(await bus.ping(1)).not.toBeNull();
142 fake.fail(new Error("device unplugged"));
143 expect(told.map((e) => e.message)).toEqual(["device unplugged"]);
144 await expect(bus.ping(1)).rejects.toThrow("device unplugged");
145 });
146 });
Software/so101/src/bus.ts +3
@@ -81,6 +81,8 @@ export class FeetechBus {
8181 #echo: boolean;
8282 #echoPending = false;
8383 #failure: Error | null = null;
84 /** Told once the port has failed (unplugged): the bus is dead from then on and the owner should let it go. */
85 onFail: ((error: Error) => void) | null = null;
outdated

This comment landed between onSyncWrite's doc comment and onSyncWrite, so that field has lost its doc and two comments stack. Move the new one down to sit directly above onFail.

8486 /** Told of every sync write before it goes on the wire: what the arm server shows as "sent" (src/machine-log.ts). */
8587 onSyncWrite: ((register: Register, values: ReadonlyArray<readonly [number, number]>) => void) | null = null;
8688
@@ -94,6 +96,7 @@ export class FeetechBus {
9496 // The port is gone (unplugged mid-write, say): fail what is in flight and everything after it.
9597 this.#failure = error;
9698 this.#pending?.fail(error);
99 this.onFail?.(error);
97100 });
98101 }
99102
Software/so101/src/cli.ts +17 −21
@@ -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 { REPLUG_MS, retryArm } from "./arm-plug.ts";
1312 import { assertPoseName, parsePoseFile, serializePoseFile, type PoseFile } from "./poses.ts";
1413 import { durationSeconds, largestStep, parseTrajectory, serializeTrajectory } from "./trajectory.ts";
1514 import {
@@ -531,33 +530,24 @@ const COMMANDS: Record<string, Command> = {
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");
533532 const { growKeyFile, startServer } = await import("./server.ts"); // lazily: it reaches bun:sqlite, which Node lacks
534 let bus = await openArm(values, true);
533 let bus = await openArm(values, true); // the newest bus opened, live or lost: what is released at the end
535534 const cameraControl = (unit: number, profile: Parameters<typeof applyProfile>[1]) => applyProfile(cameraDevice(unit), profile);
536535 const cameraStill = (unit: number) => grabStill(unit, cameraDevice(unit));
537 const options = { host, port, posesFile: values.file ?? "poses.json", modelDir: values.model, gainSyncMs: 2000, cameraControl, cameraStill, lightMs: 15_000, sensorsMs: 60_000 };
538 let server = await startServer(bus, options);
539 // Unplugged, the port is tried again every 5 s; when the arm answers, the server starts over with it (the cameras blink once).
540 const stopRetry = bus
541 ? () => {}
542 : retryArm(
543 () => openArm(values, false),
544 async (found) => {
545 await server.close();
546 bus = found;
547 server = await startServer(found, options);
548 console.log(`arm connected: serving ${found.path}`);
549 },
550 );
536 // The server swaps the bus in when the port opens (tried every 5 s) and lets it go when the port fails.
537 const reopen = async () => {
538 const found = await openArm(values, false);
539 if (found) bus = found;
540 return found;
541 };
542 const server = await startServer(bus, { host, port, posesFile: values.file ?? "poses.json", modelDir: values.model, reopen, gainSyncMs: 2000, cameraControl, cameraStill, lightMs: 15_000, sensorsMs: 60_000 });
551543 try {
552544 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`);
553545 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`);
554546 console.log("ctrl-c to stop");
555547 await new Promise<void>((resolve) => process.once("SIGINT", () => resolve()));
556 stopRetry();
557548 await server.close();
558549 if (bus) {
559 await bus.syncWrite(STS3215.Torque_Enable, [1, 2, 3, 4, 5, 6].map((id) => [id, 0] as const));
560 console.log("stopped; torque released");
550 console.log(await releaseTorque(bus));
561551 } else console.log("stopped");
562552 } finally {
563553 await bus?.close();
@@ -649,14 +639,20 @@ async function withBus<T>(values: Values, fn: (bus: FeetechBus) => Promise<T>):
649639 }
650640 }
651641
652 /** The arm's bus for `serve`, or null (one logged line when `say`) when the port is missing or cannot be opened: the cameras and the grow box serve on. */
642 /** The line `serve` ends with: torque released on all six servos, or that the port was not answering (a lost port cannot be written). */
643 export async function releaseTorque(bus: FeetechBus): Promise<string> {
644 const released = await bus.syncWrite(STS3215.Torque_Enable, [1, 2, 3, 4, 5, 6].map((id) => [id, 0] as const)).then(() => true, () => false);
645 return released ? "stopped; torque released" : "stopped; the arm's port was not answering";
646 }
647
648 /** The arm's bus for `serve`, or null (one logged line, unless `say` is off) when the port is missing or cannot be opened: the cameras and the grow box serve on. */
653649 async function openArm(values: Values, say: boolean): Promise<FeetechBus | null> {
654650 let path = values.port ?? process.env.SO101_PORT;
655651 try {
656652 path ??= await detectPort();
657653 return await FeetechBus.open(path, { baudRate: values.baud ? integer(values.baud, "--baud") : 1_000_000 });
658654 } catch (err) {
659 if (say) console.log(`arm not connected (${path ?? "no port"}: ${message(err)}); trying again every ${REPLUG_MS / 1000} s`);
655 if (say) console.log(`arm not connected (${path ?? "no port"}: ${message(err)}); trying again every 5 s`);
660656 return null;
661657 }
662658 }
Software/so101/src/fake-bus.ts +7
@@ -38,6 +38,7 @@ export class FakeBus implements Transport {
3838 /** Read and sync-read instructions heard, one per packet. */
3939 reads = 0;
4040 #listener: ((chunk: Uint8Array) => void) | undefined;
41 #onError: ((error: Error) => void) | undefined;
4142 readonly #parser = new FrameParser();
4243 readonly #options: FakeOptions;
4344 /** With a speed: each servo's goal and when its position was last brought up to date. */
@@ -68,6 +69,12 @@ export class FakeBus implements Transport {
6869
6970 on(event: "data" | "error", listener: ((chunk: Uint8Array) => void) | ((error: Error) => void)): void {
7071 if (event === "data") this.#listener = listener as (chunk: Uint8Array) => void;
72 else this.#onError = listener as (error: Error) => void;
73 }
74
75 /** The port is lost, like a pulled cable: the bus is told through its error event. */
76 fail(error: Error): void {
77 this.#onError?.(error);
7178 }
7279
7380 write(data: Uint8Array, callback: (error: Error | null | undefined) => void): void {
Software/so101/src/server.ts +71 −15
@@ -101,7 +101,8 @@ 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";
104 import { ARM_ABSENT, ARM_ROUTES, ArmAbsentError } from "./absent-bus.ts";
105 import { SwappedBus } from "./swap-bus.ts";
105106 import { ArmModel, gripperTicksToRadians, ticksToRadians, type Vec3, type Violation } from "./kinematics.ts";
106107 import { fitTablePlane, MAX_TILT_DEG } from "./table-plane.ts";
107108 import { clamp, type Frame } from "./mapping.ts";
@@ -148,6 +149,10 @@ export interface ServerOptions {
148149 heartbeatMs?: number;
149150 /** The commit the precision history tags its rows with; found from the environment or the checkout when not given. */
150151 commit?: string;
152 /** Opens the arm's port, or null when it is not there. While no arm is connected it is tried every `retryMs`; the bus it returns takes over. */
153 reopen?: () => Promise<FeetechBus | null>;
154 /** How often `reopen` is tried while no arm is connected, ms (default 5000). */
155 retryMs?: number;
151156 /** How often the arm is checked for servos whose gains need writing, ms; unset, the gains are not written (the CLI sets 2000). */
152157 gainSyncMs?: number;
153158 /** How often the grow light's schedule is checked, ms; unset, it is not run (the CLI sets 15000). The light then catches up at start. */
@@ -253,8 +258,8 @@ const MIME: Record<string, string> = { ".js": "text/javascript", ".css": "text/c
253258
254259 /** `arm` is null when the serial port could not be opened: the server still serves everything that is not the arm. */
255260 export async function startServer(arm: FeetechBus | null, options: ServerOptions = {}): Promise<RunningServer> {
256 const hasArm = arm !== null;
257 const bus = arm ?? absentBus("(no arm)");
261 const link = new SwappedBus();
262 const bus = link.bus;
258263 const ids = options.ids ?? [1, 2, 3, 4, 5, 6];
259264 const posesFile = resolve(options.posesFile ?? "poses.json");
260265 const dataDir = dirname(posesFile);
@@ -288,20 +293,28 @@ export async function startServer(arm: FeetechBus | null, options: ServerOptions
288293 /** Torque_Enable sits apart from the present block, so it is read at most every TORQUE_EVERY_MS, and at once after it is written. */
289294 let torque = new Map<number, number>();
290295 let torqueAt = -Infinity;
291 bus.onSyncWrite = (register, values) => {
296 const onSyncWrite: NonNullable<FeetechBus["onSyncWrite"]> = (register, values) => {
292297 if (register === STS3215.Goal_Position) for (const [id, v] of values) goals.set(id, v), tracking?.wrote(id, Date.now());
293298 if (register === STS3215.Torque_Enable) torqueAt = -Infinity;
294299 machine.note("sent", registerName(register), { values: perServo(values) });
295300 };
301 bus.onSyncWrite = onSyncWrite;
296302 /** The stream's dead-man hold is in force: no frame came for holdAfterMs, and the arm stays where it was. */
297303 let held = false;
298304
299305 const limits = new Map<number, { min: number; max: number }>();
300 for (const id of hasArm ? ids : []) {
301 limits.set(id, {
302 min: await bus.read(id, STS3215.Min_Position_Limit),
303 max: await bus.read(id, STS3215.Max_Position_Limit),
304 });
306 const loadLimits = async (from: FeetechBus) => {
307 for (const id of ids) {
308 limits.set(id, {
309 min: await from.read(id, STS3215.Min_Position_Limit),
310 max: await from.read(id, STS3215.Max_Position_Limit),
311 });
312 }
313 };
314 if (arm) {
315 await loadLimits(arm);
316 link.attach(arm);
317 arm.onSyncWrite = onSyncWrite;
305318 }
306319
307320 // ------------------------------------------------------------------ the model and the zone
@@ -468,7 +481,7 @@ export async function startServer(arm: FeetechBus | null, options: ServerOptions
468481 const stopped = (e: EStop) => `emergency stop (${e.by}, ${e.at.slice(0, 19).replace("T", " ")} UTC): the arm holds until an admin clears it`;
469482 /** Torque on, goal where each joint is: the arm stays put and resists. */
470483 const holdHere = async () => {
471 if (!hasArm) return;
484 if (!link.connected) return;
472485 const positions = await bus.syncRead(ids, STS3215.Present_Position);
473486 const here = ids.filter((id) => positions.has(id));
474487 await bus.syncWrite(STS3215.Goal_Position, here.map((id) => [id, positions.get(id)!] as const));
@@ -713,7 +726,7 @@ export async function startServer(arm: FeetechBus | null, options: ServerOptions
713726 }
714727
715728 const status = async () => {
716 if (!hasArm) return absentStatus();
729 if (!link.connected) return absentStatus();
717730 // Present_Position (56) through Present_Temperature (63) is one block: one round trip, not one per register.
718731 const block = await bus.syncReadBlock(ids, PRESENT);
719732 const positions = new Map([...block].map(([id, [p]]) => [id, p!]));
@@ -979,7 +992,7 @@ export async function startServer(arm: FeetechBus | null, options: ServerOptions
979992 writeAtomic(join(calibrationDir, `${kind}.json`), JSON.stringify({ ...body, saved: new Date().toISOString() }, null, 1));
980993 return json(res, 200, { stored: kind });
981994 }
982 if (!hasArm && ARM_ROUTES.has(route)) throw new HttpError(503, ARM_ABSENT);
995 if (!link.connected && ARM_ROUTES.has(route)) throw new HttpError(503, ARM_ABSENT);
983996 switch (route) {
984997 case "GET /":
985998 case "GET /index.html":
@@ -1449,7 +1462,7 @@ export async function startServer(arm: FeetechBus | null, options: ServerOptions
14491462 return c;
14501463 };
14511464 wss.on("connection", (ws: WebSocket, req?: IncomingMessage) => {
1452 if (!hasArm) {
1465 if (!link.connected) {
14531466 ws.close(NO_ARM_CLOSE, ARM_ABSENT); // 1013, try again later: nothing is driven
14541467 return;
14551468 }
@@ -1873,8 +1886,50 @@ export async function startServer(arm: FeetechBus | null, options: ServerOptions
18731886 });
18741887
18751888 await new Promise<void>((resolve) => http.listen(options.port ?? 8101, options.host ?? "0.0.0.0", resolve));
1876 const gainTimer = hasArm && options.gainSyncMs ? setInterval(() => void gainSync.tick(), options.gainSyncMs) : undefined;
1877 if (gainTimer) void gainSync.tick();
1889 // ------------------------------------------------------------------ the arm comes and goes
1890 /** The arm's port failed: whatever drives stops and is failed, never resumed; the cameras and the grow box serve on. */
1891 const lose = (lost: FeetechBus) => {
1892 if (!link.detach(lost)) return;

The abort and the 1013 closes here have no test. Cover them: start a drive stream and a move, fail the bus, and assert the stream closes with 1013 and the move rejects. Also test that an E-stop made while the arm is gone holds it on return.

1893 console.log("arm not connected (its port failed); trying again every 5 s");
1894 current?.abort();

No test fails if this line is removed (the author says so). Add one that isolates the abort, and one for playback/recording.

1895 for (const client of wss.clients) client.close(NO_ARM_CLOSE, ARM_ABSENT);
1896 void lost.close().catch(() => undefined);
1897 };
1898 const adopt = (found: FeetechBus) => {
1899 found.onFail = () => lose(found);
1900 if (link.live !== found) {
1901 link.attach(found);
1902 found.onSyncWrite = onSyncWrite;
1903 console.log(`arm connected on ${found.path}`);
1904 // A stop in force when the arm comes back holds it where it stands.
1905 if (estop || gate.held) void holdHere().catch(() => undefined);
1906 }
1907 };
1908 if (arm) adopt(arm);
1909 let reaching = false;
1910 const retryTimer = options.reopen
1911 ? setInterval(async () => {
1912 if (link.connected || reaching) return;
1913 reaching = true;
1914 try {
1915 const found = await options.reopen!();
1916 if (!found) return;
1917 try {
1918 await loadLimits(found);
1919 } catch (err) {
1920 await found.close().catch(() => undefined);
1921 throw err;
1922 }
1923 adopt(found);
1924 } catch (err) {
1925 console.log(`arm not connected (${err instanceof Error ? err.message : String(err)})`);
1926 } finally {
1927 reaching = false;
1928 }
1929 }, options.retryMs ?? 5000)
1930 : undefined;
1931 const gainTimer = options.gainSyncMs ? setInterval(() => link.connected && void gainSync.tick(), options.gainSyncMs) : undefined;

Low: GainSync keeps one #pending set for the life of the server. It only marks a joint pending again when a tick sees that joint silent, and this timer does not tick while the arm is disconnected. If a different arm (or freshly flashed servos) is plugged in, each joint answers on its first tick and is never re-marked, so its gains are never written. Fix: in adopt, when a new bus takes over, mark all gain joints pending again (e.g. a gainSync.reset()), and add a test that checks a newly attached bus gets its gains written.

1932 if (gainTimer && link.connected) void gainSync.tick();
18781933 const lightTimer = options.lightMs ? setInterval(() => DEVICES.forEach((d) => void lights[d].tick()), options.lightMs) : undefined;
18791934 if (options.lightMs) DEVICES.forEach((d) => void lights[d].boot());
18801935 void dosePumps.boot();
@@ -1888,6 +1943,7 @@ export async function startServer(arm: FeetechBus | null, options: ServerOptions
18881943 new Promise<void>((resolve) => {
18891944 clearInterval(watching);
18901945 clearInterval(gainTimer);
1946 clearInterval(retryTimer);
18911947 clearInterval(lightTimer);
18921948 clearInterval(sensorTimer);
18931949 machine.close();
Software/so101/src/stations.test.ts −13
@@ -3,7 +3,6 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
33 import { createServer, type Server } from "node:http";
44 import { tmpdir } from "node:os";
55 import { join } from "node:path";
6 import { waitForArm } from "./arm-wait.ts";
76 import { FeetechBus } from "./bus.ts";
87 import { CameraHub } from "./cameras.ts";
98 import { FakeBus } from "./fake-bus.ts";
@@ -51,18 +50,6 @@ test("the hub keeps its list in a file and starts as the arm and the grow box",
5150 }
5251 });
5352
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
6653 let relay: RunningRelay | undefined;
6754 let servers: Server[] = [];
6855 afterEach(async () => {
Software/so101/src/swap-bus.ts added +42
@@ -0,0 +1,42 @@
1 import type { FeetechBus } from "./bus.ts";
2 import { absentBus } from "./absent-bus.ts";
3
4 /** The arm's bus as the server holds it: one stable object whose wire is swapped when the arm is plugged in or lost. */
5 export class SwappedBus {
6 /** What the server calls; it forwards to the live bus, or to one that fails every call while there is none. */
7 readonly bus: FeetechBus;
8 #live: FeetechBus | null = null;
9 readonly #absent = absentBus("(no arm)");
10
11 constructor() {
12 const target = () => this.#live ?? this.#absent;
13 this.bus = new Proxy({} as FeetechBus, {
14 get: (_, key) => {
15 const t = target();
16 const value = Reflect.get(t, key, t);
17 return typeof value === "function" ? value.bind(t) : value;
18 },
19 set: (_, key, value) => Reflect.set(target(), key, value, target()),
20 });
21 }
22
23 get connected(): boolean {
24 return this.#live !== null;
25 }
26
27 /** The live bus itself, for the one caller that must ask it before it takes over. */
28 get live(): FeetechBus | null {
29 return this.#live;
30 }
31
32 attach(bus: FeetechBus): void {
33 this.#live = bus;
34 }
35
36 /** Lets go of `bus` if it is the live one; true when it was. A bus that was already replaced is none of the caller's business. */
37 detach(bus: FeetechBus): boolean {
38 if (this.#live !== bus) return false;
39 this.#live = null;
40 return true;
41 }
42 }

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs