project-foundry 1.0.8 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "project-foundry",
3
- "version": "1.0.8",
3
+ "version": "1.1.0",
4
4
  "description": "Project Foundry CLI & Local Node Daemon Manager",
5
5
  "bin": {
6
6
  "project-foundry": "bin/foundry.js",
@@ -17,6 +17,7 @@
17
17
  },
18
18
  "dependencies": {
19
19
  "@clack/prompts": "^0.9.1",
20
+ "basic-ftp": "^6.2.4",
20
21
  "commander": "^12.1.0",
21
22
  "mqtt": "^5.16.1",
22
23
  "open": "^10.1.0",
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
- cloudRes.pipe(res);
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,5 +1,81 @@
1
1
  import mqtt from 'mqtt';
2
2
  import { connect as connectTls } from 'node:tls';
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';
8
+ import { readConfig } from './config.js';
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
+ }
3
79
 
4
80
  // Cache em memória das últimas telemetrias da Bambu (serialNumber -> { timestamp, print })
5
81
  const latestBambuReports = new Map();
@@ -551,5 +627,247 @@ export async function executeLocalPrinterAction({ action, printerId, connectionC
551
627
  });
552
628
  }
553
629
 
630
+ // =====================================================
631
+ // 6. Início e Envio de Trabalho (Job Start via LAN)
632
+ // =====================================================
633
+ if (action === 'job/start') {
634
+ const cfg = readConfig();
635
+ const serverUrl = (cfg?.serverUrl || 'https://foundry.projectcore.site').replace(/\/$/, '');
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;
644
+
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 {}
654
+ }
655
+
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
+ }
673
+ }
674
+
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 {}
707
+ }
708
+
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
+ }
725
+
726
+ // KLIPPER / MOONRAKER (K1, K1 Max, Ender 3, Voron, etc.)
727
+ if (type === 'klipper') {
728
+ if (!fileBuffer) {
729
+ throw new Error(`Arquivo "${originalName}" não encontrado localmente nem disponível para download`);
730
+ }
731
+ const form = new FormData();
732
+ form.append('file', new Blob([fileBuffer], { type: 'text/x-gcode' }), remoteName);
733
+ form.append('root', 'gcodes');
734
+ form.append('print', 'true');
735
+
736
+ let uploadRes;
737
+ try {
738
+ uploadRes = await fetch(`${baseUrl}/server/files/upload`, {
739
+ method: 'POST',
740
+ headers,
741
+ body: form,
742
+ signal: AbortSignal.timeout(120000),
743
+ });
744
+ } catch (err) {
745
+ throw new Error(`Não foi possível enviar o arquivo para Klipper em ${baseUrl}: ${err.message}`);
746
+ }
747
+
748
+ if (!uploadRes.ok) {
749
+ const detail = await uploadRes.text().catch(() => '');
750
+ throw new Error(`Klipper recusou o início (${uploadRes.status}): ${detail.slice(0, 200)}`);
751
+ }
752
+
753
+ return {
754
+ online: true,
755
+ status: 'PRINTING',
756
+ message: 'Impressão iniciada com sucesso via Klipper',
757
+ filename: remoteName,
758
+ };
759
+ }
760
+
761
+ // BAMBU LAB (P1S, P1P, X1 Carbon, X1E, A1, A1 mini)
762
+ if (type === 'bambu') {
763
+ const host = connectionConfig.host;
764
+ const port = connectionConfig.ftpPort || 990;
765
+ const accessCode = connectionConfig.accessCode;
766
+ const serialNumber = connectionConfig.serialNumber;
767
+
768
+ if (!host || !accessCode || !serialNumber) {
769
+ throw new Error('Configuração da Bambu incompleta para envio');
770
+ }
771
+
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
+ }
792
+ }
793
+
794
+ // Dispara comando MQTT
795
+ return new Promise((resolve, reject) => {
796
+ const client = mqtt.connect(`mqtts://${host}:${connectionConfig.mqttPort || 8883}`, {
797
+ username: connectionConfig.username || 'bblp',
798
+ password: accessCode,
799
+ rejectUnauthorized: false,
800
+ connectTimeout: 8000,
801
+ clean: true,
802
+ });
803
+
804
+ const timer = setTimeout(() => {
805
+ try { client.end(true); } catch {}
806
+ reject(new Error('Timeout aguardando confirmação MQTT da Bambu'));
807
+ }, 12000);
808
+
809
+ const isP1orA1 = Boolean(
810
+ serialNumber.startsWith('01P') || serialNumber.startsWith('030') || serialNumber.startsWith('039')
811
+ );
812
+
813
+ const printCmd = {
814
+ print: {
815
+ sequence_id: String(Date.now()),
816
+ command: 'project_file',
817
+ param: `Metadata/plate_${payload.plateNumber || 1}.gcode`,
818
+ project_id: '0',
819
+ profile_id: '0',
820
+ task_id: '0',
821
+ subtask_id: '0',
822
+ subtask_name: remoteName,
823
+ file: remoteName,
824
+ url: `ftp:///${remoteName}`,
825
+ md5: '',
826
+ timelapse: Boolean(payload.printSettings?.timelapse),
827
+ bed_type: isP1orA1 ? 'textured_plate' : 'auto',
828
+ bed_levelling: payload.printSettings?.bedLeveling !== false,
829
+ flow_cali: isP1orA1 ? false : Boolean(payload.printSettings?.flowCalibration),
830
+ vibration_cali: payload.printSettings?.vibrationCalibration !== false,
831
+ layer_inspect: !isP1orA1,
832
+ use_ams: Boolean(payload.printSettings?.useAms),
833
+ ams_mapping: Array.isArray(payload.printSettings?.amsMapping) ? payload.printSettings.amsMapping : [0],
834
+ },
835
+ };
836
+
837
+ client.on('connect', () => {
838
+ client.publish(`device/${serialNumber}/request`, JSON.stringify(printCmd), () => {
839
+ clearTimeout(timer);
840
+ client.end(true);
841
+ resolve({
842
+ online: true,
843
+ status: 'PRINTING',
844
+ message: 'Impressão iniciada via Bambu LAN',
845
+ filename: remoteName,
846
+ });
847
+ });
848
+ });
849
+
850
+ client.on('error', (err) => {
851
+ clearTimeout(timer);
852
+ try { client.end(true); } catch {}
853
+ reject(err);
854
+ });
855
+ });
856
+ }
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
+
869
+ throw new Error(`Protocolo "${type}" não suporta envio automático de arquivos`);
870
+ }
871
+
554
872
  throw new Error(`Ação "${action}" não suportada`);
555
873
  }