Skip to content

Data flow

Every cross-process message in the stack, labelled with the actual protocol and endpoint.

Mainsail ⇄ host

sequenceDiagram
    participant UI as Mainsail
    participant H as Moonraker / Bridge

    UI->>H: WebSocket connect /websocket
    H-->>UI: notify_status_update (server.info)

    loop polling + push
        UI->>H: printer.objects.query (toolhead, extruder, …)
        H-->>UI: status snapshot
        H-->>UI: notify_status_update (deltas)
        H-->>UI: notify_gcode_response (// …)
    end

    UI->>H: printer.gcode.script {script: "G28"}
    H-->>UI: ok / error

Identical for real Moonraker and the bridge. The bridge implements the same JSON-RPC namespace (printer.objects.query, printer.gcode.script, server.files.upload, etc.) and the same websocket notification stream.

See Bridge intercepts for the specific gcode commands the bridge handles natively versus passing through.

klipper-nfc-daemon ⇄ Spoolman

sequenceDiagram
    participant D as klipper-nfc-daemon
    participant SM as Spoolman (NFC fork)

    Note over D: NFC tag scanned, raw bytes in hand

    D->>SM: POST /api/v1/nfc/lookup<br/>{uid, protocol, data (base64)}
    SM-->>D: 200 {spool_id, format: "tigertag" | "openprinttag"}

    D->>SM: GET /api/v1/spool/{spool_id}
    SM-->>D: {filament, vendor, color_hex, extruder_temp, bed_temp, …}

The lookup endpoint and the env vars (SPOOLMAN_TIGERTAG_ENABLED, SPOOLMAN_NFC_ENABLED) live only on the pr/nfc-support branch of the fork — see Spoolman fork.

klipper-nfc-daemon ⇄ host (single-tool mode)

sequenceDiagram
    participant D as klipper-nfc-daemon
    participant H as Moonraker / Bridge
    participant K as Klipper / Bridge internal

    D->>H: POST /server/spoolman/spool_id {spool_id: 42}
    H->>K: persist active spool
    H-->>D: 200

    D->>H: POST /printer/gcode/script<br/>{script: "SAVE_VARIABLE VARIABLE=nfc_material VALUE='\"PLA\"'"}
    H->>K: SAVE_VARIABLE …
    H-->>D: 200

    D->>H: POST /printer/gcode/script {script: "SAVE_VARIABLE VARIABLE=nfc_extruder_temp VALUE=210"}
    H-->>D: 200

    Note over D,H: …one POST per variable (see Save Variables reference)

klipper-nfc-daemon ⇄ host ⇄ UI (multi-tool mode)

The interesting case — the daemon doesn't know which tool the spool goes on; the operator does. Solved with a Mainsail prompt dialog:

sequenceDiagram
    participant D as klipper-nfc-daemon
    participant H as Moonraker / Bridge
    participant UI as Mainsail

    D->>H: SET_GCODE_VARIABLE MACRO=_NFC_STATE VARIABLE=pending_spool_id VALUE=42
    D->>H: SET_GCODE_VARIABLE … pending_material '"PLA"'
    D->>H: SET_GCODE_VARIABLE … pending_vendor '"PolyTerra"'
    Note over D,H: …one per metadata field

    D->>H: RESPOND TYPE=command MSG="action:prompt_begin Assign spool"
    D->>H: RESPOND TYPE=command MSG="action:prompt_text PolyTerra PLA Red"
    D->>H: RESPOND TYPE=command MSG="action:prompt_button T0|NFC_ASSIGN_TOOL T=0"
    D->>H: RESPOND TYPE=command MSG="action:prompt_button T1|NFC_ASSIGN_TOOL T=1"
    D->>H: RESPOND TYPE=command MSG="action:prompt_button Cancel|NFC_CANCEL"
    D->>H: RESPOND TYPE=command MSG="action:prompt_show"

    H-->>UI: notify_gcode_response "// action:prompt_*"
    UI->>UI: render modal with buttons

    UI->>H: printer.gcode.script {script: "NFC_ASSIGN_TOOL T=1"}
    H->>H: NFC_ASSIGN_TOOL handler<br/>(real macro on Klipper,<br/>intercepted on bridge)
    H->>H: SetSpoolID(42, tool=1)<br/>SAVE_VARIABLE nfc_t1_*
    H-->>UI: notify_gcode_response "// action:prompt_end"
    H-->>UI: notify_gcode_response "// Spool #42 assigned to T1"

On real Klipper the NFC_ASSIGN_TOOL macro lives in nfc_macros.cfg (shipped with the daemon). On the bridge it's intercepted natively — no real Klipper required.

J1S upload pipeline (bridge-only)

flowchart LR
    Slicer[PrusaSlicer<br/>OrcaSlicer<br/>Mainsail] -->|"HTTP multipart<br/>POST /server/files/upload"| MR["MultipartReader<br/>(stream)"]
    MR -->|"io.Copy"| Disk1[("gcodes/foo.gcode<br/>on disk")]
    Disk1 -->|"ProcessFile<br/>(2-pass scan)"| GCP[gcode<br/>post-processor]
    GCP -->|"streamed"| Disk2[("gcodes/foo.gcode.tmp<br/>V1 header + body")]
    Disk2 -->|"ReadAt 60 KB chunks<br/>+ md5 streaming"| SACP[SACP upload<br/>over TCP :8888]
    SACP -->|"chunked"| J1S[Snapmaker J1S]

Pre-v1.6.0 each stage held the entire file in RAM, which OOM-killed the Pi on 250 MB tile prints. The current pipeline never holds more than a few KB. See the v1.6.0 release notes for the gory details.

Toolchange (StealthChanger, Voron)

sequenceDiagram
    participant G as GCode (T1)
    participant K as Klipper
    participant TC as klipper-toolchanger
    participant T0 as Active tool T0
    participant T1 as Next tool T1

    G->>K: T1
    K->>TC: tool change to 1
    TC->>T0: park sequence (Z hop, dock approach, release)
    TC->>K: switch active extruder to T1
    TC->>T1: pickup sequence (approach, latch, leave dock)
    TC->>K: apply T1 offsets (X, Y, Z, pressure_advance)
    TC->>K: restore Z, resume motion
    K-->>G: ok

The toolchange itself is entirely upstream — see viesturz/klipper-toolchanger. What's stack-specific is that the NFC-loaded per-tool metadata (nfc_t{N}_extruder_temp, nfc_t{N}_pressure_advance, etc.) feeds the toolchange macros — see Toolchange workflow.