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 #614

Closed opened this
Closed without merging

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

  • Closed without merging. Nothing was applied to main.

Review

Sign in to review this change.
4 files changed +178 −24
Software/so101/src/arm-port.ts added +72
@@ -0,0 +1,72 @@
1 /**
2 * The arm's serial link, which may not be there. `serve` hands the server this in place of a FeetechBus: while no
3 * arm is plugged in every bus call fails with ArmAbsent (the server answers "arm: not connected" and keeps serving
4 * the cameras and the grow station), and once `plug` gives it a bus the calls go through. `retryPlug` tries the port every 5 s.
5 */
6
7 import type { FeetechBus } from "./bus.ts";
8
9 export class ArmAbsent extends Error {
10 constructor() {
11 super("arm not connected");
12 }
13 }
14
15 export const RETRY_MS = 5000;
16
17 export class ArmPort {
18 #bus: FeetechBus | null = null;
19 #onSyncWrite: FeetechBus["onSyncWrite"] = null;
20
21 get connected(): boolean {
22 return this.#bus !== null;
23 }
24
25 plug(bus: FeetechBus): void {
26 bus.onSyncWrite = this.#onSyncWrite;
27 this.#bus = bus;
28 }
29
30 /** The object the server uses as its bus. */
31 asBus(): FeetechBus {
32 return new Proxy({} as FeetechBus, {
33 get: (_, key) => {
34 if (key === "onSyncWrite") return this.#onSyncWrite;
35 if (key === "path") return this.#bus?.path ?? "(no arm)";
36 if (!this.#bus) return () => Promise.reject(new ArmAbsent());
37 const value = Reflect.get(this.#bus, key) as unknown;
38 return typeof value === "function" ? value.bind(this.#bus) : value;
39 },
40 set: (_, key, value) => {
41 if (key !== "onSyncWrite") return false;
42 this.#onSyncWrite = value as FeetechBus["onSyncWrite"];
43 if (this.#bus) this.#bus.onSyncWrite = this.#onSyncWrite;
44 return true;
45 },
46 });
47 }
48 }
49
50 /** Tries `open` now and then every `everyMs` until it works (a missing port is the normal case); returns a stop function. */
51 export function retryPlug(port: ArmPort, open: () => Promise<FeetechBus>, say: (text: string) => void, everyMs = RETRY_MS): () => void {
52 let stopped = false;
53 let timer: ReturnType<typeof setTimeout> | undefined;
54 let told = false;
55 const attempt = async () => {
56 if (stopped || port.connected) return;
57 try {
58 port.plug(await open());
59 say("arm connected");
60 return;
61 } catch (err) {
62 if (!told) say(`arm not connected (${err instanceof Error ? err.message : String(err)}); trying again every ${everyMs / 1000} s`);
63 told = true;
64 }
65 if (!stopped) timer = setTimeout(() => void attempt(), everyMs);
66 };
67 void attempt();
68 return () => {
69 stopped = true;
70 clearTimeout(timer);
71 };
72 }
Software/so101/src/cli.ts +18 −16
@@ -9,7 +9,7 @@ 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";
12 import { ArmPort, retryPlug } from "./arm-port.ts";
1313 import { assertPoseName, parsePoseFile, serializePoseFile, type PoseFile } from "./poses.ts";
1414 import { durationSeconds, largestStep, parseTrajectory, serializeTrajectory } from "./trajectory.ts";
1515 import {
@@ -530,21 +530,23 @@ const COMMANDS: Record<string, Command> = {
530530 serve: async (values) => {
531531 const [host, portText] = (values.listen ?? "0.0.0.0:8101").split(":");
532532 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));
539 const server = await startServer(bus, { host, port, posesFile: values.file ?? "poses.json", modelDir: values.model, gainSyncMs: 2000, cameraControl, cameraStill, lightMs: 15_000, sensorsMs: 60_000 });
540 console.log(`serving ${bus.path} on http://${host}:${server.port} — open it in a browser; the API is listed in src/server.ts`);
541 console.log(`the grow routes need the key in ${growKeyFile(dirname(resolve(values.file ?? "poses.json")))}: give the hosted app the same in ARM_GROW_KEY`);
542 console.log("ctrl-c to stop");
543 await new Promise<void>((resolve) => process.once("SIGINT", () => resolve()));
544 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 });
533 // The server starts with or without the arm: a missing port is "arm: not connected", and the port is tried again every 5 s.
534 const arm = new ArmPort();
535 const baudRate = values.baud ? integer(values.baud, "--baud") : 1_000_000;
536 const stopRetry = retryPlug(arm, async () => FeetechBus.open(values.port ?? process.env.SO101_PORT ?? (await detectPort()), { baudRate }), (text) => console.log(text));
537 const { growKeyFile, startServer } = await import("./server.ts"); // lazily: it reaches bun:sqlite, which Node lacks
538 const cameraControl = (unit: number, profile: Parameters<typeof applyProfile>[1]) => applyProfile(cameraDevice(unit), profile);
539 const cameraStill = (unit: number) => grabStill(unit, cameraDevice(unit));
540 const bus = arm.asBus();
541 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 });
542 console.log(`serving ${arm.connected ? bus.path : "without the arm"} on http://${host}:${server.port} — open it in a browser; the API is listed in src/server.ts`);
543 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`);
544 console.log("ctrl-c to stop");
545 await new Promise<void>((resolve) => process.once("SIGINT", () => resolve()));
546 stopRetry();
547 await server.close();
548 if (arm.connected) await bus.syncWrite(STS3215.Torque_Enable, [1, 2, 3, 4, 5, 6].map((id) => [id, 0] as const));
549 console.log("stopped; torque released");
548550 },
549551
550552 "camera-profile": async (values, [unitText]) => {
Software/so101/src/serve-no-arm.test.ts added +62
@@ -0,0 +1,62 @@
1 import { afterEach, expect, test } from "bun:test";
2 import { mkdtempSync, rmSync } from "node:fs";
3 import { tmpdir } from "node:os";
4 import { join } from "node:path";
5 import { ArmPort, retryPlug } from "./arm-port.ts";
6
7 const cli = new URL("./cli.ts", import.meta.url).pathname;
8 let child: ReturnType<typeof Bun.spawn> | undefined;
9 let dir: string | undefined;
10
11 afterEach(() => {
12 child?.kill();
13 if (dir) rmSync(dir, { recursive: true, force: true });
14 child = dir = undefined;
15 });
16
17 test("serve with SO101_PORT at a missing path still answers /status, /version and the camera routes", async () => {
18 dir = mkdtempSync(join(tmpdir(), "so101-noarm-"));
19 const inherited = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith("ARM_")));
20 const listen = 20000 + Math.floor(Math.random() * 30000);
21 // The bin runs under Node 24; this runs the same main under Bun, which is enough for a port that never opens.
22 const main = `import { main } from ${JSON.stringify(cli)}; process.exitCode = await main(process.argv.slice(1));`;
23 child = Bun.spawn(["bun", "-e", main, "serve", "--listen", `127.0.0.1:${listen}`, "--file", join(dir, "poses.json")], {
24 env: { ...inherited, SO101_PORT: join(dir, "no-such-tty") },
25 stdout: "pipe",
26 stderr: "inherit",
27 });
28 const reader = (child.stdout as ReadableStream<Uint8Array>).getReader();
29 let text = "";
30 let match: RegExpExecArray | null = null;
31 while (!(match = /on http:\/\/([^:]+):(\d+)/.exec(text))) {
32 const { value, done } = await reader.read();
33 if (done) throw new Error(`serve exited: ${text}`);
34 text += new TextDecoder().decode(value);
35 }
36 const base = `http://127.0.0.1:${match[2]}`;
37 expect(text).toContain("arm not connected");
38 const status = await fetch(`${base}/status`);
39 expect(status.status).toBe(200);
40 expect(((await status.json()) as { arm: string }).arm).toBe("not connected");
41 expect((await fetch(`${base}/version`)).status).toBe(200);
42 expect((await fetch(`${base}/camera-profile/1`)).status).toBe(200);
43 expect((await fetch(`${base}/limits`)).status).toBe(200);
44 });
45
46 test("retryPlug keeps trying a missing port and plugs the bus in once it opens", async () => {
47 const port = new ArmPort();
48 const bus = { path: "/dev/ttyACM0", onSyncWrite: null } as never;
49 let tries = 0;
50 const said: string[] = [];
51 const stop = retryPlug(port, async () => {
52 if (++tries < 3) throw new Error("No such file or directory");
53 return bus;
54 }, (t: string) => said.push(String(t)), 5);
55 await Bun.sleep(100);
56 stop();
57 expect(port.connected).toBe(true);
58 expect(port.asBus().path).toBe("/dev/ttyACM0");
59 expect(said[0]).toContain("arm not connected");
60 expect(said.at(-1)).toBe("arm connected");
61 await expect(new ArmPort().asBus().syncRead([1], 56 as never)).rejects.toThrow("arm not connected");
62 });
Software/so101/src/server.ts +26 −8
@@ -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 { ArmAbsent } from "./arm-port.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";
@@ -291,12 +292,18 @@ export async function startServer(bus: FeetechBus, options: ServerOptions = {}):
291292 let held = false;
292293
293294 const limits = new Map<number, { min: number; max: number }>();
294 for (const id of ids) {
295 limits.set(id, {
296 min: await bus.read(id, STS3215.Min_Position_Limit),
297 max: await bus.read(id, STS3215.Max_Position_Limit),
298 });
299 }
295 /** The servos' own limits, read once the arm answers (at start, or after it is plugged in); an absent arm is not an error here. */
296 const loadLimits = async () => {
297 if (limits.size) return;
298 try {
299 const read = new Map<number, { min: number; max: number }>();
300 for (const id of ids) read.set(id, { min: await bus.read(id, STS3215.Min_Position_Limit), max: await bus.read(id, STS3215.Max_Position_Limit) });
301 for (const [id, l] of read) limits.set(id, l);
302 } catch (err) {
303 if (!(err instanceof ArmAbsent)) throw err;
304 }
305 };
306 await loadLimits();
300307
301308 // ------------------------------------------------------------------ the model and the zone
302309 const here = dirname(fileURLToPath(import.meta.url));
@@ -706,6 +713,15 @@ export async function startServer(bus: FeetechBus, options: ServerOptions = {}):
706713 }
707714
708715 const status = async () => {
716 try {
717 await loadLimits();
718 return await armStatus();
719 } catch (err) {
720 if (!(err instanceof ArmAbsent)) throw err;
721 return { arm: "not connected", joints: [], busy: driver !== null, driving: driver, recording: null, zone: "off", violation: null, blocked, jammed: [] as number[] };
722 }
723 };
724 const armStatus = async () => {
709725 // Present_Position (56) through Present_Temperature (63) is one block: one round trip, not one per register.
710726 const block = await bus.syncReadBlock(ids, PRESENT);
711727 const positions = new Map([...block].map(([id, [p]]) => [id, p!]));
@@ -739,6 +755,7 @@ export async function startServer(bus: FeetechBus, options: ServerOptions = {}):
739755 const zone = evaluate(positions);
740756 approach(zone);
741757 return {
758 arm: "connected",
742759 joints,
743760 busy: driver !== null,
744761 driving: driver,
@@ -1388,6 +1405,7 @@ export async function startServer(bus: FeetechBus, options: ServerOptions = {}):
13881405 if (err.status === 413) res.setHeader("connection", "close"); // the rest of the body is unread: end the connection after the answer
13891406 return json(res, err.status, { error: err.message });
13901407 }
1408 if (err instanceof ArmAbsent) return json(res, 503, { error: err.message });
13911409 json(res, 500, { error: err instanceof Error ? err.message : String(err) });
13921410 }
13931411 });
@@ -1837,8 +1855,8 @@ export async function startServer(bus: FeetechBus, options: ServerOptions = {}):
18371855 });
18381856
18391857 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;
1841 if (gainTimer) void gainSync.tick();
1858 const gainTimer = options.gainSyncMs ? setInterval(() => void gainSync.tick().catch(() => {}), options.gainSyncMs) : undefined;
1859 if (gainTimer) void gainSync.tick().catch(() => {});
18421860 const lightTimer = options.lightMs ? setInterval(() => DEVICES.forEach((d) => void lights[d].tick()), options.lightMs) : undefined;
18431861 if (options.lightMs) DEVICES.forEach((d) => void lights[d].boot());
18441862 void dosePumps.boot();

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs