project-foundry 1.0.3 → 1.0.5
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 +23 -1
- package/package.json +2 -1
- package/src/local-printer-executor.js +176 -5
- package/src/tui.js +7 -0
- package/src/tunnel-client.js +12 -0
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
|
|
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
|
+
"version": "1.0.5",
|
|
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,72 @@
|
|
|
1
|
+
import mqtt from 'mqtt';
|
|
2
|
+
import { connect as connectTls } from 'node:tls';
|
|
3
|
+
|
|
4
|
+
function buildBambuCameraAuthPacket(accessCode) {
|
|
5
|
+
const packet = Buffer.alloc(80);
|
|
6
|
+
packet.writeUInt32LE(0x40, 0);
|
|
7
|
+
packet.writeUInt32LE(0x3000, 4);
|
|
8
|
+
packet.write('bblp', 16, 31, 'utf8');
|
|
9
|
+
packet.write(accessCode, 48, 31, 'utf8');
|
|
10
|
+
return packet;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function captureBambuJpegTls(connection) {
|
|
14
|
+
const host = (connection.host || '').trim().replace(/^\w+:\/\//, '').replace(/\/$/, '').split('/')[0];
|
|
15
|
+
const accessCode = connection.accessCode;
|
|
16
|
+
if (!host || !accessCode) throw new Error('Câmera Bambu requer IP e código de acesso LAN');
|
|
17
|
+
|
|
18
|
+
return new Promise((resolve, reject) => {
|
|
19
|
+
let pending = Buffer.alloc(0);
|
|
20
|
+
let expectedSize;
|
|
21
|
+
let settled = false;
|
|
22
|
+
|
|
23
|
+
const timer = setTimeout(() => {
|
|
24
|
+
finish(new Error('A câmera Bambu não respondeu dentro do tempo limite (12s)'));
|
|
25
|
+
}, 12000);
|
|
26
|
+
|
|
27
|
+
const socket = connectTls({ host, port: 6000, rejectUnauthorized: false });
|
|
28
|
+
|
|
29
|
+
const finish = (error, frame) => {
|
|
30
|
+
if (settled) return;
|
|
31
|
+
settled = true;
|
|
32
|
+
clearTimeout(timer);
|
|
33
|
+
try { socket.destroy(); } catch {}
|
|
34
|
+
if (error) reject(error);
|
|
35
|
+
else if (frame) resolve(frame);
|
|
36
|
+
else reject(new Error('A câmera Bambu encerrou sem entregar imagem'));
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
socket.once('secureConnect', () => {
|
|
40
|
+
try {
|
|
41
|
+
socket.write(buildBambuCameraAuthPacket(accessCode));
|
|
42
|
+
} catch (err) {
|
|
43
|
+
finish(err);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
socket.on('data', (chunk) => {
|
|
48
|
+
pending = Buffer.concat([pending, chunk]);
|
|
49
|
+
if (expectedSize === undefined && pending.length >= 16) {
|
|
50
|
+
expectedSize = pending.readUInt32LE(0);
|
|
51
|
+
if (expectedSize <= 0 || expectedSize > 12 * 1024 * 1024) {
|
|
52
|
+
finish(new Error('A câmera Bambu retornou um tamanho de quadro inválido'));
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (expectedSize !== undefined && pending.length >= expectedSize + 16) {
|
|
57
|
+
const frame = pending.subarray(16, expectedSize + 16);
|
|
58
|
+
if (frame[0] !== 0xff || frame[1] !== 0xd8) {
|
|
59
|
+
finish(new Error('A câmera Bambu não retornou uma imagem JPEG válida'));
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
finish(null, frame);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
socket.on('error', (err) => finish(err));
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
1
70
|
export async function executeLocalPrinterAction({ action, printerId, connectionConfig, payload }) {
|
|
2
71
|
if (!connectionConfig) {
|
|
3
72
|
throw new Error('Configuração de conexão da impressora não foi fornecida pelo servidor');
|
|
@@ -8,14 +77,27 @@ export async function executeLocalPrinterAction({ action, printerId, connectionC
|
|
|
8
77
|
const apiKey = connectionConfig.apiKey;
|
|
9
78
|
const headers = apiKey ? { 'X-Api-Key': apiKey } : {};
|
|
10
79
|
|
|
11
|
-
//
|
|
80
|
+
// =====================================================
|
|
81
|
+
// 1. Verificação de Conexão e Telemetria
|
|
82
|
+
// =====================================================
|
|
12
83
|
if (action === 'connect') {
|
|
84
|
+
// KLIPPER / MOONRAKER
|
|
13
85
|
if (type === 'klipper') {
|
|
14
|
-
|
|
86
|
+
let infoRes;
|
|
87
|
+
try {
|
|
88
|
+
infoRes = await fetch(`${baseUrl}/server/info`, {
|
|
89
|
+
headers,
|
|
90
|
+
signal: AbortSignal.timeout(8000),
|
|
91
|
+
});
|
|
92
|
+
} catch (err) {
|
|
93
|
+
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.`);
|
|
94
|
+
}
|
|
95
|
+
|
|
15
96
|
if (!infoRes.ok) {
|
|
16
|
-
throw new Error(`
|
|
97
|
+
throw new Error(`Klipper em ${baseUrl} respondeu com erro HTTP ${infoRes.status}`);
|
|
17
98
|
}
|
|
18
|
-
|
|
99
|
+
|
|
100
|
+
const info = await infoRes.json().catch(() => ({}));
|
|
19
101
|
const klippyState = info?.result?.klippy_state || 'ready';
|
|
20
102
|
|
|
21
103
|
let status = klippyState === 'ready' ? 'IDLE' : 'ERROR';
|
|
@@ -54,6 +136,84 @@ export async function executeLocalPrinterAction({ action, printerId, connectionC
|
|
|
54
136
|
};
|
|
55
137
|
}
|
|
56
138
|
|
|
139
|
+
// BAMBU LAB (LAN MQTT)
|
|
140
|
+
if (type === 'bambu') {
|
|
141
|
+
const host = connectionConfig.host;
|
|
142
|
+
const port = connectionConfig.mqttPort || 8883;
|
|
143
|
+
const username = connectionConfig.username || 'bblp';
|
|
144
|
+
const password = connectionConfig.accessCode;
|
|
145
|
+
const serialNumber = connectionConfig.serialNumber;
|
|
146
|
+
|
|
147
|
+
if (!host || !password || !serialNumber) {
|
|
148
|
+
throw new Error('Configuração da Bambu incompleta: host, accessCode e serialNumber são obrigatórios');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return new Promise((resolve, reject) => {
|
|
152
|
+
let isDone = false;
|
|
153
|
+
const timer = setTimeout(() => {
|
|
154
|
+
if (isDone) return;
|
|
155
|
+
isDone = true;
|
|
156
|
+
try { client.end(true); } catch {}
|
|
157
|
+
reject(new Error(`Tempo limite esgotado tentando conectar ao MQTT da Bambu em ${host}:${port}`));
|
|
158
|
+
}, 9000);
|
|
159
|
+
|
|
160
|
+
const client = mqtt.connect(`mqtts://${host}:${port}`, {
|
|
161
|
+
clientId: `foundry_edge_${Date.now()}`,
|
|
162
|
+
username,
|
|
163
|
+
password,
|
|
164
|
+
rejectUnauthorized: false,
|
|
165
|
+
connectTimeout: 8000,
|
|
166
|
+
clean: true,
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
client.on('connect', () => {
|
|
170
|
+
client.subscribe(`device/${serialNumber}/report`, () => {
|
|
171
|
+
client.publish(
|
|
172
|
+
`device/${serialNumber}/request`,
|
|
173
|
+
JSON.stringify({ pushing: { sequence_id: '1', command: 'pushall', version: 1, push_target: 1 } })
|
|
174
|
+
);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
client.on('message', (_topic, payloadBuffer) => {
|
|
179
|
+
if (isDone) return;
|
|
180
|
+
try {
|
|
181
|
+
const data = JSON.parse(payloadBuffer.toString('utf8'));
|
|
182
|
+
if (data.print) {
|
|
183
|
+
isDone = true;
|
|
184
|
+
clearTimeout(timer);
|
|
185
|
+
client.end(true);
|
|
186
|
+
const state = String(data.print.gcode_state || 'IDLE').toUpperCase();
|
|
187
|
+
const percent = Number(data.print.mc_percent || 0);
|
|
188
|
+
const isPrinting = state === 'RUNNING' || state === 'PREPARE';
|
|
189
|
+
resolve({
|
|
190
|
+
online: true,
|
|
191
|
+
status: isPrinting ? 'PRINTING' : (state === 'PAUSE' ? 'PAUSED' : 'IDLE'),
|
|
192
|
+
message: `Bambu LAN conectada (${state})`,
|
|
193
|
+
progress: percent,
|
|
194
|
+
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,
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
} catch {}
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
client.on('error', (err) => {
|
|
207
|
+
if (isDone) return;
|
|
208
|
+
isDone = true;
|
|
209
|
+
clearTimeout(timer);
|
|
210
|
+
try { client.end(true); } catch {}
|
|
211
|
+
reject(new Error(`Falha de conexão com a Bambu em ${host}: ${err.message}`));
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// OCTOPRINT
|
|
57
217
|
if (type === 'octoprint') {
|
|
58
218
|
const verRes = await fetch(`${baseUrl}/api/version`, { headers, signal: AbortSignal.timeout(8000) });
|
|
59
219
|
if (!verRes.ok) {
|
|
@@ -69,8 +229,15 @@ export async function executeLocalPrinterAction({ action, printerId, connectionC
|
|
|
69
229
|
throw new Error(`Protocolo de impressora "${type}" não suportado para conexão direta`);
|
|
70
230
|
}
|
|
71
231
|
|
|
72
|
-
//
|
|
232
|
+
// =====================================================
|
|
233
|
+
// 2. Snapshot de Câmera Local (Bambu / Klipper / OctoPrint)
|
|
234
|
+
// =====================================================
|
|
73
235
|
if (action === 'camera/snapshot') {
|
|
236
|
+
if (type === 'bambu') {
|
|
237
|
+
const jpegBuffer = await captureBambuJpegTls(connectionConfig);
|
|
238
|
+
return { base64: Buffer.from(jpegBuffer).toString('base64') };
|
|
239
|
+
}
|
|
240
|
+
|
|
74
241
|
const camUrl = connectionConfig.cameraSnapshotUrl || connectionConfig.cameraUrl || `${baseUrl}/webcam/?action=snapshot`;
|
|
75
242
|
const camRes = await fetch(camUrl, { signal: AbortSignal.timeout(10000) });
|
|
76
243
|
if (!camRes.ok) {
|
|
@@ -80,7 +247,9 @@ export async function executeLocalPrinterAction({ action, printerId, connectionC
|
|
|
80
247
|
return { base64: Buffer.from(buf).toString('base64') };
|
|
81
248
|
}
|
|
82
249
|
|
|
250
|
+
// =====================================================
|
|
83
251
|
// 3. Controle da Impressora (Pausa, Emergência, Motores, etc.)
|
|
252
|
+
// =====================================================
|
|
84
253
|
if (action === 'control') {
|
|
85
254
|
const cmd = payload?.command;
|
|
86
255
|
if (type === 'klipper') {
|
|
@@ -99,7 +268,9 @@ export async function executeLocalPrinterAction({ action, printerId, connectionC
|
|
|
99
268
|
}
|
|
100
269
|
}
|
|
101
270
|
|
|
271
|
+
// =====================================================
|
|
102
272
|
// 4. Comandos de Trabalho (Job Pause / Resume / Cancel)
|
|
273
|
+
// =====================================================
|
|
103
274
|
if (action.startsWith('job/')) {
|
|
104
275
|
const jobCmd = action.replace('job/', '');
|
|
105
276
|
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
|
);
|
package/src/tunnel-client.js
CHANGED
|
@@ -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
|
}
|