@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 +3 -3
- package/README.md +37 -47
- package/dist/bootstrap.js +40 -18
- package/dist/camera-bambu.js +92 -0
- package/dist/camera-frame-bridge.js +102 -0
- package/dist/camera-jpeg-bridge.js +8 -79
- package/dist/camera-jpeg-connection.js +2 -5
- package/dist/camera-relay.js +112 -126
- package/dist/camera-webcam-connection.js +186 -0
- package/dist/camera-webcam.js +156 -0
- package/dist/clients/MoonrakerClient.js +41 -1
- package/dist/moonraker-camera-reporter.js +21 -0
- package/dist/utils.js +5 -0
- package/package.json +4 -3
package/LICENSE
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
Vextrix3D
|
|
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
|
|
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
|
|
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
|
-
-
|
|
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
|
|
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
|
|
151
|
-
- follows a new camera address
|
|
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
|
|
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
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
|
136
|
+
const cameraSource = createWebcamCameraSource({
|
|
120
137
|
printerLabel: printer.label,
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
10
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|