diff --git a/bin/server.mjs b/bin/server.mjs index 2d6cdc3..95819e6 100644 --- a/bin/server.mjs +++ b/bin/server.mjs @@ -2,7 +2,7 @@ import http from 'node:http'; import fs from 'node:fs'; import path from 'node:path'; import { randomUUID } from 'node:crypto'; -import { startFarmPolling, setPrinters, getPrinters, farmState, jobQueue, manualOverrides, settings, printerQueues, chooseAutoDispatchJob } from '../lib/farm.mjs'; +import { startFarmPolling, stopFarmPolling, setPrinters, getPrinters, farmState, jobQueue, manualOverrides, settings, printerQueues, chooseAutoDispatchJob, requireBedClearance } from '../lib/farm.mjs'; import { localSubnets, localSubnet, scanSubnet, normalizeSubnetInput } from '../lib/discovery.mjs'; import { uploadGcode, startPrint, confirmPrinting, pausePrint, resumePrint, cancelPrint } from '../lib/creality.mjs'; import { sanitizeFilename, resolveSafePath } from '../lib/server-helpers.mjs'; @@ -31,6 +31,7 @@ if (fs.existsSync(PRINTERS_JSON)) { console.error("Failed to parse printers.json", e); } } +requireBedClearance(printers); // Polling is started in the direct execution block below @@ -297,6 +298,10 @@ export const server = http.createServer(async (req, res) => { return; } + // Creality's status socket accepts very few simultaneous connections. + // Pause the regular sweep so it cannot race the discovery probe. + stopFarmPolling(); + try { const result = await scanSubnet(subnet); @@ -311,6 +316,7 @@ export const server = http.createServer(async (req, res) => { result.found = assignStablePrinterIds(result.found); reconcileDiscoveredPrinters(result.found); + requireBedClearance(result.found); const newPrinters = result.found.map((p) => ({ id: p.id, ip: p.ip, hostname: p.hostname })); setPrinters(newPrinters); fs.writeFileSync(PRINTERS_JSON, JSON.stringify(newPrinters, null, 2)); @@ -321,6 +327,8 @@ export const server = http.createServer(async (req, res) => { } catch (err) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: err.message })); + } finally { + startFarmPolling(getPrinters(), 2000); } return; } diff --git a/lib/discovery.mjs b/lib/discovery.mjs index 271a38c..7a8eb0c 100644 --- a/lib/discovery.mjs +++ b/lib/discovery.mjs @@ -109,8 +109,12 @@ export async function scanSubnet(subnet, options = {}) { throw new Error(`Invalid or unsupported subnet: ${subnet}. Must be RFC1918 /24 (e.g. 192.168.1).`); } - const timeoutMs = options.timeoutMs || 1500; - const concurrency = options.concurrency || 40; + // Real printers on a Windows hotspot can take just over 1.5 seconds to + // accept the status socket. Give them enough time while using wider batches + // so the two-pass scan remains reasonably quick. + const timeoutMs = options.timeoutMs || 2500; + const concurrency = options.concurrency || 64; + const attempts = options.attempts || 2; const start = options.start || 1; const end = options.end || 254; const probeFn = options.probeFn || probe; @@ -120,24 +124,33 @@ export async function scanSubnet(subnet, options = {}) { targets.push({ id: `scan_${i}`, ip: `${subnet}.${i}` }); } - const found = []; + const foundByIp = new Map(); const startTime = Date.now(); + let pending = targets; - for (let i = 0; i < targets.length; i += concurrency) { - const chunk = targets.slice(i, i + concurrency); - const results = await Promise.all(chunk.map(t => probeFn(t, timeoutMs))); - - for (const r of results) { - if (r.status === 'online') { - found.push(judge(r)); + for (let attempt = 1; attempt <= attempts && pending.length > 0; attempt += 1) { + const retry = []; + + for (let i = 0; i < pending.length; i += concurrency) { + const chunk = pending.slice(i, i + concurrency); + const results = await Promise.all(chunk.map(t => probeFn(t, timeoutMs))); + + for (const r of results) { + if (r.status === 'online') { + foundByIp.set(r.ip, judge(r)); + } else { + retry.push({ id: r.id, ip: r.ip }); + } } } + + pending = retry; } const durationMs = Date.now() - startTime; return { subnet, - found, + found: [...foundByIp.values()], scanned: end - start + 1, durationMs }; diff --git a/lib/farm.mjs b/lib/farm.mjs index 04e5b17..a8c72ab 100644 --- a/lib/farm.mjs +++ b/lib/farm.mjs @@ -14,9 +14,32 @@ export const settings = { // Printer-Specific Local Queues: Map of printer IP -> Array of { id, filename, filepath } export const printerQueues = new Map(); -// Manual overrides: Map of printer IP -> "needs_clearing" +// Manual overrides: Map of printer IP -> "needs_clearing" or "free" export const manualOverrides = new Map(); +/** + * Treat idle printers as physically unverified after startup or discovery. + * Firmware "free" only means no job is running; it cannot tell whether a + * finished part is still sitting on the bed. + */ +export function requireBedClearance(printers) { + for (const printer of printers) { + if (!printer?.ip) continue; + + if (printer.farmState === 'busy' || printer.farmState === 'paused') { + manualOverrides.delete(printer.ip); + continue; + } + + manualOverrides.set(printer.ip, 'needs_clearing'); + + const current = [...farmState.values()].find(state => state.ip === printer.ip); + if (current && (current.farmState === 'free' || current.farmState === 'needs_clearing')) { + current.farmState = 'needs_clearing'; + } + } +} + /** * Selects which queue may feed a free printer. * A printer with local Auto-Print enabled is exclusively reserved for its local @@ -89,7 +112,7 @@ export function startFarmPolling(initialPrinters, intervalMs = 5000) { // An online socket without a state field is not evidence of a transition. // Preserve the last trusted snapshot so an incomplete probe cannot make a // printing machine eligible for another dispatch. - if (!final.telemetryComplete && previous) continue; + if (raw.status === 'online' && !final.telemetryComplete && previous) continue; // If a printer was busy and is now free, lock it into needs_clearing. if (previous && previous.farmState === 'busy' && final.farmState === 'free' && final.telemetryComplete) { @@ -103,8 +126,16 @@ export function startFarmPolling(initialPrinters, intervalMs = 5000) { } const override = manualOverrides.get(final.ip); - if (override === 'needs_clearing') { + if ( + override === 'needs_clearing' + && (final.farmState === 'free' || final.farmState === 'needs_clearing') + ) { final.farmState = 'needs_clearing'; + } else if ( + override === 'needs_clearing' + && (final.farmState === 'busy' || final.farmState === 'paused') + ) { + manualOverrides.delete(final.ip); } else if (override === 'free' && (final.farmState === 'free' || final.farmState === 'needs_clearing')) { final.farmState = 'free'; } diff --git a/lib/probe.mjs b/lib/probe.mjs index 502afe8..0edc9a2 100644 --- a/lib/probe.mjs +++ b/lib/probe.mjs @@ -4,6 +4,33 @@ import { isPrinterActiveState, isPrinterPausedState, isPrinterTerminalState } from './printer-state.mjs'; +/** + * Prefer the printer's percentage, but recover from Creality telemetry that + * leaves it at 0 while valid layer counters continue to advance. + */ +export function derivePrintProgress(printProgress, layer, totalLayer) { + const reported = Number(printProgress); + const currentLayer = Number(layer); + const layerCount = Number(totalLayer); + + if (Number.isFinite(reported) && reported > 0) { + return Math.max(0, Math.min(100, reported)); + } + + if ( + Number.isFinite(currentLayer) + && Number.isFinite(layerCount) + && currentLayer > 0 + && layerCount > 0 + ) { + return Math.max(0, Math.min(100, Math.round((currentLayer / layerCount) * 100))); + } + + return Number.isFinite(reported) + ? Math.max(0, Math.min(100, reported)) + : 0; +} + /** * Probe a single printer to check network connectivity. * Opens a WebSocket connection to port 9999 and resolves when connected. @@ -88,7 +115,7 @@ export function judge(printer) { const state = printer.job; // Raw telemetry payload from probe() const file = String(state.printFileName || "").split(/[\\/]/).pop(); - const progress = state.printProgress || 0; + const progress = derivePrintProgress(state.printProgress, state.layer, state.totalLayer); const telemetryComplete = state.deviceState !== undefined || state.printState !== undefined; let farmState = telemetryComplete ? "free" : "error"; @@ -101,7 +128,7 @@ export function judge(printer) { // A newly-started print can briefly report zero target temperatures. Treating // that window as free can dispatch a second job to a moving printer. farmState = "busy"; - displayJob = `${file} ${progress}%`; + displayJob = file; } else if (file && isPrinterTerminalState(state.deviceState, state)) { farmState = "needs_clearing"; displayJob = file; diff --git a/public/app.js b/public/app.js index 407a2f5..8f45d7d 100644 --- a/public/app.js +++ b/public/app.js @@ -731,22 +731,40 @@ async function fetchStatus() { // Global Upload Handler if (globalFileInput) { globalFileInput.addEventListener('change', async (e) => { - const file = e.target.files[0]; - if (!file) return; - + const files = [...e.target.files]; + if (files.length === 0) return; + + const uploadCta = document.querySelector('#globalUpload .upload-cta'); + const originalCta = uploadCta?.textContent || 'Choose files'; + const failures = []; + globalFileInput.disabled = true; + try { - const res = await fetch(`/api/upload?filename=${encodeURIComponent(file.name)}`, { - method: 'POST', - headers: { 'Content-Type': 'application/octet-stream' }, - body: file - }); - if (!res.ok) throw new Error(await res.text()); - } catch (err) { - alert(`Queue error: ${err.message}`); + for (let i = 0; i < files.length; i += 1) { + const file = files[i]; + if (uploadCta) uploadCta.textContent = `Uploading ${i + 1}/${files.length}...`; + + try { + const res = await fetch(`/api/upload?filename=${encodeURIComponent(file.name)}`, { + method: 'POST', + headers: { 'Content-Type': 'application/octet-stream' }, + body: file + }); + if (!res.ok) throw new Error(await res.text()); + } catch (err) { + failures.push(`${file.name}: ${err.message}`); + } + } } finally { + globalFileInput.disabled = false; globalFileInput.value = ''; // clear input + if (uploadCta) uploadCta.textContent = originalCta; fetchStatus(); // trigger immediate refresh } + + if (failures.length > 0) { + alert(`Could not queue ${failures.length} file(s):\n${failures.join('\n')}`); + } }); } diff --git a/public/index.html b/public/index.html index 35f0ecc..6320bca 100644 --- a/public/index.html +++ b/public/index.html @@ -33,9 +33,9 @@

