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/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/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' }); 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; - } -} 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 }, + }, + }, });