Duet3D / RepRapFirmware (RRF) β LAN Protocol¶
Status: π‘ source-read (docs.duet3d.com + the RRF wiki + the LGPL
@duet3d/connectors/@duet3d/objectmodellibraries; no Duet on the bench) Β· Firmware: RepRapFirmware 3.0+ Β· Models: Duet 2 / Duet 3 boards (WiFi/Ethernet/Mini/6HC/6XDβ¦), incl. multi-tool machines (E3D ToolChanger, Jubilee, industrial multi-head)RRF is one JSON Object Model behind two mutually-exclusive LAN dialects β standalone (a poll-only
rr_*HTTP API on:80) and SBC/DSF (a/machine/*REST API + one push WebSocket, served by a companion Raspberry Pi). Both converge on the identical Object Model, so the read logic is written once; only transport/auth/upload differ.
At a glance¶
- Transport: HTTP
:80(HTTPS only behind a reverse proxy / operator TLS). Standalone = poll-onlyrr_*endpoints, no push channel at all. SBC/DSF =/machine/*REST + a single push WebSocket at/machine. - Discovery: manual host/IP is the guaranteed path;
M550-configured<name>.localhostname mDNS/NetBIOS works where honoured. No DNS-SD service, no TXT, no SSDP/UPnP. - Auth / credential: a machine password β a session β an
X-Session-Keyheader on every later request. The default password is the well-knownreprap; the owner sets/reads their own. No certs, nothing to bundle. - Read / status: the RRF Object Model (one shared JSON tree). Standalone polls it differentially (
rr_model+ aseqschange-counter map); SBC pushes full-model-then-patch over the WebSocket. - File transfer: a raw-body upload in both modes β standalone
POST /rr_upload(+ optional CRC32); SBCPUT /machine/file/{path}. Not multipart. - Print launch: upload β launch. Upload the file, then send a GCode:
M32 "<path>"(select and start) orM23 "<path>"+M24(select, then start). - Feeders / multi-material: none (RRF has no per-slot MMU model). It is instead a toolchanger leader β
tools[]is a first-class array of physical toolheads. - β οΈ The load-bearing gotcha: the live status value for printing is
processing(there is no value literally namedprinting), and progress is a file-byte fraction, not time β two independent traps that each silently corrupt state and ETA if you assume otherwise.
Transport & connection¶
RRF speaks one of two wire dialects, never both, and a client picks between them at connect time. π‘
Standalone (rr_* on :80) β the RRF web server answers a fixed set of query-style endpoints under the host root
(http://<host>/rr_connect, β¦/rr_model, β¦). There is no MQTT, WebSocket, SockJS, or SSE β every piece of live
state is obtained by polling. HTTPS appears only if a reverse proxy fronts the board; a client just prefixes each
rr_* path with <protocol>//<host><basePath>. π‘
SBC / DSF (/machine/* + WS) β what a Duet 3 serves when paired with a Raspberry Pi running the Duet Software
Framework. The Pi's DuetWebServer hosts a REST API under /machine/* (default :80, TLS when configured) and a
single push WebSocket at /machine; DSF bridges to the mainboard over SPI. Same Object Model, different wire. π‘
The mode fingerprint β the first-party connector library resolves the dialect with an ordered probe that short-circuits on a login-class error: π‘
- Probe standalone first:
GET /rr_connect?password=β¦&sessionKey=yes.err:0+ a usable API level β standalone. - On a non-login failure (network error / 404 / non-JSON β i.e. the board doesn't speak
rr_*), fall through to SBC:GET /machine/connect?password=β¦, then open the/machineWebSocket and take its first frame as the model. - Stop immediately on a login-class result (wrong password, no free session, incompatible firmware) β that means "right transport, bad auth/version"; do not try the other dialect. βͺ (the composite router is inferred from two source-read facts: the probe order and the discriminator below)
In-band confirmation β once connected, the mode is unambiguous from the Object Model's top-level sbc key:
null/absent in standalone, populated only in SBC (the OM reference states it is "exclusively maintained by DSF in
SBC mode β¦ not available in standalone mode"). DSF version reads at sbc.dsf.version β there is no
state.dsfVersion field; don't invent one. Persist the resolved mode; re-probe only on hard failure. π‘
Version floor = RRF 3.0. The Object Model (and rr_model) were introduced in 3.0; 2.x has neither. The
connector rejects a board whose reported API level is absent/0 as too old. Refuse below 3.0 with a clear message β a
firmware-family floor, not a nag. Read the live version from boards[0].firmwareName / boards[0].firmwareVersion
(+ sbc.dsf.version in SBC mode). The RRFβDSFβDWC firmware bundle version-locks at a 3.x minor (current aligned
line 3.6.x); note the connector library is a separate version line, so don't conflate the two numbers. π‘/π΅
Discovery & identity¶
Manual host/IP is the guaranteed add path. A machine named with M550 P"name" in config.g resolves as
http://<name> / <name>.local on networks honouring mDNS/NetBIOS hostname resolution (name not
case-sensitive). π‘
β οΈ This is A-record hostname resolution, not a DNS-SD/
_http._tcpservice advertisement with TXT metadata. There is no_duet._tcpservice type, no TXT, and no SSDP/UPnP β a service-discovery capability remains an unshipped upstream wishlist request. So offer name resolution at most; do not promise a "scan the LAN for Duets" experience. βͺ (the absence is inferred β adns-sd -B/avahi-browsesniff against a real board would confirm it)
Standalone has no single "who are you" endpoint; identity is assembled from the connect response + the Object Model:
rr_connect returns a boardType string (e.g. a duetwifi102/duet3mb6hcβ¦-style identifier) and an isEmulated
flag (a client should refuse an emulated endpoint); durable identity/hostname come from OM boards[] and
network. See ../patterns/discovery-and-credentials.md. π‘
Credentials / auth¶
The only credential is a machine password, obtained by the owner from their own board β never bundled, never
hardcoded (see ../patterns/discovery-and-credentials.md). The RRF default
when unset is the well-known reprap; if no password is configured, RRF auto-creates a session on any HTTP
request. A client should prompt for the password, validate it with a successful connect, and store it encrypted; the
session key is ephemeral and should stay in memory only. π‘
Standalone handshake β GET /rr_connect?password=β¦&time=β¦&sessionKey=yes. The response carries: π‘
errβ0= ok;1= invalid password;2= no free session (session table full); any other value = generic login error.sessionTimeoutβ max idle time in milliseconds between requests before the session drops. The board returns the authoritative value on connect; a client-side seed of ~8000 ms is just a default, not a firmware constant. βͺsessionKey(RRF 3.5-b4+) β an integer the client must then send as theX-Session-Keyheader on every subsequentrr_*request. Older firmware ignores the request and binds the session to the client IP instead. π‘apiLevelβ absent/0β the board is pre-Object-Model (2.x-class) β reject;β₯1guaranteesrr_modelexists.isEmulatedβtrueβ refuse (an emulation, not real RRF).
Every rr_* request except rr_connect returns HTTP 401 without a valid session (idle-expired, firmware
restarted, or another client took the last slot). The correct recovery is a transport-level interceptor: on 401
(or 403), transparently re-issue rr_connect, refresh the stored X-Session-Key, and retry the original request
once. A second re-handshake trigger: watch state.upTime (seconds) β when it decreases between polls the board
rebooted, so re-connect and re-seed the full model. π‘
SBC handshake β GET /machine/connect?password=β¦ β {"sessionKey":"<key>"}, then the same X-Session-Key
header on every later request. A wrong password maps to 401 or 403 (map both). DSF older than 3.4-b4 had no
passwords β /machine/connect returns 404, and the client should fall back to sessionless requests. A client
not holding the WebSocket keeps the session alive by pinging GET /machine/noop; on a socket drop it re-mints the
key via /machine/connect and reopens the socket. π‘
Reading state¶
Both modes deliver the same RRF Object Model β a JSON tree keyed roughly:
state Β· heat Β· move Β· boards[] Β· tools[] Β· sensors Β· job Β· network Β· fans[] Β· spindles[] Β· limits Β· directories Β·
volumes[] Β· inputs[] Β· sbc (null in standalone) Β· seqs. The read mapping is written once; only how the tree
arrives differs. The neutral fields a client needs β lifecycle, temperatures, progress, capabilities β all extract
from this one tree. π‘
Standalone: a differential poll. GET /rr_model queries the model by key with flags characters controlling
shape: f = frequently-changing (live) subset, v = verbose, n = include nulls, o = obsolete fields, d<N> =
depth limit, a<N> = array-pagination continuation (loop on the response next until it is 0). The first-party poll
loop: π‘
- On connect, fetch
rr_model?key=seqs(the change-counter map), then each top-level key in full (flags=d99vno). - Steady state, poll
rr_model?flags=d99fn(no key β the live subset, with a freshseqs). - For any key whose
seqs[key]counter incremented, re-fetch that key in full β so rarely-changing subtrees are only re-read on change (a bandwidth-efficient differential).seqs.replysignals a waiting GCode reply, and a per-volume change counter signals a file list needs reloading.
The library's default poll interval is ~250 ms (a browser-UI cadence); a fleet client should poll gently and back off
when idle β any interval comfortably under sessionTimeout keeps the session alive, and if it backs off past that, it
simply hits the 401βre-connect path already required. Because standalone has no push, a client synthesizes a
change-event stream by diffing polls (see ../patterns/discovery-and-credentials.md Β§5). π‘/βͺ
SBC: full-model-then-patch over the WebSocket. Connect ws(s)://host/machine?sessionKey=<key>. Frame 1 is the
full Object Model; the client acks each received document with a literal OK\n text frame (the server sends the
next update only after the ack). Subsequent frames are incremental patches, merged by deep-merge β not RFC-6902
JSON Patch. Keep-alive is a bespoke line protocol: the client sends PING\n, the server replies PONG\n.
Socket close codes 1001 / 1011 signal DCS unavailable / incompatible DSF β surface as a login/version error, not
a generic drop. (GET /machine/model returns the whole tree as a poll fallback if the socket is blocked.) π‘
Lifecycle β parse state.status, an enum of 14 exact camelCase strings, never a human label: π‘
disconnected Β· starting Β· updating Β· off Β· halted Β· pausing Β· paused Β· resuming Β· cancelling Β· processing Β·
simulating Β· busy Β· changingTool Β· idle
A reasonable nativeβnormalized mapping:
state.status |
Normalized | Notes |
|---|---|---|
idle |
idle | |
processing |
printing | the printing state β not a value named printing |
simulating |
printing | a dry-run for timing β flag it, don't count it as a physical print |
paused |
paused | |
pausing / resuming / cancelling |
transient | |
changingTool |
busy | + a toolchange sub-state, below |
busy |
busy | homing/macro/motion, not a print |
starting / updating |
connecting/busy | boot / firmware flash |
halted |
error | emergency-stopped after M112; recover with M999 |
off |
offline | |
disconnected |
β | a client pseudo-status RRF doesn't emit |
There is no distinct completed value β a job returning to idle (with the just-finished job's terminal flags
set: cancelled / aborted / simulated) is how completion is inferred. An unlisted value should degrade to busy/unknown,
never raise. π‘/βͺ
Temperatures β Β°C, but behind an index dereference. RRF separates heaters (physical channels) from their
roles. heat.heaters[i] carries current, active (setpoint), standby (setpoint), state
(off/standby/active/fault/tuning/offline), min/max. heat.bedHeaters[] and heat.chamberHeaters[]
are arrays of indices into heat.heaters[] (-1 = none) β so the bed is not always heater 0 on a multi-tool
machine; resolve the index first. A heater.state == fault is a real safety signal worth surfacing. π‘
Progress & timing β file-byte progress, seconds throughout. RRF exposes no single "percent complete" β derive
it as job.filePosition / job.file.size (bytes/bytes β 0..1; guard a null file / zero size). This is a file-byte
fraction, like Klipper's, not a time fraction β so extrapolating an ETA from it is systematically wrong near the
end. Use the firmware's own estimators instead: job.timesLeft has keys {file, filament, slicer, toPause}, all in
seconds (prefer slicer β filament β file; slicer needs RRF 3.5+; there is no timesLeft.layer). Every
time field RRF reports β job.duration, warmUpDuration, pauseDuration, layerTime, file.printTime,
file.simulatedTime, state.upTime β is seconds; lengths are mm, sizes bytes, fan/PWM a 0..1 fraction. Duet needs
no time-unit normalization (progress is the only derived quantity). See
../patterns/timing-normalization.md. π‘
Capabilities read straight off the Object Model (no separate profile endpoint): build volume β move.axes[] where
letter β {X,Y,Z} {min,max} (the configured M208 travel limits β treat as hints, allow override); extruder
count β move.extruders[]; heated bed/chamber β bedHeaters/chamberHeaters non-empty (gate the control on
presence); tool count β tools[].length; machine mode β state.machineMode (FFF/CNC/Laser). π‘
Writing / control¶
RRF is GCode-native β there are no structured setpoint endpoints; every action is a GCode string. That makes "no raw passthrough" the primary safety boundary: a client should emit curated, typed, safe GCode from a registry and gate arbitrary user GCode off by default (with tool-select, e-stop, firmware-reset, and config-file writes gated harder still). The command channel differs by mode; the GCode vocabulary is identical. π‘/βͺ
- Standalone:
GET /rr_gcode?gcode=<codes>enqueues code(s); the response is{"bufferSpace":<int>}(the wire field isbufferSpaceβ0means the buffer is full). The reply text is fetched separately:GET /rr_replyreturns the last GCode reply astext/plain, and its availability is signalled by theseqs.replycounter. π‘ - SBC:
POST /machine/codewith the raw code string as the body (text/plain);async=truereturns as soon as the code is enqueued (no reply), otherwise the body is the reply text. π‘
A curated intent β GCode map (each is an uncopyrightable interface fact; verify against the RRF GCode dictionary): π‘/π΅
| Neutral intent | GCode | Notes |
|---|---|---|
| jog (relative) | G91 β G1 X.. Y.. Z.. F<mm/min> β G90 |
wrap the move in relative/absolute; gate off mid-print |
| home all / axis | G28 / G28 X|Y|Z |
|
| set tool active temp | M568 P<tool> S<Β°C> |
M568 is the modern setter (RRF 3.3+) |
| set tool standby temp | M568 P<tool> R<Β°C> |
|
| set tool state | M568 P<tool> A0|A1|A2 |
A0=off / A1=standby / A2=active |
| set tool offset | G10 P<tool> X.. Y.. Z.. |
G10 is retained for offsets only |
| set bed temp | M140 S<Β°C> (M190 = set+wait) |
gate on bedHeaters presence |
| set chamber temp | M141 S<Β°C> |
gate on chamberHeaters presence |
| select tool | T<n> (T-1 = deselect; T<n> P0 = skip macros) |
toolchanger β dangerous-tier |
| set fan speed | M106 P<fan> S<0β255 or 0β1> (M107 off) |
fan 0 = part-cooling |
| set feedrate factor | M220 S<percent> |
|
| emergency stop | M112 (β halted; recover with M999) |
dangerous-tier, hold-to-confirm UX |
β οΈ Use
M568for tool temps/state, notM104 T<n> Sβ¦. RRF migrated tool temperature/state setting fromG10toM568in firmware 3.3;M104 T<n> Sβ¦is the Marlin-lineage form, not the modern RRF setter.G10stays valid for offsets. π‘
Print launch β upload, then a GCode. Neither upload endpoint carries an auto-print flag, so upload-and-hold is the natural default and launching is a deliberate separate step: π‘
- Preferred one-shot:
M32 "0:/gcodes/<file>"= select file and start SD print (one round-trip, minimal race). - Two-step:
M23 "<path>"(select β loads, does not start) thenM24(start/resume). π΅ - Pause
M25; cancelM0(M0 H1to keep heaters). Confirm the safe cancel sequence on hardware.
There is no synchronous "print started" ack β confirm by state-poll: watch state.status β processing and
job.file.fileName matching the launched path. Gate the launch on state.status == idle (RRF refuses a second job
while one runs). βͺ
File transfer β a raw body in both modes (see the upload-dialect landscape; this is the family that uses CRC32 and
{err} in standalone, a bare PUT/201 in SBC): π‘
- Standalone
POST /rr_upload?name=<path>&crc32=<hex>β raw file bytes in the body; the optional CRC32 is lowercase hex without a0xprefix, and RRF validates it (a mismatch returnserr:1). The first-party client enables CRC by default and retries anerrβ 0upload for payloads under ~350 KiB. Result is{err:0|1}, not a path. - SBC
PUT /machine/file/{path}β raw bytes, optionaltimeModified; 201 Created on success; no checksum (DSF handles integrity over the SPI link β verify by re-readingfileinfo/size). β οΈ On SBC,502/503means DCS/DSF busy or unavailable (the Piβboard link) β transient, retry; distinct from a4xx. - Paths use RRF volume notation (
0:/gcodes/<file>, or a leading-slash relative form). The print root is conventionally0:/gcodes/but a client should readdirectories.gCodesand enumeratevolumes[]rather than hardcode. RRF prints plain.gcode(no binary-gcode format). π‘ - Verb quirk: the standalone file API is almost entirely
GETwith query params β evenrr_delete,rr_mkdir,rr_moveare GETs; onlyrr_uploadis a POST. On SBC,/machine/file/moveismultipart/form-data(from/to/optionalforce), not form-urlencoded. Metadata + thumbnails come fromrr_fileinfo/GET /machine/fileinfo(base64 thumbnail chunks; loop while anextoffset is non-zero). π‘
β οΈ The
503interlock (standalone). RRF returns HTTP503when it is out of output buffers, which typically means a blocking GCode reply is occupying the buffer. The correct recovery is drainrr_reply, then retry β not treat503as a hard failure. π‘
Multi-tool / toolchanger (not a feeder)¶
RRF core has no per-slot filament changer / MMU / spool model β multi-tool is a toolhead concept, not a feeder
concept, and the two are orthogonal (see
../patterns/multi-material-feeders.md). A client should report no
multi-material for core RRF and never fabricate slots. The only filament signal is a name: it lives on the
extruder (move.extruders[i].filament, assigned via M701/M703), resolved for a tool through
tools[n].filamentExtruder (the extruder index) β there is no tools[n].filament field, and no active-slot /
per-slot load-unload / per-slot runout. π‘
Where Duet is a leader is the toolchanger. tools[] is a native, first-class array of physical toolheads β
each with its own heater(s), extruder(s), XYZ offset, and active/standby setpoints β a far cleaner source than
scraping a status object. The neutral tool fields a client tracks map almost directly: π‘
tools[n].numberβ theT<number>;tools[n].name;tools[n].state(active/standby/off).tools[n].active[]/standby[]are the already-resolved setpoints (use directly);tools[n].heaters[]are indices intoheat.heaters[]for the live temp (heat.heaters[heaters[0]].current= the primary hotend).tools[n].offsets[]is a float array ordered by axis (X, Y, Z, β¦).tools[n].extruders[]indexesmove.extruders[]; atools[n].spindlemaps a tool to a CNC spindle.- The active tool is
state.currentTool(int,-1= none) β authoritative, not a per-tool scan; fold-1to "none".state.nextTool/state.previousToolname the incoming/outgoing tool during a change. - Toolchange status is first-class:
state.status == changingToolis the machine-level "mid tool-change" signal (cleaner than firmwares that only report a generic busy). Detect a toolchanger by presence βtools[].length > 1with independent offsets/heaters β never a vendor string. π‘/βͺ
A T<n> triggers RRF's user-authored tfree<n>.g / tpre<n>.g / tpost<n>.g macros on the SD card; a client only
issues T<n> (or T<n> P0 to suppress them), it doesn't author them. π‘
CNC / laser are in-scope for RRF (state.machineMode β {FFF, CNC, Laser}, a real spindles[] array) but out of
scope for FFF print semantics β read the mode + spindle presence as capability metadata so a UI can hide FFF-only
affordances, and build no spindle/laser control. βͺ
Quirks & gotchas¶
processing, notprintingβ the OM string enum's printing state isprocessing; the toolchange state ischangingTool. The legacyrr_statusnumeric/letter enum (with a literalprinting/toolChange) is a different, older, deprecated surface β don't use it.- Progress is file-byte (
filePosition/size), not time β ETA-from-progress is wrong near the end; usejob.timesLeft.*(seconds). - Two mutually-exclusive dialects. A standalone board answers
rr_*and 404s/machine/*; an SBC box is the reverse. Probe standalone first, fall through on a non-login error, and confirm with the OMsbckey. 401on everything butrr_connect(standalone) β a lost session is normal; re-connect + retry once. A decreasingstate.upTimemeans the board rebooted β re-handshake and re-seed.rr_gcodereturnsbufferSpace(notbuff); therr_modelaarray-continuation flag is real but not in the wiki (it lists onlyf/v/n/o/d).503differs by mode: standalone503= out of output buffers β drainrr_reply, retry; SBC502/503= DCS/DSF busy β backoff-retry.- CRC32 is lowercase hex with no
0x(standalone upload); the SBCPUThas no checksum. M568for tool temps/state;G10for offsets β neverM104 T<n> Sβ¦on RRF.- Default password
reprap; an unset password auto-creates a session on any request. - DSF bad-password =
401or403(map both); pre-3.4-b4 DSF has no password (connect 404 β sessionless). sessionTimeoutis milliseconds; the ~8000 ms figure is a client seed, not a firmware constant β the board returns the real value.
Confidence & validation¶
This is a source-read paper β nothing here is hardware-validated. There was no Duet on the bench for this pass. The API shape confidence is high because it is grounded in the vendor's own documentation and its LGPL connector/object- model libraries, but live values and behaviour are unconfirmed.
- π‘ Source-read (solid): the two-dialect model + the ordered-probe/login-short-circuit fingerprint; the
sbcmode discriminator andsbc.dsf.version; the 14-valuestate.statusenum; the top-level OM keys andjobfields; the auth model (rr_connecterr0/1/2,X-Session-Key, DSF bad-pw401/403, 401-on-everything-but-connect); the fullrr_*and/machine/*surfaces; file-byte progress (filePosition/size, nofractionPrinted); the seconds-native units ledger; the raw-body upload dialects (CRC32 vs bare PUT); the WS full-model-then-patch +OK\nack; the RRF-3.0 version floor; and the first-classtools[]toolchanger read.M568'sA0/A1/A2states are confirmed against the official M568 docs (shippable without hardware). - π΅ Community / lore: the aligned firmware bundle numbers; the
M23/M24"select does not auto-start" semantics (from reprap.org). - βͺ Inferred (validate before trusting): the composite probe-then-confirm router synthesis; the exact API-level
values in the wild and the precise
BadVersionErrorthreshold; the livesbcsubtree shape and how WS patches merge under array mutation (add/remove a tool/heater); the absence of a DNS-SD service; the numeric max-concurrent- session cap (commonly cited as a small table); the safe cancel sequence and the exact quoting ofM32 "<path>"; and CNC/laser job semantics.
Open gaps and the capture that closes each. RRF has no official virtual printer (unlike OctoPrint), so it is
not hardware-free β but it is cheap: a Pi + DSF software rig (no board) validates the entire SBC/REST mode (live
sbc subtree, the WS patch-merge under a tool/heater mutation, the router's sbc-key agreement, layer-metadata
availability), and one inexpensive Duet board validates standalone (rr_connect err/API-level/sessionKey, real
state.status transitions, mDNS hostname, the buffer-full/503 behaviour, and a tolerable fleet poll cadence). The
one genuinely-unsettled synthesis β worth capturing first β is the two-mode connect router on both a real
standalone board and a real SBC rig. Confirming state.status == changingTool actually fires during a T<n> needs an
E3D ToolChanger / Jubilee owner-report (no toolchanger hardware here). A dns-sd/avahi-browse sniff would settle the
DNS-SD absence, which is a network fact outside any Object-Model capture.
Sources¶
Clean-room, facts-only. Built from the official Duet3D documentation and the RRF wiki (the HTTP-requests rr_*
reference, the Object-Model documentation, and the GCode dictionary) for the endpoint/param/err-code surface, the
Object-Model field names, the state.status enum strings, and the GCode vocabulary β all uncopyrightable interface
facts. Exact strings and the connect / mode-probe behaviour were read-confirmed against the LGPL-2.1
@duet3d/connectors (the shared connector library, master = 3.5.7) and @duet3d/objectmodel, and the DSF
OpenAPI.yaml for the /machine/* codes. The GPL-3.0 RepRapFirmware and DuetWebControl repos were consulted for
behaviour only.
Duet's stack spans three licenses (GPL-3.0 firmware + web client, LGPL-2.1 connect/read libraries): no code was
copied or line-by-line translated from any of them β an independent client of a documented API. No secrets: the
sole credential is a user-entered machine password (default reprap), and the session key is ephemeral β none appear
here. Passed ../CLEANROOM-CHECKLIST.md.
Machine-readable:
../schemas/duet/state-status-enum.json(the 14state.statusvalues β normalized),job-and-timing.json(progress + the seconds-native unit ledger),tools-model.json(thetools[]toolchanger + active-tool + heater deref),gcode-intent-map.json(intent β GCode).