From 8ae58a63e69dbb179a70ba4f7e3b5f3c3acd8fd6 Mon Sep 17 00:00:00 2001 From: Martin Fuchs Date: Sun, 16 Aug 2026 20:50:03 +0200 Subject: [PATCH] reconnect and resume --- examples/netz/index.html | 2 +- examples/zickzack/index.html | 2 +- lib/WallPlotter.js | 365 +++++++++++++++++++++++++++++++---- 3 files changed, 334 insertions(+), 35 deletions(-) diff --git a/examples/netz/index.html b/examples/netz/index.html index 175c22b..2f9cda3 100644 --- a/examples/netz/index.html +++ b/examples/netz/index.html @@ -42,7 +42,7 @@ } - + diff --git a/examples/zickzack/index.html b/examples/zickzack/index.html index 175c22b..2f9cda3 100644 --- a/examples/zickzack/index.html +++ b/examples/zickzack/index.html @@ -42,7 +42,7 @@ } - + diff --git a/lib/WallPlotter.js b/lib/WallPlotter.js index 72ea987..aadd8b2 100644 --- a/lib/WallPlotter.js +++ b/lib/WallPlotter.js @@ -18,7 +18,7 @@ class WallPlotter { // Static constants // --------------------------------------------------------------------------- - static VERSION = '2.3.0'; + static VERSION = '2.3.4'; static MIN_FEED_MM_MIN = 1; static MAX_FEED_MM_MIN = 10000; /** Offline overlay size when not connected (mm) — 150×150 cm centred on origin */ @@ -29,6 +29,9 @@ class WallPlotter { static OK_WAIT_LOG_MS = 1000; /** Repeat “still waiting” while blocked on `ok` */ static OK_WAIT_PING_MS = 5000; + /** If a USB write does not complete, the ACM device is usually gone. */ + static WRITE_TIMEOUT_MS = 3000; + static RESUME_STORAGE_KEY = 'wallplotter.resume.v1'; // --------------------------------------------------------------------------- // Construction @@ -99,6 +102,7 @@ class WallPlotter { this._plotTestOnly = false; this._pathRunId = 0; this._pathLoopActive = false; + this._interrupted = null; this._plotStartedAt = 0; this._plotEstimateMs = 0; this._pausedAccumMs = 0; @@ -132,6 +136,7 @@ class WallPlotter { this._testLabel = 'Test'; this._stopLabel = 'Stop'; this._lastTestBtnClick = 0; + this._lastPlotBtnClick = 0; // --- Callbacks --- const userOnLog = options.onLog ?? (() => {}); @@ -150,6 +155,7 @@ class WallPlotter { this._unloadBound = false; this._installUnloadHandler(); + this._loadResumeState(); } get connected() { @@ -293,6 +299,7 @@ class WallPlotter { /** Clear the path buffer. Returns this for chaining. */ startPath() { + this._clearResumeState(); this._path = []; return this; } @@ -351,6 +358,7 @@ class WallPlotter { /** Abort the current runPath after the in-flight command's `ok`. */ stopPath() { + this._clearResumeState(); this._abortJob(); } @@ -417,19 +425,25 @@ class WallPlotter { await this._openPort(port); } this.port = port; + this._bindSerialEvents(port); this.writer = this.port.writable.getWriter(); this._startReadLoop(); this._link = 'connected'; - this.homeSet = false; - this._homeWarning = 'Home not set — jog to origin and press H'; this.onStatusChange('connected'); try { - await this.send('M17'); + await this.send('M17', { allowWithoutHome: true }); await this.penUp(true); - await this.send('M665'); - await this.send('M114'); + await this.send('M665', { allowWithoutHome: true }); + await this._syncPoseFromMachine(); + if (this._interrupted) { + this.onLog( + `saved path ${this._interrupted.index}/${this._interrupted.commands.length} — Plot resumes` + ); + this.statusText = 'Connected — Plot to resume'; + this._updatePlotButtons(); + } } catch (err) { if (err.name === 'AbortError') return; throw err; @@ -438,12 +452,16 @@ class WallPlotter { async _acquirePort() { const existing = await navigator.serial.getPorts(); - if (existing.length === 1) return existing[0]; + if (existing.length >= 1) return existing[0]; return navigator.serial.requestPort(); } async _openPort(port) { - const opened = port.open({ baudRate: this.baudRate }); + const opened = port.open({ + baudRate: this.baudRate, + bufferSize: 65536, + flowControl: 'none', + }); const timedOut = this._delay(3000).then(() => { const err = new Error( 'serial open timeout — port still held after reload. Unplug USB or close other serial tabs.' @@ -453,19 +471,19 @@ class WallPlotter { }); try { await Promise.race([opened, timedOut]); + if (typeof port.setSignals === 'function') { + try { + await port.setSignals({ dataTerminalReady: true, requestToSend: false }); + } catch (_) { + /* some stacks ignore control lines */ + } + } } catch (err) { try { await port.close(); } catch (_) { /* ignore */ } - if (typeof port.forget === 'function') { - try { - await port.forget(); - } catch (_) { - /* ignore */ - } - } throw err; } } @@ -513,8 +531,6 @@ class WallPlotter { } this._link = 'disconnected'; - this.homeSet = false; - this._homeWarning = null; this.onStatusChange('disconnected'); } @@ -531,7 +547,6 @@ class WallPlotter { this.writer = null; this.port = null; this._link = 'disconnected'; - this.homeSet = false; try { reader?.cancel(); } catch (_) { @@ -554,10 +569,114 @@ class WallPlotter { } } + _writeLine(command) { + if (!this.writer) { + return Promise.reject(new Error('Not connected to plotter')); + } + const bytes = new TextEncoder().encode(command + '\n'); + return new Promise((resolve, reject) => { + let done = false; + const timer = setTimeout(() => { + if (done) return; + done = true; + const err = new Error('serial write timeout — USB port likely gone'); + err.name = 'TimeoutError'; + reject(err); + }, WallPlotter.WRITE_TIMEOUT_MS); + this.writer.write(bytes).then( + () => { + if (done) return; + done = true; + clearTimeout(timer); + this.onLog('→ ' + command); + resolve(); + }, + (err) => { + if (done) return; + done = true; + clearTimeout(timer); + reject(err); + } + ); + }); + } + + _bindSerialEvents(port) { + if (this._serialDisconnectBound) { + navigator.serial.removeEventListener('disconnect', this._serialDisconnectBound); + } + this._serialDisconnectBound = (event) => { + if (event.target === port || event.target === this.port) { + this._onSerialGone('device unplugged or re-enumerated'); + } + }; + navigator.serial.addEventListener('disconnect', this._serialDisconnectBound); + if (port && typeof port.addEventListener === 'function') { + port.addEventListener('disconnect', () => { + this._onSerialGone('device unplugged or re-enumerated'); + }); + } + } + + _onSerialGone(reason) { + if (this._link === 'disconnected') return; + this._stashInterruptedPath(); + this.onLog('! USB serial lost — ' + reason); + this.onLog( + `kept pose X ${this.x.toFixed(1)} Y ${this.y.toFixed(1)} — reconnect will ask Marlin (M114)` + ); + this._abortJob(); + this._releaseSerialNow(); + this.statusText = this._interrupted + ? 'USB disconnected — reconnect, then Plot to resume' + : 'USB disconnected — reconnect'; + this._updatePlotButtons(); + this._redrawSketch(); + } + + _stashInterruptedPath() { + if (this._job === 'idle' && !this._pathLoopActive) return; + const commands = this.previewPath; + if (!commands.length) return; + if (this.plotProgress >= commands.length) return; + this._interrupted = { + commands, + index: this.plotProgress, + testOnly: this._plotTestOnly, + startedAt: this._plotStartedAt, + pausedAccum: this._pausedAccumMs, + estimateMs: this._plotEstimateMs, + }; + this.onLog( + `plot paused at ${this.plotProgress}/${commands.length} — Plot resumes this path` + ); + this._persistResumeState(); + } + + _resumeInterruptedPath() { + const hit = this._interrupted; + if (!hit) return false; + this._interrupted = null; + this.onLog( + `resuming path at ${hit.index}/${hit.commands.length}` + ); + Promise.resolve( + this._executePath(hit.commands, hit.testOnly, hit.index, hit) + ).catch((err) => { + this.statusText = 'Error: ' + err.message; + this.onLog('! ' + err.message); + }); + return true; + } + _installUnloadHandler() { if (this._unloadBound) return; this._unloadBound = true; - const release = () => this._releaseSerialNow(); + const release = () => { + this._stashInterruptedPath(); + this._persistResumeState(true); + this._releaseSerialNow(); + }; window.addEventListener('pagehide', release); window.addEventListener('beforeunload', release); window.addEventListener('unload', release); @@ -566,6 +685,99 @@ class WallPlotter { }); } + _resumeSnapshot() { + if (this._interrupted) return this._interrupted; + if (this._job === 'idle' || this._plotTestOnly) return null; + if (!this.previewPath.length || this.plotProgress >= this.previewPath.length) { + return null; + } + return { + commands: this.previewPath, + index: this.plotProgress, + testOnly: false, + startedAt: this._plotStartedAt, + pausedAccum: this._pausedAccumMs, + estimateMs: this._plotEstimateMs, + x: this.x, + y: this.y, + }; + } + + _persistResumeState(force = false) { + const snap = this._resumeSnapshot(); + if (!snap || snap.testOnly) { + if (force) this._clearResumeState(); + return; + } + const now = performance.now(); + if (!force && now - (this._lastResumePersistAt || 0) < 800) return; + this._lastResumePersistAt = now; + try { + sessionStorage.setItem( + WallPlotter.RESUME_STORAGE_KEY, + JSON.stringify({ + index: snap.index, + testOnly: false, + startedAt: snap.startedAt, + pausedAccum: snap.pausedAccum, + estimateMs: snap.estimateMs, + x: snap.x ?? this.x, + y: snap.y ?? this.y, + commands: snap.commands, + }) + ); + } catch (_) { + /* quota / private mode */ + } + } + + _loadResumeState() { + let raw = null; + try { + raw = sessionStorage.getItem(WallPlotter.RESUME_STORAGE_KEY); + } catch (_) { + return; + } + if (!raw) return; + try { + const snap = JSON.parse(raw); + if (!Array.isArray(snap.commands) || !snap.commands.length) return; + const index = Math.max(0, Math.min(snap.index ?? 0, snap.commands.length)); + if (index >= snap.commands.length) { + this._clearResumeState(); + return; + } + this.previewPath = snap.commands; + this.plotProgress = index; + this._plotEstimateMs = snap.estimateMs ?? 0; + this._plotStartedAt = snap.startedAt ?? 0; + this._pausedAccumMs = snap.pausedAccum ?? 0; + if (Number.isFinite(snap.x)) this.x = snap.x; + if (Number.isFinite(snap.y)) this.y = snap.y; + this._interrupted = { + commands: snap.commands, + index, + testOnly: false, + startedAt: this._plotStartedAt, + pausedAccum: this._pausedAccumMs, + estimateMs: this._plotEstimateMs, + x: this.x, + y: this.y, + }; + } catch (_) { + this._clearResumeState(); + } + } + + _clearResumeState() { + this._interrupted = null; + try { + sessionStorage.removeItem(WallPlotter.RESUME_STORAGE_KEY); + } catch (_) { + /* ignore */ + } + } + /** * Write one G-code line; by default wait for Marlin ok. * @param {string} line @@ -602,9 +814,7 @@ class WallPlotter { }) : null; try { - const written = this.writer - .write(new TextEncoder().encode(command + '\n')) - .then(() => this.onLog('→ ' + command)); + const written = this._writeLine(command); if (okPromise) { await Promise.all([ written.catch((err) => { @@ -618,6 +828,9 @@ class WallPlotter { } } catch (err) { if (err.name === 'AbortError') throw err; + if (/USB|serial write timeout|NetworkError/i.test(err.message)) { + this._onSerialGone(err.message); + } const timeoutErr = new Error(err.message); timeoutErr.name = 'TimeoutError'; this.onLog('! ' + err.message); @@ -712,6 +925,45 @@ class WallPlotter { this._syncStatus(); } + /** + * Ask Marlin where it thinks it is. USB re-enum often keeps G92; + * only the polargraph boot pose (belts at max length, Y ≈ -2600) means unhomed. + */ + async _syncPoseFromMachine() { + const beforeX = this.x; + const beforeY = this.y; + try { + await this.send('M114', { timeoutMs: 3000, allowWithoutHome: true }); + } catch (_) { + /* keep last browser pose */ + } + + if (this._isUnhomedBootPose(this.x, this.y)) { + this.homeSet = false; + this._homeWarning = 'Home not set — jog to origin and press H'; + this.onLog( + `Marlin at boot pose X ${this.x.toFixed(1)} Y ${this.y.toFixed(1)} — press H` + ); + return; + } + + this.homeSet = true; + this._homeWarning = null; + this.onLog( + `restored Marlin pose X ${this.x.toFixed(1)} Y ${this.y.toFixed(1)} (was ${beforeX.toFixed(1)}, ${beforeY.toFixed(1)})` + ); + this._syncStatus(); + } + + /** Polargraph default after reset: equal max belts → X≈0, Y≈-2600. */ + _isUnhomedBootPose(x, y) { + const span = (this.machineXMax - this.machineXMin) / 2; + const maxBelt = 3500; + const expectedY = + this.machineYMax - Math.sqrt(Math.max(0, maxBelt * maxBelt - span * span)); + return Math.abs(x) < 20 && Math.abs(y - expectedY) < 80; + } + // =========================================================================== // Internals — feed helpers // =========================================================================== @@ -729,7 +981,7 @@ class WallPlotter { // Internals — path execution & timing // =========================================================================== - async _executePath(commands, testOnly = false) { + async _executePath(commands, testOnly = false, fromIndex = 0, timing = null) { if (!testOnly && !this._requireHome()) return; const runId = ++this._pathRunId; @@ -740,25 +992,40 @@ class WallPlotter { } if (runId !== this._pathRunId) return; + this._interrupted = null; this._pathLoopActive = true; this._plotTestOnly = testOnly; this.previewPath = commands; - this.plotProgress = 0; - this._plotStartedAt = performance.now(); - this._plotEstimateMs = this._estimatePathDurationMs(commands, this.x, this.y); - this._pausedAccumMs = 0; + const startAt = Math.max(0, Math.min(fromIndex, commands.length)); + this.plotProgress = startAt; + if (timing) { + this._plotStartedAt = timing.startedAt ?? performance.now(); + this._pausedAccumMs = timing.pausedAccum ?? 0; + this._plotEstimateMs = timing.estimateMs ?? this._estimatePathDurationMs(commands, this.x, this.y); + } else { + this._plotStartedAt = performance.now(); + this._plotEstimateMs = this._estimatePathDurationMs(commands, this.x, this.y); + this._pausedAccumMs = 0; + } this._lastSegmentMs = 0; this._setJob('running'); const savedFeedTravel = this.feedRateTravel; const savedFeedDraw = this.feedRateDraw; + for (let i = 0; i < startAt; i++) { + const cmd = commands[i]; + if (cmd.type === 'speed') { + if (cmd.travel != null) this.feedRateTravel = cmd.travel; + if (cmd.draw != null) this.feedRateDraw = cmd.draw; + } + } let px = this.x; let py = this.y; const stopped = () => this._job === 'aborting' || runId !== this._pathRunId; try { - for (let i = 0; i < commands.length; i++) { + for (let i = startAt; i < commands.length; i++) { if (stopped()) break; await this._waitIfPaused(); if (stopped()) break; @@ -768,6 +1035,7 @@ class WallPlotter { if (cmd.travel != null) this.feedRateTravel = cmd.travel; if (cmd.draw != null) this.feedRateDraw = cmd.draw; this.plotProgress = i + 1; + this._persistResumeState(); this._redrawSketch(); continue; } @@ -805,6 +1073,7 @@ class WallPlotter { await this._waitIfPaused(); if (stopped()) break; this.plotProgress = i + 1; + this._persistResumeState(); continue; } throw err; @@ -817,6 +1086,7 @@ class WallPlotter { py = cmd.y; } this.plotProgress = i + 1; + this._persistResumeState(); } } catch (err) { if (err.name !== 'AbortError' && err.name !== 'TimeoutError') { @@ -837,6 +1107,7 @@ class WallPlotter { this._plotTestOnly = false; this._setJob('idle'); this._pathLoopActive = false; + if (this.plotProgress >= commands.length) this._clearResumeState(); } } @@ -1078,13 +1349,18 @@ class WallPlotter { try { while (plotter.readLoopActive) { const { value, done } = await plotter.reader.read(); - if (done) break; + if (done) { + if (plotter.readLoopActive) { + plotter._onSerialGone('serial stream closed'); + } + break; + } plotter._rxBuf += new TextDecoder().decode(value); plotter._drainRxBuffer(); } } catch (err) { if (plotter.readLoopActive) { - plotter.onLog('serial read error: ' + err.message); + plotter._onSerialGone(err.message || 'serial read error'); } } finally { try { @@ -1316,6 +1592,19 @@ class WallPlotter { return; } if (this._job !== 'idle' || !this.connected) return; + const now = typeof millis === 'function' ? millis() : Date.now(); + if (this._interrupted && !this._interrupted.testOnly) { + if (now - this._lastPlotBtnClick < 450) { + this._lastPlotBtnClick = now; + this._clearResumeState(); + this._triggerSketchFn('plot'); + return; + } + this._lastPlotBtnClick = now; + this._resumeInterruptedPath(); + return; + } + this._lastPlotBtnClick = now; this._triggerSketchFn('plot'); } @@ -1332,6 +1621,7 @@ class WallPlotter { } _restartPath(mode) { + this._clearResumeState(); if (this._pathLoopActive) this._abortJob(); this._triggerSketchFn(mode); } @@ -1345,6 +1635,7 @@ class WallPlotter { if (this._plotBtn) { let label = this._plotLabel; if (this._job === 'paused') label = this._resumeLabel; + else if (this._interrupted && this._job === 'idle') label = this._resumeLabel; else if (this._job === 'running' || this._job === 'aborting') { label = this._pauseLabel; } @@ -1529,7 +1820,8 @@ class WallPlotter { const cmd = this.previewPath[i]; if (cmd.type !== 'move' && cmd.type !== 'draw') continue; const p = this._worldToScreen(cmd.x, cmd.y, layout); - const isFuture = this.isPlotting && i >= this.plotProgress; + const isFuture = + (this.isPlotting || !!this._interrupted) && i >= this.plotProgress; if (px !== null && py !== null) { if (cmd.type === 'draw') { @@ -1601,10 +1893,17 @@ class WallPlotter { text(`preview ${cm} × ${cm} cm (connect for machine bounds)`, 16, 132); } - if (this.isPlotting) { + if (this.isPlotting || this._interrupted) { fill(180, 220, 255); + const label = this._interrupted && this._job === 'idle' + ? 'interrupted' + : this._job === 'paused' + ? 'paused' + : this._job === 'aborting' + ? 'stopped' + : 'plotting'; text( - `${this._job === 'paused' ? 'paused' : this._job === 'aborting' ? 'stopped' : 'plotting'} ${this.plotProgress}/${this.previewPath.length}`, + `${label} ${this.plotProgress}/${this.previewPath.length}`, 16, 152 );