Skip to content

A 3D printer on the line

MQTTDashboardHTTPMCP

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 printerA Bambu Lab P1S, or another Bambu printer that serves MQTT and FTPS on the LAN. Or the simulated printer, below
On the printerSettings → Network: turn on LAN Only, then Developer Mode. The same screen shows the IP address and the Access Code
On your machineDocker, and a clone of the long-tail repository
For your own partsOrcaSlicer, 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:

.env
# The site and line the printers form
BAMBU_SITE_ID=site-1
BAMBU_LINE_ID=print-1
# One block per station in BAMBU_PRINTERS: the station upper-cased, - as _
BAMBU_PRINTERS=p1s-01
BAMBU_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 with
LT_USERNAME=superadmin
LT_PASSWORD=<the password you set>
VariableWhat it holds
BAMBU_SITE_ID, BAMBU_LINE_IDThe site and line the printers form
BAMBU_PRINTERSThe stations, separated by commas. Each one names a block of BAMBU_<STATION>_* variables
BAMBU_<STATION>_SERIALThe printer’s serial. It is the printer’s address on the line and a level of its topics
BAMBU_<STATION>_HOSTThe printer’s LAN address
BAMBU_<STATION>_CODEThe printer’s access code: the password for its MQTT broker and its FTPS storage
BAMBU_<STATION>_ENABLEDtrue puts the printer on the line. Only enabled printers are bridged
LT_USERNAME, LT_PASSWORDA 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:

examples/workflows/bambu-printer/env.ts
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:

deploy/bambu-site/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
}
]
}
FieldMeaning
addressThe printer’s serial. The codec reads it from every topic, so each message names its station
roles.exitprint-unload: the person who takes the part off the plate
roles.operatorprint-servicing: the person who gets alarms, a printer that goes offline, and a print that failed
queueDepth: 1The printer holds one print at a time. A second print waits in the role queue until the first part is unloaded
requireArtifact: trueA print escalation must name a file. The gateway refuses one without it
cycleSec: 3600How 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():

start.ts
import { start, Machines } from '@hotmeshio/long-tail';
// Copied from the long-tail repository's examples/workflows/bambu-printer
import { 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:

docker-compose.bambu.yml
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:

one printer, one site
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 bot

machine-site renders one Mosquitto bridge per printer and appends it to the site broker’s configuration:

deploy/bambu-site/mosquitto/mosquitto.conf
connection site-1-print-1-p1s-01
address <printer LAN address>:8883
bridge_protocol_version mqttv311
bridge_cafile /certs/bambu-ca-01P00A000000001.pem
bridge_insecure true
remote_username bblp
remote_password <printer access code>
remote_clientid line-bridge-site-1-print-1-p1s-01
cleansession true
try_private false
notifications true
notifications_local_only true
notification_topic line/v1/site-1/print-1/01P00A000000001/bridge
topic 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/
LineWhat it does
topic report inThe printer’s reports arrive at line/v1/site-1/print-1/<serial>/report
topic request outRequests published at .../<serial>/request go to the printer’s device/<serial>/request
notification_topicThe bridge writes 1 or 0 there, retained, as it connects and drops. The codec reads it as the printer’s presence
bridge_cafileThe printer’s CA, read from the printer once
bridge_insecure trueThe 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:

examples/workflows/bambu-printer/site/setup.ts
/** 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:

shell
docker compose -f docker-compose.yml -f docker-compose.nats.yml -f docker-compose.machines.yml \
-f docker-compose.bambu.yml up -d --build
docker compose exec long-tail npm run bambu:print

1. 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:

scripts/bambu-print.ts
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 printer
const 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 unloads
const 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:

the FTPS session, port 990
(TLS from the first byte) ← 220 ready
USER bblp ← 331 Password required
PASS <printer access code> ← 230 Logged in
TYPE I ← 200
PBSZ 0 ← 200
PROT P ← 200
PASV ← 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 complete
QUIT

The 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:

examples/workflows/bambu-printer/files.ts
/** 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:

line/v1/site-1/print-1/01P00A000000001/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:

examples/workflows/bambu-printer/codec.ts
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:

line/v1/site-1/print-1/01P00A000000001/report
{ "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:

examples/workflows/bambu-printer/report.ts
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 fieldWhat the codec reads from it
gcode_stateThe phase: PREPARE, RUNNING, PAUSE, FINISH, FAILED, or idle
stg_cur0 while the printer lays down material. Other values are heating, leveling and the like
mc_percent, mc_remaining_timeProgress, and minutes left
layer_num, total_layer_numThe layer, for the progress facet
subtask_nameWhose print this is: <jobId>~<unitId>. A print started elsewhere names no job of ours
print_errorAn error as a number. 134184977 is 07FF-8011, a filament runout

A partial report at 75%:

line/v1/site-1/print-1/01P00A000000001/report
{ "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:

what the codec reads: a progress event
{
"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 escalation

With MACHINE_LIFECYCLE=on, the gateway notes only the milestones as items on the print escalation. JetStream keeps everything else:

ItemWhen
started:<unit>The printer starts laying down material: gcode_state is RUNNING and stg_cur is 0
progress:<unit>:25, :50, :75mc_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
A print with a runout, as its escalation holds it. Each milestone is an item noted by the printer's bot account.

The whole run, as the driver prints it:

npm run bambu:print (a runout at 27%, resumed)
[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$triggerStatusThe workflow receives
The print finishesresolveresolved{ received: true, receiver: 'p1s-01', units: ['U-…'] }
The printer refuses it, or the print stops unfinishedresolveresolved{ received: false, errorCode: 'dispatch-refused', detail }
Six hours pass (handoffTimeout)timeoutexpiredThe items so far, in $accumulated
Cancelnonecancellednull

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:

Real dashboard formSimulated backend

Claim the unload and confirm the part is in hand.

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:

what the codec reads: print_error 134184977
{
"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 escalation
The runout as an escalation for the person who services the printer, with the code and the job in its metadata.

Someone 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:

Real dashboard formSimulated backend

Choose retry, manual or scrap for the refused print.

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 }

A cancel names the job, and the codec sends stop only while that job is the one printing:

Terminal window
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" } }'

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 line, the printer at its serial, and the wire: each report as the codec read it and what the gateway did with it.

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:

shell
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.3mf

Any 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:

.env
BAMBU_SITE_ID=site-1
BAMBU_LINE_ID=print-1
BAMBU_PRINTERS=p1s-01
BAMBU_P1S_01_SERIAL=01P00A000000001
BAMBU_P1S_01_HOST=bambu-sim
BAMBU_P1S_01_CODE=12345678
BAMBU_P1S_01_ENABLED=true
# A print on the simulated printer takes this many seconds
BAMBU_SIM_PRINT_SEC=60

Then start the same stack with the profile:

shell
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 --build
docker compose exec long-tail npm run bambu:print

machine-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:

Terminal window
curl localhost:8092/ # the printer's state and the files on its storage
curl -X POST localhost:8092/runout # the spool runs out mid-print
curl -X POST localhost:8092/resume # filament loaded, resumed at the printer
curl -X POST localhost:8092/occupy # a print started elsewhere takes the printer
curl -X POST localhost:8092/vacate # the printer is idle again
curl -X POST localhost:8092/hold -d '{"percent":50}' # progress stops at 50% until release
curl -X POST localhost:8092/release

runout 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.