@vextrix3d/machine-agent 0.0.0-stage → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,74 @@
1
+ Vextrix3D Printer Agent Licence
2
+
3
+ Copyright (c) 2026 VEXTRIX3D LTD. All rights reserved.
4
+
5
+ VEXTRIX3D LTD is a company registered in England and Wales under company number 17230746
6
+ ("Vextrix3D", "we", "us").
7
+
8
+ 1. The Software
9
+
10
+ This licence covers the Vextrix3D Printer Agent ("the Software"): the code, files and
11
+ documentation in this package or repository, apart from the third-party components in
12
+ section 6. The Software is licensed, not sold.
13
+
14
+ 2. What you may do
15
+
16
+ Vextrix3D grants you a non-exclusive, non-transferable and revocable licence to install and
17
+ run the Software on machines you control, for the sole purpose of connecting printers and
18
+ other machines to the Vextrix3D service at vextrix3d.co.uk with a printer agent API key that
19
+ Vextrix3D has issued for them, for as long as that key and your Vextrix3D account remain
20
+ active. You may make the copies that installing and running it needs.
21
+
22
+ 3. What you may not do
23
+
24
+ Except as this licence allows, or as the law allows you despite it, you may not:
25
+
26
+ a. copy, modify, adapt or translate the Software, or create works derived from it;
27
+ b. publish, distribute, sell, rent, lend or sublicense the Software, or otherwise make it
28
+ available to anyone else;
29
+ c. decompile, disassemble or reverse engineer the Software;
30
+ d. use the Software with any service other than Vextrix3D's, or to build or support a
31
+ service that competes with it;
32
+ e. remove or alter any copyright notice in the Software, or this licence.
33
+
34
+ 4. Ownership
35
+
36
+ Vextrix3D owns the Software and every intellectual property right in it. Nothing in this
37
+ licence transfers any of them to you, and every right it does not expressly grant is
38
+ reserved.
39
+
40
+ 5. Ending
41
+
42
+ This licence ends automatically if you break any of its terms, or when the API key or your
43
+ Vextrix3D account ends, and Vextrix3D may end it at any time by telling you. When it ends,
44
+ you must stop running the Software and delete every copy of it you hold.
45
+
46
+ 6. Third-party components
47
+
48
+ The Software is distributed with components that belong to others, each under its own terms,
49
+ which this licence does not change: the packages it depends on, as listed in package.json,
50
+ and Bambu Lab's certificate authorities in certs/bambu-ca.pem.
51
+
52
+ 7. No warranty
53
+
54
+ To the fullest extent the law permits, the Software is provided "as is", without warranty of
55
+ any kind, express or implied, including any warranty of satisfactory quality or fitness for
56
+ a particular purpose. Vextrix3D does not warrant that it is free of errors or that it will
57
+ run without interruption.
58
+
59
+ 8. Liability
60
+
61
+ To the fullest extent the law permits, Vextrix3D is not liable for any loss or damage arising
62
+ from the Software or its use, including loss of profit, of data or of production, failed or
63
+ spoiled prints, or damage to a printer or other machine. Nothing in this licence limits or
64
+ excludes any liability that cannot be limited or excluded by law, including liability for
65
+ death or personal injury caused by negligence, or for fraud.
66
+
67
+ Your use of the Vextrix3D service itself is governed by its Terms of Service.
68
+
69
+ 9. Law
70
+
71
+ This licence is governed by the law of England and Wales, and the courts of England and Wales
72
+ have exclusive jurisdiction over any dispute about it.
73
+
74
+ Questions about this licence go to Vextrix3D through vextrix3d.co.uk.
package/README.md CHANGED
@@ -1,3 +1,541 @@
1
- # Temporary Holding Version
1
+ # Machine Agent
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A lightweight Node.js service that connects a single printer to the Vextrix3D platform: a Bambu Lab printer over its LAN MQTT, or a Klipper printer through Moonraker.
4
+
5
+ The agent connects to the printer, reports printer status to the backend, and synchronizes the currently assigned print attempt. Which client it uses comes from the printer's connection in vextrix3d - Bambu MQTT or Klipper Moonraker.
6
+
7
+ The backend remains the source of truth for printer configuration, production state, and print attempt management.
8
+
9
+ ---
10
+
11
+ ## Features
12
+
13
+ - Secure authentication using Machine Agent API keys
14
+ - Real-time printer monitoring via the Bambu MQTT protocol, or Moonraker's WebSocket API on a Klipper printer
15
+ - Automatic printer state synchronization
16
+ - Current print attempt retrieval
17
+ - Automatic cancellation and failure reporting
18
+ - Batched state reporting to minimize API traffic
19
+ - Automatic reconnection
20
+ - A Bambu printer's camera relayed to its page, where ffmpeg is installed
21
+ - Low bandwidth and minimal backend writes
22
+
23
+ ---
24
+
25
+ ## Architecture
26
+
27
+ ```
28
+ Vextrix3D Backend
29
+ │
30
+ │ HTTPS
31
+ ▼
32
+ Vextrix3dApiClient
33
+ │
34
+ ┌───────────────┴───────────────┐
35
+ │ │
36
+ ▼ ▼
37
+ Printer state updates Print attempt APIs
38
+ ▲ ▲
39
+ │ │
40
+ └───────────────┬───────────────┘
41
+ │
42
+ ┌───────────────┴───────────────┐
43
+ │ │
44
+ BambuMqttClient MoonrakerClient
45
+ │ │
46
+ MQTT (TLS) JSON-RPC (WebSocket)
47
+ │ │
48
+ ▼ ▼
49
+ Bambu Lab Printer Klipper (Moonraker)
50
+ ```
51
+
52
+ ---
53
+
54
+ ## Components
55
+
56
+ ### BambuMqttClient
57
+
58
+ Responsible for communication with the printer.
59
+
60
+ Responsibilities:
61
+
62
+ - establish MQTT connection
63
+ - automatically reconnect
64
+ - subscribe to printer reports
65
+ - ask for the printer's whole status on each connection (`pushall`)
66
+ - merge reports of what changed into the status built up before them
67
+ - pass on status reports only, not the printer's answers to commands
68
+ - parse MQTT messages
69
+ - expose printer events
70
+
71
+ ---
72
+
73
+ ### MoonrakerClient
74
+
75
+ Responsible for communication with a Klipper printer, through Moonraker - see [Klipper Moonraker](#klipper-moonraker).
76
+
77
+ Responsibilities:
78
+
79
+ - open Moonraker's WebSocket, with a oneshot token where it asks for an API key
80
+ - identify the agent, and subscribe to the printer objects it reads
81
+ - merge each status update into the status built up before it
82
+ - subscribe again as Klipper restarts and turns ready
83
+ - pass the status on again every 30 seconds while nothing changes, and ask again whether Klipper can give one while it cannot
84
+ - reconnect after 2, 5, 15 and 30 seconds, then once a minute
85
+ - read the slicer's estimate from a file's metadata
86
+
87
+ ---
88
+
89
+ ### Printer State Reporter
90
+
91
+ Collects printer status updates and sends them to the backend.
92
+
93
+ Features:
94
+
95
+ - reports immediately when the printer state changes
96
+ - reports progress every 5%
97
+ - periodically refreshes the backend (every 2 minutes by default)
98
+ - merges several status messages into a single API request
99
+ - prevents concurrent requests
100
+ - retries a failed report 30 seconds later, then twice as long each time up to 10 minutes, sending the newest state then, and starts at 30 seconds again once one goes through
101
+
102
+ This significantly reduces unnecessary backend traffic.
103
+
104
+ ---
105
+
106
+ ### Print Attempt Synchronizer
107
+
108
+ Keeps the backend production state synchronized with printer events.
109
+
110
+ When connected:
111
+
112
+ - loads the currently assigned print attempt
113
+
114
+ When printer state changes:
115
+
116
+ - `FAILED` → fail current print attempt
117
+ - `CANCELLED` → cancel current print attempt
118
+
119
+ A Bambu printer reports a print stopped on purpose as `FAILED` too, so the agent reads it as cancelled when the cancel code comes with it (see [Bambu MQTT](#bambu-mqtt)). Klipper reports it `cancelled` itself.
120
+
121
+ The synchronizer only reacts to state transitions, preventing duplicate API calls from repeated MQTT messages.
122
+
123
+ Every call to the backend - these, the state reports, the camera, and the printer's info as the agent starts - shares one answer to a printer that is gone: see [Fault Tolerance](#fault-tolerance).
124
+
125
+ ---
126
+
127
+ ### Vextrix3dApiClient
128
+
129
+ Provides authenticated communication with the backend.
130
+
131
+ The endpoints are Firebase functions, named after their exports and reached under `VEXTRIX3D_FUNCTIONS_URL`:
132
+
133
+ - `GET /getMachineAgentInfo`
134
+ - `POST /getMachineAgentCamera` - the printer's camera tunnel token and relay key; the first call makes the tunnel
135
+ - `POST /reportMachineAgentState`
136
+ - `GET /getMachineAgentPrintAttempt`
137
+ - `POST /cancelMachineAgentPrintAttempt`
138
+ - `POST /failMachineAgentPrintAttempt`
139
+
140
+ Authentication is performed using Machine Agent API keys.
141
+
142
+ ---
143
+
144
+ ### Camera Relay
145
+
146
+ Runs go2rtc beside the printer, and the printer's Cloudflare Tunnel, so that its page in vextrix3d can show the camera live - see [Camera relay](#camera-relay). Where ffmpeg is installed, for a Bambu printer alone: the relay reads the RTSP stream a Bambu printer serves, or the JPEG frames an A1's or P1's camera sends - see [An A1 or a P1](#an-a1-or-a-p1) - and vextrix3d relays no other printer's camera.
147
+
148
+ - fetches the builds of go2rtc and cloudflared it pins into `camera/`, from their GitHub releases, each checked against its SHA-256 before it is made executable, and keeps them there from one start to the next
149
+ - writes go2rtc's config itself, into `camera/runtime/`, readable by the agent's user alone, from what the agent already has: the printer's id, access code and serial number from vextrix3d, and the camera's address from the printer's own status (`ipcam.rtsp_url`) - or, for an A1 or a P1, the printer's own address, its camera's frames read on port 6000 and encoded to H.264
150
+ - starts go2rtc with the first status that names a camera stream, starts it again if it stops, and stops it with the agent
151
+ - follows a new camera address in the status without restarting go2rtc
152
+ - runs go2rtc on a pair of ports it finds free, and notes them where the machine's other agents find them
153
+ - takes the machine's camera requests on port 1984, together with every other agent there, and hands each to the go2rtc of the printer it names, once its relay token checks out against that printer's key
154
+ - asks vextrix3d for the printer's camera (`getMachineAgentCamera`) once the printer names a camera stream in its status and go2rtc and cloudflared are there - the relay key, and the token of the printer's own tunnel, which vextrix3d makes the first time - and runs `camera/cloudflared` with it, starting it again if it stops
155
+ - asks again until vextrix3d answers, beside the printer's connection and never holding it up, and then once a day, following a new relay key or tunnel token without a restart
156
+ - stops the camera and the tunnel for good where vextrix3d no longer knows the printer's API key (401) or the printer (410) - as the whole agent stops then
157
+
158
+ ---
159
+
160
+ ## Printer State
161
+
162
+ The backend receives the following information:
163
+
164
+ - printer state
165
+ - current subtask name, a model on the plate several times named once with how many (`Bracket.stl ×3`)
166
+ - print progress
167
+ - minutes left of the print, as the printer reckons them - or, on a Klipper printer, from the slicer's estimate
168
+ - nozzle temperature
169
+ - bed temperature
170
+ - chamber temperature
171
+ - Wi-Fi signal strength, of a Bambu printer
172
+ - whether a Bambu printer names a camera stream - a change in it sent at once, as a new state is
173
+ - the agent's own version, which the machine's page shows, from 1.1.0
174
+ - last seen timestamp
175
+
176
+ Supported printer states:
177
+
178
+ - preparing
179
+ - running
180
+ - paused
181
+ - finished
182
+ - cancelled
183
+ - failed
184
+ - idle
185
+
186
+ ---
187
+
188
+ ## Bambu MQTT
189
+
190
+ Bambu publishes nothing on the protocol; the agent follows the community's account of it, [OpenBambuAPI](https://github.com/Doridian/OpenBambuAPI/blob/main/mqtt.md), and the [Home Assistant integration](https://github.com/greghesp/ha-bambulab) where that says nothing.
191
+
192
+ - **Connection** — the printer's own broker at `mqtts://<ip>:8883`, user `bblp`, the LAN access code as the password. Reports arrive on `device/<serial>/report`, commands go to `device/<serial>/request`.
193
+ - **Whole and partial reports** — the printer pushes its status by itself, about once a second, and the agent reads every report. An X1 or a P2S sends its whole status every time, a P1 or an A1 only what changed since its last report, so the agent merges each report into the status built up before it. `pushall` is not how status arrives: the agent asks for the whole status with it once on each connection, to catch up on what was missed while it was down. OpenBambuAPI advises asking for it no oftener than every five minutes on a P1P.
194
+ - **Answers to commands** — the printer answers on the report topic, under the same key as its status (`print`). Only `print.command === "push_status"` is read as status.
195
+ - **Cancelled prints** — a print stopped on purpose ends as `gcode_state: "FAILED"`, the same as a fault, with `print_error` 0300-400C (50348044) for a couple of seconds. The agent reads that failure as cancelled for as long as the printer stays failed. This comes from the Home Assistant integration; OpenBambuAPI does not cover it.
196
+ - **Temperatures under `device`** — the newer printers pack two numbers into one: the target in the high 16 bits, the reading in the low. A bed held at 105° comes as 6881385. The chamber's (`device.ctc.info.temp`) is read the same way; the nozzle's and the bed's are read from `nozzle_temper` and `bed_temper`.
197
+ - **TLS** — capped at 1.2: P2S firmware 01.02.00.00 never answers a TLS 1.3 handshake.
198
+
199
+ The agent checks the printer's certificate, and does not connect to a printer whose certificate fails the check — it logs `MQTT error` with the reason and tries again a minute later:
200
+
201
+ - **Issued by Bambu** — against the CAs in `certs/bambu-ca.pem`. Bambu's own, `BBL CA` and `BBL CA2`, come from OpenBambuAPI's [TLS page](https://github.com/Doridian/OpenBambuAPI/blob/main/tls.md); P2S-01's certificate is issued by `BBL CA`. Some models' certificates are issued through a device CA of the model's, which the printer does not send. The file holds the three the Home Assistant integration ships — `BBL Device CA N7-V2` (some P2S units), `O1C2-V2` (H2C) and `N6-V2` (X2C) — each issued by `BBL CA2`. A model whose device CA is not there yet needs it added.
202
+ - **Issued to the printer** — with the serial number as the TLS server name, since the certificate is issued to the serial and not the IP. A printer saved with a wrong serial number is refused for it.
203
+
204
+ The status also carries what the agent does not report yet: the layer against the total (`layer_num`, `total_layer_num`), and health errors (`hms`). It carries no time a print began (`gcode_start_time` is in OpenBambuAPI's X1 report, not in the P2S's), so the backend keeps when a job began from the reports.
205
+
206
+ ---
207
+
208
+ ## Klipper Moonraker
209
+
210
+ The agent follows Moonraker's own [API documentation](https://moonraker.readthedocs.io/en/latest/external_api/introduction/). The printer is saved in vextrix3d with the Klipper Moonraker connection: its IP, its port and, where Moonraker asks for one, an API key.
211
+
212
+ - **Connection** — `ws://<ip>:<port>/websocket`, the port 7125 unless the install moved it. Moonraker most often trusts the machines of the workshop's own network (`trusted_clients` in `moonraker.conf`), and the printer is then saved with no API key. Where it asks for one, the key is shown once over HTTP, as `X-Api-Key` on `GET /access/oneshot_token`, for a token the WebSocket is opened with - a WebSocket opened from Node sends no headers of its own. Moonraker keeps the key in its own database: it is read at `http://<ip>:7125/access/api_key` from a trusted machine, or with `~/moonraker/scripts/fetch-apikey.sh` on the printer's own computer, and `POST /access/api_key` makes a new one, which the old one stops working for.
213
+ - **Status** — the agent subscribes to `webhooks`, `print_stats`, `display_status`, `extruder` and `heater_bed`, and to a chamber where the printer's config has a sensor, heater or fan named after one (`temperature_sensor chamber`). Moonraker pushes what changed, as `notify_status_update`, and nothing while nothing does.
214
+ - **States** — from `print_stats.state`: `standby` idle, `printing` running, `paused`, `complete` finished, `cancelled`, and `error` failed. Klipper itself shut down - an emergency stop, a heater fault, an MCU lost - or in error is reported failed, whatever the print last said. Klipper has no state before a print starts, so a Klipper printer is never reported preparing.
215
+ - **Klipper restarting** — a `FIRMWARE_RESTART` or a saved config drops the subscription while Moonraker stays up. The agent subscribes again on `notify_klippy_ready`, and asks with each heartbeat while Klipper is starting up.
216
+ - **Time left** — Klipper reckons none. It is the slicer's estimate, `estimated_time` in the file's metadata, less `print_stats.print_duration`, as Fluidd and Mainsail give it; from a file with no estimate, the time printed drawn out over the progress. `print_duration` counts from the first extrusion, so a print still heating up has no time left yet.
217
+ - **Job name** — `print_stats.filename`, without its folder or `.gcode`.
218
+
219
+ Tried against Mainsail's [virtual Klipper printer](https://github.com/mainsail-crew/virtual-klipper-printer) in Docker: the API key, idle, running, cancelled, finished, Klipper shut down, and subscribing again after a host restart.
220
+
221
+ ---
222
+
223
+ ## Request Optimization
224
+
225
+ The agent intentionally minimizes backend traffic.
226
+
227
+ State updates are transmitted only when:
228
+
229
+ - printer state changes
230
+ - progress changes by at least 5%
231
+ - two minutes have elapsed since the previous update
232
+
233
+ If several status messages arrive while a request is in flight, only the newest state is retained and sent afterwards. A report that fails is not sent again with the next status but after a wait - 30 seconds, then twice as long each time, up to 10 minutes - and the newest state is sent then.
234
+
235
+ ---
236
+
237
+ ## Requirements
238
+
239
+ - Node.js 22.12+
240
+ - pm2, on the printer's machine
241
+ - Network access to the printer
242
+ - Machine Agent API key
243
+ - Vextrix3D backend
244
+ - Bambu printer running LAN Mode, or a Klipper printer with Moonraker
245
+
246
+ ---
247
+
248
+ ## Environment Variables
249
+
250
+ ```text
251
+ VEXTRIX3D_FUNCTIONS_URL=https://api.vextrix3d.co.uk
252
+ VEXTRIX3D_MACHINE_AGENT_API_KEY=vx3d-ma.xxxxx.xxxxx
253
+ ```
254
+
255
+ In production `VEXTRIX3D_FUNCTIONS_URL` is `https://api.vextrix3d.co.uk`. The API key is the printer's own, made in Vextrix3D under its machine's **Agent API keys**, and shown once.
256
+
257
+ Two files hold them, in the printer's folder the agent runs in, picked by `NODE_ENV` and not by which one happens to exist:
258
+
259
+ | File | Used when |
260
+ | ------------ | --------------------- |
261
+ | `.env.prod` | `NODE_ENV=production` |
262
+ | `.env.local` | anything else |
263
+
264
+ The agent logs which of the two it read at startup, and stops at once in a folder without it.
265
+
266
+ One more is optional: `VEXTRIX3D_CAMERA_BINARIES=manual` leaves the printer's `camera/` alone, for a machine that places its own go2rtc and cloudflared - see [Setting it up](#setting-it-up).
267
+
268
+ ---
269
+
270
+ ## Deployment
271
+
272
+ It needs Node.js 22.12 or later, which `node --version` prints. Where there is none, or an older one, nvm installs it for the agent's user alone:
273
+
274
+ ```bash
275
+ curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash
276
+ ```
277
+
278
+ Then, in a new terminal:
279
+
280
+ ```bash
281
+ nvm install 24
282
+ ```
283
+
284
+ The agent is installed once on the printer's machine, beside pm2, for every printer the machine runs - with `sudo` in front where npm's global folder is root's, as with a Node from the system's packages rather than nvm:
285
+
286
+ ```bash
287
+ npm install -g @vextrix3d/machine-agent pm2
288
+ ```
289
+
290
+ ```bash
291
+ machine-agent --version
292
+ ```
293
+
294
+ One agent runs one printer, in a folder of its own named for it - `/opt/machine-agents/p2s-01` for a printer named P2S-01 - which holds the printer's `.env.prod`, with its API key, and its `camera/`. The agent reads both from the folder it is started in, and runs under pm2 named for its printer, `machine-agent-p2s-01`, so that the printers of one machine never mix - in `pm2 list`, in the logs, or in a restart. Each printer on the machine is a folder like it.
295
+
296
+ 1. **The printer's folder:**
297
+
298
+ ```bash
299
+ sudo mkdir -p /opt/machine-agents/p2s-01 && sudo chown "$USER": /opt/machine-agents /opt/machine-agents/p2s-01
300
+ ```
301
+
302
+ ```bash
303
+ cd /opt/machine-agents/p2s-01
304
+ ```
305
+
306
+ 2. **Its `.env.prod`**, with the key pasted at the prompt and Enter pressed - it stays off the screen and out of the shell's history - then readable by the agent's user alone:
307
+
308
+ ```bash
309
+ read -rs KEY && printf 'VEXTRIX3D_FUNCTIONS_URL=https://api.vextrix3d.co.uk\nVEXTRIX3D_MACHINE_AGENT_API_KEY=%s\n' "$KEY" > .env.prod; unset KEY
310
+ ```
311
+
312
+ ```bash
313
+ chmod 600 .env.prod
314
+ ```
315
+
316
+ 3. **Start it** there, `NODE_ENV=production` being what selects `.env.prod`, and save pm2's list, which brings it back after a reboot once `pm2 startup` has set pm2 up to start with the machine:
317
+
318
+ ```bash
319
+ NODE_ENV=production pm2 start machine-agent --name machine-agent-p2s-01
320
+ ```
321
+
322
+ ```bash
323
+ pm2 save
324
+ ```
325
+
326
+ 4. **Check it.** The log has `Loaded printer "P2S-01"` and `Connected to "P2S-01"`:
327
+
328
+ ```bash
329
+ pm2 logs machine-agent-p2s-01 --lines 40 --nostream
330
+ ```
331
+
332
+ pm2 keeps the folder and `NODE_ENV` with the process, so a restart needs neither:
333
+
334
+ ```bash
335
+ pm2 restart machine-agent-p2s-01
336
+ ```
337
+
338
+ The agent reads the printer's access code from Vextrix3D as it starts, and follows the camera's address as the printer reports it. A new IP address or a regenerated access code goes into the printer's Edit machine dialog in Vextrix3D, and a restart takes it up - for the camera as for the printer's connection. A DHCP reservation for the printer saves the IP address changing.
339
+
340
+ **An update** is the install's, every printer's at once, then a restart of each agent - `/machine-agent-/` names them all:
341
+
342
+ ```bash
343
+ npm install -g @vextrix3d/machine-agent@latest
344
+ ```
345
+
346
+ ```bash
347
+ pm2 restart /machine-agent-/
348
+ ```
349
+
350
+ **One agent per printer, ever.** Never run two agents for one printer, on one computer or two: both would connect the printer's tunnel, Cloudflare would share the camera's requests out between the two computers, and the camera would play only some of the time. When a printer moves to another computer, stop its agent on the old one first, and save pm2's list there so it does not come back after a reboot - then set it up on the new one as above:
351
+
352
+ ```bash
353
+ pm2 delete machine-agent-p2s-01 && pm2 save
354
+ ```
355
+
356
+ ### Moving from the printer agent
357
+
358
+ Until 2.0.0 the agent was the printer agent, `@vextrix3d/printer-agent`, run as `printer-agent-<name>` from `/opt/printer-agents/<name>`. Vextrix3D answers none of its endpoints, nor its keys (`vx3d-pa.…`), so an agent before 2.0.0 stops reporting until it is moved - its machine's page in Vextrix3D says Move to the machine agent. Moving takes a new API key, made under the machine's **Agent API keys**, and on its computer:
359
+
360
+ 1. **The machine agent**, beside the printer agent for now:
361
+
362
+ ```bash
363
+ npm install -g @vextrix3d/machine-agent
364
+ ```
365
+
366
+ 2. **For each printer**, the printer agent stopped, its folder moved beside the machine agent's, its `.env.prod` written again with the new key, and the machine agent started there:
367
+
368
+ ```bash
369
+ pm2 delete printer-agent-p2s-01
370
+ ```
371
+
372
+ ```bash
373
+ sudo mkdir -p /opt/machine-agents && sudo chown "$USER": /opt/machine-agents && mv /opt/printer-agents/p2s-01 /opt/machine-agents/
374
+ ```
375
+
376
+ ```bash
377
+ cd /opt/machine-agents/p2s-01
378
+ ```
379
+
380
+ ```bash
381
+ read -rs KEY && printf 'VEXTRIX3D_FUNCTIONS_URL=https://api.vextrix3d.co.uk\nVEXTRIX3D_MACHINE_AGENT_API_KEY=%s\n' "$KEY" > .env.prod; unset KEY
382
+ ```
383
+
384
+ ```bash
385
+ NODE_ENV=production pm2 start machine-agent --name machine-agent-p2s-01 && pm2 save
386
+ ```
387
+
388
+ 3. **The printer agent itself**, once no `printer-agent-` is left in `pm2 list`:
389
+
390
+ ```bash
391
+ npm uninstall -g @vextrix3d/printer-agent
392
+ ```
393
+
394
+ The printer's `camera/` moves with its folder, and its go2rtc and cloudflared are kept; its tunnel is the one it had.
395
+
396
+ ---
397
+
398
+ ## Camera relay
399
+
400
+ A Bambu printer's page in Vextrix3D can show its camera live. The agent runs [go2rtc](https://github.com/AlexxIT/go2rtc) beside the printer, which reads its RTSPS stream through ffmpeg, and takes the machine's camera requests on `127.0.0.1:1984`. The agent opens the printer's RTSPS itself and checks its certificate as it checks its MQTT's - issued by Bambu, to the printer's serial number - and hands ffmpeg the stream as plain RTSP on a port of this machine alone, so that the check is the same whatever ffmpeg, and whatever OpenSSL, the machine has: OpenSSL 4 matches no name against the common name a Bambu printer's certificate names its serial in, and ffmpeg before 8 checks nothing on RTSPS. A printer whose certificate fails the check is not relayed, and the log says why once, `[camera] The camera of "P2S-01" at 192.168.0.119:322 was not opened: …`. The way from there to the printer's page is the printer's own [Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/), which vextrix3d makes for it the first time the agent asks, and which the agent runs with `cloudflared` - there is nothing to set up in Cloudflare.
401
+
402
+ The machine's camera port, 1984, is every agent's on the machine at once - Linux lets the processes of one user hold a port together, and deals the connections out among them. A page's request names its printer, `/api/ws?src=<printer id>`, and whichever agent takes it hands it to that printer's go2rtc, whose port the printer's own agent notes in `~/.local/state/machine-agent/cameras/`. Every printer's tunnel points at that port, so the printers on a machine each run their own tunnel into it.
403
+
404
+ The port lets a request through only with a token vextrix3d's Worker signs for the printer, with the printer's relay key - which vextrix3d gives the printer's agent, and which the agent notes beside its go2rtc's port, readable by the agents' user alone. go2rtc asks no password of a request from this machine, so that check is what keeps the camera to the printer's page: a computer holds the keys of its own printers and nobody else's.
405
+
406
+ Nothing is filled in by hand on the machine. The agent writes go2rtc's config from the printer's id, access code and serial number, which it has from vextrix3d, and the camera's address, which the printer reports in its status - into `camera/runtime/`, readable by the agent's user alone, the access code being in it. It starts go2rtc once the printer names a camera stream, on the first pair of ports it finds free, starts it again if it stops, and stops it with the agent. It does so where ffmpeg is installed, fetching go2rtc and cloudflared into `camera/` itself, and says in its log when it does not and why.
407
+
408
+ ffmpeg is there because the printer stamps every frame of its stream with the same time. go2rtc on its own passes that on, a browser has nothing to pace the frames by, and the picture crawls - about a second of video for every thirty. ffmpeg stamps each frame with the time it arrives and copies the video as it is, which costs next to nothing. The stream has no B-frames, so arrival order is display order.
409
+
410
+ ### An A1 or a P1
411
+
412
+ An A1, A1 mini, P1P or P1S has no RTSP, and its status names no `ipcam.rtsp_url` at all. Its camera sends 1280×720 JPEG frames over TLS on port 6000 instead, one every second or two, as [OpenBambuAPI](https://github.com/Doridian/OpenBambuAPI/blob/main/video.md) documents and [Home Assistant's integration](https://github.com/greghesp/ha-bambulab) reads them: the agent sends an 80-byte login first - the size of the rest (64), its type (`0x3000`) and two zeros, each four bytes little-endian, then `bblp` and the access code in 32 bytes each, padded with zeros - and each frame follows as a 16-byte header, whose first four bytes are the JPEG's size, then the JPEG. A wrong access code has the printer close the connection without a frame.
413
+
414
+ The agent knows these models by the first three characters of their serial number - `01S` P1P, `01P` P1S, `030` A1 mini, `039` A1, and `26A` A2L, which Home Assistant's integration reads the same way - and treats one as naming a camera stream from its first status on: each state report says it has one, and it asks vextrix3d for its camera then. It reads the printer at the address its MQTT is reached on.
415
+
416
+ While a page watches, go2rtc's ffmpeg reads the frames from a bridge on this machine alone - an HTTP port the system picks, with a random path written to the camera file alone - as an MJPEG stream (`multipart/x-mixed-replace`, each frame with its length). The bridge opens the printer for each of ffmpeg's requests and closes it with it, so the printer is read only while somebody watches. It checks the printer's certificate as the RTSPS bridge does - issued by Bambu, to the printer's serial number - with TLS held to 1.2, as Home Assistant's integration holds every connection to a printer. A printer that does not prove itself, or refuses the access code, fails ffmpeg's request at once, and the log says why once, `[camera] The camera of "A1-01" at 192.168.0.120:6000 was not opened: …`. The first time frames come, the log says how often and how large, `[camera] "A1-01" sends its camera as a JPEG frame every 1.0s, 51 KB each`.
417
+
418
+ ffmpeg encodes the frames to H.264 - a browser plays no JPEG through Media Source Extensions - with libx264, which Debian's, Ubuntu's, Raspberry Pi OS's and Homebrew's ffmpeg all have; where `ffmpeg -version` shows no `--enable-libx264`, the relay is off and the log says so. It encodes two frames a second, each of the printer's repeated until the next comes: go2rtc gives a frame more than a second after the one before it a millisecond's duration (`pkg/mp4/muxer.go`), and a camera sending one every two seconds then plays as a single still picture. The repeats cost little, being the same picture. `-preset ultrafast -tune zerolatency`, 4:2:0 colour, and a keyframe every two seconds, so that a second page watching starts within two seconds.
419
+
420
+ What it costs, measured against a stand-in printer sending a 1280×720 frame a second: on an Apple M1 Max, ffmpeg takes 1.1% of one core at a frame every two seconds, 1.5% at one a second and 2% at two a second, and the stream about 0.9 Mbit/s at most, every frame of the test picture differing - a printer's picture, mostly still, takes less. On a Raspberry Pi it has not been measured: by the usual gap between the two, expect around 10 to 20% of one of a Pi 4's four cores, and perhaps twice that on a Pi 3 or a Zero 2 W. Nothing runs while nobody watches.
421
+
422
+ None of this has been tried on one of these printers yet - the models, the login and the frames are as the community documents them, and the agent is tested against a stand-in printer that checks the login and sends frames.
423
+
424
+ ### Setting it up
425
+
426
+ In the printer's folder, the agent started there as under [Deployment](#deployment). ffmpeg is all there is to install: the agent fetches go2rtc and cloudflared itself.
427
+
428
+ 1. **ffmpeg.** 4.x - Ubuntu 22.04's - works too: the agent finds the version and leaves out the camera file's `option` lines, which it does not read, and the stream starts in about seven seconds rather than three.
429
+
430
+ ```bash
431
+ sudo apt update && sudo apt install -y ffmpeg
432
+ ```
433
+
434
+ ```bash
435
+ ffmpeg -version | head -1
436
+ ```
437
+
438
+ **On macOS** ffmpeg comes from Homebrew. A Mac lets no two processes share a port, so there the machine's first agent takes 1984 to itself, and a second agent's stream arrives through it.
439
+
440
+ ```bash
441
+ brew install ffmpeg
442
+ ```
443
+
444
+ 2. **Restart the agent.** Finding ffmpeg, and once the printer names a camera stream in its status, it fetches the builds of go2rtc and cloudflared it pins into the folder's `camera/`, from their GitHub releases, each checked against its SHA-256 before it is made executable - then asks vextrix3d for the printer's camera and runs them:
445
+
446
+ ```bash
447
+ pm2 restart machine-agent-p2s-01
448
+ ```
449
+
450
+ | `uname -m` | go2rtc v1.9.14 | cloudflared 2026.10.7 |
451
+ | --------------- | ---------------------- | ------------------------------ |
452
+ | Linux `aarch64` | `go2rtc_linux_arm64` | `cloudflared-linux-arm64` |
453
+ | Linux `armv7l` | `go2rtc_linux_arm` | `cloudflared-linux-arm` |
454
+ | Linux `armv6l` | `go2rtc_linux_armv6` | `cloudflared-linux-arm` |
455
+ | Linux `x86_64` | `go2rtc_linux_amd64` | `cloudflared-linux-amd64` |
456
+ | macOS `arm64` | `go2rtc_mac_arm64.zip` | `cloudflared-darwin-arm64.tgz` |
457
+ | macOS `x86_64` | `go2rtc_mac_amd64.zip` | `cloudflared-darwin-amd64.tgz` |
458
+
459
+ The builds and their SHA-256 are pinned in the agent, so a newer go2rtc or cloudflared comes with a newer agent. On 32-bit ARM the agent goes by the ARM version its Node was built for, which `node -p process.config.variables.arm_version` prints - ARMv6's builds for `6`, and ARMv7's for anything else, which it says in its log where that is not `7`. A build already in `camera/` that matches is kept, and one that does not is replaced - by a new file moved over the old one, so that a go2rtc or cloudflared already running goes on running. On macOS the agent unpacks the zip and the tarball with the system's `tar`, and notes the archive each came from beside it - `camera/go2rtc.pin.json` and `camera/cloudflared.pin.json`, with the SHA-256 of the binary it unpacked - to know them again on its next start.
460
+
461
+ A download that fails, or does not match its SHA-256, is deleted unrun and fetched again later - five seconds on, then twice as long each time, up to an hour. The camera waits for both meanwhile, unless `camera/` has a go2rtc and a cloudflared already, which run until the pinned builds replace them. None of it holds up the printer's connection.
462
+
463
+ **A printer naming no camera stream** - its LAN live view off, or a model whose camera the agent does not read - is not relayed, and its page gets no Camera sheet: until its status names one, the agent fetches nothing and asks vextrix3d nothing, as the first ask makes the printer's tunnel and its Camera sheet, which would never play. Its log says so once, `[camera] "P2S-01" reports no camera stream (disable) - its LAN live view is off, or its camera is of a model the agent does not read - so its camera is not relayed until it names one`, with `none` where the printer names nothing at all. An A1 or a P1 names nothing, and is relayed all the same - see [An A1 or a P1](#an-a1-or-a-p1). Turning the printer's LAN live view on starts the camera with the next status, with no restart. Turned off once the camera runs, the relay and tunnel run on, and the printer's Camera sheet says the printer reports no camera stream - each state report says whether it names one.
464
+
465
+ **Placing them by hand.** `VEXTRIX3D_CAMERA_BINARIES=manual` in the printer's `.env.prod` leaves `camera/` alone, for a machine that pins its own: the agent runs whatever executable `camera/go2rtc` and `camera/cloudflared` it finds there, as it does on a machine no build is pinned for - and the relay is off without `camera/go2rtc`, the tunnel without `camera/cloudflared`.
466
+
467
+ 3. **Check the log.** An A1 or a P1 has `[camera] "A1-01" is a Bambu A1, whose camera has no RTSP: its JPEG frames are read from 192.168.0.120:6000 and encoded to H.264 while somebody watches` once its first status is in. The first start has `[camera] Fetched go2rtc v1.9.14 into camera/go2rtc (go2rtc_linux_arm64, its SHA-256 checked)` and the same for cloudflared - a later one, finding them in place, says nothing of them. Then `[camera] Taking this machine's camera requests on 127.0.0.1:1984`, `[tunnel] Opening the camera tunnel of "P2S-01"` and cloudflared's own lines, marked `[tunnel]`, with `Registered tunnel connection` once the way to the site is open - the printer's page then has its Camera sheet. Once the printer's first status is in, it has `[camera] Relaying the camera of "P2S-01" through go2rtc on 127.0.0.1:1985, its RTSP on 8555`, then go2rtc's own lines, also marked `[camera]`: `[rtsp] listen addr=127.0.0.1:8555` and `[api] listen addr=127.0.0.1:1985` - this machine only, nothing on the network. A `[camera] Could not fetch` line says why a build could not be fetched, and a `[camera] Relay off` or `[tunnel] Off` line what is missing.
468
+
469
+ ```bash
470
+ pm2 logs machine-agent-p2s-01 --lines 40 --nostream
471
+ ```
472
+
473
+ 4. **Check the relay**, asking go2rtc for the stream the way a page would - named after the printer's id, which the agent logs as it starts, `Loaded printer "P2S-01" (<id>)`, on the RTSP port of its `Relaying` line, 8555 for the machine's first agent. `codec_name=h264|width=1920|height=1080` is the answer - `width=1280|height=720` from an A1 or a P1, which takes a few seconds longer, its frames coming one every second or two:
474
+
475
+ ```bash
476
+ ffprobe -v error -rtsp_transport tcp -select_streams v -show_entries stream=codec_name,width,height -of compact=p=0 rtsp://127.0.0.1:8555/<printer id>
477
+ ```
478
+
479
+ ### Ports
480
+
481
+ All on `127.0.0.1`, none set by hand. Each agent's go2rtc takes the first pair free as it starts, and nothing else on the machine may hold a port in these ranges - `ss -ltn` lists the ports in use:
482
+
483
+ | Ports | What for |
484
+ | ----------- | --------------------------------------------------------------------------------------------------- |
485
+ | 1984 | The machine's camera port, which every agent takes requests on and every printer's tunnel points at |
486
+ | 1985 - 2083 | Each go2rtc's API, which the agents hand the requests to |
487
+ | 8555 - 8653 | Each go2rtc's RTSP, 6570 above its API port, where ffmpeg hands it the stream |
488
+
489
+ The bridge to the printer - an RTSPS stream's, or an A1's or P1's frames - listens on a port of `127.0.0.1` the system picks.
490
+
491
+ Room for 99 printers on a machine. The agents share 1984 as the processes of one user - all of a machine's agents run as the one user, under one pm2; an agent run as another cannot take it, and says so in its log.
492
+
493
+ ### Day to day
494
+
495
+ go2rtc starts ffmpeg only while a page watches the camera, about three seconds before the first picture, and stops it when the last page closes. Its lines are in the agent's log, marked `[camera]` - quiet until a page watches, and saying why when the camera cannot be reached - and cloudflared's beside them, marked `[tunnel]`:
496
+
497
+ ```bash
498
+ pm2 logs machine-agent-p2s-01
499
+ ```
500
+
501
+ Where vextrix3d cannot be asked for the camera, the agent asks again five seconds later, then twice as long each time, up to a minute, for as long as it takes - its log says so the first time, `[camera] vextrix3d did not give this printer its camera`, and again every tenth try. Once it has the camera it asks again every day, and checks the builds in `camera/` with it: a new relay key is taken up with go2rtc running on, and a new tunnel token starts cloudflared again on it - a restart takes either up at once.
502
+
503
+ Each state report is answered with the fingerprint of the printer's relay key as vextrix3d has it - the start of its SHA-256 - which **Reset camera**, or a new camera secret in vextrix3d, changes. Where it is not the fingerprint of the key the camera runs on, the agent asks vextrix3d again there and then, ahead of the daily ask, saying `[camera] vextrix3d names another key for this printer's camera - asking for its new tunnel and key now, ahead of the daily ask` - once for each fingerprint, and again with the next report where the ask fails. So after Reset camera the agent takes up the printer's new tunnel and key within about two minutes, or at once on a restart. cloudflared gives no sign of its own: once its tunnel is taken down it logs nothing and runs on as though connected, until the new token replaces it.
504
+
505
+ Where vextrix3d answers that it no longer knows the printer's API key - unknown, revoked or expired - or the printer itself, deleted, the agent stops the camera and its tunnel for good, with the rest of it - see [Fault Tolerance](#fault-tolerance).
506
+
507
+ The computer's clock has to be right to within about two minutes, as a relay token is good for a minute by the clock of vextrix3d's Worker: keep it set by NTP - systemd-timesyncd or chrony on Linux, where `timedatectl` says `System clock synchronized: yes`, and automatically on macOS. With the clock further off every stream is turned away, and the log has `[camera] A viewer asked for printer … with a relay token that has expired or is not yet valid by this computer's clock`.
508
+
509
+ Each viewer takes about 1 Mbit/s of the workshop's upload, an A1's or P1's camera less. Viewers of one printer share its camera connection here, but each gets a stream of their own through the tunnel, and a page streams only from Watch to Stop, pausing while its tab is hidden.
510
+
511
+ ---
512
+
513
+ ## Fault Tolerance
514
+
515
+ The agent is resilient to:
516
+
517
+ - temporary network failures
518
+ - printer restarts
519
+ - MQTT reconnects
520
+ - backend restarts
521
+ - transient HTTP failures
522
+
523
+ MQTT reconnects automatically. A failed state report is sent again after a wait - 30 seconds, doubling to 10 minutes - and a print attempt that could not be loaded is loaded again on the next state change. The camera is set up beside the printer's connection, and fetching go2rtc and cloudflared or asking vextrix3d for the camera is tried again until it succeeds.
524
+
525
+ **A printer vextrix3d no longer knows.** Any call answered `401` - the printer's API key unknown, revoked or expired - or `410` - the printer deleted - stops the agent's work for good: state reports, print attempts, the camera and its tunnel, and the printer's connection. The agent says so in one line, `vextrix3d no longer knows this printer's key (401) - stopped; restart the agent once it has a new key`, or `this printer (410)`, and then stays up and idle, asking vextrix3d nothing - rather than exiting, which pm2 would answer by starting it again, over and over. The same goes for the agent's first call, as it starts. Once the printer has a new API key in its `.env.prod`, a restart starts it afresh; for a printer deleted for good, `pm2 delete` its agent.
526
+
527
+ ---
528
+
529
+ ## Design Goals
530
+
531
+ - one process per printer
532
+ - stateless runtime
533
+ - minimal backend traffic
534
+ - reliable production synchronization
535
+ - automatic recovery from transient failures
536
+
537
+ ---
538
+
539
+ ## License
540
+
541
+ Proprietary, © VEXTRIX3D LTD. The agent may be installed and run only to connect printers to Vextrix3D, with an API key Vextrix3D has issued for them - [LICENSE](LICENSE) has the terms.