Public: anyone can read this; the crew can change it.
Migrated from github.com/Trevato/SO-ARM100
workbenchthis machine
Live feedback at 60 Hz and restarts that hold the arm: command to confirmed motion goes from 235/438 ms to 15/32 ms (p50/p95) #502
Check · Review · Land · Live — It conflicts with its base.
hunt/relay → main 4 commits
26 files
changed +1091
−44
Software/so101/bench/fake-serve.ts +15
| @@ -73,3 +73,18 @@ const piSync = piSyncFromEnv(process.env, remote?.http ?? null, remote?.host ?? | ||
| 73 | 73 | const relay = await startRelay({ piSync: piSync ?? undefined, host, port, simulation: `http://127.0.0.1:${simulation.port}`, remote, problem, lobby, cameras, leaders, video, summaryEveryMs: 60_000, holdFile: join(dir, "hold.json"), baselineFile: join(dir, "servo-factory.json") }); |
| 74 | 74 | const { mode, host: armHost } = relay.link(); |
| 75 | 75 | console.log(`arm on http://${host}:${relay.port} (${mode === "simulated" ? "simulated" : `arm ${armHost}: ${mode}`}; data in ${dir}${local ? "; local: every visitor drives as the owner" : ""})`); |
| 76 | ||
| 77 | // A deploy sends SIGTERM: say goodbye on purpose (streams closed, not lost, so the arm server holds the arm; pages told to | |
| 78 | // come back at once), flush the lobby's files, and be gone well inside a second. | |
| 79 | let stopping = false; | |
| 80 | for (const signal of ["SIGTERM", "SIGINT"] as const) { | |
| 81 | process.on(signal, () => { | |
| 82 | if (stopping) return; | |
| 83 | stopping = true; | |
| 84 | setTimeout(() => process.exit(0), 800).unref(); | |
| 85 | void relay.close().finally(() => { | |
| 86 | lobby.close(); | |
| 87 | process.exit(0); | |
| 88 | }); | |
| 89 | }); | |
| 90 | } |
Software/so101/bench/pipeline.ts added +77
| @@ -0,0 +1,77 @@ | ||
| 1 | /** | |
| 2 | * Latency through the whole local pipeline (fake bus, ~1 ms round trip → arm server → relay → page stand-in), per hop: | |
| 3 | * send→bus the page sends a frame; the arm server writes Goal_Position (page→relay→Pi→governor→bus) | |
| 4 | * bus→page that write until a pushed status shows the joint moved (read-back→Pi→relay→page) | |
| 5 | * total command → first confirmed motion at the page | |
| 6 | * gap time between pushed statuses while the arm moves (a sawtooth shows as a long, regular gap) | |
| 7 | * bun bench/pipeline.ts [samples=150] | |
| 8 | */ | |
| 9 | import { page, startRig } from "../src/pipeline-rig.ts"; | |
| 10 | ||
| 11 | const pct = (xs: number[], p: number) => [...xs].sort((a, b) => a - b)[Math.max(0, Math.ceil((p / 100) * xs.length) - 1)] ?? NaN; | |
| 12 | const line = (name: string, xs: number[]) => console.log(`${name.padEnd(10)} n=${String(xs.length).padStart(4)} p50 ${pct(xs, 50).toFixed(1).padStart(6)} p95 ${pct(xs, 95).toFixed(1).padStart(6)} max ${Math.max(...xs).toFixed(1).padStart(6)} ms`); | |
| 13 | const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); | |
| 14 | ||
| 15 | const n = Number(process.argv[2] ?? 60); | |
| 16 | const rig = await startRig({ latencyMs: Number(process.env.LAT ?? 1) }); | |
| 17 | const p = await page(rig.base); | |
| 18 | let wroteAt = 0; | |
| 19 | rig.bus.onSyncWrite = ((prev) => (reg, vals) => { | |
| 20 | if (reg.address === 42 && !wroteAt) wroteAt = performance.now(); | |
| 21 | prev?.(reg, vals); | |
| 22 | })(rig.bus.onSyncWrite); | |
| 23 | p.live.on('close',(c)=>console.log('live closed',c,(performance.now()/1000).toFixed(1)));p.follow.on('close',(c,r)=>console.log('follow closed',c,String(r),(performance.now()/1000).toFixed(1))); | |
| 24 | await sleep(600); | |
| 25 | ||
| 26 | let seen: { at: number; pos: number } | null = null; | |
| 27 | let last = -1; | |
| 28 | let lastAt = 0; | |
| 29 | let measuringGaps = false; | |
| 30 | const gaps: number[] = []; | |
| 31 | p.onStatus((s, at) => { | |
| 32 | const pos = s.joints.find((j) => j.id === 1)!.position; | |
| 33 | seen = { at, pos }; | |
| 34 | if (measuringGaps && lastAt) gaps.push(at - lastAt); | |
| 35 | lastAt = at; | |
| 36 | }); | |
| 37 | // Take the arm: first frame eases; wait for it. | |
| 38 | let pos = 2048; | |
| 39 | p.send({ "1": pos }); | |
| 40 | await sleep(1500); | |
| 41 | const sendBus: number[] = [], busPage: number[] = [], total: number[] = []; | |
| 42 | for (let i = 0; i < n; i++) { | |
| 43 | pos += i % 2 ? -24 : 24; | |
| 44 | const m = rig.fake.servos.get(1)!; | |
| 45 | const before = m[56]! | (m[57]! << 8); // ground truth: where the arm is as the command leaves the page | |
| 46 | wroteAt = 0; | |
| 47 | seen = null; | |
| 48 | const t0 = performance.now(); | |
| 49 | p.send({ "1": pos }); | |
| 50 | const deadline = t0 + 1000; | |
| 51 | while ((!seen || (seen as { pos: number }).pos === before) && performance.now() < deadline) await sleep(0); | |
| 52 | const s = seen as { at: number; pos: number } | null; | |
| 53 | if (s && s.pos !== before && wroteAt) { | |
| 54 | sendBus.push(wroteAt - t0); | |
| 55 | busPage.push(s.at - wroteAt); | |
| 56 | total.push(s.at - t0); | |
| 57 | } | |
| 58 | await sleep(350 + Math.random() * 60); | |
| 59 | } | |
| 60 | // continuous motion: gaps between statuses | |
| 61 | gaps.length = 0; | |
| 62 | lastAt = 0; | |
| 63 | measuringGaps = true; | |
| 64 | for (let i = 0; i < 120; i++) { | |
| 65 | pos += i < 60 ? 12 : -12; | |
| 66 | p.send({ "1": pos }); | |
| 67 | await sleep(1000 / 50); | |
| 68 | } | |
| 69 | console.log(`measured ${total.length} of ${n} commands`); | |
| 70 | line("send→bus", sendBus); | |
| 71 | line("bus→page", busPage); | |
| 72 | line("total", total); | |
| 73 | line("gap", gaps); | |
| 74 | console.log(`status pushes during 2.4 s of motion: ${gaps.length} (${((gaps.length / 2.4)).toFixed(1)} Hz)`); | |
| 75 | p.close(); | |
| 76 | await rig.close(); | |
| 77 | process.exit(0); |
Software/so101/bunfig.toml added +2
| @@ -0,0 +1,2 @@ | ||
| 1 | [test] | |
| 2 | preload = ["./test-setup.ts"] |
Software/so101/src/camera-feed.test.ts +15
| @@ -194,6 +194,21 @@ describe("a camera feed", () => { | ||
| 194 | 194 | await v.cancel(); |
| 195 | 195 | }); |
| 196 | 196 | |
| 197 | test("by default a source that blinked is back in well under a second, not after a fixed two", async () => { | |
| 198 | const closed = createServer(); | |
| 199 | const port = await listening(closed); | |
| 200 | closed.close(); | |
| 201 | const url = await serveFeed(`http://127.0.0.1:${port}/stream`); // the default retry waits: 200, 500, 1000, 2000 ms | |
| 202 | const v = await watch(url); | |
| 203 | await v.until(hasOffline); | |
| 204 | camera = fakeCamera(); | |
| 205 | await new Promise<void>((r) => camera!.server.listen(port, "127.0.0.1", () => r())); | |
| 206 | const up = performance.now(); | |
| 207 | await v.until((s) => hasFrame(after(s, PNG)), 3000); | |
| 208 | expect(performance.now() - up).toBeLessThan(450); // the first retry came at about 200 ms | |
| 209 | await v.cancel(); | |
| 210 | }); | |
| 211 | ||
| 197 | 212 | test("no source: the offline frame at once, and again while anyone watches", async () => { |
| 198 | 213 | const url = await serveFeed(null, { offlineEveryMs: 50 }); |
| 199 | 214 | const v = await watch(url); |
Software/so101/src/camera-feed.ts +10 −5
| @@ -19,13 +19,15 @@ export interface FeedOptions { | ||
| 19 | 19 | connectTimeoutMs?: number; |
| 20 | 20 | /** How long the source may go without a byte before it counts as gone. */ |
| 21 | 21 | staleMs?: number; |
| 22 | /** How soon an unreachable source is tried again, while anyone watches. */ | |
| 22 | /** How soon an unreachable source is tried again, while anyone watches: every time, when given. */ | |
| 23 | 23 | retryMs?: number; |
| 24 | /** Without `retryMs`: the waits before the 1st, 2nd, 3rd… retry, the last repeating. A source that blinked is back in a fifth of a second. */ | |
| 25 | retryBackoffMs?: readonly number[]; | |
| 24 | 26 | /** How often the offline picture is sent again, so a pane is never left waiting. */ |
| 25 | 27 | offlineEveryMs?: number; |
| 26 | 28 | /** How long the upstream connection stays open after the last viewer leaves (a page reload keeps it). */ |
| 27 | 29 | lingerMs?: number; |
| 28 | /** Bytes queued for one viewer past which it skips frames. */ | |
| 30 | /** Bytes queued for one viewer past which it skips frames: a few pictures, not a second of them, so a slow link sees the present. */ | |
| 29 | 31 | maxBacklog?: number; |
| 30 | 32 | /** Where to count frames in from the source and bytes out to viewers (`camera_frames_in`, `camera_bytes_out`). */ |
| 31 | 33 | metrics?: LiveMetrics; |
| @@ -79,7 +81,8 @@ export class CameraFeed { | ||
| 79 | 81 | private watchdog: ReturnType<typeof setInterval> | undefined; |
| 80 | 82 | private lastByte = 0; |
| 81 | 83 | private closed = false; |
| 82 | private readonly o: Required<Omit<FeedOptions, "metrics">>; | |
| 84 | private readonly o: Required<Omit<FeedOptions, "metrics" | "retryMs">> & { retryMs?: number }; | |
| 85 | private failures = 0; | |
| 83 | 86 | private readonly metrics: LiveMetrics | undefined; |
| 84 | 87 | private readonly source: string | null; |
| 85 | 88 | private readonly name: string; |
| @@ -89,7 +92,7 @@ export class CameraFeed { | ||
| 89 | 92 | this.source = source; |
| 90 | 93 | this.name = name; |
| 91 | 94 | this.metrics = options.metrics; |
| 92 | this.o = { connectTimeoutMs: 1500, staleMs: 2000, retryMs: 2000, offlineEveryMs: 2000, lingerMs: 2000, maxBacklog: 1 << 20, ...options }; | |
| 95 | this.o = { connectTimeoutMs: 1500, staleMs: 2000, retryBackoffMs: [200, 500, 1000, 2000], offlineEveryMs: 2000, lingerMs: 2000, maxBacklog: 256 << 10, ...options }; | |
| 93 | 96 | this._state = source ? "idle" : "offline"; |
| 94 | 97 | } |
| 95 | 98 | |
| @@ -208,7 +211,7 @@ export class CameraFeed { | ||
| 208 | 211 | this.retry = setTimeout(() => { |
| 209 | 212 | this.retry = undefined; |
| 210 | 213 | if (this.watching && this._state === "offline" && !this.controller) this.connect(); |
| 211 | }, this.o.retryMs); | |
| 214 | }, this.o.retryMs ?? this.o.retryBackoffMs[Math.min(this.failures - 1, this.o.retryBackoffMs.length - 1)] ?? 2000); | |
| 212 | 215 | this.retry.unref?.(); |
| 213 | 216 | } |
| 214 | 217 | } |
| @@ -222,6 +225,7 @@ export class CameraFeed { | ||
| 222 | 225 | this.watchdog = undefined; |
| 223 | 226 | if (this._state !== "offline") console.error(`${this.name} ${this.source}: ${why}; showing it offline`); |
| 224 | 227 | this._state = "offline"; |
| 228 | this.failures++; | |
| 225 | 229 | this.broadcastOffline(true); |
| 226 | 230 | this.offline(); |
| 227 | 231 | } |
| @@ -269,6 +273,7 @@ export class CameraFeed { | ||
| 269 | 273 | if (this._state !== "live") { |
| 270 | 274 | if (this._state === "offline") console.log(`${this.name} ${this.source}: live`); |
| 271 | 275 | this._state = "live"; |
| 276 | this.failures = 0; | |
| 272 | 277 | clearInterval(this.repeat); |
| 273 | 278 | this.repeat = undefined; |
| 274 | 279 | } |
Software/so101/src/command-lag.test.ts added +74
| @@ -0,0 +1,74 @@ | ||
| 1 | import { describe, expect, test } from "bun:test"; | |
| 2 | import { CommandLag } from "./command-lag.ts"; | |
| 3 | import { startRig, page } from "./pipeline-rig.ts"; | |
| 4 | ||
| 5 | describe("CommandLag", () => { | |
| 6 | test("a command is timed from the arm at rest to the first status whose positions differ", () => { | |
| 7 | const lag = new CommandLag(); | |
| 8 | lag.status("1,2", 0); | |
| 9 | lag.status("1,2", 100); // at rest: nothing has changed for 60 ms | |
| 10 | lag.command(110); | |
| 11 | lag.status("1,2", 120); // nothing yet | |
| 12 | lag.status("1,3", 140); // first confirmed motion | |
| 13 | expect(lag.lag(200)).toEqual({ p50: 30, p95: 30, n: 1 }); | |
| 14 | }); | |
| 15 | ||
| 16 | test("an arm already moving is not timed, and a command that moves nothing is dropped after the give-up time", () => { | |
| 17 | const lag = new CommandLag(100); | |
| 18 | lag.status("1", 0); | |
| 19 | lag.status("2", 10); // moving | |
| 20 | lag.command(11); | |
| 21 | lag.status("3", 20); | |
| 22 | expect(lag.lag(30)).toBeNull(); | |
| 23 | lag.status("3", 100); | |
| 24 | lag.command(101); | |
| 25 | lag.status("3", 300); // too late, and unchanged | |
| 26 | lag.status("4", 320); // a later move is not this command's | |
| 27 | expect(lag.lag(400)).toBeNull(); | |
| 28 | }); | |
| 29 | ||
| 30 | test("a second command while one is being timed does not restart the clock; percentiles are over the last samples", () => { | |
| 31 | const lag = new CommandLag(); | |
| 32 | lag.status("a", -1000); | |
| 33 | for (let i = 0; i < 20; i++) { | |
| 34 | lag.status("a", i * 1000); | |
| 35 | lag.status("a", i * 1000 + 1); | |
| 36 | lag.command(i * 1000 + 2); | |
| 37 | lag.command(i * 1000 + 6); | |
| 38 | lag.status("b", i * 1000 + 2 + (i >= 18 ? 90 : 10)); | |
| 39 | lag.status("b", i * 1000 + 500); | |
| 40 | lag.status("a", i * 1000 + 600); | |
| 41 | lag.status("a", i * 1000 + 900); | |
| 42 | } | |
| 43 | const l = lag.lag(1e9)!; | |
| 44 | expect(l.n).toBe(20); | |
| 45 | expect(l.p50).toBe(10); | |
| 46 | expect(l.p95).toBe(90); | |
| 47 | }); | |
| 48 | }); | |
| 49 | ||
| 50 | describe("through the relay", () => { | |
| 51 | test("the status the page hears carries the measured command-to-motion lag", async () => { | |
| 52 | const rig = await startRig({ latencyMs: 1 }); | |
| 53 | const p = await page(rig.base); | |
| 54 | let lag: { p50: number; p95: number; n: number } | undefined; | |
| 55 | p.live.on("message", (d) => { | |
| 56 | const m = JSON.parse(String(d)) as { type?: string; status?: { lag?: typeof lag } | null }; | |
| 57 | if (m.type === "status" && m.status?.lag) lag = m.status.lag; | |
| 58 | }); | |
| 59 | await new Promise((r) => setTimeout(r, 400)); | |
| 60 | let pos = 2048; | |
| 61 | p.send({ "1": pos }); | |
| 62 | await new Promise((r) => setTimeout(r, 1500)); | |
| 63 | for (let i = 0; i < 12; i++) { | |
| 64 | pos += i % 2 ? -24 : 24; | |
| 65 | p.send({ "1": pos }); | |
| 66 | await new Promise((r) => setTimeout(r, 400)); | |
| 67 | } | |
| 68 | p.close(); | |
| 69 | await rig.close(); | |
| 70 | expect(lag).toBeDefined(); | |
| 71 | expect(lag!.n).toBeGreaterThanOrEqual(8); | |
| 72 | expect(lag!.p95).toBeGreaterThanOrEqual(lag!.p50); // the numbers themselves are printed by bench/pipeline.ts | |
| 73 | }, 20000); | |
| 74 | }); |
Software/so101/src/command-lag.ts added +69
| @@ -0,0 +1,69 @@ | ||
| 1 | /** | |
| 2 | * The lag a driver feels: from a command leaving the page to the first confirmed motion of the arm, as the arm's own | |
| 3 | * read-back reports it. Not a round trip: a command is timed only when the arm is at rest (no position has changed for | |
| 4 | * `restMs`), and ends at the first status whose positions differ. A command that moves nothing is dropped | |
| 5 | * after `giveUpMs`. Pure state; the clock is passed in. | |
| 6 | */ | |
| 7 | export interface Lag { | |
| 8 | p50: number; | |
| 9 | p95: number; | |
| 10 | n: number; | |
| 11 | } | |
| 12 | ||
| 13 | const RING = 64; | |
| 14 | ||
| 15 | export class CommandLag { | |
| 16 | private probe: { at: number; base: string } | null = null; | |
| 17 | private current = ""; | |
| 18 | private changedAt = -Infinity; | |
| 19 | private readonly samples: number[] = []; | |
| 20 | private cached: Lag | null = null; | |
| 21 | private cachedAt = -Infinity; | |
| 22 | /** Called with every measurement, ms. */ | |
| 23 | onSample: ((ms: number) => void) | undefined; | |
| 24 | ||
| 25 | private readonly giveUpMs: number; | |
| 26 | private readonly restMs: number; | |
| 27 | ||
| 28 | constructor(giveUpMs = 1000, restMs = 60) { | |
| 29 | this.giveUpMs = giveUpMs; | |
| 30 | this.restMs = restMs; | |
| 31 | } | |
| 32 | ||
| 33 | /** A command went on to the arm at `now`. */ | |
| 34 | command(now: number): void { | |
| 35 | if (this.probe && now - this.probe.at < this.giveUpMs) return; | |
| 36 | this.probe = this.current !== "" && now - this.changedAt >= this.restMs ? { at: now, base: this.current } : null; | |
| 37 | } | |
| 38 | ||
| 39 | /** A status came in at `now`, with the positions in joint order. */ | |
| 40 | status(positions: string, now: number): void { | |
| 41 | if (positions !== this.current) this.changedAt = now; | |
| 42 | this.current = positions; | |
| 43 | const p = this.probe; | |
| 44 | if (!p) return; | |
| 45 | if (now - p.at >= this.giveUpMs) this.probe = null; | |
| 46 | else if (positions !== p.base) { | |
| 47 | this.probe = null; | |
| 48 | this.add(now - p.at); | |
| 49 | } | |
| 50 | } | |
| 51 | ||
| 52 | private add(ms: number): void { | |
| 53 | if (this.samples.length >= RING) this.samples.shift(); | |
| 54 | this.samples.push(ms); | |
| 55 | this.cachedAt = -Infinity; | |
| 56 | this.onSample?.(ms); | |
| 57 | } | |
| 58 | ||
| 59 | /** p50 and p95 of the last samples (rounded ms); null before there are any. Cached for 500 ms: it is read with every status. */ | |
| 60 | lag(now: number): Lag | null { | |
| 61 | if (!this.samples.length) return null; | |
| 62 | if (now - this.cachedAt < 500 && this.cached) return this.cached; | |
| 63 | const sorted = [...this.samples].sort((a, b) => a - b); | |
| 64 | const at = (p: number) => Math.round(sorted[Math.max(0, Math.ceil(p * sorted.length) - 1)]!); | |
| 65 | this.cached = { p50: at(0.5), p95: at(0.95), n: this.samples.length }; | |
| 66 | this.cachedAt = now; | |
| 67 | return this.cached; | |
| 68 | } | |
| 69 | } |
Software/so101/src/fake-bus.ts +3 −1
| @@ -19,6 +19,8 @@ export interface FakeOptions { | ||
| 19 | 19 | dropAfter?: Map<number, number>; |
| 20 | 20 | /** Goals are followed at this many ticks a second, read by read, instead of reached at once (bench/precision.ts). */ |
| 21 | 21 | speedTicksS?: number; |
| 22 | /** Every reply comes this many ms after the packet (a real bus round trip is about 1 ms at 1 Mbaud). */ | |
| 23 | latencyMs?: number; | |
| 22 | 24 | } |
| 23 | 25 | |
| 24 | 26 | /** Enough of an STS3215 to answer the bus: a memory map per id, replies by instruction. */ |
| @@ -75,7 +77,7 @@ export class FakeBus implements Transport { | ||
| 75 | 77 | } |
| 76 | 78 | const delay = this..lateOnce?.get(id) ?? 0; |
| 77 | 79 | this..lateOnce?.delete(id); |
| 78 | replies.push([delay, frame]); | |
| 80 | replies.push([delay + (this..latencyMs ?? 0), frame]); | |
| 79 | 81 | }; |
| 80 | 82 | // Instruction packets have the status-packet shape, so the parser reads them too. |
| 81 | 83 | for (const packet of this..push(data)) { |
Software/so101/src/feedback.test.ts added +70
| @@ -0,0 +1,70 @@ | ||
| 1 | import { afterAll, beforeAll, describe, 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 WebSocket from "ws"; | |
| 6 | import { FeetechBus } from "./bus.ts"; | |
| 7 | import { FakeBus } from "./fake-bus.ts"; | |
| 8 | import { startServer, type RunningServer } from "./server.ts"; | |
| 9 | ||
| 10 | const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); | |
| 11 | const IDS = [1, 2, 3]; | |
| 12 | let fake: FakeBus; | |
| 13 | let pi: RunningServer; | |
| 14 | beforeAll(async () => { | |
| 15 | fake = new FakeBus(IDS, { latencyMs: 1 }); | |
| 16 | pi = await startServer(new FeetechBus(fake), { host: "127.0.0.1", port: 0, ids: IDS, posesFile: join(mkdtempSync(join(tmpdir(), "so101-fb-")), "poses.json") }); | |
| 17 | }); | |
| 18 | afterAll(() => Promise.race([pi.close(), sleep(500)])); | |
| 19 | ||
| 20 | /** Statuses a watcher hears for `ms`, with when each came. */ | |
| 21 | async function listen(ms: number, during?: () => void | Promise<void>) { | |
| 22 | const ws = new WebSocket(`ws://127.0.0.1:${pi.port}/watch`); | |
| 23 | const heard: Array<{ at: number; positions: number[]; torque: boolean[] }> = []; | |
| 24 | ws.on("message", (d) => { | |
| 25 | const m = JSON.parse(String(d)) as { joints?: Array<{ position: number; torque: boolean }> }; | |
| 26 | if (m.joints) heard.push({ at: performance.now(), positions: m.joints.map((j) => j.position), torque: m.joints.map((j) => j.torque) }); | |
| 27 | }); | |
| 28 | await new Promise((r) => ws.once("open", r)); | |
| 29 | await sleep(100); | |
| 30 | const from = heard.length; | |
| 31 | const reads = fake.reads; | |
| 32 | await during?.(); | |
| 33 | await sleep(ms); | |
| 34 | ws.terminate(); | |
| 35 | return { heard: heard.slice(from), reads: fake.reads - reads }; | |
| 36 | } | |
| 37 | const moveJoint = async (steps: number, everyMs: number) => { | |
| 38 | for (let i = 0; i < steps; i++) { | |
| 39 | const m = fake.servos.get(1)!; | |
| 40 | const p = (m[56]! | (m[57]! << 8)) + 3; | |
| 41 | m[56] = p & 0xff; | |
| 42 | m[57] = p >> 8; | |
| 43 | await sleep(everyMs); | |
| 44 | } | |
| 45 | }; | |
| 46 | ||
| 47 | describe("/watch feedback", () => { | |
| 48 | test("a moving arm is pushed on every change at 25 Hz or better, from one bus read a cycle", async () => { | |
| 49 | const { heard, reads } = await listen(100, () => moveJoint(100, 10)); | |
| 50 | // 1.1 s of continuous change: every cycle differs from the last | |
| 51 | expect(heard.length).toBeGreaterThan(40); // 100 changes were made; a 4 a second push would have heard about 5 | |
| 52 | // one block read a cycle (the torque read is occasional): well under two bus reads a status | |
| 53 | expect(reads / heard.length).toBeLessThan(1.3); | |
| 54 | }); | |
| 55 | ||
| 56 | test("a quiet arm is only heard from at the heartbeat, not 50 times a second", async () => { | |
| 57 | const { heard } = await listen(1000); | |
| 58 | expect(heard.length).toBeLessThan(40); // not one a cycle: unchanged statuses are held back to the heartbeat | |
| 59 | expect(heard.length).toBeGreaterThanOrEqual(1); | |
| 60 | }); | |
| 61 | ||
| 62 | test("a torque change is in the very next status", async () => { | |
| 63 | const { heard } = await listen(300, async () => { | |
| 64 | await fetch(`http://127.0.0.1:${pi.port}/torque`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ on: true }) }); | |
| 65 | }); | |
| 66 | expect(heard.some((h) => h.torque.every(Boolean))).toBe(true); | |
| 67 | const first = heard.findIndex((h) => h.torque.every(Boolean)); | |
| 68 | expect(heard.slice(first).every((h) => h.torque.every(Boolean))).toBe(true); | |
| 69 | }); | |
| 70 | }); |
Software/so101/src/hermetic.test.ts added +9
| @@ -0,0 +1,9 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { parseRemote } from "./relay.ts"; | |
| 3 | ||
| 4 | test("the suite starts with no arm server or camera from the environment, and a child inherits that", async () => { | |
| 5 | for (const name of Object.keys(process.env)) if (name.startsWith("ARM_")) expect(process.env[name], name).toBe(""); | |
| 6 | expect(parseRemote(process.env)).toBeNull(); | |
| 7 | const child = Bun.spawn(["bun", "-e", "console.log(JSON.stringify(Object.keys(process.env).filter((k) => k.startsWith('ARM_') && process.env[k])))"], { env: process.env, stdout: "pipe" }); | |
| 8 | expect(JSON.parse(await new Response(child.stdout).text())).toEqual([]); | |
| 9 | }); |
Software/so101/src/lobby-backpressure.test.ts added +88
| @@ -0,0 +1,88 @@ | ||
| 1 | import { describe, expect, test } from "bun:test"; | |
| 2 | import { DEAD_BYTES, Lobby, SLOW_BYTES, STALL_MS } from "./lobby.ts"; | |
| 3 | import { behind } from "./send-meter.ts"; | |
| 4 | ||
| 5 | /** A page's socket that writes only when told to: sends are held until `flush`, so a slow page costs no bytes and no time. */ | |
| 6 | function fakePage() { | |
| 7 | const held: Array<{ data: string; done: () => void }> = []; | |
| 8 | const got: string[] = []; | |
| 9 | const ws = { | |
| 10 | readyState: 1, | |
| 11 | terminated: false, | |
| 12 | send(data: string, done?: () => void) { | |
| 13 | held.push({ data, done: done ?? (() => {}) }); | |
| 14 | }, | |
| 15 | terminate() { | |
| 16 | ws.terminated = true; | |
| 17 | ws.readyState = 3; | |
| 18 | }, | |
| 19 | /** The page reads everything held so far. */ | |
| 20 | flush() { | |
| 21 | for (const h of held.splice(0)) (got.push(h.data), h.done()); | |
| 22 | }, | |
| 23 | }; | |
| 24 | return { ws, got, held }; | |
| 25 | } | |
| 26 | function room(...pages: Array<ReturnType<typeof fakePage>>) { | |
| 27 | const lobby = new Lobby({ local: true }); | |
| 28 | const seats = (lobby as unknown as { seats: Set<unknown> }).seats; | |
| 29 | for (const p of pages) seats.add({ ws: p.ws, who: null, from: {}, verified: false, heard: Date.now() }); | |
| 30 | return lobby; | |
| 31 | } | |
| 32 | const pad = "p".repeat(8000); | |
| 33 | const state = (i: number) => JSON.stringify({ type: "x", i, pad }); | |
| 34 | ||
| 35 | describe("a page that stops reading", () => { | |
| 36 | test("never stalls the others, is sent only the newest state once it is behind, and gets it when it catches up", () => { | |
| 37 | const fast = fakePage(); | |
| 38 | const slow = fakePage(); | |
| 39 | const lobby = room(fast, slow); | |
| 40 | const N = 3000; | |
| 41 | let peak = 0; | |
| 42 | for (let i = 0; i < N; i++) { | |
| 43 | lobby.publishState(state(i)); | |
| 44 | fast.ws.flush(); // the fast page keeps up | |
| 45 | peak = Math.max(peak, behind(slow.ws)); | |
| 46 | } | |
| 47 | expect(fast.got.length).toBe(N); // heard every one | |
| 48 | expect(peak).toBeLessThan(SLOW_BYTES + 16_000); // the slow page's backlog stopped growing at the limit | |
| 49 | expect(slow.held.length).toBeLessThan(20); | |
| 50 | slow.ws.flush(); // it catches up: the newest held state follows at once | |
| 51 | slow.ws.flush(); | |
| 52 | expect(JSON.parse(slow.got.at(-1)!).i).toBe(N - 1); | |
| 53 | expect(slow.got.length).toBeLessThan(30); // spared nearly all the steps in between | |
| 54 | expect(slow.ws.terminated).toBe(false); | |
| 55 | }); | |
| 56 | ||
| 57 | test("a page that reads nothing at all is cut once its backlog passes DEAD_BYTES, not buffered without end", () => { | |
| 58 | const dead = fakePage(); | |
| 59 | const ok = fakePage(); | |
| 60 | const lobby = room(dead, ok); | |
| 61 | const big = JSON.stringify({ type: "chat", pad: "d".repeat(60_000) }); | |
| 62 | let sent = 0; | |
| 63 | // chat-style sends reach every page; there is no newest-wins to fall back on | |
| 64 | while (!dead.ws.terminated && sent < 200) { | |
| 65 | lobby.publish(big); | |
| 66 | ok.ws.flush(); | |
| 67 | sent++; | |
| 68 | } | |
| 69 | expect(dead.ws.terminated).toBe(true); | |
| 70 | expect(sent).toBeLessThanOrEqual(Math.ceil(DEAD_BYTES / big.length) + 2); | |
| 71 | expect(ok.ws.terminated).toBe(false); | |
| 72 | }); | |
| 73 | ||
| 74 | test("a page whose writes have not moved for STALL_MS is cut at the next push", () => { | |
| 75 | const stuck = fakePage(); | |
| 76 | const lobby = room(stuck); | |
| 77 | lobby.publishState(state(0)); | |
| 78 | const real = Date.now; | |
| 79 | Date.now = () => real() + STALL_MS + 1000; | |
| 80 | try { | |
| 81 | lobby.publishState(state(1)); | |
| 82 | lobby.publishState(state(2)); | |
| 83 | } finally { | |
| 84 | Date.now = real; | |
| 85 | } | |
| 86 | expect(stuck.ws.terminated).toBe(true); | |
| 87 | }); | |
| 88 | }); |
Software/so101/src/lobby.ts +54 −6
| @@ -38,6 +38,7 @@ | ||
| 38 | 38 | */ |
| 39 | 39 | |
| 40 | 40 | import { readFileSync, renameSync, writeFileSync } from "node:fs"; |
| 41 | import { behind, meteredSend, stalled } from "./send-meter.ts"; | |
| 41 | 42 | import { randomBytes } from "node:crypto"; |
| 42 | 43 | import { dirname, join } from "node:path"; |
| 43 | 44 | import type { IncomingMessage } from "node:http"; |
| @@ -108,8 +109,17 @@ interface Seat { | ||
| 108 | 109 | guest?: { id: string; name: GuestName }; |
| 109 | 110 | /** When this socket's last "who" was answered. */ |
| 110 | 111 | whoAt?: number; |
| 112 | /** The newest state push held back because this socket's send buffer was full: only the latest is kept, and it goes when the buffer drains. */ | |
| 113 | latest?: string; | |
| 111 | 114 | } |
| 112 | 115 | |
| 116 | /** A page with this much unsent has stopped keeping up: state pushes to it are coalesced into the newest one. */ | |
| 117 | export const SLOW_BYTES = 64 * 1024; | |
| 118 | /** A page with this much unsent is not reading at all: it is cut (its reconnect, with its seat, is cheap) rather than buffered without end. */ | |
| 119 | /** A page whose writes have not moved for this long is cut: it reconnects, with its seat. */ | |
| 120 | export const STALL_MS = 8000; | |
| 121 | export const DEAD_BYTES = 2 * 1024 * 1024; | |
| 122 | ||
| 113 | 123 | const MAX_FRAME = 4096; |
| 114 | 124 | /** Lines of chat a new page is sent: enough to fill a phone's chat, little enough for a slow link. */ |
| 115 | 125 | export const CHAT_FIRST = 30; |
| @@ -319,6 +329,11 @@ export class Lobby { | ||
| 319 | 329 | this.wss.handleUpgrade(req, socket, head, (ws) => this.seat(ws, who, from, verified, browserId(req.headers.cookie))); |
| 320 | 330 | } |
| 321 | 331 | |
| 332 | /** Tell every page to reconnect (a restart on purpose); each seat's close starts its grace period as for any drop. */ | |
| 333 | closeSockets(code: number, reason: string): void { | |
| 334 | for (const s of this.seats) s.ws.close(code, reason); | |
| 335 | } | |
| 336 | ||
| 322 | 337 | close(): void { |
| 323 | 338 | clearInterval(this.pinger); |
| 324 | 339 | clearTimeout(this.presenceTimer); |
| @@ -355,7 +370,7 @@ export class Lobby { | ||
| 355 | 370 | this.send(seat, this.adminState()); |
| 356 | 371 | this.send(seat, this.crewState()); |
| 357 | 372 | } |
| 358 | for (const listener of this.arriveListeners) listener((text) => seat.ws.readyState === WebSocket.OPEN && seat.ws.send(text)); | |
| 373 | for (const listener of this.arriveListeners) listener((text) => this.deliver(seat, text)); | |
| 359 | 374 | ws.on("message", (data, binary) => { |
| 360 | 375 | seat.heard = Date.now(); |
| 361 | 376 | if (binary) return; |
| @@ -683,7 +698,7 @@ export class Lobby { | ||
| 683 | 698 | |
| 684 | 699 | private broadcastAdmin(): void { |
| 685 | 700 | const text = JSON.stringify(this.adminState()); |
| 686 | for (const s of this.seats) if (s.who?.owner && s.ws.readyState === WebSocket.OPEN) s.ws.send(text); | |
| 701 | for (const s of this.seats) if (s.who?.owner) this.deliver(s, text); | |
| 687 | 702 | } |
| 688 | 703 | |
| 689 | 704 | /** What the crew did, for the owners: its settings, the lines it hid, and who it suggests muting. */ |
| @@ -760,14 +775,14 @@ export class Lobby { | ||
| 760 | 775 | /** Send every open page the same thing, serialized once however many watch; a string is taken as JSON already. */ |
| 761 | 776 | publish(body: unknown): void { |
| 762 | 777 | const text = typeof body === "string" ? body : JSON.stringify(body); |
| 763 | for (const s of this.seats) if (s.ws.readyState === WebSocket.OPEN) s.ws.send(text); | |
| 778 | for (const s of this.seats) this.deliver(s, text); | |
| 764 | 779 | } |
| 765 | 780 | |
| 766 | 781 | /** Tell the driver's pages alone something; in a local run, where nobody takes turns, every page. */ |
| 767 | 782 | tellDriver(body: unknown): void { |
| 768 | 783 | const text = JSON.stringify(body); |
| 769 | 784 | const id = this.turns.driver?.id; |
| 770 | for (const s of this.seats) if (s.ws.readyState === WebSocket.OPEN && (this.local || (id !== undefined && s.who?.id === id))) s.ws.send(text); | |
| 785 | for (const s of this.seats) if (this.local || (id !== undefined && s.who?.id === id)) this.deliver(s, text); | |
| 771 | 786 | } |
| 772 | 787 | |
| 773 | 788 | /** How many pages have the room open. */ |
| @@ -835,11 +850,44 @@ export class Lobby { | ||
| 835 | 850 | } |
| 836 | 851 | |
| 837 | 852 | private send(seat: Seat, body: unknown): void { |
| 838 | if (seat.ws.readyState === WebSocket.OPEN) seat.ws.send(JSON.stringify(body)); | |
| 853 | this.deliver(seat, JSON.stringify(body)); | |
| 854 | } | |
| 855 | ||
| 856 | /** One send, the only way text leaves for a page: a socket with a huge or stuck backlog is cut instead of growing without end. */ | |
| 857 | private deliver(seat: Seat, text: string): void { | |
| 858 | const ws = seat.ws; | |
| 859 | if (ws.readyState !== WebSocket.OPEN) return; | |
| 860 | if (behind(ws) > DEAD_BYTES || stalled(ws, STALL_MS)) return void ws.terminate(); | |
| 861 | meteredSend(ws, text, () => seat.latest !== undefined && behind(ws) <= SLOW_BYTES && this.flush(seat)); | |
| 862 | } | |
| 863 | ||
| 864 | /** The newest held-back state goes to a page that has caught up. */ | |
| 865 | private flush(seat: Seat): void { | |
| 866 | const text = seat.latest; | |
| 867 | seat.latest = undefined; | |
| 868 | if (text !== undefined) this.deliver(seat, text); | |
| 869 | } | |
| 870 | ||
| 871 | /** | |
| 872 | * Push the arm's state: it supersedes the one before, so a page that is behind (more than SLOW_BYTES sent and not yet | |
| 873 | * written out, src/send-meter.ts) is not sent every step, only the newest once it has caught up; everyone else hears | |
| 874 | * every one at once. | |
| 875 | */ | |
| 876 | publishState(text: string): void { | |
| 877 | for (const s of this.seats) { | |
| 878 | if (s.ws.readyState !== WebSocket.OPEN) continue; | |
| 879 | if (behind(s.ws) > SLOW_BYTES) { | |
| 880 | s.latest = text; | |
| 881 | if (stalled(s.ws, STALL_MS)) s.ws.terminate(); | |
| 882 | } else { | |
| 883 | s.latest = undefined; | |
| 884 | this.deliver(s, text); | |
| 885 | } | |
| 886 | } | |
| 839 | 887 | } |
| 840 | 888 | |
| 841 | 889 | private broadcast(body: unknown): void { |
| 842 | 890 | const text = JSON.stringify(body); |
| 843 | for (const s of this.seats) if (s.ws.readyState === WebSocket.OPEN) s.ws.send(text); | |
| 891 | for (const s of this.seats) this.deliver(s, text); | |
| 844 | 892 | } |
| 845 | 893 | } |
Software/so101/src/machine-feed.test.ts +20
| @@ -128,3 +128,23 @@ test("a panel that opens later gets the log so far; the simulation has a panel t | ||
| 128 | 128 | first.ws.close(); |
| 129 | 129 | later.ws.close(); |
| 130 | 130 | }); |
| 131 | ||
| 132 | test("a pose that changes at 50 Hz is one log line every 100 ms, and the pose it settles at is logged", async () => { | |
| 133 | const { MachineFeed, NOTE_EVERY_MS } = await import("./machine-feed.ts"); | |
| 134 | const feed = new MachineFeed({ link: () => ({ mode: "live", host: null, problem: null, rttMs: null, fps: 0, reconnects: 0 }), room: () => ({ driver: null, paused: false, speedCapPercent: 100 }) }); | |
| 135 | const at = (p: number) => ({ joints: [{ id: 1, position: p, torque: true }], driving: null }); | |
| 136 | const start = performance.now(); | |
| 137 | let p = 2000; | |
| 138 | while (performance.now() - start < 1000) { | |
| 139 | feed.status(at(p++)); | |
| 140 | await sleep(20); | |
| 141 | } | |
| 142 | const noted = feed.log.recent(1000).filter((e) => e.what === "status"); | |
| 143 | expect(noted.length).toBeLessThanOrEqual(Math.ceil(1000 / NOTE_EVERY_MS) + 1); | |
| 144 | expect(noted.length).toBeGreaterThanOrEqual(7); | |
| 145 | // it stops at p-1; the next status past the interval (a heartbeat) logs where it rests | |
| 146 | await sleep(NOTE_EVERY_MS + 10); | |
| 147 | feed.status(at(p - 1)); | |
| 148 | expect((feed.log.recent(1000).filter((e) => e.what === "status").at(-1)!.detail.positions as Record<string, number>)["1"]).toBe(p - 1); | |
| 149 | feed.close(); | |
| 150 | }); |
Software/so101/src/machine-feed.ts +17 −3
| @@ -15,6 +15,7 @@ | ||
| 15 | 15 | import type { IncomingMessage } from "node:http"; |
| 16 | 16 | import type { Duplex } from "node:stream"; |
| 17 | 17 | import WebSocket, { WebSocketServer } from "ws"; |
| 18 | import { behind, meteredSend, stalled } from "./send-meter.ts"; | |
| 18 | 19 | import { MachineLog, perServo, type DetailValue, type EntryKind, type MachineEntry } from "./machine-log.ts"; |
| 19 | 20 | |
| 20 | 21 | export interface FeedLink { |
| @@ -44,6 +45,8 @@ export interface MachineFeedOptions { | ||
| 44 | 45 | /** A viewer with this much unsent is too slow for another status; it still gets the log. */ |
| 45 | 46 | const SLOW_BYTES = 1 << 20; |
| 46 | 47 | const HELLO_ENTRIES = 300; |
| 48 | /** The most often a changed pose is written to the log. */ | |
| 49 | export const NOTE_EVERY_MS = 100; | |
| 47 | 50 | |
| 48 | 51 | type Status = Record<string, unknown>; |
| 49 | 52 | interface Joint { |
| @@ -68,6 +71,7 @@ export class MachineFeed { | ||
| 68 | 71 | readonly log: MachineLog; |
| 69 | 72 | : Status | null = null; |
| 70 | 73 | = ""; |
| 74 | = -Infinity; | |
| 71 | 75 | : string | null | undefined = undefined; |
| 72 | 76 | /** The arm server's run and the last of its entries kept, so a backlog sent again on reconnecting is not kept twice. */ |
| 73 | 77 | = ""; |
| @@ -105,9 +109,14 @@ export class MachineFeed { | ||
| 105 | 109 | if (this. !== undefined) this.log.note("state", "turn", { driver: room.driver }); |
| 106 | 110 | this. = room.driver; |
| 107 | 111 | } |
| 112 | // The arm server reads the bus 50 times a second; the log keeps a line for a change at most every NOTE_EVERY_MS, so the | |
| 113 | // minutes of history behind the Machine panel are not pushed out by the poses between. A change held back is noted by the | |
| 114 | // next status past the interval (the quiet arm's heartbeat comes every quarter second), so the pose it rests at is always there. | |
| 108 | 115 | const sig = signature(status); |
| 109 | if (sig !== this.) { | |
| 116 | const now = performance.now(); | |
| 117 | if (sig !== this. && now - this. >= NOTE_EVERY_MS) { | |
| 110 | 118 | this. = sig; |
| 119 | this. = now; | |
| 111 | 120 | this.log.note("received", "status", { positions: positions(status), driving: typeof status.driving === "string" ? status.driving : null }); |
| 112 | 121 | } |
| 113 | 122 | if (this..clients.size) this.({ type: "status", status, link: this..link(), room }, true); |
| @@ -140,8 +149,13 @@ export class MachineFeed { | ||
| 140 | 149 | if (!this..clients.size) return; |
| 141 | 150 | const text = JSON.stringify(message); |
| 142 | 151 | for (const ws of this..clients) { |
| 143 | if (ws.readyState !== WebSocket.OPEN || (droppable && ws.bufferedAmount > SLOW_BYTES)) continue; | |
| 144 | ws.send(text); | |
| 152 | if (ws.readyState !== WebSocket.OPEN) continue; | |
| 153 | if (stalled(ws, 8000)) { | |
| 154 | ws.terminate(); | |
| 155 | continue; | |
| 156 | } | |
| 157 | if (droppable && behind(ws) > SLOW_BYTES) continue; | |
| 158 | meteredSend(ws, text); | |
| 145 | 159 | } |
| 146 | 160 | } |
| 147 | 161 | } |
Software/so101/src/park-grace.test.ts added +73
| @@ -0,0 +1,73 @@ | ||
| 1 | import { afterEach, describe, 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 WebSocket from "ws"; | |
| 6 | import { FeetechBus } from "./bus.ts"; | |
| 7 | import { FakeBus } from "./fake-bus.ts"; | |
| 8 | import { startServer, type RunningServer } from "./server.ts"; | |
| 9 | ||
| 10 | const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); | |
| 11 | const IDS = [1, 2, 3]; | |
| 12 | let pi: RunningServer | undefined; | |
| 13 | afterEach(() => Promise.race([pi?.close(), sleep(500)])); | |
| 14 | ||
| 15 | async function arm(parkGraceMs: number) { | |
| 16 | const fake = new FakeBus(IDS); | |
| 17 | for (const id of IDS) fake.servos.get(id)!.set([0, 0, 0xff, 0x0f], 9); | |
| 18 | pi = await startServer(new FeetechBus(fake), { host: "127.0.0.1", port: 0, ids: IDS, parkGraceMs, parkSpeedDegS: 2000, posesFile: join(mkdtempSync(join(tmpdir(), "so101-pg-")), "poses.json") } as never); | |
| 19 | return { fake, at: (id: number) => fake.servos.get(id)![56]! | (fake.servos.get(id)![57]! << 8) }; | |
| 20 | } | |
| 21 | const stream = async (port: number) => { | |
| 22 | const ws = new WebSocket(`ws://127.0.0.1:${port}/follow`); | |
| 23 | await new Promise((r) => ws.once("open", r)); | |
| 24 | return ws; | |
| 25 | }; | |
| 26 | const drive = async (ws: WebSocket, to: number) => { | |
| 27 | for (let i = 0; i < 20; i++) { | |
| 28 | ws.send(JSON.stringify({ "1": to })); | |
| 29 | await sleep(20); | |
| 30 | } | |
| 31 | }; | |
| 32 | ||
| 33 | describe("a stream that just stops (the relay restarted) is not an abandoned driver", () => { | |
| 34 | test("the arm holds where it is for the grace, and a stream that returns in time keeps it from parking", async () => { | |
| 35 | const { at } = await arm(600); | |
| 36 | const first = await stream(pi!.port); | |
| 37 | await drive(first, 2600); | |
| 38 | await sleep(300); | |
| 39 | const where = at(1); | |
| 40 | expect(where).toBeGreaterThan(2400); | |
| 41 | first.terminate(); // no close frame: 1006 | |
| 42 | await sleep(250); | |
| 43 | expect(at(1)).toBeGreaterThan(where - 40); // holding, not parking | |
| 44 | const second = await stream(pi!.port); // the relay is back with the same driver | |
| 45 | await drive(second, 2600); | |
| 46 | await sleep(900); // past the grace: the first stream's park must have been cancelled | |
| 47 | expect(at(1)).toBeGreaterThan(2400); | |
| 48 | second.close(1000); | |
| 49 | }, 15000); | |
| 50 | ||
| 51 | test("with no stream back after the grace, the arm parks as before", async () => { | |
| 52 | const { at } = await arm(300); | |
| 53 | const first = await stream(pi!.port); | |
| 54 | await drive(first, 2600); | |
| 55 | await sleep(300); | |
| 56 | expect(at(1)).toBeGreaterThan(2400); | |
| 57 | first.terminate(); | |
| 58 | await sleep(200); | |
| 59 | expect(at(1)).toBeGreaterThan(2400); | |
| 60 | for (let i = 0; i < 40 && at(1) > 2300; i++) await sleep(50); | |
| 61 | expect(at(1)).toBeLessThan(2300); // parked toward the middle of its range | |
| 62 | }, 15000); | |
| 63 | ||
| 64 | test("a driver the relay reports lost (4001) parks at once, grace or not", async () => { | |
| 65 | const { at } = await arm(5000); | |
| 66 | const first = await stream(pi!.port); | |
| 67 | await drive(first, 2600); | |
| 68 | await sleep(300); | |
| 69 | first.close(4001, "the driver's connection was lost"); | |
| 70 | for (let i = 0; i < 40 && at(1) > 2300; i++) await sleep(50); | |
| 71 | expect(at(1)).toBeLessThan(2300); | |
| 72 | }, 15000); | |
| 73 | }); |
Software/so101/src/pipeline-rig.ts added +87
| @@ -0,0 +1,87 @@ | ||
| 1 | /** | |
| 2 | * A whole local pipeline for measuring and testing: a fake bus with a round trip → the arm server (src/server.ts) | |
| 3 | * → the relay (src/relay.ts) → a WebSocket client standing in for the page. Fake arm only; never a real address. | |
| 4 | */ | |
| 5 | import { mkdtempSync } from "node:fs"; | |
| 6 | import { tmpdir } from "node:os"; | |
| 7 | import { join } from "node:path"; | |
| 8 | import WebSocket from "ws"; | |
| 9 | import { FeetechBus } from "./bus.ts"; | |
| 10 | import { stamper } from "./command-gate.ts"; | |
| 11 | import { FakeBus } from "./fake-bus.ts"; | |
| 12 | import { Lobby } from "./lobby.ts"; | |
| 13 | import { parseRemote, startRelay, type RunningRelay } from "./relay.ts"; | |
| 14 | import { startServer, type RunningServer } from "./server.ts"; | |
| 15 | import type { ServerOptions } from "./server.ts"; | |
| 16 | ||
| 17 | export interface RigOptions { | |
| 18 | /** One bus round trip, ms. */ | |
| 19 | latencyMs?: number; | |
| 20 | relay?: Partial<Parameters<typeof startRelay>[0]>; | |
| 21 | server?: Partial<ServerOptions>; | |
| 22 | } | |
| 23 | ||
| 24 | export interface Rig { | |
| 25 | fake: FakeBus; | |
| 26 | bus: FeetechBus; | |
| 27 | pi: RunningServer; | |
| 28 | relay: RunningRelay; | |
| 29 | lobby: Lobby; | |
| 30 | dir: string; | |
| 31 | base: string; | |
| 32 | close(): Promise<void>; | |
| 33 | } | |
| 34 | ||
| 35 | const IDS = [1, 2, 3, 4, 5, 6]; | |
| 36 | const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); | |
| 37 | ||
| 38 | export async function startRig(o: RigOptions = {}): Promise<Rig> { | |
| 39 | const dir = mkdtempSync(join(tmpdir(), "so101-rig-")); | |
| 40 | const fake = new FakeBus(IDS, { latencyMs: o.latencyMs ?? 1 }); | |
| 41 | for (const id of IDS) fake.servos.get(id)!.set([0, 0, 0xff, 0x0f], 9); // full range | |
| 42 | const bus = new FeetechBus(fake); | |
| 43 | const pi = await startServer(bus, { host: "127.0.0.1", port: 0, ids: IDS, posesFile: join(dir, "poses.json"), ...o.server }); | |
| 44 | const sim = await startServer(new FeetechBus(new FakeBus(IDS)), { host: "127.0.0.1", port: 0, ids: IDS, posesFile: join(dir, "sim-poses.json") }); | |
| 45 | const lobby = new Lobby({ local: true }); | |
| 46 | const remote = parseRemote({ ARM_SERVER: `ws://127.0.0.1:${pi.port}` })!; | |
| 47 | const relay = await startRelay({ host: "127.0.0.1", port: 0, simulation: `http://127.0.0.1:${sim.port}`, remote, lobby, ...o.relay }); | |
| 48 | return { | |
| 49 | fake, bus, pi, relay, lobby, dir, | |
| 50 | base: `127.0.0.1:${relay.port}`, | |
| 51 | close: async () => { | |
| 52 | await Promise.race([relay.close(), sleep(500)]); | |
| 53 | await Promise.race([pi.close(), sleep(500)]); | |
| 54 | await Promise.race([sim.close(), sleep(500)]); | |
| 55 | }, | |
| 56 | }; | |
| 57 | } | |
| 58 | ||
| 59 | export const open = (url: string) => | |
| 60 | new Promise<WebSocket>((resolve, reject) => { | |
| 61 | const ws = new WebSocket(url); | |
| 62 | ws.once("open", () => resolve(ws)); | |
| 63 | ws.once("error", reject); | |
| 64 | }); | |
| 65 | ||
| 66 | /** A stand-in for the page: /live for the pushed status, /follow for the stream. */ | |
| 67 | export async function page(base: string) { | |
| 68 | const live = await open(`ws://${base}/live`); | |
| 69 | const follow = await open(`ws://${base}/follow`); | |
| 70 | const stamp = stamper(() => Date.now()); | |
| 71 | const listeners = new Set<(status: { joints: Array<{ id: number; position: number }> }, at: number) => void>(); | |
| 72 | live.on("message", (data) => { | |
| 73 | const at = performance.now(); | |
| 74 | const text = String(data); | |
| 75 | if (text.startsWith('{"type":"ping"')) return void live.send('{"type":"pong"}'); | |
| 76 | if (!text.includes('"status"')) return; | |
| 77 | const m = JSON.parse(text) as { type?: string; status?: { joints: Array<{ id: number; position: number }> } | null }; | |
| 78 | if (m.type === "status" && m.status) for (const l of listeners) l(m.status, at); | |
| 79 | }); | |
| 80 | live.send(JSON.stringify({ type: "join" })); | |
| 81 | return { | |
| 82 | live, follow, | |
| 83 | send: (joints: Record<string, number>) => follow.send(stamp(joints as never)), | |
| 84 | onStatus: (fn: (typeof listeners extends Set<infer F> ? F : never)) => (listeners.add(fn), () => listeners.delete(fn)), | |
| 85 | close: () => (live.terminate(), follow.terminate()), | |
| 86 | }; | |
| 87 | } |
Software/so101/src/pipeline.test.ts added +59
| @@ -0,0 +1,59 @@ | ||
| 1 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 2 | import { page, startRig, type Rig } from "./pipeline-rig.ts"; | |
| 3 | ||
| 4 | // Correctness only: nothing here asserts a time. The latency numbers (p50/p95 per hop) are printed by bench/pipeline.ts. | |
| 5 | const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); | |
| 6 | const until = async (done: () => boolean, what: string) => { | |
| 7 | for (let i = 0; i < 3000 && !done(); i++) await sleep(5); | |
| 8 | if (!done()) throw new Error(`never saw ${what}`); | |
| 9 | }; | |
| 10 | ||
| 11 | let rig: Rig; | |
| 12 | let p: Awaited<ReturnType<typeof page>>; | |
| 13 | beforeAll(async () => { | |
| 14 | rig = await startRig({ latencyMs: 1 }); | |
| 15 | p = await page(rig.base); | |
| 16 | await sleep(300); | |
| 17 | }); | |
| 18 | afterAll(async () => { | |
| 19 | p.close(); | |
| 20 | await rig.close(); | |
| 21 | }); | |
| 22 | ||
| 23 | describe("the whole local pipeline (fake bus → arm server → relay → page)", () => { | |
| 24 | test("a move reaches the page as a run of statuses, in order, ending at the target: not one batch per 200 ms", async () => { | |
| 25 | const seen: number[] = []; | |
| 26 | const off = p.onStatus((s) => seen.push(s.joints.find((j) => j.id === 1)!.position)); | |
| 27 | let goalWrites = 0; | |
| 28 | const prev = rig.bus.onSyncWrite; | |
| 29 | rig.bus.onSyncWrite = (reg, vals) => { | |
| 30 | if (reg.address === 42) goalWrites++; // Goal_Position | |
| 31 | prev?.(reg, vals); | |
| 32 | }; | |
| 33 | p.send({ "1": 2048 }); | |
| 34 | await sleep(200); | |
| 35 | seen.length = 0; | |
| 36 | goalWrites = 0; | |
| 37 | const target = 2300; | |
| 38 | let sent = 0; | |
| 39 | // keep the leader's frames coming until the arm has arrived, as a hand does | |
| 40 | await until(() => (p.send({ "1": target }), ++sent, seen.at(-1) === target), "the arm at its target"); | |
| 41 | await sleep(100); | |
| 42 | off(); | |
| 43 | rig.bus.onSyncWrite = prev; | |
| 44 | const distinct = new Set(seen); | |
| 45 | // in order: a rising move never reads lower than before | |
| 46 | for (let i = 1; i < seen.length; i++) expect(seen[i]!).toBeGreaterThanOrEqual(seen[i - 1]!); | |
| 47 | expect(seen.at(-1)).toBe(target); | |
| 48 | // not batched: the page heard about a fair share of the positions the bus wrote (a 5 a second push would hear one in a dozen) | |
| 49 | expect(goalWrites).toBeGreaterThan(10); | |
| 50 | expect(distinct.size).toBeGreaterThan(goalWrites / 4); | |
| 51 | }, 30000); | |
| 52 | ||
| 53 | test("a quiet arm still sends statuses (a heartbeat), so the link stays live", async () => { | |
| 54 | let n = 0; | |
| 55 | const off = p.onStatus(() => n++); | |
| 56 | await until(() => n >= 1, "a status from a resting arm"); | |
| 57 | off(); | |
| 58 | }); | |
| 59 | }); |
Software/so101/src/relay.ts +44 −17
| @@ -62,6 +62,8 @@ import { pipeline } from "node:stream/promises"; | ||
| 62 | 62 | import WebSocket, { WebSocketServer } from "ws"; |
| 63 | 63 | import { LiveMetrics } from "./live-metrics.ts"; |
| 64 | 64 | import { wallMs } from "./clock.ts"; |
| 65 | import { CommandLag } from "./command-lag.ts"; | |
| 66 | import { behind, meteredSend, stalled } from "./send-meter.ts"; | |
| 65 | 67 | import { ArmLink, type LinkTimings } from "./arm-link.ts"; |
| 66 | 68 | import { MachineFeed } from "./machine-feed.ts"; |
| 67 | 69 | import { refusalBatch } from "./refusals.ts"; |
| @@ -186,6 +188,8 @@ const STOPS = new Set(["POST /stop", "POST /estop"]); | ||
| 186 | 188 | const RECONNECTING = "reconnecting to the arm"; |
| 187 | 189 | /** The close code that tells the arm server its driver was lost, not done: it parks the arm. */ |
| 188 | 190 | export const LOST_CLOSE = 4001; |
| 191 | /** What a page is told when the relay goes down on purpose: reconnect now. */ | |
| 192 | export const RESTART_CLOSE = 1012; | |
| 189 | 193 | const MAX_JOIN_BODY = 1024; |
| 190 | 194 | const FLOOR = { |
| 191 | 195 | "x-content-type-options": "nosniff", |
| @@ -258,6 +262,10 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> { | ||
| 258 | 262 | const lobby = options.lobby ?? null; |
| 259 | 263 | const heartbeatMs = options.heartbeatMs ?? 1000; |
| 260 | 264 | const metrics = new LiveMetrics(); |
| 265 | /** Command → first confirmed motion, timed here from what the arm server reports (src/command-lag.ts). */ | |
| 266 | const commandLag = new CommandLag(); | |
| 267 | commandLag.onSample = (ms) => metrics.observe("command_to_motion_ms", ms); | |
| 268 | const positionsKey = (status: Record<string, unknown>) => (Array.isArray(status.joints) ? (status.joints as Array<{ position?: unknown }>).map((j) => j.position).join(",") : ""); | |
| 261 | 269 | const cameras = options.cameras ?? new CameraHub({ feed: { ...options.camera, metrics }, seed: remote ? [{ name: "Camera 1", source: remote.cameras[0]! }] : [] }); |
| 262 | 270 | const leaders = options.leaders ?? new LeaderCalibrations(); |
| 263 | 271 | const videos = new Map<number, VideoFeed>(Object.entries(options.video ?? {}).map(([id, sources]) => [Number(id), new VideoFeed(sources, {}, options.env ?? process.env)])); |
| @@ -299,6 +307,7 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> { | ||
| 299 | 307 | metrics, |
| 300 | 308 | onStatus: (status) => { |
| 301 | 309 | if (!onArm()) return; |
| 310 | commandLag.status(positionsKey(status), performance.now()); | |
| 302 | 311 | machine.status(status); |
| 303 | 312 | void push(); // on to the pages as it comes, at the arm's 25 Hz: no wait for the next tick |
| 304 | 313 | }, |
| @@ -456,7 +465,8 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> { | ||
| 456 | 465 | const last = armLink!.last; |
| 457 | 466 | const reconnecting = mode() === "reconnecting"; |
| 458 | 467 | if (!last) return { code: 503, body: { error: `${RECONNECTING}…`, source: source(), reconnecting } }; |
| 459 | return { code: 200, body: { ...last, held: gate.held ?? last.held ?? null, source: source(), reconnecting, ...(reconnecting ? { seenAt: new Date(armLink!.lastAt).toISOString() } : {}) } }; | |
| 468 | const lag = commandLag.lag(performance.now()); | |
| 469 | return { code: 200, body: { ...last, held: gate.held ?? last.held ?? null, source: source(), reconnecting, ...(lag ? { lag } : {}), ...(reconnecting ? { seenAt: new Date(armLink!.lastAt).toISOString() } : {}) } }; | |
| 460 | 470 | } |
| 461 | 471 | const answer = await fetch(simulation + path, { cache: "no-store", signal: AbortSignal.timeout(readTimeoutMs) }); |
| 462 | 472 | const body = (await answer.json()) as Record<string, unknown>; |
| @@ -547,28 +557,29 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> { | ||
| 547 | 557 | const statusEveryMs = options.statusEveryMs ?? 40; // the arm's own bus cycle: the simulation is asked this often |
| 548 | 558 | let pushed = ""; |
| 549 | 559 | let pushing = false; |
| 550 | /** A status came while one was being pushed: push again after, so the newest is never the one left behind. */ | |
| 551 | 560 | let again = false; |
| 552 | 561 | const push = async (): Promise<void> => { |
| 553 | 562 | if (!lobby) return; |
| 554 | if (pushing) return void (again = true); | |
| 555 | 563 | if (lobby.watching === 0) return void (pushed = ""); // the next page to open the room hears it at once |
| 564 | // A status that comes in while one is being made is not lost, nor made twice at once: one more pass follows. | |
| 565 | if (pushing) return void (again = true); | |
| 556 | 566 | pushing = true; |
| 557 | let text: string; | |
| 558 | 567 | try { |
| 559 | const { code, body } = await statusOf(); | |
| 560 | text = JSON.stringify(code === 200 ? { type: "status", status: body } : { type: "status", status: null, error: body.error ?? `status ${code}` }); | |
| 561 | } catch (err) { | |
| 562 | text = JSON.stringify({ type: "status", status: null, error: reason(err) }); | |
| 568 | do { | |
| 569 | again = false; | |
| 570 | let text: string; | |
| 571 | try { | |
| 572 | const { code, body } = await statusOf(); | |
| 573 | text = JSON.stringify(code === 200 ? { type: "status", status: body } : { type: "status", status: null, error: body.error ?? `status ${code}` }); | |
| 574 | } catch (err) { | |
| 575 | text = JSON.stringify({ type: "status", status: null, error: reason(err) }); | |
| 576 | } | |
| 577 | if (text !== pushed) lobby.publishState(text); | |
| 578 | pushed = text; | |
| 579 | } while (again); | |
| 563 | 580 | } finally { |
| 564 | 581 | pushing = false; |
| 565 | 582 | } |
| 566 | if (text !== pushed) lobby.publish(text); | |
| 567 | pushed = text; | |
| 568 | if (again) { | |
| 569 | again = false; | |
| 570 | return push(); | |
| 571 | } | |
| 572 | 583 | }; |
| 573 | 584 | |
| 574 | 585 | /** A static answer, gzipped: whole, so the length is known; kept when the browser may cache it too. */ |
| @@ -932,6 +943,7 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> { | ||
| 932 | 943 | if (lobby) cap.cap(text, percent); // keeps each joint's last target, so a cap set mid-stream starts from there |
| 933 | 944 | machine.note("sent", "follow frame", { values: values(text) }); |
| 934 | 945 | arm.send(stamped(data, release)); |
| 946 | if (real) commandLag.command(performance.now()); | |
| 935 | 947 | return; |
| 936 | 948 | } |
| 937 | 949 | if (!seeded) seed(); |
| @@ -940,6 +952,7 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> { | ||
| 940 | 952 | if (capped === null) return; |
| 941 | 953 | machine.note("sent", "follow frame", { values: values(capped) }); |
| 942 | 954 | arm.send(stamped(capped, release)); |
| 955 | if (real) commandLag.command(performance.now()); | |
| 943 | 956 | }); |
| 944 | 957 | arm.on("close", (code, why) => browser.close(sendable(code) ? code : 1011, why.length ? why.toString() : "the arm server went away")); |
| 945 | 958 | // A browser that closed the stream on purpose leaves the arm holding; one that vanished leaves it parking. |
| @@ -976,7 +989,11 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> { | ||
| 976 | 989 | watched.set(id, (w = { fan, viewers: 0, detach })); |
| 977 | 990 | } |
| 978 | 991 | const mine = w; |
| 979 | const viewer = { send: (data: Uint8Array) => browser.send(data, () => mine.fan.drain(viewer)), getBufferedAmount: () => browser.bufferedAmount }; | |
| 992 | // Counted by src/send-meter.ts, not browser.bufferedAmount (Bun never brings that back down); a viewer stuck for 5 s is cut and reconnects. | |
| 993 | const viewer = { | |
| 994 | send: (data: Uint8Array) => meteredSend(browser, data, () => mine.fan.drain(viewer)), | |
| 995 | getBufferedAmount: () => (stalled(browser, 5000) ? (browser.terminate(), 0) : behind(browser)), | |
| 996 | }; | |
| 980 | 997 | mine.viewers++; |
| 981 | 998 | mine.fan.add(viewer); |
| 982 | 999 | browser.on("error", () => browser.terminate()); |
| @@ -1092,9 +1109,19 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> { | ||
| 1092 | 1109 | if (!options.cameras) cameras.close(); |
| 1093 | 1110 | for (const v of videos.values()) v.close(); |
| 1094 | 1111 | for (const c of inFlight) c.abort(); |
| 1095 | for (const client of wss.clients) client.terminate(); | |
| 1112 | // On purpose, so the arm server is told a stream was closed, not lost (it holds the arm, not parks it), and pages are told to come back. | |
| 1113 | for (const [browser, arm] of armSides) { | |
| 1114 | arm.close(1000); | |
| 1115 | browser.close(RESTART_CLOSE, "the relay is restarting"); | |
| 1116 | } | |
| 1117 | lobby?.closeSockets(RESTART_CLOSE, "the relay is restarting"); | |
| 1118 | const gone = setTimeout(() => { | |
| 1119 | for (const client of wss.clients) client.terminate(); | |
| 1120 | resolve(); | |
| 1121 | }, 150); | |
| 1122 | gone.unref?.(); | |
| 1096 | 1123 | wss.close(); |
| 1097 | http.close(() => resolve()); | |
| 1124 | http.close(() => (clearTimeout(gone), resolve())); | |
| 1098 | 1125 | http.closeAllConnections(); |
| 1099 | 1126 | }), |
| 1100 | 1127 | }; |
Software/so101/src/restart.test.ts added +176
| @@ -0,0 +1,176 @@ | ||
| 1 | import { afterAll, describe, 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 WebSocket from "ws"; | |
| 6 | import { Accounts } from "./accounts.ts"; | |
| 7 | import { FeetechBus } from "./bus.ts"; | |
| 8 | import { stamper } from "./command-gate.ts"; | |
| 9 | import { FakeBus } from "./fake-bus.ts"; | |
| 10 | import { sign } from "./identity.ts"; | |
| 11 | import { Lobby } from "./lobby.ts"; | |
| 12 | import { Moderator } from "./moderator.ts"; | |
| 13 | import { parseRemote, startRelay, type RunningRelay } from "./relay.ts"; | |
| 14 | import { startServer, type RunningServer } from "./server.ts"; | |
| 15 | ||
| 16 | const KEY = "restart-test-key"; | |
| 17 | const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); | |
| 18 | const shut = (s: { close(): Promise<void> } | undefined) => (s ? Promise.race([s.close(), sleep(500)]) : undefined); | |
| 19 | const as = (id: string, name: string, role = "user") => { | |
| 20 | const identity = `${id}|${name}|${role}|${Date.now() + 3_600_000}`; | |
| 21 | return { "x-forge-identity": identity, "x-forge-signature": sign(identity, KEY) }; | |
| 22 | }; | |
| 23 | type Msg = Record<string, any>; | |
| 24 | ||
| 25 | /** What the app does: its files live in one data directory, and a restart builds everything again from them. */ | |
| 26 | function app(dir: string, pi: RunningServer, port = 0) { | |
| 27 | const lobby = new Lobby({ | |
| 28 | identityKey: KEY, | |
| 29 | chatFile: join(dir, "chat.json"), | |
| 30 | adminFile: join(dir, "admin.json"), | |
| 31 | turnsFile: join(dir, "turns.json"), | |
| 32 | wordsFile: join(dir, "words.json"), | |
| 33 | reportsFile: join(dir, "reports.json"), | |
| 34 | countsFile: join(dir, "visitors.json"), | |
| 35 | accounts: new Accounts({ file: join(dir, "accounts.sqlite") }), | |
| 36 | }); | |
| 37 | return startRelay({ | |
| 38 | host: "127.0.0.1", | |
| 39 | port, | |
| 40 | simulation: `http://127.0.0.1:${pi.port}`, | |
| 41 | remote: parseRemote({ ARM_SERVER: `ws://127.0.0.1:${pi.port}` })!, | |
| 42 | lobby, | |
| 43 | holdFile: join(dir, "hold.json"), | |
| 44 | }).then((relay) => ({ relay, lobby })); | |
| 45 | } | |
| 46 | ||
| 47 | /** The page, as far as a restart is concerned: /live and /follow, each reopened after 100 ms, then doubling. */ | |
| 48 | class Page { | |
| 49 | heard: Msg[] = []; | |
| 50 | live: WebSocket | null = null; | |
| 51 | follow: WebSocket | null = null; | |
| 52 | stopped = false; | |
| 53 | stamp = stamper(() => Date.now()); | |
| 54 | readonly port: () => number; | |
| 55 | readonly headers: Record<string, string>; | |
| 56 | constructor(port: () => number, headers: Record<string, string>) { | |
| 57 | this.port = port; | |
| 58 | this.headers = headers; | |
| 59 | this.openLive(0); | |
| 60 | } | |
| 61 | private openLive(attempt: number) { | |
| 62 | if (this.stopped) return; | |
| 63 | const ws = new WebSocket(`ws://127.0.0.1:${this.port()}/live`, { headers: this.headers }); | |
| 64 | this.live = ws; | |
| 65 | let opened = false; | |
| 66 | ws.on("open", () => (opened = true)); | |
| 67 | ws.on("message", (d) => { | |
| 68 | const m = JSON.parse(String(d)) as Msg; | |
| 69 | if (m.type === "ping") return ws.send('{"type":"pong"}'); | |
| 70 | this.heard.push(m); | |
| 71 | }); | |
| 72 | const again = () => { | |
| 73 | if (this.live !== ws) return; | |
| 74 | setTimeout(() => this.openLive(opened ? 1 : attempt + 1), Math.min(100 * 2 ** Math.max(0, (opened ? 1 : attempt + 1) - 1), 2000)); | |
| 75 | }; | |
| 76 | ws.on("close", again); | |
| 77 | ws.on("error", () => {}); | |
| 78 | } | |
| 79 | send(m: Msg) { | |
| 80 | this.live?.readyState === WebSocket.OPEN && this.live.send(JSON.stringify(m)); | |
| 81 | } | |
| 82 | driver = () => this.heard.filter((m) => m.type === "state").at(-1)?.driver ?? null; | |
| 83 | state = () => this.heard.filter((m) => m.type === "state").at(-1)!; | |
| 84 | /** Like the page: a frame opens the stream if there is none. */ | |
| 85 | command(joints: Record<string, number>) { | |
| 86 | if (!this.follow || this.follow.readyState > 1) { | |
| 87 | const ws = (this.follow = new WebSocket(`ws://127.0.0.1:${this.port()}/follow`, { headers: this.headers })); | |
| 88 | ws.on("error", () => {}); | |
| 89 | ws.on("close", () => this.follow === ws && (this.follow = null)); | |
| 90 | ws.on("open", () => ws.send(this.stamp(joints))); | |
| 91 | } else if (this.follow.readyState === WebSocket.OPEN) this.follow.send(this.stamp(joints)); | |
| 92 | } | |
| 93 | stop() { | |
| 94 | this.stopped = true; | |
| 95 | this.live?.terminate(); | |
| 96 | this.follow?.terminate(); | |
| 97 | } | |
| 98 | } | |
| 99 | ||
| 100 | let pi: RunningServer; | |
| 101 | afterAll(() => shut(pi)); | |
| 102 | ||
| 103 | describe("a relay restart", () => { | |
| 104 | test("costs under a second: the driver has the turn back with its time, the line and the chat are as they were, and a command reaches the arm", async () => { | |
| 105 | const fake = new FakeBus([1, 2, 3, 4, 5, 6]); | |
| 106 | for (const id of [1, 2, 3, 4, 5, 6]) fake.servos.get(id)!.set([0, 0, 0xff, 0x0f], 9); | |
| 107 | pi = await startServer(new FeetechBus(fake), { host: "127.0.0.1", port: 0, ids: [1, 2, 3, 4, 5, 6], posesFile: join(mkdtempSync(join(tmpdir(), "so101-rs-")), "poses.json") }); | |
| 108 | const dir = mkdtempSync(join(tmpdir(), "so101-restart-")); | |
| 109 | let current = await app(dir, pi); | |
| 110 | const port = current.relay.port; | |
| 111 | ||
| 112 | const alice = new Page(() => port, as("u-alice", "alice")); | |
| 113 | const bob = new Page(() => port, as("u-bob", "bob")); | |
| 114 | const carol = new Page(() => port, as("u-carol", "carol")); | |
| 115 | const owner = new Page(() => port, as("u-owner", "trevato", "admin")); | |
| 116 | await sleep(300); | |
| 117 | owner.send({ type: "mute", id: "u-carol", on: true, for: "forever" }); | |
| 118 | owner.send({ type: "settings", set: { turnSeconds: 77 } }); | |
| 119 | await sleep(100); | |
| 120 | alice.send({ type: "join" }); | |
| 121 | await sleep(100); | |
| 122 | bob.send({ type: "join" }); | |
| 123 | await sleep(50); | |
| 124 | carol.send({ type: "join" }); | |
| 125 | await sleep(100); | |
| 126 | bob.send({ type: "post", text: "hello from bob" }); | |
| 127 | await sleep(100); | |
| 128 | const before = alice.state(); | |
| 129 | expect(before.driver.name).toBe("alice"); | |
| 130 | expect(before.queue.map((q: Msg) => q.name)).toEqual(["bob", "carol"]); | |
| 131 | alice.command({ "1": 2100 }); | |
| 132 | await sleep(600); | |
| 133 | expect(fake.servos.get(1)![56]! | (fake.servos.get(1)![57]! << 8)).toBeGreaterThan(2048); | |
| 134 | ||
| 135 | // ---- the restart: everything in the process goes, the data directory stays | |
| 136 | const down = performance.now(); | |
| 137 | await current.relay.close(); | |
| 138 | current.lobby.close(); | |
| 139 | const closed = performance.now() - down; | |
| 140 | current = await app(dir, pi, port); | |
| 141 | const started = performance.now() - down; | |
| 142 | console.log(`close ${closed.toFixed(0)} ms, new relay listening ${(started - closed).toFixed(0)} ms after`); | |
| 143 | // the arm was not sent to its park pose by the restart: the relay said goodbye on purpose | |
| 144 | await sleep(100); | |
| 145 | expect(fake.servos.get(1)![56]! | (fake.servos.get(1)![57]! << 8)).toBeGreaterThan(2090); | |
| 146 | ||
| 147 | // within a second the same driver has the turn back, and a command reaches the arm | |
| 148 | const target = 2300; | |
| 149 | let reached = 0; | |
| 150 | const deadline = down + 1000; | |
| 151 | while (performance.now() < deadline) { | |
| 152 | alice.command({ "1": target }); | |
| 153 | await sleep(20); | |
| 154 | const m = fake.servos.get(1)!; | |
| 155 | if ((m[56]! | (m[57]! << 8)) > 2150) { | |
| 156 | reached = performance.now() - down; | |
| 157 | break; | |
| 158 | } | |
| 159 | } | |
| 160 | const after = alice.state(); | |
| 161 | console.log(`relay back in ${started.toFixed(0)} ms; first command through ${reached.toFixed(0)} ms after the old one went`); | |
| 162 | await sleep(200); // the other pages are back as well | |
| 163 | expect(reached).toBeGreaterThan(0); | |
| 164 | expect(reached).toBeLessThan(1000); | |
| 165 | expect(after.driver.name).toBe("alice"); | |
| 166 | expect(after.driver.endsAt).toBe(before.driver.endsAt); // the same turn, the same remaining time | |
| 167 | expect(after.queue.map((q: Msg) => q.name)).toEqual(["bob", "carol"]); | |
| 168 | expect(bob.heard.filter((m) => m.type === "chat").at(-1)?.messages?.map((m: Msg) => m.text)).toContain("hello from bob"); | |
| 169 | expect(carol.heard.filter((m) => m.type === "hello").at(-1)?.muted).toBe(true); // moderation state came back too | |
| 170 | expect(owner.heard.filter((m) => m.type === "admin").at(-1)?.settings.turnSeconds).toBe(77); // and the owner's settings | |
| 171 | owner.stop(); | |
| 172 | for (const p of [alice, bob, carol]) p.stop(); | |
| 173 | await shut(current.relay); | |
| 174 | current.lobby.close(); | |
| 175 | }, 30000); | |
| 176 | }); |
Software/so101/src/send-meter.test.ts added +32
| @@ -0,0 +1,32 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { behind, meteredSend, stalled } from "./send-meter.ts"; | |
| 3 | ||
| 4 | test("the count is the unconfirmed bytes: it rises with each send and falls with each confirmation", () => { | |
| 5 | const callbacks: Array<() => void> = []; | |
| 6 | const ws = { send: (_d: string | Uint8Array, cb?: () => void) => void callbacks.push(cb!) }; | |
| 7 | let drained = 0; | |
| 8 | meteredSend(ws, "x".repeat(100), () => drained++); | |
| 9 | meteredSend(ws, new Uint8Array(50)); | |
| 10 | expect(behind(ws)).toBe(150); | |
| 11 | callbacks[0]!(); | |
| 12 | expect(behind(ws)).toBe(50); | |
| 13 | expect(drained).toBe(1); | |
| 14 | callbacks[1]!(); | |
| 15 | expect(behind(ws)).toBe(0); | |
| 16 | expect(behind({})).toBe(0); | |
| 17 | }); | |
| 18 | ||
| 19 | test("a socket with bytes waiting and no write confirmed for the limit is stalled; one that is merely slow is not", () => { | |
| 20 | const callbacks: Array<() => void> = []; | |
| 21 | const ws = { send: (_d: string | Uint8Array, cb?: () => void) => void callbacks.push(cb!) }; | |
| 22 | const t0 = Date.now(); | |
| 23 | expect(stalled(ws, 1000, t0)).toBe(false); // nothing sent | |
| 24 | meteredSend(ws, "a"); | |
| 25 | meteredSend(ws, "b"); | |
| 26 | expect(stalled(ws, 1000, t0 + 500)).toBe(false); | |
| 27 | expect(stalled(ws, 1000, t0 + 1500)).toBe(true); | |
| 28 | callbacks[0]!(); // progress | |
| 29 | expect(stalled(ws, 1000, Date.now() + 500)).toBe(false); | |
| 30 | callbacks[1]!(); | |
| 31 | expect(stalled(ws, 1000, Date.now() + 99_999)).toBe(false); // nothing waiting | |
| 32 | }); |
Software/so101/src/send-meter.ts added +46
| @@ -0,0 +1,46 @@ | ||
| 1 | /** | |
| 2 | * How far behind a WebSocket is, counted here: bytes handed to `send` whose write has not been confirmed yet. | |
| 3 | * | |
| 4 | * Under Bun, `ws.bufferedAmount` is not to be trusted: once a socket has been backed up it never comes back down, though | |
| 5 | * the bytes have reached the other side (seen with a viewer that stopped reading and then caught up). Anything that used | |
| 6 | * it as "is this viewer keeping up" froze that viewer for good after one slow moment. The write callback does fire when | |
| 7 | * the bytes are really out, so the count is kept from that. A socket that has had unconfirmed bytes and no progress for | |
| 8 | * a long while is `stalled`: the callers cut it, and the page reconnects, rather than wait on a callback that may never come. | |
| 9 | */ | |
| 10 | ||
| 11 | interface Sender { | |
| 12 | send(data: string | Uint8Array, callback?: (error?: Error) => void): void; | |
| 13 | } | |
| 14 | interface Meter { | |
| 15 | bytes: number; | |
| 16 | /** When a write was last confirmed, or when the count left zero. */ | |
| 17 | progressAt: number; | |
| 18 | } | |
| 19 | ||
| 20 | const meters = new WeakMap<object, Meter>(); | |
| 21 | ||
| 22 | /** Unconfirmed bytes on this socket (a string counts its UTF-16 length: close enough for a threshold). */ | |
| 23 | export function behind(ws: object): number { | |
| 24 | return meters.get(ws)?.bytes ?? 0; | |
| 25 | } | |
| 26 | ||
| 27 | /** Whether bytes have been waiting for `ms` with no write confirmed. */ | |
| 28 | export function stalled(ws: object, ms: number, now = Date.now()): boolean { | |
| 29 | const m = meters.get(ws); | |
| 30 | return m !== undefined && m.bytes > 0 && now - m.progressAt > ms; | |
| 31 | } | |
| 32 | ||
| 33 | /** `ws.send`, counted; `done` is called when the write is confirmed (or failed). */ | |
| 34 | export function meteredSend(ws: Sender, data: string | Uint8Array, done?: () => void): void { | |
| 35 | const size = data.length; | |
| 36 | let m = meters.get(ws); | |
| 37 | if (!m) meters.set(ws, (m = { bytes: 0, progressAt: Date.now() })); | |
| 38 | if (m.bytes === 0) m.progressAt = Date.now(); | |
| 39 | m.bytes += size; | |
| 40 | const meter = m; | |
| 41 | ws.send(data, () => { | |
| 42 | meter.bytes = Math.max(0, meter.bytes - size); | |
| 43 | meter.progressAt = Date.now(); | |
| 44 | done?.(); | |
| 45 | }); | |
| 46 | } |
Software/so101/src/server.ts +22 −4
| @@ -50,7 +50,7 @@ | ||
| 50 | 50 | * "seq" and "t" (src/command-gate.ts): then a stale, replayed or pre-resume one is dropped. |
| 51 | 51 | * A frame over 1 KiB closes the stream; frames past 200 a second are dropped, and a stream that |
| 52 | 52 | * keeps that up is closed (src/frame-limit.ts) |
| 53 | * WS /watch read-only: the server reads the /status body every watchEveryMs (40: the 25 Hz bus cycle), one bus | |
| 53 | * WS /watch read-only: the server reads the /status body every watchEveryMs (17: the 60 Hz bus cycle), one bus | |
| 54 | 54 | * read shared by every watcher, and sends it when it changed, or after 200 ms of the same; |
| 55 | 55 | * the hosted app's relay keeps one open, so viewers see the arm without each of them asking the Pi. |
| 56 | 56 | * Between them, {"machine": [entries], "run": "<id>"}: every register written on the bus, every |
| @@ -121,6 +121,8 @@ export interface ServerOptions { | ||
| 121 | 121 | now?: () => number; |
| 122 | 122 | /** Longest /watch goes without sending an unchanged status, ms (200): keep it inside the relay link's staleMs. */ |
| 123 | 123 | watchQuietMs?: number; |
| 124 | /** A stream that ends without a close frame (the relay restarted) leaves the arm holding; it parks after this many ms unless a stream returns (default 0 = at once, as for any lost client). */ | |
| 125 | parkGraceMs?: number; | |
| 124 | 126 | } |
| 125 | 127 | |
| 126 | 128 | export interface RunningServer { |
| @@ -161,6 +163,8 @@ export function actor(body: Record<string, unknown>): string { | ||
| 161 | 163 | const CLEAN_CLOSE = new Set([1000, 1005]); |
| 162 | 164 | /** The close code for a stream ended by an emergency stop. */ |
| 163 | 165 | export const ESTOP_CLOSE = 4423; |
| 166 | /** The close code the relay gives a stream whose driver's browser vanished (src/relay.ts): the arm parks at once, not after the grace. */ | |
| 167 | const LOST_CLOSE = 4001; | |
| 164 | 168 | |
| 165 | 169 | class HttpError extends Error { |
| 166 | 170 | readonly status: number; |
| @@ -399,6 +403,10 @@ export async function startServer(bus: FeetechBus, options: ServerOptions = {}): | ||
| 399 | 403 | * The driver's connection is gone: go slowly to the park pose (the saved pose named in the settings, or the middle |
| 400 | 404 | * of every arm joint's range), under supervision, unless the arm is stopped, busy, or the way there is in the zone. |
| 401 | 405 | */ |
| 406 | const parkGraceMs = options.parkGraceMs ?? 0; | |
| 407 | let parkTimer: ReturnType<typeof setTimeout> | undefined; | |
| 408 | /** Streams this server cut itself for not answering pings: a hung client, not a relay that restarted. */ | |
| 409 | const heartbeatCut = new WeakSet<WebSocket>(); | |
| 402 | 410 | const park = async (why: string) => { |
| 403 | 411 | if (estop || gate.held || driver) return; |
| 404 | 412 | const controller = claim("park"); |
| @@ -1175,6 +1183,7 @@ export async function startServer(bus: FeetechBus, options: ServerOptions = {}): | ||
| 1175 | 1183 | return; |
| 1176 | 1184 | } |
| 1177 | 1185 | claim("stream"); |
| 1186 | clearTimeout(parkTimer); // a stream that comes back (the relay restarted) means the arm is not left to park | |
| 1178 | 1187 | const startedAt = Date.now(); |
| 1179 | 1188 | const stats = { frames: 0, dropped: 0, refused: 0, merged: 0, clamped: 0, gated: 0 }; |
| 1180 | 1189 | /** Seq, age and the hold, frame by frame; stamps are optional here (the CLI leader sends none). */ |
| @@ -1305,7 +1314,7 @@ export async function startServer(bus: FeetechBus, options: ServerOptions = {}): | ||
| 1305 | 1314 | let answered = true; |
| 1306 | 1315 | ws.on("pong", () => (answered = true)); |
| 1307 | 1316 | const heartbeat = setInterval(() => { |
| 1308 | if (!answered) return ws.terminate(); | |
| 1317 | if (!answered) return void (heartbeatCut.add(ws), ws.terminate()); | |
| 1309 | 1318 | answered = false; |
| 1310 | 1319 | ws.ping(); |
| 1311 | 1320 | }, heartbeatMs); |
| @@ -1524,7 +1533,15 @@ export async function startServer(bus: FeetechBus, options: ServerOptions = {}): | ||
| 1524 | 1533 | metrics.event("stream", { seconds: Number(((Date.now() - startedAt) / 1000).toFixed(1)), ...stats, ...(lost ? { lost: code } : {}) }); |
| 1525 | 1534 | release(); // whatever the bus did, the arm is free for the next driver |
| 1526 | 1535 | } |
| 1527 | if (lost && armed) void park(`the stream was lost (${code})`); | |
| 1536 | if (lost && armed) { | |
| 1537 | // The relay says its driver was lost with LOST_CLOSE (4001): park now. A stream that just stops (1006: the relay | |
| 1538 | // restarted, or the link dropped) is held where it is, and parked only if no stream comes back within the grace. | |
| 1539 | if (code === LOST_CLOSE || heartbeatCut.has(ws) || parkGraceMs <= 0) void park(`the stream was lost (${code})`); | |
| 1540 | else { | |
| 1541 | clearTimeout(parkTimer); | |
| 1542 | parkTimer = setTimeout(() => void park(`the stream was lost (${code}) and did not come back in ${parkGraceMs} ms`), parkGraceMs); | |
| 1543 | } | |
| 1544 | } | |
| 1528 | 1545 | }); |
| 1529 | 1546 | }; |
| 1530 | 1547 | }); |
| @@ -1532,7 +1549,7 @@ export async function startServer(bus: FeetechBus, options: ServerOptions = {}): | ||
| 1532 | 1549 | // ------------------------------------------------------------------ the watch |
| 1533 | 1550 | // Read-only: the status, pushed. One timer reads the bus while anyone watches; a watcher's messages are ignored. |
| 1534 | 1551 | const watchers = new WebSocketServer({ noServer: true, maxPayload: 1024 }); |
| 1535 | const watchEveryMs = options.watchEveryMs ?? 40; | |
| 1552 | const watchEveryMs = options.watchEveryMs ?? 17; | |
| 1536 | 1553 | let watching: ReturnType<typeof setInterval> | undefined; |
| 1537 | 1554 | let reading = false; |
| 1538 | 1555 | /** The last status sent and when: an unchanged one is not sent again until watchQuietMs pass, so the link stays live. */ |
| @@ -1616,6 +1633,7 @@ export async function startServer(bus: FeetechBus, options: ServerOptions = {}): | ||
| 1616 | 1633 | close: () => |
| 1617 | 1634 | new Promise<void>((resolve) => { |
| 1618 | 1635 | clearInterval(watching); |
| 1636 | clearTimeout(parkTimer); | |
| 1619 | 1637 | machine.close(); |
| 1620 | 1638 | precision.close(); |
| 1621 | 1639 | bus.onSyncWrite = null; |
Software/so101/src/ui/lobby-panel.js +12 −1
| @@ -72,6 +72,8 @@ export function createRoom(page) { | ||
| 72 | 72 | const { $, el, relayed, msg } = page; |
| 73 | 73 | // the type check has no DOM (as ./machine-panel.js) |
| 74 | 74 | const win = /** @type {any} */ (globalThis), doc = win.document, nav = win.navigator, location = win.location; |
| 75 | /** The newest status pushed and not yet drawn. @type {any} */ | |
| 76 | let newest = null; | |
| 75 | 77 | /** @type {{ ws: WebSocket | null, hello: any, state: any, mutedList: any[], bannedList: any[], crew?: any, [k: string]: any }} */ |
| 76 | 78 | const lobby = { ws: null, hello: null, state: null, offset: 0, retry: 0, attempt: 0, reopening: false, mutedList: [], bannedList: [], admin: null, statusSeen: false }; |
| 77 | 79 | function mayDrive() { return !relayed || (lobby.hello !== null && (!lobby.hello.signin || (lobby.hello.you !== null && lobby.state?.driver?.id === lobby.hello.you.id))); } |
| @@ -357,7 +359,16 @@ export function createRoom(page) { | ||
| 357 | 359 | if (m.type === "presence") { const t = presenceText(m); if (t !== null) { const b = $("presenceLine"); b.textContent = t; b.disabled = false; } return; } |
| 358 | 360 | if (m.type === "who") return showWho(m); |
| 359 | 361 | if (m.type === "refused") return page.refused?.(m); |
| 360 | if (m.type === "status") { tell("pose"); lobby.statusSeen = true; return m.status ? page.applyStatus(m.status) : page.noStatus(); } | |
| 362 | if (m.type === "status") { | |
| 363 | tell("pose"); lobby.statusSeen = true; | |
| 364 | if (!m.status) { newest = null; return page.noStatus(); } | |
| 365 | // The relay pushes the arm as fast as the arm server reads it (60 a second): one redraw a frame is enough, and it is the newest. | |
| 366 | const frame = win.requestAnimationFrame?.bind(win); | |
| 367 | if (!frame) return page.applyStatus(m.status); | |
| 368 | if (!newest) frame(() => { const s = newest; newest = null; if (s) page.applyStatus(s); }); | |
| 369 | newest = m.status; | |
| 370 | return; | |
| 371 | } | |
| 361 | 372 | if (m.type === "hello") lobby.hello = m; |
| 362 | 373 | else if (m.type === "state") { lobby.offset = m.now - Date.now(); lobby.state = m; } |
| 363 | 374 | else if (m.type === "chat") return addChat(m.messages, true), showEarlier(m.more); |
Software/so101/src/video-feed.ts +7 −5
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | 10 | import { proxyFor } from "./egress.ts"; |
| 11 | import { behind, meteredSend, stalled } from "./send-meter.ts"; | |
| 11 | 12 | import { AccessUnitSplitter } from "./h264.ts"; |
| 12 | 13 | import { LADDER, VideoFanout, type FanoutOptions, type VideoSink } from "./video-fanout.ts"; |
| 13 | 14 | import type { VideoSummary } from "./video-stats.ts"; |
| @@ -19,9 +20,9 @@ export interface VideoSources { | ||
| 19 | 20 | |
| 20 | 21 | /** The wire the feed talks to a viewer over; a `ws` WebSocket fits. */ |
| 21 | 22 | export interface VideoSocket { |
| 22 | bufferedAmount: number; | |
| 23 | 23 | readyState: number; |
| 24 | send(data: Uint8Array | string): void; | |
| 24 | send(data: Uint8Array | string, callback?: (error?: Error) => void): void; | |
| 25 | terminate?(): void; | |
| 25 | 26 | on(event: "message", listener: (data: unknown, isBinary: boolean) => void): unknown; |
| 26 | 27 | on(event: "close", listener: () => void): unknown; |
| 27 | 28 | } |
| @@ -52,9 +53,10 @@ export class VideoFeed { | ||
| 52 | 53 | serve(ws: VideoSocket, driver: boolean): void { |
| 53 | 54 | if (this.closed || !this.available) return; |
| 54 | 55 | const sink: VideoSink = { |
| 55 | backlog: () => ws.bufferedAmount, | |
| 56 | send: (data) => ws.readyState === OPEN && ws.send(data), | |
| 57 | text: (message) => ws.readyState === OPEN && ws.send(message), | |
| 56 | // Counted by src/send-meter.ts: ws.bufferedAmount never comes back down under Bun, which pinned a viewer to dropping for good. | |
| 57 | backlog: () => (stalled(ws, 5000) ? (ws.terminate?.(), 0) : behind(ws)), | |
| 58 | send: (data) => ws.readyState === OPEN && meteredSend(ws, data), | |
| 59 | text: (message) => ws.readyState === OPEN && meteredSend(ws, message), | |
| 58 | 60 | }; |
| 59 | 61 | const viewer = this.fanout.join(sink, driver); |
| 60 | 62 | ws.on("message", (data, isBinary) => { |
Software/so101/src/viewer.html +3 −2
| @@ -1559,8 +1559,9 @@ | ||
| 1559 | 1559 | status = d; |
| 1560 | 1560 | if ((explainLock() ?? "") !== was) applyLock(); |
| 1561 | 1561 | // The round trip to the arm, which every command also makes: shown once it is long enough to feel. |
| 1562 | const lag = [...rtts].sort((a, b) => a - b)[rtts.length >> 1] ?? 0; | |
| 1563 | $("lag").hidden = rtts.length < 3 || lag <= 250; $("lag").textContent = `· ${Math.round(lag)} ms lag`; | |
| 1562 | // Command → first confirmed motion, timed by the relay from the arm's own read-back; the camera's round trip only where the relay says nothing. | |
| 1563 | const felt = d.lag, lag = felt ? felt.p95 : [...rtts].sort((a, b) => a - b)[rtts.length >> 1] ?? 0; | |
| 1564 | $("lag").hidden = felt ? felt.n < 3 || lag <= 100 : rtts.length < 3 || lag <= 250; $("lag").textContent = felt ? `· ${felt.p50}/${felt.p95} ms lag` : `· ${Math.round(lag)} ms lag`; | |
| 1564 | 1565 | $("dot").className = d.reconnecting ? "dot warn" : "dot on"; |
| 1565 | 1566 | $("state").textContent = d.reconnecting ? "reconnecting…" : d.driving === null ? "idle" : d.driving === "record" ? `recording ${d.recording}` : leaderStreaming() ? `teleop · ${teleop.frames} frames` : d.driving; |
| 1566 | 1567 | if (leaderStreaming()) { |
Software/so101/test-setup.ts added +7
| @@ -0,0 +1,7 @@ | ||
| 1 | /** | |
| 2 | * Preloaded by bunfig.toml before every test file: the suite never talks to a real arm. A cell's own app config | |
| 3 | * (ARM_SERVER = the real Pi, its cameras and H.264 sources) is in the environment of the check; every test passes the | |
| 4 | * addresses it means explicitly, and anything it spawns inherits this blank. Blanked, not deleted: Bun keeps reading a | |
| 5 | * deleted variable from where the process started, and the parsers treat a blank one as unset. | |
| 6 | */ | |
| 7 | for (const name of Object.keys(process.env)) if (name === "ARM_SERVER" || name.startsWith("ARM_CAMERA") || name.startsWith("ARM_H264")) process.env[name] = ""; |