Public: anyone can read this; the crew can change it.
Migrated from github.com/Trevato/SO-ARM100
so101 serve picks the arm up when it is plugged back in, and lets it go when it is unplugged, without a restart #622
Check · Review · Land done · Live — Landed; nothing deploys from this branch, so this is the end of its path.
smith/arm:smith/616-so101-serve-picks-the-arm-up-when-it-is → main 5 commits fork 4 threads · 4 unresolved
Software/so101/README.md +1 −1
| @@ -127,7 +127,7 @@ ignored and said once on the arm server's stderr. The arm server reads it at sta | ||
| 127 | 127 | |
| 128 | 128 | The app is a workshop: one Pi, several **stations**, each with its cameras and controls. Today there are two, **the arm** and **the grow box** (spinach under a UV light). `/` is the workshop home, a card per station with its live camera snapshot first and its controls after, and the arm's own page is at `/arm` (its URL is unchanged for anyone who has it). The page and its title say "Workshop"; it shows who is looking ("Signed in as …", or "Watching as a guest · Sign in"), has a skip link, and keeps working while the arm is closed. |
| 129 | 129 | |
| 130 | - **Any subset present.** The grow box has nothing to enumerate (the light is USB power only), so it is always there. The arm's card is there only while the link to the Pi's `so101 serve` is live; unplugged, closed by the owner or unreachable it says why ("The arm: not connected", "The arm is resting") and offers no controls (its cameras still show), and the owner sees **Open now** on it. Plugging the arm back in brings its card back by itself, with no restart. `so101 serve` runs with the arm unplugged: when the port is missing or cannot be opened it logs `arm not connected (<path>: <reason>); trying again every 5 s` and keeps serving, so the cameras, `/version` and the grow box stay up and the Pi can still update itself. `GET /status` answers 200 with `arm: "not connected"`; every route that needs the servos (moves, poses, torque, calibration, the drive stream) answers 503 `{"error":"arm not connected"}`, and STOP answers 200. The port is tried again every 5 s (src/arm-plug.ts): when the arm answers, the server starts over with it and logs `arm connected: serving <path>`, with no restart of the service. | |
| 130 | - **Any subset present.** The grow box has nothing to enumerate (the light is USB power only), so it is always there. The arm's card is there only while the link to the Pi's `so101 serve` is live; unplugged, closed by the owner or unreachable it says why ("The arm: not connected", "The arm is resting") and offers no controls (its cameras still show), and the owner sees **Open now** on it. Plugging the arm back in brings its card back by itself, with no restart. `so101 serve` runs with the arm unplugged: when the port is missing or cannot be opened it logs `arm not connected (<path>: <reason>); trying again every 5 s` and keeps serving, so the cameras, `/version` and the grow box stay up and the Pi can still update itself. `GET /status` answers 200 with `arm: "not connected"`; every route that needs the servos (moves, poses, torque, calibration, the drive stream) answers 503 `{"error":"arm not connected"}`, and STOP answers 200. The port is tried again every 5 s: when the arm answers, the real bus takes over without a restart (the cameras never blink) and the server logs `arm connected on <path>`. If the port fails mid-run, whatever drives stops (failed, never resumed), open drive streams close with 1013, `/status` goes back to "not connected" and the retries start again (the log line is `arm not connected (its port failed); trying again every 5 s`). A recording under way when the port fails ends there: stopping it saves what was sampled. | |
| 131 | 131 | - **"Camera can't see the plants."** On the grow box's card, each still is checked in the browser once it has loaded: the part inside the camera's box (set on Tune, `/admin/camera-list`) is counted for blown-out and black pixels (`visibility` in src/ui/still-frame.js), and when the plants cannot be seen the card says so over the foot of the still, with a link to Tune (src/ui/grow-hint.js). The relay never decodes a JPEG, so the terminal and the JSON doors cannot show this hint; they have no pixels to count. |
| 132 | 132 | - **The config** is `so101/stations.json` under `FORGE_APP_DATA`: a list of `{id, name, kind: "arm"|"grow", cameras: [camera ids], controls: [{label, href}]}` (src/stations.ts). Without a file it is the arm (camera 1) and the grow box (camera 2, from the camera list): edit it to match the cameras. With only an arm in it, `/` is the arm's page as before. |
| 133 | 133 | - **Live.** `GET /care/events` is a server-sent stream that says `care` after every change to the book, from any door; the page listens with a few lines of script and refreshes itself (not while a field has focus). Without the script the page works the same, on reload. A refused form returns to `/care?error=…` with the message in an alert line. |
Software/so101/bench/real-probe.ts +4 −1
| @@ -63,6 +63,7 @@ async function run(url: string, ws: WebSocket, joint: Joint, target: number, ms: | ||
| 63 | 63 | ws.on("close", (code) => (closed = code)); |
| 64 | 64 | ws.send(JSON.stringify({ [joint.id]: target })); |
| 65 | 65 | const t0 = performance.now(); |
| 66 | let near = 0; | |
| 66 | 67 | while (performance.now() - t0 < ms) { |
| 67 | 68 | await sleep(SAMPLE_MS); |
| 68 | 69 | const s = await status(url); |
| @@ -71,7 +72,9 @@ async function run(url: string, ws: WebSocket, joint: Joint, target: number, ms: | ||
| 71 | 72 | const pos = s.joints.find((j) => j.id === joint.id)?.position; |
| 72 | 73 | if (typeof pos !== "number") throw new Error(`joint ${joint.id} has no position`); |
| 73 | 74 | onSample?.(pos, performance.now() - t0); |
| 74 | if (!onSample && Math.abs(pos - target) <= ARRIVE_TICKS) return; | |
| 75 | // Two samples in reach, not one: a joint that was moving out when the target changed is still in reach as it turns. | |
| 76 | near = Math.abs(pos - target) <= ARRIVE_TICKS ? near + 1 : 0; | |
| 77 | if (!onSample && near >= 2) return; | |
| 75 | 78 | } |
| 76 | 79 | } |
| 77 | 80 |
Software/so101/src/arm-plug.test.ts +45 −34
| @@ -3,56 +3,67 @@ import { mkdtempSync, rmSync } from "node:fs"; | ||
| 3 | 3 | import { tmpdir } from "node:os"; |
| 4 | 4 | import { createServer } from "node:net"; |
| 5 | 5 | import { join } from "node:path"; |
| 6 | import { retryArm } from "./arm-plug.ts"; | |
| 7 | ||
| 8 | const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); | |
| 9 | ||
| 10 | test("retryArm tries again until the arm answers, hands it over once, and stops trying", async () => { | |
| 11 | let tries = 0; | |
| 12 | const got: string[] = []; | |
| 13 | retryArm(async () => (++tries < 3 ? null : "bus"), (bus) => void got.push(bus), 10); | |
| 14 | await sleep(100); | |
| 15 | expect(got).toEqual(["bus"]); | |
| 16 | expect(tries).toBe(3); | |
| 17 | }); | |
| 18 | ||
| 19 | test("retryArm counts a thrown open as not there, and its stop stops it", async () => { | |
| 20 | let tries = 0; | |
| 21 | const stop = retryArm(async () => (++tries, Promise.reject(new Error("ENOENT"))), () => {}, 10); | |
| 22 | await sleep(45); | |
| 23 | stop(); | |
| 24 | const seen = tries; | |
| 25 | expect(seen).toBeGreaterThan(1); | |
| 26 | await sleep(40); | |
| 27 | expect(tries).toBe(seen); | |
| 28 | }); | |
| 6 | import { releaseTorque } from "./cli.ts"; | |
| 7 | import { FeetechBus } from "./bus.ts"; | |
| 8 | import { FakeBus } from "./fake-bus.ts"; | |
| 29 | 9 | |
| 30 | 10 | const dir = mkdtempSync(join(tmpdir(), "so101-plug-")); |
| 31 | 11 | afterAll(() => rmSync(dir, { recursive: true, force: true })); |
| 32 | 12 | |
| 33 | test("`so101 serve` with SO101_PORT at a missing path serves /status 200, arm: not connected, and says it will try again", async () => { | |
| 13 | /** `so101 serve` with SO101_PORT at a missing path, until it says where it listens. */ | |
| 14 | async function serveWithoutArm() { | |
| 34 | 15 | const free = createServer(); |
| 35 | 16 | await new Promise<void>((r) => free.listen(0, "127.0.0.1", r)); |
| 36 | 17 | const listen = (free.address() as { port: number }).port; |
| 37 | 18 | await new Promise((r) => free.close(r)); |
| 38 | 19 | const script = `import { main } from ${JSON.stringify(join(import.meta.dir, "cli.ts"))}; await main(["serve", "--listen", "127.0.0.1:${listen}", "--file", ${JSON.stringify(join(dir, "poses.json"))}]);`; |
| 39 | 20 | const child = Bun.spawn(["bun", "-e", script], { env: { ...process.env, SO101_PORT: "/dev/does-not-exist-ttyACM9" }, stdout: "pipe", stderr: "pipe" }); |
| 40 | try { | |
| 41 | const reader = child.stdout.getReader(); | |
| 42 | let out = ""; | |
| 43 | while (!/on http:\/\/127\.0\.0\.1:(\d+)/.test(out)) { | |
| 21 | const reader = child.stdout.getReader(); | |
| 22 | let out = ""; | |
| 23 | const read = async (until: RegExp) => { | |
| 24 | while (!until.test(out)) { | |
| 44 | 25 | const { value, done } = await reader.read(); |
| 45 | 26 | if (done) throw new Error(`serve exited: ${out}${await new Response(child.stderr).text()}`); |
| 46 | 27 | out += new TextDecoder().decode(value); |
| 47 | 28 | } |
| 48 | expect(out).toContain("arm not connected (/dev/does-not-exist-ttyACM9"); | |
| 49 | expect(out).toContain("trying again every 5 s"); | |
| 50 | const port = /on http:\/\/127\.0\.0\.1:(\d+)/.exec(out)![1]; | |
| 51 | const res = await fetch(`http://127.0.0.1:${port}/status`); | |
| 29 | return out; | |
| 30 | }; | |
| 31 | await read(/on http:\/\/127\.0\.0\.1:(\d+)/); | |
| 32 | return { child, read, port: /on http:\/\/127\.0\.0\.1:(\d+)/.exec(out)![1]!, out: () => out }; | |
| 33 | } | |
| 34 | ||
| 35 | test("`so101 serve` with SO101_PORT at a missing path serves /status 200, arm: not connected, and says it will try again", async () => { | |
| 36 | const s = await serveWithoutArm(); | |
| 37 | try { | |
| 38 | expect(s.out()).toContain("arm not connected (/dev/does-not-exist-ttyACM9"); | |
| 39 | expect(s.out()).toContain("trying again every 5 s"); | |
| 40 | const res = await fetch(`http://127.0.0.1:${s.port}/status`); | |
| 52 | 41 | expect(res.status).toBe(200); |
| 53 | 42 | expect(((await res.json()) as { arm: string }).arm).toBe("not connected"); |
| 54 | 43 | } finally { |
| 55 | child.kill(); | |
| 56 | await child.exited; | |
| 44 | s.child.kill(); | |
| 45 | await s.child.exited; | |
| 57 | 46 | } |
| 58 | 47 | }, 20_000); |
| 48 | ||
| 49 | test("`so101 serve` with no arm stops on ctrl-c with a plain `stopped`", async () => { | |
| 50 | const s = await serveWithoutArm(); | |
| 51 | try { | |
| 52 | await s.read(/ctrl-c to stop/); | |
| 53 | s.child.kill("SIGINT"); | |
| 54 | await s.read(/stopped\n/); | |
| 55 | expect(await s.child.exited).toBe(0); | |
| 56 | expect(s.out()).not.toContain("torque released"); | |
| 57 | } finally { | |
| 58 | s.child.kill(); | |
| 59 | } | |
| 60 | }, 20_000); | |
| 61 | ||
| 62 | test("the shutdown line says torque was released, or that the arm's port was not answering", async () => { | |
| 63 | const fake = new FakeBus([1, 2, 3, 4, 5, 6]); | |
| 64 | const bus = new FeetechBus(fake); | |
| 65 | expect(await releaseTorque(bus)).toBe("stopped; torque released"); | |
| 66 | expect(fake.writes.filter((w) => w.address === 40 && w.bytes[0] === 0).length).toBe(6); | |
| 67 | fake.fail(new Error("device unplugged")); | |
| 68 | expect(await releaseTorque(bus)).toBe("stopped; the arm's port was not answering"); | |
| 69 | }); |
Software/so101/src/arm-plug.ts deleted −25
| @@ -1,25 +0,0 @@ | ||
| 1 | /** `so101 serve` with the arm unplugged serves on without it and tries the port again every few seconds (hot-plug). */ | |
| 2 | ||
| 3 | /** How often an unplugged arm's port is tried again, ms. */ | |
| 4 | export const REPLUG_MS = 5000; | |
| 5 | ||
| 6 | /** | |
| 7 | * Tries `open` every `everyMs` until it gives a bus, then hands it to `plugged` once and stops. | |
| 8 | * `open` answers null while the arm is not there; tries never overlap. Returns the stop. | |
| 9 | */ | |
| 10 | export function retryArm<B>(open: () => Promise<B | null>, plugged: (bus: B) => void | Promise<void>, everyMs = REPLUG_MS): () => void { | |
| 11 | let stopped = false; | |
| 12 | let timer: ReturnType<typeof setTimeout> | undefined; | |
| 13 | const attempt = async () => { | |
| 14 | const bus = await open().catch(() => null); | |
| 15 | if (stopped) return; | |
| 16 | if (bus === null) return void (timer = setTimeout(attempt, everyMs)); | |
| 17 | stopped = true; | |
| 18 | await plugged(bus); | |
| 19 | }; | |
| 20 | timer = setTimeout(attempt, everyMs); | |
| 21 | return () => { | |
| 22 | stopped = true; | |
| 23 | clearTimeout(timer); | |
| 24 | }; | |
| 25 | } |
Software/so101/src/arm-replug.test.ts added +136
| @@ -0,0 +1,136 @@ | ||
| 1 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 2 | import WebSocket from "ws"; | |
| 3 | import { mkdtempSync } from "node:fs"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { FeetechBus } from "./bus.ts"; | |
| 7 | import { FakeBus } from "./fake-bus.ts"; | |
| 8 | import { startServer, type RunningServer } from "./server.ts"; | |
| 9 | ||
| 10 | let server: RunningServer; | |
| 11 | let base: string; | |
| 12 | let plugged: FakeBus | null = null; | |
| 13 | let opened = 0; | |
| 14 | ||
| 15 | beforeAll(async () => { | |
| 16 | const dir = mkdtempSync(join(tmpdir(), "so101-replug-")); | |
| 17 | const reopen = async () => { | |
| 18 | opened++; | |
| 19 | return plugged ? new FeetechBus(plugged) : null; | |
| 20 | }; | |
| 21 | server = await startServer(null, { host: "127.0.0.1", port: 0, posesFile: join(dir, "poses.json"), reopen, retryMs: 20 }); | |
| 22 | base = `http://127.0.0.1:${server.port}`; | |
| 23 | }); | |
| 24 | afterAll(() => void server.close()); | |
| 25 | ||
| 26 | const arm = async () => ((await (await fetch(`${base}/status`)).json()) as { arm: string }).arm; | |
| 27 | const until = async (want: (arm: string) => boolean) => { | |
| 28 | for (let i = 0; i < 100; i++) { | |
| 29 | if (want(await arm())) return; | |
| 30 | await Bun.sleep(20); | |
| 31 | } | |
| 32 | throw new Error(`arm still ${await arm()}`); | |
| 33 | }; | |
| 34 | const move = () => fetch(`${base}/move`, { method: "POST", body: JSON.stringify({ targets: { "1": 2048 } }) }); | |
| 35 | ||
| 36 | test("booted without the arm, plugging it in brings it back; losing its port lets it go again; plugging in again returns it", async () => { | |
| 37 | expect(await arm()).toBe("not connected"); | |
| 38 | expect((await move()).status).toBe(503); | |
| 39 | await Bun.sleep(60); | |
| 40 | expect(opened).toBeGreaterThan(1); // retried while absent | |
| 41 | ||
| 42 | plugged = new FakeBus([1, 2, 3, 4, 5, 6]); | |
| 43 | await until((a) => a !== "not connected"); | |
| 44 | expect((await move()).status).toBe(200); | |
| 45 | const first = plugged; | |
| 46 | ||
| 47 | plugged = null; | |
| 48 | first.fail(new Error("device unplugged")); | |
| 49 | await until((a) => a === "not connected"); | |
| 50 | expect((await move()).status).toBe(503); | |
| 51 | expect((await fetch(`${base}/version`)).status).toBe(200); | |
| 52 | expect((await fetch(`${base}/stop`, { method: "POST", body: "{}" })).status).toBe(200); | |
| 53 | ||
| 54 | plugged = new FakeBus([1, 2, 3, 4, 5, 6]); | |
| 55 | await until((a) => a !== "not connected"); | |
| 56 | await fetch(`${base}/resume`, { method: "POST", body: "{}" }); // the stop above holds the arm | |
| 57 | expect((await move()).status).toBe(200); | |
| 58 | }); | |
| 59 | ||
| 60 | describe("a port that fails with the arm in use", () => { | |
| 61 | let server: RunningServer; | |
| 62 | let base: string; | |
| 63 | let live: FakeBus; | |
| 64 | let next: FakeBus | null = null; | |
| 65 | ||
| 66 | beforeAll(async () => { | |
| 67 | const dir = mkdtempSync(join(tmpdir(), "so101-replug-live-")); | |
| 68 | live = new FakeBus([1, 2, 3, 4, 5, 6], { speedTicksS: 50 }); // slow: a long move is still under way when the cable goes | |
| 69 | server = await startServer(new FeetechBus(live), { host: "127.0.0.1", port: 0, posesFile: join(dir, "poses.json"), reopen: async () => (next ? new FeetechBus(next) : null), retryMs: 20 }); | |
| 70 | base = `http://127.0.0.1:${server.port}`; | |
| 71 | }); | |
| 72 | afterAll(() => void server.close()); | |
| 73 | ||
| 74 | const status = async () => (await (await fetch(`${base}/status`)).json()) as { arm: string; driving: string | null }; | |
| 75 | const settle = async (want: (s: { arm: string; driving: string | null }) => boolean) => { | |
| 76 | for (let i = 0; i < 100; i++) { | |
| 77 | if (want(await status())) return; | |
| 78 | await Bun.sleep(20); | |
| 79 | } | |
| 80 | throw new Error(`never settled: ${JSON.stringify(await status())}`); | |
| 81 | }; | |
| 82 | ||
| 83 | test("the move in flight fails and the drive stream closes with 1013; an E-stop made while the arm is gone holds it on return", async () => { | |
| 84 | const started = Date.now(); | |
| 85 | const moving = fetch(`${base}/move`, { method: "POST", body: JSON.stringify({ targets: { "1": 3500 }, speed: 100 }) }); | |
| 86 | await settle((s) => s.driving === "move"); | |
| 87 | live.fail(new Error("device unplugged")); | |
| 88 | const res = await moving; | |
| 89 | expect(res.status).not.toBe(200); // failed, never resumed | |
| 90 | expect(Date.now() - started).toBeLessThan(4000); // not the ~25 s the move would have taken | |
| 91 | await settle((s) => s.arm === "not connected" && s.driving === null); | |
| 92 | ||
| 93 | expect((await fetch(`${base}/estop`, { method: "POST", body: "{}" })).status).toBe(200); | |
| 94 | next = new FakeBus([1, 2, 3, 4, 5, 6]); | |
| 95 | await settle((s) => s.arm !== "not connected"); | |
| 96 | for (let i = 0; i < 50 && !next.writes.some((w) => w.address === 40 && w.bytes[0] === 1); i++) await Bun.sleep(20); | |
| 97 | expect(next.writes.some((w) => w.address === 40 && w.bytes[0] === 1)).toBe(true); // torque on: it holds where it stands | |
| 98 | expect(next.writes.some((w) => w.address === 42)).toBe(true); // goal set to the present position | |
| 99 | await fetch(`${base}/estop/clear`, { method: "POST", body: "{}" }); | |
| 100 | ||
| 101 | const ws = new WebSocket(`ws://127.0.0.1:${server.port}/follow`); | |
| 102 | await new Promise((r) => ws.once("open", r)); | |
| 103 | const closed = new Promise<number>((resolve) => ws.once("close", resolve)); | |
| 104 | ws.send(JSON.stringify({ "1": 2100 })); | |
| 105 | await settle((s) => s.driving === "stream"); | |
| 106 | next.fail(new Error("device unplugged")); | |
| 107 | expect(await closed).toBe(1013); | |
| 108 | await settle((s) => s.arm === "not connected" && s.driving === null); | |
| 109 | }); | |
| 110 | }); | |
| 111 | ||
| 112 | describe("a recording when the cable goes", () => { | |
| 113 | test("is ended by the loss itself, not by the next read failing: what was sampled saves, and the arm is free again", async () => { | |
| 114 | const dir = mkdtempSync(join(tmpdir(), "so101-replug-rec-")); | |
| 115 | const live = new FakeBus([1, 2, 3, 4, 5, 6]); | |
| 116 | let back: FakeBus | null = null; | |
| 117 | const server = await startServer(new FeetechBus(live), { host: "127.0.0.1", port: 0, posesFile: join(dir, "poses.json"), reopen: async () => (back ? new FeetechBus(back) : null), retryMs: 20 }); | |
| 118 | const base = `http://127.0.0.1:${server.port}`; | |
| 119 | const post = (path: string, body: unknown) => fetch(`${base}${path}`, { method: "POST", body: JSON.stringify(body) }); | |
| 120 | try { | |
| 121 | // 1 Hz: the loop sleeps nearly all the time, so the cable goes while it waits and no read is in flight. | |
| 122 | expect((await post("/record/start", { name: "cut", hz: 1 })).status).toBe(200); | |
| 123 | await Bun.sleep(300); | |
| 124 | live.fail(new Error("device unplugged")); | |
| 125 | for (let i = 0; i < 100 && ((await (await fetch(`${base}/status`)).json()) as { arm: string }).arm !== "not connected"; i++) await Bun.sleep(20); | |
| 126 | await Bun.sleep(1200); // past the next sample: only the abort stops the loop before it reads the dead bus | |
| 127 | back = new FakeBus([1, 2, 3, 4, 5, 6]); // routes that need the arm wait for it to come back | |
| 128 | for (let i = 0; i < 100 && ((await (await fetch(`${base}/status`)).json()) as { arm: string }).arm === "not connected"; i++) await Bun.sleep(20); | |
| 129 | const stopped = await post("/record/stop", {}); | |
| 130 | expect(await stopped.json()).toMatchObject({ saved: "cut", frames: 1 }); | |
| 131 | expect(((await (await fetch(`${base}/status`)).json()) as { driving: string | null }).driving).toBeNull(); | |
| 132 | } finally { | |
| 133 | await server.close(); | |
| 134 | } | |
| 135 | }); | |
| 136 | }); |
Software/so101/src/arm-wait.ts deleted −18
| @@ -1,18 +0,0 @@ | ||
| 1 | /** `so101 serve` with no arm plugged in waits for one instead of exiting, so the service stays up for the rest of the workshop. */ | |
| 2 | ||
| 3 | export const NO_ADAPTER = /no USB serial adapter found/; | |
| 4 | ||
| 5 | /** Returns once `detect` finds a port; any other failure (several adapters) is thrown. Says once that it is waiting. */ | |
| 6 | export async function waitForArm(detect: () => Promise<string>, say: (text: string) => void, sleep: (ms: number) => Promise<void>, everyMs = 2000): Promise<string> { | |
| 7 | let told = false; | |
| 8 | for (;;) { | |
| 9 | try { | |
| 10 | return await detect(); | |
| 11 | } catch (err) { | |
| 12 | if (!NO_ADAPTER.test((err as Error).message)) throw err; | |
| 13 | if (!told) say("arm not connected; waiting for its servo board"); | |
| 14 | told = true; | |
| 15 | await sleep(everyMs); | |
| 16 | } | |
| 17 | } | |
| 18 | } |
Software/so101/src/bus.test.ts +13
| @@ -131,3 +131,16 @@ describe("FeetechBus syncWrite ordering for playback", () => { | ||
| 131 | 131 | ]); |
| 132 | 132 | }); |
| 133 | 133 | }); |
| 134 | ||
| 135 | describe("a failing port", () => { | |
| 136 | test("onFail fires once with the transport's error, and the bus answers nothing after it", async () => { | |
| 137 | const fake = new FakeBus([1]); | |
| 138 | const bus = new FeetechBus(fake); | |
| 139 | const told: Error[] = []; | |
| 140 | bus.onFail = (error) => told.push(error); | |
| 141 | expect(await bus.ping(1)).not.toBeNull(); | |
| 142 | fake.fail(new Error("device unplugged")); | |
| 143 | expect(told.map((e) => e.message)).toEqual(["device unplugged"]); | |
| 144 | await expect(bus.ping(1)).rejects.toThrow("device unplugged"); | |
| 145 | }); | |
| 146 | }); |
Software/so101/src/bus.ts +3
| @@ -81,6 +81,8 @@ export class FeetechBus { | ||
| 81 | 81 | : boolean; |
| 82 | 82 | = false; |
| 83 | 83 | : Error | null = null; |
| 84 | /** Told once the port has failed (unplugged): the bus is dead from then on and the owner should let it go. */ | |
| 85 | onFail: ((error: Error) => void) | null = null; | |
| 84 | 86 | /** Told of every sync write before it goes on the wire: what the arm server shows as "sent" (src/machine-log.ts). */ |
| 85 | 87 | onSyncWrite: ((register: Register, values: ReadonlyArray<readonly [number, number]>) => void) | null = null; |
| 86 | 88 | |
| @@ -94,6 +96,7 @@ export class FeetechBus { | ||
| 94 | 96 | // The port is gone (unplugged mid-write, say): fail what is in flight and everything after it. |
| 95 | 97 | this. = error; |
| 96 | 98 | this.?.fail(error); |
| 99 | this.onFail?.(error); | |
| 97 | 100 | }); |
| 98 | 101 | } |
| 99 | 102 |
Software/so101/src/cli.ts +17 −21
| @@ -9,7 +9,6 @@ import { FeetechBus, range } from "./bus.ts"; | ||
| 9 | 9 | import { describeError, hex } from "./protocol.ts"; |
| 10 | 10 | import { MAX_FRAME_STEP, MotionError, moveToPose, name as jointName, playTrajectory, recordTrajectory, sleep } from "./motion.ts"; |
| 11 | 11 | import { streamLeader } from "./leader.ts"; |
| 12 | import { REPLUG_MS, retryArm } from "./arm-plug.ts"; | |
| 13 | 12 | import { assertPoseName, parsePoseFile, serializePoseFile, type PoseFile } from "./poses.ts"; |
| 14 | 13 | import { durationSeconds, largestStep, parseTrajectory, serializeTrajectory } from "./trajectory.ts"; |
| 15 | 14 | import { |
| @@ -531,33 +530,24 @@ const COMMANDS: Record<string, Command> = { | ||
| 531 | 530 | const [host, portText] = (values.listen ?? "0.0.0.0:8101").split(":"); |
| 532 | 531 | const port = bounded(integer(portText ?? "8101", "--listen port"), 1, 65535, "--listen port"); |
| 533 | 532 | const { growKeyFile, startServer } = await import("./server.ts"); // lazily: it reaches bun:sqlite, which Node lacks |
| 534 | let bus = await openArm(values, true); | |
| 533 | let bus = await openArm(values, true); // the newest bus opened, live or lost: what is released at the end | |
| 535 | 534 | const cameraControl = (unit: number, profile: Parameters<typeof applyProfile>[1]) => applyProfile(cameraDevice(unit), profile); |
| 536 | 535 | const cameraStill = (unit: number) => grabStill(unit, cameraDevice(unit)); |
| 537 | const options = { host, port, posesFile: values.file ?? "poses.json", modelDir: values.model, gainSyncMs: 2000, cameraControl, cameraStill, lightMs: 15_000, sensorsMs: 60_000 }; | |
| 538 | let server = await startServer(bus, options); | |
| 539 | // Unplugged, the port is tried again every 5 s; when the arm answers, the server starts over with it (the cameras blink once). | |
| 540 | const stopRetry = bus | |
| 541 | ? () => {} | |
| 542 | : retryArm( | |
| 543 | () => openArm(values, false), | |
| 544 | async (found) => { | |
| 545 | await server.close(); | |
| 546 | bus = found; | |
| 547 | server = await startServer(found, options); | |
| 548 | console.log(`arm connected: serving ${found.path}`); | |
| 549 | }, | |
| 550 | ); | |
| 536 | // The server swaps the bus in when the port opens (tried every 5 s) and lets it go when the port fails. | |
| 537 | const reopen = async () => { | |
| 538 | const found = await openArm(values, false); | |
| 539 | if (found) bus = found; | |
| 540 | return found; | |
| 541 | }; | |
| 542 | const server = await startServer(bus, { host, port, posesFile: values.file ?? "poses.json", modelDir: values.model, reopen, gainSyncMs: 2000, cameraControl, cameraStill, lightMs: 15_000, sensorsMs: 60_000 }); | |
| 551 | 543 | try { |
| 552 | 544 | console.log(`serving ${bus?.path ?? "no arm"} on http://${host}:${server.port} — open it in a browser; the API is listed in src/server.ts`); |
| 553 | 545 | console.log(`the grow routes need the key in ${growKeyFile(dirname(resolve(values.file ?? "poses.json")))}: give the hosted app the same in ARM_GROW_KEY`); |
| 554 | 546 | console.log("ctrl-c to stop"); |
| 555 | 547 | await new Promise<void>((resolve) => process.once("SIGINT", () => resolve())); |
| 556 | stopRetry(); | |
| 557 | 548 | await server.close(); |
| 558 | 549 | if (bus) { |
| 559 | await bus.syncWrite(STS3215.Torque_Enable, [1, 2, 3, 4, 5, 6].map((id) => [id, 0] as const)); | |
| 560 | console.log("stopped; torque released"); | |
| 550 | console.log(await releaseTorque(bus)); | |
| 561 | 551 | } else console.log("stopped"); |
| 562 | 552 | } finally { |
| 563 | 553 | await bus?.close(); |
| @@ -649,14 +639,20 @@ async function withBus<T>(values: Values, fn: (bus: FeetechBus) => Promise<T>): | ||
| 649 | 639 | } |
| 650 | 640 | } |
| 651 | 641 | |
| 652 | /** The arm's bus for `serve`, or null (one logged line when `say`) when the port is missing or cannot be opened: the cameras and the grow box serve on. */ | |
| 642 | /** The line `serve` ends with: torque released on all six servos, or that the port was not answering (a lost port cannot be written). */ | |
| 643 | export async function releaseTorque(bus: FeetechBus): Promise<string> { | |
| 644 | const released = await bus.syncWrite(STS3215.Torque_Enable, [1, 2, 3, 4, 5, 6].map((id) => [id, 0] as const)).then(() => true, () => false); | |
| 645 | return released ? "stopped; torque released" : "stopped; the arm's port was not answering"; | |
| 646 | } | |
| 647 | ||
| 648 | /** The arm's bus for `serve`, or null (one logged line, unless `say` is off) when the port is missing or cannot be opened: the cameras and the grow box serve on. */ | |
| 653 | 649 | async function openArm(values: Values, say: boolean): Promise<FeetechBus | null> { |
| 654 | 650 | let path = values.port ?? process.env.SO101_PORT; |
| 655 | 651 | try { |
| 656 | 652 | path ??= await detectPort(); |
| 657 | 653 | return await FeetechBus.open(path, { baudRate: values.baud ? integer(values.baud, "--baud") : 1_000_000 }); |
| 658 | 654 | } catch (err) { |
| 659 | if (say) console.log(`arm not connected (${path ?? "no port"}: ${message(err)}); trying again every ${REPLUG_MS / 1000} s`); | |
| 655 | if (say) console.log(`arm not connected (${path ?? "no port"}: ${message(err)}); trying again every 5 s`); | |
| 660 | 656 | return null; |
| 661 | 657 | } |
| 662 | 658 | } |
Software/so101/src/fake-bus.ts +7
| @@ -38,6 +38,7 @@ export class FakeBus implements Transport { | ||
| 38 | 38 | /** Read and sync-read instructions heard, one per packet. */ |
| 39 | 39 | reads = 0; |
| 40 | 40 | : ((chunk: Uint8Array) => void) | undefined; |
| 41 | : ((error: Error) => void) | undefined; | |
| 41 | 42 | readonly = new FrameParser(); |
| 42 | 43 | readonly : FakeOptions; |
| 43 | 44 | /** With a speed: each servo's goal and when its position was last brought up to date. */ |
| @@ -68,6 +69,12 @@ export class FakeBus implements Transport { | ||
| 68 | 69 | |
| 69 | 70 | on(event: "data" | "error", listener: ((chunk: Uint8Array) => void) | ((error: Error) => void)): void { |
| 70 | 71 | if (event === "data") this. = listener as (chunk: Uint8Array) => void; |
| 72 | else this. = listener as (error: Error) => void; | |
| 73 | } | |
| 74 | ||
| 75 | /** The port is lost, like a pulled cable: the bus is told through its error event. */ | |
| 76 | fail(error: Error): void { | |
| 77 | this.?.(error); | |
| 71 | 78 | } |
| 72 | 79 | |
| 73 | 80 | write(data: Uint8Array, callback: (error: Error | null | undefined) => void): void { |
Software/so101/src/server.ts +71 −15
| @@ -101,7 +101,8 @@ import { StillError } from "./camera-still.ts"; | ||
| 101 | 101 | import { Clock, CommandGate, HELD_CLOSE, isRelease, sentAtOf } from "./command-gate.ts"; |
| 102 | 102 | import { FLOOD_CLOSE, FrameRate, frameBytes, MAX_FRAME_BYTES, TOO_BIG_CLOSE } from "./frame-limit.ts"; |
| 103 | 103 | import type { FeetechBus } from "./bus.ts"; |
| 104 | import { ARM_ABSENT, ARM_ROUTES, ArmAbsentError, absentBus } from "./absent-bus.ts"; | |
| 104 | import { ARM_ABSENT, ARM_ROUTES, ArmAbsentError } from "./absent-bus.ts"; | |
| 105 | import { SwappedBus } from "./swap-bus.ts"; | |
| 105 | 106 | import { ArmModel, gripperTicksToRadians, ticksToRadians, type Vec3, type Violation } from "./kinematics.ts"; |
| 106 | 107 | import { fitTablePlane, MAX_TILT_DEG } from "./table-plane.ts"; |
| 107 | 108 | import { clamp, type Frame } from "./mapping.ts"; |
| @@ -148,6 +149,10 @@ export interface ServerOptions { | ||
| 148 | 149 | heartbeatMs?: number; |
| 149 | 150 | /** The commit the precision history tags its rows with; found from the environment or the checkout when not given. */ |
| 150 | 151 | commit?: string; |
| 152 | /** Opens the arm's port, or null when it is not there. While no arm is connected it is tried every `retryMs`; the bus it returns takes over. */ | |
| 153 | reopen?: () => Promise<FeetechBus | null>; | |
| 154 | /** How often `reopen` is tried while no arm is connected, ms (default 5000). */ | |
| 155 | retryMs?: number; | |
| 151 | 156 | /** How often the arm is checked for servos whose gains need writing, ms; unset, the gains are not written (the CLI sets 2000). */ |
| 152 | 157 | gainSyncMs?: number; |
| 153 | 158 | /** How often the grow light's schedule is checked, ms; unset, it is not run (the CLI sets 15000). The light then catches up at start. */ |
| @@ -253,8 +258,8 @@ const MIME: Record<string, string> = { ".js": "text/javascript", ".css": "text/c | ||
| 253 | 258 | |
| 254 | 259 | /** `arm` is null when the serial port could not be opened: the server still serves everything that is not the arm. */ |
| 255 | 260 | export async function startServer(arm: FeetechBus | null, options: ServerOptions = {}): Promise<RunningServer> { |
| 256 | const hasArm = arm !== null; | |
| 257 | const bus = arm ?? absentBus("(no arm)"); | |
| 261 | const link = new SwappedBus(); | |
| 262 | const bus = link.bus; | |
| 258 | 263 | const ids = options.ids ?? [1, 2, 3, 4, 5, 6]; |
| 259 | 264 | const posesFile = resolve(options.posesFile ?? "poses.json"); |
| 260 | 265 | const dataDir = dirname(posesFile); |
| @@ -288,20 +293,28 @@ export async function startServer(arm: FeetechBus | null, options: ServerOptions | ||
| 288 | 293 | /** Torque_Enable sits apart from the present block, so it is read at most every TORQUE_EVERY_MS, and at once after it is written. */ |
| 289 | 294 | let torque = new Map<number, number>(); |
| 290 | 295 | let torqueAt = -Infinity; |
| 291 | bus.onSyncWrite = (register, values) => { | |
| 296 | const onSyncWrite: NonNullable<FeetechBus["onSyncWrite"]> = (register, values) => { | |
| 292 | 297 | if (register === STS3215.Goal_Position) for (const [id, v] of values) goals.set(id, v), tracking?.wrote(id, Date.now()); |
| 293 | 298 | if (register === STS3215.Torque_Enable) torqueAt = -Infinity; |
| 294 | 299 | machine.note("sent", registerName(register), { values: perServo(values) }); |
| 295 | 300 | }; |
| 301 | bus.onSyncWrite = onSyncWrite; | |
| 296 | 302 | /** The stream's dead-man hold is in force: no frame came for holdAfterMs, and the arm stays where it was. */ |
| 297 | 303 | let held = false; |
| 298 | 304 | |
| 299 | 305 | const limits = new Map<number, { min: number; max: number }>(); |
| 300 | for (const id of hasArm ? ids : []) { | |
| 301 | limits.set(id, { | |
| 302 | min: await bus.read(id, STS3215.Min_Position_Limit), | |
| 303 | max: await bus.read(id, STS3215.Max_Position_Limit), | |
| 304 | }); | |
| 306 | const loadLimits = async (from: FeetechBus) => { | |
| 307 | for (const id of ids) { | |
| 308 | limits.set(id, { | |
| 309 | min: await from.read(id, STS3215.Min_Position_Limit), | |
| 310 | max: await from.read(id, STS3215.Max_Position_Limit), | |
| 311 | }); | |
| 312 | } | |
| 313 | }; | |
| 314 | if (arm) { | |
| 315 | await loadLimits(arm); | |
| 316 | link.attach(arm); | |
| 317 | arm.onSyncWrite = onSyncWrite; | |
| 305 | 318 | } |
| 306 | 319 | |
| 307 | 320 | // ------------------------------------------------------------------ the model and the zone |
| @@ -468,7 +481,7 @@ export async function startServer(arm: FeetechBus | null, options: ServerOptions | ||
| 468 | 481 | const stopped = (e: EStop) => `emergency stop (${e.by}, ${e.at.slice(0, 19).replace("T", " ")} UTC): the arm holds until an admin clears it`; |
| 469 | 482 | /** Torque on, goal where each joint is: the arm stays put and resists. */ |
| 470 | 483 | const holdHere = async () => { |
| 471 | if (!hasArm) return; | |
| 484 | if (!link.connected) return; | |
| 472 | 485 | const positions = await bus.syncRead(ids, STS3215.Present_Position); |
| 473 | 486 | const here = ids.filter((id) => positions.has(id)); |
| 474 | 487 | await bus.syncWrite(STS3215.Goal_Position, here.map((id) => [id, positions.get(id)!] as const)); |
| @@ -713,7 +726,7 @@ export async function startServer(arm: FeetechBus | null, options: ServerOptions | ||
| 713 | 726 | } |
| 714 | 727 | |
| 715 | 728 | const status = async () => { |
| 716 | if (!hasArm) return absentStatus(); | |
| 729 | if (!link.connected) return absentStatus(); | |
| 717 | 730 | // Present_Position (56) through Present_Temperature (63) is one block: one round trip, not one per register. |
| 718 | 731 | const block = await bus.syncReadBlock(ids, PRESENT); |
| 719 | 732 | const positions = new Map([...block].map(([id, [p]]) => [id, p!])); |
| @@ -979,7 +992,7 @@ export async function startServer(arm: FeetechBus | null, options: ServerOptions | ||
| 979 | 992 | writeAtomic(join(calibrationDir, `${kind}.json`), JSON.stringify({ ...body, saved: new Date().toISOString() }, null, 1)); |
| 980 | 993 | return json(res, 200, { stored: kind }); |
| 981 | 994 | } |
| 982 | if (!hasArm && ARM_ROUTES.has(route)) throw new HttpError(503, ARM_ABSENT); | |
| 995 | if (!link.connected && ARM_ROUTES.has(route)) throw new HttpError(503, ARM_ABSENT); | |
| 983 | 996 | switch (route) { |
| 984 | 997 | case "GET /": |
| 985 | 998 | case "GET /index.html": |
| @@ -1449,7 +1462,7 @@ export async function startServer(arm: FeetechBus | null, options: ServerOptions | ||
| 1449 | 1462 | return c; |
| 1450 | 1463 | }; |
| 1451 | 1464 | wss.on("connection", (ws: WebSocket, req?: IncomingMessage) => { |
| 1452 | if (!hasArm) { | |
| 1465 | if (!link.connected) { | |
| 1453 | 1466 | ws.close(NO_ARM_CLOSE, ARM_ABSENT); // 1013, try again later: nothing is driven |
| 1454 | 1467 | return; |
| 1455 | 1468 | } |
| @@ -1873,8 +1886,50 @@ export async function startServer(arm: FeetechBus | null, options: ServerOptions | ||
| 1873 | 1886 | }); |
| 1874 | 1887 | |
| 1875 | 1888 | await new Promise<void>((resolve) => http.listen(options.port ?? 8101, options.host ?? "0.0.0.0", resolve)); |
| 1876 | const gainTimer = hasArm && options.gainSyncMs ? setInterval(() => void gainSync.tick(), options.gainSyncMs) : undefined; | |
| 1877 | if (gainTimer) void gainSync.tick(); | |
| 1889 | // ------------------------------------------------------------------ the arm comes and goes | |
| 1890 | /** The arm's port failed: whatever drives stops and is failed, never resumed; the cameras and the grow box serve on. */ | |
| 1891 | const lose = (lost: FeetechBus) => { | |
| 1892 | if (!link.detach(lost)) return; | |
The abort and the 1013 closes here have no test. Cover them: start a drive stream and a move, fail the bus, and assert the stream closes with 1013 and the move rejects. Also test that an E-stop made while the arm is gone holds it on return. |
||
| 1893 | console.log("arm not connected (its port failed); trying again every 5 s"); | |
| 1894 | current?.abort(); | |
No test fails if this line is removed (the author says so). Add one that isolates the abort, and one for playback/recording. |
||
| 1895 | for (const client of wss.clients) client.close(NO_ARM_CLOSE, ARM_ABSENT); | |
| 1896 | void lost.close().catch(() => undefined); | |
| 1897 | }; | |
| 1898 | const adopt = (found: FeetechBus) => { | |
| 1899 | found.onFail = () => lose(found); | |
| 1900 | if (link.live !== found) { | |
| 1901 | link.attach(found); | |
| 1902 | found.onSyncWrite = onSyncWrite; | |
| 1903 | console.log(`arm connected on ${found.path}`); | |
| 1904 | // A stop in force when the arm comes back holds it where it stands. | |
| 1905 | if (estop || gate.held) void holdHere().catch(() => undefined); | |
| 1906 | } | |
| 1907 | }; | |
| 1908 | if (arm) adopt(arm); | |
| 1909 | let reaching = false; | |
| 1910 | const retryTimer = options.reopen | |
| 1911 | ? setInterval(async () => { | |
| 1912 | if (link.connected || reaching) return; | |
| 1913 | reaching = true; | |
| 1914 | try { | |
| 1915 | const found = await options.reopen!(); | |
| 1916 | if (!found) return; | |
| 1917 | try { | |
| 1918 | await loadLimits(found); | |
| 1919 | } catch (err) { | |
| 1920 | await found.close().catch(() => undefined); | |
| 1921 | throw err; | |
| 1922 | } | |
| 1923 | adopt(found); | |
| 1924 | } catch (err) { | |
| 1925 | console.log(`arm not connected (${err instanceof Error ? err.message : String(err)})`); | |
| 1926 | } finally { | |
| 1927 | reaching = false; | |
| 1928 | } | |
| 1929 | }, options.retryMs ?? 5000) | |
| 1930 | : undefined; | |
| 1931 | const gainTimer = options.gainSyncMs ? setInterval(() => link.connected && void gainSync.tick(), options.gainSyncMs) : undefined; | |
Low: |
||
| 1932 | if (gainTimer && link.connected) void gainSync.tick(); | |
| 1878 | 1933 | const lightTimer = options.lightMs ? setInterval(() => DEVICES.forEach((d) => void lights[d].tick()), options.lightMs) : undefined; |
| 1879 | 1934 | if (options.lightMs) DEVICES.forEach((d) => void lights[d].boot()); |
| 1880 | 1935 | void dosePumps.boot(); |
| @@ -1888,6 +1943,7 @@ export async function startServer(arm: FeetechBus | null, options: ServerOptions | ||
| 1888 | 1943 | new Promise<void>((resolve) => { |
| 1889 | 1944 | clearInterval(watching); |
| 1890 | 1945 | clearInterval(gainTimer); |
| 1946 | clearInterval(retryTimer); | |
| 1891 | 1947 | clearInterval(lightTimer); |
| 1892 | 1948 | clearInterval(sensorTimer); |
| 1893 | 1949 | machine.close(); |
Software/so101/src/stations.test.ts −13
| @@ -3,7 +3,6 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; | ||
| 3 | 3 | import { createServer, type Server } from "node:http"; |
| 4 | 4 | import { tmpdir } from "node:os"; |
| 5 | 5 | import { join } from "node:path"; |
| 6 | import { waitForArm } from "./arm-wait.ts"; | |
| 7 | 6 | import { FeetechBus } from "./bus.ts"; |
| 8 | 7 | import { CameraHub } from "./cameras.ts"; |
| 9 | 8 | import { FakeBus } from "./fake-bus.ts"; |
| @@ -51,18 +50,6 @@ test("the hub keeps its list in a file and starts as the arm and the grow box", | ||
| 51 | 50 | } |
| 52 | 51 | }); |
| 53 | 52 | |
| 54 | test("waitForArm waits through 'no adapter' and returns the port once it is plugged in", async () => { | |
| 55 | let calls = 0; | |
| 56 | const said: string[] = []; | |
| 57 | const port = await waitForArm(async () => { | |
| 58 | if (++calls < 3) throw new Error("no USB serial adapter found — plug in"); | |
| 59 | return "/dev/ttyACM0"; | |
| 60 | }, (t) => said.push(t), async () => {}); | |
| 61 | expect(port).toBe("/dev/ttyACM0"); | |
| 62 | expect(said).toHaveLength(1); | |
| 63 | await expect(waitForArm(async () => { throw new Error("several adapters found"); }, () => {}, async () => {})).rejects.toThrow(/several/); | |
| 64 | }); | |
| 65 | ||
| 66 | 53 | let relay: RunningRelay | undefined; |
| 67 | 54 | let servers: Server[] = []; |
| 68 | 55 | afterEach(async () => { |
Software/so101/src/swap-bus.ts added +42
| @@ -0,0 +1,42 @@ | ||
| 1 | import type { FeetechBus } from "./bus.ts"; | |
| 2 | import { absentBus } from "./absent-bus.ts"; | |
| 3 | ||
| 4 | /** The arm's bus as the server holds it: one stable object whose wire is swapped when the arm is plugged in or lost. */ | |
| 5 | export class SwappedBus { | |
| 6 | /** What the server calls; it forwards to the live bus, or to one that fails every call while there is none. */ | |
| 7 | readonly bus: FeetechBus; | |
| 8 | : FeetechBus | null = null; | |
| 9 | readonly = absentBus("(no arm)"); | |
| 10 | ||
| 11 | constructor() { | |
| 12 | const target = () => this. ?? this.; | |
| 13 | this.bus = new Proxy({} as FeetechBus, { | |
| 14 | get: (_, key) => { | |
| 15 | const t = target(); | |
| 16 | const value = Reflect.get(t, key, t); | |
| 17 | return typeof value === "function" ? value.bind(t) : value; | |
| 18 | }, | |
| 19 | set: (_, key, value) => Reflect.set(target(), key, value, target()), | |
| 20 | }); | |
| 21 | } | |
| 22 | ||
| 23 | get connected(): boolean { | |
| 24 | return this. !== null; | |
| 25 | } | |
| 26 | ||
| 27 | /** The live bus itself, for the one caller that must ask it before it takes over. */ | |
| 28 | get live(): FeetechBus | null { | |
| 29 | return this.; | |
| 30 | } | |
| 31 | ||
| 32 | attach(bus: FeetechBus): void { | |
| 33 | this. = bus; | |
| 34 | } | |
| 35 | ||
| 36 | /** Lets go of `bus` if it is the live one; true when it was. A bus that was already replaced is none of the caller's business. */ | |
| 37 | detach(bus: FeetechBus): boolean { | |
| 38 | if (this. !== bus) return false; | |
| 39 | this. = null; | |
| 40 | return true; | |
| 41 | } | |
| 42 | } |
This comment landed between onSyncWrite's doc comment and onSyncWrite, so that field has lost its doc and two comments stack. Move the new one down to sit directly above onFail.