Public: anyone can read this; the crew can change it.
Migrated from github.com/Trevato/SO-ARM100
workbenchthis machine
so101 serve boots with the arm unplugged: /status says "arm: not connected", cameras and the grow box keep serving #617
Check · Review · Land done · Live — Landed; nothing deploys from this branch, so this is the end of its path.
smith/arm:smith/615-so101-serve-boots-with-the-arm-unplugged → main 1 commit fork 1 thread · 1 unresolved
5 files
changed +206
−15
Software/so101/README.md +1 −1
| @@ -127,7 +127,7 @@ ignored and said once on the arm server's stderr. The arm server reads it at sta | ||
| 127 | 127 | |
| 128 | 128 | 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. |
| 129 | 129 | |
| 130 | - **Any subset present.** The grow box has nothing to enumerate (the light is USB power only), so it is always there. The arm's card is there only while the link to the Pi's `so101 serve` is live; unplugged, closed by the owner or unreachable it says why ("The arm: not connected", "The arm is resting") and offers no controls, and the owner sees **Open now** on it. Plugging the arm back in brings its card back by itself, with no restart. `so101 serve` with no servo board waits for one ("arm not connected; waiting for its servo board") instead of exiting, so the Pi's service stays up for the cameras. | |
| 130 | - **Any subset present.** The grow box has nothing to enumerate (the light is USB power only), so it is always there. The arm's card is there only while the link to the Pi's `so101 serve` is live; unplugged, closed by the owner or unreachable it says why ("The arm: not connected", "The arm is resting") and offers no controls, and the owner sees **Open now** on it. Plugging the arm back in brings its card back by itself, with no restart. `so101 serve` runs with the arm unplugged: when the port is missing or cannot be opened it logs `arm not connected (<path>: <reason>)` and keeps serving, so the cameras, `/version` and the grow box stay up and the Pi can still update itself. `GET /status` answers 200 with `arm: "not connected"`; every route that needs the servos (moves, poses, torque, calibration, the drive stream) answers 503 `{"error":"arm not connected"}`, and STOP answers 200. Plugging the arm in needs a restart of the service for now. | |
| 131 | 131 | - **"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. |
| 132 | 132 | - **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. |
| 133 | 133 | - **Live.** `GET /care/events` is a server-sent stream that says `care` after every change to the book, from any door; the page listens with a few lines of script and refreshes itself (not while a field has focus). Without the script the page works the same, on reload. A refused form returns to `/care?error=…` with the message in an alert line. |
Software/so101/src/absent-arm.test.ts added +67
| @@ -0,0 +1,67 @@ | ||
| 1 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 2 | import { mkdtempSync, readFileSync } from "node:fs"; | |
| 3 | import { tmpdir } from "node:os"; | |
| 4 | import { join } from "node:path"; | |
| 5 | import WebSocket from "ws"; | |
| 6 | import { ARM_ROUTES, FREE_ROUTES } from "./absent-bus.ts"; | |
| 7 | import { FeetechBus } from "./bus.ts"; | |
| 8 | import { startServer, type RunningServer } from "./server.ts"; | |
| 9 | ||
| 10 | let server: RunningServer; | |
| 11 | let base: string; | |
| 12 | ||
| 13 | beforeAll(async () => { | |
| 14 | const dir = mkdtempSync(join(tmpdir(), "so101-absent-")); | |
| 15 | server = await startServer(null, { host: "127.0.0.1", port: 0, posesFile: join(dir, "poses.json"), growKey: "k" }); | |
| 16 | base = `http://127.0.0.1:${server.port}`; | |
| 17 | }); | |
| 18 | afterAll(() => void server.close()); // not awaited: Bun never finishes http.close after a server-closed WebSocket (an E-stop close does the same) | |
| 19 | ||
| 20 | describe("serve without an arm", () => { | |
| 21 | test("a missing port is an open error, which the CLI turns into no bus", async () => { | |
| 22 | await expect(FeetechBus.open("/dev/does-not-exist-ttyACM9")).rejects.toThrow(); | |
| 23 | }); | |
| 24 | ||
| 25 | test("/status is 200 and says the arm is not connected", async () => { | |
| 26 | const res = await fetch(`${base}/status`); | |
| 27 | expect(res.status).toBe(200); | |
| 28 | const body = (await res.json()) as { arm: string; joints: unknown[] }; | |
| 29 | expect(body.arm).toBe("not connected"); | |
| 30 | expect(body.joints).toEqual([]); | |
| 31 | }); | |
| 32 | ||
| 33 | test("/version and the camera profile answer", async () => { | |
| 34 | expect((await fetch(`${base}/version`)).status).toBe(200); | |
| 35 | expect((await fetch(`${base}/camera-profile/1`)).status).toBe(200); | |
| 36 | expect((await fetch(`${base}/grow/dosing`, { headers: { "x-grow-key": "k" } })).status).toBe(200); | |
| 37 | }); | |
| 38 | ||
| 39 | test("a move answers 503 and STOP answers 200", async () => { | |
| 40 | const move = await fetch(`${base}/move`, { method: "POST", body: JSON.stringify({ targets: { "1": 2048 } }) }); | |
| 41 | expect(move.status).toBe(503); | |
| 42 | expect(await move.json()).toEqual({ error: "arm not connected" }); | |
| 43 | expect((await fetch(`${base}/stop`, { method: "POST", body: "{}" })).status).toBe(200); | |
| 44 | }); | |
| 45 | ||
| 46 | test("every route in server.ts is listed as an arm route or a free one, and each arm route answers 503", async () => { | |
| 47 | const source = readFileSync(join(import.meta.dir, "server.ts"), "utf8"); | |
| 48 | const routes = [...source.matchAll(/^ {8}case "((?:GET|POST|PUT) [^"]+)":/gm)].map((m) => m[1]!); | |
| 49 | expect(routes.length).toBeGreaterThan(40); | |
| 50 | for (const route of routes) expect(ARM_ROUTES.has(route) !== FREE_ROUTES.has(route), `${route} must be in exactly one list`).toBe(true); | |
| 51 | for (const route of ARM_ROUTES) { | |
| 52 | const [method, path] = route.split(" ") as [string, string]; | |
| 53 | const res = await fetch(`${base}${path}`, { method, body: method === "GET" ? undefined : "{}" }); | |
| 54 | expect(res.status, route).toBe(503); | |
| 55 | expect(await res.json(), route).toEqual({ error: "arm not connected" }); | |
| 56 | } | |
| 57 | }); | |
| 58 | ||
| 59 | test("the drive stream is closed at once with the reason, and drives nothing", async () => { | |
| 60 | const closed = await new Promise<{ code: number; reason: string }>((resolve) => { | |
| 61 | const ws = new WebSocket(`ws://127.0.0.1:${server.port}/follow`); | |
| 62 | ws.on("close", (code, reason) => (resolve({ code, reason: String(reason) }))); | |
| 63 | ws.on("error", () => {}); | |
| 64 | }); | |
| 65 | expect(closed).toEqual({ code: 1013, reason: "arm not connected" }); | |
| 66 | }); | |
| 67 | }); |
Software/so101/src/absent-bus.ts added +74
| @@ -0,0 +1,74 @@ | ||
| 1 | import type { FeetechBus } from "./bus.ts"; | |
| 2 | ||
| 3 | /** What every arm route answers while no arm is plugged in. */ | |
| 4 | export const ARM_ABSENT = "arm not connected"; | |
| 5 | ||
| 6 | /** Thrown by the absent bus: the server answers it 503, and nothing reached a wire. */ | |
| 7 | export class ArmAbsentError extends Error { | |
| 8 | constructor() { | |
| 9 | super(ARM_ABSENT); | |
| 10 | } | |
| 11 | } | |
| 12 | ||
| 13 | /** The routes that need the servos. A route in src/server.ts is in this list or in FREE_ROUTES (a test holds the line). */ | |
| 14 | export const ARM_ROUTES: ReadonlySet<string> = new Set([ | |
| 15 | "GET /limits", | |
| 16 | "GET /servo-params", | |
| 17 | "PUT /servo-params", | |
| 18 | "POST /servo-params/factory", | |
| 19 | "POST /tuning/p", | |
| 20 | "POST /settings/table-here", | |
| 21 | "POST /settings/table-touch", | |
| 22 | "POST /settings/joint-zero", | |
| 23 | "POST /pose/save", | |
| 24 | "POST /pose/go", | |
| 25 | "POST /move", | |
| 26 | "POST /grip", | |
| 27 | "POST /torque", | |
| 28 | "POST /record/start", | |
| 29 | "POST /record/stop", | |
| 30 | "POST /play", | |
| 31 | ]); | |
| 32 | ||
| 33 | /** The routes that work without an arm: pages, cameras' neighbours, the grow box, files on disk, and the stops (nothing to stop). */ | |
| 34 | export const FREE_ROUTES: ReadonlySet<string> = new Set([ | |
| 35 | "GET /", | |
| 36 | "GET /index.html", | |
| 37 | "GET /leader-map.js", | |
| 38 | "GET /status", | |
| 39 | "GET /version", | |
| 40 | "GET /grow", | |
| 41 | "GET /grow/sensors", | |
| 42 | "POST /grow/light", | |
| 43 | "POST /grow/pump", | |
| 44 | "GET /grow/dosing", | |
| 45 | "POST /grow/dosing", | |
| 46 | "GET /probes", | |
| 47 | "POST /probes/calibrate", | |
| 48 | "GET /tuning", | |
| 49 | "GET /poses", | |
| 50 | "GET /recordings", | |
| 51 | "GET /calibration", | |
| 52 | "GET /metrics", | |
| 53 | "GET /precision", | |
| 54 | "GET /precision/trace", | |
| 55 | "POST /precision/snap-back", | |
| 56 | "GET /settings", | |
| 57 | "PUT /settings", | |
| 58 | "GET /model.json", | |
| 59 | "POST /pose/delete", | |
| 60 | "POST /recording/delete", | |
| 61 | "POST /stop", | |
| 62 | "POST /resume", | |
| 63 | "POST /estop", | |
| 64 | "POST /estop/clear", | |
| 65 | ]); | |
| 66 | ||
| 67 | /** A bus that is not there: reads and writes fail with ArmAbsentError before anything is sent. */ | |
| 68 | export function absentBus(path: string, baudRate = 1_000_000): FeetechBus { | |
| 69 | const state: Record<string | symbol, unknown> = { path, baudRate, echo: false, onSyncWrite: null }; | |
| 70 | return new Proxy(state, { | |
| 71 | get: (target, key) => (key in target ? target[key] : () => Promise.reject(new ArmAbsentError())), | |
| 72 | set: (target, key, value) => ((target[key] = value), true), | |
| 73 | }) as unknown as FeetechBus; | |
| 74 | } |
Software/so101/src/cli.ts +25 −11
| @@ -9,7 +9,6 @@ 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"; | |
| 13 | 12 | import { assertPoseName, parsePoseFile, serializePoseFile, type PoseFile } from "./poses.ts"; |
| 14 | 13 | import { durationSeconds, largestStep, parseTrajectory, serializeTrajectory } from "./trajectory.ts"; |
| 15 | 14 | import { |
| @@ -530,21 +529,24 @@ const COMMANDS: Record<string, Command> = { | ||
| 530 | 529 | serve: async (values) => { |
| 531 | 530 | const [host, portText] = (values.listen ?? "0.0.0.0:8101").split(":"); |
| 532 | 531 | const port = bounded(integer(portText ?? "8101", "--listen port"), 1, 65535, "--listen port"); |
| 533 | const waiting = !values.port && !process.env.SO101_PORT; | |
| 534 | if (waiting) await waitForArm(detectPort, (text) => console.log(text), (ms) => new Promise((r) => setTimeout(r, ms))); | |
| 535 | return withBus(values, async (bus) => { | |
| 536 | const { growKeyFile, startServer } = await import("./server.ts"); // lazily: it reaches bun:sqlite, which Node lacks | |
| 537 | const cameraControl = (unit: number, profile: Parameters<typeof applyProfile>[1]) => applyProfile(cameraDevice(unit), profile); | |
| 538 | const cameraStill = (unit: number) => grabStill(unit, cameraDevice(unit)); | |
| 532 | const { growKeyFile, startServer } = await import("./server.ts"); // lazily: it reaches bun:sqlite, which Node lacks | |
| 533 | const bus = await openArm(values); | |
| 534 | const cameraControl = (unit: number, profile: Parameters<typeof applyProfile>[1]) => applyProfile(cameraDevice(unit), profile); | |
| 535 | const cameraStill = (unit: number) => grabStill(unit, cameraDevice(unit)); | |
| 536 | try { | |
| 539 | 537 | const server = await startServer(bus, { host, port, posesFile: values.file ?? "poses.json", modelDir: values.model, gainSyncMs: 2000, cameraControl, cameraStill, lightMs: 15_000, sensorsMs: 60_000 }); |
| 540 | console.log(`serving ${bus.path} on http://${host}:${server.port} — open it in a browser; the API is listed in src/server.ts`); | |
| 538 | console.log(`serving ${bus?.path ?? "no arm"} on http://${host}:${server.port} — open it in a browser; the API is listed in src/server.ts`); | |
| 541 | 539 | 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 | 540 | console.log("ctrl-c to stop"); |
| 543 | 541 | await new Promise<void>((resolve) => process.once("SIGINT", () => resolve())); |
| 544 | 542 | await server.close(); |
| 545 | await bus.syncWrite(STS3215.Torque_Enable, [1, 2, 3, 4, 5, 6].map((id) => [id, 0] as const)); | |
| 546 | console.log("stopped; torque released"); | |
| 547 | }); | |
| 543 | if (bus) { | |
| 544 | await bus.syncWrite(STS3215.Torque_Enable, [1, 2, 3, 4, 5, 6].map((id) => [id, 0] as const)); | |
| 545 | console.log("stopped; torque released"); | |
| 546 | } else console.log("stopped"); | |
| 547 | } finally { | |
| 548 | await bus?.close(); | |
| 549 | } | |
| 548 | 550 | }, |
| 549 | 551 | |
| 550 | 552 | "camera-profile": async (values, [unitText]) => { |
| @@ -632,6 +634,18 @@ async function withBus<T>(values: Values, fn: (bus: FeetechBus) => Promise<T>): | ||
| 632 | 634 | } |
| 633 | 635 | } |
| 634 | 636 | |
| 637 | /** The arm's bus for `serve`, or null (one logged line) when the port is missing or cannot be opened: the cameras and the grow box serve on. */ | |
| 638 | async function openArm(values: Values): Promise<FeetechBus | null> { | |
| 639 | let path = values.port ?? process.env.SO101_PORT; | |
| 640 | try { | |
| 641 | path ??= await detectPort(); | |
| 642 | return await FeetechBus.open(path, { baudRate: values.baud ? integer(values.baud, "--baud") : 1_000_000 }); | |
| 643 | } catch (err) { | |
| 644 | console.log(`arm not connected (${path ?? "no port"}: ${message(err)})`); | |
| 645 | return null; | |
| 646 | } | |
| 647 | } | |
| 648 | ||
| 635 | 649 | async function detectPort(): Promise<string> { |
| 636 | 650 | const candidates = (await SerialPort.list()) |
| 637 | 651 | .map((port) => port.path) |
Software/so101/src/server.ts +39 −3
| @@ -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 { ARM_ABSENT, ARM_ROUTES, ArmAbsentError, absentBus } from "./absent-bus.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"; |
| @@ -226,6 +227,8 @@ export function actor(body: Record<string, unknown>): string { | ||
| 226 | 227 | const CLEAN_CLOSE = new Set([1000, 1005]); |
| 227 | 228 | /** The close code for a stream ended by an emergency stop. */ |
| 228 | 229 | export const ESTOP_CLOSE = 4423; |
| 230 | /** The close code for a stream opened with no arm plugged in: try again later. */ | |
| 231 | const NO_ARM_CLOSE = 1013; | |
| 229 | 232 | |
| 230 | 233 | class HttpError extends Error { |
| 231 | 234 | readonly status: number; |
| @@ -248,7 +251,10 @@ const VENDORED = new Set(["three", "urdf-loader"]); | ||
| 248 | 251 | const isUiModule = (name: string) => /^[a-z][a-z0-9-]*\.(js|css)$/.test(name); |
| 249 | 252 | const MIME: Record<string, string> = { ".js": "text/javascript", ".css": "text/css", ".mjs": "text/javascript", ".json": "application/json", ".urdf": "application/xml", ".stl": "model/stl", ".dae": "model/vnd.collada+xml", ".png": "image/png", ".jpg": "image/jpeg" }; |
| 250 | 253 | |
| 251 | export async function startServer(bus: FeetechBus, options: ServerOptions = {}): Promise<RunningServer> { | |
| 254 | /** `arm` is null when the serial port could not be opened: the server still serves everything that is not the arm. */ | |
| 255 | export async function startServer(arm: FeetechBus | null, options: ServerOptions = {}): Promise<RunningServer> { | |
| 256 | const hasArm = arm !== null; | |
| 257 | const bus = arm ?? absentBus("(no arm)"); | |
| 252 | 258 | const ids = options.ids ?? [1, 2, 3, 4, 5, 6]; |
| 253 | 259 | const posesFile = resolve(options.posesFile ?? "poses.json"); |
| 254 | 260 | const dataDir = dirname(posesFile); |
| @@ -291,7 +297,7 @@ export async function startServer(bus: FeetechBus, options: ServerOptions = {}): | ||
| 291 | 297 | let held = false; |
| 292 | 298 | |
| 293 | 299 | const limits = new Map<number, { min: number; max: number }>(); |
| 294 | for (const id of ids) { | |
| 300 | for (const id of hasArm ? ids : []) { | |
| 295 | 301 | limits.set(id, { |
| 296 | 302 | min: await bus.read(id, STS3215.Min_Position_Limit), |
| 297 | 303 | max: await bus.read(id, STS3215.Max_Position_Limit), |
| @@ -462,6 +468,7 @@ export async function startServer(bus: FeetechBus, options: ServerOptions = {}): | ||
| 462 | 468 | const stopped = (e: EStop) => `emergency stop (${e.by}, ${e.at.slice(0, 19).replace("T", " ")} UTC): the arm holds until an admin clears it`; |
| 463 | 469 | /** Torque on, goal where each joint is: the arm stays put and resists. */ |
| 464 | 470 | const holdHere = async () => { |
| 471 | if (!hasArm) return; | |
| 465 | 472 | const positions = await bus.syncRead(ids, STS3215.Present_Position); |
| 466 | 473 | const here = ids.filter((id) => positions.has(id)); |
| 467 | 474 | await bus.syncWrite(STS3215.Goal_Position, here.map((id) => [id, positions.get(id)!] as const)); |
| @@ -706,6 +713,7 @@ export async function startServer(bus: FeetechBus, options: ServerOptions = {}): | ||
| 706 | 713 | } |
| 707 | 714 | |
| 708 | 715 | const status = async () => { |
| 716 | if (!hasArm) return absentStatus(); | |
| 709 | 717 | // Present_Position (56) through Present_Temperature (63) is one block: one round trip, not one per register. |
| 710 | 718 | const block = await bus.syncReadBlock(ids, PRESENT); |
| 711 | 719 | const positions = new Map([...block].map(([id, [p]]) => [id, p!])); |
| @@ -763,6 +771,28 @@ export async function startServer(bus: FeetechBus, options: ServerOptions = {}): | ||
| 763 | 771 | }; |
| 764 | 772 | }; |
| 765 | 773 | |
| 774 | /** The status without an arm: no joints, and everything the server itself knows. */ | |
| 775 | const absentStatus = () => ({ | |
| 776 | arm: "not connected", | |
| 777 | joints: [], | |
| 778 | busy: driver !== null, | |
| 779 | driving: driver, | |
| 780 | recording: recording?.name ?? null, | |
| 781 | zone: model ? settings.zone : "off", | |
| 782 | violation: null, | |
| 783 | lowestMm: null, | |
| 784 | tableZ: model ? model.tableZ(zoneSettings()) : null, | |
| 785 | blocked: null, | |
| 786 | jammed: [], | |
| 787 | jams: [], | |
| 788 | stream: streamStats, | |
| 789 | deadman: held, | |
| 790 | estop, | |
| 791 | held: gate.held, | |
| 792 | machine: { run, seq: machine.seq }, | |
| 793 | motion: speedLimits(), | |
| 794 | }); | |
| 795 | ||
| 766 | 796 | /** A supervised move to explicit targets; the one way joints move over HTTP. */ |
| 767 | 797 | const move = async (targetIds: number[], requested: number[], body: Record<string, unknown>, source: string) => { |
| 768 | 798 | const controller = claim("move"); |
| @@ -949,6 +979,7 @@ export async function startServer(bus: FeetechBus, options: ServerOptions = {}): | ||
| 949 | 979 | writeAtomic(join(calibrationDir, `${kind}.json`), JSON.stringify({ ...body, saved: new Date().toISOString() }, null, 1)); |
| 950 | 980 | return json(res, 200, { stored: kind }); |
| 951 | 981 | } |
| 982 | if (!hasArm && ARM_ROUTES.has(route)) throw new HttpError(503, ARM_ABSENT); | |
| 952 | 983 | switch (route) { |
| 953 | 984 | case "GET /": |
| 954 | 985 | case "GET /index.html": |
| @@ -1388,6 +1419,7 @@ export async function startServer(bus: FeetechBus, options: ServerOptions = {}): | ||
| 1388 | 1419 | if (err.status === 413) res.setHeader("connection", "close"); // the rest of the body is unread: end the connection after the answer |
| 1389 | 1420 | return json(res, err.status, { error: err.message }); |
| 1390 | 1421 | } |
| 1422 | if (err instanceof ArmAbsentError) return json(res, 503, { error: ARM_ABSENT }); | |
| 1391 | 1423 | json(res, 500, { error: err instanceof Error ? err.message : String(err) }); |
| 1392 | 1424 | } |
| 1393 | 1425 | }); |
| @@ -1417,6 +1449,10 @@ export async function startServer(bus: FeetechBus, options: ServerOptions = {}): | ||
| 1417 | 1449 | return c; |
| 1418 | 1450 | }; |
| 1419 | 1451 | wss.on("connection", (ws: WebSocket, req?: IncomingMessage) => { |
| 1452 | if (!hasArm) { | |
| 1453 | ws.close(NO_ARM_CLOSE, ARM_ABSENT); // 1013, try again later: nothing is driven | |
| 1454 | return; | |
| 1455 | } | |
| 1420 | 1456 | if (estop) { |
| 1421 | 1457 | ws.close(ESTOP_CLOSE, stopped(estop)); |
| 1422 | 1458 | return; |
| @@ -1837,7 +1873,7 @@ export async function startServer(bus: FeetechBus, options: ServerOptions = {}): | ||
| 1837 | 1873 | }); |
| 1838 | 1874 | |
| 1839 | 1875 | await new Promise<void>((resolve) => http.listen(options.port ?? 8101, options.host ?? "0.0.0.0", resolve)); |
| 1840 | const gainTimer = options.gainSyncMs ? setInterval(() => void gainSync.tick(), options.gainSyncMs) : undefined; | |
| 1876 | const gainTimer = hasArm && options.gainSyncMs ? setInterval(() => void gainSync.tick(), options.gainSyncMs) : undefined; | |
| 1841 | 1877 | if (gainTimer) void gainSync.tick(); |
| 1842 | 1878 | const lightTimer = options.lightMs ? setInterval(() => DEVICES.forEach((d) => void lights[d].tick()), options.lightMs) : undefined; |
| 1843 | 1879 | if (options.lightMs) DEVICES.forEach((d) => void lights[d].boot()); |
The
gettrap answers every missing key with a rejecting function, includingthen.await buswould call it and never settle. Returnundefinedforthenand for symbol keys.