n8n workflow that cuts power to the X1C’s chamber heater (an Inkbird smart switch, exposed in Home Assistant as switch.inkbird) once a print ends. It subscribes to the printer’s Bambu MQTT report topic and, on the RUNNING → FINISH/FAILED transition, turns the Inkbird off if it’s on. The Inkbird is turned on manually via Siri/Alexa — either at the start of a print or to preheat the chamber before one. This workflow handles only the auto-off side. As of 2026-06-06 the design is idempotent and preheat-safe — it acts only on real end-of-print transitions, not on steady-state FINISH telemetry, so manually turning the Inkbird on while the printer sits idle no longer gets cancelled out.

Quick Reference

ItemValue
n8n workflow IDMB5ajMgX9zg46yFP
Workflow name3DWorkFlow
Active version6c17b9ab-462b-4420-89ff-8e9f5f82fae4 (2026-06-06)
Trigger typeMQTT — device/00M09D522600299/report (Bambu printer report topic)
PrinterBambu Lab X1C — serial 00M09D522600299
HA entity controlledswitch.inkbird
Error workflowTX2y7dUINVnLM3ze — “n8n Error Notification”
StatusACTIVE

Architecture

Single execution path, 6 nodes, fully idempotent. Every MQTT message walks the chain; only the RUNNING → FINISH/FAILED transition gets past Print Finished?, and the Inkbird On? guard means the turn-off fires at most once per finish.

MQTT Trigger                  (subscribes to device/.../report — every status update fires this)
  ↓
Track State                   (Code: read/write previousGcodeState in workflow staticData;
                               emit isFinishTransition = previous==RUNNING && current ∈ {FINISH,FAILED})
  ↓
Print Finished?               (IF: isFinishTransition == true)
  ↓ [true]
Get Inkbird State             (Home Assistant: read switch.inkbird state)
  ↓
Inkbird On?                   (IF: state == "on")
  ↓ [true]
Turn Inkbird Off              (Home Assistant: switch.turn_off)
NodeTypeKey config
MQTT TriggermqttTriggertopic device/00M09D522600299/report; jsonParseBody: true, onlyMessage: false, parallelProcessing: false; cred “MQTT account”
Track Statecode v2JS reads $getWorkflowStaticData('global').previousGcodeState, compares with $json.message.print.gcode_state, writes current back. Emits { previousState, currentState, isFinishTransition }.
Print Finished?if v2.2{{ $json.isFinishTransition }} is true (boolean)
Get Inkbird StatehomeAssistantresource state, entity switch.inkbird; cred “Home Assistant account”; onError: continueRegularOutput
Inkbird On?if v2.2{{ $json.state }} equals on
Turn Inkbird OffhomeAssistantservice call, domain switch, service turn_off, entity_id switch.inkbird; onError: continueRegularOutput

Track State JS body:

const staticData = $getWorkflowStaticData('global');
const previousState = staticData.previousGcodeState ?? null;
const currentState = $input.first().json.message?.print?.gcode_state ?? null;
const isFinishTransition = previousState === 'RUNNING' && (currentState === 'FINISH' || currentState === 'FAILED');
staticData.previousGcodeState = currentState;
return [{ json: { previousState, currentState, isFinishTransition } }];

How it works

The Bambu X1C publishes its full status to device/<serial>/report over MQTT roughly once a second. The MQTT Trigger fires on every message; Track State records the new gcode_state and compares with the prior one held in workflow staticData. Print Finished? passes only when the just-observed transition was RUNNING → FINISH or RUNNING → FAILED. On a pass, Get Inkbird State reads switch.inkbird from Home Assistant, and if it’s on, Turn Inkbird Off calls switch.turn_off.

The edge-trigger is what makes the workflow preheat-safe. The printer keeps republishing FINISH until the next print starts (could be hours, could be days), so a pure level-trigger on gcode_state == FINISH would cancel any manual Inkbird-on the moment it landed. With the transition check, FINISH → FINISH ticks fall through (previous already equals current) and the manual on stands; only the one RUNNING → FINISH tick at end-of-print actually fires the turn-off.

The Inkbird On? guard is kept as a second-line defence: even if Track State somehow misfires (e.g. n8n restart loses staticData, see corner cases below), turning off an already-off switch is a harmless no-op. Both HA nodes are set to continue-on-error, so a transient HA blip won’t strand the heater on the one tick that mattered — but note the trade-off described under Known limitations.

What Track State does and doesn’t persist

