@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.
@@ -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 Error(`Moonraker error ${message.error.code}: ${message.error.message}`));
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.0.0",
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)\")\"",