paint-app: serve the app from the plotter server and retire Electron - #78
Merged
Conversation
Web Serial puts the port in the browser, which means the browser has to be on the machine holding the USB cable. That is one laptop, tethered, for the length of a print. Moving the port to a service makes the plotter a network appliance: start a page from anywhere, walk away, swap pens from a phone. Node rather than Python because src/serial.ts is not a serial wrapper, it is a pile of Marlin quirks that were expensive to find — the 2000ms boot wait after Marlin resets on open, M115 as a liveness probe so a killed board fails fast instead of twenty seconds later inside G28, echo:busy treated as a keepalive against an inactivity timeout rather than a deadline because G28 takes as long as it takes, the close-and-reopen when the OS has not released a port, the single delayed retry, and pausing that blocks before the *next* line. Porting that to another language means rediscovering all of it. Three things had to change shape to survive the move: Jobs upload whole. Marlin's flow control is lockstep — write a line, await ok — which is right over USB and ruinous over a network, where a page is thousands of round trips. The client posts the program once and watches; the send loop runs next to the cable. The pen swap is server state. It used to be a Promise parked in a React component, so closing the tab orphaned a half-drawn page. It is now running -> awaiting_pen_swap -> running, and any client can continue it. Emergency stop gets its own path. M112 is written straight to the port: no control check, no write queue, no pause gate, no waiting on the ok of whatever is in flight, and reachable over both the socket and plain HTTP. Everything blocked on a reply is failed at once rather than left to time out. Tests assert this by ordering and by wall clock, and fail if the bypass is removed. Ownership is explicit because Electron's single-instance lock does not survive a network: one controller may move the machine, the rest are read-only, handover is deliberate, and e-stop is exempt. The existing Web Serial path is untouched. Swapping the client over is a follow-up. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Pi is the deployment target, so the artifact is a container rather than a checkout to keep in sync by hand. Tags are prefixed plotter-server- so image releases do not collide with the Python package's versioning elsewhere in this repo. Debian rather than Alpine: serialport is a native addon whose prebuilt binaries are glibc. On musl there is no prebuild, so the install compiles from source and the image needs a toolchain — under QEMU that is a very long build for a runtime that then behaves differently. The runtime also installs udev. SerialPort.list() shells out to udevadm on Linux, and without it enumeration fails with "spawn udevadm ENOENT" — the server starts fine and simply has no ports to offer, which is a confusing way to discover a missing package. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Without a config of its own, vitest climbs the tree and loads the renderer's vite.config.ts, which imports React plugins this package has no reason to install. It passed locally only because the parent's node_modules happened to be there; CI, which installs just this package, could not resolve vite at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Web Serial puts the port in the browser, so the browser has to be on the machine
holding the USB cable — one laptop, tethered, for the length of a print. This
moves the plotter onto a Raspberry Pi: the Pi owns the serial port and serves
the page, and everything talks to it over the network.
device in the session sees the same job, the same progress, the same pen-swap
prompt.
layers is server state, not a
Promiseparked in a React component.paying a network round trip per line for thousands of lines.
only thing that can see the cable. Names come from
/dev/serial/by-id, sothey survive a replug.
otherwise the first surprise is a Pause button that does nothing.
does not travel over the command socket: no control check, no queue, no pause
gate, and it works over plain HTTP if the socket is the broken part.
docker compose up -don the Pi is the wholeinstall; there is no separate UI to deploy or keep at the same version.
context and a port picker, and both problems are gone.
Your projects do not follow you between devices
Projects and plotters still live in the browser's IndexedDB. The page comes from
the Pi but your work does not — open the app on your phone and you will get an
empty project list. Export/import JSON is the only way to move a project
between devices today.
This is deliberate, not an oversight: moving persistence to the server is a
separate decision with its own questions (who owns a project, what happens when
the SD card dies, whether the Pi becomes something you have to back up). But it
will be the first thing that surprises you.
What happens to
plotter-as-printerThat branch has ten unmerged commits. This PR does not delete anything on it —
it branched from
main, where none of that work exists — but it does changewhat should happen to some of it:
ce8e6dfElectron desktop shell and dark mode — split verdict. TheElectron half is superseded outright:
electron/main.ts, the preload, thedev launcher, and the
electron-builderconfig all existed to work aroundWeb Serial, and the app is now served over HTTP from a Pi. The dark mode half
(
theme.tsx, the persisted mode toggle, removing the hardcoded light theme)is unrelated good work and should be rebased on.
ae1e20bTreat plotters as printers — keep, with rework. Making theplotter a global session choice rather than a document property is orthogonal
to the transport and is the right call. But
PlotterControls.tsxis builtaround a
connect()that takes no arguments, so it needs the server-side portpicker and the control indicator folded in; it can then replace the connect
row on the home screen, which is arguably where it belonged anyway.
touch none of this and rebase cleanly.
Key Concerns
Nothing here has met a real plotter. Tests run against a simulated Marlin.
The
linux/arm64image was built and run — it serves the SPA, falls deep linksthrough to the shell, answers the API, and its port enumeration exercised the
real native binding — but no byte has reached a board.
Emergency stop. What this code guarantees is that
M112is written to theport without waiting on the control lock, the write queue, the pause gate, or
the
okof whatever is in flight, and that any client can fire it — includingone that is not allowed to send a single G-code line by the ordinary route, and
one whose session socket is shut. Tests assert all three and fail if the bypass
is removed. What it cannot guarantee is that the stop arrives: it crosses your
network, this process, and USB. A software stop over Wi-Fi is a convenience; the
thing that actually stops the machine is the power switch. Worth deciding
whether you are comfortable with a big red button in the UI implying otherwise.
Untested against hardware, and only testable there: whether
M112actuallyhalts mid-move on this board; whether Marlin's reply timing survives a
drain()after every write; whether an unplugged cable surfaces as a
closeevent ratherthan a hang; whether the 2000ms boot wait is still right over a Pi's USB stack.
Second-guess these:
now spells out what it costs the other person before you confirm, but two
people can still fight over a running job.
G0/G1/G28/G90/G91/M114/M84/…). It may betoo narrow for calibration, which is the flow that most needs it, and the
Debug panel's raw-command box now inherits that limit.
M84, to stop the pen bleeding intothe page. If the gantry is near the top of its travel that is a blind move.
the print.
CORS_ORIGINdefaults to*and there is no auth. Anyone who can reach thePi can take control of it and start a print. Fine for a LAN appliance, wrong
the moment that Pi is reachable from anywhere else — and "take control" being
a click away is now a networked capability, not a local one.
a slow link that will feel laggier than the tethered version did, and there is
no back-pressure story beyond the queue.
will show a stale entry until the heartbeat reaps it, and control may have
moved on in the meantime.
🤖 Generated with Claude Code