PrinterFarm

- Drop a file here or browse - Choose file - + Drop files here or browse + Choose files +

Network Discovery

@@ -102,9 +102,7 @@

Active Jobs

diff --git a/test/discovery.test.mjs b/test/discovery.test.mjs index e18ac99..9f05b48 100644 --- a/test/discovery.test.mjs +++ b/test/discovery.test.mjs @@ -80,6 +80,36 @@ test('scanSubnet() - returns judged online printers from probe results', async ( assert.equal(result.found[0].farmState, 'busy'); }); +test('scanSubnet() - retries a printer that misses the first discovery probe', async () => { + let calls = 0; + const result = await scanSubnet('192.168.137', { + start: 28, + end: 28, + timeoutMs: 10, + concurrency: 1, + attempts: 2, + probeFn: async (printer) => { + calls += 1; + if (calls === 1) { + return { ...printer, status: 'unreachable', job: 'timeout' }; + } + return { + ...printer, + status: 'online', + job: { + deviceState: 0, + hostname: 'Ender3V3KE-9E9F', + printFileName: '' + } + }; + } + }); + + assert.equal(calls, 2); + assert.equal(result.found.length, 1); + assert.equal(result.found[0].ip, '192.168.137.28'); +}); + test('normalizeSubnetInput() - normalizes full IPs and subnets', () => { assert.strictEqual(normalizeSubnetInput('192.168.1'), '192.168.1'); assert.strictEqual(normalizeSubnetInput('192.168.1.1'), '192.168.1'); diff --git a/test/farm.test.mjs b/test/farm.test.mjs index 9a8f79b..2c75f8d 100644 --- a/test/farm.test.mjs +++ b/test/farm.test.mjs @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { chooseAutoDispatchJob, farmState, getPrinters, setPrinters, settings } from '../lib/farm.mjs'; +import { chooseAutoDispatchJob, farmState, getPrinters, manualOverrides, requireBedClearance, setPrinters, settings, startFarmPolling, stopFarmPolling } from '../lib/farm.mjs'; test('global Auto-Print defaults off', () => { assert.equal(settings.autoAssign, false); @@ -20,6 +20,58 @@ test('setPrinters() updates active printers and prunes stale IDs', () => { assert.equal(farmState.has('old-id'), false); }); +test('requireBedClearance() locks newly discovered idle printers', () => { + farmState.clear(); + manualOverrides.clear(); + farmState.set('1', { + id: '1', + ip: '192.168.137.28', + farmState: 'free' + }); + + requireBedClearance([ + { id: '1', ip: '192.168.137.28', farmState: 'free' } + ]); + + assert.equal(manualOverrides.get('192.168.137.28'), 'needs_clearing'); + assert.equal(farmState.get('1').farmState, 'needs_clearing'); +}); + +test('requireBedClearance() does not hide an active print', () => { + farmState.clear(); + manualOverrides.clear(); + manualOverrides.set('192.168.137.28', 'needs_clearing'); + + requireBedClearance([ + { id: '1', ip: '192.168.137.28', farmState: 'busy' } + ]); + + assert.equal(manualOverrides.has('192.168.137.28'), false); +}); + +test('polling replaces stale online telemetry when a printer becomes unreachable', async (t) => { + farmState.clear(); + manualOverrides.clear(); + farmState.set('offline', { + id: 'offline', + ip: '127.0.0.2', + status: 'online', + farmState: 'free', + telemetryComplete: true + }); + + t.after(() => stopFarmPolling()); + startFarmPolling([{ id: 'offline', ip: '127.0.0.2' }], 50); + + const deadline = Date.now() + 1000; + while (farmState.get('offline')?.status !== 'unreachable' && Date.now() < deadline) { + await new Promise(resolve => setTimeout(resolve, 20)); + } + + assert.equal(farmState.get('offline')?.status, 'unreachable'); + assert.equal(farmState.get('offline')?.farmState, 'error'); +}); + test('chooseAutoDispatchJob() reserves local Auto-Print printers from the global queue', () => { const globalJob = { id: 'global', filename: 'global.gcode' }; const result = chooseAutoDispatchJob({ diff --git a/test/probe.test.mjs b/test/probe.test.mjs index 8ddb8a4..4963063 100644 --- a/test/probe.test.mjs +++ b/test/probe.test.mjs @@ -1,6 +1,13 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { judge } from '../lib/probe.mjs'; +import { derivePrintProgress, judge } from '../lib/probe.mjs'; + +test('derivePrintProgress() falls back to advancing layer telemetry', () => { + assert.equal(derivePrintProgress(0, 9, 18), 50); + assert.equal(derivePrintProgress(undefined, 1, 3), 33); + assert.equal(derivePrintProgress(0, 0, 18), 0); + assert.equal(derivePrintProgress(27, 9, 18), 27); +}); test('judge() - Returns error if network connection fails', () => { // Pass a simulated printer object that failed to connect @@ -41,7 +48,27 @@ test('judge() - Marks a printer as busy during an active print', () => { const result = judge(input); assert.equal(result.farmState, "busy"); - assert.equal(result.displayJob, "test.gcode 50%"); + assert.equal(result.displayJob, "test.gcode"); + assert.equal(result.printProgress, 50); +}); + +test('judge() derives progress from layers when printer percentage is stuck at zero', () => { + const result = judge({ + id: '4', + ip: '192.168.137.134', + status: 'online', + job: { + deviceState: 'print', + printFileName: 'kf_eric.gcode', + printProgress: 0, + layer: 9, + totalLayer: 18 + } + }); + + assert.equal(result.farmState, 'busy'); + assert.equal(result.printProgress, 50); + assert.equal(result.displayJob, 'kf_eric.gcode'); }); test('judge() - Keeps a reported print busy while target temperatures are zero', () => {