@vextrix3d/machine-agent 0.0.0-stage → 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,80 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { join } from 'node:path';
3
+ import { createInterface } from 'node:readline';
4
+ import { backoffDelayMs, isExecutable, stopChildProcess } from './utils.js';
5
+ const defaultDependencies = {
6
+ spawn: (command, args, env) => spawn(command, args, {
7
+ stdio: ['ignore', 'pipe', 'pipe'],
8
+ env,
9
+ }),
10
+ };
11
+ export const createCameraTunnel = ({ cloudflaredPath, tunnelToken, printerLabel }, dependencies = defaultDependencies) => {
12
+ let cloudflared = null;
13
+ let restartTimeoutId;
14
+ let failures = 0;
15
+ let startedAt = 0;
16
+ let isStopping = false;
17
+ const forward = (output) => {
18
+ if (!output) {
19
+ return;
20
+ }
21
+ createInterface({
22
+ input: output,
23
+ }).on('line', line => {
24
+ const log = / ERR /.test(line) ? console.error : console.log;
25
+ log(`[tunnel] ${line}`);
26
+ });
27
+ };
28
+ const scheduleStart = (reason) => {
29
+ failures = Date.now() - startedAt >= 60_000 ? 1 : failures + 1;
30
+ const delayMs = backoffDelayMs(failures, 5_000, 60_000);
31
+ console.error(`[tunnel] ${reason}; starting cloudflared again in ${delayMs / 1000}s`);
32
+ restartTimeoutId = setTimeout(() => start(), delayMs);
33
+ };
34
+ const start = () => {
35
+ startedAt = Date.now();
36
+ const child = dependencies.spawn(cloudflaredPath, ['tunnel', '--no-autoupdate', 'run'], {
37
+ ...process.env,
38
+ TUNNEL_TOKEN: tunnelToken,
39
+ });
40
+ cloudflared = child;
41
+ console.log(`[tunnel] Opening the camera tunnel of "${printerLabel}"`);
42
+ forward(child.stdout);
43
+ forward(child.stderr);
44
+ child.once('error', error => {
45
+ console.error('[tunnel] cloudflared could not start:', error);
46
+ });
47
+ child.once('exit', (code, signal) => {
48
+ cloudflared = null;
49
+ if (!isStopping) {
50
+ scheduleStart(`cloudflared stopped (${signal ?? `exit code ${code}`})`);
51
+ }
52
+ });
53
+ };
54
+ start();
55
+ return {
56
+ stop: async () => {
57
+ isStopping = true;
58
+ clearTimeout(restartTimeoutId);
59
+ if (cloudflared) {
60
+ await stopChildProcess(cloudflared);
61
+ }
62
+ },
63
+ };
64
+ };
65
+ export const resolveCameraTunnel = ({ workDir, tunnelToken, printerLabel }, dependencies) => {
66
+ if (!tunnelToken) {
67
+ console.log('[tunnel] Off: vextrix3d makes no camera tunnel here');
68
+ return null;
69
+ }
70
+ const cloudflaredPath = join(workDir, 'camera', 'cloudflared');
71
+ if (!isExecutable(cloudflaredPath)) {
72
+ console.log('[tunnel] Off: there is no camera/cloudflared to run');
73
+ return null;
74
+ }
75
+ return createCameraTunnel({
76
+ cloudflaredPath,
77
+ tunnelToken,
78
+ printerLabel,
79
+ }, dependencies);
80
+ };
@@ -0,0 +1,186 @@
1
+ import { request as requestHttp } from 'node:http';
2
+ import { request as requestHttps } from 'node:https';
3
+ import { isJpeg } from './utils.js';
4
+ export const readMjpegBoundary = (contentType) => {
5
+ const match = contentType?.match(/^\s*multipart\/x-mixed-replace\s*;(?:.*;)?\s*boundary=([^;]+)/i);
6
+ const boundary = match?.[1].trim().replace(/^"(.*)"$/, '$1');
7
+ return boundary || null;
8
+ };
9
+ const MAX_FRAME_SIZE = 16 * 1024 * 1024;
10
+ const JPEG_END = Buffer.from([0xff, 0xd9]);
11
+ export const createMjpegFrameReader = (boundary, onFrame) => {
12
+ const delimiter = Buffer.from(boundary, 'latin1');
13
+ const pieces = [];
14
+ let buffer = Buffer.alloc(0);
15
+ let piecesSize = 0;
16
+ let state = 'boundary';
17
+ let contentLength = null;
18
+ const takeLine = () => {
19
+ const end = buffer.indexOf(0x0a);
20
+ if (end < 0) {
21
+ return null;
22
+ }
23
+ const line = buffer.toString('latin1', 0, end).replace(/\r$/, '');
24
+ buffer = buffer.subarray(end + 1);
25
+ return line;
26
+ };
27
+ const emit = (frame) => {
28
+ if (isJpeg(frame)) {
29
+ onFrame(frame);
30
+ }
31
+ };
32
+ const step = () => {
33
+ switch (state) {
34
+ case 'boundary': {
35
+ const at = buffer.indexOf(delimiter);
36
+ if (at < 0) {
37
+ buffer = buffer.subarray(Math.max(0, buffer.length - delimiter.length));
38
+ return false;
39
+ }
40
+ const lineEnd = buffer.indexOf(0x0a, at + delimiter.length);
41
+ if (lineEnd < 0) {
42
+ buffer = buffer.subarray(at);
43
+ return false;
44
+ }
45
+ buffer = buffer.subarray(lineEnd + 1);
46
+ state = 'headers';
47
+ contentLength = null;
48
+ return true;
49
+ }
50
+ case 'headers': {
51
+ const line = takeLine();
52
+ if (line === null) {
53
+ return false;
54
+ }
55
+ if (line) {
56
+ const length = /^content-length\s*:\s*(\d+)\s*$/i.exec(line);
57
+ if (length) {
58
+ contentLength = Number(length[1]);
59
+ if (contentLength > MAX_FRAME_SIZE) {
60
+ throw new Error(`it sent a part of ${contentLength} bytes`);
61
+ }
62
+ }
63
+ return true;
64
+ }
65
+ state = 'body';
66
+ return true;
67
+ }
68
+ case 'body': {
69
+ if (contentLength !== null) {
70
+ if (buffer.length < contentLength) {
71
+ return false;
72
+ }
73
+ emit(buffer.subarray(0, contentLength));
74
+ buffer = buffer.subarray(contentLength);
75
+ state = 'boundary';
76
+ return true;
77
+ }
78
+ const next = buffer.indexOf(delimiter);
79
+ if (next < 0) {
80
+ return false;
81
+ }
82
+ const body = buffer.subarray(0, next);
83
+ const end = body.lastIndexOf(JPEG_END);
84
+ emit(end < 0 ? body : body.subarray(0, end + 2));
85
+ buffer = buffer.subarray(next);
86
+ state = 'boundary';
87
+ return true;
88
+ }
89
+ }
90
+ };
91
+ return (chunk) => {
92
+ pieces.push(chunk);
93
+ piecesSize += chunk.length;
94
+ const isBodyComing = state === 'body' && contentLength !== null && buffer.length + piecesSize < contentLength;
95
+ if (isBodyComing) {
96
+ return;
97
+ }
98
+ buffer =
99
+ buffer.length || pieces.length > 1
100
+ ? Buffer.concat([buffer, ...pieces], buffer.length + piecesSize)
101
+ : chunk;
102
+ pieces.length = 0;
103
+ piecesSize = 0;
104
+ while (step()) {
105
+ }
106
+ if (buffer.length > MAX_FRAME_SIZE) {
107
+ throw new Error(`it sent ${MAX_FRAME_SIZE / 1024 / 1024} MB with no frame in it`);
108
+ }
109
+ };
110
+ };
111
+ const FRAME_TIMEOUT_MS = 10_000;
112
+ export const describeWebcamUrl = (url) => {
113
+ try {
114
+ const parsed = new URL(url);
115
+ parsed.username = '';
116
+ parsed.password = '';
117
+ return parsed.toString();
118
+ }
119
+ catch {
120
+ return url;
121
+ }
122
+ };
123
+ export class CameraWebcamConnection {
124
+ request;
125
+ frameTimeoutId;
126
+ isClosed = false;
127
+ constructor({ url, onFrame, onFailure, onClose }) {
128
+ const target = new URL(url);
129
+ const fail = (error) => {
130
+ if (!this.isClosed) {
131
+ onFailure(error);
132
+ this.close();
133
+ }
134
+ };
135
+ const awaitFrame = () => {
136
+ clearTimeout(this.frameTimeoutId);
137
+ this.frameTimeoutId = setTimeout(() => fail(new Error(`it sent no frame in ${FRAME_TIMEOUT_MS / 1000}s`)), FRAME_TIMEOUT_MS);
138
+ };
139
+ const read = (response) => {
140
+ const boundary = readMjpegBoundary(response.headers['content-type']);
141
+ response.on('error', fail);
142
+ if (response.statusCode !== 200) {
143
+ fail(new Error(`it answered ${response.statusCode}`));
144
+ return;
145
+ }
146
+ if (!boundary) {
147
+ fail(new Error(`it answered with ${response.headers['content-type'] ?? 'no content type'}, not an MJPEG stream`));
148
+ return;
149
+ }
150
+ const readFrames = createMjpegFrameReader(boundary, frame => {
151
+ awaitFrame();
152
+ onFrame(frame);
153
+ });
154
+ response.on('data', (chunk) => {
155
+ try {
156
+ readFrames(chunk);
157
+ }
158
+ catch (error) {
159
+ fail(error);
160
+ }
161
+ });
162
+ response.once('end', () => fail(new Error('it ended the stream')));
163
+ };
164
+ this.request = (target.protocol === 'https:' ? requestHttps : requestHttp)(target, {
165
+ headers: {
166
+ Accept: 'multipart/x-mixed-replace, */*',
167
+ },
168
+ });
169
+ this.request.once('response', read);
170
+ this.request.on('error', fail);
171
+ this.request.once('close', () => {
172
+ this.close();
173
+ onClose();
174
+ });
175
+ this.request.end();
176
+ awaitFrame();
177
+ }
178
+ close() {
179
+ if (this.isClosed) {
180
+ return;
181
+ }
182
+ this.isClosed = true;
183
+ clearTimeout(this.frameTimeoutId);
184
+ this.request.destroy();
185
+ }
186
+ }
@@ -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
+ });
@@ -0,0 +1,7 @@
1
+ export class ApiRequestError extends Error {
2
+ status;
3
+ constructor(message, status) {
4
+ super(message);
5
+ this.status = status;
6
+ }
7
+ }
@@ -0,0 +1,108 @@
1
+ import mqtt from 'mqtt';
2
+ import { readFileSync } from 'node:fs';
3
+ import { mergeBambuStatus } from '../utils.js';
4
+ const BAMBU_CA = readFileSync(new URL('../../certs/bambu-ca.pem', import.meta.url));
5
+ export class BambuMqttClient {
6
+ options;
7
+ client;
8
+ reportTopic;
9
+ requestTopic;
10
+ sequenceId = 0;
11
+ status = null;
12
+ constructor(options) {
13
+ this.options = options;
14
+ const { ip, serialNumber, accessCode, reconnectPeriod = 60_000, connectTimeout = 30_000, keepalive = 30, } = options;
15
+ this.reportTopic = `device/${serialNumber}/report`;
16
+ this.requestTopic = `device/${serialNumber}/request`;
17
+ const connectOptions = {
18
+ username: 'bblp',
19
+ password: accessCode,
20
+ ca: BAMBU_CA,
21
+ servername: serialNumber,
22
+ maxVersion: 'TLSv1.2',
23
+ keepalive,
24
+ reconnectPeriod,
25
+ connectTimeout,
26
+ };
27
+ this.client = mqtt.connect(`mqtts://${ip}:8883`, connectOptions);
28
+ this.setupListeners();
29
+ }
30
+ stop(force = true) {
31
+ return new Promise((resolve, reject) => {
32
+ this.client.end(force, {}, error => {
33
+ if (error) {
34
+ reject(error);
35
+ return;
36
+ }
37
+ resolve();
38
+ });
39
+ });
40
+ }
41
+ setupListeners() {
42
+ this.client.on('connect', () => {
43
+ this.subscribeToReports();
44
+ });
45
+ this.client.on('message', (topic, payload) => {
46
+ if (topic !== this.reportTopic) {
47
+ return;
48
+ }
49
+ this.handleMessage(payload);
50
+ });
51
+ this.client.on('reconnect', () => {
52
+ this.options.onReconnect?.();
53
+ });
54
+ this.client.on('close', () => {
55
+ this.options.onClose?.();
56
+ });
57
+ this.client.on('error', error => {
58
+ this.handleError(error);
59
+ });
60
+ }
61
+ subscribeToReports() {
62
+ this.client.subscribe(this.reportTopic, error => {
63
+ if (error) {
64
+ this.handleError(new Error(`Failed to subscribe to MQTT topic "${this.reportTopic}"`, {
65
+ cause: error,
66
+ }));
67
+ return;
68
+ }
69
+ this.requestFullStatus();
70
+ this.options.onConnect?.();
71
+ });
72
+ }
73
+ requestFullStatus() {
74
+ const request = {
75
+ pushing: {
76
+ sequence_id: String(this.sequenceId++),
77
+ command: 'pushall',
78
+ version: 1,
79
+ push_target: 1,
80
+ },
81
+ };
82
+ this.client.publish(this.requestTopic, JSON.stringify(request), error => {
83
+ if (error) {
84
+ this.handleError(new Error(`Failed to publish to MQTT topic "${this.requestTopic}"`, {
85
+ cause: error,
86
+ }));
87
+ }
88
+ });
89
+ }
90
+ handleMessage(payload) {
91
+ try {
92
+ const data = JSON.parse(payload.toString('utf8'));
93
+ if (data.print?.command !== 'push_status') {
94
+ return;
95
+ }
96
+ this.status = mergeBambuStatus(this.status, data.print);
97
+ this.options.onStatus?.(this.status);
98
+ }
99
+ catch (error) {
100
+ this.handleError(new Error('Failed to parse Bambu MQTT message', {
101
+ cause: error,
102
+ }));
103
+ }
104
+ }
105
+ handleError(error) {
106
+ this.options.onError?.(error);
107
+ }
108
+ }