$getWorkflowStaticData('global') is per-workflow JSON that n8n flushes to the DB after each successful execution. Reads + writes are scoped to this workflow, so there’s no cross-workflow contention. The static-data write only persists if the execution itself reaches a node-output write — which every tick does (the IF nodes run synchronously and never Wait), so the failure mode that bit us 2026-06-02 (suspended execution dying mid-Wait) doesn’t exist here.

On first execution after a deploy, previousGcodeState is null. The transition check is previous === 'RUNNING', so the first tick can never fire — even if the printer is mid-RUNNING → FINISH at that exact instant. Worst case after a deploy during the actual end-of-print second: one missed auto-off. Acceptable; the next print’s end will be caught normally.

State machine in a sentence

Fires exactly when previousGcodeState == RUNNING and currentGcodeState ∈ {FINISH, FAILED}. Everything else passes through without touching the Inkbird.

State Detection

Bambu’s gcode_state values are IDLE / PREPARE / RUNNING / PAUSE / FINISH / FAILED. The workflow only acts on the RUNNING → FINISH and RUNNING → FAILED transitions; everything else (including the steady FINISH → FINISH while the printer sits idle) is ignored.

Why require previous === RUNNING and not just any transition into FINISH/FAILED?

  • PREPARE → FAILED happens when a print bails out before it ever extrudes (load failure, bed-level fail). The heater wasn’t running for an actual print yet, and if you’d manually preheated, you’d want that preheat to stand. Narrow check passes; broad check would cancel the preheat. Worth the trade-off.
  • PAUSE → FAILED (user cancels a paused print) is a rare corner where the narrow check leaves the heater on. Manually turn it off in that case. See Known limitations.

Failed-mid-print is RUNNING → FAILED, which fires — heater off, as intended.

Credentials Needed

  1. MQTT account (mqtt) — Bambu MQTT broker credentials. Used by MQTT Trigger.
  2. Home Assistant account (homeAssistantApi) — HA long-lived access token. Used by Get Inkbird State and Turn Inkbird Off.

2026-06-06 rewrite (edge-trigger for preheat support)

Symptom: turning the Inkbird on to preheat the chamber while the printer sat idle (post-print) cancelled itself within ~1 second.

Root cause: the 2026-06-02 idempotent design level-triggered on gcode_state == FINISH. Bambu republishes FINISH on every MQTT tick until the next print starts, so as soon as the manual Inkbird-on lifted state to on, the very next tick fired turn_off. The design was idempotent (the only correctness goal at the time) but not preheat-aware — the wiki itself documented “FINISH persists until the next print starts” without flagging the side effect.

Fix: edge-trigger. Inserted a Track State Code node between the MQTT trigger and the IF; it persists previousGcodeState via $getWorkflowStaticData('global') and emits isFinishTransition only when the just-observed transition was RUNNING → FINISH or RUNNING → FAILED. The IF now gates on that boolean instead of on the raw gcode_state value. Steady-state FINISH → FINISH ticks are now ignored; the Inkbird’s manual state is respected.

Why not just use the orphaned 3dPrint_v2 data table for previous-state tracking? Workflow staticData is lighter (no external table, no extra read), scoped per workflow, and has no separate failure mode. The data table is left as-is per the comment-don’t-delete convention.

2026-06-02 incident & first rewrite

Symptom: the chamber heater stayed on after every print.

Root cause: the original design used a boolean lock (data table 3dPrint_v2, row lock) to debounce repeated FINISH messages. The Release Lock node sat on the far side of a 10-minute Wait. n8n restarted (Watchtower auto-redeploy) while an execution was suspended mid-Wait; that execution died before Release Lock ran, leaving state = true permanently. From then on every FINISH dead-ended at the Lock Open? gate and the Inkbird was never touched. The lock row had been stuck at true since 2026-06-01 17:36 UTC.

Diagnosis trail: the MQTT trigger was firing ~1/sec and all executions were taking the false branch (normal). Reading the lock row showed state = true while nothing was printing → confirmed deadlock.

Fix: (1) manually reset the lock row to false to unblock immediately; (2) rewrote the workflow idempotent — dropped Get Lock Row, Lock Open?, Engage Lock, Wait, and Release Lock. Turning off an already-off switch is a harmless no-op, so the lock was solving a non-problem while introducing a deadlock class. Removing it eliminated the failure mode. (The level-triggered design that resulted lasted four days before the preheat use case surfaced — see 2026-06-06 above.)

Lesson: don’t gate a stateful lock behind a long Wait node in a stack that auto-redeploys. Prefer idempotency, or a timestamp-column debounce that holds no suspended execution.

