@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
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { createCameraFrameBridge } from './camera-frame-bridge.js';
|
|
2
|
+
import { WEBCAM_MAX_FPS } from './camera-relay.js';
|
|
3
|
+
import { CameraWebcamConnection, describeWebcamUrl } from './camera-webcam-connection.js';
|
|
4
|
+
const MJPEG_SERVICES = new Set(['mjpegstreamer', 'mjpegstreamer-adaptive', 'uv4l-mjpeg']);
|
|
5
|
+
const CAMERA_STREAMER_SERVICE = 'webrtc-camerastreamer';
|
|
6
|
+
const isMoonrakerPort = (port) => port >= 7125 && port <= 7199;
|
|
7
|
+
export const resolveWebcamBaseUrl = (host, port) => `http://${host}${port === 80 || isMoonrakerPort(port) ? '' : `:${port}`}/`;
|
|
8
|
+
const readString = (value) => (typeof value === 'string' ? value.trim() : '');
|
|
9
|
+
const readService = (webcam) => readString(webcam.service) || 'mjpegstreamer';
|
|
10
|
+
const readOrientation = (webcam) => {
|
|
11
|
+
const rotation = Number(webcam.rotation);
|
|
12
|
+
return {
|
|
13
|
+
flipHorizontal: webcam.flip_horizontal === true,
|
|
14
|
+
flipVertical: webcam.flip_vertical === true,
|
|
15
|
+
rotation: [90, 180, 270].includes(rotation) ? rotation : 0,
|
|
16
|
+
};
|
|
17
|
+
};
|
|
18
|
+
const isTurned = ({ flipHorizontal, flipVertical, rotation }) => flipHorizontal || flipVertical || rotation !== 0;
|
|
19
|
+
const resolveHttpUrl = (url, baseUrl) => {
|
|
20
|
+
if (!url) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
try {
|
|
24
|
+
const resolved = new URL(url, baseUrl);
|
|
25
|
+
return resolved.protocol === 'http:' || resolved.protocol === 'https:' ? resolved : null;
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
export const readWebcamStream = (webcam, baseUrl) => {
|
|
32
|
+
const service = readService(webcam);
|
|
33
|
+
const streamUrl = resolveHttpUrl(readString(webcam.stream_url), baseUrl);
|
|
34
|
+
if (webcam.enabled === false || !streamUrl) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
const orientation = readOrientation(webcam);
|
|
38
|
+
const stream = {
|
|
39
|
+
name: readString(webcam.name),
|
|
40
|
+
service,
|
|
41
|
+
orientation,
|
|
42
|
+
};
|
|
43
|
+
if (MJPEG_SERVICES.has(service)) {
|
|
44
|
+
return {
|
|
45
|
+
...stream,
|
|
46
|
+
kind: 'mjpeg',
|
|
47
|
+
url: streamUrl.toString(),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
if (service !== CAMERA_STREAMER_SERVICE) {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
return isTurned(orientation)
|
|
54
|
+
? {
|
|
55
|
+
...stream,
|
|
56
|
+
kind: 'mjpeg',
|
|
57
|
+
url: new URL('stream', streamUrl).toString(),
|
|
58
|
+
}
|
|
59
|
+
: {
|
|
60
|
+
...stream,
|
|
61
|
+
kind: 'h264',
|
|
62
|
+
url: new URL('video.h264', streamUrl).toString(),
|
|
63
|
+
};
|
|
64
|
+
};
|
|
65
|
+
const describeListedWebcam = (webcam) => `"${readString(webcam.name)}" (${webcam.enabled === false ? 'off' : readService(webcam)})`;
|
|
66
|
+
export const createWebcamReader = (host, port) => {
|
|
67
|
+
const baseUrl = resolveWebcamBaseUrl(host, port);
|
|
68
|
+
let last = null;
|
|
69
|
+
return (webcams) => {
|
|
70
|
+
const next = {
|
|
71
|
+
stream: webcams
|
|
72
|
+
?.map(webcam => readWebcamStream(webcam, baseUrl))
|
|
73
|
+
.find((stream) => stream !== null) ?? null,
|
|
74
|
+
listed: webcams?.map(describeListedWebcam) ?? null,
|
|
75
|
+
};
|
|
76
|
+
if (!last || JSON.stringify(last) !== JSON.stringify(next)) {
|
|
77
|
+
last = next;
|
|
78
|
+
}
|
|
79
|
+
return last;
|
|
80
|
+
};
|
|
81
|
+
};
|
|
82
|
+
export const createCameraWebcamBridge = ({ printerLabel, webcam: firstWebcam, }) => {
|
|
83
|
+
let webcam = firstWebcam;
|
|
84
|
+
const describe = () => `The webcam "${webcam.name}" of "${printerLabel}" at ${describeWebcamUrl(webcam.url)}`;
|
|
85
|
+
const bridge = createCameraFrameBridge({
|
|
86
|
+
open: handlers => new CameraWebcamConnection({
|
|
87
|
+
url: webcam.url,
|
|
88
|
+
...handlers,
|
|
89
|
+
}),
|
|
90
|
+
describe,
|
|
91
|
+
describeRate: (intervalS, frameKb) => `${describe()} sends ${intervalS > 0 ? (1 / intervalS).toFixed(1) : 'many'} frames a second, ${frameKb} KB each, of which no more than ${WEBCAM_MAX_FPS} a second are encoded`,
|
|
92
|
+
maxFps: WEBCAM_MAX_FPS,
|
|
93
|
+
});
|
|
94
|
+
return {
|
|
95
|
+
...bridge,
|
|
96
|
+
setWebcam: next => {
|
|
97
|
+
webcam = next;
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
};
|
|
101
|
+
const defaultDependencies = {
|
|
102
|
+
createWebcamBridge: options => createCameraWebcamBridge(options),
|
|
103
|
+
};
|
|
104
|
+
export const createWebcamCameraSource = ({ printerLabel }, dependencies = defaultDependencies) => ({
|
|
105
|
+
hasStream: report => Boolean(report?.stream),
|
|
106
|
+
encodedCamera: "a webcam's MJPEG",
|
|
107
|
+
describeStream: report => {
|
|
108
|
+
const stream = report?.stream;
|
|
109
|
+
if (!stream) {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
const webcam = `"${printerLabel}" relays its webcam "${stream.name}" (${stream.service})`;
|
|
113
|
+
const url = describeWebcamUrl(stream.url);
|
|
114
|
+
return stream.kind === 'h264'
|
|
115
|
+
? `${webcam}: its H.264 is read from ${url} and relayed as it comes`
|
|
116
|
+
: `${webcam}: its MJPEG is read from ${url} and encoded to H.264, at no more than ${WEBCAM_MAX_FPS} frames a second and 720 lines, while somebody watches${isTurned(stream.orientation) ? ', turned as Moonraker has it set' : ''}`;
|
|
117
|
+
},
|
|
118
|
+
describeNoStream: report => report?.listed
|
|
119
|
+
? `"${printerLabel}" has no webcam the agent reads - Moonraker lists ${report.listed.length ? report.listed.join(', ') : 'none'} - so its camera is not relayed until it lists one: an MJPEG stream, or camera-streamer's`
|
|
120
|
+
: `"${printerLabel}" has a Moonraker too old to list its webcams, with no server.webcams.list, so its camera is not relayed until Moonraker is updated`,
|
|
121
|
+
openInput: () => {
|
|
122
|
+
let bridge = null;
|
|
123
|
+
return {
|
|
124
|
+
resolveTarget: async (report) => {
|
|
125
|
+
const stream = report?.stream;
|
|
126
|
+
if (!stream) {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
if (stream.kind === 'h264') {
|
|
130
|
+
return {
|
|
131
|
+
kind: 'h264',
|
|
132
|
+
streamUrl: stream.url,
|
|
133
|
+
orientation: null,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
if (bridge) {
|
|
137
|
+
bridge.setWebcam(stream);
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
bridge = dependencies.createWebcamBridge({
|
|
141
|
+
printerLabel,
|
|
142
|
+
webcam: stream,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
return {
|
|
146
|
+
kind: 'mjpeg',
|
|
147
|
+
streamUrl: await bridge.streamUrl,
|
|
148
|
+
orientation: stream.orientation,
|
|
149
|
+
};
|
|
150
|
+
},
|
|
151
|
+
close: async () => {
|
|
152
|
+
await bridge?.close();
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
},
|
|
156
|
+
});
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import { KlippyState } from '../types/index.js';
|
|
2
2
|
import { findMoonrakerChamberObject, MOONRAKER_SUBSCRIBED_OBJECTS, mergeMoonrakerStatus, } from '../utils.js';
|
|
3
|
+
const METHOD_NOT_FOUND = -32601;
|
|
4
|
+
class MoonrakerRequestError extends Error {
|
|
5
|
+
code;
|
|
6
|
+
constructor(code, message) {
|
|
7
|
+
super(`Moonraker error ${code}: ${message}`);
|
|
8
|
+
this.code = code;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
3
11
|
const RECONNECT_DELAYS_MS = [2_000, 5_000, 15_000, 30_000, 60_000];
|
|
4
12
|
export class MoonrakerClient {
|
|
5
13
|
options;
|
|
@@ -10,6 +18,7 @@ export class MoonrakerClient {
|
|
|
10
18
|
isSubscribed = false;
|
|
11
19
|
subscribing = null;
|
|
12
20
|
isStopped = false;
|
|
21
|
+
hasListedWebcams = false;
|
|
13
22
|
reconnectAttempt = 0;
|
|
14
23
|
reconnectTimer = null;
|
|
15
24
|
heartbeatTimer = null;
|
|
@@ -87,12 +96,32 @@ export class MoonrakerClient {
|
|
|
87
96
|
url: 'https://vextrix3d.co.uk',
|
|
88
97
|
});
|
|
89
98
|
this.startHeartbeat();
|
|
99
|
+
void this.listWebcams();
|
|
90
100
|
await this.subscribeOnceKlippyAnswers();
|
|
91
101
|
}
|
|
92
102
|
catch (error) {
|
|
93
103
|
this.handleError(error);
|
|
94
104
|
}
|
|
95
105
|
}
|
|
106
|
+
async listWebcams() {
|
|
107
|
+
let webcams;
|
|
108
|
+
try {
|
|
109
|
+
const result = await this.request('server.webcams.list');
|
|
110
|
+
webcams = Array.isArray(result.webcams) ? result.webcams : [];
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
if (!(error instanceof MoonrakerRequestError && error.code === METHOD_NOT_FOUND)) {
|
|
114
|
+
this.handleError(error);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
webcams = null;
|
|
118
|
+
}
|
|
119
|
+
this.passWebcams(webcams);
|
|
120
|
+
}
|
|
121
|
+
passWebcams(webcams) {
|
|
122
|
+
this.hasListedWebcams = true;
|
|
123
|
+
this.options.onWebcams?.(webcams);
|
|
124
|
+
}
|
|
96
125
|
async subscribeOnceKlippyAnswers() {
|
|
97
126
|
const { klippy_state: klippyState } = await this.request('server.info');
|
|
98
127
|
const isAnswering = klippyState === KlippyState.READY ||
|
|
@@ -129,6 +158,9 @@ export class MoonrakerClient {
|
|
|
129
158
|
startHeartbeat() {
|
|
130
159
|
this.stopHeartbeat();
|
|
131
160
|
this.heartbeatTimer = setInterval(() => {
|
|
161
|
+
if (!this.hasListedWebcams) {
|
|
162
|
+
void this.listWebcams();
|
|
163
|
+
}
|
|
132
164
|
if (this.isSubscribed) {
|
|
133
165
|
this.options.onStatus?.(this.status);
|
|
134
166
|
return;
|
|
@@ -195,6 +227,13 @@ export class MoonrakerClient {
|
|
|
195
227
|
this.options.onStatus?.(this.status);
|
|
196
228
|
break;
|
|
197
229
|
}
|
|
230
|
+
case 'notify_webcams_changed': {
|
|
231
|
+
const { webcams } = (message.params?.[0] ?? {});
|
|
232
|
+
if (Array.isArray(webcams)) {
|
|
233
|
+
this.passWebcams(webcams);
|
|
234
|
+
}
|
|
235
|
+
break;
|
|
236
|
+
}
|
|
198
237
|
case 'notify_klippy_ready':
|
|
199
238
|
void this.subscribe().catch(error => this.handleError(error));
|
|
200
239
|
break;
|
|
@@ -211,7 +250,7 @@ export class MoonrakerClient {
|
|
|
211
250
|
clearTimeout(pending.timeout);
|
|
212
251
|
this.pendingRequests.delete(message.id);
|
|
213
252
|
if (message.error) {
|
|
214
|
-
pending.reject(new
|
|
253
|
+
pending.reject(new MoonrakerRequestError(message.error.code, message.error.message));
|
|
215
254
|
return;
|
|
216
255
|
}
|
|
217
256
|
pending.resolve(message.result);
|
|
@@ -222,6 +261,7 @@ export class MoonrakerClient {
|
|
|
222
261
|
}
|
|
223
262
|
this.socket = null;
|
|
224
263
|
this.isSubscribed = false;
|
|
264
|
+
this.hasListedWebcams = false;
|
|
225
265
|
this.stopHeartbeat();
|
|
226
266
|
this.pendingRequests.forEach(({ reject, timeout }) => {
|
|
227
267
|
clearTimeout(timeout);
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export const createMoonrakerCameraReporter = ({ stateReporter, cameraRelay, readWebcams, hasCameraStream, }) => {
|
|
2
|
+
let webcamReport;
|
|
3
|
+
let lastState = null;
|
|
4
|
+
const reportState = (state) => {
|
|
5
|
+
lastState = state;
|
|
6
|
+
stateReporter.report({
|
|
7
|
+
...state,
|
|
8
|
+
hasCameraStream: hasCameraStream(webcamReport),
|
|
9
|
+
});
|
|
10
|
+
};
|
|
11
|
+
return {
|
|
12
|
+
reportState,
|
|
13
|
+
noteWebcams: webcams => {
|
|
14
|
+
webcamReport = readWebcams(webcams);
|
|
15
|
+
cameraRelay?.update(webcamReport);
|
|
16
|
+
if (lastState) {
|
|
17
|
+
reportState(lastState);
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
};
|
package/dist/utils.js
CHANGED
|
@@ -66,6 +66,11 @@ export const stopChildProcess = (child, killAfterMs = 3_000) => new Promise(reso
|
|
|
66
66
|
});
|
|
67
67
|
child.kill('SIGTERM');
|
|
68
68
|
});
|
|
69
|
+
export const isJpeg = (frame) => frame.length >= 4 &&
|
|
70
|
+
frame[0] === 0xff &&
|
|
71
|
+
frame[1] === 0xd8 &&
|
|
72
|
+
frame[frame.length - 2] === 0xff &&
|
|
73
|
+
frame[frame.length - 1] === 0xd9;
|
|
69
74
|
export const formatSubtaskName = (value) => {
|
|
70
75
|
const subtaskName = value?.trim();
|
|
71
76
|
if (!subtaskName) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vextrix3d/machine-agent",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"description": "Connects a Bambu or Klipper printer to Vextrix3D: reports its state and print progress, and relays its camera",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"vextrix3d",
|
|
@@ -39,9 +39,10 @@
|
|
|
39
39
|
},
|
|
40
40
|
"scripts": {
|
|
41
41
|
"build": "rm -rf dist && tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json",
|
|
42
|
-
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
43
|
-
"lint": "eslint src && prettier --check src",
|
|
42
|
+
"typecheck": "tsc --noEmit -p tsconfig.json && tsc -p scripts",
|
|
43
|
+
"lint": "eslint src scripts && prettier --check src scripts",
|
|
44
44
|
"test": "vitest",
|
|
45
|
+
"check:webcam": "npm run build && node scripts/check-webcam-relay.ts",
|
|
45
46
|
"prepublishOnly": "CI=1 npm test && npm run build",
|
|
46
47
|
"start": "npm run build && pm2 start dist/bootstrap.js --name \"$(basename \"$(pwd -P)\")\"",
|
|
47
48
|
"start:prod": "npm run build && NODE_ENV=production pm2 start dist/bootstrap.js --name \"$(basename \"$(pwd -P)\")\"",
|