From 3958d9f3d1f052c408c599a8f7ea9d671eeba3ff Mon Sep 17 00:00:00 2001 From: Martin Fuchs Date: Sun, 16 Aug 2026 20:04:44 +0200 Subject: [PATCH] states fixing idle issue? --- examples/netz/index.html | 2 +- examples/zickzack/index.html | 2 +- lib/README.md | 2 +- lib/WallPlotter.js | 337 ++++++++++++++++------------------- 4 files changed, 158 insertions(+), 185 deletions(-) diff --git a/examples/netz/index.html b/examples/netz/index.html index 43a2556..175c22b 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 43a2556..175c22b 100644 --- a/examples/zickzack/index.html +++ b/examples/zickzack/index.html @@ -42,7 +42,7 @@ } - + diff --git a/lib/README.md b/lib/README.md index 2665817..9b2e309 100644 --- a/lib/README.md +++ b/lib/README.md @@ -151,7 +151,7 @@ Returns estimated duration in **milliseconds** for the current path buffer, or f ### `stopPath()` / `stopPlot()` -`stopPath()` aborts the current `runPath` and unblocks any wait for Marlin `ok`. +`stopPath()` aborts the current `runPath` after the in-flight command gets its `ok` (it does not cancel that wait — that was dropping the next `ok`). `stopPlot()` first press is the same as **Pause**: finish the current stroke, then **Resume** continues. Second press stops the browser from sending more moves. The **in-flight** `G1` still runs — this firmware does not have `EMERGENCY_PARSER`, so `M410` cannot interrupt motion (and on a polargraph it desyncs belts). Unplug power for a hard stop. diff --git a/lib/WallPlotter.js b/lib/WallPlotter.js index 89dda8e..72ea987 100644 --- a/lib/WallPlotter.js +++ b/lib/WallPlotter.js @@ -18,7 +18,7 @@ class WallPlotter { // Static constants // --------------------------------------------------------------------------- - static VERSION = '2.2.5'; + static VERSION = '2.3.0'; 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 */ @@ -82,8 +82,8 @@ class WallPlotter { this.previewMinDelayMs = options.previewMinDelayMs ?? 30; this.previewFrameMs = options.previewFrameMs ?? 16; - // --- Live machine state --- - this.connected = false; + // --- Link: disconnected | connecting | connected --- + this._link = 'disconnected'; this.homeSet = false; this.isPenDown = false; this.x = 0; @@ -91,35 +91,28 @@ class WallPlotter { this.statusText = 'Disconnected'; this.logLines = []; - // --- Path / plot progress --- + // --- Job: idle | running | paused | aborting --- + this._job = 'idle'; this._path = []; this.previewPath = []; - this.isPlotting = false; - this.isPaused = false; this.plotProgress = 0; this._plotTestOnly = false; - this._pathAbort = false; this._pathRunId = 0; - this._pendingAfterAbort = null; + this._pathLoopActive = false; this._plotStartedAt = 0; this._plotEstimateMs = 0; this._pausedAccumMs = 0; this._pauseStartedAt = 0; - this._statusBeforePause = null; - this._statusBeforeOkWait = null; - this._freezeMotion = false; this._lastSegmentMs = 0; - // --- Web Serial --- + // --- Serial: one command in flight, wait for its ok --- this.port = null; this.writer = null; this.reader = null; this.readLoopActive = false; this._rxBuf = ''; - this._okResolvers = []; + this._okWaiter = null; this._sendChain = Promise.resolve(); - this._okIgnoreUntil = 0; - this._machineBusy = false; this._lastBusyLogAt = 0; // --- Keyboard / buttons --- @@ -139,7 +132,6 @@ class WallPlotter { this._testLabel = 'Test'; this._stopLabel = 'Stop'; this._lastTestBtnClick = 0; - this._connecting = false; // --- Callbacks --- const userOnLog = options.onLog ?? (() => {}); @@ -151,8 +143,7 @@ class WallPlotter { const userOnStatusChange = options.onStatusChange ?? (() => {}); this.onStatusChange = (status) => { - if (status === 'connected') this.statusText = 'Connected'; - else if (status === 'disconnected') this.statusText = 'Disconnected'; + this._syncStatus(); this._updatePlotButtons(); userOnStatusChange(status); }; @@ -161,6 +152,45 @@ class WallPlotter { this._installUnloadHandler(); } + get connected() { + return this._link === 'connected'; + } + + get isPlotting() { + return this._job !== 'idle'; + } + + get isPaused() { + return this._job === 'paused'; + } + + _setJob(next) { + if (this._job === next) return; + if (this._job === 'paused' && this._pauseStartedAt) { + this._pausedAccumMs += performance.now() - this._pauseStartedAt; + } + this._job = next; + this._pauseStartedAt = next === 'paused' ? performance.now() : 0; + this._syncStatus(); + this._updatePlotButtons(); + this._redrawSketch(); + } + + _abortJob() { + if (this._job === 'idle') return; + this._setJob('aborting'); + } + + _syncStatus() { + if (this._link === 'disconnected') this.statusText = 'Disconnected'; + else if (this._link === 'connecting') this.statusText = 'Connecting…'; + else if (this._job === 'paused') this.statusText = 'Paused'; + else if (this._job === 'aborting') this.statusText = 'Stopped'; + else if (this._job === 'running') { + this.statusText = this._plotTestOnly ? 'Testing' : 'Plotting'; + } else this.statusText = 'Connected'; + } + // =========================================================================== // Public API — setup / UI // =========================================================================== @@ -319,40 +349,23 @@ class WallPlotter { return this._estimatePathDurationMs(commands ?? this._path, this.x, this.y); } - /** Abort the current runPath (browser side only) and unblock any `ok` wait. */ + /** Abort the current runPath after the in-flight command's `ok`. */ stopPath() { - this._pathAbort = true; - this.isPaused = false; - this._freezeMotion = true; - this._cancelPendingOk(); + this._abortJob(); } /** Pause the current runPath after the in-flight segment. */ pausePlot() { - if (!this.isPlotting || this.isPaused) return; - this.isPaused = true; - this._pauseStartedAt = performance.now(); - this._statusBeforePause = this.statusText; - this.statusText = 'Paused'; + if (this._job !== 'running') return; + this._setJob('paused'); this.onLog('plot paused'); - this._updatePlotButtons(); - this._redrawSketch(); } /** Continue a paused runPath (including after Stop or a timeout). */ resumePlot() { - if (!this.isPlotting || !this.isPaused) return; - if (this._pauseStartedAt) { - this._pausedAccumMs += performance.now() - this._pauseStartedAt; - } - this._pauseStartedAt = 0; - this.isPaused = false; - this._freezeMotion = false; - if (this._statusBeforePause) this.statusText = this._statusBeforePause; - this._statusBeforePause = null; + if (this._job !== 'paused') return; + this._setJob('running'); this.onLog('plot resumed'); - this._updatePlotButtons(); - this._redrawSketch(); } /** @@ -362,27 +375,24 @@ class WallPlotter { * a running move, and on a polargraph it desyncs belt lengths. */ async stopPlot() { - const hadWait = this._okResolvers.length > 0; - - if (this.isPlotting && this.isPaused) { - this.stopPath(); - this.statusText = 'Stopped — current stroke may still finish'; + if (this._job === 'paused') { + this._abortJob(); this.onLog( 'plot aborted — motors finish the in-flight move (unplug power for a hard stop)' ); return; } - if (this.isPlotting) { + if (this._job === 'running') { this.pausePlot(); this.onLog('plot stopped after current move — resume to continue'); return; } - if (hadWait) { + if (this._okWaiter) { this._cancelPendingOk(); this.onLog('serial wait cancelled'); - this.statusText = this.connected ? 'Connected' : 'Disconnected'; + this._syncStatus(); } } @@ -395,7 +405,7 @@ class WallPlotter { if (!('serial' in navigator)) { throw new Error('Web Serial is not supported in this browser. Use Chrome or Edge.'); } - if (this.connected) return; + if (this._link === 'connected') return; let port; try { @@ -410,7 +420,7 @@ class WallPlotter { this.writer = this.port.writable.getWriter(); this._startReadLoop(); - this.connected = true; + this._link = 'connected'; this.homeSet = false; this._homeWarning = 'Home not set — jog to origin and press H'; this.onStatusChange('connected'); @@ -462,12 +472,11 @@ class WallPlotter { /** Close the serial port. */ async disconnect() { - if (!this.connected && !this.port) return; + if (this._link === 'disconnected' && !this.port) return; this.readLoopActive = false; this._cancelPendingOk(); this._rxBuf = ''; - this._machineBusy = false; const reader = this.reader; const writer = this.writer; @@ -503,7 +512,7 @@ class WallPlotter { } } - this.connected = false; + this._link = 'disconnected'; this.homeSet = false; this._homeWarning = null; this.onStatusChange('disconnected'); @@ -511,18 +520,17 @@ class WallPlotter { /** Best-effort close for page reload (must not await — Mac keeps the USB lock otherwise). */ _releaseSerialNow() { - if (!this.port && !this.connected) return; + if (!this.port && this._link === 'disconnected') return; this.readLoopActive = false; this._cancelPendingOk(); this._rxBuf = ''; - this._machineBusy = false; const reader = this.reader; const writer = this.writer; const port = this.port; this.reader = null; this.writer = null; this.port = null; - this.connected = false; + this._link = 'disconnected'; this.homeSet = false; try { reader?.cancel(); @@ -594,25 +602,26 @@ class WallPlotter { }) : null; try { - const written = this.writer.write( - new TextEncoder().encode(command + '\n') - ); + const written = this.writer + .write(new TextEncoder().encode(command + '\n')) + .then(() => this.onLog('→ ' + command)); if (okPromise) { await Promise.all([ - written.then(() => this.onLog('→ ' + command)), + written.catch((err) => { + this._cancelPendingOk(); + throw err; + }), okPromise, ]); } else { await written; - this.onLog('→ ' + command); } } catch (err) { if (err.name === 'AbortError') throw err; - this._okIgnoreUntil = performance.now() + 400; const timeoutErr = new Error(err.message); timeoutErr.name = 'TimeoutError'; this.onLog('! ' + err.message); - if (!this.isPlotting) this.statusText = 'Error: ' + err.message; + if (this._job === 'idle') this.statusText = 'Error: ' + err.message; throw timeoutErr; } } @@ -680,12 +689,12 @@ class WallPlotter { /** G92 X0 Y0 — set current pose as home. Refused while plotting or waiting for Marlin. */ async setHomeHere() { - if (this.isPlotting) { + if (this._job !== 'idle') { this.onLog('! cannot set home while plotting — resume or abort first'); this.statusText = 'Finish or abort plot before H'; return; } - if (!this._isMachineIdle()) { + if (this._okWaiter) { this.onLog('! plotter busy — wait for ok before setting home'); this.statusText = 'Waiting for plotter…'; return; @@ -700,7 +709,7 @@ class WallPlotter { this.y = 0; this.homeSet = true; this._homeWarning = null; - if (this.connected) this.statusText = 'Connected'; + this._syncStatus(); } // =========================================================================== @@ -721,39 +730,38 @@ class WallPlotter { // =========================================================================== async _executePath(commands, testOnly = false) { + if (!testOnly && !this._requireHome()) return; + const runId = ++this._pathRunId; - this._pathAbort = false; + if (this._pathLoopActive) this._abortJob(); + while (this._pathLoopActive) { + if (runId !== this._pathRunId) return; + await this._delay(16); + } + if (runId !== this._pathRunId) return; + + this._pathLoopActive = true; this._plotTestOnly = testOnly; this.previewPath = commands; - - if (!testOnly && !this._requireHome()) { - this._plotTestOnly = false; - return; - } - - this.isPlotting = true; - this.isPaused = false; this.plotProgress = 0; this._plotStartedAt = performance.now(); this._plotEstimateMs = this._estimatePathDurationMs(commands, this.x, this.y); this._pausedAccumMs = 0; - this._pauseStartedAt = 0; - this._statusBeforePause = null; - this._freezeMotion = false; this._lastSegmentMs = 0; - this._updatePlotButtons(); + this._setJob('running'); const savedFeedTravel = this.feedRateTravel; const savedFeedDraw = this.feedRateDraw; 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++) { - if (this._pathAbort || runId !== this._pathRunId) break; + if (stopped()) break; await this._waitIfPaused(); - this._freezeMotion = false; - if (this._pathAbort || runId !== this._pathRunId) break; + if (stopped()) break; const cmd = commands[i]; if (cmd.type === 'speed') { @@ -770,6 +778,7 @@ class WallPlotter { if (!testOnly && ms > 0) { await this.send(`G4 P${Math.round(ms)}`, { timeoutMs: Math.max(WallPlotter.OK_TIMEOUT_MS, ms + 5000), + waitLogAfterMs: Math.max(WallPlotter.OK_WAIT_LOG_MS, ms + 1500), }); } await this._holdPosition(ms); @@ -787,34 +796,21 @@ class WallPlotter { } } } catch (err) { - if (err.name === 'AbortError') break; + if (err.name === 'AbortError' || stopped()) break; if (err.name === 'TimeoutError') { - if (!this.isPaused) this.pausePlot(); + if (this._job === 'running') this.pausePlot(); this.onLog('segment skipped after timeout — resume continues from here'); px = this.x; py = this.y; await this._waitIfPaused(); - if (this._pathAbort || runId !== this._pathRunId) break; - if (cmd.type === 'move' || cmd.type === 'draw') { - px = this.x; - py = this.y; - } + if (stopped()) break; this.plotProgress = i + 1; continue; } throw err; } - if (this._pathAbort || runId !== this._pathRunId) break; - - if (this._freezeMotion) { - px = this.x; - py = this.y; - await this._waitIfPaused(); - this._freezeMotion = false; - if (this._pathAbort || runId !== this._pathRunId) break; - continue; - } + if (stopped()) break; if (cmd.type === 'move' || cmd.type === 'draw') { px = cmd.x; @@ -838,18 +834,9 @@ class WallPlotter { } else { this.isPenDown = false; } - this.isPlotting = false; - this.isPaused = false; - this._pauseStartedAt = 0; - this._statusBeforePause = null; this._plotTestOnly = false; - this._updatePlotButtons(); - this._redrawSketch(); - - const next = this._pendingAfterAbort; - this._pendingAfterAbort = null; - if (next === 'test') this._triggerSketchFn('test'); - else if (next === 'plot') this._triggerSketchFn('plot'); + this._setJob('idle'); + this._pathLoopActive = false; } } @@ -903,11 +890,11 @@ class WallPlotter { let elapsed = 0; let last = performance.now(); while (elapsed < ms) { - if (this._pathAbort || this._freezeMotion) return; - if (this._plotTestOnly && this.isPaused) { + if (this._job === 'aborting') return; + if (this._job === 'paused') { await this._waitIfPaused(); last = performance.now(); - if (this._pathAbort) return; + if (this._job === 'aborting') return; } const now = performance.now(); elapsed += now - last; @@ -924,11 +911,11 @@ class WallPlotter { let last = performance.now(); while (true) { - if (this._pathAbort || this._freezeMotion) break; - if (this._plotTestOnly && this.isPaused) { + if (this._job === 'aborting') break; + if (this._plotTestOnly && this._job === 'paused') { await this._waitIfPaused(); last = performance.now(); - if (this._pathAbort || this._freezeMotion) break; + if (this._job === 'aborting') break; } const now = performance.now(); elapsed += now - last; @@ -940,7 +927,7 @@ class WallPlotter { if (t >= 1) break; await this._delay(this.previewFrameMs); } - if (!this._pathAbort && !this._freezeMotion) { + if (this._job !== 'aborting') { this.x = cmd.x; this.y = cmd.y; } @@ -948,7 +935,7 @@ class WallPlotter { /** Wait while paused. Live plots lift the pen until the next segment. */ async _waitIfPaused() { - if (!this.isPaused) return; + if (this._job !== 'paused') return; if (!this._plotTestOnly && this.connected && this.isPenDown) { try { @@ -958,7 +945,7 @@ class WallPlotter { } } - while (this.isPaused && !this._pathAbort) { + while (this._job === 'paused') { this._redrawSketch(); await this._delay(this.previewFrameMs); } @@ -1014,6 +1001,11 @@ class WallPlotter { const waitLabel = command ? ` (${command})` : ''; return new Promise((resolve, reject) => { + if (this._okWaiter) { + reject(new Error('serial already waiting for ok')); + return; + } + let settled = false; let timer = null; let waitLogTimer = null; @@ -1022,11 +1014,10 @@ class WallPlotter { const finish = (fn, arg) => { if (settled) return; settled = true; + if (this._okWaiter === entry) this._okWaiter = null; clearTimeout(timer); clearTimeout(waitLogTimer); if (waitLogInterval != null) clearInterval(waitLogInterval); - const idx = this._okResolvers.indexOf(entry); - if (idx >= 0) this._okResolvers.splice(idx, 1); fn(arg); }; @@ -1043,13 +1034,8 @@ class WallPlotter { const entry = { resolve: () => { - if (this.statusText === 'Waiting for plotter…') { - this.statusText = - this._statusBeforeOkWait ?? - (this.connected ? 'Connected' : 'Disconnected'); - this._statusBeforeOkWait = null; - } finish(resolve); + this._syncStatus(); }, reject: (err) => finish(reject, err), bump: () => { @@ -1057,15 +1043,13 @@ class WallPlotter { }, }; + this._okWaiter = entry; armTimer(); waitLogTimer = setTimeout(() => { if (settled) return; const started = performance.now(); this.onLog(`… waiting for Marlin ok${waitLabel}`); - if (this.statusText !== 'Waiting for plotter…') { - this._statusBeforeOkWait = this.statusText; - } this.statusText = 'Waiting for plotter…'; this._redrawSketch(); waitLogInterval = setInterval(() => { @@ -1073,19 +1057,15 @@ class WallPlotter { this.onLog(`… still waiting for Marlin ok${waitLabel} (${s}s)`); }, WallPlotter.OK_WAIT_PING_MS); }, waitLogAfterMs); - - this._okResolvers.push(entry); }); } _cancelPendingOk() { - this._okIgnoreUntil = performance.now() + 400; - const pending = this._okResolvers.slice(); - for (const entry of pending) { - const err = new Error('aborted'); - err.name = 'AbortError'; - entry.reject(err); - } + const waiter = this._okWaiter; + if (!waiter) return; + const err = new Error('aborted'); + err.name = 'AbortError'; + waiter.reject(err); } _startReadLoop() { @@ -1137,9 +1117,8 @@ class WallPlotter { _handleResponse(line) { if (/busy:\s*processing/i.test(line)) { - this._machineBusy = true; - for (const entry of this._okResolvers) { - if (typeof entry.bump === 'function') entry.bump(); + if (this._okWaiter && typeof this._okWaiter.bump === 'function') { + this._okWaiter.bump(); } const now = performance.now(); if (now - this._lastBusyLogAt > 4000) { @@ -1150,13 +1129,8 @@ class WallPlotter { } this.onLog('← ' + line); if (/^(echo:\s*)?ok\b/i.test(line)) { - this._machineBusy = false; - const next = this._okResolvers.shift(); - if (next) { - next.resolve(); - } else if (performance.now() < this._okIgnoreUntil) { - this.onLog('← ok ignored (stale, no waiter)'); - } + const waiter = this._okWaiter; + if (waiter) waiter.resolve(); } this._parseMachineBounds(line); if (line.startsWith('X:')) { @@ -1190,7 +1164,7 @@ class WallPlotter { } _isMachineIdle() { - return this._okResolvers.length === 0 && !this._machineBusy; + return this._job === 'idle' && !this._okWaiter; } _showHomeRequiredWarning() { @@ -1236,7 +1210,7 @@ class WallPlotter { return false; } if (k === '0') { - if (this.isPlotting || !this._isMachineIdle()) return false; + if (!this._isMachineIdle()) return false; await this.goToZero(); return false; } @@ -1256,8 +1230,8 @@ class WallPlotter { } _handleContinuousJog() { - if (!this.connected || this._jogInFlight || this.isPlotting) return; - if (!this.homeSet || !this._isMachineIdle()) return; + if (!this.connected || this._jogInFlight || !this._isMachineIdle()) return; + if (!this.homeSet) return; const draw = keyIsDown(SHIFT); const s = this.stepSize; @@ -1307,81 +1281,80 @@ class WallPlotter { } async _toggleConnection() { - if (this._connecting) return; + if (this._link === 'connecting') return; try { if (this.connected) await this.disconnect(); else { - this._connecting = true; - this.statusText = 'Connecting…'; + this._link = 'connecting'; + this._syncStatus(); this._updatePlotButtons(); await this.connect(); } } catch (err) { if (err.name === 'AbortError') return; + this._link = 'disconnected'; this.statusText = 'Error: ' + err.message; this.onLog('! ' + err.message); } finally { - this._connecting = false; + if (this._link === 'connecting') this._link = 'disconnected'; this._updatePlotButtons(); } } _onConnectButton() { - if (this.connected || this._connecting) return; + if (this._link !== 'disconnected') return; this._toggleConnection(); } _onPlotButton() { - if (this.isPlotting) { - if (this.isPaused) this.resumePlot(); - else this.pausePlot(); + if (this._job === 'paused') { + this.resumePlot(); return; } - if (!this.connected) return; + if (this._job === 'running') { + this.pausePlot(); + return; + } + if (this._job !== 'idle' || !this.connected) return; this._triggerSketchFn('plot'); } _onTestButton() { const now = millis(); - if (this.isPlotting && this._plotTestOnly) { - if (now - this._lastTestBtnClick < 450) this._restartPathAfterAbort('test'); + if (this._job === 'running' && this._plotTestOnly) { + if (now - this._lastTestBtnClick < 450) this._restartPath('test'); else this.stopPlot(); this._lastTestBtnClick = now; return; } this._lastTestBtnClick = now; - if (this.isPlotting) { - this._restartPathAfterAbort('test'); - return; - } - this._triggerSketchFn('test'); + this._restartPath('test'); } - _restartPathAfterAbort(mode) { - this._pendingAfterAbort = mode; - this._pathAbort = true; - this.isPaused = false; - this._freezeMotion = true; - this._cancelPendingOk(); + _restartPath(mode) { + if (this._pathLoopActive) this._abortJob(); + this._triggerSketchFn(mode); } _updatePlotButtons() { if (this._connectBtn) { if (this.connected) this._connectBtn.hide(); else this._connectBtn.show(); - this._connectBtn.elt.disabled = this._connecting; + this._connectBtn.elt.disabled = this._link === 'connecting'; } if (this._plotBtn) { let label = this._plotLabel; - if (this.isPlotting) { - label = this.isPaused ? this._resumeLabel : this._pauseLabel; + if (this._job === 'paused') label = this._resumeLabel; + else if (this._job === 'running' || this._job === 'aborting') { + label = this._pauseLabel; } this._plotBtn.html(label); - this._plotBtn.elt.disabled = !this.connected && !this.isPlotting; + this._plotBtn.elt.disabled = + this._job === 'aborting' || (!this.connected && this._job === 'idle'); } if (this._testBtn) { this._testBtn.html( - this.isPlotting && this._plotTestOnly ? this._stopLabel : this._testLabel + this._job !== 'idle' && this._plotTestOnly ? this._stopLabel : this._testLabel ); } } @@ -1631,7 +1604,7 @@ class WallPlotter { if (this.isPlotting) { fill(180, 220, 255); text( - `${this.isPaused ? 'paused' : 'plotting'} ${this.plotProgress}/${this.previewPath.length}`, + `${this._job === 'paused' ? 'paused' : this._job === 'aborting' ? 'stopped' : 'plotting'} ${this.plotProgress}/${this.previewPath.length}`, 16, 152 );