n8n workflow that reacts to the Bambu X1C’s print state (over MQTT) and drives two Home Assistant smart outlets:
- Inkbird chamber heater (
switch.inkbird) — turned off on theRUNNING → FINISH/FAILEDtransition if it’s on. Turned on manually via Siri/Alexa (preheat). This is the original, preheat-safe auto-off (see 2026-06-06 below). - Bento box air filter (
switch.bento_box) — turned on when a print starts (→ RUNNING), and off ~30 minutes after the print ends. Added 2026-06-28; see Bento box (air filter) auto on/off.
Both ride the single MQTT trigger and the shared Track State edge-detector; the inkbird and bento paths are independent branches. The delayed bento-off is driven by a second (Schedule) trigger.
Bento entity: wired to
switch.bento_box(the air-filter outlet, confirmed in HA 2026-06-28).switch.bento_box_ledis the plug’s indicator LED — deliberately not touched.
Quick Reference
| Item | Value |
|---|---|
| n8n workflow ID | MB5ajMgX9zg46yFP |
| Workflow name | 3DWorkFlow |
| Active version | aa57befe-4b90-4be8-a00a-fb0b9bf170d3 (2026-06-28) |
| Triggers | MQTT — device/00M09D522600299/report (print state) · Schedule — every 5 min (bento off-poll) |
| Printer | Bambu Lab X1C — serial 00M09D522600299 |
| HA entities controlled | switch.inkbird (chamber heater), switch.bento_box (air filter) |
| Off-stamp state | staticData.global.bentoOffAt (ISO8601 or unset) |
| Error workflow | TX2y7dUINVnLM3ze — “n8n Error Notification” |
| Status | ACTIVE |
Architecture
14 nodes, two triggers. The MQTT trigger feeds Track State, which fans out to three independent gates (inkbird-off, bento-on, bento-off-stamp). The Schedule trigger feeds the delayed bento-off poll.
MQTT Trigger ──> Track State ──┬─> Print Finished? (isFinishTransition) ─[true]─> Get Inkbird State ─> Inkbird On? ─[true]─> Turn Inkbird Off
│
├─> Bento Start? (isStartTransition) ─[true]─> Bento On (turn_on) ─> Clear Off Stamp (bentoOffAt = null)
│
└─> Bento End? (isFinishTransition) ─[true]─> Set Off Stamp (bentoOffAt = now + 30m)
Bento Off Poll (every 5m) ──> Bento Due? (offAt set && now >= offAt → clear, else stop) ──> Bento Off (turn_off)
| Node | Type | Key config |
|---|---|---|
| MQTT Trigger | mqttTrigger | topic device/00M09D522600299/report; jsonParseBody: true, parallelProcessing: false; cred “MQTT account” |
| Track State | code v2 | reads/writes staticData.global.previousGcodeState; emits { previousState, currentState, isFinishTransition, isStartTransition }; guards against messages lacking gcode_state (see below) |
| Print Finished? | if v2.2 | {{ $json.isFinishTransition }} is true |
| Get Inkbird State | homeAssistant | resource state, entity switch.inkbird; onError: continueRegularOutput |
| Inkbird On? | if v2.2 | {{ $json.state }} equals on |
| Turn Inkbird Off | homeAssistant | service call switch.turn_off, entity switch.inkbird; onError: continueRegularOutput |
| Bento Start? | if v2.2 | {{ $json.isStartTransition }} is true |
| Bento On | homeAssistant | service call switch.turn_on, entity switch.bento_box; onError: continueRegularOutput |
| Clear Off Stamp | code v2 | staticData.global.bentoOffAt = null (cancels a pending off when a new print starts) |
| Bento End? | if v2.2 | {{ $json.isFinishTransition }} is true (reuses the inkbird’s finish edge) |
| Set Off Stamp | code v2 | staticData.global.bentoOffAt = $now.plus({ minutes: 30 }).toISO() |
| Bento Off Poll | scheduleTrigger v1.3 | every 5 minutes |
| Bento Due? | code v2 | if bentoOffAt set and due, clear it and pass one item; else return [] (stops the branch) |
| Bento Off | homeAssistant | service call switch.turn_off, entity switch.bento_box; onError: continueRegularOutput |
Track State JS body:
const staticData = $getWorkflowStaticData('global');
const previousState = staticData.previousGcodeState ?? null;
const rawState = $input.first().json.message?.print?.gcode_state;
// Bambu MQTT pushes partial/delta reports that omit gcode_state.
// Ignore those: keep prior state, emit no edges (also protects inkbird finish-detection).
if (rawState === undefined || rawState === null) {
return [{ json: { previousState, currentState: previousState, isFinishTransition: false, isStartTransition: false } }];
}
const currentState = rawState;
const isFinishTransition = previousState === 'RUNNING' && (currentState === 'FINISH' || currentState === 'FAILED');
const isStartTransition = previousState !== 'RUNNING' && currentState === 'RUNNING';
staticData.previousGcodeState = currentState;
return [{ json: { previousState, currentState, isFinishTransition, isStartTransition } }];How it works (inkbird auto-off)
The Bambu X1C publishes its status to device/<serial>/report over MQTT roughly once a second. Track State records the new gcode_state and compares with the prior one in workflow staticData. Print Finished? passes only on the RUNNING → FINISH/FAILED transition; on a pass, Get Inkbird State reads switch.inkbird and, if on, Turn Inkbird Off calls switch.turn_off.
The edge-trigger is what makes this preheat-safe. The printer keeps republishing FINISH until the next print starts, so a pure level-trigger would cancel any manual Inkbird-on the moment it landed. With the transition check, FINISH → FINISH ticks fall through; only the one RUNNING → FINISH tick fires the turn-off.
Track State partial-message guard (2026-06-28)
Bambu’s MQTT reports are partial/delta — most messages carry print.gcode_state, but some don’t. The original Track State wrote previousGcodeState = currentState ?? null unconditionally, so a single message lacking gcode_state would null out the tracked state mid-print. That breaks the inkbird finish-detection (the next real FINISH then sees previous === null, not RUNNING, and the shutoff is missed). The guard skips state-update and edge-emission on any message without gcode_state, hardening both the inkbird and bento paths.
Bento box (air filter) auto on/off
The bento box is a separate smart outlet (same model as the inkbird) controlling the enclosure’s air filter. Unlike the heater, you want it running during the print and for a while after, to clear VOCs/particulates — so: on at start, off ~30 min after end.
- On at start.
Bento Start?fires on the→ RUNNINGedge (isStartTransition), callsswitch.turn_ononswitch.bento_box, thenClear Off Stampwipes any pending off-time. The clear is what makes a reprint cancel a pending off: if a new print starts within the 30-min window, the stamp is removed so the scheduled off never lands mid-print. - Off ~30 min after end.
Bento End?fires on the sameRUNNING → FINISH/FAILEDedge the inkbird uses and stampsstaticData.global.bentoOffAt = now + 30m. Nothing turns off here. - The delayed off is a separate Schedule trigger (
Bento Off Poll, every 5 min) →Bento Due?. IfbentoOffAtis set and due, it clears the stamp (before acting, so the next poll can’t double-fire) and letsBento Offcallswitch.turn_off; otherwise it returns[]and the branch stops. Because the off-time lives instaticData(not a suspendedWait), it survives n8n restarts — the next poll after a restart still fires it. Granularity is the poll interval, so the off actually lands 30–35 min after end.
Why a Schedule-poll instead of a Wait node: a long Wait holds a suspended execution that dies if n8n restarts mid-wait (this exact class of bug stranded the heater on 2026-06-02). The timestamp-in-staticData + poll pattern has no suspended execution to lose.
State Detection
Bambu’s gcode_state values are IDLE / PREPARE / RUNNING / PAUSE / FINISH / FAILED. The workflow acts on two edges:
- Start:
previous !== RUNNING && current === RUNNING→ bento on. (Anchored onRUNNING, i.e. first-layer, notPREPAREheating — negligible fumes during prep.) - Finish:
previous === RUNNING && current ∈ {FINISH, FAILED}→ inkbird off + bento off-stamp.
Why require previous === RUNNING for finish (and not any transition into FINISH/FAILED)?
PREPARE → FAILED(print bails before extruding) — heater wasn’t running for a real print; if you’d preheated, you’d want it to stand. Narrow check preserves the preheat.PAUSE → FAILED(user cancels a paused print) — rare corner the narrow check skips. Heater stays on and the bento off isn’t scheduled; turn both off manually. See Known limitations.
Failed-mid-print is RUNNING → FAILED, which fires both paths as intended.
Credentials Needed
MQTT account(mqtt) — Bambu MQTT broker credentials. Used byMQTT Trigger.Home Assistant account(homeAssistantApi) — HA long-lived access token. Used by all four HA nodes (Get Inkbird State,Turn Inkbird Off,Bento On,Bento 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.
Fix: edge-trigger. Inserted a Track State Code node that persists previousGcodeState via $getWorkflowStaticData('global') and emits isFinishTransition only on RUNNING → FINISH/FAILED. The IF now gates on that boolean instead of the raw gcode_state.
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. Every FINISH then dead-ended at the Lock Open? gate.
Fix: (1) manually reset the lock row to false; (2) rewrote idempotent — dropped Get Lock Row, Lock Open?, Engage Lock, Wait, 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.
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. (The bento off-poll added 2026-06-28 follows this lesson — timestamp in staticData, no Wait.)
Known limitations
- HA failures during the one transition tick are swallowed silently.
onError: continueRegularOutputon the HA nodes means a genuine HA failure won’t reach the error-notification workflow. Under the edge-triggered design, a transient HA failure on the singleRUNNING → FINISHtick will strand the heater on (next tick isFINISH → FINISHand falls through). Same applies to the bento off-stamp. If it bites, route the relevant HA node’s error path toTX2y7dUINVnLM3ze. - n8n restart at the exact end-of-print second drops that one inkbird auto-off. First tick after a deploy has
previousGcodeState == null; if that tick is theRUNNING → FINISHone, no fire. (The bento off-stamp is more robust — once set, the poll fires it even across restarts. But the stamp-set itself, on that same tick, would be missed too.) PAUSE → FAILEDdoes not fire either path. User cancelling a paused print leaves the heater on and schedules no bento off. Manually turn both off.- Bento HA calls fail silently if the entity goes missing.
onError: continueRegularOutputkeeps the workflow green even ifswitch.bento_boxis renamed/removed, so a broken entity surfaces as the bento simply not toggling, not as an error. Re-check the entity if it stops working. - Bento off granularity = 5 min, so the off lands 30–35 min after end. Drop
Bento Off Pollto a 1-minute interval if tighter timing matters. - Bento off race.
bentoOffAtis last-write-wins across the MQTT and Schedule executions; worst case the off slips one poll cycle or issues one extra (idempotent)turn_off.Bento Due?reads-and-clears in one node to shrink the window. gcode_statefield path assumption.Track Statereads$json.message.print.gcode_state. Bambu’s MQTT schema has shifted across firmware versions; if edges stop firing after a firmware update, dump a raw MQTT message from the execution log and verify the path.
Disabling
n8n UI → workflow MB5ajMgX9zg46yFP → toggle Active off. Both triggers drop. To disable only one path, disable the specific HA node (Turn Inkbird Off, Bento On, or Bento Off) or the Bento Off Poll trigger directly.
Related
- Error notification workflow: n8n workflow
TX2y7dUINVnLM3ze— “n8n Error Notification”. - Home Assistant entities:
switch.inkbird(manual on via Siri/Alexa),switch.bento_box(air filter — confirm entity). - Printer MQTT: Bambu Lab X1C — see Bambu firmware MQTT docs for full payload schema.
- Orphaned data tables:
3dPrint(398wc4bsCyFrdKOY) and3dPrint_v2(GYmAXdbJ1uuQnCpq) in projectXvuMFwjHIm5TIOoY— formerly held the lock row; now unused (state lives instaticData), left in place per the comment-don’t-delete convention.
Changelog
- 2026-06-28 — Added the bento box air-filter path. New nodes:
Bento Start?→Bento On→Clear Off Stamp(on at print start),Bento End?→Set Off Stamp(stampbentoOffAt = now+30mon the finish edge), and a second Schedule triggerBento Off Poll(every 5 min) →Bento Due?→Bento Off(delayed off, restart-safe viastaticDatatimestamp).Track Stateextended to emitisStartTransitionand guarded against MQTT messages lackinggcode_state(which previously could nullpreviousGcodeStatemid-print and silently break the inkbird finish-detection). Inkbird chain otherwise unchanged. Wired toswitch.bento_box(confirmed 2026-06-28;switch.bento_box_ledleft untouched). - 2026-06-06 — Edge-trigger rewrite for preheat support. Inserted
Track StateCode node;Print Finished?now gates onisFinishTransition(previous == RUNNING && current ∈ {FINISH, FAILED}) instead of rawgcode_state. Narrowprevious == RUNNINGcheck chosen over broad “any transition into FINISH/FAILED” to preserve preheat survival acrossPREPARE → FAILEDaborts; trade-off is thatPAUSE → FAILEDno longer auto-fires. - 2026-06-02 — Rewrote idempotent. Dropped the lock entirely after a stuck-lock deadlock left the heater on after every print (n8n restarted mid-
Wait,Release Locknever ran). Data table3dPrint_v2orphaned but retained. - 2026-05-21 — Full revamp (lock-based design, now superseded). Recreated the data table as
3dPrint_v2after the legacy3dPrintlost its backing Postgres relation; switched trigger frommc_remaining_time == 1togcode_state == FINISH OR FAILED; HA nodes set toonError: continueRegularOutput.