A 3D printer on the line
A Bambu Lab P1S resolves its print escalation the way a person resolves a form. The file goes over FTPS, the print runs over MQTT, and the shipped workflow runs unchanged.
7 stepsStart with step 1: A line from the environment →
The moment
A Bambu Lab P1S sits on a bench at Acme’s site 1. Someone wants a boat printed. They run one command. The file lands on the printer’s storage, the printer starts, and an escalation on the dashboard fills in as it prints: started, 25%, 50%, 75%, finished. The spool runs out at 27%. A servicing escalation names the error and says what to do. Someone loads filament and presses resume on the printer, and the servicing escalation closes itself. When the boat is done, an unload escalation waits for a person to take it off the plate.
Nothing in the workflow knows a printer is involved. It is machineUnitRoute, the same route the factory line runs: it parks a handoff escalation on the printer’s role and waits. The printer resolves it.
This page walks the whole example from the long-tail repository, examples/workflows/bambu-printer, with every file and message as it runs. A printer most people have seen makes each step of the machine protocol easy to picture.
What you need
| A printer | A Bambu Lab P1S, or another Bambu printer that serves MQTT and FTPS on the LAN. Or the simulated printer, below |
| On the printer | Settings → Network: turn on LAN Only, then Developer Mode. The same screen shows the IP address and the Access Code |
| On your machine | Docker, and a clone of the long-tail repository |
| For your own parts | OrcaSlicer, to slice a model for your printer |
Without Developer Mode the printer reports its status but refuses prints from anything but Bambu’s own software. It answers each print request with 0502-4007.
Secrets in .env
The printer’s serial, address and access code are the only secrets, and they live in .env at the repository root. Copy .env.example to .env; git ignores .env, so nothing in it is committed. Fill in the printer’s block:
# The site and line the printers formBAMBU_SITE_ID=site-1BAMBU_LINE_ID=print-1# One block per station in BAMBU_PRINTERS: the station upper-cased, - as _BAMBU_PRINTERS=p1s-01BAMBU_P1S_01_SERIAL=<printer serial>BAMBU_P1S_01_HOST=<printer LAN address>BAMBU_P1S_01_CODE=<printer access code>BAMBU_P1S_01_ENABLED=true
# A Long Tail login the print driver signs in withLT_USERNAME=superadminLT_PASSWORD=<the password you set>| Variable | What it holds |
|---|---|
BAMBU_SITE_ID, BAMBU_LINE_ID | The site and line the printers form |
BAMBU_PRINTERS | The stations, separated by commas. Each one names a block of BAMBU_<STATION>_* variables |
BAMBU_<STATION>_SERIAL | The printer’s serial. It is the printer’s address on the line and a level of its topics |
BAMBU_<STATION>_HOST | The printer’s LAN address |
BAMBU_<STATION>_CODE | The printer’s access code: the password for its MQTT broker and its FTPS storage |
BAMBU_<STATION>_ENABLED | true puts the printer on the line. Only enabled printers are bridged |
LT_USERNAME, LT_PASSWORD | A Long Tail login that may start the route, for the print driver |
machine-site takes the file as its env_file to render the site, and so does bambu-sim when it runs. The Long Tail container mounts the repository, and the print driver loads /app/.env when it starts. The access code reaches the printer twice: as the bridge’s MQTT password and as the FTPS password.
The setup: a line from .env
env.ts turns the printers in .env into a line: one print station per enabled printer, with the printer’s serial as its address. A person unloads at the end, and a person services the printer:
function printerDef(p: BambuPrinter): MachineDef { return { machineId: p.station, address: p.serial, process: PRINT_PROCESS, description: `Bambu printer ${p.station}`, carrier: 'none', cycleSec: 3600, changeoverSec: 0, rejectReason: 'PRINT-FAILED', recipes: { [PRINT_RECIPE.id]: [PRINT_RECIPE.version] }, materials: [], measurements: [], faults: {}, queueDepth: 1, requireArtifact: true, };}
/** The line of enabled printers: one print station each, a person unloads, a person services. */export function bambuLine(printers: BambuPrinter[], env: Env = process.env): LineDef { const enabled = printers.filter((p) => p.enabled); if (!enabled.length) throw new Error('no printer is enabled; set BAMBU_<STATION>_ENABLED=true for one'); return { siteId: required(env, 'BAMBU_SITE_ID'), lineId: required(env, 'BAMBU_LINE_ID'), roles: { exit: BAMBU_ROLES.UNLOAD, operator: BAMBU_ROLES.SERVICING }, machines: enabled.map(printerDef), };}The site setup writes the result to line.json:
{ "siteId": "site-1", "lineId": "print-1", "roles": { "exit": "print-unload", "operator": "print-servicing" }, "machines": [ { "machineId": "p1s-01", "address": "01P00A000000001", "process": "print", "description": "Bambu printer p1s-01", "carrier": "none", "cycleSec": 3600, "changeoverSec": 0, "rejectReason": "PRINT-FAILED", "recipes": { "print-file": ["1"] }, "materials": [], "measurements": [], "faults": {}, "queueDepth": 1, "requireArtifact": true } ]}| Field | Meaning |
|---|---|
address | The printer’s serial. The codec reads it from every topic, so each message names its station |
roles.exit | print-unload: the person who takes the part off the plate |
roles.operator | print-servicing: the person who gets alarms, a printer that goes offline, and a print that failed |
queueDepth: 1 | The printer holds one print at a time. A second print waits in the role queue until the first part is unloaded |
requireArtifact: true | A print escalation must name a file. The gateway refuses one without it |
cycleSec: 3600 | How long a print is expected to take, for pacing |
The station role is line-print, from the process. The line declares line-print, print-unload, print-servicing and line-quality with the line’s own forms, and makes the printer a bot account on line-print.
A host application declares the line and the codec in start():
import { start, Machines } from '@hotmeshio/long-tail';// Copied from the long-tail repository's examples/workflows/bambu-printerimport { bambuCodec } from './bambu-printer/codec';
const line = Machines.loadLine('/etc/site/line.json');
await start({ database, workers, machines: { lines: [line], codec: bambuCodec({ root: 'line/v1', line }), },});The example runs on the development server instead, which reads the line and the codec from the environment. That is the compose override:
services: long-tail: environment: MACHINE_LINE_CONFIG: /etc/site/line.json MACHINE_CODEC_MODULE: /app/examples/workflows/bambu-printer/codec.ts MACHINE_LIFECYCLE: "on" MACHINE_PROGRESS_STEP: ${MACHINE_PROGRESS_STEP:-25} MACHINE_QUIET_MS: ${MACHINE_QUIET_MS:-0} volumes: - ./deploy/bambu-site:/etc/site:ro
machine-site: env_file: - path: .env required: false environment: MACHINE_SITE_DIR: /app/deploy/bambu-site command: ["npx", "ts-node", "--transpile-only", "examples/workflows/bambu-printer/site/setup.ts"]MACHINE_CODEC_MODULE names a module whose createCodec(line) returns the codec. MACHINE_LIFECYCLE=on notes each print’s milestones on its escalation.
The site: a bridge to the printer’s own broker
A Bambu printer hosts its own MQTT broker: port 8883, TLS, user bblp, the access code as the password, topics device/<serial>/report and device/<serial>/request. The site’s broker connects out to it:
P1S (its own MQTT broker, :8883, user bblp + LAN access code) ⇅ Mosquitto bridge: report in, request out, under line/v1/<site>/<line>/<serial>/machine broker (Mosquitto) ──bridge──▶ site NATS: JetStream keeps every report │Long Tail: the gateway reads the stream, sends requests through the broker, and resolves escalations as the printer's botmachine-site renders one Mosquitto bridge per printer and appends it to the site broker’s configuration:
connection site-1-print-1-p1s-01address <printer LAN address>:8883bridge_protocol_version mqttv311bridge_cafile /certs/bambu-ca-01P00A000000001.pembridge_insecure trueremote_username bblpremote_password <printer access code>remote_clientid line-bridge-site-1-print-1-p1s-01cleansession truetry_private falsenotifications truenotifications_local_only truenotification_topic line/v1/site-1/print-1/01P00A000000001/bridgetopic report in 0 line/v1/site-1/print-1/01P00A000000001/ device/01P00A000000001/topic request out 0 line/v1/site-1/print-1/01P00A000000001/ device/01P00A000000001/| Line | What it does |
|---|---|
topic report in | The printer’s reports arrive at line/v1/site-1/print-1/<serial>/report |
topic request out | Requests published at .../<serial>/request go to the printer’s device/<serial>/request |
notification_topic | The bridge writes 1 or 0 there, retained, as it connects and drops. The codec reads it as the printer’s presence |
bridge_cafile | The printer’s CA, read from the printer once |
bridge_insecure true | The printer’s certificate is issued to its serial, not its address, so the bridge checks the chain against the CA and skips the host name |
The setup reads the CA the first time it sees a printer and keeps it in deploy/bambu-site/certs/bambu-ca-<serial>.pem:
/** The root of the chain the printer presents, as PEM. */function readPrinterCa(printer: BambuPrinter): Promise<string> { return new Promise((resolve, reject) => { const socket = connect({ host: printer.host, port: PRINTER_ACCESS.MQTT_PORT, rejectUnauthorized: false, timeout: 8_000 }, () => { let cert: DetailedPeerCertificate = socket.getPeerCertificate(true); while (cert.issuerCertificate && cert.issuerCertificate !== cert) cert = cert.issuerCertificate; socket.end(); const body = cert.raw.toString('base64').match(/.{1,64}/g)!.join('\n'); resolve(`-----BEGIN CERTIFICATE-----\n${body}\n-----END CERTIFICATE-----\n`); }); socket.on('timeout', () => socket.destroy(new Error(`${printer.station} at ${printer.host}:${PRINTER_ACCESS.MQTT_PORT} did not answer`))); socket.on('error', reject); });}The bridge keeps everything the printer sends. JetStream on the site NATS stores every report, and the gateway in Long Tail reads the stream.
Start the stack, then print:
docker compose -f docker-compose.yml -f docker-compose.nats.yml -f docker-compose.machines.yml \ -f docker-compose.bambu.yml up -d --builddocker compose exec long-tail npm run bambu:print1. The route asks for a print
npm run bambu:print is the driver. It puts the file on the printer, then starts the route with one print step and the file as the unit’s artifact:
import { UNIT_ROUTE_WORKFLOW, type UnitRouteInput } from '../examples/workflows/machine-line/workflows/route';import { exitRole, operatorRole } from '../lib/machines/line';
// 1. The artifact, on the printerconst routeKey = `P${Date.now().toString(36)}`;const sha256 = createHash('sha256').update(readFileSync(file)).digest('hex');const url = await stageFile(printer, file, `${routeKey}.gcode.3mf`);
// 2. The route: one print step at the station, then a person unloadsconst input: UnitRouteInput = { siteId: line.siteId, lineId: line.lineId, routeKey, orderRef: `ORD-${routeKey}`, units: [{ unitId: `U-${routeKey}`, carrierId: `PLATE-${routeKey}` }], steps: [{ machineId: station, process: PRINT_PROCESS, recipe: { ...PRINT_RECIPE } }], artifact: { url, sha256 }, exitRole: exitRole(line), operatorRole: operatorRole(line), handoffTimeout: env.BAMBU_HANDOFF_TIMEOUT || '6h',};await api('POST', `/workflows/${UNIT_ROUTE_WORKFLOW}/invoke`, { data: input, metadata: { source: 'bambu-print' }, workflowId });The route parks a handoff escalation on line-print and waits, exactly as it would for a person at a station. Its metadata carries the job, the unit, the recipe and the artifact’s URL and sha256. exitRole and operatorRole come from the line, so the unload and any decision land on print-unload and print-servicing.
2. The file goes to the printer
A Bambu printer on the LAN prints only files on its own storage. It serves that storage over implicit FTPS on port 990: TLS from the first byte, user bblp, the access code. The session:
(TLS from the first byte) ← 220 readyUSER bblp ← 331 Password requiredPASS <printer access code> ← 230 Logged inTYPE I ← 200PBSZ 0 ← 200PROT P ← 200PASV ← 227 Entering Passive Mode (h1,h2,h3,h4,p1,p2)STOR cache/Pmw8druzy.gcode.3mf ← 150 (data channel: TLS to port p1*256+p2, on the control channel's session) (the file's bytes, then close) ← 226 Transfer completeQUITThe data channel reuses the control channel’s TLS session, which the printer requires. Most FTP clients don’t do that, so files.ts speaks the protocol on Node’s tls directly:
/** Upload `localPath` to the printer's cache as `name`; returns the URL a print request names. */export async function stageFile(printer: Pick<BambuPrinter, 'host' | 'code'>, localPath: string, name: string): Promise<string> { const control = new Control(await tlsTo(printer.host, PRINTER_ACCESS.FTPS_PORT)); try { // 1. Log in on the TLS control channel, protect the data channel await control.reply(); await control.send(`USER ${PRINTER_ACCESS.USER}`, /^331\b/); await control.send(`PASS ${printer.code}`, /^230\b/); await control.send('TYPE I', /^200\b/); await control.send('PBSZ 0', /^200\b/); await control.send('PROT P', /^200\b/);
// 2. A passive data channel on the control channel's TLS session const pasv = await control.send('PASV', /^227\b/); const [, , , , p1, p2] = pasv.match(/\((\d+),(\d+),(\d+),(\d+),(\d+),(\d+)\)/)!.slice(1).map(Number); const stored = control.send(`STOR ${PRINTER_ACCESS.CACHE_DIR}/${name}`, /^1\d\d\b/); const data = await tlsTo(printer.host, p1 * 256 + p2, control.socket.getSession()); await stored;
// 3. Send the file, close the data channel, wait for the printer to confirm const done = control.reply(); await new Promise<void>((resolve, reject) => data.end(readFileSync(localPath), () => resolve()).on('error', reject)); const line = await done; if (!/^226\b/.test(line)) throw new Error(`printer FTPS STOR: ${line}`); return cachedFileUrl(name); } finally { control.socket.end('QUIT\r\n'); }}The file lands in cache/ and prints as file:///sdcard/cache/<name>. The printer writes to its card slowly before it confirms, so a 2.6 MB file takes a minute or more. The driver hashes the file, and the URL and sha256 become the unit’s artifact, which requireArtifact checks.
3. The printer claims the print
The gateway sees the new handoff on line-print and sends the print as a project_file request:
{ "print": { "sequence_id": "<print escalation id>:dispatch", "command": "project_file", "param": "Metadata/plate_1.gcode", "project_id": "0", "profile_id": "0", "task_id": "0", "subtask_id": "0", "subtask_name": "Pmw8druzy-p1s-01~U-Pmw8druzy", "url": "file:///sdcard/cache/Pmw8druzy.gcode.3mf", "md5": "", "timelapse": false, "bed_type": "auto", "bed_levelling": true, "flow_cali": false, "vibration_cali": false, "layer_inspect": false, "use_ams": false, "ams_mapping": [] }}subtask_name is <jobId>~<unitId>. The printer echoes it in every report, so each report names its escalation. The codec builds the request:
function printFile(ident: MachineIdent, command: MachineCommand): WireMessage[] { const args = command.args as Record<string, any>; const unit = Array.isArray(args.units) ? args.units[0] : null; const url = unit?.artifact?.url ?? args.artifact?.url; if (!unit?.unitId || typeof url !== 'string' || !url) return []; const parameters = { ...(args.parameters ?? {}), ...(unit.parameters ?? {}) }; return request(ident, { print: { sequence_id: command.cmdId, command: PRINTER_COMMAND.PROJECT_FILE, param: parameters.plate ?? DEFAULT_PLATE, project_id: '0', profile_id: '0', task_id: '0', subtask_id: '0', subtask_name: subtaskName(String(args.jobId), unit.unitId), url, md5: '', timelapse: false, bed_type: BED_TYPE_AUTO, bed_levelling: parameters.bedLevelling !== false, flow_cali: false, vibration_cali: false, layer_inspect: false, use_ams: parameters.useAms === true, ams_mapping: [], }, });}The printer acknowledges under the same sequence_id:
{ "print": { "sequence_id": "<print escalation id>:dispatch", "command": "project_file", "param": "Metadata/plate_1.gcode", "reason": "success", "result": "success" } }The codec reads the acknowledgement as an accepted reply, and the gateway claims the print escalation as the printer’s bot account, machine-site-1-print-1-<serial>. Every item and the resolution carry that account. A refusal reads as a refused reply with the printer’s error code (0502-4008 for a printer busy with another print), and the hop resolves dispatch-refused.
When the gateway takes the line, it sends pushall. The printer answers with a full report, so a print that finished while Long Tail was down still resolves its escalation.
4. Reports become progress
The printer reports about once a second. After a full report it sends only the fields that changed, so the codec keeps one merged report per printer and reads the print’s phase from it:
export function phaseOf(r: PrinterReport): Phase { switch (r.gcode_state) { case PRINT_STATE.PREPARE: return PHASE.PREPARING; case PRINT_STATE.RUNNING: { // The stage says so when the printer reports one; otherwise a layer under way does. const laying = typeof r.stg_cur === 'number' ? r.stg_cur === STAGE_PRINTING : typeof r.layer_num === 'number' && r.layer_num >= 1; return laying ? PHASE.PRINTING : PHASE.PREPARING; } case PRINT_STATE.PAUSE: return PHASE.PAUSED; case PRINT_STATE.FINISH: return PHASE.FINISHED; case PRINT_STATE.FAILED: return PHASE.FAILED; default: return PHASE.IDLE; }}| Report field | What the codec reads from it |
|---|---|
gcode_state | The phase: PREPARE, RUNNING, PAUSE, FINISH, FAILED, or idle |
stg_cur | 0 while the printer lays down material. Other values are heating, leveling and the like |
mc_percent, mc_remaining_time | Progress, and minutes left |
layer_num, total_layer_num | The layer, for the progress facet |
subtask_name | Whose print this is: <jobId>~<unitId>. A print started elsewhere names no job of ours |
print_error | An error as a number. 134184977 is 07FF-8011, a filament runout |
A partial report at 75%:
{ "print": { "stg_cur": 0, "mc_percent": 75, "mc_remaining_time": 1, "layer_num": 45, "command": "push_status", "msg": 1, "sequence_id": "1046" } }The codec turns it into a line-protocol progress event, and the gateway notes it on the print escalation:
{ "v": "1", "id": "83215978-41a0-5638-82ce-aec6fbe47d6f", "at": "2026-11-08T22:06:00.563Z", "source": { "site": "site-1", "line": "print-1", "machine": "01P00A000000001", "station": "p1s-01" }, "body": { "event": "progress", "jobId": "Pmw8druzy-p1s-01", "unitId": "U-Pmw8druzy", "orderRef": "Pmw8druzy-p1s-01", "recipe": null, "carrierId": null, "percent": 75, "remainingSec": 60, "detail": { "layer": 45, "layers": 60 } }}// gateway: accumulate progress:U-Pmw8druzy:75 on the print escalationWith MACHINE_LIFECYCLE=on, the gateway notes only the milestones as items on the print escalation. JetStream keeps everything else:
| Item | When |
|---|---|
started:<unit> | The printer starts laying down material: gcode_state is RUNNING and stg_cur is 0 |
progress:<unit>:25, :50, :75 | mc_percent crosses each step of MACHINE_PROGRESS_STEP. The progress facet holds the latest percent, time left and layer |
paused:print-error:<at> | The printer stops on an error, such as a filament runout |
resumed:print-error:<at> | Someone fixes the printer and resumes it |
arrived:<unit> | The print finishes (FINISH). This resolves the escalation |
The whole run, as the driver prints it:
[22:05:09] copying p1s-0.8-tpu.gcode.3mf to p1s-01 (a minute or two for a few MB)[22:05:09] file:///sdcard/cache/Pmw8druzy.gcode.3mf (sha256 708d5acd78cd…)[22:05:09] started bambu-Pmw8druzy on p1s-01; unload the part on the dashboard (print-unload) when it finishes[22:05:12] c5ea0fcd handoff/line-print pending claimed [started:U-Pmw8druzy][22:05:24] c5ea0fcd handoff/line-print pending claimed [started:U-Pmw8druzy, progress:U-Pmw8druzy:25][22:05:27] c5ea0fcd handoff/line-print pending claimed [started:U-Pmw8druzy, progress:U-Pmw8druzy:25, paused:print-error:2026-11-08T22:05:25.810Z][22:05:33] c5ea0fcd handoff/line-print pending claimed [started:U-Pmw8druzy, progress:U-Pmw8druzy:25, paused:print-error:2026-11-08T22:05:25.810Z, resumed:print-error:2026-11-08T22:05:25.810Z][22:05:45] c5ea0fcd handoff/line-print pending claimed [started:U-Pmw8druzy, progress:U-Pmw8druzy:25, paused:print-error:2026-11-08T22:05:25.810Z, resumed:print-error:2026-11-08T22:05:25.810Z, progress:U-Pmw8druzy:50][22:06:00] c5ea0fcd handoff/line-print pending claimed [started:U-Pmw8druzy, progress:U-Pmw8druzy:25, paused:print-error:2026-11-08T22:05:25.810Z, resumed:print-error:2026-11-08T22:05:25.810Z, progress:U-Pmw8druzy:50, progress:U-Pmw8druzy:75][22:06:15] c5ea0fcd handoff/line-print resolved claimed [started:U-Pmw8druzy, progress:U-Pmw8druzy:25, paused:print-error:2026-11-08T22:05:25.810Z, resumed:print-error:2026-11-08T22:05:25.810Z, progress:U-Pmw8druzy:50, progress:U-Pmw8druzy:75, arrived:U-Pmw8druzy][22:06:15] 894d7c92 handoff/print-unload pending [][22:06:18] 894d7c92 handoff/print-unload resolved claimed [][22:06:19] route ended: {"status":"complete","units":["U-Pmw8druzy"],"history":[{"hop":0,"to":"p1s-01","attempt":1,"received":true},{"hop":1,"to":"exit","attempt":1,"received":true}]}5. The answer comes back
At FINISH the codec reports the unit’s arrival and the job’s finished record, and the gateway resolves the print escalation as the printer:
| What ended the wait | $trigger | Status | The workflow receives |
|---|---|---|---|
| The print finishes | resolve | resolved | { received: true, receiver: 'p1s-01', units: ['U-…'] } |
| The printer refuses it, or the print stops unfinished | resolve | resolved | { received: false, errorCode: 'dispatch-refused', detail } |
Six hours pass (handoffTimeout) | timeout | expired | The items so far, in $accumulated |
| Cancel | none | cancelled | null |
The route then parks the unload on print-unload. A person takes the part off the plate and resolves it with the line’s handoff form:
{ "title": "Receive units", "description": "Confirm the units are in hand. A machine confirms its own receipts; this form is for a person receiving them.", "x-lt-order": [ "received", "receiver", "note" ], "required": [ "received" ], "properties": { "received": { "type": "boolean", "default": true, "description": "Every unit in this handoff is in hand" }, "receiver": { "type": "string", "default": "", "description": "Who or what received them" }, "note": { "type": "string", "format": "textarea", "default": "", "description": "Anything notable about the units" } }}Claim the unload and confirm the part is in hand.
Submit the form to see what the workflow resumes with.
The printer stays held until then. queueDepth: 1 keeps a second print unclaimed in the line-print queue until the first part is unloaded, so nothing prints onto a full plate.
When a print goes wrong
The spool runs out. The printer pauses with print_error 134184977. The codec raises an alarm, and the gateway opens a line-alarm escalation on print-servicing and notes the pause on the print escalation:
{ "alarmId": "print-error", "level": "critical", "code": "07FF-8011", "text": "The external spool ran out of filament. Load filament and resume the print.", "active": true, "jobId": "Pmw8druzy-p1s-01", "raisedAt": "2026-11-08T22:05:25.810Z", "clearedAt": null}// gateway: a line-alarm escalation on print-servicing,// paused:print-error:2026-11-08T22:05:25.810Z on the print escalationSomeone loads filament and resumes at the printer. print_error goes back to 0, the codec clears the alarm, the alarm escalation resolves itself, and the print escalation notes the resume. The print escalation stays open throughout and resolves at the finish.
The print stops unfinished. The codec reports the job withdrawn, with a reason: cancelled when the printer flashed a cancel code, the printer’s error code, or did-not-print when it went back to idle before printing (a failed bed leveling, say).
The printer refuses it. The hop resolves dispatch-refused, with the printer’s code in detail.
Either way, the route asks the line’s operator role what to do:
/** Ask until the operator picks one of the decisions this failure allows. */async function askOperator(input: UnitRouteInput, hop: Hop, attempt: number, senderAttempt: number, outcome: HopOutcome): Promise<OperatorResolution> { const where = stationName(hop.from, 'line entry'); const decisions = handoffDecisions(outcome.errorCode); let refused = ''; for (let round = 1; ; round++) { const answer = await conditional<OperatorResolution>(signalKeys.handoffError(input.routeKey, hop.index, attempt, round), { role: input.operatorRole ?? LINE_ROLE.OPERATOR, type: ROW_TYPE.HANDOFF_ERROR, subtype: outcome.errorCode, description: `${refused}${input.units.map((u) => u.unitId).join(', ')} stopped at ${where}: ${outcome.errorCode}. ${outcome.detail ?? ''} Choose ${decisions.join(', ')}.`.replace(/\s+/g, ' ').trim(), priority: 1, workflowType: UNIT_ROUTE_WORKFLOW, metadata: { ...hopFacets(input, hop, attempt, senderAttempt), errorCode: outcome.errorCode, detail: outcome.detail ?? null, decisions, round, } as Record<string, unknown>, timeout: input.operatorTimeout ?? OPERATOR_TIMEOUT, }); if (!answer) return { decision: DECISION.SCRAP, note: 'no decision before the timeout' }; if (decisions.includes(answer.decision)) return answer; refused = `${answer.decision} is not available here. `; }}{ "title": "Line decision", "description": "A handoff that did not complete: retry it, finish the units by hand, or scrap them. Units a machine finished as not ok are off the line: finish them by hand or scrap them. An alarm or a machine that went dark: acknowledge it once handled.", "x-lt-order": [ "decision", "note" ], "required": [ "decision" ], "properties": { "decision": { "type": "string", "enum": [ "retry", "manual", "scrap", "acknowledge", "cleared", "restored", "approve", "reject", "release", "rework" ], "default": "acknowledge", "description": "One of the row's decisions for a failed handoff; acknowledge for an alarm or a machine" }, "note": { "type": "string", "format": "textarea", "default": "", "description": "What was done" }, "clearedBy": { "type": "string", "default": "", "description": "Who cleared it at the machine, when the machine reports it" } }}Choose retry, manual or scrap for the refused print.
Submit the form to see what the workflow resumes with.
retry sends the print again as a new job. manual hands the unit to a person. scrap ends the route scrapped. No answer in seven days scraps it.
If the printer drops off the network, the bridge writes 0 and a machine-offline escalation goes to print-servicing. The print escalation stays open. When the bridge reconnects, the gateway asks for a full report, and a print that finished meanwhile resolves its escalation.
Commands are escalations too
Pause, resume, cancel and snapshot are machine-command escalations on the printer’s role. The gateway sends each as a printer request and resolves the escalation with the printer’s reply:
const { data } = await lt.escalations.create({ ...Machines.commandRow(Machines.commandTarget(line, 'p1s-01'), 'pause', {}),});// p1s-01 resolves it with { accepted, machineId, op, status, reason }curl -X POST https://lt.acme.dev/api/machines/lines/site-1/print-1/machines/p1s-01/commands \ -H "Authorization: Bearer $TOKEN" \ -H 'Content-Type: application/json' \ -d '{ "op": "pause", "args": {} }'// tool: send_machine_command{ "siteId": "site-1", "lineId": "print-1", "machineId": "p1s-01", "op": "pause" }# the gateway publishes on line/v1/site-1/print-1/01P00A000000001/request{ "print": { "sequence_id": "<command escalation id>:command", "command": "pause", "param": "" } }
# the printer answers on line/v1/site-1/print-1/01P00A000000001/report{ "print": { "sequence_id": "<command escalation id>:command", "command": "pause", "param": "", "reason": "success", "result": "success" } }A cancel names the job, and the codec sends stop only while that job is the one printing:
curl -X POST https://lt.acme.dev/api/machines/lines/site-1/print-1/machines/p1s-01/commands \ -H "Authorization: Bearer $TOKEN" \ -H 'Content-Type: application/json' \ -d '{ "op": "cancel", "args": { "jobId": "Pmw8druzy-p1s-01" } }'// tool: send_machine_command{ "siteId": "site-1", "lineId": "print-1", "machineId": "p1s-01", "op": "cancel", "args": { "jobId": "Pmw8druzy-p1s-01" } }# sent only while Pmw8druzy-p1s-01 is the print running on p1s-01{ "print": { "sequence_id": "<command escalation id>:command", "command": "stop", "param": "" } }The dashboard’s Lines view shows the printer, its presence and open escalations, and the wire: every request out and every report in. Selecting the printer opens its panel, with its last snapshot and tools to ask for a snapshot or send a command.
The boat
The sample part is #3DBenchy by Creative Tools, which is in the public domain under CC0 1.0. boat/p1s-0.8-tpu.gcode.3mf is a half-size boat sliced for a P1S with a 0.8 nozzle and TPU: 0.4 mm layers, 60 layers, 1.17 g, about 11 minutes. Clear the bed first. npm run bambu:print prints it when you name no file.
npm run bambu:boat slices a boat on your machine with OrcaSlicer. It downloads the model, scales it to half size (BAMBU_BOAT_SCALE), and slices it with the presets you name. Unset, it slices for a stock P1S with a 0.4 nozzle and PLA. The presets in boat/presets/ slice the example’s boat again:
BAMBU_BOAT_PRINTER="Bambu Lab P1S 0.8 nozzle" \BAMBU_BOAT_PROCESS=examples/workflows/bambu-printer/boat/presets/tpu-0.8-process.json \BAMBU_BOAT_FILAMENT=examples/workflows/bambu-printer/boat/presets/tpu-0.8-filament.json \ npm run bambu:boat# [13:53:12] .../examples/workflows/bambu-printer/files/boat.gcode.3mf (about 11m 2s)
docker compose exec long-tail npm run bambu:print -- examples/workflows/bambu-printer/files/boat.gcode.3mfAny other part is a file sliced for your printer, its nozzle and the filament loaded: npm run bambu:print -- path/to/part.gcode.3mf. Keep your parts in examples/workflows/bambu-printer/files/, which git ignores.
Run it without a printer
The bambu-sim profile runs a simulated P1S on the compose network. It has a Mosquitto broker on 8883 for bblp and the access code, with a certificate issued to its serial. Its storage is served over implicit FTPS on 990, and it sends the same reports and acknowledgements a printer does. A print takes a minute. Point .env at it:
BAMBU_SITE_ID=site-1BAMBU_LINE_ID=print-1BAMBU_PRINTERS=p1s-01BAMBU_P1S_01_SERIAL=01P00A000000001BAMBU_P1S_01_HOST=bambu-simBAMBU_P1S_01_CODE=12345678BAMBU_P1S_01_ENABLED=true# A print on the simulated printer takes this many secondsBAMBU_SIM_PRINT_SEC=60Then start the same stack with the profile:
docker compose -f docker-compose.yml -f docker-compose.nats.yml -f docker-compose.machines.yml \ -f docker-compose.bambu.yml --profile bambu-sim up -d --builddocker compose exec long-tail npm run bambu:printmachine-site reads the simulated printer’s CA and renders its bridge, exactly as for a printer. Drills on port 8092 play what someone at the printer would do:
curl localhost:8092/ # the printer's state and the files on its storagecurl -X POST localhost:8092/runout # the spool runs out mid-printcurl -X POST localhost:8092/resume # filament loaded, resumed at the printercurl -X POST localhost:8092/occupy # a print started elsewhere takes the printercurl -X POST localhost:8092/vacate # the printer is idle againcurl -X POST localhost:8092/hold -d '{"percent":50}' # progress stops at 50% until releasecurl -X POST localhost:8092/releaserunout raises the servicing alarm and pauses the print escalation, resume clears both, and occupy makes the next print a refused one. Every screenshot on this page comes from the simulated printer.
Developer Mode
In Developer Mode a printer may not pause on its own filament runout sensor, and it can print on with no filament. Watch prints in this mode, and turn Developer Mode and LAN Only off when you are done.
Go deeper in Machines, or read the example’s README in the long-tail repository at examples/workflows/bambu-printer.