Known limitations

  • HA failures during the one transition tick are swallowed silently. onError: continueRegularOutput on both HA nodes means a genuine HA failure won’t reach the error-notification workflow. Under the old level-triggered design this was cheap — the 1 Hz retry caught it on the next tick. Under the edge-triggered design, a transient HA failure on the single RUNNING → FINISH tick will strand the heater on because the next tick is FINISH → FINISH and falls through. If this ever bites in practice, route the Turn Inkbird Off error path to TX2y7dUINVnLM3ze so HA outages surface; for now, accepting the rare miss is cheaper than re-introducing a level-triggered retry that would conflict with preheat.
  • n8n restart at the exact end-of-print second drops that one auto-off. First tick after a deploy has previousGcodeState == null; if that tick is the RUNNING → FINISH one, no fire. Vanishingly rare; manually turn off if you see it.
  • PAUSE → FAILED does not fire. User cancelling a paused print leaves the heater on. Narrow previous == RUNNING check trades this for preheat safety on PREPARE → FAILED. Manually turn off in that case.
  • Orphaned data table. 3dPrint_v2 (GYmAXdbJ1uuQnCpq) is no longer referenced by the workflow but is left in place per the comment-don’t-delete convention. Harmless.
  • gcode_state field path assumption. Track State reads $json.message.print.gcode_state. Bambu’s MQTT schema has shifted across firmware versions; if isFinishTransition stops going true after a firmware update, dump a raw MQTT message from the execution log and verify the path. Symptom: workflow stays Active but never fires the turn-off even after prints finish.

Disabling

n8n UI → workflow MB5ajMgX9zg46yFP → toggle Active off. The MQTT subscription drops; the printer can still publish, but nothing acts on it. The Inkbird stays in whatever state you last left it in. To disable only the turn-off (e.g. during HA maintenance), disable the Turn Inkbird Off node directly.

  • Error notification workflow: n8n workflow TX2y7dUINVnLM3ze — “n8n Error Notification”.
  • Home Assistant entity: switch.inkbird. Manual on via Siri/Alexa integration (configured outside n8n).
  • Printer MQTT: Bambu Lab X1C — see Bambu firmware MQTT docs for full payload schema.
  • Orphaned data table: 3dPrint_v2 (GYmAXdbJ1uuQnCpq) in project XvuMFwjHIm5TIOoY — formerly held the lock row; now unused, left in place.

Changelog

  • 2026-06-06 — Edge-trigger rewrite for preheat support. Inserted Track State Code node between MQTT Trigger and Print Finished?; persists previousGcodeState via $getWorkflowStaticData('global'). Print Finished? now gates on isFinishTransition (previous == RUNNING && current ∈ {FINISH, FAILED}) instead of on raw gcode_state. Cancels the chain on steady-state FINISH → FINISH ticks so a manually-toggled Inkbird (for preheat) stays on. Narrow previous == RUNNING check chosen over broad “any transition into FINISH/FAILED” to preserve preheat survival across PREPARE → FAILED print-prep aborts; trade-off is that PAUSE → FAILED no longer auto-fires (see Known limitations).
  • 2026-06-02 — Rewrote idempotent. Dropped the lock entirely (Get Lock Row, Lock Open?, Engage Lock, Wait, Release Lock) after a stuck-lock deadlock left the heater on after every print (n8n restarted mid-Wait, Release Lock never ran). New chain was MQTT Trigger → Print Finished? → Get Inkbird State → Inkbird On? → Turn Inkbird Off. The Inkbird On? guard + harmless re-issue of turn_off replaced the lock’s debounce role with no suspended-execution failure mode. Data table 3dPrint_v2 orphaned but retained. (Superseded by the 2026-06-06 edge-trigger rewrite when preheat use case surfaced.)
  • 2026-05-21 — Full revamp (lock-based design, now superseded).
    • Recreated the underlying data table as 3dPrint_v2 (GYmAXdbJ1uuQnCpq) after the legacy 3dPrint (398wc4bsCyFrdKOY) lost its backing Postgres relation. Legacy metadata entry left in place — harmless.
    • Switched trigger from mc_remaining_time == 1 to gcode_state == FINISH OR FAILED. Catches real end-of-print and also catches failed prints (Inkbird still gets turned off, for safety).
    • Reordered: lock engaged before the Inkbird state check; added Release Lock + extended Wait to 10min; removed 5 dormant orphan nodes; HA nodes set to onError: continueRegularOutput; renamed all nodes for readability.