@vextrix3d/machine-agent 2.0.0 → 2.1.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 CHANGED
@@ -1,4 +1,4 @@
1
- Vextrix3D Printer Agent Licence
1
+ Vextrix3D Machine Agent Licence
2
2
 
3
3
  Copyright (c) 2026 VEXTRIX3D LTD. All rights reserved.
4
4
 
@@ -7,7 +7,7 @@ VEXTRIX3D LTD is a company registered in England and Wales under company number
7
7
 
8
8
  1. The Software
9
9
 
10
- This licence covers the Vextrix3D Printer Agent ("the Software"): the code, files and
10
+ This licence covers the Vextrix3D Machine Agent ("the Software"): the code, files and
11
11
  documentation in this package or repository, apart from the third-party components in
12
12
  section 6. The Software is licensed, not sold.
13
13
 
@@ -15,7 +15,7 @@ VEXTRIX3D LTD is a company registered in England and Wales under company number
15
15
 
16
16
  Vextrix3D grants you a non-exclusive, non-transferable and revocable licence to install and
17
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
18
+ other machines to the Vextrix3D service at vextrix3d.co.uk with a machine agent API key that
19
19
  Vextrix3D has issued for them, for as long as that key and your Vextrix3D account remain
20
20
  active. You may make the copies that installing and running it needs.
21
21
 
package/README.md CHANGED
@@ -17,7 +17,7 @@ The backend remains the source of truth for printer configuration, production st
17
17
  - Automatic cancellation and failure reporting
18
18
  - Batched state reporting to minimize API traffic
19
19
  - Automatic reconnection
20
- - A Bambu printer's camera relayed to its page, where ffmpeg is installed
20
+ - The printer's camera relayed to its page, where ffmpeg is installed - a Bambu printer's, or a Klipper printer's webcam
21
21
  - Low bandwidth and minimal backend writes
22
22
 
23
23
  ---
@@ -83,6 +83,7 @@ Responsibilities:
83
83
  - pass the status on again every 30 seconds while nothing changes, and ask again whether Klipper can give one while it cannot
84
84
  - reconnect after 2, 5, 15 and 30 seconds, then once a minute
85
85
  - read the slicer's estimate from a file's metadata
