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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "project-foundry",
3
- "version": "1.0.5",
3
+ "version": "1.0.7",
4
4
  "description": "Project Foundry CLI & Local Node Daemon Manager",
5
5
  "bin": {
6
6
  "project-foundry": "bin/foundry.js",
@@ -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(5000) }
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 stats = tel?.result?.status?.print_stats || {};
115
- const ext = tel?.result?.status?.extruder || {};
116
- const bed = tel?.result?.status?.heater_bed || {};
117
- const st = String(stats.state || '').toLowerCase();
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 (stats.message) message = stats.message;
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 isPrinting = state === 'RUNNING' || state === 'PREPARE';
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' : (state === 'PAUSE' ? 'PAUSED' : 'IDLE'),
192
- message: `Bambu LAN conectada (${state})`,
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`);