From 2dd58e5df5a5a38255ed2762aa4328332d0de7b1 Mon Sep 17 00:00:00 2001 From: Travis Bumgarner <11890057+TravisBumgarner@users.noreply.github.com> Date: Tue, 28 Jul 2026 20:31:23 -0600 Subject: [PATCH 1/3] paint-app: let the plotter server hand out the UI as well MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The renderer had to be hosted somewhere, and standing up a second web server next to the one that already owns the serial port is two things to start, two things to keep in sync, and a cross-origin WebSocket to reason about. Serving the built page from this process makes the Pi a single container on a single port. index.html goes out as no-cache since it is the file that names the current asset hashes; everything Vite fingerprints into assets/ goes out immutable. Client-side routes fall through to the shell, but never /api — an unknown endpoint there now answers JSON rather than Express's HTML error page, so a client parsing the reply reports a 404 instead of a syntax error. Two protocol additions the browser client needs: `jog` acks with the board's reply lines, so reading an M114 doesn't take a second round trip, and a new `stream` message carries a batch of interactive stroke moves. `stream` shares jog's allowlist but honours the pause gate — an interactive stroke is exactly the output pause exists to hold back — and takes far more lines, because one freehand stroke is hundreds of them. Co-Authored-By: Claude Opus 5 (1M context) --- paint-app/server/src/hub.ts | 59 ++++++++++++++++++++----- paint-app/server/src/plotter.ts | 2 +- paint-app/server/src/protocol.ts | 13 +++++- paint-app/server/src/server.test.ts | 68 +++++++++++++++++++++++++++++ paint-app/server/src/server.ts | 50 ++++++++++++++++++--- 5 files changed, 171 insertions(+), 21 deletions(-) diff --git a/paint-app/server/src/hub.ts b/paint-app/server/src/hub.ts index 94bffbc..f3a943f 100644 --- a/paint-app/server/src/hub.ts +++ b/paint-app/server/src/hub.ts @@ -26,6 +26,12 @@ const RECENT_LOG_MAX = 200; */ const JOG_ALLOWED = /^(G(0|1|4|21|28|90|91|92)|M(17|18|84|105|114|115|400))\b/i; const MAX_JOG_LINES = 32; +/** + * A `stream` batch is one freehand stroke's worth of moves, which is a few + * hundred `G1`s. The cap is here to bound a single message, not to be a + * meaningful limit on drawing — anything larger belongs in a job. + */ +const MAX_STREAM_LINES = 2000; export type Client = { id: string; @@ -224,6 +230,28 @@ export class Hub { // -------------------------------------------------------------- commands + /** + * The one hole through which a client can put arbitrary text on the wire. + * Every line has to be a single movement or status command from the + * allowlist; anything structural belongs in a job, where the line count is + * fixed up front and progress can be reported against it. + */ + private validateGcode(raw: unknown, max: number): string[] { + const lines = Array.isArray(raw) ? raw : []; + if (lines.length === 0 || lines.length > max) { + throw new Error(`Send between 1 and ${max} lines.`); + } + for (const line of lines) { + if (typeof line !== 'string' || /[\r\n]/.test(line)) { + throw new Error('Invalid G-code line.'); + } + if (!JOG_ALLOWED.test(line.trim())) { + throw new Error(`Not allowed outside a job: ${line}`); + } + } + return lines as string[]; + } + private requireControl(clientId: string) { if (this.controllerId !== clientId) { throw new Error('Read-only: another client is controlling this plotter. Take control first.'); @@ -349,19 +377,28 @@ export class Hub { if (this.runner.view?.state === 'running') { throw new Error('Cannot jog while a job is running. Pause it first.'); } - const lines = Array.isArray(msg.lines) ? msg.lines : []; - if (lines.length === 0 || lines.length > MAX_JOG_LINES) { - throw new Error(`Send between 1 and ${MAX_JOG_LINES} lines.`); - } + const lines = this.validateGcode(msg.lines, MAX_JOG_LINES); + // The reply lines come back so the client can read an `M114` or an + // `M115` banner without a second round trip. + const replies: string[] = []; for (const line of lines) { - if (typeof line !== 'string' || /[\r\n]/.test(line)) { - throw new Error('Invalid G-code line.'); - } - if (!JOG_ALLOWED.test(line.trim())) { - throw new Error(`Not allowed outside a job: ${line}`); - } + replies.push(...(await this.connection.send(line, { bypassPause: true }))); + } + return replies; + } + + case 'stream': { + this.requireControl(client.id); + // Interactive strokes and a job are two things drawing on the same + // page at once. The job wins; the stroke is refused rather than + // interleaved. + if (this.runner.isActive) { + throw new Error('Cannot draw interactively while a job is loaded. Cancel it first.'); } - await this.connection.sendMany(lines, { bypassPause: true }); + const lines = this.validateGcode(msg.lines, MAX_STREAM_LINES); + // No bypassPause: an interactive stroke is exactly the output that + // Pause exists to hold back. + await this.connection.sendMany(lines); return undefined; } diff --git a/paint-app/server/src/plotter.ts b/paint-app/server/src/plotter.ts index a87b317..3ecc9cc 100644 --- a/paint-app/server/src/plotter.ts +++ b/paint-app/server/src/plotter.ts @@ -58,7 +58,7 @@ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); /** * A serial link to a Marlin board, ported from the browser's Web Serial - * implementation (`paint-app/src/serial.ts`). + * implementation — `paint-app/src/serial.ts`, now deleted; see git history. * * Differences from the browser original, all forced by being a shared server * rather than a single page: diff --git a/paint-app/server/src/protocol.ts b/paint-app/server/src/protocol.ts index 05d5fa2..7a0f128 100644 --- a/paint-app/server/src/protocol.ts +++ b/paint-app/server/src/protocol.ts @@ -1,8 +1,10 @@ /** * The wire contract between the plotter backend and its clients. * - * This file is deliberately dependency-free so the React client can import it - * verbatim once it is ported off Web Serial. + * This file is deliberately dependency-free, and the React client imports it + * directly (`paint-app/src/plotterClient.ts`) rather than keeping a parallel + * copy — a protocol that can drift between the two halves of an emergency stop + * is not one worth having. */ export type LogKind = 'tx' | 'rx' | 'info' | 'err'; @@ -123,7 +125,14 @@ export type ClientMessage = | { id?: string; type: 'job.pause' } | { id?: string; type: 'job.resume' } | { id?: string; type: 'job.cancel' } + /** Operator-driven moves. Bypasses the pause gate; acks with the reply lines. */ | { id?: string; type: 'jog'; lines: string[] } + /** + * A batch of movement G-code from a live drawing session. Same allowlist as + * `jog` and the same one-controller rule, but it honours the pause gate and + * takes far more lines, because a single freehand stroke is hundreds of them. + */ + | { id?: string; type: 'stream'; lines: string[] } | { id?: string; type: 'position' } /** Never gated on control, never queued behind a job. See `estop` in the README. */ | { id?: string; type: 'estop' }; diff --git a/paint-app/server/src/server.test.ts b/paint-app/server/src/server.test.ts index a496a8e..a2de8e8 100644 --- a/paint-app/server/src/server.test.ts +++ b/paint-app/server/src/server.test.ts @@ -1,5 +1,8 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { createServer, type Server } from 'node:http'; import type { AddressInfo } from 'node:net'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { WebSocket } from 'ws'; import { loadConfig } from './config.js'; @@ -192,3 +195,68 @@ describe('http + websocket', () => { client.close(); }); }); + +/** + * The Pi hands out the UI and the API from one process. There is no second web + * server, no nginx, and nothing to keep in sync — which only holds if the SPA's + * client-side routes fall through to the shell without swallowing the API. + */ +describe('serving the renderer', () => { + let hub: Hub; + let server: Server; + let base: string; + let dir: string; + + beforeEach(async () => { + dir = await mkdtemp(path.join(tmpdir(), 'plotter-client-')); + await mkdir(path.join(dir, 'assets')); + await writeFile(path.join(dir, 'index.html'), 'paint-app'); + await writeFile(path.join(dir, 'assets', 'index-abc123.js'), 'console.log(1)'); + + hub = new Hub({ connection: { transport: new FakeMarlin().transport, timing: FAST_TIMING } }); + server = createServer( + buildApp({ + hub, + config: loadConfig({ PORT: '0', CLIENT_DIR: dir } as NodeJS.ProcessEnv), + version: 'test', + ports: async () => [], + }), + ); + await new Promise((r) => server.listen(0, '127.0.0.1', r)); + base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + }); + + afterEach(async () => { + hub.dispose(); + await new Promise((r) => server.close(() => r())); + await rm(dir, { recursive: true, force: true }); + }); + + it('serves the shell at the root', async () => { + const res = await fetch(`${base}/`); + expect(res.status).toBe(200); + expect(await res.text()).toContain('paint-app'); + // The file that names the current asset hashes must never be cached, or a + // deploy leaves the Pi handing out a blank page. + expect(res.headers.get('cache-control')).toBe('no-cache'); + }); + + it('serves fingerprinted assets as immutable', async () => { + const res = await fetch(`${base}/assets/index-abc123.js`); + expect(res.status).toBe(200); + expect(res.headers.get('cache-control')).toMatch(/immutable/); + }); + + it('falls a deep link through to the shell', async () => { + const res = await fetch(`${base}/projects/some-id/page/2`); + expect(res.status).toBe(200); + expect(await res.text()).toContain('paint-app'); + }); + + it('still answers the API, and does not hand HTML to a bad endpoint', async () => { + expect((await fetch(`${base}/api/health`)).status).toBe(200); + const missing = await fetch(`${base}/api/nope`); + expect(missing.status).toBe(404); + expect(missing.headers.get('content-type') ?? '').not.toMatch(/text\/html/); + }); +}); diff --git a/paint-app/server/src/server.ts b/paint-app/server/src/server.ts index 0a6732c..ff57d9b 100644 --- a/paint-app/server/src/server.ts +++ b/paint-app/server/src/server.ts @@ -1,4 +1,5 @@ import type { Server } from 'node:http'; +import path from 'node:path'; import express, { type Express } from 'express'; import { WebSocket, WebSocketServer } from 'ws'; import type { Config } from './config.js'; @@ -106,17 +107,52 @@ export function buildApp({ hub, config, version, ports = listPorts }: BuildOptio } }); - if (config.CLIENT_DIR) { - app.use(express.static(config.CLIENT_DIR)); - // SPA fallback, but never for the API. - app.get(/^(?!\/api\/).*/, (_req, res) => { - res.sendFile('index.html', { root: config.CLIENT_DIR }); - }); - } + // An unknown endpoint under /api answers as an API, not as Express's default + // HTML error page — otherwise a client parsing the reply as JSON reports a + // syntax error instead of a 404. + app.use('/api', (_req, res) => { + res.status(404).json({ error: 'No such endpoint' }); + }); + + if (config.CLIENT_DIR) serveClient(app, config.CLIENT_DIR); return app; } +/** + * Serve the built renderer from the same process and the same origin as the + * API. One container, one port, one thing to start on the Pi — and no CORS or + * mixed-origin WebSocket question to answer, because the page is served by the + * server it talks to. + */ +function serveClient(app: Express, dir: string) { + app.use( + express.static(dir, { + // Vite fingerprints everything it emits into `assets/`, so those may be + // cached forever. `index.html` must not be: it is the file that names the + // current fingerprints, and a Pi handing out a stale one serves a blank + // page after every deploy. + index: false, + setHeaders: (res, filePath) => { + const fingerprinted = filePath.includes(`${path.sep}assets${path.sep}`); + res.setHeader( + 'Cache-Control', + fingerprinted ? 'public, max-age=31536000, immutable' : 'no-cache', + ); + }, + }), + ); + + // SPA fallback: client-side routes are not files on disk, so anything that + // isn't the API and isn't a static asset gets the shell. Scoped away from + // /api so a typo'd endpoint 404s as JSON-ish rather than silently returning + // an HTML page that the client then fails to parse. + app.get(/^(?!\/api(\/|$)).*/, (_req, res) => { + res.setHeader('Cache-Control', 'no-cache'); + res.sendFile('index.html', { root: dir }); + }); +} + /** Attach the session WebSocket to an already-listening HTTP server. */ export function attachWebSocket(server: Server, hub: Hub) { const wss = new WebSocketServer({ server, path: '/ws' }); From 0bae853cae854509daa119b9817d3a88fc1c276e Mon Sep 17 00:00:00 2001 From: Travis Bumgarner <11890057+TravisBumgarner@users.noreply.github.com> Date: Tue, 28 Jul 2026 20:31:38 -0600 Subject: [PATCH 2/3] paint-app: drive the plotter over the network, not Web Serial MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Web Serial puts the port in the browser, so the browser had to be on the machine holding the USB cable — one laptop, tethered, for the length of a print. The plotter is going onto a Raspberry Pi, so the client now talks to the server that owns the port. `serial.ts` is replaced by `plotterClient.ts`, which keeps the same shape where it can — connect / disconnect / send / sendMany / pause / resume / emergencyStop / getPosition, with log and lost callbacks — so App.tsx did not have to change at all. What genuinely differs: - A print is uploaded whole and run by the server. G-code never crosses the network a line at a time; only progress comes back. - The pen swap between layers is server state, not a Promise parked in a React component, so closing the tab no longer orphans a half-drawn page and the swap prompt shows up on every device. - `navigator.serial.requestPort()` was a browser-native chooser; the port list is now fetched from the server, which is the only place that can see the cable. - One client controls, the rest observe. The top bar says which you are, because otherwise the first surprise is a Pause button that does nothing. Handover is unconditional and can land mid-print, so it asks first. Emergency stop deliberately does not go through the command socket. POST /api/estop is a bare request the server answers without consulting the control lock, the write queue, the pause gate, or the running job, and it is offered to every client including read-only ones — a safety control you have to request permission to use is not one. The client tests assert that by firing it from an observer that cannot send a single G-code line by the ordinary route, by firing it with the session socket shut, and by wall clock with a two-second move in flight. Each fails if the bypass is removed. The client test lives in the server package because that is where the fake Marlin and the real HTTP server are; a client tested only against a mock of its own server proves nothing. Co-Authored-By: Claude Opus 5 (1M context) --- paint-app/server/src/client.test.ts | 279 +++++++++++++ paint-app/server/tsconfig.build.json | 6 +- paint-app/server/tsconfig.json | 7 +- paint-app/src/components/DebugModal.tsx | 18 +- .../src/components/PlotterCalibration.tsx | 29 +- paint-app/src/components/PrintModal.tsx | 297 ++++++++------ paint-app/src/components/ProjectGate.tsx | 39 +- paint-app/src/components/SerialPortRow.tsx | 144 +++++++ paint-app/src/components/Toolbar.tsx | 153 ++++++- paint-app/src/connection.tsx | 331 ++++++++++++--- paint-app/src/gcode.ts | 2 +- paint-app/src/interactive.tsx | 23 +- paint-app/src/plotterClient.ts | 387 ++++++++++++++++++ paint-app/src/serial.ts | 322 --------------- 14 files changed, 1424 insertions(+), 613 deletions(-) create mode 100644 paint-app/server/src/client.test.ts create mode 100644 paint-app/src/components/SerialPortRow.tsx create mode 100644 paint-app/src/plotterClient.ts delete mode 100644 paint-app/src/serial.ts diff --git a/paint-app/server/src/client.test.ts b/paint-app/server/src/client.test.ts new file mode 100644 index 0000000..2484cb6 --- /dev/null +++ b/paint-app/server/src/client.test.ts @@ -0,0 +1,279 @@ +import { createServer, type Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +// The renderer's API client, exercised against the same scripted Marlin the +// server is tested with. It lives in `paint-app/src` because it runs in the +// browser; the test lives here because this is where the harness is, and a +// client tested only against a mock of its own server proves nothing. +import { PlotterClient } from '../../src/plotterClient.js'; +import { loadConfig } from './config.js'; +import { Hub } from './hub.js'; +import { attachWebSocket, buildApp } from './server.js'; +import { FAST_TIMING, FakeMarlin } from './test/fakeMarlin.js'; + +const PORT_PATH = '/dev/serial/by-id/usb-1a86_USB_Serial-if00-port0'; + +const samplePage = { + name: 'page 1', + plotterName: 'Ender-3 V3 SE', + prologue: ['; plotter: Ender-3 V3 SE', 'G21 ; mm', 'G90 ; absolute'], + layers: [ + { name: 'Ink', color: '#000000', lines: ['G0 X1 Y1', 'G1 X2 Y2'] }, + { name: 'Red', color: '#ff0000', lines: ['G0 X3 Y3', 'G1 X4 Y4'] }, + ], + epilogue: ['M84 ; disable steppers'], +}; + +const waitFor = async (pred: () => boolean, ms = 3000) => { + const deadline = Date.now() + ms; + while (!pred()) { + if (Date.now() > deadline) throw new Error('timed out waiting for condition'); + await new Promise((r) => setTimeout(r, 2)); + } +}; + +describe('the React client against a real server and a scripted board', () => { + let hub: Hub; + let server: Server; + let board: FakeMarlin; + let base: string; + const clients: PlotterClient[] = []; + + const join = async (name: string) => { + const client = new PlotterClient({ baseUrl: base, name }); + clients.push(client); + client.start(); + await waitFor(() => client.clientId !== null); + return client; + }; + + const start = async (opts?: ConstructorParameters[0]) => { + board = new FakeMarlin(opts); + hub = new Hub({ connection: { transport: board.transport, timing: FAST_TIMING } }); + server = createServer( + buildApp({ + hub, + config: loadConfig({ PORT: '0' } as NodeJS.ProcessEnv), + version: 'test', + ports: async () => [ + { + path: PORT_PATH, + device: '/dev/ttyUSB0', + label: '1a86 USB Serial (/dev/ttyUSB0)', + vendorId: '1a86', + known: true, + }, + ], + }), + ); + attachWebSocket(server, hub); + await new Promise((r) => server.listen(0, '127.0.0.1', r)); + base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + }; + + beforeEach(() => start()); + + afterEach(async () => { + for (const c of clients) c.stop(); + clients.length = 0; + hub.dispose(); + await new Promise((r) => server.close(() => r())); + }); + + it('gets its port list from the server, not from the browser', async () => { + const client = await join('laptop'); + const ports = await client.listPorts(); + expect(ports).toHaveLength(1); + expect(ports[0].path).toBe(PORT_PATH); + expect(ports[0].known).toBe(true); + }); + + it('connects, homes, and reports the port the server holds', async () => { + const client = await join('laptop'); + await client.connect(PORT_PATH); + + expect(board.written).toContain('M115'); + expect(board.written).toContain('G28'); + const { connection } = await client.snapshot(); + expect(connection.connected).toBe(true); + expect(connection.portPath).toBe(PORT_PATH); + }); + + it('uploads a page whole and watches the server run it', async () => { + // The G-code crosses the network once. Everything after that is the + // server telling us where it got to. + const client = await join('laptop'); + const states: string[] = []; + const observer = new PlotterClient({ + baseUrl: base, + name: 'phone', + onJob: (job) => { + if (job) states.push(job.state); + }, + }); + clients.push(observer); + observer.start(); + await waitFor(() => observer.clientId !== null); + + await client.connect(PORT_PATH); + board.writes.length = 0; + + const job = await client.uploadJob(samplePage); + // Eight lines counted, seven on the wire: the comment-only prologue line + // is stripped by the send loop, not by the upload. + expect(job.totalLines).toBe(8); + await client.startJob(job.id); + + // The pen swap is server state; the phone sees it without having started + // the print, which is the point. + await waitFor(() => states.includes('awaiting_pen_swap')); + await client.continueJob(); + await hub.runner.settled(); + await waitFor(() => states.includes('done')); + + expect(board.written).toEqual([ + 'G21', + 'G90', + 'G0 X1 Y1', + 'G1 X2 Y2', + 'G0 X3 Y3', + 'G1 X4 Y4', + 'M84', + ]); + }); + + it('sends an interactive stroke as one batch and drops the comments', async () => { + const client = await join('laptop'); + await client.connect(PORT_PATH); + board.writes.length = 0; + + await client.sendMany([ + '; plotter: Ender-3 V3 SE', + 'G21 ; mm', + 'G90', + 'G0 X5 Y5 F6000', + 'G1 X6 Y6 F3000', + ]); + + expect(board.written).toEqual(['G21', 'G90', 'G0 X5 Y5 F6000', 'G1 X6 Y6 F3000']); + }); + + it('reads a position back through the jog channel', async () => { + const client = await join('laptop'); + await client.connect(PORT_PATH); + expect(await client.getPosition()).toEqual({ x: 10, y: 20, z: 5 }); + }); + + it('refuses to move the machine from a client that does not hold control', async () => { + const controller = await join('laptop'); + const observer = await join('phone'); + await controller.connect(PORT_PATH); + + await expect(observer.send('G28')).rejects.toThrow(/Read-only/); + // …and says who has it, so the UI can name a name. + const { session } = await observer.snapshot(); + expect(session.clients.find((c) => c.controller)?.name).toBe('laptop'); + }); +}); + +/** + * The emergency stop is the one path where "it works" is not enough — it has to + * work while everything else is busy, blocked, or forbidden. Each test below + * removes one of the things a normal command depends on. + */ +describe('emergency stop bypasses the command path', () => { + let hub: Hub; + let server: Server; + let board: FakeMarlin; + let base: string; + const clients: PlotterClient[] = []; + + const boot = async (opts?: ConstructorParameters[0]) => { + board = new FakeMarlin(opts); + hub = new Hub({ connection: { transport: board.transport, timing: FAST_TIMING } }); + server = createServer( + buildApp({ hub, config: loadConfig({ PORT: '0' } as NodeJS.ProcessEnv), version: 'test' }), + ); + attachWebSocket(server, hub); + await new Promise((r) => server.listen(0, '127.0.0.1', r)); + base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + }; + + const join = async (name: string) => { + const client = new PlotterClient({ baseUrl: base, name }); + clients.push(client); + client.start(); + await waitFor(() => client.clientId !== null); + return client; + }; + + afterEach(async () => { + for (const c of clients) c.stop(); + clients.length = 0; + hub.dispose(); + await new Promise((r) => server.close(() => r())); + }); + + it('fires from a read-only client that is not allowed to move the machine', async () => { + await boot(); + const controller = await join('laptop'); + const observer = await join('phone'); + await controller.connect(PORT_PATH); + board.writes.length = 0; + + // The same client, on the ordinary path, cannot send a single G-code line. + await expect(observer.send('G28')).rejects.toThrow(/Read-only/); + await observer.emergencyStop(); + + expect(board.written).toContain('M112'); + expect(board.killed).toBe(true); + }); + + it('fires with the session socket shut', async () => { + // If the stop needed the WebSocket, a wedged or closed socket — exactly + // when you most want to stop the machine — would take it away. + await boot(); + const client = await join('laptop'); + await client.connect(PORT_PATH); + board.writes.length = 0; + + client.stop(); + await client.emergencyStop(); + + expect(board.written).toContain('M112'); + }); + + it('lands promptly with a long move in flight, and kills the job with it', async () => { + // A `G1` that takes two seconds is an ordinary drawing move at a slow + // feed rate. If M112 waited its turn behind the write queue, or behind the + // `ok` of whatever is in flight, this is where that would show up. + await boot({ busy: { 'G1 X': 2000 } }); + const client = await join('laptop'); + await client.connect(PORT_PATH); + + const job = await client.uploadJob({ + name: 'slow page', + plotterName: 'Ender-3 V3 SE', + prologue: [], + layers: [{ name: 'Ink', color: '#000', lines: ['G1 X1 Y1', 'G1 X2 Y2', 'G1 X3 Y3'] }], + epilogue: [], + }); + await client.startJob(job.id); + await waitFor(() => board.written.includes('G1 X1 Y1')); + + const firedAt = Date.now(); + await client.emergencyStop(); + const landedAt = board.writes.find((w) => w.line === 'M112')?.at; + + expect(landedAt).toBeDefined(); + // Generous next to the 2000ms move it jumped, tight enough that queueing + // behind it fails. + expect((landedAt as number) - firedAt).toBeLessThan(300); + + // Nothing more goes to a board that is no longer listening, and the job + // gives up now rather than after a 500ms idle timeout per remaining line. + await hub.runner.settled(); + expect(hub.runner.view?.state).toBe('failed'); + expect(board.written.filter((l) => l.startsWith('G1'))).toEqual(['G1 X1 Y1']); + }); +}); diff --git a/paint-app/server/tsconfig.build.json b/paint-app/server/tsconfig.build.json index fc94193..d09c145 100644 --- a/paint-app/server/tsconfig.build.json +++ b/paint-app/server/tsconfig.build.json @@ -1,7 +1,11 @@ { "extends": "./tsconfig.json", "compilerOptions": { - "noEmit": false + "noEmit": false, + // Only the build pins rootDir. The typecheck config deliberately does not: + // `client.test.ts` reaches into the renderer's source to exercise the API + // client against this server, and that file lives above `src/`. + "rootDir": "src" }, "exclude": ["src/**/*.test.ts", "src/test/**"] } diff --git a/paint-app/server/tsconfig.json b/paint-app/server/tsconfig.json index 1492c35..c2ecf10 100644 --- a/paint-app/server/tsconfig.json +++ b/paint-app/server/tsconfig.json @@ -5,7 +5,10 @@ "module": "NodeNext", "moduleResolution": "NodeNext", "outDir": "dist", - "rootDir": "src", + // Wide enough to reach the renderer's API client, which `client.test.ts` + // exercises against this server. `tsconfig.build.json` narrows it back to + // `src` for the emit. + "rootDir": "..", "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, @@ -17,5 +20,5 @@ "noEmit": true, "types": ["node"] }, - "include": ["src/**/*.ts"] + "include": ["src/**/*.ts", "../src/plotterClient.ts"] } diff --git a/paint-app/src/components/DebugModal.tsx b/paint-app/src/components/DebugModal.tsx index e339575..002b7e6 100644 --- a/paint-app/src/components/DebugModal.tsx +++ b/paint-app/src/components/DebugModal.tsx @@ -33,7 +33,7 @@ const JOG_STEPS = [0.1, 1, 10]; * up and responding before committing to a full print. */ export const DebugModal = ({ onClose }: Props) => { - const { connection } = useConnection(); + const { client } = useConnection(); const { state } = useStore(); const { getPlotter } = usePlotters(); const plotter = getPlotter(state.plotterId) ?? null; @@ -47,12 +47,12 @@ export const DebugModal = ({ onClose }: Props) => { const refreshPosition = async () => { try { - const p = await connection.getPosition(); + const p = await client.getPosition(); if (p) setPosition(p); } catch {} }; - // biome-ignore lint/correctness/useExhaustiveDependencies: connection is stable; fetch once on open + // biome-ignore lint/correctness/useExhaustiveDependencies: the client is stable; fetch once on open useEffect(() => { refreshPosition(); }, []); @@ -79,13 +79,13 @@ export const DebugModal = ({ onClose }: Props) => { } }; - const send = (label: string, gcode: string) => run(label, () => connection.send(gcode)); + const send = (label: string, gcode: string) => run(label, () => client.send(gcode)); const jog = (axis: 'X' | 'Y', dir: 1 | -1) => run(`Jog ${axis}${dir > 0 ? '+' : '-'}${step}`, async () => { - await connection.send('G91'); - const out = await connection.send(`G0 ${axis}${dir * step}`); - await connection.send('G90'); + await client.send('G91'); + const out = await client.send(`G0 ${axis}${dir * step}`); + await client.send('G90'); return out; }); @@ -95,8 +95,8 @@ export const DebugModal = ({ onClose }: Props) => { return; } run(label, async () => { - await connection.send('G90'); - return connection.send(`G0 ${axis}${value} F${plotter.travelFeed}`); + await client.send('G90'); + return client.send(`G0 ${axis}${value} F${plotter.travelFeed}`); }); }; diff --git a/paint-app/src/components/PlotterCalibration.tsx b/paint-app/src/components/PlotterCalibration.tsx index d2126ab..3006a4f 100644 --- a/paint-app/src/components/PlotterCalibration.tsx +++ b/paint-app/src/components/PlotterCalibration.tsx @@ -83,7 +83,7 @@ const STEP_OPTIONS = [0.1, 1, 10]; type Props = { onCreated: () => void }; export const PlotterCalibration = ({ onCreated }: Props) => { - const { connection, connected, connect } = useConnection(); + const { client, connected } = useConnection(); const { createPlotter } = usePlotters(); const [stepIdx, setStepIdx] = useState(0); @@ -113,7 +113,7 @@ export const PlotterCalibration = ({ onCreated }: Props) => { if (!connected) return; setBusy(true); try { - const p = await connection.getPosition(); + const p = await client.getPosition(); if (p) setPosition(p); } finally { setBusy(false); @@ -124,10 +124,10 @@ export const PlotterCalibration = ({ onCreated }: Props) => { if (!connected) return; setBusy(true); try { - await connection.send('G91'); - await connection.send(`G1 ${axis}${delta} F${travelFeed}`); - await connection.send('G90'); - const p = await connection.getPosition(); + await client.send('G91'); + await client.send(`G1 ${axis}${delta} F${travelFeed}`); + await client.send('G90'); + const p = await client.getPosition(); if (p) setPosition(p); } finally { setBusy(false); @@ -138,8 +138,8 @@ export const PlotterCalibration = ({ onCreated }: Props) => { if (!connected) return; setBusy(true); try { - await connection.send('G28'); - const p = await connection.getPosition(); + await client.send('G28'); + const p = await client.getPosition(); if (p) setPosition(p); } finally { setBusy(false); @@ -150,7 +150,7 @@ export const PlotterCalibration = ({ onCreated }: Props) => { if (!connected) return; setBusy(true); try { - const p = (await connection.getPosition()) ?? position; + const p = (await client.getPosition()) ?? position; if (!p) return; setPosition(p); setCaptured((prev) => { @@ -210,16 +210,7 @@ export const PlotterCalibration = ({ onCreated }: Props) => { return ( {!connected && ( - - Connect - - } - > - Connect to the plotter to calibrate. - + Connect to the plotter from the home screen to calibrate. )} diff --git a/paint-app/src/components/PrintModal.tsx b/paint-app/src/components/PrintModal.tsx index b9314fe..c94c0b7 100644 --- a/paint-app/src/components/PrintModal.tsx +++ b/paint-app/src/components/PrintModal.tsx @@ -1,139 +1,168 @@ import { + Alert, Box, Button, - CircularProgress, Dialog, DialogActions, DialogContent, DialogTitle, + LinearProgress, Typography, } from '@mui/material'; import { useEffect, useRef, useState } from 'react'; import { useConnection } from '../connection'; import { buildLayerPrograms, EPILOGUE, PROLOGUE } from '../gcode'; import { usePlotters } from '../plotters'; +import { useProject } from '../project'; import { useStore } from '../store'; type Props = { onClose: () => void; }; -type Phase = - | { kind: 'idle' } - | { kind: 'running'; layerIdx: number; layerName: string; color: string } - | { kind: 'paused'; nextLayerIdx: number; nextLayerName: string; nextColor: string } - | { kind: 'done' } - | { kind: 'error'; message: string }; +const Swatch = ({ color }: { color: string }) => ( + +); +/** The pen swap can happen while you are in another room. Make a noise. */ +const playChime = () => { + try { + const ctx = new ( + window.AudioContext || + (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext + )(); + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.connect(gain); + gain.connect(ctx.destination); + osc.frequency.value = 880; + gain.gain.setValueAtTime(0.0001, ctx.currentTime); + gain.gain.exponentialRampToValueAtTime(0.2, ctx.currentTime + 0.02); + gain.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + 0.6); + osc.start(); + osc.stop(ctx.currentTime + 0.6); + } catch {} +}; + +/** + * Starting a print used to mean this component sat in a loop feeding lines to + * the serial port and parking on a `Promise` at each pen swap — close the tab + * and the page was orphaned half-drawn. Now the whole job is uploaded once, the + * server runs it next to the cable, and this is a view of that job's state. Any + * device in the session sees the same thing, including the swap prompt. + */ export const PrintModal = ({ onClose }: Props) => { const { state } = useStore(); const { getPlotter } = usePlotters(); - const { connection } = useConnection(); + const { project } = useProject(); + const { client, job, isController, controllerName, connected, paused } = useConnection(); const { pages, layers, activePageId, plotterId } = state; const activePage = pages.find((p) => p.id === activePageId); const plotter = getPlotter(plotterId) ?? null; const programs = activePage && plotter ? buildLayerPrograms(layers, activePage, plotter) : []; - const [phase, setPhase] = useState({ kind: 'idle' }); - const cancelled = useRef(false); - const resumeRef = useRef<(() => void) | null>(null); + const [jobId, setJobId] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); - useEffect(() => { - return () => { - cancelled.current = true; - resumeRef.current?.(); - }; - }, []); + // The job we started, as opposed to one another client left lying around. + const ours = jobId && job?.job.id === jobId ? job : null; + const otherJobRunning = + !ours && job !== null && (job.state === 'running' || job.state === 'awaiting_pen_swap'); - const playChime = () => { - try { - const ctx = new ( - window.AudioContext || - (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext - )(); - const osc = ctx.createOscillator(); - const gain = ctx.createGain(); - osc.connect(gain); - gain.connect(ctx.destination); - osc.frequency.value = 880; - gain.gain.setValueAtTime(0.0001, ctx.currentTime); - gain.gain.exponentialRampToValueAtTime(0.2, ctx.currentTime + 0.02); - gain.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + 0.6); - osc.start(); - osc.stop(ctx.currentTime + 0.6); - } catch {} - }; - - const start = async () => { - if (!plotter) { - setPhase({ kind: 'error', message: 'No plotter selected for this project.' }); - return; - } - if (!activePage || programs.length === 0) { - setPhase({ kind: 'error', message: 'Nothing to print on the active page.' }); - return; + const lastState = useRef(null); + useEffect(() => { + if (ours?.state === 'awaiting_pen_swap' && lastState.current !== 'awaiting_pen_swap') { + playChime(); } - cancelled.current = false; + lastState.current = ours?.state ?? null; + }, [ours?.state]); + + const guard = async (fn: () => Promise) => { + setBusy(true); + setError(null); try { - await connection.sendMany(PROLOGUE(plotter)); - for (let i = 0; i < programs.length; i++) { - if (cancelled.current) return; - const program = programs[i]; - setPhase({ - kind: 'running', - layerIdx: i, - layerName: program.layerName, - color: program.color, - }); - await connection.sendMany(program.lines); - if (cancelled.current) return; - if (i < programs.length - 1) { - const next = programs[i + 1]; - playChime(); - setPhase({ - kind: 'paused', - nextLayerIdx: i + 1, - nextLayerName: next.layerName, - nextColor: next.color, - }); - await new Promise((resolve) => { - resumeRef.current = resolve; - }); - resumeRef.current = null; - if (cancelled.current) return; - } - } - await connection.sendMany(EPILOGUE(plotter)); - setPhase({ kind: 'done' }); + await fn(); } catch (e) { - setPhase({ kind: 'error', message: (e as Error).message }); + setError((e as Error).message); + } finally { + setBusy(false); } }; + const start = () => + guard(async () => { + if (!plotter) throw new Error('No plotter selected for this project.'); + if (!activePage || programs.length === 0) { + throw new Error('Nothing to print on the active page.'); + } + const summary = await client.uploadJob({ + name: project?.name ?? 'Untitled page', + plotterName: plotter.name, + prologue: PROLOGUE(plotter), + layers: programs.map((p) => ({ name: p.layerName, color: p.color, lines: p.lines })), + epilogue: EPILOGUE(plotter), + }); + setJobId(summary.id); + await client.startJob(summary.id); + }); + + const progress = ours?.progress ?? null; + const percent = + progress && progress.totalLines > 0 + ? Math.round((progress.sentLines / progress.totalLines) * 100) + : 0; + + const idle = !ours; + const finished = + ours?.state === 'done' || ours?.state === 'failed' || ours?.state === 'cancelled'; + const active = ours?.state === 'running' || ours?.state === 'paused'; + const swapping = ours?.state === 'awaiting_pen_swap'; + return ( - + Print - {phase.kind === 'idle' && ( + {!isController && ( + + {controllerName ? `${controllerName} has control.` : 'Another client has control.'} You + can watch this print but not start or change one. + + )} + {error && ( + + {error} + + )} + + {idle && ( <> + {otherJobRunning && ( + + The plotter is already running “{job?.job.name}”. Starting another will be refused + until it finishes or is cancelled. + + )} Plotter: {plotter?.name ?? '— none —'} About to print {programs.length} visible layer - {programs.length === 1 ? '' : 's'} on the active page. + {programs.length === 1 ? '' : 's'} on the active page. The whole job is uploaded to + the plotter server, which runs it — you can close this tab once it starts. {programs.map((p) => ( - + {p.layerName} @@ -143,78 +172,82 @@ export const PrintModal = ({ onClose }: Props) => { ))} )} - {phase.kind === 'running' && ( - - - - Printing {phase.layerName} + + {ours && !finished && progress && ( + <> + + + + {swapping ? 'Waiting at' : 'Printing'} {progress.layerName} + + + + layer {progress.layerIndex + 1}/{progress.layerCount} + + + + + {progress.sentLines} / {progress.totalLines} lines ({percent}%) + {paused && ours.state === 'paused' && ' · paused'} - - + )} - {phase.kind === 'paused' && ( - <> + + {swapping && ours?.nextLayer && ( + Swap pen - Insert pen for {phase.nextLayerName} + Insert pen for {ours.nextLayer.name} - + - + + The plotter is holding position with the pen up. It will wait as long as it takes. + + + )} + + {ours?.state === 'done' && Done.} + {ours?.state === 'cancelled' && Cancelled.} + {ours?.state === 'failed' && ( + Failed: {ours.error ?? 'unknown error'} )} - {phase.kind === 'done' && Done.} - {phase.kind === 'error' && Error: {phase.message}} - {phase.kind === 'idle' && ( + {idle && ( <> )} - {phase.kind === 'paused' && ( + {(active || swapping) && ( <> - + {swapping && ( + + )} )} - {(phase.kind === 'done' || phase.kind === 'error') && ( - - )} + {finished && } ); diff --git a/paint-app/src/components/ProjectGate.tsx b/paint-app/src/components/ProjectGate.tsx index 0df9eab..f41bdbb 100644 --- a/paint-app/src/components/ProjectGate.tsx +++ b/paint-app/src/components/ProjectGate.tsx @@ -35,6 +35,7 @@ import { INTERACTIVE_PROJECT_ID, useProject } from './../project'; import { createInitialState } from '../store'; import { type AppState, AppStateSchema, LegacyAppStateSchema } from '../types'; import { PlottersModal } from './PlottersModal'; +import { SerialPortRow } from './SerialPortRow'; import { SettingsMenu } from './SettingsMenu'; const uid = () => Math.random().toString(36).slice(2, 10); @@ -70,15 +71,7 @@ const migrateState = (raw: unknown): { state: AppState; plotterDraft?: PlotterDr export const ProjectGate = () => { const { setProject } = useProject(); const { plotters, ready: plottersReady, createPlotter } = usePlotters(); - const { - connected, - connecting, - connectPhase, - connect, - disconnect, - connectError, - dismissConnectError, - } = useConnection(); + const { connected, connectError, dismissConnectError } = useConnection(); const [projects, setProjects] = useState(null); const [name, setName] = useState(''); const [pickedPlotterId, setPickedPlotterId] = useState(''); @@ -204,33 +197,9 @@ export const ProjectGate = () => { > {plotters.length === 0 ? 'Create plotter' : 'Manage…'} - {connected ? ( - - ) : ( - - )} - {connected && ( - - Connected — homed and ready. - - )} + + )} diff --git a/paint-app/src/components/SerialPortRow.tsx b/paint-app/src/components/SerialPortRow.tsx new file mode 100644 index 0000000..b971be0 --- /dev/null +++ b/paint-app/src/components/SerialPortRow.tsx @@ -0,0 +1,144 @@ +import RefreshIcon from '@mui/icons-material/Refresh'; +import { + Alert, + Box, + Button, + CircularProgress, + FormControl, + IconButton, + InputLabel, + MenuItem, + Select, + Stack, + Tooltip, + Typography, +} from '@mui/material'; +import { useConnection } from '../connection'; + +/** + * The replacement for `navigator.serial.requestPort()`. + * + * The browser's picker only ever offered ports on the machine running the + * browser, which is the whole reason the plotter had to be tethered to a + * laptop. The list here comes from the server over HTTP, so the plotter can be + * on a Pi in another room and the page can be open on a phone. + */ +export const SerialPortRow = () => { + const { + serverReachable, + connected, + connecting, + connectPhase, + connect, + disconnect, + ports, + portsLoading, + portsError, + refreshPorts, + selectedPortPath, + setSelectedPortPath, + portPath, + isController, + controllerName, + takeControl, + } = useConnection(); + + if (!serverReachable) { + return ( + + Can't reach the plotter server. It serves this page, so if you're seeing this it either + restarted or the connection dropped — this will recover on its own once it's back. + + ); + } + + if (connected) { + return ( + + + + Connected — homed and ready. + + + + + {portPath} + + {!isController && } + + ); + } + + const label = connectPhase === 'homing' ? 'Homing…' : connecting ? 'Connecting…' : 'Connect'; + + return ( + + + + Serial port + + + + + + {portsLoading ? : } + + + + + + + {portsError && {portsError}} + {!portsLoading && !portsError && ports.length === 0 && ( + + The server sees no serial ports. Check that the plotter is plugged into it and powered on, + then rescan. + + )} + {!isController && } + + ); +}; + +const ReadOnlyNotice = ({ name, onTake }: { name: string | null; onTake: () => void }) => ( + + Take control + + } + > + {name ? `${name} has control` : 'Another client has control'} — you're watching. Taking control + works even mid-print. + +); diff --git a/paint-app/src/components/Toolbar.tsx b/paint-app/src/components/Toolbar.tsx index 6dd028c..a4fa25c 100644 --- a/paint-app/src/components/Toolbar.tsx +++ b/paint-app/src/components/Toolbar.tsx @@ -1,7 +1,10 @@ import BoltIcon from '@mui/icons-material/Bolt'; import BugReportIcon from '@mui/icons-material/BugReport'; +import CloudOffIcon from '@mui/icons-material/CloudOff'; import DangerousIcon from '@mui/icons-material/Dangerous'; import GitHubIcon from '@mui/icons-material/GitHub'; +import LockIcon from '@mui/icons-material/Lock'; +import LockOpenIcon from '@mui/icons-material/LockOpen'; import PauseIcon from '@mui/icons-material/Pause'; import PlayArrowIcon from '@mui/icons-material/PlayArrow'; import PrintIcon from '@mui/icons-material/Print'; @@ -31,6 +34,65 @@ type Props = { onPrint: () => void; }; +/** + * Who is driving. On one laptop with a USB cable this question did not exist; + * on a network it does, and the answer has to be visible before someone + * wonders why their Pause button does nothing. + */ +const ControlChip = ({ + serverReachable, + isController, + controllerName, + observers, + onTakeControl, +}: { + serverReachable: boolean; + isController: boolean; + controllerName: string | null; + observers: number; + onTakeControl: () => void; +}) => { + if (!serverReachable) { + return ( + + } label="Server offline" color="error" size="small" /> + + ); + } + if (isController) { + return ( + 0 + ? `You have control. ${observers} other ${observers === 1 ? 'device is' : 'devices are'} watching and can take it.` + : 'You have control of the plotter.' + } + > + } + label={observers > 0 ? `In control · ${observers} watching` : 'In control'} + color="success" + variant="outlined" + size="small" + /> + + ); + } + return ( + + } + label={`${controllerName ?? 'Someone else'} has control`} + color="warning" + size="small" + onClick={onTakeControl} + /> + + ); +}; + export const Toolbar = ({ onPrint }: Props) => { const { project, isDirty, isSaving, autosave, closeProject } = useProject(); const { @@ -41,7 +103,14 @@ export const Toolbar = ({ onPrint }: Props) => { emergencyStop, emergencyStopped, acknowledgeEmergencyStop, + isController, + controllerName, + takeControl, + session, + serverReachable, } = useConnection(); + const [takeoverOpen, setTakeoverOpen] = useState(false); + const observers = (session?.clients.length ?? 1) - 1; const acknowledgeStop = () => { acknowledgeEmergencyStop(); @@ -68,6 +137,13 @@ export const Toolbar = ({ onPrint }: Props) => { )} + setTakeoverOpen(true)} + /> {connected && ( { : 'Pause — stop sending after the current move; the plotter holds position' } > - + + + )} {!isInteractive && ( @@ -99,15 +178,24 @@ export const Toolbar = ({ onPrint }: Props) => { )} {connected && ( - - + + + + )} @@ -142,7 +230,10 @@ export const Toolbar = ({ onPrint }: Props) => { {connected && ( - + // Not gated on holding control, on purpose: any device in the session + // can stop the machine. It also does not go through the command + // socket — see PlotterClient.emergencyStop. + )} + setTakeoverOpen(false)}> + Take control? + + + {controllerName ? {controllerName} : 'Another client'} currently + controls this plotter. Taking control is not refused mid-print — if a job is running, + the job keeps running, but whoever had control will be locked out of pausing, continuing + at a pen swap, or aborting it. + + + + + + + {debugOpen && setDebugOpen(false)} />} Emergency stop triggered diff --git a/paint-app/src/connection.tsx b/paint-app/src/connection.tsx index ff24e56..5edc829 100644 --- a/paint-app/src/connection.tsx +++ b/paint-app/src/connection.tsx @@ -1,28 +1,116 @@ -import { createContext, type ReactNode, useCallback, useContext, useMemo, useState } from 'react'; -import { type ConnectPhase, PlotterConnection } from './serial'; +import { + createContext, + type ReactNode, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import type { + ConnectionStatus, + ConnectPhase, + JobStatus, + LogLine, + PortInfo, + SessionStatus, + SocketState, +} from './plotterClient'; +import { PlotterClient } from './plotterClient'; const SHOW_LOG_LS_KEY = 'paint-app:showLog'; +const PORT_LS_KEY = 'paint-app:portPath'; +const NAME_LS_KEY = 'paint-app:clientName'; +const MAX_LOG_LINES = 400; + +/** + * The server serves this page, so same-origin is the answer in production and + * in the container. `VITE_PLOTTER_URL` exists for pointing a `vite dev` session + * straight at a Pi; the dev proxy in `vite.config.ts` covers the usual case. + */ +const plotterBaseUrl = () => + (import.meta.env.VITE_PLOTTER_URL as string | undefined) || window.location.origin; + +const IDLE_CONNECTION: ConnectionStatus = { + connected: false, + connecting: false, + phase: null, + portPath: null, + paused: false, + emergencyStopped: false, +}; + +/** + * Something readable in the session list, so "someone else has control" can + * name a someone. Sticky per browser; nobody is asked to type it. + */ +const defaultClientName = () => { + const existing = localStorage.getItem(NAME_LS_KEY); + if (existing) return existing; + const ua = navigator.userAgent; + const os = /iPhone|iPad/.test(ua) + ? 'iOS' + : /Android/.test(ua) + ? 'Android' + : /Mac OS X/.test(ua) + ? 'Mac' + : /Windows/.test(ua) + ? 'Windows' + : /Linux/.test(ua) + ? 'Linux' + : 'browser'; + const name = `${os}-${Math.random().toString(36).slice(2, 6)}`; + localStorage.setItem(NAME_LS_KEY, name); + return name; +}; type ConnectionCtx = { - connection: PlotterConnection; + client: PlotterClient; + /** The page's link to the plotter server, not the server's link to the plotter. */ + socket: SocketState; + serverReachable: boolean; + connected: boolean; connecting: boolean; connectPhase: ConnectPhase | null; - connect: () => Promise; + /** The port the *server* is holding open, if any. */ + portPath: string | null; + /** Connect to `portPath`, or to the last-used port when omitted. */ + connect: (portPath?: string) => Promise; disconnect: () => Promise; - /** Last connect failure message, surfaced as a modal. Null when none. */ connectError: string | null; dismissConnectError: () => void; - /** Global pause: gates all G-code output (print + interactive). */ + + /** Ports the server can see. Replaces the browser's native port picker. */ + ports: PortInfo[]; + portsLoading: boolean; + portsError: string | null; + refreshPorts: () => Promise; + selectedPortPath: string | null; + setSelectedPortPath: (path: string) => void; + + /** Global pause: gates job output and interactive strokes alike. */ paused: boolean; - pause: () => void; - resume: () => void; - /** Fire M112 and tear down the (now-killed) connection. */ + pause: () => Promise; + resume: () => Promise; + emergencyStop: () => Promise; - /** True after an emergency stop until acknowledged; the board needs a - * power cycle before it will respond again. */ + /** Set by the server from an M112 until the next successful connect. */ emergencyStopped: boolean; acknowledgeEmergencyStop: () => void; + + /** The job the server is running, whoever started it. */ + job: JobStatus | null; + + /** Who is in the session, and which one of them may move the machine. */ + session: SessionStatus | null; + clientId: string | null; + isController: boolean; + controllerName: string | null; + takeControl: () => Promise; + releaseControl: () => Promise; + log: string[]; showLog: boolean; setShowLog: (v: boolean) => void; @@ -30,103 +118,216 @@ type ConnectionCtx = { const ConnectionContext = createContext(null); +const formatLog = (l: LogLine) => { + const prefix = l.kind === 'tx' ? '> ' : l.kind === 'rx' ? ' ' : l.kind === 'err' ? '! ' : '· '; + const ts = new Date(l.t).toLocaleTimeString([], { hour12: false }); + return `${ts} ${prefix}${l.line}`; +}; + export const ConnectionProvider = ({ children }: { children: ReactNode }) => { - const [connected, setConnected] = useState(false); - const [connectPhase, setConnectPhase] = useState(null); - const [emergencyStopped, setEmergencyStopped] = useState(false); + const [socket, setSocket] = useState('connecting'); + const [connection, setConnection] = useState(IDLE_CONNECTION); + const [session, setSession] = useState(null); + const [clientId, setClientId] = useState(null); + const [job, setJob] = useState(null); const [connectError, setConnectError] = useState(null); - const [paused, setPaused] = useState(false); + const [stopAcknowledged, setStopAcknowledged] = useState(true); + const [ports, setPorts] = useState([]); + const [portsLoading, setPortsLoading] = useState(false); + const [portsError, setPortsError] = useState(null); + const [selectedPortPath, setSelectedPortPathState] = useState(() => + localStorage.getItem(PORT_LS_KEY), + ); const [log, setLog] = useState([]); - const [showLog, setShowLogState] = useState(() => { - const raw = localStorage.getItem(SHOW_LOG_LS_KEY); - return raw === '1'; - }); + const [showLog, setShowLogState] = useState( + () => localStorage.getItem(SHOW_LOG_LS_KEY) === '1', + ); const setShowLog = useCallback((v: boolean) => { setShowLogState(v); localStorage.setItem(SHOW_LOG_LS_KEY, v ? '1' : '0'); }, []); - const connection = useMemo( + const setSelectedPortPath = useCallback((path: string) => { + setSelectedPortPathState(path); + localStorage.setItem(PORT_LS_KEY, path); + }, []); + + const client = useMemo( () => - new PlotterConnection( - (line, kind) => { - const prefix = kind === 'tx' ? '> ' : kind === 'rx' ? ' ' : kind === 'err' ? '! ' : '· '; - const ts = (performance.now() / 1000).toFixed(3).padStart(8, ' '); - setLog((prev) => [...prev.slice(-200), `${ts} ${prefix}${line}`]); - }, - (phase) => setConnectPhase(phase), - () => { - // Device lost / board killed outside an intentional disconnect. - setConnected(false); - setConnectPhase(null); + new PlotterClient({ + baseUrl: plotterBaseUrl(), + name: defaultClientName(), + onSocket: setSocket, + onConnection: setConnection, + onSession: (s, self) => { + setSession(s); + setClientId(self); }, - (p) => setPaused(p), - ), + onJob: setJob, + onLog: (lines) => + setLog((prev) => [...prev, ...lines.map(formatLog)].slice(-MAX_LOG_LINES)), + onLost: () => setConnectError('The plotter dropped off the port.'), + }), [], ); - const connect = useCallback(async () => { - setConnectPhase('connecting'); - setConnectError(null); + useEffect(() => { + client.start(); + return () => client.stop(); + }, [client]); + + // `tx`/`rx` is thousands of lines per print and every one of them crosses the + // network, so it is only streamed while the log panel is actually open. + useEffect(() => { + client.setLogKinds(showLog ? ['tx', 'rx', 'info', 'err'] : ['info', 'err']); + }, [client, showLog]); + + // A fresh M112 needs acknowledging; a stale one that the server already + // cleared (a successful reconnect) does not. + const emergencyStopped = connection.emergencyStopped; + const prevStopped = useRef(emergencyStopped); + useEffect(() => { + if (emergencyStopped && !prevStopped.current) setStopAcknowledged(false); + prevStopped.current = emergencyStopped; + }, [emergencyStopped]); + + const refreshPorts = useCallback(async () => { + setPortsLoading(true); + setPortsError(null); try { - await connection.connect(); - setConnected(true); + const found = await client.listPorts(); + setPorts(found); + // Default to the server's best guess so the common one-plotter case is + // a single click, the way the native picker used to be. + setSelectedPortPathState((current) => { + if (current && found.some((p) => p.path === current)) return current; + return found[0]?.path ?? current; + }); } catch (e) { - setConnectError((e as Error).message); + setPortsError((e as Error).message); } finally { - setConnectPhase(null); + setPortsLoading(false); } - }, [connection]); + }, [client]); + + // Enumerate as soon as the server is reachable, and again after a reconnect + // — a replugged plotter is exactly when the list is wrong. + useEffect(() => { + if (socket === 'open') void refreshPorts(); + }, [socket, refreshPorts]); + + const connect = useCallback( + async (portPath?: string) => { + const target = portPath ?? selectedPortPath ?? ports[0]?.path; + if (!target) { + setConnectError( + 'No serial ports found. Check that the plotter is plugged into the server and powered on.', + ); + return; + } + setConnectError(null); + try { + await client.connect(target); + setSelectedPortPath(target); + } catch (e) { + setConnectError((e as Error).message); + } + }, + [client, ports, selectedPortPath, setSelectedPortPath], + ); const disconnect = useCallback(async () => { - await connection.disconnect(); - setConnected(false); - }, [connection]); + try { + await client.disconnect(); + } catch (e) { + setConnectError((e as Error).message); + } + }, [client]); + + /** + * Every command can be refused — the server is the authority and this client + * may be read-only. Surfacing the refusal beats an unhandled rejection and a + * button that silently does nothing. + */ + const attempt = useCallback(async (fn: () => Promise) => { + try { + await fn(); + } catch (e) { + setConnectError((e as Error).message); + } + }, []); const emergencyStop = useCallback(async () => { - setEmergencyStopped(true); + setStopAcknowledged(false); try { - await connection.emergencyStop(); - } finally { - // M112 kills the firmware; the port is dead until the board is - // reconnected. Tear down so the UI shows disconnected immediately. - await connection.disconnect().catch(() => {}); - setConnected(false); - setConnectPhase(null); + await client.emergencyStop(); + } catch (e) { + setConnectError(`Emergency stop could not be delivered: ${(e as Error).message}`); } - }, [connection]); + }, [client]); + + const controller = session?.clients.find((c) => c.id === session.controllerId) ?? null; + const isController = Boolean(clientId && session?.controllerId === clientId); const value = useMemo( () => ({ - connection, - connected, - connecting: connectPhase !== null, - connectPhase, + client, + socket, + serverReachable: socket === 'open', + connected: connection.connected, + connecting: connection.connecting, + connectPhase: connection.phase, + portPath: connection.portPath, connect, disconnect, connectError, dismissConnectError: () => setConnectError(null), - paused, - pause: () => connection.pause(), - resume: () => connection.resume(), + ports, + portsLoading, + portsError, + refreshPorts, + selectedPortPath, + setSelectedPortPath, + paused: connection.paused, + pause: () => attempt(() => client.pause()), + resume: () => attempt(() => client.resume()), emergencyStop, - emergencyStopped, - acknowledgeEmergencyStop: () => setEmergencyStopped(false), + emergencyStopped: emergencyStopped && !stopAcknowledged, + acknowledgeEmergencyStop: () => setStopAcknowledged(true), + job, + session, + clientId, + isController, + controllerName: controller?.name ?? null, + takeControl: () => attempt(() => client.takeControl()), + releaseControl: () => attempt(() => client.releaseControl()), log, showLog, setShowLog, }), [ + client, + socket, connection, - connected, - connectPhase, connect, disconnect, connectError, - paused, + ports, + portsLoading, + portsError, + refreshPorts, + selectedPortPath, + setSelectedPortPath, + attempt, emergencyStop, emergencyStopped, + stopAcknowledged, + job, + session, + clientId, + isController, + controller, log, showLog, setShowLog, diff --git a/paint-app/src/gcode.ts b/paint-app/src/gcode.ts index c222c4c..fb682f0 100644 --- a/paint-app/src/gcode.ts +++ b/paint-app/src/gcode.ts @@ -77,7 +77,7 @@ export const PROLOGUE = (plotter: Plotter): string[] => [ `; plotter: ${plotter.name}`, 'G21 ; mm', 'G90 ; absolute', - // No G28 here: the device is homed once on connect (serial.ts) and + // No G28 here: the server homes once on connect and machine // position persists, so re-homing per print would just waste ~15s. `G0 Z${fmt(plotter.penUpZ)} F${plotter.travelFeed}`, ]; diff --git a/paint-app/src/interactive.tsx b/paint-app/src/interactive.tsx index 705076f..03cfc11 100644 --- a/paint-app/src/interactive.tsx +++ b/paint-app/src/interactive.tsx @@ -14,11 +14,14 @@ import { useStore } from './store'; * The first stroke after a fresh connection sends the prologue (G21/G90/G28). * Strokes are sent through a serialized promise queue so concurrent draws * don't interleave G-code on the bus. + * + * Each stroke crosses the network as one batch, not a line at a time; the + * server's send loop still waits for Marlin's `ok` per line, over USB. */ export const useInteractiveSync = () => { const { state } = useStore(); const { project } = useProject(); - const { connection, connected } = useConnection(); + const { client, connected, isController } = useConnection(); const { getPlotter } = usePlotters(); const isInteractive = project?.id === INTERACTIVE_PROJECT_ID; @@ -31,7 +34,7 @@ export const useInteractiveSync = () => { // from being plotted on load. // biome-ignore lint/correctness/useExhaustiveDependencies: only re-prime on session-boundary changes useEffect(() => { - if (!isInteractive || !connected) { + if (!isInteractive || !connected || !isController) { sentRef.current = new Set(); sessionKeyRef.current = null; return; @@ -42,11 +45,12 @@ export const useInteractiveSync = () => { } sentRef.current = ids; sessionKeyRef.current = null; - }, [isInteractive, connected, project?.id]); + }, [isInteractive, connected, isController, project?.id]); - // Stream new strokes whenever the store changes. + // Stream new strokes whenever the store changes. Read-only observers watch + // the canvas without their strokes reaching the machine. useEffect(() => { - if (!isInteractive || !connected || !project) return; + if (!isInteractive || !connected || !isController || !project) return; const plotter = getPlotter(state.plotterId); if (!plotter) return; const activePage = state.pages.find((p) => p.id === state.activePageId); @@ -70,15 +74,18 @@ export const useInteractiveSync = () => { queueRef.current = queueRef.current.then(async () => { try { if (sessionKeyRef.current !== expectedSession) { - await connection.sendMany(PROLOGUE(plotter)); + await client.sendMany(PROLOGUE(plotter)); sessionKeyRef.current = expectedSession; } for (const lines of newPrograms) { - await connection.sendMany(lines); + await client.sendMany(lines); } } catch (e) { + // A refused stroke (control lost, job started) must not leave the + // session key set, or the prologue is skipped on the next attempt. + sessionKeyRef.current = null; console.error('interactive stream failed', e); } }); - }, [state, isInteractive, connected, project, connection, getPlotter]); + }, [state, isInteractive, connected, isController, project, client, getPlotter]); }; diff --git a/paint-app/src/plotterClient.ts b/paint-app/src/plotterClient.ts new file mode 100644 index 0000000..1b47302 --- /dev/null +++ b/paint-app/src/plotterClient.ts @@ -0,0 +1,387 @@ +import type { + ClientMessage, + ConnectionStatus, + JobStatus, + JobSummary, + LogKind, + LogLine, + PortInfo, + ServerMessage, + SessionStatus, + Snapshot, +} from '../server/src/protocol.js'; + +export type { + ConnectionStatus, + ConnectPhase, + JobLayerSummary, + JobState, + JobStatus, + JobSummary, + LogKind, + LogLine, + PortInfo, + SessionStatus, +} from '../server/src/protocol.js'; + +/** How the page's own link to the server is doing, as opposed to the plotter's. */ +export type SocketState = 'connecting' | 'open' | 'closed'; + +export type JobUpload = { + name: string; + plotterName: string; + prologue: string[]; + layers: { name: string; color: string; lines: string[] }[]; + epilogue: string[]; +}; + +export type PlotterClientOptions = { + /** + * Origin of the plotter server, e.g. `http://plotter.local:8080`. Empty + * string means same-origin, which is the production case — the server serves + * this page. Resolved by the caller so this class stays free of `window` and + * can be exercised under Node against the real server. + */ + baseUrl: string; + /** Shown to other clients in the session list. */ + name?: string; + onLog?: (lines: LogLine[]) => void; + onConnection?: (status: ConnectionStatus) => void; + onSession?: (session: SessionStatus, selfId: string | null) => void; + onJob?: (job: JobStatus | null) => void; + onSocket?: (state: SocketState) => void; + /** The plotter dropped off the port without anyone asking it to. */ + onLost?: () => void; +}; + +/** Reconnect backoff. Fast enough to survive a Pi reboot without a page reload. */ +const RECONNECT_MIN_MS = 500; +const RECONNECT_MAX_MS = 5_000; + +/** + * A `stream` message is capped server-side; one long freehand stroke can exceed + * it, so batches are split. Each chunk still waits for its ack before the next + * goes out, so ordering on the wire is unchanged. + */ +const STREAM_CHUNK = 1000; + +/** Marlin has no use for our comments and the server's allowlist rejects them. */ +const stripComments = (lines: string[]) => + lines.map((l) => l.replace(/;.*$/, '').trim()).filter((l) => l.length > 0); + +const chunk = (items: T[], size: number): T[][] => { + const out: T[][] = []; + for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size)); + return out; +}; + +/** + * The client half of the plotter protocol. + * + * This replaces the Web Serial wrapper that used to live in `serial.ts`. The + * shape is deliberately close to it — `connect` / `disconnect` / `send` / + * `sendMany` / `pause` / `resume` / `emergencyStop` / `getPosition`, with `log` + * and `lost` callbacks — so the components that drive it barely changed. Three + * things genuinely differ, and they are the point of the exercise: + * + * - `connect` takes a port path, because the port is on the Pi and the browser + * has no picker for it. `listPorts()` is where that path comes from. + * - A print is uploaded whole and run by the server. `sendMany` is only for + * interactive strokes now; a page of art never crosses the network line by + * line. + * - State is the server's, not ours. Everything interesting arrives as a + * broadcast, including changes another browser made. + */ +export class PlotterClient { + readonly baseUrl: string; + + private socket: WebSocket | null = null; + private closed = false; + private reconnectDelay = RECONNECT_MIN_MS; + private reconnectTimer: ReturnType | null = null; + private nextRequestId = 0; + private pending = new Map< + string, + { resolve: (data: unknown) => void; reject: (e: Error) => void } + >(); + private openResolvers: (() => void)[] = []; + private logKinds: LogKind[] = ['info', 'err']; + private selfId: string | null = null; + private readonly opts: PlotterClientOptions; + + constructor(opts: PlotterClientOptions) { + this.opts = opts; + this.baseUrl = opts.baseUrl.replace(/\/$/, ''); + } + + get clientId() { + return this.selfId; + } + + // ----------------------------------------------------------------- socket + + /** Open the session socket and keep it open until `stop()`. */ + start() { + this.closed = false; + this.openSocket(); + } + + stop() { + this.closed = true; + if (this.reconnectTimer) clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + this.socket?.close(); + this.socket = null; + this.failPending(new Error('Disconnected from the plotter server.')); + } + + private openSocket() { + if (this.closed) return; + this.opts.onSocket?.('connecting'); + const socket = new WebSocket(this.wsUrl()); + this.socket = socket; + + socket.onopen = () => { + this.reconnectDelay = RECONNECT_MIN_MS; + this.opts.onSocket?.('open'); + // Re-announce on every reconnect: to the server this is a brand new + // client, with a new id and no memory of what the last one asked for. + if (this.opts.name) this.raw({ type: 'identify', name: this.opts.name }); + this.raw({ type: 'log.subscribe', kinds: this.logKinds }); + const waiters = this.openResolvers; + this.openResolvers = []; + for (const w of waiters) w(); + }; + + socket.onmessage = (ev) => { + let msg: ServerMessage; + try { + msg = JSON.parse(String(ev.data)); + } catch { + return; + } + this.receive(msg); + }; + + socket.onclose = () => { + if (this.socket === socket) this.socket = null; + this.opts.onSocket?.('closed'); + // Anything waiting on an ack will never get one. Fail it now rather + // than leaving a button spinning forever. + this.failPending(new Error('Lost the connection to the plotter server.')); + if (this.closed) return; + this.reconnectTimer = setTimeout(() => this.openSocket(), this.reconnectDelay); + this.reconnectDelay = Math.min(this.reconnectDelay * 2, RECONNECT_MAX_MS); + }; + + // `onerror` is always followed by `onclose`; reconnecting is handled there. + socket.onerror = () => {}; + } + + private receive(msg: ServerMessage) { + switch (msg.type) { + case 'hello': + this.selfId = msg.clientId; + this.opts.onConnection?.(msg.snapshot.connection); + this.opts.onSession?.(msg.snapshot.session, this.selfId); + this.opts.onJob?.(msg.snapshot.activeJob); + if (msg.recentLog.length > 0) this.opts.onLog?.(msg.recentLog); + break; + case 'log': + this.opts.onLog?.(msg.lines); + break; + case 'connection': + this.opts.onConnection?.(msg.connection); + break; + case 'session': + this.opts.onSession?.(msg.session, this.selfId); + break; + case 'job': + this.opts.onJob?.(msg.job); + break; + case 'lost': + this.opts.onLost?.(); + break; + case 'ack': { + const waiter = this.pending.get(msg.id); + if (!waiter) break; + this.pending.delete(msg.id); + if (msg.ok) waiter.resolve(msg.data); + else waiter.reject(new Error(msg.error ?? 'The plotter server refused that.')); + break; + } + } + } + + private failPending(error: Error) { + const waiters = [...this.pending.values()]; + this.pending.clear(); + for (const w of waiters) w.reject(error); + } + + private raw(msg: ClientMessage) { + this.socket?.send(JSON.stringify(msg)); + } + + private whenOpen(): Promise { + if (this.socket?.readyState === WebSocket.OPEN) return Promise.resolve(); + if (this.closed) return Promise.reject(new Error('Not connected to the plotter server.')); + return new Promise((resolve) => this.openResolvers.push(resolve)); + } + + /** Send a command and wait for the server's verdict on it. */ + private async request(msg: ClientMessage): Promise { + await this.whenOpen(); + const id = `c${this.nextRequestId++}`; + return new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject }); + this.socket?.send(JSON.stringify({ ...msg, id })); + }); + } + + private wsUrl() { + return `${this.baseUrl.replace(/^http/, 'ws')}/ws`; + } + + // -------------------------------------------------------------- REST bits + + private async get(path: string): Promise { + const res = await fetch(`${this.baseUrl}${path}`); + if (!res.ok) throw new Error(`${path} failed (${res.status})`); + return (await res.json()) as T; + } + + /** The port picker's contents. Replaces `navigator.serial.requestPort()`. */ + async listPorts(): Promise { + const { ports } = await this.get<{ ports: PortInfo[] }>('/api/ports'); + return ports; + } + + async snapshot(): Promise { + return this.get('/api/state'); + } + + async uploadJob(job: JobUpload): Promise { + const res = await fetch(`${this.baseUrl}/api/jobs`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(job), + }); + const body = (await res.json().catch(() => null)) as { + job?: JobSummary; + error?: string; + } | null; + if (!res.ok || !body?.job) throw new Error(body?.error ?? `Upload failed (${res.status})`); + return body.job; + } + + // ------------------------------------------------------------- the plotter + + async connect(portPath: string) { + await this.request({ type: 'connect', portPath }); + } + + async disconnect() { + await this.request({ type: 'disconnect' }); + } + + /** Send one line and get back whatever the board said before its `ok`. */ + async send(gcode: string): Promise { + const lines = stripComments([gcode]); + if (lines.length === 0) return []; + const data = await this.request({ type: 'jog', lines }); + return Array.isArray(data) ? (data as string[]) : []; + } + + /** + * Stream a batch of movement G-code — an interactive stroke, a calibration + * sequence. NOT how a print is sent: that goes through `uploadJob` so the + * send loop runs next to the cable instead of across the network. + */ + async sendMany(lines: string[]) { + const clean = stripComments(lines); + if (clean.length === 0) return; + for (const batch of chunk(clean, STREAM_CHUNK)) { + await this.request({ type: 'stream', lines: batch }); + } + } + + async pause() { + await this.request({ type: 'job.pause' }); + } + + async resume() { + await this.request({ type: 'job.resume' }); + } + + async getPosition(): Promise<{ x: number; y: number; z: number } | null> { + const data = await this.request({ type: 'position' }); + return (data as { x: number; y: number; z: number } | null) ?? null; + } + + /** + * Emergency stop. + * + * This deliberately does not go through `request()`. That path waits for the + * socket to be open, takes a slot in the ack table, and — on the server side — + * lands in the same dispatcher as everything else. A stop that can be delayed + * by any of those is not a stop. + * + * `POST /api/estop` is a bare HTTP request the server answers without + * consulting the control lock, the write queue, the pause gate, or the + * running job. Any client may fire it, including a read-only observer: a + * safety control you have to ask permission to use is not one. The socket is + * only a fallback for when HTTP itself fails, and `estop` is ungated there + * too. + */ + async emergencyStop(): Promise { + try { + const res = await fetch(`${this.baseUrl}/api/estop`, { method: 'POST' }); + if (res.ok) return; + throw new Error(`Emergency stop failed (${res.status})`); + } catch (e) { + if (this.socket?.readyState === WebSocket.OPEN) { + this.raw({ type: 'estop' }); + return; + } + throw e; + } + } + + // ------------------------------------------------------------------- jobs + + async startJob(jobId: string) { + await this.request({ type: 'job.start', jobId }); + } + + /** Operator has swapped the pen; run the next layer. */ + async continueJob() { + await this.request({ type: 'job.continue' }); + } + + async cancelJob() { + await this.request({ type: 'job.cancel' }); + } + + // ---------------------------------------------------------------- control + + async claimControl() { + await this.request({ type: 'control.claim' }); + } + + async releaseControl() { + await this.request({ type: 'control.release' }); + } + + /** Unconditional. See the takeover warning in the UI. */ + async takeControl() { + await this.request({ type: 'control.takeover' }); + } + + setLogKinds(kinds: LogKind[]) { + this.logKinds = kinds; + if (this.socket?.readyState === WebSocket.OPEN) { + this.raw({ type: 'log.subscribe', kinds }); + } + } +} diff --git a/paint-app/src/serial.ts b/paint-app/src/serial.ts deleted file mode 100644 index 294de19..0000000 --- a/paint-app/src/serial.ts +++ /dev/null @@ -1,322 +0,0 @@ -declare global { - interface Navigator { - serial: { - requestPort: (opts?: { filters?: { usbVendorId: number }[] }) => Promise; - getPorts: () => Promise; - }; - } - interface SerialPort { - open: (opts: { baudRate: number }) => Promise; - close: () => Promise; - // null while the port is closed (per the Web Serial spec); the non-null - // types are only valid once open() has resolved. - readable: ReadableStream | null; - writable: WritableStream | null; - } -} - -const BAUD = 115200; - -export type LogFn = (line: string, kind: 'tx' | 'rx' | 'info' | 'err') => void; -export type ConnectPhase = 'connecting' | 'homing'; -export type PhaseFn = (phase: ConnectPhase | null) => void; - -export class PlotterConnection { - private port: SerialPort | null = null; - private writer: WritableStreamDefaultWriter | null = null; - private reader: ReadableStreamDefaultReader | null = null; - private buffer = ''; - /** Lines received but not yet consumed by a reader. */ - private lineQueue: string[] = []; - /** Resolver for a reader currently awaiting the next line, if any. */ - private lineWaiter: ((line: string) => void) | null = null; - private readLoopPromise: Promise | null = null; - /** True only while an intentional disconnect() is in progress, so the - * read loop ending doesn't get misreported as an unexpected device loss. */ - private intentionalClose = false; - /** When true, the next send() blocks before writing until resume(). The - * in-flight line still finishes; Marlin then drains its small planner - * buffer and holds position. Applies to every G-code source (print, - * interactive) since they all funnel through send(). */ - private paused = false; - private resumeWaiters: (() => void)[] = []; - - constructor( - private log: LogFn, - private onPhase: PhaseFn = () => {}, - /** Fired once when the port drops unexpectedly (device lost, board - * reset/killed by M112). Not called for an intentional disconnect(). */ - private onLost: () => void = () => {}, - private onPauseChange: (paused: boolean) => void = () => {}, - ) {} - - get isPaused() { - return this.paused; - } - - pause() { - if (this.paused) return; - this.paused = true; - this.log('paused', 'info'); - this.onPauseChange(true); - } - - resume() { - if (!this.paused) return; - this.paused = false; - const waiters = this.resumeWaiters; - this.resumeWaiters = []; - for (const w of waiters) w(); - this.log('resumed', 'info'); - this.onPauseChange(false); - } - - /** Resolves immediately unless paused, in which case it waits for resume() - * (or a disconnect, which forcibly clears the pause). */ - private gate(): Promise { - if (!this.paused) return Promise.resolve(); - return new Promise((r) => this.resumeWaiters.push(r)); - } - - get connected() { - return this.port !== null; - } - - async connect() { - if (!('serial' in navigator)) { - throw new Error('Web Serial not supported. Use Chrome or Edge.'); - } - if (this.port) { - throw new Error('Already connected. Disconnect before connecting again.'); - } - const port = await navigator.serial.requestPort(); - this.onPhase('connecting'); - // The browser keeps a port open across our own teardown bugs, other tabs, - // or a prior connect() that threw after open() — re-opening such a port - // fails with "The port is already open." A non-null readable/writable is - // the spec's signal that it's still open, so close it for a clean start. - if (port.readable || port.writable) { - this.log('port already open — closing before reopening', 'info'); - await port.close().catch(() => {}); - // The OS doesn't release the device synchronously with close(); opening - // again immediately fails with the generic "Failed to open serial port." - // Give the previous handle time to drop before reopening. - await new Promise((r) => setTimeout(r, 300)); - } - try { - await port.open({ baudRate: BAUD }); - } catch (e) { - // A transient "Failed to open serial port" usually means the OS hasn't - // freed the device yet (just unplugged/replugged, prior session, another - // tab). One delayed retry clears the common case; a second failure is - // surfaced with actionable guidance. - this.log(`open failed (${(e as Error).message}) — retrying once`, 'info'); - await new Promise((r) => setTimeout(r, 800)); - try { - await port.open({ baudRate: BAUD }); - } catch { - throw new Error( - 'Could not open the serial port. Close any other tab or program using the plotter (including Arduino IDE / serial monitors), then unplug and replug the USB cable and try again.', - ); - } - } - if (!port.writable || !port.readable) { - await port.close().catch(() => {}); - throw new Error('Serial port opened without readable/writable streams.'); - } - this.port = port; - this.writer = port.writable.getWriter(); - this.readLoopPromise = this.readLoop(); - this.log(`opened @ ${BAUD}`, 'info'); - // Marlin resets on open; wait for boot. - this.log('boot wait 2000ms…', 'info'); - await new Promise((r) => setTimeout(r, 2000)); - this.log('boot wait done; sending M115', 'info'); - // M115 is the liveness probe: a healthy Marlin always replies with a - // FIRMWARE_NAME banner. A board that's been M112'd / killed answers - // nothing, so a short timeout here lets us fail fast instead of - // marching on (M115 timeout → G28 timeout) and falsely reporting a - // live connection to a dead board. - const info = await this.send('M115', 6_000); - const alive = info.some((l) => /FIRMWARE_NAME|marlin/i.test(l)); - if (!alive) { - this.log('no firmware response — board not responding', 'err'); - await this.disconnect().catch(() => {}); - throw new Error( - 'Plotter is not responding. If it was emergency-stopped, power-cycle the printer (off, wait, on), then reconnect.', - ); - } - this.log('M115 done; sending G28 (home)', 'info'); - this.onPhase('homing'); - await this.send('G28'); - this.log('G28 done; connected', 'info'); - this.onPhase(null); - } - - async disconnect() { - this.intentionalClose = true; - // Release anything blocked on a pause so the print/interactive loops - // can unwind instead of hanging on a closed port. - this.paused = false; - const waiters = this.resumeWaiters; - this.resumeWaiters = []; - for (const w of waiters) w(); - this.onPauseChange(false); - try { - this.reader?.cancel(); - this.writer?.releaseLock(); - await this.readLoopPromise; - await this.port?.close(); - } finally { - this.port = null; - this.writer = null; - this.reader = null; - this.readLoopPromise = null; - this.intentionalClose = false; - this.log('closed', 'info'); - } - } - - private async readLoop() { - if (!this.port?.readable) return; - const decoder = new TextDecoder(); - this.reader = this.port.readable.getReader(); - try { - while (true) { - const { value, done } = await this.reader.read(); - if (done) break; - this.buffer += decoder.decode(value, { stream: true }); - let nl: number; - // biome-ignore lint/suspicious/noAssignInExpressions: idiomatic line splitting - while ((nl = this.buffer.indexOf('\n')) >= 0) { - const line = this.buffer.slice(0, nl).replace(/\r$/, ''); - this.buffer = this.buffer.slice(nl + 1); - if (!line) continue; - this.log(line, 'rx'); - this.pushLine(line); - } - } - } catch (e) { - this.log(`read error: ${(e as Error).message}`, 'err'); - } finally { - try { - this.reader?.releaseLock(); - } catch {} - // Loop ended without an intentional disconnect() → the device was - // lost (unplugged, reset, or killed by M112). Drop state and notify - // so the UI stops showing a live connection. - if (!this.intentionalClose && this.port) { - this.port = null; - this.writer = null; - this.reader = null; - this.readLoopPromise = null; - this.log('connection lost', 'err'); - this.onLost(); - } - } - } - - /** Deliver a line to a waiting reader, or buffer it until one asks. */ - private pushLine(line: string) { - if (this.lineWaiter) { - const w = this.lineWaiter; - this.lineWaiter = null; - w(line); - } else { - this.lineQueue.push(line); - } - } - - private nextLine(timeoutMs: number) { - const queued = this.lineQueue.shift(); - if (queued !== undefined) return Promise.resolve(queued); - return new Promise((resolve) => { - let done = false; - const t = setTimeout(() => { - if (!done) { - done = true; - this.lineWaiter = null; - resolve(null); - } - }, timeoutMs); - this.lineWaiter = (line) => { - if (!done) { - done = true; - clearTimeout(t); - resolve(line); - } - }; - }); - } - - /** - * Collect reply lines until `ok`. Uses an *inactivity* timeout, not a hard - * deadline: slow commands like `G28` can take far longer than any fixed - * budget, and Marlin emits `echo:busy: processing` every ~2s precisely so - * the host knows it is still alive. We treat those as keepalives that keep - * us waiting; we only give up after `idleTimeoutMs` of true silence. - */ - private async readUntilOk(idleTimeoutMs = 20_000): Promise { - const collected: string[] = []; - while (true) { - const line = await this.nextLine(idleTimeoutMs); - if (line === null) return collected; - const lower = line.toLowerCase(); - if (lower.startsWith('ok')) return collected; - if (lower.startsWith('echo:busy') || lower.startsWith('busy')) continue; - collected.push(line); - } - } - - /** Send a single G-code line; returns any non-`ok` reply lines it produced. */ - async send(gcode: string, idleTimeoutMs?: number): Promise { - // Strip comments; Marlin sends no `ok` for a comment-only line, so - // sending one would stall readUntilOk for the full idle timeout. - const line = gcode.replace(/;.*$/, '').trim(); - if (!line) return []; - // Block here while paused so the *next* line isn't sent; the previous - // line already got its `ok`, so the machine just holds position. - await this.gate(); - if (!this.writer) return []; - // Drop any lines left over from a previous command (e.g. trailing - // async chatter after its `ok`) so they aren't mistaken for this reply. - this.lineQueue.length = 0; - this.log(line, 'tx'); - await this.writer.write(new TextEncoder().encode(`${line}\n`)); - return this.readUntilOk(idleTimeoutMs); - } - - async sendMany(lines: string[]) { - for (const l of lines) { - await this.send(l); - } - } - - /** - * Emergency stop. Writes M112 straight to the port, bypassing the - * command/`ok` queue so it lands even mid-stream (Marlin's - * EMERGENCY_PARSER acts on it immediately, full planner buffer or not). - * The board enters a killed state afterwards and must be reconnected. - */ - async emergencyStop() { - if (!this.writer) return; - this.log('M112 (emergency stop)', 'err'); - await this.writer.write(new TextEncoder().encode('M112\n')); - } - - /** Query M114 and parse the X/Y/Z position from the reply. Null if no reply. */ - async getPosition(): Promise<{ x: number; y: number; z: number } | null> { - const lines = await this.send('M114'); - for (const line of lines) { - const m = line.match(/X:(-?\d+\.?\d*)\s+Y:(-?\d+\.?\d*)\s+Z:(-?\d+\.?\d*)/); - if (m) { - return { - x: Number.parseFloat(m[1]), - y: Number.parseFloat(m[2]), - z: Number.parseFloat(m[3]), - }; - } - } - return null; - } -} From f3edbf0904148fffd589ebd13c9ee33a808b2e3b Mon Sep 17 00:00:00 2001 From: Travis Bumgarner <11890057+TravisBumgarner@users.noreply.github.com> Date: Tue, 28 Jul 2026 20:31:49 -0600 Subject: [PATCH 3/3] paint-app: ship the renderer and the server as one image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Dockerfile moves up to paint-app/ so the build context covers both halves: a stage that runs `vite build`, a stage that runs `tsc`, and a runtime that serves the first from the second. CLIENT_DIR is baked in, so pulling the image and starting it is the whole install — there is no separate UI to deploy or keep at the same version. The renderer imports the wire types straight out of the server rather than keeping a copy, so the two halves of an emergency stop cannot drift apart. That means the client build stage needs one file from server/src, and the server's typecheck config has to reach above src/ to cover the client test. @types/node becomes an explicit dependency of the renderer: it was only ever there by hoisting, which a clean `npm ci` in the image does not reproduce. CI gains a job that lints and builds the renderer, and the image build waits on both. The paths filter widens to all of paint-app, since the image now contains all of it. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/plotter-server-image.yml | 37 +++++++-- paint-app/.dockerignore | 15 ++++ paint-app/{server => }/Dockerfile | 32 ++++++-- paint-app/README.md | 81 +++++++++++++------ .../{server => }/docker-compose.example.yml | 9 ++- paint-app/package-lock.json | 18 +++++ paint-app/package.json | 4 +- paint-app/server/.dockerignore | 6 -- paint-app/server/README.md | 30 +++++-- paint-app/server/package.json | 1 - paint-app/tsconfig.node.json | 4 + paint-app/vite.config.ts | 16 +++- 12 files changed, 195 insertions(+), 58 deletions(-) create mode 100644 paint-app/.dockerignore rename paint-app/{server => }/Dockerfile (60%) rename paint-app/{server => }/docker-compose.example.yml (71%) delete mode 100644 paint-app/server/.dockerignore diff --git a/.github/workflows/plotter-server-image.yml b/.github/workflows/plotter-server-image.yml index 1cbd57f..3116bf0 100644 --- a/.github/workflows/plotter-server-image.yml +++ b/.github/workflows/plotter-server-image.yml @@ -1,6 +1,7 @@ name: plotter server image -# Publishes the plotter backend container to GHCR. +# Publishes the plotter appliance container to GHCR: the React renderer and the +# Node server that serves it and owns the serial port, in one image. # # Tags are prefixed `plotter-server-` so image releases don't collide with the # Python package's versioning elsewhere in this repo. Push @@ -11,11 +12,11 @@ on: branches: [main] tags: ["plotter-server-v*"] paths: - - "paint-app/server/**" + - "paint-app/**" - ".github/workflows/plotter-server-image.yml" pull_request: paths: - - "paint-app/server/**" + - "paint-app/**" - ".github/workflows/plotter-server-image.yml" workflow_dispatch: @@ -23,7 +24,7 @@ env: IMAGE: ghcr.io/${{ github.repository_owner }}/plotter-server jobs: - test: + server: runs-on: ubuntu-latest defaults: run: @@ -35,13 +36,32 @@ jobs: node-version: 22 cache: npm cache-dependency-path: paint-app/server/package-lock.json + # The renderer's API client is tested from here, against the same fake + # Marlin the server uses. It needs the client source on disk but not the + # renderer's dependencies — it imports nothing beyond the protocol types. - run: npm ci - run: npm run lint - run: npm run typecheck - run: npm test + client: + runs-on: ubuntu-latest + defaults: + run: + working-directory: paint-app + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: paint-app/package-lock.json + - run: npm ci + - run: npm run lint + - run: npm run build + build: - needs: test + needs: [server, client] runs-on: ubuntu-latest permissions: contents: read @@ -51,8 +71,9 @@ jobs: - uses: actions/checkout@v4 # The Pi is arm64 and these runners are amd64. Emulation is slow but the - # only compile step is `tsc`; the native serialport binding comes down as - # a prebuilt arm64 binary rather than being built here. + # only compile steps are `tsc` and `vite build`; the native serialport + # binding comes down as a prebuilt arm64 binary rather than being built + # here. - uses: docker/setup-qemu-action@v3 - uses: docker/setup-buildx-action@v3 @@ -76,7 +97,7 @@ jobs: - uses: docker/build-push-action@v6 with: - context: paint-app/server + context: paint-app platforms: linux/arm64 push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.meta.outputs.tags }} diff --git a/paint-app/.dockerignore b/paint-app/.dockerignore new file mode 100644 index 0000000..c20db4d --- /dev/null +++ b/paint-app/.dockerignore @@ -0,0 +1,15 @@ +node_modules +dist +dist-electron +release +server/node_modules +server/dist +*.log +*.tsbuildinfo +.DS_Store +.vite +experiments +deploy.sh +README.md +server/README.md +server/docker-compose.example.yml diff --git a/paint-app/server/Dockerfile b/paint-app/Dockerfile similarity index 60% rename from paint-app/server/Dockerfile rename to paint-app/Dockerfile index a50b7cd..2da56c2 100644 --- a/paint-app/server/Dockerfile +++ b/paint-app/Dockerfile @@ -1,3 +1,5 @@ +# One image: the renderer and the server it is served from. +# # Debian, not Alpine, on purpose. # # `serialport` is a native addon. Its prebuilt binaries are glibc; on musl there @@ -5,22 +7,37 @@ # needs a whole toolchain — under QEMU that turns a 30-second build into a very # long one, for a runtime that then behaves subtly differently. bookworm-slim is # a few MB larger and just works. -FROM node:22-bookworm-slim AS build -WORKDIR /app +# ---------------------------------------------------------------- renderer +FROM node:22-bookworm-slim AS client +WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci -COPY tsconfig.json tsconfig.build.json ./ +COPY tsconfig.json tsconfig.app.json tsconfig.node.json vite.config.ts index.html ./ COPY src ./src +# The client imports the wire types straight out of the server rather than +# keeping a copy of them, so the protocol cannot drift between the two halves +# of an emergency stop. Types only — nothing here ends up in the bundle. +COPY server/src/protocol.ts ./server/src/protocol.ts +RUN npm run build + +# ------------------------------------------------------------------ server +FROM node:22-bookworm-slim AS build +WORKDIR /app +COPY server/package.json server/package-lock.json ./ +RUN npm ci +COPY server/tsconfig.json server/tsconfig.build.json ./ +COPY server/src ./src RUN npm run build # Reinstall with dev dependencies pruned. `npm ci --omit=dev` from scratch is # what gets the right prebuild for the target arch into the runtime layer. FROM node:22-bookworm-slim AS deps WORKDIR /app -COPY package.json package-lock.json ./ +COPY server/package.json server/package-lock.json ./ RUN npm ci --omit=dev +# ----------------------------------------------------------------- runtime FROM node:22-bookworm-slim WORKDIR /app ENV NODE_ENV=production @@ -35,7 +52,12 @@ RUN apt-get update \ COPY --from=deps /app/node_modules ./node_modules COPY --from=build /app/dist ./dist -COPY package.json ./ +COPY --from=client /app/dist ./client +COPY server/package.json ./ + +# The UI and the API come off the same port, from this process. There is no +# second web server to run, and nothing to keep in sync between them. +ENV CLIENT_DIR=/app/client # The node user needs to be in the group that owns the tty device, or opening # it fails with EACCES. On Raspberry Pi OS that group is `dialout` (GID 20). diff --git a/paint-app/README.md b/paint-app/README.md index ccf6e44..e8ce728 100644 --- a/paint-app/README.md +++ b/paint-app/README.md @@ -1,8 +1,13 @@ # paint-app -A browser-based paint app that draws on virtual pages with stacked, color-coded -layers and emits G-code to a USB-connected pen plotter (a Creality Ender-3 V3 -SE running stock Marlin in this repo). +A paint app that draws on virtual pages with stacked, color-coded layers and +emits G-code to a pen plotter (a Creality Ender-3 V3 SE running stock Marlin in +this repo). + +The plotter is a network appliance, not a USB peripheral: [`server/`](server/README.md) +runs on a Raspberry Pi wired to the machine, owns the serial port, and serves +this UI. Open it from a laptop, start a page, walk away, and swap pens from a +phone. One container, one port, no desktop app. ## Stack @@ -12,22 +17,27 @@ SE running stock Marlin in this repo). - Zod for runtime schemas - Framer Motion for layer drag-and-drop reordering - Biome for lint + format -- [Web Serial API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Serial_API) for plotter I/O — Chrome / Edge only +- A REST + WebSocket client (`src/plotterClient.ts`) against the plotter server + — any modern browser, no Web Serial, no desktop shell ## Develop ```sh npm install -npm run dev +npm run dev # http://localhost:5173 +PLOTTER_SERVER=http://plotter.local:8080 npm run dev # against the Pi ``` -The dev server runs on `http://localhost:5173`. Web Serial requires a secure -context, which `localhost` qualifies as. +`vite dev` proxies `/api` and `/ws` to `http://localhost:8080` — run +`npm --prefix server run dev` alongside it, or point `PLOTTER_SERVER` at a real +Pi. In production none of this applies: the server serves the built page, so +the API is same-origin. ```sh -npm run build # production build -npm run check # biome lint + format with --write -npm run lint # biome check (no writes) +npm run build # production build -> dist/ +npm run check # biome lint + format with --write +npm run lint # biome check (no writes) +npm run docker:build # the arm64 appliance image, renderer and server together ``` ## Plotters @@ -113,22 +123,35 @@ strokes drawn while connected get plotted. star (3–32 points). Polygon/star spawn a small `sides` / `pts` input under the palette when active; they're built as polylines so they emit clean G-code paths just like freehand strokes. -- **Plotter output.** Toolbar → Connect, pick the `wchusbserial*` port, then - Print. The active page is sent: each visible layer becomes a block of G-code - (clipped to the page rectangle and translated to machine origin), with a - pause + audio chime + on-screen prompt between layers so you can swap pens. +- **Plotter output.** Home screen → pick the serial port the *server* can see + → Connect, then Print. The active page is turned into one job — a prologue, + a block of G-code per visible layer (clipped to the page rectangle and + translated to machine origin), an epilogue — uploaded in one request, and run + by the server. Between layers the job parks in `awaiting_pen_swap`; every + connected device shows the prompt and any of them can continue it. Closing + the tab does not orphan the print. +- **One controller.** Several browsers can watch the same plotter; exactly one + may move it. The top bar always says which you are. Taking control is + allowed mid-print, so the dialog says so before you do it. +- **Emergency stop.** Fixed bottom-right whenever the plotter is connected, + from any device, whether or not you hold control. It does not travel over the + command socket — see the emergency stop section of + [`server/README.md`](server/README.md), including what it does *not* + guarantee. + +## Persistence is per-browser + +Projects and plotters live in this browser's IndexedDB, not on the server. The +page comes from the Pi but your work does not: open the app on your phone and +you get an empty project list. Export/import JSON is the way to move a project +between devices. This is a deliberate hole, not an oversight — moving +persistence to the server is a separate decision. ## Network backend (`server/`) -Web Serial means the browser has to be on the machine holding the USB cable — -one laptop, tethered, for the whole print. [`server/`](server/README.md) is a -Node backend that owns the port instead, so the plotter can live on a Raspberry -Pi and be driven from anywhere on the network. - -It is not wired into this app yet. The client still talks Web Serial and is -unchanged; swapping it over is a follow-up. See -[`server/README.md`](server/README.md) for the protocol and the deployment -notes. +[`server/`](server/README.md) owns the serial port, runs the send loop next to +the USB cable, holds the job state machine, and serves this UI. See its README +for the protocol, the emergency-stop guarantees, and the Pi deployment notes. ## Hardware notes @@ -143,13 +166,19 @@ src/ App.tsx # shell + connection wiring store.tsx # Context + useReducer app state types.ts # Zod schemas / TS types - serial.ts # Web Serial wrapper (PlotterConnection) + plotterClient.ts # REST + WebSocket client for server/ + connection.tsx # React context over it: status, session, job, log gcode.ts # stroke -> G-code generator svg-import.ts # SVG -> sampled strokes clip.ts # Liang-Barsky polyline-vs-rect clipping components/ - Toolbar.tsx + Toolbar.tsx # control lock, pause, emergency stop + SerialPortRow.tsx # the server-side port picker LayersPanel.tsx Canvas.tsx - PrintModal.tsx + PrintModal.tsx # uploads the job, renders the server's state +Dockerfile # one image: this build + server/, arm64 ``` + +`plotterClient.ts` is exercised end to end in `server/src/client.test.ts`, +against the same scripted Marlin the server is tested with. diff --git a/paint-app/server/docker-compose.example.yml b/paint-app/docker-compose.example.yml similarity index 71% rename from paint-app/server/docker-compose.example.yml rename to paint-app/docker-compose.example.yml index fcdd665..f556d3d 100644 --- a/paint-app/server/docker-compose.example.yml +++ b/paint-app/docker-compose.example.yml @@ -2,6 +2,8 @@ # # docker compose up -d # +# The image contains the UI as well as the API, so http://plotter.local:8080 is +# the whole app. There is nothing else to run. services: plotter-server: image: ghcr.io/travisbumgarner/plotter-server:latest @@ -10,8 +12,11 @@ services: - "8080:8080" environment: PORT: "8080" - # Point at a mounted renderer build to serve the UI from the same origin. + # The renderer ships inside the image at /app/client and CLIENT_DIR + # already points there. Only override it to serve a build from the host. # CLIENT_DIR: /client + # Same-origin by default, since the server hands out the page. Only + # matters if you point a `vite dev` session at this box. # CORS_ORIGIN: "http://plotter.local:5173" # The container gets the device node itself... @@ -23,7 +28,7 @@ services: # Bind-mounting it does NOT create the device; `devices:` above does that. volumes: - /dev/serial/by-id:/dev/serial/by-id:ro - # - ../dist:/client:ro + # - ./dist:/client:ro # `devices:` grants the node, not permission to open it. The tty is owned by # root:dialout, so the container's user has to be in that group. Check the diff --git a/paint-app/package-lock.json b/paint-app/package-lock.json index dc7c4c3..450f1bc 100644 --- a/paint-app/package-lock.json +++ b/paint-app/package-lock.json @@ -20,6 +20,7 @@ }, "devDependencies": { "@biomejs/biome": "^2.4.14", + "@types/node": "^24.13.3", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", @@ -1121,6 +1122,16 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, "node_modules/@types/parse-json": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", @@ -2137,6 +2148,13 @@ "node": ">=14.17" } }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, "node_modules/vite": { "version": "8.0.11", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.11.tgz", diff --git a/paint-app/package.json b/paint-app/package.json index 112e51c..944e220 100644 --- a/paint-app/package.json +++ b/paint-app/package.json @@ -9,7 +9,8 @@ "preview": "vite preview", "lint": "biome check src", "format": "biome format --write src", - "check": "biome check --write src" + "check": "biome check --write src", + "docker:build": "docker buildx build --platform linux/arm64 -t plotter-server:local ." }, "dependencies": { "@emotion/react": "^11.14.0", @@ -24,6 +25,7 @@ }, "devDependencies": { "@biomejs/biome": "^2.4.14", + "@types/node": "^24.13.3", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", diff --git a/paint-app/server/.dockerignore b/paint-app/server/.dockerignore deleted file mode 100644 index 0321711..0000000 --- a/paint-app/server/.dockerignore +++ /dev/null @@ -1,6 +0,0 @@ -node_modules -dist -*.log -.DS_Store -README.md -docker-compose.example.yml diff --git a/paint-app/server/README.md b/paint-app/server/README.md index 04e729e..6de6a9a 100644 --- a/paint-app/server/README.md +++ b/paint-app/server/README.md @@ -4,8 +4,9 @@ The serial backend for `paint-app`. It owns the USB link to the plotter so the UI can be served from a Raspberry Pi that is physically wired to the machine, instead of only from a laptop with the cable plugged into it. -This is **backend and API only**. The React client still talks Web Serial and is -unchanged; swapping it over is a follow-up. +It also serves the built renderer, so the whole thing is one container on one +port: `CLIENT_DIR` points at a `vite build` output and the SPA is handed out +from the same origin as the API. See `../Dockerfile`. ## Why a backend at all @@ -15,7 +16,7 @@ the plotter is a network appliance: start a page from anywhere, walk away, and swap pens from a phone. Almost all of the value is in `src/plotter.ts`, which is a port of -`paint-app/src/serial.ts` — a pile of Marlin-specific behaviour that was +`paint-app/src/serial.ts` (now deleted) — a pile of Marlin-specific behaviour that was expensive to discover and would have to be rediscovered from scratch in another language: @@ -108,8 +109,9 @@ jog and e-stop want a socket that is already open. Not WebSocket-only either: uploading a job is a request with a response, and e-stop over plain HTTP is a useful thing to have when the socket is the broken part. -Every type below is exported from `src/protocol.ts`, which is dependency-free so -the client can import it verbatim. +Every type below is exported from `src/protocol.ts`, which is dependency-free +and imported directly by `paint-app/src/plotterClient.ts` — one definition, so +the two halves of an emergency stop cannot drift apart. ### REST @@ -165,9 +167,14 @@ with a matching `ack`. | `job.continue` | yes | Pen swapped, carry on | | `job.pause` / `job.resume` | yes | Gates before the next line | | `job.cancel` | yes | | -| `jog` | yes | `{ lines }`, allowlisted, refused mid-layer | +| `jog` | yes | `{ lines }`, allowlisted, refused mid-layer, acks with the reply lines | +| `stream` | yes | `{ lines }`, same allowlist, honours pause, for interactive strokes | | `position` | yes | `M114`, parsed | +`jog` bypasses the pause gate because operator moves are the point of having +paused; `stream` does not, because an interactive stroke is exactly the output +pause exists to hold back. + Server to client: | Type | | @@ -209,8 +216,10 @@ control lock, the write queue, the pause gate, or the `ok` of whatever is in flight. It is reachable from any client, controller or not, over the socket or over `POST /api/estop`. Everything waiting on a reply is failed immediately rather than left to time out, and the running job is failed rather than drained. -`src/plotter.test.ts` asserts this by ordering and by wall clock, and the tests -fail if the bypass is removed. +`src/plotter.test.ts` asserts this by ordering and by wall clock, and +`src/client.test.ts` asserts the same of the browser client: it fires from a +read-only observer that cannot send a single G-code line by the ordinary route, +and it fires with the session socket shut. Both fail if the bypass is removed. Not guaranteed by this code: that the stop reaches the board. It travels over your network, through this process, over USB, and relies on Marlin's @@ -233,6 +242,11 @@ src/ server.ts # express routes + websocket wiring config.ts index.ts # entry, graceful shutdown + client.test.ts # the renderer's API client, end to end against this server test/ fakeMarlin.ts # a scripted board: ok, banners, echo:busy, unplug, M112 ``` + +`client.test.ts` tests code that lives in `../src/plotterClient.ts`. It sits here +because this is where the fake board and the real HTTP server are, and a client +tested only against a mock of its own server proves nothing. diff --git a/paint-app/server/package.json b/paint-app/server/package.json index 9e3cae9..42b1b64 100644 --- a/paint-app/server/package.json +++ b/paint-app/server/package.json @@ -17,7 +17,6 @@ "lint": "biome check src scripts", "format": "biome format --write src scripts", "check": "biome check --write src scripts", - "docker:build": "docker buildx build --platform linux/arm64 -t plotter-server:local .", "release": "node scripts/release.mjs" }, "dependencies": { diff --git a/paint-app/tsconfig.node.json b/paint-app/tsconfig.node.json index 2626445..307863b 100644 --- a/paint-app/tsconfig.node.json +++ b/paint-app/tsconfig.node.json @@ -16,6 +16,10 @@ "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true, + // The vite config reads PLOTTER_SERVER out of the environment to point the + // dev proxy at a Pi. + "types": ["node"], + "skipLibCheck": true }, "include": ["vite.config.ts"] diff --git a/paint-app/vite.config.ts b/paint-app/vite.config.ts index 0466183..6aa441b 100644 --- a/paint-app/vite.config.ts +++ b/paint-app/vite.config.ts @@ -1,6 +1,20 @@ -import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; +import { defineConfig } from 'vite'; + +/** + * In production the plotter server serves this build itself, so the API is + * same-origin and none of this applies. `vite dev` is the odd case: the page + * comes from :5173 and the server is elsewhere, so proxy the two paths it owns. + * `PLOTTER_SERVER` points the proxy at a Pi on the LAN instead of localhost. + */ +const target = process.env.PLOTTER_SERVER ?? 'http://localhost:8080'; export default defineConfig({ plugins: [react()], + server: { + proxy: { + '/api': { target, changeOrigin: true }, + '/ws': { target, ws: true, changeOrigin: true }, + }, + }, });