From a0b917cc8921f30717293dba1528b2be6de5e46a Mon Sep 17 00:00:00 2001
From: Bernard <63512176+BernardJen@users.noreply.github.com>
Date: Mon, 14 Sep 2026 17:28:44 +0200
Subject: [PATCH] feat(gateway): power the motors down after 1 h idle, and
admit what that costs
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The steppers were energized 24/7: holding current, heat in the drivers and coil
whine on a machine that is idle most of the day. `$MD` existed only behind a
manual button and nothing ever fired it.
Turning them off is the easy half. With no limit switches, no homing and no
encoder feedback, the energized steppers are the only thing holding the gantry
on the work origin the operator set by hand — and FluidNC goes on reporting the
pre-shutdown coordinates whatever the gantry actually did. `posReady` already
means "the position is trustworthy", but nothing cleared it while the daemon
stayed connected, which is exactly what a power-down creates. Left alone, the
daemon would keep persisting a position recorded after the gantry was free and
hand it to `G10 L20` on the next connect. With soft limits disabled per-axis,
the next plot drives into the frame. So auto-off without the trust handling is
worse than leaving the motors on, and the two halves ship together.
- Idle auto-off on a 30 s tick, default 1 h, `0` = never (which doubles as the
keep-the-motors-on override). Gated on `isPlotting()` — the existing predicate,
reused rather than reimplemented, because it already encodes the pen-change
hold: deliberately Idle with an empty queue and very much mid-job.
- A power-down (timer or the manual button — same free gantry, same lost origin)
clears `posReady`, rewrites the state file with `trusted: false` synchronously,
and broadcasts. A file with no flag predates this and is still restored, so no
machine loses its remembered home to the upgrade.
- `restoreSavedPosition` refuses an untrusted file instead of reinstating it,
which is what gives the feature a daemon-restart story.
- The gateway refuses Plot and Go to home with the reason, next to the command
handlers, so a second tab cannot believe the origin is fine. Jog stays
available — the operator needs it to reach the corner. Stop still stops; while
untrusted it runs the abort without the rapid to an origin nobody believes.
- `src/grbl/motorPower.ts` holds both rules as pure functions, unit-tested,
including every way the timer must not fire.
- A re-zero wizard opens on the transition and from the Home/calibration panel,
says the gantry will not fight back, and ends on the same `setWorkZero()` call
Calibrate makes. A banner keeps it visible until it is cleared.
Daemon-driven rather than FluidNC's own idle disable: `$MD` is documented and
`$ME` is not, and the status report carries no motor-enable field — a dropout
the daemon cannot see is a lost origin it cannot flag. Nothing new is sent to
re-energize; the motors come back on the first move, and confirming that is a
hardware task.
Refs #81
---
CHANGELOG.md | 24 ++
README.md | 18 ++
gateway/server.ts | 265 ++++++++++++++++--
.../idle-motor-power-down/.openspec.yaml | 2 +
.../changes/idle-motor-power-down/design.md | 80 ++++++
.../changes/idle-motor-power-down/proposal.md | 70 +++++
.../specs/app-settings/spec.md | 18 ++
.../specs/gateway-protocol/spec.md | 18 ++
.../specs/motor-power/spec.md | 106 +++++++
.../specs/work-coordinates/spec.md | 58 ++++
.../changes/idle-motor-power-down/tasks.md | 53 ++++
src/gateway/protocol.ts | 15 +
src/grbl/__tests__/motorPower.test.ts | 180 ++++++++++++
src/grbl/motorPower.ts | 136 +++++++++
src/grbl/settings.ts | 9 +
src/transport/GatewayClient.ts | 17 ++
src/ui/App.tsx | 85 +++++-
src/ui/RezeroWizard.tsx | 239 ++++++++++++++++
src/ui/SettingsPage.tsx | 16 ++
19 files changed, 1389 insertions(+), 20 deletions(-)
create mode 100644 openspec/changes/idle-motor-power-down/.openspec.yaml
create mode 100644 openspec/changes/idle-motor-power-down/design.md
create mode 100644 openspec/changes/idle-motor-power-down/proposal.md
create mode 100644 openspec/changes/idle-motor-power-down/specs/app-settings/spec.md
create mode 100644 openspec/changes/idle-motor-power-down/specs/gateway-protocol/spec.md
create mode 100644 openspec/changes/idle-motor-power-down/specs/motor-power/spec.md
create mode 100644 openspec/changes/idle-motor-power-down/specs/work-coordinates/spec.md
create mode 100644 openspec/changes/idle-motor-power-down/tasks.md
create mode 100644 src/grbl/__tests__/motorPower.test.ts
create mode 100644 src/grbl/motorPower.ts
create mode 100644 src/ui/RezeroWizard.tsx
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 22b27f1..08828bb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Added
+
+- **Idle motor power-down, with a re-zero wizard.** The steppers were energized around the
+ clock; they are now de-energized (`$MD`) after a configurable idle period, default 1 hour,
+ `0` to never. The timer is driven off the existing `isPlotting()` predicate, so it cannot
+ fire while a plot is streaming, queued, paused, in `Run`/`Hold`, or **held at a pen
+ change** — a case that is deliberately idle with an empty queue and very much mid-job.
+- **Position trust is explicit state, shared by every client.** With no limit switches and
+ no encoder feedback, de-energizing the motors frees the gantry and the reported position
+ stops being a measurement. A power-down — automatic *or* the manual **Motors off** button
+ — now marks the position untrusted, stops persisting it, and rewrites the saved state
+ file with `trusted: false` so a daemon restarted hours later refuses to reinstate it as
+ the work origin. The state is in the snapshot and pushed as an event, so a device that
+ attaches long afterwards learns that home is gone. A state file written by an older
+ daemon carries no flag and is still restored, as before.
+- **The gateway refuses Plot and Go to home while the origin is unknown**, naming the
+ reason, next to the command handlers rather than in the browser. Jog stays available —
+ the operator needs it to reach the corner. Stop still stops; while untrusted it runs the
+ abort without the rapid to an origin nobody believes.
+- **Re-zero wizard** (`src/ui/RezeroWizard.tsx`), opening by itself when the origin is
+ lost and from the Home/calibration panel: what happened, a warning that the gantry will
+ not fight back, jog or push to the paper's top-left corner, then Set home. A banner keeps
+ the state visible until it is cleared.
+
## [1.3.0] - 2026-09-12
### Added
diff --git a/README.md b/README.md
index a17af57..b23c73a 100644
--- a/README.md
+++ b/README.md
@@ -93,6 +93,19 @@ unattended **Raspberry Pi** setup needs.
a plot that hangs, and a diagnostic log panel keeps the last events.
- **Manual control.** Jog, pen up/down, set work zero, go to work zero, motors off, and a
live feed-rate override.
+- **Idle motors power down, and the app admits what that costs.** After an hour with
+ nothing to do (configurable; `0` never) the daemon de-energizes the steppers — no
+ holding current, no heat, no coil whine on a machine that is idle most of the day. It
+ never fires mid-job, including while a plot is *held at a pen change*, which looks idle
+ and is not. Because there are no limit switches, freeing the gantry also loses the work
+ origin, so the daemon marks the position untrusted the moment it happens, stops saving
+ it, invalidates what is on disk so a restart cannot reinstate it, and refuses Plot and
+ Go to home — for every connected device, not just the one that was watching. A
+ **re-zero wizard** then walks you back: it says the motors are off and the gantry will
+ not resist, you push or jog the head to the paper's top-left corner, and Set home puts
+ everything back. Jogging stays available throughout; its coordinates simply mean nothing
+ until you have re-zeroed. **Motors off** does exactly the same thing, because it has
+ exactly the same consequence.
- **Settings page.** The gear in the header opens everything that is configured once: work
area, pen-down/up Z, dwell, the draw/travel/jog feeds, image-import defaults, the
connection and version info, and a read-only view of the controller's raw `$$` settings.
@@ -135,6 +148,11 @@ The G-code generator bakes in this specific machine's setup:
- After a power cycle the daemon restores the last saved position so you needn't
re-calibrate, but without homing this is approximate (~1 cm). Stop the plot before
powering off for the closest restore, and re-run **Set Work Zero** if it drifts.
+- **A position saved after the motors were powered down is never restored.** The state
+ file records whether the position was trustworthy when written; one written on the wrong
+ side of a `$MD` is read back, reported, and *not* fed to `G10 L20`. That is deliberate:
+ reinstating it would make a coordinate nobody measured into the work origin, and with
+ soft limits disabled per-axis nothing downstream would stop the plot.
## Architecture
diff --git a/gateway/server.ts b/gateway/server.ts
index 1f596f2..1caf953 100644
--- a/gateway/server.ts
+++ b/gateway/server.ts
@@ -17,6 +17,15 @@ import type {
UpdateStatus,
} from '../src/gateway/protocol';
import { projectFileName, sanitizeProjectName } from '../src/plot/project';
+import {
+ canRestorePosition,
+ MOTORS_HOLDING,
+ reduceMotorPower,
+ shouldDropMotors,
+ type MotorPower,
+ type MotorPowerEvent,
+} from '../src/grbl/motorPower';
+import { DEFAULT_CALIBRATION } from '../src/grbl/settings';
import {
appSettingsFromLegacySession,
normalizeAppSettings,
@@ -89,6 +98,14 @@ interface SavedState {
wpos: Vec3;
wco: Vec3;
savedAt: string;
+ /**
+ * Whether this position was trustworthy when it was written. Absent on files
+ * from before the motors could be powered down — those were only ever written
+ * while the steppers were holding the gantry, so absent reads as `true`.
+ * `false` means it was recorded on the wrong side of a power-down and must
+ * never be reinstated as the work origin.
+ */
+ trusted?: boolean;
}
let lastWpos: Vec3 | null = null;
let lastWco: Vec3 = { x: 0, y: 0, z: 0 };
@@ -99,38 +116,79 @@ let posReady = false;
let lastSavedKey = '';
let writing = false; // serialize async writes so 5 Hz updates can't overlap/corrupt
+
+// Write atomically (temp file + rename) so an abrupt power-off can never leave a
+// half-written/empty file — a corrupt file reads back as null and loses the home.
+// The sync path additionally fsyncs, for the writes that happen as the process is
+// on its way out (SIGTERM, the plotter dropping, the motors being disabled).
+function writeStateSync(data: SavedState) {
+ const tmp = `${STATE_FILE}.tmp`;
+ try {
+ writeFileSync(tmp, JSON.stringify(data, null, 2));
+ const fd = openSync(tmp, 'r'); // fsync the data to disk before the rename
+ fsyncSync(fd);
+ closeSync(fd);
+ renameSync(tmp, STATE_FILE);
+ } catch {
+ /* ignore */
+ }
+}
+
function persistState(sync = false) {
if (!lastWpos) return;
// Skip when unchanged (idle machine → no churn) or while a write is in flight.
const key = `${lastWpos.x.toFixed(2)},${lastWpos.y.toFixed(2)},${lastWpos.z.toFixed(2)}`;
if (!sync && (key === lastSavedKey || writing)) return;
lastSavedKey = key;
- const data: SavedState = { wpos: lastWpos, wco: lastWco, savedAt: new Date().toISOString() };
- const json = JSON.stringify(data, null, 2);
- // Write atomically (temp file + rename) so an abrupt power-off can never leave a
- // half-written/empty file — a corrupt file reads back as null and loses the home.
- const tmp = `${STATE_FILE}.tmp`;
+ // Callers gate on `posReady`, so anything written here is an origin the
+ // operator established and the steppers have held ever since.
+ const data: SavedState = {
+ wpos: lastWpos,
+ wco: lastWco,
+ savedAt: new Date().toISOString(),
+ trusted: true,
+ };
if (sync) {
- try {
- writeFileSync(tmp, json);
- const fd = openSync(tmp, 'r'); // fsync the data to disk before the rename
- fsyncSync(fd);
- closeSync(fd);
- renameSync(tmp, STATE_FILE);
- } catch {
- /* ignore */
- }
+ writeStateSync(data);
return;
}
writing = true;
- void writeFile(tmp, json)
- .then(() => rename(tmp, STATE_FILE))
+ void writeFile(`${STATE_FILE}.tmp`, JSON.stringify(data, null, 2))
+ .then(() => rename(`${STATE_FILE}.tmp`, STATE_FILE))
.catch(() => undefined)
.finally(() => {
writing = false;
});
}
+/**
+ * Mark the position on disk as no longer an origin — called the instant the
+ * steppers are de-energized.
+ *
+ * Without this the daemon would restart hours later, read a position recorded
+ * when the gantry was already free, and hand it straight to `G10 L20` as the
+ * work origin. With soft limits disabled per-axis, the next plot then drives
+ * into the frame. The coordinates are kept (they say where it *thought* it was,
+ * which is worth having) — only the claim that they mean something is dropped.
+ *
+ * Written synchronously: the whole point is that it survives whatever happens
+ * next, including someone pulling the plug.
+ */
+function invalidateSavedPosition() {
+ const saved = readSavedState();
+ const wpos = lastWpos ?? saved?.wpos;
+ if (!wpos) return; // nothing was ever saved — nothing can be wrongly restored
+ writeStateSync({
+ wpos,
+ wco: lastWco,
+ savedAt: new Date().toISOString(),
+ trusted: false,
+ });
+ // The dedupe key would otherwise suppress the first write after a re-zero (the
+ // position has not changed yet), leaving `trusted: false` on disk.
+ lastSavedKey = '';
+}
+
function readSavedState(): SavedState | null {
try {
return JSON.parse(readFileSync(STATE_FILE, 'utf8')) as SavedState;
@@ -233,6 +291,20 @@ async function broadcastProjects(): Promise {
async function restoreSavedPosition() {
const saved = readSavedState();
if (!saved?.wpos) return;
+ // Recorded after the motors were powered down: the gantry was free from that
+ // moment on, so this is a coordinate, not an origin. Reinstating it is exactly
+ // the failure this feature exists to prevent — come up untrusted instead and
+ // let the operator re-zero. (An older file has no flag and is still trusted.)
+ if (!canRestorePosition(saved)) {
+ setMotors({ kind: 'staleRestore' });
+ restoredNote =
+ `Not restoring the saved position (${saved.wpos.x.toFixed(1)}, ${saved.wpos.y.toFixed(1)}, ` +
+ `saved ${saved.savedAt}): the motors were powered down after it was recorded, so the gantry ` +
+ 'may have moved. Re-zero at the paper’s top-left corner before plotting.';
+ log(restoredNote);
+ broadcast({ type: 'event', event: 'log', payload: { dir: 'info', text: restoredNote } });
+ return;
+ }
try {
// Restore X/Y (paper alignment) but zero Z: restoring the pen's last Z would
// make "pen up" (work Z0) a negative machine Z. Work Z0 = pen-up at boot.
@@ -247,6 +319,83 @@ async function restoreSavedPosition() {
broadcast({ type: 'event', event: 'log', payload: { dir: 'info', text: restoredNote } });
}
+// ---- motor power + position trust ----
+// The steppers are the only thing holding the gantry (no limit switches, no
+// homing), so de-energizing them is also how the work origin gets lost. Both
+// facts live here; the rules that move between them are pure and tested in
+// src/grbl/motorPower.ts.
+let motors: MotorPower = { ...MOTORS_HOLDING };
+/** Epoch ms of the last commanded motion — what the idle timer measures from. */
+let lastActivityAt = Date.now();
+/** Guard so a slow `$MD` can't have a second tick queue another one behind it. */
+let droppingMotors = false;
+
+/** Apply a motor/origin event and tell every client, since there is one machine. */
+function setMotors(event: MotorPowerEvent) {
+ const next = reduceMotorPower(motors, event);
+ if (
+ next.powered === motors.powered &&
+ next.posTrusted === motors.posTrusted &&
+ next.reason === motors.reason
+ ) {
+ return; // no change (e.g. motion on an already-energized machine) → no chatter
+ }
+ motors = next;
+ broadcast({ type: 'event', event: 'motors', payload: motors });
+}
+
+/** The configured idle period, falling back to the default when nothing is stored yet. */
+function motorIdleMinutes(): number {
+ return appSettings?.calibration.motorIdleMin ?? DEFAULT_CALIBRATION.motorIdleMin;
+}
+
+/**
+ * De-energize the steppers and record what that costs.
+ *
+ * Order matters: `$MD` goes out first and the state only changes once it has,
+ * because a disable that never reached the controller leaves the motors holding
+ * and the origin perfectly good. Then persistence stops *before* the file is
+ * invalidated, so the 5 Hz status handler cannot slip a `trusted: true` write in
+ * between.
+ */
+async function dropMotors(event: { kind: 'idleTimeout'; minutes: number } | { kind: 'manualOff' }) {
+ await ctrl.motorsOff();
+ posReady = false;
+ invalidateSavedPosition();
+ setMotors(event);
+ log(motors.reason ?? 'motors powered down');
+ broadcast({ type: 'event', event: 'log', payload: { dir: 'info', text: motors.reason ?? '' } });
+}
+
+/**
+ * Idle check, run on a coarse tick. The period is measured in minutes, so being
+ * up to one tick late costs nothing and keeps the Pi from waking 120× an hour to
+ * be punctual about something nobody is watching.
+ */
+const IDLE_TICK_MS = 30_000;
+async function checkIdleMotors() {
+ if (droppingMotors) return;
+ const minutes = motorIdleMinutes();
+ const due = shouldDropMotors({
+ now: Date.now(),
+ lastActivityAt,
+ idleMinutes: minutes,
+ connected,
+ busy: isPlotting(),
+ powered: motors.powered,
+ });
+ if (!due) return;
+ droppingMotors = true;
+ try {
+ await dropMotors({ kind: 'idleTimeout', minutes });
+ } catch (e) {
+ // Nothing is marked: the motors are still on, so the origin is still good.
+ log(`idle power-down failed: ${String((e as Error)?.message ?? e)}`);
+ } finally {
+ droppingMotors = false;
+ }
+}
+
// ---- daemon state (for snapshots) ----
let connected = false;
let version = 'unknown';
@@ -392,6 +541,10 @@ ctrl.on('disconnected', () => {
});
ctrl.on('status', (s) => {
lastStatus = s;
+ // Anything actually moving is use, whoever asked for it. Without this the idle
+ // clock would run from the command that *started* a two-hour plot, and the
+ // motors would be due to drop the moment it finished.
+ if (s.state === 'Run' || s.state === 'Jog' || isPlotting()) lastActivityAt = Date.now();
if (s.wco) lastWco = s.wco;
lastWpos = { x: s.mpos.x - lastWco.x, y: s.mpos.y - lastWco.y, z: s.mpos.z - lastWco.z };
// Persist on every changed status (~5 Hz) so a mid-plot power-off restores
@@ -479,6 +632,7 @@ function snapshot(ws: WebSocket): Snapshot {
// Filled in by the caller: listing the directory is async, and a snapshot
// has to be ready the moment a client attaches.
projects: knownProjects,
+ motors,
};
}
@@ -494,12 +648,68 @@ function releaseControlOnClose(ws: WebSocket) {
}
}
+/**
+ * Commands that mean the operator is standing at the machine using it. Only
+ * these reset the idle clock: a browser that autosaves its session every couple
+ * of seconds would otherwise hold the steppers energized forever, which is the
+ * whole thing this change is trying to stop.
+ */
+const ACTIVITY_COMMANDS = new Set([
+ 'plot',
+ 'resume',
+ 'stop',
+ 'jog',
+ 'penUp',
+ 'penDown',
+ 'goToWorkZero',
+ 'setWorkZero',
+ 'continueProgram',
+ 'unlock',
+]);
+
+/**
+ * The subset that actually drives the steppers, which is what brings them back
+ * after a `$MD` (FluidNC re-energizes on motion). `setWorkZero` and `unlock` are
+ * not here on purpose: they write an offset and clear an alarm. Reporting the
+ * gantry as held because of either would tell the operator it is safe to walk
+ * away from a machine that is still free to be pushed.
+ */
+const MOVES_THE_MACHINE = new Set([
+ 'plot',
+ 'resume',
+ 'jog',
+ 'penUp',
+ 'penDown',
+ 'goToWorkZero',
+ 'continueProgram',
+]);
+
+/**
+ * Commands whose meaning depends on the work origin. Refused while the position
+ * is untrusted — in the daemon, not in the browser, because there can be several
+ * browsers and only one gantry. Jog is deliberately absent: the operator needs it
+ * to reach the corner, and the motion is fine, it is the numbers that are fiction.
+ */
+const NEEDS_TRUSTED_POSITION = new Set(['plot', 'goToWorkZero']);
+
async function handleCommand(ws: WebSocket, msg: ClientMessage) {
const id = msg.id;
if (controller !== ws) {
send(ws, { type: 'cmdError', id, message: 'Another operator is in control.' });
return;
}
+ if (!motors.posTrusted && NEEDS_TRUSTED_POSITION.has(msg.cmd)) {
+ send(ws, {
+ type: 'cmdError',
+ id,
+ message: `Refused: ${motors.reason} Move the head to the paper’s top-left corner and set home (Calibrate) first.`,
+ });
+ return;
+ }
+ if (ACTIVITY_COMMANDS.has(msg.cmd)) lastActivityAt = Date.now();
+ // Commanding a move re-energizes the steppers. It says nothing about the
+ // origin — only a human at the paper's corner can restore that.
+ if (MOVES_THE_MACHINE.has(msg.cmd)) setMotors({ kind: 'motion' });
try {
switch (msg.cmd) {
case 'plot':
@@ -512,7 +722,11 @@ async function handleCommand(ws: WebSocket, msg: ClientMessage) {
ctrl.resume();
break;
case 'stop':
- await ctrl.stopAndReturnHome();
+ // Stop always stops. But `stopAndReturnHome` is two actions welded
+ // together, and while the origin is unknown the second one is a rapid to
+ // a coordinate nothing has measured — so run the abort on its own.
+ if (motors.posTrusted) await ctrl.stopAndReturnHome();
+ else await ctrl.stop();
break;
case 'jog':
await ctrl.jog(msg.dx, msg.dy, msg.dz, msg.feed);
@@ -532,13 +746,25 @@ async function handleCommand(ws: WebSocket, msg: ClientMessage) {
case 'setWorkZero':
await ctrl.setWorkZero();
posReady = true;
+ // `G10 L20 P1 X0 Y0 Z0` *defines* this spot as work zero, so the work
+ // position is 0,0,0 by construction. Say so rather than persisting the
+ // pre-calibration reading that `lastWpos` still holds until the next
+ // status arrives (~200 ms) — that value would restore a wrong origin if
+ // the daemon died in between, which is the whole hazard here.
+ lastWpos = { x: 0, y: 0, z: 0 };
persistState();
+ // The one thing that restores trust: a person put the head on the corner
+ // and said so. Nothing the machine can do on its own counts.
+ setMotors({ kind: 'setWorkZero' });
break;
case 'goToWorkZero':
await ctrl.goToWorkZero();
break;
case 'motorsOff':
- await ctrl.motorsOff();
+ // Deliberately identical to the idle timeout: the operator switching the
+ // motors off frees the gantry exactly as the timer does, and the origin
+ // is exactly as gone.
+ await dropMotors({ kind: 'manualOff' });
break;
case 'unlock':
await ctrl.unlock();
@@ -749,4 +975,7 @@ httpServer.listen(PORT, HOST, () => {
// Best-effort latest-release lookup: once on boot, then every 6 h. Non-blocking.
void refreshLatestVersion();
setInterval(() => void refreshLatestVersion(), 6 * 60 * 60 * 1000);
+ // Idle motor power-down. Started here rather than at module load so it never
+ // ticks in a process that failed to come up.
+ setInterval(() => void checkIdleMotors(), IDLE_TICK_MS);
});
diff --git a/openspec/changes/idle-motor-power-down/.openspec.yaml b/openspec/changes/idle-motor-power-down/.openspec.yaml
new file mode 100644
index 0000000..a40cb63
--- /dev/null
+++ b/openspec/changes/idle-motor-power-down/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-09-14
diff --git a/openspec/changes/idle-motor-power-down/design.md b/openspec/changes/idle-motor-power-down/design.md
new file mode 100644
index 0000000..a9ff922
--- /dev/null
+++ b/openspec/changes/idle-motor-power-down/design.md
@@ -0,0 +1,80 @@
+## Context
+
+`posReady` already exists and already means "the position is trustworthy" — it is cleared on
+disconnect and re-set by a restore or by Calibrate. What it does not have is the third case: the
+position going bad **while the daemon stays connected**, which is precisely what de-energizing the
+steppers creates on a machine with no limit switches and no encoder feedback.
+
+So this is not a new concept bolted on. It is the missing transition in a state machine that was
+already there, plus the auto-off that makes it happen.
+
+## Decisions
+
+- **Daemon-driven `$MD`, not FluidNC's own idle disable.** FluidNC can disable idle steppers itself
+ (per-motor `idle_ms`), and that would survive a daemon restart — but the daemon would then have to
+ *detect* the dropout to invalidate the origin, and FluidNC does not report motor-enable state:
+ `$MD` (`$Motor/Disable`) is documented, there is no documented `$ME`, and the status report
+ (``) carries no enable field. A dropout the daemon cannot see is a lost origin the
+ daemon cannot flag, which is the dangerous half. Daemon-driven means the daemon knows exactly
+ when it happened, and it is the one writing the file that outlives it.
+
+- **`isPlotting()` is the busy predicate — the only one.** It already encodes `inflight`, `queued`,
+ `isPaused`, `penChange !== null`, `Run` and `Hold`. The pen-change hold is the case that matters:
+ it is deliberately `Idle` with an empty queue and no motion, and it is exempt from the UI's 20 s
+ stall watchdog for exactly that reason. Dropping the motors there would shift the gantry mid-plot
+ and land the rest of the drawing offset on a half-finished sheet — there is no resume and no
+ second sheet. A second predicate would be a second chance to forget that case.
+
+- **The gate lives in the gateway, next to the command handlers.** The browser is a thin client and
+ there can be several of them; a second tab must not be able to believe the origin is fine. The
+ refusal names the reason. The UI disables the same controls, but that is a courtesy, not the
+ enforcement.
+
+- **The saved state file carries `trusted`, rather than being deleted.** Deleting it loses the last
+ position entirely, which is the one thing worth keeping for diagnostics ("where did it think it
+ was when it dropped?"). `trusted: false` is written **synchronously**, the same fsync+rename path
+ a power-off flush uses, and `restoreSavedPosition` refuses a file that carries it. A file written
+ by an older daemon has no `trusted` field and is read as trusted — that is the existing behaviour,
+ unchanged, and it is correct: those files were only ever written while the motors were on.
+
+- **A stale file makes the *daemon* start untrusted.** That is what gives the feature a restart
+ story without controller-side support: power down at 02:00, daemon restarts at 09:00, the file
+ says `trusted: false`, so no `G10 L20` goes out and the wizard is waiting.
+
+- **Jog stays allowed; Stop stops but does not go home.** The operator needs jog to reach the corner
+ — the motion is fine, it is the *coordinates* that mean nothing. `stopAndReturnHome` is two
+ actions welded together, and only the second one is dangerous while untrusted, so while untrusted
+ Stop runs the abort alone. Refusing Stop outright would take away a brake.
+
+- **`powered` and `posTrusted` are independent axes.** The motors come back on with the next
+ commanded motion (FluidNC re-energizes on a move); trust comes back only when a human sets work
+ zero. Collapsing them into one flag would either claim the gantry is held when it is free, or
+ claim home is known because something moved.
+
+- **The idle period rides in `Calibration`.** That record is already the shared "machine setup and
+ import defaults" grab-bag (it carries `pngThreshold` and `detail`), it already normalises every
+ numeric key generically, and it already has a settings-page control pattern. `0 = never` doubles
+ as the "keep the motors on" override without a second mechanism to reason about.
+
+- **Nothing new is sent to re-energize.** There is no documented `$ME`; inventing one is how a
+ position-restore bug passes review and fails on the Pi. The motors come back with the first move,
+ the wizard says so, and confirming that on the machine is a hardware task.
+
+- **The rules are pure functions in `src/grbl/motorPower.ts`.** `shouldDropMotors` and
+ `reduceMotorPower` are where the branches are, and that directory is inside the coverage floor.
+ A timer that calls a tested function is a timer with nothing left to test.
+
+## Risks / Trade-offs
+
+- **A 30 s tick against an hour-long timeout** means the drop lands up to 30 s late. That is the
+ right trade: the alternative is waking the Pi 120× more often to be punctual about something
+ nobody is watching.
+- **The activity clock counts motion commands, not every command.** A client that autosaves its
+ session must not hold the motors on forever, so housekeeping traffic is deliberately excluded —
+ at the cost that an operator who is only *looking* at the app gets no reprieve. The plot itself is
+ covered by `isPlotting()` and by bumping the clock on every `Run`/`Jog` status.
+- **An operator who never opens Settings gets the 1 h default.** For someone mid-setup who steps
+ away, the machine will have forgotten the corner when they get back. That is the honest outcome
+ of a machine with no homing; the escape hatch is `0 = never`.
+- **If `$MD` itself fails, nothing is marked.** The state only changes after the command resolves,
+ so a failed drop leaves the motors on and the origin trusted — which is the truth.
diff --git a/openspec/changes/idle-motor-power-down/proposal.md b/openspec/changes/idle-motor-power-down/proposal.md
new file mode 100644
index 0000000..b8b8e98
--- /dev/null
+++ b/openspec/changes/idle-motor-power-down/proposal.md
@@ -0,0 +1,70 @@
+## Why
+
+The steppers are energized 24/7. `motorsOff()` (FluidNC `$MD`) exists only behind a manual button,
+and nothing — no timer, no keep-alive, no controller-side idle disable — ever de-energizes them. An
+unattended plotter therefore sits at full holding current indefinitely: wasted power, heat in the
+drivers, coil whine in the room, and wear for no work done. The machine is idle far more than it
+plots.
+
+Turning them off is the easy half. This machine has **no limit switches and no homing** (`$22=0`),
+so the work origin exists only because the operator put it there by hand, and the energized
+steppers are the only thing holding the gantry. Once they drop out the gantry can be pushed or sag
+on the long axis, and FluidNC has no encoder feedback — it keeps reporting the pre-shutdown
+coordinates whatever the gantry actually did. The reported position becomes a *claim*, not a
+measurement.
+
+The daemon has no way to say that today. `posReady` is cleared on **disconnect** and re-set by a
+restore or by Calibrate, but nothing clears it while the daemon stays connected — which is exactly
+the case a motor power-down creates. Worse, `persistState` keeps writing the position to disk and
+`restoreSavedPosition` feeds it back through `G10 L20` on the next connect, reinstating an origin
+recorded after the gantry was free to move. With soft limits disabled per-axis, nothing then stops
+a plot from driving into the frame. **Auto-off without the trust handling is worse than leaving the
+motors on**, so the two halves ship together.
+
+## What Changes
+
+- **Idle auto-off.** The daemon tracks time since the last commanded motion and sends `$MD` after a
+ configurable period (default 1 h; 0 = never, which is also the "keep the motors on" override).
+ It never fires while `isPlotting()` — which already encodes streaming, queued, paused, `Run`,
+ `Hold` **and a pen-change hold**, the case that looks idle and is not.
+- **Position trust becomes explicit state.** Dropping the motors — on the timer *or* on the manual
+ **Motors off** button — marks the position untrusted, stops persistence, and rewrites the saved
+ state file with `trusted: false` so a restore after a daemon restart refuses it instead of
+ reinstating a dead origin.
+- **The gateway gates motion that depends on the origin.** Plot and Go to home are refused with the
+ reason, for every connected client, enforced next to the command handlers rather than in the
+ browser. Stop still stops — it just does not rapid to an origin nobody believes. Jog stays
+ allowed: the operator needs it to reach the corner, and its numbers are simply meaningless until
+ re-zeroed.
+- **A re-zero wizard** (`src/ui/RezeroWizard.tsx`, modelled on the registration wizard) explains
+ that the motors were powered down and home is gone, says plainly that the gantry will not fight
+ back, walks the operator to the paper's top-left corner by hand or by jog, and ends on Set —
+ `setWorkZero()`, the same call Calibrate makes. It opens by itself when the position goes
+ untrusted and from the Home/calibration panel, and a banner keeps the state visible until it is
+ cleared.
+- **`src/grbl/motorPower.ts`** holds the two pure rules — when the motors may be dropped, and what
+ each event does to trust — so the part that decides is unit-tested rather than living in a timer.
+
+## Capabilities
+
+### Added Capabilities
+- `motor-power`: the steppers are energized only while they are needed, and the software knows the
+ moment the reported position stopped being a measurement.
+
+### Modified Capabilities
+- `work-coordinates`: the work origin can be *lost* as well as set, and motion that depends on it is
+ refused while it is.
+- `gateway-protocol`: motor power and position trust are in the snapshot and pushed as an event, so
+ every client agrees.
+- `app-settings`: the idle period is part of the shared machine setup.
+
+## Impact
+
+- **Code:** new `src/grbl/motorPower.ts` (+ tests) and `src/ui/RezeroWizard.tsx`;
+ `gateway/server.ts`, `src/gateway/protocol.ts`, `src/grbl/settings.ts`,
+ `src/transport/GatewayClient.ts`, `src/ui/App.tsx`, `src/ui/SettingsPage.tsx`.
+- **Behaviour:** an operator who walks away for an hour comes back to a machine that has forgotten
+ where the paper is and says so. That is the point — it forgot the moment the motors dropped; the
+ change is that it now admits it instead of plotting from a guess.
+- **Hardware:** unverified on the machine. Whether the drivers audibly drop out, whether the gantry
+ is genuinely free, and whether the motors re-energize on the next move all want the real UUNA TEK.
diff --git a/openspec/changes/idle-motor-power-down/specs/app-settings/spec.md b/openspec/changes/idle-motor-power-down/specs/app-settings/spec.md
new file mode 100644
index 0000000..1eff7be
--- /dev/null
+++ b/openspec/changes/idle-motor-power-down/specs/app-settings/spec.md
@@ -0,0 +1,18 @@
+## ADDED Requirements
+
+### Requirement: The idle power-down period is part of the shared machine setup
+
+The idle period after which the motors are powered down SHALL be stored with the app settings the
+daemon owns, so every client of one plotter uses the same value, and SHALL be editable from the
+settings page. A missing or non-numeric value SHALL fall back to the default rather than disabling
+or shortening the timer by accident.
+
+#### Scenario: Changed on one device
+
+- **WHEN** the operator changes the idle period on one device
+- **THEN** the daemon stores it and the other devices show the new value
+
+#### Scenario: A settings file without the field
+
+- **WHEN** the daemon reads a settings file written before this setting existed
+- **THEN** the default period is used
diff --git a/openspec/changes/idle-motor-power-down/specs/gateway-protocol/spec.md b/openspec/changes/idle-motor-power-down/specs/gateway-protocol/spec.md
new file mode 100644
index 0000000..a07c566
--- /dev/null
+++ b/openspec/changes/idle-motor-power-down/specs/gateway-protocol/spec.md
@@ -0,0 +1,18 @@
+## ADDED Requirements
+
+### Requirement: Motor power and position trust are part of the shared state
+
+The gateway SHALL include the motor power state, whether the position is trusted, and the reason it
+is not, in the snapshot sent to a client on attach, and SHALL push the same record as an event
+whenever it changes. One machine has one answer to "is home still known", and every client SHALL see
+it — including a client that attaches long after the power-down.
+
+#### Scenario: Attaching after a power-down
+
+- **WHEN** a client attaches to a daemon whose motors were powered down
+- **THEN** its snapshot says the motors are off and the position is untrusted, with the reason
+
+#### Scenario: State change is pushed
+
+- **WHEN** the motors are powered down, or the operator re-zeroes
+- **THEN** every connected client is sent the new state without having to ask
diff --git a/openspec/changes/idle-motor-power-down/specs/motor-power/spec.md b/openspec/changes/idle-motor-power-down/specs/motor-power/spec.md
new file mode 100644
index 0000000..4ac1947
--- /dev/null
+++ b/openspec/changes/idle-motor-power-down/specs/motor-power/spec.md
@@ -0,0 +1,106 @@
+## Purpose
+
+The steppers hold the gantry, and on a machine with no limit switches and no encoder feedback they
+are the only thing that does. Keeping them energized around the clock costs power, heat and wear;
+dropping them costs the work origin. This capability is both halves: when they may be dropped, and
+what the software knows the moment they are.
+
+## ADDED Requirements
+
+### Requirement: The motors power down after an idle period
+
+The system SHALL de-energize the stepper motors after a configurable period with no commanded
+motion. The period SHALL default to 1 hour and SHALL be settable, with a value of zero meaning the
+motors are never powered down automatically. The setting SHALL be shared by every client of one
+plotter.
+
+#### Scenario: Idle long enough
+
+- **WHEN** the machine has been connected and idle for the configured period
+- **THEN** the daemon disables the steppers and tells every connected client
+
+#### Scenario: Auto-off disabled
+
+- **WHEN** the period is set to zero
+- **THEN** the motors are never powered down on a timer, however long the machine sits
+
+#### Scenario: Not connected
+
+- **WHEN** there is no plotter connected
+- **THEN** no power-down is attempted
+
+### Requirement: Auto-off never fires mid-job
+
+The system SHALL NOT power the motors down while a program is streaming, queued, paused, while the
+machine reports `Run` or `Hold`, **or while the program is held at a pen change**. A pen-change hold
+is deliberately idle with an empty queue and no motion; it is mid-job, and dropping the motors there
+would shift the gantry and land the rest of the drawing offset on a sheet that cannot be restarted.
+
+#### Scenario: Held at a pen change
+
+- **WHEN** a plot is waiting for the operator to change the pen, for longer than the idle period
+- **THEN** the motors stay energized, and the job resumes on the same origin it started on
+
+#### Scenario: Paused mid-plot
+
+- **WHEN** a plot is paused for longer than the idle period
+- **THEN** the motors stay energized
+
+#### Scenario: The clock starts when the job ends
+
+- **WHEN** a long plot finishes
+- **THEN** the idle period is measured from the end of the job, not from the last command before it
+
+### Requirement: A motor power-down makes the position untrusted
+
+When the motors are de-energized — automatically or by the operator's **Motors off** — the system
+SHALL mark the reported position as no longer trustworthy, stop persisting it, and record the reason
+in words. Manual and automatic power-down SHALL be treated identically: both free the gantry.
+
+#### Scenario: Automatic power-down
+
+- **WHEN** the idle timer disables the motors
+- **THEN** the position is marked untrusted, with a reason naming the idle period, and the daemon
+ stops writing the position to disk
+
+#### Scenario: The operator switches the motors off
+
+- **WHEN** the operator presses Motors off
+- **THEN** the position is marked untrusted exactly as it would be on the timer
+
+#### Scenario: The power-down fails
+
+- **WHEN** the disable command does not reach the controller
+- **THEN** the position stays trusted, because the motors are still holding the gantry
+
+### Requirement: A position recorded across a power-down is never restored as the origin
+
+The system SHALL record, with the persisted position, whether it was trustworthy when written, and
+SHALL refuse to reinstate a position marked untrustworthy as the work origin. A persisted position
+written before this was recorded SHALL continue to be treated as trustworthy.
+
+#### Scenario: Daemon restarts after a power-down
+
+- **WHEN** the motors were powered down and the daemon is restarted hours later
+- **THEN** the saved position is not applied as the work origin, and the machine comes up asking to
+ be re-zeroed
+
+#### Scenario: Normal restart
+
+- **WHEN** the daemon restarts while the position was trustworthy
+- **THEN** the position is restored as the work origin, as before
+
+### Requirement: Trust is restored only by setting work zero
+
+The system SHALL treat the position as trustworthy again only when the operator sets the work origin.
+Movement alone SHALL NOT restore trust, however much of it there is.
+
+#### Scenario: Re-zeroed
+
+- **WHEN** the operator sets work zero after a power-down
+- **THEN** the position is trusted again, persistence resumes, and the refusals lift
+
+#### Scenario: Jogging is not re-zeroing
+
+- **WHEN** the operator jogs the machine after a power-down without setting work zero
+- **THEN** the position is still untrusted
diff --git a/openspec/changes/idle-motor-power-down/specs/work-coordinates/spec.md b/openspec/changes/idle-motor-power-down/specs/work-coordinates/spec.md
new file mode 100644
index 0000000..feb10a1
--- /dev/null
+++ b/openspec/changes/idle-motor-power-down/specs/work-coordinates/spec.md
@@ -0,0 +1,58 @@
+## ADDED Requirements
+
+### Requirement: Motion that depends on the work origin is refused while it is unknown
+
+While the position is untrusted, the system SHALL refuse to start a plot and SHALL refuse to return
+to the work origin, and the refusal SHALL name the reason rather than failing silently. The refusal
+SHALL be enforced by the daemon, so it applies to every connected client and not only the one that
+saw the power-down. Jogging SHALL remain available — the operator needs it to reach the corner —
+with the understanding that its coordinates mean nothing until the origin is set again.
+
+#### Scenario: Plot refused
+
+- **WHEN** a client asks to plot while the position is untrusted
+- **THEN** the request is refused with a message saying the motors were powered down and home is
+ lost, and nothing is streamed
+
+#### Scenario: Go to home refused
+
+- **WHEN** a client asks to return to the work origin while the position is untrusted
+- **THEN** the request is refused with the same reason, and the machine does not move
+
+#### Scenario: A second client is refused too
+
+- **WHEN** another device connects after the power-down and asks to plot
+- **THEN** it is refused on the same grounds, having been told the state when it attached
+
+#### Scenario: Jogging still works
+
+- **WHEN** the operator jogs while the position is untrusted
+- **THEN** the machine moves as asked
+
+#### Scenario: Stopping still stops
+
+- **WHEN** the operator stops the machine while the position is untrusted
+- **THEN** the motion is aborted, but the machine does not rapid to an origin that is not known
+
+### Requirement: The operator is walked back to a work origin
+
+The system SHALL offer a guided re-zero that opens when the position becomes untrusted and can be
+reopened from the home/calibration controls. It SHALL state that the motors were powered down and
+the origin is lost, warn that the gantry is free and will not resist being pushed, let the operator
+reach the paper's top-left corner by hand or by jogging, and finish by setting the work origin there.
+The untrusted state SHALL stay visible until it is cleared.
+
+#### Scenario: The wizard opens on its own
+
+- **WHEN** the position becomes untrusted
+- **THEN** the guided re-zero opens, saying the motors are off and home is gone
+
+#### Scenario: Reopened later
+
+- **WHEN** the operator dismisses it and later chooses to re-zero
+- **THEN** it opens again from the home/calibration controls, and the state was visible in between
+
+#### Scenario: Finishing the wizard
+
+- **WHEN** the operator positions the head at the paper's top-left corner and sets home
+- **THEN** the work origin is set there, the position is trusted again, and plotting is available
diff --git a/openspec/changes/idle-motor-power-down/tasks.md b/openspec/changes/idle-motor-power-down/tasks.md
new file mode 100644
index 0000000..8d27ac8
--- /dev/null
+++ b/openspec/changes/idle-motor-power-down/tasks.md
@@ -0,0 +1,53 @@
+## 1. The rules, as pure functions
+
+- [x] 1.1 `src/grbl/motorPower.ts`: `MotorPower` (`powered` / `posTrusted` / `reason`),
+ `shouldDropMotors` (now, last activity, period, connected, busy, already-off) and
+ `reduceMotorPower` over `idleTimeout | manualOff | staleRestore | motion | setWorkZero`
+- [x] 1.2 Unit tests: the timer against the busy predicate (streaming, queued, paused, `Run`,
+ `Hold`, **pen-change hold**), period of zero, negative and non-finite periods, not connected,
+ already off, exactly-at-the-boundary, and the trust state machine idle → untrusted →
+ jogged (still untrusted) → re-zeroed → trusted
+- [x] 1.3 `motorIdleMin` in `Calibration` (default 60, `0 = never`), picked up by the existing
+ generic normalisation
+
+## 2. The daemon owns the state
+
+- [x] 2.1 `gateway/server.ts`: activity clock bumped by motion commands and by `Run`/`Jog` status,
+ not by session/project/settings housekeeping
+- [x] 2.2 30 s ticker calling `shouldDropMotors`; on a drop, `$MD` first and state only after it
+ resolves
+- [x] 2.3 A drop clears `posReady`, invalidates the saved position (`trusted: false`, written
+ synchronously) and broadcasts; `motorsOff` from a client takes the same path
+- [x] 2.4 `restoreSavedPosition` refuses a file marked untrusted and comes up untrusted instead;
+ a file with no flag is still trusted
+- [x] 2.5 `setWorkZero` restores trust, resumes persistence and broadcasts
+- [x] 2.6 Refuse `plot` and `goToWorkZero` with the reason while untrusted; `stop` aborts without
+ the return-home; jog untouched
+
+## 3. The protocol, and the clients
+
+- [x] 3.1 `src/gateway/protocol.ts`: `motors` in `Snapshot` and in `ForwardedEvents`
+- [x] 3.2 `src/transport/GatewayClient.ts`: emit `motors` from the snapshot and forward the event
+
+## 4. The wizard
+
+- [x] 4.1 `src/ui/RezeroWizard.tsx`: explain → position (jog, pen up/down, live readout marked
+ meaningless) → set home → confirm
+- [x] 4.2 `src/ui/App.tsx`: open it on the transition to untrusted and from the Home/calibration
+ panel, a banner while untrusted, Plot and Go to home disabled with the reason
+- [x] 4.3 `src/ui/SettingsPage.tsx`: the idle period, with `0 = never` spelled out
+
+## 5. Docs, gate, verification
+
+- [x] 5.1 README, CHANGELOG
+- [x] 5.2 `mise run ci` green
+- [x] 5.3 Verified in the browser: the wizard opens when the position goes untrusted, the banner
+ shows the reason, Plot and Go to home are blocked, and setting home clears all of it
+- [ ] 5.4 ⚙ HARDWARE: after the configured period the drivers audibly drop out and the gantry can
+ be pushed by hand
+- [ ] 5.5 ⚙ HARDWARE: the motors re-energize on the first move after a power-down (no `$ME` is
+ sent — FluidNC documents no such command)
+- [ ] 5.6 ⚙ HARDWARE: a plot started after completing the wizard lands on the paper, and a plot is
+ genuinely refused before it
+- [ ] 5.7 ⚙ HARDWARE: a pen-change hold longer than the idle period does not drop the motors, and
+ the rest of the drawing lands in register
diff --git a/src/gateway/protocol.ts b/src/gateway/protocol.ts
index 8c4cbbf..9641170 100644
--- a/src/gateway/protocol.ts
+++ b/src/gateway/protocol.ts
@@ -4,6 +4,7 @@
*/
import type { GrblSettings, StatusReport } from '../grbl/types';
import type { Calibration } from '../grbl/settings';
+import type { MotorPower } from '../grbl/motorPower';
import type { AppSettings } from './appSettings';
export const DEFAULT_GATEWAY_PORT = 8717;
@@ -100,6 +101,14 @@ export interface Snapshot {
penChange: { index: number; label: string } | null;
/** Projects stored on the daemon, newest first. */
projects: ProjectSummary[];
+ /**
+ * Whether the steppers are energized and whether the work origin can still be
+ * believed. In the snapshot because there is one machine and one answer: a
+ * client that attaches an hour after the motors dropped has to learn that the
+ * origin is gone, or it would happily ask for a plot from a position nothing
+ * has measured. See `src/grbl/motorPower.ts`.
+ */
+ motors: MotorPower;
}
/** What the client needs to list a stored project without loading it. */
@@ -140,6 +149,12 @@ export interface ForwardedEvents {
* client except the one that saved them, so all clients show one setup.
*/
appSettings: AppSettings;
+ /**
+ * Daemon-originated: the motors were powered down (or the origin was set
+ * again). Pushed to every client, because the machine's origin is not a
+ * per-tab opinion.
+ */
+ motors: MotorPower;
}
export type ServerMessage =
diff --git a/src/grbl/__tests__/motorPower.test.ts b/src/grbl/__tests__/motorPower.test.ts
new file mode 100644
index 0000000..1954147
--- /dev/null
+++ b/src/grbl/__tests__/motorPower.test.ts
@@ -0,0 +1,180 @@
+import { describe, expect, it } from 'vitest';
+import {
+ canRestorePosition,
+ describeIdlePeriod,
+ MOTORS_HOLDING,
+ reduceMotorPower,
+ shouldDropMotors,
+ type IdleCheck,
+ type MotorPower,
+} from '../motorPower';
+
+const HOUR_MS = 60 * 60 * 1000;
+
+/** An idle, connected, energized machine that has done nothing for two hours. */
+function idle(over: Partial = {}): IdleCheck {
+ return {
+ now: 2 * HOUR_MS,
+ lastActivityAt: 0,
+ idleMinutes: 60,
+ connected: true,
+ busy: false,
+ powered: true,
+ ...over,
+ };
+}
+
+describe('shouldDropMotors', () => {
+ it('drops the motors once the machine has been idle for the period', () => {
+ expect(shouldDropMotors(idle())).toBe(true);
+ });
+
+ it('waits while the period has not elapsed', () => {
+ expect(shouldDropMotors(idle({ now: 59 * 60_000 }))).toBe(false);
+ });
+
+ it('drops exactly on the boundary', () => {
+ expect(shouldDropMotors(idle({ now: 60 * 60_000 }))).toBe(true);
+ });
+
+ // The whole point of the feature is that it never costs a sheet of paper.
+ // isPlotting() folds streaming, queued, paused, Run, Hold AND the pen-change
+ // hold into one flag; the timer must respect it whatever put it there.
+ it('never fires while the machine is busy', () => {
+ expect(shouldDropMotors(idle({ busy: true }))).toBe(false);
+ });
+
+ it('never fires while a job is held at a pen change, however long the wait', () => {
+ // A pen-change hold is deliberately Idle with an empty queue: only `busy`
+ // distinguishes it from a machine nobody is using.
+ expect(shouldDropMotors(idle({ busy: true, now: 99 * HOUR_MS }))).toBe(false);
+ });
+
+ it('does nothing when no plotter is connected', () => {
+ expect(shouldDropMotors(idle({ connected: false }))).toBe(false);
+ });
+
+ it('does not disable motors that are already off', () => {
+ expect(shouldDropMotors(idle({ powered: false }))).toBe(false);
+ });
+
+ it('treats zero as never', () => {
+ expect(shouldDropMotors(idle({ idleMinutes: 0, now: 99 * HOUR_MS }))).toBe(false);
+ });
+
+ it('treats a negative period as never, rather than as always', () => {
+ expect(shouldDropMotors(idle({ idleMinutes: -1 }))).toBe(false);
+ });
+
+ it('treats a non-finite period as never — a bad settings file must not free the gantry', () => {
+ expect(shouldDropMotors(idle({ idleMinutes: Number.NaN }))).toBe(false);
+ expect(shouldDropMotors(idle({ idleMinutes: Number.POSITIVE_INFINITY }))).toBe(false);
+ });
+
+ it('honours a short period', () => {
+ expect(shouldDropMotors(idle({ idleMinutes: 5, now: 5 * 60_000 }))).toBe(true);
+ expect(shouldDropMotors(idle({ idleMinutes: 5, now: 4 * 60_000 }))).toBe(false);
+ });
+});
+
+describe('reduceMotorPower', () => {
+ it('starts out holding the gantry with a known origin', () => {
+ expect(MOTORS_HOLDING).toEqual({ powered: true, posTrusted: true, reason: null });
+ });
+
+ it('loses the motors and the origin on the idle timeout, and says why', () => {
+ const s = reduceMotorPower(MOTORS_HOLDING, { kind: 'idleTimeout', minutes: 60 });
+ expect(s.powered).toBe(false);
+ expect(s.posTrusted).toBe(false);
+ expect(s.reason).toContain('1 hour');
+ expect(s.reason).toContain('home is no longer known');
+ });
+
+ it('treats Motors off exactly like the timer — same free gantry', () => {
+ const s = reduceMotorPower(MOTORS_HOLDING, { kind: 'manualOff' });
+ expect(s.powered).toBe(false);
+ expect(s.posTrusted).toBe(false);
+ expect(s.reason).toBeTruthy();
+ });
+
+ it('distrusts a position saved across a power-down without claiming the motors are off', () => {
+ const s = reduceMotorPower(MOTORS_HOLDING, { kind: 'staleRestore' });
+ expect(s.posTrusted).toBe(false);
+ expect(s.powered).toBe(true); // the controller just reset; nothing here disabled it
+ expect(s.reason).toContain('not restored');
+ });
+
+ it('re-energizes on motion but does not invent an origin', () => {
+ const off = reduceMotorPower(MOTORS_HOLDING, { kind: 'idleTimeout', minutes: 60 });
+ const moved = reduceMotorPower(off, { kind: 'motion' });
+ expect(moved.powered).toBe(true);
+ expect(moved.posTrusted).toBe(false);
+ expect(moved.reason).toBe(off.reason); // the operator still needs to hear it
+ });
+
+ it('restores trust only when the operator sets work zero', () => {
+ const off = reduceMotorPower(MOTORS_HOLDING, { kind: 'idleTimeout', minutes: 60 });
+ const zeroed = reduceMotorPower(off, { kind: 'setWorkZero' });
+ expect(zeroed.posTrusted).toBe(true);
+ expect(zeroed.reason).toBeNull();
+ });
+
+ it('walks the whole cycle: idle → untrusted → jogged → re-zeroed → trusted', () => {
+ let s: MotorPower = MOTORS_HOLDING;
+ s = reduceMotorPower(s, { kind: 'idleTimeout', minutes: 60 });
+ expect(s.posTrusted).toBe(false);
+ s = reduceMotorPower(s, { kind: 'motion' }); // jogged towards the corner
+ expect(s.posTrusted).toBe(false);
+ s = reduceMotorPower(s, { kind: 'motion' }); // and again
+ expect(s.posTrusted).toBe(false);
+ s = reduceMotorPower(s, { kind: 'setWorkZero' });
+ expect(s).toEqual({ powered: true, posTrusted: true, reason: null });
+ });
+
+ it('does not claim the gantry is held just because home was set', () => {
+ // G10 L20 writes an offset; it does not move anything, so it cannot
+ // re-energize a stepper. Claiming otherwise would tell the operator the
+ // gantry is safe to leave when it is still free.
+ const off = reduceMotorPower(MOTORS_HOLDING, { kind: 'manualOff' });
+ expect(reduceMotorPower(off, { kind: 'setWorkZero' }).powered).toBe(false);
+ });
+
+ it('never mutates the state it was given', () => {
+ const before = { ...MOTORS_HOLDING };
+ reduceMotorPower(MOTORS_HOLDING, { kind: 'manualOff' });
+ expect(MOTORS_HOLDING).toEqual(before);
+ });
+});
+
+describe('describeIdlePeriod', () => {
+ it('says an hour rather than sixty minutes', () => {
+ expect(describeIdlePeriod(60)).toBe('1 hour');
+ });
+ it('counts whole hours', () => {
+ expect(describeIdlePeriod(120)).toBe('2 hours');
+ });
+ it('falls back to minutes', () => {
+ expect(describeIdlePeriod(90)).toBe('90 minutes');
+ expect(describeIdlePeriod(5)).toBe('5 minutes');
+ expect(describeIdlePeriod(1)).toBe('1 minute');
+ });
+});
+
+describe('canRestorePosition', () => {
+ it('restores a position saved while the motors were holding it', () => {
+ expect(canRestorePosition({ trusted: true })).toBe(true);
+ });
+
+ it('refuses a position saved after a power-down — the case this feature exists for', () => {
+ expect(canRestorePosition({ trusted: false })).toBe(false);
+ });
+
+ it('trusts a file written before the flag existed, rather than losing every upgrade’s home', () => {
+ expect(canRestorePosition({})).toBe(true);
+ });
+
+ it('has nothing to restore from a missing file', () => {
+ expect(canRestorePosition(null)).toBe(false);
+ expect(canRestorePosition(undefined)).toBe(false);
+ });
+});
diff --git a/src/grbl/motorPower.ts b/src/grbl/motorPower.ts
new file mode 100644
index 0000000..8ae4ae0
--- /dev/null
+++ b/src/grbl/motorPower.ts
@@ -0,0 +1,136 @@
+/**
+ * Motor power and position trust.
+ *
+ * This machine has no limit switches and no homing (`$22=0`), and FluidNC has no
+ * encoder feedback. The work origin exists only because the operator set it by
+ * hand, and the energized steppers are the only thing holding the gantry there.
+ * The moment they drop out, the coordinates the controller keeps reporting are a
+ * *claim* about a machine that can be pushed, not a measurement of one.
+ *
+ * So the two things worth tracking are independent:
+ *
+ * - `powered` — are the steppers holding the gantry? They go off on `$MD` and
+ * come back with the next commanded move (FluidNC re-energizes on motion).
+ * - `posTrusted` — is the work origin still the one the operator set? Only a
+ * human standing at the paper's corner can answer yes. No amount of movement
+ * restores it.
+ *
+ * Pure rules, no timers and no transport, so the part that decides whether it is
+ * safe to drop the motors is unit-tested rather than buried in an interval.
+ */
+
+/** What the daemon knows about the steppers and the origin they were holding. */
+export interface MotorPower {
+ /** True while the steppers are believed to be energized and holding the gantry. */
+ powered: boolean;
+ /** True while the work origin is still the one the operator set. */
+ posTrusted: boolean;
+ /** Why the position cannot be trusted, in words for the operator. Null when it can. */
+ reason: string | null;
+}
+
+/** Motors on, origin known — what a freshly calibrated machine looks like. */
+export const MOTORS_HOLDING: MotorPower = { powered: true, posTrusted: true, reason: null };
+
+/** Things that change what is known about the motors or the origin. */
+export type MotorPowerEvent =
+ /** The idle timer disabled the steppers after `minutes` with nothing to do. */
+ | { kind: 'idleTimeout'; minutes: number }
+ /** The operator pressed Motors off — same physical consequence, chosen on purpose. */
+ | { kind: 'manualOff' }
+ /** A persisted position was found that was written after a power-down: not usable as an origin. */
+ | { kind: 'staleRestore' }
+ /** Something was commanded to move, which re-energizes the steppers. Says nothing about the origin. */
+ | { kind: 'motion' }
+ /** The operator set the work origin at the head's current position. */
+ | { kind: 'setWorkZero' };
+
+/** "1 hour", "90 minutes" — how long the machine sat, said the way a person would. */
+export function describeIdlePeriod(minutes: number): string {
+ if (minutes === 60) return '1 hour';
+ if (minutes > 60 && minutes % 60 === 0) return `${minutes / 60} hours`;
+ return `${minutes} minute${minutes === 1 ? '' : 's'}`;
+}
+
+/**
+ * Apply one event to the known motor/origin state.
+ *
+ * `motion` deliberately leaves trust alone: the gantry moving proves the drivers
+ * are live, and proves nothing at all about where the paper is.
+ */
+export function reduceMotorPower(prev: MotorPower, event: MotorPowerEvent): MotorPower {
+ switch (event.kind) {
+ case 'idleTimeout':
+ return {
+ powered: false,
+ posTrusted: false,
+ reason:
+ `The motors were powered down after ${describeIdlePeriod(event.minutes)} idle. ` +
+ 'The gantry is free, so home is no longer known.',
+ };
+ case 'manualOff':
+ return {
+ powered: false,
+ posTrusted: false,
+ reason:
+ 'The motors were switched off, so the gantry can be moved by hand and home is no ' +
+ 'longer known.',
+ };
+ case 'staleRestore':
+ // The daemon has just started and found a position saved on the wrong side
+ // of a power-down. The steppers themselves are whatever the controller's
+ // reset left them as — untouched here — but that position is not an origin.
+ return {
+ powered: prev.powered,
+ posTrusted: false,
+ reason:
+ 'The saved position was recorded after the motors were powered down, so it was not ' +
+ 'restored. Home is not known.',
+ };
+ case 'motion':
+ return { powered: true, posTrusted: prev.posTrusted, reason: prev.reason };
+ case 'setWorkZero':
+ return { powered: prev.powered, posTrusted: true, reason: null };
+ }
+}
+
+/**
+ * Whether a persisted position may be reinstated as the work origin.
+ *
+ * A file written before motors could be powered down carries no `trusted` field.
+ * Those positions were only ever saved while the steppers were holding the
+ * gantry, so an absent flag reads as trusted — reading it the other way would
+ * throw away the remembered home of every machine that upgrades.
+ */
+export function canRestorePosition(saved: { trusted?: boolean } | null | undefined): boolean {
+ return saved != null && saved.trusted !== false;
+}
+
+/** Everything the idle rule needs to decide. All of it, so the rule has no hidden inputs. */
+export interface IdleCheck {
+ now: number;
+ /** When the machine was last commanded to move (epoch ms). */
+ lastActivityAt: number;
+ /** Configured idle period in minutes. Zero or less means never power down. */
+ idleMinutes: number;
+ /** Is a plotter attached at all? */
+ connected: boolean;
+ /**
+ * `isPlotting()` — streaming, queued, paused, `Run`, `Hold`, **or held at a pen
+ * change**. That last one is the trap: it is deliberately Idle with an empty
+ * queue and no motion, and it is mid-job. Dropping the motors there shifts the
+ * gantry and lands the rest of the drawing offset on a sheet nobody can restart.
+ */
+ busy: boolean;
+ /** Are the steppers currently energized? No point disabling them twice. */
+ powered: boolean;
+}
+
+/** True when the steppers may be de-energized right now. */
+export function shouldDropMotors(c: IdleCheck): boolean {
+ if (!c.connected || !c.powered || c.busy) return false;
+ // A non-finite period is a hand-edited settings file, not an instruction to
+ // drop the motors immediately — and zero is the operator saying "never".
+ if (!Number.isFinite(c.idleMinutes) || c.idleMinutes <= 0) return false;
+ return c.now - c.lastActivityAt >= c.idleMinutes * 60_000;
+}
diff --git a/src/grbl/settings.ts b/src/grbl/settings.ts
index 96722ee..efaff2a 100644
--- a/src/grbl/settings.ts
+++ b/src/grbl/settings.ts
@@ -28,6 +28,14 @@ export interface Calibration {
/** Machine limits, for reference and UI clamping. */
maxFeedXY: number;
maxFeedZ: number;
+ /**
+ * Minutes of no commanded motion after which the daemon de-energizes the
+ * steppers (`$MD`). `0` means never — which is also the "keep the motors on"
+ * override, for an operator who is stepping away mid-setup and wants the
+ * origin held. Powering down frees the gantry, and with no homing that loses
+ * the work origin, so this always costs a re-zero. See src/grbl/motorPower.ts.
+ */
+ motorIdleMin: number;
}
export const DEFAULT_CALIBRATION: Calibration = {
@@ -44,4 +52,5 @@ export const DEFAULT_CALIBRATION: Calibration = {
pngLevels: 1,
maxFeedXY: 11000,
maxFeedZ: 5000,
+ motorIdleMin: 60,
};
diff --git a/src/transport/GatewayClient.ts b/src/transport/GatewayClient.ts
index 6cd1313..a5740cb 100644
--- a/src/transport/GatewayClient.ts
+++ b/src/transport/GatewayClient.ts
@@ -9,6 +9,7 @@ import type {
UpdateStatus,
} from '../gateway/protocol';
import { normalizeAppSettings, type AppSettings } from '../gateway/appSettings';
+import { MOTORS_HOLDING, type MotorPower } from '../grbl/motorPower';
type ClientEvents = {
connected: { version: string };
@@ -44,6 +45,12 @@ type ClientEvents = {
projects: ProjectSummary[];
/** A project this client asked for. */
projectLoaded: { name: string; project: unknown };
+ /**
+ * Motor power and whether the work origin can still be believed. Emitted from
+ * the snapshot too, so a tab that attaches long after the motors dropped finds
+ * out that home is gone rather than offering to plot from it.
+ */
+ motors: MotorPower;
};
/**
@@ -94,6 +101,7 @@ export class GatewayClient {
private _streamDebug: StreamDebug = { inflight: 0, bytes: 0, queued: 0 };
private _inControl = false;
private _calibration: Calibration | null = null;
+ private _motors: MotorPower = { ...MOTORS_HOLDING };
private nextId = 1;
private pending = new Map void; reject: (e: Error) => void }>();
@@ -127,6 +135,10 @@ export class GatewayClient {
get inControl(): boolean {
return this._inControl;
}
+ /** Motor power + position trust, as the daemon last reported it. */
+ get motors(): MotorPower {
+ return this._motors;
+ }
/** Calibration is pushed to the daemon (the engine there reads pen Z / feeds / dwell). */
set calibration(cal: Calibration) {
@@ -211,6 +223,10 @@ export class GatewayClient {
// Normalised here too: a pre-1.3 daemon sends no field at all.
this.events.emit('appSettings', s.appSettings ? normalizeAppSettings(s.appSettings) : null);
this.events.emit('penChange', s.penChange ?? null);
+ // A daemon older than this feature sends no `motors` — it cannot power
+ // the steppers down either, so "holding, origin known" is the truth there.
+ this._motors = s.motors ?? MOTORS_HOLDING;
+ this.events.emit('motors', this._motors);
this.events.emit('projects', s.projects ?? []);
// Continue a plot that was paused by a previous Disconnect-as-pause.
if (s.paused) this.resume();
@@ -245,6 +261,7 @@ export class GatewayClient {
this._appVersion = msg.payload.appVersion;
this._latestVersion = msg.payload.latestVersion;
} else if (msg.event === 'updateStatus') this._updateStatus = msg.payload;
+ else if (msg.event === 'motors') this._motors = msg.payload;
this.events.emit(msg.event, msg.payload as never);
break;
}
diff --git a/src/ui/App.tsx b/src/ui/App.tsx
index ad9c195..aeadcb4 100644
--- a/src/ui/App.tsx
+++ b/src/ui/App.tsx
@@ -6,6 +6,7 @@ import { loadAppSettings, saveAppSettings } from './settingsStore';
import { loadSession, saveSession, type Session, type PersistedArt } from './sessionStore';
import { flattenSvg, ABORTED } from '../plot/svg';
import { RegistrationWizard } from './RegistrationWizard';
+import { RezeroWizard } from './RezeroWizard';
import { StepPicker } from './StepPicker';
import { btn, btnPrimary, field } from './styles';
import { imageToField, traceField, type FieldSource } from '../plot/raster';
@@ -61,6 +62,7 @@ import { SettingsPage } from './SettingsPage';
import { Logo } from './Logo';
import type { UpdateStatus } from '../gateway/protocol';
import type { AppSettings } from '../gateway/appSettings';
+import { MOTORS_HOLDING, type MotorPower } from '../grbl/motorPower';
type Orientation = 'landscape' | 'portrait';
@@ -248,6 +250,10 @@ export function App() {
// The pen the machine is waiting for, or null. Comes from the daemon (snapshot
// or event), so any client — including one that just attached — can answer it.
const [penChange, setPenChange] = useState<{ index: number; label: string } | null>(null);
+ // Motor power + whether the work origin still means anything. Owned by the
+ // daemon (one machine, one answer) — this is only the local mirror of it.
+ const [motors, setMotors] = useState(MOTORS_HOLDING);
+ const [showRezero, setShowRezero] = useState(false);
// Don't push to the daemon until we've synced with its stored session on connect
// (avoids a stale local push overwriting a newer session from another device).
const sessionLoadedRef = useRef(false);
@@ -370,6 +376,19 @@ export function App() {
}
}),
ctrl.on('projects', (list) => setProjects(list)),
+ ctrl.on('motors', (m) => {
+ setMotors(m);
+ // Losing the origin is not something to find out about by pressing Plot
+ // and being refused, so the wizard comes to the operator. It is closable
+ // — the banner and the gateway's refusal are what actually hold the line.
+ if (!m.posTrusted) setShowRezero(true);
+ pushLog(
+ 'SYS',
+ m.posTrusted
+ ? `position trusted again (motors ${m.powered ? 'on' : 'off'})`
+ : (m.reason ?? 'position untrusted'),
+ );
+ }),
ctrl.on('projectLoaded', (e) => {
openProject(e.project, e.name);
}),
@@ -1336,6 +1355,12 @@ export function App() {
function onPlot() {
const c = ctrl();
if (!c || items.length === 0) return;
+ // The gateway refuses this too, and that refusal is the one that counts.
+ // Here it is about telling the operator what to do instead of failing.
+ if (!motors.posTrusted) {
+ setShowRezero(true);
+ return;
+ }
const placed = displayItems.flatMap((i) => placePolylines(i.polylines, i.placement));
const b = bounds(placed);
if (b.minX < -0.01 || b.minY < -0.01 || b.maxX > bedW + 0.01 || b.maxY > bedH + 0.01) {
@@ -1494,6 +1519,30 @@ export function App() {
}}
/>
)}
+ {showRezero && (
+
+ void ctrl()
+ ?.penUp()
+ .catch(() => undefined)
+ }
+ onPenDown={() =>
+ void ctrl()
+ ?.penDown()
+ .catch(() => undefined)
+ }
+ onSetHome={() => void run(() => ctrl()!.setWorkZero())}
+ onClose={() => setShowRezero(false)}
+ />
+ )}
{showSettings && (
+ The motors are off — the gantry moves freely until the next command.
+
+ )}
run(() => ctrl()!.goToWorkZero())}
>
Go to home
@@ -1925,6 +1999,7 @@ export function App() {
run(() => ctrl()!.motorsOff())}
>
Motors off
@@ -1937,6 +2012,12 @@ export function App() {
Unlock
+ setShowRezero(true)}
+ >
+ Re-zero…
+
diff --git a/src/ui/RezeroWizard.tsx b/src/ui/RezeroWizard.tsx
new file mode 100644
index 0000000..43ec5e4
--- /dev/null
+++ b/src/ui/RezeroWizard.tsx
@@ -0,0 +1,239 @@
+import { useEffect, useRef, useState } from 'react';
+import type { Point } from '../plot/types';
+import { btn, btnPrimary } from './styles';
+import { StepPicker } from './StepPicker';
+
+export interface RezeroWizardProps {
+ /** Why the origin is gone, in the daemon's words. */
+ reason: string | null;
+ /** False once the daemon has dropped the steppers and nothing has moved since. */
+ motorsPowered: boolean;
+ /** Flips true the moment the daemon accepts the new origin — the wizard's finish line. */
+ posTrusted: boolean;
+ connected: boolean;
+ /** Live work position, or null when unknown. Meaningless until home is set again. */
+ penPos: Point | null;
+ jogStep: number;
+ setJogStep: (mm: number) => void;
+ onJog: (dx: number, dy: number) => void;
+ onPenUp: () => void;
+ onPenDown: () => void;
+ /** Set the work origin here — `setWorkZero()`, the same call Calibrate makes. */
+ onSetHome: () => void;
+ onClose: () => void;
+}
+
+const STEPS = 3;
+
+/**
+ * Re-zero after the motors were powered down.
+ *
+ * The Home/calibration panel has always described this sequence in prose; with
+ * an idle power-down it stops being advice. There are no limit switches on this
+ * machine, so once the steppers drop out nothing knows where the paper is — and
+ * the controller goes on reporting the old numbers as if it did. This walks the
+ * operator back to a real origin and does not let the app pretend otherwise in
+ * the meantime.
+ *
+ * Deliberately not dismissible into silence: closing it leaves the banner, and
+ * the gateway refuses Plot and Go to home regardless of what this component does.
+ */
+export function RezeroWizard(p: RezeroWizardProps) {
+ // 0 = what happened, 1 = get to the corner, 2 = done.
+ const [step, setStep] = useState(0);
+ const canJog = p.connected;
+
+ // The daemon is the authority on trust, not this component. Watch for the
+ // moment it *becomes* trusted — which is how a re-zero done from another tab,
+ // or from the Calibrate button behind this dialog, finishes the wizard too.
+ // Only the edge counts: the wizard can also be opened voluntarily on a machine
+ // whose origin is perfectly good, and that must not skip straight to the end.
+ const { posTrusted } = p;
+ const wasTrusted = useRef(posTrusted);
+ useEffect(() => {
+ if (posTrusted && !wasTrusted.current) setStep(STEPS - 1);
+ wasTrusted.current = posTrusted;
+ }, [posTrusted]);
+
+ // Arrow keys jog while the operator is placing the head; their eyes are on the
+ // pen tip, not on the screen. Same as the registration wizard.
+ const { onJog } = p;
+ useEffect(() => {
+ if (step !== 1) return;
+ const map: Record = {
+ ArrowUp: [0, -1],
+ ArrowDown: [0, 1],
+ ArrowLeft: [-1, 0],
+ ArrowRight: [1, 0],
+ };
+ const onKey = (e: KeyboardEvent) => {
+ const d = map[e.key];
+ if (!d || !canJog) return;
+ e.preventDefault();
+ onJog(d[0], d[1]);
+ };
+ window.addEventListener('keydown', onKey);
+ return () => window.removeEventListener('keydown', onKey);
+ }, [step, canJog, onJog]);
+
+ return (
+
+
+
+
Re-zero the machine
+
+ step {step + 1} of {STEPS}
+
+
+
+ {step === 0 && (
+ <>
+
+ {p.reason ?? 'The motors were powered down, so home is no longer known.'}
+
+
+ This machine has no limit switches, so it cannot find the paper again by itself — and
+ it will keep reporting the old coordinates as if nothing happened. Plotting is blocked
+ until you set home again.
+
+
+ Nothing is lost: your artwork, placement and pens are untouched. This is only about
+ where the machine thinks the paper is.
+
+
+
+ Not now
+
+ setStep(1)}>
+ Re-zero now
+
+
+ >
+ )}
+
+ {step === 1 && (
+ <>
+
+ Move the pen tip to the paper’s top-left corner
+ , then press Set home.
+
+ {p.motorsPowered ? (
+
+ The motors are energized again — the gantry will resist being pushed. Use the arrows
+ to jog it.
+
+ ) : (
+
+ The motors are off: the gantry moves freely
+ and will not fight back. Push it by hand, or jog — the first move switches the
+ motors back on.
+
+ )}
+ {!p.connected && (
+
Not connected — connect to jog and set.
+ )}
+
+
+ Reported position:{' '}
+
+ {p.penPos ? `${p.penPos.x.toFixed(2)}, ${p.penPos.y.toFixed(2)}` : '—'}
+ {' '}
+ (means nothing until you set home)
+
+
+
+ Pen down
+
+
+ Pen up
+
+ Arrow keys jog too.
+
+
+
+ Cancel
+
+
+ setStep(0)}>
+ Back
+
+ {
+ p.onSetHome();
+ setStep(2);
+ }}
+ title="Set the work origin at the pen’s current position"
+ >
+ Set home here
+
+
+
+ >
+ )}
+
+ {step === 2 && (
+ <>
+
+ {p.posTrusted
+ ? 'Home is set. The position is trusted again and plotting is available.'
+ : 'Home was not set — the machine still does not know where the paper is. Go back and try again.'}
+
+
+ {p.motorsPowered
+ ? 'The motors are holding the gantry.'
+ : 'The motors come back on with the first move — jog once, or just start the plot.'}
+
+
+ Check it before committing a sheet: Go to home should bring the pen back to this
+ corner.
+
+ The steppers hold the gantry in place; left on, they draw current and get warm for
+ nothing. After this many minutes with no movement they are switched off.
+ This machine has no limit switches, so that also loses the work origin — you
+ will be walked through setting it again before the next plot. Set{' '}
+ 0 to never switch them off, e.g. while you are mid-setup and want home kept.
+
+
+
+
This machine has an inverted Z: Z+ moves the pen down. Pen-down Z is