Public: anyone can read this; the crew can change it.
Migrated from github.com/Trevato/SO-ARM100
workbenchthis machine
Clone, one command, drive: a guided setup TUI that finds both arms, reuses existing calibration, and starts local teleop #566
smith/arm:smith/564-clone-one-command-drive-a-guided-setup-t → main 5 commits fork
14 files
changed +856
−1
Software/so101/README.md +15
| @@ -2,6 +2,21 @@ | ||
| 2 | 2 | |
| 3 | 3 | `bench/fake-serve.ts` is the hosted app (the page, the room, the relay); `so101 serve` is the arm server on the Pi. |
| 4 | 4 | |
| 5 | ## Plug in and play | |
| 6 | ||
| 7 | Two SO-101 arms on USB, Node 22.6+ and Bun, nothing else: | |
| 8 | ||
| 9 | ||
| 10 | bun install && bun so101 setup | |
| 11 | ||
| 12 | ||
| 13 | Setup is full-screen: it finds both arms (and waits if only one shows up), asks you to wiggle the one you will hold | |
| 14 | (remembered by USB serial number in `~/.config/so101/arms.json`), checks each servo's calibrated range, and reuses LeRobot's | |
| 15 | files in `~/.cache/huggingface/lerobot/calibration/` when a servo has forgotten its range. Only a joint that is out of range | |
| 16 | is calibrated again, one at a time. Then the leader drives the follower, 60 Hz, space to STOP. Later, `bun so101 teleop` | |
| 17 | skips the questions. `bun so101 setup --dry-run` shows the plan without touching an arm. Assembly and calibration background: | |
| 18 | https://huggingface.co/docs/lerobot/so101 | |
| 19 | ||
| 5 | 20 | ## Open / Closed |
| 6 | 21 | |
| 7 | 22 | The owner can close the whole app when the shop is dark: on `/admin`, **Open / Closed** has **Close the arm** with an optional note for visitors ("Back Saturday 10am CT", at most 140 characters) and an optional "back around" time (the owner's local time); **Open the arm** undoes it. In the terminal: `/close [note]` and `/open`. Over `/live`, the owner sends `{"type": "open", "open": false, "note": "…", "reopenAt": "2026-10-31T10:00-05:00"}`. Every change is in the admin log; the switch is kept in `so101/open.json` under `FORGE_APP_DATA` (src/opening.ts). |
Software/so101/package.json +1
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | "so101": "bin/so101" |
| 9 | 9 | }, |
| 10 | 10 | "scripts": { |
| 11 | "so101": "node bin/so101", | |
| 11 | 12 | "typecheck": "tsc --noEmit", |
| 12 | 13 | "test": "bun test" |
| 13 | 14 | }, |
Software/so101/src/arm-calibration.test.ts added +23
| @@ -0,0 +1,23 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { judge, parseLerobot } from "./arm-calibration.ts"; | |
| 3 | ||
| 4 | const lerobot = { shoulder_pan: { id: 1, drive_mode: 0, homing_offset: 5, range_min: 800, range_max: 3200 }, gripper: { id: 6, range_min: 5, range_max: 6 }, bogus: {} }; | |
| 5 | ||
| 6 | test("LeRobot's file maps joint names to servo ranges and skips junk", () => { | |
| 7 | expect([...parseLerobot(lerobot)]).toEqual([[1, { min: 800, max: 3200 }]]); | |
| 8 | expect(parseLerobot("x").size).toBe(0); | |
| 9 | }); | |
| 10 | ||
| 11 | test("a calibrated joint is kept; a forgotten one comes back from LeRobot's file; without a file it is recalibrated", () => { | |
| 12 | const limits = new Map([[1, { min: 0, max: 4095 }], [2, { min: 700, max: 3300 }], [3, { min: 0, max: 4095 }]]); | |
| 13 | const present = new Map([[1, 2000], [2, 2000], [3, 2000]]); | |
| 14 | const verdicts = judge(limits, present, parseLerobot(lerobot)); | |
| 15 | expect(verdicts[0]).toEqual({ id: 1, state: "restore", range: { min: 800, max: 3200 } }); | |
| 16 | expect(verdicts[1]).toEqual({ id: 2, state: "ok" }); | |
| 17 | expect(verdicts[2]?.state).toBe("recalibrate"); | |
| 18 | }); | |
| 19 | ||
| 20 | test("a joint sitting outside its range is flagged", () => { | |
| 21 | const verdicts = judge(new Map([[2, { min: 700, max: 3300 }]]), new Map([[2, 4000]])); | |
| 22 | expect(verdicts[0]).toMatchObject({ state: "recalibrate" }); | |
| 23 | }); |
Software/so101/src/arm-calibration.ts added +73
| @@ -0,0 +1,73 @@ | ||
| 1 | /** | |
| 2 | * Does an arm still carry a usable calibration? The servos keep their own range limits in EEPROM (that is what | |
| 3 | * LeRobot's calibration writes), so the question is answered by reading them back, and by comparing with the | |
| 4 | * calibration files LeRobot left on the computer. | |
| 5 | */ | |
| 6 | ||
| 7 | import { readdirSync, readFileSync, statSync } from "node:fs"; | |
| 8 | import { homedir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import type { Range } from "./mapping.ts"; | |
| 11 | import { JOINTS } from "./sts3215.ts"; | |
| 12 | ||
| 13 | /** A joint whose range is narrower than this was never calibrated (factory is 0-4095, or 0-0). */ | |
| 14 | export const MIN_RANGE = 200; | |
| 15 | export const FULL_TURN = 4095; | |
| 16 | ||
| 17 | export type Verdict = | |
| 18 | | { id: number; state: "ok" } | |
| 19 | | { id: number; state: "restore"; range: Range } // the servo forgot; LeRobot's file knows | |
| 20 | | { id: number; state: "recalibrate"; why: string }; | |
| 21 | ||
| 22 | export function lerobotDirs(kind: "robots" | "teleoperators", home: string = homedir(), env: Record<string, string | undefined> = process.env): string { | |
| 23 | const cache = env.HF_HOME ? join(env.HF_HOME, "lerobot") : join(env.XDG_CACHE_HOME ?? join(home, ".cache"), "huggingface", "lerobot"); | |
| 24 | return join(cache, "calibration", kind); | |
| 25 | } | |
| 26 | ||
| 27 | /** LeRobot's calibration JSON: { joint_name: { id, range_min, range_max, … } } → servo id → range. Bad entries are skipped. */ | |
| 28 | export function parseLerobot(body: unknown): Map<number, Range> { | |
| 29 | const out = new Map<number, Range>(); | |
| 30 | if (typeof body !== "object" || body === null) return out; | |
| 31 | for (const [name, joint] of Object.entries(body)) { | |
| 32 | const j = joint as { id?: unknown; range_min?: unknown; range_max?: unknown } | null; | |
| 33 | const id = Object.entries(JOINTS).find(([, joint]) => joint === name)?.[0]; | |
| 34 | const tick = (v: unknown): v is number => typeof v === "number" && Number.isInteger(v) && v >= 0 && v <= FULL_TURN; | |
| 35 | if (id === undefined || !j || !tick(j.range_min) || !tick(j.range_max) || j.range_max - j.range_min < MIN_RANGE) continue; | |
| 36 | out.set(Number(id), { min: j.range_min, max: j.range_max }); | |
| 37 | } | |
| 38 | return out; | |
| 39 | } | |
| 40 | ||
| 41 | /** The newest calibration file in `dir`, parsed; an empty map when there is none. */ | |
| 42 | export function newestLerobot(dir: string): Map<number, Range> { | |
| 43 | let best: { file: string; at: number } | null = null; | |
| 44 | try { | |
| 45 | for (const name of readdirSync(dir)) { | |
| 46 | if (!name.endsWith(".json")) continue; | |
| 47 | const file = join(dir, name); | |
| 48 | const at = statSync(file).mtimeMs; | |
| 49 | if (!best || at > best.at) best = { file, at }; | |
| 50 | } | |
| 51 | return best ? parseLerobot(JSON.parse(readFileSync(best.file, "utf8"))) : new Map(); | |
| 52 | } catch { | |
| 53 | return new Map(); | |
| 54 | } | |
| 55 | } | |
| 56 | ||
| 57 | /** Check every joint: the servo's limits against being set at all, the arm's present position against them, LeRobot's file as the way back. */ | |
| 58 | export function judge(limits: ReadonlyMap<number, Range>, present: ReadonlyMap<number, number>, reference: ReadonlyMap<number, Range> = new Map()): Verdict[] { | |
| 59 | const verdicts: Verdict[] = []; | |
| 60 | for (const [id, range] of limits) { | |
| 61 | const ref = reference.get(id); | |
| 62 | const set = range.max - range.min >= MIN_RANGE && !(range.min === 0 && range.max === FULL_TURN); | |
| 63 | const at = present.get(id); | |
| 64 | if (!set) { | |
| 65 | verdicts.push(ref ? { id, state: "restore", range: ref } : { id, state: "recalibrate", why: "its range was never set" }); | |
| 66 | } else if (at !== undefined && (at < range.min - 20 || at > range.max + 20)) { | |
| 67 | verdicts.push(ref && at >= ref.min - 20 && at <= ref.max + 20 ? { id, state: "restore", range: ref } : { id, state: "recalibrate", why: `it sits at ${at}, outside ${range.min}-${range.max}` }); | |
| 68 | } else { | |
| 69 | verdicts.push({ id, state: "ok" }); | |
| 70 | } | |
| 71 | } | |
| 72 | return verdicts; | |
| 73 | } |
Software/so101/src/arms.test.ts added +45
| @@ -0,0 +1,45 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { mkdtempSync } from "node:fs"; | |
| 3 | import { tmpdir } from "node:os"; | |
| 4 | import { join } from "node:path"; | |
| 5 | import { adaptersOf, resolveRoles, foundSentence, loadRoles, movement, saveRoles, savedRoles } from "./arms.ts"; | |
| 6 | ||
| 7 | test("adapters: USB serial only, one per board, macOS callout device", () => { | |
| 8 | const list = [ | |
| 9 | { path: "/dev/tty.usbmodem1", serialNumber: "A" }, | |
| 10 | { path: "/dev/cu.usbmodem1", serialNumber: "A" }, | |
| 11 | { path: "/dev/cu.Bluetooth-Incoming-Port" }, | |
| 12 | { path: "/dev/cu.usbmodem2", serialNumber: "B" }, | |
| 13 | ]; | |
| 14 | expect(adaptersOf(list, "darwin")).toEqual([ | |
| 15 | { path: "/dev/cu.usbmodem1", serial: "A" }, | |
| 16 | { path: "/dev/cu.usbmodem2", serial: "B" }, | |
| 17 | ]); | |
| 18 | }); | |
| 19 | ||
| 20 | test("the sentence for one arm says what to do", () => { | |
| 21 | expect(foundSentence(1)).toBe("I see 1 arm. Plug in the other one, or check its power. Watching…"); | |
| 22 | }); | |
| 23 | ||
| 24 | test("movement sums ticks over joints both readings share", () => { | |
| 25 | expect(movement(new Map([[1, 100], [2, 200]]), new Map([[1, 150], [2, 180], [3, 9]]))).toBe(70); | |
| 26 | }); | |
| 27 | ||
| 28 | test("roles are remembered by serial and found again on other ports", () => { | |
| 29 | const file = join(mkdtempSync(join(tmpdir(), "arms-")), "nested", "arms.json"); | |
| 30 | expect(loadRoles(file)).toBeNull(); | |
| 31 | saveRoles(file, { leader: "B", follower: "A" }); | |
| 32 | const found = savedRoles(loadRoles(file), [{ path: "/dev/x", serial: "A" }, { path: "/dev/y", serial: "B" }]); | |
| 33 | expect(found?.leader.path).toBe("/dev/y"); | |
| 34 | expect(savedRoles(loadRoles(file), [{ path: "/dev/x", serial: "A" }])).toBeNull(); | |
| 35 | }); | |
| 36 | ||
| 37 | test("teleop's ports: the command line wins, else the remembered roles, else a plain error", () => { | |
| 38 | const adapters = [{ path: "/dev/x", serial: "A" }, { path: "/dev/y", serial: "B" }]; | |
| 39 | const saved = { leader: "B", follower: "A" }; | |
| 40 | expect(resolveRoles("/dev/l", "/dev/f", null, [])).toEqual({ leader: "/dev/l", follower: "/dev/f" }); | |
| 41 | expect(resolveRoles(undefined, undefined, saved, adapters)).toEqual({ leader: "/dev/y", follower: "/dev/x" }); | |
| 42 | expect(resolveRoles("/dev/l", undefined, saved, adapters)).toEqual({ leader: "/dev/l", follower: "/dev/x" }); | |
| 43 | expect(() => resolveRoles(undefined, undefined, null, adapters)).toThrow("run: so101 setup"); | |
| 44 | expect(() => resolveRoles(undefined, undefined, saved, [adapters[0]!])).toThrow("run: so101 setup"); | |
| 45 | }); |
Software/so101/src/arms.ts added +93
| @@ -0,0 +1,93 @@ | ||
| 1 | /** Finding the two arms: which USB adapters are arms, which one is the leader, and remembering that by serial number. */ | |
| 2 | ||
| 3 | import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; | |
| 4 | import { homedir } from "node:os"; | |
| 5 | import { dirname, join } from "node:path"; | |
| 6 | ||
| 7 | export const ADAPTER = /usbmodem|usbserial|ttyACM|ttyUSB/i; | |
| 8 | ||
| 9 | export interface Adapter { | |
| 10 | path: string; | |
| 11 | /** The USB serial number; stays with the board when it moves to another port. */ | |
| 12 | serial: string | null; | |
| 13 | } | |
| 14 | ||
| 15 | export interface RawPort { | |
| 16 | path: string; | |
| 17 | serialNumber?: string | undefined; | |
| 18 | } | |
| 19 | ||
| 20 | /** macOS exposes each port twice; the cu.* node is the right one for a host-initiated link. */ | |
| 21 | export function calloutDevice(path: string, platform: string = process.platform): string { | |
| 22 | return platform === "darwin" ? path.replace(/^\/dev\/tty\./, "/dev/cu.") : path; | |
| 23 | } | |
| 24 | ||
| 25 | /** The USB serial adapters among `SerialPort.list()`, once each, in a stable order. */ | |
| 26 | export function adaptersOf(list: readonly RawPort[], platform: string = process.platform): Adapter[] { | |
| 27 | const seen = new Map<string, Adapter>(); | |
| 28 | for (const port of list) { | |
| 29 | if (!ADAPTER.test(port.path)) continue; | |
| 30 | const path = calloutDevice(port.path, platform); | |
| 31 | seen.set(path, { path, serial: port.serialNumber ?? null }); | |
| 32 | } | |
| 33 | return [...seen.values()].sort((a, b) => a.path.localeCompare(b.path)); | |
| 34 | } | |
| 35 | ||
| 36 | export function foundSentence(count: number): string { | |
| 37 | if (count >= 2) return `I see ${count} arms.`; | |
| 38 | if (count === 1) return "I see 1 arm. Plug in the other one, or check its power. Watching…"; | |
| 39 | return "I see no arms. Plug them in and check their power. Watching…"; | |
| 40 | } | |
| 41 | ||
| 42 | /** Total ticks the joints moved between two readings; a joint missing from one is ignored. */ | |
| 43 | export function movement(before: ReadonlyMap<number, number>, after: ReadonlyMap<number, number>): number { | |
| 44 | let total = 0; | |
| 45 | for (const [id, ticks] of after) { | |
| 46 | const was = before.get(id); | |
| 47 | if (was !== undefined) total += Math.abs(ticks - was); | |
| 48 | } | |
| 49 | return total; | |
| 50 | } | |
| 51 | ||
| 52 | /** Ticks of total movement (about 20°) that mean "this is the arm being held". */ | |
| 53 | export const WIGGLE_TICKS = 220; | |
| 54 | ||
| 55 | export interface Roles { | |
| 56 | leader: string; | |
| 57 | follower: string; | |
| 58 | } | |
| 59 | ||
| 60 | /** Serial numbers of the arms, leader and follower; the saved choice by serial, or null when it does not apply to these adapters. */ | |
| 61 | export function savedRoles(saved: Roles | null, adapters: readonly Adapter[]): { leader: Adapter; follower: Adapter } | null { | |
| 62 | if (!saved) return null; | |
| 63 | const leader = adapters.find((a) => a.serial === saved.leader); | |
| 64 | const follower = adapters.find((a) => a.serial === saved.follower); | |
| 65 | return leader && follower && leader !== follower ? { leader, follower } : null; | |
| 66 | } | |
| 67 | ||
| 68 | /** The two ports `so101 teleop` uses: those named on the command line, else the remembered roles found among the adapters. */ | |
| 69 | export function resolveRoles(leaderPath: string | undefined, followerPath: string | undefined, saved: Roles | null, adapters: readonly Adapter[]): { leader: string; follower: string } { | |
| 70 | if (leaderPath && followerPath) return { leader: leaderPath, follower: followerPath }; | |
| 71 | const found = savedRoles(saved, adapters); | |
| 72 | if (!found) throw new Error("I don't know which arm is the leader yet — run: so101 setup (or pass --leader-port and --follower-port)"); | |
| 73 | return { leader: leaderPath ?? found.leader.path, follower: followerPath ?? found.follower.path }; | |
| 74 | } | |
| 75 | ||
| 76 | export function configFile(env: Record<string, string | undefined> = process.env): string { | |
| 77 | return join(env.SO101_HOME ?? env.FORGE_APP_DATA ?? join(env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "so101"), "arms.json"); | |
| 78 | } | |
| 79 | ||
| 80 | export function loadRoles(file: string): Roles | null { | |
| 81 | try { | |
| 82 | const stored = JSON.parse(readFileSync(file, "utf8")) as Partial<Roles>; | |
| 83 | return typeof stored.leader === "string" && typeof stored.follower === "string" ? { leader: stored.leader, follower: stored.follower } : null; | |
| 84 | } catch { | |
| 85 | return null; | |
| 86 | } | |
| 87 | } | |
| 88 | ||
| 89 | export function saveRoles(file: string, roles: Roles): void { | |
| 90 | mkdirSync(dirname(file), { recursive: true }); | |
| 91 | writeFileSync(`${file}.tmp`, JSON.stringify(roles)); | |
| 92 | renameSync(`${file}.tmp`, file); | |
| 93 | } |
Software/so101/src/cli-node.test.ts added +25
| @@ -0,0 +1,25 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { spawnSync } from "node:child_process"; | |
| 3 | ||
| 4 | // The README promises a fresh clone runs under Node 22.6+ (types stripped at load). A Bun-only import reached from the CLI | |
| 5 | // (bun:sqlite, say) crashes it on a laptop while every Bun test passes. | |
| 6 | const version = spawnSync("node", ["--version"], { encoding: "utf8" }); | |
| 7 | const [major = 0, minor = 0] = (version.stdout ?? "").replace(/^v/, "").split(".").map(Number); | |
| 8 | const strips = version.status === 0 && (major > 22 || (major === 22 && minor >= 6)); | |
| 9 | const cli = new URL("../bin/so101", import.meta.url).pathname; | |
| 10 | ||
| 11 | function so101(...args: string[]) { | |
| 12 | return spawnSync("node", ["--experimental-strip-types", "--no-warnings", cli, ...args], { encoding: "utf8", timeout: 30_000, env: { ...process.env, SO101_HOME: process.env.TMPDIR ?? "/tmp" } }); | |
| 13 | } | |
| 14 | ||
| 15 | test.skipIf(!strips)("--help, ports and setup --dry-run run under Node", () => { | |
| 16 | const help = so101("--help"); | |
| 17 | expect(help.stderr).toBe(""); | |
| 18 | expect(help.stdout).toContain("setup"); | |
| 19 | expect(help.status).toBe(0); | |
| 20 | expect(so101("ports").status).toBe(0); | |
| 21 | const dry = so101("setup", "--dry-run"); | |
| 22 | expect(dry.stderr).not.toMatch(/ERR_/); | |
| 23 | expect(dry.stdout).toContain("dry run"); | |
| 24 | expect(dry.status).toBe(0); | |
| 25 | }); |
Software/so101/src/cli.ts +21 −1
| @@ -6,7 +6,6 @@ import { describeError, hex } from "./protocol.ts"; | ||
| 6 | 6 | import { MAX_FRAME_STEP, MotionError, moveToPose, name as jointName, playTrajectory, recordTrajectory, sleep } from "./motion.ts"; |
| 7 | 7 | import { streamLeader } from "./leader.ts"; |
| 8 | 8 | import { assertPoseName, parsePoseFile, serializePoseFile, type PoseFile } from "./poses.ts"; |
| 9 | import { startServer } from "./server.ts"; | |
| 10 | 9 | import { durationSeconds, largestStep, parseTrajectory, serializeTrajectory } from "./trajectory.ts"; |
| 11 | 10 | import { |
| 12 | 11 | BAUD_RATES, |
| @@ -50,6 +49,9 @@ usage: so101 <command> [options] | ||
| 50 | 49 | HTTP API + WebSocket target stream for this arm (run where the follower is) |
| 51 | 50 | leader --to ws://host:8101 [--hz 60] |
| 52 | 51 | read this leader arm and drive a remote follower through its serve |
| 52 | setup [--dry-run] guided: find both arms, reuse calibration, then drive | |
| 53 | teleop [--leader-port p --follower-port p] [--hz 60] | |
| 54 | the leader arm drives the follower here, space stops it | |
| 53 | 55 | |
| 54 | 56 | options: |
| 55 | 57 | --port <path> serial port (default: $SO101_PORT, else the only USB adapter) |
| @@ -75,6 +77,9 @@ const OPTIONS = { | ||
| 75 | 77 | listen: { type: "string" }, |
| 76 | 78 | to: { type: "string" }, |
| 77 | 79 | model: { type: "string" }, |
| 80 | "dry-run": { type: "boolean" }, | |
| 81 | "leader-port": { type: "string" }, | |
| 82 | "follower-port": { type: "string" }, | |
| 78 | 83 | } as const; |
| 79 | 84 | |
| 80 | 85 | interface Values { |
| @@ -96,6 +101,9 @@ interface Values { | ||
| 96 | 101 | listen?: string; |
| 97 | 102 | model?: string; |
| 98 | 103 | to?: string; |
| 104 | "dry-run"?: boolean; | |
| 105 | "leader-port"?: string; | |
| 106 | "follower-port"?: string; | |
| 99 | 107 | } |
| 100 | 108 | |
| 101 | 109 | type Command = (values: Values, positionals: string[]) => Promise<void>; |
| @@ -498,6 +506,7 @@ const COMMANDS: Record<string, Command> = { | ||
| 498 | 506 | const [host, portText] = (values.listen ?? "0.0.0.0:8101").split(":"); |
| 499 | 507 | const port = bounded(integer(portText ?? "8101", "--listen port"), 1, 65535, "--listen port"); |
| 500 | 508 | return withBus(values, async (bus) => { |
| 509 | const { startServer } = await import("./server.ts"); // lazily: it reaches bun:sqlite, which Node lacks | |
| 501 | 510 | const server = await startServer(bus, { host, port, posesFile: values.file ?? "poses.json", modelDir: values.model, gainSyncMs: 2000 }); |
| 502 | 511 | console.log(`serving ${bus.path} on http://${host}:${server.port} — open it in a browser; the API is listed in src/server.ts`); |
| 503 | 512 | console.log("ctrl-c to stop"); |
| @@ -527,6 +536,17 @@ const COMMANDS: Record<string, Command> = { | ||
| 527 | 536 | }); |
| 528 | 537 | }, |
| 529 | 538 | |
| 539 | setup: async (values) => { | |
| 540 | const { dryRun, setup } = await import("./setup.ts"); | |
| 541 | if (values["dry-run"]) return dryRun(); | |
| 542 | await setup({ hz: bounded(integer(values.hz ?? "60", "--hz"), 1, 120, "--hz") }); | |
| 543 | }, | |
| 544 | ||
| 545 | teleop: async (values) => { | |
| 546 | const { teleop } = await import("./setup.ts"); | |
| 547 | await teleop(values["leader-port"], values["follower-port"], bounded(integer(values.hz ?? "60", "--hz"), 1, 120, "--hz")); | |
| 548 | }, | |
| 549 | ||
| 530 | 550 | "set-id": (values, [currentArg, nextArg]) => { |
| 531 | 551 | if (currentArg === undefined || nextArg === undefined) throw new Error("usage: so101 set-id <current> <new>"); |
| 532 | 552 | const current = servoId(currentArg, "current id"); |
Software/so101/src/keys.test.ts added +30
| @@ -0,0 +1,30 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { keyQueue } from "./keys.ts"; | |
| 3 | ||
| 4 | test("a key pressed after a step ends is not lost to that step's waiter", async () => { | |
| 5 | const keys = keyQueue(); | |
| 6 | const step = new AbortController(); | |
| 7 | const stale = keys.next(step.signal); // the step that ended without a key | |
| 8 | step.abort(); | |
| 9 | expect(await stale).toBeNull(); | |
| 10 | keys.push("down"); | |
| 11 | expect(await keys.next()).toBe("down"); | |
| 12 | }); | |
| 13 | ||
| 14 | test("keys pressed while nobody waits are kept in order; an aborted signal returns at once", async () => { | |
| 15 | const keys = keyQueue(); | |
| 16 | keys.push("a"); | |
| 17 | keys.push("b"); | |
| 18 | const done = new AbortController(); | |
| 19 | done.abort(); | |
| 20 | expect(await keys.next(done.signal)).toBe("a"); | |
| 21 | expect(await keys.next()).toBe("b"); | |
| 22 | expect(await keys.next(done.signal)).toBeNull(); | |
| 23 | }); | |
| 24 | ||
| 25 | test("a waiting step gets the key", async () => { | |
| 26 | const keys = keyQueue(); | |
| 27 | const waiting = keys.next(new AbortController().signal); | |
| 28 | keys.push("return"); | |
| 29 | expect(await waiting).toBe("return"); | |
| 30 | }); |
Software/so101/src/keys.ts added +61
| @@ -0,0 +1,61 @@ | ||
| 1 | /** Key presses as a queue that no step can lose a key from: a waiter lives only as long as its signal. */ | |
| 2 | ||
| 3 | import { emitKeypressEvents } from "node:readline"; | |
| 4 | ||
| 5 | export interface Keys { | |
| 6 | /** The next key, or null when `signal` aborts first. A key pressed while nobody waits is kept for the next caller. */ | |
| 7 | next(signal?: AbortSignal): Promise<string | null>; | |
| 8 | close(): void; | |
| 9 | } | |
| 10 | ||
| 11 | export interface KeyQueue extends Keys { | |
| 12 | push(key: string): void; | |
| 13 | } | |
| 14 | ||
| 15 | export function keyQueue(onClose: () => void = () => {}): KeyQueue { | |
| 16 | const queue: string[] = []; | |
| 17 | const waiters = new Set<(key: string) => void>(); | |
| 18 | return { | |
| 19 | push(key) { | |
| 20 | const waiter = waiters.values().next().value; | |
| 21 | if (waiter) { | |
| 22 | waiters.delete(waiter); | |
| 23 | waiter(key); | |
| 24 | } else queue.push(key); | |
| 25 | }, | |
| 26 | next(signal) { | |
| 27 | if (queue.length) return Promise.resolve(queue.shift()!); | |
| 28 | if (signal?.aborted) return Promise.resolve(null); | |
| 29 | return new Promise((resolve) => { | |
| 30 | const waiter = (key: string) => { | |
| 31 | signal?.removeEventListener("abort", abort); | |
| 32 | resolve(key); | |
| 33 | }; | |
| 34 | const abort = () => { | |
| 35 | waiters.delete(waiter); | |
| 36 | resolve(null); | |
| 37 | }; | |
| 38 | waiters.add(waiter); | |
| 39 | signal?.addEventListener("abort", abort, { once: true }); | |
| 40 | }); | |
| 41 | }, | |
| 42 | close: onClose, | |
| 43 | }; | |
| 44 | } | |
| 45 | ||
| 46 | /** Single key presses from the terminal: "up", "down", "return", "space", "q", "ctrl-c" … */ | |
| 47 | export function terminalKeys(): Keys { | |
| 48 | emitKeypressEvents(process.stdin); | |
| 49 | if (process.stdin.isTTY) process.stdin.setRawMode(true); | |
| 50 | process.stdin.resume(); | |
| 51 | const queue = keyQueue(() => { | |
| 52 | process.stdin.off("keypress", on); | |
| 53 | if (process.stdin.isTTY) process.stdin.setRawMode(false); | |
| 54 | process.stdin.pause(); | |
| 55 | }); | |
| 56 | const on = (_: unknown, key: { name?: string; ctrl?: boolean; sequence?: string } | undefined) => { | |
| 57 | queue.push(key?.ctrl && key.name === "c" ? "ctrl-c" : (key?.name ?? key?.sequence ?? "")); | |
| 58 | }; | |
| 59 | process.stdin.on("keypress", on); | |
| 60 | return queue; | |
| 61 | } |
Software/so101/src/local-teleop.test.ts added +13
| @@ -0,0 +1,13 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { percentile, teleopScreen } from "./local-teleop.ts"; | |
| 3 | ||
| 4 | test("percentile of a sorted list", () => { | |
| 5 | expect(percentile([1, 2, 3, 4], 0.5)).toBe(3); | |
| 6 | expect(percentile([], 0.95)).toBe(0); | |
| 7 | }); | |
| 8 | ||
| 9 | test("the screen shows STOP while live and says held when stopped", () => { | |
| 10 | const s = { sent: 3, lagMs: { p50: 1, p95: 2 }, leader: new Map([[1, 2000]]), target: { "1": 2010 }, stopped: false }; | |
| 11 | expect(teleopScreen(s).join("\n")).toContain("press SPACE to STOP"); | |
| 12 | expect(teleopScreen({ ...s, stopped: true }).join("\n")).toContain("STOPPED"); | |
| 13 | }); |
Software/so101/src/local-teleop.ts added +102
| @@ -0,0 +1,102 @@ | ||
| 1 | /** The leader drives the follower directly, in this one process: no serve, no network. */ | |
| 2 | ||
| 3 | import type { FeetechBus } from "./bus.ts"; | |
| 4 | import { MAX_FRAME_STEP } from "./motion.ts"; | |
| 5 | import { mapFrame, type Range } from "./mapping.ts"; | |
| 6 | import { JOINTS, STS3215 } from "./sts3215.ts"; | |
| 7 | ||
| 8 | export const IDS = [1, 2, 3, 4, 5, 6] as const; | |
| 9 | const GRIPPER = 6; | |
| 10 | /** Most ticks a goal moves per frame while catching up with the leader: 400 at once is the most motion.ts allows between frames; this is gentler at 60 Hz. */ | |
| 11 | export const RAMP_STEP = Math.round(MAX_FRAME_STEP / 8); | |
| 12 | ||
| 13 | export interface TeleopSnapshot { | |
| 14 | sent: number; | |
| 15 | /** Leader reading → follower target written, ms, over the last second or so. */ | |
| 16 | lagMs: { p50: number; p95: number }; | |
| 17 | leader: ReadonlyMap<number, number>; | |
| 18 | target: Record<string, number>; | |
| 19 | stopped: boolean; | |
| 20 | } | |
| 21 | ||
| 22 | export interface LocalTeleopOptions { | |
| 23 | hz?: number; | |
| 24 | signal?: AbortSignal; | |
| 25 | /** While true the follower is not sent anything and holds where it is. */ | |
| 26 | stopped?: () => boolean; | |
| 27 | onTick?: (snapshot: TeleopSnapshot) => void; | |
| 28 | } | |
| 29 | ||
| 30 | export async function readLimits(bus: FeetechBus, ids: readonly number[] = IDS): Promise<Map<number, Range>> { | |
| 31 | const limits = new Map<number, Range>(); | |
| 32 | for (const id of ids) limits.set(id, { min: await bus.read(id, STS3215.Min_Position_Limit), max: await bus.read(id, STS3215.Max_Position_Limit) }); | |
| 33 | return limits; | |
| 34 | } | |
| 35 | ||
| 36 | export function percentile(sorted: readonly number[], p: number): number { | |
| 37 | return sorted.length ? sorted[Math.min(sorted.length - 1, Math.floor(p * sorted.length))]! : 0; | |
| 38 | } | |
| 39 | ||
| 40 | /** Runs until the signal aborts; returns the frames sent. Torque on the follower stays on, so it holds where it ended. */ | |
| 41 | export async function runLocalTeleop(leader: FeetechBus, follower: FeetechBus, { hz = 60, signal, stopped = () => false, onTick }: LocalTeleopOptions = {}): Promise<number> { | |
| 42 | const leaderLimits = await readLimits(leader); | |
| 43 | const followerLimits = Object.fromEntries([...(await readLimits(follower))].map(([id, r]) => [String(id), r])); | |
| 44 | await leader.syncWrite(STS3215.Torque_Enable, IDS.map((id) => [id, 0] as const)); // the leader is moved by hand | |
| 45 | // Start from where the follower is, so the first frame does not snap it across the room. | |
| 46 | const here = await follower.syncRead(IDS, STS3215.Present_Position); | |
| 47 | await follower.syncWrite(STS3215.Goal_Position, here); | |
| 48 | await follower.syncWrite(STS3215.Torque_Enable, IDS.map((id) => [id, 1] as const)); | |
| 49 | const period = 1000 / hz; | |
| 50 | const lags: number[] = []; | |
| 51 | const started = performance.now(); | |
| 52 | let sent = 0; | |
| 53 | let loops = 0; | |
| 54 | // After a stop the leader may be far from where the follower waits: walk the goal there in bounded steps, never jump. | |
| 55 | let goal = new Map<number, number>(here); | |
| 56 | let wasStopped = false; | |
| 57 | while (!signal?.aborted) { | |
| 58 | const t0 = performance.now(); | |
| 59 | const reading = await leader.syncRead(IDS, STS3215.Present_Position); | |
| 60 | const frame = mapFrame(reading, leaderLimits, followerLimits, GRIPPER); | |
| 61 | const hold = stopped(); | |
| 62 | if (hold) wasStopped = true; | |
| 63 | else if (Object.keys(frame).length) { | |
| 64 | if (wasStopped) { | |
| 65 | goal = new Map(await follower.syncRead(IDS, STS3215.Present_Position)); // re-seed from where the follower really is | |
| 66 | wasStopped = false; | |
| 67 | } | |
| 68 | const next = Object.entries(frame).map(([id, ticks]) => { | |
| 69 | const from = goal.get(Number(id)) ?? ticks; | |
| 70 | const to = from + Math.max(-RAMP_STEP, Math.min(RAMP_STEP, ticks - from)); | |
| 71 | goal.set(Number(id), to); | |
| 72 | return [Number(id), to] as const; | |
| 73 | }); | |
| 74 | await follower.syncWrite(STS3215.Goal_Position, next); | |
| 75 | sent++; | |
| 76 | } | |
| 77 | lags.push(performance.now() - t0); | |
| 78 | if (lags.length > hz) lags.shift(); | |
| 79 | const sorted = [...lags].sort((a, b) => a - b); | |
| 80 | onTick?.({ sent, lagMs: { p50: percentile(sorted, 0.5), p95: percentile(sorted, 0.95) }, leader: reading, target: frame, stopped: hold }); | |
| 81 | loops++; | |
| 82 | const wait = started + loops * period - performance.now(); | |
| 83 | if (wait > 0) await new Promise((r) => setTimeout(r, wait)); | |
| 84 | } | |
| 85 | return sent; | |
| 86 | } | |
| 87 | ||
| 88 | export function teleopScreen(s: TeleopSnapshot): string[] { | |
| 89 | const rows = IDS.map((id) => { | |
| 90 | const target = s.target[String(id)]; | |
| 91 | return ` ${(JOINTS[id] ?? "").padEnd(14)} leader ${String(s.leader.get(id) ?? "-").padStart(5)} → follower ${String(target ?? "-").padStart(5)}`; | |
| 92 | }); | |
| 93 | return [ | |
| 94 | "so101 teleop — the leader arm drives the follower", | |
| 95 | "", | |
| 96 | ...rows, | |
| 97 | "", | |
| 98 | ` lag ${s.lagMs.p50.toFixed(1)} ms typical, ${s.lagMs.p95.toFixed(1)} ms worst frames ${s.sent}`, | |
| 99 | "", | |
| 100 | s.stopped ? " STOPPED — the follower holds. Press space to go on, q to quit (torque stays on)." : " ● LIVE press SPACE to STOP, q to quit (q leaves the follower holding, torque on)", | |
| 101 | ]; | |
| 102 | } |
Software/so101/src/setup.test.ts added +130
| @@ -0,0 +1,130 @@ | ||
| 1 | import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; | |
| 2 | import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; | |
| 3 | import { tmpdir } from "node:os"; | |
| 4 | import { join } from "node:path"; | |
| 5 | import { FeetechBus } from "./bus.ts"; | |
| 6 | import { FakeBus } from "./fake-bus.ts"; | |
| 7 | import { keyQueue } from "./keys.ts"; | |
| 8 | import { IDS, RAMP_STEP, runLocalTeleop } from "./local-teleop.ts"; | |
| 9 | import { driveScreen, ensureCalibrated, recalibrateJoint } from "./setup.ts"; | |
| 10 | import { STS3215 } from "./sts3215.ts"; | |
| 11 | ||
| 12 | const arm = () => { | |
| 13 | const fake = new FakeBus([...IDS]); | |
| 14 | return { fake, bus: new FeetechBus(fake) }; | |
| 15 | }; | |
| 16 | const setPresent = (fake: FakeBus, id: number, ticks: number) => { | |
| 17 | const m = fake.servos.get(id)!; | |
| 18 | m[56] = ticks & 0xff; | |
| 19 | m[57] = ticks >> 8; | |
| 20 | }; | |
| 21 | const limits = async (bus: FeetechBus, id: number) => [await bus.read(id, STS3215.Min_Position_Limit), await bus.read(id, STS3215.Max_Position_Limit)]; | |
| 22 | const settle = (ms: number) => new Promise((r) => setTimeout(r, ms)); | |
| 23 | ||
| 24 | let out: ReturnType<typeof spyOn>; | |
| 25 | beforeEach(() => void (out = spyOn(process.stdout, "write").mockImplementation(() => true))); | |
| 26 | afterEach(() => out.mockRestore()); | |
| 27 | ||
| 28 | async function calibrated(fake: FakeBus, bus: FeetechBus) { | |
| 29 | for (const id of IDS) { | |
| 30 | await bus.write(id, STS3215.Min_Position_Limit, 1000); | |
| 31 | await bus.write(id, STS3215.Max_Position_Limit, 3000); | |
| 32 | setPresent(fake, id, 2000); | |
| 33 | } | |
| 34 | } | |
| 35 | ||
| 36 | test("teleop: the follower follows the leader, holds while stopped, and resumes without a jump", async () => { | |
| 37 | const leader = arm(); | |
| 38 | const follower = arm(); | |
| 39 | await calibrated(leader.fake, leader.bus); | |
| 40 | await calibrated(follower.fake, follower.bus); | |
| 41 | let stopped = false; | |
| 42 | const controller = new AbortController(); | |
| 43 | const run = runLocalTeleop(leader.bus, follower.bus, { hz: 200, signal: controller.signal, stopped: () => stopped }); | |
| 44 | await settle(40); | |
| 45 | setPresent(leader.fake, 1, 2100); | |
| 46 | await settle(60); | |
| 47 | expect(follower.fake.position(1)).toBe(2100); | |
| 48 | ||
| 49 | stopped = true; | |
| 50 | await settle(30); | |
| 51 | setPresent(leader.fake, 1, 2900); // moved far while stopped | |
| 52 | await settle(60); | |
| 53 | expect(follower.fake.position(1)).toBe(2100); // STOP holds | |
| 54 | ||
| 55 | const goals: number[] = []; | |
| 56 | const before = follower.fake.writes.length; | |
| 57 | stopped = false; | |
| 58 | await settle(120); | |
| 59 | controller.abort(); | |
| 60 | await run; | |
| 61 | for (const w of follower.fake.writes.slice(before)) if (w.address === STS3215.Goal_Position.address) goals.push(...[w.bytes[0]! | (w.bytes[1]! << 8)]); | |
| 62 | expect(goals.length).toBeGreaterThan(0); | |
| 63 | expect(Math.abs(goals[0]! - 2100)).toBeLessThanOrEqual(RAMP_STEP); // the first goal after resume is one bounded step away | |
| 64 | expect(follower.fake.position(1)).toBeGreaterThan(2100); | |
| 65 | }); | |
| 66 | ||
| 67 | test("recalibrateJoint: writes the swept range of just that joint when enter is pressed", async () => { | |
| 68 | const { fake, bus } = arm(); | |
| 69 | const keys = keyQueue(); | |
| 70 | const done = recalibrateJoint(keys, bus, 2, "test"); | |
| 71 | for (const at of [1500, 400, 3500, 2000]) { | |
| 72 | setPresent(fake, 2, at); | |
| 73 | await settle(80); | |
| 74 | } | |
| 75 | keys.push("return"); | |
| 76 | await done; | |
| 77 | expect(await limits(bus, 2)).toEqual([400, 3500]); | |
| 78 | expect(await limits(bus, 3)).toEqual([0, 4095]); | |
| 79 | }); | |
| 80 | ||
| 81 | test("recalibrateJoint: refuses a sweep that barely moved", async () => { | |
| 82 | const { bus } = arm(); | |
| 83 | const keys = keyQueue(); | |
| 84 | const done = recalibrateJoint(keys, bus, 2, "test"); | |
| 85 | await settle(120); | |
| 86 | keys.push("return"); | |
| 87 | await expect(done).rejects.toThrow("only moved"); | |
| 88 | }); | |
| 89 | ||
| 90 | test("ensureCalibrated: a forgotten range comes back from LeRobot's file; a good one is left alone", async () => { | |
| 91 | const home = mkdtempSync(join(tmpdir(), "lerobot-")); | |
| 92 | const dir = join(home, "lerobot", "calibration", "robots"); | |
| 93 | mkdirSync(dir, { recursive: true }); | |
| 94 | writeFileSync(join(dir, "so101.json"), JSON.stringify({ shoulder_pan: { id: 1, range_min: 800, range_max: 3200 } })); | |
| 95 | const old = process.env.HF_HOME; | |
| 96 | process.env.HF_HOME = home; | |
| 97 | try { | |
| 98 | const { fake, bus } = arm(); | |
| 99 | await bus.write(2, STS3215.Min_Position_Limit, 700); | |
| 100 | await bus.write(2, STS3215.Max_Position_Limit, 3300); | |
| 101 | for (const id of [3, 4, 5, 6]) { | |
| 102 | await bus.write(id, STS3215.Min_Position_Limit, 700); | |
| 103 | await bus.write(id, STS3215.Max_Position_Limit, 3300); | |
| 104 | } | |
| 105 | await ensureCalibrated(keyQueue(), bus, "robots", "follower"); | |
| 106 | expect(await limits(bus, 1)).toEqual([800, 3200]); | |
| 107 | expect(await limits(bus, 2)).toEqual([700, 3300]); | |
| 108 | expect(fake.position(1)).toBe(2048); | |
| 109 | } finally { | |
| 110 | if (old === undefined) delete process.env.HF_HOME; | |
| 111 | else process.env.HF_HOME = old; | |
| 112 | } | |
| 113 | }); | |
| 114 | ||
| 115 | test("driveScreen: space stops, q ends it, and no key waiter is left behind", async () => { | |
| 116 | const leader = arm(); | |
| 117 | const follower = arm(); | |
| 118 | await calibrated(leader.fake, leader.bus); | |
| 119 | await calibrated(follower.fake, follower.bus); | |
| 120 | const keys = keyQueue(); | |
| 121 | const done = driveScreen(keys, leader.bus, follower.bus, 100); | |
| 122 | await settle(50); | |
| 123 | keys.push("space"); | |
| 124 | await settle(30); | |
| 125 | keys.push("q"); | |
| 126 | await done; | |
| 127 | keys.push("down"); // the next step's key | |
| 128 | expect(await keys.next()).toBe("down"); | |
| 129 | expect(await follower.bus.read(1, STS3215.Torque_Enable)).toBe(1); // q leaves torque on | |
| 130 | }); |
Software/so101/src/setup.ts added +224
| @@ -0,0 +1,224 @@ | ||
| 1 | /** `so101 setup`: a guided, full-screen walk from two plugged-in arms to driving one with the other. */ | |
| 2 | ||
| 3 | import { SerialPort } from "serialport"; | |
| 4 | import { adaptersOf, resolveRoles, configFile, foundSentence, loadRoles, movement, saveRoles, savedRoles, WIGGLE_TICKS, type Adapter } from "./arms.ts"; | |
| 5 | import { judge, lerobotDirs, newestLerobot, type Verdict } from "./arm-calibration.ts"; | |
| 6 | import { FeetechBus } from "./bus.ts"; | |
| 7 | import { IDS, readLimits, runLocalTeleop, teleopScreen } from "./local-teleop.ts"; | |
| 8 | import { sleep } from "./motion.ts"; | |
| 9 | import { JOINTS, STS3215 } from "./sts3215.ts"; | |
| 10 | import { terminalKeys, type Keys } from "./keys.ts"; | |
| 11 | ||
| 12 | const STEPS = ["Find the arms", "Which is the leader", "Calibration", "Drive"]; | |
| 13 | ||
| 14 | export function progress(step: number): string { | |
| 15 | return STEPS.map((name, i) => (i === step ? `[${i + 1} ${name}]` : `${i < step ? "✓" : " "}${i + 1} ${name}`)).join(" › "); | |
| 16 | } | |
| 17 | ||
| 18 | function show(step: number, lines: string[]): void { | |
| 19 | const frame = ["so101 setup", progress(step), "", ...lines, ""].join("\n"); | |
| 20 | process.stdout.write(process.stdout.isTTY ? `\x1b[2J\x1b[H${frame}\n` : `${frame}\n`); | |
| 21 | } | |
| 22 | ||
| 23 | /** The dry run: what setup would do on this computer, touching no servo. */ | |
| 24 | export async function dryRun(write: (line: string) => void = console.log): Promise<void> { | |
| 25 | const adapters = adaptersOf(await SerialPort.list()); | |
| 26 | const saved = loadRoles(configFile()); | |
| 27 | write("so101 setup (dry run) — nothing is moved or written"); | |
| 28 | write(progress(0)); | |
| 29 | write(` 1. ${foundSentence(adapters.length)} ${adapters.map((a) => `${a.path}${a.serial ? ` (#${a.serial})` : ""}`).join(", ")}`); | |
| 30 | write(` 2. ${savedRoles(saved, adapters) ? "leader remembered by serial number" : "you wiggle the arm you will hold; its serial number is remembered"} (${configFile()})`); | |
| 31 | for (const kind of ["robots", "teleoperators"] as const) { | |
| 32 | const found = newestLerobot(lerobotDirs(kind)); | |
| 33 | write(` 3. LeRobot ${kind} calibration: ${found.size ? `${found.size} joints found, reused where the servos disagree` : "none found"}`); | |
| 34 | } | |
| 35 | write(" each servo's own limits are checked; only a joint out of range is calibrated again, one at a time"); | |
| 36 | write(" 4. so101 teleop drives the follower from the leader at 60 Hz; space stops it"); | |
| 37 | } | |
| 38 | ||
| 39 | async function choose(key: Keys, step: number, title: string, options: string[]): Promise<number> { | |
| 40 | let at = 0; | |
| 41 | for (;;) { | |
| 42 | show(step, [title, "", ...options.map((o, i) => ` ${i === at ? "›" : " "} ${o}`), "", " ↑/↓ to choose, enter to confirm, q to quit"]); | |
| 43 | const k = await key.next(); | |
| 44 | if (k === "up") at = (at + options.length - 1) % options.length; | |
| 45 | else if (k === "down") at = (at + 1) % options.length; | |
| 46 | else if (k === "return") return at; | |
| 47 | else if (k === "q" || k === "ctrl-c") throw new Quit(); | |
| 48 | } | |
| 49 | } | |
| 50 | ||
| 51 | class Quit extends Error {} | |
| 52 | ||
| 53 | async function waitForArms(key: Keys): Promise<Adapter[]> { | |
| 54 | for (;;) { | |
| 55 | const found = adaptersOf(await SerialPort.list()); | |
| 56 | show(0, [foundSentence(found.length), ...found.map((a) => ` • ${a.path}${a.serial ? ` #${a.serial}` : ""}`), "", " q to quit"]); | |
| 57 | if (found.length >= 2) return found; | |
| 58 | const step = new AbortController(); | |
| 59 | const timer = setTimeout(() => step.abort(), 1000); | |
| 60 | const waited = await key.next(step.signal); | |
| 61 | clearTimeout(timer); | |
| 62 | step.abort(); | |
| 63 | if (waited === "q" || waited === "ctrl-c") throw new Quit(); | |
| 64 | } | |
| 65 | } | |
| 66 | ||
| 67 | /** Watch both buses until one is moved by hand; that one is the leader. */ | |
| 68 | async function whoIsTheLeader(key: Keys, adapters: Adapter[]): Promise<{ leader: Adapter; follower: Adapter }> { | |
| 69 | const [a, b] = adapters as [Adapter, Adapter]; | |
| 70 | const buses = [await FeetechBus.open(a.path), await FeetechBus.open(b.path)] as const; | |
| 71 | try { | |
| 72 | const base = await Promise.all(buses.map((bus) => bus.syncRead(IDS, STS3215.Present_Position))); | |
| 73 | const step = new AbortController(); | |
| 74 | let stop = false; | |
| 75 | void key.next(step.signal).then((k) => (stop = k !== null)); | |
| 76 | try { | |
| 77 | while (!stop) { | |
| 78 | show(1, ["Wiggle the arm you'll hold (the leader).", "", ` watching ${a.path} and ${b.path}…`, "", " q to quit"]); | |
| 79 | await sleep(100); | |
| 80 | const now = await Promise.all(buses.map((bus) => bus.syncRead(IDS, STS3215.Present_Position))); | |
| 81 | const moved = now.map((m, i) => movement(base[i]!, m)); | |
| 82 | const [ma = 0, mb = 0] = moved; | |
| 83 | if (Math.max(ma, mb) >= WIGGLE_TICKS && Math.abs(ma - mb) >= WIGGLE_TICKS / 2) return ma > mb ? { leader: a, follower: b } : { leader: b, follower: a }; | |
| 84 | } | |
| 85 | throw new Quit(); | |
| 86 | } finally { | |
| 87 | step.abort(); | |
| 88 | } | |
| 89 | } finally { | |
| 90 | await Promise.all(buses.map((bus) => bus.close())); | |
| 91 | } | |
| 92 | } | |
| 93 | ||
| 94 | /** One joint, one live bar: release the servo, watch the range the person sweeps, write it when they press enter. */ | |
| 95 | export async function recalibrateJoint(key: Keys, bus: FeetechBus, id: number, why: string): Promise<void> { | |
| 96 | await bus.write(id, STS3215.Torque_Enable, 0); | |
| 97 | let lo = 4095; | |
| 98 | let hi = 0; | |
| 99 | let done = false; | |
| 100 | const step = new AbortController(); | |
| 101 | void key.next(step.signal).then((k) => (done = k !== null)); | |
| 102 | try { | |
| 103 | while (!done) { | |
| 104 | const at = await bus.read(id, STS3215.Present_Position); | |
| 105 | lo = Math.min(lo, at); | |
| 106 | hi = Math.max(hi, at); | |
| 107 | const width = 40; | |
| 108 | const cell = (t: number) => Math.min(width - 1, Math.floor((t / 4096) * width)); | |
| 109 | const barCells = Array.from({ length: width }, (_, i) => (i === cell(at) ? "█" : i >= cell(lo) && i <= cell(hi) ? "▒" : "·")); | |
| 110 | show(2, [ | |
| 111 | `${JOINTS[id]} (servo ${id}) needs calibrating: ${why}.`, | |
| 112 | "", | |
| 113 | ` Move only the ${JOINTS[id]?.replace("_", " ")} through its whole travel, both ends.`, | |
| 114 | "", | |
| 115 | ` [${barCells.join("")}]`, | |
| 116 | ` now ${at} low ${lo} high ${hi}`, | |
| 117 | "", | |
| 118 | " enter when you have reached both ends", | |
| 119 | ]); | |
| 120 | await sleep(50); | |
| 121 | } | |
| 122 | } finally { | |
| 123 | step.abort(); | |
| 124 | } | |
| 125 | if (hi - lo < 200) throw new Error(`${JOINTS[id]} only moved ${hi - lo} ticks; run setup again and sweep its whole travel`); | |
| 126 | await bus.write(id, STS3215.Min_Position_Limit, lo); | |
| 127 | await bus.write(id, STS3215.Max_Position_Limit, hi); | |
| 128 | } | |
| 129 | ||
| 130 | export async function ensureCalibrated(key: Keys, bus: FeetechBus, kind: "robots" | "teleoperators", who: string): Promise<void> { | |
| 131 | const present = await bus.syncRead(IDS, STS3215.Present_Position); | |
| 132 | const verdicts: Verdict[] = judge(await readLimits(bus), present, newestLerobot(lerobotDirs(kind))); | |
| 133 | for (const v of verdicts) { | |
| 134 | if (v.state === "restore") await bus.write(v.id, STS3215.Min_Position_Limit, v.range.min).then(() => bus.write(v.id, STS3215.Max_Position_Limit, v.range.max)); | |
| 135 | else if (v.state === "recalibrate") await recalibrateJoint(key, bus, v.id, `${who}: ${v.why}`); | |
| 136 | } | |
| 137 | const fixed = verdicts.filter((v) => v.state !== "ok").length; | |
| 138 | show(2, [`${who}: ${fixed ? `${fixed} joint${fixed === 1 ? "" : "s"} fixed, the rest kept as calibrated` : "calibration is fine, kept as it is"}.`]); | |
| 139 | await sleep(700); | |
| 140 | } | |
| 141 | ||
| 142 | export interface SetupOptions { | |
| 143 | hz?: number; | |
| 144 | } | |
| 145 | ||
| 146 | /** Drive the follower from the leader on screen until q. Space stops and resumes; q leaves the follower holding with torque on. */ | |
| 147 | export async function driveScreen(key: Keys, leader: FeetechBus, follower: FeetechBus, hz = 60): Promise<void> { | |
| 148 | const controller = new AbortController(); | |
| 149 | let stopped = false; | |
| 150 | let last = 0; | |
| 151 | const listening = (async () => { | |
| 152 | for (;;) { | |
| 153 | const k = await key.next(controller.signal); | |
| 154 | if (k === null) return; | |
| 155 | if (k === "space") stopped = !stopped; | |
| 156 | else if (k === "q" || k === "ctrl-c") return controller.abort(); | |
| 157 | } | |
| 158 | })(); | |
| 159 | try { | |
| 160 | await runLocalTeleop(leader, follower, { | |
| 161 | hz, | |
| 162 | signal: controller.signal, | |
| 163 | stopped: () => stopped, | |
| 164 | onTick: (s) => { | |
| 165 | if (performance.now() - last < 100) return; | |
| 166 | last = performance.now(); | |
| 167 | process.stdout.write(`\x1b[2J\x1b[H${teleopScreen(s).join("\n")}\n`); | |
| 168 | }, | |
| 169 | }); | |
| 170 | } finally { | |
| 171 | controller.abort(); | |
| 172 | await listening; | |
| 173 | } | |
| 174 | } | |
| 175 | ||
| 176 | export async function setup(options: SetupOptions = {}): Promise<void> { | |
| 177 | const key = terminalKeys(); | |
| 178 | let leader: FeetechBus | undefined; | |
| 179 | let follower: FeetechBus | undefined; | |
| 180 | try { | |
| 181 | const adapters = await waitForArms(key); | |
| 182 | const file = configFile(); | |
| 183 | let roles = savedRoles(loadRoles(file), adapters); | |
| 184 | if (roles) { | |
| 185 | const again = await choose(key, 1, `I remember the leader (#${roles.leader.serial}).`, ["Use it", "Tell the arms apart again"]); | |
| 186 | if (again === 1) roles = null; | |
| 187 | } | |
| 188 | if (!roles) { | |
| 189 | roles = await whoIsTheLeader(key, adapters); | |
| 190 | if (roles.leader.serial && roles.follower.serial) saveRoles(file, { leader: roles.leader.serial, follower: roles.follower.serial }); | |
| 191 | } | |
| 192 | leader = await FeetechBus.open(roles.leader.path); | |
| 193 | follower = await FeetechBus.open(roles.follower.path); | |
| 194 | await ensureCalibrated(key, leader, "teleoperators", "leader"); | |
| 195 | await ensureCalibrated(key, follower, "robots", "follower"); | |
| 196 | await driveScreen(key, leader, follower, options.hz ?? 60); | |
| 197 | show(3, ["Stopped. The follower is still holding with torque on; `so101 torque off` releases it."]); | |
| 198 | } catch (err) { | |
| 199 | if (!(err instanceof Quit)) throw err; | |
| 200 | show(3, ["Bye."]); | |
| 201 | } finally { | |
| 202 | key.close(); | |
| 203 | await leader?.close(); | |
| 204 | await follower?.close(); | |
| 205 | } | |
| 206 | } | |
| 207 | ||
| 208 | /** `so101 teleop`: skip the questions, use the remembered arms. */ | |
| 209 | export async function teleop(leaderPath: string | undefined, followerPath: string | undefined, hz: number): Promise<void> { | |
| 210 | const ports = resolveRoles(leaderPath, followerPath, loadRoles(configFile()), leaderPath && followerPath ? [] : adaptersOf(await SerialPort.list())); | |
| 211 | let key: Keys | undefined; | |
| 212 | let leader: FeetechBus | undefined; | |
| 213 | let follower: FeetechBus | undefined; | |
| 214 | try { | |
| 215 | leader = await FeetechBus.open(ports.leader); | |
| 216 | follower = await FeetechBus.open(ports.follower); | |
| 217 | key = terminalKeys(); // raw mode only once both ports are open | |
| 218 | await driveScreen(key, leader, follower, hz); | |
| 219 | } finally { | |
| 220 | key?.close(); | |
| 221 | await leader?.close(); | |
| 222 | await follower?.close(); | |
| 223 | } | |
| 224 | } |
new above
offline