The file goes to the machine first
A 3D printer on the lineStep 2 of 7The file goes to the printer
The boat is a 2.6 MB file on a laptop. The printer will only print what is on its own card.
← A line from the environmentA machine that hosts its own broker →
A printer prints only files on its own storage: upload over implicit FTPS, hash it, and the URL and sha256 become the unit's artifact.
The situation
A machine that runs a program needs the program on the machine before the job starts, and the job should name exactly that file. Built by hand, that is a file share, a naming convention and hope. Here the driver uploads the file, hashes it, and the route carries the URL and the hash on every handoff into a machine.
The setup
A Bambu printer serves its storage over implicit FTPS on port 990, user bblp, the access code as the password. The data channel must reuse the control channel’s TLS 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 completeQUITfiles.ts speaks it on Node’s tls, then returns the URL a print request names:
/** 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 driver puts the URL and the file’s sha256 on the route as its 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 });On the floor
The handoff into the printer carries artifactUrl and artifactSha256 in its metadata. The codec sends the URL in project_file as file:///sdcard/cache/<name>. The printer writes to its card slowly, so the upload takes a minute or more before the printer confirms with 226.
The guarantee
With requireArtifact: true, the gateway refuses to dispatch a handoff into the machine that names no file. The artifact on the handoff is the file the job ran, by URL and by hash. See Machines.
Related patterns
The physical world
Handoff escalation
Step 2 of 12 · A factory line over MQTT
Each move between stations is one escalation naming the units that move; it resolves with a receipt or a typed error code.
The physical world
Idempotent commands
Step 6 of 12 · A factory line over MQTT
Every command carries an id and an expiry, so a retry is answered rather than run twice, and a late copy is refused.
The physical world
A machine is a resolver
Step 1 of 12 · A factory line over MQTT
A workflow parks an escalation and waits; a machine resolves it over MQTT exactly as a person would from the dashboard.
