A CNC-first web front end for RepRapFirmware controllers.
DWC is an excellent 3D-printer interface, and that is the problem: on a router its information architecture is shaped around a machine that does a different job. Axis Control is arranged around the things a CNC operator actually reaches for — work coordinates, jogging, tool length, probing, and watching a program run — and it is honest about what the controller can and cannot do rather than showing buttons that quietly fail.
It is written against RepRapFirmware's documented HTTP API, contains no DWC or
RRF code, and is not a replacement for either: firmware updates, the config
tool and network setup are all still DWC's job, and DWC stays installed at /.
This drives machinery that can injure you. It moves a spindle and a gantry, and it will do exactly what it is told. Nothing here replaces your own judgement: verify every generated program before you run it, keep a hand on the stop, and treat the simulation as a drawing rather than a promise. See the warranty and liability sections of LICENSE.
Twenty-one panels, arranged onto pages you lay out yourself:
- Position — work and machine coordinates, WCS selection (G54–G59.3), zeroing
- Motion — a jog rose of concentric rings, eight directions each, with distances that are always numbers a person would choose
- Spindle & Tools — the active tool stated large, ATC slots, and tool
libraries importable straight from a Fusion 360
.toolsexport - Toolpath — WebGL viewer with live cutter tracking, a time slider, run-from-line picking, and the actual cutter drawn to size
- Probing — separate flows for tool length, workpiece and feature probing, which are three different jobs and never conflated
- Machining, Surfacing, Import — conversational operations, spoilboard flattening, and SVG/DXF import with tool-radius offsetting
- Tool changer — set up a RapidChange ATC and install its macros: pocket geometry checked against the machine's own travel, every file shown before it is written, and nothing run for you
- Camera — live H.264 over HTTP-FLV where the camera allows it, pipelined stills where it does not, with pan/tilt and lighting for Reolink. Click the picture to bring that point to the middle, double-click to zoom in on it
- Job, Console, Files, Macros, Object model, Diagnostics, Preflight
Every field that holds a position has a crosshair beside it that fills it in from where the machine is standing — in machine or work coordinates, whichever that field actually means, and refused with a reason when the axis it needs is not homed. Depths and diameters take it too: jog down to depth, or out to the wall, and press the button.
Dependencies, all permissive: Lit for templating, dockview for the layout, clipper-lib for polygon offsetting, and mpegts.js for camera video — the last loaded only when video is actually attempted. About 225 KB gzipped for the app, plus 62 KB for the video demuxer if it is ever needed.
npm install
npm run dev # esbuild watch + dev server on :8080
npm run build # dist/, with .gz siblings for the controller
npm run typecheck
A Reolink answers every request but sends no Access-Control-Allow-Origin, and
without that header a browser will not hand the reply to a page served from
anywhere else. It is not a camera setting — the firmware is built for Reolink's
own app and for NVRs, neither of which is a browser — so the panel says
blind and works from what it can do without an answer.
That is most of it. Commands need no reply: pan, tilt, presets, zoom by the buttons, click-to-aim, IR and the spotlight. What needs an answer does not work: the model, the live state of those controls, preset names, day/night (which has to read the whole image block before writing it back), the zoom slider, and live video — which unlike the snapshots is an ordinary fetch.
Forwarding the same requests through something that does add the header gets all of it back:
npm run camera-proxy -- http://192.168.1.40 # listens on :8100
Then point the camera panel at http://<that-host>:8100 instead of the camera.
Run it somewhere that is always on — a Pi, a NAS, whatever serves the app — not
a laptop that sleeps. It adds no authentication of its own, so put it only where
you would be willing to expose the camera itself.
node tools/mock-rrf.mjs # mock controller on :8081, also serves dist/
node tools/mock-camera.mjs 8090 # camera, no CORS headers
node tools/mock-camera.mjs 8091 --cors # camera, CORS headers
The mocks are deliberately unkind. Over the course of building this they grew every trap the real hardware sprang: sparse arrays with genuine holes, replies in mm/s where the docs imply mm/min, a directory long enough to need scrolling, an endpoint that accepts a connection and then says nothing at all. Each one is there because something shipped broken without it.
The mock implements the rr_* endpoints against a synthetic object model
shaped like the real Ultimate Bee: X/Y/Z plus the U dust-shoe axis, a
0–24000 rpm VFD spindle, the 8-slot RapidChange ATC, and the atc*/dustShoe*
globals a real ATC configuration declares. It simulates motion and job progress, so the DRO
moves and the viewer's live cutter tracking has something to follow. Two sample
programs are included, one with arcs and a full circle to exercise the
tessellator.
Open http://localhost:8081 and it connects to itself.
Either host it anywhere on the LAN and point it at the controller, or copy
dist/ onto the SD card:
cp -r dist/* /path/to/sd/www/axis/
then browse to http://<controller>/axis/. Ship the .gz files alongside the
originals; the Duet serves them when the browser sends Accept-Encoding: gzip,
which matters because it reads off the SD card single-threaded.
Served from anywhere other than the controller, cross-origin rules apply, so
the controller needs M586 C"*" in its network config. Note what that does
not fix: RRF answers a CORS preflight with nothing useful, so cross-origin
requests must stay inside the CORS-simple envelope — which is why the driver
drops the session-key header and falls back to RRF's implicit per-IP session
when it is not same-origin.
On iOS, Share → Add to Home Screen runs it full screen with no address bar.
src/
core/ signals (reactivity), app store, CRC32, helpers
machine/
types.ts vendor-neutral machine model — the only vocabulary panels see
driver.ts the MachineDriver contract
registry.ts driver list
drivers/
rrf/ RepRapFirmware: HTTP transport, object model, state mapping
carvera/ Makera Carvera / Z1 — stub, see its README first
ui/ panel base class, dashboard layout, top bar, prompt dialog
panels/ one file per panel, self-registering
viewer/ G-code parser, WebGL2 renderer, mat4
Panels read only machine/types.ts. Nothing above the driver layer may
import RRF object-model types or rr_* endpoint names. A second controller is a
new MachineDriver implementation, not a fork of the UI.
Each driver publishes a Capabilities record, and panels hide themselves when
the active controller can't back them — so a partially-implemented driver still
yields a coherent UI instead of dead buttons. The object-model browser is the
one panel that deliberately reaches through driver.native, gated on
capabilities.objectModel.
Standalone RRF has no WebSocket (reserved in the API, unimplemented — the board has ~8 sockets and half may go to non-HTTP services). So the driver polls:
- one cheap
rr_model?flags=d99fnper tick for live values across the whole tree, plusseqs; - a full
rr_model?key=<k>&flags=d99vnonly for subtrees whose sequence number moved; rr_replywhenseqs.replyadvances.
250 ms while active, 500 ms when idle. Replies are buffered per HTTP client on
3.5+, so running this alongside DWC and tools/grr.py doesn't steal output.
Sessions use sessionKey/X-Session-Key where available, falling back to
implicit per-IP sessions on older firmware.
The parser records the source byte offset on every vertex. RRF reports
job.filePosition as a byte offset, so comparing the two in the fragment shader
is what draws the cut/uncut boundary and places the live cutter. A parser that
discards offsets can draw a toolpath but can never track a running job.
- Implement
MachineDriverundersrc/machine/drivers/<name>/. - Set
Capabilitieshonestly as you go. - Register a factory in
src/machine/registry.ts.
No panel changes. See drivers/carvera/README.md — note especially that the
Carvera/Z1 speaks raw TCP or USB serial rather than HTTP, so it needs WebSerial
or a small WebSocket⇄TCP bridge. connect(config) takes a URL string precisely
so that choice stays inside the driver.
Used daily on one machine — a 750x1500 router with an 8-slot RapidChange ATC, a 2.2kW spindle on a Huangyang VFD, and a U-axis dust shoe — which is to say it is proven on a sample of exactly one. The RRF driver is complete enough for real work; the Carvera/Z1 driver is a stub.
Known gaps: job history, an ATC carousel view, and no automated test suite — verification so far has been a real headless browser driven against the mocks, which has caught a great deal but is not the same thing.
Browser support goes back further than you would expect: the bundle targets ES2019, and there is a compatibility layer for Safari 12 (an iPad mini 2 makes a fine control screen). WebGL2 is used where present and WebGL1 where it is not.
Issues and pull requests welcome. Two things to know:
- The driver boundary is the point. Panels see only
machine/types.ts. If a change needs anrr_*endpoint name above the driver layer, it belongs in the driver instead. - Say what you verified. This drives machinery; "should work" and "tested against the mock" and "ran it on my machine" are three very different claims and the review will ask which one it is.
Apache-2.0 — see LICENSE, NOTICE and THIRD-PARTY-NOTICES.md.
RepRapFirmware and Duet Web Control are GPL-3.0 and are separate programs; this talks to them over HTTP and contains none of their code.
M292is sent withS<seq>and, for input prompts,R<value>. Both are 3.5+ additions — on older firmware, drop them for a bareM292 P0.- Hold-to-jog fires repeated discrete relative moves; there is no continuous-jog command over HTTP polling. A pendant will always feel better than a browser.
/sysfiles are editable but deliberately not runnable — handingconfig.gtoM32would try to execute it as a job.

