project-foundry 1.0.3 → 1.0.4

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/bin/foundry.js CHANGED
@@ -17,7 +17,7 @@ program
17
17
  .description('Project Foundry CLI & Edge Node Manager')
18
18
  .version('1.0.0');
19
19
 
20
- // Comando: daemon (segundo plano)
20
+ // Comando: daemon
21
21
  program
22
22
  .command('daemon')
23
23
  .description('Inicia o servidor e o túnel reverso em segundo plano')
@@ -25,6 +25,28 @@ program
25
25
  startDaemon();
26
26
  });
27
27
 
28
+ // Comando: start (inicia o daemon diretamente exibindo logs em tempo real)
29
+ program
30
+ .command('start')
31
+ .description('Inicia o servidor local e o túnel com logs visíveis no terminal')
32
+ .action(() => {
33
+ console.log(pc.bold(pc.cyan('Iniciando Project Foundry Local Server...')));
34
+ startDaemon();
35
+ });
36
+
37
+ // Comando: service-install
38
+ program
39
+ .command('service-install')
40
+ .description('Instala ou atualiza o serviço de autostart no sistema operacional')
41
+ .action(() => {
42
+ const res = installAutostartService(process.argv[1]);
43
+ if (res.success) {
44
+ console.log(pc.green(`Serviço registrado com sucesso (${res.type}): ${res.name}`));
45
+ } else {
46
+ console.error(pc.red(`Falha ao registrar serviço: ${res.error}`));
47
+ }
48
+ });
49
+
28
50
  // Comando: setup
29
51
  program
30
52
  .command('setup')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "project-foundry",
3
- "version": "1.0.3",
3
+ "version": "1.0.4",
4
4
  "description": "Project Foundry CLI & Local Node Daemon Manager",
