project-foundry 1.0.5 → 1.0.7
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/package.json +1 -1
- package/src/local-printer-executor.js +279 -15
package/package.json
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import mqtt from 'mqtt';
|
|
2
2
|
import { connect as connectTls } from 'node:tls';
|
|
3
3
|
|
|
4
|
+
// Cache em memória das últimas telemetrias da Bambu (serialNumber -> { timestamp, print })
|
|
5
|
+
const latestBambuReports = new Map();
|
|
6
|
+
|
|
4
7
|
function buildBambuCameraAuthPacket(accessCode) {
|
|
5
8
|
const packet = Buffer.alloc(80);
|
|
6
9
|
packet.writeUInt32LE(0x40, 0);
|
|
@@ -67,6 +70,45 @@ function captureBambuJpegTls(connection) {
|
|
|
67
70
|
});
|
|
68
71
|
}
|
|
69
72
|
|
|
73
|
+
function bambuColor(value) {
|
|
74
|
+
if (typeof value !== 'string') return undefined;
|
|
75
|
+
const normalized = value.replace(/^#/, '').slice(0, 6);
|
|
76
|
+
return /^[0-9a-f]{6}$/i.test(normalized) ? `#${normalized.toUpperCase()}` : undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function parseBambuAmsState(print) {
|
|
80
|
+
const ams = print?.ams || {};
|
|
81
|
+
const units = Array.isArray(ams.ams) ? ams.ams : [];
|
|
82
|
+
const slots = units.flatMap((unit, unitIndex) => {
|
|
83
|
+
const amsId = (unit.id !== undefined && !Number.isNaN(Number(unit.id))) ? Number(unit.id) : unitIndex;
|
|
84
|
+
const trays = Array.isArray(unit.tray) ? unit.tray : [];
|
|
85
|
+
return trays.map((tray, slotIndex) => {
|
|
86
|
+
const slotId = (tray.id !== undefined && !Number.isNaN(Number(tray.id))) ? Number(tray.id) : slotIndex;
|
|
87
|
+
const trayId = amsId * 4 + slotId;
|
|
88
|
+
const filamentType = typeof tray.tray_type === 'string' && tray.tray_type ? tray.tray_type : undefined;
|
|
89
|
+
const hasMetadata = Boolean(filamentType || tray.tray_info_idx || tray.setting_id);
|
|
90
|
+
const remaining = Number(tray.remain);
|
|
91
|
+
return {
|
|
92
|
+
trayId,
|
|
93
|
+
amsId,
|
|
94
|
+
slotId,
|
|
95
|
+
label: `AMS ${String.fromCharCode(65 + amsId)} · slot ${slotId + 1}`,
|
|
96
|
+
filamentType,
|
|
97
|
+
color: bambuColor(tray.tray_color),
|
|
98
|
+
remainingPercent: Number.isFinite(remaining) && remaining >= 0 ? remaining : undefined,
|
|
99
|
+
empty: !hasMetadata,
|
|
100
|
+
};
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
const currentTrayId = Number(ams.tray_now);
|
|
104
|
+
return {
|
|
105
|
+
available: slots.length > 0,
|
|
106
|
+
currentTrayId: Number.isFinite(currentTrayId) && currentTrayId >= 0 && currentTrayId < 255 ? currentTrayId : undefined,
|
|
107
|
+
slots,
|
|
108
|
+
readAt: new Date().toISOString(),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
70
112
|
export async function executeLocalPrinterAction({ action, printerId, connectionConfig, payload }) {
|
|
71
113
|
if (!connectionConfig) {
|
|
72
114
|
throw new Error('Configuração de conexão da impressora não foi fornecida pelo servidor');
|
|
@@ -103,20 +145,40 @@ export async function executeLocalPrinterAction({ action, printerId, connectionC
|
|
|
103
145
|
let status = klippyState === 'ready' ? 'IDLE' : 'ERROR';
|
|
104
146
|
let message = `Klipper: ${klippyState}`;
|
|
105
147
|
let temperatures = {};
|
|
148
|
+
let progress = 0;
|
|
149
|
+
let currentLayer = 0;
|
|
150
|
+
let totalLayers = 0;
|
|
151
|
+
let filename;
|
|
152
|
+
let elapsedSeconds = 0;
|
|
106
153
|
|
|
107
154
|
try {
|
|
108
155
|
const telRes = await fetch(
|
|
109
|
-
`${baseUrl}/printer/objects/query?print_stats&extruder&heater_bed`,
|
|
110
|
-
{ headers, signal: AbortSignal.timeout(
|
|
156
|
+
`${baseUrl}/printer/objects/query?print_stats&display_status&virtual_sdcard&extruder&heater_bed`,
|
|
157
|
+
{ headers, signal: AbortSignal.timeout(6000) }
|
|
111
158
|
);
|
|
112
159
|
if (telRes.ok) {
|
|
113
160
|
const tel = await telRes.json();
|
|
114
|
-
const
|
|
115
|
-
const
|
|
116
|
-
const
|
|
117
|
-
const
|
|
161
|
+
const s = tel?.result?.status || {};
|
|
162
|
+
const printStats = s.print_stats || {};
|
|
163
|
+
const display = s.display_status || {};
|
|
164
|
+
const virtualSd = s.virtual_sdcard || {};
|
|
165
|
+
const ext = s.extruder || {};
|
|
166
|
+
const bed = s.heater_bed || {};
|
|
167
|
+
|
|
168
|
+
const st = String(printStats.state || '').toLowerCase();
|
|
118
169
|
if (st === 'printing') status = 'PRINTING';
|
|
119
170
|
else if (st === 'paused') status = 'PAUSED';
|
|
171
|
+
else if (st === 'complete') status = 'IDLE';
|
|
172
|
+
else if (st === 'error') status = 'ERROR';
|
|
173
|
+
|
|
174
|
+
const dispProg = Number(display.progress || 0);
|
|
175
|
+
const sdProg = Number(virtualSd.progress || 0);
|
|
176
|
+
progress = Math.round(Math.max(dispProg, sdProg) * 100);
|
|
177
|
+
|
|
178
|
+
currentLayer = Number(virtualSd.layer || printStats?.info?.current_layer || 0);
|
|
179
|
+
totalLayers = Number(virtualSd.layer_count || printStats?.info?.total_layer || 0);
|
|
180
|
+
filename = printStats.filename || undefined;
|
|
181
|
+
elapsedSeconds = Number(printStats.print_duration || printStats.total_duration || 0);
|
|
120
182
|
|
|
121
183
|
temperatures = {
|
|
122
184
|
hotend: ext.temperature,
|
|
@@ -124,7 +186,7 @@ export async function executeLocalPrinterAction({ action, printerId, connectionC
|
|
|
124
186
|
bed: bed.temperature,
|
|
125
187
|
bedTarget: bed.target,
|
|
126
188
|
};
|
|
127
|
-
if (
|
|
189
|
+
if (printStats.message) message = printStats.message;
|
|
128
190
|
}
|
|
129
191
|
} catch {}
|
|
130
192
|
|
|
@@ -132,6 +194,11 @@ export async function executeLocalPrinterAction({ action, printerId, connectionC
|
|
|
132
194
|
online: true,
|
|
133
195
|
status,
|
|
134
196
|
message,
|
|
197
|
+
progress,
|
|
198
|
+
currentLayer,
|
|
199
|
+
totalLayers,
|
|
200
|
+
filename,
|
|
201
|
+
elapsedSeconds,
|
|
135
202
|
temperatures,
|
|
136
203
|
};
|
|
137
204
|
}
|
|
@@ -180,23 +247,49 @@ export async function executeLocalPrinterAction({ action, printerId, connectionC
|
|
|
180
247
|
try {
|
|
181
248
|
const data = JSON.parse(payloadBuffer.toString('utf8'));
|
|
182
249
|
if (data.print) {
|
|
250
|
+
latestBambuReports.set(serialNumber, {
|
|
251
|
+
timestamp: Date.now(),
|
|
252
|
+
print: data.print,
|
|
253
|
+
});
|
|
254
|
+
|
|
183
255
|
isDone = true;
|
|
184
256
|
clearTimeout(timer);
|
|
185
257
|
client.end(true);
|
|
258
|
+
|
|
186
259
|
const state = String(data.print.gcode_state || 'IDLE').toUpperCase();
|
|
187
260
|
const percent = Number(data.print.mc_percent || 0);
|
|
188
|
-
const
|
|
261
|
+
const isFinished = state === 'FINISH' || (state === 'IDLE' && percent === 100);
|
|
262
|
+
const isPrinting = (state === 'RUNNING' || state === 'PREPARE') && !isFinished;
|
|
263
|
+
const isPaused = state === 'PAUSE';
|
|
264
|
+
|
|
265
|
+
const filename =
|
|
266
|
+
typeof data.print.subtask_name === 'string' && data.print.subtask_name.trim()
|
|
267
|
+
? data.print.subtask_name.trim()
|
|
268
|
+
: typeof data.print.gcode_file === 'string' && data.print.gcode_file.trim()
|
|
269
|
+
? data.print.gcode_file.trim()
|
|
270
|
+
: undefined;
|
|
271
|
+
|
|
272
|
+
const remainingMins = Number(data.print.mc_remaining_time);
|
|
273
|
+
const remainingSeconds = Number.isFinite(remainingMins) ? Math.max(0, remainingMins * 60) : undefined;
|
|
274
|
+
|
|
189
275
|
resolve({
|
|
190
276
|
online: true,
|
|
191
|
-
status: isPrinting ? 'PRINTING' : (
|
|
192
|
-
message:
|
|
277
|
+
status: isPrinting ? 'PRINTING' : (isPaused ? 'PAUSED' : 'IDLE'),
|
|
278
|
+
message: isFinished
|
|
279
|
+
? 'Impressão concluída com sucesso'
|
|
280
|
+
: `Bambu LAN conectada (${state})`,
|
|
193
281
|
progress: percent,
|
|
282
|
+
currentLayer: Number(data.print.layer_num || 0),
|
|
283
|
+
totalLayers: Number(data.print.total_layer_num || 0),
|
|
284
|
+
filename,
|
|
285
|
+
remainingSeconds,
|
|
286
|
+
jobFinished: isFinished,
|
|
194
287
|
temperatures: {
|
|
195
|
-
hotend: data.print.nozzle_temper,
|
|
196
|
-
hotendTarget: data.print.nozzle_target_temper,
|
|
197
|
-
bed: data.print.bed_temper,
|
|
198
|
-
bedTarget: data.print.bed_target_temper,
|
|
199
|
-
chamber: data.print.chamber_temper,
|
|
288
|
+
hotend: Number(data.print.nozzle_temper || 0),
|
|
289
|
+
hotendTarget: Number(data.print.nozzle_target_temper || 0),
|
|
290
|
+
bed: Number(data.print.bed_temper || 0),
|
|
291
|
+
bedTarget: Number(data.print.bed_target_temper || 0),
|
|
292
|
+
chamber: Number(data.print.chamber_temper || 0),
|
|
200
293
|
},
|
|
201
294
|
});
|
|
202
295
|
}
|
|
@@ -266,6 +359,45 @@ export async function executeLocalPrinterAction({ action, printerId, connectionC
|
|
|
266
359
|
const res = await fetch(`${baseUrl}${endpoint}`, { method: 'POST', headers, signal: AbortSignal.timeout(8000) });
|
|
267
360
|
return { success: res.ok };
|
|
268
361
|
}
|
|
362
|
+
|
|
363
|
+
if (type === 'bambu') {
|
|
364
|
+
const host = connectionConfig.host;
|
|
365
|
+
const port = connectionConfig.mqttPort || 8883;
|
|
366
|
+
const username = connectionConfig.username || 'bblp';
|
|
367
|
+
const password = connectionConfig.accessCode;
|
|
368
|
+
const serialNumber = connectionConfig.serialNumber;
|
|
369
|
+
const bCmd = cmd === 'pause' ? 'pause' : cmd === 'resume' ? 'resume' : cmd === 'cancel' ? 'stop' : null;
|
|
370
|
+
|
|
371
|
+
if (!bCmd) throw new Error(`Comando "${cmd}" não suportado para Bambu`);
|
|
372
|
+
|
|
373
|
+
return new Promise((resolve, reject) => {
|
|
374
|
+
const client = mqtt.connect(`mqtts://${host}:${port}`, {
|
|
375
|
+
username, password, rejectUnauthorized: false, connectTimeout: 6000, clean: true,
|
|
376
|
+
});
|
|
377
|
+
const timer = setTimeout(() => {
|
|
378
|
+
try { client.end(true); } catch {}
|
|
379
|
+
reject(new Error(`Timeout ao enviar comando "${cmd}" para a Bambu`));
|
|
380
|
+
}, 7000);
|
|
381
|
+
|
|
382
|
+
client.on('connect', () => {
|
|
383
|
+
client.publish(
|
|
384
|
+
`device/${serialNumber}/request`,
|
|
385
|
+
JSON.stringify({ print: { sequence_id: String(Date.now()), command: bCmd, param: '' } }),
|
|
386
|
+
() => {
|
|
387
|
+
clearTimeout(timer);
|
|
388
|
+
client.end(true);
|
|
389
|
+
resolve({ success: true });
|
|
390
|
+
}
|
|
391
|
+
);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
client.on('error', (err) => {
|
|
395
|
+
clearTimeout(timer);
|
|
396
|
+
try { client.end(true); } catch {}
|
|
397
|
+
reject(err);
|
|
398
|
+
});
|
|
399
|
+
});
|
|
400
|
+
}
|
|
269
401
|
}
|
|
270
402
|
|
|
271
403
|
// =====================================================
|
|
@@ -285,6 +417,138 @@ export async function executeLocalPrinterAction({ action, printerId, connectionC
|
|
|
285
417
|
return { success: res.ok };
|
|
286
418
|
}
|
|
287
419
|
}
|
|
420
|
+
|
|
421
|
+
if (type === 'bambu') {
|
|
422
|
+
const host = connectionConfig.host;
|
|
423
|
+
const port = connectionConfig.mqttPort || 8883;
|
|
424
|
+
const username = connectionConfig.username || 'bblp';
|
|
425
|
+
const password = connectionConfig.accessCode;
|
|
426
|
+
const serialNumber = connectionConfig.serialNumber;
|
|
427
|
+
const bCmd = jobCmd === 'pause' ? 'pause' : jobCmd === 'resume' ? 'resume' : jobCmd === 'cancel' ? 'stop' : null;
|
|
428
|
+
|
|
429
|
+
if (bCmd) {
|
|
430
|
+
return new Promise((resolve, reject) => {
|
|
431
|
+
const client = mqtt.connect(`mqtts://${host}:${port}`, {
|
|
432
|
+
username, password, rejectUnauthorized: false, connectTimeout: 6000, clean: true,
|
|
433
|
+
});
|
|
434
|
+
const timer = setTimeout(() => {
|
|
435
|
+
try { client.end(true); } catch {}
|
|
436
|
+
reject(new Error(`Timeout ao enviar comando "${jobCmd}" para a Bambu`));
|
|
437
|
+
}, 7000);
|
|
438
|
+
|
|
439
|
+
client.on('connect', () => {
|
|
440
|
+
client.publish(
|
|
441
|
+
`device/${serialNumber}/request`,
|
|
442
|
+
JSON.stringify({ print: { sequence_id: String(Date.now()), command: bCmd, param: '' } }),
|
|
443
|
+
() => {
|
|
444
|
+
clearTimeout(timer);
|
|
445
|
+
client.end(true);
|
|
446
|
+
resolve({ success: true });
|
|
447
|
+
}
|
|
448
|
+
);
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
client.on('error', (err) => {
|
|
452
|
+
clearTimeout(timer);
|
|
453
|
+
try { client.end(true); } catch {}
|
|
454
|
+
reject(err);
|
|
455
|
+
});
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// =====================================================
|
|
462
|
+
// 5. Bambu AMS State (Leitura de slots e filamentos)
|
|
463
|
+
// =====================================================
|
|
464
|
+
if (action === 'ams') {
|
|
465
|
+
if (type !== 'bambu') {
|
|
466
|
+
return {
|
|
467
|
+
available: false,
|
|
468
|
+
slots: [],
|
|
469
|
+
readAt: new Date().toISOString(),
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const host = connectionConfig.host;
|
|
474
|
+
const port = connectionConfig.mqttPort || 8883;
|
|
475
|
+
const username = connectionConfig.username || 'bblp';
|
|
476
|
+
const password = connectionConfig.accessCode;
|
|
477
|
+
const serialNumber = connectionConfig.serialNumber;
|
|
478
|
+
|
|
479
|
+
if (!host || !password || !serialNumber) {
|
|
480
|
+
throw new Error('Configuração da Bambu incompleta: host, accessCode e serialNumber são obrigatórios');
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// Cache recente (< 30s)
|
|
484
|
+
const cached = latestBambuReports.get(serialNumber);
|
|
485
|
+
if (cached && (Date.now() - cached.timestamp < 30000) && cached.print?.ams) {
|
|
486
|
+
return parseBambuAmsState(cached.print);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// Consulta MQTT na LAN local
|
|
490
|
+
return new Promise((resolve) => {
|
|
491
|
+
let isDone = false;
|
|
492
|
+
const timer = setTimeout(() => {
|
|
493
|
+
if (isDone) return;
|
|
494
|
+
isDone = true;
|
|
495
|
+
try { client.end(true); } catch {}
|
|
496
|
+
if (cached?.print?.ams) {
|
|
497
|
+
resolve(parseBambuAmsState(cached.print));
|
|
498
|
+
} else {
|
|
499
|
+
resolve({ available: false, slots: [], readAt: new Date().toISOString() });
|
|
500
|
+
}
|
|
501
|
+
}, 7000);
|
|
502
|
+
|
|
503
|
+
const client = mqtt.connect(`mqtts://${host}:${port}`, {
|
|
504
|
+
clientId: `foundry_edge_ams_${Date.now()}`,
|
|
505
|
+
username,
|
|
506
|
+
password,
|
|
507
|
+
rejectUnauthorized: false,
|
|
508
|
+
connectTimeout: 6000,
|
|
509
|
+
clean: true,
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
client.on('connect', () => {
|
|
513
|
+
client.subscribe(`device/${serialNumber}/report`, () => {
|
|
514
|
+
client.publish(
|
|
515
|
+
`device/${serialNumber}/request`,
|
|
516
|
+
JSON.stringify({ pushing: { sequence_id: '1', command: 'pushall', version: 1, push_target: 1 } })
|
|
517
|
+
);
|
|
518
|
+
});
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
client.on('message', (_topic, payloadBuffer) => {
|
|
522
|
+
if (isDone) return;
|
|
523
|
+
try {
|
|
524
|
+
const data = JSON.parse(payloadBuffer.toString('utf8'));
|
|
525
|
+
if (data.print) {
|
|
526
|
+
latestBambuReports.set(serialNumber, {
|
|
527
|
+
timestamp: Date.now(),
|
|
528
|
+
print: data.print,
|
|
529
|
+
});
|
|
530
|
+
if (data.print.ams) {
|
|
531
|
+
isDone = true;
|
|
532
|
+
clearTimeout(timer);
|
|
533
|
+
client.end(true);
|
|
534
|
+
resolve(parseBambuAmsState(data.print));
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
} catch {}
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
client.on('error', () => {
|
|
541
|
+
if (isDone) return;
|
|
542
|
+
isDone = true;
|
|
543
|
+
clearTimeout(timer);
|
|
544
|
+
try { client.end(true); } catch {}
|
|
545
|
+
if (cached?.print?.ams) {
|
|
546
|
+
resolve(parseBambuAmsState(cached.print));
|
|
547
|
+
} else {
|
|
548
|
+
resolve({ available: false, slots: [], readAt: new Date().toISOString() });
|
|
549
|
+
}
|
|
550
|
+
});
|
|
551
|
+
});
|
|
288
552
|
}
|
|
289
553
|
|
|
290
554
|
throw new Error(`Ação "${action}" não suportada`);
|