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 && ( + + )} + {/* Self-update banner: shown when a newer release exists or an update is in flight. "Update now" is disabled while plotting (the daemon also refuses). */} {(updateAvailable || updating || updateStatus?.state === 'error') && ( @@ -1907,6 +1970,16 @@ export function App() { Move the pen to the paper's top-left corner — jog with the arrows, or use “Motors off” and push it by hand — then Calibrate.

+ {!motors.posTrusted && ( +

+ {motors.reason} +

+ )} + {motors.posTrusted && !motors.powered && ( +

+ The motors are off — the gantry moves freely until the next command. +

+ )} +
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. +

+
+ + +
+ + )} + + {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) + +
+
+ +
+
+ + onJog(0, -1)} /> + + onJog(-1, 0)} /> + + onJog(1, 0)} /> + + onJog(0, 1)} /> + +
+
+ + + Arrow keys jog too. +
+
+ +
+ + +
+
+ + )} + + {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. +

+
+ {!p.posTrusted && ( + + )} + +
+ + )} +
+
+ ); +} + +function Arrow(props: { label: string; disabled: boolean; onPress: () => void }) { + return ( + + ); +} diff --git a/src/ui/SettingsPage.tsx b/src/ui/SettingsPage.tsx index 71dc0e8..7b6a178 100644 --- a/src/ui/SettingsPage.tsx +++ b/src/ui/SettingsPage.tsx @@ -97,6 +97,22 @@ export function SettingsPage(p: SettingsPageProps) { /> + +

+ 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