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

opened this

Check · Review · Land · Live — It conflicts with its base. Next: builder, it conflicts with its base.

Conflicts with main

hunt/relay → main 4 commits

  • Can't merge. Conflicts in 5 files: Software/so101/src/fake-bus.ts, Software/so101/src/lobby.ts, Software/so101/src/relay.ts…. Merge main into the branch and push again.

    Conflicts in 5 filesdeploy 2c466e8

Review

Sign in to review this change.
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 ??
7373 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") });
7474 const { mode, host: armHost } = relay.link();
7575 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", () => {
194194 await v.cancel();
195195 });
196196
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
197212 test("no source: the offline frame at once, and again while anyone watches", async () => {
198213 const url = await serveFeed(null, { offlineEveryMs: 50 });
199214 const v = await watch(url);
Software/so101/src/camera-feed.ts +10 −5
@@ -19,13 +19,15 @@ export interface FeedOptions {
1919 connectTimeoutMs?: number;
2020 /** How long the source may go without a byte before it counts as gone. */
2121 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. */
2323 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[];
2426 /** How often the offline picture is sent again, so a pane is never left waiting. */
2527 offlineEveryMs?: number;
2628 /** How long the upstream connection stays open after the last viewer leaves (a page reload keeps it). */
2729 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. */
2931 maxBacklog?: number;
3032 /** Where to count frames in from the source and bytes out to viewers (`camera_frames_in`, `camera_bytes_out`). */
3133 metrics?: LiveMetrics;
@@ -79,7 +81,8 @@ export class CameraFeed {
7981 private watchdog: ReturnType<typeof setInterval> | undefined;
8082 private lastByte = 0;
8183 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;
8386 private readonly metrics: LiveMetrics | undefined;
8487 private readonly source: string | null;
8588 private readonly name: string;
@@ -89,7 +92,7 @@ export class CameraFeed {
8992 this.source = source;
9093 this.name = name;
9194 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 };
9396 this._state = source ? "idle" : "offline";
9497 }
9598
@@ -208,7 +211,7 @@ export class CameraFeed {
208211 this.retry = setTimeout(() => {
209212 this.retry = undefined;
210213 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);
212215 this.retry.unref?.();
213216 }
214217 }
@@ -222,6 +225,7 @@ export class CameraFeed {
222225 this.watchdog = undefined;
223226 if (this._state !== "offline") console.error(`${this.name} ${this.source}: ${why}; showing it offline`);
224227 this._state = "offline";
228 this.failures++;
225229 this.broadcastOffline(true);
226230 this.offline();
227231 }
@@ -269,6 +273,7 @@ export class CameraFeed {
269273 if (this._state !== "live") {
270274 if (this._state === "offline") console.log(`${this.name} ${this.source}: live`);
271275 this._state = "live";
276 this.failures = 0;
272277 clearInterval(this.repeat);
273278 this.repeat = undefined;
274279 }
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 {
1919 dropAfter?: Map<number, number>;
2020 /** Goals are followed at this many ticks a second, read by read, instead of reached at once (bench/precision.ts). */
2121 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;
2224 }
2325
2426 /** 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 {
7577 }
7678 const delay = this.#options.lateOnce?.get(id) ?? 0;
7779 this.#options.lateOnce?.delete(id);
78 replies.push([delay, frame]);
80 replies.push([delay + (this.#options.latencyMs ?? 0), frame]);
7981 };
8082 // Instruction packets have the status-packet shape, so the parser reads them too.
8183 for (const packet of this.#parser.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 @@
3838 */
3939
4040 import { readFileSync, renameSync, writeFileSync } from "node:fs";
41 import { behind, meteredSend, stalled } from "./send-meter.ts";
4142 import { randomBytes } from "node:crypto";
4243 import { dirname, join } from "node:path";
4344 import type { IncomingMessage } from "node:http";
@@ -108,8 +109,17 @@ interface Seat {
108109 guest?: { id: string; name: GuestName };
109110 /** When this socket's last "who" was answered. */
110111 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;
111114 }
112115
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
113123 const MAX_FRAME = 4096;
114124 /** Lines of chat a new page is sent: enough to fill a phone's chat, little enough for a slow link. */
115125 export const CHAT_FIRST = 30;
@@ -319,6 +329,11 @@ export class Lobby {
319329 this.wss.handleUpgrade(req, socket, head, (ws) => this.seat(ws, who, from, verified, browserId(req.headers.cookie)));
320330 }
321331
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
322337 close(): void {
323338 clearInterval(this.pinger);
324339 clearTimeout(this.presenceTimer);
@@ -355,7 +370,7 @@ export class Lobby {
355370 this.send(seat, this.adminState());
356371 this.send(seat, this.crewState());
357372 }
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));
359374 ws.on("message", (data, binary) => {
360375 seat.heard = Date.now();
361376 if (binary) return;
@@ -683,7 +698,7 @@ export class Lobby {
683698
684699 private broadcastAdmin(): void {
685700 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);
687702 }
688703
689704 /** 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 {
760775 /** Send every open page the same thing, serialized once however many watch; a string is taken as JSON already. */
761776 publish(body: unknown): void {
762777 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);
764779 }
765780
766781 /** Tell the driver's pages alone something; in a local run, where nobody takes turns, every page. */
767782 tellDriver(body: unknown): void {
768783 const text = JSON.stringify(body);
769784 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);
771786 }
772787
773788 /** How many pages have the room open. */
@@ -835,11 +850,44 @@ export class Lobby {
835850 }
836851
837852 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 }
839887 }
840888
841889 private broadcast(body: unknown): void {
842890 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);
844892 }
845893 }
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
128128 first.ws.close();
129129 later.ws.close();
130130 });
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 @@
1515 import type { IncomingMessage } from "node:http";
1616 import type { Duplex } from "node:stream";
1717 import WebSocket, { WebSocketServer } from "ws";
18 import { behind, meteredSend, stalled } from "./send-meter.ts";
1819 import { MachineLog, perServo, type DetailValue, type EntryKind, type MachineEntry } from "./machine-log.ts";
1920
2021 export interface FeedLink {
@@ -44,6 +45,8 @@ export interface MachineFeedOptions {
4445 /** A viewer with this much unsent is too slow for another status; it still gets the log. */
4546 const SLOW_BYTES = 1 << 20;
4647 const HELLO_ENTRIES = 300;
48 /** The most often a changed pose is written to the log. */
49 export const NOTE_EVERY_MS = 100;
4750
4851 type Status = Record<string, unknown>;
4952 interface Joint {
@@ -68,6 +71,7 @@ export class MachineFeed {
6871 readonly log: MachineLog;
6972 #status: Status | null = null;
7073 #signature = "";
74 #notedAt = -Infinity;
7175 #driver: string | null | undefined = undefined;
7276 /** The arm server's run and the last of its entries kept, so a backlog sent again on reconnecting is not kept twice. */
7377 #armRun = "";
@@ -105,9 +109,14 @@ export class MachineFeed {
105109 if (this.#driver !== undefined) this.log.note("state", "turn", { driver: room.driver });
106110 this.#driver = room.driver;
107111 }
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.
108115 const sig = signature(status);
109 if (sig !== this.#signature) {
116 const now = performance.now();
117 if (sig !== this.#signature && now - this.#notedAt >= NOTE_EVERY_MS) {
110118 this.#signature = sig;
119 this.#notedAt = now;
111120 this.log.note("received", "status", { positions: positions(status), driving: typeof status.driving === "string" ? status.driving : null });
112121 }
113122 if (this.#wss.clients.size) this.#send({ type: "status", status, link: this.#o.link(), room }, true);
@@ -140,8 +149,13 @@ export class MachineFeed {
140149 if (!this.#wss.clients.size) return;
141150 const text = JSON.stringify(message);
142151 for (const ws of this.#wss.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);
145159 }
146160 }
147161 }
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";
6262 import WebSocket, { WebSocketServer } from "ws";
6363 import { LiveMetrics } from "./live-metrics.ts";
6464 import { wallMs } from "./clock.ts";
65 import { CommandLag } from "./command-lag.ts";
66 import { behind, meteredSend, stalled } from "./send-meter.ts";
6567 import { ArmLink, type LinkTimings } from "./arm-link.ts";
6668 import { MachineFeed } from "./machine-feed.ts";
6769 import { refusalBatch } from "./refusals.ts";
@@ -186,6 +188,8 @@ const STOPS = new Set(["POST /stop", "POST /estop"]);
186188 const RECONNECTING = "reconnecting to the arm";
187189 /** The close code that tells the arm server its driver was lost, not done: it parks the arm. */
188190 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;
189193 const MAX_JOIN_BODY = 1024;
190194 const FLOOR = {
191195 "x-content-type-options": "nosniff",
@@ -258,6 +262,10 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> {
258262 const lobby = options.lobby ?? null;
259263 const heartbeatMs = options.heartbeatMs ?? 1000;
260264 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(",") : "");
261269 const cameras = options.cameras ?? new CameraHub({ feed: { ...options.camera, metrics }, seed: remote ? [{ name: "Camera 1", source: remote.cameras[0]! }] : [] });
262270 const leaders = options.leaders ?? new LeaderCalibrations();
263271 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> {
299307 metrics,
300308 onStatus: (status) => {
301309 if (!onArm()) return;
310 commandLag.status(positionsKey(status), performance.now());
302311 machine.status(status);
303312 void push(); // on to the pages as it comes, at the arm's 25 Hz: no wait for the next tick
304313 },
@@ -456,7 +465,8 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> {
456465 const last = armLink!.last;
457466 const reconnecting = mode() === "reconnecting";
458467 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() } : {}) } };
460470 }
461471 const answer = await fetch(simulation + path, { cache: "no-store", signal: AbortSignal.timeout(readTimeoutMs) });
462472 const body = (await answer.json()) as Record<string, unknown>;
@@ -547,28 +557,29 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> {
547557 const statusEveryMs = options.statusEveryMs ?? 40; // the arm's own bus cycle: the simulation is asked this often
548558 let pushed = "";
549559 let pushing = false;
550 /** A status came while one was being pushed: push again after, so the newest is never the one left behind. */
551560 let again = false;
552561 const push = async (): Promise<void> => {
553562 if (!lobby) return;
554 if (pushing) return void (again = true);
555563 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);
556566 pushing = true;
557 let text: string;
558567 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);
563580 } finally {
564581 pushing = false;
565582 }
566 if (text !== pushed) lobby.publish(text);
567 pushed = text;
568 if (again) {
569 again = false;
570 return push();
571 }
572583 };
573584
574585 /** 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> {
932943 if (lobby) cap.cap(text, percent); // keeps each joint's last target, so a cap set mid-stream starts from there
933944 machine.note("sent", "follow frame", { values: values(text) });
934945 arm.send(stamped(data, release));
946 if (real) commandLag.command(performance.now());
935947 return;
936948 }
937949 if (!seeded) seed();
@@ -940,6 +952,7 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> {
940952 if (capped === null) return;
941953 machine.note("sent", "follow frame", { values: values(capped) });
942954 arm.send(stamped(capped, release));
955 if (real) commandLag.command(performance.now());
943956 });
944957 arm.on("close", (code, why) => browser.close(sendable(code) ? code : 1011, why.length ? why.toString() : "the arm server went away"));
945958 // 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> {
976989 watched.set(id, (w = { fan, viewers: 0, detach }));
977990 }
978991 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 };
980997 mine.viewers++;
981998 mine.fan.add(viewer);
982999 browser.on("error", () => browser.terminate());
@@ -1092,9 +1109,19 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> {
10921109 if (!options.cameras) cameras.close();
10931110 for (const v of videos.values()) v.close();
10941111 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?.();
10961123 wss.close();
1097 http.close(() => resolve());
1124 http.close(() => (clearTimeout(gone), resolve()));
10981125 http.closeAllConnections();
10991126 }),
11001127 };
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 @@
5050 * "seq" and "t" (src/command-gate.ts): then a stale, replayed or pre-resume one is dropped.
5151 * A frame over 1 KiB closes the stream; frames past 200 a second are dropped, and a stream that
5252 * 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
5454 * read shared by every watcher, and sends it when it changed, or after 200 ms of the same;
5555 * the hosted app's relay keeps one open, so viewers see the arm without each of them asking the Pi.
5656 * Between them, {"machine": [entries], "run": "<id>"}: every register written on the bus, every
@@ -121,6 +121,8 @@ export interface ServerOptions {
121121 now?: () => number;
122122 /** Longest /watch goes without sending an unchanged status, ms (200): keep it inside the relay link's staleMs. */
123123 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;
124126 }
125127
126128 export interface RunningServer {
@@ -161,6 +163,8 @@ export function actor(body: Record<string, unknown>): string {
161163 const CLEAN_CLOSE = new Set([1000, 1005]);
162164 /** The close code for a stream ended by an emergency stop. */
163165 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;
164168
165169 class HttpError extends Error {
166170 readonly status: number;
@@ -399,6 +403,10 @@ export async function startServer(bus: FeetechBus, options: ServerOptions = {}):
399403 * The driver's connection is gone: go slowly to the park pose (the saved pose named in the settings, or the middle
400404 * of every arm joint's range), under supervision, unless the arm is stopped, busy, or the way there is in the zone.
401405 */
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>();
402410 const park = async (why: string) => {
403411 if (estop || gate.held || driver) return;
404412 const controller = claim("park");
@@ -1175,6 +1183,7 @@ export async function startServer(bus: FeetechBus, options: ServerOptions = {}):
11751183 return;
11761184 }
11771185 claim("stream");
1186 clearTimeout(parkTimer); // a stream that comes back (the relay restarted) means the arm is not left to park
11781187 const startedAt = Date.now();
11791188 const stats = { frames: 0, dropped: 0, refused: 0, merged: 0, clamped: 0, gated: 0 };
11801189 /** 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 = {}):
13051314 let answered = true;
13061315 ws.on("pong", () => (answered = true));
13071316 const heartbeat = setInterval(() => {
1308 if (!answered) return ws.terminate();
1317 if (!answered) return void (heartbeatCut.add(ws), ws.terminate());
13091318 answered = false;
13101319 ws.ping();
13111320 }, heartbeatMs);
@@ -1524,7 +1533,15 @@ export async function startServer(bus: FeetechBus, options: ServerOptions = {}):
15241533 metrics.event("stream", { seconds: Number(((Date.now() - startedAt) / 1000).toFixed(1)), ...stats, ...(lost ? { lost: code } : {}) });
15251534 release(); // whatever the bus did, the arm is free for the next driver
15261535 }
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 }
15281545 });
15291546 };
15301547 });
@@ -1532,7 +1549,7 @@ export async function startServer(bus: FeetechBus, options: ServerOptions = {}):
15321549 // ------------------------------------------------------------------ the watch
15331550 // Read-only: the status, pushed. One timer reads the bus while anyone watches; a watcher's messages are ignored.
15341551 const watchers = new WebSocketServer({ noServer: true, maxPayload: 1024 });
1535 const watchEveryMs = options.watchEveryMs ?? 40;
1552 const watchEveryMs = options.watchEveryMs ?? 17;
15361553 let watching: ReturnType<typeof setInterval> | undefined;
15371554 let reading = false;
15381555 /** 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 = {}):
16161633 close: () =>
16171634 new Promise<void>((resolve) => {
16181635 clearInterval(watching);
1636 clearTimeout(parkTimer);
16191637 machine.close();
16201638 precision.close();
16211639 bus.onSyncWrite = null;
Software/so101/src/ui/lobby-panel.js +12 −1
@@ -72,6 +72,8 @@ export function createRoom(page) {
7272 const { $, el, relayed, msg } = page;
7373 // the type check has no DOM (as ./machine-panel.js)
7474 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;
7577 /** @type {{ ws: WebSocket | null, hello: any, state: any, mutedList: any[], bannedList: any[], crew?: any, [k: string]: any }} */
7678 const lobby = { ws: null, hello: null, state: null, offset: 0, retry: 0, attempt: 0, reopening: false, mutedList: [], bannedList: [], admin: null, statusSeen: false };
7779 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) {
357359 if (m.type === "presence") { const t = presenceText(m); if (t !== null) { const b = $("presenceLine"); b.textContent = t; b.disabled = false; } return; }
358360 if (m.type === "who") return showWho(m);
359361 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 }
361372 if (m.type === "hello") lobby.hello = m;
362373 else if (m.type === "state") { lobby.offset = m.now - Date.now(); lobby.state = m; }
363374 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 @@
88 */
99
1010 import { proxyFor } from "./egress.ts";
11 import { behind, meteredSend, stalled } from "./send-meter.ts";
1112 import { AccessUnitSplitter } from "./h264.ts";
1213 import { LADDER, VideoFanout, type FanoutOptions, type VideoSink } from "./video-fanout.ts";
1314 import type { VideoSummary } from "./video-stats.ts";
@@ -19,9 +20,9 @@ export interface VideoSources {
1920
2021 /** The wire the feed talks to a viewer over; a `ws` WebSocket fits. */
2122 export interface VideoSocket {
22 bufferedAmount: number;
2323 readyState: number;
24 send(data: Uint8Array | string): void;
24 send(data: Uint8Array | string, callback?: (error?: Error) => void): void;
25 terminate?(): void;
2526 on(event: "message", listener: (data: unknown, isBinary: boolean) => void): unknown;
2627 on(event: "close", listener: () => void): unknown;
2728 }
@@ -52,9 +53,10 @@ export class VideoFeed {
5253 serve(ws: VideoSocket, driver: boolean): void {
5354 if (this.closed || !this.available) return;
5455 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),
5860 };
5961 const viewer = this.fanout.join(sink, driver);
6062 ws.on("message", (data, isBinary) => {
Software/so101/src/viewer.html +3 −2
@@ -1559,8 +1559,9 @@
15591559 status = d;
15601560 if ((explainLock() ?? "") !== was) applyLock();
15611561 // 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`;
15641565 $("dot").className = d.reconnecting ? "dot warn" : "dot on";
15651566 $("state").textContent = d.reconnecting ? "reconnecting…" : d.driving === null ? "idle" : d.driving === "record" ? `recording ${d.recording}` : leaderStreaming() ? `teleop · ${teleop.frames} frames` : d.driving;
15661567 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] = "";

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs