@leumas/machines
Drive a machine that makes something — 3D printers, CNC mills, routers and laser engravers. An isomorphic core (G-code lexer, dialect table, ONE credit-based streamer covering both Marlin's ok-acking...
@leumas/machines
Drive a machine that makes something — a 3D printer, a CNC mill or router, a laser engraver.
Studio's Devices domain could already see almost every kind of device a customer owns and could drive none of them. This is the layer underneath the Printers, CNC / PCB and Serial tabs: a G-code lexer, a firmware dialect table, one streamer that covers both flow-control models, a job runner and the safety interlocks. It does no I/O; transports are injected, so the same session runs over a host serial port, over a LeumasNode, and over a mock.
Roadmap: ops/todos/devices-machines-roadmap.md.
The one idea
A printer, a mill and a laser are the same code path plus a small table. Everything that differs between firmwares is declared in a dialect; everything above the dialect is written once.
import { createStreamer } from '@leumas/machines';
import { dialectFor } from '@leumas/machines/dialects';
const streamer = createStreamer({ dialect: dialectFor('grbl'), write, writeBytes });
streamer.enqueue('G1 X10 Y10 F1000');
streamer.feed(dialect.parseLine(lineFromTheWire)); // an ack frees a slot and sends the next line
await streamer.realtime('estop'); // bypasses the queue entirely
can is derived from what a dialect implements, never declared — so a surface renders a temperature control because can.setTemp is true, and there is no kind === 'fdm' test anywhere above this layer. Delete encode.setTemp from a dialect and the temperature controls disappear.
Layout
src/vocab.js · limits.js | the frozen vocabularies and every bound, with the measurement behind each |
src/gcode/{lex,lines,modal,scan}.js | one line classified; chunks → lines; modal state; the preflight |
src/dialect.js | defineDialect / extendDialect — validated at definition time |
src/dialects/ | marlin · grbl · fluidnc (≈20 lines) · reprap (≈25 lines) |
src/streamer.js | the one hard file — see below |
src/interlocks.js | temperature ceiling, jog-while-running, envelope, the laser gate |
src/host/ | Node transports over @leumas/serial (lazy — absent is unavailable, never a crash) |
src/mock/ | virtual Marlin and GRBL with a real 128-byte buffer |
The streamer, and why it is one file
Marlin acks line-by-line; GRBL counts characters against a 128-byte receive buffer. Two senders would mean pause, resume, cancel, progress, checkpoints and the watchdog written twice. Instead both reduce to may I send the next line? and the difference is three declared fields.
Seven traps are written into that file, each a bug that has shipped in real G-code senders:
error:credits exactly likeok— GRBL frees the slot for a rejected line too. Credit only
on ok and the stream stalls forever after the first bad line. The most common bug in the genre.
busy:and status reports credit nothing — they renew the watchdog, that is all.- A line larger than the buffer deadlocks; it is refused up front instead.
Resend:is fatal, not a retry — lines are not numbered, so it cannot be honoured honestly.- Blocking commands withhold their
okfor minutes (M109heat soak,G28homing). One fixed
ack timeout kills every print at its first heat-up, so the timeout is per-line.
- Realtime bytes bypass the queue and are never charged. An emergency stop modelled as a queued
command waits behind every buffered move.
- Writes are serialised — fire-and-forget interleaves two lines on the wire.
Testing without hardware
src/mock/ ships a virtual Marlin and a virtual GRBL. The GRBL board throws on a buffer overrun rather than silently corrupting, so a sender that miscounts fails a test instead of ruining a workpiece. Its replies are copied from a real capture — including ok T:0.0 /0.0 B:0.0 /0.0 @:0 B@:0 (the temperature rides on the ack) and X:0.00 … Count X: 0 Y:0 (inconsistent spacing), both of which break a parser written against the tidy documentation examples.
pnpm --filter @leumas/machines test
What is deliberately absent
- Klipper. A Klipper machine's USB port speaks Klipper's own binary MCU protocol, not G-code; it
cannot be driven over serial at all. A klipper dialect would connect, say nothing, and never ack.
- Line numbering and checksums. See trap 4.
- A slicer.
@leumas/adapter-fabrication-planemits a toolchain descriptor and executes nothing.
What it exports
151 exports: 34 components, 0 hooks, 117 helpers. Import from @leumas/machines.
ART_MATERIALSART_MODESAXESCAM_PROFILESCLIPPING_INSTALL_HINTDEFAULT_LIMITSDIALECT_IDSDIALECTSDITHER_MODESENCODERSFLOW_CONTROLFRAME_MAX_PERCENTGRBL_RX_BUFFERIMAGE_DECODER_INSTALL_HINTIMAGE_FORMATSJOB_STATESLINK_KINDSMACHINE_KIND_LABELSMACHINE_KINDSMACHINE_STATESMAX_DECODED_PIXELSMAX_ENGRAVE_PIXELSMAX_IMAGE_BYTESMAX_MODEL_BYTESMODEL_FORMATSORIGINSORIGINSPCB_TOOLSPROGRAM_EXTENSIONSREALTIME_ACTIONSSERIAL_INSTALL_HINTSLICE_TIMEOUT_MSSLICERSSTROKE_FACESacceptsMotionartLimitationsartMaterialChoicesartMaterialForbuildRasterIrbuildToolpathbuildVectorIrcamProfileChoicescamProfileForcanFireLasercanJogcanRunUnattendedcapabilitiesOfcheckEnvelopecheckLaserPowercheckLinecheckProgramcheckTemperatureclassifyLineconvexHullcreateJobRunnercreateLineSplittercreateMachineSessioncreateMockMachinecreateModalTrackercreateSerialTransportcreateStreamercreateVirtualGrblcreateVirtualMarlincustomPrintercustomPrinterdecodePngdefineDialectdefinePrinterdefinePrinterdetectSlicerdialectFordialectsForKinddithereffectiveWidthengraveArtextendDialectfetchModelfitsEnvelopeflattenPathfluidncformatOfframeJobgerberLimitationsgrblidentifyDialectidentifyPrinteridentifyPrinterimageLimitationsinflateisAxisisMachineKindisMachineStateisolationRingsisRealtimeTextjobHoldsMachinelistPortslistPrinterslistPrinterslooksLikeProgrammachineFromPrintermachineFromPrintermachineFromProfilemakeOnMachinemakeOnMachinemarlinmatchDrillsmaterialRefusalsmillBoardnormalizeMachineopenMachineopenMachineoutlineRingoverscanForparseGrblLineparseMarlinLineparseTransformpcbLimitationspostEngravepostProcesspreflightpreflightprinterForprinterForprofileChoicesreadCircuitJsonreadCircuitJsonRichreadExcellonreadFingerprintreadFingerprintreadGerberreadImagereadModelreadSvgregisterPrinterregisterPrinterreprapresampleToresolveLimitsringsBoundsrunSlicersanitizeMachinescanProgramsliceModelsniffImagesourceFromLinesstripCommentsstrokeFaceChoicessvgLimitationstextPathstoGrayscaletranspose