Skip to content

paint-app: serve the app from the plotter server and retire Electron - #78

Merged
TravisBumgarner merged 3 commits into
mainfrom
plotter-server
Jul 29, 2026
Merged

paint-app: serve the app from the plotter server and retire Electron#78
TravisBumgarner merged 3 commits into
mainfrom
plotter-server

Conversation

@TravisBumgarner

@TravisBumgarner TravisBumgarner commented Jul 29, 2026

Copy link
Copy Markdown
Owner

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.

  • Start a print from a laptop, walk away, and swap pens from a phone. Every
    device in the session sees the same job, the same progress, the same pen-swap
    prompt.
  • Closing the tab no longer orphans a half-drawn page. The pen swap between
    layers is server state, not a Promise parked in a React component.
  • A page of art is uploaded once and run next to the USB cable, rather than
    paying a network round trip per line for thousands of lines.
  • The port picker is now the server's list of ports, because the server is the
    only thing that can see the cable. Names come from /dev/serial/by-id, so
    they survive a replug.
  • One client controls, the rest observe. The top bar always says which you are —
    otherwise the first surprise is a Pause button that does nothing.
  • Emergency stop is offered to every device including read-only observers, and
    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.
  • One container, one port. docker compose up -d on the Pi is the whole
    install; there is no separate UI to deploy or keep at the same version.
  • The Electron shell is retired. It existed only to give Web Serial a secure
    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-printer

That 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 change
what should happen to some of it:

  • ce8e6df Electron desktop shell and dark mode — split verdict. The
    Electron half is superseded outright: electron/main.ts, the preload, the
    dev launcher, and the electron-builder config all existed to work around
    Web 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.
  • ae1e20b Treat plotters as printers — keep, with rework. Making the
    plotter a global session choice rather than a document property is orthogonal
    to the transport and is the right call. But PlotterControls.tsx is built
    around a connect() that takes no arguments, so it needs the server-side port
    picker and the control indicator folded in; it can then replace the connect
    row on the home screen, which is arguably where it belonged anyway.
  • The other eight commits (photo studio, connected data util, page sizes)
    touch none of this and rebase cleanly.

Key Concerns

Nothing here has met a real plotter. Tests run against a simulated Marlin.
The linux/arm64 image was built and run — it serves the SPA, falls deep links
through 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 M112 is written to the
port without waiting on the control lock, the write queue, the pause gate, or
the ok of whatever is in flight, and that any client can fire it — including
one 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 M112 actually
halts mid-move on this board; whether Marlin's reply timing survives a drain()
after every write; whether an unplugged cable surfaces as a close event rather
than a hang; whether the 2000ms boot wait is still right over a Pi's USB stack.

Second-guess these:

  • Control handover is unconditional — a takeover mid-print is allowed. The UI
    now spells out what it costs the other person before you confirm, but two
    people can still fight over a running job.
  • Jog is gated behind an allowlist (G0/G1/G28/G90/G91/M114/M84/…). It may be
    too narrow for calibration, which is the flow that most needs it, and the
    Debug panel's raw-command box now inherits that limit.
  • Shutdown lifts Z by 5mm relative before M84, to stop the pen bleeding into
    the page. If the gantry is near the top of its travel that is a blind move.
  • Jobs live in memory only. A server restart mid-print loses the job as well as
    the print.
  • CORS_ORIGIN defaults to * and there is no auth. Anyone who can reach the
    Pi 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.
  • Interactive mode streams a whole stroke per message and waits for its ack. On
    a slow link that will feel laggier than the tethered version did, and there is
    no back-pressure story beyond the queue.
  • A dropped socket reconnects as a new client with a new id. The session list
    will show a stale entry until the heartbeat reaps it, and control may have
    moved on in the meantime.

🤖 Generated with Claude Code

TravisBumgarner and others added 3 commits July 28, 2026 19:53
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>
@TravisBumgarner
TravisBumgarner merged commit a0e3975 into main Jul 29, 2026
2 checks passed
@TravisBumgarner TravisBumgarner changed the title paint-app: run the plotter serial link from a Node backend paint-app: serve the app from the plotter server and retire Electron Jul 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant