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
Check · Review · Land · Live — It conflicts with its base.
hunt/page → main 1 commit
12 files
changed +185
−25
Software/so101/src/camera-feed.test.ts +34 −3
| @@ -1,7 +1,8 @@ | ||
| 1 | 1 | import { afterEach, describe, expect, test } from "bun:test"; |
| 2 | 2 | import { createServer, type Server, type ServerResponse } from "node:http"; |
| 3 | 3 | 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"; | |
| 5 | 6 | import { OFFLINE_FRAME } from "./offline-frame.ts"; |
| 6 | 7 | |
| 7 | 8 | const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); |
| @@ -10,7 +11,7 @@ const PNG = OFFLINE_FRAME.subarray(0, 8); | ||
| 10 | 11 | const frame = (n: number) => Buffer.from(`\xff\xd8frame-${n}\xff\xd9`, "latin1"); |
| 11 | 12 | |
| 12 | 13 | /** A µStreamer stand-in: frames every 30 ms to each request, counting the connections it gets. */ |
| 13 | function fakeCamera() { | |
| 14 | function fakeCamera(repeat = false) { | |
| 14 | 15 | const clients = new Set<ServerResponse>(); |
| 15 | 16 | let connections = 0; |
| 16 | 17 | let n = 0; |
| @@ -21,7 +22,7 @@ function fakeCamera() { | ||
| 21 | 22 | res.socket?.once("close", () => clients.delete(res)); // Bun's node:http tells the socket, not the response |
| 22 | 23 | }); |
| 23 | 24 | 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 | |
| 25 | 26 | 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")])); |
| 26 | 27 | }, 30); |
| 27 | 28 | return { |
| @@ -87,6 +88,10 @@ async function serveFeed(source: string | null, options: FeedOptions = {}) { | ||
| 87 | 88 | } |
| 88 | 89 | |
| 89 | 90 | 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 | ||
| 90 | 95 | test("frames reach two viewers from one upstream connection", async () => { |
| 91 | 96 | camera = fakeCamera(); |
| 92 | 97 | const url = await serveFeed(`http://127.0.0.1:${await listening(camera.server)}/stream`); |
| @@ -180,6 +185,31 @@ describe("a camera feed", () => { | ||
| 180 | 185 | await v.cancel(); |
| 181 | 186 | }); |
| 182 | 187 | |
| 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 | ||
| 183 | 213 | test("a source that comes back: live frames resume in the same response", async () => { |
| 184 | 214 | const closed = createServer(); |
| 185 | 215 | const port = await listening(closed); |
| @@ -226,3 +256,4 @@ describe("a camera feed", () => { | ||
| 226 | 256 | page.close(); |
| 227 | 257 | }); |
| 228 | 258 | }); |
| 259 |
Software/so101/src/camera-feed.ts +26 −1
| @@ -25,6 +25,8 @@ export interface FeedOptions { | ||
| 25 | 25 | offlineEveryMs?: number; |
| 26 | 26 | /** How long the upstream connection stays open after the last viewer leaves (a page reload keeps it). */ |
| 27 | 27 | 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; | |
| 28 | 30 | /** Bytes queued for one viewer past which it skips frames. */ |
| 29 | 31 | maxBacklog?: number; |
| 30 | 32 | /** Where to count frames in from the source and bytes out to viewers (`camera_frames_in`, `camera_bytes_out`). */ |
| @@ -39,6 +41,13 @@ export interface FrameSink { | ||
| 39 | 41 | offline(): void; |
| 40 | 42 | } |
| 41 | 43 | |
| 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 | ||
| 42 | 51 | export type FeedState = "idle" | "connecting" | "live" | "offline"; |
| 43 | 52 | |
| 44 | 53 | const BOUNDARY = "armframe"; |
| @@ -78,6 +87,10 @@ export class CameraFeed { | ||
| 78 | 87 | private repeat: ReturnType<typeof setInterval> | undefined; |
| 79 | 88 | private watchdog: ReturnType<typeof setInterval> | undefined; |
| 80 | 89 | 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; | |
| 81 | 94 | private closed = false; |
| 82 | 95 | private readonly o: Required<Omit<FeedOptions, "metrics">>; |
| 83 | 96 | private readonly metrics: LiveMetrics | undefined; |
| @@ -89,7 +102,7 @@ export class CameraFeed { | ||
| 89 | 102 | this.source = source; |
| 90 | 103 | this.name = name; |
| 91 | 104 | 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 }; | |
| 93 | 106 | this._state = source ? "idle" : "offline"; |
| 94 | 107 | } |
| 95 | 108 | |
| @@ -195,6 +208,7 @@ export class CameraFeed { | ||
| 195 | 208 | this.controller = null; |
| 196 | 209 | for (const t of [this.retry, this.linger]) clearTimeout(t); |
| 197 | 210 | 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 | |
| 198 | 212 | this.retry = this.linger = this.repeat = this.watchdog = undefined; |
| 199 | 213 | this._state = this.source ? "idle" : "offline"; |
| 200 | 214 | } |
| @@ -251,6 +265,7 @@ export class CameraFeed { | ||
| 251 | 265 | const parser = new MjpegParser(boundary, { |
| 252 | 266 | start: (type) => { |
| 253 | 267 | this.part = this.sinks.size ? [] : null; |
| 268 | this.acc = 0n; | |
| 254 | 269 | const now = performance.now(); |
| 255 | 270 | for (const v of this.viewers) { |
| 256 | 271 | // 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 { | ||
| 261 | 276 | } |
| 262 | 277 | }, |
| 263 | 278 | data: (bytes) => { |
| 279 | this.acc = Bun.hash(bytes, this.acc) as bigint; | |
| 264 | 280 | this.part?.push(bytes.slice()); |
| 265 | 281 | for (const v of this.viewers) if (v.inPart) this.write(v, bytes); |
| 266 | 282 | }, |
| 267 | 283 | end: () => { |
| 268 | 284 | 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 | } | |
| 269 | 293 | if (this._state !== "live") { |
| 270 | 294 | if (this._state === "offline") console.log(`${this.name} ${this.source}: live`); |
| 271 | 295 | this._state = "live"; |
| @@ -299,3 +323,4 @@ export class CameraFeed { | ||
| 299 | 323 | })(); |
| 300 | 324 | } |
| 301 | 325 | } |
| 326 |
Software/so101/src/portrait-layout.test.ts +29 −9
| @@ -122,8 +122,22 @@ beforeAll(async () => { | ||
| 122 | 122 | }); |
| 123 | 123 | afterAll(() => Promise.race([server.close(), new Promise((r) => setTimeout(r, 500))])); |
| 124 | 124 | |
| 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 | } | |
| 127 | 141 | |
| 128 | 142 | describe("a phone held upright", () => { |
| 129 | 143 | 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", () => { | ||
| 150 | 164 | expect(areas).toEqual(["view", "strip", "model", "sheet", "dock"]); |
| 151 | 165 | const rows = decl(mobile, `html #shell.docked[data-panel="drive"]${driving ? ".mayDrive" : ":not(.mayDrive)"}`, "grid-template-rows")!; |
| 152 | 166 | 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 | |
| 156 | 168 | const { box, total } = layout(rows, areas, size, { strip: STRIP, sheet }, vars); |
| 157 | 169 | expect(total).toBeCloseTo(size.h, 3); |
| 158 | 170 | const camera = box.view!, model = box.model!; |
| @@ -161,7 +173,7 @@ describe("a phone held upright", () => { | ||
| 161 | 173 | expect(camera.bottom).toBeLessThanOrEqual(box.strip!.top); |
| 162 | 174 | expect(box.strip!.bottom).toBeLessThanOrEqual(model.top); |
| 163 | 175 | 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) | |
| 165 | 177 | expect(model.bottom).toBeCloseTo(driving ? box.sheet!.top : box.dock!.top, 3); // down to the bar, or the controls on your turn |
| 166 | 178 | expect(box.dock!.bottom).toBeCloseTo(size.h, 3); |
| 167 | 179 | }); |
| @@ -179,6 +191,12 @@ describe("a phone held upright", () => { | ||
| 179 | 191 | expect(box.model!.top).toBeGreaterThanOrEqual(box.strip!.bottom); |
| 180 | 192 | }); |
| 181 | 193 | |
| 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 | ||
| 182 | 200 | test("on the Drive tab no portrait rule hides the view, a pane or the model", () => { |
| 183 | 201 | const targets = [/#app\b/, /#stage\b/, /\.pane\b/, /#pane3\b/, /#model\b/, /urdf-viewer/]; |
| 184 | 202 | const offenders = all |
| @@ -194,7 +212,7 @@ describe("a phone held upright", () => { | ||
| 194 | 212 | const tap = 44; // --tap: one compact row, and the tray's one line |
| 195 | 213 | const sheetFor = (body: number) => { |
| 196 | 214 | 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)); | |
| 198 | 216 | }; |
| 199 | 217 | const boxes = (sheet: number) => { |
| 200 | 218 | const areas = ["view", "strip", "model", "sheet", "dock"]; |
| @@ -234,7 +252,8 @@ describe("a phone held upright", () => { | ||
| 234 | 252 | const gap = px(decl(all, "#tray", "gap")!, size.w, {}); |
| 235 | 253 | const body = tap + gap + 6 * tap; |
| 236 | 254 | 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 | |
| 238 | 257 | const box = boxes(sheet); |
| 239 | 258 | expect(box.sheet!.top).toBeCloseTo(box.model!.bottom, 3); // under the model |
| 240 | 259 | expect(box.model!.height).toBeGreaterThan(120); |
| @@ -247,9 +266,10 @@ describe("a phone held upright", () => { | ||
| 247 | 266 | const open = boxes(sheetFor(tap + px(decl(all, "#tray", "gap")!, size.w, {}) + 6 * tap)); |
| 248 | 267 | const shut = boxes(sheetFor(tap)); |
| 249 | 268 | 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); | |
| 251 | 270 | expect(shut.strip!.height).toBe(STRIP); |
| 252 | 271 | expect(shut.dock!.bottom).toBeCloseTo(size.h, 3); |
| 253 | 272 | }); |
| 254 | 273 | }); |
| 255 | 274 | }); |
| 275 |
Software/so101/src/relay.test.ts +14 −1
| @@ -133,11 +133,23 @@ describe("relay", () => { | ||
| 133 | 133 | const found = /<script type="application\/json" id="boot">(.*?)<\/script>\n<\/head>/.exec(html); |
| 134 | 134 | expect(found).not.toBeNull(); |
| 135 | 135 | 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 | |
| 137 | 137 | } |
| 138 | 138 | cameras.close(); |
| 139 | 139 | }); |
| 140 | 140 | |
| 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 | ||
| 141 | 153 | test("unset: everything is the simulation, and the page says so", async () => { |
| 142 | 154 | relay = await startRelay({ port: 0, simulation: simBase }); |
| 143 | 155 | const base = `http://127.0.0.1:${relay.port}`; |
| @@ -480,3 +492,4 @@ describe("relay", () => { | ||
| 480 | 492 | expect(await link(`http://127.0.0.1:${relay.port}`)).toEqual({ mode: "simulated", host: null, problem: 'ARM_SERVER "ftp://x" must start with http://' }); |
| 481 | 493 | }); |
| 482 | 494 | }); |
| 495 |
Software/so101/src/relay.ts +4 −1
| @@ -563,6 +563,8 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> { | ||
| 563 | 563 | const cache = response.headers.get("cache-control") ?? "no-store"; |
| 564 | 564 | const headers: Record<string, string> = { "cache-control": cache, vary: "accept-encoding" }; |
| 565 | 565 | 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; | |
| 566 | 568 | if (response.status !== 200 || !worthCompressing(type, body.length)) { |
| 567 | 569 | res.writeHead(response.status, headers); |
| 568 | 570 | return res.end(body); |
| @@ -579,7 +581,7 @@ export async function startRelay(options: RelayOptions): Promise<RunningRelay> { | ||
| 579 | 581 | */ |
| 580 | 582 | const page = async (req: IncomingMessage, res: ServerResponse, { response }: Awaited<ReturnType<typeof ask>>) => { |
| 581 | 583 | 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"); | |
| 583 | 585 | const body = Buffer.from(response.status === 200 ? html.replace("</head>", `<script type="application/json" id="boot">${boot}</script>\n</head>`) : html); |
| 584 | 586 | const headers: Record<string, string> = { "content-type": response.headers.get("content-type") ?? "text/html; charset=utf-8", "cache-control": "no-store", vary: "accept-encoding" }; |
| 585 | 587 | const browser = browserCookie(req.headers.cookie); |
| @@ -1094,3 +1096,4 @@ export function parseVideo(env: Record<string, string | undefined>): Record<numb | ||
| 1094 | 1096 | } |
| 1095 | 1097 | return out; |
| 1096 | 1098 | } |
| 1099 |
Software/so101/src/server.test.ts +16
| @@ -595,6 +595,21 @@ describe("the safe zone, the jam watch, and the model routes", () => { | ||
| 595 | 595 | expect((await fetch(`${zurl}/vendor/three/../ws/index.js`)).status).toBe(404); |
| 596 | 596 | }); |
| 597 | 597 | |
| 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 | ||
| 598 | 613 | test("serves the printable sheets, and nothing else from that folder", async () => { |
| 599 | 614 | const list = (await (await fetch(`${zurl}/printables`)).json()) as Array<{ file: string; url: string; bytes: number }>; |
| 600 | 615 | expect(list.map((p) => p.file)).toEqual(["charuco_board.png", "cube_markers.png", "gripper_marker.png"]); |
| @@ -1195,3 +1210,4 @@ describe("the safety envelope", () => { | ||
| 1195 | 1210 | expect((await status()).estop).toBeNull(); |
| 1196 | 1211 | }); |
| 1197 | 1212 | }); |
| 1213 |
Software/so101/src/server.ts +12 −4
| @@ -515,8 +515,15 @@ export async function startServer(bus: FeetechBus, options: ServerOptions = {}): | ||
| 515 | 515 | res.writeHead(status, { "content-type": "application/json" }); |
| 516 | 516 | res.end(JSON.stringify(body)); |
| 517 | 517 | }; |
| 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 }); | |
| 520 | 527 | res.end(readFileSync(path)); |
| 521 | 528 | }; |
| 522 | 529 | const readBody = (req: IncomingMessage) => |
| @@ -722,7 +729,7 @@ export async function startServer(bus: FeetechBus, options: ServerOptions = {}): | ||
| 722 | 729 | if (req.method === "GET" && url.pathname.startsWith("/model/") && modelDir) { |
| 723 | 730 | const path = safeFile(resolve(modelDir), decode(url.pathname.slice("/model/".length))); |
| 724 | 731 | 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); | |
| 726 | 733 | } |
| 727 | 734 | if (req.method === "GET" && url.pathname === "/printables") { |
| 728 | 735 | let files: string[] = []; |
| @@ -742,7 +749,7 @@ export async function startServer(bus: FeetechBus, options: ServerOptions = {}): | ||
| 742 | 749 | const [pkg, ...rest] = decode(url.pathname.slice("/vendor/".length)).split("/"); |
| 743 | 750 | const path = pkg && VENDORED.has(pkg) && rest.length ? safeFile(join(nodeModules, pkg), rest.join("/")) : null; |
| 744 | 751 | 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); | |
| 746 | 753 | } |
| 747 | 754 | const ui = /^\/ui\/[0-9a-f]{12}\/([a-z][a-z0-9-]*\.(?:js|css))$/.exec(url.pathname); |
| 748 | 755 | if (req.method === "GET" && ui) { |
| @@ -1593,3 +1600,4 @@ export async function startServer(bus: FeetechBus, options: ServerOptions = {}): | ||
| 1593 | 1600 | }), |
| 1594 | 1601 | }; |
| 1595 | 1602 | } |
| 1603 |
Software/so101/src/stop-layout.test.ts +11
| @@ -100,6 +100,16 @@ describe("the served page's STOP", () => { | ||
| 100 | 100 | for (const el of chain) expect(el.attrs).not.toHaveProperty("hidden"); |
| 101 | 101 | }); |
| 102 | 102 | |
| 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 | ||
| 103 | 113 | test("it stops on pointerdown, not click", () => { |
| 104 | 114 | const button = findStops(html)[0]!.at(-1)!; |
| 105 | 115 | expect(button.attrs).not.toHaveProperty("onclick"); |
| @@ -172,3 +182,4 @@ describe("notes on the served page", () => { | ||
| 172 | 182 | expect(notesCss.some((r) => r.selector.includes("notesPill"))).toBe(false); |
| 173 | 183 | }); |
| 174 | 184 | }); |
| 185 |
Software/so101/src/ui/mobile.css +7 −1
| @@ -10,7 +10,7 @@ | ||
| 10 | 10 | html #shell.docked { grid-template-rows: var() auto minmax(0, 1fr) minmax(0, 1fr) calc(56px + var()); grid-template-areas: "view" "strip" "model" "sheet" "dock"; } |
| 11 | 11 | html #shell.docked[data-panel="drive"]:not(.mayDrive) { grid-template-rows: var() auto minmax(0, 1fr) 0 calc(56px + var()); } |
| 12 | 12 | html #shell.docked[data-panel="drive"].mayDrive { grid-template-rows: var() auto minmax(0, 1fr) auto calc(56px + var()); } |
| 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() - 100px - 56px - var() - 200px))); } | |
| 14 | 14 | html #shell.docked:not([data-panel="drive"]) { grid-template-rows: 0 auto 0 minmax(0, 1fr) calc(56px + var()); } |
| 15 | 15 | /* The notch: a black band the height of the top inset, above the 16:9 camera row (it is counted in --cam). */ |
| 16 | 16 | #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 @@ | ||
| 24 | 24 | #chatGlass .gl b { margin-right: 6px; font-weight: 650; } |
| 25 | 25 | /* The strip sits over the view's middle row, so it paints above it. */ |
| 26 | 26 | #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; } | |
| 27 | 32 | /* The model is always on here, so its view button goes; the camera buttons pick which camera is on top. */ |
| 28 | 33 | #v3 { display: none; } |
| 29 | 34 | /* 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 @@ | ||
| 46 | 51 | .j .v { justify-content: flex-end; } |
| 47 | 52 | } |
| 48 | 53 | |
| 54 |
Software/so101/src/ui/video.js +9 −3
| @@ -10,6 +10,8 @@ | ||
| 10 | 10 | export const QUIET_MS = 2000; |
| 11 | 11 | /** The wait before trying again after a socket that closed or failed, so a relay that is down is not hammered. */ |
| 12 | 12 | 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; | |
| 13 | 15 | |
| 14 | 16 | /** The socket's address on the page's own origin. @param {{ protocol: string, host: string }} loc @param {number | string} id */ |
| 15 | 17 | export function socketUrl(loc, id) { |
| @@ -32,7 +34,9 @@ export function openFeed(options) { | ||
| 32 | 34 | const after = options.after ?? ((fn, ms) => setTimeout(fn, ms)); |
| 33 | 35 | const cancel = options.cancel ?? ((t) => clearTimeout(t)); |
| 34 | 36 | 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)); | |
| 36 | 40 | |
| 37 | 41 | const stop = () => { |
| 38 | 42 | cancel(watchdog); watchdog = null; |
| @@ -50,18 +54,19 @@ export function openFeed(options) { | ||
| 50 | 54 | watchdog = null; |
| 51 | 55 | if (closed) return; |
| 52 | 56 | let w; |
| 53 | try { w = open(url); } catch { return again(RETRY_MS); } | |
| 57 | try { w = open(url); } catch { return again(backoff()); } | |
| 54 | 58 | ws = w; |
| 55 | 59 | w.binaryType = /** @type {any} */ ("blob"); // Bun's types know only arraybuffer and nodebuffer; browsers default to blob anyway |
| 56 | 60 | w.onmessage = (e) => { |
| 57 | 61 | if (ws !== w || !(e.data instanceof Blob)) return; |
| 62 | failures = 0; | |
| 58 | 63 | cancel(watchdog); watchdog = after(stalled, QUIET_MS); |
| 59 | 64 | const t = now(); |
| 60 | 65 | if (t - shown < gap) return; // held to fewer frames on a slow link |
| 61 | 66 | shown = t; |
| 62 | 67 | onFrame(e.data); |
| 63 | 68 | }; |
| 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()); } }; | |
| 65 | 70 | watchdog = after(stalled, QUIET_MS); // the first frame is due too |
| 66 | 71 | } |
| 67 | 72 | connect(); |
| @@ -99,3 +104,4 @@ export function attach(cam, epoch, env) { | ||
| 99 | 104 | if (cam.epoch !== epoch || !env.visible()) return; |
| 100 | 105 | 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") }); |
| 101 | 106 | } |
| 107 |
Software/so101/src/ui/video.test.ts +21 −1
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | 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"; | |
| 3 | 3 | |
| 4 | 4 | /** A clock and a socket factory the test drives by hand. */ |
| 5 | 5 | function rig(maxFps?: number) { |
| @@ -66,6 +66,25 @@ describe("the relayed feed", () => { | ||
| 66 | 66 | old.onmessage?.({ data: new Blob([]) }); |
| 67 | 67 | expect(r.frames.length).toBe(0); |
| 68 | 68 | }); |
| 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 | }); | |
| 69 | 88 | test("close stops everything", () => { |
| 70 | 89 | const r = rig(); |
| 71 | 90 | r.feed.close(); |
| @@ -144,3 +163,4 @@ describe("the page's wiring", () => { | ||
| 144 | 163 | expect(page).toContain("clearTimeout(c.timer); stopFeed(c); c.img.src"); |
| 145 | 164 | }); |
| 146 | 165 | }); |
| 166 |
Software/so101/src/viewer.html +2 −1
| @@ -261,7 +261,7 @@ | ||
| 261 | 261 | #shell.bare #strip { max-width: calc(100% - var() - 18px - var() - var()); } |
| 262 | 262 | #strip #clock { color: #9aa4ae; } |
| 263 | 263 | #sheetToggle { display: inline-flex ; 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()); background: rgba(13,17,23,.9); background: color-mix(in srgb, var() 90%, transparent); -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px); border-left: 1px solid var(); transition: transform .2s ease-out; } | |
| 264 | #sheet { position: absolute; z-index: 6; top: 0; bottom: 0; right: 0; width: calc(min(380px, 48vw) + var()); background: rgba(13,17,23,.9); background: color-mix(in srgb, var() 90%, transparent); -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px); border-left: 1px solid var(); transition: transform .2s ease-out; } | |
| 265 | 265 | #shell.bare #sheet { transform: translateX(100%); visibility: hidden; transition: transform .2s ease-out, visibility 0s .2s; } |
| 266 | 266 | #top { top: auto; bottom: 6px; justify-content: flex-start; right: calc(min(380px, 48vw) + 12px + var()); } |
| 267 | 267 | #shell.bare #top { right: calc(var() + var()); } |
| @@ -1623,3 +1623,4 @@ | ||
| 1623 | 1623 | </body> |
| 1624 | 1624 | </html> |
| 1625 | 1625 | |
| 1626 |