This document defines the stable interface that external software (a print host, a slicer plugin, an orchestration script) uses to drive the multiACE engine. It is both the technical contract and the basis for keeping a separate program at arm's length from the GPL-licensed engine.
Companion documents: LOADOUT_API.md (the HTTP API of the multiACE web
backend: read the loadout, hand a file back) and SEND_TO_MULTIACE.md (the
upload endpoint in detail).
multiACE is a Klipper [extras] module and is GPL-3.0. Any code that
imports it (import ace, subclassing its objects, etc.) becomes a derivative
work and inherits the GPL.
An external program stays a separate work by talking to the engine only through arm's-length IPC - never importing engine modules:
- Moonraker REST/WebSocket is the bus.
- The gcode command vocabulary (section 3) and the
acestatus object (section 4) are the entire contract.
As long as the external program communicates only via these, it is not a derivative of multiACE and may carry its own licence. Recipients of the engine still get its source (GPL §3); the external program does not.
| Direction | Mechanism |
|---|---|
| Command | POST /printer/gcode/script with {"script": "ACE_… …"} |
| Read state | GET /printer/objects/query?ace |
| Live state | POST /printer/objects/subscribe for ace (+ print_stats) over the Moonraker WebSocket |
Commands are plain gcode; parameters are KEY=VALUE tokens. Moonraker enforces
print-state rules (busy/paused/printing) on its end. Commands should be treated
as idempotent-safe to retry on transport errors.
These are the commands an orchestrator relies on. [..] = optional.
Every machine-readable index is 0-based: the gcode parameters below
(HEAD=, ACE=, SLOT=), the status object of section 4, and the fields of
the events in section 5. So HEAD/SLOT 0-3 and ACE 0-3 (device index).
Human-readable output is offset. Console messages, klippy.log, the web UI
and the pause/error texts multiACE raises on the touchscreen add
[ace] display_index_base, which the shipped config sets to 1. (The
touchscreen's own tile labels are the stock firmware's numbering and are not
ours to shift.) The same slot therefore reads
| where | how it appears |
|---|---|
| gcode you send | SLOT=1 |
| status object / events | "slot": 1 |
| log line, web UI, multiACE popup | Slot 2 |
Nothing is converted on the way in: an index you send is always taken as
0-based, whatever the logs display. Set display_index_base: 0 in [ace] to
make logs and UI match the API - that changes presentation only, never the
values on the wire.
| Command | Parameters | Effect |
|---|---|---|
ACE_LOAD_HEAD |
HEAD=n [ACE=n] [SLOT=n] |
Load a toolhead from an ACE slot. Ignored for manual heads. |
ACE_UNLOAD_HEAD |
HEAD=n [RETRACT_LENGTH=mm] [KEEP_HEAT=temp] |
Unload a head back to its ACE. KEEP_HEAT>0 holds the hotend at temp (no cold cool-down). Ignored for manual heads. |
ACE_UNLOAD_ALL_HEADS |
- | Unload every loaded head (manual heads skipped). |
ACE_SWAP_HEAD |
HEAD=n ACE=n [SLOT=n] [ANTI_OOZE=mm] [INITIAL=1] |
Mid-print swap: unload current, load new. A no-op when the head already holds that ACE/slot. ANTI_OOZE = the end-of-swap retract, sized to the un-retract the following gcode will push back (default swap_anti_ooze_retract). INITIAL=1 marks a swap inside the auto-load block at print start (parks at the discard position instead of restoring a print position). The flush length comes from ACE_SET_PURGE, not from a swap parameter. Ignored for manual heads. |
ACE_SWITCH |
TARGET=n [AUTOLOAD=1] |
Make ACE TARGET the active device. |
ACE_RETRACT |
INDEX=n LENGTH=mm [SPEED=mm/s] |
Low-level retract of a slot. |
ACE_FEED |
(feeds active slot) | Low-level feed. |
ACE_CLEAR_HEADS |
[HEAD=n] |
Clear head→ACE/slot mapping (all or one head). |
| Command | Parameters | Effect |
|---|---|---|
ACE_SET_PURGE |
LENGTH=mm | RESET=1 | `MATRIX=0 |
1 [PERSIST=0]` |
| Command | Parameters | Effect |
|---|---|---|
ACE_SET_HEAD_MANUAL |
HEAD=n ENABLE=0|1 |
Toggle manual bypass for a head (no ACE feed/retract/feed-assist/RFID; load by hand). Multi mode. Persisted. |
ACE_SET_HEAD_ACE |
HEAD=n ACE=n |
Head mode: wire a head to the ACE that feeds it (1:1 - one ACE per head, never shared). Persisted. |
ACE_SET_HEAD_FEEDER |
HEAD=n ENABLE=0|1 |
Head mode: the head loads/unloads via the printer's own side feeder and the ACE never touches it. Persisted. |
The operating mode itself (normal / multi / head, status field mode) is
switched by the user via the web UI; a host should read it and plan
accordingly rather than switch it. In multi mode slot N feeds head N on
every unit; in head mode each ACE-driven head is wired to one ACE
(head_ace) and swaps between that unit's four slots, while head_feeder
heads print one fixed colour from the side feeder.
| Command | Parameters | Effect |
|---|---|---|
ACE_BG_SWAP |
HEAD=n SLOT=n [ACE=n] [TEMP=] [ANTI_OOZE=] [PURGE=mm] [QUIET=1] [FORCE=1] |
Unload and reload a parked head's ACE slot while another head prints, so the arrival toolchange becomes a no-op. Requires the head to be opted in (ACE_BG_SET_HEAD HEAD=n ENABLE=1, a hardware declaration that the dock is open below). QUIET=1 turns every refusal into a log line (the arrival then swaps inline). Requires the [ace_bg_swap] section. Not yet part of the stable contract - parameters may still change. |
| Command | Effect |
|---|---|
ACE_ENABLE_FEED_ASSIST / ACE_DISABLE_FEED_ASSIST |
Enable/disable feed assist. |
| Command | Parameters |
|---|---|
ACE_START_DRYING |
TEMP=… [DURATION=…] |
ACE_STOP_DRYING |
[ACE=n] |
ACE_DRY |
ACE=n [TEMP=] [DURATION=] |
| Command | Parameters | Effect |
|---|---|---|
ACE_SEQ |
PLAN=… [UNLOAD=0|1] |
Scripted load/unload sequence. PLAN tokens: H:A=load HEAD from ACE, A0=all from ACE 0, U=unload all, U0=unload head 0. UNLOAD (default 1) runs a final unload-all. |
ACE_PRELOAD |
same syntax as ACE_SEQ |
Preload heads/slots; UNLOAD defaults to 0 (no final unload). Use to pre-stage the next colour while printing. |
ACE_HEAD_STATUS, ACE_LIST, ACE_USB_STATS, ACE_DEBUG, ACE_TEST, and the
low-level A_* protocol pokes (A_FEED, A_STATUS, A_FEEDCHECK, A_RAW, …)
are for humans/debugging. They expose raw protocol behaviour and may change
without notice - do not build an orchestrator on them.
All indices below are 0-based, independent of display_index_base (see
section 3).
GET /printer/objects/query?ace returns:
Key fields for orchestration:
api_version- engine contract version; gate capability on this (section 6).head_source[h]- which ACE/slot currently feeds headh(null = unloaded).head_manual[h]- head is in manual bypass (skip it in plans).swap_in_progress- a swap is running; don't issue conflicting commands.swap_phase- finer swap state (idle|unload|load|flush|done); a host can pre-stage onflushor wait foridlebefore the next action.last_swap_result-{head, ace, slot, status, ts}of the most recent swap (status='ok'or a failure tag such asunload_failed/load_failed/slot_empty).event_seq- monotonic counter bumped on every emitted event (below); compare across polls to detect a missed update.aces[i].status/slots[].status- device/slot busy vs idle, used to wait for an operation to finish (e.g. a rollback returning toready).aces[i].slots[].rfid/material/color- spool identity.mode/head_ace/head_feeder- the routing topology (section 3, per-head mode); read it before planning which slot can serve which head.
The object carries further fields (spool table and bindings, Spoolman and SpoolLink state, tag read/write progress, per-spool pressure advance, dryer automation, calibration). They are additive (section 6): a host may ignore them, and their shape is not yet frozen - build on the fields listed above.
Filament type/colour is otherwise a free string pushed via the stock
SET_PRINT_FILAMENT_CONFIG (the engine does not enforce a material whitelist).
Two complementary channels:
a) Status-object transitions (subscribe-based). Subscribe to the ace object
(and print_stats) and react to swap_phase, last_swap_result, swap_in_progress,
slot status, head_source. event_seq lets you detect a missed update between
polls. This is sufficient for the section 7 flow.
b) Push events (low-latency). For timing-sensitive actions (pre-purge / pre-stage
before a swap) the engine also emits machine-readable lines on Moonraker's
gcode_response stream - subscribe to it over the WebSocket. Format:
multiace_event <name> key=val key=val ... seq=<n>
Emitted events:
| Event | When | Fields |
|---|---|---|
swap_imminent |
a swap has started, before the unload | head ace slot from_ace from_slot seq |
slot_ready |
the new slot is loaded and wiped, swap about to finish | head ace slot seq |
swap_done |
swap completed successfully | head ace slot status seq |
swap_failed |
swap exited via recovery/abort (paired with a paused error) | head ace slot status seq |
resistance_suspect |
a passing load-time flow measurement sat far above the lane's own baseline (back-pressure / partial blockage suspect); with resistance_pause on, the second suspect on a lane or head pauses the print |
head ace slot site delta baseline strikes head_strikes seq |
airlog_chew |
the in-print flow sampler ([ace] airlog: true) saw sustained huge back-pressure while extruding (chew / air-print suspect); with resistance_pause on this pauses the print |
head delta run seq |
status on swap_failed carries the failure tag (unload_failed, load_failed,
slot_empty, …). error is the generic fallback for an exit that names no tag of
its own - it means "the swap failed", nothing more; the paused error message that
comes with it is the specific one. ok is never a legal value on this event.
All push events are best-effort: a transport hiccup never disturbs
the swap, so treat the status object (channel a) as the source of truth and the push
events as latency hints.
Every event is additionally mirrored to klippy.log as
[multiACE] multiace_event <name> …. The gcode_response stream is live-only - Moonraker keeps roughly the last 1000 console lines and nothing on disk - so the log
is where an event can still be read after the fact. Use it for post-mortems and bug
reports; do not poll the log as a transport.
The ace status object exposes api_version (currently 1). It bumps only on
a breaking change to the command vocabulary (section 3) or the status-object
shape (section 4); additive changes (new optional fields, new push events) do not
bump it. A host should read api_version once and refuse / degrade if it sees a
major version it does not support.
- Subscribe to
ace+print_stats. - Ingest slicer gcode, derive the toolchange/swap plan and a per-colour-pair purge map.
- While printing colour A,
ACE_PRELOADthe next colour into a free slot. - Before each swap:
ACE_SET_PURGE LENGTH=<pair>; thenACE_SWAP_HEAD …; thenACE_SET_PURGE RESET=1. - Wait for
swap_in_progress=falseand the slotstatusto settle before the next action.
{ "api_version": 1, // engine contract version (section 6) "status": "ready|busy|unknown", // active device aggregate "temp": 0, "dryer_status": { ... }, "gate_status": [ ... ], // active device, per-slot gate flag "active_device": 0, "device_count": 1, "swap_phase": "idle", // idle|unload|load|flush|done (finer than swap_in_progress) "last_swap_result": null, // { head, ace, slot, status, ts } of the most recent swap, or null "event_seq": 0, // monotonic; bumps on every emitted engine event "head_source": { "0": {ace_index, slot, type, color, brand, ...} | null, ... }, "head_manual": { "0": false, "1": false, "2": false, "3": false }, "mode": "multi", // normal|multi|head "head_ace": { "0": 0, "1": 1, "2": 2, "3": 3 }, // head mode: head -> ACE wiring "head_feeder": { "0": false, ... }, // head mode: side-feeder heads "settings_volatile": [], // settings whose RAM value deviates from the config line "swap_in_progress": false, "aces": [ { "idx": 0, "connected": true, "protocol": "v1|v2", "status": "ready|busy|unknown", "temp": 0, "humidity": null, "dryer_status": { ... }, "gate_status": [ ... ], "feed_assist": -1, // armed slot, -1 = none "slots": [ { "index": 0, "status": "empty|ready|…", "sku": "", "material": "", "rfid": 0, "brand": "", "color": [r,g,b] } // color is the RAW device triple. [0,0,0] is ambiguous on its // own - it is the tag's black only when "material" is set, // otherwise the slot declares no colour. Engine-internal // captures already carry that resolved: "" = unknown, // "000000" = a declared black. ] } ] }