project-foundry 1.0.9 → 1.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/package.json +1 -1
- package/src/daemon.js +21 -1
- package/src/local-printer-executor.js +193 -39
package/package.json
CHANGED
package/src/daemon.js
CHANGED
|
@@ -90,7 +90,27 @@ export function startDaemon() {
|
|
|
90
90
|
},
|
|
91
91
|
}, (cloudRes) => {
|
|
92
92
|
res.writeHead(cloudRes.statusCode, cloudRes.headers);
|
|
93
|
-
|
|
93
|
+
let responseBody = '';
|
|
94
|
+
cloudRes.on('data', (chunk) => {
|
|
95
|
+
res.write(chunk);
|
|
96
|
+
responseBody += chunk.toString();
|
|
97
|
+
});
|
|
98
|
+
cloudRes.on('end', () => {
|
|
99
|
+
res.end();
|
|
100
|
+
try {
|
|
101
|
+
const parsed = JSON.parse(responseBody);
|
|
102
|
+
const originalName = parsed?.files?.local?.name;
|
|
103
|
+
const fileId = parsed?.flowSystem3D?.fileId;
|
|
104
|
+
if (originalName) {
|
|
105
|
+
fs.copyFileSync(tempFilePath, path.join(storageDir, originalName));
|
|
106
|
+
}
|
|
107
|
+
if (fileId) {
|
|
108
|
+
const ext = (originalName && originalName.endsWith('.3mf')) ? '.3mf' : '.gcode';
|
|
109
|
+
fs.copyFileSync(tempFilePath, path.join(storageDir, `${fileId}${ext}`));
|
|
110
|
+
}
|
|
111
|
+
fs.unlinkSync(tempFilePath);
|
|
112
|
+
} catch {}
|
|
113
|
+
});
|
|
94
114
|
});
|
|
95
115
|
|
|
96
116
|
cloudReq.on('error', (err) => {
|
|
@@ -1,8 +1,82 @@
|
|
|
1
1
|
import mqtt from 'mqtt';
|
|
2
2
|
import { connect as connectTls } from 'node:tls';
|
|
3
3
|
import { Client as FtpClient } from 'basic-ftp';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import http from 'node:http';
|
|
7
|
+
import https from 'node:https';
|
|
4
8
|
import { readConfig } from './config.js';
|
|
5
9
|
|
|
10
|
+
function downloadFileFromCloud({ serverUrl, fileId, pat }) {
|
|
11
|
+
return new Promise((resolve, reject) => {
|
|
12
|
+
const rawUrl = `${serverUrl}/api/v1/files/${fileId}/download`;
|
|
13
|
+
let parsed;
|
|
14
|
+
try {
|
|
15
|
+
parsed = new URL(rawUrl);
|
|
16
|
+
} catch {
|
|
17
|
+
return reject(new Error(`URL inválida para download: ${rawUrl}`));
|
|
18
|
+
}
|
|
19
|
+
const isHttps = parsed.protocol === 'https:';
|
|
20
|
+
const transport = isHttps ? https : http;
|
|
21
|
+
|
|
22
|
+
const req = transport.request(parsed, {
|
|
23
|
+
method: 'GET',
|
|
24
|
+
family: 4,
|
|
25
|
+
rejectUnauthorized: false,
|
|
26
|
+
headers: pat ? { 'Authorization': `Bearer ${pat}` } : {},
|
|
27
|
+
timeout: 60000,
|
|
28
|
+
}, (res) => {
|
|
29
|
+
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
30
|
+
return resolve(downloadFileFromCloud({ serverUrl: res.headers.location.replace(/\/api\/v1\/files\/.*$/, ''), fileId, pat }));
|
|
31
|
+
}
|
|
32
|
+
if (res.statusCode !== 200) {
|
|
33
|
+
return reject(new Error(`Nuvem retornou HTTP ${res.statusCode} ao baixar arquivo`));
|
|
34
|
+
}
|
|
35
|
+
const chunks = [];
|
|
36
|
+
res.on('data', chunk => chunks.push(chunk));
|
|
37
|
+
res.on('end', () => resolve(Buffer.concat(chunks)));
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
req.on('error', (err) => {
|
|
41
|
+
// Fallback: se DNS falhar para foundry.projectcore.site, tentar direto pelo IP da VPS
|
|
42
|
+
if (parsed.hostname === 'foundry.projectcore.site' && isHttps) {
|
|
43
|
+
const ipReq = https.request({
|
|
44
|
+
hostname: '164.152.35.52',
|
|
45
|
+
port: 443,
|
|
46
|
+
path: parsed.pathname + parsed.search,
|
|
47
|
+
method: 'GET',
|
|
48
|
+
family: 4,
|
|
49
|
+
rejectUnauthorized: false,
|
|
50
|
+
servername: 'foundry.projectcore.site',
|
|
51
|
+
headers: {
|
|
52
|
+
'Host': 'foundry.projectcore.site',
|
|
53
|
+
...(pat ? { 'Authorization': `Bearer ${pat}` } : {}),
|
|
54
|
+
},
|
|
55
|
+
timeout: 60000,
|
|
56
|
+
}, (ipRes) => {
|
|
57
|
+
if (ipRes.statusCode !== 200) {
|
|
58
|
+
return reject(new Error(`Nuvem (via IP) retornou HTTP ${ipRes.statusCode} ao baixar arquivo`));
|
|
59
|
+
}
|
|
60
|
+
const chunks = [];
|
|
61
|
+
ipRes.on('data', chunk => chunks.push(chunk));
|
|
62
|
+
ipRes.on('end', () => resolve(Buffer.concat(chunks)));
|
|
63
|
+
});
|
|
64
|
+
ipReq.on('error', (ipErr) => reject(new Error(`Falha ao baixar arquivo da nuvem (DNS e IP): ${ipErr.message}`)));
|
|
65
|
+
ipReq.on('timeout', () => { ipReq.destroy(); reject(new Error('Timeout de 60s ao baixar arquivo via IP')); });
|
|
66
|
+
ipReq.end();
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
reject(new Error(`Falha ao baixar arquivo da nuvem: ${err.message}`));
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
req.on('timeout', () => {
|
|
73
|
+
req.destroy();
|
|
74
|
+
reject(new Error('Timeout de 60s ao baixar arquivo da nuvem'));
|
|
75
|
+
});
|
|
76
|
+
req.end();
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
6
80
|
// Cache em memória das últimas telemetrias da Bambu (serialNumber -> { timestamp, print })
|
|
7
81
|
const latestBambuReports = new Map();
|
|
8
82
|
|
|
@@ -558,34 +632,102 @@ export async function executeLocalPrinterAction({ action, printerId, connectionC
|
|
|
558
632
|
// =====================================================
|
|
559
633
|
if (action === 'job/start') {
|
|
560
634
|
const cfg = readConfig();
|
|
561
|
-
const serverUrl = (cfg?.serverUrl || '').replace(/\/$/, '');
|
|
635
|
+
const serverUrl = (cfg?.serverUrl || 'https://foundry.projectcore.site').replace(/\/$/, '');
|
|
562
636
|
const pat = cfg?.apiKey;
|
|
637
|
+
const storageDir = cfg?.storagePath || path.join(process.cwd(), 'storage');
|
|
638
|
+
|
|
639
|
+
const originalName = payload?.originalName || payload?.filename || 'print.gcode';
|
|
640
|
+
const remoteName = payload?.remoteName || originalName;
|
|
641
|
+
const fileId = payload?.fileId;
|
|
642
|
+
|
|
643
|
+
let fileBuffer = null;
|
|
563
644
|
|
|
564
|
-
|
|
565
|
-
|
|
645
|
+
// A. Buffer recebido inline no WebSocket (Base64)
|
|
646
|
+
if (payload?.fileBase64) {
|
|
647
|
+
try {
|
|
648
|
+
fileBuffer = Buffer.from(payload.fileBase64, 'base64');
|
|
649
|
+
try {
|
|
650
|
+
if (!fs.existsSync(storageDir)) fs.mkdirSync(storageDir, { recursive: true });
|
|
651
|
+
fs.writeFileSync(path.join(storageDir, remoteName), fileBuffer);
|
|
652
|
+
} catch {}
|
|
653
|
+
} catch {}
|
|
566
654
|
}
|
|
567
655
|
|
|
568
|
-
//
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
656
|
+
// B. Se não veio inline, buscar no HD local do Edge Node
|
|
657
|
+
if (!fileBuffer && fs.existsSync(storageDir)) {
|
|
658
|
+
const candidates = [
|
|
659
|
+
path.join(storageDir, remoteName),
|
|
660
|
+
path.join(storageDir, originalName),
|
|
661
|
+
fileId ? path.join(storageDir, `${fileId}.gcode`) : null,
|
|
662
|
+
fileId ? path.join(storageDir, `${fileId}.3mf`) : null,
|
|
663
|
+
].filter(Boolean);
|
|
664
|
+
|
|
665
|
+
for (const cand of candidates) {
|
|
666
|
+
if (fs.existsSync(cand)) {
|
|
667
|
+
try {
|
|
668
|
+
fileBuffer = fs.readFileSync(cand);
|
|
669
|
+
break;
|
|
670
|
+
} catch {}
|
|
671
|
+
}
|
|
672
|
+
}
|
|
578
673
|
}
|
|
579
674
|
|
|
580
|
-
|
|
581
|
-
|
|
675
|
+
// C. Se for Klipper e o arquivo já estiver na máquina, iniciar imediatamente
|
|
676
|
+
if (!fileBuffer && type === 'klipper') {
|
|
677
|
+
try {
|
|
678
|
+
const listRes = await fetch(`${baseUrl}/server/files/list?root=gcodes`, {
|
|
679
|
+
headers,
|
|
680
|
+
signal: AbortSignal.timeout(6000),
|
|
681
|
+
});
|
|
682
|
+
if (listRes.ok) {
|
|
683
|
+
const filesData = await listRes.json();
|
|
684
|
+
const items = Array.isArray(filesData?.result) ? filesData.result : [];
|
|
685
|
+
const matched = items.find(item =>
|
|
686
|
+
item.filename === remoteName ||
|
|
687
|
+
item.filename === originalName ||
|
|
688
|
+
item.filename === path.basename(remoteName)
|
|
689
|
+
);
|
|
690
|
+
if (matched) {
|
|
691
|
+
const startRes = await fetch(`${baseUrl}/printer/print/start?filename=${encodeURIComponent(matched.filename)}`, {
|
|
692
|
+
method: 'POST',
|
|
693
|
+
headers,
|
|
694
|
+
signal: AbortSignal.timeout(10000),
|
|
695
|
+
});
|
|
696
|
+
if (startRes.ok) {
|
|
697
|
+
return {
|
|
698
|
+
online: true,
|
|
699
|
+
status: 'PRINTING',
|
|
700
|
+
message: `Impressão iniciada via Klipper (${matched.filename})`,
|
|
701
|
+
filename: matched.filename,
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
} catch {}
|
|
582
707
|
}
|
|
583
708
|
|
|
584
|
-
|
|
709
|
+
// D. Se ainda não tiver o arquivo localmente, baixar da nuvem com resiliência total
|
|
710
|
+
if (!fileBuffer && fileId) {
|
|
711
|
+
try {
|
|
712
|
+
fileBuffer = await downloadFileFromCloud({ serverUrl, fileId, pat });
|
|
713
|
+
if (fileBuffer) {
|
|
714
|
+
try {
|
|
715
|
+
if (!fs.existsSync(storageDir)) fs.mkdirSync(storageDir, { recursive: true });
|
|
716
|
+
fs.writeFileSync(path.join(storageDir, remoteName), fileBuffer);
|
|
717
|
+
} catch {}
|
|
718
|
+
}
|
|
719
|
+
} catch (err) {
|
|
720
|
+
if (type !== 'bambu') {
|
|
721
|
+
throw err;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
}
|
|
585
725
|
|
|
586
|
-
// KLIPPER / MOONRAKER (
|
|
726
|
+
// KLIPPER / MOONRAKER (K1, K1 Max, Ender 3, Voron, etc.)
|
|
587
727
|
if (type === 'klipper') {
|
|
588
|
-
|
|
728
|
+
if (!fileBuffer) {
|
|
729
|
+
throw new Error(`Arquivo "${originalName}" não encontrado localmente nem disponível para download`);
|
|
730
|
+
}
|
|
589
731
|
const form = new FormData();
|
|
590
732
|
form.append('file', new Blob([fileBuffer], { type: 'text/x-gcode' }), remoteName);
|
|
591
733
|
form.append('root', 'gcodes');
|
|
@@ -600,7 +742,7 @@ export async function executeLocalPrinterAction({ action, printerId, connectionC
|
|
|
600
742
|
signal: AbortSignal.timeout(120000),
|
|
601
743
|
});
|
|
602
744
|
} catch (err) {
|
|
603
|
-
throw new Error(`Não foi possível enviar o arquivo para
|
|
745
|
+
throw new Error(`Não foi possível enviar o arquivo para Klipper em ${baseUrl}: ${err.message}`);
|
|
604
746
|
}
|
|
605
747
|
|
|
606
748
|
if (!uploadRes.ok) {
|
|
@@ -616,36 +758,37 @@ export async function executeLocalPrinterAction({ action, printerId, connectionC
|
|
|
616
758
|
};
|
|
617
759
|
}
|
|
618
760
|
|
|
619
|
-
// BAMBU LAB (P1S,
|
|
761
|
+
// BAMBU LAB (P1S, P1P, X1 Carbon, X1E, A1, A1 mini)
|
|
620
762
|
if (type === 'bambu') {
|
|
621
763
|
const host = connectionConfig.host;
|
|
622
764
|
const port = connectionConfig.ftpPort || 990;
|
|
623
765
|
const accessCode = connectionConfig.accessCode;
|
|
624
766
|
const serialNumber = connectionConfig.serialNumber;
|
|
625
|
-
const remoteName = payload.remoteName || payload.originalName || 'print.gcode.3mf';
|
|
626
767
|
|
|
627
768
|
if (!host || !accessCode || !serialNumber) {
|
|
628
769
|
throw new Error('Configuração da Bambu incompleta para envio');
|
|
629
770
|
}
|
|
630
771
|
|
|
631
|
-
//
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
772
|
+
// Se temos o buffer, tenta enviar via FTPS
|
|
773
|
+
if (fileBuffer) {
|
|
774
|
+
const ftpClient = new FtpClient(60000);
|
|
775
|
+
try {
|
|
776
|
+
await ftpClient.access({
|
|
777
|
+
host,
|
|
778
|
+
port,
|
|
779
|
+
user: 'bblp',
|
|
780
|
+
password: accessCode,
|
|
781
|
+
secure: 'implicit',
|
|
782
|
+
secureOptions: { rejectUnauthorized: false },
|
|
783
|
+
});
|
|
784
|
+
await ftpClient.ensureDir('/');
|
|
785
|
+
const { Readable } = await import('node:stream');
|
|
786
|
+
await ftpClient.uploadFrom(Readable.from(fileBuffer), remoteName);
|
|
787
|
+
} catch (err) {
|
|
788
|
+
// Se falhar FTPS, prossegue com MQTT caso o arquivo já exista no cartão
|
|
789
|
+
} finally {
|
|
790
|
+
ftpClient.close();
|
|
791
|
+
}
|
|
649
792
|
}
|
|
650
793
|
|
|
651
794
|
// Dispara comando MQTT
|
|
@@ -712,6 +855,17 @@ export async function executeLocalPrinterAction({ action, printerId, connectionC
|
|
|
712
855
|
});
|
|
713
856
|
}
|
|
714
857
|
|
|
858
|
+
// OCTOPRINT / PRUSALINK / OUTROS
|
|
859
|
+
if (type === 'octoprint') {
|
|
860
|
+
if (!fileBuffer) throw new Error('Arquivo não disponível para envio ao OctoPrint');
|
|
861
|
+
const form = new FormData();
|
|
862
|
+
form.append('file', new Blob([fileBuffer], { type: 'text/x-gcode' }), remoteName);
|
|
863
|
+
form.append('select', 'true');
|
|
864
|
+
form.append('print', 'true');
|
|
865
|
+
const res = await fetch(`${baseUrl}/api/files/local`, { method: 'POST', headers, body: form });
|
|
866
|
+
return { online: true, status: 'PRINTING', message: 'Iniciado via OctoPrint', filename: remoteName };
|
|
867
|
+
}
|
|
868
|
+
|
|
715
869
|
throw new Error(`Protocolo "${type}" não suporta envio automático de arquivos`);
|
|
716
870
|
}
|
|
717
871
|
|