reconnect and resume

This commit is contained in:
Martin Fuchs
2026-08-16 20:50:03 +02:00
parent 3958d9f3d1
commit 8ae58a63e6
3 changed files with 334 additions and 35 deletions
+1 -1
View File
@@ -42,7 +42,7 @@
} }
</style> </style>
<script src="https://cdn.jsdelivr.net/npm/p5@1.11.3/lib/p5.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/p5@1.11.3/lib/p5.min.js"></script>
<script src="../../lib/WallPlotter.js?v=2.3.0"></script> <script src="../../lib/WallPlotter.js?v=2.3.4"></script>
<script src="sketch.js"></script> <script src="sketch.js"></script>
</head> </head>
<body> <body>
+1 -1
View File
@@ -42,7 +42,7 @@
} }
</style> </style>
<script src="https://cdn.jsdelivr.net/npm/p5@1.11.3/lib/p5.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/p5@1.11.3/lib/p5.min.js"></script>
<script src="../../lib/WallPlotter.js?v=2.3.0"></script> <script src="../../lib/WallPlotter.js?v=2.3.4"></script>
<script src="sketch.js"></script> <script src="sketch.js"></script>
</head> </head>
<body> <body>
+332 -33
View File
@@ -18,7 +18,7 @@ class WallPlotter {
// Static constants // Static constants
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
static VERSION = '2.3.0'; static VERSION = '2.3.4';
static MIN_FEED_MM_MIN = 1; static MIN_FEED_MM_MIN = 1;
static MAX_FEED_MM_MIN = 10000; static MAX_FEED_MM_MIN = 10000;
/** Offline overlay size when not connected (mm) — 150×150 cm centred on origin */ /** 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; static OK_WAIT_LOG_MS = 1000;
/** Repeat “still waiting” while blocked on `ok` */ /** Repeat “still waiting” while blocked on `ok` */
static OK_WAIT_PING_MS = 5000; 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 // Construction
@@ -99,6 +102,7 @@ class WallPlotter {
this._plotTestOnly = false; this._plotTestOnly = false;
this._pathRunId = 0; this._pathRunId = 0;
this._pathLoopActive = false; this._pathLoopActive = false;
this._interrupted = null;
this._plotStartedAt = 0; this._plotStartedAt = 0;
this._plotEstimateMs = 0; this._plotEstimateMs = 0;
this._pausedAccumMs = 0; this._pausedAccumMs = 0;
@@ -132,6 +136,7 @@ class WallPlotter {
this._testLabel = 'Test'; this._testLabel = 'Test';
this._stopLabel = 'Stop'; this._stopLabel = 'Stop';
this._lastTestBtnClick = 0; this._lastTestBtnClick = 0;
this._lastPlotBtnClick = 0;
// --- Callbacks --- // --- Callbacks ---
const userOnLog = options.onLog ?? (() => {}); const userOnLog = options.onLog ?? (() => {});
@@ -150,6 +155,7 @@ class WallPlotter {
this._unloadBound = false; this._unloadBound = false;
this._installUnloadHandler(); this._installUnloadHandler();
this._loadResumeState();
} }
get connected() { get connected() {
@@ -293,6 +299,7 @@ class WallPlotter {
/** Clear the path buffer. Returns this for chaining. */ /** Clear the path buffer. Returns this for chaining. */
startPath() { startPath() {
this._clearResumeState();
this._path = []; this._path = [];
return this; return this;
} }
@@ -351,6 +358,7 @@ class WallPlotter {
/** Abort the current runPath after the in-flight command's `ok`. */ /** Abort the current runPath after the in-flight command's `ok`. */
stopPath() { stopPath() {
this._clearResumeState();
this._abortJob(); this._abortJob();
} }
@@ -417,19 +425,25 @@ class WallPlotter {
await this._openPort(port); await this._openPort(port);
} }
this.port = port; this.port = port;
this._bindSerialEvents(port);
this.writer = this.port.writable.getWriter(); this.writer = this.port.writable.getWriter();
this._startReadLoop(); this._startReadLoop();
this._link = 'connected'; this._link = 'connected';
this.homeSet = false;
this._homeWarning = 'Home not set — jog to origin and press H';
this.onStatusChange('connected'); this.onStatusChange('connected');
try { try {
await this.send('M17'); await this.send('M17', { allowWithoutHome: true });
await this.penUp(true); await this.penUp(true);
await this.send('M665'); await this.send('M665', { allowWithoutHome: true });
await this.send('M114'); 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) { } catch (err) {
if (err.name === 'AbortError') return; if (err.name === 'AbortError') return;
throw err; throw err;
@@ -438,12 +452,16 @@ class WallPlotter {
async _acquirePort() { async _acquirePort() {
const existing = await navigator.serial.getPorts(); const existing = await navigator.serial.getPorts();
if (existing.length === 1) return existing[0]; if (existing.length >= 1) return existing[0];
return navigator.serial.requestPort(); return navigator.serial.requestPort();
} }
async _openPort(port) { 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 timedOut = this._delay(3000).then(() => {
const err = new Error( const err = new Error(
'serial open timeout — port still held after reload. Unplug USB or close other serial tabs.' 'serial open timeout — port still held after reload. Unplug USB or close other serial tabs.'
@@ -453,19 +471,19 @@ class WallPlotter {
}); });
try { try {
await Promise.race([opened, timedOut]); 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) { } catch (err) {
try { try {
await port.close(); await port.close();
} catch (_) { } catch (_) {
/* ignore */ /* ignore */
} }
if (typeof port.forget === 'function') {
try {
await port.forget();
} catch (_) {
/* ignore */
}
}
throw err; throw err;
} }
} }
@@ -513,8 +531,6 @@ class WallPlotter {
} }
this._link = 'disconnected'; this._link = 'disconnected';
this.homeSet = false;
this._homeWarning = null;
this.onStatusChange('disconnected'); this.onStatusChange('disconnected');
} }
@@ -531,7 +547,6 @@ class WallPlotter {
this.writer = null; this.writer = null;
this.port = null; this.port = null;
this._link = 'disconnected'; this._link = 'disconnected';
this.homeSet = false;
try { try {
reader?.cancel(); reader?.cancel();
} catch (_) { } 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() { _installUnloadHandler() {
if (this._unloadBound) return; if (this._unloadBound) return;
this._unloadBound = true; this._unloadBound = true;
const release = () => this._releaseSerialNow(); const release = () => {
this._stashInterruptedPath();
this._persistResumeState(true);
this._releaseSerialNow();
};
window.addEventListener('pagehide', release); window.addEventListener('pagehide', release);
window.addEventListener('beforeunload', release); window.addEventListener('beforeunload', release);
window.addEventListener('unload', 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. * Write one G-code line; by default wait for Marlin ok.
* @param {string} line * @param {string} line
@@ -602,9 +814,7 @@ class WallPlotter {
}) })
: null; : null;
try { try {
const written = this.writer const written = this._writeLine(command);
.write(new TextEncoder().encode(command + '\n'))
.then(() => this.onLog('→ ' + command));
if (okPromise) { if (okPromise) {
await Promise.all([ await Promise.all([
written.catch((err) => { written.catch((err) => {
@@ -618,6 +828,9 @@ class WallPlotter {
} }
} catch (err) { } catch (err) {
if (err.name === 'AbortError') throw 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); const timeoutErr = new Error(err.message);
timeoutErr.name = 'TimeoutError'; timeoutErr.name = 'TimeoutError';
this.onLog('! ' + err.message); this.onLog('! ' + err.message);
@@ -712,6 +925,45 @@ class WallPlotter {
this._syncStatus(); 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 // Internals — feed helpers
// =========================================================================== // ===========================================================================
@@ -729,7 +981,7 @@ class WallPlotter {
// Internals — path execution & timing // Internals — path execution & timing
// =========================================================================== // ===========================================================================
async _executePath(commands, testOnly = false) { async _executePath(commands, testOnly = false, fromIndex = 0, timing = null) {
if (!testOnly && !this._requireHome()) return; if (!testOnly && !this._requireHome()) return;
const runId = ++this._pathRunId; const runId = ++this._pathRunId;
@@ -740,25 +992,40 @@ class WallPlotter {
} }
if (runId !== this._pathRunId) return; if (runId !== this._pathRunId) return;
this._interrupted = null;
this._pathLoopActive = true; this._pathLoopActive = true;
this._plotTestOnly = testOnly; this._plotTestOnly = testOnly;
this.previewPath = commands; this.previewPath = commands;
this.plotProgress = 0; const startAt = Math.max(0, Math.min(fromIndex, commands.length));
this._plotStartedAt = performance.now(); this.plotProgress = startAt;
this._plotEstimateMs = this._estimatePathDurationMs(commands, this.x, this.y); if (timing) {
this._pausedAccumMs = 0; 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._lastSegmentMs = 0;
this._setJob('running'); this._setJob('running');
const savedFeedTravel = this.feedRateTravel; const savedFeedTravel = this.feedRateTravel;
const savedFeedDraw = this.feedRateDraw; 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 px = this.x;
let py = this.y; let py = this.y;
const stopped = () => this._job === 'aborting' || runId !== this._pathRunId; const stopped = () => this._job === 'aborting' || runId !== this._pathRunId;
try { try {
for (let i = 0; i < commands.length; i++) { for (let i = startAt; i < commands.length; i++) {
if (stopped()) break; if (stopped()) break;
await this._waitIfPaused(); await this._waitIfPaused();
if (stopped()) break; if (stopped()) break;
@@ -768,6 +1035,7 @@ class WallPlotter {
if (cmd.travel != null) this.feedRateTravel = cmd.travel; if (cmd.travel != null) this.feedRateTravel = cmd.travel;
if (cmd.draw != null) this.feedRateDraw = cmd.draw; if (cmd.draw != null) this.feedRateDraw = cmd.draw;
this.plotProgress = i + 1; this.plotProgress = i + 1;
this._persistResumeState();
this._redrawSketch(); this._redrawSketch();
continue; continue;
} }
@@ -805,6 +1073,7 @@ class WallPlotter {
await this._waitIfPaused(); await this._waitIfPaused();
if (stopped()) break; if (stopped()) break;
this.plotProgress = i + 1; this.plotProgress = i + 1;
this._persistResumeState();
continue; continue;
} }
throw err; throw err;
@@ -817,6 +1086,7 @@ class WallPlotter {
py = cmd.y; py = cmd.y;
} }
this.plotProgress = i + 1; this.plotProgress = i + 1;
this._persistResumeState();
} }
} catch (err) { } catch (err) {
if (err.name !== 'AbortError' && err.name !== 'TimeoutError') { if (err.name !== 'AbortError' && err.name !== 'TimeoutError') {
@@ -837,6 +1107,7 @@ class WallPlotter {
this._plotTestOnly = false; this._plotTestOnly = false;
this._setJob('idle'); this._setJob('idle');
this._pathLoopActive = false; this._pathLoopActive = false;
if (this.plotProgress >= commands.length) this._clearResumeState();
} }
} }
@@ -1078,13 +1349,18 @@ class WallPlotter {
try { try {
while (plotter.readLoopActive) { while (plotter.readLoopActive) {
const { value, done } = await plotter.reader.read(); 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._rxBuf += new TextDecoder().decode(value);
plotter._drainRxBuffer(); plotter._drainRxBuffer();
} }
} catch (err) { } catch (err) {
if (plotter.readLoopActive) { if (plotter.readLoopActive) {
plotter.onLog('serial read error: ' + err.message); plotter._onSerialGone(err.message || 'serial read error');
} }
} finally { } finally {
try { try {
@@ -1316,6 +1592,19 @@ class WallPlotter {
return; return;
} }
if (this._job !== 'idle' || !this.connected) 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'); this._triggerSketchFn('plot');
} }
@@ -1332,6 +1621,7 @@ class WallPlotter {
} }
_restartPath(mode) { _restartPath(mode) {
this._clearResumeState();
if (this._pathLoopActive) this._abortJob(); if (this._pathLoopActive) this._abortJob();
this._triggerSketchFn(mode); this._triggerSketchFn(mode);
} }
@@ -1345,6 +1635,7 @@ class WallPlotter {
if (this._plotBtn) { if (this._plotBtn) {
let label = this._plotLabel; let label = this._plotLabel;
if (this._job === 'paused') label = this._resumeLabel; 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') { else if (this._job === 'running' || this._job === 'aborting') {
label = this._pauseLabel; label = this._pauseLabel;
} }
@@ -1529,7 +1820,8 @@ class WallPlotter {
const cmd = this.previewPath[i]; const cmd = this.previewPath[i];
if (cmd.type !== 'move' && cmd.type !== 'draw') continue; if (cmd.type !== 'move' && cmd.type !== 'draw') continue;
const p = this._worldToScreen(cmd.x, cmd.y, layout); 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 (px !== null && py !== null) {
if (cmd.type === 'draw') { if (cmd.type === 'draw') {
@@ -1601,10 +1893,17 @@ class WallPlotter {
text(`preview ${cm} × ${cm} cm (connect for machine bounds)`, 16, 132); text(`preview ${cm} × ${cm} cm (connect for machine bounds)`, 16, 132);
} }
if (this.isPlotting) { if (this.isPlotting || this._interrupted) {
fill(180, 220, 255); fill(180, 220, 255);
const label = this._interrupted && this._job === 'idle'
? 'interrupted'
: this._job === 'paused'
? 'paused'
: this._job === 'aborting'
? 'stopped'
: 'plotting';
text( text(
`${this._job === 'paused' ? 'paused' : this._job === 'aborting' ? 'stopped' : 'plotting'} ${this.plotProgress}/${this.previewPath.length}`, `${label} ${this.plotProgress}/${this.previewPath.length}`,
16, 16,
152 152
); );