Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion bin/server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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);

Expand All @@ -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));
Expand All @@ -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;
}
Expand Down
35 changes: 24 additions & 11 deletions lib/discovery.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
};
Expand Down
37 changes: 34 additions & 3 deletions lib/farm.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand All @@ -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';
}
Expand Down
31 changes: 29 additions & 2 deletions lib/probe.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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";
Expand All @@ -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;
Expand Down
40 changes: 29 additions & 11 deletions public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')}`);
}
});
}

Expand Down
10 changes: 4 additions & 6 deletions public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,9 @@ <h1>PrinterFarm</h1>
<line x1="12" y1="3" x2="12" y2="15"></line>
</svg>
<label>Add G-code</label>
<span class="upload-hint">Drop a file here or browse</span>
<span class="upload-cta">Choose file</span>
<input type="file" accept=".gcode" id="globalFileInput">
<span class="upload-hint">Drop files here or browse</span>
<span class="upload-cta">Choose files</span>
<input type="file" accept=".gcode" id="globalFileInput" multiple>
</div>
<div class="discovery-section">
<h3>Network Discovery</h3>
Expand Down Expand Up @@ -102,9 +102,7 @@ <h2 id="active-jobs-title">Active Jobs</h2>
<div id="farm-empty-state" style="display: none; padding: 32px; text-align: center; color: var(--color-secondary); border: 1px dashed var(--color-border, #444); border-radius: 8px; margin-top: 8px;">
<p style="margin-bottom: 8px; font-weight: 600;">No printers yet</p>
<p style="font-size: 12px; line-height: 1.6;">
Use <strong>DISCOVER</strong> in the sidebar to scan your network for printers.<br>
No printer? Run the simulator: <code>node bin/mock-printer.mjs</code>, add
<code>{ "id": "1", "ip": "127.0.0.1" }</code> to <code>printers.json</code>, and restart.
Use <strong>DISCOVER</strong> in the sidebar to find printers on your network.
</p>
</div>
</main>
Expand Down
30 changes: 30 additions & 0 deletions test/discovery.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
54 changes: 53 additions & 1 deletion test/farm.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
Expand All @@ -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({
Expand Down
Loading
Loading