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