states fixing idle issue?

This commit is contained in:
Martin Fuchs
2026-08-16 20:04:44 +02:00
parent a87b373b9e
commit 3958d9f3d1
4 changed files with 158 additions and 185 deletions
+155 -182
View File
@@ -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
);