@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,185 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { chmodSync, createWriteStream, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { Readable, Transform } from 'node:stream';
|
|
6
|
+
import { pipeline } from 'node:stream/promises';
|
|
7
|
+
import { promisify } from 'node:util';
|
|
8
|
+
import { describeError, sha256File } from './utils.js';
|
|
9
|
+
const GO2RTC_VERSION = 'v1.9.14';
|
|
10
|
+
const CLOUDFLARED_VERSION = '2026.10.7';
|
|
11
|
+
const pinGo2rtc = (file, sha256) => ({
|
|
12
|
+
name: 'go2rtc',
|
|
13
|
+
version: GO2RTC_VERSION,
|
|
14
|
+
file,
|
|
15
|
+
url: `https://github.com/AlexxIT/go2rtc/releases/download/${GO2RTC_VERSION}/${file}`,
|
|
16
|
+
sha256,
|
|
17
|
+
packing: file.endsWith('.zip') ? 'zip' : 'raw',
|
|
18
|
+
});
|
|
19
|
+
const pinCloudflared = (file, sha256) => ({
|
|
20
|
+
name: 'cloudflared',
|
|
21
|
+
version: CLOUDFLARED_VERSION,
|
|
22
|
+
file,
|
|
23
|
+
url: `https://github.com/cloudflare/cloudflared/releases/download/${CLOUDFLARED_VERSION}/${file}`,
|
|
24
|
+
sha256,
|
|
25
|
+
packing: file.endsWith('.tgz') ? 'tgz' : 'raw',
|
|
26
|
+
});
|
|
27
|
+
const LINUX_ARM_CLOUDFLARED_PIN = pinCloudflared('cloudflared-linux-arm', '74f690f6707182839b63e0465726c7776dd63f0a174390b911d5d71f35cab94e');
|
|
28
|
+
const CAMERA_BINARY_PINS = {
|
|
29
|
+
'linux-arm64': [
|
|
30
|
+
pinGo2rtc('go2rtc_linux_arm64', '359fabade8a7a51e81a55fe6df6b0ef81764a5e1d63179577534eaaa71904b50'),
|
|
31
|
+
pinCloudflared('cloudflared-linux-arm64', '3df43aedc9ff291d1735b5cb576cb97e0b157488796db2e32b28aecabb445775'),
|
|
32
|
+
],
|
|
33
|
+
'linux-arm': [
|
|
34
|
+
pinGo2rtc('go2rtc_linux_arm', '4d7e1639af5a2722a28e864468fd8099b3c1682565446c798bf9e3b38fde12e4'),
|
|
35
|
+
LINUX_ARM_CLOUDFLARED_PIN,
|
|
36
|
+
],
|
|
37
|
+
'linux-x64': [
|
|
38
|
+
pinGo2rtc('go2rtc_linux_amd64', '32d616af226bd731678ffde328b94cfb94e30339bfefc469cfb76323144615a6'),
|
|
39
|
+
pinCloudflared('cloudflared-linux-amd64', '1f99fb518b83dec2ba8d870a7cc991d82a77e6196295a3d2754ba17ab9f91872'),
|
|
40
|
+
],
|
|
41
|
+
'darwin-arm64': [
|
|
42
|
+
pinGo2rtc('go2rtc_mac_arm64.zip', '919b78adc759d6b3883d1e1b2ac915ac0985bb903ff1897b4d228527bd64690c'),
|
|
43
|
+
pinCloudflared('cloudflared-darwin-arm64.tgz', '3586ccf397e98e5f437e60e7c307ca6ac463e110580503af8bb8e6f9ebf831bd'),
|
|
44
|
+
],
|
|
45
|
+
'darwin-x64': [
|
|
46
|
+
pinGo2rtc('go2rtc_mac_amd64.zip', '9b0b9a27a4dc3a5b8b93376e7e8fc2787c6af624a512842622be84aec0171c7a'),
|
|
47
|
+
pinCloudflared('cloudflared-darwin-amd64.tgz', '02f3edfd87684dd5b705607688e4eb4b323f6e777b81e7b3a85af556aa356848'),
|
|
48
|
+
],
|
|
49
|
+
};
|
|
50
|
+
const LINUX_ARMV6_PINS = [
|
|
51
|
+
pinGo2rtc('go2rtc_linux_armv6', '4dc20370556b29f3a90f4c7a09dcd95472c8f74cca56d4d1fb91f32bdd15174c'),
|
|
52
|
+
LINUX_ARM_CLOUDFLARED_PIN,
|
|
53
|
+
];
|
|
54
|
+
export const findCameraBinaryPins = (platform, arch, armVersion) => {
|
|
55
|
+
if (platform === 'linux' && arch === 'arm') {
|
|
56
|
+
if (armVersion === '6') {
|
|
57
|
+
return LINUX_ARMV6_PINS;
|
|
58
|
+
}
|
|
59
|
+
if (armVersion !== '7') {
|
|
60
|
+
console.log(`[camera] Node reports ARM version ${armVersion || 'none'} - fetching go2rtc and cloudflared for ARMv7`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const key = `${platform}-${arch}`;
|
|
64
|
+
return Object.hasOwn(CAMERA_BINARY_PINS, key) ? CAMERA_BINARY_PINS[key] : null;
|
|
65
|
+
};
|
|
66
|
+
const execFileAsync = promisify(execFile);
|
|
67
|
+
export const unpackArchiveEntry = async (archivePath, name, dir) => {
|
|
68
|
+
try {
|
|
69
|
+
await execFileAsync('tar', ['-xf', archivePath, '-C', dir, name]);
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
const reason = error.stderr?.trim().split('\n')[0];
|
|
73
|
+
throw new Error(`tar could not unpack ${name}${reason ? ` (${reason})` : ''}`, {
|
|
74
|
+
cause: error,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
export const resolveCameraBinarySourcePath = (binaryPath) => `${binaryPath}.pin.json`;
|
|
79
|
+
const isPinnedBuild = async (path, pin) => {
|
|
80
|
+
try {
|
|
81
|
+
if (pin.packing === 'raw') {
|
|
82
|
+
return (await sha256File(path)) === pin.sha256;
|
|
83
|
+
}
|
|
84
|
+
const source = JSON.parse(readFileSync(resolveCameraBinarySourcePath(path), 'utf8'));
|
|
85
|
+
return (source.archiveSha256 === pin.sha256 &&
|
|
86
|
+
typeof source.sha256 === 'string' &&
|
|
87
|
+
(await sha256File(path)) === source.sha256);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
const defaultDependencies = {
|
|
94
|
+
fetch: (url, init) => fetch(url, init),
|
|
95
|
+
timeoutMs: 10 * 60_000,
|
|
96
|
+
unpack: unpackArchiveEntry,
|
|
97
|
+
};
|
|
98
|
+
const download = async (url, path, { fetch, timeoutMs }) => {
|
|
99
|
+
const response = await fetch(url, {
|
|
100
|
+
redirect: 'follow',
|
|
101
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
102
|
+
});
|
|
103
|
+
if (!response.ok || !response.body) {
|
|
104
|
+
throw new Error(`GitHub answered ${response.status} ${response.statusText}`.trim());
|
|
105
|
+
}
|
|
106
|
+
const hash = createHash('sha256');
|
|
107
|
+
await pipeline(Readable.from(response.body), new Transform({
|
|
108
|
+
transform(chunk, _encoding, callback) {
|
|
109
|
+
hash.update(chunk);
|
|
110
|
+
callback(null, chunk);
|
|
111
|
+
},
|
|
112
|
+
}), createWriteStream(path, {
|
|
113
|
+
mode: 0o600,
|
|
114
|
+
}));
|
|
115
|
+
return hash.digest('hex');
|
|
116
|
+
};
|
|
117
|
+
const ensureCameraBinary = async (cameraDir, pin, dependencies) => {
|
|
118
|
+
const path = join(cameraDir, pin.name);
|
|
119
|
+
if (await isPinnedBuild(path, pin)) {
|
|
120
|
+
if ((statSync(path).mode & 0o111) === 0) {
|
|
121
|
+
chmodSync(path, 0o755);
|
|
122
|
+
}
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const hadFile = existsSync(path);
|
|
126
|
+
const downloadPath = `${path}.download`;
|
|
127
|
+
const unpackedPath = `${path}.unpacked`;
|
|
128
|
+
const removeLeftovers = () => {
|
|
129
|
+
rmSync(downloadPath, {
|
|
130
|
+
force: true,
|
|
131
|
+
});
|
|
132
|
+
rmSync(unpackedPath, {
|
|
133
|
+
recursive: true,
|
|
134
|
+
force: true,
|
|
135
|
+
});
|
|
136
|
+
};
|
|
137
|
+
mkdirSync(cameraDir, {
|
|
138
|
+
recursive: true,
|
|
139
|
+
});
|
|
140
|
+
removeLeftovers();
|
|
141
|
+
try {
|
|
142
|
+
const sha256 = await download(pin.url, downloadPath, dependencies);
|
|
143
|
+
if (sha256 !== pin.sha256) {
|
|
144
|
+
throw new Error(`its SHA-256 is ${sha256}, not the pinned ${pin.sha256} - deleted unrun`);
|
|
145
|
+
}
|
|
146
|
+
if (pin.packing === 'raw') {
|
|
147
|
+
chmodSync(downloadPath, 0o755);
|
|
148
|
+
renameSync(downloadPath, path);
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
mkdirSync(unpackedPath);
|
|
152
|
+
await dependencies.unpack(downloadPath, pin.name, unpackedPath);
|
|
153
|
+
const unpackedBinaryPath = join(unpackedPath, pin.name);
|
|
154
|
+
const source = {
|
|
155
|
+
file: pin.file,
|
|
156
|
+
version: pin.version,
|
|
157
|
+
archiveSha256: pin.sha256,
|
|
158
|
+
sha256: await sha256File(unpackedBinaryPath),
|
|
159
|
+
};
|
|
160
|
+
chmodSync(unpackedBinaryPath, 0o755);
|
|
161
|
+
renameSync(unpackedBinaryPath, path);
|
|
162
|
+
writeFileSync(resolveCameraBinarySourcePath(path), `${JSON.stringify(source, null, 2)}\n`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
finally {
|
|
166
|
+
removeLeftovers();
|
|
167
|
+
}
|
|
168
|
+
console.log(`[camera] ${hadFile
|
|
169
|
+
? `Replaced camera/${pin.name} with ${pin.name} ${pin.version}`
|
|
170
|
+
: `Fetched ${pin.name} ${pin.version} into camera/${pin.name}`} (${pin.file}, its SHA-256 checked)`);
|
|
171
|
+
};
|
|
172
|
+
export const ensureCameraBinaries = async (cameraDir, pins, dependencies = defaultDependencies) => {
|
|
173
|
+
let isReady = true;
|
|
174
|
+
for (const pin of pins) {
|
|
175
|
+
try {
|
|
176
|
+
await ensureCameraBinary(cameraDir, pin, dependencies);
|
|
177
|
+
}
|
|
178
|
+
catch (error) {
|
|
179
|
+
isReady = false;
|
|
180
|
+
const path = join(cameraDir, pin.name);
|
|
181
|
+
console.error(`[camera] Could not fetch ${pin.name} ${pin.version} (${pin.file}): ${describeError(error)}; ${existsSync(path) ? `the camera/${pin.name} there is used meanwhile` : 'trying again later'}`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return isReady;
|
|
185
|
+
};
|
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { createServer } from 'node:http';
|
|
4
|
+
import { connect } from 'node:net';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
export const CAMERA_GATEWAY_PORT = 1984;
|
|
8
|
+
export const CAMERA_REGISTRY_DIR = join(homedir(), '.local', 'state', 'machine-agent', 'cameras');
|
|
9
|
+
const PRINTER_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/;
|
|
10
|
+
export const CAMERA_RELAY_HEADER = 'x-vextrix3d-camera';
|
|
11
|
+
const CAMERA_RELAY_TOKEN_PAST_SECS = 120;
|
|
12
|
+
const CAMERA_RELAY_TOKEN_FUTURE_SECS = 180;
|
|
13
|
+
export const checkCameraRelayToken = (token, printerId, key, nowSecs) => {
|
|
14
|
+
const match = token?.match(/^(\d{1,12})\.([A-Za-z0-9_-]{43})$/);
|
|
15
|
+
if (!match) {
|
|
16
|
+
return 'invalid';
|
|
17
|
+
}
|
|
18
|
+
const expiresAt = Number(match[1]);
|
|
19
|
+
const expected = createHmac('sha256', key).update(`${printerId}.${expiresAt}`).digest();
|
|
20
|
+
const signature = Buffer.from(match[2], 'base64url');
|
|
21
|
+
if (signature.length !== expected.length || !timingSafeEqual(signature, expected)) {
|
|
22
|
+
return 'invalid';
|
|
23
|
+
}
|
|
24
|
+
const isInTime = expiresAt >= nowSecs - CAMERA_RELAY_TOKEN_PAST_SECS &&
|
|
25
|
+
expiresAt <= nowSecs + CAMERA_RELAY_TOKEN_FUTURE_SECS;
|
|
26
|
+
return isInTime ? 'valid' : 'mistimed';
|
|
27
|
+
};
|
|
28
|
+
export const createCameraRegistry = (dir) => {
|
|
29
|
+
const resolvePath = (printerId) => join(dir, `${printerId}.json`);
|
|
30
|
+
return {
|
|
31
|
+
register: (printerId, port, key) => {
|
|
32
|
+
if (!PRINTER_ID_PATTERN.test(printerId)) {
|
|
33
|
+
throw new Error(`"${printerId}" is not a printer id the camera registry can hold`);
|
|
34
|
+
}
|
|
35
|
+
mkdirSync(dir, {
|
|
36
|
+
recursive: true,
|
|
37
|
+
});
|
|
38
|
+
chmodSync(dir, 0o700);
|
|
39
|
+
const path = resolvePath(printerId);
|
|
40
|
+
const pendingPath = `${path}.${process.pid}`;
|
|
41
|
+
writeFileSync(pendingPath, JSON.stringify({ port, key }), {
|
|
42
|
+
mode: 0o600,
|
|
43
|
+
});
|
|
44
|
+
renameSync(pendingPath, path);
|
|
45
|
+
},
|
|
46
|
+
unregister: printerId => {
|
|
47
|
+
if (PRINTER_ID_PATTERN.test(printerId)) {
|
|
48
|
+
rmSync(resolvePath(printerId), {
|
|
49
|
+
force: true,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
lookup: printerId => {
|
|
54
|
+
if (!PRINTER_ID_PATTERN.test(printerId)) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
const { port, key } = JSON.parse(readFileSync(resolvePath(printerId), 'utf8'));
|
|
59
|
+
if (!Number.isInteger(port)) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
port,
|
|
64
|
+
key: typeof key === 'string' && key ? key : null,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
};
|
|
73
|
+
const refuse = (socket, status) => {
|
|
74
|
+
socket.end(`HTTP/1.1 ${status}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`, () => socket.destroy());
|
|
75
|
+
};
|
|
76
|
+
const buildRequestHead = (request) => {
|
|
77
|
+
const lines = [`${request.method} ${request.url} HTTP/${request.httpVersion}`];
|
|
78
|
+
for (let index = 0; index < request.rawHeaders.length; index += 2) {
|
|
79
|
+
if (request.rawHeaders[index].toLowerCase() !== CAMERA_RELAY_HEADER) {
|
|
80
|
+
lines.push(`${request.rawHeaders[index]}: ${request.rawHeaders[index + 1]}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return `${lines.join('\r\n')}\r\n\r\n`;
|
|
84
|
+
};
|
|
85
|
+
export const createCameraGateway = ({ port, registry, reusePort, retryDelayMs = 30_000, }) => {
|
|
86
|
+
const sockets = new Set();
|
|
87
|
+
let server = null;
|
|
88
|
+
let retryTimeoutId;
|
|
89
|
+
let hasReportedTaken = false;
|
|
90
|
+
let isStopping = false;
|
|
91
|
+
const pass = (request, socket, head) => {
|
|
92
|
+
sockets.add(socket);
|
|
93
|
+
socket.once('close', () => sockets.delete(socket));
|
|
94
|
+
socket.on('error', () => socket.destroy());
|
|
95
|
+
const url = new URL(request.url ?? '/', 'http://127.0.0.1');
|
|
96
|
+
const printerId = url.searchParams.get('src') ?? '';
|
|
97
|
+
if (url.pathname !== '/api/ws' || request.headers.upgrade?.toLowerCase() !== 'websocket') {
|
|
98
|
+
refuse(socket, '404 Not Found');
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const entry = registry.lookup(printerId);
|
|
102
|
+
if (!entry) {
|
|
103
|
+
console.error(`[camera] A viewer asked for printer ${JSON.stringify(printerId)}, which no agent on this machine relays`);
|
|
104
|
+
refuse(socket, '404 Not Found');
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const token = request.headers[CAMERA_RELAY_HEADER];
|
|
108
|
+
const check = entry.key === null
|
|
109
|
+
? 'invalid'
|
|
110
|
+
: checkCameraRelayToken(typeof token === 'string' ? token : undefined, printerId, entry.key, Date.now() / 1000);
|
|
111
|
+
if (check === 'mistimed') {
|
|
112
|
+
console.error(`[camera] A viewer asked for printer ${printerId} with a relay token that has expired or is not yet valid by this computer's clock - is the clock more than two minutes off? Keep it set by NTP`);
|
|
113
|
+
}
|
|
114
|
+
else if (check === 'invalid') {
|
|
115
|
+
console.error(`[camera] A viewer asked for printer ${printerId} without a valid relay token${entry.key === null ? ' - its agent has no relay key; restart it' : ''}`);
|
|
116
|
+
}
|
|
117
|
+
if (check !== 'valid') {
|
|
118
|
+
refuse(socket, '401 Unauthorized');
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const go2rtcPort = entry.port;
|
|
122
|
+
const go2rtc = connect(go2rtcPort, '127.0.0.1');
|
|
123
|
+
sockets.add(go2rtc);
|
|
124
|
+
go2rtc.once('close', () => sockets.delete(go2rtc));
|
|
125
|
+
let isConnected = false;
|
|
126
|
+
const close = () => {
|
|
127
|
+
socket.destroy();
|
|
128
|
+
go2rtc.destroy();
|
|
129
|
+
};
|
|
130
|
+
socket.once('close', close);
|
|
131
|
+
go2rtc.on('error', error => {
|
|
132
|
+
if (isConnected) {
|
|
133
|
+
close();
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
console.error(`[camera] The go2rtc of printer ${printerId} is not answering on 127.0.0.1:${go2rtcPort}:`, error.message);
|
|
137
|
+
refuse(socket, '502 Bad Gateway');
|
|
138
|
+
});
|
|
139
|
+
go2rtc.once('connect', () => {
|
|
140
|
+
isConnected = true;
|
|
141
|
+
go2rtc.once('close', close);
|
|
142
|
+
go2rtc.write(buildRequestHead(request));
|
|
143
|
+
if (head.length) {
|
|
144
|
+
go2rtc.write(head);
|
|
145
|
+
}
|
|
146
|
+
socket.pipe(go2rtc).pipe(socket);
|
|
147
|
+
});
|
|
148
|
+
};
|
|
149
|
+
const listen = () => new Promise(resolve => {
|
|
150
|
+
const nextServer = createServer((_request, response) => {
|
|
151
|
+
response.writeHead(404).end();
|
|
152
|
+
});
|
|
153
|
+
nextServer.on('upgrade', pass);
|
|
154
|
+
nextServer.once('error', (error) => {
|
|
155
|
+
if (error.code !== 'EADDRINUSE') {
|
|
156
|
+
console.error(`[camera] Could not take camera requests on 127.0.0.1:${port}:`, error);
|
|
157
|
+
resolve(false);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (!hasReportedTaken) {
|
|
161
|
+
hasReportedTaken = true;
|
|
162
|
+
console.error(`[camera] Port ${port}, where this machine's agents take camera requests together, is held by something else - the printer-camera pm2 process from before? (\`yarn pm2 delete printer-camera\`). Trying again every ${retryDelayMs / 1000}s`);
|
|
163
|
+
}
|
|
164
|
+
if (!isStopping) {
|
|
165
|
+
retryTimeoutId = setTimeout(() => void listen(), retryDelayMs);
|
|
166
|
+
}
|
|
167
|
+
resolve(false);
|
|
168
|
+
});
|
|
169
|
+
nextServer.listen({
|
|
170
|
+
host: '127.0.0.1',
|
|
171
|
+
port,
|
|
172
|
+
reusePort,
|
|
173
|
+
}, () => {
|
|
174
|
+
if (isStopping) {
|
|
175
|
+
nextServer.close();
|
|
176
|
+
resolve(false);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
server = nextServer;
|
|
180
|
+
console.log(`[camera] Taking this machine's camera requests on 127.0.0.1:${port}`);
|
|
181
|
+
resolve(true);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
return {
|
|
185
|
+
start: listen,
|
|
186
|
+
stop: async () => {
|
|
187
|
+
isStopping = true;
|
|
188
|
+
clearTimeout(retryTimeoutId);
|
|
189
|
+
for (const socket of sockets) {
|
|
190
|
+
socket.destroy();
|
|
191
|
+
}
|
|
192
|
+
const runningServer = server;
|
|
193
|
+
if (runningServer) {
|
|
194
|
+
await new Promise(resolve => {
|
|
195
|
+
runningServer.close(() => resolve());
|
|
196
|
+
runningServer.closeAllConnections();
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { createCameraFrameBridge } from './camera-frame-bridge.js';
|
|
3
|
+
import { CameraJpegConnection } from './camera-jpeg-connection.js';
|
|
4
|
+
export const createCameraJpegBridge = ({ host, port, serialNumber, accessCode, caFile, printerLabel, }) => {
|
|
5
|
+
const ca = readFileSync(caFile);
|
|
6
|
+
return createCameraFrameBridge({
|
|
7
|
+
open: handlers => new CameraJpegConnection({
|
|
8
|
+
host,
|
|
9
|
+
port,
|
|
10
|
+
serialNumber,
|
|
11
|
+
accessCode,
|
|
12
|
+
ca,
|
|
13
|
+
...handlers,
|
|
14
|
+
}),
|
|
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
|
+
});
|
|
19
|
+
};
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { connect } from 'node:tls';
|
|
2
|
+
import { checkPrinterCertificate } from './camera-stream-connection.js';
|
|
3
|
+
import { isJpeg } from './utils.js';
|
|
4
|
+
export const JPEG_CAMERA_PORT = 6000;
|
|
5
|
+
const JPEG_CAMERA_MODELS = {
|
|
6
|
+
'01S': 'P1P',
|
|
7
|
+
'01P': 'P1S',
|
|
8
|
+
'030': 'A1 mini',
|
|
9
|
+
'039': 'A1',
|
|
10
|
+
'26A': 'A2L',
|
|
11
|
+
};
|
|
12
|
+
export const readJpegCamera = (serialNumber, host) => {
|
|
13
|
+
const model = JPEG_CAMERA_MODELS[serialNumber.slice(0, 3).toUpperCase()];
|
|
14
|
+
return model
|
|
15
|
+
? {
|
|
16
|
+
model,
|
|
17
|
+
host,
|
|
18
|
+
port: JPEG_CAMERA_PORT,
|
|
19
|
+
}
|
|
20
|
+
: null;
|
|
21
|
+
};
|
|
22
|
+
export const buildJpegCameraAuthPacket = (accessCode) => {
|
|
23
|
+
const packet = Buffer.alloc(80);
|
|
24
|
+
packet.writeUInt32LE(0x40, 0);
|
|
25
|
+
packet.writeUInt32LE(0x3000, 4);
|
|
26
|
+
packet.write('bblp', 16, 32, 'ascii');
|
|
27
|
+
packet.write(accessCode, 48, 32, 'ascii');
|
|
28
|
+
return packet;
|
|
29
|
+
};
|
|
30
|
+
const FRAME_HEADER_SIZE = 16;
|
|
31
|
+
const MAX_FRAME_SIZE = 4 * 1024 * 1024;
|
|
32
|
+
export const createJpegFrameReader = (onFrame) => {
|
|
33
|
+
const chunks = [];
|
|
34
|
+
let buffered = 0;
|
|
35
|
+
let frameSize = null;
|
|
36
|
+
const take = (size) => {
|
|
37
|
+
const joined = chunks.length === 1 ? chunks[0] : Buffer.concat(chunks, buffered);
|
|
38
|
+
chunks.length = 0;
|
|
39
|
+
if (joined.length > size) {
|
|
40
|
+
chunks.push(joined.subarray(size));
|
|
41
|
+
}
|
|
42
|
+
buffered -= size;
|
|
43
|
+
return joined.subarray(0, size);
|
|
44
|
+
};
|
|
45
|
+
return (chunk) => {
|
|
46
|
+
chunks.push(chunk);
|
|
47
|
+
buffered += chunk.length;
|
|
48
|
+
while (true) {
|
|
49
|
+
if (frameSize === null) {
|
|
50
|
+
if (buffered < FRAME_HEADER_SIZE) {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const size = take(FRAME_HEADER_SIZE).readUInt32LE(0);
|
|
54
|
+
if (size < 4 || size > MAX_FRAME_SIZE) {
|
|
55
|
+
throw new Error(`it sent a frame of ${size} bytes`);
|
|
56
|
+
}
|
|
57
|
+
frameSize = size;
|
|
58
|
+
}
|
|
59
|
+
if (buffered < frameSize) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const frame = take(frameSize);
|
|
63
|
+
frameSize = null;
|
|
64
|
+
if (!isJpeg(frame)) {
|
|
65
|
+
throw new Error('it sent a frame that is not a JPEG');
|
|
66
|
+
}
|
|
67
|
+
onFrame(frame);
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
};
|
|
71
|
+
export class CameraJpegConnection {
|
|
72
|
+
printer;
|
|
73
|
+
hasFrame = false;
|
|
74
|
+
isClosed = false;
|
|
75
|
+
constructor({ host, port, serialNumber, accessCode, ca, onFrame, onFailure, onClose, }) {
|
|
76
|
+
this.printer = connect({
|
|
77
|
+
host,
|
|
78
|
+
port,
|
|
79
|
+
servername: serialNumber,
|
|
80
|
+
ca,
|
|
81
|
+
checkServerIdentity: checkPrinterCertificate(serialNumber),
|
|
82
|
+
maxVersion: 'TLSv1.2',
|
|
83
|
+
});
|
|
84
|
+
const fail = (error) => {
|
|
85
|
+
if (!this.isClosed) {
|
|
86
|
+
onFailure(error);
|
|
87
|
+
this.close();
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
const readFrames = createJpegFrameReader(frame => {
|
|
91
|
+
this.hasFrame = true;
|
|
92
|
+
onFrame(frame);
|
|
93
|
+
});
|
|
94
|
+
this.printer.once('secureConnect', () => {
|
|
95
|
+
this.printer.write(buildJpegCameraAuthPacket(accessCode));
|
|
96
|
+
});
|
|
97
|
+
this.printer.on('data', (chunk) => {
|
|
98
|
+
try {
|
|
99
|
+
readFrames(chunk);
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
fail(error);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
this.printer.on('error', fail);
|
|
106
|
+
this.printer.once('close', () => {
|
|
107
|
+
if (!this.hasFrame) {
|
|
108
|
+
fail(new Error('the printer closed the connection before sending a frame, as it does where the access code is wrong'));
|
|
109
|
+
}
|
|
110
|
+
this.close();
|
|
111
|
+
onClose();
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
close() {
|
|
115
|
+
if (this.isClosed) {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
this.isClosed = true;
|
|
119
|
+
this.printer.destroy();
|
|
120
|
+
}
|
|
121
|
+
}
|