Public: anyone can read this; the crew can change it.

Migrated from github.com/Trevato/SO-ARM100

workbenchthis machine

Page, mobile and camera fixes from the bug hunt: the model keeps 200 px on phones, STOP is never covered, a frozen camera never looks live, H.264 that works #491

opened this

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

Conflicts with main

hunt/page → main 1 commit

  • Can't merge. Conflicts in 3 files: Software/so101/src/relay.test.ts, Software/so101/src/relay.ts, Software/so101/src/viewer.html. Merge main into the branch and push again.

    Conflicts in 3 filesdeploy 784a951

Review

Sign in to review this change.
12 files changed +185 −25
Software/so101/src/camera-feed.test.ts +34 −3
@@ -1,7 +1,8 @@
11 import { afterEach, describe, expect, test } from "bun:test";
22 import { createServer, type Server, type ServerResponse } from "node:http";
33 import { createServer as createTcpServer, type Server as TcpServer } from "node:net";
4 import { CameraFeed, parseFps, type FeedOptions } from "./camera-feed.ts";
4 import { CameraFeed, OFFLINE_EVERY_MS, parseFps, type FeedOptions } from "./camera-feed.ts";
5 import { QUIET_MS } from "./ui/video.js";
56 import { OFFLINE_FRAME } from "./offline-frame.ts";
67
78 const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
@@ -10,7 +11,7 @@ const PNG = OFFLINE_FRAME.subarray(0, 8);
1011 const frame = (n: number) => Buffer.from(`\xff\xd8frame-${n}\xff\xd9`, "latin1");
1112
1213 /** A µStreamer stand-in: frames every 30 ms to each request, counting the connections it gets. */
13 function fakeCamera() {
14 function fakeCamera(repeat = false) {
1415 const clients = new Set<ServerResponse>();
1516 let connections = 0;
1617 let n = 0;
@@ -21,7 +22,7 @@ function fakeCamera() {
2122 res.socket?.once("close", () => clients.delete(res)); // Bun's node:http tells the socket, not the response
2223 });
2324 const timer = setInterval(() => {
24 const body = frame(++n);
25 const body = frame(repeat ? 0 : ++n); // `repeat`: a camera whose device hung, still answering with its last picture
2526 for (const res of clients) res.write(Buffer.concat([Buffer.from(`--boundarydonotcross\r\nContent-Type: image/jpeg\r\nContent-Length: ${body.length}\r\n\r\n`), body, Buffer.from("\r\n")]));
2627 }, 30);
2728 return {
@@ -87,6 +88,10 @@ async function serveFeed(source: string | null, options: FeedOptions = {}) {
8788 }
8889
8990 describe("a camera feed", () => {
91 test("the offline picture repeats well inside the page socket's quiet time, or the page reconnects over and over while a camera is off", () => {
92 expect(OFFLINE_EVERY_MS * 2).toBeLessThanOrEqual(QUIET_MS);
93 });
94
9095 test("frames reach two viewers from one upstream connection", async () => {
9196 camera = fakeCamera();
9297 const url = await serveFeed(`http://127.0.0.1:${await listening(camera.server)}/stream`);
@@ -180,6 +185,31 @@ describe("a camera feed", () => {
180185 await v.cancel();
181186 });
182187
188 test("a source that keeps sending the same picture is a frozen camera: offline, not live", async () => {
189 camera = fakeCamera(true);
190 const url = await serveFeed(`http://127.0.0.1:${await listening(camera.server)}/stream`, { frozenMs: 400 });
191 const v = await watch(url);
192 await v.until(hasFrame);
193 const started = Date.now();
194 await v.until(hasOffline);
195 expect(Date.now() - started).toBeLessThan(3000);
196 expect(feed!.state).toBe("offline");
197 await sleep(700); // the reconnect brings the same picture again: it must not read as live
198 expect(feed!.state).toBe("offline");
199 await v.cancel();
200 });
201
202 test("a camera whose pictures change is never called frozen", async () => {
203 camera = fakeCamera();
204 const url = await serveFeed(`http://127.0.0.1:${await listening(camera.server)}/stream`, { frozenMs: 300 });
205 const v = await watch(url);
206 await v.until(hasFrame);
207 await sleep(900);
208 expect(feed!.state).toBe("live");
209 expect(hasOffline(v.seen)).toBe(false);
210 await v.cancel();
211 });
212
183213 test("a source that comes back: live frames resume in the same response", async () => {
184214 const closed = createServer();
185215 const port = await listening(closed);
@@ -226,3 +256,4 @@ describe("a camera feed", () => {
226256 page.close();
227257 });
228258 });
259
Software/so101/src/camera-feed.ts +26 −1
@@ -25,6 +25,8 @@ export interface FeedOptions {
2525 offlineEveryMs?: number;
2626 /** How long the upstream connection stays open after the last viewer leaves (a page reload keeps it). */
2727 lingerMs?: number;
28 /** How long the source may send one and the same picture before it counts as frozen (a camera whose device hung but whose server answers); 0 turns it off. */
29 frozenMs?: number;
2830 /** Bytes queued for one viewer past which it skips frames. */
2931 maxBacklog?: number;
3032 /** Where to count frames in from the source and bytes out to viewers (`camera_frames_in`, `camera_bytes_out`). */
@@ -39,6 +41,13 @@ export interface FrameSink {
3941 offline(): void;
4042 }
4143
44 /**
45 * How often the offline picture goes out again while a source is down. The page's socket (src/ui/video.js) reopens after QUIET_MS
46 * without a frame, so this must be well under it: at 2000 against 2000 the page tore down and reopened its socket every few seconds
47 * for as long as a camera was off (6 sockets in 25 s measured).
48 */
49 export const OFFLINE_EVERY_MS = 1000;
50
4251 export type FeedState = "idle" | "connecting" | "live" | "offline";
4352
4453 const BOUNDARY = "armframe";
@@ -78,6 +87,10 @@ export class CameraFeed {
7887 private repeat: ReturnType<typeof setInterval> | undefined;
7988 private watchdog: ReturnType<typeof setInterval> | undefined;
8089 private lastByte = 0;
90 /** Fingerprint of the picture being read and of the last whole one, and since when that last one has been the same. */
91 private acc = 0n;
92 private sig: bigint | null = null;
93 private sameSince = 0;
8194 private closed = false;
8295 private readonly o: Required<Omit<FeedOptions, "metrics">>;
8396 private readonly metrics: LiveMetrics | undefined;
@@ -89,7 +102,7 @@ export class CameraFeed {
89102 this.source = source;
90103 this.name = name;
91104 this.metrics = options.metrics;
92 this.o = { connectTimeoutMs: 1500, staleMs: 2000, retryMs: 2000, offlineEveryMs: 2000, lingerMs: 2000, maxBacklog: 1 << 20, ...options };
105 this.o = { connectTimeoutMs: 1500, staleMs: 2000, retryMs: 2000, offlineEveryMs: OFFLINE_EVERY_MS, lingerMs: 2000, frozenMs: 5000, maxBacklog: 1 << 20, ...options };
93106 this._state = source ? "idle" : "offline";
94107 }
95108
@@ -195,6 +208,7 @@ export class CameraFeed {
195208 this.controller = null;
196209 for (const t of [this.retry, this.linger]) clearTimeout(t);
197210 for (const t of [this.repeat, this.watchdog]) clearInterval(t);
211 this.sig = null; // nobody watched: a picture hours later is not "the same" as the one before
198212 this.retry = this.linger = this.repeat = this.watchdog = undefined;
199213 this._state = this.source ? "idle" : "offline";
200214 }
@@ -251,6 +265,7 @@ export class CameraFeed {
251265 const parser = new MjpegParser(boundary, {
252266 start: (type) => {
253267 this.part = this.sinks.size ? [] : null;
268 this.acc = 0n;
254269 const now = performance.now();
255270 for (const v of this.viewers) {
256271 // A frame a little early still counts as on time: a 10 fps source held to 5 sends every other frame, not every third.
@@ -261,11 +276,20 @@ export class CameraFeed {
261276 }
262277 },
263278 data: (bytes) => {
279 this.acc = Bun.hash(bytes, this.acc) as bigint;
264280 this.part?.push(bytes.slice());
265281 for (const v of this.viewers) if (v.inPart) this.write(v, bytes);
266282 },
267283 end: () => {
268284 this.metrics?.count("camera_frames_in");
285 const t = Date.now();
286 if (this.acc !== this.sig) (this.sig = this.acc), (this.sameSince = t);
287 else if (this.o.frozenMs > 0 && t - this.sameSince >= this.o.frozenMs) {
288 // The same picture again and again: the viewers who got it see the offline picture next, and the retry asks the source anew
289 // (this.sig is kept, so a source still frozen after it is not taken for live until its picture changes).
290 this.part = null;
291 return this.fail(attempt, `the same picture for ${this.o.frozenMs} ms`);
292 }
269293 if (this._state !== "live") {
270294 if (this._state === "offline") console.log(`${this.name} ${this.source}: live`);
271295 this._state = "live";
@@ -299,3 +323,4 @@ export class CameraFeed {
299323 })();
300324 }
301325 }
326
Software/so101/src/portrait-layout.test.ts +29 −9
@@ -122,8 +122,22 @@ beforeAll(async () => {
122122 });
123123 afterAll(() => Promise.race([server.close(), new Promise((r) => setTimeout(r, 500))]));
124124
125 const SIZES = [{ w: 390, h: 844 }, { w: 412, h: 915 }];
126 const STRIP = 64; // #strip's min-height: one line of who drives and the STOP
125 const SIZES = [{ w: 390, h: 844 }, { w: 412, h: 915 }, { w: 360, h: 780 }, { w: 360, h: 640 }];
126 // The strip's height as Chromium lays it out in the Drive tab (measured at 390x844, 360x780, 412x915, local notice line
127 // included): name, time, presence and arm lines. It was 158 px while the hold line reserved three lines and the reconnect pill
128 // held a line of its own, and the model got 31 px (13 px at 360x780) on a driver's phone.
129 const STRIP = 91;
130
131 /** #sheet's max-height on your turn, in px: the css's own max()/min()/calc() evaluated for a viewport. */
132 function sheetCap(size: { w: number; h: number }): number {
133 const css = decl(mobile, 'html #shell.docked[data-panel="drive"].mayDrive #sheet', "max-height")!;
134 const js = css
135 .replace(/var\(--cam\)/g, `(${(size.w * 9) / 16})`).replace(/var\(--safe-b\)/g, "0")
136 .replace(/([\d.]+)svh/g, (_, n: string) => `(${n}*${size.h}/100)`).replace(/([\d.]+)px/g, "$1")
137 .replace(/\bcalc\(/g, "(").replace(/\bmax\(/g, "Math.max(").replace(/\bmin\(/g, "Math.min(");
138 expect(js.replace(/Math\.(max|min)/g, "")).toMatch(/^[\d\s.()+\-*/,]+$/);
139 return Function(`return ${js}`)() as number;
140 }
127141
128142 describe("a phone held upright", () => {
129143 test("ui/mobile.css is linked for phone portrait only, and the page stacks its views under that same query", () => {
@@ -150,9 +164,7 @@ describe("a phone held upright", () => {
150164 expect(areas).toEqual(["view", "strip", "model", "sheet", "dock"]);
151165 const rows = decl(mobile, `html #shell.docked[data-panel="drive"]${driving ? ".mayDrive" : ":not(.mayDrive)"}`, "grid-template-rows")!;
152166 const vars = { "--cam": decl(mobile, "#shell", "--cam")! };
153 const cap = decl(mobile, 'html #shell.docked[data-panel="drive"].mayDrive #sheet', "max-height")!;
154 expect(cap).toMatch(/^[\d.]+svh$/);
155 const sheet = driving ? (parseFloat(cap) * size.h) / 100 : 0; // the controls at their tallest
167 const sheet = driving ? sheetCap(size) : 0; // the controls at their tallest
156168 const { box, total } = layout(rows, areas, size, { strip: STRIP, sheet }, vars);
157169 expect(total).toBeCloseTo(size.h, 3);
158170 const camera = box.view!, model = box.model!;
@@ -161,7 +173,7 @@ describe("a phone held upright", () => {
161173 expect(camera.bottom).toBeLessThanOrEqual(box.strip!.top);
162174 expect(box.strip!.bottom).toBeLessThanOrEqual(model.top);
163175 expect(model.top).toBeCloseTo(camera.height + STRIP, 3); // directly under the strip
164 expect(model.height).toBeGreaterThan(driving ? 120 : 300);
176 expect(model.height).toBeGreaterThan(driving ? 150 : 250); // a real 3D view, not a sliver (it was 31 px)
165177 expect(model.bottom).toBeCloseTo(driving ? box.sheet!.top : box.dock!.top, 3); // down to the bar, or the controls on your turn
166178 expect(box.dock!.bottom).toBeCloseTo(size.h, 3);
167179 });
@@ -179,6 +191,12 @@ describe("a phone held upright", () => {
179191 expect(box.model!.top).toBeGreaterThanOrEqual(box.strip!.bottom);
180192 });
181193
194 test("the strip stays compact: the reconnect pill is out of flow and an empty hold line takes no room", () => {
195 expect(decl(mobile, "#strip #reconnect", "position")).toBe("absolute");
196 expect(decl(mobile, "#strip #holdLine", "min-height")).toBe("0");
197 expect(decl(mobile, "#strip #holdLine:empty", "display")).toBe("none");
198 });
199
182200 test("on the Drive tab no portrait rule hides the view, a pane or the model", () => {
183201 const targets = [/#app\b/, /#stage\b/, /\.pane\b/, /#pane3\b/, /#model\b/, /urdf-viewer/];
184202 const offenders = all
@@ -194,7 +212,7 @@ describe("a phone held upright", () => {
194212 const tap = 44; // --tap: one compact row, and the tray's one line
195213 const sheetFor = (body: number) => {
196214 const pad = tracks(decl(all, ".tab", "padding")!).map((t) => px(t, size.w, {}));
197 return Math.min(body + pad[0]! + pad[2]!, (parseFloat(decl(mobile, 'html #shell.docked[data-panel="drive"].mayDrive #sheet', "max-height")!) * size.h) / 100);
215 return Math.min(body + pad[0]! + pad[2]!, sheetCap(size));
198216 };
199217 const boxes = (sheet: number) => {
200218 const areas = ["view", "strip", "model", "sheet", "dock"];
@@ -234,7 +252,8 @@ describe("a phone held upright", () => {
234252 const gap = px(decl(all, "#tray", "gap")!, size.w, {});
235253 const body = tap + gap + 6 * tap;
236254 const sheet = sheetFor(body);
237 expect(sheet).toBe(body + 28); // nothing clipped: every slider shows without scrolling
255 expect(sheet).toBe(Math.min(body + 28, sheetCap(size))); // the tray scrolls (.tab is overflow:auto) when all six rows would leave the model under 200 px
256 expect(sheet).toBeGreaterThan(200); // and still shows most of them at once
238257 const box = boxes(sheet);
239258 expect(box.sheet!.top).toBeCloseTo(box.model!.bottom, 3); // under the model
240259 expect(box.model!.height).toBeGreaterThan(120);
@@ -247,9 +266,10 @@ describe("a phone held upright", () => {
247266 const open = boxes(sheetFor(tap + px(decl(all, "#tray", "gap")!, size.w, {}) + 6 * tap));
248267 const shut = boxes(sheetFor(tap));
249268 expect(shut.sheet!.height).toBe(tap + 28);
250 expect(shut.model!.height).toBeGreaterThan(open.model!.height + 5 * tap);
269 expect(shut.model!.height).toBeGreaterThan(open.model!.height + 150);
251270 expect(shut.strip!.height).toBe(STRIP);
252271 expect(shut.dock!.bottom).toBeCloseTo(size.h, 3);
253272 });
254273 });
255274 });
275
Software/so101/src/relay.test.ts +14 −1
@@ -133,11 +133,23 @@ describe("relay", () => {
133133 const found = /<script type="application\/json" id="boot">(.*?)<\/script>\n<\/head>/.exec(html);
134134 expect(found).not.toBeNull();
135135 expect(found![1]).not.toContain("<");
136 expect(JSON.parse(found![1]!)).toEqual({ link: { mode: "simulated", host: null, problem: null }, cameras: [{ id: 1, name: "Desk </script><img src=x onerror=alert(1)>", online: null }] }); // not watched yet: unknown
136 expect(JSON.parse(found![1]!)).toEqual({ link: { mode: "simulated", host: null, problem: null }, cameras: [{ id: 1, name: "Desk </script><img src=x onerror=alert(1)>", online: null, h264: false }] }); // not watched yet: unknown; no encoder streams, so no H.264
137137 }
138138 cameras.close();
139139 });
140140
141 test("the first view's camera list says which cameras play H.264, as /cameras does (else the page starts frames and never switches)", async () => {
142 const cameras = new CameraHub({ seed: [{ name: "Desk", source: "http://127.0.0.1:9/stream" }, { name: "Side", source: "http://127.0.0.1:9/stream" }] });
143 relay = await startRelay({ port: 0, simulation: simBase, cameras, video: { 1: { "480p": "http://127.0.0.1:9/h264" } } });
144 const base = `http://127.0.0.1:${relay.port}`;
145 const html = await (await fetch(`${base}/`)).text();
146 const boot = JSON.parse(/<script type="application\/json" id="boot">(.*?)<\/script>\n<\/head>/.exec(html)![1]!);
147 const listed = await (await fetch(`${base}/cameras`)).json();
148 expect(boot.cameras.map((c: { h264: boolean }) => c.h264)).toEqual([true, false]);
149 expect(boot.cameras.map((c: { id: number; h264: boolean }) => [c.id, c.h264])).toEqual(listed.map((c: { id: number; h264: boolean }) => [c.id, c.h264]));
150 cameras.close();
151 });
152
141153 test("unset: everything is the simulation, and the page says so", async () => {
142154 relay = await startRelay({ port: 0, simulation: simBase });
143155 const base = `http://127.0.0.1:${relay.port}`;
@@ -480,3 +492,4 @@ describe("relay", () => {
480492 expect(await link(`http://127.0.0.1:${relay.port}`)).toEqual({ mode: "simulated", host: null, problem: 'ARM_SERVER "ftp://x" must start with http://' });
481493 });
482494 });
495
Software/so101/src/relay.ts +4 −1
@@ -563,6 +563,8 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> {
563563 const cache = response.headers.get("cache-control") ?? "no-store";
564564 const headers: Record<string, string> = { "cache-control": cache, vary: "accept-encoding" };
565565 if (type) headers["content-type"] = type;
566 const etag = response.headers.get("etag"); // a weak one, so the same for the plain and the gzipped body
567 if (etag) headers.etag = etag;
566568 if (response.status !== 200 || !worthCompressing(type, body.length)) {
567569 res.writeHead(response.status, headers);
568570 return res.end(body);
@@ -579,7 +581,7 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> {
579581 */
580582 const page = async (req: IncomingMessage, res: ServerResponse, { response }: Awaited<ReturnType<typeof ask>>) => {
581583 const html = await response.text();
582 const boot = JSON.stringify({ link: link(), cameras: cameras.list().map(({ id, name, online }) => ({ id, name, online })) }).replace(/</g, "\\u003c");
584 const boot = JSON.stringify({ link: link(), cameras: cameras.list().map(({ id, name, online }) => ({ id, name, online, h264: videos.get(id)?.available ?? false })) }).replace(/</g, "\\u003c");
583585 const body = Buffer.from(response.status === 200 ? html.replace("</head>", `<script type="application/json" id="boot">${boot}</script>\n</head>`) : html);
584586 const headers: Record<string, string> = { "content-type": response.headers.get("content-type") ?? "text/html; charset=utf-8", "cache-control": "no-store", vary: "accept-encoding" };
585587 const browser = browserCookie(req.headers.cookie);
@@ -1094,3 +1096,4 @@ export function parseVideo(env: Record<string, string | undefined>): Record<numb
10941096 }
10951097 return out;
10961098 }
1099
Software/so101/src/server.test.ts +16
@@ -595,6 +595,21 @@ describe("the safe zone, the jam watch, and the model routes", () => {
595595 expect((await fetch(`${zurl}/vendor/three/../ws/index.js`)).status).toBe(404);
596596 });
597597
598 test("meshes and vendored libraries carry an ETag: a revalidation after max-age is a 304, not another 6 MB of STL over a phone's link", async () => {
599 for (const path of ["/model/assets/base_so101_v2.stl", "/vendor/three/build/three.module.js"]) {
600 const first = await fetch(`${zurl}${path}`);
601 const etag = first.headers.get("etag");
602 expect(etag).toMatch(/^W\/"[0-9a-z-]+"$/);
603 await first.arrayBuffer();
604 const again = await fetch(`${zurl}${path}`, { headers: { "if-none-match": etag! } });
605 expect(again.status).toBe(304);
606 expect((await again.arrayBuffer()).byteLength).toBe(0);
607 expect(again.headers.get("etag")).toBe(etag!);
608 expect(again.headers.get("cache-control")).toBe(first.headers.get("cache-control"));
609 expect((await fetch(`${zurl}${path}`, { headers: { "if-none-match": 'W/"stale"' } })).status).toBe(200);
610 }
611 });
612
598613 test("serves the printable sheets, and nothing else from that folder", async () => {
599614 const list = (await (await fetch(`${zurl}/printables`)).json()) as Array<{ file: string; url: string; bytes: number }>;
600615 expect(list.map((p) => p.file)).toEqual(["charuco_board.png", "cube_markers.png", "gripper_marker.png"]);
@@ -1195,3 +1210,4 @@ describe("the safety envelope", () => {
11951210 expect((await status()).estop).toBeNull();
11961211 });
11971212 });
1213
Software/so101/src/server.ts +12 −4
@@ -515,8 +515,15 @@ export async function startServer(bus: FeetechBus, options: ServerOptions = {}):
515515 res.writeHead(status, { "content-type": "application/json" });
516516 res.end(JSON.stringify(body));
517517 };
518 const file = (res: ServerResponse, path: string, cache: string) => {
519 res.writeHead(200, { "content-type": MIME[extname(path).toLowerCase()] ?? "application/octet-stream", "cache-control": cache });
518 /** A static file. Its weak ETag (size and mtime) lets a browser whose max-age ran out ask again and get a 304 with no body. */
519 const file = (res: ServerResponse, path: string, cache: string, req?: IncomingMessage) => {
520 const st = statSync(path);
521 const etag = `W/"${st.size.toString(36)}-${Math.round(st.mtimeMs).toString(36)}"`;
522 if (req?.headers["if-none-match"] === etag) {
523 res.writeHead(304, { etag, "cache-control": cache });
524 return res.end();
525 }
526 res.writeHead(200, { "content-type": MIME[extname(path).toLowerCase()] ?? "application/octet-stream", "cache-control": cache, etag });
520527 res.end(readFileSync(path));
521528 };
522529 const readBody = (req: IncomingMessage) =>
@@ -722,7 +729,7 @@ export async function startServer(bus: FeetechBus, options: ServerOptions = {}):
722729 if (req.method === "GET" && url.pathname.startsWith("/model/") && modelDir) {
723730 const path = safeFile(resolve(modelDir), decode(url.pathname.slice("/model/".length)));
724731 if (!path || ![".urdf", ".stl", ".dae", ".png", ".jpg"].includes(extname(path).toLowerCase())) return json(res, 404, { error: "no such model file" });
725 return file(res, path, "max-age=86400");
732 return file(res, path, "max-age=86400", req);
726733 }
727734 if (req.method === "GET" && url.pathname === "/printables") {
728735 let files: string[] = [];
@@ -742,7 +749,7 @@ export async function startServer(bus: FeetechBus, options: ServerOptions = {}):
742749 const [pkg, ...rest] = decode(url.pathname.slice("/vendor/".length)).split("/");
743750 const path = pkg && VENDORED.has(pkg) && rest.length ? safeFile(join(nodeModules, pkg), rest.join("/")) : null;
744751 if (!path || ![".js", ".mjs", ".json"].includes(extname(path).toLowerCase())) return json(res, 404, { error: "no such vendored file" });
745 return file(res, path, "max-age=86400");
752 return file(res, path, "max-age=86400", req);
746753 }
747754 const ui = /^\/ui\/[0-9a-f]{12}\/([a-z][a-z0-9-]*\.(?:js|css))$/.exec(url.pathname);
748755 if (req.method === "GET" && ui) {
@@ -1593,3 +1600,4 @@ export async function startServer(bus: FeetechBus, options: ServerOptions = {}):
15931600 }),
15941601 };
15951602 }
1603
Software/so101/src/stop-layout.test.ts +11
@@ -100,6 +100,16 @@ describe("the served page's STOP", () => {
100100 for (const el of chain) expect(el.attrs).not.toHaveProperty("hidden");
101101 });
102102
103 test("sideways, the sheet that floats over the view paints under STOP: STOP lives in the strip's stacking context, so the strip must out-rank the sheet", () => {
104 // #stop is z-index 9 but inside #strip (position:absolute; z-index set), which traps it at the strip's own level: with the strip and the
105 // sheet both at 7 the later one, the sheet, covered STOP on every phone on its side on your turn (elementFromPoint hit #sheet).
106 const css = [...html.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi)].map((m) => m[1]!).join("\n");
107 const z = (id: string) => Math.max(0, ...flatRules(css).filter((r) => r.selector.split(",").some((x) => x.trim() === `#${id}`) && /position\s*:\s*absolute/.test(r.body)).map((r) => Number(/z-index\s*:\s*(\d+)/.exec(r.body)?.[1] ?? 0)));
108 expect(z("strip")).toBeGreaterThan(0);
109 expect(z("sheet")).toBeGreaterThan(0);
110 expect(z("sheet")).toBeLessThan(z("strip"));
111 });
112
103113 test("it stops on pointerdown, not click", () => {
104114 const button = findStops(html)[0]!.at(-1)!;
105115 expect(button.attrs).not.toHaveProperty("onclick");
@@ -172,3 +182,4 @@ describe("notes on the served page", () => {
172182 expect(notesCss.some((r) => r.selector.includes("notesPill"))).toBe(false);
173183 });
174184 });
185
Software/so101/src/ui/mobile.css +7 −1
@@ -10,7 +10,7 @@
1010 html #shell.docked { grid-template-rows: var(--cam) auto minmax(0, 1fr) minmax(0, 1fr) calc(56px + var(--safe-b)); grid-template-areas: "view" "strip" "model" "sheet" "dock"; }
1111 html #shell.docked[data-panel="drive"]:not(.mayDrive) { grid-template-rows: var(--cam) auto minmax(0, 1fr) 0 calc(56px + var(--safe-b)); }
1212 html #shell.docked[data-panel="drive"].mayDrive { grid-template-rows: var(--cam) auto minmax(0, 1fr) auto calc(56px + var(--safe-b)); }
13 html #shell.docked[data-panel="drive"].mayDrive #sheet { max-height: 45svh; }
13 html #shell.docked[data-panel="drive"].mayDrive #sheet { max-height: max(120px, min(45svh, calc(100svh - var(--cam) - 100px - 56px - var(--safe-b) - 200px))); }
1414 html #shell.docked:not([data-panel="drive"]) { grid-template-rows: 0 auto 0 minmax(0, 1fr) calc(56px + var(--safe-b)); }
1515 /* The notch: a black band the height of the top inset, above the 16:9 camera row (it is counted in --cam). */
1616 #app { grid-area: auto; grid-column: 1 / -1; grid-row: view-start / model-end; display: grid; grid-template-columns: 100%; grid-template-rows: subgrid; border-top: env(safe-area-inset-top, 0px) solid #000; }
@@ -24,6 +24,11 @@
2424 #chatGlass .gl b { margin-right: 6px; font-weight: 650; }
2525 /* The strip sits over the view's middle row, so it paints above it. */
2626 #strip { position: relative; z-index: 1; }
27 /* It is one compact block (name, time, presence, arm state), not the tall one the desktop reserves: the hold line's three
28 reserved lines and the reconnect pill's own line made it 158 px, which squeezed the model to 31 px on a driver's phone. */
29 #strip #reconnect { position: absolute; top: 0; right: 0; }
30 #strip #holdLine { min-height: 0; margin-top: 0; }
31 #strip #holdLine:empty { display: none; }
2732 /* The model is always on here, so its view button goes; the camera buttons pick which camera is on top. */
2833 #v3 { display: none; }
2934 /* The keyboard is up: the view gives way to the chat, as it does on every layout, and the model folds away. */
@@ -46,3 +51,4 @@
4651 .j .v { justify-content: flex-end; }
4752 }
4853
54
Software/so101/src/ui/video.js +9 −3
@@ -10,6 +10,8 @@
1010 export const QUIET_MS = 2000;
1111 /** The wait before trying again after a socket that closed or failed, so a relay that is down is not hammered. */
1212 export const RETRY_MS = 100;
13 /** The wait doubles with each socket that fails without a single frame, up to this, so a relay that is down costs a phone one attempt every few seconds, not ten a second. */
14 export const RETRY_MAX_MS = 3000;
1315
1416 /** The socket's address on the page's own origin. @param {{ protocol: string, host: string }} loc @param {number | string} id */
1517 export function socketUrl(loc, id) {
@@ -32,7 +34,9 @@ export function openFeed(options) {
3234 const after = options.after ?? ((fn, ms) => setTimeout(fn, ms));
3335 const cancel = options.cancel ?? ((t) => clearTimeout(t));
3436 const gap = options.maxFps && options.maxFps > 0 ? 1000 / options.maxFps : 0;
35 let ws = /** @type {WebSocket | null} */ (null), watchdog = /** @type {any} */ (null), closed = false, shown = -Infinity;
37 let ws = /** @type {WebSocket | null} */ (null), watchdog = /** @type {any} */ (null), closed = false, shown = -Infinity, failures = 0;
38 /** The wait before the next try after a socket that failed: RETRY_MS, then twice that, and so on. */
39 const backoff = () => Math.min(RETRY_MAX_MS, RETRY_MS * 2 ** Math.min(failures++, 10));
3640
3741 const stop = () => {
3842 cancel(watchdog); watchdog = null;
@@ -50,18 +54,19 @@ export function openFeed(options) {
5054 watchdog = null;
5155 if (closed) return;
5256 let w;
53 try { w = open(url); } catch { return again(RETRY_MS); }
57 try { w = open(url); } catch { return again(backoff()); }
5458 ws = w;
5559 w.binaryType = /** @type {any} */ ("blob"); // Bun's types know only arraybuffer and nodebuffer; browsers default to blob anyway
5660 w.onmessage = (e) => {
5761 if (ws !== w || !(e.data instanceof Blob)) return;
62 failures = 0;
5863 cancel(watchdog); watchdog = after(stalled, QUIET_MS);
5964 const t = now();
6065 if (t - shown < gap) return; // held to fewer frames on a slow link
6166 shown = t;
6267 onFrame(e.data);
6368 };
64 w.onclose = w.onerror = () => { if (ws === w) { options.onReconnect?.(); again(RETRY_MS); } };
69 w.onclose = w.onerror = () => { if (ws === w) { options.onReconnect?.(); again(backoff()); } };
6570 watchdog = after(stalled, QUIET_MS); // the first frame is due too
6671 }
6772 connect();
@@ -99,3 +104,4 @@ export function attach(cam, epoch, env) {
99104 if (cam.epoch !== epoch || !env.visible()) return;
100105 cam.feed = openFeed({ url: socketUrl(env.loc, cam.id), maxFps: env.maxFps, onFrame: (frame) => { tell("cam-frame"); drawFrame(cam, frame, env); }, onStall: () => tell("cam-stall"), onReconnect: () => tell("cam-reconnect") });
101106 }
107
Software/so101/src/ui/video.test.ts +21 −1
@@ -1,5 +1,5 @@
11 import { describe, expect, test } from "bun:test";
2 import { attach, drawFrame, isLive, openFeed, QUIET_MS, RETRY_MS, socketUrl } from "./video.js";
2 import { attach, drawFrame, isLive, openFeed, QUIET_MS, RETRY_MAX_MS, RETRY_MS, socketUrl } from "./video.js";
33
44 /** A clock and a socket factory the test drives by hand. */
55 function rig(maxFps?: number) {
@@ -66,6 +66,25 @@ describe("the relayed feed", () => {
6666 old.onmessage?.({ data: new Blob([]) });
6767 expect(r.frames.length).toBe(0);
6868 });
69 test("a relay that stays down is tried less and less often, and a frame starts the short waits again", () => {
70 const r = rig();
71 let wait = RETRY_MS;
72 for (let i = 0; i < 9; i++) {
73 const n = r.sockets.length;
74 r.sockets.at(-1).onclose();
75 r.advance(wait - 1);
76 expect(r.sockets.length).toBe(n); // not yet
77 r.advance(1);
78 expect(r.sockets.length).toBe(n + 1);
79 wait = Math.min(RETRY_MAX_MS, wait * 2);
80 }
81 expect(wait).toBe(RETRY_MAX_MS);
82 r.frame(); // the relay is back and sends a picture
83 const n = r.sockets.length;
84 r.sockets.at(-1).onclose();
85 r.advance(RETRY_MS);
86 expect(r.sockets.length).toBe(n + 1);
87 });
6988 test("close stops everything", () => {
7089 const r = rig();
7190 r.feed.close();
@@ -144,3 +163,4 @@ describe("the page's wiring", () => {
144163 expect(page).toContain("clearTimeout(c.timer); stopFeed(c); c.img.src");
145164 });
146165 });
166
Software/so101/src/viewer.html +2 −1
@@ -261,7 +261,7 @@
261261 #shell.bare #strip { max-width: calc(100% - var(--stop-w) - 18px - var(--safe-l) - var(--safe-r)); }
262262 #strip #clock { color: #9aa4ae; }
263263 #sheetToggle { display: inline-flex !important; align-items: center; background: rgba(255,255,255,.08); color: #e8eaed; border-color: rgba(255,255,255,.18); }
264 #sheet { position: absolute; z-index: 7; top: 0; bottom: 0; right: 0; width: calc(min(380px, 48vw) + var(--safe-r)); background: rgba(13,17,23,.9); background: color-mix(in srgb, var(--surface) 90%, transparent); -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px); border-left: 1px solid var(--line); transition: transform .2s ease-out; }
264 #sheet { position: absolute; z-index: 6; top: 0; bottom: 0; right: 0; width: calc(min(380px, 48vw) + var(--safe-r)); background: rgba(13,17,23,.9); background: color-mix(in srgb, var(--surface) 90%, transparent); -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px); border-left: 1px solid var(--line); transition: transform .2s ease-out; }
265265 #shell.bare #sheet { transform: translateX(100%); visibility: hidden; transition: transform .2s ease-out, visibility 0s .2s; }
266266 #top { top: auto; bottom: 6px; justify-content: flex-start; right: calc(min(380px, 48vw) + 12px + var(--safe-r)); }
267267 #shell.bare #top { right: calc(var(--pill) + var(--safe-r)); }
@@ -1623,3 +1623,4 @@
16231623 </body>
16241624 </html>
16251625
1626

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs