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 @@