86
+ - list the webcams as it connects, and again with each `notify_webcams_changed` - see [A Klipper printer's webcam](#a-klipper-printers-webcam)
86
87
 
87
88
  ---
88
89
 
@@ -143,12 +144,12 @@ Authentication is performed using Machine Agent API keys.
143
144
 
144
145
  ### Camera Relay
145
146
 
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
+ 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: 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) - or a Klipper printer's first webcam in Moonraker - see [A Klipper printer's webcam](#a-klipper-printers-webcam).
147
148
 
148
149
  - 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
150
+ - 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 - or, for a Klipper printer, its webcam as Moonraker lists it
151
+ - starts go2rtc with the first report of a camera stream - a Bambu printer's status, or Moonraker's list of webcams - starts it again if it stops, and stops it with the agent
152
+ - follows a new camera address without restarting go2rtc, and starts it again at once for a webcam to be read another way
152
153
  - runs go2rtc on a pair of ports it finds free, and notes them where the machine's other agents find them
153
154
  - 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
155
  - 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
@@ -169,7 +170,7 @@ The backend receives the following information:
169
170
  - bed temperature
170
171
  - chamber temperature
171
172
  - 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
+ - whether the printer has a camera stream - a Bambu printer one it names, a Klipper printer a webcam the agent reads - a change in it sent at once, as a new state is
173
174
  - the agent's own version, which the machine's page shows, from 1.1.0
174
175
  - last seen timestamp
175
176
 
@@ -215,6 +216,7 @@ The agent follows Moonraker's own [API documentation](https://moonraker.readthed
215
216
  - **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
217
  - **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
218
  - **Job name** — `print_stats.filename`, without its folder or `.gcode`.
219
+ - **Webcams** — `server.webcams.list` as the agent connects, whatever Klipper's state, and `notify_webcams_changed` as they are changed in Mainsail or Fluidd; a Moonraker from before 2023, which has no list, has none, and the log says it is too old to list them. The first the agent reads is relayed - see [A Klipper printer's webcam](#a-klipper-printers-webcam).
218
220
 
219
221
  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
222
 
@@ -353,51 +355,11 @@ pm2 restart /machine-agent-/
353
355
  pm2 delete machine-agent-p2s-01 && pm2 save
354
356
  ```
355
357
 
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
358
  ---
397
359
 
398
360
  ## Camera relay
399
361
 
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.
362
+ A printer's page in Vextrix3D can show its camera live - a Bambu printer's, as below, or a Klipper printer's webcam, as under [A Klipper printer's webcam](#a-klipper-printers-webcam). 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
363
 
402
364
  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
365
 
@@ -421,6 +383,34 @@ What it costs, measured against a stand-in printer sending a 1280×720 frame a s
421
383
 
422
384
  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
385
 
386
+ ### A Klipper printer's webcam
387
+
388
+ A Klipper printer's camera is the first of its webcams that the agent reads, as Moonraker lists them (`server.webcams.list`) - the order Mainsail shows them in - set in Mainsail's or Fluidd's settings, or in `moonraker.conf`. Moonraker is asked as the agent connects and tells it of each change (`notify_webcams_changed`), so a webcam added, changed or removed is followed with no restart; one in `moonraker.conf` is read again as Moonraker restarts. Each state report says whether the printer has one, and the agent asks vextrix3d for its camera once it does.
389
+
390
+ An address with no host - Mainsail fills a new webcam in with `/webcam/?action=stream` - is on the page's host, as Mainsail resolves it: the printer's address, on port 80, where nginx hands `/webcam/` to the streamer, or on the port the printer is saved with where that is not a Moonraker's own - 7125, and the 7126, 7127 and on that KIAUH gives a second and third printer's Moonraker on the same computer, all behind the one page. crowsnest's streamer listens on the printer's own computer alone unless its `no_proxy` is set, so nginx is the way to it from another computer. A login in the address is sent as Basic auth, and left out of the log.
391
+
392
+ By the webcam's service:
393
+
394
+ - **MJPEG-Streamer, Adaptive MJPEG-Streamer and UV4L-MJPEG** (`mjpegstreamer`, `mjpegstreamer-adaptive`, `uv4l-mjpeg`) - crowsnest's ustreamer, mjpg-streamer, and what Mainsail gives a new webcam. The agent reads the MJPEG stream, and ffmpeg encodes it to H.264 while somebody watches. A bridge on this machine alone, as for an A1 or a P1, hands ffmpeg no more than 10 frames a second and drops the rest before ffmpeg decodes them. ffmpeg then encodes at a constant 10 frames a second - each frame repeated until the next where the webcam stalls, as go2rtc gives a frame that comes more than a second after the last a millisecond's duration. It scales the picture down to 720 lines where it has more, holds it to 1.5 Mbit/s, and puts a keyframe every two seconds. It turns the picture as the webcam's rotation and flips say, rotated first, as Mainsail and Fluidd turn it on their pages.
395
+ - **WebRTC (camera-streamer)** (`webrtc-camerastreamer`) - camera-streamer, which crowsnest runs for a Raspberry Pi's own camera, and which encodes H.264 on the Pi itself. The webcam's address is its WebRTC page, `/webcam/webrtc`, and ffmpeg reads the H.264 beside it, `video.h264`, and copies it as it comes, which costs next to nothing. Where the webcam is flipped or rotated, which only encoding can do, its MJPEG beside it, `stream`, is encoded as above instead.
396
+ - **Anything else** - go2rtc's, MediaMTX's or Janus's WebRTC, HLS, an iframe - names a page or a player rather than a stream, and is not read.
397
+
398
+ The log says which webcam it relays, and how, `[camera] "Voron-01" relays its webcam "Webcam" (mjpegstreamer-adaptive): its MJPEG is read from http://192.168.0.130/webcam/?action=stream and encoded to H.264 …`. Where there is none, it lists the webcams Moonraker has, `[camera] "Voron-01" has no webcam the agent reads - Moonraker lists "Bed" (webrtc-janus) - …`. The first time frames come, it says how many come and how large, `[camera] The webcam "Webcam" of "Voron-01" at … sends 15.0 frames a second, 40 KB each, of which no more than 10 a second are encoded`. A webcam that cannot be read fails ffmpeg's request at once, and the log says why once, `… was not opened: it answered 404`. A webcam changed to be read another way - camera-streamer where it was MJPEG, or turned - starts go2rtc again on a config for it.
399
+
400
+ What an MJPEG webcam costs, measured with ffmpeg 9 on an Apple M1 Max, for a still scene with sensor noise, 10 frames a second encoded:
401
+
402
+ | Webcam | ffmpeg, of one core | Sent |
403
+ | --- | --- | --- |
404
+ | 640×480 | 2.7% | 0.8 Mbit/s |
405
+ | 1280×720 | 6.1% | 1.6 Mbit/s |
406
+ | 1920×1080, scaled to 720 lines | 8% | 1.0 Mbit/s |
407
+
408
+ Decoding every frame of a webcam sending 30 a second, rather than the bridge's 10, cost 1.7 to 2 times as much. On a Raspberry Pi it has not been measured. By the usual gap between the two, expect a Pi 4 to take 15 to 30% of one of its four cores for crowsnest's default 640×480, and 40 to 80% for a 1080p webcam. That is while somebody watches, and often on the Pi running Klipper itself. A Pi 5 takes about half that, and a Pi 3 about twice.
409
+
410
+ Tried with go2rtc 1.9.14 and ffmpeg 9 against a stand-in ustreamer at 30 frames a second, at 720p and 1080p, rotated and flipped, and stalling for two and a half seconds, and a stand-in camera-streamer's H.264, each played through go2rtc's WebSocket as the page plays it. None of it has been tried with a real Klipper printer: crowsnest, nginx and camera-streamer's `video.h264` are as their sources read.
411
+
412
+ `npm run check:webcam` runs those checks again from a checkout, with go2rtc in `camera/`, ffmpeg and ffprobe installed, and Node 22.18 or later, which runs the script's TypeScript as it is - `-- 1080`, `-- turned`, `-- stall` or `-- h264` for the others - and `-- --url http://voron.local/webcam/?action=stream` reads a real webcam on this computer's network instead, with `--service`, `--rotation`, `--flip-horizontal` and `--flip-vertical` as Moonraker would list it. It says what played: `Played 1280×720, 69 frames over 6.7s, 10.1 a second, the longest step between two 0.10s, …`.
413
+
424
414
  ### Setting it up
425
415
 
426
416
  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.
package/dist/bootstrap.js CHANGED
@@ -3,11 +3,14 @@ import { existsSync, readFileSync } from 'node:fs';
3
3
  import { dirname, resolve } from 'node:path';
4
4
  import { loadEnvFile } from 'node:process';
5
5
  import { fileURLToPath } from 'node:url';
6
+ import { createBambuCameraSource, readCameraStream } from './camera-bambu.js';
6
7
  import { readJpegCamera } from './camera-jpeg-connection.js';
7
- import { hasCameraStream, readCameraStream, resolveCameraRelay } from './camera-relay.js';
8
+ import { resolveCameraRelay } from './camera-relay.js';
9
+ import { createWebcamCameraSource, createWebcamReader } from './camera-webcam.js';
8
10
  import { BambuMqttClient } from './clients/BambuMqttClient.js';
9
11
  import { MoonrakerClient } from './clients/MoonrakerClient.js';
10
12
  import { PrinterConnectionType, Vextrix3dApiClient, } from './clients/Vextrix3dApiClient.js';
13
+ import { createMoonrakerCameraReporter } from './moonraker-camera-reporter.js';
11
14
  import { createPrintAttemptSynchronizer } from './print-attempt-synchronizer.js';
12
15
  import { createPrinterGone } from './printer-gone.js';
13
16
  import { createPrinterStateReporter } from './printer-state-reporter.js';
@@ -40,17 +43,12 @@ const parseWifiSignalDbm = (value) => {
40
43
  const parsed = Number.parseInt(value, 10);
41
44
  return Number.isFinite(parsed) ? parsed : null;
42
45
  };
43
- const connectToBambuPrinter = (printer, connection, apiClient, printerGone) => {
44
- const { ip, serialNumber, accessCode } = connection;
45
- const jpegCamera = readJpegCamera(serialNumber, ip);
46
+ const startPrinterReporting = (printer, cameraSource, apiClient, printerGone) => {
46
47
  const cameraRelay = resolveCameraRelay({
47
48
  workDir,
48
- caFile: resolve(packageDir, 'certs', 'bambu-ca.pem'),
49
49
  printerId: printer.id,
50
50
  printerLabel: printer.label,
51
- accessCode,
52
- serialNumber,
53
- jpegCamera,
51
+ source: cameraSource,
54
52
  fetchesBinaries: fetchesCameraBinaries,
55
53
  getCameraAccess: () => apiClient.getCamera(),
56
54
  printerGone,
@@ -64,6 +62,27 @@ const connectToBambuPrinter = (printer, connection, apiClient, printerGone) => {
64
62
  },
65
63
  printerGone,
66
64
  });
65
+ return {
66
+ cameraRelay,
67
+ stateReporter,
68
+ hasCameraStream: report => (report === undefined ? undefined : cameraSource.hasStream(report)),
69
+ stop: async () => {
70
+ await cameraRelay?.stop();
71
+ await stateReporter.flush();
72
+ stateReporter.stop();
73
+ },
74
+ };
75
+ };
76
+ const connectToBambuPrinter = (printer, connection, apiClient, printerGone) => {
77
+ const { ip, serialNumber, accessCode } = connection;
78
+ const cameraSource = createBambuCameraSource({
79
+ printerLabel: printer.label,
80
+ accessCode,
81
+ serialNumber,
82
+ caFile: resolve(packageDir, 'certs', 'bambu-ca.pem'),
83
+ jpegCamera: readJpegCamera(serialNumber, ip),
84
+ });
85
+ const { cameraRelay, stateReporter, hasCameraStream, stop: stopReporting, } = startPrinterReporting(printer, cameraSource, apiClient, printerGone);
67
86
  const printAttemptSynchronizer = createPrintAttemptSynchronizer({
68
87
  printerLabel: printer.label,
69
88
  apiClient,
@@ -90,7 +109,7 @@ const connectToBambuPrinter = (printer, connection, apiClient, printerGone) => {
90
109
  bedTemperature: print.bed_temper ?? null,
91
110
  chamberTemperature: readBambuTemperature(print.device?.ctc?.info?.temp),
92
111
  wifiSignalDbm: parseWifiSignalDbm(print.wifi_signal),
93
- hasCameraStream: cameraStream === undefined ? undefined : hasCameraStream(cameraStream, jpegCamera),
112
+ hasCameraStream: hasCameraStream(cameraStream),
94
113
  });
95
114
  printAttemptSynchronizer.handleState(state);
96
115
  cameraRelay?.update(cameraStream);
@@ -107,19 +126,22 @@ const connectToBambuPrinter = (printer, connection, apiClient, printerGone) => {
107
126
  });
108
127
  return {
109
128
  stop: once(async () => {
110
- await cameraRelay?.stop();
111
- await stateReporter.flush();
112
- stateReporter.stop();
129
+ await stopReporting();
113
130
  await mqttClient.stop();
114
131
  }),
115
132
  };
116
133
  };
117
134
  const connectToMoonrakerPrinter = (printer, connection, apiClient, printerGone) => {
118
135
  const { ip, port, apiKey } = connection;
119
- const stateReporter = createPrinterStateReporter({
136
+ const cameraSource = createWebcamCameraSource({
120
137
  printerLabel: printer.label,
121
- reportPrinterState: state => apiClient.reportPrinterState(state),
122
- printerGone,
138
+ });
139
+ const { cameraRelay, stateReporter, hasCameraStream, stop: stopReporting, } = startPrinterReporting(printer, cameraSource, apiClient, printerGone);
140
+ const cameraReporter = createMoonrakerCameraReporter({
141
+ stateReporter,
142
+ cameraRelay,
143
+ readWebcams: createWebcamReader(ip, port),
144
+ hasCameraStream,
123
145
  });
124
146
  const printAttemptSynchronizer = createPrintAttemptSynchronizer({
125
147
  printerLabel: printer.label,
@@ -160,9 +182,10 @@ const connectToMoonrakerPrinter = (printer, connection, apiClient, printerGone)
160
182
  const filename = status.print_stats?.filename;
161
183
  loadEstimatedTime(typeof filename === 'string' ? filename : '');
162
184
  const agentState = buildMoonrakerAgentState(status, estimatedTimeS);
163
- stateReporter.report(agentState);
185
+ cameraReporter.reportState(agentState);
164
186
  printAttemptSynchronizer.handleState(agentState.state);
165
187
  },
188
+ onWebcams: webcams => cameraReporter.noteWebcams(webcams),
166
189
  onReconnect: () => {
167
190
  console.log(`Reconnecting to "${printer.label}"...`);
168
191
  },
@@ -175,8 +198,7 @@ const connectToMoonrakerPrinter = (printer, connection, apiClient, printerGone)
175
198
  });
176
199
  return {
177
200
  stop: once(async () => {
178
- await stateReporter.flush();
179
- stateReporter.stop();
201
+ await stopReporting();
180
202
  await client.stop();
181
203
  }),
182
204
  };
@@ -0,0 +1,92 @@
1
+ import { createCameraJpegBridge } from './camera-jpeg-bridge.js';
2
+ import { createCameraStreamBridge, } from './camera-stream-bridge.js';
3
+ export const readCameraStream = (ipcam) => ipcam === undefined ? undefined : (ipcam.rtsp_url ?? null);
4
+ const parseCameraStreamUrl = (rtspUrl) => {
5
+ if (!rtspUrl) {
6
+ return null;
7
+ }
8
+ let url;
9
+ try {
10
+ url = new URL(rtspUrl);
11
+ }
12
+ catch {
13
+ return null;
14
+ }
15
+ return url.protocol === 'rtsps:' || url.protocol === 'rtsp:' ? url : null;
16
+ };
17
+ export const hasCameraStream = (rtspUrl, jpegCamera = null) => (jpegCamera ? rtspUrl !== undefined : parseCameraStreamUrl(rtspUrl) !== null);
18
+ export const readCameraStreamTarget = (rtspUrl) => {
19
+ const url = parseCameraStreamUrl(rtspUrl);
20
+ return url?.protocol === 'rtsps:'
21
+ ? {
22
+ host: url.hostname,
23
+ port: Number(url.port) || 322,
24
+ }
25
+ : null;
26
+ };
27
+ export const buildCameraStreamUrl = (rtspUrl, accessCode, bridgePort) => {
28
+ const url = parseCameraStreamUrl(rtspUrl);
29
+ if (!url) {
30
+ return null;
31
+ }
32
+ const streamUrl = url.protocol === 'rtsps:'
33
+ ? new URL(`rtsp://127.0.0.1:${bridgePort}${url.pathname}${url.search}`)
34
+ : url;
35
+ streamUrl.username = 'bblp';
36
+ streamUrl.password = accessCode;
37
+ return streamUrl.toString();
38
+ };
39
+ const defaultBambuDependencies = {
40
+ createStreamBridge: options => createCameraStreamBridge(options),
41
+ createJpegBridge: options => createCameraJpegBridge(options),
42
+ };
43
+ export const createBambuCameraSource = ({ printerLabel, accessCode, serialNumber, caFile, jpegCamera }, dependencies = defaultBambuDependencies) => ({
44
+ hasStream: rtspUrl => hasCameraStream(rtspUrl, jpegCamera),
45
+ encodedCamera: jpegCamera ? `a Bambu ${jpegCamera.model}'s camera` : null,
46
+ describeStream: () => jpegCamera
47
+ ? `"${printerLabel}" is a Bambu ${jpegCamera.model}, whose camera has no RTSP: its JPEG frames are read from ${jpegCamera.host}:${jpegCamera.port} and encoded to H.264 while somebody watches`
48
+ : null,
49
+ describeNoStream: rtspUrl => `"${printerLabel}" reports no camera stream (${rtspUrl || 'none'}) - 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`,
50
+ openInput: () => {
51
+ if (jpegCamera) {
52
+ const bridge = dependencies.createJpegBridge({
53
+ host: jpegCamera.host,
54
+ port: jpegCamera.port,
55
+ serialNumber,
56
+ accessCode,
57
+ caFile,
58
+ printerLabel,
59
+ });
60
+ return {
61
+ resolveTarget: async () => ({
62
+ kind: 'jpeg',
63
+ streamUrl: await bridge.streamUrl,
64
+ orientation: null,
65
+ }),
66
+ close: bridge.close,
67
+ };
68
+ }
69
+ const bridge = dependencies.createStreamBridge({
70
+ serialNumber,
71
+ caFile,
72
+ printerLabel,
73
+ });
74
+ return {
75
+ resolveTarget: async (rtspUrl) => {
76
+ const target = readCameraStreamTarget(rtspUrl);
77
+ if (target) {
78
+ bridge.setTarget(target.host, target.port);
79
+ }
80
+ const streamUrl = buildCameraStreamUrl(rtspUrl, accessCode, await bridge.port);
81
+ return streamUrl
82
+ ? {
83
+ kind: 'rtsp',
84
+ streamUrl,
85
+ orientation: null,
86
+ }
87
+ : null;
88
+ },
89
+ close: bridge.close,
90
+ };
91
+ },
92
+ });
@@ -0,0 +1,102 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { createServer } from 'node:http';
3
+ const BOUNDARY = 'frame';
4
+ const FRAMES_MEASURED = 10;
5
+ export const createFramePacer = (maxFps) => {
6
+ if (maxFps === null) {
7
+ return () => true;
8
+ }
9
+ const intervalMs = 1000 / maxFps;
10
+ let dueAt = -Infinity;
11
+ return (now) => {
12
+ if (now < dueAt) {
13
+ return false;
14
+ }
15
+ dueAt = Math.max(dueAt + intervalMs, now + intervalMs / 2);
16
+ return true;
17
+ };
18
+ };
19
+ export const createCameraFrameBridge = ({ open, describe, describeRate, maxFps, }) => {
20
+ const path = `/${randomBytes(16).toString('hex')}`;
21
+ const sources = new Set();
22
+ let reportedFailure = null;
23
+ let hasReportedRate = false;
24
+ const reportRate = (firstFrameAt, bytes) => {
25
+ hasReportedRate = true;
26
+ const intervalS = (Date.now() - firstFrameAt) / 1000 / (FRAMES_MEASURED - 1);
27
+ console.log(`[camera] ${describeRate(intervalS, Math.round(bytes / FRAMES_MEASURED / 1024))}`);
28
+ };
29
+ const reportFailure = (error) => {
30
+ if (error.message === reportedFailure) {
31
+ return;
32
+ }
33
+ reportedFailure = error.message;
34
+ console.error(`[camera] ${describe()} was not opened: ${error.message}`);
35
+ };
36
+ const relay = (request, response) => {
37
+ if (request.method !== 'GET' || request.url !== path) {
38
+ response.writeHead(404).end();
39
+ return;
40
+ }
41
+ const isDue = createFramePacer(maxFps);
42
+ let frames = 0;
43
+ let bytes = 0;
44
+ let firstFrameAt = 0;
45
+ const source = open({
46
+ onFrame: frame => {
47
+ if (!frames) {
48
+ reportedFailure = null;
49
+ firstFrameAt = Date.now();
50
+ response.writeHead(200, {
51
+ 'Content-Type': `multipart/x-mixed-replace; boundary=${BOUNDARY}`,
52
+ 'Cache-Control': 'no-store',
53
+ });
54
+ }
55
+ frames += 1;
56
+ bytes += frame.length;
57
+ if (frames === FRAMES_MEASURED && !hasReportedRate) {
58
+ reportRate(firstFrameAt, bytes);
59
+ }
60
+ if (!isDue(Date.now()) || response.writableNeedDrain) {
61
+ return;
62
+ }
63
+ response.write(`--${BOUNDARY}\r\nContent-Type: image/jpeg\r\nContent-Length: ${frame.length}\r\n\r\n`);
64
+ response.write(frame);
65
+ response.write('\r\n');
66
+ },
67
+ onFailure: error => {
68
+ if (!response.headersSent) {
69
+ response.writeHead(502).end();
70
+ }
71
+ reportFailure(error);
72
+ },
73
+ onClose: () => {
74
+ sources.delete(source);
75
+ if (!response.writableEnded) {
76
+ response.destroy();
77
+ }
78
+ },
79
+ });
80
+ sources.add(source);
81
+ response.once('close', () => source.close());
82
+ };
83
+ const server = createServer(relay);
84
+ const streamUrl = new Promise((resolve, reject) => {
85
+ server.once('error', reject);
86
+ server.listen({
87
+ host: '127.0.0.1',
88
+ port: 0,
89
+ }, () => {
90
+ const address = server.address();
91
+ resolve(`http://127.0.0.1:${typeof address === 'object' && address ? address.port : 0}${path}`);
92
+ });
93
+ });
94
+ return {
95
+ streamUrl,
96
+ close: () => new Promise(resolve => {
97
+ sources.forEach(source => source.close());
98
+ server.close(() => resolve());
99
+ server.closeAllConnections();
100
+ }),
101
+ };
102
+ };
@@ -1,90 +1,19 @@
1
- import { randomBytes } from 'node:crypto';
2
1
  import { readFileSync } from 'node:fs';
3
- import { createServer } from 'node:http';
2
+ import { createCameraFrameBridge } from './camera-frame-bridge.js';
4
3
  import { CameraJpegConnection } from './camera-jpeg-connection.js';
5
- const BOUNDARY = 'frame';
6
- const FRAMES_MEASURED = 10;
7
4
  export const createCameraJpegBridge = ({ host, port, serialNumber, accessCode, caFile, printerLabel, }) => {
8
5
  const ca = readFileSync(caFile);
9
- const path = `/${randomBytes(16).toString('hex')}`;
10
- const connections = new Set();
11
- let reportedFailure = null;
12
- let hasReportedRate = false;
13
- const reportRate = (firstFrameAt, bytes) => {
14
- hasReportedRate = true;
15
- const intervalS = (Date.now() - firstFrameAt) / 1000 / (FRAMES_MEASURED - 1);
16
- console.log(`[camera] "${printerLabel}" sends its camera as a JPEG frame every ${intervalS.toFixed(1)}s, ${Math.round(bytes / FRAMES_MEASURED / 1024)} KB each`);
17
- };
18
- const relay = (request, response) => {
19
- if (request.method !== 'GET' || request.url !== path) {
20
- response.writeHead(404).end();
21
- return;
22
- }
23
- let frames = 0;
24
- let bytes = 0;
25
- let firstFrameAt = 0;
26
- const connection = new CameraJpegConnection({
6
+ return createCameraFrameBridge({
7
+ open: handlers => new CameraJpegConnection({
27
8
  host,
28
9
  port,
29
10
  serialNumber,
30
11
  accessCode,
31
12
  ca,
32
- onFrame: frame => {
33
- if (!frames) {
34
- reportedFailure = null;
35
- firstFrameAt = Date.now();
36
- response.writeHead(200, {
37
- 'Content-Type': `multipart/x-mixed-replace; boundary=${BOUNDARY}`,
38
- 'Cache-Control': 'no-store',
39
- });
40
- }
41
- frames += 1;
42
- bytes += frame.length;
43
- if (frames === FRAMES_MEASURED && !hasReportedRate) {
44
- reportRate(firstFrameAt, bytes);
45
- }
46
- if (response.writableNeedDrain) {
47
- return;
48
- }
49
- response.write(`--${BOUNDARY}\r\nContent-Type: image/jpeg\r\nContent-Length: ${frame.length}\r\n\r\n`);
50
- response.write(frame);
51
- response.write('\r\n');
52
- },
53
- onFailure: error => {
54
- if (!response.headersSent) {
55
- response.writeHead(502).end();
56
- }
57
- if (error.message === reportedFailure) {
58
- return;
59
- }
60
- reportedFailure = error.message;
61
- console.error(`[camera] The camera of "${printerLabel}" at ${host}:${port} was not opened: ${error.message}`);
62
- },
63
- onClose: () => {
64
- connections.delete(connection);
65
- response.destroy();
66
- },
67
- });
68
- connections.add(connection);
69
- response.once('close', () => connection.close());
70
- };
71
- const server = createServer(relay);
72
- const streamUrl = new Promise((resolve, reject) => {
73
- server.once('error', reject);
74
- server.listen({
75
- host: '127.0.0.1',
76
- port: 0,
77
- }, () => {
78
- const address = server.address();
79
- resolve(`http://127.0.0.1:${typeof address === 'object' && address ? address.port : 0}${path}`);
80
- });
81
- });
82
- return {
83
- streamUrl,
84
- close: () => new Promise(resolve => {
85
- connections.forEach(connection => connection.close());
86
- server.close(() => resolve());
87
- server.closeAllConnections();
13
+ ...handlers,
88
14
  }),
89
- };
15
+ describe: () => `The camera of "${printerLabel}" at ${host}:${port}`,
16
+ describeRate: (intervalS, frameKb) => `"${printerLabel}" sends its camera as a JPEG frame every ${intervalS.toFixed(1)}s, ${frameKb} KB each`,
17
+ maxFps: null,
18
+ });
90
19
  };
@@ -1,5 +1,6 @@
1
1
  import { connect } from 'node:tls';
2
2
  import { checkPrinterCertificate } from './camera-stream-connection.js';
3
+ import { isJpeg } from './utils.js';
3
4
  export const JPEG_CAMERA_PORT = 6000;
4
5
  const JPEG_CAMERA_MODELS = {
5
6
  '01S': 'P1P',
@@ -60,11 +61,7 @@ export const createJpegFrameReader = (onFrame) => {
60
61
  }
61
62
  const frame = take(frameSize);
62
63
  frameSize = null;
63
- const isJpeg = frame[0] === 0xff &&
64
- frame[1] === 0xd8 &&
65
- frame[frame.length - 2] === 0xff &&
66
- frame[frame.length - 1] === 0xd9;
67
- if (!isJpeg) {
64
+ if (!isJpeg(frame)) {
68
65
  throw new Error('it sent a frame that is not a JPEG');
69
66
  }
70
67
  onFrame(frame);