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
smith/arm:smith/581-the-workshop-one-pi-several-stations-the → main 8 commits fork
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 | : FeetechBus | null = null; | |
| 19 | : FeetechBus["onSyncWrite"] = null; | |
| 20 | ||
| 21 | get connected(): boolean { | |
| 22 | return this. !== null; | |
| 23 | } | |
| 24 | ||
| 25 | plug(bus: FeetechBus): void { | |
| 26 | bus.onSyncWrite = this.; | |
| 27 | this. = 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.; | |
| 35 | if (key === "path") return this.?.path ?? "(no arm)"; | |
| 36 | if (!this.) return () => Promise.reject(new ArmAbsent()); | |
| 37 | const value = Reflect.get(this., key) as unknown; | |
| 38 | return typeof value === "function" ? value.bind(this.) : value; | |
| 39 | }, | |
| 40 | set: (_, key, value) => { | |
| 41 | if (key !== "onSyncWrite") return false; | |
| 42 | this. = value as FeetechBus["onSyncWrite"]; | |
| 43 | if (this.) this..onSyncWrite = this.; | |
| 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"; | ||
| 9 | 9 | import { describeError, hex } from "./protocol.ts"; |
| 10 | 10 | import { MAX_FRAME_STEP, MotionError, moveToPose, name as jointName, playTrajectory, recordTrajectory, sleep } from "./motion.ts"; |
| 11 | 11 | import { streamLeader } from "./leader.ts"; |
| 12 | import { waitForArm } from "./arm-wait.ts"; | |
| 12 | import { ArmPort, retryPlug } from "./arm-port.ts"; | |
| 13 | 13 | import { assertPoseName, parsePoseFile, serializePoseFile, type PoseFile } from "./poses.ts"; |
| 14 | 14 | import { durationSeconds, largestStep, parseTrajectory, serializeTrajectory } from "./trajectory.ts"; |
| 15 | 15 | import { |
| @@ -530,21 +530,23 @@ const COMMANDS: Record<string, Command> = { | ||
| 530 | 530 | serve: async (values) => { |
| 531 | 531 | const [host, portText] = (values.listen ?? "0.0.0.0:8101").split(":"); |
| 532 | 532 | 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"); | |
| 548 | 550 | }, |
| 549 | 551 | |
| 550 | 552 | "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"; | ||
| 101 | 101 | import { Clock, CommandGate, HELD_CLOSE, isRelease, sentAtOf } from "./command-gate.ts"; |
| 102 | 102 | import { FLOOD_CLOSE, FrameRate, frameBytes, MAX_FRAME_BYTES, TOO_BIG_CLOSE } from "./frame-limit.ts"; |
| 103 | 103 | import type { FeetechBus } from "./bus.ts"; |
| 104 | import { ArmAbsent } from "./arm-port.ts"; | |
| 104 | 105 | import { ArmModel, gripperTicksToRadians, ticksToRadians, type Vec3, type Violation } from "./kinematics.ts"; |
| 105 | 106 | import { fitTablePlane, MAX_TILT_DEG } from "./table-plane.ts"; |
| 106 | 107 | import { clamp, type Frame } from "./mapping.ts"; |
| @@ -291,12 +292,18 @@ export async function startServer(bus: FeetechBus, options: ServerOptions = {}): | ||
| 291 | 292 | let held = false; |
| 292 | 293 | |
| 293 | 294 | 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(); | |
| 300 | 307 | |
| 301 | 308 | // ------------------------------------------------------------------ the model and the zone |
| 302 | 309 | const here = dirname(fileURLToPath(import.meta.url)); |
| @@ -706,6 +713,15 @@ export async function startServer(bus: FeetechBus, options: ServerOptions = {}): | ||
| 706 | 713 | } |
| 707 | 714 | |
| 708 | 715 | 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 () => { | |
| 709 | 725 | // Present_Position (56) through Present_Temperature (63) is one block: one round trip, not one per register. |
| 710 | 726 | const block = await bus.syncReadBlock(ids, PRESENT); |
| 711 | 727 | const positions = new Map([...block].map(([id, [p]]) => [id, p!])); |
| @@ -739,6 +755,7 @@ export async function startServer(bus: FeetechBus, options: ServerOptions = {}): | ||
| 739 | 755 | const zone = evaluate(positions); |
| 740 | 756 | approach(zone); |
| 741 | 757 | return { |
| 758 | arm: "connected", | |
| 742 | 759 | joints, |
| 743 | 760 | busy: driver !== null, |
| 744 | 761 | driving: driver, |
| @@ -1388,6 +1405,7 @@ export async function startServer(bus: FeetechBus, options: ServerOptions = {}): | ||
| 1388 | 1405 | if (err.status === 413) res.setHeader("connection", "close"); // the rest of the body is unread: end the connection after the answer |
| 1389 | 1406 | return json(res, err.status, { error: err.message }); |
| 1390 | 1407 | } |
| 1408 | if (err instanceof ArmAbsent) return json(res, 503, { error: err.message }); | |
| 1391 | 1409 | json(res, 500, { error: err instanceof Error ? err.message : String(err) }); |
| 1392 | 1410 | } |
| 1393 | 1411 | }); |
| @@ -1837,8 +1855,8 @@ export async function startServer(bus: FeetechBus, options: ServerOptions = {}): | ||
| 1837 | 1855 | }); |
| 1838 | 1856 | |
| 1839 | 1857 | 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(() => {}); | |
| 1842 | 1860 | const lightTimer = options.lightMs ? setInterval(() => DEVICES.forEach((d) => void lights[d].tick()), options.lightMs) : undefined; |
| 1843 | 1861 | if (options.lightMs) DEVICES.forEach((d) => void lights[d].boot()); |
| 1844 | 1862 | void dosePumps.boot(); |