@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.
- package/LICENSE +74 -0
- package/README.md +530 -2
- package/certs/bambu-ca.pem +218 -0
- package/dist/bootstrap.js +252 -0
- package/dist/camera-bambu.js +92 -0
- package/dist/camera-binaries.js +185 -0
- package/dist/camera-frame-bridge.js +102 -0
- package/dist/camera-gateway.js +201 -0
- package/dist/camera-jpeg-bridge.js +19 -0
- package/dist/camera-jpeg-connection.js +121 -0
- package/dist/camera-relay.js +542 -0
- package/dist/camera-stream-bridge.js +58 -0
- package/dist/camera-stream-connection.js +84 -0
- package/dist/camera-tunnel.js +80 -0
- package/dist/camera-webcam-connection.js +186 -0
- package/dist/camera-webcam.js +156 -0
- package/dist/clients/ApiRequestError.js +7 -0
- package/dist/clients/BambuMqttClient.js +108 -0
- package/dist/clients/MoonrakerClient.js +289 -0
- package/dist/clients/Vextrix3dApiClient.js +121 -0
- package/dist/moonraker-camera-reporter.js +21 -0
- package/dist/print-attempt-synchronizer.js +76 -0
- package/dist/printer-gone.js +26 -0
- package/dist/printer-state-reporter.js +93 -0
- package/dist/types/bambu.types.js +10 -0
- package/dist/types/index.js +2 -0
- package/dist/types/moonraker.types.js +16 -0
- package/dist/utils.js +238 -0
- package/package.json +70 -4
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { chmodSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { createInterface } from 'node:readline';
|
|
6
|
+
import { ensureCameraBinaries, findCameraBinaryPins } from './camera-binaries.js';
|
|
7
|
+
import { CAMERA_GATEWAY_PORT, CAMERA_REGISTRY_DIR, createCameraGateway, createCameraRegistry, } from './camera-gateway.js';
|
|
8
|
+
import { resolveCameraTunnel } from './camera-tunnel.js';
|
|
9
|
+
import { backoffDelayMs, isExecutable, isPortFree, readArmVersion, stopChildProcess, } from './utils.js';
|
|
10
|
+
const quoteFfconcat = (value) => `'${value.replace(/'/g, `'\\''`)}'`;
|
|
11
|
+
export const buildCameraFile = ({ streamUrl, kind, readsOptions }) => [
|
|
12
|
+
'ffconcat version 1.0',
|
|
13
|
+
`file ${quoteFfconcat(streamUrl)}`,
|
|
14
|
+
...(readsOptions
|
|
15
|
+
? [
|
|
16
|
+
...(kind === 'rtsp' ? ['option rtsp_transport tcp'] : []),
|
|
17
|
+
'option analyzeduration 1000000',
|
|
18
|
+
'option probesize 1000000',
|
|
19
|
+
'option fflags nobuffer',
|
|
20
|
+
]
|
|
21
|
+
: []),
|
|
22
|
+
'',
|
|
23
|
+
].join('\n');
|
|
24
|
+
const RTSP_PORT_OFFSET = 6570;
|
|
25
|
+
export const GO2RTC_API_PORT_RANGE = [CAMERA_GATEWAY_PORT + 1, CAMERA_GATEWAY_PORT + 99];
|
|
26
|
+
export const findFreeCameraRelayPorts = async (isPortFree) => {
|
|
27
|
+
const [firstApiPort, lastApiPort] = GO2RTC_API_PORT_RANGE;
|
|
28
|
+
for (let apiPort = firstApiPort; apiPort <= lastApiPort; apiPort++) {
|
|
29
|
+
const rtspPort = apiPort + RTSP_PORT_OFFSET;
|
|
30
|
+
if ((await isPortFree(apiPort)) && (await isPortFree(rtspPort))) {
|
|
31
|
+
return {
|
|
32
|
+
apiPort,
|
|
33
|
+
rtspPort,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
};
|
|
39
|
+
const buildH264Encoding = ({ ffmpegFpsModeFlag, fps, filters, maxrateKbps, }) => [
|
|
40
|
+
...(filters ? [`-vf ${filters}`] : []),
|
|
41
|
+
'-c:v libx264 -preset ultrafast -tune zerolatency -pix_fmt yuv420p',
|
|
42
|
+
...(maxrateKbps ? [`-maxrate ${maxrateKbps}k -bufsize ${maxrateKbps}k`] : []),
|
|
43
|
+
'-force_key_frames expr:gte(t,n_forced*2)',
|
|
44
|
+
`${ffmpegFpsModeFlag} cfr -r ${fps}`,
|
|
45
|
+
].join(' ');
|
|
46
|
+
export const WEBCAM_MAX_FPS = 10;
|
|
47
|
+
const WEBCAM_MAX_HEIGHT = 720;
|
|
48
|
+
export const buildWebcamFilters = (orientation) => [
|
|
49
|
+
`scale=-2:'min(ih,${WEBCAM_MAX_HEIGHT})'`,
|
|
50
|
+
...(orientation?.rotation === 90 ? ['transpose=clock'] : []),
|
|
51
|
+
...(orientation?.rotation === 180 ? ['hflip,vflip'] : []),
|
|
52
|
+
...(orientation?.rotation === 270 ? ['transpose=cclock'] : []),
|
|
53
|
+
...(orientation?.flipHorizontal ? ['hflip'] : []),
|
|
54
|
+
...(orientation?.flipVertical ? ['vflip'] : []),
|
|
55
|
+
].join(',');
|
|
56
|
+
const PROTOCOL_WHITELISTS = {
|
|
57
|
+
rtsp: 'file,rtsp,tcp,udp,rtp',
|
|
58
|
+
jpeg: 'file,http,tcp',
|
|
59
|
+
mjpeg: 'file,http,tcp',
|
|
60
|
+
h264: 'file,http,https,tcp,tls',
|
|
61
|
+
};
|
|
62
|
+
export const buildFfmpegCommand = ({ cameraFile, kind, orientation, ffmpegFpsModeFlag, }) => {
|
|
63
|
+
const encodings = {
|
|
64
|
+
rtsp: '-c copy',
|
|
65
|
+
jpeg: buildH264Encoding({
|
|
66
|
+
ffmpegFpsModeFlag,
|
|
67
|
+
fps: 2,
|
|
68
|
+
}),
|
|
69
|
+
mjpeg: buildH264Encoding({
|
|
70
|
+
ffmpegFpsModeFlag,
|
|
71
|
+
fps: WEBCAM_MAX_FPS,
|
|
72
|
+
filters: buildWebcamFilters(orientation),
|
|
73
|
+
maxrateKbps: 1500,
|
|
74
|
+
}),
|
|
75
|
+
h264: '-c copy',
|
|
76
|
+
};
|
|
77
|
+
return [
|
|
78
|
+
'exec:ffmpeg -hide_banner -loglevel error',
|
|
79
|
+
'-fflags nobuffer -analyzeduration 1000000 -probesize 1000000',
|
|
80
|
+
'-use_wallclock_as_timestamps 1',
|
|
81
|
+
`-f concat -safe 0 -protocol_whitelist ${PROTOCOL_WHITELISTS[kind]}`,
|
|
82
|
+
`-i ${cameraFile}`,
|
|
83
|
+
`-map 0:v ${encodings[kind]}`,
|
|
84
|
+
'-rtsp_transport tcp -f rtsp {output}',
|
|
85
|
+
].join(' ');
|
|
86
|
+
};
|
|
87
|
+
export const buildGo2rtcConfig = ({ printerId, ports, ffmpegCommand, }) => `${JSON.stringify({
|
|
88
|
+
streams: {
|
|
89
|
+
[printerId]: ffmpegCommand,
|
|
90
|
+
},
|
|
91
|
+
api: {
|
|
92
|
+
listen: `127.0.0.1:${ports.apiPort}`,
|
|
93
|
+
allow_paths: ['/api/ws'],
|
|
94
|
+
},
|
|
95
|
+
rtsp: {
|
|
96
|
+
listen: `127.0.0.1:${ports.rtspPort}`,
|
|
97
|
+
},
|
|
98
|
+
webrtc: {
|
|
99
|
+
listen: '',
|
|
100
|
+
},
|
|
101
|
+
srtp: {
|
|
102
|
+
listen: '',
|
|
103
|
+
},
|
|
104
|
+
}, null, 2)}\n`;
|
|
105
|
+
export const parseFfmpegMajorVersion = (output) => {
|
|
106
|
+
const match = output.match(/^ffmpeg version n?(\d+)\./m);
|
|
107
|
+
return match ? Number(match[1]) : null;
|
|
108
|
+
};
|
|
109
|
+
const defaultDependencies = {
|
|
110
|
+
spawn: (command, args) => spawn(command, args, {
|
|
111
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
112
|
+
}),
|
|
113
|
+
ensurePrivateDir: path => {
|
|
114
|
+
mkdirSync(path, {
|
|
115
|
+
recursive: true,
|
|
116
|
+
});
|
|
117
|
+
chmodSync(path, 0o700);
|
|
118
|
+
},
|
|
119
|
+
writePrivateFile: (path, content) => {
|
|
120
|
+
writeFileSync(path, content, {
|
|
121
|
+
mode: 0o600,
|
|
122
|
+
});
|
|
123
|
+
chmodSync(path, 0o600);
|
|
124
|
+
},
|
|
125
|
+
findFreePorts: () => findFreeCameraRelayPorts(isPortFree),
|
|
126
|
+
};
|
|
127
|
+
export const createCameraRelay = ({ printerId, printerLabel, go2rtcPath, runtimeDir, ffmpegReadsOptions, ffmpegFpsModeFlag, source, relayKey, registry, }, dependencies = defaultDependencies) => {
|
|
128
|
+
const configPath = join(runtimeDir, 'go2rtc.json');
|
|
129
|
+
const cameraFilePath = join(runtimeDir, 'stream.ffconcat');
|
|
130
|
+
const input = source.openInput();
|
|
131
|
+
let currentRelayKey = relayKey;
|
|
132
|
+
let reported;
|
|
133
|
+
let target = null;
|
|
134
|
+
let startedCommand = null;
|
|
135
|
+
let go2rtc = null;
|
|
136
|
+
let registeredPort = null;
|
|
137
|
+
let restartTimeoutId;
|
|
138
|
+
let failures = 0;
|
|
139
|
+
let startedAt = 0;
|
|
140
|
+
let isRestarting = false;
|
|
141
|
+
let isStopping = false;
|
|
142
|
+
const buildCommand = ({ kind, orientation }) => buildFfmpegCommand({
|
|
143
|
+
cameraFile: cameraFilePath,
|
|
144
|
+
kind,
|
|
145
|
+
orientation,
|
|
146
|
+
ffmpegFpsModeFlag,
|
|
147
|
+
});
|
|
148
|
+
const forward = (output, log) => {
|
|
149
|
+
if (!output) {
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
createInterface({
|
|
153
|
+
input: output,
|
|
154
|
+
}).on('line', line => log(`[camera] ${line}`));
|
|
155
|
+
};
|
|
156
|
+
const scheduleStart = (reason) => {
|
|
157
|
+
failures = Date.now() - startedAt >= 60_000 ? 1 : failures + 1;
|
|
158
|
+
const delayMs = backoffDelayMs(failures, 5_000, 60_000);
|
|
159
|
+
console.error(`[camera] ${reason}; starting go2rtc again in ${delayMs / 1000}s`);
|
|
160
|
+
restartTimeoutId = setTimeout(() => void start(), delayMs);
|
|
161
|
+
};
|
|
162
|
+
const start = async () => {
|
|
163
|
+
startedAt = Date.now();
|
|
164
|
+
const ports = await dependencies.findFreePorts();
|
|
165
|
+
if (isStopping || !target) {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (!ports) {
|
|
169
|
+
const [firstApiPort, lastApiPort] = GO2RTC_API_PORT_RANGE;
|
|
170
|
+
scheduleStart(`No pair of free ports for go2rtc between ${firstApiPort} and ${lastApiPort}`);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
startedCommand = buildCommand(target);
|
|
174
|
+
dependencies.writePrivateFile(configPath, buildGo2rtcConfig({
|
|
175
|
+
printerId,
|
|
176
|
+
ports,
|
|
177
|
+
ffmpegCommand: startedCommand,
|
|
178
|
+
}));
|
|
179
|
+
const child = dependencies.spawn(go2rtcPath, ['-config', configPath]);
|
|
180
|
+
go2rtc = child;
|
|
181
|
+
registeredPort = ports.apiPort;
|
|
182
|
+
registry.register(printerId, ports.apiPort, currentRelayKey);
|
|
183
|
+
console.log(`[camera] Relaying the camera of "${printerLabel}" through go2rtc on 127.0.0.1:${ports.apiPort}, its RTSP on ${ports.rtspPort}`);
|
|
184
|
+
forward(child.stdout, line => console.log(line));
|
|
185
|
+
forward(child.stderr, line => console.error(line));
|
|
186
|
+
child.once('error', error => {
|
|
187
|
+
console.error('[camera] go2rtc could not start:', error);
|
|
188
|
+
});
|
|
189
|
+
child.once('exit', (code, signal) => {
|
|
190
|
+
go2rtc = null;
|
|
191
|
+
if (isStopping) {
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (isRestarting) {
|
|
195
|
+
isRestarting = false;
|
|
196
|
+
void start();
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
scheduleStart(`go2rtc stopped (${signal ?? `exit code ${code}`})`);
|
|
200
|
+
});
|
|
201
|
+
};
|
|
202
|
+
const writeCameraFile = async (report) => {
|
|
203
|
+
const nextTarget = await input.resolveTarget(report);
|
|
204
|
+
if (isStopping || !nextTarget || report !== reported) {
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
dependencies.ensurePrivateDir(runtimeDir);
|
|
208
|
+
dependencies.writePrivateFile(cameraFilePath, buildCameraFile({
|
|
209
|
+
streamUrl: nextTarget.streamUrl,
|
|
210
|
+
kind: nextTarget.kind,
|
|
211
|
+
readsOptions: ffmpegReadsOptions,
|
|
212
|
+
}));
|
|
213
|
+
const isFirst = target === null;
|
|
214
|
+
target = nextTarget;
|
|
215
|
+
if (isFirst) {
|
|
216
|
+
void start();
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
if (!go2rtc || isRestarting || buildCommand(nextTarget) === startedCommand) {
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
console.log(`[camera] The camera of "${printerLabel}" is to be read another way; starting go2rtc again for it`);
|
|
223
|
+
isRestarting = true;
|
|
224
|
+
void stopChildProcess(go2rtc);
|
|
225
|
+
};
|
|
226
|
+
return {
|
|
227
|
+
update: report => {
|
|
228
|
+
if (isStopping || !source.hasStream(report) || report === reported) {
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
reported = report;
|
|
232
|
+
void writeCameraFile(report).catch(error => {
|
|
233
|
+
console.error('[camera] Could not write the camera file:', error);
|
|
234
|
+
});
|
|
235
|
+
},
|
|
236
|
+
setRelayKey: nextRelayKey => {
|
|
237
|
+
currentRelayKey = nextRelayKey;
|
|
238
|
+
if (registeredPort !== null && !isStopping) {
|
|
239
|
+
registry.register(printerId, registeredPort, currentRelayKey);
|
|
240
|
+
}
|
|
241
|
+
},
|
|
242
|
+
stop: async () => {
|
|
243
|
+
isStopping = true;
|
|
244
|
+
clearTimeout(restartTimeoutId);
|
|
245
|
+
if (target) {
|
|
246
|
+
registry.unregister(printerId);
|
|
247
|
+
}
|
|
248
|
+
await input.close();
|
|
249
|
+
if (go2rtc) {
|
|
250
|
+
await stopChildProcess(go2rtc);
|
|
251
|
+
}
|
|
252
|
+
},
|
|
253
|
+
};
|
|
254
|
+
};
|
|
255
|
+
export const fingerprintRelayKey = (relayKey) => createHash('sha256').update(relayKey).digest('hex').slice(0, 16);
|
|
256
|
+
const createDefaultResolveDependencies = () => ({
|
|
257
|
+
readFfmpegVersion: () => {
|
|
258
|
+
try {
|
|
259
|
+
return execFileSync('ffmpeg', ['-version'], {
|
|
260
|
+
encoding: 'utf8',
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
},
|
|
267
|
+
isExecutable,
|
|
268
|
+
findBinaryPins: () => findCameraBinaryPins(process.platform, process.arch, readArmVersion()),
|
|
269
|
+
ensureBinaries: (cameraDir, pins) => ensureCameraBinaries(cameraDir, pins),
|
|
270
|
+
createRegistry: () => createCameraRegistry(CAMERA_REGISTRY_DIR),
|
|
271
|
+
createGateway: registry => createCameraGateway({
|
|
272
|
+
port: CAMERA_GATEWAY_PORT,
|
|
273
|
+
registry,
|
|
274
|
+
reusePort: process.platform === 'linux',
|
|
275
|
+
}),
|
|
276
|
+
createRelay: options => createCameraRelay(options),
|
|
277
|
+
resolveTunnel: options => resolveCameraTunnel(options),
|
|
278
|
+
});
|
|
279
|
+
const CAMERA_ACCESS_REFRESH_MS = 24 * 60 * 60_000;
|
|
280
|
+
export const resolveCameraRelay = ({ printerId, printerLabel, source, workDir, fetchesBinaries, getCameraAccess, printerGone, }, dependencies = createDefaultResolveDependencies()) => {
|
|
281
|
+
const ffmpegVersion = dependencies.readFfmpegVersion();
|
|
282
|
+
if (ffmpegVersion === null) {
|
|
283
|
+
console.log('[camera] Relay off: ffmpeg is not installed');
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
if (source.encodedCamera && !/--enable-libx264\b/.test(ffmpegVersion)) {
|
|
287
|
+
console.log(`[camera] Relay off: ffmpeg has no libx264, which ${source.encodedCamera} is encoded to H.264 with`);
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
const ffmpegMajorVersion = parseFfmpegMajorVersion(ffmpegVersion);
|
|
291
|
+
const cameraDir = join(workDir, 'camera');
|
|
292
|
+
const go2rtcPath = join(cameraDir, 'go2rtc');
|
|
293
|
+
const cloudflaredPath = join(cameraDir, 'cloudflared');
|
|
294
|
+
const pins = fetchesBinaries ? dependencies.findBinaryPins() : null;
|
|
295
|
+
if (!fetchesBinaries) {
|
|
296
|
+
console.log('[camera] VEXTRIX3D_CAMERA_BINARIES=manual: running camera/go2rtc and camera/cloudflared as they are');
|
|
297
|
+
}
|
|
298
|
+
else if (!pins) {
|
|
299
|
+
console.log(`[camera] No go2rtc or cloudflared build is pinned for ${process.platform} ${process.arch}: running camera/go2rtc and camera/cloudflared as they are`);
|
|
300
|
+
}
|
|
301
|
+
const relayOptions = {
|
|
302
|
+
printerId,
|
|
303
|
+
printerLabel,
|
|
304
|
+
source,
|
|
305
|
+
go2rtcPath,
|
|
306
|
+
runtimeDir: join(cameraDir, 'runtime'),
|
|
307
|
+
ffmpegReadsOptions: ffmpegMajorVersion === null || ffmpegMajorVersion >= 5,
|
|
308
|
+
ffmpegFpsModeFlag: ffmpegMajorVersion === null || ffmpegMajorVersion >= 6 ? '-fps_mode' : '-vsync',
|
|
309
|
+
};
|
|
310
|
+
let camera = null;
|
|
311
|
+
let lastAccess = null;
|
|
312
|
+
let report;
|
|
313
|
+
let isWaitingForStream = true;
|
|
314
|
+
let hasReportedNoStream = false;
|
|
315
|
+
let timeoutId;
|
|
316
|
+
let accessFailures = 0;
|
|
317
|
+
let binariesFailures = 0;
|
|
318
|
+
let binariesDueAt = 0;
|
|
319
|
+
let isTaskRunning = false;
|
|
320
|
+
let askedRelayKeyFingerprint = null;
|
|
321
|
+
let isStopping = false;
|
|
322
|
+
const run = (task) => {
|
|
323
|
+
isTaskRunning = true;
|
|
324
|
+
void task()
|
|
325
|
+
.catch(error => {
|
|
326
|
+
console.error('[camera] Could not set the camera up:', error);
|
|
327
|
+
schedule(refresh, CAMERA_ACCESS_REFRESH_MS);
|
|
328
|
+
})
|
|
329
|
+
.finally(() => {
|
|
330
|
+
isTaskRunning = false;
|
|
331
|
+
});
|
|
332
|
+
};
|
|
333
|
+
const schedule = (task, delayMs) => {
|
|
334
|
+
if (!isStopping) {
|
|
335
|
+
timeoutId = setTimeout(() => run(task), delayMs);
|
|
336
|
+
}
|
|
337
|
+
};
|
|
338
|
+
const fetchBinaries = async () => {
|
|
339
|
+
if (!pins) {
|
|
340
|
+
return true;
|
|
341
|
+
}
|
|
342
|
+
try {
|
|
343
|
+
return await dependencies.ensureBinaries(cameraDir, pins);
|
|
344
|
+
}
|
|
345
|
+
catch (error) {
|
|
346
|
+
console.error('[camera] Could not fetch go2rtc and cloudflared:', error);
|
|
347
|
+
return false;
|
|
348
|
+
}
|
|
349
|
+
};
|
|
350
|
+
const startTunnel = (tunnelToken) => dependencies.resolveTunnel({
|
|
351
|
+
workDir,
|
|
352
|
+
tunnelToken,
|
|
353
|
+
printerLabel,
|
|
354
|
+
});
|
|
355
|
+
const startCamera = (relayKey, tunnelToken) => {
|
|
356
|
+
const registry = dependencies.createRegistry();
|
|
357
|
+
const gateway = dependencies.createGateway(registry);
|
|
358
|
+
const relay = dependencies.createRelay({
|
|
359
|
+
...relayOptions,
|
|
360
|
+
relayKey,
|
|
361
|
+
registry,
|
|
362
|
+
});
|
|
363
|
+
void gateway.start();
|
|
364
|
+
camera = {
|
|
365
|
+
gateway,
|
|
366
|
+
relay,
|
|
367
|
+
tunnel: startTunnel(tunnelToken),
|
|
368
|
+
};
|
|
369
|
+
if (report !== undefined) {
|
|
370
|
+
relay.update(report);
|
|
371
|
+
}
|
|
372
|
+
};
|
|
373
|
+
const stopCamera = async () => {
|
|
374
|
+
const running = camera;
|
|
375
|
+
camera = null;
|
|
376
|
+
if (running) {
|
|
377
|
+
await Promise.all([running.gateway.stop(), running.relay.stop(), running.tunnel?.stop()]);
|
|
378
|
+
}
|
|
379
|
+
};
|
|
380
|
+
const stop = async () => {
|
|
381
|
+
isStopping = true;
|
|
382
|
+
clearTimeout(timeoutId);
|
|
383
|
+
await stopCamera();
|
|
384
|
+
};
|
|
385
|
+
const apply = async (access) => {
|
|
386
|
+
const previous = lastAccess;
|
|
387
|
+
const { relayKey, tunnelToken } = access;
|
|
388
|
+
lastAccess = access;
|
|
389
|
+
if (!relayKey) {
|
|
390
|
+
if (camera) {
|
|
391
|
+
console.log('[camera] Relay off: vextrix3d gives this printer no camera key any more');
|
|
392
|
+
await stopCamera();
|
|
393
|
+
}
|
|
394
|
+
else if (!previous || previous.relayKey) {
|
|
395
|
+
console.log('[camera] Relay off: vextrix3d gives this printer no camera key');
|
|
396
|
+
}
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
if (!camera) {
|
|
400
|
+
startCamera(relayKey, tunnelToken);
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
const running = camera;
|
|
404
|
+
if (relayKey !== previous?.relayKey) {
|
|
405
|
+
running.relay.setRelayKey(relayKey);
|
|
406
|
+
console.log('[camera] vextrix3d gave this printer a new relay key, which its streams are checked against from now on');
|
|
407
|
+
}
|
|
408
|
+
const isTunnelStale = tunnelToken !== previous?.tunnelToken || (tunnelToken !== null && running.tunnel === null);
|
|
409
|
+
if (!isTunnelStale) {
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
const previousTunnel = running.tunnel;
|
|
413
|
+
running.tunnel = null;
|
|
414
|
+
if (previousTunnel) {
|
|
415
|
+
console.log(tunnelToken
|
|
416
|
+
? '[tunnel] vextrix3d gave this printer a new tunnel token; starting cloudflared again on it'
|
|
417
|
+
: '[tunnel] vextrix3d gives this printer no camera tunnel any more; stopping cloudflared');
|
|
418
|
+
await previousTunnel.stop();
|
|
419
|
+
}
|
|
420
|
+
if (isStopping || camera !== running || !tunnelToken) {
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
running.tunnel = startTunnel(tunnelToken);
|
|
424
|
+
};
|
|
425
|
+
const askForCamera = async (onFailure) => {
|
|
426
|
+
let access;
|
|
427
|
+
try {
|
|
428
|
+
access = await getCameraAccess();
|
|
429
|
+
}
|
|
430
|
+
catch (error) {
|
|
431
|
+
if (printerGone.notice(error)) {
|
|
432
|
+
await stop();
|
|
433
|
+
}
|
|
434
|
+
else {
|
|
435
|
+
onFailure(error);
|
|
436
|
+
}
|
|
437
|
+
return false;
|
|
438
|
+
}
|
|
439
|
+
if (isStopping) {
|
|
440
|
+
return false;
|
|
441
|
+
}
|
|
442
|
+
await apply(access);
|
|
443
|
+
schedule(refresh, CAMERA_ACCESS_REFRESH_MS);
|
|
444
|
+
return true;
|
|
445
|
+
};
|
|
446
|
+
const start = async () => {
|
|
447
|
+
if (Date.now() >= binariesDueAt) {
|
|
448
|
+
const isReady = await fetchBinaries();
|
|
449
|
+
binariesFailures = isReady ? 0 : binariesFailures + 1;
|
|
450
|
+
binariesDueAt = isReady
|
|
451
|
+
? Infinity
|
|
452
|
+
: Date.now() + backoffDelayMs(binariesFailures, 5_000, 3_600_000);
|
|
453
|
+
}
|
|
454
|
+
if (isStopping) {
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
const hasGo2rtc = dependencies.isExecutable(go2rtcPath);
|
|
458
|
+
if (!pins && !hasGo2rtc) {
|
|
459
|
+
console.log('[camera] Relay off: there is no camera/go2rtc to run');
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
if (pins && !(hasGo2rtc && dependencies.isExecutable(cloudflaredPath))) {
|
|
463
|
+
if (binariesDueAt === Infinity) {
|
|
464
|
+
binariesDueAt = 0;
|
|
465
|
+
}
|
|
466
|
+
schedule(start, Math.max(5_000, binariesDueAt - Date.now()));
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
const isAnswered = await askForCamera(error => {
|
|
470
|
+
accessFailures++;
|
|
471
|
+
const delayMs = backoffDelayMs(accessFailures, 5_000, 60_000);
|
|
472
|
+
if (accessFailures === 1) {
|
|
473
|
+
console.error(`[camera] vextrix3d did not give this printer its camera; asking again in ${delayMs / 1000}s, and on until it does:`, error);
|
|
474
|
+
}
|
|
475
|
+
else if (accessFailures % 10 === 0) {
|
|
476
|
+
console.error(`[camera] vextrix3d has not given this printer its camera in ${accessFailures} tries; asking again every ${delayMs / 1000}s`);
|
|
477
|
+
}
|
|
478
|
+
schedule(start, delayMs);
|
|
479
|
+
});
|
|
480
|
+
if (isAnswered && accessFailures) {
|
|
481
|
+
console.log(`[camera] vextrix3d gave this printer its camera after ${accessFailures + 1} tries`);
|
|
482
|
+
accessFailures = 0;
|
|
483
|
+
}
|
|
484
|
+
};
|
|
485
|
+
const refresh = async () => {
|
|
486
|
+
await fetchBinaries();
|
|
487
|
+
if (isStopping) {
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
await askForCamera(error => {
|
|
491
|
+
console.error('[camera] Could not ask vextrix3d for the camera again, which runs on as it is; asking again in a day:', error);
|
|
492
|
+
schedule(refresh, CAMERA_ACCESS_REFRESH_MS);
|
|
493
|
+
});
|
|
494
|
+
};
|
|
495
|
+
const noteRelayKeyFingerprint = (reportedFingerprint) => {
|
|
496
|
+
const heldFingerprint = lastAccess?.relayKey ? fingerprintRelayKey(lastAccess.relayKey) : null;
|
|
497
|
+
const isAskDue = typeof reportedFingerprint === 'string' &&
|
|
498
|
+
lastAccess !== null &&
|
|
499
|
+
reportedFingerprint !== heldFingerprint &&
|
|
500
|
+
reportedFingerprint !== askedRelayKeyFingerprint;
|
|
501
|
+
if (!isAskDue || isTaskRunning || isStopping) {
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
askedRelayKeyFingerprint = reportedFingerprint;
|
|
505
|
+
console.log("[camera] vextrix3d names another key for this printer's camera - asking for its new tunnel and key now, ahead of the daily ask");
|
|
506
|
+
clearTimeout(timeoutId);
|
|
507
|
+
run(async () => {
|
|
508
|
+
await askForCamera(error => {
|
|
509
|
+
console.error('[camera] Could not ask vextrix3d for the camera again, which runs on as it is; asking again with the next state report:', error);
|
|
510
|
+
askedRelayKeyFingerprint = null;
|
|
511
|
+
schedule(refresh, CAMERA_ACCESS_REFRESH_MS);
|
|
512
|
+
});
|
|
513
|
+
});
|
|
514
|
+
};
|
|
515
|
+
return {
|
|
516
|
+
noteRelayKeyFingerprint,
|
|
517
|
+
update: nextReport => {
|
|
518
|
+
report = nextReport;
|
|
519
|
+
if (isStopping) {
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
if (!isWaitingForStream) {
|
|
523
|
+
camera?.relay.update(nextReport);
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
if (source.hasStream(nextReport)) {
|
|
527
|
+
isWaitingForStream = false;
|
|
528
|
+
const description = source.describeStream(nextReport);
|
|
529
|
+
if (description) {
|
|
530
|
+
console.log(`[camera] ${description}`);
|
|
531
|
+
}
|
|
532
|
+
run(start);
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
if (nextReport !== undefined && !hasReportedNoStream) {
|
|
536
|
+
hasReportedNoStream = true;
|
|
537
|
+
console.log(`[camera] ${source.describeNoStream(nextReport)}`);
|
|
538
|
+
}
|
|
539
|
+
},
|
|
540
|
+
stop,
|
|
541
|
+
};
|
|
542
|
+
};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { createServer } from 'node:net';
|
|
3
|
+
import { CameraStreamConnection } from './camera-stream-connection.js';
|
|
4
|
+
export const createCameraStreamBridge = ({ serialNumber, caFile, printerLabel, }) => {
|
|
5
|
+
const ca = readFileSync(caFile);
|
|
6
|
+
const connections = new Set();
|
|
7
|
+
let target = null;
|
|
8
|
+
let reportedFailure = null;
|
|
9
|
+
const bridge = (ffmpeg) => {
|
|
10
|
+
if (!target) {
|
|
11
|
+
ffmpeg.destroy();
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
const { host, port } = target;
|
|
15
|
+
const connection = new CameraStreamConnection(ffmpeg, {
|
|
16
|
+
host,
|
|
17
|
+
port,
|
|
18
|
+
serialNumber,
|
|
19
|
+
ca,
|
|
20
|
+
onOpen: () => {
|
|
21
|
+
reportedFailure = null;
|
|
22
|
+
},
|
|
23
|
+
onFailure: error => {
|
|
24
|
+
if (error.message === reportedFailure) {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
reportedFailure = error.message;
|
|
28
|
+
console.error(`[camera] The camera of "${printerLabel}" at ${host}:${port} was not opened: ${error.message}`);
|
|
29
|
+
},
|
|
30
|
+
onClose: () => connections.delete(connection),
|
|
31
|
+
});
|
|
32
|
+
connections.add(connection);
|
|
33
|
+
};
|
|
34
|
+
const server = createServer(bridge);
|
|
35
|
+
const port = new Promise((resolve, reject) => {
|
|
36
|
+
server.once('error', reject);
|
|
37
|
+
server.listen({
|
|
38
|
+
host: '127.0.0.1',
|
|
39
|
+
port: 0,
|
|
40
|
+
}, () => {
|
|
41
|
+
const address = server.address();
|
|
42
|
+
resolve(typeof address === 'object' && address ? address.port : 0);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
return {
|
|
46
|
+
port,
|
|
47
|
+
setTarget: (host, targetPort) => {
|
|
48
|
+
target = {
|
|
49
|
+
host,
|
|
50
|
+
port: targetPort,
|
|
51
|
+
};
|
|
52
|
+
},
|
|
53
|
+
close: () => new Promise(resolve => {
|
|
54
|
+
connections.forEach(connection => connection.close());
|
|
55
|
+
server.close(() => resolve());
|
|
56
|
+
}),
|
|
57
|
+
};
|
|
58
|
+
};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { connect } from 'node:tls';
|
|
2
|
+
export const checkPrinterCertificate = (serialNumber) => (_hostname, certificate) => {
|
|
3
|
+
const commonName = certificate.subject?.CN;
|
|
4
|
+
return commonName === serialNumber
|
|
5
|
+
? undefined
|
|
6
|
+
: new Error(`its certificate is issued to ${commonName || 'nobody'}, not ${serialNumber}`);
|
|
7
|
+
};
|
|
8
|
+
const toRtspsRequestLine = (head) => head.replace(/^([A-Z_]+) rtsp:\/\//, '$1 rtsps://');
|
|
9
|
+
export const createRtspRequestRewriter = (write) => {
|
|
10
|
+
let pending = Buffer.alloc(0);
|
|
11
|
+
return (chunk) => {
|
|
12
|
+
pending = pending.length ? Buffer.concat([pending, chunk]) : chunk;
|
|
13
|
+
while (pending.length) {
|
|
14
|
+
if (pending[0] === 0x24) {
|
|
15
|
+
if (pending.length < 4) {
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const frameSize = 4 + pending.readUInt16BE(2);
|
|
19
|
+
if (pending.length < frameSize) {
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
write(pending.subarray(0, frameSize));
|
|
23
|
+
pending = pending.subarray(frameSize);
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
const headEnd = pending.indexOf('\r\n\r\n');
|
|
27
|
+
if (headEnd < 0) {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const head = pending.toString('latin1', 0, headEnd + 4);
|
|
31
|
+
const bodySize = Number(/^content-length:\s*(\d+)/im.exec(head)?.[1] ?? 0);
|
|
32
|
+
const requestSize = headEnd + 4 + bodySize;
|
|
33
|
+
if (pending.length < requestSize) {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
write(Buffer.concat([
|
|
37
|
+
Buffer.from(toRtspsRequestLine(head), 'latin1'),
|
|
38
|
+
pending.subarray(headEnd + 4, requestSize),
|
|
39
|
+
]));
|
|
40
|
+
pending = pending.subarray(requestSize);
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
export class CameraStreamConnection {
|
|
45
|
+
ffmpeg;
|
|
46
|
+
printer;
|
|
47
|
+
isClosed = false;
|
|
48
|
+
constructor(ffmpeg, { host, port, serialNumber, ca, onOpen, onFailure, onClose }) {
|
|
49
|
+
this.ffmpeg = ffmpeg;
|
|
50
|
+
this.printer = connect({
|
|
51
|
+
host,
|
|
52
|
+
port,
|
|
53
|
+
servername: serialNumber,
|
|
54
|
+
ca,
|
|
55
|
+
checkServerIdentity: checkPrinterCertificate(serialNumber),
|
|
56
|
+
});
|
|
57
|
+
this.printer.once('secureConnect', onOpen);
|
|
58
|
+
this.printer.on('error', error => {
|
|
59
|
+
onFailure(error);
|
|
60
|
+
this.close();
|
|
61
|
+
});
|
|
62
|
+
this.ffmpeg.on('error', () => this.close());
|
|
63
|
+
let openSides = 2;
|
|
64
|
+
const closeSide = () => {
|
|
65
|
+
this.close();
|
|
66
|
+
openSides -= 1;
|
|
67
|
+
if (!openSides) {
|
|
68
|
+
onClose();
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
this.ffmpeg.once('close', closeSide);
|
|
72
|
+
this.printer.once('close', closeSide);
|
|
73
|
+
this.ffmpeg.on('data', createRtspRequestRewriter(chunk => this.printer.write(chunk)));
|
|
74
|
+
this.printer.pipe(this.ffmpeg);
|
|
75
|
+
}
|
|
76
|
+
close() {
|
|
77
|
+
if (this.isClosed) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
this.isClosed = true;
|
|
81
|
+
this.ffmpeg.destroy();
|
|
82
|
+
this.printer.destroy();
|
|
83
|
+
}
|
|
84
|
+
}
|