5
5
  "bin": {
6
6
  "project-foundry": "bin/foundry.js",
@@ -18,6 +18,7 @@
18
18
  "dependencies": {
19
19
  "@clack/prompts": "^0.9.1",
20
20
  "commander": "^12.1.0",
21
+ "mqtt": "^5.16.1",
21
22
  "open": "^10.1.0",
22
23
  "picocolors": "^1.1.1",
23
24
  "ws": "^8.22.0"
@@ -1,3 +1,5 @@
1
+ import mqtt from 'mqtt';
2
+
1
3
  export async function executeLocalPrinterAction({ action, printerId, connectionConfig, payload }) {
2
4
  if (!connectionConfig) {
3
5
  throw new Error('Configuração de conexão da impressora não foi fornecida pelo servidor');
@@ -8,14 +10,27 @@ export async function executeLocalPrinterAction({ action, printerId, connectionC
8
10
  const apiKey = connectionConfig.apiKey;
9
11
  const headers = apiKey ? { 'X-Api-Key': apiKey } : {};
10
12
 
13
+ // =====================================================
11
14
  // 1. Verificação de Conexão
15
+ // =====================================================
12
16
  if (action === 'connect') {
17
+ // KLIPPER / MOONRAKER
13
18
  if (type === 'klipper') {
14
- const infoRes = await fetch(`${baseUrl}/server/info`, { headers, signal: AbortSignal.timeout(8000) });
19
+ let infoRes;
20
+ try {
21
+ infoRes = await fetch(`${baseUrl}/server/info`, {
22
+ headers,
23
+ signal: AbortSignal.timeout(8000),
24
+ });
25
+ } catch (err) {
26
+ throw new Error(`Não foi possível conectar ao Klipper em ${baseUrl} (${err.code || err.message}). Verifique se a impressora está ligada e o IP/porta estão corretos.`);
27
+ }
28
+
15
29
  if (!infoRes.ok) {
16
- throw new Error(`Moonraker retornou HTTP ${infoRes.status}: ${infoRes.statusText}`);
30
+ throw new Error(`Klipper em ${baseUrl} respondeu com erro HTTP ${infoRes.status}`);
17
31
  }
18
- const info = await infoRes.json();
32
+
33
+ const info = await infoRes.json().catch(() => ({}));
19
34
  const klippyState = info?.result?.klippy_state || 'ready';
20
35
 
21
36
  let status = klippyState === 'ready' ? 'IDLE' : 'ERROR';
@@ -54,6 +69,82 @@ export async function executeLocalPrinterAction({ action, printerId, connectionC
54
69
  };
55
70
  }
56
71
 
72
+ // BAMBU LAB (LAN MQTT)
73
+ if (type === 'bambu') {
74
+ const host = connectionConfig.host;
75
+ const port = connectionConfig.mqttPort || 8883;
76
+ const username = connectionConfig.username || 'bblp';
77
+ const password = connectionConfig.accessCode;
78
+ const serialNumber = connectionConfig.serialNumber;
79
+
80
+ if (!host || !password || !serialNumber) {
81
+ throw new Error('Configuração da Bambu incompleta: host, accessCode e serialNumber são obrigatórios');
82
+ }
83
+
84
+ return new Promise((resolve, reject) => {
85
+ let isDone = false;
86
+ const timer = setTimeout(() => {
87
+ if (isDone) return;
88
+ isDone = true;
89
+ try { client.end(true); } catch {}
90
+ reject(new Error(`Tempo limite esgotado tentando conectar ao MQTT da Bambu em ${host}:${port}`));
91
+ }, 9000);
92
+
93
+ const client = mqtt.connect(`mqtts://${host}:${port}`, {
94
+ clientId: `foundry_edge_${Date.now()}`,
95
+ username,
96
+ password,
97
+ rejectUnauthorized: false,
98
+ connectTimeout: 8000,
99
+ clean: true,
100
+ });
101
+
102
+ client.on('connect', () => {
103
+ client.subscribe(`device/${serialNumber}/report`, () => {
104
+ client.publish(
105
+ `device/${serialNumber}/request`,
106
+ JSON.stringify({ pushing: { sequence_id: '1', command: 'pushall', version: 1, push_target: 1 } })
107
+ );
108
+ });
109
+ });
110
+
111
+ client.on('message', (_topic, payloadBuffer) => {
112
+ if (isDone) return;
113
+ try {
114
+ const data = JSON.parse(payloadBuffer.toString('utf8'));
115
+ if (data.print) {
116
+ isDone = true;
117
+ clearTimeout(timer);
118
+ client.end(true);
119
+ const state = String(data.print.gcode_state || 'IDLE').toUpperCase();
120
+ const percent = Number(data.print.mc_percent || 0);
121
+ resolve({
122
+ online: true,
123
+ status: state === 'RUNNING' || state === 'PREPARE' ? 'PRINTING' : (state === 'PAUSE' ? 'PAUSED' : 'IDLE'),
124
+ message: `Bambu LAN conectada (${state})`,
125
+ progress: percent,
126
+ temperatures: {
127
+ hotend: data.print.nozzle_temper,
128
+ hotendTarget: data.print.nozzle_target_temper,
129
+ bed: data.print.bed_temper,
130
+ bedTarget: data.print.bed_target_temper,
131
+ },
132
+ });
133
+ }
134
+ } catch {}
135
+ });
136
+
137
+ client.on('error', (err) => {
138
+ if (isDone) return;
139
+ isDone = true;
140
+ clearTimeout(timer);
141
+ try { client.end(true); } catch {}
142
+ reject(new Error(`Falha de conexão com a Bambu em ${host}: ${err.message}`));
143
+ });
144
+ });
145
+ }
146
+
147
+ // OCTOPRINT
57
148
  if (type === 'octoprint') {
58
149
  const verRes = await fetch(`${baseUrl}/api/version`, { headers, signal: AbortSignal.timeout(8000) });
59
150
  if (!verRes.ok) {
@@ -69,7 +160,9 @@ export async function executeLocalPrinterAction({ action, printerId, connectionC
69
160
  throw new Error(`Protocolo de impressora "${type}" não suportado para conexão direta`);
70
161
  }
71
162
 
163
+ // =====================================================
72
164
  // 2. Snapshot de Câmera Local
165
+ // =====================================================
73
166
  if (action === 'camera/snapshot') {
74
167
  const camUrl = connectionConfig.cameraSnapshotUrl || connectionConfig.cameraUrl || `${baseUrl}/webcam/?action=snapshot`;
75
168
  const camRes = await fetch(camUrl, { signal: AbortSignal.timeout(10000) });
@@ -80,7 +173,9 @@ export async function executeLocalPrinterAction({ action, printerId, connectionC
80
173
  return { base64: Buffer.from(buf).toString('base64') };
81
174
  }
82
175
 
176
+ // =====================================================
83
177
  // 3. Controle da Impressora (Pausa, Emergência, Motores, etc.)
178
+ // =====================================================
84
179
  if (action === 'control') {
85
180
  const cmd = payload?.command;
86
181
  if (type === 'klipper') {
@@ -99,7 +194,9 @@ export async function executeLocalPrinterAction({ action, printerId, connectionC
99
194
  }
100
195
  }
101
196
 
197
+ // =====================================================
102
198
  // 4. Comandos de Trabalho (Job Pause / Resume / Cancel)
199
+ // =====================================================
103
200
  if (action.startsWith('job/')) {
104
201
  const jobCmd = action.replace('job/', '');
105
202
  if (type === 'klipper') {
package/src/tui.js CHANGED
@@ -7,6 +7,7 @@ import pc from 'picocolors';
7
7
  import open from 'open';
8
8
  import { readConfig, updateStoragePath } from './config.js';
9
9
  import { isServerHealthy, stopRunningDaemon, restartAutostartService } from './service-manager.js';
10
+ import { edgeTunnelClient } from './tunnel-client.js';
10
11
 
11
12
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
12
13
 
@@ -25,12 +26,18 @@ export async function runInteractiveTui() {
25
26
  const localPort = config?.localPort || 3010;
26
27
  const localUrl = `http://localhost:${localPort}`;
27
28
 
29
+ // Garante que o túnel de comandos com a Nuvem esteja ativo
30
+ if (config?.apiKey && config?.serverUrl) {
31
+ edgeTunnelClient.start();
32
+ }
33
+
28
34
  console.clear();
29
35
 
30
36
  p.intro(
31
37
  `${pc.bold(pc.cyan('======================================================'))}\n` +
32
38
  ` ${pc.bold(pc.yellow(`Project Foundry Interface (${version})`))}\n` +
33
39
  ` 🚀 Server: ${pc.green(localUrl)}\n` +
40
+ ` 🌐 Nuvem: ${edgeTunnelClient.isConnected() ? pc.green('Conectado (Túnel Ativo)') : pc.cyan('Túnel Iniciado')}\n` +
34
41
  ` 💾 Storage: ${pc.cyan(config?.storagePath || 'Padrão')}\n` +
35
42
  `${pc.bold(pc.cyan('======================================================'))}`
36
43
  );
@@ -8,6 +8,11 @@ export class EdgeTunnelClient {
8
8
  this.pingTimer = null;
9
9
  this.reconnectTimer = null;
10
10
  this.isStopping = false;
11
+ this.status = 'DISCONNECTED';
12
+ }
13
+
14
+ isConnected() {
15
+ return Boolean(this.ws && this.ws.readyState === WebSocket.OPEN);
11
16
  }
12
17
 
13
18
  start() {
@@ -16,12 +21,15 @@ export class EdgeTunnelClient {
16
21
  return;
17
22
  }
18
23
 
24
+ if (this.isConnected()) return;
25
+
19
26
  this.isStopping = false;
20
27
  this.connect();
21
28
  }
22
29
 
23
30
  stop() {
24
31
  this.isStopping = true;
32
+ this.status = 'DISCONNECTED';
25
33
  if (this.pingTimer) clearInterval(this.pingTimer);
26
34
  if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
27
35
  if (this.ws) {
@@ -50,6 +58,7 @@ export class EdgeTunnelClient {
50
58
  });
51
59
 
52
60
  this.ws.on('open', () => {
61
+ this.status = 'CONNECTED';
53
62
  // Envia PING a cada 25 segundos
54
63
  if (this.pingTimer) clearInterval(this.pingTimer);
55
64
  this.pingTimer = setInterval(() => {
@@ -69,15 +78,18 @@ export class EdgeTunnelClient {
69
78
  });
70
79
 
71
80
  this.ws.on('close', () => {
81
+ this.status = 'DISCONNECTED';
72
82
  if (this.pingTimer) clearInterval(this.pingTimer);
73
83
  this.scheduleReconnect();
74
84
  });
75
85
 
76
86
  this.ws.on('error', () => {
87
+ this.status = 'DISCONNECTED';
77
88
  if (this.pingTimer) clearInterval(this.pingTimer);
78
89
  this.scheduleReconnect();
79
90
  });
80
91
  } catch {
92
+ this.status = 'DISCONNECTED';
81
93
  this.scheduleReconnect();
82
94
  }
83
95
  }