From e987d831535a175643d21f19c598d977af9c16ca Mon Sep 17 00:00:00 2001
From: tyeth
Date: Sat, 29 Aug 2026 20:15:53 +0100
Subject: [PATCH 001/135] Perf: Add startup timing marks across main, server
and renderer
Startup cost was unmeasurable: nothing between process start, server
ready and first paint carried a timestamp.
src/startup-timeline.js records named marks against one epoch. Main
stamps LUBAN_START_T0 into its environment; the forked server inherits
it and the renderer reads it off window.process, so marks from all three
processes are comparable. Each prints one ordered table at the end of
its own startup.
Lives at the top of src/ so every entry point reaches it with no build
change - src/*.js is babel-compiled for main, and both webpack bundles
resolve ../startup-timeline.
No behaviour change.
Co-Authored-By: Claude Opus 5
---
src/app/index.jsx | 18 ++++++++++-
src/main.js | 22 +++++++++++--
src/server-cli.js | 12 ++++++-
src/server/index.js | 6 ++++
src/startup-timeline.js | 71 +++++++++++++++++++++++++++++++++++++++++
5 files changed, 124 insertions(+), 5 deletions(-)
create mode 100644 src/startup-timeline.js
diff --git a/src/app/index.jsx b/src/app/index.jsx
index 829a06b9b4..f20da7285f 100644
--- a/src/app/index.jsx
+++ b/src/app/index.jsx
@@ -20,8 +20,16 @@ import './styles/app.styl';
import './styles/vendor.styl';
import workerManager from './lib/manager/workerManager';
import App from './ui/App';
+import { formatTimeline as formatStartupTimeline, mark as startupMark, markAt as startupMarkAt } from '../startup-timeline';
+// Marked here, at module scope, so the gap from navigation to the first line
+// of app code (bundle download + parse) is visible in the table.
+if (typeof performance !== 'undefined' && performance.timeOrigin) {
+ startupMarkAt('renderer: document start', performance.timeOrigin);
+}
+startupMark('renderer: script eval');
+
function setupLog() {
log.setLevel(settings.log.level);
}
@@ -50,6 +58,7 @@ async function setup() {
// Setup i18n
await setupI18next();
+ startupMark('renderer: i18n ready');
// Setup worker
setupWorkerManager();
@@ -67,9 +76,11 @@ series([
const token = machineStore.get('session.token');
user.signin({ token: token })
.then(({ authenticated }) => {
+ startupMark('renderer: signin done');
if (authenticated) {
log.error('Create and establish a WebSocket connection');
controller.connect(() => {
+ startupMark('renderer: socket connected');
next();
});
return;
@@ -111,6 +122,11 @@ series([
,
- container
+ container,
+ () => {
+ startupMark('renderer: first paint');
+ log.info(`
+${formatStartupTimeline('Luban startup - renderer')}`);
+ }
);
});
diff --git a/src/main.js b/src/main.js
index ae8a88ca8d..3670e24180 100644
--- a/src/main.js
+++ b/src/main.js
@@ -19,6 +19,7 @@ import DataStorage from './DataStorage';
import MenuBuilder, { addRecentFile, cleanAllRecentFiles } from './electron-app/Menu';
import { configureWindow } from './electron-app/window';
import pkg from './package.json';
+import { ENV_KEY as STARTUP_EPOCH_KEY, epoch as startupEpoch, formatTimeline as formatStartupTimeline, mark as startupMark } from './startup-timeline';
import * as Sentry from "@sentry/electron/main";
@@ -40,6 +41,9 @@ Sentry.init({
log.setLevel(log.levels.INFO);
+// One clock for main, the forked server and the renderer.
+process.env[STARTUP_EPOCH_KEY] = String(startupEpoch);
+
const config = new Store();
const userDataDir = app.getPath('userData');
global.luban = {
@@ -331,10 +335,17 @@ const startToBegin = (data) => {
const webContentsSession = mainWindow.webContents.session;
electronEnable(mainWindow.webContents);
+ startupMark('main: navigate to app');
webContentsSession.setProxy({ proxyRules: 'direct://' })
- .then(() => mainWindow.loadURL(loadUrl).catch(err => {
- console.log('err', err.message);
- }));
+ .then(() => mainWindow.loadURL(loadUrl)
+ .then(() => {
+ startupMark('main: app page loaded');
+ log.info(`
+${formatStartupTimeline('Luban startup - main process')}`);
+ })
+ .catch(err => {
+ console.log('err', err.message);
+ }));
try {
// TODO: move to server
@@ -346,8 +357,10 @@ const startToBegin = (data) => {
let serverProcess;
const showMainWindow = async () => {
+ startupMark('main: app ready');
const windowOptions = getBrowserWindowOptions();
const window = new BrowserWindow(windowOptions);
+ startupMark('main: window created');
mainWindow = window;
// Monitor policy links, do not allow redirection
window.webContents.on('did-attach-webview', (e, webContent)=> {
@@ -377,6 +390,7 @@ const showMainWindow = async () => {
startToBegin({ ...data, port: CLIENT_PORT });
});
} else {
+ startupMark('main: server fork requested');
serverProcess = childProcess.fork(
path.resolve(__dirname, 'server-cli.js'),
[],
@@ -390,6 +404,7 @@ const showMainWindow = async () => {
);
serverProcess.on('message', (data) => {
if (data.type === SERVER_DATA) {
+ startupMark('main: server ready');
startToBegin(data);
} else if (data.type === UPLOAD_WINDOWS) {
window.loadURL(loadUrl).catch(err => {
@@ -399,6 +414,7 @@ const showMainWindow = async () => {
});
}
// window.webContents.openDevTools();
+ startupMark('main: splash requested');
window.loadURL(path.resolve(__dirname, 'app', 'loading.html'))
.then(() => window.setTitle(`Snapmaker Luban ${pkg.version}`))
.catch(err => {
diff --git a/src/server-cli.js b/src/server-cli.js
index 681a286ccf..0ebb35879a 100644
--- a/src/server-cli.js
+++ b/src/server-cli.js
@@ -4,6 +4,7 @@ import path from 'path';
import program from 'commander';
import isElectron from 'is-electron';
import pkg from './package.json';
+import { formatTimeline as formatStartupTimeline, mark as startupMark } from './startup-timeline';
const SERVER_DATA = 'serverData';
// Defaults to 'production'
@@ -44,10 +45,15 @@ const launchServer = () => new Promise((resolve, reject) => {
userDataDir: process.env.USER_DATA_DIR
};
+ startupMark('server: child entry');
+
// Change working directory to 'server' before require('./server')
process.chdir(path.resolve(__dirname, 'server'));
- require('./server').createServer({
+ const server = require('./server');
+ startupMark('server: bundle required');
+
+ server.createServer({
port: options.port,
host: options.host,
backlog: options.backlog,
@@ -61,6 +67,10 @@ const launchServer = () => new Promise((resolve, reject) => {
reject(err);
return;
}
+ startupMark('server: ready');
+ // eslint-disable-next-line no-console
+ console.log(`
+${formatStartupTimeline('Luban startup - server child')}`);
process.send({ type: SERVER_DATA, ...data });
resolve(data);
});
diff --git a/src/server/index.js b/src/server/index.js
index fd49c3e96a..7bbb1424b0 100644
--- a/src/server/index.js
+++ b/src/server/index.js
@@ -15,11 +15,13 @@ import logger from './lib/logger';
import { startServices } from './services';
import config from './services/configstore';
import monitor from './services/monitor';
+import { mark as startupMark } from '../startup-timeline';
const log = logger('init');
const createServer = (options, callback) => {
+ startupMark('server: createServer entry');
options = { ...options };
const profile = path.resolve(settings.rcfile);
@@ -79,12 +81,16 @@ const createServer = (options, callback) => {
process.env.Tmpdir = DataStorage.tmpDir;
const app = createApplication();
+ startupMark('server: application created');
const { port = 0, host, backlog } = options;
const server = http.createServer(app);
server.listen(port, host, backlog, () => {
+ startupMark('server: listening');
+
// Start socket service
startServices(server);
+ startupMark('server: services started');
// Deal with address bindings
const realAddress = server.address().address;
diff --git a/src/startup-timeline.js b/src/startup-timeline.js
new file mode 100644
index 0000000000..87e23048dd
--- /dev/null
+++ b/src/startup-timeline.js
@@ -0,0 +1,71 @@
+/*
+ * Startup timing marks, shared by the Electron main process, the forked server
+ * child and the renderer.
+ *
+ * All three read the same epoch from LUBAN_START_T0 -- main sets it, the fork
+ * inherits it, and the renderer sees it through nodeIntegration -- so marks
+ * from every process land on one timeline and interleave in the log.
+ *
+ * No dependencies and no I/O until something asks for the table.
+ */
+
+const ENV_KEY = 'LUBAN_START_T0';
+
+const readEpoch = () => {
+ try {
+ // In the renderer, webpack replaces bare `process` with a shim whose env
+ // holds only what DefinePlugin injected. `window.process` is the real
+ // Node process and is left alone, so read that first.
+ if (typeof window !== 'undefined' && window.process && window.process.env) {
+ return Number(window.process.env[ENV_KEY]);
+ }
+ return process && process.env && Number(process.env[ENV_KEY]);
+ } catch (err) {
+ return 0;
+ }
+};
+
+// Set by whichever process starts first; main writes it back to the environment
+// so children and the renderer share it.
+const epoch = readEpoch() || Date.now();
+
+const marks = [];
+
+const elapsed = () => Date.now() - epoch;
+
+/** Record a named point in startup. Returns ms since the shared epoch. */
+const mark = (name) => {
+ const at = elapsed();
+ marks.push({ name, at });
+ return at;
+};
+
+/** Record a point that already happened, given its absolute epoch time. */
+const markAt = (name, absoluteMs) => {
+ const at = Math.round(absoluteMs - epoch);
+ marks.push({ name, at });
+ marks.sort((a, b) => a.at - b.at);
+ return at;
+};
+
+/** Render the marks recorded in this process as one ordered table. */
+const formatTimeline = (title) => {
+ const lines = [`${title} (ms since process start)`];
+ let previous = 0;
+ for (const entry of marks) {
+ const step = entry.at - previous;
+ lines.push(` ${String(entry.at).padStart(7)} +${String(step).padStart(6)} ${entry.name}`);
+ previous = entry.at;
+ }
+ return lines.join('\n');
+};
+
+export {
+ ENV_KEY,
+ epoch,
+ elapsed,
+ mark,
+ markAt,
+ marks,
+ formatTimeline,
+};
From 6001ae9dcdc435d4342dfc55f0809212c98e11d1 Mon Sep 17 00:00:00 2001
From: tyeth
Date: Sat, 29 Aug 2026 20:24:24 +0100
Subject: [PATCH 002/135] Chore: Use an escaped newline in the startup timeline
logs
A literal newline had ended up inside the template literals. Same
output, but the escape is what was meant.
Co-Authored-By: Claude Opus 5
---
src/app/index.jsx | 3 +--
src/main.js | 3 +--
src/server-cli.js | 3 +--
3 files changed, 3 insertions(+), 6 deletions(-)
diff --git a/src/app/index.jsx b/src/app/index.jsx
index f20da7285f..dc1a09f55d 100644
--- a/src/app/index.jsx
+++ b/src/app/index.jsx
@@ -125,8 +125,7 @@ series([
container,
() => {
startupMark('renderer: first paint');
- log.info(`
-${formatStartupTimeline('Luban startup - renderer')}`);
+ log.info(`\n${formatStartupTimeline('Luban startup - renderer')}`);
}
);
});
diff --git a/src/main.js b/src/main.js
index 3670e24180..b7cecf46e1 100644
--- a/src/main.js
+++ b/src/main.js
@@ -340,8 +340,7 @@ const startToBegin = (data) => {
.then(() => mainWindow.loadURL(loadUrl)
.then(() => {
startupMark('main: app page loaded');
- log.info(`
-${formatStartupTimeline('Luban startup - main process')}`);
+ log.info(`\n${formatStartupTimeline('Luban startup - main process')}`);
})
.catch(err => {
console.log('err', err.message);
diff --git a/src/server-cli.js b/src/server-cli.js
index 0ebb35879a..655334ec0d 100644
--- a/src/server-cli.js
+++ b/src/server-cli.js
@@ -69,8 +69,7 @@ const launchServer = () => new Promise((resolve, reject) => {
}
startupMark('server: ready');
// eslint-disable-next-line no-console
- console.log(`
-${formatStartupTimeline('Luban startup - server child')}`);
+ console.log(`\n${formatStartupTimeline('Luban startup - server child')}`);
process.send({ type: SERVER_DATA, ...data });
resolve(data);
});
From 9cd9987e9064274c6d694fb53572de91d3884a53 Mon Sep 17 00:00:00 2001
From: tyeth
Date: Sat, 29 Aug 2026 20:25:31 +0100
Subject: [PATCH 003/135] Perf: Paint the renderer before the backend handshake
index.jsx would not render until signin had returned and the socket had
emitted 'startup'. Neither is needed by anything on the home screen, and
controller.connect() has no timeout, so an unreachable backend left the
user on the spinner indefinitely.
Render once i18n is ready; run signin and connect afterwards. i18n keeps
a 3s cap so a stalled backend delays the paint by at most that.
socket-controller now buffers on/once/channel registrations made before
a socket exists and replays them on connect, because components mount
before connect() is called.
Co-Authored-By: Claude Opus 5
---
src/app/index.jsx | 72 ++++++++++++++++++++------------
src/app/lib/socket-controller.js | 40 ++++++++++++++++++
2 files changed, 86 insertions(+), 26 deletions(-)
diff --git a/src/app/index.jsx b/src/app/index.jsx
index dc1a09f55d..3ef0a3f53e 100644
--- a/src/app/index.jsx
+++ b/src/app/index.jsx
@@ -1,9 +1,9 @@
import { ConfigProvider } from 'antd';
import 'antd/dist/antd.css';
-import series from 'async/series';
import i18next from 'i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import i18nHttpApi from 'i18next-http-backend';
+import once from 'lodash/once';
import React from 'react';
import ReactDOM from 'react-dom';
import { initReactI18next } from 'react-i18next';
@@ -34,15 +34,29 @@ function setupLog() {
log.setLevel(settings.log.level);
}
+// Translations are the one thing worth waiting for -- painting without them
+// shows raw keys. Capped so an unreachable backend delays the paint by at most
+// I18N_TIMEOUT; i18next carries on loading in the background either way.
+const I18N_TIMEOUT = 3000;
+
async function setupI18next() {
return new Promise((resolve) => {
+ const done = once(resolve);
+
i18next
.use(i18nHttpApi)
.use(LanguageDetector)
.use(initReactI18next)
.init(settings.i18next, () => {
- resolve();
+ done();
});
+
+ setTimeout(() => {
+ if (!i18next.isInitialized) {
+ log.warn(`i18n not ready after ${I18N_TIMEOUT}ms, painting anyway`);
+ }
+ done();
+ }, I18N_TIMEOUT);
});
}
@@ -66,29 +80,7 @@ async function setup() {
log.info('Bootstrap finished.');
}
-series([
- async (next) => {
- // setup
- await setup();
- next();
- },
- (next) => {
- const token = machineStore.get('session.token');
- user.signin({ token: token })
- .then(({ authenticated }) => {
- startupMark('renderer: signin done');
- if (authenticated) {
- log.error('Create and establish a WebSocket connection');
- controller.connect(() => {
- startupMark('renderer: socket connected');
- next();
- });
- return;
- }
- next();
- });
- },
-], () => {
+function renderApp() {
log.info(`Launching Snapmaker Luban v${settings.version}...`);
// Prevent browser from loading a drag-and-dropped file
@@ -128,4 +120,32 @@ series([
log.info(`\n${formatStartupTimeline('Luban startup - renderer')}`);
}
);
-});
+}
+
+// Authenticate and open the socket. Deliberately not awaited: nothing on the
+// home screen needs a session, and controller.connect()'s callback only fires
+// once the server answers -- which used to mean an unreachable backend left the
+// user staring at the spinner for ever.
+function connectBackend() {
+ const token = machineStore.get('session.token');
+
+ return user.signin({ token: token })
+ .then(({ authenticated }) => {
+ startupMark('renderer: signin done');
+ if (!authenticated) {
+ log.warn('Not authenticated; socket not opened');
+ return;
+ }
+ controller.connect(() => {
+ startupMark('renderer: socket connected');
+ });
+ })
+ .catch(err => log.error('Backend connect failed', err));
+}
+
+setup()
+ .catch(err => log.error('Bootstrap failed', err))
+ .then(() => {
+ renderApp();
+ connectBackend();
+ });
diff --git a/src/app/lib/socket-controller.js b/src/app/lib/socket-controller.js
index 206aa1104f..a5af6a63e5 100644
--- a/src/app/lib/socket-controller.js
+++ b/src/app/lib/socket-controller.js
@@ -9,6 +9,13 @@ class SocketController {
callbacks = {};
+ // Registrations made before connect(). The renderer now mounts before the
+ // socket exists, so on/once/channel have to survive a null socket and be
+ // replayed once there is one.
+ pending = [];
+
+ connectWaiters = [];
+
get connected() {
return !!(this.socket && this.socket.connected);
}
@@ -34,6 +41,28 @@ class SocketController {
next = null;
}
});
+
+ const pending = this.pending;
+ this.pending = [];
+ for (const { method, args } of pending) {
+ this[method](...args);
+ }
+
+ const waiters = this.connectWaiters;
+ this.connectWaiters = [];
+ for (const resolve of waiters) {
+ resolve();
+ }
+ }
+
+ /** Resolves once a socket exists. Already-connected callers resolve immediately. */
+ whenSocketExists() {
+ if (this.socket) {
+ return Promise.resolve();
+ }
+ return new Promise((resolve) => {
+ this.connectWaiters.push(resolve);
+ });
}
disconnect() {
@@ -48,6 +77,10 @@ class SocketController {
}
on(eventName, callback) {
+ if (!this.socket) {
+ this.pending.push({ method: 'on', args: [eventName, callback] });
+ return;
+ }
if (!this.callbacks[eventName]) {
this.callbacks[eventName] = [];
}
@@ -63,6 +96,10 @@ class SocketController {
}
once(eventName, callback) {
+ if (!this.socket) {
+ this.pending.push({ method: 'once', args: [eventName, callback] });
+ return this;
+ }
this.socket.once(eventName, (...args) => {
callback(...args);
});
@@ -71,6 +108,9 @@ class SocketController {
}
channel(topic, params, onMessage) {
+ if (!this.socket) {
+ return this.whenSocketExists().then(() => this.channel(topic, params, onMessage));
+ }
return new Promise((resolve, reject) => {
const actionid = uuid();
const listener = (_actionid, _STATUS_, result) => {
From 5a91c6381bfa2b8017f255d8cb2d1ff702afaa6b Mon Sep 17 00:00:00 2001
From: tyeth
Date: Sat, 29 Aug 2026 20:38:45 +0100
Subject: [PATCH 004/135] Refactor: Make the backend origin explicit
The API layer used origin-relative paths and socket-controller did
io.connect(''), both of which only work while the page is served by the
backend. #3 loads the app off disk over luban:// before the server
exists, so neither will hold.
backend-origin.js owns the answer. It defaults to window.location.origin
for an http(s) page, so this changes nothing today, and otherwise waits
for main to hand it over: ipcMain answers 'get-server-origin' on demand
and pushes 'server-origin' once the server is listening.
No behaviour change; plumbing for #3.
Co-Authored-By: Claude Opus 5
---
src/app/api/base.ts | 11 +++++
src/app/index.jsx | 4 ++
src/app/lib/backend-origin.js | 70 ++++++++++++++++++++++++++++++++
src/app/lib/socket-controller.js | 4 +-
src/main.js | 11 +++++
5 files changed, 99 insertions(+), 1 deletion(-)
create mode 100644 src/app/lib/backend-origin.js
diff --git a/src/app/api/base.ts b/src/app/api/base.ts
index de6dc4d196..e731b1fa99 100644
--- a/src/app/api/base.ts
+++ b/src/app/api/base.ts
@@ -4,6 +4,7 @@ import superagent from 'superagent';
import TaskQueue from './TaskQueue';
import { machineStore } from '../store/local-storage';
import ensureArray from '../lib/ensure-array';
+import { getBackendOrigin } from '../lib/backend-origin';
const bearer = (request) => {
const token = machineStore.get('session.token');
@@ -12,6 +13,15 @@ const bearer = (request) => {
}
};
+// Relative URLs only resolve while the page is served from the backend. Once it
+// is loaded off disk they need the origin spelling out.
+const backendOrigin = (request) => {
+ const origin = getBackendOrigin();
+ if (origin && typeof request.url === 'string' && request.url.charAt(0) === '/') {
+ request.url = origin + request.url;
+ }
+};
+
const noCache = (request) => {
const now = Date.now();
request.set('Cache-Control', 'no-cache');
@@ -25,6 +35,7 @@ const noCache = (request) => {
const request = superagentUse(superagent);
request.use(bearer);
+request.use(backendOrigin);
request.use(noCache);
diff --git a/src/app/index.jsx b/src/app/index.jsx
index 3ef0a3f53e..7b5e92049b 100644
--- a/src/app/index.jsx
+++ b/src/app/index.jsx
@@ -11,6 +11,7 @@ import { Provider } from 'react-redux';
import settings from './config/settings';
import { controller } from './communication/socket-communication';
+import { listenForBackendOrigin } from './lib/backend-origin';
import { initialize } from './lib/gaEvent';
import log from './lib/log';
import user from './lib/user';
@@ -70,6 +71,9 @@ async function setup() {
// Setup log level
setupLog();
+ // Find out where the backend is before anything asks for it
+ listenForBackendOrigin();
+
// Setup i18n
await setupI18next();
startupMark('renderer: i18n ready');
diff --git a/src/app/lib/backend-origin.js b/src/app/lib/backend-origin.js
new file mode 100644
index 0000000000..6d7afcef73
--- /dev/null
+++ b/src/app/lib/backend-origin.js
@@ -0,0 +1,70 @@
+import isElectron from 'is-electron';
+
+/*
+ * Where the Luban backend lives.
+ *
+ * The renderer is served from the backend's own origin today, so relative URLs
+ * resolve on their own. That stops being true once the window loads the app off
+ * disk over luban:// before the server exists, so the API and socket layers ask
+ * here instead of relying on the page origin.
+ */
+
+// Same-origin when the page came from the server, which is the current case and
+// keeps behaviour identical. Empty under luban://, until main hands us the URL.
+const originFromLocation = () => {
+ if (typeof window === 'undefined' || !window.location) {
+ return '';
+ }
+ return /^https?:$/.test(window.location.protocol) ? window.location.origin : '';
+};
+
+let origin = originFromLocation();
+let waiters = [];
+
+const getBackendOrigin = () => origin;
+
+const setBackendOrigin = (value) => {
+ if (!value || value === origin) {
+ return;
+ }
+ origin = value;
+
+ const pending = waiters;
+ waiters = [];
+ for (const resolve of pending) {
+ resolve(origin);
+ }
+};
+
+/** Resolves once the backend origin is known. */
+const whenBackendOrigin = () => {
+ if (origin) {
+ return Promise.resolve(origin);
+ }
+ return new Promise((resolve) => {
+ waiters.push(resolve);
+ });
+};
+
+/** Ask main for the server URL, and listen for it in case it is not up yet. */
+const listenForBackendOrigin = () => {
+ if (!isElectron() || origin) {
+ return;
+ }
+
+ const { ipcRenderer } = window.require('electron');
+
+ ipcRenderer.on('server-origin', (event, url) => setBackendOrigin(url));
+ ipcRenderer.invoke('get-server-origin')
+ .then(url => setBackendOrigin(url))
+ .catch(() => {
+ // Server not up yet; the 'server-origin' event will arrive later.
+ });
+};
+
+export {
+ getBackendOrigin,
+ setBackendOrigin,
+ whenBackendOrigin,
+ listenForBackendOrigin,
+};
diff --git a/src/app/lib/socket-controller.js b/src/app/lib/socket-controller.js
index a5af6a63e5..c096c485e2 100644
--- a/src/app/lib/socket-controller.js
+++ b/src/app/lib/socket-controller.js
@@ -2,6 +2,8 @@ import noop from 'lodash/noop';
import io from 'socket.io-client';
import { v4 as uuid } from 'uuid';
+import { getBackendOrigin } from './backend-origin';
+
class SocketController {
socket = null;
@@ -31,7 +33,7 @@ class SocketController {
this.socket && this.socket.destroy();
- this.socket = io.connect('', {
+ this.socket = io.connect(getBackendOrigin(), {
query: `token=${token}`,
});
diff --git a/src/main.js b/src/main.js
index b7cecf46e1..0b4bbb9117 100644
--- a/src/main.js
+++ b/src/main.js
@@ -65,6 +65,10 @@ const UPLOAD_WINDOWS = 'uploadWindows';
const { CLIENT_PORT, SERVER_PORT } = pkg.config;
+// The renderer asks for this as soon as it boots, which can be before the server
+// is listening. Answering null is fine - 'server-origin' follows when it is up.
+ipcMain.handle('get-server-origin', () => loadUrl || null);
+
function getBrowserWindowOptions() {
const defaultOptions = {
@@ -285,6 +289,13 @@ const startToBegin = (data) => {
loadUrl = `http://${address}:${port}`;
+ // Tell the renderer where the backend is. Sent now for a page that is already
+ // up, and again on load for one that is not.
+ mainWindow.webContents.send('server-origin', loadUrl);
+ mainWindow.webContents.on('did-finish-load', () => {
+ mainWindow.webContents.send('server-origin', loadUrl);
+ });
+
// register file protocol
protocol.registerFileProtocol(
'luban',
From 027aa1aeb7dc2817ff433cce8b2ba5e10727284f Mon Sep 17 00:00:00 2001
From: tyeth
Date: Sat, 29 Aug 2026 21:57:42 +0100
Subject: [PATCH 005/135] Perf: Load the app off disk instead of waiting for
the server
Closes the splash wait. The window loaded app/loading.html and only
navigated to http://127.0.0.1: once the forked server reported
ready, so the user watched a GIF for however long the fork took - 1.6s
warm, 12-26s cold.
The luban:// handler already serves app/** off disk, so the window now
navigates straight there. The protocol registration, @electron/remote
and the proxy config move ahead of the first load into
prepareAppEnvironment(); startToBegin() keeps only bookkeeping.
That makes every API call cross-origin, so the server answers CORS for
luban:// origins - including the OPTIONS preflight superagent forces by
sending Authorization and Cache-Control - and socket.io gets the cors
option v4 requires.
Three things the move exposed:
- the scheme was registered without supportFetchAPI, so i18next's
requests never reached the handler and always hit the 3s cap
- express mounts the app directory at both / and /worker, so worker URLs
arrive with a prefix that is not on disk; the handler now strips it,
and reports a missing file rather than returning a stream that throws
- components call the API as they mount, about a second before the
server origin arrives, so those calls resolved against luban://.
defaultAPIFactory now holds each call until the origin is known
First paint 3275ms -> 1503ms warm, and now precedes server-ready rather
than following it.
Co-Authored-By: Claude Opus 5
---
src/app/api/base.ts | 39 ++++++----
src/app/index.jsx | 32 ++++----
src/main.js | 103 +++++++++++++++++---------
src/server/app.js | 29 ++++++++
src/server/lib/SocketManager/index.ts | 9 ++-
5 files changed, 149 insertions(+), 63 deletions(-)
diff --git a/src/app/api/base.ts b/src/app/api/base.ts
index e731b1fa99..c15c2e3189 100644
--- a/src/app/api/base.ts
+++ b/src/app/api/base.ts
@@ -4,7 +4,7 @@ import superagent from 'superagent';
import TaskQueue from './TaskQueue';
import { machineStore } from '../store/local-storage';
import ensureArray from '../lib/ensure-array';
-import { getBackendOrigin } from '../lib/backend-origin';
+import { getBackendOrigin, whenBackendOrigin } from '../lib/backend-origin';
const bearer = (request) => {
const token = machineStore.get('session.token');
@@ -44,21 +44,28 @@ const taskQueue = new TaskQueue(4);
// Default API factory that performs the request, and then convert its result to `Promise`.
const defaultAPIFactory = (genRequest) => {
- return async (...args) => new Promise((resolve, reject) => {
- taskQueue.push(
- () => genRequest(...args),
- (response, cb) => {
- response.end((err, res) => {
- if (err) {
- reject(res);
- } else {
- resolve(res);
- }
- cb();
- });
- }
- );
- });
+ return async (...args) => {
+ // The window is up before the server is, and components start calling
+ // the API as soon as they mount. Hold each call until we know where the
+ // backend is, or it resolves against the luban:// handler instead.
+ await whenBackendOrigin();
+
+ return new Promise((resolve, reject) => {
+ taskQueue.push(
+ () => genRequest(...args),
+ (response, cb) => {
+ response.end((err, res) => {
+ if (err) {
+ reject(res);
+ } else {
+ resolve(res);
+ }
+ cb();
+ });
+ }
+ );
+ });
+ };
};
export {
diff --git a/src/app/index.jsx b/src/app/index.jsx
index 7b5e92049b..210d70a3c1 100644
--- a/src/app/index.jsx
+++ b/src/app/index.jsx
@@ -11,7 +11,7 @@ import { Provider } from 'react-redux';
import settings from './config/settings';
import { controller } from './communication/socket-communication';
-import { listenForBackendOrigin } from './lib/backend-origin';
+import { listenForBackendOrigin, whenBackendOrigin } from './lib/backend-origin';
import { initialize } from './lib/gaEvent';
import log from './lib/log';
import user from './lib/user';
@@ -131,19 +131,25 @@ function renderApp() {
// once the server answers -- which used to mean an unreachable backend left the
// user staring at the spinner for ever.
function connectBackend() {
- const token = machineStore.get('session.token');
-
- return user.signin({ token: token })
- .then(({ authenticated }) => {
- startupMark('renderer: signin done');
- if (!authenticated) {
- log.warn('Not authenticated; socket not opened');
- return;
- }
- controller.connect(() => {
- startupMark('renderer: socket connected');
+ // The page can be up before the server is, so wait to be told where it is
+ // rather than firing at a URL that would resolve to the file handler.
+ return whenBackendOrigin().then(() => {
+ startupMark('renderer: backend origin known');
+
+ const token = machineStore.get('session.token');
+
+ return user.signin({ token: token })
+ .then(({ authenticated }) => {
+ startupMark('renderer: signin done');
+ if (!authenticated) {
+ log.warn('Not authenticated; socket not opened');
+ return;
+ }
+ controller.connect(() => {
+ startupMark('renderer: socket connected');
+ });
});
- })
+ })
.catch(err => log.error('Backend connect failed', err));
}
diff --git a/src/main.js b/src/main.js
index 0b4bbb9117..8f062d7e77 100644
--- a/src/main.js
+++ b/src/main.js
@@ -65,6 +65,10 @@ const UPLOAD_WINDOWS = 'uploadWindows';
const { CLIENT_PORT, SERVER_PORT } = pkg.config;
+// The app is served off disk through the luban:// handler, so the window no
+// longer has to wait for the server to be listening before it can load.
+const APP_URL = 'luban://127.0.0.1/';
+
// The renderer asks for this as soon as it boots, which can be before the server
// is listening. Answering null is fine - 'server-origin' follows when it is up.
ipcMain.handle('get-server-origin', () => loadUrl || null);
@@ -280,30 +284,38 @@ if (process.platform === 'win32') {
}
}
-const startToBegin = (data) => {
- serverData = data;
- const { address, port } = data;
- configureWindow(mainWindow);
-
- updateHandle();
-
- loadUrl = `http://${address}:${port}`;
+// Everything the window needs before it can load the app off disk. Runs once,
+// before the first navigation, and no longer waits on the server.
+let appEnvironmentReady = false;
+const prepareAppEnvironment = (window) => {
+ electronEnable(window.webContents);
- // Tell the renderer where the backend is. Sent now for a page that is already
- // up, and again on load for one that is not.
- mainWindow.webContents.send('server-origin', loadUrl);
- mainWindow.webContents.on('did-finish-load', () => {
- mainWindow.webContents.send('server-origin', loadUrl);
- });
+ if (appEnvironmentReady) {
+ return Promise.resolve();
+ }
+ appEnvironmentReady = true;
// register file protocol
protocol.registerFileProtocol(
'luban',
(request, callback) => {
- console.log('file protocol URL:', request.url);
const { pathname } = url.parse(request.url);
- const p = pathname === '/' ? 'index.html' : pathname.substr(1);
+ let p = pathname === '/' ? 'index.html' : pathname.substr(1);
+
+ // The server mounts the app directory at both / and /worker, so a
+ // worker URL carries a prefix that is not part of the path on disk.
+ if (p.indexOf('worker/') === 0) {
+ p = p.substr('worker/'.length);
+ }
+
const filePath = path.normalize(`${__dirname}/app/${p}`);
+
+ if (!fs.existsSync(filePath)) {
+ console.error('luban protocol: not found', filePath);
+ callback({ error: -6 }); // net::ERR_FILE_NOT_FOUND
+ return;
+ }
+
callback(fs.createReadStream(filePath));
},
(error) => {
@@ -341,21 +353,31 @@ const startToBegin = (data) => {
// Ignore proxy settings
// https://electronjs.org/docs/api/session#sessetproxyconfig-callback
+
+
electronRemoteMainInitialize();
- const webContentsSession = mainWindow.webContents.session;
- electronEnable(mainWindow.webContents);
+ // Ignore proxy settings
+ // https://electronjs.org/docs/api/session#sessetproxyconfig-callback
+ return window.webContents.session.setProxy({ proxyRules: 'direct://' });
+};
+
+const startToBegin = (data) => {
+ serverData = data;
+ const { address, port } = data;
+ configureWindow(mainWindow);
+
+ updateHandle();
+
+ loadUrl = `http://${address}:${port}`;
+
+ // Tell the renderer where the backend is. Sent now for a page that is already
+ // up, and again on load for one that is not.
+ mainWindow.webContents.send('server-origin', loadUrl);
+ mainWindow.webContents.on('did-finish-load', () => {
+ mainWindow.webContents.send('server-origin', loadUrl);
+ });
- startupMark('main: navigate to app');
- webContentsSession.setProxy({ proxyRules: 'direct://' })
- .then(() => mainWindow.loadURL(loadUrl)
- .then(() => {
- startupMark('main: app page loaded');
- log.info(`\n${formatStartupTimeline('Luban startup - main process')}`);
- })
- .catch(err => {
- console.log('err', err.message);
- }));
try {
// TODO: move to server
@@ -417,16 +439,21 @@ const showMainWindow = async () => {
startupMark('main: server ready');
startToBegin(data);
} else if (data.type === UPLOAD_WINDOWS) {
- window.loadURL(loadUrl).catch(err => {
+ window.loadURL(APP_URL).catch(err => {
console.log('err', err.message);
});
}
});
}
// window.webContents.openDevTools();
- startupMark('main: splash requested');
- window.loadURL(path.resolve(__dirname, 'app', 'loading.html'))
- .then(() => window.setTitle(`Snapmaker Luban ${pkg.version}`))
+ startupMark('main: app load requested');
+ prepareAppEnvironment(window)
+ .then(() => window.loadURL(APP_URL))
+ .then(() => {
+ window.setTitle(`Snapmaker Luban ${pkg.version}`);
+ startupMark('main: app page loaded');
+ log.info(`\n${formatStartupTimeline('Luban startup - main process')}`);
+ })
.catch(err => {
console.log('err', err.message);
});
@@ -767,7 +794,17 @@ app.on('second-instance', (event, commandLine) => {
}
}
});
-protocol.registerSchemesAsPrivileged([{ scheme: 'luban', privileges: { standard: true, corsEnabled: true } }]);
+protocol.registerSchemesAsPrivileged([{
+ scheme: 'luban',
+ privileges: {
+ standard: true,
+ corsEnabled: true,
+ // i18next and the worker pool fetch over this scheme now that the app is
+ // loaded from it. Not marked secure: the API still lives on plain http.
+ supportFetchAPI: true,
+ stream: true,
+ }
+}]);
/**
* when ready
diff --git a/src/server/app.js b/src/server/app.js
index 98d31db882..7482c8691b 100644
--- a/src/server/app.js
+++ b/src/server/app.js
@@ -54,6 +54,9 @@ const verifyToken = (token) => {
};
const DEFAULT_FILE = 'index.html';
+// Origins allowed to call this server cross-origin: the app loaded off disk.
+const LUBAN_ORIGIN = /^luban:\/\//;
+
const createApplication = () => {
const app = express();
@@ -91,6 +94,32 @@ const createApplication = () => {
log.debug('app.settings: %j', app.settings);
+ // The renderer is served off disk over luban:// so that it need not wait for
+ // this server to start, which makes every call to it cross-origin. Only that
+ // scheme is allowed; it can only be produced by our own protocol handler.
+ app.use((req, res, next) => {
+ const origin = req.get('Origin');
+
+ if (origin && LUBAN_ORIGIN.test(origin)) {
+ res.setHeader('Access-Control-Allow-Origin', origin);
+ res.setHeader('Access-Control-Allow-Credentials', 'true');
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
+ res.setHeader(
+ 'Access-Control-Allow-Headers',
+ 'Authorization, Content-Type, Cache-Control, X-Requested-With'
+ );
+
+ // superagent sets Authorization and Cache-Control, so these are
+ // preflighted rather than simple requests.
+ if (req.method === 'OPTIONS') {
+ res.status(204).end();
+ return;
+ }
+ }
+
+ next();
+ });
+
// Check if client's IP address is in the whitelist
app.use((req, res, next) => {
const ipaddr = req.ip || req.connection.remoteAddress;
diff --git a/src/server/lib/SocketManager/index.ts b/src/server/lib/SocketManager/index.ts
index 4f0613c71d..489766e32e 100644
--- a/src/server/lib/SocketManager/index.ts
+++ b/src/server/lib/SocketManager/index.ts
@@ -35,7 +35,14 @@ class SocketServer extends EventEmitter {
allowEIO3: true,
pingTimeout: 180000, // 60s without pong to consider the connection closed
path: '/socket.io',
- maxHttpBufferSize: 1e8
+ maxHttpBufferSize: 1e8,
+ // The renderer may be served from luban:// rather than from this
+ // server, which makes the handshake cross-origin. socket.io v4
+ // rejects that by default.
+ cors: {
+ origin: (origin, callback) => callback(null, !origin || /^luban:\/\//.test(origin)),
+ credentials: true,
+ }
});
// JWT (JSON Web Tokens) support
From 95cdaa69689f2210d480c09c1749cd4541973864 Mon Sep 17 00:00:00 2001
From: tyeth
Date: Sat, 29 Aug 2026 22:11:05 +0100
Subject: [PATCH 006/135] Perf: Bind the port before loading the heavy services
Closes #2.
DataStorage's constructor logs from module scope and createServer()
logs as its first statement, so the gap between those two lines is
pure require() of ./app and ./services. Across 15 launches of my own
log that gap was 12-24s cold and ~0.6s warm; a cold run of this
branch's parent measured 26.6s.
Nothing above the listener needs either module. The port now binds on
a bare http.createServer whose handler parks requests, the ready
callback fires immediately, and express, the machine channels, the
slicer and the task workers load on the next tick. Parked requests are
replayed once the app exists, so the gap refuses nothing.
DataStorage.init() moves into the deferred block too - it must run
after app.js, which installs the process-wide unhandledRejection
handler that its floating font download relies on.
The startup table prints at 'ready', which is now before all this, so
the deferred phase reports its own line.
Warm: bundle required 1081ms -> 369ms, server ready 1764ms -> 982ms,
services ready at 1711ms and off the critical path.
Co-Authored-By: Claude Opus 5
---
src/server/index.js | 59 +++++++++++++++++++++++++++++++++------------
1 file changed, 44 insertions(+), 15 deletions(-)
diff --git a/src/server/index.js b/src/server/index.js
index 7bbb1424b0..138fa3d0f0 100644
--- a/src/server/index.js
+++ b/src/server/index.js
@@ -9,13 +9,11 @@ import path from 'path';
import http from 'http';
import DataStorage from './DataStorage';
-import createApplication from './app';
import settings from './config/settings';
import logger from './lib/logger';
-import { startServices } from './services';
import config from './services/configstore';
import monitor from './services/monitor';
-import { mark as startupMark } from '../startup-timeline';
+import { elapsed as startupElapsed, mark as startupMark } from '../startup-timeline';
const log = logger('init');
@@ -74,24 +72,25 @@ const createServer = (options, callback) => {
set(settings, 'allowRemoteAccess', allowRemoteAccess);
}
- // Data storage initialize
- log.info('Initializing user data storage...');
- DataStorage.init();
-
process.env.Tmpdir = DataStorage.tmpDir;
- const app = createApplication();
- startupMark('server: application created');
-
const { port = 0, host, backlog } = options;
- const server = http.createServer(app);
+
+ // Bind before loading the heavy half, so the port is known as early as
+ // possible. Anything that arrives in the gap is parked, not refused.
+ let app = null;
+ const parked = [];
+ const server = http.createServer((req, res) => {
+ if (app) {
+ app(req, res);
+ return;
+ }
+ parked.push([req, res]);
+ });
+
server.listen(port, host, backlog, () => {
startupMark('server: listening');
- // Start socket service
- startServices(server);
- startupMark('server: services started');
-
// Deal with address bindings
const realAddress = server.address().address;
const realPort = server.address().port;
@@ -102,6 +101,36 @@ const createServer = (options, callback) => {
log.info(`Starting the server at ${chalk.cyan(`http://${realAddress}:${realPort}`)}`);
+ // Requiring these pulls in express, the machine channels, the slicer and
+ // the task workers: ~1s warm and far worse cold, which is what the splash
+ // used to wait on. Nothing above needs them.
+ setImmediate(() => {
+ // eslint-disable-next-line global-require
+ app = require('./app').default();
+ startupMark('server: application created');
+
+ // Deferred to here because app.js installs the process-wide
+ // unhandledRejection handler, and this leaves floating promises.
+ log.info('Initializing user data storage...');
+ DataStorage.init();
+
+ // eslint-disable-next-line global-require
+ require('./services').startServices(server);
+ startupMark('server: services started');
+
+ // The startup table is printed at 'ready', which is now before this
+ // point, so report the deferred phase separately.
+ log.info(`Services ready ${startupElapsed()}ms after process start`);
+
+ const waiting = parked.splice(0);
+ for (const [req, res] of waiting) {
+ app(req, res);
+ }
+ if (waiting.length) {
+ log.info(`Replayed ${waiting.length} request(s) received before the app was ready`);
+ }
+ });
+
dns.lookup(os.hostname(), { family: 4, all: true }, (err, addresses) => {
if (err) {
log.error(`Can't resolve host name: ${err}`);
From bd64bd334d97373c13f85f9fec59f2f435980b35 Mon Sep 17 00:00:00 2001
From: tyeth
Date: Sat, 29 Aug 2026 22:33:19 +0100
Subject: [PATCH 007/135] Fix: Bound every outbound call so offline fails
instead of hanging
Closes #5.
Behind a VPN or offline the online-resource proxies never answered.
All five logged the error and returned without touching res, so the
renderer's XHR stayed open until the socket gave up. They now carry a
5s response / 10s deadline timeout and always reply, 503 on failure.
getUserInfoData also stops calling agent.use() per request, which
accumulated Authorization plugins on the shared agent and leaked
whichever token was set last into unrelated calls.
downloadManager had no timeout and no catch, so an unreachable font or
calibration map left a floating rejection. It now aborts at 15s and
resolves a boolean - callers treat these as best-effort.
whenBackendOrigin() was unbounded, noted as a known gap in #18; it now
rejects after 30s so API calls fail rather than queue for ever.
CaseResource ran an access probe with no timeout at all, leaving a
blank frame until its 60s iframe timer fired. Bounded at 2s, matching
the home page probe. It was also mounted unconditionally and merely
hidden with display-none, so every launch loaded an iframe from
resources.snapmaker.com even for users who never open the Library; it
now mounts on first open and stays mounted after.
The home page says so inline - an empty grid with a label above the
local examples - rather than popping a dialog.
Co-Authored-By: Claude Opus 5
---
src/app/lib/backend-origin.js | 22 +++++-
src/app/resources/i18n/en/resource.json | 1 +
src/app/ui/layouts/AppLayout.jsx | 10 +++
src/app/ui/pages/CaseResource/index.jsx | 25 +++++--
src/app/ui/pages/HomePage/CaseLibrary.tsx | 9 +++
src/app/ui/pages/HomePage/styles.styl | 8 +++
src/server/lib/downloadManager.ts | 54 +++++++++-----
.../api/api-online-resources-service.js | 71 +++++++++++--------
8 files changed, 146 insertions(+), 54 deletions(-)
diff --git a/src/app/lib/backend-origin.js b/src/app/lib/backend-origin.js
index 6d7afcef73..242a0d80dd 100644
--- a/src/app/lib/backend-origin.js
+++ b/src/app/lib/backend-origin.js
@@ -36,13 +36,29 @@ const setBackendOrigin = (value) => {
}
};
-/** Resolves once the backend origin is known. */
+// Long enough to cover a cold server start, short enough that a backend which
+// is never coming fails the call instead of queueing it for ever.
+const ORIGIN_TIMEOUT = 30000;
+
+/**
+ * Resolves once the backend origin is known.
+ *
+ * Rejects if it never arrives, so callers fail rather than hang.
+ */
const whenBackendOrigin = () => {
if (origin) {
return Promise.resolve(origin);
}
- return new Promise((resolve) => {
- waiters.push(resolve);
+ return new Promise((resolve, reject) => {
+ const timer = setTimeout(
+ () => reject(new Error(`Backend origin unknown after ${ORIGIN_TIMEOUT}ms`)),
+ ORIGIN_TIMEOUT
+ );
+
+ waiters.push((value) => {
+ clearTimeout(timer);
+ resolve(value);
+ });
});
};
diff --git a/src/app/resources/i18n/en/resource.json b/src/app/resources/i18n/en/resource.json
index edccdfabe5..50d90d7498 100644
--- a/src/app/resources/i18n/en/resource.json
+++ b/src/app/resources/i18n/en/resource.json
@@ -1388,6 +1388,7 @@
"key-HomePage/CaseLibrary-Lion Box": "Lion Box",
"key-HomePage/CaseLibrary-Lion Chess Piece": "Lion Chess Piece",
"key-HomePage/CaseLibrary-Luban Lock": "Luban Lock",
+ "key-HomePage/CaseLibrary-Offline": "Online case library unavailable offline. Local examples are shown below.",
"key-HomePage/CaseLibrary-Pen Holder": "Pen Holder",
"key-HomePage/CaseLibrary-Phone Holder": "Phone Holder",
"key-HomePage/CaseLibrary-Quick Start": "Quick Start",
diff --git a/src/app/ui/layouts/AppLayout.jsx b/src/app/ui/layouts/AppLayout.jsx
index dc1242caec..c67a3118f0 100644
--- a/src/app/ui/layouts/AppLayout.jsx
+++ b/src/app/ui/layouts/AppLayout.jsx
@@ -293,6 +293,16 @@ class AppLayout extends React.PureComponent {
});
},
renderCaseResource: () => {
+ // CaseResource was mounted unconditionally and merely hidden with
+ // display-none, so every launch loaded an iframe from
+ // resources.snapmaker.com and ran its access probe even for users
+ // who never open the Library. Mount it on first open, then keep it
+ // so reopening stays instant.
+ if (!this.props.showCaseResource && !this.caseResourceOpened) {
+ return null;
+ }
+ this.caseResourceOpened = true;
+
const onClose = () => { this.props.updateShowCaseReource(false); };
const onCallBack = () => { };
return (
diff --git a/src/app/ui/pages/CaseResource/index.jsx b/src/app/ui/pages/CaseResource/index.jsx
index e36744bca1..c6503da3f9 100644
--- a/src/app/ui/pages/CaseResource/index.jsx
+++ b/src/app/ui/pages/CaseResource/index.jsx
@@ -80,17 +80,34 @@ const CaseResource = (props) => {
let mainToolBarHeight = 66;
// test access of iframe src by path /access-test.css.
// Front end should provid this file in server
+ // Bounded, like the one on the home page. Unbounded, an unreachable host
+ // left the reader on a blank frame until the 60s iframe timer fired.
+ const ACCESS_TEST_TIMEOUT = 2000;
+
const accessTest = (cb) => {
const link = document.createElement('link');
+ let isOver = false;
+
+ const failed = () => {
+ if (isOver) return;
+ isOver = true;
+ cb();
+ setIsIframeLoaded(false);
+ link.parentNode && document.head.removeChild(link);
+ };
+
link.rel = 'stylesheet';
link.type = 'text/css';
link.href = `${resourcesDomain}/access-test.css`;
- link.onerror = () => {
- cb();
- setIsIframeLoaded(false);
- document.head.removeChild(link);
+ link.onerror = failed;
+ link.onload = () => {
+ if (isOver) return;
+ isOver = true;
+ link.parentNode && document.head.removeChild(link);
};
document.head.appendChild(link);
+
+ setTimeout(failed, ACCESS_TEST_TIMEOUT);
};
const handleIframe = () => {
const iframe = caseResourceIframe.current;
diff --git a/src/app/ui/pages/HomePage/CaseLibrary.tsx b/src/app/ui/pages/HomePage/CaseLibrary.tsx
index 819679f2c7..0e029ced78 100644
--- a/src/app/ui/pages/HomePage/CaseLibrary.tsx
+++ b/src/app/ui/pages/HomePage/CaseLibrary.tsx
@@ -236,6 +236,15 @@ const CaseLibrary = (props) => {
)}
+ {/*
+ * Offline or behind a VPN the online case library simply is not
+ * there. Say so inline, above the local examples - not in a dialog.
+ */}
+ {!showCaseResource && canAccessWeb === AccessResourceWebState.BLOCKED && (
+
+ {i18n._('key-HomePage/CaseLibrary-Offline')}
+
+ )}
{!showCaseResource && }
{showQuickStartModal && renderQuickStartModal()}
>
diff --git a/src/app/ui/pages/HomePage/styles.styl b/src/app/ui/pages/HomePage/styles.styl
index f635ea1aa3..7113f78fa8 100644
--- a/src/app/ui/pages/HomePage/styles.styl
+++ b/src/app/ui/pages/HomePage/styles.styl
@@ -200,6 +200,14 @@
}
}
+// Shown in place of the online case grid when it cannot be reached.
+.case-list-empty {
+ padding: 2vw 3.33vw 0;
+ color: #85888C;
+ font-size: 14px;
+ line-height: 20px;
+}
+
.quick-start-container {
display: flex;
flex-direction: column;
diff --git a/src/server/lib/downloadManager.ts b/src/server/lib/downloadManager.ts
index 169fbac556..97af1947e7 100644
--- a/src/server/lib/downloadManager.ts
+++ b/src/server/lib/downloadManager.ts
@@ -1,34 +1,52 @@
import fetch from 'node-fetch';
import fs from 'fs';
+import logger from './logger';
+
+const log = logger('lib:downloadManager');
+
+// These downloads are optional extras - a CJK font, the camera calibration maps.
+// Offline they used to reject with no catch and no timeout, leaving a floating
+// rejection for the process-wide handler to pick up.
+const DOWNLOAD_TIMEOUT = 15000;
class DownloadManager {
- public async download(url: string, savePath: string): Promise {
- return new Promise((resolve, reject) => {
- fetch(url, {
+ public async download(url: string, savePath: string): Promise {
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), DOWNLOAD_TIMEOUT);
+
+ try {
+ const res = await fetch(url, {
headers: { 'Content-Type': 'application/octet-stream' },
- })
- .then(res => res.buffer())
- .then(_ => {
- fs.writeFile(savePath, _, 'binary', (err) => {
- if (err) {
- reject();
- } else {
- resolve();
- }
- });
- });
- });
+ signal: controller.signal,
+ });
+
+ if (!res.ok) {
+ log.warn(`Download failed (${res.status}): ${url}`);
+ return false;
+ }
+
+ const buffer = await res.buffer();
+ await fs.promises.writeFile(savePath, buffer, 'binary');
+ return true;
+ } catch (err) {
+ log.warn(`Download failed: ${url} (${err && err.message ? err.message : err})`);
+ return false;
+ } finally {
+ clearTimeout(timer);
+ }
}
/**
* Download if file on target path not exists.
+ *
+ * Resolves either way - callers treat these as best-effort.
*/
- public async downloadIfNotExist(url: string, savePath: string): Promise {
+ public async downloadIfNotExist(url: string, savePath: string): Promise {
if (fs.existsSync(savePath)) {
- return;
+ return true;
}
- await this.download(url, savePath);
+ return this.download(url, savePath);
}
}
diff --git a/src/server/services/api/api-online-resources-service.js b/src/server/services/api/api-online-resources-service.js
index a4f0a94266..ae4669fe31 100644
--- a/src/server/services/api/api-online-resources-service.js
+++ b/src/server/services/api/api-online-resources-service.js
@@ -10,6 +10,13 @@ if (process.env.NODE_ENV === 'production') {
const log = logger('api:commands');
+// Offline, or behind a VPN with no route to api.snapmaker.com, an unbounded
+// request leaves the renderer's XHR open until the socket gives up. Fail fast
+// and answer instead: the caller can render an offline state, but only if it
+// is told.
+const RESPONSE_TIMEOUT = 5000;
+const DEADLINE_TIMEOUT = 10000;
+
const agent = superagentUse(superagent);
const addPrefix = (prefix) => {
return function (request) {
@@ -22,9 +29,28 @@ const addPrefix = (prefix) => {
};
agent.use(addPrefix(domain));
+const withTimeout = (request) => request.timeout({
+ response: RESPONSE_TIMEOUT,
+ deadline: DEADLINE_TIMEOUT,
+});
+
+// Every one of these used to log and return, leaving the response open.
+const failed = (res, what, err) => {
+ log.error(`${what} failed:`, err && err.message ? err.message : JSON.stringify(err));
+
+ if (res.headersSent) {
+ return;
+ }
+
+ res.status(503).send({
+ error: what,
+ offline: true,
+ message: 'Snapmaker online resources are unreachable.',
+ });
+};
export function getCaseList(req, res) {
- agent.get('/api/resource/sample/list/client')
+ withTimeout(agent.get('/api/resource/sample/list/client'))
.query({
page: 1,
pageSize: 10,
@@ -36,14 +62,12 @@ export function getCaseList(req, res) {
res.status(200).send({
...result.body
});
- }).catch((err) => {
- log.error('get case list err:', JSON.stringify(err));
- });
+ }).catch((err) => failed(res, 'get case list', err));
}
export function getSvgShapeList(req, res) {
- agent.get('/api/resource/svg-shape-library/client/list')
+ withTimeout(agent.get('/api/resource/svg-shape-library/client/list'))
.query({
page: 1,
pageSize: 10,
@@ -53,14 +77,12 @@ export function getSvgShapeList(req, res) {
res.status(200).send({
...result.body
});
- }).catch((err) => {
- log.error(`get svg shape libray list with query: ${JSON.stringify(req.query)}, err:`, JSON.stringify(err));
- });
+ }).catch((err) => failed(res, 'get svg shape library list', err));
}
export function getSvgShapeLabelList(req, res) {
- agent.get('/api/resource/svg-shape-library/client/label/list')
+ withTimeout(agent.get('/api/resource/svg-shape-library/client/label/list'))
.query({
page: 1,
pageSize: 10,
@@ -70,41 +92,32 @@ export function getSvgShapeLabelList(req, res) {
res.status(200).send({
...result.body
});
- }).catch((err) => {
- log.error(`get svg shape libray list with query: ${JSON.stringify(req.query)}, err:`, JSON.stringify(err));
- });
+ }).catch((err) => failed(res, 'get svg shape label list', err));
}
export function getInformationFlowData(req, res) {
const { lang } = req.query;
- agent.get(`/v1/luban-information-flow?lang=${lang}`)
+ withTimeout(agent.get(`/v1/luban-information-flow?lang=${lang}`))
.then((result) => {
res.status(200).send({
...result.body
});
- }).catch((err) => {
- log.error('get information flow err:', JSON.stringify(err));
- });
+ }).catch((err) => failed(res, 'get information flow', err));
}
-const addAuthorization = (token) => {
- return function (request) {
- request.set('Authorization', `Bearer ${token}`);
- return request;
- };
-};
-
export function getUserInfoData(req, res) {
const userDomain = 'https://account.snapmaker.com';
const { token } = req.query;
- agent.use(addAuthorization(token));
- agent.get(`${userDomain}/api/common/accounts/current`)
+
+ // Set per request. This used to agent.use() a new Authorization plugin on
+ // every call, which accumulated on the shared agent and leaked whichever
+ // token was set last into unrelated requests.
+ withTimeout(agent.get(`${userDomain}/api/common/accounts/current`))
+ .set('Authorization', `Bearer ${token}`)
.then((result) => {
res.status(200).send({
...result.body
});
- }).catch((err) => {
- log.error('get information flow err:', JSON.stringify(err));
- });
-}
\ No newline at end of file
+ }).catch((err) => failed(res, 'get user info', err));
+}
From 62a0e6e4e28cefc394f8f54aac8d141adf010964 Mon Sep 17 00:00:00 2001
From: tyeth
Date: Sun, 30 Aug 2026 11:33:16 +0100
Subject: [PATCH 008/135] Perf: Make crash reporting opt-in and load the
updater lazily
Closes #21.
Sentry.init ran at module scope, before app.whenReady, on every launch:
21 integrations, the Electron crashReporter, four OpenTelemetry globals
and a read of its offline envelope store - all ahead of the first
window, and all useless to someone offline or behind a VPN. Nobody
opted in. It is now required and initialised only when the stored
enableCrashReporting flag is set, which defaults to false. debug: true
is dropped from the shipped config.
electron-updater was imported at module scope and updateHandle() wired
its listeners during startup, though nothing checks for updates until
the renderer asks 200ms after mount. The wiring moves into
wireAutoUpdater(), called on first use. node-fetch is likewise required
where the changelog is fetched, its only caller.
The flag is exposed as a Settings > General checkbox and a Settings
menu item, both stating it applies on next start, backed by
get/set-crash-reporting IPC. Main reads the store before any renderer
exists, so the store is authoritative.
Warm, same profile: app ready 497ms -> 76ms, first paint 1413ms ->
1055ms. Startup log drops from 106 to 69 lines.
Co-Authored-By: Claude Opus 5
---
src/app/config/menu/settingsMenu.ts | 23 ++
src/app/resources/i18n/en/resource.json | 3 +
.../settings-modal/General/General.jsx | 35 ++-
src/main.js | 229 +++++++++++-------
4 files changed, 202 insertions(+), 88 deletions(-)
diff --git a/src/app/config/menu/settingsMenu.ts b/src/app/config/menu/settingsMenu.ts
index 8eb59a5292..6a94d6d58c 100644
--- a/src/app/config/menu/settingsMenu.ts
+++ b/src/app/config/menu/settingsMenu.ts
@@ -83,6 +83,29 @@ export default {
}
},
{ type: 'separator' },
+ {
+ id: 'crash-reporting',
+ label: 'key-App/Menu-Crash Reporting',
+ enabled: true,
+ click: (menuItem, browserWindow) => {
+ // Toggle in the main process store; it is read at startup, so
+ // this applies on next start.
+ if (isElectron()) {
+ const { ipcRenderer } = window.require('electron');
+ ipcRenderer.invoke('get-crash-reporting')
+ .then((enabled) => {
+ ipcRenderer.send('set-crash-reporting', !enabled);
+ browserWindow.webContents.send('preferences.show', {
+ activeTab: 'general'
+ });
+ });
+ } else {
+ UniApi.Event.emit('appbar-menu:preferences.show', {
+ activeTab: 'general'
+ });
+ }
+ }
+ },
{
id: 'open-config-folder',
label: 'key-App/Menu-Open Config Folder',
diff --git a/src/app/resources/i18n/en/resource.json b/src/app/resources/i18n/en/resource.json
index 50d90d7498..ee827431c6 100644
--- a/src/app/resources/i18n/en/resource.json
+++ b/src/app/resources/i18n/en/resource.json
@@ -1022,6 +1022,7 @@
"key-App/Menu-Forum": "Forum",
"key-App/Menu-Help": "Help",
"key-App/Menu-Import Object": "Import Object",
+ "key-App/Menu-Crash Reporting": "Toggle Crash Reporting",
"key-App/Menu-Language": "Language",
"key-App/Menu-Laser": "Laser",
"key-App/Menu-Machine Settings": "Machine Settings",
@@ -1067,11 +1068,13 @@
"key-App/Settings/General-A web-based interface which is able to do 3D printing, laser engraving and CNC carving.": "A web-based interface which is able to 3D print, laser engrave, and CNC carve.",
"key-App/Settings/General-Automatically check for updates": "Automatically check for updates",
"key-App/Settings/General-Check for updates": "Check for updates",
+ "key-App/Settings/General-Crash Reporting": "Crash Reporting",
"key-App/Settings/General-File Preview": "File Preview",
"key-App/Settings/General-Language": "Language",
"key-App/Settings/General-Learn more": "Learn more",
"key-App/Settings/General-Preview file when import G code to workspace": "Preview file when importing G-code to Workspace",
"key-App/Settings/General-Software Update": "Software Update",
+ "key-App/Settings/General-Send crash reports": "Send anonymous crash reports to Snapmaker (applies on next start)",
"key-App/Settings/General-Workspace Hide the console when working": "Hide the Console during machining",
"key-App/Settings/General-Workspace Setting": "Workspace Setting",
"key-App/Settings/MachineSettings-10W Laser": "10W Laser",
diff --git a/src/app/ui/pages/global-modals/settings-modal/General/General.jsx b/src/app/ui/pages/global-modals/settings-modal/General/General.jsx
index ea3490267a..1638c80f64 100644
--- a/src/app/ui/pages/global-modals/settings-modal/General/General.jsx
+++ b/src/app/ui/pages/global-modals/settings-modal/General/General.jsx
@@ -1,6 +1,7 @@
import classNames from 'classnames';
+import isElectron from 'is-electron';
import get from 'lodash/get';
-import React, { useEffect } from 'react';
+import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
import FacebookLoading from 'react-facebook-loading';
@@ -130,6 +131,27 @@ function General({ state: generalState, actions }) {
return cleanup;
}, [handlers]);
+ // Crash reporting lives in the main process store: it is read once at
+ // startup, so a change here applies on next start.
+ const [crashReporting, setCrashReporting] = useState(false);
+
+ useEffect(() => {
+ if (!isElectron()) {
+ return;
+ }
+ const { ipcRenderer } = window.require('electron');
+ ipcRenderer.invoke('get-crash-reporting')
+ .then(enabled => setCrashReporting(!!enabled))
+ .catch(() => setCrashReporting(false));
+ }, []);
+
+ const updateCrashReporting = (enabled) => {
+ setCrashReporting(enabled);
+ if (isElectron()) {
+ window.require('electron').ipcRenderer.send('set-crash-reporting', enabled);
+ }
+ };
+
const lang = get(generalState, 'lang', 'en');
if (generalState.api.loading) {
@@ -212,6 +234,17 @@ function General({ state: generalState, actions }) {
+ {isElectron() && (
+
+ { updateCrashReporting(event.target.checked); }}
+ />
+
+ {i18n._('key-App/Settings/General-Send crash reports')}
+
+
+ )}
{
+ if (!store.get(CRASH_REPORTING_KEY, false)) {
+ log.info('Crash reporting disabled');
+ return;
}
- return event;
- }
-});
+
+ try {
+ // eslint-disable-next-line global-require
+ const Sentry = require('@sentry/electron/main');
+
+ Sentry.init({
+ dsn: 'https://cd2af28a126afbc7a8257a75b3b5d0ab@o4508125599563776.ingest.us.sentry.io/4508125605068800',
+ release: pkg.version,
+ tracesSampleRate: 1.0,
+ beforeSend(event) {
+ if (event.exception) {
+ log.error('Captured exception:', event.exception.values[0]);
+ }
+ return event;
+ }
+ });
+ log.info('Crash reporting enabled');
+ } catch (err) {
+ log.warn('Crash reporting failed to initialise', err);
+ }
+};
log.setLevel(log.levels.INFO);
@@ -45,6 +63,8 @@ log.setLevel(log.levels.INFO);
process.env[STARTUP_EPOCH_KEY] = String(startupEpoch);
const config = new Store();
+
+initCrashReporting(config);
const userDataDir = app.getPath('userData');
global.luban = {
userDataDir
@@ -73,6 +93,13 @@ const APP_URL = 'luban://127.0.0.1/';
// is listening. Answering null is fine - 'server-origin' follows when it is up.
ipcMain.handle('get-server-origin', () => loadUrl || null);
+// Crash reporting is read once at startup, so a change applies on next start.
+ipcMain.handle('get-crash-reporting', () => config.get(CRASH_REPORTING_KEY, false));
+ipcMain.on('set-crash-reporting', (event, enabled) => {
+ config.set(CRASH_REPORTING_KEY, !!enabled);
+ log.info(`Crash reporting ${enabled ? 'enabled' : 'disabled'}, applies on next start`);
+});
+
function getBrowserWindowOptions() {
const defaultOptions = {
@@ -151,6 +178,19 @@ function sendUpdateMessage(text) {
}
// handle update issue
+// Required on first use. Nothing checks for updates until the renderer asks,
+// and offline it is dead weight loaded before the window.
+let autoUpdaterInstance = null;
+const getAutoUpdater = () => {
+ if (!autoUpdaterInstance) {
+ // eslint-disable-next-line global-require
+ autoUpdaterInstance = require('electron-updater').autoUpdater;
+ }
+ return autoUpdaterInstance;
+};
+
+let autoUpdaterWired = false;
+
function updateHandle() {
const message = {
error: 'key-settings_message-error',
@@ -158,95 +198,110 @@ function updateHandle() {
updateAva: 'key-settings_message-updateAva',
updateNotAva: 'key-settings_message-update_not_ava'
};
- // Official document: https://www.electron.build/auto-update.html
- autoUpdater.autoDownload = false;
- // Whether to automatically install a downloaded update on app quit. Applicable only on Windows and Linux.
- autoUpdater.autoInstallOnAppQuit = false;
+ // Wired on first use so requiring electron-updater stays off the startup path.
+ const wireAutoUpdater = () => {
+ const updater = getAutoUpdater();
+ if (autoUpdaterWired) {
+ return updater;
+ }
+ autoUpdaterWired = true;
- autoUpdater.on('error', (err) => {
- sendUpdateMessage(message.error, err);
- });
- // Emitted when checking if an update has started.
- autoUpdater.on('checking-for-update', () => {
- sendUpdateMessage(message.checking);
- });
+ // Official document: https://www.electron.build/auto-update.html
+ updater.autoDownload = false;
+ // Whether to automatically install a downloaded update on app quit. Applicable only on Windows and Linux.
+ updater.autoInstallOnAppQuit = false;
- // Emitted when there is an available update. The update is downloaded automatically if autoDownload is true.
- autoUpdater.on('update-available', async (downloadInfo) => {
- // {
- // version: string;
- // files: Array<{ url: string; sha512: string; size: number; }>;
- // path: string;
- // sha512: string;
- // releaseDate: string;
- // releaseNotes: string;
- // }
- log.debug('event: update-available');
-
- sendUpdateMessage(message.updateAva);
-
- // Get chinese version of release note for zh-CN locale
- if (app.getLocale() === 'zh-CN') {
- if (!downloadInfo.releaseNotes && process.platform !== 'linux') {
- // for aliyuncs
- const changelogUrl = `https://snapmaker.oss-cn-beijing.aliyuncs.com/snapmaker.com/download/luban/Snapmaker-Luban-${downloadInfo.version}.changelog.md`;
- const result = await fetch(changelogUrl,
- {
- mode: 'cors',
- method: 'GET',
- headers: {
- 'Content-Type': 'text/markdown'
- }
- })
- .then((response) => {
- response.headers['access-control-allow-origin'] = { value: '*' };
- return response.text();
- });
+ updater.on('error', (err) => {
+ sendUpdateMessage(message.error, err);
+ });
+ // Emitted when checking if an update has started.
+ updater.on('checking-for-update', () => {
+ sendUpdateMessage(message.checking);
+ });
- downloadInfo.releaseChangeLog = result;
- downloadInfo.releaseName = `v${downloadInfo.version}`;
+ // Emitted when there is an available update. The update is downloaded automatically if autoDownload is true.
+ updater.on('update-available', async (downloadInfo) => {
+ // {
+ // version: string;
+ // files: Array<{ url: string; sha512: string; size: number; }>;
+ // path: string;
+ // sha512: string;
+ // releaseDate: string;
+ // releaseNotes: string;
+ // }
+ log.debug('event: update-available');
+
+ sendUpdateMessage(message.updateAva);
+
+ // Get chinese version of release note for zh-CN locale
+ if (app.getLocale() === 'zh-CN') {
+ if (!downloadInfo.releaseNotes && process.platform !== 'linux') {
+ // for aliyuncs
+ const changelogUrl = `https://snapmaker.oss-cn-beijing.aliyuncs.com/snapmaker.com/download/luban/Snapmaker-Luban-${downloadInfo.version}.changelog.md`;
+ // eslint-disable-next-line global-require
+ const fetch = require('node-fetch');
+ const result = await fetch(changelogUrl,
+ {
+ mode: 'cors',
+ method: 'GET',
+ headers: {
+ 'Content-Type': 'text/markdown'
+ }
+ })
+ .then((response) => {
+ response.headers['access-control-allow-origin'] = { value: '*' };
+ return response.text();
+ });
+
+ downloadInfo.releaseChangeLog = result;
+ downloadInfo.releaseName = `v${downloadInfo.version}`;
+ }
}
- }
- mainWindow.webContents.send('update-available', { ...downloadInfo, prevVersion: app.getVersion() });
- });
- // Emitted when there is no available update.
- autoUpdater.on('update-not-available', () => {
- sendUpdateMessage(message.updateNotAva);
- });
- autoUpdater.on('download-progress', (progressObj) => {
- mainWindow.setProgressBar(progressObj.percent / 100);
- });
- // downloadInfo — for generic and github providers
- autoUpdater.on('update-downloaded', debounce((downloadInfo) => {
- ipcMain.on('replaceAppNow', () => {
- // some code here to handle event
- try {
- autoUpdater.quitAndInstall();
- } catch (err) {
- log.error('quitAndInstall get err', err);
- }
+ mainWindow.webContents.send('update-available', { ...downloadInfo, prevVersion: app.getVersion() });
+ });
+ // Emitted when there is no available update.
+ updater.on('update-not-available', () => {
+ sendUpdateMessage(message.updateNotAva);
+ });
+ updater.on('download-progress', (progressObj) => {
+ mainWindow.setProgressBar(progressObj.percent / 100);
});
- mainWindow.webContents.send('is-replacing-app-now', downloadInfo);
- }), 300);
+ // downloadInfo — for generic and github providers
+ updater.on('update-downloaded', debounce((downloadInfo) => {
+ ipcMain.on('replaceAppNow', () => {
+ // some code here to handle event
+ try {
+ updater.quitAndInstall();
+ } catch (err) {
+ log.error('quitAndInstall get err', err);
+ }
+ });
+ mainWindow.webContents.send('is-replacing-app-now', downloadInfo);
+ }), 300);
+
+ return updater;
+ };
+
// Emitted when the user agrees to download
ipcMain.on('startingDownloadUpdate', () => {
mainWindow.webContents.send('download-has-started');
- autoUpdater.downloadUpdate();
+ wireAutoUpdater().downloadUpdate();
});
// Emitted when is ready to check for update
ipcMain.on('checkForUpdate', async (event, autoUpdateProviderOptions) => {
+ const updater = wireAutoUpdater();
+
// Set feed URL
if (autoUpdateProviderOptions.provider === 'generic') {
log.info(`Check for updates, feed URL: ${autoUpdateProviderOptions.url}`);
- autoUpdater.setFeedURL(autoUpdateProviderOptions);
} else {
log.info(`Check for updates, provider: ${autoUpdateProviderOptions.provider}`);
- autoUpdater.setFeedURL(autoUpdateProviderOptions);
}
+ updater.setFeedURL(autoUpdateProviderOptions);
try {
- await autoUpdater.checkForUpdates();
+ await updater.checkForUpdates();
} catch (e) {
log.warn('Check for update failed', e);
}
From 8e423b1e15d2a56cba3189f2e8442ff2c3120f84 Mon Sep 17 00:00:00 2001
From: tyeth
Date: Sat, 29 Aug 2026 20:25:49 +0100
Subject: [PATCH 009/135] Fix: Keep one saved token per machine instead of only
the last connected
machine.json held a single server.{name,address,token} triple, so
connecting to a second machine overwrote the first machine's token,
forcing a touchscreen re-auth on every switch even though tokens stay
valid until the machine powers off.
Persist server.machines as [{name, address, token, lastConnectedAt}]:
- lookup by address first, name as DHCP-reallocation fallback,
most recent record wins on multiple matches
- upsert on successful connect, keyed by address
- legacy keys migrated on first read and still written after each
connect for external readers and UI auto-select
Fixes #6
Co-Authored-By: Claude Opus 5
---
src/app/flux/workspace/actions-connect.ts | 91 ++++++++++++++++++++---
1 file changed, 82 insertions(+), 9 deletions(-)
diff --git a/src/app/flux/workspace/actions-connect.ts b/src/app/flux/workspace/actions-connect.ts
index 73847aa66c..d2cc934b53 100644
--- a/src/app/flux/workspace/actions-connect.ts
+++ b/src/app/flux/workspace/actions-connect.ts
@@ -79,6 +79,81 @@ const setServerToken = (token) => (dispatch) => {
machineStore.set('server.token', token);
};
+/**
+ * Saved machine record, one per known machine.
+ *
+ * Tokens remain valid until the machine is powered off, so keep one
+ * per machine instead of only the last connected one.
+ */
+interface SavedMachineRecord {
+ name: string;
+ address: string;
+ token: string;
+ lastConnectedAt: number;
+}
+
+const getSavedMachines = (): SavedMachineRecord[] => {
+ const machines = machineStore.get('server.machines');
+ if (Array.isArray(machines)) {
+ return machines;
+ }
+
+ // Migrate legacy single-machine keys (server.address / server.name / server.token)
+ const address = machineStore.get('server.address');
+ const token = machineStore.get('server.token');
+ if (address && token) {
+ const migrated: SavedMachineRecord[] = [{
+ name: machineStore.get('server.name') || '',
+ address,
+ token,
+ lastConnectedAt: 0,
+ }];
+ machineStore.replace('server.machines', migrated);
+ return migrated;
+ }
+
+ return [];
+};
+
+/**
+ * Find saved token for the agent.
+ *
+ * Match by address first; fall back to name in case the address got re-allocated.
+ * On multiple matches, prefer the most recently connected record.
+ */
+const findSavedToken = (agent: MachineAgent): string => {
+ const machines = getSavedMachines();
+
+ const byAddress = machines.filter(m => m.address === agent.address);
+ const byName = machines.filter(m => m.name === agent.name);
+ const candidates = byAddress.length > 0 ? byAddress : byName;
+
+ if (candidates.length === 0) {
+ return '';
+ }
+
+ return candidates.reduce((a, b) => (a.lastConnectedAt >= b.lastConnectedAt ? a : b)).token;
+};
+
+/**
+ * Upsert machine record on successful connect, keyed by address.
+ *
+ * Records with the same name but a different address are kept — the machine
+ * may come back on its old address; stale ones lose on lastConnectedAt.
+ */
+const saveMachineToken = (agent: MachineAgent) => {
+ const machines = getSavedMachines().filter(m => m.address !== agent.address);
+
+ machines.push({
+ name: agent.name,
+ address: agent.address,
+ token: agent.getToken(),
+ lastConnectedAt: Date.now(),
+ });
+
+ machineStore.replace('server.machines', machines);
+};
+
const setManualIP = (manualIp) => (dispatch) => {
dispatch(baseActions.updateState({ manualIp }));
@@ -139,15 +214,9 @@ const connect = (agent: MachineAgent) => {
}
// Re-use saved token if possible
- const savedServerName = getState().workspace.savedServerName;
- const savedServerAddress = getState().workspace.savedServerAddress;
- const savedServerToken = getState().workspace.savedServerToken;
-
- if (agent.address === savedServerAddress) {
- agent.setToken(savedServerToken);
- } else if (agent.name === savedServerName) {
- // In case server address is re-allocated, check for saved server name
- agent.setToken(savedServerToken);
+ const savedToken = findSavedToken(agent);
+ if (savedToken) {
+ agent.setToken(savedToken);
}
// update connection status
@@ -171,6 +240,10 @@ const connect = (agent: MachineAgent) => {
isOpen: true,
}));
+ // per-machine token registry
+ saveMachineToken(agent);
+
+ // legacy last-connected keys, kept for external readers and UI auto-select
dispatch(setServerName(agent.name));
dispatch(setServerAddress(agent.address));
dispatch(setServerToken(agent.getToken()));
From cea32e7a419f473a40ec9bf4cc1e4b5878c2617c Mon Sep 17 00:00:00 2001
From: tyeth
Date: Sun, 30 Aug 2026 16:03:46 +0100
Subject: [PATCH 010/135] Feature: Add flag-gated MCP server scaffold in the
backend
MCP Streamable HTTP endpoint (stateless JSON-RPC over POST /mcp) on its
own loopback-only HTTP server, off unless LUBAN_MCP_PORT or configstore
mcpPort is set. Hand-rolled transport: the official SDK needs Node 18+
and Electron 15 embeds Node 16. Tool registry plus one read-only seed
tool, get_connection_status, backed by a new ConnectionManager accessor.
Closes #7.
Co-Authored-By: Claude Opus 5
---
src/server/services/index.ts | 6 +
.../services/machine/ConnectionManager.ts | 35 +++
src/server/services/mcp/McpServer.ts | 229 ++++++++++++++++++
src/server/services/mcp/index.ts | 63 +++++
src/server/services/mcp/registry.ts | 52 ++++
src/server/services/mcp/tools/status.ts | 21 ++
6 files changed, 406 insertions(+)
create mode 100644 src/server/services/mcp/McpServer.ts
create mode 100644 src/server/services/mcp/index.ts
create mode 100644 src/server/services/mcp/registry.ts
create mode 100644 src/server/services/mcp/tools/status.ts
diff --git a/src/server/services/index.ts b/src/server/services/index.ts
index ccbc691229..baf99edf80 100644
--- a/src/server/services/index.ts
+++ b/src/server/services/index.ts
@@ -6,6 +6,7 @@ import * as meshHandlers from './channel-handlers/mesh';
import configstore from './configstore';
import { connectionManager } from './machine/ConnectionManager';
import { textSerialChannel } from './machine/channels/TextSerialChannel';
+import { startMcpService } from './mcp';
import monitor from './monitor';
import { register as registerDiscoverHandlers } from './socket/discover-handlers';
import { register as registerMachineHandlers } from './socket/machine-handlers';
@@ -70,6 +71,11 @@ function startServices(server) {
socketServer.registerChannel('get-free-memory', system.getSystemFreeMemorySize);
socketServer.start(server);
+
+ // ===============
+ // MCP server (off unless a port is configured)
+ // ===============
+ startMcpService();
}
function registerApis(app) {
diff --git a/src/server/services/machine/ConnectionManager.ts b/src/server/services/machine/ConnectionManager.ts
index b610cfcbde..edc9a7fc89 100644
--- a/src/server/services/machine/ConnectionManager.ts
+++ b/src/server/services/machine/ConnectionManager.ts
@@ -115,6 +115,9 @@ class ConnectionManager {
// connected machine instance to handle life cycle
private machineInstance: MachineInstance = null;
+ // identifier of the connected machine, kept for status reporting
+ private machineIdentifier: string | null = null;
+
private scheduledTasksHandle;
/**
@@ -124,6 +127,36 @@ class ConnectionManager {
return this.protocol;
}
+ /**
+ * Stable channel name for reporting. constructor.name is useless in
+ * production builds (webpack minifies class names to single letters).
+ */
+ private describeChannel(): string | null {
+ switch (this.channel) {
+ case null: return null;
+ case sstpHttpChannel: return 'sstp-http';
+ case sacpTcpChannel: return 'sacp-tcp';
+ case sacpUdpChannel: return 'sacp-udp';
+ case sacpSerialChannel: return 'sacp-serial';
+ case textSerialChannel: return 'text-serial';
+ default: return 'unknown';
+ }
+ }
+
+ /**
+ * Read-only snapshot of the connection, for status reporting (MCP).
+ */
+ public getConnectionStatus() {
+ return {
+ connected: !!this.channel,
+ channelName: this.describeChannel(),
+ connectionType: this.channel ? this.connectionType : null,
+ protocol: this.channel ? this.protocol : null,
+ machineIdentifier: this.machineIdentifier,
+ machineReady: !!this.machineInstance,
+ };
+ }
+
// TODO: Refactor this
public onConnection = (socket: SocketServer) => {
sstpHttpChannel.onConnection();
@@ -203,6 +236,7 @@ class ConnectionManager {
const machineIdentifier = data?.machineIdentifier;
log.debug(`machineIdentifier = ${machineIdentifier}`);
+ this.machineIdentifier = machineIdentifier || null;
// configure machine instance
this.machineInstance = null;
@@ -398,6 +432,7 @@ class ConnectionManager {
// destroy channel
this.unbindChannelEvents();
this.channel = null;
+ this.machineIdentifier = null;
// destroy machine instance
if (this.machineInstance) {
diff --git a/src/server/services/mcp/McpServer.ts b/src/server/services/mcp/McpServer.ts
new file mode 100644
index 0000000000..b50cef0407
--- /dev/null
+++ b/src/server/services/mcp/McpServer.ts
@@ -0,0 +1,229 @@
+import http from 'http';
+
+import logger from '../../lib/logger';
+import { McpToolError, ToolRegistry } from './registry';
+
+const log = logger('service:mcp');
+
+// MCP Streamable HTTP transport, stateless mode, implemented directly:
+// the official SDK requires Node >= 18 and Electron 15 embeds Node 16.
+//
+// Scope: JSON-RPC 2.0 over POST /mcp. No session ids, no SSE stream (GET
+// returns 405, which the spec permits for servers that don't offer one).
+const PROTOCOL_VERSION = '2025-03-26';
+
+const JSONRPC_PARSE_ERROR = -32700;
+const JSONRPC_INVALID_REQUEST = -32600;
+const JSONRPC_METHOD_NOT_FOUND = -32601;
+const JSONRPC_INVALID_PARAMS = -32602;
+const JSONRPC_INTERNAL_ERROR = -32603;
+
+const MAX_BODY_BYTES = 4 * 1024 * 1024;
+
+interface JsonRpcMessage {
+ jsonrpc?: string;
+ id?: number | string | null;
+ method?: string;
+ params?: { [key: string]: unknown };
+}
+
+function rpcResult(id: number | string, result: object): object {
+ return { jsonrpc: '2.0', id, result };
+}
+
+function rpcError(id: number | string | null, code: number, message: string): object {
+ return { jsonrpc: '2.0', id, error: { code, message } };
+}
+
+// Requests from browsers carry an Origin header; a permitted one is only
+// ever localhost (or the app's own luban:// scheme). Anything else is a
+// DNS-rebinding attempt on a loopback-only server, per MCP spec guidance.
+function isAllowedOrigin(origin: string | undefined): boolean {
+ if (!origin) {
+ return true;
+ }
+ return /^(luban:\/\/|https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$)/.test(origin);
+}
+
+function isLoopback(address: string | undefined): boolean {
+ return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1';
+}
+
+export class McpServer {
+ private registry: ToolRegistry;
+
+ private serverName: string;
+
+ private serverVersion: string;
+
+ public constructor(registry: ToolRegistry, serverName: string, serverVersion: string) {
+ this.registry = registry;
+ this.serverName = serverName;
+ this.serverVersion = serverVersion;
+ }
+
+ public handleRequest = (req: http.IncomingMessage, res: http.ServerResponse): void => {
+ // Bound to loopback; re-check per request as defense in depth.
+ if (!isLoopback(req.socket.remoteAddress)) {
+ this.respond(res, 403, { error: 'loopback only' });
+ return;
+ }
+ if (!isAllowedOrigin(req.headers.origin)) {
+ log.warn(`MCP request with disallowed origin rejected: ${req.headers.origin}`);
+ this.respond(res, 403, { error: 'origin not allowed' });
+ return;
+ }
+
+ const url = new URL(req.url, 'http://localhost');
+ if (url.pathname !== '/mcp') {
+ this.respond(res, 404, { error: 'not found' });
+ return;
+ }
+ if (req.method !== 'POST') {
+ // No SSE stream is offered (GET) and no sessions exist to end (DELETE).
+ res.writeHead(405, { Allow: 'POST' });
+ res.end();
+ return;
+ }
+
+ this.readBody(req, res, (body) => {
+ this.handlePost(body, res);
+ });
+ };
+
+ private readBody(req: http.IncomingMessage, res: http.ServerResponse, callback: (body: string) => void): void {
+ const chunks: Buffer[] = [];
+ let size = 0;
+ req.on('data', (chunk: Buffer) => {
+ size += chunk.length;
+ if (size > MAX_BODY_BYTES) {
+ req.destroy();
+ this.respond(res, 413, { error: 'body too large' });
+ return;
+ }
+ chunks.push(chunk);
+ });
+ req.on('end', () => {
+ if (!res.writableEnded) {
+ callback(Buffer.concat(chunks).toString('utf8'));
+ }
+ });
+ req.on('error', (err) => {
+ log.warn(`MCP request error: ${err.message}`);
+ });
+ }
+
+ private async handlePost(body: string, res: http.ServerResponse): Promise {
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(body);
+ } catch (err) {
+ this.respond(res, 400, rpcError(null, JSONRPC_PARSE_ERROR, 'Parse error'));
+ return;
+ }
+
+ const messages: JsonRpcMessage[] = Array.isArray(parsed) ? parsed : [parsed as JsonRpcMessage];
+ if (messages.length === 0) {
+ this.respond(res, 400, rpcError(null, JSONRPC_INVALID_REQUEST, 'Empty batch'));
+ return;
+ }
+
+ const responses = [];
+ for (const message of messages) {
+ // eslint-disable-next-line no-await-in-loop
+ const response = await this.handleMessage(message);
+ if (response) {
+ responses.push(response);
+ }
+ }
+
+ if (responses.length === 0) {
+ // Notifications only
+ res.writeHead(202);
+ res.end();
+ } else if (Array.isArray(parsed)) {
+ this.respond(res, 200, responses);
+ } else {
+ this.respond(res, 200, responses[0]);
+ }
+ }
+
+ private async handleMessage(message: JsonRpcMessage): Promise
+ ${directBanner}
Head
${escapeHtml(job.headType)}
Lines / motion lines
${v.lineCount} / ${v.motionLineCount}
diff --git a/src/server/services/mcp/tools/camera.ts b/src/server/services/mcp/tools/camera.ts
index 1506200887..189a24f69d 100644
--- a/src/server/services/mcp/tools/camera.ts
+++ b/src/server/services/mcp/tools/camera.ts
@@ -22,7 +22,7 @@ const POST_SETTLE_DWELL_MS = 300;
const HOME_TIMEOUT_MS = 120000;
const HOME_POLL_MS = 1000;
-interface GcodeChannel {
+export interface GcodeChannel {
executeGcode?: (gcode: string) => Promise<{ result: number; text?: string }>;
}
@@ -31,7 +31,7 @@ interface GcodeChannel {
* controller's reply) to the UI console, so the operator can see which
* coordinate frame every MCP-issued command ran in.
*/
-async function sendGcodeVisible(channel: GcodeChannel, tool: string, gcode: string): Promise<{ result: number; text?: string }> {
+export async function sendGcodeVisible(channel: GcodeChannel, tool: string, gcode: string): Promise<{ result: number; text?: string }> {
mcpBroadcast('mcp:gcode', { tool, gcode });
const executed = await channel.executeGcode(gcode);
mcpBroadcast('mcp:gcode', { tool, response: executed.text || (executed.result === 0 ? 'ok' : `result=${executed.result}`) });
diff --git a/src/server/services/mcp/tools/gcode.ts b/src/server/services/mcp/tools/gcode.ts
index 247d9dff78..d59dbfedaf 100644
--- a/src/server/services/mcp/tools/gcode.ts
+++ b/src/server/services/mcp/tools/gcode.ts
@@ -1,9 +1,13 @@
/* eslint-disable camelcase */
// MCP tool arguments are snake_case by convention.
+import * as fs from 'fs-extra';
+
import { connectionManager } from '../../machine/ConnectionManager';
import { jobManager } from '../jobs';
import { McpToolError, ToolRegistry } from '../registry';
import { validateGcode } from '../validator';
+import { GcodeChannel, sendGcodeVisible } from './camera';
+import { PositionSnapshot, getMachineSizeByIdentifier, getPositionSnapshot } from './machine';
// Motion policy (#23): compound motion leaves this process only as a G-code
// file submitted through the same prepare/start path as "Start on Luban",
@@ -14,6 +18,7 @@ import { validateGcode } from '../validator';
const HEAD_TYPES = ['cnc', 'laser', 'printing'];
interface JobChannel {
+ executeGcode?: (gcode: string) => Promise<{ result: number; text?: string }>;
uploadGcodeFile?: (filePath: string, type: string, renderName: string, callback: (msg: unknown, data?: unknown) => void) => void;
startGcodeJob?: () => Promise<{ ok: boolean; code?: number; text?: string }>;
stopGcodeJob?: () => Promise<{ ok: boolean; code?: number; text?: string }>;
@@ -30,6 +35,34 @@ function getJobChannel(): JobChannel {
return channel;
}
+/**
+ * Wait for two consecutive identical heartbeats fresher than issuedAt, so a
+ * direct move's returned position is settled firmware truth.
+ */
+async function waitForStableHeartbeat(issuedAt: number): Promise {
+ const deadline = issuedAt + 30000;
+ let previous: string | null = null;
+ while (Date.now() < deadline) {
+ await new Promise((resolve) => {
+ setTimeout(resolve, 500);
+ });
+ let now: PositionSnapshot;
+ try {
+ now = getPositionSnapshot();
+ } catch (err) {
+ continue;
+ }
+ const reportTime = Date.now() - now.reportAgeMs;
+ const fingerprint = JSON.stringify([now.work, now.originOffset]);
+ const stable = fingerprint === previous;
+ previous = fingerprint;
+ if (reportTime > issuedAt && stable && now.machineStatus === 'idle') {
+ return now;
+ }
+ }
+ return null;
+}
+
function machineStatus(): string | null {
const state = connectionManager.getLatestMachineState();
return state ? ((state as { status?: string }).status || null) : null;
@@ -130,6 +163,29 @@ export function registerGcodeTools(registry: ToolRegistry, getConfirmBaseUrl: ()
throw new McpToolError(verdict.reason || 'Confirmation failed.');
}
+ if (job.kind === 'direct') {
+ // Direct moves execute over the realtime path so position
+ // PERSISTS - the firmware parks back at the work origin when
+ // a file job completes, so a file job cannot hold a Z. The
+ // operator approved exactly this gcode on the confirm page.
+ job.state = 'starting';
+ job.startedAt = Date.now();
+ const gcodeText = fs.readFileSync(job.filePath, 'utf8');
+ const executed = await sendGcodeVisible(channel as GcodeChannel, `direct:${job.name}`, gcodeText);
+ if (executed.result !== 0) {
+ job.state = 'start_failed';
+ job.error = `Controller rejected the move: ${executed.text || executed.result}`;
+ throw new McpToolError(job.error);
+ }
+ const position = await waitForStableHeartbeat(job.startedAt);
+ job.state = 'completed';
+ return {
+ job: jobManager.describe(job),
+ position,
+ note: 'Direct move executed and settled; the position persists (no end-of-job park).',
+ };
+ }
+
job.state = 'starting';
const uploadError = await new Promise((resolve) => {
channel.uploadGcodeFile(job.filePath, job.headType, `${job.name}.nc`, (msg) => {
@@ -159,6 +215,88 @@ export function registerGcodeTools(registry: ToolRegistry, getConfirmBaseUrl: ()
},
});
+ registry.register({
+ name: 'move_z',
+ description: 'Request a SINGLE absolute Z move, executed on the direct path so the position '
+ + 'PERSISTS (file jobs park back at the work origin when they complete - firmware '
+ + 'behaviour). Every request goes to the operator confirm page showing current Z, '
+ + 'target, delta and feed; only their one-time code executes it. NOT door-interlocked - '
+ + 'the operator supervises. Spindle must be off; the toolhead always carries a tool.',
+ inputSchema: {
+ type: 'object',
+ properties: {
+ z: { type: 'number', description: 'Absolute target Z.' },
+ coordinate_system: {
+ type: 'string',
+ enum: ['work', 'machine'],
+ description: 'Which frame z is in. Default work.',
+ },
+ feed_rate: { type: 'number', description: 'mm/min, default 300, max 600.' },
+ reason: { type: 'string', description: 'Shown to the operator: why this Z move is needed.' },
+ },
+ required: ['z', 'reason'],
+ additionalProperties: false,
+ },
+ handler: async (args: { z?: number; coordinate_system?: string; feed_rate?: number; reason?: string }) => {
+ const targetZ = Number(args.z);
+ if (!Number.isFinite(targetZ)) {
+ throw new McpToolError('z must be a finite number.');
+ }
+ const coordinateSystem = args.coordinate_system || 'work';
+ if (!['work', 'machine'].includes(coordinateSystem)) {
+ throw new McpToolError('coordinate_system must be "work" or "machine".');
+ }
+ const feedRate = Math.min(Math.max(Number(args.feed_rate) || 300, 50), 600);
+
+ const position = getPositionSnapshot();
+ if (position.machineStatus !== 'idle') {
+ throw new McpToolError(`Machine is ${position.machineStatus || 'in an unknown state'}, not idle.`);
+ }
+ if (position.isHomed !== true) {
+ throw new McpToolError('Machine does not report homed; home before any Z positioning.');
+ }
+ const state = connectionManager.getLatestMachineState() as { headStatus?: unknown; headPower?: unknown } | null;
+ const headPower = Number(state && state.headPower);
+ if ((Number.isFinite(headPower) && headPower > 0) || (state && (state.headStatus === true || state.headStatus === 'on'))) {
+ throw new McpToolError('Toolhead appears to be on; refusing to move Z.');
+ }
+
+ const currentZ = coordinateSystem === 'work' ? position.work.z : position.machine.z;
+ if (currentZ === null) {
+ throw new McpToolError('Current Z unknown; cannot describe the move to the operator.');
+ }
+ const machineTargetZ = coordinateSystem === 'machine' ? targetZ : targetZ - position.originOffset.z;
+ const size = getMachineSizeByIdentifier(connectionManager.getConnectionStatus().machineIdentifier);
+ if (size && (machineTargetZ < -1 || machineTargetZ > size.z + 40)) {
+ throw new McpToolError(`Target (machine Z ${machineTargetZ.toFixed(1)}) is outside the `
+ + `0..${size.z} travel.`);
+ }
+
+ const move = `G1 Z${targetZ.toFixed(3)} F${feedRate}`;
+ const gcode = coordinateSystem === 'machine'
+ ? `G90\nG53;\n${move};\nG54;`
+ : `G90\n${move}`;
+ const delta = targetZ - currentZ;
+ const name = `z-move ${coordinateSystem} Z${targetZ.toFixed(1)} (${delta >= 0 ? '+' : ''}${delta.toFixed(1)}mm) - ${String(args.reason).slice(0, 40)}`;
+
+ const validation = validateGcode(gcode);
+ const job = jobManager.submit(gcode, name, 'cnc', validation, 'direct');
+
+ return {
+ job: jobManager.describe(job),
+ current_z: currentZ,
+ target_z: targetZ,
+ delta_mm: delta,
+ feed_rate: feedRate,
+ coordinate_system: coordinateSystem,
+ confirm_url: `${getConfirmBaseUrl()}/confirm/${job.id}`,
+ next_step: 'Ask the operator to open confirm_url, review the DIRECT-move banner '
+ + '(current Z, target, delta, feed), and approve. Their one-time code passed to '
+ + 'start_gcode_job executes the move; the position then persists.',
+ };
+ },
+ });
+
registry.register({
name: 'get_gcode_job_status',
description: 'Job record plus live progress from the machine heartbeat. Read-only.',
From 6697201235018ce1c841141164c28e81c7953446 Mon Sep 17 00:00:00 2001
From: tyeth
Date: Sun, 30 Aug 2026 21:33:41 +0100
Subject: [PATCH 025/135] Improvement: Frame-reading guidance and expected tool
region on captures
Two live-session frame misreads - the calibration board dismissed as a
cutting mat, and the endmill never identified - drove a blind 80mm
descent past a usable view. The skill gains a frame-reading section:
identify by evidence not remembered composition (the board is the
yellow-brown surface with printed black grid and cell labels), the
rig-mounted-vs-scene heuristic (frame-position invariant across moves
means camera-mounted), and the endmill's near-lens blur signature.
capture_frame now reports the operator-configured expectedToolRegion
box (configstore mcpToolRegion) with every frame, turning tool
identification into a lookup on fixed rig geometry.
Co-Authored-By: Claude Opus 5
---
.claude/skills/cnc-visual-alignment/SKILL.md | 24 ++++++++++++++++++++
src/server/services/mcp/tools/camera.ts | 20 ++++++++++++++++
2 files changed, 44 insertions(+)
diff --git a/.claude/skills/cnc-visual-alignment/SKILL.md b/.claude/skills/cnc-visual-alignment/SKILL.md
index 6735226162..9086510768 100644
--- a/.claude/skills/cnc-visual-alignment/SKILL.md
+++ b/.claude/skills/cnc-visual-alignment/SKILL.md
@@ -120,6 +120,30 @@ Z positioning is not part of the servo: raise or lower Z via a one-line `submit_
through the operator's confirm page. Refuse to servo from a height where parallax exceeds
the tolerance you are claiming.
+## Reading a toolhead-camera frame (hardware-learned, the hard way)
+
+Two live-session failures came from misreading frames, not from geometry. Both are avoidable:
+
+**Identify by evidence, not by remembered composition.** Never assert "no board in view"
+because the frame fails to match a reference framing you were *told about* but do not have.
+Describe what IS in the frame and test it against context. On this machine the calibration
+board is a **yellow-brown surface with a printed black grid and alphanumeric cell labels
+(C1, L1, ...)** — a labeled coordinate grid is a calibration board, not a "cutting mat",
+however mat-like its colour. If you have no reference image, say so and reason from content.
+
+**The rig-mounted vs scene heuristic.** Anything whose frame position is **invariant across
+machine moves** is mounted to the same assembly as the camera — the endmill, the spindle
+housing — not part of the scene. Scene content (board, rail, bed) visibly shifts between
+captures. You always have multiple position-stamped frames; cross-reference before guessing.
+
+**The endmill's visual signature.** For a toolhead-mounted camera the tool sits millimetres
+from the lens: it images as an **oversized, extremely defocused shape entering from a frame
+edge at a fixed orientation** (here: from the bottom edge ~2/3 along, pointing diagonally
+toward top-left, ~20 % of frame height). That blur is diagnostic of near-lens distance —
+categorically different from the resolvable distance-blur of the scene. `capture_frame`
+reports the operator-configured `expectedToolRegion` box with every frame — check it before
+concluding anything about "an unidentified blurry shape".
+
## Datums: check the landmark is actually in frame
A stated datum is worthless if it is outside the field of view. Verify visually before
diff --git a/src/server/services/mcp/tools/camera.ts b/src/server/services/mcp/tools/camera.ts
index 189a24f69d..e790fdca45 100644
--- a/src/server/services/mcp/tools/camera.ts
+++ b/src/server/services/mcp/tools/camera.ts
@@ -44,6 +44,25 @@ async function sleep(ms: number): Promise {
});
}
+/**
+ * The camera-to-spindle offset is fixed hardware geometry, so where the
+ * endmill images is a constant of the rig, not something to re-discover by
+ * vision each frame. The operator records it once in configstore
+ * mcpToolRegion (fractional box {u0,v0,u1,v1}, optional note) and every
+ * frame carries it - turning tool identification into a lookup.
+ */
+function expectedToolRegion(): object | null {
+ const raw = config.get('mcpToolRegion');
+ if (!raw) {
+ return null;
+ }
+ try {
+ return typeof raw === 'string' ? JSON.parse(raw) : (raw as object);
+ } catch (err) {
+ return null;
+ }
+}
+
function frameContent(frame: CapturedFrame, meta: object): object {
return {
mcpContent: [
@@ -56,6 +75,7 @@ function frameContent(frame: CapturedFrame, meta: object): object {
provider: frame.provider,
device: frame.device,
capturedAt: frame.capturedAt,
+ expectedToolRegion: expectedToolRegion(),
},
}),
},
From 0261f5913d7611f6228dac2c0d78df04fae83daf Mon Sep 17 00:00:00 2001
From: tyeth
Date: Sun, 30 Aug 2026 22:09:41 +0100
Subject: [PATCH 026/135] Improvement: Servo divergence tripwire and explicit
sign convention
A live sign-flipped calibration (M = -J^-1) drove two servo steps away
from the target and was caught only by manual re-measurement. The tool
contract held; the gap was no automatic safeguard. visual_servo now
remembers the last step per calibration+target and warns the moment the
pixel error fails to shrink - one wasted step instead of several. Both
tool descriptions state the convention outright (error = target -
feature; M = +J^-1; J.(M.e) must reproduce +e) and the skill's
derivation step now includes the sign verification protocol.
Co-Authored-By: Claude Opus 5
---
.claude/skills/cnc-visual-alignment/SKILL.md | 9 +++--
src/server/services/mcp/tools/calibration.ts | 38 ++++++++++++++++----
2 files changed, 39 insertions(+), 8 deletions(-)
diff --git a/.claude/skills/cnc-visual-alignment/SKILL.md b/.claude/skills/cnc-visual-alignment/SKILL.md
index 9086510768..75c3b62581 100644
--- a/.claude/skills/cnc-visual-alignment/SKILL.md
+++ b/.claude/skills/cnc-visual-alignment/SKILL.md
@@ -107,8 +107,13 @@ Never compute a machine coordinate from one frame and drive to it. With the MCP:
explicitly confirmed Z and path clearance (`operator_confirmed_clearance`, which you
pass ONLY on the operator's word, never on your own judgment).
3. Derive the 2×2 matrix at the working Y and Z: command 2–3 known small XY offsets with
- `move_and_capture`, track one feature's pixel displacement, fit, store with
- `set_camera_calibration` (record residuals in `notes`).
+ `move_and_capture`, track one feature's pixel displacement, fit the forward Jacobian J
+ (pixel shift per mm), and store M = +J⁻¹ with `set_camera_calibration` (residuals in
+ `notes`). **Verify the sign before storing**: the tool computes error = target − feature
+ (check `pixel_error` in a real response against your own numbers), and J·(M·e) must
+ reproduce +e — a flipped M drives every "correction" away from the target, and it looks
+ plausible right up until the error grows. The tool warns when consecutive steps fail to
+ shrink the error; treat that warning as "stop and re-derive", never "push through".
4. Iterate `visual_servo` — each call is one clamped step and returns the frame; two or
three passes converge. It auto-selects the nearest-Y calibration and warns when a step
moves Y (self-invalidating) — re-derive or re-select when it does.
diff --git a/src/server/services/mcp/tools/calibration.ts b/src/server/services/mcp/tools/calibration.ts
index 52dbe5fb7f..5ec576e30a 100644
--- a/src/server/services/mcp/tools/calibration.ts
+++ b/src/server/services/mcp/tools/calibration.ts
@@ -25,12 +25,20 @@ function describeEntry(entry: CalibrationEntry): object {
}
export function registerCalibrationTools(registry: ToolRegistry): void {
+ // Divergence tripwire: a sign-flipped matrix makes each "correction" grow
+ // the error. Remember the last step per calibration+target and warn the
+ // moment the error fails to shrink - one wasted step instead of a manual
+ // catch several steps later.
+ let lastServoStep: { calibrationId: string; tu: number; tv: number; magnitude: number; at: number } | null = null;
registry.register({
name: 'set_camera_calibration',
description: 'Persist a pixel-to-machine calibration, keyed by the machine Y it was '
+ 'derived at (the SM2 platform travels in Y, so a mapping is only valid at that Y). '
+ 'matrix maps a pixel delta [du, dv] to the machine XY move [dx, dy] in mm that '
- + 'cancels it. Survives restarts.',
+ + 'cancels it - i.e. M = +J^-1 where J is the forward Jacobian (pixel shift per mm of '
+ + 'machine move), and visual_servo applies M to error = target - feature. VERIFY THE '
+ + 'SIGN before storing: J.(M.e) must reproduce +e, not -e - a flipped M silently '
+ + 'drives the servo away from the target. Survives restarts.',
inputSchema: {
type: 'object',
properties: {
@@ -121,11 +129,14 @@ export function registerCalibrationTools(registry: ToolRegistry): void {
registry.register({
name: 'visual_servo',
- description: 'One visual-servo correction step: turns a pixel error (target_pixel - '
- + 'feature_pixel) into a machine XY move using a stored calibration, executes it '
- + 'through the same guarded single-move path as move_and_capture, and returns the '
- + 'new frame. The iteration loop belongs to the caller. Step size is clamped '
- + `(max_step_mm, default ${DEFAULT_STEP_LIMIT_MM}, cap ${MAX_STEP_LIMIT_MM}).`,
+ description: 'One visual-servo correction step: computes error = target_pixel - '
+ + 'feature_pixel (this exact convention), applies the stored matrix to it, executes '
+ + 'the machine XY move through the same guarded single-move path as move_and_capture, '
+ + 'and returns the new frame. The iteration loop belongs to the caller. Step size is '
+ + `clamped (max_step_mm, default ${DEFAULT_STEP_LIMIT_MM}, cap ${MAX_STEP_LIMIT_MM}). `
+ + 'If the pixel error GROWS between consecutive steps with the same calibration and '
+ + 'target, the response warns of a likely sign-flipped matrix - stop and re-verify '
+ + 'rather than iterating.',
inputSchema: {
type: 'object',
properties: {
@@ -203,6 +214,19 @@ export function registerCalibrationTools(registry: ToolRegistry): void {
}
const warnings: string[] = [];
+ const errorMagnitude = Math.hypot(du, dv);
+ const tu = Number(args.target_pixel?.u);
+ const tv = Number(args.target_pixel?.v);
+ if (lastServoStep
+ && lastServoStep.calibrationId === entry.id
+ && Math.abs(lastServoStep.tu - tu) < 5 && Math.abs(lastServoStep.tv - tv) < 5
+ && Date.now() - lastServoStep.at < 10 * 60 * 1000
+ && errorMagnitude >= lastServoStep.magnitude * 0.95) {
+ warnings.push('Pixel error did not shrink after the previous servo step with this calibration '
+ + `(${lastServoStep.magnitude.toFixed(1)}px -> ${errorMagnitude.toFixed(1)}px). A sign-flipped or `
+ + 'badly scaled matrix drives AWAY from the target: verify J.(M.e) reproduces +e before '
+ + 'iterating further.');
+ }
if (entryDistance !== null && entryDistance > DEFAULT_Y_TOLERANCE_MM) {
warnings.push(`Calibration ${entry.id} is ${entryDistance.toFixed(1)} mm from the current Y; scale may be off.`);
}
@@ -218,6 +242,8 @@ export function registerCalibrationTools(registry: ToolRegistry): void {
operator_confirmed_clearance: args.operator_confirmed_clearance,
}) as { mcpContent: object[] };
+ lastServoStep = { calibrationId: entry.id, tu, tv, magnitude: errorMagnitude, at: Date.now() };
+
// Splice servo metadata into the text part of the frame result.
const servoMeta = {
pixel_error: { du, dv },
From eea92a88ed721f3e8ee0c5b82769bcefc242b9b8 Mon Sep 17 00:00:00 2001
From: tyeth
Date: Sun, 30 Aug 2026 22:39:29 +0100
Subject: [PATCH 027/135] Feature: Add track_feature - template matching
between cached frames
The dominant field error source was hand-estimated pixel coordinates:
a misread hole position caused a ~50% calibration error, and the sign
bug was only caught by manual cross-checking. Captures now carry a
frameId (last 12 frames cached in memory) and track_feature template-
matches a patch between two frames by id - zero-mean NCC on jpeg-js
(already a dependency), returning the matched pixel, shift, confidence,
and a second-peak gap that warns of repetitive-grid ambiguity. Verified
offline: a synthetic (17,-9) shift recovered exactly at NCC 0.92 in
211ms.
Co-Authored-By: Claude Opus 5
---
src/server/services/mcp/camera.ts | 26 ++++
src/server/services/mcp/tools/camera.ts | 67 +++++++++-
src/server/services/mcp/tracking.ts | 168 ++++++++++++++++++++++++
3 files changed, 260 insertions(+), 1 deletion(-)
create mode 100644 src/server/services/mcp/tracking.ts
diff --git a/src/server/services/mcp/camera.ts b/src/server/services/mcp/camera.ts
index f068e63db0..0f91b07e3d 100644
--- a/src/server/services/mcp/camera.ts
+++ b/src/server/services/mcp/camera.ts
@@ -21,6 +21,7 @@ const log = logger('service:mcp:camera');
const CAPTURE_TIMEOUT_MS = 15000;
export interface CapturedFrame {
+ frameId: string;
imageBase64: string;
mimeType: string;
provider: string;
@@ -28,6 +29,29 @@ export interface CapturedFrame {
capturedAt: number;
}
+// Recent frames kept in memory so track_feature can template-match between
+// them by id - the dominant field error source was hand-estimated pixel
+// coordinates, so measurement between cached frames replaces eyeballing.
+const FRAME_CACHE_LIMIT = 12;
+const frameCache = new Map();
+
+function cacheFrame(jpg: Buffer): string {
+ const frameId = crypto.randomBytes(4).toString('hex');
+ frameCache.set(frameId, jpg);
+ while (frameCache.size > FRAME_CACHE_LIMIT) {
+ frameCache.delete(frameCache.keys().next().value);
+ }
+ return frameId;
+}
+
+export function getCachedFrameIds(): string[] {
+ return [...frameCache.keys()];
+}
+
+export function getCachedFrame(frameId: string): Buffer | null {
+ return frameCache.get(frameId) || null;
+}
+
function ffmpegBinary(): string {
return config.get('mcpFfmpegPath') || 'ffmpeg';
}
@@ -82,6 +106,7 @@ async function captureViaHttp(url: string): Promise {
return;
}
resolve({
+ frameId: cacheFrame(body),
imageBase64: body.toString('base64'),
mimeType: contentType,
provider: 'http',
@@ -123,6 +148,7 @@ async function captureViaFfmpeg(): Promise {
}
const body = await fs.readFile(outPath);
return {
+ frameId: cacheFrame(body),
imageBase64: body.toString('base64'),
mimeType: 'image/jpeg',
provider: 'ffmpeg-dshow',
diff --git a/src/server/services/mcp/tools/camera.ts b/src/server/services/mcp/tools/camera.ts
index e790fdca45..d7876d0f89 100644
--- a/src/server/services/mcp/tools/camera.ts
+++ b/src/server/services/mcp/tools/camera.ts
@@ -3,7 +3,8 @@
import config from '../../configstore';
import { mcpBroadcast } from '../index';
import { connectionManager } from '../../machine/ConnectionManager';
-import { CapturedFrame, captureFrame, listCameras } from '../camera';
+import { CapturedFrame, captureFrame, getCachedFrame, getCachedFrameIds, listCameras } from '../camera';
+import { decodeToGray, trackFeature } from '../tracking';
import { McpToolError, ToolRegistry } from '../registry';
import { PositionSnapshot, getMachineSizeByIdentifier, getPositionSnapshot } from './machine';
@@ -72,6 +73,7 @@ function frameContent(frame: CapturedFrame, meta: object): object {
text: JSON.stringify({
...meta,
camera: {
+ frameId: frame.frameId,
provider: frame.provider,
device: frame.device,
capturedAt: frame.capturedAt,
@@ -255,6 +257,69 @@ export function registerCameraTools(registry: ToolRegistry): void {
},
});
+ registry.register({
+ name: 'track_feature',
+ description: 'Template-match a patch between two cached frames (by the frameId each capture '
+ + 'reports): give the pixel of a feature in one frame and get its measured pixel in the '
+ + 'other, with an NCC confidence score. Use this instead of eyeballing pixel coordinates '
+ + 'when deriving calibrations or measuring servo error - hand-estimated pixels were the '
+ + 'dominant field error source. A small second-peak gap warns of repetitive-grid '
+ + 'ambiguity. No motion, no capture.',
+ inputSchema: {
+ type: 'object',
+ properties: {
+ template_frame_id: { type: 'string', description: 'Frame the feature pixel refers to.' },
+ search_frame_id: { type: 'string', description: 'Frame to locate the feature in.' },
+ point: {
+ type: 'object',
+ properties: { u: { type: 'number' }, v: { type: 'number' } },
+ required: ['u', 'v'],
+ description: 'Feature pixel in the template frame.',
+ },
+ patch_size: { type: 'number', description: 'Odd patch edge in px, default 41, max 101.' },
+ search_radius: { type: 'number', description: 'Search half-window in px, default 120, max 250.' },
+ },
+ required: ['template_frame_id', 'search_frame_id', 'point'],
+ additionalProperties: false,
+ },
+ handler: async (args: {
+ template_frame_id?: string;
+ search_frame_id?: string;
+ point?: { u?: number; v?: number };
+ patch_size?: number;
+ search_radius?: number;
+ }) => {
+ const templateJpg = getCachedFrame(String(args.template_frame_id || ''));
+ const searchJpg = getCachedFrame(String(args.search_frame_id || ''));
+ if (!templateJpg || !searchJpg) {
+ throw new McpToolError(`Unknown frame id. Cached frames: ${getCachedFrameIds().join(', ') || 'none'} `
+ + '(the cache holds the last 12 captures of this session).');
+ }
+ const u = Math.round(Number(args.point?.u));
+ const v = Math.round(Number(args.point?.v));
+ if (!Number.isFinite(u) || !Number.isFinite(v)) {
+ throw new McpToolError('point.u and point.v must be numbers.');
+ }
+ let patch = Math.round(Number(args.patch_size) || 41);
+ patch = Math.min(Math.max(patch % 2 === 0 ? patch + 1 : patch, 11), 101);
+ const radius = Math.min(Math.max(Math.round(Number(args.search_radius) || 120), 20), 250);
+
+ const result = trackFeature(decodeToGray(templateJpg), decodeToGray(searchJpg), u, v, patch, radius);
+ return {
+ template_frame_id: args.template_frame_id,
+ search_frame_id: args.search_frame_id,
+ point: { u, v },
+ matched_point: result.matchedPoint,
+ pixel_shift: { du: result.du, dv: result.dv },
+ score: result.score,
+ second_peak_gap: result.secondPeakGap,
+ patch_size: patch,
+ search_radius: radius,
+ warnings: result.warnings,
+ };
+ },
+ });
+
registry.register({
name: 'query_firmware_position',
description: 'Ask the firmware directly for its position report (M114) and return the RAW '
diff --git a/src/server/services/mcp/tracking.ts b/src/server/services/mcp/tracking.ts
new file mode 100644
index 0000000000..7724c997ed
--- /dev/null
+++ b/src/server/services/mcp/tracking.ts
@@ -0,0 +1,168 @@
+import jpeg from 'jpeg-js';
+
+import { McpToolError } from './registry';
+
+// Zero-mean normalized cross-correlation template matching between two
+// cached frames. Replaces hand-estimated pixel coordinates - the dominant
+// error source in live calibration sessions - with a measurement. Pure JS
+// on jpeg-js (already a dependency); a 41px patch over a 120px radius is
+// well under a second.
+
+interface GrayImage {
+ width: number;
+ height: number;
+ data: Float32Array;
+}
+
+export function decodeToGray(jpg: Buffer): GrayImage {
+ const decoded = jpeg.decode(jpg, { useTArray: true, maxMemoryUsageInMB: 64 });
+ const { width, height } = decoded;
+ const gray = new Float32Array(width * height);
+ for (let i = 0; i < width * height; i++) {
+ const o = i * 4;
+ gray[i] = 0.299 * decoded.data[o] + 0.587 * decoded.data[o + 1] + 0.114 * decoded.data[o + 2];
+ }
+ return { width, height, data: gray };
+}
+
+function patchStats(img: GrayImage, cx: number, cy: number, half: number): { mean: number; norm: number } | null {
+ let sum = 0;
+ const n = (2 * half + 1) ** 2;
+ for (let dy = -half; dy <= half; dy++) {
+ for (let dx = -half; dx <= half; dx++) {
+ sum += img.data[(cy + dy) * img.width + (cx + dx)];
+ }
+ }
+ const mean = sum / n;
+ let sq = 0;
+ for (let dy = -half; dy <= half; dy++) {
+ for (let dx = -half; dx <= half; dx++) {
+ const v = img.data[(cy + dy) * img.width + (cx + dx)] - mean;
+ sq += v * v;
+ }
+ }
+ const norm = Math.sqrt(sq);
+ return norm < 1e-6 ? null : { mean, norm };
+}
+
+export interface TrackResult {
+ matchedPoint: { u: number; v: number };
+ du: number;
+ dv: number;
+ score: number;
+ secondPeakGap: number;
+ warnings: string[];
+}
+
+/**
+ * Locate the patch around (u, v) of the template image inside the search
+ * image, scanning a square window of the given radius around the same
+ * coordinates. Returns the best match with its NCC score and the gap to the
+ * best score found outside the peak's immediate neighbourhood (a small gap
+ * means a repetitive scene - a grid - where the wrong intersection can win).
+ */
+export function trackFeature(
+ template: GrayImage,
+ search: GrayImage,
+ u: number,
+ v: number,
+ patchSize: number,
+ searchRadius: number
+): TrackResult {
+ const half = Math.floor(patchSize / 2);
+ const warnings: string[] = [];
+
+ if (u - half < 0 || v - half < 0 || u + half >= template.width || v + half >= template.height) {
+ throw new McpToolError(`The ${patchSize}px patch around (${u}, ${v}) does not fit inside the `
+ + `${template.width}x${template.height} template frame.`);
+ }
+ const tStats = patchStats(template, u, v, half);
+ if (!tStats) {
+ throw new McpToolError('The template patch is featureless (uniform brightness); pick a point '
+ + 'on a corner or line intersection.');
+ }
+ const tPatch = new Float32Array((2 * half + 1) ** 2);
+ let k = 0;
+ for (let dy = -half; dy <= half; dy++) {
+ for (let dx = -half; dx <= half; dx++) {
+ tPatch[k++] = template.data[(v + dy) * template.width + (u + dx)] - tStats.mean;
+ }
+ }
+
+ const uMin = Math.max(half, u - searchRadius);
+ const uMax = Math.min(search.width - 1 - half, u + searchRadius);
+ const vMin = Math.max(half, v - searchRadius);
+ const vMax = Math.min(search.height - 1 - half, v + searchRadius);
+ if (uMin > uMax || vMin > vMax) {
+ throw new McpToolError('The search window falls entirely outside the search frame.');
+ }
+ if (uMin > u - searchRadius || uMax < u + searchRadius || vMin > v - searchRadius || vMax < v + searchRadius) {
+ warnings.push('Search window was clamped by the frame edge; the true match may lie outside it.');
+ }
+
+ let best = -2;
+ let bestU = u;
+ let bestV = v;
+ const scores: number[][] = [];
+ for (let sv = vMin; sv <= vMax; sv++) {
+ const row: number[] = [];
+ for (let su = uMin; su <= uMax; su++) {
+ const sStats = patchStats(search, su, sv, half);
+ if (!sStats) {
+ row.push(-2);
+ continue;
+ }
+ let dot = 0;
+ let i = 0;
+ for (let dy = -half; dy <= half; dy++) {
+ for (let dx = -half; dx <= half; dx++) {
+ dot += tPatch[i++] * (search.data[(sv + dy) * search.width + (su + dx)] - sStats.mean);
+ }
+ }
+ const score = dot / (tStats.norm * sStats.norm);
+ row.push(score);
+ if (score > best) {
+ best = score;
+ bestU = su;
+ bestV = sv;
+ }
+ }
+ scores.push(row);
+ }
+
+ // Best score outside the peak's 2-patch neighbourhood: on a repetitive
+ // grid the runner-up intersection scores nearly as high, and a small gap
+ // says "do not trust this match blindly".
+ let second = -2;
+ const exclusion = 2 * half + 1;
+ for (let sv = vMin; sv <= vMax; sv++) {
+ for (let su = uMin; su <= uMax; su++) {
+ if (Math.abs(su - bestU) <= exclusion && Math.abs(sv - bestV) <= exclusion) {
+ continue;
+ }
+ const score = scores[sv - vMin][su - uMin];
+ if (score > second) {
+ second = score;
+ }
+ }
+ }
+
+ if (best < 0.5) {
+ warnings.push(`Low match confidence (NCC ${best.toFixed(2)}); the feature may have left the frame `
+ + 'or changed appearance (lighting, focus, Z change).');
+ }
+ const gap = second <= -2 ? 1 : best - second;
+ if (gap < 0.1 && best >= 0.5) {
+ warnings.push(`Ambiguous match: the runner-up scores nearly as high (gap ${gap.toFixed(2)}) - `
+ + 'typical of a repetitive grid. Verify against a larger patch or a distinctive feature.');
+ }
+
+ return {
+ matchedPoint: { u: bestU, v: bestV },
+ du: bestU - u,
+ dv: bestV - v,
+ score: best,
+ secondPeakGap: gap,
+ warnings,
+ };
+}
From f058091258d2ef74606721f39e445516cc50f142 Mon Sep 17 00:00:00 2001
From: tyeth
Date: Sun, 30 Aug 2026 22:44:40 +0100
Subject: [PATCH 028/135] Improvement: Field fixes - jacobian check,
tolerances, tool region, camera, batch Z
Five fixes from the first full calibration session:
- set_camera_calibration accepts the fitted jacobian and REJECTS a
sign-flipped matrix (M.J ~ -identity) before it reaches hardware;
large identity residuals warn.
- visual_servo auto-select no longer hard-fails at 2mm - a single step
drifts Y 2-6mm - it picks the nearest entry within 25mm and keeps the
scale-may-be-off warning beyond 2mm.
- New set_tool_region tool so expectedToolRegion converges from live
frames instead of config edits.
- Camera device choice is sticky (last-good remembered, preferred) with
one retry on transient open failure; a vanished device is an error,
never a silent substitution to another (possibly dead) camera.
- move_z accepts z_targets (max 20): the operator approves the exact
list once, each start_gcode_job call executes one step with settling
between, and the series can be abandoned anywhere - same safety
property, one approval instead of N.
Co-Authored-By: Claude Opus 5
---
src/server/services/mcp/camera.ts | 33 +++++-
src/server/services/mcp/jobs.ts | 11 +-
src/server/services/mcp/tools/calibration.ts | 46 +++++++-
src/server/services/mcp/tools/camera.ts | 37 ++++++
src/server/services/mcp/tools/gcode.ts | 118 ++++++++++++++-----
5 files changed, 205 insertions(+), 40 deletions(-)
diff --git a/src/server/services/mcp/camera.ts b/src/server/services/mcp/camera.ts
index 0f91b07e3d..2e0ad470cf 100644
--- a/src/server/services/mcp/camera.ts
+++ b/src/server/services/mcp/camera.ts
@@ -126,6 +126,11 @@ async function captureViaHttp(url: string): Promise {
}
async function captureViaFfmpeg(): Promise {
+ // Device choice is sticky: enumeration order is not stable across
+ // restarts, and a capture that silently falls back to a different
+ // (possibly dead virtual) camera is worse than an error. The last
+ // device that produced a frame is remembered and preferred; a missing
+ // device is an error, never a substitution.
let device = config.get('mcpCameraDevice');
if (!device) {
const { devices } = await listCameras();
@@ -133,19 +138,39 @@ async function captureViaFfmpeg(): Promise {
throw new McpToolError('No DirectShow video devices found. Set configstore key mcpCameraDevice, '
+ 'or mcpCameraUrl for an HTTP snapshot source.');
}
- device = devices[0];
+ const lastGood = config.get('mcpCameraLastGood');
+ if (lastGood && devices.includes(String(lastGood))) {
+ device = lastGood;
+ } else if (lastGood) {
+ throw new McpToolError(`The last working camera ("${lastGood}") is not in the current device list `
+ + `(${devices.join(', ')}). Re-plug it and retry, or set mcpCameraDevice explicitly - refusing `
+ + 'to silently substitute a different device.');
+ } else {
+ device = devices[0];
+ }
}
const outPath = path.join(DataStorage.tmpDir, `mcp-frame-${crypto.randomBytes(4).toString('hex')}.jpg`);
try {
- const { code, stderr } = await runFfmpeg([
+ const ffmpegArgs = [
'-hide_banner', '-loglevel', 'error',
'-f', 'dshow', '-i', `video=${device}`,
'-frames:v', '1', '-f', 'image2', '-y', outPath,
- ]);
+ ];
+ let { code, stderr } = await runFfmpeg(ffmpegArgs);
+ if (code !== 0 || !fs.existsSync(outPath)) {
+ // One retry after a beat: first-open flakiness on USB cameras is
+ // real and transient; a different device is never substituted.
+ await new Promise((resolve) => {
+ setTimeout(resolve, 1200);
+ });
+ ({ code, stderr } = await runFfmpeg(ffmpegArgs));
+ }
if (code !== 0 || !fs.existsSync(outPath)) {
- throw new McpToolError(`ffmpeg capture failed: ${stderr.split(/\r?\n/).filter(Boolean).slice(-2).join(' ')}`);
+ throw new McpToolError(`ffmpeg capture from "${device}" failed after retry: `
+ + `${stderr.split(/\r?\n/).filter(Boolean).slice(-2).join(' ')}`);
}
+ config.set('mcpCameraLastGood', String(device));
const body = await fs.readFile(outPath);
return {
frameId: cacheFrame(body),
diff --git a/src/server/services/mcp/jobs.ts b/src/server/services/mcp/jobs.ts
index c517cb3ab3..a3639fc0e4 100644
--- a/src/server/services/mcp/jobs.ts
+++ b/src/server/services/mcp/jobs.ts
@@ -49,6 +49,11 @@ export interface McpJob {
tokenUsed: boolean;
startedAt: number | null;
error: string | null;
+ // Batch direct jobs: the operator approved this exact list; each
+ // start_gcode_job call executes ONE step, so captures can happen between
+ // steps and the series can be abandoned at any point.
+ steps?: string[];
+ nextStep?: number;
}
function escapeHtml(text: string): string {
@@ -76,7 +81,7 @@ export class JobManager {
return this.jobsDir;
}
- public submit(gcode: string, name: string, headType: string, validation: GcodeValidationReport, kind: McpJobKind = 'file'): McpJob {
+ public submit(gcode: string, name: string, headType: string, validation: GcodeValidationReport, kind: McpJobKind = 'file', steps?: string[]): McpJob {
const id = crypto.randomBytes(6).toString('hex');
const safeName = (name || 'job').replace(/[^\w.-]/g, '_').slice(0, 64);
const filePath = path.join(this.ensureJobsDir(), `${id}_${safeName}.nc`);
@@ -96,6 +101,8 @@ export class JobManager {
tokenUsed: false,
startedAt: null,
error: null,
+ steps,
+ nextStep: steps ? 0 : undefined,
};
this.jobs.set(id, job);
this.prune();
@@ -143,6 +150,8 @@ export class JobManager {
approvedAt: job.approvedAt,
startedAt: job.startedAt,
error: job.error,
+ totalSteps: job.steps ? job.steps.length : undefined,
+ nextStep: job.steps ? job.nextStep : undefined,
validation: job.validation,
};
}
diff --git a/src/server/services/mcp/tools/calibration.ts b/src/server/services/mcp/tools/calibration.ts
index 5ec576e30a..288df0f088 100644
--- a/src/server/services/mcp/tools/calibration.ts
+++ b/src/server/services/mcp/tools/calibration.ts
@@ -13,6 +13,9 @@ import { getPositionSnapshot } from './machine';
const DEFAULT_STEP_LIMIT_MM = 5;
const MAX_STEP_LIMIT_MM = 20;
const DEFAULT_Y_TOLERANCE_MM = 2;
+// Auto-select gives up only beyond this - a single servo step routinely
+// drifts Y by 2-6mm, so the old hard 2mm cutoff forced explicit ids.
+const MAX_Y_DISTANCE_MM = 25;
function isMatrix(value: unknown): value is [[number, number], [number, number]] {
return Array.isArray(value) && value.length === 2
@@ -52,11 +55,20 @@ export function registerCalibrationTools(registry: ToolRegistry): void {
maxItems: 2,
},
notes: { type: 'string', description: 'Free-form provenance: grid used, residuals, tilt.' },
+ jacobian: {
+ type: 'array',
+ description: 'Optional 2x2 forward Jacobian J (pixel shift per mm) you fitted. When '
+ + 'given, the tool checks M.J against +identity and REJECTS a sign-flipped matrix '
+ + 'before it can reach hardware.',
+ items: { type: 'array', items: { type: 'number' }, minItems: 2, maxItems: 2 },
+ minItems: 2,
+ maxItems: 2,
+ },
},
required: ['valid_at_y', 'z', 'matrix'],
additionalProperties: false,
},
- handler: async (args: { valid_at_y?: number; z?: number; matrix?: unknown; notes?: string }) => {
+ handler: async (args: { valid_at_y?: number; z?: number; matrix?: unknown; notes?: string; jacobian?: unknown }) => {
const validAtY = Number(args.valid_at_y);
const z = Number(args.z);
if (!Number.isFinite(validAtY) || !Number.isFinite(z)) {
@@ -65,13 +77,39 @@ export function registerCalibrationTools(registry: ToolRegistry): void {
if (!isMatrix(args.matrix)) {
throw new McpToolError('matrix must be a 2x2 array of numbers.');
}
+
+ const warnings: string[] = [];
+ if (args.jacobian !== undefined) {
+ if (!isMatrix(args.jacobian)) {
+ throw new McpToolError('jacobian must be a 2x2 array of numbers.');
+ }
+ // P = M.J should be +identity: -identity is the sign flip that
+ // drives the servo away from the target - reject it outright.
+ const m = args.matrix;
+ const j = args.jacobian;
+ const p = [
+ [m[0][0] * j[0][0] + m[0][1] * j[1][0], m[0][0] * j[0][1] + m[0][1] * j[1][1]],
+ [m[1][0] * j[0][0] + m[1][1] * j[1][0], m[1][0] * j[0][1] + m[1][1] * j[1][1]],
+ ];
+ const devPlus = Math.max(Math.abs(p[0][0] - 1), Math.abs(p[1][1] - 1), Math.abs(p[0][1]), Math.abs(p[1][0]));
+ const devMinus = Math.max(Math.abs(p[0][0] + 1), Math.abs(p[1][1] + 1), Math.abs(p[0][1]), Math.abs(p[1][0]));
+ if (devMinus < devPlus && devMinus < 0.5) {
+ throw new McpToolError('REJECTED: matrix is sign-flipped - M.J is approximately -identity, '
+ + 'so every correction would drive AWAY from the target. Store M = +J^-1, not -J^-1.');
+ }
+ if (devPlus > 0.25) {
+ warnings.push(`M.J deviates from identity (max residual ${devPlus.toFixed(2)}) - the matrix `
+ + 'may be inaccurate; expect slow or wandering convergence.');
+ }
+ }
+
const entry = calibrationStore.add({
validAtY,
z,
matrix: args.matrix,
notes: args.notes ? String(args.notes) : null,
});
- return { entry: describeEntry(entry) };
+ return { entry: describeEntry(entry), warnings };
},
});
@@ -192,9 +230,9 @@ export function registerCalibrationTools(registry: ToolRegistry): void {
}
entryDistance = Math.abs(entry.validAtY - position.machine.y);
} else {
- const match = calibrationStore.findNearest(position.machine.y, DEFAULT_Y_TOLERANCE_MM);
+ const match = calibrationStore.findNearest(position.machine.y, MAX_Y_DISTANCE_MM);
if (!match) {
- throw new McpToolError(`No calibration within ${DEFAULT_Y_TOLERANCE_MM} mm of machine Y `
+ throw new McpToolError(`No calibration within ${MAX_Y_DISTANCE_MM} mm of machine Y `
+ `${position.machine.y.toFixed(1)}. Store one with set_camera_calibration, or pass calibration_id.`);
}
entry = match.entry;
diff --git a/src/server/services/mcp/tools/camera.ts b/src/server/services/mcp/tools/camera.ts
index d7876d0f89..c5395533bf 100644
--- a/src/server/services/mcp/tools/camera.ts
+++ b/src/server/services/mcp/tools/camera.ts
@@ -257,6 +257,43 @@ export function registerCameraTools(registry: ToolRegistry): void {
},
});
+ registry.register({
+ name: 'set_tool_region',
+ description: 'Update the expectedToolRegion that every capture reports: the fractional box '
+ + 'where the endmill images (fixed camera-to-spindle geometry). Refine it from live '
+ + 'frames instead of round-tripping through configuration edits. Persists.',
+ inputSchema: {
+ type: 'object',
+ properties: {
+ u0: { type: 'number' },
+ v0: { type: 'number' },
+ u1: { type: 'number' },
+ v1: { type: 'number' },
+ note: { type: 'string', description: 'Provenance: how the box was determined.' },
+ },
+ required: ['u0', 'v0', 'u1', 'v1'],
+ additionalProperties: false,
+ },
+ handler: async (args: { u0?: number; v0?: number; u1?: number; v1?: number; note?: string }) => {
+ const box = [args.u0, args.v0, args.u1, args.v1].map(Number);
+ if (box.some((value) => !Number.isFinite(value) || value < 0 || value > 1)) {
+ throw new McpToolError('u0/v0/u1/v1 must be fractions in [0, 1].');
+ }
+ if (box[0] >= box[2] || box[1] >= box[3]) {
+ throw new McpToolError('Require u0 < u1 and v0 < v1.');
+ }
+ const region = {
+ u0: box[0],
+ v0: box[1],
+ u1: box[2],
+ v1: box[3],
+ note: args.note ? String(args.note) : undefined,
+ };
+ config.set('mcpToolRegion', region);
+ return { stored: region };
+ },
+ });
+
registry.register({
name: 'track_feature',
description: 'Template-match a patch between two cached frames (by the frameId each capture '
diff --git a/src/server/services/mcp/tools/gcode.ts b/src/server/services/mcp/tools/gcode.ts
index d59dbfedaf..0fc421144d 100644
--- a/src/server/services/mcp/tools/gcode.ts
+++ b/src/server/services/mcp/tools/gcode.ts
@@ -168,16 +168,38 @@ export function registerGcodeTools(registry: ToolRegistry, getConfirmBaseUrl: ()
// PERSISTS - the firmware parks back at the work origin when
// a file job completes, so a file job cannot hold a Z. The
// operator approved exactly this gcode on the confirm page.
+ // For a batch, each call executes ONE approved step; the
+ // token stays valid for the remaining steps (within its TTL).
job.state = 'starting';
- job.startedAt = Date.now();
- const gcodeText = fs.readFileSync(job.filePath, 'utf8');
+ const issuedAt = Date.now();
+ job.startedAt = job.startedAt || issuedAt;
+ const isBatch = Array.isArray(job.steps) && job.steps.length > 0;
+ const gcodeText = isBatch
+ ? job.steps[job.nextStep]
+ : fs.readFileSync(job.filePath, 'utf8');
const executed = await sendGcodeVisible(channel as GcodeChannel, `direct:${job.name}`, gcodeText);
if (executed.result !== 0) {
job.state = 'start_failed';
job.error = `Controller rejected the move: ${executed.text || executed.result}`;
throw new McpToolError(job.error);
}
- const position = await waitForStableHeartbeat(job.startedAt);
+ const position = await waitForStableHeartbeat(issuedAt);
+ if (isBatch) {
+ job.nextStep += 1;
+ if (job.nextStep < job.steps.length) {
+ // Same operator-approved list; keep the token usable
+ // for the remaining steps.
+ job.tokenUsed = false;
+ job.state = 'started';
+ return {
+ job: jobManager.describe(job),
+ position,
+ remaining_steps: job.steps.length - job.nextStep,
+ note: 'Step executed and settled. Call start_gcode_job again with the same '
+ + 'job_id and code for the next approved step; positions persist.',
+ };
+ }
+ }
job.state = 'completed';
return {
job: jobManager.describe(job),
@@ -217,31 +239,54 @@ export function registerGcodeTools(registry: ToolRegistry, getConfirmBaseUrl: ()
registry.register({
name: 'move_z',
- description: 'Request a SINGLE absolute Z move, executed on the direct path so the position '
- + 'PERSISTS (file jobs park back at the work origin when they complete - firmware '
- + 'behaviour). Every request goes to the operator confirm page showing current Z, '
- + 'target, delta and feed; only their one-time code executes it. NOT door-interlocked - '
- + 'the operator supervises. Spindle must be off; the toolhead always carries a tool.',
+ description: 'Request absolute Z motion, executed on the direct path so positions PERSIST '
+ + '(file jobs park back at the work origin when they complete - firmware behaviour). '
+ + 'Either one target (z) or an ordered list (z_targets, max 20) for a methodical search: '
+ + 'the operator approves the EXACT list once, and each start_gcode_job call then executes '
+ + 'one step, so you can capture between steps and abandon the series at any point. The '
+ + 'confirm page shows current Z, every target, deltas and feed; only the operator one-time '
+ + 'code executes anything. NOT door-interlocked - the operator supervises. Spindle must be '
+ + 'off; the toolhead always carries a tool.',
inputSchema: {
type: 'object',
properties: {
- z: { type: 'number', description: 'Absolute target Z.' },
+ z: { type: 'number', description: 'Absolute target Z (single move).' },
+ z_targets: {
+ type: 'array',
+ items: { type: 'number' },
+ minItems: 1,
+ maxItems: 20,
+ description: 'Ordered absolute Z targets; one approval covers the exact list, '
+ + 'one start_gcode_job call per step. Mutually exclusive with z.',
+ },
coordinate_system: {
type: 'string',
enum: ['work', 'machine'],
description: 'Which frame z is in. Default work.',
},
feed_rate: { type: 'number', description: 'mm/min, default 300, max 600.' },
- reason: { type: 'string', description: 'Shown to the operator: why this Z move is needed.' },
+ reason: { type: 'string', description: 'Shown to the operator: why this Z motion is needed.' },
},
- required: ['z', 'reason'],
+ required: ['reason'],
additionalProperties: false,
},
- handler: async (args: { z?: number; coordinate_system?: string; feed_rate?: number; reason?: string }) => {
- const targetZ = Number(args.z);
- if (!Number.isFinite(targetZ)) {
- throw new McpToolError('z must be a finite number.');
+ handler: async (args: {
+ z?: number;
+ z_targets?: number[];
+ coordinate_system?: string;
+ feed_rate?: number;
+ reason?: string;
+ }) => {
+ if ((args.z === undefined) === (args.z_targets === undefined)) {
+ throw new McpToolError('Provide exactly one of z or z_targets.');
}
+ const targets = args.z_targets !== undefined
+ ? args.z_targets.map(Number)
+ : [Number(args.z)];
+ if (!targets.length || targets.length > 20 || targets.some((t) => !Number.isFinite(t))) {
+ throw new McpToolError('Targets must be 1-20 finite numbers.');
+ }
+ const targetZ = targets[targets.length - 1];
const coordinateSystem = args.coordinate_system || 'work';
if (!['work', 'machine'].includes(coordinateSystem)) {
throw new McpToolError('coordinate_system must be "work" or "machine".');
@@ -265,34 +310,45 @@ export function registerGcodeTools(registry: ToolRegistry, getConfirmBaseUrl: ()
if (currentZ === null) {
throw new McpToolError('Current Z unknown; cannot describe the move to the operator.');
}
- const machineTargetZ = coordinateSystem === 'machine' ? targetZ : targetZ - position.originOffset.z;
const size = getMachineSizeByIdentifier(connectionManager.getConnectionStatus().machineIdentifier);
- if (size && (machineTargetZ < -1 || machineTargetZ > size.z + 40)) {
- throw new McpToolError(`Target (machine Z ${machineTargetZ.toFixed(1)}) is outside the `
- + `0..${size.z} travel.`);
+ for (const t of targets) {
+ const machineT = coordinateSystem === 'machine' ? t : t - position.originOffset.z;
+ if (size && (machineT < -1 || machineT > size.z + 40)) {
+ throw new McpToolError(`Target ${coordinateSystem} Z ${t} (machine Z ${machineT.toFixed(1)}) `
+ + `is outside the 0..${size.z} travel.`);
+ }
}
- const move = `G1 Z${targetZ.toFixed(3)} F${feedRate}`;
- const gcode = coordinateSystem === 'machine'
- ? `G90\nG53;\n${move};\nG54;`
- : `G90\n${move}`;
+ const stepGcode = (t: number) => (coordinateSystem === 'machine'
+ ? `G90\nG53;\nG1 Z${t.toFixed(3)} F${feedRate};\nG54;`
+ : `G90\nG1 Z${t.toFixed(3)} F${feedRate}`);
+ const steps = targets.map(stepGcode);
+ const isBatch = targets.length > 1;
+ const reviewText = steps.join('\n; --- next approved step ---\n');
const delta = targetZ - currentZ;
- const name = `z-move ${coordinateSystem} Z${targetZ.toFixed(1)} (${delta >= 0 ? '+' : ''}${delta.toFixed(1)}mm) - ${String(args.reason).slice(0, 40)}`;
+ const name = isBatch
+ ? `z-series ${coordinateSystem} [${targets.map((t) => t.toFixed(1)).join(', ')}] - ${String(args.reason).slice(0, 40)}`
+ : `z-move ${coordinateSystem} Z${targetZ.toFixed(1)} (${delta >= 0 ? '+' : ''}${delta.toFixed(1)}mm) - ${String(args.reason).slice(0, 40)}`;
- const validation = validateGcode(gcode);
- const job = jobManager.submit(gcode, name, 'cnc', validation, 'direct');
+ const validation = validateGcode(reviewText);
+ const job = jobManager.submit(reviewText, name, 'cnc', validation, 'direct', isBatch ? steps : undefined);
return {
job: jobManager.describe(job),
current_z: currentZ,
- target_z: targetZ,
- delta_mm: delta,
+ targets,
+ final_delta_mm: delta,
feed_rate: feedRate,
coordinate_system: coordinateSystem,
confirm_url: `${getConfirmBaseUrl()}/confirm/${job.id}`,
- next_step: 'Ask the operator to open confirm_url, review the DIRECT-move banner '
- + '(current Z, target, delta, feed), and approve. Their one-time code passed to '
- + 'start_gcode_job executes the move; the position then persists.',
+ next_step: isBatch
+ ? 'Ask the operator to open confirm_url, review the DIRECT-move banner and the full '
+ + 'target list, and approve once. Then call start_gcode_job with the code once PER '
+ + 'STEP - each call executes the next approved target and settles; capture between '
+ + 'steps as needed. The series can be abandoned at any point.'
+ : 'Ask the operator to open confirm_url, review the DIRECT-move banner '
+ + '(current Z, target, delta, feed), and approve. Their one-time code passed to '
+ + 'start_gcode_job executes the move; the position then persists.',
};
},
});
From f8221e792bcbc11d03003ae62fbac3a995a27ecb Mon Sep 17 00:00:00 2001
From: tyeth
Date: Sun, 30 Aug 2026 22:49:02 +0100
Subject: [PATCH 029/135] Fix: Verify returned positions against the move
before reporting them
Observed live: a move_z to -95 returned completed while the heartbeat
still reported -85 - two identical post-issue beats can both predate
the motion because the heartbeat lags ~1s. Direct moves now parse the
absolute Z target from the executed gcode (G53 wrap selects the frame)
and the settle loop waits until the reported Z matches within 0.15mm;
home requires the position to move off its pre-G28 value at least once
before accepting stability (25s fallback when starting at home). On
timeout the position is returned with position_verified: false and a
warning naming query_firmware_position - never silently. XY moves
already verified against their target.
Co-Authored-By: Claude Opus 5
---
src/server/services/mcp/tools/camera.ts | 12 ++++-
src/server/services/mcp/tools/gcode.ts | 62 ++++++++++++++++++++-----
2 files changed, 62 insertions(+), 12 deletions(-)
diff --git a/src/server/services/mcp/tools/camera.ts b/src/server/services/mcp/tools/camera.ts
index c5395533bf..158e1991d6 100644
--- a/src/server/services/mcp/tools/camera.ts
+++ b/src/server/services/mcp/tools/camera.ts
@@ -423,6 +423,8 @@ export function registerCameraTools(registry: ToolRegistry): void {
// (offset zeroed) before G54 reselects workspace 0, and returning
// that transient produced a nonsense snapshot on hardware.
const deadline = issuedAt + HOME_TIMEOUT_MS;
+ const initialFingerprint = JSON.stringify([before.work, before.originOffset]);
+ let sawChange = false;
let previous: string | null = null;
while (Date.now() < deadline) {
await sleep(HOME_POLL_MS);
@@ -434,7 +436,15 @@ export function registerCameraTools(registry: ToolRegistry): void {
const fingerprint = JSON.stringify([now.work, now.originOffset]);
const stable = fingerprint === previous;
previous = fingerprint;
- if (reportTime > issuedAt && stable && now.isHomed === true && now.machineStatus === 'idle') {
+ if (fingerprint !== initialFingerprint) {
+ sawChange = true;
+ }
+ // The heartbeat lags ~1s, so two identical post-issue beats can
+ // both predate the motion. Require the position to have moved
+ // off its pre-G28 value at least once - homing always travels -
+ // before accepting stability (or 25s, if it started at home).
+ const changeOk = sawChange || Date.now() - issuedAt > 25000;
+ if (reportTime > issuedAt && stable && changeOk && now.isHomed === true && now.machineStatus === 'idle') {
return {
homed: true,
position: now,
diff --git a/src/server/services/mcp/tools/gcode.ts b/src/server/services/mcp/tools/gcode.ts
index 0fc421144d..b356a68f65 100644
--- a/src/server/services/mcp/tools/gcode.ts
+++ b/src/server/services/mcp/tools/gcode.ts
@@ -36,12 +36,20 @@ function getJobChannel(): JobChannel {
}
/**
- * Wait for two consecutive identical heartbeats fresher than issuedAt, so a
- * direct move's returned position is settled firmware truth.
+ * Wait until the heartbeat is settled AND, when the executed gcode names an
+ * absolute Z target, until the reported Z actually matches it. Two identical
+ * post-issue beats alone are not enough: the heartbeat lags the controller
+ * by ~1s, so both can be pre-motion beats showing the old position (observed
+ * live: a -95 move "completed" while still reporting -85). Every returned
+ * position is either verified or flagged as not.
*/
-async function waitForStableHeartbeat(issuedAt: number): Promise {
- const deadline = issuedAt + 30000;
+async function waitForStableHeartbeat(
+ issuedAt: number,
+ expect?: { frame: 'work' | 'machine'; z: number }
+): Promise<{ position: PositionSnapshot | null; verified: boolean; warning?: string }> {
+ const deadline = issuedAt + 45000;
let previous: string | null = null;
+ let last: PositionSnapshot | null = null;
while (Date.now() < deadline) {
await new Promise((resolve) => {
setTimeout(resolve, 500);
@@ -52,15 +60,43 @@ async function waitForStableHeartbeat(issuedAt: number): Promise issuedAt && stable && now.machineStatus === 'idle') {
- return now;
+ if (reportTime <= issuedAt || !stable || now.machineStatus !== 'idle') {
+ continue;
+ }
+ if (expect) {
+ const reportedZ = expect.frame === 'work' ? now.work.z : now.machine.z;
+ if (reportedZ === null || Math.abs(reportedZ - expect.z) > 0.15) {
+ continue; // settled, but not AT the target yet - keep waiting
+ }
}
+ return { position: now, verified: true };
+ }
+ return {
+ position: last,
+ verified: false,
+ warning: expect
+ ? `Timed out waiting for the heartbeat to report ${expect.frame} Z ${expect.z}; the position `
+ + 'shown is the last read and may be stale - verify with query_firmware_position.'
+ : 'Timed out waiting for a settled heartbeat; the position shown may be stale - verify with '
+ + 'query_firmware_position.',
+ };
+}
+
+/**
+ * Absolute Z target of a direct-move gcode, for settle verification.
+ * G53-wrapped moves are machine-frame; plain ones are work-frame.
+ */
+function parseZTarget(gcode: string): { frame: 'work' | 'machine'; z: number } | undefined {
+ const match = gcode.match(/G0*1[^;\n]*?Z(-?\d+(?:\.\d+)?)/i);
+ if (!match) {
+ return undefined;
}
- return null;
+ return { frame: gcode.includes('G53') ? 'machine' : 'work', z: Number(match[1]) };
}
function machineStatus(): string | null {
@@ -183,7 +219,7 @@ export function registerGcodeTools(registry: ToolRegistry, getConfirmBaseUrl: ()
job.error = `Controller rejected the move: ${executed.text || executed.result}`;
throw new McpToolError(job.error);
}
- const position = await waitForStableHeartbeat(issuedAt);
+ const settle = await waitForStableHeartbeat(issuedAt, parseZTarget(gcodeText));
if (isBatch) {
job.nextStep += 1;
if (job.nextStep < job.steps.length) {
@@ -193,7 +229,9 @@ export function registerGcodeTools(registry: ToolRegistry, getConfirmBaseUrl: ()
job.state = 'started';
return {
job: jobManager.describe(job),
- position,
+ position: settle.position,
+ position_verified: settle.verified,
+ warning: settle.warning,
remaining_steps: job.steps.length - job.nextStep,
note: 'Step executed and settled. Call start_gcode_job again with the same '
+ 'job_id and code for the next approved step; positions persist.',
@@ -203,8 +241,10 @@ export function registerGcodeTools(registry: ToolRegistry, getConfirmBaseUrl: ()
job.state = 'completed';
return {
job: jobManager.describe(job),
- position,
- note: 'Direct move executed and settled; the position persists (no end-of-job park).',
+ position: settle.position,
+ position_verified: settle.verified,
+ warning: settle.warning,
+ note: 'Direct move executed; the position persists (no end-of-job park).',
};
}
From e307be2c1fe6e785dc99601098648e0097f787e8 Mon Sep 17 00:00:00 2001
From: tyeth
Date: Sun, 30 Aug 2026 22:52:41 +0100
Subject: [PATCH 030/135] Improvement: Add wait-until-moved option to motion
tools
Every motion tool now takes wait_until_moved (default true: block until
the returned position is verifiably settled at the target). false
returns as soon as the controller accepts the command - useful during
the ~15-20s home or long Z descents - always with position_verified:
false and a poll-get_position note, never a silently stale number.
move_and_capture and goto_work_origin skip the frame too when not
waiting (it would not show the commanded position); visual_servo
deliberately has no such arg, since its contract is measure-after-step.
move_z stages the preference on the job; start_gcode_job can override
per call.
Co-Authored-By: Claude Opus 5
---
src/server/services/mcp/jobs.ts | 3 ++
src/server/services/mcp/tools/camera.ts | 53 +++++++++++++++++++++++--
src/server/services/mcp/tools/gcode.ts | 29 +++++++++++++-
3 files changed, 80 insertions(+), 5 deletions(-)
diff --git a/src/server/services/mcp/jobs.ts b/src/server/services/mcp/jobs.ts
index a3639fc0e4..609ba3f87c 100644
--- a/src/server/services/mcp/jobs.ts
+++ b/src/server/services/mcp/jobs.ts
@@ -54,6 +54,9 @@ export interface McpJob {
// steps and the series can be abandoned at any point.
steps?: string[];
nextStep?: number;
+ // Staged default for direct execution: whether start_gcode_job should
+ // block until the move verifiably settles (call-time arg overrides).
+ waitUntilMoved?: boolean;
}
function escapeHtml(text: string): string {
diff --git a/src/server/services/mcp/tools/camera.ts b/src/server/services/mcp/tools/camera.ts
index 158e1991d6..6b5d016529 100644
--- a/src/server/services/mcp/tools/camera.ts
+++ b/src/server/services/mcp/tools/camera.ts
@@ -120,6 +120,7 @@ export interface BoundedMoveArgs {
coordinate_system?: string;
feed_rate?: number;
operator_confirmed_clearance?: boolean;
+ wait_until_moved?: boolean;
// Internal (not exposed in any tool schema): lifts the per-call travel
// limit for fixed, operator-set destinations like the work origin.
unbounded_travel?: boolean;
@@ -188,6 +189,18 @@ export async function executeBoundedMoveAndCapture(args: BoundedMoveArgs): Promi
throw new McpToolError(`Move rejected by controller: ${executed.text || executed.result}`);
}
+ if (args.wait_until_moved === false) {
+ // Fire-and-return: no settle, no capture - the frame would not show
+ // the commanded position. Poll get_position before relying on it.
+ return {
+ commanded: { ...target, coordinate_system: coordinateSystem, feed_rate: feedRate },
+ position: null,
+ position_verified: false,
+ note: 'wait_until_moved was false: move accepted but not awaited, and no frame was '
+ + 'captured (it would not show the commanded position). Poll get_position.',
+ };
+ }
+
// Wait for a post-move heartbeat that reports the target, twice,
// so the returned position is what the firmware says, not what
// was commanded (#11).
@@ -389,8 +402,19 @@ export function registerCameraTools(registry: ToolRegistry): void {
+ 'fitted, G28 also homes B - stock indexed on the rotary WILL rotate (observed -45 to 0 '
+ 'on hardware); warn the operator first. Requires an idle machine with the toolhead '
+ 'off. Waits for the firmware to report homed.',
- inputSchema: { type: 'object', properties: {}, additionalProperties: false },
- handler: async () => {
+ inputSchema: {
+ type: 'object',
+ properties: {
+ wait_until_moved: {
+ type: 'boolean',
+ description: 'Default true: block ~15-20s until the firmware reports homed and '
+ + 'settled. false returns right after G28 is accepted - poll get_position '
+ + 'for isHomed before any motion.',
+ },
+ },
+ additionalProperties: false,
+ },
+ handler: async (args: { wait_until_moved?: boolean }) => {
const before = getPositionSnapshot();
if (before.machineStatus !== 'idle') {
throw new McpToolError(`Machine is ${before.machineStatus || 'in an unknown state'}, not idle.`);
@@ -416,6 +440,16 @@ export function registerCameraTools(registry: ToolRegistry): void {
throw new McpToolError(`Homing rejected by controller: ${executed.text || executed.result}`);
}
+ if (args.wait_until_moved === false) {
+ return {
+ homed: null,
+ position_verified: false,
+ note: 'wait_until_moved was false: G28 accepted but not awaited (homing takes '
+ + '~15-20s). Poll get_position until isHomed is true and the position is '
+ + 'stable before any motion.',
+ };
+ }
+
// Homing on the A350 takes tens of seconds; wait for TWO
// consecutive identical heartbeats (position AND offset) that
// report homed and idle. A single fresh heartbeat is not enough:
@@ -476,10 +510,16 @@ export function registerCameraTools(registry: ToolRegistry): void {
description: 'Set true ONLY when the human operator has explicitly confirmed the '
+ 'current Z and an obstacle-free path at this Z; skips the homed-first requirement.',
},
+ wait_until_moved: {
+ type: 'boolean',
+ description: 'Default true: settle at the origin and capture there. false returns '
+ + 'right after the controller accepts the move - no settle, no frame; poll '
+ + 'get_position afterwards.',
+ },
},
additionalProperties: false,
},
- handler: async (args: { feed_rate?: number; operator_confirmed_clearance?: boolean }) => {
+ handler: async (args: { feed_rate?: number; operator_confirmed_clearance?: boolean; wait_until_moved?: boolean }) => {
// The work origin is a fixed, operator-set destination, so the
// per-call travel limit (meant to bound the blast radius of a
// wrong coordinate) does not apply; every other guard does.
@@ -489,6 +529,7 @@ export function registerCameraTools(registry: ToolRegistry): void {
coordinate_system: 'work',
feed_rate: args.feed_rate,
operator_confirmed_clearance: args.operator_confirmed_clearance,
+ wait_until_moved: args.wait_until_moved,
unbounded_travel: true,
});
},
@@ -517,6 +558,12 @@ export function registerCameraTools(registry: ToolRegistry): void {
description: 'Set true ONLY when the human operator has explicitly confirmed the '
+ 'current Z and an obstacle-free path at this Z; skips the homed-first requirement.',
},
+ wait_until_moved: {
+ type: 'boolean',
+ description: 'Default true: settle at the target and capture there. false returns '
+ + 'right after the controller accepts the move - no settle, NO FRAME, '
+ + 'position_verified: false; poll get_position afterwards.',
+ },
},
additionalProperties: false,
},
diff --git a/src/server/services/mcp/tools/gcode.ts b/src/server/services/mcp/tools/gcode.ts
index b356a68f65..b14c813bfc 100644
--- a/src/server/services/mcp/tools/gcode.ts
+++ b/src/server/services/mcp/tools/gcode.ts
@@ -174,11 +174,17 @@ export function registerGcodeTools(registry: ToolRegistry, getConfirmBaseUrl: ()
properties: {
job_id: { type: 'string' },
confirm_token: { type: 'string', description: 'One-time code from the operator.' },
+ wait_until_moved: {
+ type: 'boolean',
+ description: 'Direct jobs only. Default true: block until the heartbeat verifiably '
+ + 'reports the move done. false returns immediately after the controller accepts '
+ + 'the command, with position_verified: false - poll get_position afterwards.',
+ },
},
required: ['job_id', 'confirm_token'],
additionalProperties: false,
},
- handler: async (args: { job_id?: string; confirm_token?: string }) => {
+ handler: async (args: { job_id?: string; confirm_token?: string; wait_until_moved?: boolean }) => {
const job = jobManager.get(String(args.job_id || ''));
if (!job) {
throw new McpToolError('Unknown job_id.');
@@ -219,7 +225,17 @@ export function registerGcodeTools(registry: ToolRegistry, getConfirmBaseUrl: ()
job.error = `Controller rejected the move: ${executed.text || executed.result}`;
throw new McpToolError(job.error);
}
- const settle = await waitForStableHeartbeat(issuedAt, parseZTarget(gcodeText));
+ const shouldWait = args.wait_until_moved !== undefined
+ ? args.wait_until_moved !== false
+ : job.waitUntilMoved !== false;
+ const settle = !shouldWait
+ ? {
+ position: null,
+ verified: false,
+ warning: 'wait_until_moved was false: the move was accepted but not awaited - '
+ + 'poll get_position (or query_firmware_position) before relying on position.',
+ }
+ : await waitForStableHeartbeat(issuedAt, parseZTarget(gcodeText));
if (isBatch) {
job.nextStep += 1;
if (job.nextStep < job.steps.length) {
@@ -306,6 +322,13 @@ export function registerGcodeTools(registry: ToolRegistry, getConfirmBaseUrl: ()
},
feed_rate: { type: 'number', description: 'mm/min, default 300, max 600.' },
reason: { type: 'string', description: 'Shown to the operator: why this Z motion is needed.' },
+ wait_until_moved: {
+ type: 'boolean',
+ description: 'Staged default for execution (start_gcode_job can override per call). '
+ + 'Default true: each step blocks until the heartbeat verifiably reports the '
+ + 'target Z. false: steps return on controller accept with '
+ + 'position_verified: false - poll get_position.',
+ },
},
required: ['reason'],
additionalProperties: false,
@@ -316,6 +339,7 @@ export function registerGcodeTools(registry: ToolRegistry, getConfirmBaseUrl: ()
coordinate_system?: string;
feed_rate?: number;
reason?: string;
+ wait_until_moved?: boolean;
}) => {
if ((args.z === undefined) === (args.z_targets === undefined)) {
throw new McpToolError('Provide exactly one of z or z_targets.');
@@ -372,6 +396,7 @@ export function registerGcodeTools(registry: ToolRegistry, getConfirmBaseUrl: ()
const validation = validateGcode(reviewText);
const job = jobManager.submit(reviewText, name, 'cnc', validation, 'direct', isBatch ? steps : undefined);
+ job.waitUntilMoved = args.wait_until_moved !== false;
return {
job: jobManager.describe(job),
From d220af767033ed7b002a906557ad7edecd28af59 Mon Sep 17 00:00:00 2001
From: tyeth
Date: Sun, 30 Aug 2026 23:38:28 +0100
Subject: [PATCH 031/135] Fix: Allow home-overtravel targets, optional capture,
tidy console replies
Live failure: keeping the current work X while parked at home converts
to machine X -19 - the machine's own resting position at its overtravel
switch - and the envelope floor of -0.5 rejected it. Floors now match
the position-sanity bounds (-25..+40). move_and_capture gains a capture
arg (default true): false still settles and verifies the position but
skips the frame - a plain verified move; its description also names
mcpMaxJogDistance as the configurable travel cap. Multi-line controller
replies in the verbose console are now split per line instead of
rendering misaligned.
Co-Authored-By: Claude Opus 5
---
src/app/ui/widgets/Console/Console.jsx | 7 ++++++-
src/server/services/mcp/tools/camera.ts | 26 ++++++++++++++++++++++---
2 files changed, 29 insertions(+), 4 deletions(-)
diff --git a/src/app/ui/widgets/Console/Console.jsx b/src/app/ui/widgets/Console/Console.jsx
index a4898d70a3..e086fa1d58 100644
--- a/src/app/ui/widgets/Console/Console.jsx
+++ b/src/app/ui/widgets/Console/Console.jsx
@@ -145,7 +145,12 @@ function Console({ widgetId, widgetActions, minimized, isDefault, clearRenderSta
});
}
if (response) {
- terminal.writeln(color.magenta(`${stamp()}[mcp:${tool}] < ${String(response).slice(0, 200)}`));
+ // Controller replies (e.g. M114 reports) are multi-line; one
+ // writeln with embedded newlines renders misaligned in xterm.
+ String(response).slice(0, 400).split(/\r?\n/).forEach((line) => {
+ line = line.trim();
+ line && terminal.writeln(color.magenta(`${stamp()}[mcp:${tool}] < ${line}`));
+ });
}
},
// MCP tool activity mirrored from the server (verbose mode)
diff --git a/src/server/services/mcp/tools/camera.ts b/src/server/services/mcp/tools/camera.ts
index 6b5d016529..dca5c067cc 100644
--- a/src/server/services/mcp/tools/camera.ts
+++ b/src/server/services/mcp/tools/camera.ts
@@ -121,6 +121,7 @@ export interface BoundedMoveArgs {
feed_rate?: number;
operator_confirmed_clearance?: boolean;
wait_until_moved?: boolean;
+ capture?: boolean;
// Internal (not exposed in any tool schema): lifts the per-call travel
// limit for fixed, operator-set destinations like the work origin.
unbounded_travel?: boolean;
@@ -169,10 +170,14 @@ export async function executeBoundedMoveAndCapture(args: BoundedMoveArgs): Promi
y: target.y - before.originOffset.y,
};
if (size) {
- if (machineTarget.x < -0.5 || machineTarget.x > size.x + 0.5
- || machineTarget.y < -0.5 || machineTarget.y > size.y + 0.5) {
+ // Floors allow real overtravel: the A350 X home switch sits at
+ // machine -19, so "keep the current X while parked at home" must
+ // pass (a target at the machine's own resting position was being
+ // rejected live). Matches the position-sanity bounds.
+ if (machineTarget.x < -25 || machineTarget.x > size.x + 40
+ || machineTarget.y < -25 || machineTarget.y > size.y + 40) {
throw new McpToolError(`Target (machine ${machineTarget.x.toFixed(1)}, ${machineTarget.y.toFixed(1)}) `
- + `is outside the ${size.x}x${size.y} build area.`);
+ + `is outside the ${size.x}x${size.y} build area (overtravel allowance -25..+40).`);
}
}
@@ -238,12 +243,21 @@ export async function executeBoundedMoveAndCapture(args: BoundedMoveArgs): Promi
}
await sleep(POST_SETTLE_DWELL_MS);
+ if (args.capture === false) {
+ return {
+ commanded: { ...target, coordinate_system: coordinateSystem, feed_rate: feedRate },
+ position: getPositionSnapshot(),
+ position_verified: true,
+ note: 'position is firmware-reported after settling; capture was false so no frame was taken',
+ };
+ }
const frame = await captureFrame();
const after = getPositionSnapshot();
return frameContent(frame, {
commanded: { ...target, coordinate_system: coordinateSystem, feed_rate: feedRate },
position: after,
+ position_verified: true,
note: 'position is firmware-reported after settling, not the commanded target',
});
}
@@ -564,6 +578,12 @@ export function registerCameraTools(registry: ToolRegistry): void {
+ 'right after the controller accepts the move - no settle, NO FRAME, '
+ 'position_verified: false; poll get_position afterwards.',
},
+ capture: {
+ type: 'boolean',
+ description: 'Default true. false still settles and verifies the position but '
+ + 'skips the frame - a plain verified move. (The XY travel cap per call is '
+ + 'the configstore key mcpMaxJogDistance, default 100mm.)',
+ },
},
additionalProperties: false,
},
From 79688c70b2f9a268031648090894fd11ca4f5e93 Mon Sep 17 00:00:00 2001
From: tyeth
Date: Mon, 31 Aug 2026 13:08:29 +0100
Subject: [PATCH 032/135] Docs: Add MCP server README - the durable record of
design and hardware facts
Written for context-compaction survival: configuration keys, the
loopback architecture and why the transport is hand-rolled (Electron 15
= Node 16), the operator-defined safety model (file jobs + door
interlock, confirm-page one-time codes, Z policy, verified-settle
contract), every hardware-verified machine fact (G53/G54 workspace
model, home (-19,342,328) via G53;G28;G54, origins persist, firmware
parks at work origin on job completion, B homes with G28, toolhead
camera geometry, tool height checker identity), the 21-tool surface,
the development workflow gotchas (gulp exits 0 on failure, EBUSY vs
running instances, build side-effect files, stacked-PR conventions,
per-command credential override), and the open threads (#50-53 from the
latest field report, #38, #24, #25). The skill gains the same session's
lessons: landmark identity is operator truth not visual analogy,
calibrations are depth-plane-specific (~4x parallax error observed),
and track_feature's second_peak_gap is a soft signal on repetitive
grids.
Co-Authored-By: Claude Opus 5
---
.claude/skills/cnc-visual-alignment/SKILL.md | 20 ++-
src/server/services/mcp/README.md | 146 +++++++++++++++++++
2 files changed, 165 insertions(+), 1 deletion(-)
create mode 100644 src/server/services/mcp/README.md
diff --git a/.claude/skills/cnc-visual-alignment/SKILL.md b/.claude/skills/cnc-visual-alignment/SKILL.md
index 75c3b62581..8f802a2198 100644
--- a/.claude/skills/cnc-visual-alignment/SKILL.md
+++ b/.claude/skills/cnc-visual-alignment/SKILL.md
@@ -107,13 +107,23 @@ Never compute a machine coordinate from one frame and drive to it. With the MCP:
explicitly confirmed Z and path clearance (`operator_confirmed_clearance`, which you
pass ONLY on the operator's word, never on your own judgment).
3. Derive the 2×2 matrix at the working Y and Z: command 2–3 known small XY offsets with
- `move_and_capture`, track one feature's pixel displacement, fit the forward Jacobian J
+ `move_and_capture`, measure the feature's pixel displacement with `track_feature`
+ (never by eye - hand-estimated pixels caused a ~50% calibration error live; on
+ repetitive grids the second_peak_gap is a SOFT signal, ~0.17-0.25 even for correct
+ matches, so verify low-gap matches against the Jacobian prediction), fit the forward Jacobian J
(pixel shift per mm), and store M = +J⁻¹ with `set_camera_calibration` (residuals in
`notes`). **Verify the sign before storing**: the tool computes error = target − feature
(check `pixel_error` in a real response against your own numbers), and J·(M·e) must
reproduce +e — a flipped M drives every "correction" away from the target, and it looks
plausible right up until the error grows. The tool warns when consecutive steps fail to
shrink the error; treat that warning as "stop and re-derive", never "push through".
+3b. **Calibrations are depth-plane-specific.** The matrix is only valid for features on
+ the same physical surface it was derived from: applying a bracket-screw calibration to
+ a feature on the board (different height under a close, tilted camera) predicted ~4×
+ wrong — real parallax, not a bug. Derive on the surface you will servo on, record the
+ surface in `notes`, and before trusting any tracked shift, sanity-check it against the
+ Jacobian prediction (J·Δmachine ≈ Δpixel); a sharp divergence means wrong plane, wrong
+ match, or both (automatic cross-check planned as fork issue #51).
4. Iterate `visual_servo` — each call is one clamped step and returns the frame; two or
three passes converge. It auto-selects the nearest-Y calibration and warns when a step
moves Y (self-invalidating) — re-derive or re-select when it does.
@@ -149,6 +159,14 @@ categorically different from the resolvable distance-blur of the scene. `capture
reports the operator-configured `expectedToolRegion` box with every frame — check it before
concluding anything about "an unidentified blurry shape".
+**Landmark identity is operator truth, not visual analogy.** A recurring unidentified
+object must not be assigned an identity from what it sits near ("beside jaw-shaped blocks,
+so chuck-related") — on this machine the gold cylinder at machine Y≈176–340 is the **tool
+height checker**, misidentified twice by analogy before the operator corrected it. If the
+operator has named a landmark, use that; if not, ask — never assert a guess as resolved
+fact. (A named-landmark registry is planned as fork issue #50; until it exists, landmark
+identities live in this paragraph and the operator's word.)
+
## Datums: check the landmark is actually in frame
A stated datum is worthless if it is outside the field of view. Verify visually before
diff --git a/src/server/services/mcp/README.md b/src/server/services/mcp/README.md
new file mode 100644
index 0000000000..a8155e4367
--- /dev/null
+++ b/src/server/services/mcp/README.md
@@ -0,0 +1,146 @@
+# Luban MCP server
+
+An MCP (Model Context Protocol) server inside the Luban backend, exposing the connected
+Snapmaker machine to local AI agents over guarded, operator-gated tools. Built and
+hardware-verified against a Snapmaker 2.0 A350 (CNC, 200 W toolhead, rotary module,
+bracing kit) during 2026-08; this file is the durable record of the design, the machine
+facts learned on hardware, and the development workflow — written so a fresh session can
+continue the work without re-deriving any of it.
+
+## Enabling and configuration
+
+Off by default. The server configstore is `~/.snapmaker-luban.json` (shared with the
+official Luban); the Settings → MCP Server pane edits the main keys, `GET/POST /api/mcp`
+serves them, and `LUBAN_MCP_PORT` (env) overrides everything for one run.
+
+| Key | Meaning |
+|---|---|
+| `mcpEnabled`, `mcpPort` | Start the server on 127.0.0.1:port (default 40889). Legacy: `mcpPort` alone enables when `mcpEnabled` was never written. |
+| `mcpCameraUrl` | HTTP(S) snapshot URL; takes precedence over ffmpeg. |
+| `mcpFfmpegPath`, `mcpCameraDevice`, `mcpCameraLastGood` | ffmpeg DirectShow capture. Device choice is sticky (last-good preferred); a vanished device is an error, never a silent substitution. |
+| `mcpMaxJogDistance` | Per-call XY travel cap for direct moves, default 100 mm. `goto_work_origin` is exempt (fixed operator-set destination). |
+| `mcpToolRegion` | Fractional box where the endmill images (fixed camera-to-spindle geometry); returned with every frame; settable via the `set_tool_region` tool. |
+| `mcpInstalledModules` | e.g. `["snapmaker-2.0-bracing-kit-module"]` — feeds effective work ranges in `get_machine_profile`. |
+
+A project-scope `.mcp.json` at the repo root points Claude Code sessions at
+`http://127.0.0.1:40889/mcp` automatically.
+
+## Architecture
+
+Own `http.Server` bound strictly to loopback — NOT a route on Luban's Express app (whose
+`/api` carries the renderer session JWT and whose IP whitelist is LAN-wide). The MCP
+transport is hand-rolled stateless Streamable HTTP (JSON-RPC over `POST /mcp`): Electron 15
+embeds Node 16 and the official SDK needs ≥ 18. Zero added dependencies anywhere —
+`jpeg-js` (tracking) was already in the tree; the lockfile has never changed.
+
+```
+mcp/
+ index.ts start/stop, config resolution, routing (/mcp, /confirm), mcpBroadcast
+ McpServer.ts JSON-RPC transport, per-call logging + mcp:activity broadcast
+ registry.ts tool registration/dispatch; McpToolError = tool-level failure
+ jobs.ts JobManager + human confirm pages (/confirm/); job kinds file|direct
+ validator.ts static gcode inspection (extents, spindle, distance-mode hazards)
+ camera.ts capture providers, frame cache (last 12, frameId), sticky device
+ tracking.ts zero-mean NCC template matching between cached frames
+ calibration.ts Y/Z-keyed pixel->mm calibration store (userDataDir, persists)
+ tools/ status, machine, gcode, camera, calibration registrations
+```
+
+UI integration: verbose console toggle (Workspace) mirrors heartbeat position changes,
+every MCP-sent gcode line and controller reply (`[mcp:home] > G28` / `< X:-19.00 ...`),
+and tool activity — all timestamped. Events: `mcp:activity`, `mcp:gcode` (whitelisted in
+`socket-communication.ts`).
+
+## Safety model (operator-defined, non-negotiable)
+
+- **Compound motion and all cutting goes out as gcode FILES** through the same
+ `prepare_print`/`start_print` path as Luban's Start button, so the controller job state
+ machine and the enclosure **door interlock** apply (fork issue #23). The direct
+ (`execute_code`) path is reserved for single guarded actions.
+- **Human confirm page** (`/confirm/`, loopback + Origin-checked): shows validation,
+ extents, warnings, and for direct moves a banner stating the interlock does NOT apply.
+ Approval mints a one-time code (15 min TTL) that is never returned over MCP — a model
+ cannot self-authorise motion. Batch direct jobs: one approval covers an exact target
+ list; each `start_gcode_job` call executes one step.
+- **Z policy**: no Z through XY tools, ever. `move_z` = per-move operator confirmation
+ showing current Z, target, delta, feed. Every request needs a `reason`.
+- **Guards on every direct move**: machine idle, toolhead off (headStatus/headPower),
+ homed-first (override `operator_confirmed_clearance` only on the operator's explicit
+ word), travel cap, build-envelope check with overtravel allowance (machine −25..+40 —
+ X home rests at −19).
+- **Verified-settle contract**: motion tools block until the returned position verifiably
+ matches the move (Z parsed from the executed gcode, ±0.15 mm; XY at target; home must
+ leave its pre-G28 position at least once). `wait_until_moved: false` opts out and always
+ returns `position_verified: false` with a poll instruction. `capture: false` = verified
+ move without a frame.
+- The endmill is ALWAYS in the collet; the rotary is fitted and may hold stock. Report
+ dimensions with uncertainty. See `.claude/skills/cnc-visual-alignment/` for the full
+ vision methodology.
+
+## Machine facts (hardware-verified 2026-08-30)
+
+- Controller has **G53 (machine workspace) and G54+ (numbered workspaces)**; the heartbeat
+ `pos` is in the *currently selected* workspace. Convention everywhere:
+ `machine = work − originOffset`.
+- **Machine home = (−19, 342, 328)**; homing = `G53;G28;G54` exactly like Luban's button
+ (a bare G28 leaves reporting in an unselected workspace → impossible derived coords like
+ Y 464/Z 656). Homing takes ~15–20 s and **also homes B — stock on the rotary rotates**.
+- **Work origins are operator-set per workspace and persist across homing.**
+- **The firmware parks at the work origin when a file job COMPLETES** — file jobs cannot
+ hold a position; that is why `move_z` exists on the direct path.
+- Heartbeat period ~1 s; a settled-looking heartbeat can predate the motion (hence the
+ verified-settle contract). `query_firmware_position` (M114) is the authoritative check.
+- Camera is **toolhead-mounted** (rides X/Z; the platform moves under it in Y): pixel→mm
+ calibration is keyed by machine Y AND Z. The board-viewing anchor pose is the pre-home
+ park (machine X0/Y0), not machine home. The **gold cylinder at machine Y≈176–340 is the
+ TOOL HEIGHT CHECKER** (operator-confirmed; was misidentified twice).
+- Fleet: machine named "Snapmaker" @ 192.168.1.173 = the A350 CNC; "F350" @ .130 = the
+ 3D printer. Same Luban profile identifier — distinguish by NAME/address, never profile.
+
+## Tool surface (21)
+
+`get_connection_status` · `get_machine_profile` (kinematics, module offsets) ·
+`get_position` (both frames, warnings on incoherent reporting) ·
+`query_firmware_position` (raw M114) · `validate_gcode` · `submit_gcode_job` →
+`start_gcode_job` → `get_gcode_job_status` / `stop_gcode_job` · `move_z` (single or
+`z_targets` batch) · `home` · `goto_work_origin` · `move_and_capture` · `list_cameras` ·
+`capture_frame` (position-stamped, `frameId`, `expectedToolRegion`) · `set_tool_region` ·
+`track_feature` (NCC between cached frames — use instead of eyeballing pixels) ·
+`set_/get_/delete_camera_calibration` (Y/Z-keyed; optional `jacobian` REJECTS sign-flipped
+matrices, M·J ≈ −I) · `visual_servo` (one clamped step per call; trips when the error
+fails to shrink).
+
+## Development workflow (learned the hard way)
+
+- **Build**: Node 16 (`/c/dev/software/snapmaker/.tools/node-v16.20.2-win-x64`), python
+ 3.11 for node-gyp, `npm run build` (~4 min). gulp **exits 0 on failure**: require
+ `Finished 'production'` and zero `errored` lines, and verify key strings in
+ `dist/Luban/src/server/index.js`. NEVER build while any electron.exe from this worktree
+ runs (EBUSY leaves dist half-deleted); count processes read-only first and ask the
+ operator to close their copy. The build dirties `src/package.json` and
+ `MaterialTestGcodeParams.jsx` — revert before staging.
+- **Stack**: stacked single-commit PRs `mcp/N-*`, each targeting the previous branch
+ (#15 → #49 as of writing; origin = Snapmaker/Luban is NEVER pushed). Mid-stack changes:
+ amend + rebase the chain. commitlint enforces `Type: Sentence-case subject` (20–100
+ chars).
+- **Credentials**: active gh account is `tyeth-ai-assisted` (no push). Per-command
+ override only: `GH_TOKEN=$(gh auth token -u tyeth)` for gh calls;
+ `git -c "credential.helper=!f() { echo username=tyeth; echo password=$GH_TOKEN; }; f" push ...`.
+ Never `gh auth switch`, never store the token.
+- eslint judged against baseline (pre-existing errors in ConnectionManager/SstpHttpChannel
+ stay); `npx tsc -p tsconfig-server.json --noEmit` filtered to `services/mcp` must be
+ clean.
+
+## Open threads
+
+- **#50 named-landmark registry** — `expectedToolRegion` for scene landmarks (tool height
+ checker, rotary span), set once, surfaced near their coordinates.
+- **#51 depth-plane calibration tag + Jacobian-mismatch warning** — a calibration from one
+ surface applied to another depth read ~4× wrong (parallax); tools should cross-check the
+ early measured shift against the Jacobian prediction automatically.
+- **#52 expected-shift hint for `track_feature`** — second-peak gap is a soft signal on
+ repetitive patterns; a Jacobian-derived expectation should break ties.
+- **#53 stored-state overview call** — one call returning calibrations + landmarks + tool
+ region so a fresh session orients without motion.
+- **#38 startup socket.io gap** — properly belongs in the startup stack; hotfixed here.
+- **#24 measurement systems, #25 collision watcher** — designed but not started.
From 908136871d8892ffacbf5e52c3c3a71a25a363ee Mon Sep 17 00:00:00 2001
From: tyeth
Date: Mon, 31 Aug 2026 13:19:18 +0100
Subject: [PATCH 033/135] Feature: Landmarks, stored-state overview,
depth-plane and expectation checks
Implements the four field-report gaps (#50-53):
- set_landmark/delete_landmark persist named scene features by machine
extent (userDataDir); captures surface nearbyLandmarks within 120mm,
so identities the operator stated once are never re-guessed (#50).
- Calibrations take a surface tag, and visual_servo cross-checks each
step against the calibration prediction (J = inverse(M) applied to
the executed move) - sharp divergence warns of a depth-plane mismatch
or wrong match, the ~4x parallax failure observed live (#51).
- track_feature accepts expected_shift and uses it to break ties among
near-best candidates on repetitive grids, reporting chosen_by,
distance_from_expected and the raw best when overridden (#52).
- get_stored_state returns calibrations, landmarks, tool region, limits,
camera config and the connection snapshot in one read-only call - the
first call of any fresh session (#53).
README and skill updated accordingly; tool surface is now 24.
Co-Authored-By: Claude Opus 5
---
.claude/skills/cnc-visual-alignment/SKILL.md | 8 +-
src/server/services/mcp/README.md | 27 +++--
src/server/services/mcp/calibration.ts | 4 +
src/server/services/mcp/index.ts | 2 +
src/server/services/mcp/landmarks.ts | 106 +++++++++++++++++
src/server/services/mcp/tools/calibration.ts | 67 ++++++++++-
src/server/services/mcp/tools/camera.ts | 56 +++++++--
src/server/services/mcp/tools/landmarks.ts | 118 +++++++++++++++++++
src/server/services/mcp/tracking.ts | 53 ++++++++-
9 files changed, 409 insertions(+), 32 deletions(-)
create mode 100644 src/server/services/mcp/landmarks.ts
create mode 100644 src/server/services/mcp/tools/landmarks.ts
diff --git a/.claude/skills/cnc-visual-alignment/SKILL.md b/.claude/skills/cnc-visual-alignment/SKILL.md
index 8f802a2198..d32b3d34cf 100644
--- a/.claude/skills/cnc-visual-alignment/SKILL.md
+++ b/.claude/skills/cnc-visual-alignment/SKILL.md
@@ -123,7 +123,8 @@ Never compute a machine coordinate from one frame and drive to it. With the MCP:
wrong — real parallax, not a bug. Derive on the surface you will servo on, record the
surface in `notes`, and before trusting any tracked shift, sanity-check it against the
Jacobian prediction (J·Δmachine ≈ Δpixel); a sharp divergence means wrong plane, wrong
- match, or both (automatic cross-check planned as fork issue #51).
+ match, or both - visual_servo also performs this cross-check automatically and warns
+ on divergence; tag calibrations with their `surface` so the warning can name it.
4. Iterate `visual_servo` — each call is one clamped step and returns the frame; two or
three passes converge. It auto-selects the nearest-Y calibration and warns when a step
moves Y (self-invalidating) — re-derive or re-select when it does.
@@ -164,8 +165,9 @@ object must not be assigned an identity from what it sits near ("beside jaw-shap
so chuck-related") — on this machine the gold cylinder at machine Y≈176–340 is the **tool
height checker**, misidentified twice by analogy before the operator corrected it. If the
operator has named a landmark, use that; if not, ask — never assert a guess as resolved
-fact. (A named-landmark registry is planned as fork issue #50; until it exists, landmark
-identities live in this paragraph and the operator's word.)
+fact. Landmark identities persist in the registry: call `get_stored_state` first in any
+session, and record new operator-stated identities with `set_landmark` - captures then
+carry `nearbyLandmarks` automatically.
## Datums: check the landmark is actually in frame
diff --git a/src/server/services/mcp/README.md b/src/server/services/mcp/README.md
index a8155e4367..2b659b2f2b 100644
--- a/src/server/services/mcp/README.md
+++ b/src/server/services/mcp/README.md
@@ -97,7 +97,7 @@ and tool activity — all timestamped. Events: `mcp:activity`, `mcp:gcode` (whit
- Fleet: machine named "Snapmaker" @ 192.168.1.173 = the A350 CNC; "F350" @ .130 = the
3D printer. Same Luban profile identifier — distinguish by NAME/address, never profile.
-## Tool surface (21)
+## Tool surface (24)
`get_connection_status` · `get_machine_profile` (kinematics, module offsets) ·
`get_position` (both frames, warnings on incoherent reporting) ·
@@ -106,9 +106,14 @@ and tool activity — all timestamped. Events: `mcp:activity`, `mcp:gcode` (whit
`z_targets` batch) · `home` · `goto_work_origin` · `move_and_capture` · `list_cameras` ·
`capture_frame` (position-stamped, `frameId`, `expectedToolRegion`) · `set_tool_region` ·
`track_feature` (NCC between cached frames — use instead of eyeballing pixels) ·
-`set_/get_/delete_camera_calibration` (Y/Z-keyed; optional `jacobian` REJECTS sign-flipped
-matrices, M·J ≈ −I) · `visual_servo` (one clamped step per call; trips when the error
-fails to shrink).
+`set_/get_/delete_camera_calibration` (Y/Z-keyed; optional `surface` depth-plane tag;
+optional `jacobian` REJECTS sign-flipped matrices, M·J ≈ −I) · `visual_servo` (one clamped
+step per call; trips when the error fails to shrink OR when the measured response diverges
+from the calibration prediction — the depth-plane parallax signature) ·
+`set_landmark` / `delete_landmark` (named scene features by machine extent; nearby ones are
+surfaced on every capture) · `get_stored_state` (one-call orientation: calibrations,
+landmarks, tool region, limits, camera config, connection — call this first in a fresh
+session).
## Development workflow (learned the hard way)
@@ -133,14 +138,10 @@ fails to shrink).
## Open threads
-- **#50 named-landmark registry** — `expectedToolRegion` for scene landmarks (tool height
- checker, rotary span), set once, surfaced near their coordinates.
-- **#51 depth-plane calibration tag + Jacobian-mismatch warning** — a calibration from one
- surface applied to another depth read ~4× wrong (parallax); tools should cross-check the
- early measured shift against the Jacobian prediction automatically.
-- **#52 expected-shift hint for `track_feature`** — second-peak gap is a soft signal on
- repetitive patterns; a Jacobian-derived expectation should break ties.
-- **#53 stored-state overview call** — one call returning calibrations + landmarks + tool
- region so a fresh session orients without motion.
+- **#50–#53 implemented** in `mcp/22-stored-state`: landmark registry (`set_landmark`,
+ surfaced as `nearbyLandmarks` on captures within 120 mm), calibration `surface` tag +
+ automatic Jacobian-prediction divergence warning in `visual_servo`, `expected_shift`
+ tie-breaking in `track_feature` (reports `chosen_by`/`raw_best`), and `get_stored_state`.
+ Seed the landmark registry with the tool height checker (machine Y≈176–340).
- **#38 startup socket.io gap** — properly belongs in the startup stack; hotfixed here.
- **#24 measurement systems, #25 collision watcher** — designed but not started.
diff --git a/src/server/services/mcp/calibration.ts b/src/server/services/mcp/calibration.ts
index 5e4c88fbc3..a9043f714c 100644
--- a/src/server/services/mcp/calibration.ts
+++ b/src/server/services/mcp/calibration.ts
@@ -22,6 +22,10 @@ export interface CalibrationEntry {
validAtY: number; // machine Y the frame was captured at
z: number; // machine Z the frame was captured at
matrix: [[number, number], [number, number]];
+ // Physical surface the calibration was derived on (#51): a matrix is only
+ // valid for features on the same depth plane - applying a bracket-derived
+ // matrix to the board surface read ~4x wrong on hardware (parallax).
+ surface: string | null;
notes: string | null;
createdAt: number;
}
diff --git a/src/server/services/mcp/index.ts b/src/server/services/mcp/index.ts
index ed00a97e9c..51bff14c0d 100644
--- a/src/server/services/mcp/index.ts
+++ b/src/server/services/mcp/index.ts
@@ -9,6 +9,7 @@ import { ToolRegistry } from './registry';
import { registerCalibrationTools } from './tools/calibration';
import { registerCameraTools } from './tools/camera';
import { registerGcodeTools } from './tools/gcode';
+import { registerLandmarkTools } from './tools/landmarks';
import { registerMachineTools } from './tools/machine';
import { registerStatusTools } from './tools/status';
@@ -105,6 +106,7 @@ export function startMcpService(socketServer?: McpBroadcaster): void {
registerGcodeTools(registry, () => `http://127.0.0.1:${port}`);
registerCameraTools(registry);
registerCalibrationTools(registry);
+ registerLandmarkTools(registry);
registeredToolCount = registry.list().length;
broadcaster = socketServer || null;
diff --git a/src/server/services/mcp/landmarks.ts b/src/server/services/mcp/landmarks.ts
new file mode 100644
index 0000000000..31e5ca0942
--- /dev/null
+++ b/src/server/services/mcp/landmarks.ts
@@ -0,0 +1,106 @@
+import crypto from 'crypto';
+import * as fs from 'fs-extra';
+import path from 'path';
+
+import DataStorage from '../../DataStorage';
+import logger from '../../lib/logger';
+
+const log = logger('service:mcp:landmarks');
+
+// Named scene landmarks (#50): expectedToolRegion solved "what is that blurry
+// thing" for the endmill; this does the same for fixed scene features (tool
+// height checker, rotary span, tool post). Set once from operator knowledge,
+// persisted, and surfaced on every capture taken near their machine
+// coordinates - so no agent re-derives an identity the operator already gave.
+
+export interface Landmark {
+ id: string;
+ name: string;
+ description: string;
+ // Machine-coordinate XY extent of the feature on/over the bed.
+ machine: { x0: number; y0: number; x1: number; y1: number };
+ notes: string | null;
+ createdAt: number;
+}
+
+interface LandmarkFile {
+ landmarks: Landmark[];
+}
+
+export class LandmarkStore {
+ private filePath: string | null = null;
+
+ private cache: LandmarkFile | null = null;
+
+ private file(): string {
+ if (!this.filePath) {
+ this.filePath = path.join(DataStorage.userDataDir, 'mcp-landmarks.json');
+ }
+ return this.filePath;
+ }
+
+ private load(): LandmarkFile {
+ if (this.cache) {
+ return this.cache;
+ }
+ try {
+ const raw = fs.readJsonSync(this.file());
+ this.cache = { landmarks: Array.isArray(raw?.landmarks) ? raw.landmarks : [] };
+ } catch (err) {
+ this.cache = { landmarks: [] };
+ }
+ return this.cache;
+ }
+
+ private save(): void {
+ try {
+ fs.writeJsonSync(this.file(), this.cache, { spaces: 2 });
+ } catch (err) {
+ log.error(`Failed to persist landmarks: ${err.message}`);
+ }
+ }
+
+ public add(landmark: Omit): Landmark {
+ const data = this.load();
+ // Same name replaces: landmarks are identities, not history.
+ data.landmarks = data.landmarks.filter((l) => l.name !== landmark.name);
+ const full: Landmark = {
+ ...landmark,
+ id: crypto.randomBytes(4).toString('hex'),
+ createdAt: Date.now(),
+ };
+ data.landmarks.push(full);
+ this.save();
+ log.info(`Landmark stored: ${full.name} (${full.id})`);
+ return full;
+ }
+
+ public list(): Landmark[] {
+ return this.load().landmarks;
+ }
+
+ public remove(idOrName: string): boolean {
+ const data = this.load();
+ const before = data.landmarks.length;
+ data.landmarks = data.landmarks.filter((l) => l.id !== idOrName && l.name !== idOrName);
+ if (data.landmarks.length !== before) {
+ this.save();
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Landmarks whose extent lies within `radius` mm of a machine XY point -
+ * what a toolhead camera at that position could plausibly see.
+ */
+ public near(x: number, y: number, radius: number): Landmark[] {
+ return this.load().landmarks.filter((l) => {
+ const dx = Math.max(l.machine.x0 - x, 0, x - l.machine.x1);
+ const dy = Math.max(l.machine.y0 - y, 0, y - l.machine.y1);
+ return Math.hypot(dx, dy) <= radius;
+ });
+ }
+}
+
+export const landmarkStore = new LandmarkStore();
diff --git a/src/server/services/mcp/tools/calibration.ts b/src/server/services/mcp/tools/calibration.ts
index 288df0f088..ece82ac488 100644
--- a/src/server/services/mcp/tools/calibration.ts
+++ b/src/server/services/mcp/tools/calibration.ts
@@ -32,7 +32,18 @@ export function registerCalibrationTools(registry: ToolRegistry): void {
// the error. Remember the last step per calibration+target and warn the
// moment the error fails to shrink - one wasted step instead of a manual
// catch several steps later.
- let lastServoStep: { calibrationId: string; tu: number; tv: number; magnitude: number; at: number } | null = null;
+ let lastServoStep: {
+ calibrationId: string;
+ tu: number;
+ tv: number;
+ magnitude: number;
+ du: number;
+ dv: number;
+ appliedDx: number;
+ appliedDy: number;
+ matrix: [[number, number], [number, number]];
+ at: number;
+ } | null = null;
registry.register({
name: 'set_camera_calibration',
description: 'Persist a pixel-to-machine calibration, keyed by the machine Y it was '
@@ -55,6 +66,12 @@ export function registerCalibrationTools(registry: ToolRegistry): void {
maxItems: 2,
},
notes: { type: 'string', description: 'Free-form provenance: grid used, residuals, tilt.' },
+ surface: {
+ type: 'string',
+ description: 'Physical surface the calibration was derived on (e.g. "board", '
+ + '"bracket"). A matrix is only valid on its own depth plane - cross-plane '
+ + 'use read ~4x wrong on hardware.',
+ },
jacobian: {
type: 'array',
description: 'Optional 2x2 forward Jacobian J (pixel shift per mm) you fitted. When '
@@ -68,7 +85,7 @@ export function registerCalibrationTools(registry: ToolRegistry): void {
required: ['valid_at_y', 'z', 'matrix'],
additionalProperties: false,
},
- handler: async (args: { valid_at_y?: number; z?: number; matrix?: unknown; notes?: string; jacobian?: unknown }) => {
+ handler: async (args: { valid_at_y?: number; z?: number; matrix?: unknown; notes?: string; surface?: string; jacobian?: unknown }) => {
const validAtY = Number(args.valid_at_y);
const z = Number(args.z);
if (!Number.isFinite(validAtY) || !Number.isFinite(z)) {
@@ -107,6 +124,7 @@ export function registerCalibrationTools(registry: ToolRegistry): void {
validAtY,
z,
matrix: args.matrix,
+ surface: args.surface ? String(args.surface) : null,
notes: args.notes ? String(args.notes) : null,
});
return { entry: describeEntry(entry), warnings };
@@ -255,16 +273,42 @@ export function registerCalibrationTools(registry: ToolRegistry): void {
const errorMagnitude = Math.hypot(du, dv);
const tu = Number(args.target_pixel?.u);
const tv = Number(args.target_pixel?.v);
- if (lastServoStep
+ const sameSeries = lastServoStep
&& lastServoStep.calibrationId === entry.id
&& Math.abs(lastServoStep.tu - tu) < 5 && Math.abs(lastServoStep.tv - tv) < 5
- && Date.now() - lastServoStep.at < 10 * 60 * 1000
- && errorMagnitude >= lastServoStep.magnitude * 0.95) {
+ && Date.now() - lastServoStep.at < 10 * 60 * 1000;
+ if (sameSeries && errorMagnitude >= lastServoStep.magnitude * 0.95) {
warnings.push('Pixel error did not shrink after the previous servo step with this calibration '
+ `(${lastServoStep.magnitude.toFixed(1)}px -> ${errorMagnitude.toFixed(1)}px). A sign-flipped or `
+ 'badly scaled matrix drives AWAY from the target: verify J.(M.e) reproduces +e before '
+ 'iterating further.');
}
+ // Depth-plane / wrong-match cross-check (#51): predict this step
+ // error from the last one using J = inverse(M) and the applied
+ // move; sharp divergence means the tracked feature sits on a
+ // different physical surface than the calibration (parallax read
+ // ~4x wrong on hardware) or the match latched onto the wrong spot.
+ if (sameSeries) {
+ const m = lastServoStep.matrix;
+ const det = m[0][0] * m[1][1] - m[0][1] * m[1][0];
+ if (Math.abs(det) > 1e-9) {
+ const j = [
+ [m[1][1] / det, -m[0][1] / det],
+ [-m[1][0] / det, m[0][0] / det],
+ ];
+ const predDu = lastServoStep.du - (j[0][0] * lastServoStep.appliedDx + j[0][1] * lastServoStep.appliedDy);
+ const predDv = lastServoStep.dv - (j[1][0] * lastServoStep.appliedDx + j[1][1] * lastServoStep.appliedDy);
+ const expectedChange = Math.hypot(lastServoStep.du - predDu, lastServoStep.dv - predDv);
+ const deviation = Math.hypot(du - predDu, dv - predDv);
+ if (expectedChange > 3 && deviation > Math.max(0.5 * expectedChange, 5)) {
+ warnings.push('Measured response diverges from the calibration prediction (expected error near '
+ + `(${predDu.toFixed(0)}, ${predDv.toFixed(0)})px, measured (${du.toFixed(0)}, ${dv.toFixed(0)})px). `
+ + 'Likely a depth-plane mismatch - the feature sits on a different surface than the calibration'
+ + `${entry.surface ? ` (derived on "${entry.surface}")` : ''} - or the tracked match is wrong. `
+ + 'Re-derive on the working surface before iterating.');
+ }
+ }
+ }
if (entryDistance !== null && entryDistance > DEFAULT_Y_TOLERANCE_MM) {
warnings.push(`Calibration ${entry.id} is ${entryDistance.toFixed(1)} mm from the current Y; scale may be off.`);
}
@@ -280,7 +324,18 @@ export function registerCalibrationTools(registry: ToolRegistry): void {
operator_confirmed_clearance: args.operator_confirmed_clearance,
}) as { mcpContent: object[] };
- lastServoStep = { calibrationId: entry.id, tu, tv, magnitude: errorMagnitude, at: Date.now() };
+ lastServoStep = {
+ calibrationId: entry.id,
+ tu,
+ tv,
+ magnitude: errorMagnitude,
+ du,
+ dv,
+ appliedDx: dx,
+ appliedDy: dy,
+ matrix: entry.matrix,
+ at: Date.now(),
+ };
// Splice servo metadata into the text part of the frame result.
const servoMeta = {
diff --git a/src/server/services/mcp/tools/camera.ts b/src/server/services/mcp/tools/camera.ts
index dca5c067cc..97e1746cce 100644
--- a/src/server/services/mcp/tools/camera.ts
+++ b/src/server/services/mcp/tools/camera.ts
@@ -6,6 +6,7 @@ import { connectionManager } from '../../machine/ConnectionManager';
import { CapturedFrame, captureFrame, getCachedFrame, getCachedFrameIds, listCameras } from '../camera';
import { decodeToGray, trackFeature } from '../tracking';
import { McpToolError, ToolRegistry } from '../registry';
+import { landmarkStore } from '../landmarks';
import { PositionSnapshot, getMachineSizeByIdentifier, getPositionSnapshot } from './machine';
// Motion policy (#23, refined): the direct move path is for the odd single
@@ -64,6 +65,28 @@ function expectedToolRegion(): object | null {
}
}
+function positionOrNull(): PositionSnapshot | null {
+ try {
+ return getPositionSnapshot();
+ } catch (err) {
+ return null;
+ }
+}
+
+/**
+ * Landmarks near the current machine XY (#50): identities the operator has
+ * stated once, surfaced on every capture so they are never re-guessed.
+ */
+function nearbyLandmarks(): object[] {
+ const position = positionOrNull();
+ const x = position?.machine.x;
+ const y = position?.machine.y;
+ if (x === null || x === undefined || y === null || y === undefined) {
+ return [];
+ }
+ return landmarkStore.near(x, y, 120);
+}
+
function frameContent(frame: CapturedFrame, meta: object): object {
return {
mcpContent: [
@@ -78,6 +101,7 @@ function frameContent(frame: CapturedFrame, meta: object): object {
device: frame.device,
capturedAt: frame.capturedAt,
expectedToolRegion: expectedToolRegion(),
+ nearbyLandmarks: nearbyLandmarks(),
},
}),
},
@@ -85,14 +109,6 @@ function frameContent(frame: CapturedFrame, meta: object): object {
};
}
-function positionOrNull(): PositionSnapshot | null {
- try {
- return getPositionSnapshot();
- } catch (err) {
- return null;
- }
-}
-
function assertSafeToMove(position: PositionSnapshot, operatorConfirmedClearance: boolean): void {
if (position.machineStatus !== 'idle') {
throw new McpToolError(`Machine is ${position.machineStatus || 'in an unknown state'}, not idle.`);
@@ -342,6 +358,14 @@ export function registerCameraTools(registry: ToolRegistry): void {
},
patch_size: { type: 'number', description: 'Odd patch edge in px, default 41, max 101.' },
search_radius: { type: 'number', description: 'Search half-window in px, default 120, max 250.' },
+ expected_shift: {
+ type: 'object',
+ properties: { du: { type: 'number' }, dv: { type: 'number' } },
+ required: ['du', 'dv'],
+ description: 'Optional predicted pixel shift (e.g. J x commanded move). Breaks '
+ + 'ties among near-best candidates on repetitive grids; the response says '
+ + 'when the expectation, not the raw score, chose the match.',
+ },
},
required: ['template_frame_id', 'search_frame_id', 'point'],
additionalProperties: false,
@@ -352,6 +376,7 @@ export function registerCameraTools(registry: ToolRegistry): void {
point?: { u?: number; v?: number };
patch_size?: number;
search_radius?: number;
+ expected_shift?: { du?: number; dv?: number };
}) => {
const templateJpg = getCachedFrame(String(args.template_frame_id || ''));
const searchJpg = getCachedFrame(String(args.search_frame_id || ''));
@@ -368,7 +393,17 @@ export function registerCameraTools(registry: ToolRegistry): void {
patch = Math.min(Math.max(patch % 2 === 0 ? patch + 1 : patch, 11), 101);
const radius = Math.min(Math.max(Math.round(Number(args.search_radius) || 120), 20), 250);
- const result = trackFeature(decodeToGray(templateJpg), decodeToGray(searchJpg), u, v, patch, radius);
+ let expected: { du: number; dv: number } | undefined;
+ if (args.expected_shift) {
+ const edu = Number(args.expected_shift.du);
+ const edv = Number(args.expected_shift.dv);
+ if (!Number.isFinite(edu) || !Number.isFinite(edv)) {
+ throw new McpToolError('expected_shift.du and .dv must be numbers.');
+ }
+ expected = { du: edu, dv: edv };
+ }
+
+ const result = trackFeature(decodeToGray(templateJpg), decodeToGray(searchJpg), u, v, patch, radius, expected);
return {
template_frame_id: args.template_frame_id,
search_frame_id: args.search_frame_id,
@@ -377,6 +412,9 @@ export function registerCameraTools(registry: ToolRegistry): void {
pixel_shift: { du: result.du, dv: result.dv },
score: result.score,
second_peak_gap: result.secondPeakGap,
+ chosen_by: result.chosenBy,
+ distance_from_expected: result.distanceFromExpected,
+ raw_best: result.rawBest,
patch_size: patch,
search_radius: radius,
warnings: result.warnings,
diff --git a/src/server/services/mcp/tools/landmarks.ts b/src/server/services/mcp/tools/landmarks.ts
new file mode 100644
index 0000000000..121eb4bcad
--- /dev/null
+++ b/src/server/services/mcp/tools/landmarks.ts
@@ -0,0 +1,118 @@
+/* eslint-disable camelcase */
+// MCP tool arguments are snake_case by convention.
+import config from '../../configstore';
+import { connectionManager } from '../../machine/ConnectionManager';
+import { calibrationStore } from '../calibration';
+import { Landmark, landmarkStore } from '../landmarks';
+import { McpToolError, ToolRegistry } from '../registry';
+
+// Named scene landmarks (#50) and the stored-state overview (#53): operator
+// knowledge captured once, surfaced every session, so no agent spends moves
+// re-deriving what the operator already said.
+
+function describeLandmark(landmark: Landmark): object {
+ return landmark;
+}
+
+export function registerLandmarkTools(registry: ToolRegistry): void {
+ registry.register({
+ name: 'set_landmark',
+ description: 'Persist a named scene landmark (tool height checker, rotary span, tool '
+ + 'post...) with its machine-coordinate XY extent and a description. Same name '
+ + 'replaces. Landmarks near the current position are surfaced with every capture, '
+ + 'so identities the operator has stated once are never re-guessed. Record identity '
+ + 'from OPERATOR knowledge, not visual analogy.',
+ inputSchema: {
+ type: 'object',
+ properties: {
+ name: { type: 'string', description: 'Short unique name, e.g. "tool-height-checker".' },
+ description: { type: 'string', description: 'What it is and how it looks on camera.' },
+ x0: { type: 'number', description: 'Machine-coordinate extent of the feature.' },
+ y0: { type: 'number' },
+ x1: { type: 'number' },
+ y1: { type: 'number' },
+ notes: { type: 'string' },
+ },
+ required: ['name', 'description', 'x0', 'y0', 'x1', 'y1'],
+ additionalProperties: false,
+ },
+ handler: async (args: {
+ name?: string;
+ description?: string;
+ x0?: number;
+ y0?: number;
+ x1?: number;
+ y1?: number;
+ notes?: string;
+ }) => {
+ const name = String(args.name || '').trim();
+ const description = String(args.description || '').trim();
+ if (!name || !description) {
+ throw new McpToolError('name and description are required.');
+ }
+ const box = [args.x0, args.y0, args.x1, args.y1].map(Number);
+ if (box.some((v) => !Number.isFinite(v)) || box[0] >= box[2] || box[1] >= box[3]) {
+ throw new McpToolError('Require finite machine coordinates with x0 < x1 and y0 < y1.');
+ }
+ const landmark = landmarkStore.add({
+ name,
+ description,
+ machine: { x0: box[0], y0: box[1], x1: box[2], y1: box[3] },
+ notes: args.notes ? String(args.notes) : null,
+ });
+ return { landmark: describeLandmark(landmark) };
+ },
+ });
+
+ registry.register({
+ name: 'delete_landmark',
+ description: 'Delete one stored landmark by id or name.',
+ inputSchema: {
+ type: 'object',
+ properties: { id: { type: 'string', description: 'Landmark id or name.' } },
+ required: ['id'],
+ additionalProperties: false,
+ },
+ handler: async (args: { id?: string }) => {
+ if (!landmarkStore.remove(String(args.id || ''))) {
+ throw new McpToolError('Unknown landmark id or name.');
+ }
+ return { removed: true };
+ },
+ });
+
+ registry.register({
+ name: 'get_stored_state',
+ description: 'Everything already known about this machine and bed in one read-only call, '
+ + 'so a fresh session orients WITHOUT moving anything: stored calibrations (with '
+ + 'surface tags), named landmarks, the expected tool region, motion limits, camera '
+ + 'config, and the live connection snapshot. Call this first.',
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
+ handler: async () => {
+ let toolRegion: object | null = null;
+ const rawRegion = config.get('mcpToolRegion');
+ if (rawRegion) {
+ try {
+ toolRegion = typeof rawRegion === 'string' ? JSON.parse(rawRegion) : (rawRegion as object);
+ } catch (err) {
+ toolRegion = null;
+ }
+ }
+ return {
+ connection: connectionManager.getConnectionStatus(),
+ calibrations: calibrationStore.list(),
+ landmarks: landmarkStore.list().map(describeLandmark),
+ expectedToolRegion: toolRegion,
+ limits: {
+ maxJogDistanceMm: Number(config.get('mcpMaxJogDistance')) || 100,
+ },
+ camera: {
+ url: config.get('mcpCameraUrl') || null,
+ device: config.get('mcpCameraDevice') || null,
+ lastGoodDevice: config.get('mcpCameraLastGood') || null,
+ },
+ installedModules: config.get('mcpInstalledModules') || [],
+ };
+ },
+ });
+}
diff --git a/src/server/services/mcp/tracking.ts b/src/server/services/mcp/tracking.ts
index 7724c997ed..a6abe6a367 100644
--- a/src/server/services/mcp/tracking.ts
+++ b/src/server/services/mcp/tracking.ts
@@ -51,6 +51,9 @@ export interface TrackResult {
dv: number;
score: number;
secondPeakGap: number;
+ chosenBy: 'score' | 'expectation';
+ distanceFromExpected: number | null;
+ rawBest: { u: number; v: number; score: number } | null;
warnings: string[];
}
@@ -67,7 +70,8 @@ export function trackFeature(
u: number,
v: number,
patchSize: number,
- searchRadius: number
+ searchRadius: number,
+ expected?: { du: number; dv: number }
): TrackResult {
const half = Math.floor(patchSize / 2);
const warnings: string[] = [];
@@ -147,6 +151,50 @@ export function trackFeature(
}
}
+ // Expectation tie-breaking (#52): on a repetitive grid several
+ // intersections score within noise of each other and the raw best can be
+ // the wrong one. Given an expected shift (from the running Jacobian),
+ // choose the near-best candidate closest to it instead - and always say
+ // which rule chose.
+ const rawBest = { u: bestU, v: bestV, score: best };
+ let chosenBy: 'score' | 'expectation' = 'score';
+ let distanceFromExpected: number | null = null;
+ if (expected && best > -1) {
+ const eu = u + expected.du;
+ const ev = v + expected.dv;
+ const threshold = best - 0.08;
+ let candU = bestU;
+ let candV = bestV;
+ let candScore = best;
+ let candDist = Math.hypot(bestU - eu, bestV - ev);
+ for (let sv = vMin; sv <= vMax; sv++) {
+ for (let su = uMin; su <= uMax; su++) {
+ const score = scores[sv - vMin][su - uMin];
+ if (score < threshold) {
+ continue;
+ }
+ const dist = Math.hypot(su - eu, sv - ev);
+ if (dist < candDist) {
+ candDist = dist;
+ candU = su;
+ candV = sv;
+ candScore = score;
+ }
+ }
+ }
+ distanceFromExpected = candDist;
+ if (candU !== bestU || candV !== bestV) {
+ chosenBy = 'expectation';
+ warnings.push(`Expectation override: the raw best match at (${bestU}, ${bestV}) `
+ + `(NCC ${best.toFixed(2)}) was replaced by a near-best candidate at (${candU}, ${candV}) `
+ + `(NCC ${candScore.toFixed(2)}) closer to the expected shift - typical on repetitive `
+ + 'grids. If the expectation itself is suspect, re-run without expected_shift.');
+ bestU = candU;
+ bestV = candV;
+ best = candScore;
+ }
+ }
+
if (best < 0.5) {
warnings.push(`Low match confidence (NCC ${best.toFixed(2)}); the feature may have left the frame `
+ 'or changed appearance (lighting, focus, Z change).');
@@ -163,6 +211,9 @@ export function trackFeature(
dv: bestV - v,
score: best,
secondPeakGap: gap,
+ chosenBy,
+ distanceFromExpected,
+ rawBest: chosenBy === 'expectation' ? rawBest : null,
warnings,
};
}
From d515da7e106d7e5886c813c5398ae9bcb996c469 Mon Sep 17 00:00:00 2001
From: tyeth
Date: Mon, 31 Aug 2026 15:36:14 +0100
Subject: [PATCH 034/135] Improvement: Show connecting state while the server
starts
On a cold boot the backend finishes starting seconds after the renderer
mounts, so entering the Workspace early hit the 'Server has stopped
working' dialog even though the socket heals itself (503 + Retry-After
handshake parking from the issue #38 hotfix). Until the socket has
connected once, show a self-dismissing 'Connecting to the server...'
notice instead; the stopped-working dialog with Reload now appears only
when an established connection drops, or when the first connection has
not arrived within a 30s grace period. Applies to both the Workspace
and the Ray laser workspace pages.
Co-Authored-By: Claude Fable 5
---
src/app/resources/i18n/en/resource.json | 2 ++
src/app/ui/pages/Workspace.tsx | 33 +++++++++++++++++++
.../laser-workspace-ray/RayLaserWorkspace.tsx | 33 +++++++++++++++++++
3 files changed, 68 insertions(+)
diff --git a/src/app/resources/i18n/en/resource.json b/src/app/resources/i18n/en/resource.json
index c6a983564d..dcbabc0bd4 100644
--- a/src/app/resources/i18n/en/resource.json
+++ b/src/app/resources/i18n/en/resource.json
@@ -2142,8 +2142,10 @@
"key-Workspace/Page-Filament Runout Recovery": "Filament Runout Recovery",
"key-Workspace/Page-Filament has run out. Please load new filament to continue printing.": "Filament has run out. Please load new filament to continue printing.",
"key-Workspace/Page-Loaded G-code successfully.": "Loaded G-code successfully.",
+ "key-Workspace/Page-Connecting to the server...": "Connecting to the server...",
"key-Workspace/Page-Loading G-code...{{progress}}%": "Loading G-code...{{progress}}%",
"key-Workspace/Page-Loading...": "Loading...",
+ "key-Workspace/Page-The server is still starting up. This page will connect automatically.": "The server is still starting up. This page will connect automatically.",
"key-Workspace/Page-One or both of the enclosure panels is/are opened. Please close the panel(s) to continue printing.": "One or both of the enclosure panels is/are opened. Please close the panel(s) to continue printing.",
"key-Workspace/Page-Only G-code files are supported.": "Only G-code files are supported.",
"key-Workspace/Page-Please wait one second after you close the panel(s) to continue printing.": "Please wait one second after you close the panel(s) to continue printing.",
diff --git a/src/app/ui/pages/Workspace.tsx b/src/app/ui/pages/Workspace.tsx
index 9664cd3e0f..eafa65ff64 100644
--- a/src/app/ui/pages/Workspace.tsx
+++ b/src/app/ui/pages/Workspace.tsx
@@ -125,6 +125,12 @@ const Workspace: React.FC = ({ isPopup, onClose, style, classNam
const [isDraggingWidget, setIsDraggingWidget] = useState(false);
const [connected, setConnected] = useState(controller.connected);
+ // The backend finishes starting well after the renderer mounts on a cold
+ // boot; until the socket has connected once, the gap is startup, not a
+ // crash, and the socket retries by itself. Only claim the server stopped
+ // if we had a connection and lost it, or startup grace runs out.
+ const everConnectedRef = useRef(controller.connected);
+ const [startupGraceExpired, setStartupGraceExpired] = useState(false);
const [leftItems, setLeftItems] = useState([
{
title: i18n._('key-Workspace/Page-Back'),
@@ -143,6 +149,9 @@ const Workspace: React.FC = ({ isPopup, onClose, style, classNam
const controllerEvents = {
'connect': () => {
+ if (controller.connected) {
+ everConnectedRef.current = true;
+ }
setConnected(controller.connected);
},
'disconnect': () => {
@@ -241,7 +250,12 @@ const Workspace: React.FC = ({ isPopup, onClose, style, classNam
pathname: '/workspace'
});
+ const graceTimer = setTimeout(() => {
+ setStartupGraceExpired(true);
+ }, 30000);
+
return () => {
+ clearTimeout(graceTimer);
removeControllerEvents();
};
}, []);
@@ -249,6 +263,25 @@ const Workspace: React.FC = ({ isPopup, onClose, style, classNam
function renderModalView(_connected) {
if (_connected) {
return null;
+ } else if (!everConnectedRef.current && !startupGraceExpired) {
+ // Cold-boot startup gap: the socket retries on its own and this
+ // modal dismisses itself on the first successful connection.
+ return (
+
+
+
+
+
+
{i18n._('key-Workspace/Page-Connecting to the server...')}
+
{i18n._('key-Workspace/Page-The server is still starting up. This page will connect automatically.')}
+
+
+
+
+ );
} else {
return (
= ({ isPopup, onClose,
const [isDraggingWidget, setIsDraggingWidget] = useState(false);
const [connected, setConnected] = useState(controller.connected);
+ // The backend finishes starting well after the renderer mounts on a cold
+ // boot; until the socket has connected once, the gap is startup, not a
+ // crash, and the socket retries by itself. Only claim the server stopped
+ // if we had a connection and lost it, or startup grace runs out.
+ const everConnectedRef = useRef(controller.connected);
+ const [startupGraceExpired, setStartupGraceExpired] = useState(false);
const [showMachineSettingsModal, setShowMachineSettingsModal] = useState(false);
const [showFirmwareUpgradeModal, setShowFirmwareUpgradeModal] = useState(false);
@@ -180,6 +186,9 @@ const RayLaserWorkspace: React.FC = ({ isPopup, onClose,
const controllerEvents = {
'connect': () => {
+ if (controller.connected) {
+ everConnectedRef.current = true;
+ }
setConnected(controller.connected);
},
'disconnect': () => {
@@ -280,7 +289,12 @@ const RayLaserWorkspace: React.FC = ({ isPopup, onClose,
actions.addReturnButton();
}
+ const graceTimer = setTimeout(() => {
+ setStartupGraceExpired(true);
+ }, 30000);
+
return () => {
+ clearTimeout(graceTimer);
removeControllerEvents();
};
}, []);
@@ -288,6 +302,25 @@ const RayLaserWorkspace: React.FC = ({ isPopup, onClose,
function renderModalView(_connected) {
if (_connected) {
return null;
+ } else if (!everConnectedRef.current && !startupGraceExpired) {
+ // Cold-boot startup gap: the socket retries on its own and this
+ // modal dismisses itself on the first successful connection.
+ return (
+
+
+
+
+
+
{i18n._('key-Workspace/Page-Connecting to the server...')}
+
{i18n._('key-Workspace/Page-The server is still starting up. This page will connect automatically.')}
+
+
+
+
+ );
} else {
return (
Date: Mon, 31 Aug 2026 15:50:08 +0100
Subject: [PATCH 035/135] Improvement: Bundle server dependencies into the
production build
Cold starts (first launch after a rebuild or reboot) took 10-15s to
reach services-ready because the server bundle externalized all of
node_modules and require() crawled thousands of small files, each
paying first-open antivirus scanning. Bundle the pure-JS dependencies
into the server bundle instead; only packages that cannot live inside
a bundle stay external: native addons (serialport, font-scanner,
lzma-native), packages that spawn or load sibling binaries/assets from
their own package directory (snapmaker-lunar, snapmaker-luban-engine,
opencv-wasm, errorhandler, formidable, socket.io serveClient), and
template engines resolved by name at runtime (consolidate, hogan.js).
Measured on the same machine: services-ready 9766ms for the first
launch of the unbundled build vs 736ms launching the bundled build
from a fresh folder of never-scanned files. Verified working: HTTP
API, JWT signin, multipart upload, socket.io handshake, MCP tool
surface, workspace page.
Co-Authored-By: Claude Fable 5
---
webpack.config.server.production.js | 40 +++++++++++++++++++++--------
1 file changed, 30 insertions(+), 10 deletions(-)
diff --git a/webpack.config.server.production.js b/webpack.config.server.production.js
index 2ff27ae43c..384fe08d13 100644
--- a/webpack.config.server.production.js
+++ b/webpack.config.server.production.js
@@ -1,5 +1,4 @@
const crypto = require('crypto');
-const fs = require('fs');
const path = require('path');
const webpack = require('webpack');
const TerserPlugin = require('terser-webpack-plugin');
@@ -8,15 +7,36 @@ const pkg = require('./package.json');
const NODE_MODULES = path.resolve(__dirname, 'node_modules');
-// http://jlongster.com/Backend-Apps-with-Webpack--Part-I
-const externals = {};
-fs.readdirSync(NODE_MODULES)
- .filter((x) => {
- return ['.bin'].indexOf(x) === -1;
- })
- .forEach((mod) => {
- externals[mod] = `commonjs ${mod}`;
- });
+// Bundle dependencies into the server bundle instead of externalizing all of
+// node_modules: a cold start used to crawl thousands of small files (each
+// scanned by antivirus on first open, ~10s+ after a rebuild or reboot before
+// the backend answered), whereas one bundle is a single read. Only packages
+// that cannot live inside a bundle stay external: native addons, packages
+// that spawn or load sibling binaries/assets out of their own package
+// directory, and template engines resolved by name at runtime.
+const KEEP_EXTERNAL = [
+ 'serialport', // native (@serialport/bindings-cpp)
+ 'font-scanner', // native
+ 'lzma-native', // native
+ '@snapmaker/snapmaker-lunar', // spawns LunarTPP binaries from its package dir
+ 'snapmaker-luban-engine', // spawns CuraEngine binaries from its package dir
+ 'opencv-wasm', // loads .wasm from its package dir
+ 'consolidate', // requires template engines by name at runtime
+ 'hogan.js', // loaded dynamically by consolidate
+ 'formidable', // constructor requires its plugin files from its package dir
+ 'errorhandler', // reads its stylesheet from its package dir
+ 'socket.io', // serveClient reads the client bundle from its package dir
+];
+const externals = [
+ (context, request, callback) => {
+ const external = request.startsWith('@serialport/')
+ || KEEP_EXTERNAL.some((mod) => request === mod || request.startsWith(`${mod}/`));
+ if (external) {
+ return callback(null, `commonjs ${request}`);
+ }
+ return callback();
+ },
+];
// Use publicPath for production
// const payload = pkg.version;
From 32953fe0af270ca5f1ee29a1a40424151ce9ff2a Mon Sep 17 00:00:00 2001
From: tyeth
Date: Mon, 31 Aug 2026 16:10:59 +0100
Subject: [PATCH 036/135] Improvement: Make dispatch builds work on forks
without signing keys
The 'Build on PR' workflow only worked in the upstream repo: checkout
hard-required the SACP_TOKEN secret, the macOS package step crashed in
notarize.js whenever CI was set but the Apple credentials were not,
and every job ended by uploading nightlies into a Snapmaker/Luban
release that forks cannot write to.
- checkout token falls back to github.token when SACP_TOKEN is absent
(the submodules are public repos, checkout rewrites their ssh URLs)
- notarize.js skips notarization when APPLEID/APPLEIDPASS/TEAMID are
not configured instead of throwing; electron-builder already skips
code signing when CSC_LINK is absent, so macOS packages build unsigned
- the deploy-nightly steps are gated on github.repository ==
'Snapmaker/Luban'; artifact uploads still run everywhere
- workflow_dispatch gains a platforms input (windows/macos/linux/all,
default all) so a manual run can build a single platform
- concurrent dispatches on the same ref cancel the superseded run
Co-Authored-By: Claude Fable 5
---
.github/workflows/build-on-pull-request.yml | 33 +++++++++++++++++++--
build/notarize.js | 7 +++++
2 files changed, 37 insertions(+), 3 deletions(-)
diff --git a/.github/workflows/build-on-pull-request.yml b/.github/workflows/build-on-pull-request.yml
index 46caf1c9a2..6fc3462170 100644
--- a/.github/workflows/build-on-pull-request.yml
+++ b/.github/workflows/build-on-pull-request.yml
@@ -2,13 +2,30 @@ name: Build on PR
on:
workflow_dispatch:
+ inputs:
+ platforms:
+ description: 'Platforms to build'
+ type: choice
+ default: all
+ options:
+ - windows
+ - macos
+ - linux
+ - all
push:
branches:
- main
- release/*
+
+# Repeat dispatches on the same ref supersede the previous run.
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
jobs:
build-windows:
name: Build Windows Packages
+ if: github.event_name != 'workflow_dispatch' || inputs.platforms == 'windows' || inputs.platforms == 'all'
runs-on: windows-2022
steps:
- name: Prepare VC++ Runtime
@@ -17,7 +34,7 @@ jobs:
- name: Checkout Code
uses: actions/checkout@v4
with:
- token: ${{ secrets.SACP_TOKEN }}
+ token: ${{ secrets.SACP_TOKEN || github.token }}
submodules: 'true'
- name: Checkout submodules
@@ -68,6 +85,7 @@ jobs:
path: ${{ github.workspace }}/output/${{ env.SM_RELEASE }}-win-x64.exe
- name: Deploy Windows release
+ if: github.repository == 'Snapmaker/Luban'
uses: WebFreak001/deploy-nightly@v3.2.0
with:
overwrite: true
@@ -81,6 +99,7 @@ jobs:
build-macos:
name: Build macOS Packages
+ if: github.event_name != 'workflow_dispatch' || inputs.platforms == 'macos' || inputs.platforms == 'all'
# macos-11.7
runs-on: macos-latest
@@ -89,7 +108,7 @@ jobs:
- name: Checkout Code
uses: actions/checkout@v4
with:
- token: ${{ secrets.SACP_TOKEN }}
+ token: ${{ secrets.SACP_TOKEN || github.token }}
submodules: 'true'
- name: Checkout submodules
@@ -147,6 +166,7 @@ jobs:
path: ${{ github.workspace }}/output/${{ env.SM_RELEASE }}-mac-arm64.dmg
- name: Deploy mac-arm64-dmg nightly
+ if: github.repository == 'Snapmaker/Luban'
uses: WebFreak001/deploy-nightly@v3.2.0
with:
upload_url: https://uploads.github.com/repos/Snapmaker/Luban/releases/190586441/assets{?name,label}
@@ -163,6 +183,7 @@ jobs:
name: ${{ env.SM_RELEASE }}-mac-arm64.zip
path: ${{ github.workspace }}/output/${{ env.SM_RELEASE }}-mac-arm64.zip
- name: Deploy mac-arm64-zip nightly
+ if: github.repository == 'Snapmaker/Luban'
uses: WebFreak001/deploy-nightly@v3.2.0
with:
upload_url: https://uploads.github.com/repos/Snapmaker/Luban/releases/190586441/assets{?name,label}
@@ -181,6 +202,7 @@ jobs:
name: ${{ env.SM_RELEASE }}-mac-x64.dmg
path: ${{ github.workspace }}/output/${{ env.SM_RELEASE }}-mac-x64.dmg
- name: Deploy mac-x64-dmg nightly
+ if: github.repository == 'Snapmaker/Luban'
uses: WebFreak001/deploy-nightly@v3.2.0
with:
upload_url: https://uploads.github.com/repos/Snapmaker/Luban/releases/190586441/assets{?name,label}
@@ -197,6 +219,7 @@ jobs:
name: ${{ env.SM_RELEASE }}-mac-x64.zip
path: ${{ github.workspace }}/output/${{ env.SM_RELEASE }}-mac-x64.zip
- name: Deploy mac-x64-dmg nightly
+ if: github.repository == 'Snapmaker/Luban'
uses: WebFreak001/deploy-nightly@v3.2.0
with:
upload_url: https://uploads.github.com/repos/Snapmaker/Luban/releases/190586441/assets{?name,label}
@@ -209,6 +232,7 @@ jobs:
build-linux:
name: Build Linux Packages
+ if: github.event_name != 'workflow_dispatch' || inputs.platforms == 'linux' || inputs.platforms == 'all'
# Ubuntu 20.04: ubuntu-latest or ubuntu-20.04
runs-on: ubuntu-latest
@@ -216,7 +240,7 @@ jobs:
- name: Checkout Code
uses: actions/checkout@v4
with:
- token: ${{ secrets.SACP_TOKEN }}
+ token: ${{ secrets.SACP_TOKEN || github.token }}
submodules: 'true'
- name: Checkout submodules
@@ -271,6 +295,7 @@ jobs:
name: ${{ env.SM_RELEASE }}-linux-amd64.deb
path: ${{ github.workspace }}/output/${{ env.SM_RELEASE }}-linux-amd64.deb
- name: Deploy Linux-deb nightly
+ if: github.repository == 'Snapmaker/Luban'
uses: WebFreak001/deploy-nightly@v3.2.0
with:
upload_url: https://uploads.github.com/repos/Snapmaker/Luban/releases/190586441/assets{?name,label}
@@ -288,6 +313,7 @@ jobs:
name: ${{ env.SM_RELEASE }}-linux.x86_64.rpm
path: ${{ github.workspace }}/output/${{ env.SM_RELEASE }}-linux.x86_64.rpm
- name: Deploy Linux-rpm nightly
+ if: github.repository == 'Snapmaker/Luban'
uses: WebFreak001/deploy-nightly@v3.2.0
with:
upload_url: https://uploads.github.com/repos/Snapmaker/Luban/releases/190586441/assets{?name,label}
@@ -306,6 +332,7 @@ jobs:
path: ${{ github.workspace }}/output/${{ env.SM_RELEASE }}-linux-x64.tar.gz
- name: Deploy Linux-tar nightly
+ if: github.repository == 'Snapmaker/Luban'
uses: WebFreak001/deploy-nightly@v3.2.0
with:
upload_url: https://uploads.github.com/repos/Snapmaker/Luban/releases/190586441/assets{?name,label}
diff --git a/build/notarize.js b/build/notarize.js
index 82cd1c9a55..6a8c099122 100644
--- a/build/notarize.js
+++ b/build/notarize.js
@@ -11,6 +11,13 @@ module.exports = async function notarizing(context) {
return;
}
+ // Forks build unsigned: skip notarization when the Apple credentials are
+ // not configured instead of crashing the whole package step.
+ if (!process.env.APPLEID || !process.env.APPLEIDPASS || !process.env.TEAMID) {
+ console.log('Skipping notarization: Apple signing credentials are not configured.');
+ return;
+ }
+
// Notarize only when running on Travis-CI and has a tag.
console.log('Notarizing application...');
From 5ff1f070cc88fed783df0bc9a4b73e476bcc41b5 Mon Sep 17 00:00:00 2001
From: tyeth
Date: Mon, 31 Aug 2026 16:32:44 +0100
Subject: [PATCH 037/135] Improvement: Prune packaged runtime dependencies to
the unbundled set
Since the server bundle now compiles its pure-JS dependencies in,
the packaged app only needs to install what still loads from
node_modules at runtime: the unbundled main process files and the
server bundle's keep-external list. pkgsync.js (which regenerates
src/package.json on every npm install) previously scanned all server
and shared sources, shipping every import even when it lives inside
the bundle.
Scan only src/*.{ts,js} and src/electron-app for main-process imports,
add electron-updater and node-fetch explicitly (lazy require()s inside
functions in main.js, invisible to the top-level import scan), and add
the server externals mirroring KEEP_EXTERNAL in
webpack.config.server.production.js. formidable and encoding are
pinned outright: their former parents (superagent, node-fetch) are
bundled now, so they are no longer installable as transitive deps.
A clean 'npm install --omit=dev' of the manifest drops from 20726
files / 264MB (~404 packages) to 13392 files / 168MB, and every bare
require() in the built dist artifacts was verified to resolve against
the pruned install.
Co-Authored-By: Claude Fable 5
---
build/pkgsync.js | 33 +++++++++++++++++++++++-----
src/package.json | 57 +++---------------------------------------------
2 files changed, 31 insertions(+), 59 deletions(-)
diff --git a/build/pkgsync.js b/build/pkgsync.js
index cadd361b17..48bf1f212a 100755
--- a/build/pkgsync.js
+++ b/build/pkgsync.js
@@ -9,18 +9,36 @@ const findImports = require('find-imports');
const pkg = require('../package.json');
const pkgApp = require('../src/package.json');
+// Only the main process ships unbundled (src/main.js, src/electron-app/*,
+// src/server-cli.js...), so only its imports need installing into the
+// packaged app. The server's dependencies are compiled into its webpack
+// bundle, except the keep-external list below — mirror of KEEP_EXTERNAL in
+// webpack.config.server.production.js.
const files = [
'src/*.{ts,js}',
- 'src/server/**/*.{ts,js,jsx}',
- 'src/shared/**/*.{ts,js,jsx}',
- 'packages/**/*.ts',
+ 'src/electron-app/**/*.{ts,js}',
+];
+const serverExternals = [
+ 'serialport',
+ 'font-scanner',
+ '@snapmaker/snapmaker-lunar',
+ 'snapmaker-luban-engine',
+ 'opencv-wasm',
+ 'consolidate',
+ 'hogan.js',
+ 'errorhandler',
+ 'socket.io',
];
const deps = [
'@babel/runtime', // 'babel-runtime' is required for electron app
'debug', // 'debug' is required for electron app
'@electron/remote', // '@electron/remote/main' is required
- '@sentry/electron'
-].concat(findImports(files, { flatten: true })).sort();
+ '@sentry/electron',
+ // Lazy require()s inside functions in src/main.js, invisible to
+ // findImports' top-level import scan:
+ 'electron-updater',
+ 'node-fetch',
+].concat(serverExternals).concat(findImports(files, { flatten: true })).sort();
pkgApp.name = pkg.name;
pkgApp.version = pkg.version;
@@ -31,6 +49,11 @@ pkgApp.repository = pkg.repository;
// Copy only Node.js dependencies to application package.json
pkgApp.dependencies = _.pick(pkg.dependencies, deps);
+// Runtime externals of the server bundle whose former parents (superagent,
+// node-fetch) are now bundled, so they are not root dependencies: pin them
+// to the versions the parents resolve today.
+pkgApp.dependencies.formidable = '2.1.2';
+pkgApp.dependencies.encoding = '0.1.13';
pkgApp.config = pkg.config;
const target = path.resolve(__dirname, '../src/package.json');
diff --git a/src/package.json b/src/package.json
index abf9d420f3..54b07fa1e2 100755
--- a/src/package.json
+++ b/src/package.json
@@ -20,77 +20,26 @@
"@electron/remote": "2.0.8",
"@sentry/electron": "^5.6.0",
"@snapmaker/snapmaker-lunar": "^1.4.9",
- "@snapmaker/snapmaker-sacp-sdk": "0.1.1",
- "@xmldom/xmldom": "^0.8.2",
- "bcrypt-nodejs": "0.0.3",
- "body-parser": "1.20.1",
- "chalk": "2.4.2",
"commander": "7.2.0",
- "compression": "1.7.4",
- "connect-multiparty": "2.2.0",
- "connect-restreamer": "1.0.3",
"consolidate": "0.15.1",
- "cookie-parser": "1.4.6",
"core-js": "3.6.5",
"debug": "3.1.0",
- "earcut": "2.2.3",
"electron-store": "8.1.0",
"electron-updater": "4.3.9",
- "ensure-array": "1.0.0",
"errorhandler": "1.5.1",
- "esprima-next": "5.7.0",
- "express": "4.18.2",
- "express-jwt": "6.1.2",
- "express-session": "1.15.6",
"font-scanner": "0.2.1",
"fs-extra": "11.1.0",
"hogan.js": "3.0.2",
- "i18next": "^22.5.1",
- "i18next-http-middleware": "3.3.2",
- "i18next-node-fs-backend": "2.1.3",
"is-electron": "2.1.0",
- "jimp": "0.16.2",
- "jpeg-autorotate": "7.1.1",
- "jpeg-js": "0.4.4",
- "jsonwebtoken": "~9.0.0",
- "jszip": "^3.10.0",
- "linebyline": "^1.3.0",
"lodash": "4.17.21",
"loglevel": "1.8.1",
- "method-override": "3.0.0",
- "minimatch": "3.0.4",
- "morgan": "1.9.1",
- "multicast-dns": "7.2.5",
- "mv": "2.1.1",
"node-fetch": "2.6.7",
- "node-schedule": "2.1.0",
- "node-wifi": "2.0.16",
"opencv-wasm": "^4.3.0-10",
- "opentype.js": "0.9.0",
- "parse-json": "2.2.0",
- "perspective-transform": "1.1.3",
- "potrace": "2.1.8",
- "range_check": "1.4.0",
- "semver": "5.6.0",
"serialport": "10.5.0",
- "serve-favicon": "2.5.0",
- "session-file-store": "1.1.2",
- "shortid": "2.2.16",
"snapmaker-luban-engine": "^0.9.1",
"socket.io": "4.5.3",
- "socketio-jwt": "4.5.1",
- "spawn-default-shell": "2.0.0",
- "static-eval": "2.0.5",
- "superagent": "8.1.2",
- "superagent-use": "0.1.0",
- "svgpath": "^2.5.0",
- "tar": "6.1.11",
- "three": "0.124.0",
- "uuid": "8.3.2",
- "watch": "1.0.2",
- "winston": "3.0.1",
- "workerpool": "6.1.5",
- "xml2js": "0.6.2"
+ "formidable": "2.1.2",
+ "encoding": "0.1.13"
},
"config": {
"commitizen": {
@@ -99,4 +48,4 @@
"CLIENT_PORT": 8080,
"SERVER_PORT": 8000
}
-}
\ No newline at end of file
+}
From 9ec0a39f31ca80f30f935e8e47466d5ed4e5b7e5 Mon Sep 17 00:00:00 2001
From: tyeth
Date: Mon, 31 Aug 2026 21:35:19 +0100
Subject: [PATCH 038/135] Feature: Probe sensor feed over MQTT with overtravel
tripwire
External probe sensors (tool height setter, overtravel switch, CNC touch
probe) report over a message feed, not the machine controller. This adds
the transport: a hand-rolled minimal MQTT 3.1.1 client over net/tls (zero
new dependencies, same rationale as the MCP transport itself - Node 16),
verified round-trip against public brokers on both plain TCP and TLS, and
a ProbeFeedService that resolves configuration environment-first
(LUBAN_MCP_MQTT_*) with the configstore as fallback, editable in a new
Settings -> MCP Server section (password never echoed back).
Feed channels: toolsetter, overtravel, probe. Topics accept an Adafruit
IO feed key ({user}/feeds/{key}) or a full topic path; on Adafruit IO
hosts the last value is primed via /get. The client id defaults to
username + MAC bytes. Per-channel polarity: the Inverted setting lists
channels whose sensor idles HIGH and reads low on contact (normally-open
probe circuits - the operator's CNC touch probe), flipping the triggered
interpretation; an empty payload is never contact.
The overtravel channel is a tripwire: any triggered reading stops the
running job, force-closes the machine connection, latches an alarm that
blocks every motion tool, and reports to the operator. The latch clears
only via clear_overtravel_alarm on the operator's explicit word (refused
while the feed still reads triggered) or a restart.
New tools (28 total): get_probe_feed_status, connect_probe_feed,
disconnect_probe_feed, clear_overtravel_alarm; get_stored_state now
carries the feed status.
Co-Authored-By: Claude Fable 5
---
src/app/resources/i18n/en/resource.json | 14 +
.../settings-modal/McpServer/index.tsx | 118 ++++-
src/app/ui/widgets/Console/Console.jsx | 12 +-
src/server/services/api/api-mcp.js | 85 ++-
src/server/services/mcp/README.md | 29 +-
src/server/services/mcp/index.ts | 12 +
src/server/services/mcp/mqtt.ts | 284 ++++++++++
src/server/services/mcp/probeFeed.ts | 497 ++++++++++++++++++
src/server/services/mcp/tools/camera.ts | 3 +
src/server/services/mcp/tools/gcode.ts | 3 +
src/server/services/mcp/tools/landmarks.ts | 2 +
src/server/services/mcp/tools/probe.ts | 91 ++++
12 files changed, 1138 insertions(+), 12 deletions(-)
create mode 100644 src/server/services/mcp/mqtt.ts
create mode 100644 src/server/services/mcp/probeFeed.ts
create mode 100644 src/server/services/mcp/tools/probe.ts
diff --git a/src/app/resources/i18n/en/resource.json b/src/app/resources/i18n/en/resource.json
index dcbabc0bd4..7d93a77621 100644
--- a/src/app/resources/i18n/en/resource.json
+++ b/src/app/resources/i18n/en/resource.json
@@ -1099,14 +1099,28 @@
"key-App/Settings/MachineSettings-Snapmaker 2.0 Bracing Kit": "Snapmaker 2.0 Bracing Kit",
"key-App/Settings/MachineSettings-Snapmaker 2.0 Quick Swap Kit": "Snapmaker 2.0 Quick Swap Kit",
"key-App/Settings/MachineSettings-Standard CNC": "Standard",
+ "key-App/Settings/McpServer-(saved - leave blank to keep)": "(saved - leave blank to keep)",
+ "key-App/Settings/McpServer-CNC probe feed": "CNC probe feed",
"key-App/Settings/McpServer-Enable MCP server (applies after restart)": "Enable MCP server (applies after restart)",
+ "key-App/Settings/McpServer-External tool setter, overtravel and touch probe sensors report over this feed. Feed fields accept an Adafruit IO feed key or a full topic path. Applies at the next feed connection.": "External tool setter, overtravel and touch probe sensors report over this feed. Feed fields accept an Adafruit IO feed key or a full topic path. Applies at the next feed connection.",
+ "key-App/Settings/McpServer-Inverted": "Inverted",
"key-App/Settings/McpServer-Local agents connect at": "Local agents connect at",
+ "key-App/Settings/McpServer-Normally-open sensor: idles at 1, reads 0 on contact": "Normally-open sensor: idles at 1, reads 0 on contact",
"key-App/Settings/McpServer-Loopback only; never reachable from the network": "Loopback only; never reachable from the network.",
"key-App/Settings/McpServer-MCP Server": "MCP Server",
+ "key-App/Settings/McpServer-MQTT client id": "MQTT client id",
+ "key-App/Settings/McpServer-MQTT host": "MQTT host",
+ "key-App/Settings/McpServer-MQTT password / key": "MQTT password / key",
+ "key-App/Settings/McpServer-MQTT port": "MQTT port",
+ "key-App/Settings/McpServer-MQTT username": "MQTT username",
"key-App/Settings/McpServer-Not running this session": "Not running this session",
"key-App/Settings/McpServer-Overridden by LUBAN_MCP_PORT": "overridden by LUBAN_MCP_PORT environment variable",
+ "key-App/Settings/McpServer-Overridden by environment variables:": "Overridden by environment variables:",
+ "key-App/Settings/McpServer-Overtravel feed": "Overtravel feed",
+ "key-App/Settings/McpServer-Probe sensor feed (MQTT)": "Probe sensor feed (MQTT)",
"key-App/Settings/McpServer-Running this session at": "Running this session at",
"key-App/Settings/McpServer-Status unknown": "Status unknown",
+ "key-App/Settings/McpServer-Tool setter feed": "Tool setter feed",
"key-App/Settings/Model Examination": "Model Examination",
"key-App/Settings/Pop up a reminder when importing deficient model(s)": "Pop up a reminder when importing deficient model(s)",
"key-App/Settings/Preferences-Cancel": "Cancel",
diff --git a/src/app/ui/pages/global-modals/settings-modal/McpServer/index.tsx b/src/app/ui/pages/global-modals/settings-modal/McpServer/index.tsx
index 55d4142607..5cc3722a55 100644
--- a/src/app/ui/pages/global-modals/settings-modal/McpServer/index.tsx
+++ b/src/app/ui/pages/global-modals/settings-modal/McpServer/index.tsx
@@ -7,6 +7,24 @@ import UniApi from '../../../../../lib/uni-api';
import SvgIcon from '../../../../components/SvgIcon';
import styles from '../form.styl';
+interface McpMqttSettings {
+ values: {
+ host: string;
+ port: string;
+ user: string;
+ clientId: string;
+ feedToolsetter: string;
+ feedOvertravel: string;
+ feedProbe: string;
+ inverted: string;
+ };
+ passSet: boolean;
+ envOverrides: string[];
+ configured: boolean;
+ missing: string[];
+ defaultClientId: string;
+}
+
interface McpStatus {
running: boolean;
port: number | null;
@@ -16,17 +34,36 @@ interface McpStatus {
port: number;
source: 'env' | 'config';
};
+ mqtt: McpMqttSettings;
}
+const MQTT_FIELDS: Array<{ name: keyof McpMqttSettings['values']; labelKey: string; placeholder?: string; channel?: string }> = [
+ { name: 'host', labelKey: 'key-App/Settings/McpServer-MQTT host', placeholder: 'io.adafruit.com' },
+ { name: 'port', labelKey: 'key-App/Settings/McpServer-MQTT port', placeholder: '8883 (TLS)' },
+ { name: 'user', labelKey: 'key-App/Settings/McpServer-MQTT username' },
+ { name: 'clientId', labelKey: 'key-App/Settings/McpServer-MQTT client id' },
+ { name: 'feedToolsetter', labelKey: 'key-App/Settings/McpServer-Tool setter feed', channel: 'toolsetter' },
+ { name: 'feedOvertravel', labelKey: 'key-App/Settings/McpServer-Overtravel feed', channel: 'overtravel' },
+ { name: 'feedProbe', labelKey: 'key-App/Settings/McpServer-CNC probe feed', channel: 'probe' },
+];
+
+const CHANNELS = ['toolsetter', 'overtravel', 'probe'];
+
/**
* MCP server settings: enabled + port, persisted in the server configstore.
* Changes apply at the next application start; the label reports what this
- * run is actually doing.
+ * run is actually doing. The probe feed (MQTT) section configures the
+ * external tool-setter / overtravel / touch-probe sensor transport -
+ * environment variables LUBAN_MCP_MQTT_* override these fields.
*/
const McpServer: React.FC = () => {
const [status, setStatus] = useState(null);
const [enabled, setEnabled] = useState(false);
const [port, setPort] = useState('');
+ const [mqtt, setMqtt] = useState<{ [field: string]: string }>({});
+ const [inverted, setInverted] = useState<{ [channel: string]: boolean }>({});
+ const [mqttPass, setMqttPass] = useState('');
+ const [mqttPassTouched, setMqttPassTouched] = useState(false);
useEffect(() => {
api.getMcpStatus()
@@ -35,6 +72,14 @@ const McpServer: React.FC = () => {
setStatus(body);
setEnabled(body.settings.enabled);
setPort(String(body.settings.port));
+ const { inverted: invertedNames, ...values } = body.mqtt.values;
+ setMqtt(values);
+ const names = String(invertedNames || '').split(',').map((n) => n.trim().toLowerCase());
+ const flags: { [channel: string]: boolean } = {};
+ CHANNELS.forEach((channel) => {
+ flags[channel] = names.includes(channel);
+ });
+ setInverted(flags);
})
.catch(() => setStatus(null));
}, []);
@@ -44,7 +89,12 @@ const McpServer: React.FC = () => {
if (!Number.isInteger(value) || value < 1 || value > 65535) {
return;
}
- await api.setMcpSettings({ enabled, port: value });
+ const mqttUpdate: { [field: string]: string } = { ...mqtt };
+ mqttUpdate.inverted = CHANNELS.filter((channel) => inverted[channel]).join(',');
+ if (mqttPassTouched) {
+ mqttUpdate.pass = mqttPass;
+ }
+ await api.setMcpSettings({ enabled, port: value, mqtt: mqttUpdate });
};
useEffect(() => {
@@ -71,6 +121,8 @@ const McpServer: React.FC = () => {
}
}
+ const envOverrides = status ? status.mqtt.envOverrides : [];
+
return (
+ {i18n._('key-App/Settings/McpServer-External tool setter, overtravel and touch probe sensors report over this feed. Feed fields accept an Adafruit IO feed key or a full topic path. Applies at the next feed connection.')}
+
+ {envOverrides.length > 0 && (
+
+ {i18n._('key-App/Settings/McpServer-Overridden by environment variables:')} {envOverrides.join(', ')}
+
+ let directBanner = '';
+ if (job.kind === 'direct') {
+ directBanner = `
DIRECT MOVE: on start this executes over the realtime path so the
position persists - but it does NOT run as a job, so the enclosure door
- interlock does not apply. Supervise it.
`
- : '';
+ interlock does not apply. Supervise it.`;
+ } else if (job.kind === 'procedure') {
+ directBanner = `
+ SERVER-DRIVEN PROCEDURE: on start the server steps the machine
+ within the envelope below, gated by live probe-sensor feedback - it stops early on
+ contact and can never exceed the extents shown. It runs on the realtime path, so
+ the enclosure door interlock does NOT apply, and the overtravel tripwire must be
+ armed. Supervise it.
Submitted by an agent over MCP. Review before approving - approval mints a
one-time code the agent needs to start it.
${directBanner}
diff --git a/src/server/services/mcp/toolSetter.ts b/src/server/services/mcp/toolSetter.ts
new file mode 100644
index 0000000000..c981eea635
--- /dev/null
+++ b/src/server/services/mcp/toolSetter.ts
@@ -0,0 +1,615 @@
+/* eslint-disable camelcase */
+// MCP tool arguments are snake_case by convention (planToolSetterRun takes
+// the run_tool_setter arguments verbatim).
+import logger from '../../lib/logger';
+import config from '../configstore';
+import { connectionManager } from '../machine/ConnectionManager';
+import { mcpBroadcast } from './index';
+import { probeFeedService } from './probeFeed';
+import { McpToolError } from './registry';
+import { GcodeChannel, sendGcodeVisible } from './tools/camera';
+import { getPositionSnapshot } from './tools/machine';
+
+const log = logger('service:mcp:tool-setter');
+
+// Tool height measurement against the fixed tool setter (the gold cylinder
+// on the A350 bed; a normally-open switch that reports over the probe feed).
+// The whole procedure is ONE operator approval: the confirm page shows the
+// motion envelope (XY centre, start Z, hard floor Z, increments, feeds) and
+// the server-side runner drives the steps deterministically against live
+// sensor feedback - the model never chooses a Z during the run.
+//
+// Staged approach (operator-specified):
+// 1. travel: XY to the centre at the current (post-home) Z, then Z down to
+// startZ = reference trigger Z + (longest bit - reference bit) + 50 mm
+// 2. coarse: descend in 1 mm steps, checking the toolsetter feed after
+// each settled step, until contact
+// 3. release: retreat in 1 mm steps until the feed reports released
+// 4. fine: descend in 0.1 mm steps until contact
+// 5. confirm: back off 0.3 mm, then descend 0.1 mm per >=2 s until contact
+// 6. retreat to startZ and report
+// A hard floor (expected trigger Z for the declared bit minus a margin)
+// aborts the descent; the overtravel tripwire aborts everything at any time.
+
+const CONFIG_KEY = 'mcpToolSetter';
+
+const TRAVEL_FEED = 600; // mm/min, matches the move_z cap
+const COARSE_FEED = 100;
+const FINE_FEED = 60;
+const SETTLE_TIMEOUT_MS = 30000;
+const SETTLE_POLL_MS = 250;
+const SETTLE_TOLERANCE_MM = 0.15;
+const MAX_RETREAT_MM = 5; // still triggered after this much retreat = stuck sensor
+
+export interface ToolSetterConfig {
+ centerX: number; // machine coords of the setter's centre
+ centerY: number;
+ triggerZ: number; // machine Z at trigger with the reference bit fitted
+ referenceBitLengthMm: number;
+ longestBitLengthMm: number;
+ floorMarginMm: number; // how far below the expected trigger Z to allow
+ notes: string | null;
+}
+
+export function getToolSetterConfig(): ToolSetterConfig | null {
+ const raw = config.get(CONFIG_KEY);
+ if (!raw || typeof raw !== 'object') {
+ return null;
+ }
+ const cfg = raw as { [key: string]: unknown };
+ const numbers = ['centerX', 'centerY', 'triggerZ', 'referenceBitLengthMm', 'longestBitLengthMm'];
+ if (numbers.some((key) => !Number.isFinite(Number(cfg[key])))) {
+ return null;
+ }
+ return {
+ centerX: Number(cfg.centerX),
+ centerY: Number(cfg.centerY),
+ triggerZ: Number(cfg.triggerZ),
+ referenceBitLengthMm: Number(cfg.referenceBitLengthMm),
+ longestBitLengthMm: Number(cfg.longestBitLengthMm),
+ floorMarginMm: Number.isFinite(Number(cfg.floorMarginMm)) ? Number(cfg.floorMarginMm) : 3,
+ notes: cfg.notes ? String(cfg.notes) : null,
+ };
+}
+
+export function setToolSetterConfig(cfg: ToolSetterConfig): void {
+ config.set(CONFIG_KEY, cfg);
+ log.info(`Tool setter config stored: centre (${cfg.centerX}, ${cfg.centerY}), `
+ + `trigger Z ${cfg.triggerZ} with ${cfg.referenceBitLengthMm} mm reference bit`);
+}
+
+export interface ToolSetterPlan {
+ config: ToolSetterConfig;
+ bitLengthMm: number;
+ expectedTriggerZ: number;
+ startZ: number;
+ floorZ: number;
+ // Bottom of the coarse ladder: coarse steps stop this far ABOVE the
+ // expected trigger and the descent continues in fine steps, so a
+ // correctly-declared bit presses at most one fine step into the setter
+ // (a full coarse step can overrun by up to its own size - observed
+ // 0.5 mm on the first live run).
+ coarseFloorZ: number;
+ slowZoneMm: number;
+ coarseStepMm: number;
+ fineStepMm: number;
+ backoffMm: number;
+ sensorDelayMs: number;
+ confirmPasses: number;
+ storeAsReference: boolean;
+}
+
+/**
+ * Derive the motion envelope for a declared bit length. Throws McpToolError
+ * with operator guidance when configuration or physics rule the run out.
+ */
+export function planToolSetterRun(args: {
+ bit_length_mm?: number;
+ coarse_step_mm?: number;
+ fine_step_mm?: number;
+ backoff_mm?: number;
+ sensor_delay_ms?: number;
+ confirm_passes?: number;
+ start_clearance_mm?: number;
+ slow_zone_mm?: number;
+ store_as_reference?: boolean;
+}): ToolSetterPlan {
+ const cfg = getToolSetterConfig();
+ if (!cfg) {
+ throw new McpToolError('Tool setter is not configured. Ask the operator for the setter centre '
+ + '(machine XY), the machine Z at trigger with a known bit, that bit\'s length, and the '
+ + 'longest bit in use, then store them with set_tool_setter_config.');
+ }
+ const bitLengthMm = Number(args.bit_length_mm);
+ if (!Number.isFinite(bitLengthMm) || bitLengthMm <= 0 || bitLengthMm > 300) {
+ throw new McpToolError('bit_length_mm must be the approximate protrusion of the fitted bit in mm '
+ + '(0-300), as stated by the operator.');
+ }
+ const coarseStepMm = Math.min(Math.max(Number(args.coarse_step_mm) || 1, 0.2), 2);
+ const fineStepMm = Math.min(Math.max(Number(args.fine_step_mm) || 0.1, 0.02), 0.5);
+ const backoffMm = Math.min(Math.max(Number(args.backoff_mm) || 0.3, fineStepMm), 2);
+ // 200ms default is tuned to the operator's local-broker latency; the
+ // hard floor and the overtravel tripwire backstop a missed message.
+ const sensorDelayMs = Math.min(Math.max(Number(args.sensor_delay_ms) || 200, 100), 10000);
+ const confirmPasses = Math.min(Math.max(Math.round(Number(args.confirm_passes) || 3), 1), 10);
+ const startClearanceMm = Math.min(Math.max(Number(args.start_clearance_mm) || 30, 10), 150);
+ const slowZoneMm = Math.min(Math.max(Number(args.slow_zone_mm) || 1, fineStepMm), 10);
+
+ const expectedTriggerZ = cfg.triggerZ + (bitLengthMm - cfg.referenceBitLengthMm);
+ const startZ = cfg.triggerZ + (cfg.longestBitLengthMm - cfg.referenceBitLengthMm) + startClearanceMm;
+ const floorZ = expectedTriggerZ - cfg.floorMarginMm;
+ if (floorZ < 0) {
+ throw new McpToolError(`Computed floor Z ${floorZ.toFixed(1)} is below machine Z 0 - the declared `
+ + 'bit length or stored trigger reference must be wrong. Re-check with the operator.');
+ }
+ if (startZ <= expectedTriggerZ) {
+ throw new McpToolError('Computed start Z is at or below the expected trigger Z; check '
+ + 'longestBitLengthMm and the clearance.');
+ }
+
+ return {
+ config: cfg,
+ bitLengthMm,
+ expectedTriggerZ,
+ startZ,
+ floorZ,
+ coarseFloorZ: Math.min(Math.max(expectedTriggerZ + slowZoneMm, floorZ), startZ),
+ slowZoneMm,
+ coarseStepMm,
+ fineStepMm,
+ backoffMm,
+ sensorDelayMs,
+ confirmPasses,
+ storeAsReference: args.store_as_reference === true,
+ };
+}
+
+/**
+ * The gcode shown on the confirm page. Every line is sent INDIVIDUALLY: the
+ * runner waits for each move to verifiably settle, then checks the sensor
+ * feed, before issuing the next line - so the coarse descent is enumerated
+ * step by step exactly as it would execute against a silent sensor. It stops
+ * at the first contact, after which the fine/backoff/confirm steps repeat
+ * the same one-command-per-check pattern in fine increments around the
+ * contact Z (their exact targets depend on where contact happens, and all of
+ * them lie between the contact Z plus backoff and the hard floor).
+ */
+export function describePlanAsGcode(plan: ToolSetterPlan): string {
+ const c = plan.config;
+ const lines = [
+ '; TOOL SETTER MEASUREMENT PROCEDURE (server-driven, sensor-gated)',
+ '; EVERY LINE IS SENT INDIVIDUALLY: after each move settles, the toolsetter',
+ '; feed is checked before the next line is issued. Descent stops at first',
+ '; contact - the full ladder below only executes if the sensor stays silent,',
+ `; and then the run ABORTS at the hard floor Z ${plan.floorZ.toFixed(2)}.`,
+ `; centre: machine X${c.centerX} Y${c.centerY}; declared bit ${plan.bitLengthMm} mm; expected trigger Z ${plan.expectedTriggerZ.toFixed(2)}`,
+ '; overtravel feed trips -> job stop + connection close + latched alarm',
+ 'G90',
+ 'G53;',
+ `G0 X${c.centerX.toFixed(3)} Y${c.centerY.toFixed(3)}; XY to setter centre at current (post-home) Z`,
+ `G1 Z${plan.startZ.toFixed(3)} F${TRAVEL_FEED}; travel to start height`,
+ ];
+ let z = plan.startZ;
+ let step = 0;
+ while (z - plan.coarseStepMm >= plan.coarseFloorZ - 1e-9) {
+ z = Math.max(z - plan.coarseStepMm, plan.coarseFloorZ);
+ step += 1;
+ lines.push(`G1 Z${z.toFixed(3)} F${COARSE_FEED}; coarse step ${step} - settle, check sensor, stop at contact`);
+ }
+ lines.push(
+ `; coarse ladder ends ${plan.slowZoneMm} mm ABOVE the expected trigger; contact above this`,
+ `; line means the bit is longer than declared (retreat ${plan.coarseStepMm} mm steps until released).`,
+ `; SLOW ZONE - fine ${plan.fineStepMm} mm steps, max press into the setter = one step:`,
+ );
+ step = 0;
+ while (z - plan.fineStepMm >= plan.floorZ - 1e-9) {
+ z -= plan.fineStepMm;
+ step += 1;
+ lines.push(`G1 Z${z.toFixed(3)} F${FINE_FEED}; fine step ${step} - settle, check sensor, stop at contact`);
+ }
+ lines.push(
+ `; ...on contact: ${plan.confirmPasses} quick confirm cycles, each = lift ${plan.backoffMm} mm, wait for the`,
+ `; sensor to release, re-approach in ${plan.fineStepMm} mm steps to contact. Result = median of the`,
+ '; cycle contacts (spread reported); a cycle never descends more than 0.5 mm below first contact.',
+ `G1 Z${plan.startZ.toFixed(3)} F${TRAVEL_FEED}; retreat to start height when done (also on any abort)`,
+ 'G54;',
+ );
+ return lines.join('\n');
+}
+
+interface StepResult {
+ contact: boolean;
+ reading: { value: string; receivedAt: number } | null;
+}
+
+class ProcedureAbort extends Error {}
+
+function getDirectChannel(): GcodeChannel {
+ const channel = connectionManager.getCurrentChannel() as unknown as GcodeChannel;
+ if (!channel || typeof channel.executeGcode !== 'function') {
+ throw new ProcedureAbort('No machine channel with direct command support.');
+ }
+ return channel;
+}
+
+async function sleep(ms: number): Promise {
+ return new Promise((resolve) => {
+ setTimeout(resolve, ms);
+ });
+}
+
+/**
+ * Issue one absolute machine-frame move and block until the heartbeat
+ * verifiably reports it (same contract as move_z): report newer than issue,
+ * two identical consecutive beats, machine idle, and axes at target.
+ */
+async function moveMachineSettled(
+ tool: string,
+ target: { x?: number; y?: number; z?: number },
+ feed: number
+): Promise {
+ probeFeedService.assertNoOvertravel();
+ const channel = getDirectChannel();
+ const words = [
+ target.x !== undefined ? `X${target.x.toFixed(3)}` : '',
+ target.y !== undefined ? `Y${target.y.toFixed(3)}` : '',
+ target.z !== undefined ? `Z${target.z.toFixed(3)}` : '',
+ ].filter(Boolean).join(' ');
+ const gcode = `G90\nG53;\nG1 ${words} F${feed};\nG54;`;
+
+ // When every commanded axis moves by well over the tolerance, a stale
+ // pre-move heartbeat cannot sit within tolerance of the target, so the
+ // FIRST post-issue beat at the target is already proof of arrival - no
+ // need to wait out a second identical beat (~1-1.5s/step saved on the
+ // coarse ladder). Small steps (fine/confirm, at or under the tolerance)
+ // keep the strict stable-double-beat rule. Judged from the position
+ // BEFORE the move is issued.
+ const before = getPositionSnapshot();
+ const bigMove = (['x', 'y', 'z'] as const).every((axis) => {
+ const want = target[axis];
+ if (want === undefined) {
+ return true;
+ }
+ const from = before.machine[axis];
+ return from !== null && Math.abs(want - from) > SETTLE_TOLERANCE_MM * 3;
+ });
+
+ const issuedAt = Date.now();
+ const executed = await sendGcodeVisible(channel, tool, gcode);
+ if (executed.result !== 0) {
+ throw new ProcedureAbort(`Controller rejected the move: ${executed.text || executed.result}`);
+ }
+
+ // Fast path: the HTTP channel executes gcode synchronously and its reply
+ // echoes the position after completion ("X:.. Y:.. Z:.." in WORK
+ // coordinates, hardware-observed to always match the exact target). An
+ // exact echo match (0.02 mm, tighter than one fine step) is proof of
+ // arrival with no heartbeat wait (~2s/step saved - the heartbeat only
+ // ticks ~2s on the wifi channel). Anything less falls through to the
+ // strict heartbeat settle below.
+ const echo = String(executed.text || '').match(/X:(-?\d+(?:\.\d+)?)\s+Y:(-?\d+(?:\.\d+)?)\s+Z:(-?\d+(?:\.\d+)?)/);
+ if (echo) {
+ const offset = before.originOffset;
+ const echoMachine = {
+ x: Number(echo[1]) - offset.x,
+ y: Number(echo[2]) - offset.y,
+ z: Number(echo[3]) - offset.z,
+ };
+ const exact = (['x', 'y', 'z'] as const).every((axis) => {
+ const want = target[axis];
+ return want === undefined || Math.abs(echoMachine[axis] - want) <= 0.02;
+ });
+ if (exact) {
+ return;
+ }
+ }
+
+ const deadline = issuedAt + SETTLE_TIMEOUT_MS;
+ let previous: string | null = null;
+ while (Date.now() < deadline) {
+ await sleep(SETTLE_POLL_MS);
+ probeFeedService.assertNoOvertravel();
+ let now;
+ try {
+ now = getPositionSnapshot();
+ } catch (err) {
+ continue;
+ }
+ const reportTime = Date.now() - now.reportAgeMs;
+ const fingerprint = JSON.stringify([now.work, now.originOffset]);
+ const stable = fingerprint === previous;
+ previous = fingerprint;
+ if (reportTime <= issuedAt || now.machineStatus !== 'idle') {
+ continue;
+ }
+ if (!bigMove && !stable) {
+ continue;
+ }
+ const atTarget = (['x', 'y', 'z'] as const).every((axis) => {
+ const want = target[axis];
+ const have = now.machine[axis];
+ return want === undefined || (have !== null && Math.abs(have - want) <= SETTLE_TOLERANCE_MM);
+ });
+ if (atTarget) {
+ return;
+ }
+ }
+ throw new ProcedureAbort(`Timed out waiting for the heartbeat to verify the move to ${words}.`);
+}
+
+/**
+ * After a settled step, give the sensor's report time to arrive (early-exits
+ * when a fresh reading lands), then judge contact from the LAST KNOWN state -
+ * the sensor publishes on change, so silence means unchanged.
+ *
+ * This short window is for CONTACT detection while descending, where a late
+ * message merely costs one extra step before detection (self-correcting).
+ */
+async function senseAfter(stepIssuedAt: number, delayMs: number): Promise {
+ const fresh = await probeFeedService.waitForReading('toolsetter', stepIssuedAt, delayMs);
+ const state = fresh || probeFeedService.getReading('toolsetter');
+ return {
+ contact: !!(state && state.triggered),
+ reading: state ? { value: state.value, receivedAt: state.receivedAt } : null,
+ };
+}
+
+/**
+ * RELEASE detection needs different patience: a stale "triggered" here is a
+ * false abort (live-hit on run 2: the cloud round-trip of the release
+ * message exceeded the 200 ms contact window). Wait until the last known
+ * state reads untriggered, early-exiting on fresh readings, up to timeoutMs;
+ * only then is "still triggered" believed.
+ */
+async function senseReleaseAfter(stepIssuedAt: number, timeoutMs: number): Promise {
+ const deadline = Date.now() + timeoutMs;
+ for (;;) {
+ const state = probeFeedService.getReading('toolsetter');
+ if (state && !state.triggered) {
+ return { contact: false, reading: { value: state.value, receivedAt: state.receivedAt } };
+ }
+ const remaining = deadline - Date.now();
+ if (remaining <= 0) {
+ return {
+ contact: !!(state && state.triggered),
+ reading: state ? { value: state.value, receivedAt: state.receivedAt } : null,
+ };
+ }
+ await probeFeedService.waitForReading(
+ 'toolsetter',
+ state ? state.receivedAt : stepIssuedAt,
+ Math.min(remaining, 250)
+ );
+ }
+}
+
+function assertFeedReady(): void {
+ probeFeedService.assertNoOvertravel();
+ if (!probeFeedService.isConnected()) {
+ throw new McpToolError('Probe feed is not connected - connect_probe_feed first. The overtravel '
+ + 'tripwire MUST be armed for a tool setter run.');
+ }
+ const setter = probeFeedService.getReading('toolsetter');
+ if (!setter) {
+ throw new McpToolError('No reading has ever arrived on the toolsetter feed - cannot trust the '
+ + 'sensor. Ask the operator to trigger the setter by hand and watch get_probe_feed_status '
+ + 'until the touch shows up.');
+ }
+ if (setter.triggered) {
+ throw new McpToolError(`The toolsetter feed already reads triggered ("${setter.value}") before `
+ + 'any approach - the sensor is stuck or something is resting on it. Resolve physically first.');
+ }
+}
+
+export interface ToolSetterResult {
+ measuredTriggerZ: number;
+ confirmPassContacts: number[];
+ spreadMm: number;
+ expectedTriggerZ: number;
+ deltaMm: number;
+ derivedBitLengthMm: number;
+ phases: { phase: string; z: number; note?: string }[];
+ storedAsReference: boolean;
+ note: string;
+ warning?: string;
+}
+
+/**
+ * The operator-approved run. Every motion re-checks the overtravel latch;
+ * any abort retreats to the start height when the machine still answers.
+ */
+export async function runToolSetterProcedure(plan: ToolSetterPlan): Promise {
+ assertFeedReady();
+ const position = getPositionSnapshot();
+ if (position.machineStatus !== 'idle') {
+ throw new McpToolError(`Machine is ${position.machineStatus || 'in an unknown state'}, not idle.`);
+ }
+ if (position.isHomed !== true) {
+ throw new McpToolError('Machine does not report homed; the tool setter centre is only valid '
+ + 'after homing. Call home first.');
+ }
+ const state = connectionManager.getLatestMachineState() as { headStatus?: unknown; headPower?: unknown } | null;
+ const headPower = Number(state && state.headPower);
+ if ((Number.isFinite(headPower) && headPower > 0) || (state && (state.headStatus === true || state.headStatus === 'on'))) {
+ throw new McpToolError('Toolhead appears to be on; refusing to run the tool setter.');
+ }
+
+ const phases: { phase: string; z: number; note?: string }[] = [];
+ const c = plan.config;
+ const announce = (phase: string, z: number, note?: string) => {
+ phases.push({ phase, z: Number(z.toFixed(3)), note });
+ mcpBroadcast('mcp:activity', { tool: 'run_tool_setter', phase, z: Number(z.toFixed(3)), note });
+ };
+
+ let currentZ = plan.startZ;
+ try {
+ // Phase 1: position over the centre, then travel down to start height.
+ // XY first at the current (post-home, high) Z, so the bit never sweeps
+ // across the bed at approach height.
+ announce('travel-xy', position.machine.z ?? plan.startZ, `XY to (${c.centerX}, ${c.centerY})`);
+ await moveMachineSettled('toolsetter:travel', { x: c.centerX, y: c.centerY }, TRAVEL_FEED);
+ announce('travel-z', plan.startZ);
+ await moveMachineSettled('toolsetter:travel', { z: plan.startZ }, TRAVEL_FEED);
+
+ // Phase 2: coarse descent, sensor-checked after every settled step,
+ // ONLY down to the slow zone above the expected trigger. Contact in
+ // this phase means the bit is longer than declared (a coarse step can
+ // press up to its own size into the setter - the slow zone keeps a
+ // correctly-declared bit out of that regime).
+ let coarseContactZ: number | null = null;
+ while (currentZ - plan.coarseStepMm >= plan.coarseFloorZ - 1e-9) {
+ const stepStart = Date.now();
+ currentZ = Math.max(currentZ - plan.coarseStepMm, plan.coarseFloorZ);
+ await moveMachineSettled('toolsetter:coarse', { z: currentZ }, COARSE_FEED);
+ const sensed = await senseAfter(stepStart, plan.sensorDelayMs);
+ if (sensed.contact) {
+ coarseContactZ = currentZ;
+ announce('coarse-contact', currentZ,
+ `sensor "${sensed.reading?.value}" ABOVE the slow zone - bit longer than declared`);
+ break;
+ }
+ }
+
+ // Release-type checks wait out the feed's real-world latency (the
+ // release message has been observed arriving ~1s after the motion);
+ // a short window here caused a false "hysteresis" abort on run 2.
+ const releaseTimeoutMs = Math.max(plan.sensorDelayMs * 4, 2500);
+
+ // Phase 3: only after a coarse contact - retreat until released, so
+ // the fine approach starts from a clear sensor.
+ if (coarseContactZ !== null) {
+ let releasedZ: number | null = null;
+ while (currentZ < coarseContactZ + MAX_RETREAT_MM) {
+ const stepStart = Date.now();
+ currentZ += plan.coarseStepMm;
+ await moveMachineSettled('toolsetter:release', { z: currentZ }, COARSE_FEED);
+ const sensed = await senseReleaseAfter(stepStart, releaseTimeoutMs);
+ if (!sensed.contact) {
+ releasedZ = currentZ;
+ announce('released', currentZ);
+ break;
+ }
+ }
+ if (releasedZ === null) {
+ throw new ProcedureAbort(`Sensor still reads triggered ${MAX_RETREAT_MM} mm above first contact - `
+ + 'stuck switch or feed fault.');
+ }
+ } else {
+ announce('slow-zone', currentZ, 'coarse ladder done, no contact; continuing in fine steps');
+ }
+
+ // Phase 4: fine approach - the primary contact phase when the
+ // declared bit length is right (max press = one fine step).
+ let fineContactZ: number | null = null;
+ while (currentZ - plan.fineStepMm >= plan.floorZ - 1e-9) {
+ const stepStart = Date.now();
+ currentZ -= plan.fineStepMm;
+ await moveMachineSettled('toolsetter:fine', { z: currentZ }, FINE_FEED);
+ const sensed = await senseAfter(stepStart, plan.sensorDelayMs);
+ if (sensed.contact) {
+ fineContactZ = currentZ;
+ announce('fine-contact', currentZ, `sensor "${sensed.reading?.value}"`);
+ break;
+ }
+ }
+ if (fineContactZ === null) {
+ throw new ProcedureAbort(`Reached the hard floor Z ${plan.floorZ.toFixed(2)} without contact. `
+ + 'The declared bit length, the stored trigger reference, or the sensor is wrong.');
+ }
+
+ // Phase 5: repeated quick lift-and-retest cycles (operator-specified
+ // protocol): each pass lifts by the backoff, waits for the sensor to
+ // actually release (patient - correctness gates on it), then
+ // re-approaches in fine steps with the SHORT contact window. A pass
+ // risks only a couple of fine steps, so a feed timing aberration
+ // shows up as spread between passes instead of biasing the result;
+ // the reported trigger Z is the median.
+ const passContacts: number[] = [];
+ const cycleFloor = Math.max(plan.floorZ, fineContactZ - 0.5);
+ let referenceContactZ = fineContactZ;
+ for (let pass = 1; pass <= plan.confirmPasses; pass++) {
+ const liftIssuedAt = Date.now();
+ currentZ = referenceContactZ + plan.backoffMm;
+ await moveMachineSettled('toolsetter:backoff', { z: currentZ }, FINE_FEED);
+ const liftSense = await senseReleaseAfter(liftIssuedAt, releaseTimeoutMs);
+ if (liftSense.contact) {
+ throw new ProcedureAbort(`Sensor still triggered ${releaseTimeoutMs} ms after backing off `
+ + `${plan.backoffMm} mm - trigger hysteresis exceeds the backoff. Rerun with a larger backoff_mm.`);
+ }
+ let passContact: number | null = null;
+ while (currentZ - plan.fineStepMm >= cycleFloor - 1e-9) {
+ const stepStart = Date.now();
+ currentZ -= plan.fineStepMm;
+ await moveMachineSettled('toolsetter:confirm', { z: currentZ }, FINE_FEED);
+ const sensed = await senseAfter(stepStart, plan.sensorDelayMs);
+ if (sensed.contact) {
+ passContact = currentZ;
+ break;
+ }
+ }
+ if (passContact === null) {
+ throw new ProcedureAbort(`Confirm pass ${pass} descended to ${cycleFloor.toFixed(2)} `
+ + '(0.5 mm below the first fine contact) without re-contact - inconsistent sensor.');
+ }
+ passContacts.push(Number(passContact.toFixed(3)));
+ announce(`confirm-${pass}`, passContact, `of ${plan.confirmPasses}`);
+ referenceContactZ = passContact;
+ }
+ const sorted = [...passContacts].sort((a, b) => a - b);
+ const measuredZ = sorted[Math.floor((sorted.length - 1) / 2)];
+ const spreadMm = Number((sorted[sorted.length - 1] - sorted[0]).toFixed(3));
+ announce('measured', measuredZ, `median of [${passContacts.join(', ')}], spread ${spreadMm} mm`);
+
+ // Phase 6: retreat to the start height and report.
+ await moveMachineSettled('toolsetter:retreat', { z: plan.startZ }, TRAVEL_FEED);
+ announce('retreated', plan.startZ);
+
+ const derivedBitLengthMm = c.referenceBitLengthMm + (measuredZ - c.triggerZ);
+ let storedAsReference = false;
+ if (plan.storeAsReference) {
+ setToolSetterConfig({
+ ...c,
+ triggerZ: measuredZ,
+ referenceBitLengthMm: plan.bitLengthMm,
+ });
+ storedAsReference = true;
+ }
+
+ const result: ToolSetterResult = {
+ measuredTriggerZ: measuredZ,
+ confirmPassContacts: passContacts,
+ spreadMm,
+ expectedTriggerZ: plan.expectedTriggerZ,
+ deltaMm: Number((measuredZ - plan.expectedTriggerZ).toFixed(3)),
+ derivedBitLengthMm: Number(derivedBitLengthMm.toFixed(3)),
+ phases,
+ storedAsReference,
+ note: `Trigger at machine Z ${measuredZ.toFixed(3)} - median of ${plan.confirmPasses} confirm `
+ + `passes [${passContacts.join(', ')}], spread ${spreadMm} mm (+/- ${plan.fineStepMm} mm step `
+ + 'resolution). The derived bit length assumes the stored reference is exact; report it '
+ + 'with that uncertainty.',
+ warning: spreadMm > plan.fineStepMm + 1e-9
+ ? `Confirm passes spread ${spreadMm} mm exceeds one fine step - feed timing was unstable; `
+ + 'consider more confirm_passes or a longer sensor_delay_ms.'
+ : undefined,
+ };
+ return result as unknown as object;
+ } catch (err) {
+ // Best-effort retreat to the safe start height, unless the failure is
+ // the overtravel latch itself (the connection is being force-closed).
+ const isTrip = !!probeFeedService.getTrip();
+ if (!isTrip) {
+ try {
+ await moveMachineSettled('toolsetter:abort-retreat', { z: plan.startZ }, TRAVEL_FEED);
+ announce('abort-retreated', plan.startZ);
+ } catch (retreatErr) {
+ log.error(`Tool setter abort retreat failed: ${retreatErr.message}`);
+ }
+ }
+ if (err instanceof ProcedureAbort) {
+ throw new McpToolError(`Tool setter run aborted: ${err.message} `
+ + `Phases completed: ${JSON.stringify(phases)}`);
+ }
+ throw err;
+ }
+}
diff --git a/src/server/services/mcp/tools/gcode.ts b/src/server/services/mcp/tools/gcode.ts
index 4d887e67d9..b54f041650 100644
--- a/src/server/services/mcp/tools/gcode.ts
+++ b/src/server/services/mcp/tools/gcode.ts
@@ -207,6 +207,30 @@ export function registerGcodeTools(registry: ToolRegistry, getConfirmBaseUrl: ()
throw new McpToolError(verdict.reason || 'Confirmation failed.');
}
+ if (job.kind === 'procedure') {
+ // Server-driven measurement routine (e.g. run_tool_setter):
+ // the operator approved the envelope; the runner steps within
+ // it against live sensor feedback and returns the result.
+ if (typeof job.runner !== 'function') {
+ throw new McpToolError('Procedure job has no runner (was the server restarted since '
+ + 'submission?). Submit it again.');
+ }
+ job.state = 'started';
+ job.startedAt = Date.now();
+ try {
+ const outcome = await job.runner();
+ job.state = 'completed';
+ return {
+ job: jobManager.describe(job),
+ result: outcome,
+ };
+ } catch (err) {
+ job.state = 'start_failed';
+ job.error = err.message;
+ throw err;
+ }
+ }
+
if (job.kind === 'direct') {
// Direct moves execute over the realtime path so position
// PERSISTS - the firmware parks back at the work origin when
diff --git a/src/server/services/mcp/tools/landmarks.ts b/src/server/services/mcp/tools/landmarks.ts
index 8a7b937d98..b5bbbb0067 100644
--- a/src/server/services/mcp/tools/landmarks.ts
+++ b/src/server/services/mcp/tools/landmarks.ts
@@ -6,6 +6,7 @@ import { calibrationStore } from '../calibration';
import { Landmark, landmarkStore } from '../landmarks';
import { probeFeedService } from '../probeFeed';
import { McpToolError, ToolRegistry } from '../registry';
+import { getToolSetterConfig } from '../toolSetter';
// Named scene landmarks (#50) and the stored-state overview (#53): operator
// knowledge captured once, surfaced every session, so no agent spends moves
@@ -114,6 +115,7 @@ export function registerLandmarkTools(registry: ToolRegistry): void {
},
installedModules: config.get('mcpInstalledModules') || [],
probeFeed: probeFeedService.status(),
+ toolSetter: getToolSetterConfig(),
};
},
});
diff --git a/src/server/services/mcp/tools/toolsetter.ts b/src/server/services/mcp/tools/toolsetter.ts
new file mode 100644
index 0000000000..7ba21553d7
--- /dev/null
+++ b/src/server/services/mcp/tools/toolsetter.ts
@@ -0,0 +1,161 @@
+/* eslint-disable camelcase */
+// MCP tool arguments are snake_case by convention.
+import { jobManager } from '../jobs';
+import { McpToolError, ToolRegistry } from '../registry';
+import {
+ describePlanAsGcode,
+ getToolSetterConfig,
+ planToolSetterRun,
+ runToolSetterProcedure,
+ setToolSetterConfig,
+} from '../toolSetter';
+import { validateGcode } from '../validator';
+
+export function registerToolSetterTools(registry: ToolRegistry, getConfirmBaseUrl: () => string): void {
+ registry.register({
+ name: 'set_tool_setter_config',
+ description: 'Store the tool setter reference: its centre in MACHINE coordinates, the machine '
+ + 'Z at which a known bit triggers it, that bit\'s length, and the longest bit in use. All '
+ + 'values come from the OPERATOR (or a store_as_reference run) - never from visual '
+ + 'estimation. The expected trigger Z for any bit follows as '
+ + 'triggerZ + (bit length - reference length).',
+ inputSchema: {
+ type: 'object',
+ properties: {
+ center_x: { type: 'number', description: 'Machine X of the setter centre.' },
+ center_y: { type: 'number', description: 'Machine Y of the setter centre.' },
+ trigger_z: { type: 'number', description: 'Machine Z at trigger with the reference bit.' },
+ reference_bit_length_mm: { type: 'number', description: 'Protrusion of the reference bit, mm.' },
+ longest_bit_length_mm: { type: 'number', description: 'Longest bit in use, mm - sets the safe start height.' },
+ floor_margin_mm: { type: 'number', description: 'Allowed descent below the expected trigger Z, default 3.' },
+ notes: { type: 'string' },
+ },
+ required: ['center_x', 'center_y', 'trigger_z', 'reference_bit_length_mm', 'longest_bit_length_mm'],
+ additionalProperties: false,
+ },
+ handler: async (args: {
+ center_x?: number;
+ center_y?: number;
+ trigger_z?: number;
+ reference_bit_length_mm?: number;
+ longest_bit_length_mm?: number;
+ floor_margin_mm?: number;
+ notes?: string;
+ }) => {
+ const numbers = {
+ centerX: Number(args.center_x),
+ centerY: Number(args.center_y),
+ triggerZ: Number(args.trigger_z),
+ referenceBitLengthMm: Number(args.reference_bit_length_mm),
+ longestBitLengthMm: Number(args.longest_bit_length_mm),
+ };
+ if (Object.values(numbers).some((v) => !Number.isFinite(v))) {
+ throw new McpToolError('All coordinates and lengths must be finite numbers.');
+ }
+ if (numbers.referenceBitLengthMm <= 0 || numbers.longestBitLengthMm < numbers.referenceBitLengthMm - 0.001) {
+ throw new McpToolError('Bit lengths must be positive and longest >= reference.');
+ }
+ const floorMarginMm = args.floor_margin_mm !== undefined ? Number(args.floor_margin_mm) : 3;
+ if (!Number.isFinite(floorMarginMm) || floorMarginMm < 0.5 || floorMarginMm > 20) {
+ throw new McpToolError('floor_margin_mm must be 0.5-20.');
+ }
+ setToolSetterConfig({
+ ...numbers,
+ floorMarginMm,
+ notes: args.notes ? String(args.notes) : null,
+ });
+ return { config: getToolSetterConfig() };
+ },
+ });
+
+ registry.register({
+ name: 'get_tool_setter_config',
+ description: 'The stored tool setter reference (centre, trigger Z, bit lengths). Read-only.',
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
+ handler: async () => ({ config: getToolSetterConfig() }),
+ });
+
+ registry.register({
+ name: 'run_tool_setter',
+ description: 'Stage a tool height measurement against the tool setter for human confirmation. '
+ + 'The declared bit_length_mm comes from the OPERATOR. Preconditions: machine homed and '
+ + 'idle, probe feed connected (overtravel tripwire armed), toolsetter sensor readable and '
+ + 'not triggered. The confirm page shows the full motion envelope; after approval one '
+ + 'start_gcode_job call runs the whole server-driven routine: XY to the centre, Z travel '
+ + 'to the safe start height, 1 mm sensor-gated descent to a slow zone, 0.1 mm approach to '
+ + 'contact, then repeated quick lift-and-retest confirm cycles - retreats and reports the '
+ + 'median trigger Z, the per-pass contacts and spread, and the derived bit length. '
+ + 'A hard floor and the overtravel tripwire bound it (~1-2 minutes).',
+ inputSchema: {
+ type: 'object',
+ properties: {
+ bit_length_mm: {
+ type: 'number',
+ description: 'Approximate protrusion of the FITTED bit in mm, as stated by the operator.',
+ },
+ coarse_step_mm: { type: 'number', description: 'Coarse descent step, default 1 (0.2-2).' },
+ fine_step_mm: { type: 'number', description: 'Fine step, default 0.1 (0.02-0.5).' },
+ backoff_mm: { type: 'number', description: 'Backoff before the confirm pass, default 0.3.' },
+ sensor_delay_ms: { type: 'number', description: 'Wait for the sensor report after each step, default 200.' },
+ confirm_passes: {
+ type: 'number',
+ description: 'Quick lift-and-retest cycles after first fine contact; the result is '
+ + 'their median and the spread is reported. Default 3 (1-10).',
+ },
+ start_clearance_mm: { type: 'number', description: 'Clearance above the longest-bit trigger height, default 30 (10-150).' },
+ slow_zone_mm: {
+ type: 'number',
+ description: 'The coarse ladder stops this far above the expected trigger and fine '
+ + 'steps take over, capping the press into the setter at one fine step. Default 1.',
+ },
+ store_as_reference: {
+ type: 'boolean',
+ description: 'After a successful run, store the measured trigger Z and this bit '
+ + 'length as the new reference (locks in the setter height).',
+ },
+ reason: { type: 'string', description: 'Shown to the operator: why this measurement is needed.' },
+ },
+ required: ['bit_length_mm', 'reason'],
+ additionalProperties: false,
+ },
+ handler: async (args: { bit_length_mm?: number; reason?: string; [key: string]: unknown }) => {
+ if (!String(args.reason || '').trim()) {
+ throw new McpToolError('reason is required; it is shown to the operator.');
+ }
+ const plan = planToolSetterRun(args);
+ const envelope = describePlanAsGcode(plan);
+ const validation = validateGcode(envelope);
+ const job = jobManager.submit(
+ envelope,
+ `tool-setter bit ${plan.bitLengthMm}mm - ${String(args.reason).slice(0, 40)}`,
+ 'cnc',
+ validation,
+ 'procedure'
+ );
+ job.runner = async () => runToolSetterProcedure(plan);
+
+ return {
+ job: jobManager.describe(job),
+ plan: {
+ center: { x: plan.config.centerX, y: plan.config.centerY },
+ expected_trigger_z: plan.expectedTriggerZ,
+ start_z: plan.startZ,
+ floor_z: plan.floorZ,
+ coarse_floor_z: plan.coarseFloorZ,
+ slow_zone_mm: plan.slowZoneMm,
+ coarse_step_mm: plan.coarseStepMm,
+ fine_step_mm: plan.fineStepMm,
+ backoff_mm: plan.backoffMm,
+ sensor_delay_ms: plan.sensorDelayMs,
+ confirm_passes: plan.confirmPasses,
+ store_as_reference: plan.storeAsReference,
+ },
+ confirm_url: `${getConfirmBaseUrl()}/confirm/${job.id}`,
+ next_step: 'Ask the operator to open confirm_url, review the SERVER-DRIVEN PROCEDURE '
+ + 'banner and the motion envelope, and approve. Their one-time code passed to '
+ + 'start_gcode_job runs the whole routine; it returns the measurement when done '
+ + '(several minutes - the confirm pass is deliberately slow).',
+ };
+ },
+ });
+}
From 3b8e753fefd24c3ba1d2adde741b53d3493e363c Mon Sep 17 00:00:00 2001
From: tyeth
Date: Mon, 31 Aug 2026 22:55:49 +0100
Subject: [PATCH 040/135] Fix: Survive a stray HSTS file occupying the userData
path
A force-killed session can leave the Electron userData PATH occupied by a
~200-byte Chromium HSTS/TransportSecurity state FILE instead of the
directory (observed live 2026-08-31, multiple times: the network service
flushes it to the userData root when it dies while the directory is
gone). electron-store's conf then throws EEXIST on mkdir - crashing the
next boot at module load, and blocking QUIT, whose winBounds save mkdirs
the same path (the file also reappeared mid-session, so a clean launch
does not guarantee a clean close).
Boot guard: if the userData path is a file, rename it aside
(.stray-), recreate the directory, log. Close guard: re-run the
guard before the winBounds save and never let that write block quit.
Diagnostic fs.watch on the parent logs every create/delete/replace of the
userData entry with its new state, to pin down the exact moment the
directory vanishes (root cause still unconfirmed).
Co-Authored-By: Claude Fable 5
---
src/main.js | 93 +++++++++++++++++++++++++++++++++++++++++++++++++++--
1 file changed, 90 insertions(+), 3 deletions(-)
diff --git a/src/main.js b/src/main.js
index e935747b65..4756517702 100644
--- a/src/main.js
+++ b/src/main.js
@@ -62,6 +62,41 @@ log.setLevel(log.levels.INFO);
// One clock for main, the forked server and the renderer.
process.env[STARTUP_EPOCH_KEY] = String(startupEpoch);
+/*
+ * A force-killed session can leave the userData PATH occupied by a small
+ * Chromium HSTS/TransportSecurity state FILE (~200 bytes of {"sts":[...]})
+ * instead of the directory - observed live 2026-08-31: the network service
+ * flushes it to the userData root when it dies while the directory is gone.
+ * electron-store then throws EEXIST on mkdir, crashing boot (and blocking
+ * quit, whose winBounds save also mkdirs). Guard: rename the stray file
+ * aside and log; callable again before any late store write.
+ */
+const ensureUserDataDir = (label) => {
+ const dir = app.getPath('userData');
+ try {
+ const stat = fs.statSync(dir);
+ if (stat.isDirectory()) {
+ return;
+ }
+ const strayPath = `${dir}.stray-${Date.now()}`;
+ fs.renameSync(dir, strayPath);
+ log.warn(`[userData ${label}] path was a ${stat.size}-byte FILE, not a directory - moved to ${strayPath}`);
+ } catch (err) {
+ if (err.code !== 'ENOENT') {
+ log.warn(`[userData ${label}] guard failed: ${err.message}`);
+ return;
+ }
+ log.warn(`[userData ${label}] directory missing`);
+ }
+ try {
+ fs.mkdirSync(dir, { recursive: true });
+ log.warn(`[userData ${label}] directory (re)created`);
+ } catch (err) {
+ log.warn(`[userData ${label}] mkdir failed: ${err.message}`);
+ }
+};
+
+ensureUserDataDir('boot');
const config = new Store();
initCrashReporting(config);
@@ -69,6 +104,53 @@ const userDataDir = app.getPath('userData');
global.luban = {
userDataDir
};
+
+const childProcess = require('child_process');
+
+// Diagnostic watch (pre-fix forensics, cheap enough to keep): log STATE
+// TRANSITIONS of the userData entry itself (directory <-> file <-> missing;
+// writes inside the directory fire 'change' events on the entry, so raw
+// events are too noisy to log). On a flip to FILE - the stray-HSTS
+// signature - also capture the content head and which electron processes
+// exist at that instant, to attribute the writer. Race unreproduced as of
+// 2026-08-31 despite bare/populated/fresh-profile speed-run attempts; the
+// two live-caught occurrences were ~10s after the GitHub update-check
+// response (Chromium's delayed HSTS persist window).
+try {
+ let watchedState = 'directory';
+ fs.watch(path.dirname(userDataDir), (eventType, filename) => {
+ if (filename !== path.basename(userDataDir)) {
+ return;
+ }
+ let state = 'MISSING';
+ let isFile = false;
+ try {
+ const stat = fs.statSync(userDataDir);
+ isFile = !stat.isDirectory();
+ state = isFile ? `FILE (${stat.size} bytes)` : 'directory';
+ } catch (err) {
+ // keep MISSING
+ }
+ if (state === watchedState) {
+ return;
+ }
+ watchedState = state;
+ log.warn(`[userData watch] ${new Date().toISOString()} ${eventType}: path is now ${state}`);
+ if (isFile) {
+ try {
+ const head = fs.readFileSync(userDataDir, 'utf8').slice(0, 120);
+ log.warn(`[userData watch] stray content head: ${head}`);
+ } catch (err) {
+ log.warn(`[userData watch] could not read stray file: ${err.message}`);
+ }
+ childProcess.exec('tasklist /FI "IMAGENAME eq electron.exe" /FO CSV', (err, stdout) => {
+ log.warn(`[userData watch] electron processes at flip:\n${err ? err.message : stdout}`);
+ });
+ }
+ });
+} catch (err) {
+ log.warn(`[userData watch] could not watch: ${err.message}`);
+}
let serverData = null;
let mainWindow = null;
let loadUrl = '';
@@ -78,8 +160,6 @@ const loadingMenu = [{
label: '',
}];
-const childProcess = require('child_process');
-
const SERVER_DATA = 'serverData';
const UPLOAD_WINDOWS = 'uploadWindows';
@@ -540,7 +620,14 @@ const showMainWindow = async () => {
...bounds
};
- config.set('winBounds', options);
+ // A failed save must never block quit (EEXIST here when the stray
+ // HSTS file has reoccupied the userData path mid-session).
+ try {
+ ensureUserDataDir('quit');
+ config.set('winBounds', options);
+ } catch (err) {
+ log.warn(`Skipping winBounds save on close: ${err.message}`);
+ }
window.webContents.send('save-and-close');
mainWindow = null;
From 75cd9b19e6b4384273c1df4610bdac9b3e446d05 Mon Sep 17 00:00:00 2001
From: tyeth
Date: Mon, 31 Aug 2026 23:59:16 +0100
Subject: [PATCH 041/135] Feature: Tool change workflows with work-origin
offset preservation
Changing the tool replaces the one physical thing the work origin Z was
calibrated through: the tool tip. Two operator flows, both built on the
tool setter (operator-specified):
Flow A (MCP-managed): measure the old tool, goto_tool_change_position
(operator-set park stored as tool_change_x/y/z in the setter config -
this machine: Z at homing height, X at the far end, Y free; one approval,
two steps: Z up first, then X/Y), operator swaps by hand, measure the new
tool, then apply_tool_length_offset stages a single confirmed
G92 Z(current work Z - (new-old)): nothing moves, the work frame shifts
so work Z 0 stays on the same physical plane. Measurement history
(last/previous, persisted with the config) feeds the delta; differences
over 50mm are refused as a mismatched pair.
Flow B (touchscreen manual-swap wizard, firmware matches tip positions
itself - MCP applies NO offset): run_tool_setter gains stay_at_trigger
(measure then HOLD the tip in contact so the operator confirms the
matched position on the touchscreen; abort still retreats) and
start_from_current (skip travel when the wizard has returned the tool
over the setter - verified within 1.5mm of the centre and above the
floor before trusting it).
Adds .claude/skills/tool-change/SKILL.md with both flows and their
failure modes. 33 tools total.
Co-Authored-By: Claude Fable 5
---
.claude/skills/tool-change/SKILL.md | 78 +++++++++
src/server/services/mcp/README.md | 31 +++-
src/server/services/mcp/toolSetter.ts | 154 +++++++++++++++--
src/server/services/mcp/tools/toolsetter.ts | 179 +++++++++++++++++++-
4 files changed, 419 insertions(+), 23 deletions(-)
create mode 100644 .claude/skills/tool-change/SKILL.md
diff --git a/.claude/skills/tool-change/SKILL.md b/.claude/skills/tool-change/SKILL.md
new file mode 100644
index 0000000000..5b8a230372
--- /dev/null
+++ b/.claude/skills/tool-change/SKILL.md
@@ -0,0 +1,78 @@
+---
+name: tool-change
+description: "Change the CNC tool and keep the work origin true — measure the old tool on the tool setter, park for a manual swap, measure the new tool, and shift the work origin Z by the length difference, all through the Luban MCP tool surface. Use whenever the user wants to change bits/tools mid-job-setup without re-touching the stock."
+---
+
+# Tool change without losing the work origin
+
+A tool change replaces the one physical thing the work origin Z was calibrated
+through: the tool tip. The tool setter (fixed switch on the bed, probe feed
+channel `toolsetter`) measures each tool's trigger height, and the difference
+between two measurements IS the length difference — so the work origin can be
+shifted exactly, without ever re-touching the stock.
+
+## Preconditions
+
+- Probe feed connected (`get_probe_feed_status` — this also arms the overtravel
+ tripwire) and the machine homed and idle.
+- Tool setter reference and the tool-change park position stored
+ (`get_tool_setter_config`; the operator sets them once with
+ `set_tool_setter_config` — on this machine the park is Z at the homing
+ height, X at the far end, Y free).
+- Every motion step below stages a job the OPERATOR approves on a confirm page;
+ the one-time code they give you goes to `start_gcode_job`.
+
+## Two flows — ask which one the operator is using
+
+**A. MCP-managed offset** (operator at the computer): measure old → park → swap
+→ measure new → `apply_tool_length_offset` shifts the work origin. Steps below.
+
+**B. Touchscreen manual-swap wizard** (operator at the machine): the FIRMWARE
+matches the tip positions itself, so no origin shift is applied by MCP — the
+agent's job is only to find and HOLD the trigger height for each tool:
+
+1. `run_tool_setter` with `stay_at_trigger: true` — one move up, over to the
+ setter, measure, and hold the tip in contact. Send no other motion.
+2. The operator confirms the position on the touchscreen and swaps the tool by
+ hand; the wizard returns the new tool over the setter near height.
+3. `run_tool_setter` again with `stay_at_trigger: true, start_from_current:
+ true` (skips travel; verified over the centre within 1.5 mm). The operator
+ confirms the matched position on the touchscreen — the firmware applies the
+ offset. Do NOT also call `apply_tool_length_offset` (it would double-apply).
+4. Only after the operator says the wizard is finished may motion resume.
+
+If the new tool reads already-triggered before the second run (a longer tool
+pressed into the setter by the wizard), the run refuses to start - the
+operator raises it slightly from the touchscreen first.
+
+## The sequence (flow A)
+
+1. **Measure the old tool** — `run_tool_setter` with the operator-stated
+ `bit_length_mm`. Skip only if the last stored measurement
+ (`get_tool_setter_config` → `measurements.last`) is from this same tool,
+ this session, and the operator confirms nothing has moved.
+2. **Park** — `goto_tool_change_position`. One approval, two
+ `start_gcode_job` calls: Z rises to the park height first, then X/Y.
+3. **The operator swaps the tool by hand.** Wait for their word; never infer
+ it. Ask them for the new tool's approximate length.
+4. **Measure the new tool** — `run_tool_setter` with the new `bit_length_mm`.
+ The measurement history now holds previous = old tool, last = new tool.
+5. **Shift the work origin** — `apply_tool_length_offset` (defaults to those
+ two measurements). It stages a single `G92` — nothing moves; the work frame
+ shifts by `new − old`. A longer tool makes the current work Z read LOWER.
+6. **Verify** — `get_position`: `originOffset.z` must have changed by the
+ delta, and the operator should sanity-check the displayed work Z against
+ physical reality before any cutting.
+
+## Failure modes to respect
+
+- The spread reported by `run_tool_setter` is the trust metric: passes that
+ disagree by more than one fine step mean feed latency or a loose tool —
+ re-measure before applying any offset.
+- `apply_tool_length_offset` refuses deltas over 50 mm; if it triggers, the
+ stored measurements are not an old/new pair (stale history, wrong bit
+ declared). Pass `old_trigger_z`/`new_trigger_z` explicitly from known-good
+ values instead of loosening anything.
+- If the overtravel alarm latches at any point, everything stops until the
+ operator physically inspects and explicitly clears it
+ (`clear_overtravel_alarm`).
diff --git a/src/server/services/mcp/README.md b/src/server/services/mcp/README.md
index 5104320926..7d35bd531c 100644
--- a/src/server/services/mcp/README.md
+++ b/src/server/services/mcp/README.md
@@ -102,7 +102,9 @@ and tool activity — all timestamped. Events: `mcp:activity`, `mcp:gcode` (whit
- Controller has **G53 (machine workspace) and G54+ (numbered workspaces)**; the heartbeat
`pos` is in the *currently selected* workspace. Convention everywhere:
`machine = work − originOffset`.
-- **Machine home = (−19, 342, 328)**; homing = `G53;G28;G54` exactly like Luban's button
+- **Machine home = (−19, 342, 328)**; **firmware X limit = 339** (sweep-verified 2026-09-01:
+ tracks requests exactly through 330, clamps a 340 request at 339 — the tool-change park X);
+ homing = `G53;G28;G54` exactly like Luban's button
(a bare G28 leaves reporting in an unselected workspace → impossible derived coords like
Y 464/Z 656). Homing takes ~15–20 s and **also homes B — stock on the rotary rotates**.
- **Work origins are operator-set per workspace and persist across homing.**
@@ -120,7 +122,7 @@ and tool activity — all timestamped. Events: `mcp:activity`, `mcp:gcode` (whit
- Fleet: machine named "Snapmaker" @ 192.168.1.173 = the A350 CNC; "F350" @ .130 = the
3D printer. Same Luban profile identifier — distinguish by NAME/address, never profile.
-## Tool surface (31)
+## Tool surface (33)
`get_connection_status` · `get_machine_profile` (kinematics, module offsets) ·
`get_position` (both frames, warnings on incoherent reporting) ·
@@ -144,7 +146,30 @@ measurement: ONE operator approval covers a server-driven envelope-bounded routi
centre, Z to `triggerZ + (longest−ref) + 50`, 1 mm sensor-gated descent, release, 0.1 mm
approach, 0.3 mm backoff, ≥2 s/0.1 mm confirm pass, retreat; hard floor at expected
trigger − margin; requires the probe feed connected and the toolsetter sensor readable
-and untriggered; `store_as_reference` locks the measured Z in as the new reference).
+and untriggered; `store_as_reference` locks the measured Z in as the new reference;
+`stay_at_trigger` / `start_from_current` support the touchscreen swap wizard) ·
+`goto_tool_change_position` (two approved steps: Z up, then X/Y to the operator-set park) ·
+`apply_tool_length_offset` (confirmed G92 shifting work-origin Z by the measured
+new−old tool length difference — flow A only).
+
+## Tool change workflows
+
+**A — MCP-managed offset**: measure old tool (`run_tool_setter`) →
+`goto_tool_change_position` (operator-set park: machine Z at homing height, X at the far
+end, Y free — stored via `set_tool_setter_config` `tool_change_x/y/z`) → operator swaps by
+hand → measure new tool → `apply_tool_length_offset` stages a single
+`G92 Z(current work Z − (new−old))` for confirmation: nothing moves, the work frame shifts
+so work Z 0 stays on the same physical plane. Measurement history (last/previous) persists
+in the config; deltas over 50 mm are refused.
+
+**B — touchscreen manual-swap wizard**: the firmware matches tip positions itself, so MCP
+applies NO offset. `run_tool_setter` with `stay_at_trigger: true` measures and HOLDS the
+tip in contact for the operator to confirm on the touchscreen; after the swap the wizard
+returns the tool over the setter, and the second run adds `start_from_current: true`
+(skips travel, verified over the centre within 1.5 mm). Never combine flow B with
+`apply_tool_length_offset` — it would double-apply.
+
+Full agent guidance in `.claude/skills/tool-change/SKILL.md`.
## Development workflow (learned the hard way)
diff --git a/src/server/services/mcp/toolSetter.ts b/src/server/services/mcp/toolSetter.ts
index c981eea635..0a06d99c07 100644
--- a/src/server/services/mcp/toolSetter.ts
+++ b/src/server/services/mcp/toolSetter.ts
@@ -48,15 +48,51 @@ export interface ToolSetterConfig {
referenceBitLengthMm: number;
longestBitLengthMm: number;
floorMarginMm: number; // how far below the expected trigger Z to allow
+ // Tool-change park position (machine coords), operator preference: on
+ // this setup Z = the homing height and X = the far end; Y is free (null
+ // = leave the current Y alone).
+ changeX: number | null;
+ changeY: number | null;
+ changeZ: number | null;
notes: string | null;
}
-export function getToolSetterConfig(): ToolSetterConfig | null {
+/** One completed tool setter measurement, kept for tool-change offsets. */
+export interface ToolMeasurement {
+ measuredTriggerZ: number;
+ bitLengthMm: number;
+ spreadMm: number;
+ at: number;
+}
+
+function rawConfig(): { [key: string]: unknown } {
const raw = config.get(CONFIG_KEY);
+ return raw && typeof raw === 'object' ? (raw as { [key: string]: unknown }) : {};
+}
+
+function numberOrNull(value: unknown): number | null {
+ return Number.isFinite(Number(value)) && value !== null && value !== '' && value !== undefined
+ ? Number(value) : null;
+}
+
+function parseMeasurement(raw: unknown): ToolMeasurement | null {
if (!raw || typeof raw !== 'object') {
return null;
}
- const cfg = raw as { [key: string]: unknown };
+ const m = raw as { [key: string]: unknown };
+ if (!Number.isFinite(Number(m.measuredTriggerZ)) || !Number.isFinite(Number(m.at))) {
+ return null;
+ }
+ return {
+ measuredTriggerZ: Number(m.measuredTriggerZ),
+ bitLengthMm: Number(m.bitLengthMm),
+ spreadMm: Number(m.spreadMm) || 0,
+ at: Number(m.at),
+ };
+}
+
+export function getToolSetterConfig(): ToolSetterConfig | null {
+ const cfg = rawConfig();
const numbers = ['centerX', 'centerY', 'triggerZ', 'referenceBitLengthMm', 'longestBitLengthMm'];
if (numbers.some((key) => !Number.isFinite(Number(cfg[key])))) {
return null;
@@ -68,16 +104,38 @@ export function getToolSetterConfig(): ToolSetterConfig | null {
referenceBitLengthMm: Number(cfg.referenceBitLengthMm),
longestBitLengthMm: Number(cfg.longestBitLengthMm),
floorMarginMm: Number.isFinite(Number(cfg.floorMarginMm)) ? Number(cfg.floorMarginMm) : 3,
+ changeX: numberOrNull(cfg.changeX),
+ changeY: numberOrNull(cfg.changeY),
+ changeZ: numberOrNull(cfg.changeZ),
notes: cfg.notes ? String(cfg.notes) : null,
};
}
+/** Merge-write: measurement history and unknown fields survive config edits. */
export function setToolSetterConfig(cfg: ToolSetterConfig): void {
- config.set(CONFIG_KEY, cfg);
+ config.set(CONFIG_KEY, { ...rawConfig(), ...cfg });
log.info(`Tool setter config stored: centre (${cfg.centerX}, ${cfg.centerY}), `
+ `trigger Z ${cfg.triggerZ} with ${cfg.referenceBitLengthMm} mm reference bit`);
}
+export function getMeasurements(): { last: ToolMeasurement | null; previous: ToolMeasurement | null } {
+ const cfg = rawConfig();
+ return {
+ last: parseMeasurement(cfg.lastMeasurement),
+ previous: parseMeasurement(cfg.previousMeasurement),
+ };
+}
+
+/** Record a completed measurement, shifting the old one down a slot. */
+export function recordMeasurement(measurement: ToolMeasurement): void {
+ const cfg = rawConfig();
+ config.set(CONFIG_KEY, {
+ ...cfg,
+ previousMeasurement: cfg.lastMeasurement || null,
+ lastMeasurement: measurement,
+ });
+}
+
export interface ToolSetterPlan {
config: ToolSetterConfig;
bitLengthMm: number;
@@ -97,6 +155,11 @@ export interface ToolSetterPlan {
sensorDelayMs: number;
confirmPasses: number;
storeAsReference: boolean;
+ // Touchscreen manual-swap wizard support: hold at the measured trigger
+ // (in contact) instead of retreating, and/or start the descent from the
+ // current position (wizard returns the new tool over the setter).
+ stayAtTrigger: boolean;
+ startFromCurrent: boolean;
}
/**
@@ -113,6 +176,8 @@ export function planToolSetterRun(args: {
start_clearance_mm?: number;
slow_zone_mm?: number;
store_as_reference?: boolean;
+ stay_at_trigger?: boolean;
+ start_from_current?: boolean;
}): ToolSetterPlan {
const cfg = getToolSetterConfig();
if (!cfg) {
@@ -161,6 +226,8 @@ export function planToolSetterRun(args: {
sensorDelayMs,
confirmPasses,
storeAsReference: args.store_as_reference === true,
+ stayAtTrigger: args.stay_at_trigger === true,
+ startFromCurrent: args.start_from_current === true,
};
}
@@ -186,9 +253,16 @@ export function describePlanAsGcode(plan: ToolSetterPlan): string {
'; overtravel feed trips -> job stop + connection close + latched alarm',
'G90',
'G53;',
- `G0 X${c.centerX.toFixed(3)} Y${c.centerY.toFixed(3)}; XY to setter centre at current (post-home) Z`,
- `G1 Z${plan.startZ.toFixed(3)} F${TRAVEL_FEED}; travel to start height`,
];
+ if (plan.startFromCurrent) {
+ lines.push('; START FROM CURRENT POSITION (verified over the setter centre within 1.5 mm);',
+ '; no travel moves - the descent below begins at the current Z (capped at the start height).');
+ } else {
+ lines.push(
+ `G0 X${c.centerX.toFixed(3)} Y${c.centerY.toFixed(3)}; XY to setter centre at current (post-home) Z`,
+ `G1 Z${plan.startZ.toFixed(3)} F${TRAVEL_FEED}; travel to start height`,
+ );
+ }
let z = plan.startZ;
let step = 0;
while (z - plan.coarseStepMm >= plan.coarseFloorZ - 1e-9) {
@@ -211,9 +285,14 @@ export function describePlanAsGcode(plan: ToolSetterPlan): string {
`; ...on contact: ${plan.confirmPasses} quick confirm cycles, each = lift ${plan.backoffMm} mm, wait for the`,
`; sensor to release, re-approach in ${plan.fineStepMm} mm steps to contact. Result = median of the`,
'; cycle contacts (spread reported); a cycle never descends more than 0.5 mm below first contact.',
- `G1 Z${plan.startZ.toFixed(3)} F${TRAVEL_FEED}; retreat to start height when done (also on any abort)`,
- 'G54;',
);
+ if (plan.stayAtTrigger) {
+ lines.push('; HOLD AT TRIGGER when done: the tip stays in contact for the touchscreen manual-swap',
+ `; wizard - NO final retreat. (Any ABORT still retreats to Z ${plan.startZ.toFixed(3)}.)`);
+ } else {
+ lines.push(`G1 Z${plan.startZ.toFixed(3)} F${TRAVEL_FEED}; retreat to start height when done (also on any abort)`);
+ }
+ lines.push('G54;');
return lines.join('\n');
}
@@ -443,13 +522,33 @@ export async function runToolSetterProcedure(plan: ToolSetterPlan): Promise 1.5 || Math.abs(y - c.centerY) > 1.5) {
+ throw new ProcedureAbort(`start_from_current: machine XY (${x.toFixed(1)}, ${y.toFixed(1)}) `
+ + `is not over the setter centre (${c.centerX}, ${c.centerY}) within 1.5 mm.`);
+ }
+ if (z <= plan.floorZ) {
+ throw new ProcedureAbort(`start_from_current: machine Z ${z.toFixed(2)} is at or below the `
+ + `hard floor ${plan.floorZ.toFixed(2)}.`);
+ }
+ currentZ = Math.min(z, plan.startZ);
+ announce('start-from-current', currentZ, 'skipping travel; descending from the current position');
+ } else {
+ // Phase 1: position over the centre, then travel down to start
+ // height. XY first at the current (post-home, high) Z, so the bit
+ // never sweeps across the bed at approach height.
+ announce('travel-xy', position.machine.z ?? plan.startZ, `XY to (${c.centerX}, ${c.centerY})`);
+ await moveMachineSettled('toolsetter:travel', { x: c.centerX, y: c.centerY }, TRAVEL_FEED);
+ announce('travel-z', plan.startZ);
+ await moveMachineSettled('toolsetter:travel', { z: plan.startZ }, TRAVEL_FEED);
+ }
// Phase 2: coarse descent, sensor-checked after every settled step,
// ONLY down to the slow zone above the expected trigger. Contact in
@@ -560,11 +659,27 @@ export async function runToolSetterProcedure(plan: ToolSetterPlan): Promise plan.fineStepMm + 1e-9
? `Confirm passes spread ${spreadMm} mm exceeds one fine step - feed timing was unstable; `
+ 'consider more confirm_passes or a longer sensor_delay_ms.'
diff --git a/src/server/services/mcp/tools/toolsetter.ts b/src/server/services/mcp/tools/toolsetter.ts
index 7ba21553d7..4c2357c688 100644
--- a/src/server/services/mcp/tools/toolsetter.ts
+++ b/src/server/services/mcp/tools/toolsetter.ts
@@ -1,15 +1,19 @@
/* eslint-disable camelcase */
// MCP tool arguments are snake_case by convention.
+import { connectionManager } from '../../machine/ConnectionManager';
import { jobManager } from '../jobs';
+import { probeFeedService } from '../probeFeed';
import { McpToolError, ToolRegistry } from '../registry';
import {
describePlanAsGcode,
+ getMeasurements,
getToolSetterConfig,
planToolSetterRun,
runToolSetterProcedure,
setToolSetterConfig,
} from '../toolSetter';
import { validateGcode } from '../validator';
+import { getPositionSnapshot } from './machine';
export function registerToolSetterTools(registry: ToolRegistry, getConfirmBaseUrl: () => string): void {
registry.register({
@@ -28,6 +32,9 @@ export function registerToolSetterTools(registry: ToolRegistry, getConfirmBaseUr
reference_bit_length_mm: { type: 'number', description: 'Protrusion of the reference bit, mm.' },
longest_bit_length_mm: { type: 'number', description: 'Longest bit in use, mm - sets the safe start height.' },
floor_margin_mm: { type: 'number', description: 'Allowed descent below the expected trigger Z, default 3.' },
+ tool_change_x: { type: 'number', description: 'Machine X of the tool-change park position (operator preference).' },
+ tool_change_y: { type: 'number', description: 'Machine Y of the park position; omit if Y does not matter.' },
+ tool_change_z: { type: 'number', description: 'Machine Z of the park position, typically the homing height.' },
notes: { type: 'string' },
},
required: ['center_x', 'center_y', 'trigger_z', 'reference_bit_length_mm', 'longest_bit_length_mm'],
@@ -40,6 +47,9 @@ export function registerToolSetterTools(registry: ToolRegistry, getConfirmBaseUr
reference_bit_length_mm?: number;
longest_bit_length_mm?: number;
floor_margin_mm?: number;
+ tool_change_x?: number;
+ tool_change_y?: number;
+ tool_change_z?: number;
notes?: string;
}) => {
const numbers = {
@@ -59,9 +69,22 @@ export function registerToolSetterTools(registry: ToolRegistry, getConfirmBaseUr
if (!Number.isFinite(floorMarginMm) || floorMarginMm < 0.5 || floorMarginMm > 20) {
throw new McpToolError('floor_margin_mm must be 0.5-20.');
}
+ const existing = getToolSetterConfig();
+ const changeCoord = (value: number | undefined, previous: number | null): number | null => {
+ if (value === undefined) {
+ return previous;
+ }
+ if (!Number.isFinite(Number(value))) {
+ throw new McpToolError('Tool change coordinates must be finite numbers.');
+ }
+ return Number(value);
+ };
setToolSetterConfig({
...numbers,
floorMarginMm,
+ changeX: changeCoord(args.tool_change_x, existing ? existing.changeX : null),
+ changeY: changeCoord(args.tool_change_y, existing ? existing.changeY : null),
+ changeZ: changeCoord(args.tool_change_z, existing ? existing.changeZ : null),
notes: args.notes ? String(args.notes) : null,
});
return { config: getToolSetterConfig() };
@@ -70,9 +93,10 @@ export function registerToolSetterTools(registry: ToolRegistry, getConfirmBaseUr
registry.register({
name: 'get_tool_setter_config',
- description: 'The stored tool setter reference (centre, trigger Z, bit lengths). Read-only.',
+ description: 'The stored tool setter reference (centre, trigger Z, bit lengths, tool-change '
+ + 'park position) and the last two measurements. Read-only.',
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
- handler: async () => ({ config: getToolSetterConfig() }),
+ handler: async () => ({ config: getToolSetterConfig(), measurements: getMeasurements() }),
});
registry.register({
@@ -113,6 +137,18 @@ export function registerToolSetterTools(registry: ToolRegistry, getConfirmBaseUr
description: 'After a successful run, store the measured trigger Z and this bit '
+ 'length as the new reference (locks in the setter height).',
},
+ stay_at_trigger: {
+ type: 'boolean',
+ description: 'HOLD the tip at the measured trigger (in contact) instead of '
+ + 'retreating - for the touchscreen manual-swap wizard, where the operator '
+ + 'confirms the matched position there. Send no other motion until they finish.',
+ },
+ start_from_current: {
+ type: 'boolean',
+ description: 'Skip the XY move and Z travel; descend from the CURRENT position '
+ + '(verified over the setter centre within 1.5 mm) - for when the touchscreen '
+ + 'wizard has already returned the new tool over the setter.',
+ },
reason: { type: 'string', description: 'Shown to the operator: why this measurement is needed.' },
},
required: ['bit_length_mm', 'reason'],
@@ -149,6 +185,8 @@ export function registerToolSetterTools(registry: ToolRegistry, getConfirmBaseUr
sensor_delay_ms: plan.sensorDelayMs,
confirm_passes: plan.confirmPasses,
store_as_reference: plan.storeAsReference,
+ stay_at_trigger: plan.stayAtTrigger,
+ start_from_current: plan.startFromCurrent,
},
confirm_url: `${getConfirmBaseUrl()}/confirm/${job.id}`,
next_step: 'Ask the operator to open confirm_url, review the SERVER-DRIVEN PROCEDURE '
@@ -158,4 +196,141 @@ export function registerToolSetterTools(registry: ToolRegistry, getConfirmBaseUr
};
},
});
+
+ registry.register({
+ name: 'goto_tool_change_position',
+ description: 'Stage the move to the operator-set tool-change park position (machine coords '
+ + 'from set_tool_setter_config: typically Z at the homing height and X at the far end; '
+ + 'Y only if configured). Two approved steps: Z up first, then X/Y. The operator then '
+ + 'swaps the tool BY HAND; afterwards run_tool_setter measures the new tool and '
+ + 'apply_tool_length_offset shifts the work origin by the length difference.',
+ inputSchema: {
+ type: 'object',
+ properties: {
+ reason: { type: 'string', description: 'Shown to the operator.' },
+ },
+ required: ['reason'],
+ additionalProperties: false,
+ },
+ handler: async (args: { reason?: string }) => {
+ probeFeedService.assertNoOvertravel();
+ const cfg = getToolSetterConfig();
+ if (!cfg || cfg.changeZ === null || cfg.changeX === null) {
+ throw new McpToolError('No tool-change position stored. Ask the operator for it and set '
+ + 'tool_change_x / tool_change_z (and optionally _y) via set_tool_setter_config.');
+ }
+ const position = getPositionSnapshot();
+ if (position.machineStatus !== 'idle') {
+ throw new McpToolError(`Machine is ${position.machineStatus || 'in an unknown state'}, not idle.`);
+ }
+ if (position.isHomed !== true) {
+ throw new McpToolError('Machine does not report homed; home before the tool-change move.');
+ }
+ const state = connectionManager.getLatestMachineState() as { headStatus?: unknown; headPower?: unknown } | null;
+ const headPower = Number(state && state.headPower);
+ if ((Number.isFinite(headPower) && headPower > 0) || (state && (state.headStatus === true || state.headStatus === 'on'))) {
+ throw new McpToolError('Toolhead appears to be on; refusing the tool-change move.');
+ }
+
+ // Z clears first, at the move_z feed cap; XY travels as a rapid
+ // only once at height. One approval covers both exact steps.
+ const steps = [
+ `G90\nG53;\nG1 Z${cfg.changeZ.toFixed(3)} F600;\nG54;`,
+ `G90\nG53;\nG0 X${cfg.changeX.toFixed(3)}${cfg.changeY !== null ? ` Y${cfg.changeY.toFixed(3)}` : ''};\nG54;`,
+ ];
+ const reviewText = steps.join('\n; --- next approved step ---\n');
+ const validation = validateGcode(reviewText);
+ const job = jobManager.submit(
+ reviewText,
+ `tool-change park Z${cfg.changeZ} X${cfg.changeX} - ${String(args.reason).slice(0, 40)}`,
+ 'cnc',
+ validation,
+ 'direct',
+ steps
+ );
+ return {
+ job: jobManager.describe(job),
+ park: { x: cfg.changeX, y: cfg.changeY, z: cfg.changeZ },
+ confirm_url: `${getConfirmBaseUrl()}/confirm/${job.id}`,
+ next_step: 'Operator approves once; call start_gcode_job with the code TWICE (Z step, '
+ + 'then XY step). Then the operator swaps the tool by hand; measure it with '
+ + 'run_tool_setter and finish with apply_tool_length_offset.',
+ };
+ },
+ });
+
+ registry.register({
+ name: 'apply_tool_length_offset',
+ description: 'After a tool change: shift the CURRENT work origin Z by the measured length '
+ + 'difference between the last two tool setter measurements (new - previous; overridable '
+ + 'via explicit old/new trigger Zs), so work Z keeps meaning the same physical plane with '
+ + 'the new tool. Stages a single G92 for operator confirmation - nothing moves; the work '
+ + 'coordinate frame shifts. Verify with get_position afterwards.',
+ inputSchema: {
+ type: 'object',
+ properties: {
+ old_trigger_z: { type: 'number', description: 'Machine trigger Z of the OLD tool. Default: the previous measurement.' },
+ new_trigger_z: { type: 'number', description: 'Machine trigger Z of the NEW tool. Default: the last measurement.' },
+ reason: { type: 'string', description: 'Shown to the operator.' },
+ },
+ required: ['reason'],
+ additionalProperties: false,
+ },
+ handler: async (args: { old_trigger_z?: number; new_trigger_z?: number; reason?: string }) => {
+ probeFeedService.assertNoOvertravel();
+ const measurements = getMeasurements();
+ const oldZ = args.old_trigger_z !== undefined ? Number(args.old_trigger_z)
+ : measurements.previous?.measuredTriggerZ;
+ const newZ = args.new_trigger_z !== undefined ? Number(args.new_trigger_z)
+ : measurements.last?.measuredTriggerZ;
+ if (oldZ === undefined || newZ === undefined || !Number.isFinite(oldZ) || !Number.isFinite(newZ)) {
+ throw new McpToolError('Need two measurements: previous (old tool) and last (new tool). '
+ + 'Either run run_tool_setter before and after the change, or pass old_trigger_z / '
+ + `new_trigger_z explicitly. Stored: ${JSON.stringify(measurements)}`);
+ }
+ const deltaMm = Number((newZ - oldZ).toFixed(3));
+ if (Math.abs(deltaMm) > 50) {
+ throw new McpToolError(`Computed length difference ${deltaMm} mm exceeds the 50 mm sanity `
+ + 'limit - the two measurements are probably not an old/new pair of the same setup.');
+ }
+
+ const position = getPositionSnapshot();
+ if (position.machineStatus !== 'idle') {
+ throw new McpToolError(`Machine is ${position.machineStatus || 'in an unknown state'}, not idle.`);
+ }
+ if (position.work.z === null) {
+ throw new McpToolError('Current work Z unknown; cannot compute the G92.');
+ }
+ // A tool longer by delta puts the tip delta lower at the same
+ // toolhead height, so the SAME position must now read delta LESS
+ // work Z: G92 Z(current work Z - delta). Nothing moves.
+ const newWorkZ = Number((position.work.z - deltaMm).toFixed(3));
+ const gcode = [
+ `; tool length offset: new tool trigger Z ${newZ} vs old ${oldZ} -> ${deltaMm >= 0 ? '+' : ''}${deltaMm} mm ${deltaMm >= 0 ? 'longer' : 'shorter'}`,
+ `; current work Z reads ${position.work.z}; after this G92 it reads ${newWorkZ} (no motion)`,
+ '; work origin Z shifts so work Z 0 stays on the same physical plane with the new tool',
+ `G92 Z${newWorkZ.toFixed(3)}`,
+ ].join('\n');
+ const validation = validateGcode(gcode);
+ const job = jobManager.submit(
+ gcode,
+ `tool-offset ${deltaMm >= 0 ? '+' : ''}${deltaMm}mm - ${String(args.reason).slice(0, 40)}`,
+ 'cnc',
+ validation,
+ 'direct'
+ );
+ return {
+ job: jobManager.describe(job),
+ old_trigger_z: oldZ,
+ new_trigger_z: newZ,
+ delta_mm: deltaMm,
+ current_work_z: position.work.z,
+ work_z_after: newWorkZ,
+ confirm_url: `${getConfirmBaseUrl()}/confirm/${job.id}`,
+ next_step: 'Operator reviews the G92 (no motion - the work frame shifts by the tool '
+ + 'length difference) and approves; start_gcode_job executes it. Verify with '
+ + 'get_position that originOffset.z changed by the delta.',
+ };
+ },
+ });
}
From ca71360b5eca54c049295bd31ae17104ecec9074 Mon Sep 17 00:00:00 2001
From: tyeth
Date: Tue, 1 Sep 2026 00:21:49 +0100
Subject: [PATCH 042/135] Feature: Touch probe point measurement and whole-bed
camera survey
The spindle touch probe (normally-open, probe feed channel with inverted
polarity) turns the machine into a measuring device; the camera survey
gives the agent the visual context to know WHERE to measure ('measure the
stock on the rotary axis').
probing.ts extracts the sensor-gated motion engine hardware-proven on the
tool setter (echo-verified settled moves, short contact windows, patient
release detection, channel readiness guards) with multi-channel sensing:
contact = ANY watched channel triggered. run_tool_setter gains
accept_probe_contact for measuring the touch probe ITSELF on the setter -
the probe's own channel fires before the setter switch and pressing on
would bend it (operator-stated), so either trigger confirms the height
and the result notes which channel fired.
probe_point: a single-axis sensor-gated march (+/-X, +/-Y, -Z) from the
current position with a REQUIRED hard travel limit, staged for operator
confirmation with every stepped command enumerated and the envelope
anchored to the staging position (re-verified before motion). Same staged
mechanics as the tool setter: coarse to contact, retreat to release, fine
approach, quick lift-and-retest confirm cycles, median + spread reported,
retreat to start on completion or abort.
survey_bed: one approval drives a serpentine XY grid at the current Z
(machine Z >= 250 unless the operator explicitly confirms clearance),
capturing a frame per settled waypoint; frames are saved to disk with a
machine-position index (bypassing the 12-frame cache) so the whole bed
can be reviewed. Aborts on the first capture failure. 35 tools total.
Co-Authored-By: Claude Fable 5
---
src/server/services/mcp/McpServer.ts | 25 +-
src/server/services/mcp/index.ts | 2 +
src/server/services/mcp/probeTool.ts | 316 ++++++++++++++++++++
src/server/services/mcp/probing.ts | 245 +++++++++++++++
src/server/services/mcp/toolSetter.ts | 245 ++-------------
src/server/services/mcp/tools/camera.ts | 13 +-
src/server/services/mcp/tools/probing.ts | 232 ++++++++++++++
src/server/services/mcp/tools/toolsetter.ts | 7 +
8 files changed, 865 insertions(+), 220 deletions(-)
create mode 100644 src/server/services/mcp/probeTool.ts
create mode 100644 src/server/services/mcp/probing.ts
create mode 100644 src/server/services/mcp/tools/probing.ts
diff --git a/src/server/services/mcp/McpServer.ts b/src/server/services/mcp/McpServer.ts
index 5958cf18df..0c3715fc98 100644
--- a/src/server/services/mcp/McpServer.ts
+++ b/src/server/services/mcp/McpServer.ts
@@ -204,12 +204,29 @@ export class McpServer {
return rpcError(id, JSONRPC_INVALID_PARAMS, `Unknown tool: ${name}`);
}
- // One line per call, arguments elided (they can carry whole gcode
- // files); enough to follow agent activity from the server log.
+ // Every call logs its arguments and a result summary (truncated -
+ // args can carry whole gcode files, results whole images), so the
+ // server log alone tells the story of an agent session.
+ const summarize = (value: unknown, limit: number): string => {
+ let text: string;
+ try {
+ text = JSON.stringify(value, (key, v) => {
+ if (typeof v === 'string' && v.length > 300) {
+ return `${v.slice(0, 120)}...<${v.length} chars>`;
+ }
+ return v;
+ }) || 'undefined';
+ } catch (err) {
+ text = String(value);
+ }
+ return text.length > limit ? `${text.slice(0, limit)}...` : text;
+ };
+ const args = ((params && params.arguments) as object) || {};
const startedAt = Date.now();
+ log.info(`tool ${name} <- ${summarize(args, 600)}`);
try {
- const result = await this.registry.call(name, ((params && params.arguments) as object) || {});
- log.info(`tool ${name} ok in ${Date.now() - startedAt}ms`);
+ const result = await this.registry.call(name, args);
+ log.info(`tool ${name} ok in ${Date.now() - startedAt}ms -> ${summarize(result, 900)}`);
this.onActivity && this.onActivity({ tool: name, ok: true, durationMs: Date.now() - startedAt });
// A tool that returns non-text content (e.g. an image) supplies
// the MCP content array itself via mcpContent.
diff --git a/src/server/services/mcp/index.ts b/src/server/services/mcp/index.ts
index f3c80b62a8..0de0d03bbf 100644
--- a/src/server/services/mcp/index.ts
+++ b/src/server/services/mcp/index.ts
@@ -13,6 +13,7 @@ import { registerGcodeTools } from './tools/gcode';
import { registerLandmarkTools } from './tools/landmarks';
import { registerMachineTools } from './tools/machine';
import { registerProbeTools } from './tools/probe';
+import { registerProbingTools } from './tools/probing';
import { registerStatusTools } from './tools/status';
import { registerToolSetterTools } from './tools/toolsetter';
@@ -112,6 +113,7 @@ export function startMcpService(socketServer?: McpBroadcaster): void {
registerLandmarkTools(registry);
registerProbeTools(registry);
registerToolSetterTools(registry, () => `http://127.0.0.1:${port}`);
+ registerProbingTools(registry, () => `http://127.0.0.1:${port}`);
registeredToolCount = registry.list().length;
broadcaster = socketServer || null;
diff --git a/src/server/services/mcp/probeTool.ts b/src/server/services/mcp/probeTool.ts
new file mode 100644
index 0000000000..6ac28616c0
--- /dev/null
+++ b/src/server/services/mcp/probeTool.ts
@@ -0,0 +1,316 @@
+/* eslint-disable camelcase */
+// MCP tool arguments are snake_case by convention (planProbePoint takes the
+// probe_point arguments verbatim).
+import { mcpBroadcast } from './index';
+import { probeFeedService } from './probeFeed';
+import {
+ COARSE_FEED,
+ FINE_FEED,
+ MAX_RETREAT_MM,
+ ProcedureAbort,
+ TRAVEL_FEED,
+ assertChannelReady,
+ assertMachineReadyForProcedure,
+ moveMachineSettled,
+ senseAfter,
+ senseReleaseAfter,
+} from './probing';
+import { McpToolError } from './registry';
+import { getMachineSizeByIdentifier, getPositionSnapshot } from './tools/machine';
+import { connectionManager } from '../machine/ConnectionManager';
+
+// Point probing with the spindle-mounted touch probe (normally-open, probe
+// feed channel, inverted polarity handled by the feed): a single-axis
+// sensor-gated march from the CURRENT position, using the same staged
+// mechanics hardware-proven on the tool setter - coarse steps to contact,
+// retreat to release, fine steps, then quick lift-and-retest confirm cycles;
+// the result is the median contact coordinate with the spread reported.
+//
+// The envelope the operator approves is anchored to the position at staging:
+// the runner re-verifies it before moving, so the page is always truthful.
+
+export type ProbeAxis = 'x' | 'y' | 'z';
+
+export interface ProbePointPlan {
+ axis: ProbeAxis;
+ direction: 1 | -1;
+ start: { x: number; y: number; z: number }; // machine coords at staging
+ maxTravelMm: number;
+ limitCoord: number; // start[axis] + direction * maxTravel, envelope-clamped
+ coarseStepMm: number;
+ fineStepMm: number;
+ backoffMm: number;
+ sensorDelayMs: number;
+ confirmPasses: number;
+}
+
+export function planProbePoint(args: {
+ axis?: string;
+ direction?: number;
+ max_travel_mm?: number;
+ coarse_step_mm?: number;
+ fine_step_mm?: number;
+ backoff_mm?: number;
+ sensor_delay_ms?: number;
+ confirm_passes?: number;
+}): ProbePointPlan {
+ const axis = String(args.axis || '').toLowerCase() as ProbeAxis;
+ if (!['x', 'y', 'z'].includes(axis)) {
+ throw new McpToolError('axis must be "x", "y" or "z".');
+ }
+ const direction = Number(args.direction);
+ if (direction !== 1 && direction !== -1) {
+ throw new McpToolError('direction must be 1 or -1 (the sign of travel along the axis).');
+ }
+ if (axis === 'z' && direction === 1) {
+ throw new McpToolError('Z probing is downward only (direction -1).');
+ }
+ const maxTravelMm = Number(args.max_travel_mm);
+ if (!Number.isFinite(maxTravelMm) || maxTravelMm < 1 || maxTravelMm > 150) {
+ throw new McpToolError('max_travel_mm is required: how far the probe may march before aborting (1-150).');
+ }
+
+ const position = getPositionSnapshot();
+ const { x, y, z } = position.machine;
+ if (x === null || y === null || z === null) {
+ throw new McpToolError('Current machine position unknown; cannot anchor the probe envelope.');
+ }
+
+ let limitCoord = { x, y, z }[axis] + direction * maxTravelMm;
+ // Clamp to the same envelope the direct-move guards use (machine
+ // -25..size+40 for X/Y; Z never below 0).
+ const size = getMachineSizeByIdentifier(connectionManager.getConnectionStatus().machineIdentifier);
+ if (axis === 'z') {
+ limitCoord = Math.max(limitCoord, 0);
+ } else if (size) {
+ limitCoord = Math.min(Math.max(limitCoord, -25), size[axis] + 40);
+ }
+ if (Math.abs(limitCoord - { x, y, z }[axis]) < 0.5) {
+ throw new McpToolError('The clamped probe travel is under 0.5 mm - already at the envelope edge.');
+ }
+
+ return {
+ axis,
+ direction: direction as 1 | -1,
+ start: { x, y, z },
+ maxTravelMm,
+ limitCoord: Number(limitCoord.toFixed(3)),
+ coarseStepMm: Math.min(Math.max(Number(args.coarse_step_mm) || 1, 0.2), 2),
+ fineStepMm: Math.min(Math.max(Number(args.fine_step_mm) || 0.1, 0.02), 0.5),
+ backoffMm: Math.min(Math.max(Number(args.backoff_mm) || 0.5, Number(args.fine_step_mm) || 0.1), 3),
+ sensorDelayMs: Math.min(Math.max(Number(args.sensor_delay_ms) || 200, 100), 10000),
+ confirmPasses: Math.min(Math.max(Math.round(Number(args.confirm_passes) || 3), 1), 10),
+ };
+}
+
+/** Confirm-page gcode: every stepped command as it would execute. */
+export function describeProbePlanAsGcode(plan: ProbePointPlan): string {
+ const word = plan.axis.toUpperCase();
+ const lines = [
+ '; TOUCH PROBE POINT MEASUREMENT (server-driven, sensor-gated on the probe channel)',
+ '; EVERY LINE IS SENT INDIVIDUALLY: after each move settles, the probe feed is checked',
+ '; before the next line. The march stops at first contact; running the full ladder',
+ `; without contact ABORTS at the travel limit ${word} ${plan.limitCoord.toFixed(3)}.`,
+ `; anchored at machine (${plan.start.x.toFixed(2)}, ${plan.start.y.toFixed(2)}, ${plan.start.z.toFixed(2)})`
+ + ' - re-verified before any motion',
+ '; overtravel feed trips -> job stop + connection close + latched alarm',
+ 'G90',
+ 'G53;',
+ ];
+ let coord = plan.start[plan.axis];
+ let step = 0;
+ const towards = (value: number) => (plan.direction === 1
+ ? Math.min(value, plan.limitCoord) : Math.max(value, plan.limitCoord));
+ while (Math.abs(coord - plan.limitCoord) > 1e-9) {
+ coord = towards(coord + plan.direction * plan.coarseStepMm);
+ step += 1;
+ lines.push(`G1 ${word}${coord.toFixed(3)} F${COARSE_FEED}; coarse step ${step} - settle, check probe, stop at contact`);
+ }
+ lines.push(
+ `; ...on contact: retreat ${plan.coarseStepMm} mm steps until released, approach in ${plan.fineStepMm} mm`,
+ `; steps to contact, then ${plan.confirmPasses} quick confirm cycles (lift ${plan.backoffMm} mm, wait for release,`,
+ `; re-approach). Result = median contact ${word} (spread reported).`,
+ `G1 ${word}${plan.start[plan.axis].toFixed(3)} F${TRAVEL_FEED}; retreat to the start ${word} when done (also on any abort)`,
+ 'G54;',
+ );
+ return lines.join('\n');
+}
+
+export interface ProbePointResult {
+ axis: ProbeAxis;
+ direction: number;
+ contactMachine: { x: number; y: number; z: number };
+ contactWorkOffsetApplied: string;
+ confirmPassContacts: number[];
+ spreadMm: number;
+ phases: { phase: string; coord: number; note?: string }[];
+ note: string;
+ warning?: string;
+}
+
+export async function runProbePointProcedure(plan: ProbePointPlan): Promise {
+ assertChannelReady('probe', 'touch probe');
+ assertMachineReadyForProcedure();
+
+ const position = getPositionSnapshot();
+ const here = position.machine;
+ if (here.x === null || here.y === null || here.z === null
+ || Math.abs(here.x - plan.start.x) > 0.5
+ || Math.abs(here.y - plan.start.y) > 0.5
+ || Math.abs(here.z - plan.start.z) > 0.5) {
+ throw new McpToolError('The machine is not at the position this probe envelope was staged from '
+ + `(staged (${plan.start.x}, ${plan.start.y}, ${plan.start.z}), now `
+ + `(${here.x}, ${here.y}, ${here.z})). Stage probe_point again from the current position.`);
+ }
+
+ const word = plan.axis.toUpperCase();
+ const phases: { phase: string; coord: number; note?: string }[] = [];
+ const announce = (phase: string, coord: number, note?: string) => {
+ phases.push({ phase, coord: Number(coord.toFixed(3)), note });
+ mcpBroadcast('mcp:activity', { tool: 'probe_point', phase, axis: plan.axis, coord: Number(coord.toFixed(3)), note });
+ };
+ const releaseTimeoutMs = Math.max(plan.sensorDelayMs * 4, 2500);
+ const startCoord = plan.start[plan.axis];
+ const towards = (value: number) => (plan.direction === 1
+ ? Math.min(value, plan.limitCoord) : Math.max(value, plan.limitCoord));
+ const move = async (tool: string, coord: number, feed: number) => {
+ await moveMachineSettled(tool, { [plan.axis]: coord } as { x?: number; y?: number; z?: number }, feed);
+ };
+
+ let current = startCoord;
+ try {
+ // Coarse march to first contact.
+ let coarseContact: number | null = null;
+ while (Math.abs(current - plan.limitCoord) > 1e-9) {
+ const stepStart = Date.now();
+ current = towards(current + plan.direction * plan.coarseStepMm);
+ await move('probe:coarse', current, COARSE_FEED);
+ const sensed = await senseAfter('probe', stepStart, plan.sensorDelayMs);
+ if (sensed.contact) {
+ coarseContact = current;
+ announce('coarse-contact', current, `probe "${sensed.reading?.value}"`);
+ break;
+ }
+ }
+ if (coarseContact === null) {
+ throw new ProcedureAbort(`Reached the travel limit ${word} ${plan.limitCoord.toFixed(3)} without `
+ + 'contact - nothing to probe within max_travel_mm, or the probe is not reporting.');
+ }
+
+ // Retreat until released.
+ let released = false;
+ while (Math.abs(current - startCoord) > 1e-9 && Math.abs(current - coarseContact) < MAX_RETREAT_MM + 1e-9) {
+ const stepStart = Date.now();
+ current = plan.direction === 1
+ ? Math.max(current - plan.coarseStepMm, startCoord)
+ : Math.min(current + plan.coarseStepMm, startCoord);
+ await move('probe:release', current, COARSE_FEED);
+ const sensed = await senseReleaseAfter('probe', stepStart, releaseTimeoutMs);
+ if (!sensed.contact) {
+ released = true;
+ announce('released', current);
+ break;
+ }
+ }
+ if (!released) {
+ throw new ProcedureAbort(`Probe still reads triggered ${MAX_RETREAT_MM} mm back from first `
+ + 'contact - stuck probe or feed fault.');
+ }
+
+ // Fine approach.
+ let fineContact: number | null = null;
+ while (Math.abs(current - plan.limitCoord) > 1e-9) {
+ const stepStart = Date.now();
+ current = towards(current + plan.direction * plan.fineStepMm);
+ await move('probe:fine', current, FINE_FEED);
+ const sensed = await senseAfter('probe', stepStart, plan.sensorDelayMs);
+ if (sensed.contact) {
+ fineContact = current;
+ announce('fine-contact', current, `probe "${sensed.reading?.value}"`);
+ break;
+ }
+ }
+ if (fineContact === null) {
+ throw new ProcedureAbort('Fine approach reached the travel limit without re-contact after a '
+ + 'coarse contact - inconsistent probe.');
+ }
+
+ // Quick lift-and-retest confirm cycles; median wins, spread reported.
+ const passContacts: number[] = [];
+ const cycleLimit = towards(fineContact + plan.direction * 0.5);
+ let reference = fineContact;
+ for (let pass = 1; pass <= plan.confirmPasses; pass++) {
+ const liftIssuedAt = Date.now();
+ current = reference - plan.direction * plan.backoffMm;
+ await move('probe:backoff', current, FINE_FEED);
+ const liftSense = await senseReleaseAfter('probe', liftIssuedAt, releaseTimeoutMs);
+ if (liftSense.contact) {
+ throw new ProcedureAbort(`Probe still triggered ${releaseTimeoutMs} ms after backing off `
+ + `${plan.backoffMm} mm - hysteresis exceeds the backoff. Rerun with a larger backoff_mm.`);
+ }
+ let passContact: number | null = null;
+ while (Math.abs(current - cycleLimit) > 1e-9) {
+ const stepStart = Date.now();
+ current = plan.direction === 1
+ ? Math.min(current + plan.fineStepMm, cycleLimit)
+ : Math.max(current - plan.fineStepMm, cycleLimit);
+ await move('probe:confirm', current, FINE_FEED);
+ const sensed = await senseAfter('probe', stepStart, plan.sensorDelayMs);
+ if (sensed.contact) {
+ passContact = current;
+ break;
+ }
+ }
+ if (passContact === null) {
+ throw new ProcedureAbort(`Confirm pass ${pass} went 0.5 mm past the first contact without `
+ + 're-contact - inconsistent probe.');
+ }
+ passContacts.push(Number(passContact.toFixed(3)));
+ announce(`confirm-${pass}`, passContact, `of ${plan.confirmPasses}`);
+ reference = passContact;
+ }
+ const sorted = [...passContacts].sort((a, b) => a - b);
+ const measured = sorted[Math.floor((sorted.length - 1) / 2)];
+ const spreadMm = Number((sorted[sorted.length - 1] - sorted[0]).toFixed(3));
+ announce('measured', measured, `median of [${passContacts.join(', ')}], spread ${spreadMm} mm`);
+
+ // Retreat along the probed axis to the start coordinate.
+ await move('probe:retreat', startCoord, TRAVEL_FEED);
+ announce('retreated', startCoord);
+
+ const contactMachine = { ...plan.start, [plan.axis]: measured };
+ const after = getPositionSnapshot();
+ const result: ProbePointResult = {
+ axis: plan.axis,
+ direction: plan.direction,
+ contactMachine,
+ contactWorkOffsetApplied: `work = machine + originOffset (${JSON.stringify(after.originOffset)})`,
+ confirmPassContacts: passContacts,
+ spreadMm,
+ phases,
+ note: `Probe contact at machine ${word} ${measured.toFixed(3)} - median of ${plan.confirmPasses} `
+ + `passes [${passContacts.join(', ')}], spread ${spreadMm} mm (+/- ${plan.fineStepMm} mm step `
+ + 'resolution, minus the probe tip radius on side probes - the tip touches before its centre).',
+ warning: spreadMm > plan.fineStepMm + 1e-9
+ ? `Confirm passes spread ${spreadMm} mm exceeds one fine step - feed timing was unstable; `
+ + 'consider more confirm_passes or a longer sensor_delay_ms.'
+ : undefined,
+ };
+ return result as unknown as object;
+ } catch (err) {
+ const isTrip = !!probeFeedService.getTrip();
+ if (!isTrip) {
+ try {
+ await move('probe:abort-retreat', startCoord, TRAVEL_FEED);
+ announce('abort-retreated', startCoord);
+ } catch (retreatErr) {
+ // The abort itself already reports; retreat failure is logged
+ // by the activity stream.
+ }
+ }
+ if (err instanceof ProcedureAbort) {
+ throw new McpToolError(`Probe run aborted: ${err.message} Phases completed: ${JSON.stringify(phases)}`);
+ }
+ throw err;
+ }
+}
diff --git a/src/server/services/mcp/probing.ts b/src/server/services/mcp/probing.ts
new file mode 100644
index 0000000000..281ab19531
--- /dev/null
+++ b/src/server/services/mcp/probing.ts
@@ -0,0 +1,245 @@
+import { connectionManager } from '../machine/ConnectionManager';
+import { ProbeChannel, probeFeedService } from './probeFeed';
+import { McpToolError } from './registry';
+import { GcodeChannel, sendGcodeVisible } from './tools/camera';
+import { getPositionSnapshot } from './tools/machine';
+
+// The shared sensor-gated motion engine: settled single moves on the direct
+// path, contact/release sensing against a probe feed channel, and the
+// readiness guard. Extracted from the tool setter (where every piece was
+// hardware-proven 2026-08-31/09-01) so the CNC touch probe reuses the exact
+// same verified mechanics against its own feed channel.
+
+export const TRAVEL_FEED = 600; // mm/min, matches the move_z cap
+export const COARSE_FEED = 100;
+export const FINE_FEED = 60;
+const SETTLE_TIMEOUT_MS = 30000;
+const SETTLE_POLL_MS = 250;
+export const SETTLE_TOLERANCE_MM = 0.15;
+export const MAX_RETREAT_MM = 5; // still triggered after this much retreat = stuck sensor
+
+export class ProcedureAbort extends Error {}
+
+export interface StepResult {
+ contact: boolean;
+ reading: { value: string; receivedAt: number } | null;
+ // Which channel fired, when sensing across several (e.g. measuring the
+ // spindle touch probe on the tool setter accepts either).
+ channel?: ProbeChannel;
+}
+
+export function getDirectChannel(): GcodeChannel {
+ const channel = connectionManager.getCurrentChannel() as unknown as GcodeChannel;
+ if (!channel || typeof channel.executeGcode !== 'function') {
+ throw new ProcedureAbort('No machine channel with direct command support.');
+ }
+ return channel;
+}
+
+export async function sleep(ms: number): Promise {
+ return new Promise((resolve) => {
+ setTimeout(resolve, ms);
+ });
+}
+
+/**
+ * Issue one absolute machine-frame move and block until it verifiably
+ * completed. Fast path: the HTTP channel executes gcode synchronously and its
+ * reply echoes the completed position in WORK coordinates - an exact match
+ * (0.02 mm) is proof of arrival with no heartbeat wait. Fallback: heartbeat
+ * settle (report newer than issue, machine idle, axes at target; moves larger
+ * than 3x the tolerance accept the first at-target beat, smaller ones require
+ * a stable double-beat since a stale beat could pass). The overtravel latch
+ * is re-checked before the move and on every poll.
+ */
+export async function moveMachineSettled(
+ tool: string,
+ target: { x?: number; y?: number; z?: number },
+ feed: number
+): Promise {
+ probeFeedService.assertNoOvertravel();
+ const channel = getDirectChannel();
+ const words = [
+ target.x !== undefined ? `X${target.x.toFixed(3)}` : '',
+ target.y !== undefined ? `Y${target.y.toFixed(3)}` : '',
+ target.z !== undefined ? `Z${target.z.toFixed(3)}` : '',
+ ].filter(Boolean).join(' ');
+ const gcode = `G90\nG53;\nG1 ${words} F${feed};\nG54;`;
+
+ const before = getPositionSnapshot();
+ const bigMove = (['x', 'y', 'z'] as const).every((axis) => {
+ const want = target[axis];
+ if (want === undefined) {
+ return true;
+ }
+ const from = before.machine[axis];
+ return from !== null && Math.abs(want - from) > SETTLE_TOLERANCE_MM * 3;
+ });
+
+ const issuedAt = Date.now();
+ const executed = await sendGcodeVisible(channel, tool, gcode);
+ if (executed.result !== 0) {
+ throw new ProcedureAbort(`Controller rejected the move: ${executed.text || executed.result}`);
+ }
+
+ const echo = String(executed.text || '').match(/X:(-?\d+(?:\.\d+)?)\s+Y:(-?\d+(?:\.\d+)?)\s+Z:(-?\d+(?:\.\d+)?)/);
+ if (echo) {
+ const offset = before.originOffset;
+ const echoMachine = {
+ x: Number(echo[1]) - offset.x,
+ y: Number(echo[2]) - offset.y,
+ z: Number(echo[3]) - offset.z,
+ };
+ const exact = (['x', 'y', 'z'] as const).every((axis) => {
+ const want = target[axis];
+ return want === undefined || Math.abs(echoMachine[axis] - want) <= 0.02;
+ });
+ if (exact) {
+ return;
+ }
+ }
+
+ const deadline = issuedAt + SETTLE_TIMEOUT_MS;
+ let previous: string | null = null;
+ while (Date.now() < deadline) {
+ await sleep(SETTLE_POLL_MS);
+ probeFeedService.assertNoOvertravel();
+ let now;
+ try {
+ now = getPositionSnapshot();
+ } catch (err) {
+ continue;
+ }
+ const reportTime = Date.now() - now.reportAgeMs;
+ const fingerprint = JSON.stringify([now.work, now.originOffset]);
+ const stable = fingerprint === previous;
+ previous = fingerprint;
+ if (reportTime <= issuedAt || now.machineStatus !== 'idle') {
+ continue;
+ }
+ if (!bigMove && !stable) {
+ continue;
+ }
+ const atTarget = (['x', 'y', 'z'] as const).every((axis) => {
+ const want = target[axis];
+ const have = now.machine[axis];
+ return want === undefined || (have !== null && Math.abs(have - want) <= SETTLE_TOLERANCE_MM);
+ });
+ if (atTarget) {
+ return;
+ }
+ }
+ throw new ProcedureAbort(`Timed out waiting for the heartbeat to verify the move to ${words}.`);
+}
+
+/**
+ * CONTACT detection after a settled step: give the sensor's report a short
+ * window to arrive (early-exit on a fresh reading), then judge from the LAST
+ * KNOWN state - the sensor publishes on change, so silence means unchanged.
+ * A late contact message merely costs one extra step (self-correcting).
+ */
+export async function senseAfter(
+ channels: ProbeChannel | ProbeChannel[],
+ stepIssuedAt: number,
+ delayMs: number
+): Promise {
+ const list = Array.isArray(channels) ? channels : [channels];
+ const deadline = Date.now() + delayMs;
+ for (;;) {
+ for (const channel of list) {
+ const state = probeFeedService.getReading(channel);
+ if (state && state.triggered) {
+ return {
+ contact: true,
+ reading: { value: state.value, receivedAt: state.receivedAt },
+ channel,
+ };
+ }
+ }
+ if (Date.now() >= deadline) {
+ const state = probeFeedService.getReading(list[0]);
+ return {
+ contact: false,
+ reading: state ? { value: state.value, receivedAt: state.receivedAt } : null,
+ channel: list[0],
+ };
+ }
+ await sleep(Math.min(50, Math.max(1, deadline - Date.now())));
+ }
+}
+
+/**
+ * RELEASE detection needs different patience: a stale "triggered" here is a
+ * false abort (live-hit on the tool setter: the cloud round-trip of the
+ * release message exceeded the contact window). Wait until the last known
+ * state reads untriggered, early-exiting on fresh readings, up to timeoutMs;
+ * only then is "still triggered" believed.
+ */
+export async function senseReleaseAfter(
+ channels: ProbeChannel | ProbeChannel[],
+ stepIssuedAt: number,
+ timeoutMs: number
+): Promise {
+ const list = Array.isArray(channels) ? channels : [channels];
+ const deadline = Date.now() + timeoutMs;
+ for (;;) {
+ const states = list.map((channel) => ({ channel, state: probeFeedService.getReading(channel) }));
+ const stillTriggered = states.find((s) => s.state && s.state.triggered);
+ if (!stillTriggered) {
+ const first = states[0].state;
+ return {
+ contact: false,
+ reading: first ? { value: first.value, receivedAt: first.receivedAt } : null,
+ channel: states[0].channel,
+ };
+ }
+ if (Date.now() >= deadline) {
+ return {
+ contact: true,
+ reading: stillTriggered.state
+ ? { value: stillTriggered.state.value, receivedAt: stillTriggered.state.receivedAt }
+ : null,
+ channel: stillTriggered.channel,
+ };
+ }
+ await sleep(Math.min(100, Math.max(1, deadline - Date.now())));
+ }
+}
+
+/**
+ * The sensor must be connected, have reported at least once, and read
+ * untriggered before any sensor-gated approach may start.
+ */
+export function assertChannelReady(channel: ProbeChannel, what: string): void {
+ probeFeedService.assertNoOvertravel();
+ if (!probeFeedService.isConnected()) {
+ throw new McpToolError('Probe feed is not connected - connect_probe_feed first. The overtravel '
+ + `tripwire MUST be armed for a ${what} run.`);
+ }
+ const reading = probeFeedService.getReading(channel);
+ if (!reading) {
+ throw new McpToolError(`No reading has ever arrived on the ${channel} feed - cannot trust the `
+ + 'sensor. Ask the operator to trigger it by hand and watch get_probe_feed_status until '
+ + 'the touch shows up.');
+ }
+ if (reading.triggered) {
+ throw new McpToolError(`The ${channel} feed already reads triggered ("${reading.value}") before `
+ + 'any approach - the sensor is stuck or already in contact. Resolve physically first.');
+ }
+}
+
+/** Machine idle, homed, toolhead off - the common motion preconditions. */
+export function assertMachineReadyForProcedure(): void {
+ const position = getPositionSnapshot();
+ if (position.machineStatus !== 'idle') {
+ throw new McpToolError(`Machine is ${position.machineStatus || 'in an unknown state'}, not idle.`);
+ }
+ if (position.isHomed !== true) {
+ throw new McpToolError('Machine does not report homed; home first.');
+ }
+ const state = connectionManager.getLatestMachineState() as { headStatus?: unknown; headPower?: unknown } | null;
+ const headPower = Number(state && state.headPower);
+ if ((Number.isFinite(headPower) && headPower > 0) || (state && (state.headStatus === true || state.headStatus === 'on'))) {
+ throw new McpToolError('Toolhead appears to be on; refusing to run the procedure.');
+ }
+}
diff --git a/src/server/services/mcp/toolSetter.ts b/src/server/services/mcp/toolSetter.ts
index 0a06d99c07..6add382c8d 100644
--- a/src/server/services/mcp/toolSetter.ts
+++ b/src/server/services/mcp/toolSetter.ts
@@ -3,11 +3,21 @@
// the run_tool_setter arguments verbatim).
import logger from '../../lib/logger';
import config from '../configstore';
-import { connectionManager } from '../machine/ConnectionManager';
import { mcpBroadcast } from './index';
-import { probeFeedService } from './probeFeed';
+import { ProbeChannel, probeFeedService } from './probeFeed';
+import {
+ COARSE_FEED,
+ FINE_FEED,
+ MAX_RETREAT_MM,
+ ProcedureAbort,
+ TRAVEL_FEED,
+ assertChannelReady,
+ assertMachineReadyForProcedure,
+ moveMachineSettled,
+ senseAfter,
+ senseReleaseAfter,
+} from './probing';
import { McpToolError } from './registry';
-import { GcodeChannel, sendGcodeVisible } from './tools/camera';
import { getPositionSnapshot } from './tools/machine';
const log = logger('service:mcp:tool-setter');
@@ -33,13 +43,7 @@ const log = logger('service:mcp:tool-setter');
const CONFIG_KEY = 'mcpToolSetter';
-const TRAVEL_FEED = 600; // mm/min, matches the move_z cap
-const COARSE_FEED = 100;
-const FINE_FEED = 60;
-const SETTLE_TIMEOUT_MS = 30000;
-const SETTLE_POLL_MS = 250;
-const SETTLE_TOLERANCE_MM = 0.15;
-const MAX_RETREAT_MM = 5; // still triggered after this much retreat = stuck sensor
+// Motion/sensing engine shared with the CNC touch probe: probing.ts.
export interface ToolSetterConfig {
centerX: number; // machine coords of the setter's centre
@@ -160,6 +164,10 @@ export interface ToolSetterPlan {
// current position (wizard returns the new tool over the setter).
stayAtTrigger: boolean;
startFromCurrent: boolean;
+ // Measuring the spindle TOUCH PROBE on the setter: the probe's own
+ // channel fires before the setter's switch, and pressing on would bend
+ // the probe - accept EITHER channel as the height confirmation.
+ acceptProbeContact: boolean;
}
/**
@@ -178,6 +186,7 @@ export function planToolSetterRun(args: {
store_as_reference?: boolean;
stay_at_trigger?: boolean;
start_from_current?: boolean;
+ accept_probe_contact?: boolean;
}): ToolSetterPlan {
const cfg = getToolSetterConfig();
if (!cfg) {
@@ -228,6 +237,7 @@ export function planToolSetterRun(args: {
storeAsReference: args.store_as_reference === true,
stayAtTrigger: args.stay_at_trigger === true,
startFromCurrent: args.start_from_current === true,
+ acceptProbeContact: args.accept_probe_contact === true,
};
}
@@ -296,189 +306,6 @@ export function describePlanAsGcode(plan: ToolSetterPlan): string {
return lines.join('\n');
}
-interface StepResult {
- contact: boolean;
- reading: { value: string; receivedAt: number } | null;
-}
-
-class ProcedureAbort extends Error {}
-
-function getDirectChannel(): GcodeChannel {
- const channel = connectionManager.getCurrentChannel() as unknown as GcodeChannel;
- if (!channel || typeof channel.executeGcode !== 'function') {
- throw new ProcedureAbort('No machine channel with direct command support.');
- }
- return channel;
-}
-
-async function sleep(ms: number): Promise {
- return new Promise((resolve) => {
- setTimeout(resolve, ms);
- });
-}
-
-/**
- * Issue one absolute machine-frame move and block until the heartbeat
- * verifiably reports it (same contract as move_z): report newer than issue,
- * two identical consecutive beats, machine idle, and axes at target.
- */
-async function moveMachineSettled(
- tool: string,
- target: { x?: number; y?: number; z?: number },
- feed: number
-): Promise {
- probeFeedService.assertNoOvertravel();
- const channel = getDirectChannel();
- const words = [
- target.x !== undefined ? `X${target.x.toFixed(3)}` : '',
- target.y !== undefined ? `Y${target.y.toFixed(3)}` : '',
- target.z !== undefined ? `Z${target.z.toFixed(3)}` : '',
- ].filter(Boolean).join(' ');
- const gcode = `G90\nG53;\nG1 ${words} F${feed};\nG54;`;
-
- // When every commanded axis moves by well over the tolerance, a stale
- // pre-move heartbeat cannot sit within tolerance of the target, so the
- // FIRST post-issue beat at the target is already proof of arrival - no
- // need to wait out a second identical beat (~1-1.5s/step saved on the
- // coarse ladder). Small steps (fine/confirm, at or under the tolerance)
- // keep the strict stable-double-beat rule. Judged from the position
- // BEFORE the move is issued.
- const before = getPositionSnapshot();
- const bigMove = (['x', 'y', 'z'] as const).every((axis) => {
- const want = target[axis];
- if (want === undefined) {
- return true;
- }
- const from = before.machine[axis];
- return from !== null && Math.abs(want - from) > SETTLE_TOLERANCE_MM * 3;
- });
-
- const issuedAt = Date.now();
- const executed = await sendGcodeVisible(channel, tool, gcode);
- if (executed.result !== 0) {
- throw new ProcedureAbort(`Controller rejected the move: ${executed.text || executed.result}`);
- }
-
- // Fast path: the HTTP channel executes gcode synchronously and its reply
- // echoes the position after completion ("X:.. Y:.. Z:.." in WORK
- // coordinates, hardware-observed to always match the exact target). An
- // exact echo match (0.02 mm, tighter than one fine step) is proof of
- // arrival with no heartbeat wait (~2s/step saved - the heartbeat only
- // ticks ~2s on the wifi channel). Anything less falls through to the
- // strict heartbeat settle below.
- const echo = String(executed.text || '').match(/X:(-?\d+(?:\.\d+)?)\s+Y:(-?\d+(?:\.\d+)?)\s+Z:(-?\d+(?:\.\d+)?)/);
- if (echo) {
- const offset = before.originOffset;
- const echoMachine = {
- x: Number(echo[1]) - offset.x,
- y: Number(echo[2]) - offset.y,
- z: Number(echo[3]) - offset.z,
- };
- const exact = (['x', 'y', 'z'] as const).every((axis) => {
- const want = target[axis];
- return want === undefined || Math.abs(echoMachine[axis] - want) <= 0.02;
- });
- if (exact) {
- return;
- }
- }
-
- const deadline = issuedAt + SETTLE_TIMEOUT_MS;
- let previous: string | null = null;
- while (Date.now() < deadline) {
- await sleep(SETTLE_POLL_MS);
- probeFeedService.assertNoOvertravel();
- let now;
- try {
- now = getPositionSnapshot();
- } catch (err) {
- continue;
- }
- const reportTime = Date.now() - now.reportAgeMs;
- const fingerprint = JSON.stringify([now.work, now.originOffset]);
- const stable = fingerprint === previous;
- previous = fingerprint;
- if (reportTime <= issuedAt || now.machineStatus !== 'idle') {
- continue;
- }
- if (!bigMove && !stable) {
- continue;
- }
- const atTarget = (['x', 'y', 'z'] as const).every((axis) => {
- const want = target[axis];
- const have = now.machine[axis];
- return want === undefined || (have !== null && Math.abs(have - want) <= SETTLE_TOLERANCE_MM);
- });
- if (atTarget) {
- return;
- }
- }
- throw new ProcedureAbort(`Timed out waiting for the heartbeat to verify the move to ${words}.`);
-}
-
-/**
- * After a settled step, give the sensor's report time to arrive (early-exits
- * when a fresh reading lands), then judge contact from the LAST KNOWN state -
- * the sensor publishes on change, so silence means unchanged.
- *
- * This short window is for CONTACT detection while descending, where a late
- * message merely costs one extra step before detection (self-correcting).
- */
-async function senseAfter(stepIssuedAt: number, delayMs: number): Promise {
- const fresh = await probeFeedService.waitForReading('toolsetter', stepIssuedAt, delayMs);
- const state = fresh || probeFeedService.getReading('toolsetter');
- return {
- contact: !!(state && state.triggered),
- reading: state ? { value: state.value, receivedAt: state.receivedAt } : null,
- };
-}
-
-/**
- * RELEASE detection needs different patience: a stale "triggered" here is a
- * false abort (live-hit on run 2: the cloud round-trip of the release
- * message exceeded the 200 ms contact window). Wait until the last known
- * state reads untriggered, early-exiting on fresh readings, up to timeoutMs;
- * only then is "still triggered" believed.
- */
-async function senseReleaseAfter(stepIssuedAt: number, timeoutMs: number): Promise {
- const deadline = Date.now() + timeoutMs;
- for (;;) {
- const state = probeFeedService.getReading('toolsetter');
- if (state && !state.triggered) {
- return { contact: false, reading: { value: state.value, receivedAt: state.receivedAt } };
- }
- const remaining = deadline - Date.now();
- if (remaining <= 0) {
- return {
- contact: !!(state && state.triggered),
- reading: state ? { value: state.value, receivedAt: state.receivedAt } : null,
- };
- }
- await probeFeedService.waitForReading(
- 'toolsetter',
- state ? state.receivedAt : stepIssuedAt,
- Math.min(remaining, 250)
- );
- }
-}
-
-function assertFeedReady(): void {
- probeFeedService.assertNoOvertravel();
- if (!probeFeedService.isConnected()) {
- throw new McpToolError('Probe feed is not connected - connect_probe_feed first. The overtravel '
- + 'tripwire MUST be armed for a tool setter run.');
- }
- const setter = probeFeedService.getReading('toolsetter');
- if (!setter) {
- throw new McpToolError('No reading has ever arrived on the toolsetter feed - cannot trust the '
- + 'sensor. Ask the operator to trigger the setter by hand and watch get_probe_feed_status '
- + 'until the touch shows up.');
- }
- if (setter.triggered) {
- throw new McpToolError(`The toolsetter feed already reads triggered ("${setter.value}") before `
- + 'any approach - the sensor is stuck or something is resting on it. Resolve physically first.');
- }
-}
export interface ToolSetterResult {
measuredTriggerZ: number;
@@ -498,20 +325,12 @@ export interface ToolSetterResult {
* any abort retreats to the start height when the machine still answers.
*/
export async function runToolSetterProcedure(plan: ToolSetterPlan): Promise {
- assertFeedReady();
- const position = getPositionSnapshot();
- if (position.machineStatus !== 'idle') {
- throw new McpToolError(`Machine is ${position.machineStatus || 'in an unknown state'}, not idle.`);
- }
- if (position.isHomed !== true) {
- throw new McpToolError('Machine does not report homed; the tool setter centre is only valid '
- + 'after homing. Call home first.');
- }
- const state = connectionManager.getLatestMachineState() as { headStatus?: unknown; headPower?: unknown } | null;
- const headPower = Number(state && state.headPower);
- if ((Number.isFinite(headPower) && headPower > 0) || (state && (state.headStatus === true || state.headStatus === 'on'))) {
- throw new McpToolError('Toolhead appears to be on; refusing to run the tool setter.');
+ const contactChannels: ProbeChannel[] = plan.acceptProbeContact ? ['toolsetter', 'probe'] : ['toolsetter'];
+ for (const channel of contactChannels) {
+ assertChannelReady(channel, 'tool setter');
}
+ assertMachineReadyForProcedure();
+ const position = getPositionSnapshot();
const phases: { phase: string; z: number; note?: string }[] = [];
const c = plan.config;
@@ -560,7 +379,7 @@ export async function runToolSetterProcedure(plan: ToolSetterPlan): Promise Promise<{ result: number; text?: string }>;
}
+const gcodeLog = logger('service:mcp:gcode');
+
/**
* Send gcode on the direct path AND mirror exactly what was sent (plus the
- * controller's reply) to the UI console, so the operator can see which
- * coordinate frame every MCP-issued command ran in.
+ * controller's reply) to the UI console AND the server log - the console
+ * broadcast is invisible to anyone reading the process log, and a headless
+ * session debugging "controller said ok but nothing moved" needs the reply.
*/
export async function sendGcodeVisible(channel: GcodeChannel, tool: string, gcode: string): Promise<{ result: number; text?: string }> {
mcpBroadcast('mcp:gcode', { tool, gcode });
+ gcodeLog.info(`[${tool}] > ${gcode.replace(/\r?\n/g, ' | ')}`);
const executed = await channel.executeGcode(gcode);
- mcpBroadcast('mcp:gcode', { tool, response: executed.text || (executed.result === 0 ? 'ok' : `result=${executed.result}`) });
+ const response = executed.text || (executed.result === 0 ? 'ok' : `result=${executed.result}`);
+ mcpBroadcast('mcp:gcode', { tool, response });
+ gcodeLog.info(`[${tool}] < ${String(response).replace(/\r?\n/g, ' | ')}`);
return executed;
}
diff --git a/src/server/services/mcp/tools/probing.ts b/src/server/services/mcp/tools/probing.ts
new file mode 100644
index 0000000000..49cf581875
--- /dev/null
+++ b/src/server/services/mcp/tools/probing.ts
@@ -0,0 +1,232 @@
+/* eslint-disable camelcase */
+// MCP tool arguments are snake_case by convention.
+import crypto from 'crypto';
+import * as fs from 'fs-extra';
+import path from 'path';
+
+import DataStorage from '../../../DataStorage';
+import { connectionManager } from '../../machine/ConnectionManager';
+import { captureFrame } from '../camera';
+import { jobManager } from '../jobs';
+import { describeProbePlanAsGcode, planProbePoint, runProbePointProcedure } from '../probeTool';
+import { probeFeedService } from '../probeFeed';
+import { TRAVEL_FEED, assertMachineReadyForProcedure, moveMachineSettled } from '../probing';
+import { McpToolError, ToolRegistry } from '../registry';
+import { getMachineSizeByIdentifier, getPositionSnapshot } from './machine';
+import { validateGcode } from '../validator';
+
+// The spindle touch probe (probe feed channel) and the whole-bed camera
+// survey: the survey gives the agent visual context ("measure the stock on
+// the rotary axis"), the probe turns that context into millimetres.
+
+const SURVEY_MIN_MACHINE_Z = 250;
+
+export function registerProbingTools(registry: ToolRegistry, getConfirmBaseUrl: () => string): void {
+ registry.register({
+ name: 'probe_point',
+ description: 'Stage a touch-probe point measurement for human confirmation: a single-axis '
+ + 'sensor-gated march FROM THE CURRENT POSITION along +/-X, +/-Y or -Z, using the '
+ + 'spindle touch probe (probe feed channel). Same staged mechanics as the tool setter: '
+ + 'coarse steps to contact, retreat to release, fine steps, quick lift-and-retest '
+ + 'confirm cycles - returns the median contact coordinate in machine coords with '
+ + 'spread. Position first (move_and_capture / move_z), then stage; the envelope is '
+ + 'anchored to the staging position and re-verified before motion. Remember the tip '
+ + 'radius on side probes: the surface is one tip-radius beyond the contact centre.',
+ inputSchema: {
+ type: 'object',
+ properties: {
+ axis: { type: 'string', enum: ['x', 'y', 'z'], description: 'Axis to march along.' },
+ direction: { type: 'number', enum: [1, -1], description: 'Sign of travel; Z is -1 only.' },
+ max_travel_mm: {
+ type: 'number',
+ description: 'REQUIRED hard travel limit (1-150): the march aborts here without contact.',
+ },
+ coarse_step_mm: { type: 'number', description: 'Coarse step, default 1 (0.2-2).' },
+ fine_step_mm: { type: 'number', description: 'Fine step, default 0.1 (0.02-0.5).' },
+ backoff_mm: { type: 'number', description: 'Confirm-cycle lift, default 0.5.' },
+ sensor_delay_ms: { type: 'number', description: 'Contact-check window per step, default 200.' },
+ confirm_passes: { type: 'number', description: 'Lift-and-retest cycles, default 3 (1-10).' },
+ reason: { type: 'string', description: 'Shown to the operator: what is being measured and why.' },
+ },
+ required: ['axis', 'direction', 'max_travel_mm', 'reason'],
+ additionalProperties: false,
+ },
+ handler: async (args: { [key: string]: unknown }) => {
+ if (!String(args.reason || '').trim()) {
+ throw new McpToolError('reason is required; it is shown to the operator.');
+ }
+ probeFeedService.assertNoOvertravel();
+ const plan = planProbePoint(args as Parameters[0]);
+ const envelope = describeProbePlanAsGcode(plan);
+ const validation = validateGcode(envelope);
+ const job = jobManager.submit(
+ envelope,
+ `probe ${plan.direction === 1 ? '+' : '-'}${plan.axis.toUpperCase()} `
+ + `${plan.maxTravelMm}mm - ${String(args.reason).slice(0, 40)}`,
+ 'cnc',
+ validation,
+ 'procedure'
+ );
+ job.runner = async () => runProbePointProcedure(plan);
+ return {
+ job: jobManager.describe(job),
+ plan,
+ confirm_url: `${getConfirmBaseUrl()}/confirm/${job.id}`,
+ next_step: 'Ask the operator to open confirm_url, review the envelope (anchored to the '
+ + 'current position), and approve. Their one-time code passed to start_gcode_job '
+ + 'runs the march and returns the contact coordinate.',
+ };
+ },
+ });
+
+ registry.register({
+ name: 'survey_bed',
+ description: 'Stage a whole-bed camera survey for human confirmation: a serpentine XY grid at '
+ + 'the CURRENT Z (which must be high - machine Z >= 250 unless the operator has confirmed '
+ + 'clearance), capturing a frame at every waypoint. Frames are saved to disk with a '
+ + 'machine-position index so the scene can be reviewed as a whole (read the files '
+ + 'directly); they do NOT go through the 12-frame cache. Requires a working camera '
+ + '(mcpCameraUrl or ffmpeg).',
+ inputSchema: {
+ type: 'object',
+ properties: {
+ pitch_mm: { type: 'number', description: 'Grid spacing, default 80 (40-160).' },
+ margin_mm: { type: 'number', description: 'Inset from the default bounds, default 10.' },
+ x_min: { type: 'number', description: 'Machine-coord grid bounds. Defaults: margin..(size-margin).' },
+ x_max: { type: 'number', description: 'Set beyond the nominal size to cover reachable overtravel (e.g. the far-X column the camera angle otherwise misses - setup-specific, so state it explicitly).' },
+ y_min: { type: 'number' },
+ y_max: { type: 'number' },
+ operator_confirmed_clearance: {
+ type: 'boolean',
+ description: 'Set true ONLY on the operator\'s explicit word that the current Z '
+ + 'clears everything on the bed; required when machine Z < 250.',
+ },
+ reason: { type: 'string', description: 'Shown to the operator.' },
+ },
+ required: ['reason'],
+ additionalProperties: false,
+ },
+ handler: async (args: {
+ pitch_mm?: number;
+ margin_mm?: number;
+ x_min?: number;
+ x_max?: number;
+ y_min?: number;
+ y_max?: number;
+ operator_confirmed_clearance?: boolean;
+ reason?: string;
+ }) => {
+ probeFeedService.assertNoOvertravel();
+ const position = getPositionSnapshot();
+ const { x, y, z } = position.machine;
+ if (x === null || y === null || z === null) {
+ throw new McpToolError('Current machine position unknown.');
+ }
+ if (z < SURVEY_MIN_MACHINE_Z && args.operator_confirmed_clearance !== true) {
+ throw new McpToolError(`Machine Z ${z.toFixed(1)} is below the survey height floor `
+ + `${SURVEY_MIN_MACHINE_Z} - raise Z (move_z), or pass operator_confirmed_clearance: `
+ + 'true only on the operator\'s explicit word that this Z clears everything on the bed.');
+ }
+ const size = getMachineSizeByIdentifier(connectionManager.getConnectionStatus().machineIdentifier);
+ if (!size) {
+ throw new McpToolError('Unknown machine size; cannot plan the grid.');
+ }
+ const pitch = Math.min(Math.max(Number(args.pitch_mm) || 80, 40), 160);
+ const margin = Math.min(Math.max(Number(args.margin_mm) || 10, 0), 50);
+
+ // Serpentine at the current Z. Bounds are explicit (clamped to the
+ // direct-move envelope) and BOTH endpoints are always covered - a
+ // pitch that undershoots gets a final row/column at the far edge,
+ // because what the camera sees at the extremes is setup-specific
+ // and the far reach is often the only view of its region.
+ const clampAxis = (value: number, max: number) => Math.min(Math.max(value, -25), max + 40);
+ const bounds = {
+ xMin: clampAxis(args.x_min !== undefined ? Number(args.x_min) : margin, size.x),
+ xMax: clampAxis(args.x_max !== undefined ? Number(args.x_max) : size.x - margin, size.x),
+ yMin: clampAxis(args.y_min !== undefined ? Number(args.y_min) : margin, size.y),
+ yMax: clampAxis(args.y_max !== undefined ? Number(args.y_max) : size.y - margin, size.y),
+ };
+ if (!(bounds.xMax > bounds.xMin) || !(bounds.yMax > bounds.yMin)) {
+ throw new McpToolError('Survey bounds are empty after clamping; check x/y min/max.');
+ }
+ const axisPoints = (min: number, max: number): number[] => {
+ const points: number[] = [];
+ for (let value = min; value <= max + 1e-9; value += pitch) {
+ points.push(Number(value.toFixed(1)));
+ }
+ if (points[points.length - 1] < max - 1) {
+ points.push(Number(max.toFixed(1)));
+ }
+ return points;
+ };
+ const xs = axisPoints(bounds.xMin, bounds.xMax);
+ const ys = axisPoints(bounds.yMin, bounds.yMax);
+ const waypoints: { x: number; y: number }[] = [];
+ ys.forEach((wy, row) => {
+ const ordered = row % 2 === 0 ? xs : [...xs].reverse();
+ ordered.forEach((wx) => waypoints.push({ x: wx, y: wy }));
+ });
+
+ const envelope = [
+ `; BED SURVEY: ${waypoints.length} waypoints on a ${pitch} mm serpentine grid at CURRENT machine Z ${z.toFixed(1)}`,
+ '; one frame captured per waypoint after the move settles; frames saved to disk with a',
+ '; machine-position index. Each line is sent individually. Aborts on the first capture failure.',
+ 'G90',
+ 'G53;',
+ ...waypoints.map((w, i) => `G0 X${w.x.toFixed(1)} Y${w.y.toFixed(1)}; waypoint ${i + 1} + capture`),
+ 'G54;',
+ ].join('\n');
+ const validation = validateGcode(envelope);
+ const job = jobManager.submit(
+ envelope,
+ `bed-survey ${waypoints.length}pts pitch${pitch} - ${String(args.reason).slice(0, 40)}`,
+ 'cnc',
+ validation,
+ 'procedure'
+ );
+ job.runner = async () => {
+ assertMachineReadyForProcedure();
+ const surveyId = crypto.randomBytes(4).toString('hex');
+ const dir = path.join(DataStorage.userDataDir, 'mcp-surveys', surveyId);
+ fs.ensureDirSync(dir);
+ const frames: object[] = [];
+ for (let i = 0; i < waypoints.length; i++) {
+ const w = waypoints[i];
+ await moveMachineSettled('survey:move', { x: w.x, y: w.y }, TRAVEL_FEED * 4);
+ let frame;
+ try {
+ frame = await captureFrame();
+ } catch (err) {
+ throw new McpToolError(`Capture failed at waypoint ${i + 1}/${waypoints.length} `
+ + `(machine ${w.x}, ${w.y}): ${err.message}. Survey aborted; `
+ + `${frames.length} frames saved in ${dir}.`);
+ }
+ const file = path.join(dir, `wp${String(i + 1).padStart(3, '0')}_x${w.x}_y${w.y}.jpg`);
+ fs.writeFileSync(file, Buffer.from(frame.imageBase64, 'base64'));
+ frames.push({ file, machine: { x: w.x, y: w.y, z }, capturedAt: frame.capturedAt });
+ }
+ const index = { surveyId, machineZ: z, pitchMm: pitch, frames };
+ fs.writeJsonSync(path.join(dir, 'index.json'), index, { spaces: 2 });
+ return {
+ surveyId,
+ directory: dir,
+ frameCount: frames.length,
+ index_file: path.join(dir, 'index.json'),
+ frames,
+ note: 'Frames are position-stamped files on disk - read them directly to view the '
+ + 'bed. The camera is toolhead-mounted: each frame is centred near the waypoint '
+ + 'plus the fixed camera-to-spindle offset.',
+ };
+ };
+ return {
+ job: jobManager.describe(job),
+ waypoints: waypoints.length,
+ grid: { pitch_mm: pitch, machine_z: z, columns: xs.length, rows: ys.length },
+ confirm_url: `${getConfirmBaseUrl()}/confirm/${job.id}`,
+ next_step: 'Ask the operator to open confirm_url, check the Z clears everything on the '
+ + 'bed (rotary included), and approve. start_gcode_job then drives the whole grid '
+ + 'and returns the frame index.',
+ };
+ },
+ });
+}
diff --git a/src/server/services/mcp/tools/toolsetter.ts b/src/server/services/mcp/tools/toolsetter.ts
index 4c2357c688..d20d81d6bc 100644
--- a/src/server/services/mcp/tools/toolsetter.ts
+++ b/src/server/services/mcp/tools/toolsetter.ts
@@ -149,6 +149,12 @@ export function registerToolSetterTools(registry: ToolRegistry, getConfirmBaseUr
+ '(verified over the setter centre within 1.5 mm) - for when the touchscreen '
+ 'wizard has already returned the new tool over the setter.',
},
+ accept_probe_contact: {
+ type: 'boolean',
+ description: 'REQUIRED when the fitted tool is the spindle TOUCH PROBE: its own '
+ + 'channel fires before the setter switch and pressing on would bend it, so '
+ + 'EITHER channel counts as the height confirmation (the result notes which).',
+ },
reason: { type: 'string', description: 'Shown to the operator: why this measurement is needed.' },
},
required: ['bit_length_mm', 'reason'],
@@ -187,6 +193,7 @@ export function registerToolSetterTools(registry: ToolRegistry, getConfirmBaseUr
store_as_reference: plan.storeAsReference,
stay_at_trigger: plan.stayAtTrigger,
start_from_current: plan.startFromCurrent,
+ accept_probe_contact: plan.acceptProbeContact,
},
confirm_url: `${getConfirmBaseUrl()}/confirm/${job.id}`,
next_step: 'Ask the operator to open confirm_url, review the SERVER-DRIVEN PROCEDURE '
From 45842104631e26f04293181eda0c07eebcc6a683 Mon Sep 17 00:00:00 2001
From: tyeth
Date: Tue, 1 Sep 2026 01:19:32 +0100
Subject: [PATCH 043/135] Fix: Motion laws after the probe crash - traverse
height, obstacles, crash guard
Post-mortem of the 2026-09-01 probe destruction: the operator authorised
'step 1' (Z to 200) of a three-step plan; the XY traverse was chained
onto it in one command and fired 117ms later with no decision point, at a
clearance height fabricated from assumptions about the rotary stock's
geometry. The tip crossed the rotary at physical 128.9mm, struck the
stock, and destroyed the probe; the firmware reported ok throughout and
the settle verified. The one sensor that knew - the probe itself - was
connected and ignored, because only the overtravel channel was a
tripwire.
Enforcement, mapped to causes:
- Safe traverse height (operator law: 'always retreat to top gantry
height before x/y moves'): direct XY moves below mcpSafeTraverseZ
(default 320) are refused without operator_confirmed_clearance, in
move_and_capture/goto_work_origin/visual_servo, the tool setter's
travel phase, and survey_bed's staging gate.
- Obstacle landmarks: set_landmark gains clearance_z (minimum safe
toolhead machine Z over the box); direct XY paths crossing an
obstacle's box below it are refused (segment-vs-AABB with 5mm margin).
- Crash tripwire: a triggered reading on probe or toolsetter during
motion (bracketed in sendGcodeVisible) that no procedure declared as
expected contact latches a CRASH alarm - stop job, force-close
connection, block all motion - the same machinery as overtravel;
clear_overtravel_alarm clears either kind on the operator's word.
Procedures declare their expected channels (tool setter: toolsetter
+probe with accept_probe_contact; probe_point: probe).
- Feed readings now logged server-side (the crash left no trace in the
server log of whether the probe fired).
- The approved-code page re-shows the exact gcode next to the one-time
code (operator request): the last thing seen before handing over the
code is what the code will run.
- New skill .claude/skills/cnc-probing/SKILL.md and a Motion laws README
section carry the incident and the process law that no session may
chain motion calls or fabricate clearance heights again.
Co-Authored-By: Claude Fable 5
---
.claude/skills/cnc-probing/SKILL.md | 71 +++++++++++++++++
src/server/services/mcp/README.md | 48 ++++++++++-
src/server/services/mcp/jobs.ts | 12 ++-
src/server/services/mcp/landmarks.ts | 56 ++++++++++++-
src/server/services/mcp/probeFeed.ts | 92 +++++++++++++++-------
src/server/services/mcp/probeTool.ts | 5 ++
src/server/services/mcp/toolSetter.ts | 19 ++++-
src/server/services/mcp/tools/camera.ts | 42 +++++++++-
src/server/services/mcp/tools/landmarks.ts | 12 +++
src/server/services/mcp/tools/machine.ts | 12 +++
src/server/services/mcp/tools/probe.ts | 9 ++-
src/server/services/mcp/tools/probing.ts | 12 +--
12 files changed, 342 insertions(+), 48 deletions(-)
create mode 100644 .claude/skills/cnc-probing/SKILL.md
diff --git a/.claude/skills/cnc-probing/SKILL.md b/.claude/skills/cnc-probing/SKILL.md
new file mode 100644
index 0000000000..c53fcbf9e9
--- /dev/null
+++ b/.claude/skills/cnc-probing/SKILL.md
@@ -0,0 +1,71 @@
+---
+name: cnc-probing
+description: "Measure work with the spindle touch probe and the whole-bed camera survey via the Luban MCP tools (probe_point, survey_bed, run_tool_setter with accept_probe_contact) — including the motion laws written in the aftermath of a probe-destroying crash. Use whenever the user wants to probe stock, find surfaces/edges, survey the bed, or calibrate the touch probe."
+---
+
+# CNC probing: the probe, the survey, and the motion laws
+
+The spindle touch probe turns contact into coordinates; the bed survey turns
+the camera into context. Between them sit the motion laws — written after a
+real crash (2026-09-01) in which an XY traverse at a fabricated "clearance"
+height drove the probe into the rotary stock and destroyed it.
+
+## The motion laws (operator law — never overridden on model judgment)
+
+1. **One motion per instruction.** When the operator enumerates steps,
+ execute exactly the step they name and stop. NEVER chain motion calls in
+ a single command (`&&`, one script, one turn) — each motion needs a
+ decision point in front of it. The crash happened because step 2 fired
+ 117 ms after step 1 succeeded, with no chance to intervene.
+2. **X/Y traverses happen at top gantry height.** Retreat Z (operator-
+ confirmed `move_z`) to the safe traverse height FIRST, traverse, then
+ descend at the destination. Enforced: direct XY moves below
+ `mcpSafeTraverseZ` (default 320) are refused without
+ `operator_confirmed_clearance` — which only the operator's explicit words
+ authorise.
+3. **Never fabricate clearance.** Only measured numbers or operator-stated
+ numbers count for heights. Visual inference from survey frames is for
+ FINDING things, not for clearing them — the crash analysis misread the
+ same stock's orientation twice from photos. If a height is unknown, ask,
+ or measure from a proven-safe height with `probe_point`.
+4. **Landmarks are obstacles.** Give bed fixtures a `clearance_z` in
+ `set_landmark`; XY paths crossing their box below it are refused.
+5. **Contact sensors are crash sensors.** During any motion, a trigger on a
+ probe channel that no procedure declared as expected trips a CRASH alarm:
+ job stopped, connection closed, motion latched until the operator clears
+ it. Do not disconnect the probe feed while anything might move.
+
+## Probe calibration (do once per probe fitting)
+
+`run_tool_setter` with `accept_probe_contact: true` and a conservative
+`bit_length_mm` (declare LOW: the floor sits only floor_margin below the
+declared expectation, and coarse contact presses at most one coarse step —
+fine for a sprung probe). On this rig the setter's switch is softer than the
+probe's axial spring, so the setter fires first; either channel confirms.
+
+- Setter surface = machine Z 100.5 (trigger 175.5 with the 75 mm reference).
+- Probe effective length = measured trigger Z − 100.5 (measured 71.1 mm,
+ 2026-09-01, spread 0 across three passes).
+- **Any probed surface height = probe contact toolhead Z − probe length.**
+
+## Point probing
+
+`probe_point` marches one axis (±X, ±Y, −Z) from the CURRENT position with a
+required `max_travel_mm`; the envelope is anchored to the staging position
+and re-verified before motion. Position first (top-height traverse, then
+operator-confirmed descent), stage, operator approves, run. Results: median
+of lift-and-retest passes, spread as the trust metric. Side probes touch one
+tip-radius before the tip centre — correct for it.
+
+Feed latency (hardware-measured, Adafruit IO): trigger message ~120–150 ms
+after physical contact; 200 ms contact windows are right. Release messages
+lag ~1 s — release checks are patient by design; never shorten them.
+
+## Bed survey
+
+`survey_bed` at top gantry height: serpentine grid, one settled frame per
+waypoint, saved to disk with a machine-position index. Cover the FULL
+reachable envelope (`x_max` etc. beyond nominal size when reachable — the
+camera on this rig looks ~90–150 mm in −X of the toolhead, so the far-X
+column is the only view of the bed centre-right). Read the frames from disk;
+landmarks near each position are the identities the operator already stated.
diff --git a/src/server/services/mcp/README.md b/src/server/services/mcp/README.md
index 7d35bd531c..049f71f468 100644
--- a/src/server/services/mcp/README.md
+++ b/src/server/services/mcp/README.md
@@ -71,6 +71,28 @@ every MCP-sent gcode line and controller reply (`[mcp:home] > G28` / `< X:-19.00
and tool activity — all timestamped. Events: `mcp:activity`, `mcp:gcode` (whitelisted in
`socket-communication.ts`).
+## Motion laws (operator law after the 2026-09-01 probe crash)
+
+An XY traverse at a fabricated "clearance" height (Z200, derived from assumptions about
+the rotary stock's geometry — wrong twice over) destroyed the fitted touch probe. The
+step had also been chained onto an approved Z move in one command, executing 117 ms after
+it with no decision point, when the operator had authorised "step 1" only. Laws:
+
+1. **One motion per instruction** — never chain motion tool calls in a single command or
+ turn; each motion gets its own decision point. Enumerated steps run one at a time.
+2. **X/Y traverses at top gantry height** — direct XY moves below `mcpSafeTraverseZ`
+ (default 320) are refused without `operator_confirmed_clearance`. Retreat, traverse,
+ descend — in that order.
+3. **No fabricated clearances** — only measured or operator-stated heights count. Visual
+ inference finds things; it never clears them.
+4. **Landmarks are obstacles** — `clearance_z` on a landmark refuses XY paths crossing its
+ box below that height.
+5. **Contact sensors are crash sensors** — a probe/toolsetter trigger during motion that no
+ procedure declared as expected trips a CRASH alarm (stop + force-close + latch), the
+ same machinery as overtravel; `clear_overtravel_alarm` clears either kind on the
+ operator's explicit word. Feed readings and all gcode traffic are logged server-side.
+6. The approved-code page re-shows the exact gcode next to the one-time code.
+
## Safety model (operator-defined, non-negotiable)
- **Compound motion and all cutting goes out as gcode FILES** through the same
@@ -116,9 +138,29 @@ and tool activity — all timestamped. Events: `mcp:activity`, `mcp:gcode` (whit
calibration is keyed by machine Y AND Z. The board-viewing anchor pose is the pre-home
park (machine X0/Y0), not machine home. The **gold cylinder at machine Y≈176–340 is the
TOOL HEIGHT CHECKER** (operator-confirmed; was misidentified twice).
-- **Tool setter (operator-stated 2026-08-31): centre at machine (X79, Y293); it triggers at
- machine Z176 with a 75 mm endmill in the holder** — so expected trigger Z for a bit of
- length L is `176 + (L − 75)`. Seed `set_tool_setter_config` with these on first run.
+- **Tool setter: centre at machine (X79, Y293), top-left of bed. MEASURED trigger Z 175.500
+ with the 75 mm reference endmill (three confirm passes agreed, spread 0)** — so the setter
+ SURFACE is machine Z 100.5, and expected trigger Z for a bit of length L is
+ `175.5 + (L − 75)`. Config + measurement history live in `mcpToolSetter`.
+- **Touch probe (measured 2026-09-01): effective length 71.1 mm** (trigger on the setter at
+ machine Z 171.6 ⇒ any surface = probe contact toolhead Z − 71.1). The probe's AXIAL
+ spring is stiffer than the setter's switch, so on the setter the SETTER fires first —
+ measuring the probe there is safe with `accept_probe_contact: true` (either channel
+ confirms). Probe feed latency, hardware-measured: **~120–150 ms** sensor→feed on a local
+ bump test (trigger message 452 ms after move issue incl. ~330 ms motion) — the 200 ms
+ contact windows are correctly sized.
+- **Camera offset**: the toolhead camera looks roughly **90–150 mm in −X** of the toolhead
+ (setup-specific — verify per rig): features at machine X appear in frames taken from
+ toolhead X+90..150. The far-X column of a bed survey is therefore the only view of the
+ bed centre-right.
+- **Bed map (operator-stated + survey 25c4f87a, 2026-09-01)**: rotary module along the bed
+ centre at **machine X≈170**, axis along Y, chuck at the back (~Y250–320), yellow tailstock
+ at the front; tool setter top-left. Both stored as landmarks (`rotary-axis`,
+ `tool-setter`).
+- **Silent non-motion signature**: the controller can reply `ok` to a direct move and not
+ move at all (settle times out with position unchanged; observed once 2026-09-01, suspected
+ enclosure door open — unconfirmed). The verified-settle contract catches it; do not trust
+ an `ok` alone.
- Fleet: machine named "Snapmaker" @ 192.168.1.173 = the A350 CNC; "F350" @ .130 = the
3D printer. Same Luban profile identifier — distinguish by NAME/address, never profile.
diff --git a/src/server/services/mcp/jobs.ts b/src/server/services/mcp/jobs.ts
index 3f89159d5b..172dff5212 100644
--- a/src/server/services/mcp/jobs.ts
+++ b/src/server/services/mcp/jobs.ts
@@ -210,11 +210,21 @@ export class JobManager {
job.approvedAt = Date.now();
job.state = 'approved';
log.info(`MCP job ${job.id} approved by operator`);
+ // The gcode is shown AGAIN next to the code (operator request
+ // after the 2026-09-01 probe crash): the last thing seen before
+ // handing over the code is exactly what the code will run.
+ const approvedGcode = fs.readFileSync(job.filePath, 'utf8');
+ const approvedLines = approvedGcode.split(/\r?\n/);
+ const approvedPreview = approvedLines.length > 80
+ ? [...approvedLines.slice(0, 40), `... ${approvedLines.length - 80} lines elided ...`, ...approvedLines.slice(-40)].join('\n')
+ : approvedGcode;
this.page(res, 200, `
Approved
Give this one-time code to the agent to start ${escapeHtml(job.name)}:
${job.confirmToken}
-
It expires in 15 minutes and works once.
`);
+
It expires in 15 minutes and works once.
+
This code will run exactly:
+
${escapeHtml(approvedPreview)}
`);
return;
}
if (req.method === 'POST' && action === 'reject') {
diff --git a/src/server/services/mcp/landmarks.ts b/src/server/services/mcp/landmarks.ts
index 31e5ca0942..a488715e3e 100644
--- a/src/server/services/mcp/landmarks.ts
+++ b/src/server/services/mcp/landmarks.ts
@@ -19,6 +19,13 @@ export interface Landmark {
description: string;
// Machine-coordinate XY extent of the feature on/over the bed.
machine: { x0: number; y0: number; x1: number; y1: number };
+ // Obstacle clearance: minimum safe TOOLHEAD machine Z when the XY path
+ // crosses this landmark's box (operator accounts for tool length when
+ // setting it). null = not an obstacle. Enforced by the direct XY move
+ // guard - added after the 2026-09-01 probe crash, where a traverse at a
+ // fabricated "clearance" height crossed the rotary and destroyed the
+ // fitted touch probe.
+ clearanceZ: number | null;
notes: string | null;
createdAt: number;
}
@@ -45,7 +52,9 @@ export class LandmarkStore {
}
try {
const raw = fs.readJsonSync(this.file());
- this.cache = { landmarks: Array.isArray(raw?.landmarks) ? raw.landmarks : [] };
+ const landmarks = (Array.isArray(raw?.landmarks) ? raw.landmarks : [])
+ .map((l: Landmark) => ({ ...l, clearanceZ: Number.isFinite(Number(l.clearanceZ)) ? Number(l.clearanceZ) : null }));
+ this.cache = { landmarks };
} catch (err) {
this.cache = { landmarks: [] };
}
@@ -90,6 +99,51 @@ export class LandmarkStore {
return false;
}
+ /**
+ * Obstacle landmarks (clearanceZ set) whose box - inflated by margin -
+ * the straight XY segment from (x0,y0) to (x1,y1) touches, and whose
+ * clearanceZ the given toolhead machine Z is BELOW. These are collisions
+ * waiting to happen; the direct XY guard refuses them.
+ */
+ public obstaclesOnPath(
+ x0: number, y0: number, x1: number, y1: number,
+ toolheadZ: number, marginMm = 5
+ ): Landmark[] {
+ return this.load().landmarks.filter((l) => {
+ if (l.clearanceZ === null || toolheadZ >= l.clearanceZ) {
+ return false;
+ }
+ const bx0 = l.machine.x0 - marginMm;
+ const by0 = l.machine.y0 - marginMm;
+ const bx1 = l.machine.x1 + marginMm;
+ const by1 = l.machine.y1 + marginMm;
+ // 2D segment-vs-AABB slab test.
+ const dx = x1 - x0;
+ const dy = y1 - y0;
+ let tMin = 0;
+ let tMax = 1;
+ for (const [p, d, lo, hi] of [[x0, dx, bx0, bx1], [y0, dy, by0, by1]] as [number, number, number, number][]) {
+ if (Math.abs(d) < 1e-12) {
+ if (p < lo || p > hi) {
+ return false;
+ }
+ } else {
+ let t1 = (lo - p) / d;
+ let t2 = (hi - p) / d;
+ if (t1 > t2) {
+ [t1, t2] = [t2, t1];
+ }
+ tMin = Math.max(tMin, t1);
+ tMax = Math.min(tMax, t2);
+ if (tMin > tMax) {
+ return false;
+ }
+ }
+ }
+ return true;
+ });
+ }
+
/**
* Landmarks whose extent lies within `radius` mm of a machine XY point -
* what a toolhead camera at that position could plausibly see.
diff --git a/src/server/services/mcp/probeFeed.ts b/src/server/services/mcp/probeFeed.ts
index 8569b8968a..7415208d6a 100644
--- a/src/server/services/mcp/probeFeed.ts
+++ b/src/server/services/mcp/probeFeed.ts
@@ -170,7 +170,9 @@ export interface ProbeReading {
topic: string;
}
-interface OvertravelTrip {
+interface SafetyTrip {
+ kind: 'overtravel' | 'crash';
+ channel: ProbeChannel;
at: number;
value: string;
actions: string[];
@@ -186,7 +188,15 @@ export class ProbeFeedService {
private readings = new Map();
- private trip: OvertravelTrip | null = null;
+ private trip: SafetyTrip | null = null;
+
+ // Crash guard (added after the 2026-09-01 probe crash): while any
+ // sensor-visible motion is in flight, a triggered reading on a contact
+ // channel that is NOT expected to touch anything is a collision - same
+ // response as overtravel. Procedures declare their expected channels.
+ private motionCount = 0;
+
+ private expectedContact = new Set();
private reconnectTimer: NodeJS.Timeout | null = null;
@@ -264,7 +274,7 @@ export class ProbeFeedService {
});
}
- public getTrip(): OvertravelTrip | null {
+ public getTrip(): SafetyTrip | null {
return this.trip;
}
@@ -274,20 +284,40 @@ export class ProbeFeedService {
*/
public assertNoOvertravel(): void {
if (this.trip) {
- throw new McpToolError(`OVERTRAVEL ALARM latched at ${new Date(this.trip.at).toISOString()} `
- + `(sensor value "${this.trip.value}"). All motion is blocked. The operator must inspect `
+ throw new McpToolError(`${this.trip.kind === 'crash' ? 'CRASH' : 'OVERTRAVEL'} ALARM latched at `
+ + `${new Date(this.trip.at).toISOString()} (${this.trip.channel} sensor value "${this.trip.value}"). `
+ + 'All motion is blocked. The operator must inspect '
+ 'the machine and explicitly clear the alarm (clear_overtravel_alarm) or restart Luban.');
}
}
/** Operator-authorised alarm clear; refuses while still reporting triggered. */
public clearTrip(): void {
- const reading = this.readings.get('overtravel');
+ const channel = this.trip ? this.trip.channel : 'overtravel';
+ const reading = this.readings.get(channel);
if (reading && reading.triggered) {
- throw new McpToolError('The overtravel feed still reports triggered; refusing to clear the alarm.');
+ throw new McpToolError(`The ${channel} feed still reports triggered; refusing to clear the alarm.`);
}
this.trip = null;
- log.warn('Overtravel alarm cleared by operator authority.');
+ log.warn('Safety alarm cleared by operator authority.');
+ }
+
+ /** Motion-in-flight bracket for the crash guard. Always pair with motionEnd. */
+ public motionBegin(): void {
+ this.motionCount += 1;
+ }
+
+ public motionEnd(): void {
+ this.motionCount = Math.max(0, this.motionCount - 1);
+ }
+
+ /** Declare which channels a probing procedure EXPECTS to touch. */
+ public setExpectedContact(channels: ProbeChannel[]): void {
+ this.expectedContact = new Set(channels);
+ }
+
+ public clearExpectedContact(): void {
+ this.expectedContact.clear();
}
public status(): object {
@@ -319,7 +349,7 @@ export class ProbeFeedService {
clientId: cfg.clientId,
configSources: cfg.sources,
feeds,
- overtravelTrip: this.trip,
+ safetyTrip: this.trip,
reconnectAttempts: this.reconnectAttempts,
lastError: this.lastError,
};
@@ -436,8 +466,15 @@ export class ProbeFeedService {
value: payload,
triggered: reading.triggered,
});
- if (channel === 'overtravel' && reading.triggered && !this.trip) {
- this.tripOvertravel(reading);
+ log.info(`reading ${channel}=${payload} triggered=${reading.triggered}`);
+ if (!this.trip && reading.triggered) {
+ if (channel === 'overtravel') {
+ this.tripSafety('overtravel', channel, reading);
+ } else if (this.motionCount > 0 && !this.expectedContact.has(channel)) {
+ // A contact sensor fired during motion that expected no
+ // contact: collision. Stop everything.
+ this.tripSafety('crash', channel, reading);
+ }
}
return;
}
@@ -448,35 +485,35 @@ export class ProbeFeedService {
* machine connection, latch the alarm, tell the operator. Best-effort on
* every step - a failed stop must not prevent the disconnect.
*/
- private tripOvertravel(reading: ProbeReading): void {
+ private tripSafety(kind: 'overtravel' | 'crash', channel: ProbeChannel, reading: ProbeReading): void {
const actions: string[] = [];
- this.trip = { at: reading.receivedAt, value: reading.value, actions };
- log.error(`OVERTRAVEL reported by probe feed (value "${reading.value}") - aborting all jobs and connections`);
+ this.trip = { kind, channel, at: reading.receivedAt, value: reading.value, actions };
+ log.error(`${kind.toUpperCase()} reported by ${channel} feed (value "${reading.value}") - aborting all jobs and connections`);
- const channel = connectionManager.getCurrentChannel() as unknown as {
+ const machineChannel = connectionManager.getCurrentChannel() as unknown as {
stopGcodeJob?: () => Promise;
connectionClose?: (options: { force: boolean }) => Promise;
} | null;
- if (channel) {
+ if (machineChannel) {
(async () => {
- if (typeof channel.stopGcodeJob === 'function') {
+ if (typeof machineChannel.stopGcodeJob === 'function') {
try {
- await channel.stopGcodeJob();
+ await machineChannel.stopGcodeJob();
actions.push('stop_gcode_job sent');
- log.error('Overtravel abort: stop sent to the machine');
+ log.error(`${kind} abort: stop sent to the machine`);
} catch (err) {
actions.push(`stop_gcode_job failed: ${err.message}`);
- log.error(`Overtravel abort: stop failed: ${err.message}`);
+ log.error(`${kind} abort: stop failed: ${err.message}`);
}
}
- if (typeof channel.connectionClose === 'function') {
+ if (typeof machineChannel.connectionClose === 'function') {
try {
- await channel.connectionClose({ force: true });
+ await machineChannel.connectionClose({ force: true });
actions.push('connection force-closed');
- log.error('Overtravel abort: machine connection force-closed');
+ log.error(`${kind} abort: machine connection force-closed`);
} catch (err) {
actions.push(`connection close failed: ${err.message}`);
- log.error(`Overtravel abort: close failed: ${err.message}`);
+ log.error(`${kind} abort: close failed: ${err.message}`);
}
}
})();
@@ -486,10 +523,11 @@ export class ProbeFeedService {
mcpBroadcast('mcp:activity', {
tool: 'probe_feed',
- phase: 'OVERTRAVEL_ALARM',
+ phase: kind === 'crash' ? 'CRASH_ALARM' : 'OVERTRAVEL_ALARM',
value: reading.value,
- message: 'Overtravel sensor tripped: running job stopped, machine connection force-closed, '
- + 'all MCP motion blocked until the operator clears the alarm.',
+ message: `${kind === 'crash' ? `Collision: ${channel} sensor fired during motion that expected no contact` : 'Overtravel sensor tripped'}: `
+ + 'running job stopped, machine connection force-closed, all MCP motion blocked '
+ + 'until the operator clears the alarm.',
});
}
}
diff --git a/src/server/services/mcp/probeTool.ts b/src/server/services/mcp/probeTool.ts
index 6ac28616c0..76db2ee99d 100644
--- a/src/server/services/mcp/probeTool.ts
+++ b/src/server/services/mcp/probeTool.ts
@@ -151,6 +151,9 @@ export interface ProbePointResult {
export async function runProbePointProcedure(plan: ProbePointPlan): Promise {
assertChannelReady('probe', 'touch probe');
assertMachineReadyForProcedure();
+ // The probe is EXPECTED to touch during this procedure; the toolsetter
+ // firing instead would be a collision (crash guard). Cleared in finally.
+ probeFeedService.setExpectedContact(['probe']);
const position = getPositionSnapshot();
const here = position.machine;
@@ -312,5 +315,7 @@ export async function runProbePointProcedure(plan: ProbePointPlan): Promise {
mcpBroadcast('mcp:gcode', { tool, gcode });
gcodeLog.info(`[${tool}] > ${gcode.replace(/\r?\n/g, ' | ')}`);
- const executed = await channel.executeGcode(gcode);
+ // Crash-guard bracket: the HTTP channel executes synchronously, so the
+ // await spans the motion window - a contact-sensor trigger inside it
+ // that no procedure expects is treated as a collision (probeFeed).
+ probeFeedService.motionBegin();
+ let executed;
+ try {
+ executed = await channel.executeGcode(gcode);
+ } finally {
+ probeFeedService.motionEnd();
+ }
const response = executed.text || (executed.result === 0 ? 'ok' : `result=${executed.result}`);
mcpBroadcast('mcp:gcode', { tool, response });
gcodeLog.info(`[${tool}] < ${String(response).replace(/\r?\n/g, ' | ')}`);
@@ -194,6 +203,35 @@ export async function executeBoundedMoveAndCapture(args: BoundedMoveArgs): Promi
x: target.x - before.originOffset.x,
y: target.y - before.originOffset.y,
};
+
+ // OPERATOR LAW (2026-09-01, after the probe crash): X/Y traverses happen
+ // at top gantry height. An XY move below the safe traverse Z, or one
+ // whose path crosses an obstacle landmark below its clearance height, is
+ // refused unless the operator has EXPLICITLY confirmed this corridor -
+ // never on the model's own judgment, never derived from assumptions
+ // about what is on the bed.
+ const machineZ = before.machine.z;
+ if (args.operator_confirmed_clearance !== true && machineZ !== null) {
+ const traverseFloor = safeTraverseZ();
+ if (machineZ < traverseFloor) {
+ throw new McpToolError(`XY move refused: machine Z ${machineZ.toFixed(1)} is below the safe `
+ + `traverse height ${traverseFloor} (top gantry). Retreat Z first (move_z, operator-`
+ + 'confirmed), then traverse, then descend at the destination. Only the operator\'s '
+ + 'explicit word (operator_confirmed_clearance: true) authorises a lower corridor.');
+ }
+ const machineFrom = { x: before.machine.x, y: before.machine.y };
+ if (machineFrom.x !== null && machineFrom.y !== null) {
+ const obstacles = landmarkStore.obstaclesOnPath(
+ machineFrom.x, machineFrom.y, machineTarget.x, machineTarget.y, machineZ
+ );
+ if (obstacles.length) {
+ throw new McpToolError('XY move refused: the path crosses obstacle landmark(s) '
+ + `${obstacles.map((l) => `"${l.name}" (clearance Z ${l.clearanceZ})`).join(', ')} `
+ + `while at machine Z ${machineZ.toFixed(1)}. Raise Z above the clearance, or get the `
+ + 'operator\'s explicit confirmation for this corridor.');
+ }
+ }
+ }
if (size) {
// Floors allow real overtravel: the A350 X home switch sits at
// machine -19, so "keep the current X while parked at home" must
diff --git a/src/server/services/mcp/tools/landmarks.ts b/src/server/services/mcp/tools/landmarks.ts
index b5bbbb0067..c60079973d 100644
--- a/src/server/services/mcp/tools/landmarks.ts
+++ b/src/server/services/mcp/tools/landmarks.ts
@@ -33,6 +33,12 @@ export function registerLandmarkTools(registry: ToolRegistry): void {
y0: { type: 'number' },
x1: { type: 'number' },
y1: { type: 'number' },
+ clearance_z: {
+ type: 'number',
+ description: 'Marks this landmark as an OBSTACLE: minimum safe toolhead machine Z '
+ + 'when an XY path crosses its box (operator accounts for tool length). Direct '
+ + 'XY moves below it across the box are refused. Omit for non-obstacles.',
+ },
notes: { type: 'string' },
},
required: ['name', 'description', 'x0', 'y0', 'x1', 'y1'],
@@ -45,6 +51,7 @@ export function registerLandmarkTools(registry: ToolRegistry): void {
y0?: number;
x1?: number;
y1?: number;
+ clearance_z?: number;
notes?: string;
}) => {
const name = String(args.name || '').trim();
@@ -56,10 +63,15 @@ export function registerLandmarkTools(registry: ToolRegistry): void {
if (box.some((v) => !Number.isFinite(v)) || box[0] >= box[2] || box[1] >= box[3]) {
throw new McpToolError('Require finite machine coordinates with x0 < x1 and y0 < y1.');
}
+ const clearanceZ = args.clearance_z !== undefined ? Number(args.clearance_z) : null;
+ if (clearanceZ !== null && !Number.isFinite(clearanceZ)) {
+ throw new McpToolError('clearance_z must be a finite machine Z when given.');
+ }
const landmark = landmarkStore.add({
name,
description,
machine: { x0: box[0], y0: box[1], x1: box[2], y1: box[3] },
+ clearanceZ,
notes: args.notes ? String(args.notes) : null,
});
return { landmark: describeLandmark(landmark) };
diff --git a/src/server/services/mcp/tools/machine.ts b/src/server/services/mcp/tools/machine.ts
index 4975fec133..9ece38ad92 100644
--- a/src/server/services/mcp/tools/machine.ts
+++ b/src/server/services/mcp/tools/machine.ts
@@ -57,6 +57,18 @@ function axisValue(value: unknown): number | null {
return Number.isFinite(n) ? n : null;
}
+/**
+ * The minimum toolhead machine Z for X/Y traverses - OPERATOR LAW after the
+ * 2026-09-01 probe crash: "always retreat to top gantry height (home
+ * effectively) before x/y moves". Default 320 (home Z is 328 on the A350);
+ * override via configstore mcpSafeTraverseZ. Anything lower needs the
+ * operator's explicit clearance for that specific corridor.
+ */
+export function safeTraverseZ(): number {
+ const raw = Number(config.get('mcpSafeTraverseZ'));
+ return Number.isFinite(raw) && raw > 0 ? raw : 320;
+}
+
export interface PositionSnapshot {
work: { x: number | null; y: number | null; z: number | null };
machine: { x: number | null; y: number | null; z: number | null };
diff --git a/src/server/services/mcp/tools/probe.ts b/src/server/services/mcp/tools/probe.ts
index 25ee89fe0d..b232de57dc 100644
--- a/src/server/services/mcp/tools/probe.ts
+++ b/src/server/services/mcp/tools/probe.ts
@@ -55,10 +55,11 @@ export function registerProbeTools(registry: ToolRegistry): void {
registry.register({
name: 'clear_overtravel_alarm',
- description: 'Clear the latched overtravel alarm that is blocking all motion. ONLY on the '
- + 'operator\'s explicit word, after they have physically inspected the machine and the '
- + 'overtravel mechanism: pass operator_confirmed: true and repeat their words in reason. '
- + 'Refused while the overtravel feed still reports triggered.',
+ description: 'Clear the latched safety alarm (overtravel OR crash - a contact sensor firing '
+ + 'during motion that expected no contact) that is blocking all motion. ONLY on the '
+ + 'operator\'s explicit word, after they have physically inspected the machine: pass '
+ + 'operator_confirmed: true and repeat their words in reason. Refused while the tripped '
+ + 'channel still reports triggered.',
inputSchema: {
type: 'object',
properties: {
diff --git a/src/server/services/mcp/tools/probing.ts b/src/server/services/mcp/tools/probing.ts
index 49cf581875..b971e899c9 100644
--- a/src/server/services/mcp/tools/probing.ts
+++ b/src/server/services/mcp/tools/probing.ts
@@ -12,14 +12,13 @@ import { describeProbePlanAsGcode, planProbePoint, runProbePointProcedure } from
import { probeFeedService } from '../probeFeed';
import { TRAVEL_FEED, assertMachineReadyForProcedure, moveMachineSettled } from '../probing';
import { McpToolError, ToolRegistry } from '../registry';
-import { getMachineSizeByIdentifier, getPositionSnapshot } from './machine';
+import { getMachineSizeByIdentifier, getPositionSnapshot, safeTraverseZ } from './machine';
import { validateGcode } from '../validator';
// The spindle touch probe (probe feed channel) and the whole-bed camera
// survey: the survey gives the agent visual context ("measure the stock on
// the rotary axis"), the probe turns that context into millimetres.
-const SURVEY_MIN_MACHINE_Z = 250;
export function registerProbingTools(registry: ToolRegistry, getConfirmBaseUrl: () => string): void {
registry.register({
@@ -122,10 +121,11 @@ export function registerProbingTools(registry: ToolRegistry, getConfirmBaseUrl:
if (x === null || y === null || z === null) {
throw new McpToolError('Current machine position unknown.');
}
- if (z < SURVEY_MIN_MACHINE_Z && args.operator_confirmed_clearance !== true) {
- throw new McpToolError(`Machine Z ${z.toFixed(1)} is below the survey height floor `
- + `${SURVEY_MIN_MACHINE_Z} - raise Z (move_z), or pass operator_confirmed_clearance: `
- + 'true only on the operator\'s explicit word that this Z clears everything on the bed.');
+ if (z < safeTraverseZ() && args.operator_confirmed_clearance !== true) {
+ throw new McpToolError(`Machine Z ${z.toFixed(1)} is below the safe traverse height `
+ + `${safeTraverseZ()} (top gantry - operator law for all X/Y motion) - raise Z `
+ + '(move_z), or pass operator_confirmed_clearance: true only on the operator\'s '
+ + 'explicit word that this Z clears everything on the bed.');
}
const size = getMachineSizeByIdentifier(connectionManager.getConnectionStatus().machineIdentifier);
if (!size) {
From 049134bd19944df7138bbbdc6a7f183f6df9b242 Mon Sep 17 00:00:00 2001
From: tyeth
Date: Tue, 1 Sep 2026 01:40:06 +0100
Subject: [PATCH 044/135] Fix: Motion tools resist misuse - required reasons,
pacing guard, purpose docs
Operator critique after the crash: using move_and_capture as a transport
primitive (capture: false, the move was the real goal), chaining motion
calls from scripts, and touching the backend directly all abuse the
interface - the guards live in the tools, so working around their intent
works around their protection.
- move_and_capture and goto_work_origin now require a reason (like
move_z); it is broadcast to the console and written to the gcode log,
so a move whose stated purpose does not match its target is visible.
- Pacing guard: direct XY moves are single supervised actions. The 2nd+
move within 15s carries a pacing_warning in the result; the 4th is
refused outright unless the operator explicitly confirmed the sequence
- a script looping the direct tools is an unsupervised procedure
without a confirm page, which is exactly what the crash was. Staged
mechanisms (survey_bed, move_z batches, procedures, submit_gcode_job)
are the named alternatives in every message.
- move_and_capture's description now states it is a vision reposition,
not transport; visual_servo steps carry their own reason.
- README motion laws 7 (tools are used for their purpose) and 8 (the MCP
surface is the only interface - no direct backend/configstore/machine
access while the app runs), mirrored in the cnc-probing skill.
Co-Authored-By: Claude Fable 5
---
.claude/skills/cnc-probing/SKILL.md | 8 +++
src/server/services/mcp/README.md | 9 +++
src/server/services/mcp/tools/calibration.ts | 1 +
src/server/services/mcp/tools/camera.ts | 64 +++++++++++++++++---
4 files changed, 73 insertions(+), 9 deletions(-)
diff --git a/.claude/skills/cnc-probing/SKILL.md b/.claude/skills/cnc-probing/SKILL.md
index c53fcbf9e9..4aa3a79385 100644
--- a/.claude/skills/cnc-probing/SKILL.md
+++ b/.claude/skills/cnc-probing/SKILL.md
@@ -34,6 +34,14 @@ height drove the probe into the rotary stock and destroyed it.
probe channel that no procedure declared as expected trips a CRASH alarm:
job stopped, connection closed, motion latched until the operator clears
it. Do not disconnect the probe feed while anything might move.
+6. **Use tools for their purpose, through the MCP surface only.**
+ `move_and_capture` is a vision reposition, not a goto — its required
+ `reason` is shown to the operator, and rapid sequential direct moves are
+ refused (pacing guard). A script looping motion calls is an unsupervised
+ procedure without a confirm page: use `survey_bed`, `move_z` batches,
+ probing procedures, or `submit_gcode_job` instead. Never touch the
+ backend, configstore, or machine directly while the app runs — the
+ guards live in the tools.
## Probe calibration (do once per probe fitting)
diff --git a/src/server/services/mcp/README.md b/src/server/services/mcp/README.md
index 049f71f468..e44bc48414 100644
--- a/src/server/services/mcp/README.md
+++ b/src/server/services/mcp/README.md
@@ -92,6 +92,15 @@ it with no decision point, when the operator had authorised "step 1" only. Laws:
same machinery as overtravel; `clear_overtravel_alarm` clears either kind on the
operator's explicit word. Feed readings and all gcode traffic are logged server-side.
6. The approved-code page re-shows the exact gcode next to the one-time code.
+7. **Use tools for their purpose** — `move_and_capture` is a vision reposition, not a
+ transport primitive (its `reason` is required and shown to the operator); sequences of
+ motion belong in the staged, operator-approved mechanisms (`survey_bed`, `move_z`
+ batches, procedures, `submit_gcode_job`). Enforced: rapid sequential direct moves are
+ warned then refused (pacing guard) — scripting a motion loop around the direct tools is
+ an unsupervised procedure without a confirm page, which is what the crash was.
+8. **The MCP surface is the only interface** — no agent may touch the machine, its
+ configstore, or the backend APIs directly while the app runs; every guard lives in the
+ tools, so bypassing them bypasses all of it.
## Safety model (operator-defined, non-negotiable)
diff --git a/src/server/services/mcp/tools/calibration.ts b/src/server/services/mcp/tools/calibration.ts
index ece82ac488..8af5e63a81 100644
--- a/src/server/services/mcp/tools/calibration.ts
+++ b/src/server/services/mcp/tools/calibration.ts
@@ -320,6 +320,7 @@ export function registerCalibrationTools(registry: ToolRegistry): void {
x: position.machine.x + dx,
y: position.machine.y + dy,
coordinate_system: 'machine',
+ reason: 'visual servo correction step',
feed_rate: args.feed_rate,
operator_confirmed_clearance: args.operator_confirmed_clearance,
}) as { mcpContent: object[] };
diff --git a/src/server/services/mcp/tools/camera.ts b/src/server/services/mcp/tools/camera.ts
index 41960605fe..b37a4179b9 100644
--- a/src/server/services/mcp/tools/camera.ts
+++ b/src/server/services/mcp/tools/camera.ts
@@ -153,6 +153,9 @@ export interface BoundedMoveArgs {
y?: number;
coordinate_system?: string;
feed_rate?: number;
+ // Why this move is happening - required, shown in the console/log, so a
+ // motion whose real goal is transport rather than vision is visible.
+ reason?: string;
operator_confirmed_clearance?: boolean;
wait_until_moved?: boolean;
capture?: boolean;
@@ -161,6 +164,14 @@ export interface BoundedMoveArgs {
unbounded_travel?: boolean;
}
+// Pacing guard (2026-09-01, interface-respect): direct XY moves are single
+// supervised actions, not a scripting primitive. Rapid sequences belong in
+// the staged, operator-approved mechanisms (survey_bed, batch move_z,
+// procedures). Timestamps of recent SENT direct moves:
+const recentDirectMoves: number[] = [];
+const PACING_WINDOW_MS = 15000;
+const PACING_REFUSE_AT = 4; // the 4th move inside the window is refused
+
/**
* The single bounded XY move + settle + capture behind move_and_capture,
* shared with visual_servo. Enforces every guard.
@@ -169,6 +180,12 @@ export async function executeBoundedMoveAndCapture(args: BoundedMoveArgs): Promi
if (args.x === undefined && args.y === undefined) {
throw new McpToolError('Provide x and/or y.');
}
+ const reason = String(args.reason || '').trim();
+ if (!reason) {
+ throw new McpToolError('reason is required: say why this XY move is needed (it is shown to the '
+ + 'operator). Direct XY moves are single supervised actions - sequences belong in staged, '
+ + 'operator-approved mechanisms (survey_bed, move_z batches, probing procedures).');
+ }
const coordinateSystem = args.coordinate_system || 'work';
if (!['work', 'machine'].includes(coordinateSystem)) {
throw new McpToolError('coordinate_system must be "work" or "machine".');
@@ -249,10 +266,29 @@ export async function executeBoundedMoveAndCapture(args: BoundedMoveArgs): Promi
throw new McpToolError('The connected channel does not support direct moves.');
}
+ // Pacing: warn on the 2nd+ direct move inside the window; refuse from
+ // the 4th unless the operator explicitly confirmed the sequence.
+ const pacingNow = Date.now();
+ while (recentDirectMoves.length && pacingNow - recentDirectMoves[0] > PACING_WINDOW_MS) {
+ recentDirectMoves.shift();
+ }
+ if (recentDirectMoves.length >= PACING_REFUSE_AT - 1 && args.operator_confirmed_clearance !== true) {
+ throw new McpToolError(`Refused: this is direct XY move number ${recentDirectMoves.length + 1} within `
+ + `${PACING_WINDOW_MS / 1000}s. Direct moves are single supervised actions, not a scripting `
+ + 'primitive - use a staged operator-approved mechanism (survey_bed, move_z batch, a probing '
+ + 'procedure, or submit_gcode_job), or pass operator_confirmed_clearance: true only when the '
+ + 'operator explicitly directed this sequence.');
+ }
+ const pacingWarning = recentDirectMoves.length >= 1
+ ? `Direct-move pacing: ${recentDirectMoves.length + 1} moves in ${PACING_WINDOW_MS / 1000}s - sequences `
+ + 'belong in staged, operator-approved mechanisms.'
+ : undefined;
+ recentDirectMoves.push(pacingNow);
+
const move = `G0 X${target.x.toFixed(3)} Y${target.y.toFixed(3)} F${feedRate}`;
const gcode = coordinateSystem === 'machine' ? `G53;\n${move};\nG54;` : move;
const issuedAt = Date.now();
- const executed = await sendGcodeVisible(channel, 'move', gcode);
+ const executed = await sendGcodeVisible(channel, `move - ${reason.slice(0, 60)}`, gcode);
if (executed.result !== 0) {
throw new McpToolError(`Move rejected by controller: ${executed.text || executed.result}`);
}
@@ -264,6 +300,7 @@ export async function executeBoundedMoveAndCapture(args: BoundedMoveArgs): Promi
commanded: { ...target, coordinate_system: coordinateSystem, feed_rate: feedRate },
position: null,
position_verified: false,
+ pacing_warning: pacingWarning,
note: 'wait_until_moved was false: move accepted but not awaited, and no frame was '
+ 'captured (it would not show the commanded position). Poll get_position.',
};
@@ -311,6 +348,7 @@ export async function executeBoundedMoveAndCapture(args: BoundedMoveArgs): Promi
commanded: { ...target, coordinate_system: coordinateSystem, feed_rate: feedRate },
position: getPositionSnapshot(),
position_verified: true,
+ pacing_warning: pacingWarning,
note: 'position is firmware-reported after settling; capture was false so no frame was taken',
};
}
@@ -321,6 +359,7 @@ export async function executeBoundedMoveAndCapture(args: BoundedMoveArgs): Promi
commanded: { ...target, coordinate_system: coordinateSystem, feed_rate: feedRate },
position: after,
position_verified: true,
+ pacing_warning: pacingWarning,
note: 'position is firmware-reported after settling, not the commanded target',
});
}
@@ -599,11 +638,12 @@ export function registerCameraTools(registry: ToolRegistry): void {
description: 'Go to the WORK origin: one bounded XY move to work X0 Y0 at the CURRENT Z - '
+ 'semantically distinct from home, which drives to the machine limit switches. Z is '
+ 'deliberately not touched; position Z via submit_gcode_job first if needed. Same '
- + 'guards as move_and_capture (idle, toolhead off, homed-first unless the operator '
- + 'has confirmed clearance), and a frame is captured on arrival.',
+ + 'guards as move_and_capture (idle, toolhead off, homed-first, safe traverse height, '
+ + 'obstacle landmarks, pacing), and a frame is captured on arrival.',
inputSchema: {
type: 'object',
properties: {
+ reason: { type: 'string', description: 'Why this move is needed; shown to the operator.' },
feed_rate: { type: 'number', description: `mm/min, default ${DEFAULT_FEED_RATE}, max 3000.` },
operator_confirmed_clearance: {
type: 'boolean',
@@ -617,9 +657,10 @@ export function registerCameraTools(registry: ToolRegistry): void {
+ 'get_position afterwards.',
},
},
+ required: ['reason'],
additionalProperties: false,
},
- handler: async (args: { feed_rate?: number; operator_confirmed_clearance?: boolean; wait_until_moved?: boolean }) => {
+ handler: async (args: { reason?: string; feed_rate?: number; operator_confirmed_clearance?: boolean; wait_until_moved?: boolean }) => {
// The work origin is a fixed, operator-set destination, so the
// per-call travel limit (meant to bound the blast radius of a
// wrong coordinate) does not apply; every other guard does.
@@ -627,6 +668,7 @@ export function registerCameraTools(registry: ToolRegistry): void {
x: 0,
y: 0,
coordinate_system: 'work',
+ reason: args.reason,
feed_rate: args.feed_rate,
operator_confirmed_clearance: args.operator_confirmed_clearance,
wait_until_moved: args.wait_until_moved,
@@ -637,16 +679,19 @@ export function registerCameraTools(registry: ToolRegistry): void {
registry.register({
name: 'move_and_capture',
- description: 'ONE bounded XY move at the current Z via the direct path, wait for the '
- + 'firmware-reported position to settle, then capture a frame stamped with that '
- + 'position. No Z parameter by design: Z changes and compound motion must go through '
- + 'submit_gcode_job (door interlock). Requires an idle machine with the toolhead off. '
- + `Travel per call is limited (configstore mcpMaxJogDistance, default ${DEFAULT_MAX_TRAVEL_MM} mm).`,
+ description: 'ONE vision-driven XY reposition at the current Z: move, settle-verify, and '
+ + 'capture a position-stamped frame. This is NOT a transport primitive - travel and '
+ + 'sequences belong in staged operator-approved mechanisms (survey_bed, move_z batches, '
+ + 'probing procedures, submit_gcode_job), and rapid sequential calls are refused '
+ + '(pacing guard). XY happens at top gantry height (safe traverse guard) with obstacle '
+ + 'landmarks enforced. No Z parameter by design. Requires an idle machine, toolhead '
+ + `off, a stated reason. Travel per call is capped (mcpMaxJogDistance, default ${DEFAULT_MAX_TRAVEL_MM} mm).`,
inputSchema: {
type: 'object',
properties: {
x: { type: 'number', description: 'Target X. Omit to keep current X.' },
y: { type: 'number', description: 'Target Y. Omit to keep current Y.' },
+ reason: { type: 'string', description: 'Why this move is needed; shown to the operator.' },
coordinate_system: {
type: 'string',
enum: ['work', 'machine'],
@@ -671,6 +716,7 @@ export function registerCameraTools(registry: ToolRegistry): void {
+ 'the configstore key mcpMaxJogDistance, default 100mm.)',
},
},
+ required: ['reason'],
additionalProperties: false,
},
handler: async (args: BoundedMoveArgs) => executeBoundedMoveAndCapture(args),
From c0648d80c9967aaa6212e293aaab1b634fa05cf0 Mon Sep 17 00:00:00 2001
From: Tyeth Gundry
Date: Wed, 2 Sep 2026 03:21:04 +0100
Subject: [PATCH 045/135] Upgrade Node.js version to 22 in workflows
---
.github/workflows/build-on-pull-request.yml | 12 ++++++------
1 file changed, 6 insertions(+), 6 deletions(-)
diff --git a/.github/workflows/build-on-pull-request.yml b/.github/workflows/build-on-pull-request.yml
index 6fc3462170..28bbf582a8 100644
--- a/.github/workflows/build-on-pull-request.yml
+++ b/.github/workflows/build-on-pull-request.yml
@@ -52,11 +52,11 @@ jobs:
- name: Use Node.js 16
uses: actions/setup-node@v3
with:
- node-version: 16
+ node-version: 22
- name: install
run: |
- npm install -g npm@^9
+ npm install -g npm
npm install
- name: build
run: npm run build
@@ -126,12 +126,12 @@ jobs:
- name: Use Node.js 16
uses: actions/setup-node@v3
with:
- node-version: 16
+ node-version: 22
# install setuptools to reintroduce distutils missing in Python 3.12
# https://github.com/nodejs/node-gyp/issues/2869
- run: pip install setuptools
- - run: npm install -g npm@^9
+ # - run: npm install -g npm@^9
- run: npm install
- run: npm run build
@@ -264,9 +264,9 @@ jobs:
- name: Use Node.js 16
uses: actions/setup-node@v3
with:
- node-version: 16
+ node-version: 22
- - run: npm install -g npm@^9 --unsafe-perm
+ # - run: npm install -g npm@^9 --unsafe-perm
- run: npm install --unsafe-perm
- run: npm run build
From 06eadbc54054f6b4a693c0881d475b918e5bfc89 Mon Sep 17 00:00:00 2001
From: Tyeth Gundry
Date: Wed, 2 Sep 2026 03:26:33 +0100
Subject: [PATCH 046/135] Upgrade Node.js setup action from v3 to v7
---
.github/workflows/build-on-pull-request.yml | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/.github/workflows/build-on-pull-request.yml b/.github/workflows/build-on-pull-request.yml
index 28bbf582a8..9c2978ac94 100644
--- a/.github/workflows/build-on-pull-request.yml
+++ b/.github/workflows/build-on-pull-request.yml
@@ -50,7 +50,7 @@ jobs:
python -m pip install --upgrade pip
- name: Use Node.js 16
- uses: actions/setup-node@v3
+ uses: actions/setup-node@v7
with:
node-version: 22
@@ -124,7 +124,7 @@ jobs:
python -m pip install --upgrade pip
- name: Use Node.js 16
- uses: actions/setup-node@v3
+ uses: actions/setup-node@v7
with:
node-version: 22
@@ -262,7 +262,7 @@ jobs:
python -m pip install --upgrade pip
- name: Use Node.js 16
- uses: actions/setup-node@v3
+ uses: actions/setup-node@v7
with:
node-version: 22
From f4b202485e47f3e6016f08ff09e74c895f3417f3 Mon Sep 17 00:00:00 2001
From: tyeth
Date: Wed, 2 Sep 2026 12:49:32 +0100
Subject: [PATCH 047/135] Fix: File jobs reach a terminal state when the
machine goes idle
File-kind gcode jobs stayed 'started' forever: nothing watched the
heartbeat after start (observed 2026-09-02, job b3f4ef467a93 - a 10s
home+traverse finished on the machine, state never moved).
- watchFileJobCompletion: armed on file-job start; heartbeat polled at
1s. Once the job is seen active, 3 consecutive idle polls mark it
completed; a job too short to observe active completes after 20
consecutive idle polls. Unreadable machine state for 2 minutes gives
up and records 'completion unverified' without guessing a state.
Bails if another path finalises the job; timer unref'd.
- Jobs carry endedAt, set on completed/stopped, exposed in describe().
- Corrected end-of-job docs (operator-clarified): the machine
interpreter returns to Z top at the job finish position - XY holds;
it does NOT park at the work origin. The split that matters is the
door-detector e-stop: machine-interpreter jobs have it, MCP direct
ops do not.
- Motion-law docs: no inferred approvals (a motion named in passing is
not a command), and chat is not a motion gate - staged jobs with the
one-time code against the literal gcode are.
Co-Authored-By: Claude Fable 5
---
.claude/skills/cnc-probing/SKILL.md | 26 +++++++--
src/server/services/mcp/README.md | 20 +++++--
src/server/services/mcp/jobs.ts | 12 +++-
src/server/services/mcp/tools/gcode.ts | 81 ++++++++++++++++++++++++--
4 files changed, 121 insertions(+), 18 deletions(-)
diff --git a/.claude/skills/cnc-probing/SKILL.md b/.claude/skills/cnc-probing/SKILL.md
index 4aa3a79385..0df0801e5d 100644
--- a/.claude/skills/cnc-probing/SKILL.md
+++ b/.claude/skills/cnc-probing/SKILL.md
@@ -12,11 +12,17 @@ height drove the probe into the rotary stock and destroyed it.
## The motion laws (operator law — never overridden on model judgment)
-1. **One motion per instruction.** When the operator enumerates steps,
- execute exactly the step they name and stop. NEVER chain motion calls in
- a single command (`&&`, one script, one turn) — each motion needs a
- decision point in front of it. The crash happened because step 2 fired
- 117 ms after step 1 succeeded, with no chance to intervene.
+1. **One motion per instruction, and no inferred approvals.** When the
+ operator enumerates steps, execute exactly the step they name and stop.
+ NEVER chain motion calls in a single command (`&&`, one script, one
+ turn) — each motion needs a decision point in front of it. The crash
+ happened because step 2 fired 117 ms after step 1 succeeded, with no
+ chance to intervene. A motion is authorized ONLY by an explicit
+ imperative in the operator's latest message ("home it", "go", "run the
+ probe"). A motion mentioned in passing — "take a photo before homing",
+ "then we'll traverse", an approved plan that lists it — is context, not
+ a command: announce the next motion and WAIT for the word (violated
+ 2026-09-02: homed off the back of "before homing").
2. **X/Y traverses happen at top gantry height.** Retreat Z (operator-
confirmed `move_z`) to the safe traverse height FIRST, traverse, then
descend at the destination. Enforced: direct XY moves below
@@ -34,7 +40,15 @@ height drove the probe into the rotary stock and destroyed it.
probe channel that no procedure declared as expected trips a CRASH alarm:
job stopped, connection closed, motion latched until the operator clears
it. Do not disconnect the probe feed while anything might move.
-6. **Use tools for their purpose, through the MCP surface only.**
+6. **Chat is not a motion gate — the staged job is.** Deliberate traverses
+ and descents go through `submit_gcode_job` / staged procedures, so the
+ operator authorises the literal gcode with a one-time code from the
+ confirm page. A "go" in chat is only permission to STAGE; interpretation
+ of chat wording is exactly what fails (see law 1's violations). Direct
+ move tools are for small vision nudges only. Home (re-prove position)
+ before a traverse whenever position state has any doubt — including
+ after any motion that wasn't part of the agreed sequence.
+7. **Use tools for their purpose, through the MCP surface only.**
`move_and_capture` is a vision reposition, not a goto — its required
`reason` is shown to the operator, and rapid sequential direct moves are
refused (pacing guard). A script looping motion calls is an unsupervised
diff --git a/src/server/services/mcp/README.md b/src/server/services/mcp/README.md
index e44bc48414..d6ab56c492 100644
--- a/src/server/services/mcp/README.md
+++ b/src/server/services/mcp/README.md
@@ -78,8 +78,11 @@ the rotary stock's geometry — wrong twice over) destroyed the fitted touch pro
step had also been chained onto an approved Z move in one command, executing 117 ms after
it with no decision point, when the operator had authorised "step 1" only. Laws:
-1. **One motion per instruction** — never chain motion tool calls in a single command or
- turn; each motion gets its own decision point. Enumerated steps run one at a time.
+1. **One motion per instruction, no inferred approvals** — never chain motion tool calls
+ in a single command or turn; each motion gets its own decision point. Enumerated steps
+ run one at a time. Only an explicit imperative in the operator's latest message
+ authorizes a motion; a motion mentioned in passing ("before homing", "then we'll…",
+ a previously approved plan) is context, not a command — announce and wait.
2. **X/Y traverses at top gantry height** — direct XY moves below `mcpSafeTraverseZ`
(default 320) are refused without `operator_confirmed_clearance`. Retreat, traverse,
descend — in that order.
@@ -92,6 +95,11 @@ it with no decision point, when the operator had authorised "step 1" only. Laws:
same machinery as overtravel; `clear_overtravel_alarm` clears either kind on the
operator's explicit word. Feed readings and all gcode traffic are logged server-side.
6. The approved-code page re-shows the exact gcode next to the one-time code.
+ **Chat is not a motion gate — the staged job is** (operator, 2026-09-02): deliberate
+ traverses/descents go through staged jobs so authorization is the one-time code against
+ the literal gcode, not a model's reading of chat wording. A "go" in chat only permits
+ staging. Re-prove position (home) before a traverse when state is in any doubt,
+ including after any motion that wasn't part of the agreed sequence.
7. **Use tools for their purpose** — `move_and_capture` is a vision reposition, not a
transport primitive (its `reason` is required and shown to the operator); sequences of
motion belong in the staged, operator-approved mechanisms (`survey_bed`, `move_z`
@@ -139,8 +147,12 @@ it with no decision point, when the operator had authorised "step 1" only. Laws:
(a bare G28 leaves reporting in an unselected workspace → impossible derived coords like
Y 464/Z 656). Homing takes ~15–20 s and **also homes B — stock on the rotary rotates**.
- **Work origins are operator-set per workspace and persist across homing.**
-- **The firmware parks at the work origin when a file job COMPLETES** — file jobs cannot
- hold a position; that is why `move_z` exists on the direct path.
+- **The machine interpreter returns to Z top at the job's finish position when a file job
+ COMPLETES** (operator-clarified 2026-09-02; supersedes the earlier "parks at the work
+ origin" reading — Z top and this setup's work-origin Z are both 328, which hid the
+ difference). XY holds; Z does not — that is why `move_z` exists on the direct path.
+ The job-concept split that matters: machine-interpreter (file) jobs get the door-detector
+ emergency stop; MCP direct single-command ops do NOT.
- Heartbeat period ~1 s; a settled-looking heartbeat can predate the motion (hence the
verified-settle contract). `query_firmware_position` (M114) is the authoritative check.
- Camera is **toolhead-mounted** (rides X/Z; the platform moves under it in Y): pixel→mm
diff --git a/src/server/services/mcp/jobs.ts b/src/server/services/mcp/jobs.ts
index 172dff5212..eb2e7022a2 100644
--- a/src/server/services/mcp/jobs.ts
+++ b/src/server/services/mcp/jobs.ts
@@ -29,9 +29,11 @@ export type McpJobState =
/**
* 'file' runs through prepare_print/start_print (door interlock applies, and
- * the firmware parks at the work origin on completion). 'direct' executes
- * over execute_code on approval - it persists position but is NOT subject to
- * the door interlock, so the confirm page says so and the operator supervises.
+ * the machine interpreter returns to Z top at the job's finish position on
+ * completion - XY holds, Z does not; operator-clarified 2026-09-02). 'direct'
+ * executes over execute_code on approval - it persists position but is NOT
+ * subject to the door interlock, so the confirm page says so and the operator
+ * supervises.
* 'procedure' is a server-driven measurement routine (e.g. the tool setter):
* the operator approves a motion ENVELOPE and the runner steps within it
* against live sensor feedback - also on the direct path, not interlocked.
@@ -51,6 +53,8 @@ export interface McpJob {
approvedAt: number | null;
tokenUsed: boolean;
startedAt: number | null;
+ // Set when the job reaches a terminal state (completed / stopped).
+ endedAt: number | null;
error: string | null;
// Batch direct jobs: the operator approved this exact list; each
// start_gcode_job call executes ONE step, so captures can happen between
@@ -109,6 +113,7 @@ export class JobManager {
approvedAt: null,
tokenUsed: false,
startedAt: null,
+ endedAt: null,
error: null,
steps,
nextStep: steps ? 0 : undefined,
@@ -158,6 +163,7 @@ export class JobManager {
createdAt: job.createdAt,
approvedAt: job.approvedAt,
startedAt: job.startedAt,
+ endedAt: job.endedAt,
error: job.error,
totalSteps: job.steps ? job.steps.length : undefined,
nextStep: job.steps ? job.nextStep : undefined,
diff --git a/src/server/services/mcp/tools/gcode.ts b/src/server/services/mcp/tools/gcode.ts
index b54f041650..0cc9917b95 100644
--- a/src/server/services/mcp/tools/gcode.ts
+++ b/src/server/services/mcp/tools/gcode.ts
@@ -2,8 +2,9 @@
// MCP tool arguments are snake_case by convention.
import * as fs from 'fs-extra';
+import logger from '../../../lib/logger';
import { connectionManager } from '../../machine/ConnectionManager';
-import { jobManager } from '../jobs';
+import { McpJob, jobManager } from '../jobs';
import { probeFeedService } from '../probeFeed';
import { McpToolError, ToolRegistry } from '../registry';
import { validateGcode } from '../validator';
@@ -105,6 +106,69 @@ function machineStatus(): string | null {
return state ? ((state as { status?: string }).status || null) : null;
}
+const log = logger('service:mcp:gcode-jobs');
+
+// File jobs run on the machine's own interpreter, which reports progress only
+// through the heartbeat - nothing calls back when the job ends, so a started
+// file job previously stayed "started" forever (observed 2026-09-02, job
+// b3f4ef467a93: a 10s job, heartbeat back to idle, state never moved). Watch
+// the heartbeat: once the job has been seen active, a debounced return to
+// idle is completion. A job too short to ever show as active is completed
+// after the heartbeat holds idle for a longer fallback window.
+const FILE_JOB_POLL_MS = 1000;
+const FILE_JOB_IDLE_DEBOUNCE_POLLS = 3;
+const FILE_JOB_NEVER_SEEN_ACTIVE_IDLE_POLLS = 20;
+const FILE_JOB_UNREADABLE_POLLS_GIVE_UP = 120;
+const FILE_JOB_ACTIVE_STATUSES = ['running', 'paused', 'pausing', 'stopping', 'resuming'];
+
+function watchFileJobCompletion(job: McpJob): void {
+ let sawActive = false;
+ let idleStreak = 0;
+ let unreadableStreak = 0;
+ const timer = setInterval(() => {
+ if (job.state !== 'started') {
+ // Stopped (or otherwise finalised) through another path.
+ clearInterval(timer);
+ return;
+ }
+ const status = machineStatus();
+ if (status === null) {
+ unreadableStreak += 1;
+ idleStreak = 0;
+ if (unreadableStreak >= FILE_JOB_UNREADABLE_POLLS_GIVE_UP) {
+ clearInterval(timer);
+ job.error = 'Completion unverified: machine state became unreadable after the job '
+ + 'started (connection lost?). The job may still be running on the machine.';
+ log.warn(`MCP file job ${job.id}: ${job.error}`);
+ }
+ return;
+ }
+ unreadableStreak = 0;
+ if (FILE_JOB_ACTIVE_STATUSES.includes(status)) {
+ sawActive = true;
+ idleStreak = 0;
+ return;
+ }
+ if (status === 'idle') {
+ idleStreak += 1;
+ const needed = sawActive ? FILE_JOB_IDLE_DEBOUNCE_POLLS : FILE_JOB_NEVER_SEEN_ACTIVE_IDLE_POLLS;
+ if (idleStreak >= needed) {
+ clearInterval(timer);
+ job.state = 'completed';
+ job.endedAt = Date.now();
+ log.info(`MCP file job ${job.id} completed: heartbeat idle for ${idleStreak}s`
+ + `${sawActive ? '' : ' (job too short for an active heartbeat to be observed)'}`);
+ }
+ return;
+ }
+ // Unknown status string: treat as activity of some kind, keep waiting.
+ idleStreak = 0;
+ }, FILE_JOB_POLL_MS);
+ if (typeof timer.unref === 'function') {
+ timer.unref();
+ }
+}
+
export function registerGcodeTools(registry: ToolRegistry, getConfirmBaseUrl: () => string): void {
registry.register({
name: 'validate_gcode',
@@ -220,6 +284,7 @@ export function registerGcodeTools(registry: ToolRegistry, getConfirmBaseUrl: ()
try {
const outcome = await job.runner();
job.state = 'completed';
+ job.endedAt = Date.now();
return {
job: jobManager.describe(job),
result: outcome,
@@ -233,8 +298,9 @@ export function registerGcodeTools(registry: ToolRegistry, getConfirmBaseUrl: ()
if (job.kind === 'direct') {
// Direct moves execute over the realtime path so position
- // PERSISTS - the firmware parks back at the work origin when
- // a file job completes, so a file job cannot hold a Z. The
+ // PERSISTS - the machine interpreter raises Z to top at the
+ // finish position when a file job completes, so a file job
+ // cannot hold a working Z (XY holds). The
// operator approved exactly this gcode on the confirm page.
// For a batch, each call executes ONE approved step; the
// token stays valid for the remaining steps (within its TTL).
@@ -281,6 +347,7 @@ export function registerGcodeTools(registry: ToolRegistry, getConfirmBaseUrl: ()
}
}
job.state = 'completed';
+ job.endedAt = Date.now();
return {
job: jobManager.describe(job),
position: settle.position,
@@ -311,10 +378,12 @@ export function registerGcodeTools(registry: ToolRegistry, getConfirmBaseUrl: ()
job.state = 'started';
job.startedAt = Date.now();
+ watchFileJobCompletion(job);
return {
job: jobManager.describe(job),
note: 'Job started. Poll get_gcode_job_status; the controller, its door interlock '
- + 'and the machine UI remain in control.',
+ + 'and the machine UI remain in control. The job is marked completed when the '
+ + 'heartbeat settles back to idle.',
};
},
});
@@ -322,7 +391,8 @@ export function registerGcodeTools(registry: ToolRegistry, getConfirmBaseUrl: ()
registry.register({
name: 'move_z',
description: 'Request absolute Z motion, executed on the direct path so positions PERSIST '
- + '(file jobs park back at the work origin when they complete - firmware behaviour). '
+ + '(the machine interpreter raises Z to top at the finish position when a file job '
+ + 'completes, so a file job cannot hold a working Z - firmware behaviour). '
+ 'Either one target (z) or an ordered list (z_targets, max 20) for a methodical search: '
+ 'the operator approves the EXACT list once, and each start_gcode_job call then executes '
+ 'one step, so you can capture between steps and abandon the series at any point. The '
@@ -494,6 +564,7 @@ export function registerGcodeTools(registry: ToolRegistry, getConfirmBaseUrl: ()
const stopped = await channel.stopGcodeJob();
if (stopped.ok) {
job.state = 'stopped';
+ job.endedAt = Date.now();
}
return {
ok: stopped.ok,
From 4e9db93c0d8b9bc3846f94cd59160cf18abf6022 Mon Sep 17 00:00:00 2001
From: tyeth
Date: Wed, 2 Sep 2026 14:28:40 +0100
Subject: [PATCH 048/135] Feat: probe_vector + probe_circle, and the law-2
hardening they forced
Two new staged probing procedures, both hardware-validated 2026-09-02 on
the tool-setter air-blast post (4-side + 8-point runs, all confirm-pass
spreads 0; circle fit agreed with the independent 4-side answer within
0.05mm, max residual 0.074mm):
- probe_vector: sensor-gated march along an ARBITRARY direction (any XY
heading, optionally angled downward, never upward), parametrized along
the unit vector so every commanded position lies on the approved
segment. probe_point is the axis-aligned special case.
- probe_circle: N radial marches around a round vertical feature, then a
least-squares (Kasa) fit. Requires operator MIN/MAX diameter estimates
(start beyond max/2, ABORT at min/2 without contact) and a MEASURED
top height. Reports fitted centre, COMBINED diameter (feature + tip,
inseparable without one known) and per-azimuth residuals (tip
roundness). Repositioning obeys motion law 2: hops only at the safe
traverse height.
Motion-law hardening from live operator corrections:
- Law 2 absolute: ALL XY moves over 1mm at top gantry height - no local
hop heights; sub-gantry XY is fine positioning <= 1mm only.
- probe_point/probe_vector finish at the traverse height inside the
approved envelope (no separate staged lift between probes).
- move_z staged gcode self-documents: reason, frame, per-step deltas,
and the sensor arrangement (no contact expected; trigger = CRASH).
Probe envelopes carry their reason as a comment.
- Direct execute path strips comment/blank lines: the controller never
replies to a payload led by comments (hung live, job e34b19a913ff);
the header stays on the confirm page only.
Job-flow fixes found live:
- Batch direct jobs can resume past step 1 (consumeToken accepted only
'approved'; a mid-series batch reads 'started' with its token
re-armed).
- The confirm page re-shows a still-valid code on revisit - a lost tab
no longer dead-ends an approval.
Docs: work origins do NOT survive machine reboots (re-verify
originOffset every session); corrected end-of-job notes.
Co-Authored-By: Claude Fable 5
---
.claude/skills/cnc-probing/SKILL.md | 30 +-
src/server/services/mcp/README.md | 15 +-
src/server/services/mcp/jobs.ts | 57 ++-
src/server/services/mcp/probeCircle.ts | 453 +++++++++++++++++++++++
src/server/services/mcp/probeTool.ts | 23 +-
src/server/services/mcp/probeVector.ts | 367 ++++++++++++++++++
src/server/services/mcp/tools/gcode.ts | 49 ++-
src/server/services/mcp/tools/probing.ts | 143 ++++++-
8 files changed, 1104 insertions(+), 33 deletions(-)
create mode 100644 src/server/services/mcp/probeCircle.ts
create mode 100644 src/server/services/mcp/probeVector.ts
diff --git a/.claude/skills/cnc-probing/SKILL.md b/.claude/skills/cnc-probing/SKILL.md
index 0df0801e5d..95ed025544 100644
--- a/.claude/skills/cnc-probing/SKILL.md
+++ b/.claude/skills/cnc-probing/SKILL.md
@@ -23,12 +23,17 @@ height drove the probe into the rotary stock and destroyed it.
"then we'll traverse", an approved plan that lists it — is context, not
a command: announce the next motion and WAIT for the word (violated
2026-09-02: homed off the back of "before homing").
-2. **X/Y traverses happen at top gantry height.** Retreat Z (operator-
- confirmed `move_z`) to the safe traverse height FIRST, traverse, then
- descend at the destination. Enforced: direct XY moves below
- `mcpSafeTraverseZ` (default 320) are refused without
- `operator_confirmed_clearance` — which only the operator's explicit words
- authorise.
+2. **X/Y traverses happen at top gantry height — ALL of them.** Any XY move
+ over 1 mm is planned at the safe traverse height, with no exceptions: not
+ between probe points, not "local hops" above a measured feature top, not
+ at any other "measured safe" height (operator, 2026-09-02: "x/y motion
+ over 1mm is never below gantry height"). Retreat Z FIRST, traverse, then
+ descend at the destination. The only sub-gantry XY motion is fine
+ positioning of <= 1 mm (touch-test nudges, probe march steps). Enforced:
+ direct XY moves below `mcpSafeTraverseZ` (default 320) are refused
+ without `operator_confirmed_clearance` — which only the operator's
+ explicit words authorise, and which is for emergencies, not for planning
+ around this law.
3. **Never fabricate clearance.** Only measured numbers or operator-stated
numbers count for heights. Visual inference from survey frames is for
FINDING things, not for clearing them — the crash analysis misread the
@@ -83,6 +88,19 @@ Feed latency (hardware-measured, Adafruit IO): trigger message ~120–150 ms
after physical contact; 200 ms contact windows are right. Release messages
lag ~1 s — release checks are patient by design; never shorten them.
+## Circle probing
+
+`probe_circle` measures a roughly-round vertical feature (post, boss, pin):
+N radial marches from evenly spaced azimuths, one staged envelope, then a
+least-squares circle fit. It REQUIRES the operator's min/max diameter
+estimates (marches start beyond max/2 and abort at min/2 without contact)
+and a MEASURED top height. Physics: every contact adds the tip's effective
+radius, so the fit yields the COMBINED diameter — feature and tip are
+inseparable unless one is known. Direction-dependent residuals expose an
+out-of-round tip (the post-unbending health check). Repositioning between
+points obeys law 2 in full: lift to the safe traverse height, hop, descend;
+a probe touch during a hop or descent latches the CRASH alarm.
+
## Bed survey
`survey_bed` at top gantry height: serpentine grid, one settled frame per
diff --git a/src/server/services/mcp/README.md b/src/server/services/mcp/README.md
index d6ab56c492..e4c42f4fbb 100644
--- a/src/server/services/mcp/README.md
+++ b/src/server/services/mcp/README.md
@@ -83,9 +83,13 @@ it with no decision point, when the operator had authorised "step 1" only. Laws:
run one at a time. Only an explicit imperative in the operator's latest message
authorizes a motion; a motion mentioned in passing ("before homing", "then we'll…",
a previously approved plan) is context, not a command — announce and wait.
-2. **X/Y traverses at top gantry height** — direct XY moves below `mcpSafeTraverseZ`
- (default 320) are refused without `operator_confirmed_clearance`. Retreat, traverse,
- descend — in that order.
+2. **X/Y traverses at top gantry height — ALL of them** (operator, 2026-09-02: "x/y motion
+ over 1mm is never below gantry height"). Any XY move over 1 mm is planned at the safe
+ traverse height — no local hops above a measured feature, no other "measured safe"
+ heights. Retreat, traverse, descend — in that order. Sub-gantry XY is only fine
+ positioning <= 1 mm (touch nudges, probe march steps). Enforced: direct XY below
+ `mcpSafeTraverseZ` (default 320) refused without `operator_confirmed_clearance`, which
+ is for emergencies on the operator's explicit words, not a planning device.
3. **No fabricated clearances** — only measured or operator-stated heights count. Visual
inference finds things; it never clears them.
4. **Landmarks are obstacles** — `clearance_z` on a landmark refuses XY paths crossing its
@@ -146,7 +150,10 @@ it with no decision point, when the operator had authorised "step 1" only. Laws:
homing = `G53;G28;G54` exactly like Luban's button
(a bare G28 leaves reporting in an unselected workspace → impossible derived coords like
Y 464/Z 656). Homing takes ~15–20 s and **also homes B — stock on the rotary rotates**.
-- **Work origins are operator-set per workspace and persist across homing.**
+- **Work origins are operator-set per workspace and persist across homing — but NOT across
+ machine reboots** (operator, 2026-09-02): a rebooted machine gets a new work origin, so
+ never assume it carries over between sessions; re-verify `originOffset` after every
+ (re)connect before trusting work coordinates.
- **The machine interpreter returns to Z top at the job's finish position when a file job
COMPLETES** (operator-clarified 2026-09-02; supersedes the earlier "parks at the work
origin" reading — Z top and this setup's work-origin Z are both 328, which hid the
diff --git a/src/server/services/mcp/jobs.ts b/src/server/services/mcp/jobs.ts
index eb2e7022a2..6b528922cf 100644
--- a/src/server/services/mcp/jobs.ts
+++ b/src/server/services/mcp/jobs.ts
@@ -132,7 +132,15 @@ export class JobManager {
* Verify a human-supplied confirm token for a job. Single-use, expiring.
*/
public consumeToken(job: McpJob, token: string): { ok: boolean; reason?: string } {
- if (job.state !== 'approved' || !job.confirmToken) {
+ // A batch direct job mid-series reads 'started' with steps remaining
+ // and its token re-armed; the operator's one approval covers the
+ // exact list, so the same code keeps working (bug found live
+ // 2026-09-02: step 2 of a 9-step ladder was refused).
+ const batchMidSeries = job.state === 'started'
+ && Array.isArray(job.steps)
+ && (job.nextStep || 0) < job.steps.length
+ && !job.tokenUsed;
+ if ((job.state !== 'approved' && !batchMidSeries) || !job.confirmToken) {
return { ok: false, reason: `Job is ${job.state}, not approved.` };
}
if (job.tokenUsed) {
@@ -204,6 +212,15 @@ export class JobManager {
const action = match[3];
if (req.method === 'GET' && !action) {
+ // Revisiting an approved job re-shows its code while it is still
+ // valid (unused, unexpired) - losing the tab must not dead-end
+ // the approval (operator request 2026-09-02). MCP still never
+ // sees the code; this is the loopback browser only.
+ if (job.state === 'approved' && job.confirmToken && !job.tokenUsed
+ && Date.now() - (job.approvedAt || 0) <= CONFIRM_TOKEN_TTL_MS) {
+ this.page(res, 200, this.approvedPage(job));
+ return;
+ }
this.page(res, 200, this.reviewPage(job));
return;
}
@@ -216,21 +233,7 @@ export class JobManager {
job.approvedAt = Date.now();
job.state = 'approved';
log.info(`MCP job ${job.id} approved by operator`);
- // The gcode is shown AGAIN next to the code (operator request
- // after the 2026-09-01 probe crash): the last thing seen before
- // handing over the code is exactly what the code will run.
- const approvedGcode = fs.readFileSync(job.filePath, 'utf8');
- const approvedLines = approvedGcode.split(/\r?\n/);
- const approvedPreview = approvedLines.length > 80
- ? [...approvedLines.slice(0, 40), `... ${approvedLines.length - 80} lines elided ...`, ...approvedLines.slice(-40)].join('\n')
- : approvedGcode;
- this.page(res, 200, `
-
Approved
-
Give this one-time code to the agent to start ${escapeHtml(job.name)}:
-
${job.confirmToken}
-
It expires in 15 minutes and works once.
-
This code will run exactly:
-
${escapeHtml(approvedPreview)}
`);
+ this.page(res, 200, this.approvedPage(job));
return;
}
if (req.method === 'POST' && action === 'reject') {
@@ -244,6 +247,28 @@ export class JobManager {
res.end();
}
+ /**
+ * The gcode is shown AGAIN next to the code (operator request after the
+ * 2026-09-01 probe crash): the last thing seen before handing over the
+ * code is exactly what the code will run. Re-rendered on GET while the
+ * code is still valid, so a lost tab does not dead-end the approval.
+ */
+ private approvedPage(job: McpJob): string {
+ const approvedGcode = fs.readFileSync(job.filePath, 'utf8');
+ const approvedLines = approvedGcode.split(/\r?\n/);
+ const approvedPreview = approvedLines.length > 80
+ ? [...approvedLines.slice(0, 40), `... ${approvedLines.length - 80} lines elided ...`, ...approvedLines.slice(-40)].join('\n')
+ : approvedGcode;
+ return `
+
Approved
+
Give this one-time code to the agent to start ${escapeHtml(job.name)}:
+
${job.confirmToken}
+
It expires 15 minutes after approval and works once
+ (a batch of moves: once per approved step).
+
This code will run exactly:
+
${escapeHtml(approvedPreview)}
`;
+ }
+
private reviewPage(job: McpJob): string {
const v = job.validation;
const gcodeText = fs.readFileSync(job.filePath, 'utf8');
diff --git a/src/server/services/mcp/probeCircle.ts b/src/server/services/mcp/probeCircle.ts
new file mode 100644
index 0000000000..836db24e40
--- /dev/null
+++ b/src/server/services/mcp/probeCircle.ts
@@ -0,0 +1,453 @@
+/* eslint-disable camelcase */
+// MCP tool arguments are snake_case by convention (planProbeCircle takes the
+// probe_circle arguments verbatim).
+import { mcpBroadcast } from './index';
+import { probeFeedService } from './probeFeed';
+import {
+ COARSE_FEED,
+ FINE_FEED,
+ MAX_RETREAT_MM,
+ ProcedureAbort,
+ TRAVEL_FEED,
+ assertChannelReady,
+ assertMachineReadyForProcedure,
+ moveMachineSettled,
+ senseAfter,
+ senseReleaseAfter,
+} from './probing';
+import { McpToolError } from './registry';
+import { getMachineSizeByIdentifier, getPositionSnapshot, safeTraverseZ } from './tools/machine';
+import { connectionManager } from '../machine/ConnectionManager';
+
+// Circle probing: N sensor-gated radial marches around a roughly-round
+// vertical feature (a post, a boss, a pin), then a least-squares circle fit.
+// Physics note the result carries: every contact adds the probe tip's
+// effective radius, so the fit yields the COMBINED diameter
+// (feature + tip) - they cannot be separated without one being known.
+// The operator's min/max diameter estimates bound every march: approaches
+// start beyond max/2 and abort at min/2 without contact instead of
+// pressing on into a wrong guess.
+//
+// Safety inherits the probe_point mechanics (hardware-proven), plus: the
+// probe channel is EXPECTED contact only while marching/confirming - during
+// repositioning hops and descents it is not, so a graze there latches the
+// CRASH alarm instead of being absorbed. Repositioning between points obeys
+// motion law 2 in full (operator, 2026-09-02: "x/y motion over 1mm is never
+// below gantry height"): every hop lifts to the safe traverse height,
+// traverses, then descends - no local hop heights above the feature.
+
+export interface ProbeCirclePoint {
+ azimuthDeg: number;
+ startXY: { x: number; y: number };
+}
+
+export interface ProbeCirclePlan {
+ center: { x: number; y: number }; // operator estimate, machine coords
+ diameterMinMm: number;
+ diameterMaxMm: number;
+ topZMachine: number; // toolhead machine Z with the tip at the feature top
+ probeZ: number; // side-contact toolhead Z
+ hopZ: number; // repositioning toolhead Z: the safe traverse height (law 2)
+ startRadiusMm: number;
+ floorRadiusMm: number;
+ points: ProbeCirclePoint[];
+ coarseStepMm: number;
+ fineStepMm: number;
+ backoffMm: number;
+ sensorDelayMs: number;
+ confirmPasses: number;
+ staged: { x: number; y: number; z: number }; // position at staging
+}
+
+export function planProbeCircle(args: {
+ center_x?: number;
+ center_y?: number;
+ diameter_min_mm?: number;
+ diameter_max_mm?: number;
+ top_z_machine?: number;
+ probe_depth_mm?: number;
+ points?: number;
+ approach_clearance_mm?: number;
+ coarse_step_mm?: number;
+ fine_step_mm?: number;
+ backoff_mm?: number;
+ sensor_delay_ms?: number;
+ confirm_passes?: number;
+}): ProbeCirclePlan {
+ const centerX = Number(args.center_x);
+ const centerY = Number(args.center_y);
+ if (!Number.isFinite(centerX) || !Number.isFinite(centerY)) {
+ throw new McpToolError('center_x and center_y (machine coords, operator estimate) are required.');
+ }
+ const dMin = Number(args.diameter_min_mm);
+ const dMax = Number(args.diameter_max_mm);
+ if (!Number.isFinite(dMin) || !Number.isFinite(dMax) || dMin <= 0 || dMax < dMin || dMax > 100) {
+ throw new McpToolError('diameter_min_mm and diameter_max_mm are required: the operator\'s bounds '
+ + 'on the feature diameter (0 < min <= max <= 100). They bound every march - no contact by '
+ + 'the min-diameter radius aborts instead of pressing on.');
+ }
+ const topZ = Number(args.top_z_machine);
+ if (!Number.isFinite(topZ) || topZ <= 0 || topZ > 400) {
+ throw new McpToolError('top_z_machine is required: the toolhead machine Z at which the probe tip '
+ + 'touches the feature TOP - a measured or operator-stated number, never a guess.');
+ }
+ const probeDepth = Math.min(Math.max(Number(args.probe_depth_mm) || 3, 0.5), 20);
+ const pointCount = Math.min(Math.max(Math.round(Number(args.points) || 8), 4), 16);
+ const approach = Math.min(Math.max(Number(args.approach_clearance_mm) || 5, 1), 20);
+
+ const startRadius = dMax / 2 + approach;
+ const floorRadius = dMin / 2;
+ if (startRadius - floorRadius < 1) {
+ throw new McpToolError('Less than 1 mm between the approach start radius and the min-diameter '
+ + 'floor - widen approach_clearance_mm or the diameter bounds.');
+ }
+
+ const position = getPositionSnapshot();
+ const { x, y, z } = position.machine;
+ if (x === null || y === null || z === null) {
+ throw new McpToolError('Current machine position unknown; cannot anchor the envelope.');
+ }
+
+ const size = getMachineSizeByIdentifier(connectionManager.getConnectionStatus().machineIdentifier);
+ const points: ProbeCirclePoint[] = [];
+ for (let i = 0; i < pointCount; i++) {
+ const azimuth = (360 / pointCount) * i;
+ const rad = (azimuth * Math.PI) / 180;
+ const sx = Number((centerX + startRadius * Math.cos(rad)).toFixed(3));
+ const sy = Number((centerY + startRadius * Math.sin(rad)).toFixed(3));
+ if (size && (sx < -25 || sx > size.x + 40 || sy < -25 || sy > size.y + 40)) {
+ throw new McpToolError(`Approach start for azimuth ${azimuth.toFixed(0)} deg `
+ + `(${sx}, ${sy}) falls outside the machine envelope.`);
+ }
+ points.push({ azimuthDeg: azimuth, startXY: { x: sx, y: sy } });
+ }
+
+ return {
+ center: { x: centerX, y: centerY },
+ diameterMinMm: dMin,
+ diameterMaxMm: dMax,
+ topZMachine: topZ,
+ probeZ: Number((topZ - probeDepth).toFixed(3)),
+ hopZ: safeTraverseZ(),
+ startRadiusMm: Number(startRadius.toFixed(3)),
+ floorRadiusMm: Number(floorRadius.toFixed(3)),
+ points,
+ coarseStepMm: Math.min(Math.max(Number(args.coarse_step_mm) || 0.5, 0.2), 2),
+ fineStepMm: Math.min(Math.max(Number(args.fine_step_mm) || 0.1, 0.02), 0.5),
+ backoffMm: Math.min(Math.max(Number(args.backoff_mm) || 0.5, Number(args.fine_step_mm) || 0.1), 3),
+ sensorDelayMs: Math.min(Math.max(Number(args.sensor_delay_ms) || 200, 100), 10000),
+ confirmPasses: Math.min(Math.max(Math.round(Number(args.confirm_passes) || 2), 1), 5),
+ staged: { x, y, z },
+ };
+}
+
+/** Confirm-page gcode: every commanded move of the whole star, enumerated. */
+export function describeProbeCirclePlanAsGcode(plan: ProbeCirclePlan): string {
+ const lines = [
+ `; TOUCH PROBE CIRCLE MEASUREMENT: ${plan.points.length} radial marches around `
+ + `estimated centre (${plan.center.x}, ${plan.center.y})`,
+ `; operator diameter bounds ${plan.diameterMinMm}..${plan.diameterMaxMm} mm; approaches start at `
+ + `radius ${plan.startRadiusMm} and ABORT at radius ${plan.floorRadiusMm} without contact`,
+ `; feature top measured at toolhead Z ${plan.topZMachine}; side contacts at Z ${plan.probeZ}; `
+ + `hops between points at the safe traverse height Z ${plan.hopZ} (motion law 2)`,
+ '; EVERY LINE IS SENT INDIVIDUALLY and settle-verified; the probe feed is checked after each',
+ '; march step. A probe touch during a hop or descent latches the CRASH alarm.',
+ '; overtravel feed trips -> job stop + connection close + latched alarm',
+ 'G90',
+ 'G53;',
+ ];
+ for (const point of plan.points) {
+ const rad = (point.azimuthDeg * Math.PI) / 180;
+ lines.push(`; --- point at azimuth ${point.azimuthDeg.toFixed(1)} deg ---`);
+ lines.push(`G1 Z${plan.hopZ.toFixed(3)} F${TRAVEL_FEED}; lift to safe traverse height`);
+ lines.push(`G1 X${point.startXY.x.toFixed(3)} Y${point.startXY.y.toFixed(3)} F${TRAVEL_FEED}; approach start`);
+ lines.push(`G1 Z${plan.probeZ.toFixed(3)} F${TRAVEL_FEED}; descend to probing depth`);
+ let r = plan.startRadiusMm;
+ let step = 0;
+ while (r - plan.floorRadiusMm > 1e-9) {
+ r = Math.max(r - plan.coarseStepMm, plan.floorRadiusMm);
+ step += 1;
+ const px = plan.center.x + r * Math.cos(rad);
+ const py = plan.center.y + r * Math.sin(rad);
+ lines.push(`G1 X${px.toFixed(3)} Y${py.toFixed(3)} F${COARSE_FEED}; coarse step ${step} `
+ + `(radius ${r.toFixed(3)}) - settle, check probe, stop at contact`);
+ }
+ lines.push(`; ...on contact: retreat ${plan.coarseStepMm} mm radially until released, fine approach `
+ + `in ${plan.fineStepMm} mm steps, ${plan.confirmPasses} confirm cycle(s) (lift ${plan.backoffMm} mm)`);
+ lines.push(`G1 X${point.startXY.x.toFixed(3)} Y${point.startXY.y.toFixed(3)} F${TRAVEL_FEED}; retreat to approach start`);
+ }
+ lines.push(`G1 Z${plan.hopZ.toFixed(3)} F${TRAVEL_FEED}; final lift to hop height`);
+ lines.push('G54;');
+ return lines.join('\n');
+}
+
+/** Kasa least-squares circle fit; returns centre, radius and residuals. */
+function fitCircle(contacts: { x: number; y: number }[]): {
+ center: { x: number; y: number };
+ radius: number;
+ residuals: number[];
+ rmsResidual: number;
+ maxResidual: number;
+} {
+ // Linear system: x^2 + y^2 = 2ax + 2by + c, solved by normal equations.
+ let sxx = 0; let sxy = 0; let syy = 0; let sx = 0; let sy = 0;
+ let sxz = 0; let syz = 0; let sz = 0;
+ const n = contacts.length;
+ for (const p of contacts) {
+ const zz = p.x * p.x + p.y * p.y;
+ sxx += p.x * p.x; sxy += p.x * p.y; syy += p.y * p.y;
+ sx += p.x; sy += p.y;
+ sxz += p.x * zz; syz += p.y * zz; sz += zz;
+ }
+ // Solve [2sxx 2sxy sx; 2sxy 2syy sy; 2sx 2sy n] * [a b c]' = [sxz syz sz]'
+ const m = [
+ [2 * sxx, 2 * sxy, sx, sxz],
+ [2 * sxy, 2 * syy, sy, syz],
+ [2 * sx, 2 * sy, n, sz],
+ ];
+ for (let col = 0; col < 3; col++) {
+ let pivot = col;
+ for (let row = col + 1; row < 3; row++) {
+ if (Math.abs(m[row][col]) > Math.abs(m[pivot][col])) {
+ pivot = row;
+ }
+ }
+ [m[col], m[pivot]] = [m[pivot], m[col]];
+ if (Math.abs(m[col][col]) < 1e-12) {
+ throw new ProcedureAbort('Circle fit is degenerate (contacts nearly collinear).');
+ }
+ for (let row = 0; row < 3; row++) {
+ if (row === col) {
+ continue;
+ }
+ const factor = m[row][col] / m[col][col];
+ for (let k = col; k < 4; k++) {
+ m[row][k] -= factor * m[col][k];
+ }
+ }
+ }
+ const a = m[0][3] / m[0][0];
+ const b = m[1][3] / m[1][1];
+ const c = m[2][3] / m[2][2];
+ const radius = Math.sqrt(Math.max(c + a * a + b * b, 0));
+ const residuals = contacts.map((p) => Number((Math.hypot(p.x - a, p.y - b) - radius).toFixed(4)));
+ const rms = Math.sqrt(residuals.reduce((acc, r) => acc + r * r, 0) / n);
+ return {
+ center: { x: Number(a.toFixed(3)), y: Number(b.toFixed(3)) },
+ radius: Number(radius.toFixed(4)),
+ residuals,
+ rmsResidual: Number(rms.toFixed(4)),
+ maxResidual: Number(Math.max(...residuals.map((r) => Math.abs(r))).toFixed(4)),
+ };
+}
+
+export async function runProbeCircleProcedure(plan: ProbeCirclePlan): Promise {
+ assertChannelReady('probe', 'circle probe');
+ assertMachineReadyForProcedure();
+
+ const position = getPositionSnapshot();
+ const here = position.machine;
+ if (here.x === null || here.y === null || here.z === null
+ || Math.abs(here.x - plan.staged.x) > 0.5
+ || Math.abs(here.y - plan.staged.y) > 0.5
+ || Math.abs(here.z - plan.staged.z) > 0.5) {
+ throw new McpToolError('The machine is not at the position this envelope was staged from '
+ + `(staged (${plan.staged.x}, ${plan.staged.y}, ${plan.staged.z}), now `
+ + `(${here.x}, ${here.y}, ${here.z})). Stage probe_circle again.`);
+ }
+ if (here.z < plan.hopZ - 0.5) {
+ throw new McpToolError(`Current machine Z ${here.z} is below the hop height ${plan.hopZ}; `
+ + 'position at or above it before staging.');
+ }
+
+ const phases: { phase: string; note?: string }[] = [];
+ const announce = (phase: string, note?: string) => {
+ phases.push({ phase, note });
+ mcpBroadcast('mcp:activity', { tool: 'probe_circle', phase, note });
+ };
+ const releaseTimeoutMs = Math.max(plan.sensorDelayMs * 4, 2500);
+ const contacts: { azimuthDeg: number; x: number; y: number; radius: number; passRadii: number[]; spreadMm: number }[] = [];
+
+ const radialXY = (azimuthRad: number, r: number) => ({
+ x: Number((plan.center.x + r * Math.cos(azimuthRad)).toFixed(3)),
+ y: Number((plan.center.y + r * Math.sin(azimuthRad)).toFixed(3)),
+ });
+
+ try {
+ for (const point of plan.points) {
+ const rad = (point.azimuthDeg * Math.PI) / 180;
+ const label = `az${point.azimuthDeg.toFixed(0)}`;
+
+ // Reposition: contact here is NOT expected - a graze latches CRASH.
+ probeFeedService.clearExpectedContact();
+ await moveMachineSettled(`circle:hop:${label}`, { z: plan.hopZ }, TRAVEL_FEED);
+ await moveMachineSettled(`circle:hop:${label}`, { ...point.startXY }, TRAVEL_FEED);
+ await moveMachineSettled(`circle:descend:${label}`, { z: plan.probeZ }, TRAVEL_FEED);
+ announce(`start-${label}`, `approach start (${point.startXY.x}, ${point.startXY.y}) Z${plan.probeZ}`);
+
+ // March radially inward; contact on the probe channel is expected.
+ probeFeedService.setExpectedContact(['probe']);
+ let r = plan.startRadiusMm;
+ let coarseContactR: number | null = null;
+ while (r - plan.floorRadiusMm > 1e-9) {
+ const stepStart = Date.now();
+ r = Math.max(r - plan.coarseStepMm, plan.floorRadiusMm);
+ await moveMachineSettled(`circle:coarse:${label}`, radialXY(rad, r), COARSE_FEED);
+ const sensed = await senseAfter('probe', stepStart, plan.sensorDelayMs);
+ if (sensed.contact) {
+ coarseContactR = r;
+ announce(`coarse-contact-${label}`, `radius ${r.toFixed(3)}`);
+ break;
+ }
+ }
+ if (coarseContactR === null) {
+ throw new ProcedureAbort(`No contact by the min-diameter radius ${plan.floorRadiusMm} at `
+ + `azimuth ${point.azimuthDeg.toFixed(0)} deg - the centre estimate or diameter bounds `
+ + 'are wrong, or the probe is not reporting.');
+ }
+
+ // Retreat radially until released.
+ let released = false;
+ while (r < plan.startRadiusMm - 1e-9 && r - coarseContactR < MAX_RETREAT_MM + 1e-9) {
+ const stepStart = Date.now();
+ r = Math.min(r + plan.coarseStepMm, plan.startRadiusMm);
+ await moveMachineSettled(`circle:release:${label}`, radialXY(rad, r), COARSE_FEED);
+ const sensed = await senseReleaseAfter('probe', stepStart, releaseTimeoutMs);
+ if (!sensed.contact) {
+ released = true;
+ break;
+ }
+ }
+ if (!released) {
+ throw new ProcedureAbort(`Probe still triggered ${MAX_RETREAT_MM} mm back from contact at `
+ + `azimuth ${point.azimuthDeg.toFixed(0)} deg - stuck probe or feed fault.`);
+ }
+
+ // Fine approach.
+ let fineContactR: number | null = null;
+ while (r - plan.floorRadiusMm > 1e-9) {
+ const stepStart = Date.now();
+ r = Math.max(r - plan.fineStepMm, plan.floorRadiusMm);
+ await moveMachineSettled(`circle:fine:${label}`, radialXY(rad, r), FINE_FEED);
+ const sensed = await senseAfter('probe', stepStart, plan.sensorDelayMs);
+ if (sensed.contact) {
+ fineContactR = r;
+ break;
+ }
+ }
+ if (fineContactR === null) {
+ throw new ProcedureAbort(`Fine approach lost the contact at azimuth ${point.azimuthDeg.toFixed(0)} deg.`);
+ }
+
+ // Confirm cycles on the radius; median wins.
+ const passRadii: number[] = [];
+ const cycleFloor = Math.max(fineContactR - 0.5, plan.floorRadiusMm);
+ let reference = fineContactR;
+ for (let pass = 1; pass <= plan.confirmPasses; pass++) {
+ const liftIssuedAt = Date.now();
+ r = Math.min(reference + plan.backoffMm, plan.startRadiusMm);
+ await moveMachineSettled(`circle:backoff:${label}`, radialXY(rad, r), FINE_FEED);
+ const liftSense = await senseReleaseAfter('probe', liftIssuedAt, releaseTimeoutMs);
+ if (liftSense.contact) {
+ throw new ProcedureAbort(`Probe still triggered after backing off ${plan.backoffMm} mm at `
+ + `azimuth ${point.azimuthDeg.toFixed(0)} deg - hysteresis exceeds the backoff.`);
+ }
+ let passContact: number | null = null;
+ while (r - cycleFloor > 1e-9) {
+ const stepStart = Date.now();
+ r = Math.max(r - plan.fineStepMm, cycleFloor);
+ await moveMachineSettled(`circle:confirm:${label}`, radialXY(rad, r), FINE_FEED);
+ const sensed = await senseAfter('probe', stepStart, plan.sensorDelayMs);
+ if (sensed.contact) {
+ passContact = r;
+ break;
+ }
+ }
+ if (passContact === null) {
+ throw new ProcedureAbort(`Confirm pass ${pass} lost the contact at azimuth `
+ + `${point.azimuthDeg.toFixed(0)} deg.`);
+ }
+ passRadii.push(Number(passContact.toFixed(3)));
+ reference = passContact;
+ }
+ const sorted = [...passRadii].sort((a, b) => a - b);
+ const measuredR = sorted[Math.floor((sorted.length - 1) / 2)];
+ const spreadMm = Number((sorted[sorted.length - 1] - sorted[0]).toFixed(3));
+ const contactPoint = radialXY(rad, measuredR);
+ contacts.push({
+ azimuthDeg: point.azimuthDeg,
+ ...contactPoint,
+ radius: Number(measuredR.toFixed(3)),
+ passRadii,
+ spreadMm,
+ });
+ announce(`measured-${label}`, `radius ${measuredR.toFixed(3)} spread ${spreadMm}`);
+
+ // Retreat radially to the approach start (still expected-contact
+ // until physically clear).
+ await moveMachineSettled(`circle:retreat:${label}`, { ...point.startXY }, TRAVEL_FEED);
+ }
+
+ // Final lift; reposition rules apply again.
+ probeFeedService.clearExpectedContact();
+ await moveMachineSettled('circle:final-lift', { z: plan.hopZ }, TRAVEL_FEED);
+ announce('final-lift', `Z${plan.hopZ}`);
+
+ const fit = fitCircle(contacts.map((c) => ({ x: c.x, y: c.y })));
+ const combinedDiameter = Number((fit.radius * 2).toFixed(3));
+ const tipMin = Number(Math.max(combinedDiameter - plan.diameterMaxMm, 0).toFixed(3));
+ const tipMax = Number(Math.max(combinedDiameter - plan.diameterMinMm, 0).toFixed(3));
+ const worstSpread = Math.max(...contacts.map((c) => c.spreadMm));
+ return {
+ contacts,
+ fit: {
+ center: fit.center,
+ combinedDiameterMm: combinedDiameter,
+ rmsResidualMm: fit.rmsResidual,
+ maxResidualMm: fit.maxResidual,
+ residualsMm: fit.residuals,
+ },
+ centerOffsetFromEstimate: {
+ x: Number((fit.center.x - plan.center.x).toFixed(3)),
+ y: Number((fit.center.y - plan.center.y).toFixed(3)),
+ },
+ tipEffectiveDiameterMm: { min: tipMin, max: tipMax },
+ phases,
+ note: `Fitted centre machine (${fit.center.x}, ${fit.center.y}), COMBINED diameter `
+ + `${combinedDiameter} mm (feature + probe tip - inseparable without one known: if the `
+ + `feature is truly ${plan.diameterMinMm}..${plan.diameterMaxMm} mm, the tip's effective `
+ + `diameter is ${tipMin}..${tipMax} mm). Fit residuals rms ${fit.rmsResidual} / max `
+ + `${fit.maxResidual} mm - direction-dependent residuals mean an out-of-round tip or `
+ + `feature. Worst per-point confirm spread ${worstSpread} mm.`,
+ warning: fit.maxResidual > 0.2
+ ? 'Max fit residual exceeds 0.2 mm: the feature or the probe tip is significantly '
+ + 'out of round, or a contact was bad. Inspect residualsMm by azimuth.'
+ : undefined,
+ };
+ } catch (err) {
+ const isTrip = !!probeFeedService.getTrip();
+ if (!isTrip) {
+ try {
+ // Abort retreat: radially out to the current point's start
+ // radius is unknown here, so lift straight up to hopZ only if
+ // the probe reads released; a stuck-triggered probe means
+ // physical contact - leave the machine where it is.
+ const reading = probeFeedService.getReading('probe');
+ if (!reading || !reading.triggered) {
+ await moveMachineSettled('circle:abort-lift', { z: plan.hopZ }, TRAVEL_FEED);
+ announce('abort-lifted', `Z${plan.hopZ}`);
+ } else {
+ announce('abort-held', 'probe still triggered - holding position for the operator');
+ }
+ } catch (retreatErr) {
+ // Logged by the activity stream.
+ }
+ }
+ if (err instanceof ProcedureAbort) {
+ throw new McpToolError(`Circle probe aborted: ${err.message} Phases completed: ${JSON.stringify(phases)}`);
+ }
+ throw err;
+ } finally {
+ probeFeedService.clearExpectedContact();
+ }
+}
diff --git a/src/server/services/mcp/probeTool.ts b/src/server/services/mcp/probeTool.ts
index 76db2ee99d..faa3d8c0ab 100644
--- a/src/server/services/mcp/probeTool.ts
+++ b/src/server/services/mcp/probeTool.ts
@@ -16,7 +16,7 @@ import {
senseReleaseAfter,
} from './probing';
import { McpToolError } from './registry';
-import { getMachineSizeByIdentifier, getPositionSnapshot } from './tools/machine';
+import { getMachineSizeByIdentifier, getPositionSnapshot, safeTraverseZ } from './tools/machine';
import { connectionManager } from '../machine/ConnectionManager';
// Point probing with the spindle-mounted touch probe (normally-open, probe
@@ -131,8 +131,12 @@ export function describeProbePlanAsGcode(plan: ProbePointPlan): string {
`; steps to contact, then ${plan.confirmPasses} quick confirm cycles (lift ${plan.backoffMm} mm, wait for release,`,
`; re-approach). Result = median contact ${word} (spread reported).`,
`G1 ${word}${plan.start[plan.axis].toFixed(3)} F${TRAVEL_FEED}; retreat to the start ${word} when done (also on any abort)`,
- 'G54;',
);
+ const traverse = safeTraverseZ();
+ if (plan.start.z < traverse) {
+ lines.push(`G1 Z${traverse.toFixed(3)} F${TRAVEL_FEED}; finish at the safe traverse height (motion law 2)`);
+ }
+ lines.push('G54;');
return lines.join('\n');
}
@@ -277,9 +281,16 @@ export async function runProbePointProcedure(plan: ProbePointPlan): Promise 1e-9) {
+ throw new McpToolError('Upward probing (dz > 0) is refused - the probe cannot measure the gantry.');
+ }
+ const unit = { x: dx / norm, y: dy / norm, z: dz / norm };
+
+ const requested = Number(args.max_travel_mm);
+ if (!Number.isFinite(requested) || requested < 1 || requested > 150) {
+ throw new McpToolError('max_travel_mm is required: how far the probe may march before aborting (1-150).');
+ }
+
+ const position = getPositionSnapshot();
+ const { x, y, z } = position.machine;
+ if (x === null || y === null || z === null) {
+ throw new McpToolError('Current machine position unknown; cannot anchor the probe envelope.');
+ }
+ const start = { x, y, z };
+
+ // Clamp the travel SCALAR so the entire segment stays inside the same
+ // envelope the direct-move guards use (machine -25..size+40 for X/Y,
+ // Z never below 0) - clamping per-axis would change the direction.
+ let travel = requested;
+ const size = getMachineSizeByIdentifier(connectionManager.getConnectionStatus().machineIdentifier);
+ const clampAxis = (u: number, from: number, lo: number, hi: number) => {
+ if (Math.abs(u) < 1e-9) {
+ return Infinity;
+ }
+ const bound = u > 0 ? hi : lo;
+ return (bound - from) / u;
+ };
+ if (size) {
+ travel = Math.min(travel, clampAxis(unit.x, start.x, -25, size.x + 40));
+ travel = Math.min(travel, clampAxis(unit.y, start.y, -25, size.y + 40));
+ }
+ travel = Math.min(travel, clampAxis(unit.z, start.z, 0, Infinity));
+ if (!Number.isFinite(travel) || travel < 0.5) {
+ throw new McpToolError('The clamped probe travel is under 0.5 mm - already at the envelope edge '
+ + 'along this direction.');
+ }
+ travel = Number(travel.toFixed(3));
+
+ return {
+ unit: {
+ x: Number(unit.x.toFixed(6)),
+ y: Number(unit.y.toFixed(6)),
+ z: Number(unit.z.toFixed(6)),
+ },
+ start,
+ maxTravelMm: travel,
+ requestedTravelMm: requested,
+ limit: {
+ x: Number((start.x + unit.x * travel).toFixed(3)),
+ y: Number((start.y + unit.y * travel).toFixed(3)),
+ z: Number((start.z + unit.z * travel).toFixed(3)),
+ },
+ coarseStepMm: Math.min(Math.max(Number(args.coarse_step_mm) || 1, 0.2), 2),
+ fineStepMm: Math.min(Math.max(Number(args.fine_step_mm) || 0.1, 0.02), 0.5),
+ backoffMm: Math.min(Math.max(Number(args.backoff_mm) || 0.5, Number(args.fine_step_mm) || 0.1), 3),
+ sensorDelayMs: Math.min(Math.max(Number(args.sensor_delay_ms) || 200, 100), 10000),
+ confirmPasses: Math.min(Math.max(Math.round(Number(args.confirm_passes) || 3), 1), 10),
+ };
+}
+
+function pointAt(plan: ProbeVectorPlan, s: number): { x: number; y: number; z: number } {
+ return {
+ x: Number((plan.start.x + plan.unit.x * s).toFixed(3)),
+ y: Number((plan.start.y + plan.unit.y * s).toFixed(3)),
+ z: Number((plan.start.z + plan.unit.z * s).toFixed(3)),
+ };
+}
+
+/** Which axes the move actually needs - pure-XY vectors never command Z. */
+function moveWords(plan: ProbeVectorPlan, s: number): { x?: number; y?: number; z?: number } {
+ const p = pointAt(plan, s);
+ const words: { x?: number; y?: number; z?: number } = {};
+ if (Math.abs(plan.unit.x) > 1e-9) {
+ words.x = p.x;
+ }
+ if (Math.abs(plan.unit.y) > 1e-9) {
+ words.y = p.y;
+ }
+ if (Math.abs(plan.unit.z) > 1e-9) {
+ words.z = p.z;
+ }
+ return words;
+}
+
+/** Confirm-page gcode: every stepped command as it would execute. */
+export function describeProbeVectorPlanAsGcode(plan: ProbeVectorPlan): string {
+ const dir = `(${plan.unit.x}, ${plan.unit.y}, ${plan.unit.z})`;
+ const lines = [
+ '; TOUCH PROBE VECTOR MEASUREMENT (server-driven, sensor-gated on the probe channel)',
+ `; march along unit direction ${dir} from the staging position, max travel ${plan.maxTravelMm} mm`
+ + (plan.maxTravelMm < plan.requestedTravelMm
+ ? ` (requested ${plan.requestedTravelMm}, clamped to the machine envelope)` : ''),
+ '; EVERY LINE IS SENT INDIVIDUALLY: after each move settles, the probe feed is checked',
+ '; before the next line. The march stops at first contact; running the full ladder',
+ `; without contact ABORTS at (${plan.limit.x}, ${plan.limit.y}, ${plan.limit.z}).`,
+ `; anchored at machine (${plan.start.x.toFixed(2)}, ${plan.start.y.toFixed(2)}, ${plan.start.z.toFixed(2)})`
+ + ' - re-verified before any motion',
+ '; overtravel feed trips -> job stop + connection close + latched alarm',
+ 'G90',
+ 'G53;',
+ ];
+ let s = 0;
+ let step = 0;
+ while (plan.maxTravelMm - s > 1e-9) {
+ s = Math.min(s + plan.coarseStepMm, plan.maxTravelMm);
+ step += 1;
+ const w = moveWords(plan, s);
+ const wordText = [
+ w.x !== undefined ? `X${w.x.toFixed(3)}` : '',
+ w.y !== undefined ? `Y${w.y.toFixed(3)}` : '',
+ w.z !== undefined ? `Z${w.z.toFixed(3)}` : '',
+ ].filter(Boolean).join(' ');
+ lines.push(`G1 ${wordText} F${COARSE_FEED}; coarse step ${step} (${s.toFixed(3)} mm along) - settle, check probe, stop at contact`);
+ }
+ lines.push(
+ `; ...on contact: retreat ${plan.coarseStepMm} mm steps along the reverse vector until released,`,
+ `; approach in ${plan.fineStepMm} mm steps to contact, then ${plan.confirmPasses} quick confirm cycles`,
+ `; (lift ${plan.backoffMm} mm along the reverse vector, wait for release, re-approach).`,
+ '; Result = median contact distance -> machine XYZ (spread reported).',
+ `G1 X${plan.start.x.toFixed(3)} Y${plan.start.y.toFixed(3)} Z${plan.start.z.toFixed(3)} F${TRAVEL_FEED}; retreat to the start when done (also on any abort)`,
+ );
+ const traverse = safeTraverseZ();
+ if (plan.start.z < traverse) {
+ lines.push(`G1 Z${traverse.toFixed(3)} F${TRAVEL_FEED}; finish at the safe traverse height (motion law 2)`);
+ }
+ lines.push('G54;');
+ return lines.join('\n');
+}
+
+export async function runProbeVectorProcedure(plan: ProbeVectorPlan): Promise {
+ assertChannelReady('probe', 'vector probe');
+ assertMachineReadyForProcedure();
+ probeFeedService.setExpectedContact(['probe']);
+
+ const position = getPositionSnapshot();
+ const here = position.machine;
+ if (here.x === null || here.y === null || here.z === null
+ || Math.abs(here.x - plan.start.x) > 0.5
+ || Math.abs(here.y - plan.start.y) > 0.5
+ || Math.abs(here.z - plan.start.z) > 0.5) {
+ throw new McpToolError('The machine is not at the position this probe envelope was staged from '
+ + `(staged (${plan.start.x}, ${plan.start.y}, ${plan.start.z}), now `
+ + `(${here.x}, ${here.y}, ${here.z})). Stage probe_vector again from the current position.`);
+ }
+
+ const phases: { phase: string; s: number; note?: string }[] = [];
+ const announce = (phase: string, s: number, note?: string) => {
+ phases.push({ phase, s: Number(s.toFixed(3)), note });
+ mcpBroadcast('mcp:activity', { tool: 'probe_vector', phase, s: Number(s.toFixed(3)), note });
+ };
+ const releaseTimeoutMs = Math.max(plan.sensorDelayMs * 4, 2500);
+ const move = async (tool: string, s: number, feed: number) => {
+ await moveMachineSettled(tool, moveWords(plan, s), feed);
+ };
+
+ let s = 0;
+ try {
+ // Coarse march to first contact.
+ let coarseContactS: number | null = null;
+ while (plan.maxTravelMm - s > 1e-9) {
+ const stepStart = Date.now();
+ s = Math.min(s + plan.coarseStepMm, plan.maxTravelMm);
+ await move('probe-vec:coarse', s, COARSE_FEED);
+ const sensed = await senseAfter('probe', stepStart, plan.sensorDelayMs);
+ if (sensed.contact) {
+ coarseContactS = s;
+ announce('coarse-contact', s, `probe "${sensed.reading?.value}"`);
+ break;
+ }
+ }
+ if (coarseContactS === null) {
+ throw new ProcedureAbort(`Reached the travel limit ${plan.maxTravelMm.toFixed(3)} mm without `
+ + 'contact - nothing to probe within max_travel_mm, or the probe is not reporting.');
+ }
+
+ // Retreat until released.
+ let released = false;
+ while (s > 1e-9 && coarseContactS - s < MAX_RETREAT_MM + 1e-9) {
+ const stepStart = Date.now();
+ s = Math.max(s - plan.coarseStepMm, 0);
+ await move('probe-vec:release', s, COARSE_FEED);
+ const sensed = await senseReleaseAfter('probe', stepStart, releaseTimeoutMs);
+ if (!sensed.contact) {
+ released = true;
+ announce('released', s);
+ break;
+ }
+ }
+ if (!released) {
+ throw new ProcedureAbort(`Probe still reads triggered ${MAX_RETREAT_MM} mm back from first `
+ + 'contact - stuck probe or feed fault.');
+ }
+
+ // Fine approach.
+ let fineContactS: number | null = null;
+ while (plan.maxTravelMm - s > 1e-9) {
+ const stepStart = Date.now();
+ s = Math.min(s + plan.fineStepMm, plan.maxTravelMm);
+ await move('probe-vec:fine', s, FINE_FEED);
+ const sensed = await senseAfter('probe', stepStart, plan.sensorDelayMs);
+ if (sensed.contact) {
+ fineContactS = s;
+ announce('fine-contact', s, `probe "${sensed.reading?.value}"`);
+ break;
+ }
+ }
+ if (fineContactS === null) {
+ throw new ProcedureAbort('Fine approach reached the travel limit without re-contact after a '
+ + 'coarse contact - inconsistent probe.');
+ }
+
+ // Quick lift-and-retest confirm cycles; median wins, spread reported.
+ const passContacts: number[] = [];
+ const cycleLimit = Math.min(fineContactS + 0.5, plan.maxTravelMm);
+ let reference = fineContactS;
+ for (let pass = 1; pass <= plan.confirmPasses; pass++) {
+ const liftIssuedAt = Date.now();
+ s = Math.max(reference - plan.backoffMm, 0);
+ await move('probe-vec:backoff', s, FINE_FEED);
+ const liftSense = await senseReleaseAfter('probe', liftIssuedAt, releaseTimeoutMs);
+ if (liftSense.contact) {
+ throw new ProcedureAbort(`Probe still triggered ${releaseTimeoutMs} ms after backing off `
+ + `${plan.backoffMm} mm - hysteresis exceeds the backoff. Rerun with a larger backoff_mm.`);
+ }
+ let passContact: number | null = null;
+ while (cycleLimit - s > 1e-9) {
+ const stepStart = Date.now();
+ s = Math.min(s + plan.fineStepMm, cycleLimit);
+ await move('probe-vec:confirm', s, FINE_FEED);
+ const sensed = await senseAfter('probe', stepStart, plan.sensorDelayMs);
+ if (sensed.contact) {
+ passContact = s;
+ break;
+ }
+ }
+ if (passContact === null) {
+ throw new ProcedureAbort(`Confirm pass ${pass} went 0.5 mm past the first contact without `
+ + 're-contact - inconsistent probe.');
+ }
+ passContacts.push(Number(passContact.toFixed(3)));
+ announce(`confirm-${pass}`, passContact, `of ${plan.confirmPasses}`);
+ reference = passContact;
+ }
+ const sorted = [...passContacts].sort((a, b) => a - b);
+ const measuredS = sorted[Math.floor((sorted.length - 1) / 2)];
+ const spreadMm = Number((sorted[sorted.length - 1] - sorted[0]).toFixed(3));
+ announce('measured', measuredS, `median of [${passContacts.join(', ')}], spread ${spreadMm} mm`);
+
+ // Retreat along the reverse vector to the start, then finish at
+ // the safe traverse height (operator request 2026-09-02).
+ await move('probe-vec:retreat', 0, TRAVEL_FEED);
+ announce('retreated', 0);
+ const traverse = safeTraverseZ();
+ if (plan.start.z < traverse) {
+ await moveMachineSettled('probe-vec:raise', { z: traverse }, TRAVEL_FEED);
+ announce('raised', 0, `safe traverse height Z${traverse}`);
+ }
+
+ const contactMachine = pointAt(plan, measuredS);
+ const after = getPositionSnapshot();
+ return {
+ direction: plan.unit,
+ contactDistanceMm: measuredS,
+ contactMachine,
+ contactWorkOffsetApplied: `work = machine + originOffset (${JSON.stringify(after.originOffset)})`,
+ confirmPassContacts: passContacts,
+ spreadMm,
+ phases,
+ note: `Probe contact ${measuredS.toFixed(3)} mm along (${plan.unit.x}, ${plan.unit.y}, `
+ + `${plan.unit.z}) from the start -> machine (${contactMachine.x}, ${contactMachine.y}, `
+ + `${contactMachine.z}) - median of ${plan.confirmPasses} passes [${passContacts.join(', ')}], `
+ + `spread ${spreadMm} mm (+/- ${plan.fineStepMm} mm step resolution, minus the probe tip `
+ + 'radius on lateral probes - the tip touches before its centre).',
+ warning: spreadMm > plan.fineStepMm + 1e-9
+ ? `Confirm passes spread ${spreadMm} mm exceeds one fine step - feed timing was unstable; `
+ + 'consider more confirm_passes or a longer sensor_delay_ms.'
+ : undefined,
+ };
+ } catch (err) {
+ const isTrip = !!probeFeedService.getTrip();
+ if (!isTrip) {
+ try {
+ await move('probe-vec:abort-retreat', 0, TRAVEL_FEED);
+ announce('abort-retreated', 0);
+ const traverse = safeTraverseZ();
+ const reading = probeFeedService.getReading('probe');
+ if (plan.start.z < traverse && (!reading || !reading.triggered)) {
+ await moveMachineSettled('probe-vec:abort-raise', { z: traverse }, TRAVEL_FEED);
+ announce('abort-raised', 0, `safe traverse height Z${traverse}`);
+ }
+ } catch (retreatErr) {
+ // Logged by the activity stream.
+ }
+ }
+ if (err instanceof ProcedureAbort) {
+ throw new McpToolError(`Vector probe aborted: ${err.message} Phases completed: ${JSON.stringify(phases)}`);
+ }
+ throw err;
+ } finally {
+ probeFeedService.clearExpectedContact();
+ }
+}
diff --git a/src/server/services/mcp/tools/gcode.ts b/src/server/services/mcp/tools/gcode.ts
index 0cc9917b95..706690c76a 100644
--- a/src/server/services/mcp/tools/gcode.ts
+++ b/src/server/services/mcp/tools/gcode.ts
@@ -311,7 +311,18 @@ export function registerGcodeTools(registry: ToolRegistry, getConfirmBaseUrl: ()
const gcodeText = isBatch
? job.steps[job.nextStep]
: fs.readFileSync(job.filePath, 'utf8');
- const executed = await sendGcodeVisible(channel as GcodeChannel, `direct:${job.name}`, gcodeText);
+ // The staged text carries a human-facing comment header for
+ // the confirm page; the realtime execute path must get pure
+ // commands - the controller never replied to a payload led by
+ // comment lines (hung live, 2026-09-02, job e34b19a913ff).
+ const executable = gcodeText
+ .split(/\r?\n/)
+ .filter((line) => {
+ const trimmed = line.trim();
+ return trimmed.length > 0 && !trimmed.startsWith(';');
+ })
+ .join('\n');
+ const executed = await sendGcodeVisible(channel as GcodeChannel, `direct:${job.name}`, executable);
if (executed.result !== 0) {
job.state = 'start_failed';
job.error = `Controller rejected the move: ${executed.text || executed.result}`;
@@ -327,7 +338,7 @@ export function registerGcodeTools(registry: ToolRegistry, getConfirmBaseUrl: ()
warning: 'wait_until_moved was false: the move was accepted but not awaited - '
+ 'poll get_position (or query_firmware_position) before relying on position.',
}
- : await waitForStableHeartbeat(issuedAt, parseZTarget(gcodeText));
+ : await waitForStableHeartbeat(issuedAt, parseZTarget(executable));
if (isBatch) {
job.nextStep += 1;
if (job.nextStep < job.steps.length) {
@@ -485,8 +496,40 @@ export function registerGcodeTools(registry: ToolRegistry, getConfirmBaseUrl: ()
: `G90\nG1 Z${t.toFixed(3)} F${feedRate}`);
const steps = targets.map(stepGcode);
const isBatch = targets.length > 1;
- const reviewText = steps.join('\n; --- next approved step ---\n');
const delta = targetZ - currentZ;
+ // The gcode preview must let the operator validate the numbers
+ // without trusting chat: state the reason, the frame, where the
+ // machine is now and the delta of every step (operator request
+ // 2026-09-02). Sensors: no contact is expected during a Z move.
+ const wrapText = (text: string, width: number): string[] => {
+ const words = String(text).split(/\s+/);
+ const rows: string[] = [];
+ let row = '';
+ for (const word of words) {
+ if (row && (row.length + word.length + 1) > width) {
+ rows.push(row);
+ row = word;
+ } else {
+ row = row ? `${row} ${word}` : word;
+ }
+ }
+ if (row) {
+ rows.push(row);
+ }
+ return rows;
+ };
+ const header = [
+ ...wrapText(`reason: ${args.reason}`, 90).map((row) => `; ${row}`),
+ `; frame: ${coordinateSystem} coords; current ${coordinateSystem} Z ${currentZ.toFixed(3)}`,
+ ...targets.map((t, i) => {
+ const from = i === 0 ? currentZ : targets[i - 1];
+ const d = t - from;
+ return `; step ${i + 1}: Z ${from.toFixed(3)} -> ${t.toFixed(3)} (${d >= 0 ? '+' : ''}${d.toFixed(3)} mm) F${feedRate}`;
+ }),
+ '; sensors: NO contact expected during these moves - a probe/toolsetter trigger',
+ '; while moving latches the CRASH alarm (probe feed must be armed).',
+ ].join('\n');
+ const reviewText = `${header}\n${steps.join('\n; --- next approved step ---\n')}`;
const name = isBatch
? `z-series ${coordinateSystem} [${targets.map((t) => t.toFixed(1)).join(', ')}] - ${String(args.reason).slice(0, 40)}`
: `z-move ${coordinateSystem} Z${targetZ.toFixed(1)} (${delta >= 0 ? '+' : ''}${delta.toFixed(1)}mm) - ${String(args.reason).slice(0, 40)}`;
diff --git a/src/server/services/mcp/tools/probing.ts b/src/server/services/mcp/tools/probing.ts
index b971e899c9..f9ab9f48d9 100644
--- a/src/server/services/mcp/tools/probing.ts
+++ b/src/server/services/mcp/tools/probing.ts
@@ -8,7 +8,9 @@ import DataStorage from '../../../DataStorage';
import { connectionManager } from '../../machine/ConnectionManager';
import { captureFrame } from '../camera';
import { jobManager } from '../jobs';
+import { describeProbeCirclePlanAsGcode, planProbeCircle, runProbeCircleProcedure } from '../probeCircle';
import { describeProbePlanAsGcode, planProbePoint, runProbePointProcedure } from '../probeTool';
+import { describeProbeVectorPlanAsGcode, planProbeVector, runProbeVectorProcedure } from '../probeVector';
import { probeFeedService } from '../probeFeed';
import { TRAVEL_FEED, assertMachineReadyForProcedure, moveMachineSettled } from '../probing';
import { McpToolError, ToolRegistry } from '../registry';
@@ -56,7 +58,8 @@ export function registerProbingTools(registry: ToolRegistry, getConfirmBaseUrl:
}
probeFeedService.assertNoOvertravel();
const plan = planProbePoint(args as Parameters[0]);
- const envelope = describeProbePlanAsGcode(plan);
+ const envelope = `; reason: ${String(args.reason).trim()}
+${describeProbePlanAsGcode(plan)}`;
const validation = validateGcode(envelope);
const job = jobManager.submit(
envelope,
@@ -78,6 +81,144 @@ export function registerProbingTools(registry: ToolRegistry, getConfirmBaseUrl:
},
});
+ registry.register({
+ name: 'probe_vector',
+ description: 'Stage a touch-probe march along an ARBITRARY direction for human confirmation: '
+ + 'from the CURRENT position along the given (dx, dy, dz) heading - any XY direction, '
+ + 'optionally angled downward, never upward. Same staged mechanics as probe_point '
+ + '(coarse steps to contact, retreat to release, fine steps, lift-and-retest confirm '
+ + 'cycles); the march is parametrized along the unit vector so every commanded position '
+ + 'lies on the approved segment. Returns the median contact as machine XYZ plus the '
+ + 'distance along the vector. probe_point is the axis-aligned special case; use this '
+ + 'for angled edges, chamfers, polygon faces, and inside-a-hole checks. Position first '
+ + '(top-gantry traverse, operator-confirmed descent), then stage.',
+ inputSchema: {
+ type: 'object',
+ properties: {
+ dx: { type: 'number', description: 'Direction X component (machine frame). Magnitude ignored.' },
+ dy: { type: 'number', description: 'Direction Y component.' },
+ dz: { type: 'number', description: 'Direction Z component; must be <= 0 (downward or level).' },
+ max_travel_mm: {
+ type: 'number',
+ description: 'REQUIRED hard travel limit (1-150) along the vector: the march aborts '
+ + 'there without contact. Clamped so the whole segment stays in the machine envelope.',
+ },
+ coarse_step_mm: { type: 'number', description: 'Coarse step, default 1 (0.2-2).' },
+ fine_step_mm: { type: 'number', description: 'Fine step, default 0.1 (0.02-0.5).' },
+ backoff_mm: { type: 'number', description: 'Confirm-cycle lift, default 0.5.' },
+ sensor_delay_ms: { type: 'number', description: 'Contact-check window per step, default 200.' },
+ confirm_passes: { type: 'number', description: 'Lift-and-retest cycles, default 3 (1-10).' },
+ reason: { type: 'string', description: 'Shown to the operator: what is being measured and why.' },
+ },
+ required: ['max_travel_mm', 'reason'],
+ additionalProperties: false,
+ },
+ handler: async (args: { [key: string]: unknown }) => {
+ if (!String(args.reason || '').trim()) {
+ throw new McpToolError('reason is required; it is shown to the operator.');
+ }
+ probeFeedService.assertNoOvertravel();
+ const plan = planProbeVector(args as Parameters[0]);
+ const envelope = `; reason: ${String(args.reason).trim()}
+${describeProbeVectorPlanAsGcode(plan)}`;
+ const validation = validateGcode(envelope);
+ const job = jobManager.submit(
+ envelope,
+ `probe-vector (${plan.unit.x},${plan.unit.y},${plan.unit.z}) ${plan.maxTravelMm}mm `
+ + `- ${String(args.reason).slice(0, 40)}`,
+ 'cnc',
+ validation,
+ 'procedure'
+ );
+ job.runner = async () => runProbeVectorProcedure(plan);
+ return {
+ job: jobManager.describe(job),
+ plan,
+ confirm_url: `${getConfirmBaseUrl()}/confirm/${job.id}`,
+ next_step: 'Ask the operator to open confirm_url, review the envelope (anchored to the '
+ + 'current position), and approve. Their one-time code passed to start_gcode_job '
+ + 'runs the march and returns the contact coordinate.',
+ };
+ },
+ });
+
+ registry.register({
+ name: 'probe_circle',
+ description: 'Stage an N-point circle measurement of a roughly-round vertical feature (post, '
+ + 'boss, pin) for human confirmation: radial sensor-gated marches from evenly spaced '
+ + 'azimuths, then a least-squares circle fit. Requires the operator\'s MIN and MAX '
+ + 'diameter estimates - they bound every march (start beyond max/2, abort at min/2 '
+ + 'without contact) - and a MEASURED top height (top_z_machine). Yields the fitted '
+ + 'centre and the COMBINED diameter (feature + probe tip, inseparable without one '
+ + 'known), with residuals exposing an out-of-round tip or feature. Repositioning between '
+ + 'points obeys motion law 2: lift to the safe traverse height, hop, descend; a probe '
+ + 'touch during a hop or descent latches the CRASH alarm.',
+ inputSchema: {
+ type: 'object',
+ properties: {
+ center_x: { type: 'number', description: 'Estimated feature centre X, machine coords.' },
+ center_y: { type: 'number', description: 'Estimated feature centre Y, machine coords.' },
+ diameter_min_mm: {
+ type: 'number',
+ description: 'Operator\'s LOWER bound on the feature diameter: marches abort at this '
+ + 'radius without contact instead of pressing on.',
+ },
+ diameter_max_mm: {
+ type: 'number',
+ description: 'Operator\'s UPPER bound on the feature diameter: approaches start '
+ + 'approach_clearance_mm beyond this radius.',
+ },
+ top_z_machine: {
+ type: 'number',
+ description: 'Toolhead machine Z at which the probe tip touches the feature TOP - '
+ + 'measured (probe_point -Z) or operator-stated, never guessed.',
+ },
+ probe_depth_mm: { type: 'number', description: 'Side contacts this far below the top, default 3 (0.5-20).' },
+ points: { type: 'number', description: 'Contact points around the circle, default 8 (4-16).' },
+ approach_clearance_mm: {
+ type: 'number',
+ description: 'Start-radius margin beyond max/2, default 5 (1-20). Must exceed the '
+ + 'probe tip radius plus a safety margin.',
+ },
+ coarse_step_mm: { type: 'number', description: 'Coarse radial step, default 0.5 (0.2-2).' },
+ fine_step_mm: { type: 'number', description: 'Fine step, default 0.1 (0.02-0.5).' },
+ backoff_mm: { type: 'number', description: 'Confirm-cycle lift, default 0.5.' },
+ sensor_delay_ms: { type: 'number', description: 'Contact-check window per step, default 200.' },
+ confirm_passes: { type: 'number', description: 'Lift-and-retest cycles per point, default 2 (1-5).' },
+ reason: { type: 'string', description: 'Shown to the operator: what is being measured and why.' },
+ },
+ required: ['center_x', 'center_y', 'diameter_min_mm', 'diameter_max_mm', 'top_z_machine', 'reason'],
+ additionalProperties: false,
+ },
+ handler: async (args: { [key: string]: unknown }) => {
+ if (!String(args.reason || '').trim()) {
+ throw new McpToolError('reason is required; it is shown to the operator.');
+ }
+ probeFeedService.assertNoOvertravel();
+ const plan = planProbeCircle(args as Parameters[0]);
+ const envelope = `; reason: ${String(args.reason).trim()}
+${describeProbeCirclePlanAsGcode(plan)}`;
+ const validation = validateGcode(envelope);
+ const job = jobManager.submit(
+ envelope,
+ `probe-circle ${plan.points.length}pts d${plan.diameterMinMm}-${plan.diameterMaxMm} `
+ + `- ${String(args.reason).slice(0, 40)}`,
+ 'cnc',
+ validation,
+ 'procedure'
+ );
+ job.runner = async () => runProbeCircleProcedure(plan);
+ return {
+ job: jobManager.describe(job),
+ plan,
+ confirm_url: `${getConfirmBaseUrl()}/confirm/${job.id}`,
+ next_step: 'Ask the operator to open confirm_url, review the full envelope (all marches, '
+ + 'hops and depths), and approve. Their one-time code passed to start_gcode_job runs '
+ + 'the whole star and returns the circle fit.',
+ };
+ },
+ });
+
registry.register({
name: 'survey_bed',
description: 'Stage a whole-bed camera survey for human confirmation: a serpentine XY grid at '
From 9dec3c3e198faf281082abd835cb3b38de665f06 Mon Sep 17 00:00:00 2001
From: tyeth
Date: Wed, 2 Sep 2026 14:54:10 +0100
Subject: [PATCH 049/135] Fix: stale-heartbeat refusal, honest M114,
probe_circle inside mode
The machine disconnected and the server never noticed (live, 2026-09-02):
get_position served a 5.7-minute-old heartbeat as truth with no warning,
and M114 returned success with no position text. Staging against that
phantom position - with the probe tip inside a hole in the chuck - is how
crashes happen.
- getPositionSnapshot flags reports older than 10s (period ~1s) with a
loud STALE warning; assertFreshHeartbeat refuses procedure starts,
move_z staging, job starts and direct moves on stale state.
- query_firmware_position errors on an empty M114 reply (the
dead-connection signature), and parses a real one as what it is:
WORK coordinates in the selected workspace (operator-clarified) -
primarily a liveness/frame check - with derived_machine computed via
the heartbeat originOffset.
- probe_circle gains INSIDE mode (operator-requested, hole checking):
the operator positions the tip inside the hole at depth; marches step
OUTWARD from that staged origin to the wall, retreat to the origin
between azimuths - no hops, no Z motion until the final vertical
raise out along the entry path. Result = hole diameter MINUS tip.
Bounds still gate every march (abort at max/2 + eccentricity margin).
First hardware run: chuck fixed hole, 8 points, fit rms 0.021mm, max
residual 0.041mm, centre found 0.27mm from the staged origin.
- Recording rule (operator): MACHINE coordinates only - work origins
are volatile (a machine reboot mints a new one).
Co-Authored-By: Claude Fable 5
---
.claude/skills/cnc-probing/SKILL.md | 12 +
src/server/services/mcp/probeCircle.ts | 336 ++++++++++++++---------
src/server/services/mcp/probing.ts | 3 +-
src/server/services/mcp/tools/camera.ts | 41 ++-
src/server/services/mcp/tools/gcode.ts | 9 +-
src/server/services/mcp/tools/machine.ts | 33 ++-
src/server/services/mcp/tools/probing.ts | 39 ++-
7 files changed, 326 insertions(+), 147 deletions(-)
diff --git a/.claude/skills/cnc-probing/SKILL.md b/.claude/skills/cnc-probing/SKILL.md
index 95ed025544..396004e649 100644
--- a/.claude/skills/cnc-probing/SKILL.md
+++ b/.claude/skills/cnc-probing/SKILL.md
@@ -62,6 +62,18 @@ height drove the probe into the rotary stock and destroyed it.
backend, configstore, or machine directly while the app runs — the
guards live in the tools.
+## Recording rules
+
+- **Machine coordinates only** (operator, 2026-09-02): datums, landmarks and
+ measurements are recorded in the machine frame. Work origins are volatile —
+ a machine reboot mints a new one — so saved work coordinates are meaningless
+ later. Re-verify `originOffset` after every (re)connect.
+- **Heartbeat freshness is a precondition**: a stale report (>10 s; period is
+ ~1 s) means the connection dropped without the server noticing (seen live —
+ 5.7 min of stale state served as truth). Motion and staging refuse on
+ staleness; an M114 that returns no position text is a dead connection, not
+ a success.
+
## Probe calibration (do once per probe fitting)
`run_tool_setter` with `accept_probe_contact: true` and a conservative
diff --git a/src/server/services/mcp/probeCircle.ts b/src/server/services/mcp/probeCircle.ts
index 836db24e40..46be99ce5d 100644
--- a/src/server/services/mcp/probeCircle.ts
+++ b/src/server/services/mcp/probeCircle.ts
@@ -19,22 +19,32 @@ import { McpToolError } from './registry';
import { getMachineSizeByIdentifier, getPositionSnapshot, safeTraverseZ } from './tools/machine';
import { connectionManager } from '../machine/ConnectionManager';
-// Circle probing: N sensor-gated radial marches around a roughly-round
-// vertical feature (a post, a boss, a pin), then a least-squares circle fit.
-// Physics note the result carries: every contact adds the probe tip's
-// effective radius, so the fit yields the COMBINED diameter
-// (feature + tip) - they cannot be separated without one being known.
-// The operator's min/max diameter estimates bound every march: approaches
-// start beyond max/2 and abort at min/2 without contact instead of
-// pressing on into a wrong guess.
+// Circle probing: N sensor-gated radial marches around (or inside) a
+// roughly-round vertical feature, then a least-squares circle fit.
+//
+// OUTSIDE mode (a post, a boss, a pin): marches start beyond the feature and
+// step inward. Every contact adds the probe tip's effective radius, so the
+// fit yields the COMBINED diameter (feature + tip). Repositioning between
+// points obeys motion law 2 in full (operator, 2026-09-02: "x/y motion over
+// 1mm is never below gantry height"): every hop lifts to the safe traverse
+// height, traverses, then descends.
+//
+// INSIDE mode (a hole, operator-requested 2026-09-02): the operator
+// positions the tip INSIDE the hole at the measuring depth first; every
+// march starts from that staged position and steps OUTWARD to the wall,
+// retreating back to the start between azimuths - no hops, no Z motion
+// until the final raise to the traverse height (a vertical exit along the
+// path the probe entered by). Contacts SUBTRACT the tip radius: the fit
+// yields hole diameter MINUS tip diameter.
+//
+// The operator's min/max diameter estimates bound every march - outside:
+// start beyond max/2, abort at min/2; inside: abort at max/2 (+ the
+// eccentricity margin) - so a wrong guess aborts instead of pressing on.
//
// Safety inherits the probe_point mechanics (hardware-proven), plus: the
// probe channel is EXPECTED contact only while marching/confirming - during
// repositioning hops and descents it is not, so a graze there latches the
-// CRASH alarm instead of being absorbed. Repositioning between points obeys
-// motion law 2 in full (operator, 2026-09-02: "x/y motion over 1mm is never
-// below gantry height"): every hop lifts to the safe traverse height,
-// traverses, then descends - no local hop heights above the feature.
+// CRASH alarm instead of being absorbed.
export interface ProbeCirclePoint {
azimuthDeg: number;
@@ -42,14 +52,15 @@ export interface ProbeCirclePoint {
}
export interface ProbeCirclePlan {
- center: { x: number; y: number }; // operator estimate, machine coords
+ inside: boolean;
+ center: { x: number; y: number }; // machine coords: estimate (outside) / march origin (inside)
diameterMinMm: number;
diameterMaxMm: number;
- topZMachine: number; // toolhead machine Z with the tip at the feature top
+ topZMachine: number | null; // toolhead machine Z with the tip at the feature top (outside mode)
probeZ: number; // side-contact toolhead Z
- hopZ: number; // repositioning toolhead Z: the safe traverse height (law 2)
- startRadiusMm: number;
- floorRadiusMm: number;
+ hopZ: number; // repositioning / final-raise toolhead Z: the safe traverse height (law 2)
+ startRadiusMm: number; // outside: approach start radius; inside: 0
+ limitRadiusMm: number; // outside: abort floor (min/2); inside: abort ceiling (max/2 + margin)
points: ProbeCirclePoint[];
coarseStepMm: number;
fineStepMm: number;
@@ -60,6 +71,7 @@ export interface ProbeCirclePlan {
}
export function planProbeCircle(args: {
+ inside?: boolean;
center_x?: number;
center_y?: number;
diameter_min_mm?: number;
@@ -74,98 +86,144 @@ export function planProbeCircle(args: {
sensor_delay_ms?: number;
confirm_passes?: number;
}): ProbeCirclePlan {
- const centerX = Number(args.center_x);
- const centerY = Number(args.center_y);
- if (!Number.isFinite(centerX) || !Number.isFinite(centerY)) {
- throw new McpToolError('center_x and center_y (machine coords, operator estimate) are required.');
- }
+ const inside = args.inside === true;
const dMin = Number(args.diameter_min_mm);
const dMax = Number(args.diameter_max_mm);
if (!Number.isFinite(dMin) || !Number.isFinite(dMax) || dMin <= 0 || dMax < dMin || dMax > 100) {
throw new McpToolError('diameter_min_mm and diameter_max_mm are required: the operator\'s bounds '
- + 'on the feature diameter (0 < min <= max <= 100). They bound every march - no contact by '
- + 'the min-diameter radius aborts instead of pressing on.');
- }
- const topZ = Number(args.top_z_machine);
- if (!Number.isFinite(topZ) || topZ <= 0 || topZ > 400) {
- throw new McpToolError('top_z_machine is required: the toolhead machine Z at which the probe tip '
- + 'touches the feature TOP - a measured or operator-stated number, never a guess.');
+ + 'on the feature diameter (0 < min <= max <= 100). They bound every march - a wrong guess '
+ + 'aborts instead of pressing on.');
}
- const probeDepth = Math.min(Math.max(Number(args.probe_depth_mm) || 3, 0.5), 20);
const pointCount = Math.min(Math.max(Math.round(Number(args.points) || 8), 4), 16);
const approach = Math.min(Math.max(Number(args.approach_clearance_mm) || 5, 1), 20);
- const startRadius = dMax / 2 + approach;
- const floorRadius = dMin / 2;
- if (startRadius - floorRadius < 1) {
- throw new McpToolError('Less than 1 mm between the approach start radius and the min-diameter '
- + 'floor - widen approach_clearance_mm or the diameter bounds.');
- }
-
const position = getPositionSnapshot();
const { x, y, z } = position.machine;
if (x === null || y === null || z === null) {
throw new McpToolError('Current machine position unknown; cannot anchor the envelope.');
}
-
+ const staged = { x, y, z };
const size = getMachineSizeByIdentifier(connectionManager.getConnectionStatus().machineIdentifier);
+
+ let center: { x: number; y: number };
+ let probeZ: number;
+ let topZ: number | null;
+ let startRadius: number;
+ let limitRadius: number;
+ if (inside) {
+ // The operator has already positioned the tip inside the hole at the
+ // measuring depth: the staged position is the march origin AND the
+ // probing depth. No estimated centre is needed - the fit finds it.
+ center = { x, y };
+ probeZ = z;
+ topZ = args.top_z_machine !== undefined ? Number(args.top_z_machine) : null;
+ startRadius = 0;
+ // The eccentricity margin: the staged position may sit off the true
+ // hole centre; marches may legitimately travel farther on one side.
+ limitRadius = Number((dMax / 2 + approach).toFixed(3));
+ } else {
+ const centerX = Number(args.center_x);
+ const centerY = Number(args.center_y);
+ if (!Number.isFinite(centerX) || !Number.isFinite(centerY)) {
+ throw new McpToolError('center_x and center_y (machine coords, operator estimate) are '
+ + 'required for an outside measurement.');
+ }
+ center = { x: centerX, y: centerY };
+ topZ = Number(args.top_z_machine);
+ if (!Number.isFinite(topZ) || topZ <= 0 || topZ > 400) {
+ throw new McpToolError('top_z_machine is required: the toolhead machine Z at which the probe '
+ + 'tip touches the feature TOP - a measured or operator-stated number, never a guess.');
+ }
+ const probeDepth = Math.min(Math.max(Number(args.probe_depth_mm) || 3, 0.5), 20);
+ probeZ = Number((topZ - probeDepth).toFixed(3));
+ startRadius = Number((dMax / 2 + approach).toFixed(3));
+ limitRadius = Number((dMin / 2).toFixed(3));
+ if (startRadius - limitRadius < 1) {
+ throw new McpToolError('Less than 1 mm between the approach start radius and the '
+ + 'min-diameter floor - widen approach_clearance_mm or the diameter bounds.');
+ }
+ }
+
const points: ProbeCirclePoint[] = [];
for (let i = 0; i < pointCount; i++) {
const azimuth = (360 / pointCount) * i;
const rad = (azimuth * Math.PI) / 180;
- const sx = Number((centerX + startRadius * Math.cos(rad)).toFixed(3));
- const sy = Number((centerY + startRadius * Math.sin(rad)).toFixed(3));
- if (size && (sx < -25 || sx > size.x + 40 || sy < -25 || sy > size.y + 40)) {
- throw new McpToolError(`Approach start for azimuth ${azimuth.toFixed(0)} deg `
- + `(${sx}, ${sy}) falls outside the machine envelope.`);
+ const reach = inside ? limitRadius : startRadius;
+ const sx = Number((center.x + (inside ? 0 : startRadius) * Math.cos(rad)).toFixed(3));
+ const sy = Number((center.y + (inside ? 0 : startRadius) * Math.sin(rad)).toFixed(3));
+ const fx = center.x + reach * Math.cos(rad);
+ const fy = center.y + reach * Math.sin(rad);
+ if (size && (fx < -25 || fx > size.x + 40 || fy < -25 || fy > size.y + 40
+ || sx < -25 || sx > size.x + 40 || sy < -25 || sy > size.y + 40)) {
+ throw new McpToolError(`March for azimuth ${azimuth.toFixed(0)} deg falls outside the `
+ + 'machine envelope.');
}
points.push({ azimuthDeg: azimuth, startXY: { x: sx, y: sy } });
}
return {
- center: { x: centerX, y: centerY },
+ inside,
+ center,
diameterMinMm: dMin,
diameterMaxMm: dMax,
topZMachine: topZ,
- probeZ: Number((topZ - probeDepth).toFixed(3)),
+ probeZ,
hopZ: safeTraverseZ(),
- startRadiusMm: Number(startRadius.toFixed(3)),
- floorRadiusMm: Number(floorRadius.toFixed(3)),
+ startRadiusMm: startRadius,
+ limitRadiusMm: limitRadius,
points,
coarseStepMm: Math.min(Math.max(Number(args.coarse_step_mm) || 0.5, 0.2), 2),
fineStepMm: Math.min(Math.max(Number(args.fine_step_mm) || 0.1, 0.02), 0.5),
backoffMm: Math.min(Math.max(Number(args.backoff_mm) || 0.5, Number(args.fine_step_mm) || 0.1), 3),
sensorDelayMs: Math.min(Math.max(Number(args.sensor_delay_ms) || 200, 100), 10000),
confirmPasses: Math.min(Math.max(Math.round(Number(args.confirm_passes) || 2), 1), 5),
- staged: { x, y, z },
+ staged,
};
}
/** Confirm-page gcode: every commanded move of the whole star, enumerated. */
export function describeProbeCirclePlanAsGcode(plan: ProbeCirclePlan): string {
- const lines = [
- `; TOUCH PROBE CIRCLE MEASUREMENT: ${plan.points.length} radial marches around `
- + `estimated centre (${plan.center.x}, ${plan.center.y})`,
- `; operator diameter bounds ${plan.diameterMinMm}..${plan.diameterMaxMm} mm; approaches start at `
- + `radius ${plan.startRadiusMm} and ABORT at radius ${plan.floorRadiusMm} without contact`,
- `; feature top measured at toolhead Z ${plan.topZMachine}; side contacts at Z ${plan.probeZ}; `
- + `hops between points at the safe traverse height Z ${plan.hopZ} (motion law 2)`,
+ const lines = plan.inside
+ ? [
+ `; TOUCH PROBE INSIDE-CIRCLE (HOLE) MEASUREMENT: ${plan.points.length} radial marches `
+ + `OUTWARD from the staged position (${plan.center.x}, ${plan.center.y}, Z${plan.probeZ})`,
+ '; the operator positioned the tip INSIDE the hole at the measuring depth; each march',
+ '; steps outward to the wall and retreats back to the start - no hops, no Z motion',
+ `; until the final vertical raise to the traverse height Z${plan.hopZ}.`,
+ `; operator hole-diameter bounds ${plan.diameterMinMm}..${plan.diameterMaxMm} mm; a march `
+ + `ABORTS at radius ${plan.limitRadiusMm} (max/2 + eccentricity margin) without contact`,
+ '; fit yields hole diameter MINUS the probe tip effective diameter.',
+ ]
+ : [
+ `; TOUCH PROBE CIRCLE MEASUREMENT: ${plan.points.length} radial marches around `
+ + `estimated centre (${plan.center.x}, ${plan.center.y})`,
+ `; operator diameter bounds ${plan.diameterMinMm}..${plan.diameterMaxMm} mm; approaches start at `
+ + `radius ${plan.startRadiusMm} and ABORT at radius ${plan.limitRadiusMm} without contact`,
+ `; feature top measured at toolhead Z ${plan.topZMachine}; side contacts at Z ${plan.probeZ}; `
+ + `hops between points at the safe traverse height Z ${plan.hopZ} (motion law 2)`,
+ ];
+ lines.push(
'; EVERY LINE IS SENT INDIVIDUALLY and settle-verified; the probe feed is checked after each',
'; march step. A probe touch during a hop or descent latches the CRASH alarm.',
'; overtravel feed trips -> job stop + connection close + latched alarm',
'G90',
'G53;',
- ];
+ );
for (const point of plan.points) {
const rad = (point.azimuthDeg * Math.PI) / 180;
lines.push(`; --- point at azimuth ${point.azimuthDeg.toFixed(1)} deg ---`);
- lines.push(`G1 Z${plan.hopZ.toFixed(3)} F${TRAVEL_FEED}; lift to safe traverse height`);
- lines.push(`G1 X${point.startXY.x.toFixed(3)} Y${point.startXY.y.toFixed(3)} F${TRAVEL_FEED}; approach start`);
- lines.push(`G1 Z${plan.probeZ.toFixed(3)} F${TRAVEL_FEED}; descend to probing depth`);
- let r = plan.startRadiusMm;
+ if (!plan.inside) {
+ lines.push(`G1 Z${plan.hopZ.toFixed(3)} F${TRAVEL_FEED}; lift to safe traverse height`);
+ lines.push(`G1 X${point.startXY.x.toFixed(3)} Y${point.startXY.y.toFixed(3)} F${TRAVEL_FEED}; approach start`);
+ lines.push(`G1 Z${plan.probeZ.toFixed(3)} F${TRAVEL_FEED}; descend to probing depth`);
+ }
+ let r = plan.inside ? 0 : plan.startRadiusMm;
let step = 0;
- while (r - plan.floorRadiusMm > 1e-9) {
- r = Math.max(r - plan.coarseStepMm, plan.floorRadiusMm);
+ const done = () => (plan.inside ? plan.limitRadiusMm - r <= 1e-9 : r - plan.limitRadiusMm <= 1e-9);
+ while (!done()) {
+ r = plan.inside
+ ? Math.min(r + plan.coarseStepMm, plan.limitRadiusMm)
+ : Math.max(r - plan.coarseStepMm, plan.limitRadiusMm);
step += 1;
const px = plan.center.x + r * Math.cos(rad);
const py = plan.center.y + r * Math.sin(rad);
@@ -174,9 +232,10 @@ export function describeProbeCirclePlanAsGcode(plan: ProbeCirclePlan): string {
}
lines.push(`; ...on contact: retreat ${plan.coarseStepMm} mm radially until released, fine approach `
+ `in ${plan.fineStepMm} mm steps, ${plan.confirmPasses} confirm cycle(s) (lift ${plan.backoffMm} mm)`);
- lines.push(`G1 X${point.startXY.x.toFixed(3)} Y${point.startXY.y.toFixed(3)} F${TRAVEL_FEED}; retreat to approach start`);
+ lines.push(`G1 X${point.startXY.x.toFixed(3)} Y${point.startXY.y.toFixed(3)} F${TRAVEL_FEED}; retreat to `
+ + `${plan.inside ? 'the march origin' : 'approach start'}`);
}
- lines.push(`G1 Z${plan.hopZ.toFixed(3)} F${TRAVEL_FEED}; final lift to hop height`);
+ lines.push(`G1 Z${plan.hopZ.toFixed(3)} F${TRAVEL_FEED}; final ${plan.inside ? 'vertical raise out of the hole' : 'lift'} to the traverse height`);
lines.push('G54;');
return lines.join('\n');
}
@@ -199,7 +258,6 @@ function fitCircle(contacts: { x: number; y: number }[]): {
sx += p.x; sy += p.y;
sxz += p.x * zz; syz += p.y * zz; sz += zz;
}
- // Solve [2sxx 2sxy sx; 2sxy 2syy sy; 2sx 2sy n] * [a b c]' = [sxz syz sz]'
const m = [
[2 * sxx, 2 * sxy, sx, sxz],
[2 * sxy, 2 * syy, sy, syz],
@@ -255,9 +313,9 @@ export async function runProbeCircleProcedure(plan: ProbeCirclePlan): Promise (plan.inside ? s : plan.startRadiusMm - s);
try {
for (const point of plan.points) {
const rad = (point.azimuthDeg * Math.PI) / 180;
const label = `az${point.azimuthDeg.toFixed(0)}`;
- // Reposition: contact here is NOT expected - a graze latches CRASH.
- probeFeedService.clearExpectedContact();
- await moveMachineSettled(`circle:hop:${label}`, { z: plan.hopZ }, TRAVEL_FEED);
- await moveMachineSettled(`circle:hop:${label}`, { ...point.startXY }, TRAVEL_FEED);
- await moveMachineSettled(`circle:descend:${label}`, { z: plan.probeZ }, TRAVEL_FEED);
- announce(`start-${label}`, `approach start (${point.startXY.x}, ${point.startXY.y}) Z${plan.probeZ}`);
+ if (!plan.inside) {
+ // Reposition: contact here is NOT expected - a graze latches CRASH.
+ probeFeedService.clearExpectedContact();
+ await moveMachineSettled(`circle:hop:${label}`, { z: plan.hopZ }, TRAVEL_FEED);
+ await moveMachineSettled(`circle:hop:${label}`, { ...point.startXY }, TRAVEL_FEED);
+ await moveMachineSettled(`circle:descend:${label}`, { z: plan.probeZ }, TRAVEL_FEED);
+ announce(`start-${label}`, `approach start (${point.startXY.x}, ${point.startXY.y}) Z${plan.probeZ}`);
+ } else {
+ announce(`start-${label}`, `outward march from (${point.startXY.x}, ${point.startXY.y}) Z${plan.probeZ}`);
+ }
- // March radially inward; contact on the probe channel is expected.
+ // March radially; contact on the probe channel is expected.
probeFeedService.setExpectedContact(['probe']);
- let r = plan.startRadiusMm;
- let coarseContactR: number | null = null;
- while (r - plan.floorRadiusMm > 1e-9) {
+ let s = 0;
+ let coarseContactS: number | null = null;
+ while (travelBudget - s > 1e-9) {
const stepStart = Date.now();
- r = Math.max(r - plan.coarseStepMm, plan.floorRadiusMm);
- await moveMachineSettled(`circle:coarse:${label}`, radialXY(rad, r), COARSE_FEED);
+ s = Math.min(s + plan.coarseStepMm, travelBudget);
+ await moveMachineSettled(`circle:coarse:${label}`, radialXY(rad, radiusAt(s)), COARSE_FEED);
const sensed = await senseAfter('probe', stepStart, plan.sensorDelayMs);
if (sensed.contact) {
- coarseContactR = r;
- announce(`coarse-contact-${label}`, `radius ${r.toFixed(3)}`);
+ coarseContactS = s;
+ announce(`coarse-contact-${label}`, `radius ${radiusAt(s).toFixed(3)}`);
break;
}
}
- if (coarseContactR === null) {
- throw new ProcedureAbort(`No contact by the min-diameter radius ${plan.floorRadiusMm} at `
- + `azimuth ${point.azimuthDeg.toFixed(0)} deg - the centre estimate or diameter bounds `
- + 'are wrong, or the probe is not reporting.');
+ if (coarseContactS === null) {
+ throw new ProcedureAbort(`No contact by radius ${radiusAt(travelBudget).toFixed(3)} at `
+ + `azimuth ${point.azimuthDeg.toFixed(0)} deg - the ${plan.inside ? 'hole is larger '
+ + 'than diameter_max_mm (or the start is not inside it)' : 'centre estimate or '
+ + 'diameter bounds are wrong'}, or the probe is not reporting.`);
}
- // Retreat radially until released.
+ // Retreat until released.
let released = false;
- while (r < plan.startRadiusMm - 1e-9 && r - coarseContactR < MAX_RETREAT_MM + 1e-9) {
+ while (s > 1e-9 && coarseContactS - s < MAX_RETREAT_MM + 1e-9) {
const stepStart = Date.now();
- r = Math.min(r + plan.coarseStepMm, plan.startRadiusMm);
- await moveMachineSettled(`circle:release:${label}`, radialXY(rad, r), COARSE_FEED);
+ s = Math.max(s - plan.coarseStepMm, 0);
+ await moveMachineSettled(`circle:release:${label}`, radialXY(rad, radiusAt(s)), COARSE_FEED);
const sensed = await senseReleaseAfter('probe', stepStart, releaseTimeoutMs);
if (!sensed.contact) {
released = true;
@@ -324,42 +392,42 @@ export async function runProbeCircleProcedure(plan: ProbeCirclePlan): Promise 1e-9) {
+ let fineContactS: number | null = null;
+ while (travelBudget - s > 1e-9) {
const stepStart = Date.now();
- r = Math.max(r - plan.fineStepMm, plan.floorRadiusMm);
- await moveMachineSettled(`circle:fine:${label}`, radialXY(rad, r), FINE_FEED);
+ s = Math.min(s + plan.fineStepMm, travelBudget);
+ await moveMachineSettled(`circle:fine:${label}`, radialXY(rad, radiusAt(s)), FINE_FEED);
const sensed = await senseAfter('probe', stepStart, plan.sensorDelayMs);
if (sensed.contact) {
- fineContactR = r;
+ fineContactS = s;
break;
}
}
- if (fineContactR === null) {
+ if (fineContactS === null) {
throw new ProcedureAbort(`Fine approach lost the contact at azimuth ${point.azimuthDeg.toFixed(0)} deg.`);
}
- // Confirm cycles on the radius; median wins.
- const passRadii: number[] = [];
- const cycleFloor = Math.max(fineContactR - 0.5, plan.floorRadiusMm);
- let reference = fineContactR;
+ // Confirm cycles on the march distance; median wins.
+ const passS: number[] = [];
+ const cycleLimit = Math.min(fineContactS + 0.5, travelBudget);
+ let reference = fineContactS;
for (let pass = 1; pass <= plan.confirmPasses; pass++) {
const liftIssuedAt = Date.now();
- r = Math.min(reference + plan.backoffMm, plan.startRadiusMm);
- await moveMachineSettled(`circle:backoff:${label}`, radialXY(rad, r), FINE_FEED);
+ s = Math.max(reference - plan.backoffMm, 0);
+ await moveMachineSettled(`circle:backoff:${label}`, radialXY(rad, radiusAt(s)), FINE_FEED);
const liftSense = await senseReleaseAfter('probe', liftIssuedAt, releaseTimeoutMs);
if (liftSense.contact) {
throw new ProcedureAbort(`Probe still triggered after backing off ${plan.backoffMm} mm at `
+ `azimuth ${point.azimuthDeg.toFixed(0)} deg - hysteresis exceeds the backoff.`);
}
let passContact: number | null = null;
- while (r - cycleFloor > 1e-9) {
+ while (cycleLimit - s > 1e-9) {
const stepStart = Date.now();
- r = Math.max(r - plan.fineStepMm, cycleFloor);
- await moveMachineSettled(`circle:confirm:${label}`, radialXY(rad, r), FINE_FEED);
+ s = Math.min(s + plan.fineStepMm, cycleLimit);
+ await moveMachineSettled(`circle:confirm:${label}`, radialXY(rad, radiusAt(s)), FINE_FEED);
const sensed = await senseAfter('probe', stepStart, plan.sensorDelayMs);
if (sensed.contact) {
- passContact = r;
+ passContact = s;
break;
}
}
@@ -367,12 +435,14 @@ export async function runProbeCircleProcedure(plan: ProbeCirclePlan): Promise a - b);
- const measuredR = sorted[Math.floor((sorted.length - 1) / 2)];
+ const sorted = [...passS].sort((a, b) => a - b);
+ const measuredS = sorted[Math.floor((sorted.length - 1) / 2)];
+ const passRadii = passS.map((v) => Number(radiusAt(v).toFixed(3)));
const spreadMm = Number((sorted[sorted.length - 1] - sorted[0]).toFixed(3));
+ const measuredR = radiusAt(measuredS);
const contactPoint = radialXY(rad, measuredR);
contacts.push({
azimuthDeg: point.azimuthDeg,
@@ -383,26 +453,32 @@ export async function runProbeCircleProcedure(plan: ProbeCirclePlan): Promise ({ x: c.x, y: c.y })));
- const combinedDiameter = Number((fit.radius * 2).toFixed(3));
- const tipMin = Number(Math.max(combinedDiameter - plan.diameterMaxMm, 0).toFixed(3));
- const tipMax = Number(Math.max(combinedDiameter - plan.diameterMinMm, 0).toFixed(3));
+ const fittedDiameter = Number((fit.radius * 2).toFixed(3));
+ // Outside: fitted = feature + tip. Inside: fitted = hole - tip.
+ const tipMin = plan.inside
+ ? Number(Math.max(plan.diameterMinMm - fittedDiameter, 0).toFixed(3))
+ : Number(Math.max(fittedDiameter - plan.diameterMaxMm, 0).toFixed(3));
+ const tipMax = plan.inside
+ ? Number(Math.max(plan.diameterMaxMm - fittedDiameter, 0).toFixed(3))
+ : Number(Math.max(fittedDiameter - plan.diameterMinMm, 0).toFixed(3));
const worstSpread = Math.max(...contacts.map((c) => c.spreadMm));
return {
+ inside: plan.inside,
contacts,
fit: {
center: fit.center,
- combinedDiameterMm: combinedDiameter,
+ fittedDiameterMm: fittedDiameter,
rmsResidualMm: fit.rmsResidual,
maxResidualMm: fit.maxResidual,
residualsMm: fit.residuals,
@@ -413,12 +489,19 @@ export async function runProbeCircleProcedure(plan: ProbeCirclePlan): Promise 0.2
? 'Max fit residual exceeds 0.2 mm: the feature or the probe tip is significantly '
+ 'out of round, or a contact was bad. Inspect residualsMm by azimuth.'
@@ -428,12 +511,17 @@ export async function runProbeCircleProcedure(plan: ProbeCirclePlan): Promise HEARTBEAT_STALE_MS) {
+ throw new McpToolError(`Refusing ${what}: the last heartbeat is ${(age / 1000).toFixed(0)}s old `
+ + '(period ~1s) - the machine connection has likely dropped without the server noticing. '
+ + 'Reconnect the machine, verify get_position reports a fresh, correct position, then retry.');
+ }
+}
+
/**
* Position from the latest heartbeat, shared by get_position and the
* capture tools. Throws McpToolError when unavailable.
@@ -123,6 +147,13 @@ export function getPositionSnapshot(): PositionSnapshot {
// coordinates land outside the build volume (e.g. Z 656 on a 325 mm
// machine). Flag it rather than let an agent trust it.
const warnings: string[] = [];
+ const reportAgeMs = Date.now() - state.timestamp;
+ if (reportAgeMs > HEARTBEAT_STALE_MS) {
+ warnings.push(`STALE: the last heartbeat is ${(reportAgeMs / 1000).toFixed(0)}s old `
+ + `(period ~1s) - the machine connection has likely dropped without the server `
+ + 'noticing (observed live 2026-09-02). Do NOT trust this position; reconnect and '
+ + 're-verify before any motion.');
+ }
const size = getMachineSizeByIdentifier(status.machineIdentifier);
if (size) {
// Floors/headroom allow real overtravel: the A350 X home switch sits
@@ -147,7 +178,7 @@ export function getPositionSnapshot(): PositionSnapshot {
isFourAxis: !!pos.isFourAxis,
isHomed: (state as { isHomed?: boolean }).isHomed ?? null,
machineStatus: (state as { status?: string }).status || null,
- reportAgeMs: Date.now() - state.timestamp,
+ reportAgeMs,
convention: 'machine = work - originOffset; heartbeat reports work coordinates',
warnings,
};
diff --git a/src/server/services/mcp/tools/probing.ts b/src/server/services/mcp/tools/probing.ts
index f9ab9f48d9..8afefc1c6f 100644
--- a/src/server/services/mcp/tools/probing.ts
+++ b/src/server/services/mcp/tools/probing.ts
@@ -144,20 +144,28 @@ ${describeProbeVectorPlanAsGcode(plan)}`;
registry.register({
name: 'probe_circle',
- description: 'Stage an N-point circle measurement of a roughly-round vertical feature (post, '
- + 'boss, pin) for human confirmation: radial sensor-gated marches from evenly spaced '
- + 'azimuths, then a least-squares circle fit. Requires the operator\'s MIN and MAX '
- + 'diameter estimates - they bound every march (start beyond max/2, abort at min/2 '
- + 'without contact) - and a MEASURED top height (top_z_machine). Yields the fitted '
- + 'centre and the COMBINED diameter (feature + probe tip, inseparable without one '
- + 'known), with residuals exposing an out-of-round tip or feature. Repositioning between '
- + 'points obeys motion law 2: lift to the safe traverse height, hop, descend; a probe '
- + 'touch during a hop or descent latches the CRASH alarm.',
+ description: 'Stage an N-point circle measurement of a roughly-round vertical feature for '
+ + 'human confirmation: radial sensor-gated marches from evenly spaced azimuths, then a '
+ + 'least-squares circle fit. OUTSIDE mode (post/boss/pin, default): marches step inward; '
+ + 'requires the estimated centre and a MEASURED top height; yields the COMBINED diameter '
+ + '(feature + tip). INSIDE mode (inside: true, a HOLE): the operator first positions the '
+ + 'tip INSIDE the hole at the measuring depth; marches step OUTWARD from that staged '
+ + 'position to the wall, no hops, ending with a vertical raise out along the entry path; '
+ + 'yields hole diameter MINUS tip. Both need the operator\'s MIN and MAX diameter '
+ + 'estimates - they bound every march so a wrong guess aborts instead of pressing on. '
+ + 'Residuals expose an out-of-round tip or feature. Outside repositioning obeys motion '
+ + 'law 2 (traverse-height hops); a probe touch during a hop or descent latches CRASH. '
+ + 'All results in MACHINE coordinates.',
inputSchema: {
type: 'object',
properties: {
- center_x: { type: 'number', description: 'Estimated feature centre X, machine coords.' },
- center_y: { type: 'number', description: 'Estimated feature centre Y, machine coords.' },
+ inside: {
+ type: 'boolean',
+ description: 'true = measure a HOLE from inside (tip already positioned in it at '
+ + 'depth); false/omitted = measure a feature from outside.',
+ },
+ center_x: { type: 'number', description: 'OUTSIDE mode: estimated feature centre X, machine coords. Ignored inside (the staged position is the march origin).' },
+ center_y: { type: 'number', description: 'OUTSIDE mode: estimated feature centre Y, machine coords.' },
diameter_min_mm: {
type: 'number',
description: 'Operator\'s LOWER bound on the feature diameter: marches abort at this '
@@ -170,8 +178,9 @@ ${describeProbeVectorPlanAsGcode(plan)}`;
},
top_z_machine: {
type: 'number',
- description: 'Toolhead machine Z at which the probe tip touches the feature TOP - '
- + 'measured (probe_point -Z) or operator-stated, never guessed.',
+ description: 'OUTSIDE mode (required there): toolhead machine Z at which the probe '
+ + 'tip touches the feature TOP - measured (probe_point -Z) or operator-stated, '
+ + 'never guessed. Optional record-keeping inside.',
},
probe_depth_mm: { type: 'number', description: 'Side contacts this far below the top, default 3 (0.5-20).' },
points: { type: 'number', description: 'Contact points around the circle, default 8 (4-16).' },
@@ -187,7 +196,7 @@ ${describeProbeVectorPlanAsGcode(plan)}`;
confirm_passes: { type: 'number', description: 'Lift-and-retest cycles per point, default 2 (1-5).' },
reason: { type: 'string', description: 'Shown to the operator: what is being measured and why.' },
},
- required: ['center_x', 'center_y', 'diameter_min_mm', 'diameter_max_mm', 'top_z_machine', 'reason'],
+ required: ['diameter_min_mm', 'diameter_max_mm', 'reason'],
additionalProperties: false,
},
handler: async (args: { [key: string]: unknown }) => {
@@ -201,7 +210,7 @@ ${describeProbeCirclePlanAsGcode(plan)}`;
const validation = validateGcode(envelope);
const job = jobManager.submit(
envelope,
- `probe-circle ${plan.points.length}pts d${plan.diameterMinMm}-${plan.diameterMaxMm} `
+ `probe-circle${plan.inside ? ' INSIDE' : ''} ${plan.points.length}pts d${plan.diameterMinMm}-${plan.diameterMaxMm} `
+ `- ${String(args.reason).slice(0, 40)}`,
'cnc',
validation,
From 3aba2206f179c96a66385bd98680ef7424541eff Mon Sep 17 00:00:00 2001
From: tyeth
Date: Thu, 3 Sep 2026 12:51:27 +0100
Subject: [PATCH 050/135] Feat: probe_sequence - a whole measurement circuit as
ONE staged approval
Operator-requested (2026-09-02): an ordered hop/descend/probe plan,
simulated at staging so the confirm page enumerates every commanded move
with concrete numbers. Hops rise to the safe traverse height mechanically
(motion law 2 enforced, not trusted), descents are stated absolute
heights, and contact anywhere outside a named march latches CRASH. The
runner re-verifies position (+/-0.5 mm) against the simulation before
every march; marches reuse the proven coarse/release/fine/confirm
mechanics shared with probe_point/vector/circle.
Co-Authored-By: Claude Fable 5
---
src/server/services/mcp/probeCircle.ts | 26 +-
src/server/services/mcp/probeSequence.ts | 462 +++++++++++++++++++++++
src/server/services/mcp/probeTool.ts | 8 +-
src/server/services/mcp/probeVector.ts | 8 +-
src/server/services/mcp/tools/probing.ts | 88 ++++-
5 files changed, 572 insertions(+), 20 deletions(-)
create mode 100644 src/server/services/mcp/probeSequence.ts
diff --git a/src/server/services/mcp/probeCircle.ts b/src/server/services/mcp/probeCircle.ts
index 46be99ce5d..8403e57cd5 100644
--- a/src/server/services/mcp/probeCircle.ts
+++ b/src/server/services/mcp/probeCircle.ts
@@ -15,6 +15,7 @@ import {
senseAfter,
senseReleaseAfter,
} from './probing';
+import { DESCENT_GUARD_MM } from './probeSequence';
import { McpToolError } from './registry';
import { getMachineSizeByIdentifier, getPositionSnapshot, safeTraverseZ } from './tools/machine';
import { connectionManager } from '../machine/ConnectionManager';
@@ -174,8 +175,8 @@ export function planProbeCircle(args: {
points,
coarseStepMm: Math.min(Math.max(Number(args.coarse_step_mm) || 0.5, 0.2), 2),
fineStepMm: Math.min(Math.max(Number(args.fine_step_mm) || 0.1, 0.02), 0.5),
- backoffMm: Math.min(Math.max(Number(args.backoff_mm) || 0.5, Number(args.fine_step_mm) || 0.1), 3),
- sensorDelayMs: Math.min(Math.max(Number(args.sensor_delay_ms) || 200, 100), 10000),
+ backoffMm: Math.min(Math.max(Number(args.backoff_mm) || 1, Number(args.fine_step_mm) || 0.1), 3),
+ sensorDelayMs: Math.min(Math.max(Number(args.sensor_delay_ms) || 300, 100), 10000),
confirmPasses: Math.min(Math.max(Math.round(Number(args.confirm_passes) || 2), 1), 5),
staged,
};
@@ -215,7 +216,9 @@ export function describeProbeCirclePlanAsGcode(plan: ProbeCirclePlan): string {
if (!plan.inside) {
lines.push(`G1 Z${plan.hopZ.toFixed(3)} F${TRAVEL_FEED}; lift to safe traverse height`);
lines.push(`G1 X${point.startXY.x.toFixed(3)} Y${point.startXY.y.toFixed(3)} F${TRAVEL_FEED}; approach start`);
- lines.push(`G1 Z${plan.probeZ.toFixed(3)} F${TRAVEL_FEED}; descend to probing depth`);
+ lines.push(`G1 Z${(plan.probeZ + DESCENT_GUARD_MM).toFixed(3)} F${TRAVEL_FEED}; descend fast to ${DESCENT_GUARD_MM} mm above probing depth`);
+ lines.push(`; ...guarded final approach: ${DESCENT_GUARD_MM} x 1 mm sensor-checked steps to Z${plan.probeZ.toFixed(3)} -`);
+ lines.push(`G1 Z${plan.probeZ.toFixed(3)} F${COARSE_FEED}; ANY contact during descent aborts + latches CRASH`);
}
let r = plan.inside ? 0 : plan.startRadiusMm;
let step = 0;
@@ -323,7 +326,7 @@ export async function runProbeCircleProcedure(plan: ProbeCirclePlan): Promise ({
@@ -346,7 +349,18 @@ export async function runProbeCircleProcedure(plan: ProbeCirclePlan): Promise 1e-9) {
+ const t0 = Date.now();
+ gz = Math.max(gz - 1, plan.probeZ);
+ await moveMachineSettled(`circle:descend-guard:${label}`, { z: gz }, COARSE_FEED);
+ const sensed = await senseAfter('probe', t0, plan.sensorDelayMs);
+ if (sensed.contact) {
+ throw new ProcedureAbort(`UNEXPECTED CONTACT during guarded descent at Z${gz.toFixed(3)} `
+ + '- something is where the plan says nothing should be. Machine held.');
+ }
+ }
announce(`start-${label}`, `approach start (${point.startXY.x}, ${point.startXY.y}) Z${plan.probeZ}`);
} else {
announce(`start-${label}`, `outward march from (${point.startXY.x}, ${point.startXY.y}) Z${plan.probeZ}`);
@@ -409,7 +423,7 @@ export async function runProbeCircleProcedure(plan: ProbeCirclePlan): Promise 60) {
+ throw new McpToolError('steps is required: 1-60 entries of {kind: hop|descend|probe, ...}.');
+ }
+
+ const position = getPositionSnapshot();
+ const { x, y, z } = position.machine;
+ if (x === null || y === null || z === null) {
+ throw new McpToolError('Current machine position unknown; cannot anchor the envelope.');
+ }
+ const staged = { x, y, z };
+ const hopZ = safeTraverseZ();
+ const size = getMachineSizeByIdentifier(connectionManager.getConnectionStatus().machineIdentifier);
+ const inEnvelope = (px: number, py: number) => !size
+ || (px >= -25 && px <= size.x + 40 && py >= -25 && py <= size.y + 40);
+
+ // Simulate the walk so every step is anchored to concrete coordinates.
+ const virtual = { ...staged };
+ const steps: SequenceStep[] = [];
+ const names = new Set();
+ let probeCount = 0;
+ (args.steps as { [key: string]: unknown }[]).forEach((raw, index) => {
+ const kind = String(raw.kind || '');
+ const at = `steps[${index}]`;
+ if (kind === 'hop') {
+ const hx = Number(raw.x);
+ const hy = Number(raw.y);
+ if (!Number.isFinite(hx) || !Number.isFinite(hy) || !inEnvelope(hx, hy)) {
+ throw new McpToolError(`${at}: hop needs finite x/y inside the machine envelope.`);
+ }
+ steps.push({ kind: 'hop', x: hx, y: hy });
+ virtual.x = hx;
+ virtual.y = hy;
+ virtual.z = hopZ; // the runner raises before every hop
+ } else if (kind === 'descend') {
+ const dz = Number(raw.z);
+ if (!Number.isFinite(dz) || dz < 0 || dz > hopZ) {
+ throw new McpToolError(`${at}: descend needs an absolute machine Z in 0..${hopZ}.`);
+ }
+ steps.push({ kind: 'descend', z: dz });
+ virtual.z = dz;
+ } else if (kind === 'probe') {
+ const name = String(raw.name || `probe${probeCount + 1}`);
+ if (names.has(name)) {
+ throw new McpToolError(`${at}: duplicate probe name "${name}".`);
+ }
+ const dx = Number(raw.dx) || 0;
+ const dy = Number(raw.dy) || 0;
+ const dz = Number(raw.dz) || 0;
+ const norm = Math.hypot(dx, dy, dz);
+ if (!Number.isFinite(norm) || norm < 1e-9) {
+ throw new McpToolError(`${at}: probe needs a direction (dx/dy/dz, at least one non-zero).`);
+ }
+ if (dz > 1e-9) {
+ throw new McpToolError(`${at}: upward probing (dz > 0) is refused.`);
+ }
+ const unit = {
+ x: Number((dx / norm).toFixed(6)),
+ y: Number((dy / norm).toFixed(6)),
+ z: Number((dz / norm).toFixed(6)),
+ };
+ const travel = Number(raw.max_travel_mm);
+ if (!Number.isFinite(travel) || travel < 1 || travel > 150) {
+ throw new McpToolError(`${at}: max_travel_mm required (1-150).`);
+ }
+ const limit = {
+ x: virtual.x + unit.x * travel,
+ y: virtual.y + unit.y * travel,
+ z: virtual.z + unit.z * travel,
+ };
+ if (!inEnvelope(limit.x, limit.y) || limit.z < 0) {
+ throw new McpToolError(`${at}: the march limit leaves the machine envelope.`);
+ }
+ steps.push({
+ kind: 'probe',
+ name,
+ unit,
+ maxTravelMm: travel,
+ start: { ...virtual },
+ });
+ names.add(name);
+ probeCount += 1;
+ // The march retreats to its own start; the runner then raises to
+ // the traverse height before whatever comes next.
+ virtual.z = hopZ;
+ } else {
+ throw new McpToolError(`${at}: kind must be hop, descend or probe.`);
+ }
+ });
+ if (probeCount === 0) {
+ throw new McpToolError('The sequence has no probe steps - use move_z / a gcode job for pure motion.');
+ }
+
+ return {
+ steps,
+ coarseStepMm: Math.min(Math.max(Number(args.coarse_step_mm) || 1, 0.2), 2),
+ fineStepMm: Math.min(Math.max(Number(args.fine_step_mm) || 0.1, 0.02), 0.5),
+ backoffMm: Math.min(Math.max(Number(args.backoff_mm) || 1, Number(args.fine_step_mm) || 0.1), 3),
+ sensorDelayMs: Math.min(Math.max(Number(args.sensor_delay_ms) || 300, 100), 10000),
+ confirmPasses: Math.min(Math.max(Math.round(Number(args.confirm_passes) || 3), 1), 10),
+ hopZ,
+ staged,
+ };
+}
+
+function pointAlong(start: { x: number; y: number; z: number }, unit: { x: number; y: number; z: number }, s: number) {
+ return {
+ x: Number((start.x + unit.x * s).toFixed(3)),
+ y: Number((start.y + unit.y * s).toFixed(3)),
+ z: Number((start.z + unit.z * s).toFixed(3)),
+ };
+}
+
+function marchWords(step: SequenceStepProbe, s: number): { x?: number; y?: number; z?: number } {
+ const p = pointAlong(step.start, step.unit, s);
+ const words: { x?: number; y?: number; z?: number } = {};
+ if (Math.abs(step.unit.x) > 1e-9) {
+ words.x = p.x;
+ }
+ if (Math.abs(step.unit.y) > 1e-9) {
+ words.y = p.y;
+ }
+ if (Math.abs(step.unit.z) > 1e-9) {
+ words.z = p.z;
+ }
+ return words;
+}
+
+/** Confirm-page gcode: the whole circuit, every commanded move enumerated. */
+export function describeProbeSequencePlanAsGcode(plan: ProbeSequencePlan): string {
+ const probes = plan.steps.filter((s) => s.kind === 'probe').length;
+ const lines = [
+ `; PROBE SEQUENCE: one approved circuit of ${plan.steps.length} steps (${probes} sensor-gated marches)`,
+ `; anchored at machine (${plan.staged.x.toFixed(2)}, ${plan.staged.y.toFixed(2)}, ${plan.staged.z.toFixed(2)})`
+ + ' - re-verified before any motion, and before EVERY march',
+ `; law 2 enforced mechanically: every hop first raises to the traverse height Z${plan.hopZ};`,
+ '; every march retreats to its start and raises before the next step.',
+ '; sensors: contact is EXPECTED only during marches - a probe/toolsetter trigger during',
+ '; any hop, raise or descent latches the CRASH alarm (feed must be armed).',
+ '; overtravel feed trips -> job stop + connection close + latched alarm',
+ 'G90',
+ 'G53;',
+ ];
+ for (const step of plan.steps) {
+ if (step.kind === 'hop') {
+ lines.push(`G1 Z${plan.hopZ.toFixed(3)} F${TRAVEL_FEED}; raise to traverse height (law 2)`);
+ lines.push(`G1 X${step.x.toFixed(3)} Y${step.y.toFixed(3)} F${TRAVEL_FEED}; hop`);
+ } else if (step.kind === 'descend') {
+ lines.push(`G1 Z${(step.z + DESCENT_GUARD_MM).toFixed(3)} F${TRAVEL_FEED}; descend fast to ${DESCENT_GUARD_MM} mm above target`);
+ lines.push(`; ...guarded final approach (operator, 2026-09-02): 1 mm steps, sensor-checked after each -`);
+ lines.push('; ANY contact during a descent aborts and latches the CRASH alarm.');
+ for (let gz = step.z + DESCENT_GUARD_MM - 1; gz > step.z - 1e-9; gz -= 1) {
+ const zz = Math.max(gz, step.z);
+ lines.push(`G1 Z${zz.toFixed(3)} F${COARSE_FEED}; guarded descent step`);
+ }
+ } else {
+ const dir = `(${step.unit.x}, ${step.unit.y}, ${step.unit.z})`;
+ lines.push(`; --- march "${step.name}" along ${dir}, max ${step.maxTravelMm} mm ---`);
+ let s = 0;
+ let n = 0;
+ while (step.maxTravelMm - s > 1e-9) {
+ s = Math.min(s + plan.coarseStepMm, step.maxTravelMm);
+ n += 1;
+ const w = marchWords(step, s);
+ const text = [
+ w.x !== undefined ? `X${w.x.toFixed(3)}` : '',
+ w.y !== undefined ? `Y${w.y.toFixed(3)}` : '',
+ w.z !== undefined ? `Z${w.z.toFixed(3)}` : '',
+ ].filter(Boolean).join(' ');
+ lines.push(`G1 ${text} F${COARSE_FEED}; coarse ${n} - settle, check probe, stop at contact`);
+ }
+ lines.push(`; ...on contact: retreat/release, ${plan.fineStepMm} mm fine approach, `
+ + `${plan.confirmPasses} confirm cycle(s) (lift ${plan.backoffMm} mm); ABORTS at the limit without contact`);
+ lines.push(`G1 X${step.start.x.toFixed(3)} Y${step.start.y.toFixed(3)} Z${step.start.z.toFixed(3)} `
+ + `F${TRAVEL_FEED}; retreat to the march start (also on any abort)`);
+ lines.push(`G1 Z${plan.hopZ.toFixed(3)} F${TRAVEL_FEED}; raise to traverse height`);
+ }
+ }
+ lines.push('G54;');
+ return lines.join('\n');
+}
+
+export async function runProbeSequenceProcedure(plan: ProbeSequencePlan): Promise {
+ assertChannelReady('probe', 'probe sequence');
+ assertMachineReadyForProcedure();
+
+ const position = getPositionSnapshot();
+ const here = position.machine;
+ if (here.x === null || here.y === null || here.z === null
+ || Math.abs(here.x - plan.staged.x) > 0.5
+ || Math.abs(here.y - plan.staged.y) > 0.5
+ || Math.abs(here.z - plan.staged.z) > 0.5) {
+ throw new McpToolError('The machine is not at the position this sequence was staged from '
+ + `(staged (${plan.staged.x}, ${plan.staged.y}, ${plan.staged.z}), now `
+ + `(${here.x}, ${here.y}, ${here.z})). Stage probe_sequence again.`);
+ }
+
+ const phases: { phase: string; note?: string }[] = [];
+ const announce = (phase: string, note?: string) => {
+ phases.push({ phase, note });
+ mcpBroadcast('mcp:activity', { tool: 'probe_sequence', phase, note });
+ };
+ const releaseTimeoutMs = Math.max(plan.sensorDelayMs * 4, 3500);
+ const results: {
+ name: string;
+ contactMachine: { x: number; y: number; z: number };
+ contactDistanceMm: number;
+ confirmPassContacts: number[];
+ spreadMm: number;
+ }[] = [];
+
+ let stepIndex = 0;
+ try {
+ for (const step of plan.steps) {
+ stepIndex += 1;
+ if (step.kind === 'hop') {
+ probeFeedService.clearExpectedContact();
+ await moveMachineSettled(`seq:raise:${stepIndex}`, { z: plan.hopZ }, TRAVEL_FEED);
+ await moveMachineSettled(`seq:hop:${stepIndex}`, { x: step.x, y: step.y }, TRAVEL_FEED);
+ announce(`hop-${stepIndex}`, `(${step.x}, ${step.y}) at Z${plan.hopZ}`);
+ } else if (step.kind === 'descend') {
+ probeFeedService.clearExpectedContact();
+ const guardTop = step.z + DESCENT_GUARD_MM;
+ const zNow = getPositionSnapshot().machine.z;
+ if (zNow !== null && zNow > guardTop + 1e-9) {
+ await moveMachineSettled(`seq:descend:${stepIndex}`, { z: guardTop }, TRAVEL_FEED);
+ }
+ let gz = Math.min(zNow === null ? guardTop : Math.max(zNow, step.z), guardTop);
+ while (gz - step.z > 1e-9) {
+ const t0 = Date.now();
+ gz = Math.max(gz - 1, step.z);
+ await moveMachineSettled(`seq:descend-guard:${stepIndex}`, { z: gz }, COARSE_FEED);
+ const sensed = await senseAfter('probe', t0, plan.sensorDelayMs);
+ if (sensed.contact) {
+ throw new ProcedureAbort(`UNEXPECTED CONTACT during guarded descent at Z${gz.toFixed(3)} `
+ + '- something is where the plan says nothing should be. Machine held.');
+ }
+ }
+ announce(`descend-${stepIndex}`, `Z${step.z} (guarded final ${DESCENT_GUARD_MM} mm)`);
+ } else {
+ // Re-verify the walk matches the simulation before marching.
+ const now = getPositionSnapshot().machine;
+ if (now.x === null || now.y === null || now.z === null
+ || Math.abs(now.x - step.start.x) > 0.5
+ || Math.abs(now.y - step.start.y) > 0.5
+ || Math.abs(now.z - step.start.z) > 0.5) {
+ throw new ProcedureAbort(`March "${step.name}": machine at `
+ + `(${now.x}, ${now.y}, ${now.z}) but the plan expects `
+ + `(${step.start.x}, ${step.start.y}, ${step.start.z}).`);
+ }
+ probeFeedService.setExpectedContact(['probe']);
+ const move = async (tool: string, s: number, feed: number) => {
+ await moveMachineSettled(tool, marchWords(step, s), feed);
+ };
+
+ let s = 0;
+ let coarseContactS: number | null = null;
+ while (step.maxTravelMm - s > 1e-9) {
+ const t0 = Date.now();
+ s = Math.min(s + plan.coarseStepMm, step.maxTravelMm);
+ await move(`seq:coarse:${step.name}`, s, COARSE_FEED);
+ const sensed = await senseAfter('probe', t0, plan.sensorDelayMs);
+ if (sensed.contact) {
+ coarseContactS = s;
+ announce(`coarse-contact-${step.name}`, `${s.toFixed(3)} mm along`);
+ break;
+ }
+ }
+ if (coarseContactS === null) {
+ throw new ProcedureAbort(`March "${step.name}": no contact within ${step.maxTravelMm} mm.`);
+ }
+ let released = false;
+ while (s > 1e-9 && coarseContactS - s < MAX_RETREAT_MM + 1e-9) {
+ const t0 = Date.now();
+ s = Math.max(s - plan.coarseStepMm, 0);
+ await move(`seq:release:${step.name}`, s, COARSE_FEED);
+ const sensed = await senseReleaseAfter('probe', t0, releaseTimeoutMs);
+ if (!sensed.contact) {
+ released = true;
+ break;
+ }
+ }
+ if (!released) {
+ throw new ProcedureAbort(`March "${step.name}": still triggered ${MAX_RETREAT_MM} mm back.`);
+ }
+ let fineContactS: number | null = null;
+ while (step.maxTravelMm - s > 1e-9) {
+ const t0 = Date.now();
+ s = Math.min(s + plan.fineStepMm, step.maxTravelMm);
+ await move(`seq:fine:${step.name}`, s, FINE_FEED);
+ const sensed = await senseAfter('probe', t0, plan.sensorDelayMs);
+ if (sensed.contact) {
+ fineContactS = s;
+ break;
+ }
+ }
+ if (fineContactS === null) {
+ throw new ProcedureAbort(`March "${step.name}": fine approach lost the contact.`);
+ }
+ const passContacts: number[] = [];
+ const cycleLimit = Math.min(fineContactS + Math.max(0.5, plan.backoffMm), step.maxTravelMm);
+ let reference = fineContactS;
+ for (let pass = 1; pass <= plan.confirmPasses; pass++) {
+ const t0 = Date.now();
+ s = Math.max(reference - plan.backoffMm, 0);
+ await move(`seq:backoff:${step.name}`, s, FINE_FEED);
+ const lifted = await senseReleaseAfter('probe', t0, releaseTimeoutMs);
+ if (lifted.contact) {
+ throw new ProcedureAbort(`March "${step.name}": hysteresis exceeds the backoff.`);
+ }
+ let passContact: number | null = null;
+ while (cycleLimit - s > 1e-9) {
+ const t1 = Date.now();
+ s = Math.min(s + plan.fineStepMm, cycleLimit);
+ await move(`seq:confirm:${step.name}`, s, FINE_FEED);
+ const sensed = await senseAfter('probe', t1, plan.sensorDelayMs);
+ if (sensed.contact) {
+ passContact = s;
+ break;
+ }
+ }
+ if (passContact === null) {
+ throw new ProcedureAbort(`March "${step.name}": confirm pass ${pass} lost the contact.`);
+ }
+ passContacts.push(Number(passContact.toFixed(3)));
+ reference = passContact;
+ }
+ const sorted = [...passContacts].sort((a, b) => a - b);
+ const measuredS = sorted[Math.floor((sorted.length - 1) / 2)];
+ const spreadMm = Number((sorted[sorted.length - 1] - sorted[0]).toFixed(3));
+ const contact = pointAlong(step.start, step.unit, measuredS);
+ results.push({
+ name: step.name,
+ contactMachine: contact,
+ contactDistanceMm: measuredS,
+ confirmPassContacts: passContacts,
+ spreadMm,
+ });
+ announce(`measured-${step.name}`, `(${contact.x}, ${contact.y}, ${contact.z}) spread ${spreadMm}`);
+
+ // Retreat to the march start, then raise (still expected-contact
+ // until physically clear of the surface).
+ await moveMachineSettled(`seq:retreat:${step.name}`, {
+ x: step.start.x, y: step.start.y, z: step.start.z,
+ }, TRAVEL_FEED);
+ probeFeedService.clearExpectedContact();
+ await moveMachineSettled(`seq:raise:${step.name}`, { z: plan.hopZ }, TRAVEL_FEED);
+ }
+ }
+
+ announce('sequence-complete', `${results.length} contacts`);
+ return {
+ results,
+ phases,
+ note: `Probe sequence complete: ${results.length} contacts, all MACHINE coordinates. `
+ + 'Worst confirm spread '
+ + `${Math.max(...results.map((r) => r.spreadMm)).toFixed(3)} mm.`,
+ };
+ } catch (err) {
+ const isTrip = !!probeFeedService.getTrip();
+ if (!isTrip) {
+ try {
+ const reading = probeFeedService.getReading('probe');
+ if (!reading || !reading.triggered) {
+ await moveMachineSettled('seq:abort-raise', { z: plan.hopZ }, TRAVEL_FEED);
+ announce('abort-raised', `Z${plan.hopZ}`);
+ } else {
+ announce('abort-held', 'probe still triggered - holding position for the operator');
+ }
+ } catch (retreatErr) {
+ // Logged by the activity stream.
+ }
+ }
+ if (err instanceof ProcedureAbort) {
+ throw new McpToolError(`Probe sequence aborted at step ${stepIndex}: ${err.message} `
+ + `Completed contacts: ${JSON.stringify(results)} Phases: ${JSON.stringify(phases)}`);
+ }
+ throw err;
+ } finally {
+ probeFeedService.clearExpectedContact();
+ }
+}
diff --git a/src/server/services/mcp/probeTool.ts b/src/server/services/mcp/probeTool.ts
index faa3d8c0ab..2fd43c8657 100644
--- a/src/server/services/mcp/probeTool.ts
+++ b/src/server/services/mcp/probeTool.ts
@@ -97,8 +97,8 @@ export function planProbePoint(args: {
limitCoord: Number(limitCoord.toFixed(3)),
coarseStepMm: Math.min(Math.max(Number(args.coarse_step_mm) || 1, 0.2), 2),
fineStepMm: Math.min(Math.max(Number(args.fine_step_mm) || 0.1, 0.02), 0.5),
- backoffMm: Math.min(Math.max(Number(args.backoff_mm) || 0.5, Number(args.fine_step_mm) || 0.1), 3),
- sensorDelayMs: Math.min(Math.max(Number(args.sensor_delay_ms) || 200, 100), 10000),
+ backoffMm: Math.min(Math.max(Number(args.backoff_mm) || 1, Number(args.fine_step_mm) || 0.1), 3),
+ sensorDelayMs: Math.min(Math.max(Number(args.sensor_delay_ms) || 300, 100), 10000),
confirmPasses: Math.min(Math.max(Math.round(Number(args.confirm_passes) || 3), 1), 10),
};
}
@@ -176,7 +176,7 @@ export async function runProbePointProcedure(plan: ProbePointPlan): Promise (plan.direction === 1
? Math.min(value, plan.limitCoord) : Math.max(value, plan.limitCoord));
@@ -244,7 +244,7 @@ export async function runProbePointProcedure(plan: ProbePointPlan): Promise {
await moveMachineSettled(tool, moveWords(plan, s), feed);
};
@@ -276,7 +276,7 @@ export async function runProbeVectorProcedure(plan: ProbeVectorPlan): Promise {
+ if (!String(args.reason || '').trim()) {
+ throw new McpToolError('reason is required; it is shown to the operator.');
+ }
+ probeFeedService.assertNoOvertravel();
+ const plan = planProbeSequence(args as Parameters[0]);
+ const envelope = `; reason: ${String(args.reason).trim()}
+${describeProbeSequencePlanAsGcode(plan)}`;
+ const validation = validateGcode(envelope);
+ const marches = plan.steps.filter((s) => s.kind === 'probe').length;
+ const job = jobManager.submit(
+ envelope,
+ `probe-sequence ${plan.steps.length}steps/${marches}marches - ${String(args.reason).slice(0, 40)}`,
+ 'cnc',
+ validation,
+ 'procedure'
+ );
+ job.runner = async () => runProbeSequenceProcedure(plan);
+ return {
+ job: jobManager.describe(job),
+ plan,
+ confirm_url: `${getConfirmBaseUrl()}/confirm/${job.id}`,
+ next_step: 'Ask the operator to open confirm_url, review the ENTIRE circuit (every hop, '
+ + 'descent and march is enumerated), and approve once. Their one-time code passed to '
+ + 'start_gcode_job runs the whole circuit and returns all named contacts.',
+ };
+ },
+ });
+
registry.register({
name: 'probe_circle',
description: 'Stage an N-point circle measurement of a roughly-round vertical feature for '
@@ -191,8 +267,8 @@ ${describeProbeVectorPlanAsGcode(plan)}`;
},
coarse_step_mm: { type: 'number', description: 'Coarse radial step, default 0.5 (0.2-2).' },
fine_step_mm: { type: 'number', description: 'Fine step, default 0.1 (0.02-0.5).' },
- backoff_mm: { type: 'number', description: 'Confirm-cycle lift, default 0.5.' },
- sensor_delay_ms: { type: 'number', description: 'Contact-check window per step, default 200.' },
+ backoff_mm: { type: 'number', description: 'Confirm-cycle lift, default 1 (also the confirm re-contact window).' },
+ sensor_delay_ms: { type: 'number', description: 'Contact-check window per step, default 300.' },
confirm_passes: { type: 'number', description: 'Lift-and-retest cycles per point, default 2 (1-5).' },
reason: { type: 'string', description: 'Shown to the operator: what is being measured and why.' },
},
From 6565e957a54dd0e290a0e0db4a0aaad2fd9753ab Mon Sep 17 00:00:00 2001
From: tyeth
Date: Fri, 4 Sep 2026 23:16:18 +0100
Subject: [PATCH 051/135] Feature: GPIO probe feed transport (Blinka/U2IF) with
settings and sensor pills
The probe feed gains a second transport beside MQTT: contact sensors on GPIO
pins read through Adafruit Blinka - by default via U2IF (a Pico/KB2040 as a
USB GPIO bridge), any Blinka board via the Blinka-environment field. The
ProbeFeedService is now transport-generic behind the ProbeTransport contract
(probeTransport.ts): readings, polarity, the safety latch and reconnect
backoff stay in the service; MQTT moved unchanged into MqttProbeTransport.
The GPIO backend (gpioFeed.ts) spawns a python -c Blinka monitor that polls
the pins (default 10 ms) and streams JSON lines - readings on change plus a
1 s heartbeat that is both liveness watchdog (silent 5 s -> reconnect) and
freshness refresh. Pins configure as name:pull ("D2:up"); polarity uses the
shared inverted mechanism. Verified on a real KB2040_U2IF (Ubuntu 24.04 box,
probe A0 inverted, toolsetter D2, overtravel D3; bump test passed) and with a
stubbed Blinka. gpio_bumptest.py verifies wiring and polarity by hand;
requirements.txt pins Blinka + hidapi.
Settings -> MCP Server exposes the transport choice (Auto/MQTT/GPIO), pins
with inversion, python, poll interval and Blinka environment, flagging env
overrides. Workspace -> Connection shows Probe / Tool Setter / Setter
Overtravel pills (yellow unknown, green idle, red contact) driven by GET
/api/mcp + mcp:activity, with an ALARM marker and an operator Clear alarm
button (POST /api/mcp/clear-alarm -> clearTrip).
Safety policy (operator, 2026-09-04): the overtravel tripwire latches only
while a sensor-gated procedure or MCP motion is in flight (procedureArmed);
a hand bump test with the machine idle is reported, not latched. The crash
guard is now armed inside moveMachineSettled (every procedure move), not
only move_and_capture. README, skills and install steps updated.
Co-Authored-By: Claude Fable 5.1
---
.claude/skills/cnc-probing/SKILL.md | 30 +-
.claude/skills/tool-change/SKILL.md | 15 +-
src/app/api/index.ts | 2 +
src/app/resources/i18n/en/resource.json | 34 +-
.../settings-modal/McpServer/index.tsx | 240 +++++++--
.../widgets/Connection/NetworkConnection.tsx | 3 +
.../widgets/Connection/SerialConnection.tsx | 3 +
.../components/ProbeFeedStatusBadges.tsx | 220 ++++++++
src/app/ui/widgets/Console/Console.jsx | 2 +-
src/server/services/api/api-mcp.js | 119 +++-
src/server/services/index.ts | 1 +
src/server/services/mcp/README.md | 107 +++-
src/server/services/mcp/gpioFeed.ts | 508 ++++++++++++++++++
src/server/services/mcp/gpio_bumptest.py | 154 ++++++
src/server/services/mcp/index.ts | 7 +-
src/server/services/mcp/probeFeed.ts | 462 +++++++++++-----
src/server/services/mcp/probeTransport.ts | 33 ++
src/server/services/mcp/probing.ts | 22 +-
src/server/services/mcp/requirements.txt | 21 +
src/server/services/mcp/tools/probe.ts | 31 +-
20 files changed, 1797 insertions(+), 217 deletions(-)
create mode 100644 src/app/ui/widgets/Connection/components/ProbeFeedStatusBadges.tsx
create mode 100644 src/server/services/mcp/gpioFeed.ts
create mode 100644 src/server/services/mcp/gpio_bumptest.py
create mode 100644 src/server/services/mcp/probeTransport.ts
create mode 100644 src/server/services/mcp/requirements.txt
diff --git a/.claude/skills/cnc-probing/SKILL.md b/.claude/skills/cnc-probing/SKILL.md
index 396004e649..e2803933b9 100644
--- a/.claude/skills/cnc-probing/SKILL.md
+++ b/.claude/skills/cnc-probing/SKILL.md
@@ -41,10 +41,18 @@ height drove the probe into the rotary stock and destroyed it.
or measure from a proven-safe height with `probe_point`.
4. **Landmarks are obstacles.** Give bed fixtures a `clearance_z` in
`set_landmark`; XY paths crossing their box below it are refused.
-5. **Contact sensors are crash sensors.** During any motion, a trigger on a
- probe channel that no procedure declared as expected trips a CRASH alarm:
- job stopped, connection closed, motion latched until the operator clears
- it. Do not disconnect the probe feed while anything might move.
+5. **Contact sensors are crash sensors.** While MCP motion is in flight (every
+ procedure move via `moveMachineSettled`, and `move_and_capture`), a trigger
+ on a probe channel that no procedure declared as expected trips a CRASH
+ alarm: job stopped, connection closed, motion latched until the operator
+ clears it. The overtravel switch latches the same way, but ONLY while a
+ procedure or MCP motion is in progress (operator rule, 2026-09-04) - the
+ operator pressing it by hand with the machine idle just flashes the
+ Workspace pill red and logs `overtravel_unarmed`; it is not an alarm. A
+ latched alarm shows as an ALARM pill in Workspace -> Connection; the
+ operator clears it there (Clear alarm button) or via
+ `clear_overtravel_alarm` - never you on your own judgment. Do not
+ disconnect the probe feed while anything might move.
6. **Chat is not a motion gate — the staged job is.** Deliberate traverses
and descents go through `submit_gcode_job` / staged procedures, so the
operator authorises the literal gcode with a one-time code from the
@@ -96,9 +104,17 @@ operator-confirmed descent), stage, operator approves, run. Results: median
of lift-and-retest passes, spread as the trust metric. Side probes touch one
tip-radius before the tip centre — correct for it.
-Feed latency (hardware-measured, Adafruit IO): trigger message ~120–150 ms
-after physical contact; 200 ms contact windows are right. Release messages
-lag ~1 s — release checks are patient by design; never shorten them.
+Feed latency is TRANSPORT-dependent - read `transport` from
+`get_probe_feed_status` first. MQTT (Adafruit IO, hardware-measured): trigger
+message ~120-150 ms after physical contact; the default 200-300 ms contact
+windows are right, and release messages lag ~1 s, so release checks are patient
+by design - never shorten them on MQTT. GPIO (Blinka/U2IF, the Ubuntu box):
+readings are polled every 10 ms locally, so `sensor_delay_ms` can drop to
+~50 ms and releases are seen immediately; the defaults still work, they are
+just slower than necessary. Before any sensor-gated run, sanity-check the
+sensors: the Workspace -> Connection pills (Probe / Tool Setter / Setter
+Overtravel) must all be green (yellow = no reading or feed down), and
+`get_probe_feed_status` must show the channel untriggered with a fresh age.
## Circle probing
diff --git a/.claude/skills/tool-change/SKILL.md b/.claude/skills/tool-change/SKILL.md
index 5b8a230372..f6b79d1673 100644
--- a/.claude/skills/tool-change/SKILL.md
+++ b/.claude/skills/tool-change/SKILL.md
@@ -13,8 +13,13 @@ shifted exactly, without ever re-touching the stock.
## Preconditions
-- Probe feed connected (`get_probe_feed_status` — this also arms the overtravel
- tripwire) and the machine homed and idle.
+- Probe feed connected (`get_probe_feed_status` to check; `connect_probe_feed`
+ if not - the feed auto-connects at start when configured) and the machine
+ homed and idle. The tool setter's overtravel switch is a tripwire ONLY while
+ this procedure (or other MCP motion) is running: pushing the setter past
+ contact mid-run latches the alarm; by hand with the machine idle it just
+ flashes the Workspace pill. The Workspace -> Connection pills (Tool Setter /
+ Setter Overtravel) should both read green before you start.
- Tool setter reference and the tool-change park position stored
(`get_tool_setter_config`; the operator sets them once with
`set_tool_setter_config` — on this machine the park is Z at the homing
@@ -74,5 +79,7 @@ operator raises it slightly from the touchscreen first.
declared). Pass `old_trigger_z`/`new_trigger_z` explicitly from known-good
values instead of loosening anything.
- If the overtravel alarm latches at any point, everything stops until the
- operator physically inspects and explicitly clears it
- (`clear_overtravel_alarm`).
+ operator physically inspects and explicitly clears it - the Clear alarm
+ button on the ALARM pill in Workspace -> Connection, or
+ `clear_overtravel_alarm` with their words as `reason`. Both refuse while the
+ sensor still reads triggered.
diff --git a/src/app/api/index.ts b/src/app/api/index.ts
index c9801dd910..ff3ba2ceb3 100644
--- a/src/app/api/index.ts
+++ b/src/app/api/index.ts
@@ -132,6 +132,7 @@ const unsetState = defaultAPIFactory(({ key }) => request.delete('/api/state').q
//
const getMcpStatus = defaultAPIFactory(() => request.get('/api/mcp'));
const setMcpSettings = defaultAPIFactory((options) => request.post('/api/mcp').send(options));
+const clearMcpAlarm = defaultAPIFactory((options) => request.post('/api/mcp/clear-alarm').send(options || {}));
/**
* Load G-code
@@ -362,6 +363,7 @@ export default {
// MCP server
getMcpStatus,
setMcpSettings,
+ clearMcpAlarm,
// G-code
loadGCode,
diff --git a/src/app/resources/i18n/en/resource.json b/src/app/resources/i18n/en/resource.json
index 7d93a77621..b38584c046 100644
--- a/src/app/resources/i18n/en/resource.json
+++ b/src/app/resources/i18n/en/resource.json
@@ -1102,7 +1102,24 @@
"key-App/Settings/McpServer-(saved - leave blank to keep)": "(saved - leave blank to keep)",
"key-App/Settings/McpServer-CNC probe feed": "CNC probe feed",
"key-App/Settings/McpServer-Enable MCP server (applies after restart)": "Enable MCP server (applies after restart)",
- "key-App/Settings/McpServer-External tool setter, overtravel and touch probe sensors report over this feed. Feed fields accept an Adafruit IO feed key or a full topic path. Applies at the next feed connection.": "External tool setter, overtravel and touch probe sensors report over this feed. Feed fields accept an Adafruit IO feed key or a full topic path. Applies at the next feed connection.",
+ "key-App/Settings/McpServer-External tool setter, overtravel and touch probe sensors report over this feed. Applies at the next feed connection.": "External tool setter, overtravel and touch probe sensors report over this feed. Applies at the next feed connection.",
+ "key-App/Settings/McpServer-Feed fields accept an Adafruit IO feed key or a full topic path.": "Feed fields accept an Adafruit IO feed key or a full topic path.",
+ "key-App/Settings/McpServer-Transport": "Transport",
+ "key-App/Settings/McpServer-Auto": "Auto",
+ "key-App/Settings/McpServer-Active this session:": "Active this session:",
+ "key-App/Settings/McpServer-Overridden by LUBAN_MCP_PROBE_TRANSPORT": "overridden by LUBAN_MCP_PROBE_TRANSPORT environment variable",
+ "key-App/Settings/McpServer-Auto picks MQTT unless only the GPIO side is configured.": "Auto picks MQTT unless only the GPIO side is configured.",
+ "key-App/Settings/McpServer-MQTT (Adafruit IO)": "MQTT (Adafruit IO)",
+ "key-App/Settings/McpServer-GPIO (Adafruit Blinka / U2IF)": "GPIO (Adafruit Blinka / U2IF)",
+ "key-App/Settings/McpServer-Sensors wired to pins of a Blinka board - by default a U2IF USB bridge (Pico / KB2040). Pin = Blinka pin name with an optional :up / :down / :float pull suffix. Python = an interpreter with adafruit-blinka installed (the venv from requirements.txt).": "Sensors wired to pins of a Blinka board — by default a U2IF USB bridge (Pico / KB2040). Pin = Blinka pin name with an optional :up / :down / :float pull suffix. Python = an interpreter with adafruit-blinka installed (the venv from requirements.txt).",
+ "key-App/Settings/McpServer-Not configured - missing:": "Not configured — missing:",
+ "key-App/Settings/McpServer-Tool setter pin": "Tool setter pin",
+ "key-App/Settings/McpServer-Overtravel pin": "Overtravel pin",
+ "key-App/Settings/McpServer-CNC probe pin": "CNC probe pin",
+ "key-App/Settings/McpServer-Python interpreter": "Python interpreter",
+ "key-App/Settings/McpServer-Poll interval (ms)": "Poll interval (ms)",
+ "key-App/Settings/McpServer-Blinka environment": "Blinka environment",
+ "key-App/Settings/McpServer-NAME=VALUE pairs handed to the monitor so Blinka picks the board: BLINKA_U2IF=1 (default: Pico / KB2040 U2IF bridge), BLINKA_MCP2221=1, BLINKA_FT232H=1, BLINKA_FORCEBOARD=... - or \"native\" for on-board GPIO such as a Raspberry Pi header.": "NAME=VALUE pairs handed to the monitor so Blinka picks the board: BLINKA_U2IF=1 (default: Pico / KB2040 U2IF bridge), BLINKA_MCP2221=1, BLINKA_FT232H=1, BLINKA_FORCEBOARD=… — or \"native\" for on-board GPIO such as a Raspberry Pi header.",
"key-App/Settings/McpServer-Inverted": "Inverted",
"key-App/Settings/McpServer-Local agents connect at": "Local agents connect at",
"key-App/Settings/McpServer-Normally-open sensor: idles at 1, reads 0 on contact": "Normally-open sensor: idles at 1, reads 0 on contact",
@@ -1117,7 +1134,7 @@
"key-App/Settings/McpServer-Overridden by LUBAN_MCP_PORT": "overridden by LUBAN_MCP_PORT environment variable",
"key-App/Settings/McpServer-Overridden by environment variables:": "Overridden by environment variables:",
"key-App/Settings/McpServer-Overtravel feed": "Overtravel feed",
- "key-App/Settings/McpServer-Probe sensor feed (MQTT)": "Probe sensor feed (MQTT)",
+ "key-App/Settings/McpServer-Probe sensor feed": "Probe sensor feed",
"key-App/Settings/McpServer-Running this session at": "Running this session at",
"key-App/Settings/McpServer-Status unknown": "Status unknown",
"key-App/Settings/McpServer-Tool setter feed": "Tool setter feed",
@@ -2004,6 +2021,19 @@
"key-Workspace/Connection-emergencyStopButton": "Emergency Stop Button",
"key-Workspace/Connection-enclosure": "Enclosure",
"key-Workspace/Connection-rotaryModule": "Rotary Module",
+ "key-Workspace/Connection-probeTool": "Probe",
+ "key-Workspace/Connection-toolSetter": "Tool Setter",
+ "key-Workspace/Connection-toolSetterOvertravel": "Setter Overtravel",
+ "key-Workspace/Connection-Sensor idle": "Sensor idle",
+ "key-Workspace/Connection-Sensor in contact": "Sensor in contact",
+ "key-Workspace/Connection-Sensor unknown (feed not connected or no reading yet)": "Sensor unknown (feed not connected or no reading yet)",
+ "key-Workspace/Connection-Safety alarm latched - all motion blocked until the operator clears it (clear_overtravel_alarm) or Luban restarts": "Safety alarm latched — all motion blocked until the operator clears it (clear_overtravel_alarm) or Luban restarts",
+ "key-Workspace/Connection-ALARM": "ALARM",
+ "key-Workspace/Connection-Clear alarm": "Clear alarm",
+ "key-Workspace/Connection-Clear the latched safety alarm?": "Clear the latched safety alarm?",
+ "key-Workspace/Connection-Only after you have physically inspected the machine. All MCP motion stays blocked until the alarm is cleared; the clear is refused while the sensor still reads triggered.": "Only after you have physically inspected the machine. All MCP motion stays blocked until the alarm is cleared; the clear is refused while the sensor still reads triggered.",
+ "key-Workspace/Connection-No safety alarm was latched.": "No safety alarm was latched.",
+ "key-Workspace/Connection-Alarm not cleared:": "Alarm not cleared:",
"key-Workspace/Console-Connected to {{-port}}": "Connected to {{-port}}",
"key-Workspace/Console-Connected via Wi-Fi": "Connected via Wi-Fi",
"key-Workspace/Console-Console": "Console",
diff --git a/src/app/ui/pages/global-modals/settings-modal/McpServer/index.tsx b/src/app/ui/pages/global-modals/settings-modal/McpServer/index.tsx
index 5cc3722a55..aab21a1f93 100644
--- a/src/app/ui/pages/global-modals/settings-modal/McpServer/index.tsx
+++ b/src/app/ui/pages/global-modals/settings-modal/McpServer/index.tsx
@@ -1,4 +1,4 @@
-import { Input, Switch } from 'antd';
+import { Input, Radio, Switch } from 'antd';
import React, { useState, useEffect } from 'react';
import api from '../../../../../api';
@@ -25,6 +25,30 @@ interface McpMqttSettings {
defaultClientId: string;
}
+interface McpGpioSettings {
+ values: {
+ python: string;
+ pinToolsetter: string;
+ pinOvertravel: string;
+ pinProbe: string;
+ inverted: string;
+ pollMs: string;
+ blinkaEnv: string;
+ };
+ envOverrides: string[];
+ configured: boolean;
+ missing: string[];
+ defaultPython: string;
+ defaultBlinkaEnv: string;
+}
+
+interface McpTransportSettings {
+ /** Stored choice: '' = auto, 'mqtt' or 'gpio'. */
+ stored: string;
+ envOverride: boolean;
+ active: 'mqtt' | 'gpio';
+}
+
interface McpStatus {
running: boolean;
port: number | null;
@@ -34,7 +58,9 @@ interface McpStatus {
port: number;
source: 'env' | 'config';
};
+ transport: McpTransportSettings;
mqtt: McpMqttSettings;
+ gpio: McpGpioSettings;
}
const MQTT_FIELDS: Array<{ name: keyof McpMqttSettings['values']; labelKey: string; placeholder?: string; channel?: string }> = [
@@ -47,39 +73,67 @@ const MQTT_FIELDS: Array<{ name: keyof McpMqttSettings['values']; labelKey: stri
{ name: 'feedProbe', labelKey: 'key-App/Settings/McpServer-CNC probe feed', channel: 'probe' },
];
+// Pin fields carry the pull as a suffix ("D2:up"); polarity is the separate
+// inverted switch, exactly like the MQTT channels.
+const GPIO_PIN_FIELDS: Array<{ name: keyof McpGpioSettings['values']; labelKey: string; placeholder: string; channel: string }> = [
+ { name: 'pinToolsetter', labelKey: 'key-App/Settings/McpServer-Tool setter pin', placeholder: 'D2:up', channel: 'toolsetter' },
+ { name: 'pinOvertravel', labelKey: 'key-App/Settings/McpServer-Overtravel pin', placeholder: 'D3:up', channel: 'overtravel' },
+ { name: 'pinProbe', labelKey: 'key-App/Settings/McpServer-CNC probe pin', placeholder: 'A0:down', channel: 'probe' },
+];
+
const CHANNELS = ['toolsetter', 'overtravel', 'probe'];
+const LABEL_STYLE = { width: 160, flexShrink: 0 };
+
+function parseInvertedFlags(names: string): { [channel: string]: boolean } {
+ const list = String(names || '').split(',').map((n) => n.trim().toLowerCase());
+ const flags: { [channel: string]: boolean } = {};
+ CHANNELS.forEach((channel) => {
+ flags[channel] = list.includes(channel);
+ });
+ return flags;
+}
+
/**
* MCP server settings: enabled + port, persisted in the server configstore.
* Changes apply at the next application start; the label reports what this
- * run is actually doing. The probe feed (MQTT) section configures the
- * external tool-setter / overtravel / touch-probe sensor transport -
- * environment variables LUBAN_MCP_MQTT_* override these fields.
+ * run is actually doing. The probe feed section configures the external
+ * tool-setter / overtravel / touch-probe sensor transport - MQTT (Adafruit
+ * IO) or direct GPIO through Adafruit Blinka / U2IF - with the transport
+ * choice itself. Environment variables (LUBAN_MCP_PROBE_TRANSPORT,
+ * LUBAN_MCP_MQTT_*, LUBAN_MCP_GPIO_*) override these fields; the pane
+ * flags which ones are currently overridden.
*/
const McpServer: React.FC = () => {
const [status, setStatus] = useState(null);
const [enabled, setEnabled] = useState(false);
const [port, setPort] = useState('');
+ const [transport, setTransport] = useState('');
const [mqtt, setMqtt] = useState<{ [field: string]: string }>({});
const [inverted, setInverted] = useState<{ [channel: string]: boolean }>({});
const [mqttPass, setMqttPass] = useState('');
const [mqttPassTouched, setMqttPassTouched] = useState(false);
+ const [gpio, setGpio] = useState<{ [field: string]: string }>({});
+ const [gpioInverted, setGpioInverted] = useState<{ [channel: string]: boolean }>({});
useEffect(() => {
api.getMcpStatus()
.then((res) => {
- const body: McpStatus = res.body;
+ const body = (res as { body: McpStatus }).body;
setStatus(body);
setEnabled(body.settings.enabled);
setPort(String(body.settings.port));
- const { inverted: invertedNames, ...values } = body.mqtt.values;
- setMqtt(values);
- const names = String(invertedNames || '').split(',').map((n) => n.trim().toLowerCase());
- const flags: { [channel: string]: boolean } = {};
- CHANNELS.forEach((channel) => {
- flags[channel] = names.includes(channel);
- });
- setInverted(flags);
+ setTransport(body.transport ? body.transport.stored : '');
+
+ const { inverted: mqttInvertedNames, ...mqttValues } = body.mqtt.values;
+ setMqtt({ ...mqttValues });
+ setInverted(parseInvertedFlags(mqttInvertedNames));
+
+ if (body.gpio) {
+ const { inverted: gpioInvertedNames, ...gpioValues } = body.gpio.values;
+ setGpio({ ...gpioValues });
+ setGpioInverted(parseInvertedFlags(gpioInvertedNames));
+ }
})
.catch(() => setStatus(null));
}, []);
@@ -94,7 +148,9 @@ const McpServer: React.FC = () => {
if (mqttPassTouched) {
mqttUpdate.pass = mqttPass;
}
- await api.setMcpSettings({ enabled, port: value, mqtt: mqttUpdate });
+ const gpioUpdate: { [field: string]: string } = { ...gpio };
+ gpioUpdate.inverted = CHANNELS.filter((channel) => gpioInverted[channel]).join(',');
+ await api.setMcpSettings({ enabled, port: value, transport, mqtt: mqttUpdate, gpio: gpioUpdate });
};
useEffect(() => {
@@ -121,7 +177,35 @@ const McpServer: React.FC = () => {
}
}
- const envOverrides = status ? status.mqtt.envOverrides : [];
+ let transportLine = '';
+ if (status && status.transport) {
+ transportLine = `${i18n._('key-App/Settings/McpServer-Active this session:')} ${status.transport.active.toUpperCase()}`;
+ if (status.transport.envOverride) {
+ transportLine += ` — ${i18n._('key-App/Settings/McpServer-Overridden by LUBAN_MCP_PROBE_TRANSPORT')}`;
+ }
+ }
+
+ const mqttEnvOverrides = status ? status.mqtt.envOverrides : [];
+ const gpioEnvOverrides = status && status.gpio ? status.gpio.envOverrides : [];
+
+ const renderInvertedSwitch = (checked: boolean, onChange: (checked: boolean) => void) => (
+ <>
+
+
+ {i18n._('key-App/Settings/McpServer-Inverted')}
+
+ >
+ );
return (
+ {i18n._('key-App/Settings/McpServer-External tool setter, overtravel and touch probe sensors report over this feed. Applies at the next feed connection.')}
+
- {i18n._('key-App/Settings/McpServer-External tool setter, overtravel and touch probe sensors report over this feed. Feed fields accept an Adafruit IO feed key or a full topic path. Applies at the next feed connection.')}
+ {i18n._('key-App/Settings/McpServer-Feed fields accept an Adafruit IO feed key or a full topic path.')}
+ {i18n._('key-App/Settings/McpServer-Sensors wired to pins of a Blinka board - by default a U2IF USB bridge (Pico / KB2040). Pin = Blinka pin name with an optional :up / :down / :float pull suffix. Python = an interpreter with adafruit-blinka installed (the venv from requirements.txt).')}
+
+ {gpioEnvOverrides.length > 0 && (
+
+ {i18n._('key-App/Settings/McpServer-Overridden by environment variables:')} {gpioEnvOverrides.join(', ')}
+
+ {i18n._('key-App/Settings/McpServer-NAME=VALUE pairs handed to the monitor so Blinka picks the board: BLINKA_U2IF=1 (default: Pico / KB2040 U2IF bridge), BLINKA_MCP2221=1, BLINKA_FT232H=1, BLINKA_FORCEBOARD=... - or "native" for on-board GPIO such as a Raspberry Pi header.')}
+
+
);
};
diff --git a/src/app/ui/widgets/Connection/NetworkConnection.tsx b/src/app/ui/widgets/Connection/NetworkConnection.tsx
index 9309a60399..ec80710ab3 100644
--- a/src/app/ui/widgets/Connection/NetworkConnection.tsx
+++ b/src/app/ui/widgets/Connection/NetworkConnection.tsx
@@ -42,6 +42,7 @@ import Select from '../../components/Select';
import SvgIcon from '../../components/SvgIcon';
import useThrottle from '../../utils/useThrottle';
import MachineModuleStatusBadge from './components/MachineModuleStatusBadge';
+import ProbeFeedStatusBadges from './components/ProbeFeedStatusBadges';
import LaserLockModal from './modals/LaserLockModal';
import MismatchModal from './modals/MismatchModal';
import MismatchNozzleModal from './modals/MismatchNozzleModal';
@@ -709,6 +710,8 @@ const NetworkConnection: React.FC = () => {
)
}
+ {/* MCP probe sensor feed pills (touch probe / tool setter / overtravel) */}
+
)
}
diff --git a/src/app/ui/widgets/Connection/SerialConnection.tsx b/src/app/ui/widgets/Connection/SerialConnection.tsx
index 88a5374486..f0cbe563f7 100644
--- a/src/app/ui/widgets/Connection/SerialConnection.tsx
+++ b/src/app/ui/widgets/Connection/SerialConnection.tsx
@@ -34,6 +34,7 @@ import { Button } from '../../components/Buttons';
import Select from '../../components/Select';
import SvgIcon from '../../components/SvgIcon';
import MachineModuleStatusBadge from './components/MachineModuleStatusBadge';
+import ProbeFeedStatusBadges from './components/ProbeFeedStatusBadges';
import MismatchModal from './modals/MismatchModal';
import styles from './styles.styl';
@@ -413,6 +414,8 @@ const SerialConnection: React.FC = () => {
)
}
+ {/* MCP probe sensor feed pills (touch probe / tool setter / overtravel) */}
+
)
}
diff --git a/src/app/ui/widgets/Connection/components/ProbeFeedStatusBadges.tsx b/src/app/ui/widgets/Connection/components/ProbeFeedStatusBadges.tsx
new file mode 100644
index 0000000000..5398038b80
--- /dev/null
+++ b/src/app/ui/widgets/Connection/components/ProbeFeedStatusBadges.tsx
@@ -0,0 +1,220 @@
+import { Modal, message } from 'antd';
+import React, { useEffect, useState } from 'react';
+
+import api from '../../../../api';
+import { controller } from '../../../../communication/socket-communication';
+import i18n from '../../../../lib/i18n';
+
+// Live pills for the MCP probe sensor feed (touch probe, tool setter contact,
+// tool setter overtravel), shown beside the machine module badges as a visual
+// test aid: press a sensor, watch its pill go red.
+// yellow - unknown: feed not connected, channel unbound, or no reading yet
+// green - reading present and idle
+// red - in contact / tripped, or the safety alarm is latched on it
+// Seeded from GET /api/mcp (probeFeed snapshot), then driven by the server's
+// mcp:activity events (tool 'probe_feed'), with a slow poll as reconciliation.
+
+type Channel = 'toolsetter' | 'overtravel' | 'probe';
+
+const CHANNELS: Array<{ channel: Channel; labelKey: string }> = [
+ { channel: 'probe', labelKey: 'key-Workspace/Connection-probeTool' },
+ { channel: 'toolsetter', labelKey: 'key-Workspace/Connection-toolSetter' },
+ { channel: 'overtravel', labelKey: 'key-Workspace/Connection-toolSetterOvertravel' },
+];
+
+const COLORS = {
+ unknown: '#FFA940',
+ ok: '#4CB518',
+ bad: '#FF4D4F',
+};
+
+const POLL_MS = 10000;
+
+interface FeedSnapshot {
+ configured: boolean;
+ connected: boolean;
+ transport: string;
+ bound: { [channel in Channel]?: boolean };
+ triggered: { [channel in Channel]?: boolean | null };
+ alarmChannel: Channel | null;
+}
+
+interface ProbeFeedStatusBody {
+ transport: string;
+ configured: boolean;
+ connected: boolean;
+ feeds: { [channel: string]: { source: string | null; last: { triggered: boolean } | null } };
+ safetyTrip: { channel: Channel } | null;
+}
+
+function snapshotFromStatus(body: ProbeFeedStatusBody | undefined): FeedSnapshot | null {
+ if (!body) {
+ return null;
+ }
+ const snapshot: FeedSnapshot = {
+ configured: !!body.configured,
+ connected: !!body.connected,
+ transport: body.transport,
+ bound: {},
+ triggered: {},
+ alarmChannel: body.safetyTrip ? body.safetyTrip.channel : null,
+ };
+ CHANNELS.forEach(({ channel }) => {
+ const feed = body.feeds ? body.feeds[channel] : null;
+ snapshot.bound[channel] = !!(feed && feed.source);
+ snapshot.triggered[channel] = feed && feed.last ? !!feed.last.triggered : null;
+ });
+ return snapshot;
+}
+
+const ProbeFeedStatusBadges: React.FC = () => {
+ const [snapshot, setSnapshot] = useState(null);
+
+ useEffect(() => {
+ let alive = true;
+ const load = () => {
+ api.getMcpStatus()
+ .then((res) => {
+ if (alive) {
+ setSnapshot(snapshotFromStatus((res as { body: { probeFeed?: ProbeFeedStatusBody } }).body.probeFeed));
+ }
+ })
+ .catch(() => undefined);
+ };
+ load();
+ const timer = setInterval(load, POLL_MS);
+
+ const onActivity = (options) => {
+ const { tool, phase, channel, triggered } = options || {};
+ if (tool !== 'probe_feed') {
+ return;
+ }
+ setSnapshot((previous) => {
+ if (!previous) {
+ return previous;
+ }
+ if (phase === 'reading' && channel) {
+ return { ...previous, connected: true, triggered: { ...previous.triggered, [channel]: !!triggered } };
+ }
+ if (phase === 'connected') {
+ return { ...previous, connected: true };
+ }
+ if (phase === 'disconnected') {
+ return { ...previous, connected: false, triggered: {} };
+ }
+ if (phase === 'OVERTRAVEL_ALARM' || phase === 'CRASH_ALARM') {
+ return { ...previous, alarmChannel: channel || previous.alarmChannel };
+ }
+ return previous;
+ });
+ };
+ controller.on('mcp:activity', onActivity);
+
+ return () => {
+ alive = false;
+ clearInterval(timer);
+ controller.off('mcp:activity', onActivity);
+ };
+ }, []);
+
+ if (!snapshot || !snapshot.configured) {
+ return null;
+ }
+ const pills = CHANNELS.filter(({ channel }) => snapshot.bound[channel]);
+ if (!pills.length) {
+ return null;
+ }
+
+ // The operator's own click is the explicit word the safety model asks
+ // for; the server still refuses while the sensor reads triggered.
+ const confirmClearAlarm = () => {
+ Modal.confirm({
+ title: i18n._('key-Workspace/Connection-Clear the latched safety alarm?'),
+ content: i18n._('key-Workspace/Connection-Only after you have physically inspected the machine. All MCP motion stays blocked until the alarm is cleared; the clear is refused while the sensor still reads triggered.'),
+ okText: i18n._('key-Workspace/Connection-Clear alarm'),
+ okType: 'danger',
+ onOk: async () => api.clearMcpAlarm({ reason: 'operator clicked Clear alarm on the Workspace connection panel' })
+ .then((res) => {
+ const body = (res as { body: { cleared: boolean; probeFeed?: ProbeFeedStatusBody } }).body;
+ setSnapshot(snapshotFromStatus(body.probeFeed));
+ if (!body.cleared) {
+ message.info(i18n._('key-Workspace/Connection-No safety alarm was latched.'));
+ }
+ })
+ .catch((err) => {
+ const msg = err && err.response && err.response.body && err.response.body.msg;
+ message.error(`${i18n._('key-Workspace/Connection-Alarm not cleared:')} ${msg || err.message || err}`);
+ }),
+ });
+ };
+
+ return (
+
+ {pills.map(({ channel, labelKey }) => {
+ let state: keyof typeof COLORS = 'unknown';
+ let detail = i18n._('key-Workspace/Connection-Sensor unknown (feed not connected or no reading yet)');
+ const latched = snapshot.alarmChannel === channel;
+ if (latched) {
+ // The sensor may well be idle again; the LATCH is what is
+ // red. It survives reconnects on purpose - only the
+ // operator's explicit clear (clear_overtravel_alarm) or a
+ // restart releases it.
+ state = 'bad';
+ detail = i18n._('key-Workspace/Connection-Safety alarm latched - all motion blocked until the operator clears it (clear_overtravel_alarm) or Luban restarts');
+ } else if (snapshot.connected && snapshot.triggered[channel] === true) {
+ state = 'bad';
+ detail = i18n._('key-Workspace/Connection-Sensor in contact');
+ } else if (snapshot.connected && snapshot.triggered[channel] === false) {
+ state = 'ok';
+ detail = i18n._('key-Workspace/Connection-Sensor idle');
+ }
+ return (
+
+ );
+};
+
+export default ProbeFeedStatusBadges;
diff --git a/src/app/ui/widgets/Console/Console.jsx b/src/app/ui/widgets/Console/Console.jsx
index ce93d67541..10fc8282de 100644
--- a/src/app/ui/widgets/Console/Console.jsx
+++ b/src/app/ui/widgets/Console/Console.jsx
@@ -170,7 +170,7 @@ function Console({ widgetId, widgetActions, minimized, isDefault, clearRenderSta
.map(([key, value]) => `${key}=${typeof value === 'object' ? JSON.stringify(value) : value}`)
.join(' ');
const line = `${stamp()}[mcp] ${tool} ${phase}${detail ? ` ${detail}` : ''}`;
- terminal.writeln(phase === 'OVERTRAVEL_ALARM' ? color.red(line) : color.cyan(line));
+ terminal.writeln(phase === 'OVERTRAVEL_ALARM' || phase === 'CRASH_ALARM' ? color.red(line) : color.cyan(line));
} else if (ok) {
terminal.writeln(color.cyan(`${stamp()}[mcp] ${tool} ok ${durationMs}ms`));
} else {
diff --git a/src/server/services/api/api-mcp.js b/src/server/services/api/api-mcp.js
index 492a318625..097b9d92d3 100644
--- a/src/server/services/api/api-mcp.js
+++ b/src/server/services/api/api-mcp.js
@@ -1,6 +1,7 @@
import config from '../configstore';
import { getMcpStatus } from '../mcp';
-import { resolveProbeFeedConfig } from '../mcp/probeFeed';
+import { DEFAULT_BLINKA_ENV, resolveGpioFeedConfig } from '../mcp/gpioFeed';
+import { probeFeedService, resolveProbeFeedConfig, resolveProbeTransportKind } from '../mcp/probeFeed';
const ERR_BAD_REQUEST = 400;
@@ -32,6 +33,30 @@ const MQTT_SOURCE_FIELDS = {
inverted: 'inverted',
};
+// Probe feed (Blinka GPIO) fields, same env-first resolution
+// (LUBAN_MCP_GPIO_*). Pin values are a Blinka pin name with an optional
+// pull suffix, e.g. "GP6:up".
+const GPIO_FIELD_KEYS = {
+ python: 'mcpGpioPython',
+ pinToolsetter: 'mcpGpioPinToolsetter',
+ pinOvertravel: 'mcpGpioPinOvertravel',
+ pinProbe: 'mcpGpioPinProbe',
+ inverted: 'mcpGpioInverted',
+ pollMs: 'mcpGpioPollMs',
+ blinkaEnv: 'mcpGpioBlinkaEnv',
+};
+
+// api field name -> gpioFeed resolver field name (for env-override display)
+const GPIO_SOURCE_FIELDS = {
+ python: 'python',
+ pinToolsetter: 'toolsetter',
+ pinOvertravel: 'overtravel',
+ pinProbe: 'probe',
+ inverted: 'inverted',
+ pollMs: 'pollMs',
+ blinkaEnv: 'blinkaEnv',
+};
+
function mqttSettings() {
const resolved = resolveProbeFeedConfig();
const values = {};
@@ -55,8 +80,59 @@ function mqttSettings() {
};
}
+function gpioSettings() {
+ const resolved = resolveGpioFeedConfig();
+ const values = {};
+ for (const [field, key] of Object.entries(GPIO_FIELD_KEYS)) {
+ const raw = config.get(key);
+ values[field] = (raw === undefined || raw === null) ? '' : String(raw);
+ }
+ const envOverrides = Object.entries(GPIO_SOURCE_FIELDS)
+ .filter(([, sourceField]) => resolved.sources[sourceField] === 'env')
+ .map(([field]) => field);
+ return {
+ values,
+ envOverrides,
+ configured: resolved.configured,
+ missing: resolved.missing,
+ defaultPython: resolved.python,
+ defaultBlinkaEnv: DEFAULT_BLINKA_ENV,
+ };
+}
+
+function transportSettings() {
+ return {
+ // What the operator stored (may be empty = auto), and what is live.
+ stored: String(config.get('mcpProbeTransport') || ''),
+ envOverride: !!(process.env.LUBAN_MCP_PROBE_TRANSPORT || '').trim(),
+ active: resolveProbeTransportKind(),
+ };
+}
+
export const getStatus = (req, res) => {
- res.send({ ...getMcpStatus(), mqtt: mqttSettings() });
+ res.send({ ...getMcpStatus(), transport: transportSettings(), mqtt: mqttSettings(), gpio: gpioSettings() });
+};
+
+/**
+ * Operator clears the latched safety alarm (overtravel or crash) from the
+ * Workspace pill. A human click in the app IS the operator's explicit word;
+ * the same guard as clear_overtravel_alarm applies - refused (409) while the
+ * tripped channel still reads triggered.
+ */
+export const clearAlarm = (req, res) => {
+ const trip = probeFeedService.getTrip();
+ if (!trip) {
+ res.send({ cleared: false, note: 'No safety alarm is latched.', probeFeed: probeFeedService.status() });
+ return;
+ }
+ try {
+ probeFeedService.clearTrip();
+ } catch (err) {
+ res.status(409).send({ msg: err.message, probeFeed: probeFeedService.status() });
+ return;
+ }
+ const reason = String((req.body || {}).reason || 'cleared from the Workspace connection panel');
+ res.send({ cleared: true, previousTrip: trip, reason, probeFeed: probeFeedService.status() });
};
/**
@@ -66,7 +142,7 @@ export const getStatus = (req, res) => {
* omitted field is left unchanged (the pane omits an untouched password).
*/
export const updateSettings = (req, res) => {
- const { enabled, port, mqtt } = req.body || {};
+ const { enabled, port, mqtt, gpio, transport } = req.body || {};
if (port !== undefined) {
const value = Number(port);
@@ -103,5 +179,40 @@ export const updateSettings = (req, res) => {
}
}
- res.send({ ...getMcpStatus(), mqtt: mqttSettings() });
+ if (transport !== undefined) {
+ const value = String(transport).trim().toLowerCase();
+ if (value === '') {
+ config.unset('mcpProbeTransport'); // back to auto-detect
+ } else if (value === 'mqtt' || value === 'gpio') {
+ config.set('mcpProbeTransport', value);
+ } else {
+ res.status(ERR_BAD_REQUEST).send({ msg: `Invalid probe transport: ${transport} (mqtt, gpio or empty)` });
+ return;
+ }
+ }
+
+ if (gpio && typeof gpio === 'object') {
+ for (const [field, key] of Object.entries(GPIO_FIELD_KEYS)) {
+ if (gpio[field] === undefined) {
+ continue;
+ }
+ const value = String(gpio[field]).trim();
+ if (value === '') {
+ config.unset(key);
+ continue;
+ }
+ if (field === 'pollMs') {
+ const numeric = Number(value);
+ if (!Number.isFinite(numeric) || numeric < 2 || numeric > 1000) {
+ res.status(ERR_BAD_REQUEST).send({ msg: `Invalid GPIO poll interval: ${value} (2-1000 ms)` });
+ return;
+ }
+ config.set(key, numeric);
+ continue;
+ }
+ config.set(key, value);
+ }
+ }
+
+ res.send({ ...getMcpStatus(), transport: transportSettings(), mqtt: mqttSettings(), gpio: gpioSettings() });
};
diff --git a/src/server/services/index.ts b/src/server/services/index.ts
index 7c692db344..9f6d784757 100644
--- a/src/server/services/index.ts
+++ b/src/server/services/index.ts
@@ -102,6 +102,7 @@ function registerApis(app) {
// MCP server (status is live; settings apply at next start)
app.get(urljoin(settings.route, 'api/mcp'), api.mcp.getStatus);
app.post(urljoin(settings.route, 'api/mcp'), api.mcp.updateSettings);
+ app.post(urljoin(settings.route, 'api/mcp/clear-alarm'), api.mcp.clearAlarm);
// State
// depecated?
diff --git a/src/server/services/mcp/README.md b/src/server/services/mcp/README.md
index e4c42f4fbb..f3248097eb 100644
--- a/src/server/services/mcp/README.md
+++ b/src/server/services/mcp/README.md
@@ -22,10 +22,35 @@ serves them, and `LUBAN_MCP_PORT` (env) overrides everything for one run.
| `mcpToolRegion` | Fractional box where the endmill images (fixed camera-to-spindle geometry); returned with every frame; settable via the `set_tool_region` tool. |
| `mcpInstalledModules` | e.g. `["snapmaker-2.0-bracing-kit-module"]` — feeds effective work ranges in `get_machine_profile`. |
| `mcpMqtt*` | Probe sensor feed (MQTT): `Host`, `Port` (default 8883 = TLS, 1883 = plain), `User`, `Pass`, `ClientId` (default = username + MAC bytes), `FeedToolsetter`, `FeedOvertravel`, `FeedProbe` (Adafruit IO feed key, or a full topic when it contains `/`), `Inverted` (comma-separated channels whose sensor idles HIGH and reads low on contact — the operator's CNC touch probe is normally open, so `"probe"`). Env `LUBAN_MCP_MQTT_HOST/PORT/USER/PASS/CLIENT_ID/FEED_TOOLSETTER/FEED_OVERTRAVEL/FEED_PROBE/INVERTED` override field-by-field. |
+| `mcpProbeTransport` | Which probe feed backend: `mqtt` or `gpio`. Unset = auto: `mqtt`, unless only the GPIO side is configured. Env `LUBAN_MCP_PROBE_TRANSPORT` overrides. |
+| `mcpGpio*` | Probe sensor feed (direct GPIO via Adafruit Blinka, default over U2IF — a Pi Pico as USB GPIO bridge): `PinToolsetter`, `PinOvertravel`, `PinProbe` (Blinka pin name with optional pull suffix, e.g. `GP6:up`, `GP7:down`, `GP8` = floating), `Inverted` (same semantics as the MQTT field — a pull-up NO switch idles `1`, so its channel goes here), `Python` (interpreter with `adafruit-blinka` installed, e.g. the venv's; default `python3`/`python`), `PollMs` (default 10, clamp 2–1000), `BlinkaEnv` (NAME=VALUE pairs handed to the monitor so Blinka picks the board — default `BLINKA_U2IF=1`; `BLINKA_MCP2221=1`, `BLINKA_FT232H=1`, `BLINKA_FORCEBOARD=…`, or `native` for on-board GPIO). Env `LUBAN_MCP_GPIO_PIN_TOOLSETTER/PIN_OVERTRAVEL/PIN_PROBE/INVERTED/PYTHON/POLL_MS/BLINKA_ENV` override field-by-field. All of it is editable on Settings → MCP Server, which also flags active env overrides. |
A project-scope `.mcp.json` at the repo root points Claude Code sessions at
`http://127.0.0.1:40889/mcp` automatically.
+## Installing
+
+- **Release/CI builds**: the fork publishes no releases — installers come from CI
+ artifacts. `Build on PR` (`build-on-pull-request.yml`) auto-runs only on pushes to
+ `main`/`release/*`; for a feature branch dispatch it manually (Actions → Build on PR →
+ Run workflow → pick branch + platforms, or `gh workflow run build-on-pull-request.yml
+ --ref -f platforms=all`) and download the platform installer from the run's
+ artifacts.
+- **Local dev**: Node 16 + python 3.11 for node-gyp (details under Development workflow),
+ `npm install`, then `npm run dev` (watch mode) or `npm run build` + `npm run
+ start-electron` (production build; see the build caveats below).
+- **Python venv — GPIO probe transport only**: `python3 -m venv .venv &&
+ .venv/bin/pip install -r src/server/services/mcp/requirements.txt` (Windows:
+ `.venv\Scripts\pip`), then point `LUBAN_MCP_GPIO_PYTHON` / `mcpGpioPython` at that
+ interpreter. Nothing else needs Python at runtime. On Linux, if the monitor dies with
+ an HID open/permission error, grant the user access to the Pico's hidraw device (udev
+ rule) and re-plug.
+- **ffmpeg — camera capture, Windows only**: the ffmpeg path is DirectShow (`-f dshow`),
+ so it only serves Windows; install any ffmpeg build and set `mcpFfmpegPath` if not on
+ PATH. On Linux/macOS use an HTTP snapshot endpoint via `mcpCameraUrl` (e.g. Android IP
+ Webcam) — it takes precedence over ffmpeg everywhere and is platform-independent.
+ (A v4l2 capture provider would be the Linux-native alternative; not built.)
+
## Architecture
Own `http.Server` bound strictly to loopback — NOT a route on Luban's Express app (whose
@@ -45,26 +70,62 @@ mcp/
tracking.ts zero-mean NCC template matching between cached frames
calibration.ts Y/Z-keyed pixel->mm calibration store (userDataDir, persists)
mqtt.ts minimal MQTT 3.1.1 client over net/tls (hand-rolled, no deps)
+ probeTransport.ts probe channel names + the ProbeTransport contract
probeFeed.ts external probe sensor feed: config resolution (env->config),
- last-reading cache per channel, overtravel tripwire latch
+ last-reading cache per channel, overtravel tripwire latch,
+ transport selection + the MQTT backend
+ gpioFeed.ts GPIO backend: Blinka/U2IF python monitor subprocess (embedded
+ source, JSON lines: ready|reading|hb|fatal), stall watchdog
toolSetter.ts tool height measurement: config, envelope planner, staged runner
tools/ status, machine, gcode, camera, calibration, probe, toolsetter
```
External probe sensors (tool height setter, overtravel switch, CNC touch probe) report
-over a message feed, not the controller. The transport is abstracted behind
-`ProbeFeedService`; the first implementation is MQTT (built for Adafruit IO:
-`{user}/feeds/{key}` topics, TLS on 8883, and a `/get` publish primes the last
-value on connect). The feed auto-connects at service start when fully configured, and
-verified against public brokers over both plain TCP and TLS.
+over a sensor feed, not the controller. The transport is abstracted behind
+`ProbeFeedService` (contract in `probeTransport.ts`; readings, polarity, the alarm latch
+and reconnect backoff all live in the service — backends only deliver raw values). Two
+backends exist, selected by `mcpProbeTransport` / `LUBAN_MCP_PROBE_TRANSPORT`:
+
+- **MQTT** (built for Adafruit IO: `{user}/feeds/{key}` topics, TLS on 8883, and a
+ `/get` publish primes the last value on connect); verified against public
+ brokers over both plain TCP and TLS. Change-reporting: silence means unchanged.
+- **GPIO** (2026-09-03): sensors wired to pins read through Adafruit Blinka, by default
+ via U2IF (a Pi Pico as a USB GPIO bridge, `BLINKA_U2IF=1`). Blinka is Python, so the
+ transport spawns a monitor subprocess (`python -c`, source embedded in `gpioFeed.ts`)
+ that polls the pins (default 10 ms) and streams JSON lines: `reading` on change plus a
+ 1 s heartbeat with all values. The heartbeat doubles as a liveness watchdog (silent
+ ≥5 s ⇒ kill + reconnect) and refreshes reading ages without log/broadcast spam; trip
+ decisions stay on change events, matching MQTT semantics. Pin config carries the pull
+ (`GP6:up`); polarity stays in the shared `inverted` mechanism. Latency is poll + USB
+ round-trip (single-digit ms vs MQTT's measured ~120–150 ms). Verified against a real
+ PICO_U2IF (board detect, pulls, heartbeats) and a stubbed Blinka (change detection,
+ fatal paths incl. unknown-pin reporting the board's available pins).
-**Overtravel tripwire**: any triggered reading on the overtravel channel immediately
+The feed auto-connects at service start when fully configured.
+
+**Overtravel tripwire**: while a sensor-gated procedure is running (the tool setter /
+probing runners declare expected contacts for their whole run) or MCP direct motion is in
+flight (`procedureArmed()`), a triggered reading on the overtravel channel immediately
stops the running job, force-closes the machine connection, latches an alarm that blocks
every motion tool (`assertNoOvertravel` in `assertSafeToMove`, `home`, `move_z`,
`start_gcode_job`), and reports to the operator. The latch clears only via
`clear_overtravel_alarm` on the operator's explicit word (refused while the feed still
-reads triggered) or an application restart. `disconnect_probe_feed` disarms the tripwire
-— never disconnect while a probing procedure could run.
+reads triggered) or an application restart. Outside that window — machine idle, operator
+pressing the switch by hand — it is logged and broadcast (`overtravel_unarmed`) but does
+NOT latch (operator decision 2026-09-04: "we only care about the overtravel alarm latching
+during a tool height test, not for general use"). `disconnect_probe_feed` disarms the
+tripwire — never disconnect while a probing procedure could run.
+
+**Sensor pills** (Workspace → Connection, beside the module badges): Probe / Tool Setter /
+Setter Overtravel, each yellow (unknown: feed not connected, no reading yet), green (idle)
+or red (in contact) — a visual bump-test aid. A red pill with an **ALARM** marker is the
+safety LATCH, not the sensor: it survives reconnects and clears only on the operator's
+word — the pill's **Clear alarm** button (confirm dialog → `POST /api/mcp/clear-alarm`,
+the operator's own click) or the `clear_overtravel_alarm` tool — or a restart; both paths
+refuse while the sensor still reads triggered. Hand bump tests with the machine idle no
+longer latch (see the tripwire arming rule above) — the pill just goes red while pressed.
+Seeded from `GET /api/mcp` (`probeFeed`), driven live by `mcp:activity` (`probe_feed`
+readings / connected / disconnected / alarms), reconciled by a 10 s poll.
UI integration: verbose console toggle (Workspace) mirrors heartbeat position changes,
every MCP-sent gcode line and controller reply (`[mcp:home] > G28` / `< X:-19.00 ...`),
@@ -96,8 +157,10 @@ it with no decision point, when the operator had authorised "step 1" only. Laws:
box below that height.
5. **Contact sensors are crash sensors** — a probe/toolsetter trigger during motion that no
procedure declared as expected trips a CRASH alarm (stop + force-close + latch), the
- same machinery as overtravel; `clear_overtravel_alarm` clears either kind on the
- operator's explicit word. Feed readings and all gcode traffic are logged server-side.
+ same machinery as overtravel; `clear_overtravel_alarm` (or the Workspace pill's Clear
+ alarm button) clears either kind on the operator's explicit word. Motion is "in
+ flight" inside `moveMachineSettled` (every procedure move, since 2026-09-04) and
+ `move_and_capture`. Feed readings and all gcode traffic are logged server-side.
6. The approved-code page re-shows the exact gcode next to the one-time code.
**Chat is not a motion gate — the staged job is** (operator, 2026-09-02): deliberate
traverses/descents go through staged jobs so authorization is the one-time code against
@@ -145,6 +208,8 @@ it with no decision point, when the operator had authorised "step 1" only. Laws:
- Controller has **G53 (machine workspace) and G54+ (numbered workspaces)**; the heartbeat
`pos` is in the *currently selected* workspace. Convention everywhere:
`machine = work − originOffset`.
+- **Bed = 320 × 340 × 330 mm (X/Y/Z), with extra travel on BOTH ends of every axis**
+ (operator, 2026-09-02) — matching the observed extremes below.
- **Machine home = (−19, 342, 328)**; **firmware X limit = 339** (sweep-verified 2026-09-01:
tracks requests exactly through 330, clamps a 340 request at 339 — the tool-change park X);
homing = `G53;G28;G54` exactly like Luban's button
@@ -209,7 +274,8 @@ from the calibration prediction — the depth-plane parallax signature) ·
surfaced on every capture) · `get_stored_state` (one-call orientation: calibrations,
landmarks, tool region, limits, camera config, connection, probe feed — call this first in
a fresh session) · `get_probe_feed_status` · `connect_probe_feed` / `disconnect_probe_feed`
-(MQTT sensor feed; connecting arms the overtravel tripwire) · `clear_overtravel_alarm`
+(sensor feed over MQTT or Blinka GPIO; connecting arms the overtravel tripwire) ·
+`clear_overtravel_alarm`
(operator's explicit word only) · `set_/get_tool_setter_config` (setter centre, trigger Z
with a reference bit, bit lengths — operator-stated) · `run_tool_setter` (tool height
measurement: ONE operator approval covers a server-driven envelope-bounded routine — XY to
@@ -277,12 +343,11 @@ Full agent guidance in `.claude/skills/tool-change/SKILL.md`.
Long term: a manual probe/inspection file driving a probing session, and a report
exportable to CAD/CAM (Fusion + free tools). The tool setter's staged
approach/release/confirm runner in `toolSetter.ts` is the motion template.
-- **Future probe transports (operator-stated 2026-08-31)**: MQTT may be replaced or joined
- by hardwired USB-GPIO, or an HTTP service (e.g. a Pi running Python with a USB camera
- plus GPIOs for the contact sensors). The transport contract is the `ProbeFeedService`
- surface (`connect/disconnect/getReading/waitForReading/assertNoOvertravel/status`) —
- consumers never see MQTT; a new backend implements that surface and feeds the same
- reading cache and overtravel latch. Sensor latency is transport-dependent: local GPIO
- would let `sensor_delay_ms` and the release timeouts collapse to near zero. The Pi's
- camera half already fits `mcpCameraUrl` (any HTTP snapshot endpoint), so one box could
- serve both.
+- **Probe transports**: the USB-GPIO transport landed 2026-09-03 (Blinka/U2IF backend in
+ `gpioFeed.ts`, behind the `ProbeTransport` contract — consumers still only see the
+ `ProbeFeedService` surface). Remaining ideas from the 2026-08-31 operator note: an HTTP
+ service (e.g. a Pi running Python with a USB camera plus GPIOs — its camera half
+ already fits `mcpCameraUrl`), and UART sensors through the same U2IF Pico. With local
+ GPIO the `sensor_delay_ms` contact windows and release timeouts can collapse to near
+ zero — the defaults are still MQTT-sized, so tighten them per-call when running on
+ gpio.
diff --git a/src/server/services/mcp/gpioFeed.ts b/src/server/services/mcp/gpioFeed.ts
new file mode 100644
index 0000000000..9a45422b27
--- /dev/null
+++ b/src/server/services/mcp/gpioFeed.ts
@@ -0,0 +1,508 @@
+import { ChildProcess, spawn } from 'child_process';
+import { EventEmitter } from 'events';
+
+import logger from '../../lib/logger';
+import config from '../configstore';
+import { PROBE_CHANNELS, ProbeChannel, ProbeTransport } from './probeTransport';
+
+const log = logger('service:mcp:gpio-feed');
+
+// Direct-GPIO probe feed transport: the contact sensors are wired to pins
+// read through Adafruit Blinka (the CircuitPython-on-CPython compatibility
+// layer), by default via U2IF - a Raspberry Pi Pico acting as a USB GPIO
+// bridge (BLINKA_U2IF=1). Blinka is Python, so this transport spawns a small
+// monitor subprocess (embedded below, passed via `python -c`) that polls the
+// pins and streams JSON lines on stdout; the Node side turns those into the
+// same reading events the MQTT transport produces. Latency is the poll
+// interval plus a USB round trip - single-digit milliseconds against MQTT's
+// hardware-measured ~120-150 ms cloud trip.
+//
+// The monitor emits a 'reading' line only when a pin CHANGES, plus a 1 Hz
+// heartbeat carrying every pin's current value. The heartbeat is both the
+// liveness watchdog (a silent monitor is killed and the service reconnects)
+// and a freshness refresh for the reading cache - unlike MQTT, "no message"
+// here never has to be trusted to mean "unchanged".
+
+interface FieldSpec {
+ env: string;
+ key: string;
+}
+
+const FIELDS: { [name: string]: FieldSpec } = {
+ python: { env: 'LUBAN_MCP_GPIO_PYTHON', key: 'mcpGpioPython' },
+ // Pin per channel: a Blinka board pin name with an optional pull suffix,
+ // e.g. "GP6:up", "GP7:down", "GP8" or "GP8:float" (floating is default).
+ toolsetter: { env: 'LUBAN_MCP_GPIO_PIN_TOOLSETTER', key: 'mcpGpioPinToolsetter' },
+ overtravel: { env: 'LUBAN_MCP_GPIO_PIN_OVERTRAVEL', key: 'mcpGpioPinOvertravel' },
+ probe: { env: 'LUBAN_MCP_GPIO_PIN_PROBE', key: 'mcpGpioPinProbe' },
+ // Comma-separated channel names whose sensors idle HIGH and read low on
+ // contact - same semantics as the MQTT `inverted` field.
+ inverted: { env: 'LUBAN_MCP_GPIO_INVERTED', key: 'mcpGpioInverted' },
+ pollMs: { env: 'LUBAN_MCP_GPIO_POLL_MS', key: 'mcpGpioPollMs' },
+ // Environment handed to the monitor so Blinka picks the right board:
+ // "BLINKA_U2IF=1" (default; Pico/KB2040 U2IF bridge), "BLINKA_MCP2221=1",
+ // "BLINKA_FT232H=1", "BLINKA_FORCEBOARD=..." etc. - space/comma separated
+ // NAME=VALUE pairs - or "native" for Blinka's own detection (Pi header).
+ blinkaEnv: { env: 'LUBAN_MCP_GPIO_BLINKA_ENV', key: 'mcpGpioBlinkaEnv' },
+};
+
+export const DEFAULT_BLINKA_ENV = 'BLINKA_U2IF=1';
+
+/**
+ * Parse the Blinka environment field. Returns the env map, or a string
+ * describing what is wrong with the text. "native"/"none" -> empty map.
+ */
+export function parseBlinkaEnv(text: string): { [name: string]: string } | string {
+ const trimmed = text.trim();
+ if (!trimmed || ['native', 'none', 'off'].includes(trimmed.toLowerCase())) {
+ return {};
+ }
+ const env: { [name: string]: string } = {};
+ for (const token of trimmed.split(/[\s,]+/).filter(Boolean)) {
+ const match = token.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
+ if (!match) {
+ return `Blinka environment entry "${token}" is not NAME=VALUE`;
+ }
+ env[match[1]] = match[2];
+ }
+ return env;
+}
+
+function resolveField(name: string): { value: string; source: 'env' | 'config' | null } {
+ const spec = FIELDS[name];
+ const envRaw = process.env[spec.env];
+ if (envRaw !== undefined && String(envRaw).trim() !== '') {
+ return { value: String(envRaw).trim(), source: 'env' };
+ }
+ const configRaw = config.get(spec.key);
+ if (configRaw !== undefined && configRaw !== null && String(configRaw).trim() !== '') {
+ return { value: String(configRaw).trim(), source: 'config' };
+ }
+ return { value: '', source: null };
+}
+
+export type GpioPull = 'up' | 'down' | 'float';
+
+export interface GpioPinSpec {
+ pin: string;
+ pull: GpioPull;
+}
+
+export interface GpioFeedConfig {
+ configured: boolean;
+ missing: string[];
+ python: string;
+ /** As configured (or the default) - shown in status. */
+ blinkaEnvText: string;
+ /** Parsed NAME=VALUE pairs merged into the monitor's environment. */
+ blinkaEnv: { [name: string]: string };
+ pollMs: number;
+ pins: { [channel in ProbeChannel]: GpioPinSpec | null };
+ inverted: { [channel in ProbeChannel]: boolean };
+ sources: { [field: string]: 'env' | 'config' | null };
+}
+
+const DEFAULT_POLL_MS = 10;
+const HEARTBEAT_MS = 1000;
+
+/** Human label for a pin binding, shown in status: "GP6 (pull-up)". */
+export function describePin(spec: GpioPinSpec | null): string | null {
+ if (!spec) {
+ return null;
+ }
+ return spec.pull === 'float' ? spec.pin : `${spec.pin} (pull-${spec.pull})`;
+}
+
+/**
+ * Resolve the GPIO feed configuration, environment first then configstore,
+ * mirroring resolveProbeFeedConfig. Read fresh on every connect attempt.
+ */
+export function resolveGpioFeedConfig(): GpioFeedConfig {
+ const sources: { [field: string]: 'env' | 'config' | null } = {};
+ const raw: { [field: string]: string } = {};
+ for (const name of Object.keys(FIELDS)) {
+ const field = resolveField(name);
+ raw[name] = field.value;
+ sources[name] = field.source;
+ }
+
+ const missing: string[] = [];
+ const pins = {} as { [channel in ProbeChannel]: GpioPinSpec | null };
+ for (const channel of PROBE_CHANNELS) {
+ const text = raw[channel];
+ if (!text) {
+ pins[channel] = null;
+ continue;
+ }
+ const [pin, ...rest] = text.split(':').map((part) => part.trim());
+ const pull = (rest.join(':') || 'float').toLowerCase();
+ if (!pin || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(pin)) {
+ missing.push(`${channel} pin "${text}" is not a Blinka pin name`);
+ pins[channel] = null;
+ } else if (pull !== 'up' && pull !== 'down' && pull !== 'float') {
+ missing.push(`${channel} pin "${text}" pull must be up, down or float`);
+ pins[channel] = null;
+ } else {
+ pins[channel] = { pin, pull: pull as GpioPull };
+ }
+ }
+ if (!PROBE_CHANNELS.some((channel) => pins[channel])) {
+ missing.push('at least one pin (LUBAN_MCP_GPIO_PIN_TOOLSETTER / _OVERTRAVEL / _PROBE)');
+ }
+
+ const invertedNames = raw.inverted.split(',').map((name) => name.trim().toLowerCase()).filter(Boolean);
+ const inverted = {} as { [channel in ProbeChannel]: boolean };
+ for (const channel of PROBE_CHANNELS) {
+ inverted[channel] = invertedNames.includes(channel);
+ }
+
+ const pollMs = Math.min(Math.max(Number(raw.pollMs) || DEFAULT_POLL_MS, 2), 1000);
+ const blinkaEnvText = raw.blinkaEnv || DEFAULT_BLINKA_ENV;
+ const parsedEnv = parseBlinkaEnv(blinkaEnvText);
+ let blinkaEnv: { [name: string]: string } = {};
+ if (typeof parsedEnv === 'string') {
+ missing.push(parsedEnv);
+ } else {
+ blinkaEnv = parsedEnv;
+ }
+
+ return {
+ configured: missing.length === 0,
+ missing,
+ python: raw.python || (process.platform === 'win32' ? 'python' : 'python3'),
+ blinkaEnvText,
+ blinkaEnv,
+ pollMs,
+ pins,
+ inverted,
+ sources,
+ };
+}
+
+// The monitor subprocess. Plain Python 3, stdlib + Blinka only, config as a
+// JSON argv - nothing here may contain a backtick or "${" (it lives in a TS
+// template literal). Protocol: one JSON object per stdout line, "t" field is
+// ready | reading | hb | fatal.
+const MONITOR_SOURCE = `
+import json
+import sys
+import time
+
+def emit(obj):
+ print(json.dumps(obj), flush=True)
+
+def main():
+ cfg = json.loads(sys.argv[1])
+ try:
+ import board
+ import digitalio
+ except Exception as err:
+ emit({'t': 'fatal', 'error': 'Blinka import failed (pip install adafruit-blinka): %s' % err})
+ return 1
+ board_id = getattr(board, 'board_id', 'unknown')
+ lines = {}
+ for channel, spec in cfg['pins'].items():
+ name = spec['pin']
+ if not hasattr(board, name):
+ available = [n for n in dir(board) if not n.startswith('_')]
+ emit({'t': 'fatal', 'error': 'board %s has no pin %s' % (board_id, name), 'available': available})
+ return 1
+ try:
+ line = digitalio.DigitalInOut(getattr(board, name))
+ line.direction = digitalio.Direction.INPUT
+ pull = spec.get('pull', 'float')
+ if pull == 'up':
+ line.pull = digitalio.Pull.UP
+ elif pull == 'down':
+ line.pull = digitalio.Pull.DOWN
+ except Exception as err:
+ emit({'t': 'fatal', 'error': 'configuring %s (%s) failed: %s' % (name, channel, err)})
+ return 1
+ lines[channel] = line
+ emit({'t': 'ready', 'board': board_id})
+ poll_s = cfg['poll_ms'] / 1000.0
+ hb_s = cfg['heartbeat_ms'] / 1000.0
+ last = {}
+ next_hb = 0.0
+ while True:
+ try:
+ values = {}
+ for channel, line in lines.items():
+ value = '1' if line.value else '0'
+ values[channel] = value
+ if last.get(channel) != value:
+ last[channel] = value
+ emit({'t': 'reading', 'channel': channel, 'value': value})
+ now = time.monotonic()
+ if now >= next_hb:
+ next_hb = now + hb_s
+ emit({'t': 'hb', 'values': values})
+ time.sleep(poll_s)
+ except Exception as err:
+ emit({'t': 'fatal', 'error': 'read loop failed: %s' % err})
+ return 1
+
+sys.exit(main())
+`;
+
+const READY_TIMEOUT_MS = 30000; // first Blinka import + U2IF enumeration can be slow
+const STALL_MS = HEARTBEAT_MS * 5;
+const STDERR_TAIL_CHARS = 2000;
+
+/**
+ * ProbeTransport backend over the Blinka monitor subprocess. Events per the
+ * ProbeTransport contract; a dead monitor (exit, stall, spawn failure) emits
+ * 'close' and the ProbeFeedService's backoff builds a fresh instance.
+ */
+export class GpioProbeTransport extends EventEmitter implements ProbeTransport {
+ private cfg: GpioFeedConfig;
+
+ private child: ChildProcess | null = null;
+
+ private lineBuffer = '';
+
+ private stderrTail = '';
+
+ private lastFatal: string | null = null;
+
+ private boardId: string | null = null;
+
+ private stallTimer: NodeJS.Timeout | null = null;
+
+ private ended = false;
+
+ private ready = false;
+
+ public constructor(cfg: GpioFeedConfig) {
+ super();
+ this.cfg = cfg;
+ }
+
+ public async connect(): Promise {
+ return new Promise((resolve, reject) => {
+ let settled = false;
+ const settle = (err: Error | null) => {
+ if (settled) {
+ return;
+ }
+ settled = true;
+ if (err) {
+ reject(err);
+ } else {
+ resolve();
+ }
+ };
+ // Whatever kills the transport before the ready line (stall
+ // watchdog, ready timeout, spawn failure) must also settle the
+ // connect promise - 'close' is the common exit of every path.
+ this.once('close', () => {
+ settle(new Error(`GPIO monitor closed before ready${this.detailSuffix()}`));
+ });
+
+ const pins: { [channel: string]: GpioPinSpec } = {};
+ for (const channel of PROBE_CHANNELS) {
+ const spec = this.cfg.pins[channel];
+ if (spec) {
+ pins[channel] = spec;
+ }
+ }
+ const monitorConfig = JSON.stringify({
+ pins,
+ poll_ms: this.cfg.pollMs,
+ heartbeat_ms: HEARTBEAT_MS,
+ });
+
+ let child: ChildProcess;
+ try {
+ child = spawn(this.cfg.python, ['-u', '-c', MONITOR_SOURCE, monitorConfig], {
+ env: { ...process.env, ...this.cfg.blinkaEnv },
+ stdio: ['ignore', 'pipe', 'pipe'],
+ windowsHide: true,
+ });
+ } catch (err) {
+ settle(new Error(`Failed to spawn "${this.cfg.python}": ${err.message}`));
+ return;
+ }
+ this.child = child;
+
+ const readyTimer = setTimeout(() => {
+ const err = new Error(`GPIO monitor produced no ready line within ${READY_TIMEOUT_MS} ms${this.detailSuffix()}`);
+ settle(err);
+ this.fail(err);
+ }, READY_TIMEOUT_MS);
+
+ child.on('error', (err: Error) => {
+ // Typically ENOENT: the python executable does not exist.
+ clearTimeout(readyTimer);
+ const wrapped = new Error(`GPIO monitor spawn failed ("${this.cfg.python}"): ${err.message}`);
+ settle(wrapped);
+ this.fail(wrapped);
+ });
+
+ if (child.stderr) {
+ child.stderr.on('data', (chunk: Buffer) => {
+ this.stderrTail = (this.stderrTail + chunk.toString('utf8')).slice(-STDERR_TAIL_CHARS);
+ });
+ }
+
+ if (child.stdout) {
+ child.stdout.on('data', (chunk: Buffer) => {
+ this.bumpWatchdog();
+ this.lineBuffer += chunk.toString('utf8');
+ for (;;) {
+ const newline = this.lineBuffer.indexOf('\n');
+ if (newline < 0) {
+ return;
+ }
+ const line = this.lineBuffer.slice(0, newline).trim();
+ this.lineBuffer = this.lineBuffer.slice(newline + 1);
+ if (!line) {
+ continue;
+ }
+ let message: { t?: string; [key: string]: unknown };
+ try {
+ message = JSON.parse(line);
+ } catch (err) {
+ log.warn(`GPIO monitor emitted a non-JSON line: ${line}`);
+ continue;
+ }
+ this.onMonitorMessage(message, () => {
+ clearTimeout(readyTimer);
+ settle(null);
+ });
+ }
+ });
+ }
+
+ child.on('exit', (code: number | null, signal: string | null) => {
+ clearTimeout(readyTimer);
+ if (this.stallTimer) {
+ clearTimeout(this.stallTimer);
+ this.stallTimer = null;
+ }
+ this.ready = false;
+ if (this.child === child) {
+ this.child = null;
+ }
+ const err = new Error(`GPIO monitor exited (${signal || `code ${code}`})${this.detailSuffix()}`);
+ if (!settled) {
+ settle(err);
+ }
+ if (!this.ended) {
+ this.ended = true;
+ if (code !== 0) {
+ this.emit('error', err);
+ }
+ this.emit('close');
+ }
+ });
+ });
+ }
+
+ public end(): void {
+ this.ended = true;
+ this.killChild();
+ }
+
+ public isConnected(): boolean {
+ return this.ready && !!this.child && !this.ended;
+ }
+
+ public describe(): object {
+ return {
+ python: this.cfg.python,
+ blinkaEnv: this.cfg.blinkaEnvText,
+ pollMs: this.cfg.pollMs,
+ board: this.boardId,
+ monitorPid: this.child ? this.child.pid : null,
+ configSources: this.cfg.sources,
+ };
+ }
+
+ private onMonitorMessage(message: { t?: string; [key: string]: unknown }, onReady: () => void): void {
+ if (message.t === 'ready') {
+ this.ready = true;
+ this.boardId = String(message.board || 'unknown');
+ const bound = PROBE_CHANNELS
+ .filter((channel) => this.cfg.pins[channel])
+ .map((channel) => `${channel}=${describePin(this.cfg.pins[channel])}`);
+ log.info(`GPIO monitor ready on board ${this.boardId} (pid ${this.child ? this.child.pid : '?'}, `
+ + `python "${this.cfg.python}", env "${this.cfg.blinkaEnvText}"): ${bound.join(', ')}`);
+ onReady();
+ return;
+ }
+ if (message.t === 'reading') {
+ const channel = String(message.channel) as ProbeChannel;
+ if (PROBE_CHANNELS.includes(channel)) {
+ this.emit('reading', channel, String(message.value));
+ }
+ return;
+ }
+ if (message.t === 'hb') {
+ const values = (message.values || {}) as { [channel: string]: string };
+ for (const channel of PROBE_CHANNELS) {
+ if (values[channel] !== undefined) {
+ this.emit('refresh', channel, String(values[channel]));
+ }
+ }
+ return;
+ }
+ if (message.t === 'fatal') {
+ this.lastFatal = String(message.error || 'unknown fatal error');
+ if (message.available) {
+ this.lastFatal += ` - available pins: ${(message.available as string[]).join(', ')}`;
+ }
+ log.error(`GPIO monitor fatal: ${this.lastFatal}`);
+ // The monitor exits right after a fatal line; the exit handler
+ // carries this detail into the error/close events.
+ }
+ }
+
+ /** Any stdout traffic proves liveness; silence past STALL_MS is death. */
+ private bumpWatchdog(): void {
+ if (this.stallTimer) {
+ clearTimeout(this.stallTimer);
+ }
+ if (this.ended) {
+ return;
+ }
+ this.stallTimer = setTimeout(() => {
+ this.fail(new Error(`GPIO monitor went silent for ${STALL_MS} ms (expected a ${HEARTBEAT_MS} ms `
+ + 'heartbeat) - killing it'));
+ }, STALL_MS);
+ }
+
+ /** Kill the monitor and report the transport dead so the service reconnects. */
+ private fail(err: Error): void {
+ if (this.ended) {
+ return;
+ }
+ this.ended = true;
+ this.ready = false;
+ log.error(`GPIO probe transport failed: ${err.message}`);
+ this.emit('error', err);
+ this.killChild();
+ this.emit('close');
+ }
+
+ private killChild(): void {
+ if (this.stallTimer) {
+ clearTimeout(this.stallTimer);
+ this.stallTimer = null;
+ }
+ if (this.child) {
+ const child = this.child;
+ this.child = null;
+ try {
+ child.kill();
+ } catch (err) {
+ // Already dead; nothing to do.
+ }
+ }
+ }
+
+ private detailSuffix(): string {
+ const detail = this.lastFatal || this.stderrTail.trim().split('\n').slice(-3).join(' | ');
+ return detail ? `: ${detail}` : '';
+ }
+}
diff --git a/src/server/services/mcp/gpio_bumptest.py b/src/server/services/mcp/gpio_bumptest.py
new file mode 100644
index 0000000000..13445de01b
--- /dev/null
+++ b/src/server/services/mcp/gpio_bumptest.py
@@ -0,0 +1,154 @@
+#!/usr/bin/env python3
+"""Bump-test the probe feed GPIO wiring the way the MCP server will see it.
+
+Reads the SAME configuration the server uses (~/.snapmaker-luban.json keys
+mcpGpioPin*/mcpGpioInverted/mcpGpioU2if, overridden by LUBAN_MCP_GPIO_*
+environment variables), opens the pins through Blinka with the configured
+pulls, and prints every change as raw value -> interpreted state (idle or
+TRIGGERED, polarity applied exactly like probeFeed.ts isTriggeredValue).
+Trigger each sensor by hand while it runs; the summary says whether each
+channel was seen idle AND triggered, and flags a channel whose resting state
+reads TRIGGERED (polarity almost certainly wrong).
+
+ ~/dev/Luban/.venv/bin/python src/server/services/mcp/gpio_bumptest.py --seconds 90
+
+Run under the interpreter named by mcpGpioPython (the venv from
+requirements.txt). Stdlib + Blinka only.
+"""
+import argparse
+import json
+import os
+import sys
+import time
+
+CHANNELS = ('toolsetter', 'overtravel', 'probe')
+CONFIG_KEYS = {
+ 'toolsetter': ('LUBAN_MCP_GPIO_PIN_TOOLSETTER', 'mcpGpioPinToolsetter'),
+ 'overtravel': ('LUBAN_MCP_GPIO_PIN_OVERTRAVEL', 'mcpGpioPinOvertravel'),
+ 'probe': ('LUBAN_MCP_GPIO_PIN_PROBE', 'mcpGpioPinProbe'),
+ 'inverted': ('LUBAN_MCP_GPIO_INVERTED', 'mcpGpioInverted'),
+ 'blinkaEnv': ('LUBAN_MCP_GPIO_BLINKA_ENV', 'mcpGpioBlinkaEnv'),
+}
+DEFAULT_BLINKA_ENV = 'BLINKA_U2IF=1'
+
+
+def load_settings(config_path):
+ stored = {}
+ if os.path.exists(config_path):
+ with open(config_path, encoding='utf-8') as handle:
+ stored = json.load(handle)
+ settings = {}
+ for name, (env, key) in CONFIG_KEYS.items():
+ value = os.environ.get(env, '').strip() or str(stored.get(key, '') or '').strip()
+ settings[name] = value
+ return settings
+
+
+def parse_pin(spec):
+ """'GP6:up' -> ('GP6', 'up'); bare name -> floating."""
+ parts = [part.strip() for part in spec.split(':')]
+ name = parts[0]
+ pull = (parts[1] if len(parts) > 1 and parts[1] else 'float').lower()
+ if pull not in ('up', 'down', 'float'):
+ raise SystemExit('pin %r: pull must be up, down or float' % spec)
+ return name, pull
+
+
+def is_triggered(raw, inverted):
+ # Mirrors isTriggeredValue: numeric > 0 is "on", inverted flips it.
+ return (not raw) if inverted else raw
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__.split('\n')[0])
+ parser.add_argument('--seconds', type=float, default=0,
+ help='stop after this long (default: run until Ctrl-C)')
+ parser.add_argument('--poll-ms', type=float, default=10)
+ parser.add_argument('--config', default=os.path.expanduser('~/.snapmaker-luban.json'))
+ args = parser.parse_args()
+
+ settings = load_settings(args.config)
+ # Same Blinka environment the server hands its monitor (gpioFeed.ts):
+ # NAME=VALUE pairs, or "native" for Blinka's own board detection.
+ blinka_env = settings['blinkaEnv'] or DEFAULT_BLINKA_ENV
+ if blinka_env.strip().lower() not in ('native', 'none', 'off'):
+ for token in blinka_env.replace(',', ' ').split():
+ if '=' not in token:
+ raise SystemExit('Blinka environment entry %r is not NAME=VALUE' % token)
+ name, value = token.split('=', 1)
+ os.environ[name] = value
+
+ pins = {}
+ for channel in CHANNELS:
+ if settings[channel]:
+ pins[channel] = parse_pin(settings[channel])
+ if not pins:
+ raise SystemExit('No pins configured (mcpGpioPin* in %s or LUBAN_MCP_GPIO_PIN_*).' % args.config)
+ inverted = {name.strip().lower() for name in settings['inverted'].split(',') if name.strip()}
+
+ import board # noqa: E402 (after the Blinka environment is set)
+ import digitalio # noqa: E402
+
+ board_id = getattr(board, 'board_id', 'unknown')
+ print('board: %s config: %s' % (board_id, args.config))
+ lines = {}
+ for channel, (name, pull) in pins.items():
+ if not hasattr(board, name):
+ available = ', '.join(n for n in dir(board) if not n.startswith('_') and n[:1].isupper())
+ raise SystemExit('board %s has no pin %s. Available: %s' % (board_id, name, available))
+ line = digitalio.DigitalInOut(getattr(board, name))
+ line.direction = digitalio.Direction.INPUT
+ if pull == 'up':
+ line.pull = digitalio.Pull.UP
+ elif pull == 'down':
+ line.pull = digitalio.Pull.DOWN
+ lines[channel] = line
+ print(' %-10s %-4s pull-%-5s %s' % (channel, name, pull,
+ 'INVERTED (contact reads 0)' if channel in inverted else 'direct (contact reads 1)'))
+ print('Trigger each sensor by hand. Ctrl-C to stop.\n')
+
+ started = time.monotonic()
+ last = {}
+ seen = {channel: set() for channel in lines}
+ resting = {}
+ try:
+ while True:
+ for channel, line in lines.items():
+ raw = bool(line.value)
+ if last.get(channel) == raw:
+ continue
+ last[channel] = raw
+ triggered = is_triggered(raw, channel in inverted)
+ seen[channel].add(triggered)
+ if channel not in resting:
+ resting[channel] = triggered
+ stamp = '%7.2fs' % (time.monotonic() - started)
+ print('%s %-10s %-4s raw=%d -> %s' % (stamp, channel, pins[channel][0], int(raw),
+ 'TRIGGERED' if triggered else 'idle'))
+ if args.seconds and time.monotonic() - started >= args.seconds:
+ break
+ time.sleep(args.poll_ms / 1000.0)
+ except KeyboardInterrupt:
+ pass
+ finally:
+ for line in lines.values():
+ line.deinit()
+
+ print('\nSummary:')
+ exit_code = 0
+ for channel in lines:
+ states = seen[channel]
+ if resting.get(channel):
+ verdict = 'RESTING STATE READS TRIGGERED - polarity is almost certainly wrong (toggle it in mcpGpioInverted)'
+ exit_code = 1
+ elif states == {False, True}:
+ verdict = 'OK - seen idle and TRIGGERED'
+ else:
+ verdict = 'never triggered - only idle seen (not exercised, or wiring/pin wrong)'
+ exit_code = 1
+ print(' %-10s %s' % (channel, verdict))
+ return exit_code
+
+
+if __name__ == '__main__':
+ sys.exit(main())
diff --git a/src/server/services/mcp/index.ts b/src/server/services/mcp/index.ts
index 0de0d03bbf..47eca1c78d 100644
--- a/src/server/services/mcp/index.ts
+++ b/src/server/services/mcp/index.ts
@@ -5,7 +5,7 @@ import logger from '../../lib/logger';
import config from '../configstore';
import { McpServer, isAllowedOrigin, isLoopback } from './McpServer';
import { jobManager } from './jobs';
-import { probeFeedService, resolveProbeFeedConfig } from './probeFeed';
+import { probeFeedService, resolveActiveProbeConfig } from './probeFeed';
import { ToolRegistry } from './registry';
import { registerCalibrationTools } from './tools/calibration';
import { registerCameraTools } from './tools/camera';
@@ -86,6 +86,9 @@ export function getMcpStatus() {
port: runningPort,
toolCount: registeredToolCount,
settings,
+ // Sensor feed snapshot for the Workspace connection pills; live
+ // updates arrive over mcp:activity (tool 'probe_feed').
+ probeFeed: probeFeedService.status(),
};
}
@@ -157,7 +160,7 @@ export function startMcpService(socketServer?: McpBroadcaster): void {
// Arm the external probe feed (and its overtravel tripwire) without any
// agent involvement when it is fully configured. Failure is logged and
// retried by the feed's own backoff; it must never break startup.
- if (resolveProbeFeedConfig().configured) {
+ if (resolveActiveProbeConfig().configured) {
probeFeedService.connect().catch((err: Error) => {
log.error(`Probe feed auto-connect failed: ${err.message}`);
});
diff --git a/src/server/services/mcp/probeFeed.ts b/src/server/services/mcp/probeFeed.ts
index 7415208d6a..7beb782742 100644
--- a/src/server/services/mcp/probeFeed.ts
+++ b/src/server/services/mcp/probeFeed.ts
@@ -1,31 +1,41 @@
+import { EventEmitter } from 'events';
import os from 'os';
import logger from '../../lib/logger';
import config from '../configstore';
import { connectionManager } from '../machine/ConnectionManager';
+import { GpioProbeTransport, describePin, resolveGpioFeedConfig } from './gpioFeed';
import { mcpBroadcast } from './index';
import { MqttClient } from './mqtt';
+import { PROBE_CHANNELS, ProbeChannel, ProbeTransport, ProbeTransportKind } from './probeTransport';
import { McpToolError } from './registry';
const log = logger('service:mcp:probe-feed');
// External probe sensors (tool height setter, CNC touch probe) report over a
-// message feed rather than the machine controller - the controller has no
-// input for them. The transport is abstracted behind ProbeFeedService; the
-// first implementation is MQTT (Adafruit IO), configured from environment
-// variables with the server configstore as the fallback, editable on the
-// Settings -> MCP Server pane.
+// sensor feed rather than the machine controller - the controller has no
+// input for them. The transport is abstracted behind ProbeFeedService
+// (see probeTransport.ts for the contract); the backends are MQTT
+// (Adafruit IO, below in this file) and Blinka GPIO (gpioFeed.ts, direct
+// pins over U2IF/native). Each is configured from environment variables
+// with the server configstore as the fallback, editable on the Settings ->
+// MCP Server pane; LUBAN_MCP_PROBE_TRANSPORT / mcpProbeTransport picks the
+// backend (default mqtt, or gpio when only gpio is configured).
//
// The overtravel channel is a TRIPWIRE: the sensor only reports overtravel
-// when the physical mechanism has been pushed past its safe range, so any
-// triggered reading immediately stops the running job, force-closes the
-// machine connection, latches an alarm that blocks every motion tool, and
-// reports to the operator. The latch clears only on the operator's explicit
-// word (clear_overtravel_alarm) or an application restart.
-
-export type ProbeChannel = 'toolsetter' | 'overtravel' | 'probe';
-
-export const PROBE_CHANNELS: ProbeChannel[] = ['toolsetter', 'overtravel', 'probe'];
+// when the physical mechanism has been pushed past its safe range. While a
+// sensor-gated procedure is running (or MCP direct motion is in flight - see
+// procedureArmed), a triggered reading immediately stops the running job,
+// force-closes the machine connection, latches an alarm that blocks every
+// motion tool, and reports to the operator. The latch clears only on the
+// operator's explicit word (clear_overtravel_alarm) or an application
+// restart. Outside that window (machine idle, operator bump-testing the
+// switch by hand) it is reported but does not latch (operator, 2026-09-04).
+
+// Channel names moved to probeTransport.ts (shared with the transport
+// backends); re-exported here so existing consumers keep their import path.
+export { PROBE_CHANNELS };
+export type { ProbeChannel };
interface FieldSpec {
env: string;
@@ -145,6 +155,93 @@ export function resolveProbeFeedConfig(): ProbeFeedConfig {
};
}
+/**
+ * Which transport backend the probe feed uses. Explicit setting wins
+ * (LUBAN_MCP_PROBE_TRANSPORT env, then mcpProbeTransport config); with
+ * nothing stated the default is mqtt, unless only the GPIO side is
+ * configured - so the Blinka box needs nothing beyond its pin variables.
+ */
+export function resolveProbeTransportKind(): ProbeTransportKind {
+ const raw = String(process.env.LUBAN_MCP_PROBE_TRANSPORT || config.get('mcpProbeTransport') || '')
+ .trim().toLowerCase();
+ if (raw === 'mqtt' || raw === 'gpio') {
+ return raw;
+ }
+ if (raw) {
+ log.warn(`Unknown probe transport "${raw}" (expected mqtt or gpio) - falling back to auto-detect`);
+ }
+ if (!resolveProbeFeedConfig().configured && resolveGpioFeedConfig().configured) {
+ return 'gpio';
+ }
+ return 'mqtt';
+}
+
+/** The transport-agnostic view of the active backend's configuration. */
+export interface ActiveProbeConfig {
+ kind: ProbeTransportKind;
+ configured: boolean;
+ missing: string[];
+ /** Per channel: the MQTT topic or GPIO pin label bound to it, if any. */
+ channels: { [channel in ProbeChannel]: string | null };
+ inverted: { [channel in ProbeChannel]: boolean };
+ /** Where the operator fixes a missing configuration, for error messages. */
+ settingsHint: string;
+}
+
+export function resolveActiveProbeConfig(): ActiveProbeConfig {
+ const kind = resolveProbeTransportKind();
+ if (kind === 'gpio') {
+ const cfg = resolveGpioFeedConfig();
+ const channels = {} as { [channel in ProbeChannel]: string | null };
+ for (const channel of PROBE_CHANNELS) {
+ channels[channel] = describePin(cfg.pins[channel]);
+ }
+ return {
+ kind,
+ configured: cfg.configured,
+ missing: cfg.missing,
+ channels,
+ inverted: cfg.inverted,
+ settingsHint: 'Set LUBAN_MCP_GPIO_PIN_TOOLSETTER/_OVERTRAVEL/_PROBE (Blinka pin name with an '
+ + 'optional :up/:down/:float pull suffix, e.g. "GP6:up"), plus LUBAN_MCP_GPIO_PYTHON, '
+ + '_INVERTED, _POLL_MS, _BLINKA_ENV as needed - or the matching mcpGpio* config keys '
+ + '(Settings -> MCP Server).',
+ };
+ }
+ const cfg = resolveProbeFeedConfig();
+ return {
+ kind,
+ configured: cfg.configured,
+ missing: cfg.missing,
+ channels: cfg.topics,
+ inverted: cfg.inverted,
+ settingsHint: 'Set the MQTT fields on Settings -> MCP Server or the LUBAN_MCP_MQTT_* '
+ + 'environment variables.',
+ };
+}
+
+/** Static (not-yet-connected) transport detail for status reports. */
+function describeTransportConfig(kind: ProbeTransportKind): object {
+ if (kind === 'gpio') {
+ const cfg = resolveGpioFeedConfig();
+ return {
+ python: cfg.python,
+ blinkaEnv: cfg.blinkaEnvText,
+ pollMs: cfg.pollMs,
+ configSources: cfg.sources,
+ };
+ }
+ const cfg = resolveProbeFeedConfig();
+ return {
+ host: cfg.host || null,
+ port: cfg.port,
+ tls: cfg.tls,
+ username: cfg.username || null,
+ clientId: cfg.clientId,
+ configSources: cfg.sources,
+ };
+}
+
/**
* Sensor payloads that count as "in contact" / "tripped". `inverted` flips
* the polarity for normally-open circuits that idle HIGH and read low on
@@ -167,7 +264,8 @@ export interface ProbeReading {
value: string;
triggered: boolean;
receivedAt: number;
- topic: string;
+ /** MQTT topic or GPIO pin label the value arrived on. */
+ source: string;
}
interface SafetyTrip {
@@ -181,10 +279,128 @@ interface SafetyTrip {
const RECONNECT_BASE_MS = 5000;
const RECONNECT_MAX_MS = 60000;
-export class ProbeFeedService {
+/**
+ * ProbeTransport backend over the hand-rolled MqttClient: subscribes the
+ * configured channel topics, primes Adafruit IO's last-value replay, and
+ * re-emits inbound publishes as channel readings. MQTT brokers only report
+ * changes, so this transport never emits 'refresh'.
+ */
+class MqttProbeTransport extends EventEmitter implements ProbeTransport {
+ private cfg: ProbeFeedConfig;
+
private client: MqttClient | null = null;
- private activeConfig: ProbeFeedConfig | null = null;
+ private connected = false;
+
+ public constructor(cfg: ProbeFeedConfig) {
+ super();
+ this.cfg = cfg;
+ }
+
+ public async connect(): Promise {
+ return new Promise((resolve, reject) => {
+ const cfg = this.cfg;
+ const client = new MqttClient({
+ host: cfg.host,
+ port: cfg.port,
+ tls: cfg.tls,
+ clientId: cfg.clientId,
+ username: cfg.username,
+ password: cfg.password,
+ });
+ this.client = client;
+ let settled = false;
+
+ client.on('connect', () => {
+ this.connected = true;
+ const topics = PROBE_CHANNELS.map((channel) => cfg.topics[channel]).filter(Boolean) as string[];
+ client.subscribe(topics);
+ // Adafruit IO replays a feed's last value when an empty
+ // message is published to /get - prime the cache so a
+ // fresh session knows the resting state without waiting for
+ // the sensor to change.
+ if (cfg.host.toLowerCase().includes('adafruit')) {
+ for (const topic of topics) {
+ client.publish(`${topic}/get`, '');
+ }
+ }
+ log.info(`Probe feed connected to ${cfg.host}:${cfg.port} as ${cfg.clientId}, `
+ + `subscribed to ${topics.length} topic(s)`);
+ if (!settled) {
+ settled = true;
+ resolve();
+ }
+ });
+
+ client.on('message', (topic: string, payload: string) => {
+ for (const channel of PROBE_CHANNELS) {
+ if (cfg.topics[channel] === topic) {
+ this.emit('reading', channel, payload);
+ return;
+ }
+ }
+ });
+
+ client.on('error', (err: Error) => {
+ if (!settled) {
+ settled = true;
+ reject(err);
+ } else {
+ this.emit('error', err);
+ }
+ });
+
+ client.on('close', () => {
+ this.connected = false;
+ if (this.client === client) {
+ this.client = null;
+ }
+ if (!settled) {
+ settled = true;
+ reject(new Error(`MQTT connection to ${cfg.host}:${cfg.port} closed before CONNACK`));
+ }
+ this.emit('close');
+ });
+
+ client.connect();
+ });
+ }
+
+ public end(): void {
+ if (this.client) {
+ this.client.end();
+ this.client = null;
+ }
+ this.connected = false;
+ }
+
+ public isConnected(): boolean {
+ return this.connected && !!this.client && this.client.connected;
+ }
+
+ public describe(): object {
+ return {
+ host: this.cfg.host || null,
+ port: this.cfg.port,
+ tls: this.cfg.tls,
+ username: this.cfg.username || null,
+ clientId: this.cfg.clientId,
+ configSources: this.cfg.sources,
+ };
+ }
+}
+
+/** Build a fresh transport for the kind from freshly resolved configuration. */
+function buildTransport(kind: ProbeTransportKind): ProbeTransport {
+ return kind === 'gpio'
+ ? new GpioProbeTransport(resolveGpioFeedConfig())
+ : new MqttProbeTransport(resolveProbeFeedConfig());
+}
+
+export class ProbeFeedService {
+ private transport: ProbeTransport | null = null;
+
+ private activeConfig: ActiveProbeConfig | null = null;
private readings = new Map();
@@ -214,13 +430,13 @@ export class ProbeFeedService {
* broker accepts the session and subscriptions are sent.
*/
public async connect(): Promise {
- if (this.client && this.client.connected) {
+ if (this.transport && this.transport.isConnected()) {
return;
}
- const cfg = resolveProbeFeedConfig();
+ const cfg = resolveActiveProbeConfig();
if (!cfg.configured) {
- throw new Error(`Probe feed is not configured: missing ${cfg.missing.join(', ')}. `
- + 'Set the MQTT fields on Settings -> MCP Server or the LUBAN_MCP_MQTT_* environment variables.');
+ throw new Error(`Probe feed (${cfg.kind} transport) is not configured: `
+ + `missing ${cfg.missing.join(', ')}. ${cfg.settingsHint}`);
}
this.wantConnected = true;
await this.openOnce(cfg);
@@ -233,15 +449,15 @@ export class ProbeFeedService {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
- if (this.client) {
- this.client.end();
- this.client = null;
+ if (this.transport) {
+ this.transport.end();
+ this.transport = null;
}
this.connecting = false;
}
public isConnected(): boolean {
- return !!(this.client && this.client.connected);
+ return !!(this.transport && this.transport.isConnected());
}
public getReading(channel: ProbeChannel): ProbeReading | null {
@@ -316,17 +532,28 @@ export class ProbeFeedService {
this.expectedContact = new Set(channels);
}
+ /**
+ * True while the machine is under MCP control that a sensor could
+ * legitimately protect: a sensor-gated procedure has declared its
+ * expected contacts (tool setter / probing runners bracket their whole
+ * run), or a direct motion is in flight (motionBegin). The overtravel
+ * tripwire only latches inside this window.
+ */
+ public procedureArmed(): boolean {
+ return this.motionCount > 0 || this.expectedContact.size > 0;
+ }
+
public clearExpectedContact(): void {
this.expectedContact.clear();
}
public status(): object {
- const cfg = this.activeConfig || resolveProbeFeedConfig();
+ const cfg = this.activeConfig || resolveActiveProbeConfig();
const feeds: { [channel: string]: object | null } = {};
for (const channel of PROBE_CHANNELS) {
const reading = this.readings.get(channel);
feeds[channel] = {
- topic: cfg.topics[channel],
+ source: cfg.channels[channel],
inverted: cfg.inverted[channel],
last: reading ? {
value: reading.value,
@@ -337,17 +564,12 @@ export class ProbeFeedService {
};
}
return {
- transport: 'mqtt',
+ transport: cfg.kind,
configured: cfg.configured,
missing: cfg.missing,
connected: this.isConnected(),
connecting: this.connecting,
- host: cfg.host || null,
- port: cfg.port,
- tls: cfg.tls,
- username: cfg.username || null,
- clientId: cfg.clientId,
- configSources: cfg.sources,
+ ...(this.transport ? this.transport.describe() : describeTransportConfig(cfg.kind)),
feeds,
safetyTrip: this.trip,
reconnectAttempts: this.reconnectAttempts,
@@ -355,72 +577,45 @@ export class ProbeFeedService {
};
}
- private async openOnce(cfg: ProbeFeedConfig): Promise {
+ private async openOnce(cfg: ActiveProbeConfig): Promise {
if (this.connecting) {
- return Promise.resolve();
+ return;
}
this.connecting = true;
this.activeConfig = cfg;
- return new Promise((resolve, reject) => {
- const client = new MqttClient({
- host: cfg.host,
- port: cfg.port,
- tls: cfg.tls,
- clientId: cfg.clientId,
- username: cfg.username,
- password: cfg.password,
- });
- this.client = client;
- let settled = false;
-
- client.on('connect', () => {
- this.connecting = false;
- this.reconnectAttempts = 0;
- this.lastError = null;
- const topics = PROBE_CHANNELS.map((channel) => cfg.topics[channel]).filter(Boolean) as string[];
- client.subscribe(topics);
- // Adafruit IO replays a feed's last value when an empty
- // message is published to /get - prime the cache so a
- // fresh session knows the resting state without waiting for
- // the sensor to change.
- if (cfg.host.toLowerCase().includes('adafruit')) {
- for (const topic of topics) {
- client.publish(`${topic}/get`, '');
- }
- }
- log.info(`Probe feed connected to ${cfg.host}:${cfg.port} as ${cfg.clientId}, `
- + `subscribed to ${topics.length} topic(s)`);
- mcpBroadcast('mcp:activity', { tool: 'probe_feed', phase: 'connected', host: cfg.host });
- if (!settled) {
- settled = true;
- resolve();
- }
- });
-
- client.on('message', (topic: string, payload: string) => this.onMessage(topic, payload));
-
- client.on('error', (err: Error) => {
- this.lastError = err.message;
- log.error(`Probe feed error: ${err.message}`);
- if (!settled) {
- settled = true;
- this.connecting = false;
- reject(err);
- }
- });
-
- client.on('close', () => {
- this.connecting = false;
- if (this.client === client) {
- this.client = null;
- }
- if (this.wantConnected) {
- this.scheduleReconnect();
- }
- });
-
- client.connect();
+ const transport = buildTransport(cfg.kind);
+ this.transport = transport;
+
+ transport.on('reading', (channel: ProbeChannel, value: string) => this.onReading(channel, value, false));
+ transport.on('refresh', (channel: ProbeChannel, value: string) => this.onReading(channel, value, true));
+ transport.on('error', (err: Error) => {
+ this.lastError = err.message;
+ log.error(`Probe feed error: ${err.message}`);
+ });
+ transport.on('close', () => {
+ this.connecting = false;
+ if (this.transport === transport) {
+ this.transport = null;
+ }
+ // Lets the UI's sensor pills fall back to "unknown" rather than
+ // showing the last reading as if it were still live.
+ mcpBroadcast('mcp:activity', { tool: 'probe_feed', phase: 'disconnected', transport: cfg.kind });
+ if (this.wantConnected) {
+ this.scheduleReconnect();
+ }
});
+
+ try {
+ await transport.connect();
+ this.connecting = false;
+ this.reconnectAttempts = 0;
+ this.lastError = null;
+ mcpBroadcast('mcp:activity', { tool: 'probe_feed', phase: 'connected', transport: cfg.kind });
+ } catch (err) {
+ this.connecting = false;
+ this.lastError = err.message;
+ throw err;
+ }
}
private scheduleReconnect(): void {
@@ -432,7 +627,7 @@ export class ProbeFeedService {
log.info(`Probe feed reconnect ${this.reconnectAttempts} in ${delay}ms`);
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
- const cfg = resolveProbeFeedConfig();
+ const cfg = resolveActiveProbeConfig();
if (!cfg.configured) {
this.wantConnected = false;
return;
@@ -443,40 +638,60 @@ export class ProbeFeedService {
}, delay);
}
- private onMessage(topic: string, payload: string): void {
+ private onReading(channel: ProbeChannel, value: string, refreshOnly: boolean): void {
const cfg = this.activeConfig;
if (!cfg) {
return;
}
- for (const channel of PROBE_CHANNELS) {
- if (cfg.topics[channel] !== topic) {
- continue;
- }
- const reading: ProbeReading = {
- value: payload,
- triggered: isTriggeredValue(payload, cfg.inverted[channel]),
- receivedAt: Date.now(),
- topic,
- };
- this.readings.set(channel, reading);
- mcpBroadcast('mcp:activity', {
- tool: 'probe_feed',
- phase: 'reading',
- channel,
- value: payload,
- triggered: reading.triggered,
- });
- log.info(`reading ${channel}=${payload} triggered=${reading.triggered}`);
- if (!this.trip && reading.triggered) {
- if (channel === 'overtravel') {
+ const existing = this.readings.get(channel);
+ if (refreshOnly && existing && existing.value === value) {
+ // Polled-but-unchanged (the GPIO heartbeat): freshness only, no
+ // broadcast/log spam. Trip decisions stay on CHANGE events so the
+ // latch semantics match the change-reporting MQTT transport; a
+ // refresh carrying a DIFFERENT value (a missed change) falls
+ // through to full handling as a safety net.
+ existing.receivedAt = Date.now();
+ return;
+ }
+ const reading: ProbeReading = {
+ value,
+ triggered: isTriggeredValue(value, cfg.inverted[channel]),
+ receivedAt: Date.now(),
+ source: cfg.channels[channel] || channel,
+ };
+ this.readings.set(channel, reading);
+ mcpBroadcast('mcp:activity', {
+ tool: 'probe_feed',
+ phase: 'reading',
+ channel,
+ value,
+ triggered: reading.triggered,
+ });
+ log.info(`reading ${channel}=${value} triggered=${reading.triggered}`);
+ if (!this.trip && reading.triggered) {
+ if (channel === 'overtravel') {
+ // Operator decision (2026-09-04): the overtravel tripwire is
+ // armed only while a sensor-gated procedure (tool height
+ // test, probing) is running or MCP direct motion is in
+ // flight. Pressing the switch by hand with the machine idle
+ // is a bump test, not an emergency - report, don't latch.
+ if (this.procedureArmed()) {
this.tripSafety('overtravel', channel, reading);
- } else if (this.motionCount > 0 && !this.expectedContact.has(channel)) {
- // A contact sensor fired during motion that expected no
- // contact: collision. Stop everything.
- this.tripSafety('crash', channel, reading);
+ } else {
+ log.warn(`overtravel reported while no procedure is running (value "${value}") - not latching`);
+ mcpBroadcast('mcp:activity', {
+ tool: 'probe_feed',
+ phase: 'overtravel_unarmed',
+ channel,
+ value,
+ message: 'Overtravel switch triggered with no procedure running - no alarm latched.',
+ });
}
+ } else if (this.motionCount > 0 && !this.expectedContact.has(channel)) {
+ // A contact sensor fired during motion that expected no
+ // contact: collision. Stop everything.
+ this.tripSafety('crash', channel, reading);
}
- return;
}
}
@@ -524,6 +739,7 @@ export class ProbeFeedService {
mcpBroadcast('mcp:activity', {
tool: 'probe_feed',
phase: kind === 'crash' ? 'CRASH_ALARM' : 'OVERTRAVEL_ALARM',
+ channel,
value: reading.value,
message: `${kind === 'crash' ? `Collision: ${channel} sensor fired during motion that expected no contact` : 'Overtravel sensor tripped'}: `
+ 'running job stopped, machine connection force-closed, all MCP motion blocked '
diff --git a/src/server/services/mcp/probeTransport.ts b/src/server/services/mcp/probeTransport.ts
new file mode 100644
index 0000000000..547fb6f1a3
--- /dev/null
+++ b/src/server/services/mcp/probeTransport.ts
@@ -0,0 +1,33 @@
+// Shared vocabulary of the external probe sensor feed, in its own module so
+// the orchestrator (probeFeed.ts) and the transport backends (MQTT in
+// probeFeed.ts, Blinka GPIO in gpioFeed.ts) can all import it without cycles.
+
+export type ProbeChannel = 'toolsetter' | 'overtravel' | 'probe';
+
+export const PROBE_CHANNELS: ProbeChannel[] = ['toolsetter', 'overtravel', 'probe'];
+
+export type ProbeTransportKind = 'mqtt' | 'gpio';
+
+/**
+ * A probe feed transport delivers raw sensor values per channel; the
+ * ProbeFeedService owns everything downstream (polarity, the reading cache,
+ * the overtravel/crash latch, reconnection). Implementations extend
+ * EventEmitter and emit:
+ *
+ * 'reading' (channel: ProbeChannel, value: string) - the sensor CHANGED
+ * 'refresh' (channel: ProbeChannel, value: string) - polled, unchanged
+ * (freshness only; MQTT never emits it, polling GPIO does)
+ * 'error' (err: Error) - after connect() resolved; connect failures reject
+ * 'close' () - the transport is dead; the service reconnects
+ *
+ * A closed transport is not reusable - the service builds a new one from
+ * freshly resolved configuration on every (re)connect.
+ */
+export interface ProbeTransport {
+ connect(): Promise;
+ end(): void;
+ isConnected(): boolean;
+ /** Transport-specific fields merged into get_probe_feed_status. */
+ describe(): object;
+ on(event: string, listener: (...args: unknown[]) => void): this;
+}
diff --git a/src/server/services/mcp/probing.ts b/src/server/services/mcp/probing.ts
index 09d8c5a5c3..8f2e8b98b1 100644
--- a/src/server/services/mcp/probing.ts
+++ b/src/server/services/mcp/probing.ts
@@ -52,7 +52,7 @@ export async function sleep(ms: number): Promise {
* a stable double-beat since a stale beat could pass). The overtravel latch
* is re-checked before the move and on every poll.
*/
-export async function moveMachineSettled(
+async function moveMachineSettledUnguarded(
tool: string,
target: { x?: number; y?: number; z?: number },
feed: number
@@ -132,6 +132,26 @@ export async function moveMachineSettled(
throw new ProcedureAbort(`Timed out waiting for the heartbeat to verify the move to ${words}.`);
}
+/**
+ * moveMachineSettledUnguarded inside the motion-in-flight bracket: while the
+ * move runs, a contact sensor the procedure did NOT declare as expected
+ * (setExpectedContact) firing is a collision and latches CRASH, and the
+ * overtravel tripwire is armed (operator, 2026-09-04: alarm on unexpected
+ * probe contact during an X/Y/Z move outside the region of interest).
+ */
+export async function moveMachineSettled(
+ tool: string,
+ target: { x?: number; y?: number; z?: number },
+ feed: number
+): Promise {
+ probeFeedService.motionBegin();
+ try {
+ await moveMachineSettledUnguarded(tool, target, feed);
+ } finally {
+ probeFeedService.motionEnd();
+ }
+}
+
/**
* CONTACT detection after a settled step: give the sensor's report a short
* window to arrive (early-exit on a fresh reading), then judge from the LAST
diff --git a/src/server/services/mcp/requirements.txt b/src/server/services/mcp/requirements.txt
new file mode 100644
index 0000000000..f88766eeb8
--- /dev/null
+++ b/src/server/services/mcp/requirements.txt
@@ -0,0 +1,21 @@
+# Python dependencies for the probe feed GPIO transport ONLY (gpioFeed.ts
+# spawns a Blinka monitor subprocess under the interpreter named by
+# LUBAN_MCP_GPIO_PYTHON / mcpGpioPython). Nothing else in Luban needs Python
+# at runtime. Install into a venv and point the config at it:
+#
+# python3 -m venv .venv
+# .venv/bin/pip install -r src/server/services/mcp/requirements.txt
+# export LUBAN_MCP_GPIO_PYTHON=$PWD/.venv/bin/python
+#
+# (Windows: .venv\Scripts\pip and .venv\Scripts\python.exe)
+#
+# Version floors are what the transport was verified against (2026-09-03,
+# real PICO_U2IF on Windows).
+
+Adafruit-Blinka>=9.2.0
+
+# U2IF (BLINKA_U2IF=1 - the default Blinka environment; a Pico/KB2040 as USB
+# GPIO bridge) talks over USB HID. Not needed with Blinka environment
+# "native" (on-board GPIO, e.g. a Pi header); other bridges (BLINKA_MCP2221=1,
+# BLINKA_FT232H=1) bring their own extra packages - see the Blinka docs.
+hidapi>=0.15.0
diff --git a/src/server/services/mcp/tools/probe.ts b/src/server/services/mcp/tools/probe.ts
index b232de57dc..958d5538c3 100644
--- a/src/server/services/mcp/tools/probe.ts
+++ b/src/server/services/mcp/tools/probe.ts
@@ -1,19 +1,20 @@
/* eslint-disable camelcase */
// MCP tool arguments are snake_case by convention.
import { McpToolError, ToolRegistry } from '../registry';
-import { probeFeedService, resolveProbeFeedConfig } from '../probeFeed';
+import { probeFeedService, resolveActiveProbeConfig } from '../probeFeed';
// External probe sensors (tool height setter, CNC touch probe, overtravel
-// switch) report over a message feed (MQTT / Adafruit IO). These tools manage
-// the feed connection; the sensors themselves are read by the measurement
-// procedures that consume them.
+// switch) report over a sensor feed - MQTT (Adafruit IO) or direct GPIO
+// (Blinka/U2IF), chosen by LUBAN_MCP_PROBE_TRANSPORT / mcpProbeTransport.
+// These tools manage the feed connection; the sensors themselves are read by
+// the measurement procedures that consume them.
export function registerProbeTools(registry: ToolRegistry): void {
registry.register({
name: 'get_probe_feed_status',
- description: 'State of the external probe sensor feed (MQTT): configuration, connection, '
- + 'the last reading per channel (toolsetter / overtravel / probe) with its age, and '
- + 'whether the overtravel alarm is latched. Read-only.',
+ description: 'State of the external probe sensor feed (transport mqtt or gpio): configuration, '
+ + 'connection, the last reading per channel (toolsetter / overtravel / probe) with its age, '
+ + 'and whether the overtravel alarm is latched. Read-only.',
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
handler: async () => probeFeedService.status(),
});
@@ -21,16 +22,18 @@ export function registerProbeTools(registry: ToolRegistry): void {
registry.register({
name: 'connect_probe_feed',
description: 'Connect (or reconnect) to the probe sensor feed using the current settings '
- + '(environment variables first, then the Settings -> MCP Server MQTT fields). '
- + 'Subscribes to the configured toolsetter/overtravel/probe topics and arms the '
- + 'overtravel tripwire. Idempotent.',
+ + '(environment variables first, then the Settings -> MCP Server fields) over the '
+ + 'configured transport - MQTT topics, or direct GPIO pins polled through a Blinka '
+ + 'monitor subprocess. Binds the configured toolsetter/overtravel/probe channels and '
+ + 'arms the overtravel tripwire (which latches only while a sensor-gated procedure or '
+ + 'MCP motion is in progress). Idempotent.',
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
handler: async () => {
- const cfg = resolveProbeFeedConfig();
+ const cfg = resolveActiveProbeConfig();
if (!cfg.configured) {
- throw new McpToolError(`Probe feed is not configured: missing ${cfg.missing.join(', ')}. `
- + 'Ask the operator to fill in the MQTT fields on Settings -> MCP Server '
- + '(or set LUBAN_MCP_MQTT_* environment variables) and restart or retry.');
+ throw new McpToolError(`Probe feed (${cfg.kind} transport) is not configured: `
+ + `missing ${cfg.missing.join(', ')}. Ask the operator: ${cfg.settingsHint} `
+ + 'Then retry.');
}
try {
await probeFeedService.connect();
From ef2e23c1060793b1e6fa073d2d13106608785d3c Mon Sep 17 00:00:00 2001
From: tyeth
Date: Fri, 4 Sep 2026 23:16:25 +0100
Subject: [PATCH 052/135] Fix: Linux camera capture (v4l2), Ubuntu 24.04
AppArmor launch crash, mac fork builds
camera.ts picks the ffmpeg input by platform: DirectShow on Windows
(unchanged), v4l2 on Linux. Devices are enumerated from
/sys/class/video4linux (index-0 capture nodes only) and reported by udev's
stable /dev/v4l/by-id path, because /dev/videoN renumbers when cameras come
and go. The sticky last-good logic is untouched; a vanished device is still
an error, never a substitution.
Ubuntu 23.10+ restricts unprivileged user namespaces; without an AppArmor
profile granting userns the Chromium sandbox aborts and Luban dies on launch
with "Trace/breakpoint trap". The deb/rpm post-install
(build/linux-after-install.sh, replacing electron-builder's template while
keeping its symlink / SUID sandbox / desktop-db duties) writes
/etc/apparmor.d/snapmaker-luban and reloads it; post-remove deletes it.
Mac fork builds: unset repo secrets arrive as EMPTY env vars and an
empty-but-set CSC_LINK made electron-builder import a certificate from ""
(" not a file"). electron-builder.sh unsets empty CSC_* (before set -x)
and disables keychain auto-discovery; the afterSign hook ad-hoc signs so the
x64 + arm64 dmg/zip launch on Apple Silicon. All three platforms build green
via workflow_dispatch. cnc-visual-alignment notes the per-platform camera
device entries.
Co-Authored-By: Claude Fable 5.1
---
.claude/skills/cnc-visual-alignment/SKILL.md | 1 +
build/electron-builder.sh | 13 +++
build/linux-after-install.sh | 41 +++++++++
build/linux-after-remove.sh | 13 +++
build/notarize.js | 26 ++++--
electron-builder.yml | 6 ++
src/server/services/mcp/README.md | 27 ++++--
src/server/services/mcp/camera.ts | 93 ++++++++++++++++++--
8 files changed, 200 insertions(+), 20 deletions(-)
create mode 100644 build/linux-after-install.sh
create mode 100644 build/linux-after-remove.sh
diff --git a/.claude/skills/cnc-visual-alignment/SKILL.md b/.claude/skills/cnc-visual-alignment/SKILL.md
index d32b3d34cf..9a90a7f3b0 100644
--- a/.claude/skills/cnc-visual-alignment/SKILL.md
+++ b/.claude/skills/cnc-visual-alignment/SKILL.md
@@ -27,6 +27,7 @@ better frames.
| Orient yourself | `get_connection_status`, `get_machine_profile`, `get_position` | Profile carries kinematics and module offsets (bracing kit shifts the envelope). `get_position` reports BOTH coordinate systems, report age, and a `warnings` array — a non-empty `warnings` means position reporting is incoherent; stop and verify. |
| Authoritative frame check | `query_firmware_position` | Raw M114 from the controller. When heartbeat-derived numbers look wrong, this is the truth. |
| Frames | `list_cameras`, `capture_frame` | Every frame is stamped with the firmware-reported position it was taken at. That stamp is what makes calibration possible — never discard it. |
+| Camera device | `mcpCameraDevice` (operator config) | Windows names cameras by DirectShow friendly name; Linux `list_cameras` returns stable `/dev/v4l/by-id/… (Name)` entries (plain `/dev/videoN` renumbers on replug). The operator pins one; a vanished device is an error to report, never a silent substitution — and with two cameras attached, confirm which is the toolhead cam from a frame (at home it sees the enclosure's silver extrusion up close) before trusting any calibration. |
| Machine home | `home` | Sends `G53;G28;G54` like Luban's own button. **Homing also homes B: stock indexed on the rotary rotates.** Warn the operator before homing when a rotary is fitted. |
| Work origin | `goto_work_origin` | XY only, at the current Z. Distinct from homing — never conflate the two. |
| Single guarded move | `move_and_capture` | ONE bounded XY move at current Z, settle, capture. No Z parameter by design. |
diff --git a/build/electron-builder.sh b/build/electron-builder.sh
index 1ea2addf4c..7efdd86312 100755
--- a/build/electron-builder.sh
+++ b/build/electron-builder.sh
@@ -1,4 +1,17 @@
#!/bin/bash
+
+# Forks build unsigned: repo secrets that are not configured arrive as EMPTY
+# env vars, and an empty-but-set CSC_LINK makes electron-builder try to
+# import a certificate from "" (it dies with " not a file" right after
+# packaging). Unset them and turn off keychain auto-discovery so the build
+# proceeds unsigned; the afterSign hook (build/notarize.js) then ad-hoc
+# signs mac apps so they still launch on Apple Silicon.
+# (Before set -x so a real certificate value is never echoed to the log.)
+if [ -z "${CSC_LINK:-}" ]; then
+ unset CSC_LINK CSC_KEY_PASSWORD
+ export CSC_IDENTITY_AUTO_DISCOVERY=false
+fi
+
set -x
__dirname="$(CDPATH= cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
diff --git a/build/linux-after-install.sh b/build/linux-after-install.sh
new file mode 100644
index 0000000000..0e4f03e4e5
--- /dev/null
+++ b/build/linux-after-install.sh
@@ -0,0 +1,41 @@
+#!/bin/bash
+# deb/rpm post-install. electron-builder substitutes ${sanitizedProductName}
+# and ${executable} with a plain regex, so no other "dollar-brace" sequences
+# may appear in this file. Replaces electron-builder's default template, so
+# its three duties come first.
+
+# Link to the binary
+ln -sf '/opt/${sanitizedProductName}/${executable}' '/usr/bin/${executable}'
+
+# SUID chrome-sandbox for Electron 5+
+chmod 4755 '/opt/${sanitizedProductName}/chrome-sandbox' || true
+
+update-mime-database /usr/share/mime || true
+update-desktop-database /usr/share/applications || true
+
+# Ubuntu 23.10+ restricts unprivileged user namespaces
+# (kernel.apparmor_restrict_unprivileged_userns=1). Without an AppArmor
+# profile granting userns, Chromium's sandbox cannot start and the app dies
+# on launch with "Trace/breakpoint trap (core dumped)" (observed on Ubuntu
+# 24.04, 2026-09-04). Install the same shape of profile Ubuntu ships for
+# other Electron apps (Discord, code, ...). Only where AppArmor is new
+# enough to know abi/4.0 - older releases neither have the restriction nor
+# accept the syntax.
+if [ -d /etc/apparmor.d ] && [ -f /etc/apparmor.d/abi/4.0 ]; then
+ cat > '/etc/apparmor.d/${executable}' <<'EOF'
+# Allow Snapmaker Luban (Electron) to create the unprivileged user namespace
+# its Chromium sandbox needs. Installed by the snapmaker-luban package.
+abi ,
+include
+
+profile ${executable} "/opt/${sanitizedProductName}/${executable}" flags=(unconfined) {
+ userns,
+
+ # Site-specific additions and overrides. See local/README for details.
+ include if exists
+}
+EOF
+ if command -v apparmor_parser > /dev/null 2>&1; then
+ apparmor_parser -r '/etc/apparmor.d/${executable}' || true
+ fi
+fi
diff --git a/build/linux-after-remove.sh b/build/linux-after-remove.sh
new file mode 100644
index 0000000000..def5e9c2df
--- /dev/null
+++ b/build/linux-after-remove.sh
@@ -0,0 +1,13 @@
+#!/bin/bash
+# deb/rpm post-remove; see linux-after-install.sh for the placeholder rule.
+
+# Delete the link to the binary
+rm -f '/usr/bin/${executable}'
+
+# Drop the AppArmor profile installed by linux-after-install.sh.
+if [ -f '/etc/apparmor.d/${executable}' ]; then
+ if command -v apparmor_parser > /dev/null 2>&1; then
+ apparmor_parser -R '/etc/apparmor.d/${executable}' || true
+ fi
+ rm -f '/etc/apparmor.d/${executable}'
+fi
diff --git a/build/notarize.js b/build/notarize.js
index 6a8c099122..8e2263f2ae 100644
--- a/build/notarize.js
+++ b/build/notarize.js
@@ -1,3 +1,4 @@
+const { execFileSync } = require('child_process');
const { notarize } = require('@electron/notarize');
module.exports = async function notarizing(context) {
@@ -11,24 +12,35 @@ module.exports = async function notarizing(context) {
return;
}
- // Forks build unsigned: skip notarization when the Apple credentials are
- // not configured instead of crashing the whole package step.
+ const appName = context.packager.appInfo.productFilename;
+ const appPath = `${appOutDir}/${appName}.app`;
+
+ // Forks build without a Developer ID: electron-builder skipped signing
+ // (electron-builder.sh unsets the empty CSC_* secrets), but a completely
+ // unsigned app is killed on launch by Apple Silicon. Ad-hoc sign so the
+ // dmg/zip run on both Intel and arm64; being un-notarized, the first
+ // launch still needs Gatekeeper's one-time "Open Anyway" (right-click ->
+ // Open on macOS <= 14, System Settings -> Privacy & Security on 15+) -
+ // no terminal required.
+ if (!process.env.CSC_LINK) {
+ console.log('Ad-hoc signing (no signing certificate configured)...');
+ execFileSync('codesign', ['--force', '--deep', '--sign', '-', appPath], { stdio: 'inherit' });
+ }
+
+ // Forks also skip notarization: no Apple credentials, no crash.
if (!process.env.APPLEID || !process.env.APPLEIDPASS || !process.env.TEAMID) {
console.log('Skipping notarization: Apple signing credentials are not configured.');
return;
}
- // Notarize only when running on Travis-CI and has a tag.
console.log('Notarizing application...');
- const appName = context.packager.appInfo.productFilename;
-
- const teamId = process.env.TEAMID;;
+ const teamId = process.env.TEAMID;
const appleId = process.env.APPLEID;
const appleIdPassword = process.env.APPLEIDPASS;
await notarize({
- appPath: `${appOutDir}/${appName}.app`,
+ appPath,
appleId,
appleIdPassword,
teamId,
diff --git a/electron-builder.yml b/electron-builder.yml
index adab44c283..0b40173d46 100644
--- a/electron-builder.yml
+++ b/electron-builder.yml
@@ -85,10 +85,16 @@ deb:
- libxtst6
- libnss3
- libasound2
+ # Default duties (symlink, SUID sandbox, desktop db) plus an AppArmor
+ # userns profile for Ubuntu 23.10+ - see the scripts.
+ afterInstall: build/linux-after-install.sh
+ afterRemove: build/linux-after-remove.sh
rpm:
# snapmaker-luban-X.Y.Z-linux.x86_64.rpm
artifactName: Snapmaker-luban-${version}-linux.${arch}.${ext}
+ afterInstall: build/linux-after-install.sh
+ afterRemove: build/linux-after-remove.sh
#
# Hooks
diff --git a/src/server/services/mcp/README.md b/src/server/services/mcp/README.md
index f3248097eb..e909e1e44c 100644
--- a/src/server/services/mcp/README.md
+++ b/src/server/services/mcp/README.md
@@ -17,7 +17,7 @@ serves them, and `LUBAN_MCP_PORT` (env) overrides everything for one run.
|---|---|
| `mcpEnabled`, `mcpPort` | Start the server on 127.0.0.1:port (default 40889). Legacy: `mcpPort` alone enables when `mcpEnabled` was never written. |
| `mcpCameraUrl` | HTTP(S) snapshot URL; takes precedence over ffmpeg. |
-| `mcpFfmpegPath`, `mcpCameraDevice`, `mcpCameraLastGood` | ffmpeg DirectShow capture. Device choice is sticky (last-good preferred); a vanished device is an error, never a silent substitution. |
+| `mcpFfmpegPath`, `mcpCameraDevice`, `mcpCameraLastGood` | ffmpeg capture — DirectShow on Windows (device = friendly name), v4l2 on Linux (device = a `list_cameras` entry, preferably the stable `/dev/v4l/by-id/… (Name)` form; a bare `/dev/videoN` works but renumbers on replug). Device choice is sticky (last-good preferred); a vanished device is an error, never a silent substitution. |
| `mcpMaxJogDistance` | Per-call XY travel cap for direct moves, default 100 mm. `goto_work_origin` is exempt (fixed operator-set destination). |
| `mcpToolRegion` | Fractional box where the endmill images (fixed camera-to-spindle geometry); returned with every frame; settable via the `set_tool_region` tool. |
| `mcpInstalledModules` | e.g. `["snapmaker-2.0-bracing-kit-module"]` — feeds effective work ranges in `get_machine_profile`. |
@@ -36,6 +36,16 @@ A project-scope `.mcp.json` at the repo root points Claude Code sessions at
Run workflow → pick branch + platforms, or `gh workflow run build-on-pull-request.yml
--ref -f platforms=all`) and download the platform installer from the run's
artifacts.
+- **Ubuntu 23.10+ / AppArmor**: these releases restrict unprivileged user namespaces
+ (`kernel.apparmor_restrict_unprivileged_userns=1`), which kills Chromium's sandbox — the
+ app dies on launch with `Trace/breakpoint trap (core dumped)` (seen on Ubuntu 24.04,
+ 2026-09-04). The `.deb`/`.rpm` post-install (`build/linux-after-install.sh`) writes
+ `/etc/apparmor.d/snapmaker-luban` granting `userns` (same shape Ubuntu ships for
+ Discord/code) and reloads it; post-remove deletes it. For a package built before that,
+ install the profile by hand once:
+ `sudo tee /etc/apparmor.d/snapmaker-luban > /dev/null <<'EOF'` … (contents in the
+ script) … `EOF && sudo apparmor_parser -r /etc/apparmor.d/snapmaker-luban`. Last-resort
+ workaround: launch with `snapmaker-luban --no-sandbox` (also proves the diagnosis).
- **Local dev**: Node 16 + python 3.11 for node-gyp (details under Development workflow),
`npm install`, then `npm run dev` (watch mode) or `npm run build` + `npm run
start-electron` (production build; see the build caveats below).
@@ -45,11 +55,16 @@ A project-scope `.mcp.json` at the repo root points Claude Code sessions at
interpreter. Nothing else needs Python at runtime. On Linux, if the monitor dies with
an HID open/permission error, grant the user access to the Pico's hidraw device (udev
rule) and re-plug.
-- **ffmpeg — camera capture, Windows only**: the ffmpeg path is DirectShow (`-f dshow`),
- so it only serves Windows; install any ffmpeg build and set `mcpFfmpegPath` if not on
- PATH. On Linux/macOS use an HTTP snapshot endpoint via `mcpCameraUrl` (e.g. Android IP
- Webcam) — it takes precedence over ffmpeg everywhere and is platform-independent.
- (A v4l2 capture provider would be the Linux-native alternative; not built.)
+- **ffmpeg — camera capture (Windows + Linux)**: install any ffmpeg build (`apt install
+ ffmpeg` on Ubuntu) and set `mcpFfmpegPath` if not on PATH. Windows captures via
+ DirectShow (device = friendly name); Linux via v4l2 — devices are enumerated from
+ `/sys/class/video4linux` (capture nodes only, listed as
+ `/dev/v4l/by-id/usb-…-video-index0 (Name)` — stable across replugs, unlike
+ `/dev/videoN`, which renumbers when cameras come and go), and the user must be able to
+ read `/dev/video*` (usually the `video` group). Pin `mcpCameraDevice` to the by-id
+ entry from `list_cameras`, never to a bare `/dev/videoN`. macOS has no
+ ffmpeg input wired up. `mcpCameraUrl` (HTTP snapshot, e.g. Android IP Webcam) remains
+ platform-independent and takes precedence everywhere.
## Architecture
diff --git a/src/server/services/mcp/camera.ts b/src/server/services/mcp/camera.ts
index 2e0ad470cf..3cd3207b37 100644
--- a/src/server/services/mcp/camera.ts
+++ b/src/server/services/mcp/camera.ts
@@ -16,10 +16,13 @@ const log = logger('service:mcp:camera');
// plain forked Node process (no Electron media stack), so capture goes
// through one of two providers:
// - mcpCameraUrl: HTTP(S) snapshot URL returning a JPEG/PNG per GET
-// - ffmpeg DirectShow: mcpFfmpegPath (or ffmpeg on PATH) reading the
-// device named by mcpCameraDevice
+// - ffmpeg: mcpFfmpegPath (or ffmpeg on PATH) reading the device named by
+// mcpCameraDevice - DirectShow on Windows, v4l2 on Linux. macOS has no
+// ffmpeg input wired up; use mcpCameraUrl there.
const CAPTURE_TIMEOUT_MS = 15000;
+const FFMPEG_PROVIDER = process.platform === 'win32' ? 'ffmpeg-dshow' : 'ffmpeg-v4l2';
+
export interface CapturedFrame {
frameId: string;
imageBase64: string;
@@ -69,12 +72,72 @@ async function runFfmpeg(args: string[]): Promise<{ code: number; stderr: string
});
}
+/**
+ * Enumerate v4l2 capture devices from sysfs (ffmpeg cannot list them). Each
+ * physical camera exposes several /dev/video* nodes; only `index` 0 is the
+ * actual capture node (the rest are metadata companions), so only those are
+ * listed. /dev/videoN numbering shuffles whenever cameras are (un)plugged
+ * (seen on the Ubuntu box: the toolhead camera moved video0 -> video2 when
+ * a second camera appeared), so entries prefer udev's stable per-device
+ * symlink: "/dev/v4l/by-id/usb-...-video-index0 (Friendly Name)", falling
+ * back to "/dev/videoN (Friendly Name)". The same string is stored as the
+ * sticky device and the leading path is parsed back out at capture time.
+ */
+function listV4l2Devices(): string[] {
+ const root = '/sys/class/video4linux';
+ if (!fs.existsSync(root)) {
+ return [];
+ }
+ const byIdDir = '/dev/v4l/by-id';
+ const stablePath: { [node: string]: string } = {};
+ if (fs.existsSync(byIdDir)) {
+ for (const link of fs.readdirSync(byIdDir)) {
+ try {
+ const target = path.basename(fs.readlinkSync(path.join(byIdDir, link)));
+ stablePath[target] = path.join(byIdDir, link);
+ } catch (err) {
+ // not a symlink; ignore
+ }
+ }
+ }
+ const nodes = fs.readdirSync(root)
+ .filter((entry) => /^video\d+$/.test(entry))
+ .sort((a, b) => Number(a.slice(5)) - Number(b.slice(5)));
+ const devices: string[] = [];
+ for (const node of nodes) {
+ const devPath = stablePath[node] || `/dev/${node}`;
+ try {
+ const index = fs.readFileSync(path.join(root, node, 'index'), 'utf8').trim();
+ if (index !== '0') {
+ continue;
+ }
+ const name = fs.readFileSync(path.join(root, node, 'name'), 'utf8').trim();
+ devices.push(name ? `${devPath} (${name})` : devPath);
+ } catch (err) {
+ devices.push(devPath);
+ }
+ }
+ return devices;
+}
+
export async function listCameras(): Promise<{ provider: string; devices: string[]; note?: string }> {
const cameraUrl = config.get('mcpCameraUrl');
if (cameraUrl) {
return { provider: 'http', devices: [String(cameraUrl)], note: 'mcpCameraUrl is set; it takes precedence.' };
}
+ if (process.platform === 'linux') {
+ return { provider: 'ffmpeg-v4l2', devices: listV4l2Devices() };
+ }
+ if (process.platform !== 'win32') {
+ return {
+ provider: 'ffmpeg',
+ devices: [],
+ note: `No ffmpeg camera input is wired up for ${process.platform}; set mcpCameraUrl to an `
+ + 'HTTP snapshot URL instead.',
+ };
+ }
+
const { stderr } = await runFfmpeg(['-hide_banner', '-list_devices', 'true', '-f', 'dshow', '-i', 'dummy']);
// ffmpeg prints device lines as: [dshow @ ...] "Device Name" (video)
const devices: string[] = [];
@@ -131,12 +194,20 @@ async function captureViaFfmpeg(): Promise {
// (possibly dead virtual) camera is worse than an error. The last
// device that produced a frame is remembered and preferred; a missing
// device is an error, never a substitution.
+ if (process.platform !== 'win32' && process.platform !== 'linux') {
+ throw new McpToolError(`No ffmpeg camera input is wired up for ${process.platform}. `
+ + 'Set mcpCameraUrl to an HTTP snapshot URL instead.');
+ }
let device = config.get('mcpCameraDevice');
if (!device) {
const { devices } = await listCameras();
if (!devices.length) {
- throw new McpToolError('No DirectShow video devices found. Set configstore key mcpCameraDevice, '
- + 'or mcpCameraUrl for an HTTP snapshot source.');
+ const linuxHint = process.platform === 'linux'
+ ? ' (v4l2 devices are read from /sys/class/video4linux; check the camera is attached '
+ + 'and the user can read /dev/video* - video group.)'
+ : '';
+ throw new McpToolError(`No ${process.platform === 'win32' ? 'DirectShow' : 'v4l2'} video devices `
+ + `found. Set configstore key mcpCameraDevice, or mcpCameraUrl for an HTTP snapshot source.${linuxHint}`);
}
const lastGood = config.get('mcpCameraLastGood');
if (lastGood && devices.includes(String(lastGood))) {
@@ -150,11 +221,19 @@ async function captureViaFfmpeg(): Promise {
}
}
+ // dshow addresses cameras by friendly name; v4l2 by device path. Linux
+ // list entries read " (Name)" where path is a /dev/v4l/by-id
+ // symlink or /dev/videoN - parse the path back out, and accept a bare
+ // path set directly in mcpCameraDevice.
+ const inputArgs = process.platform === 'win32'
+ ? ['-f', 'dshow', '-i', `video=${device}`]
+ : ['-f', 'v4l2', '-i', (String(device).match(/^(\/dev\/\S+)/) || [])[1] || String(device)];
+
const outPath = path.join(DataStorage.tmpDir, `mcp-frame-${crypto.randomBytes(4).toString('hex')}.jpg`);
try {
const ffmpegArgs = [
'-hide_banner', '-loglevel', 'error',
- '-f', 'dshow', '-i', `video=${device}`,
+ ...inputArgs,
'-frames:v', '1', '-f', 'image2', '-y', outPath,
];
let { code, stderr } = await runFfmpeg(ffmpegArgs);
@@ -176,7 +255,7 @@ async function captureViaFfmpeg(): Promise {
frameId: cacheFrame(body),
imageBase64: body.toString('base64'),
mimeType: 'image/jpeg',
- provider: 'ffmpeg-dshow',
+ provider: FFMPEG_PROVIDER,
device: String(device),
capturedAt: Date.now(),
};
@@ -191,6 +270,6 @@ export async function captureFrame(): Promise {
log.debug(`Capturing frame via HTTP snapshot: ${cameraUrl}`);
return captureViaHttp(String(cameraUrl));
}
- log.debug('Capturing frame via ffmpeg dshow');
+ log.debug(`Capturing frame via ${FFMPEG_PROVIDER}`);
return captureViaFfmpeg();
}
From 79fa9e4867237876bd9b157638986286854310b1 Mon Sep 17 00:00:00 2001
From: tyeth
Date: Sat, 5 Sep 2026 00:08:13 +0100
Subject: [PATCH 053/135] Improvement: machine profile from Luban Machine
Settings; collate notes; H&S notice
get_machine_profile and get_stored_state now read the machine series,
toolheads and installed add-on modules (bracing kit, quick-swap) from
Luban's own Machine Settings - userData/machine.json, state.machine - on
every call, so what the operator selects in the app (which returns to its
home page when the machine config changes) is what the MCP reports. The
private configstore key mcpInstalledModules is removed; the profile also
falls back to the selected machine when none is connected and reports
machineSettings + installedModulesSource.
The MCP README absorbs everything that previously lived only in the
assistant's memory files, so no new user (or agent) depends on them: the
Ubuntu box deployment notes (KB2040/U2IF wiring and polarity, the udev
rule needing SUBSYSTEM=="usb" because pip hidapi uses libusb, camera
identification and by-id pinning, AppArmor, launching into an RDP
session, where operator state lives and how to migrate it), the remaining
hardware measurements (rotary stock, chuck fixed hole, post+hole = 10.41),
standing operator rules not enforced in code, the current PR stack
(#70-#73), Claude Code's MCP schema cache, startup facts, assistant
tooling gotchas, and the open threads (results on the job record,
traverse_xy, console ANSI leak, install size, GPIO-sized delays).
The main Luban README gains a single line linking the MCP server with a
health and safety warning: do not use it unless you accept software-
commanded motion, never leave the CNC unattended or approach it while
running, wear appropriate PPE.
Co-Authored-By: Claude Fable 5.1
---
README.md | 3 +
src/server/services/mcp/README.md | 100 ++++++++++++++++++++-
src/server/services/mcp/tools/landmarks.ts | 5 +-
src/server/services/mcp/tools/machine.ts | 69 +++++++++++---
4 files changed, 162 insertions(+), 15 deletions(-)
diff --git a/README.md b/README.md
index e9577cb18b..0d63b23c4f 100644
--- a/README.md
+++ b/README.md
@@ -10,6 +10,9 @@ The software also provides G-code generation support for 3D models, laser engrav
Our goal is to provide a multi-functional 3D software, while making it as accessible and customizable as possible for new users / beginners.
The software is inspired by [cncjs](https://github.com/cncjs/cncjs) by cheton.
+
+> ⚠️ **This fork adds an experimental MCP server that lets AI agents drive the machine** — see [src/server/services/mcp/README.md](src/server/services/mcp/README.md). **Health & safety:** do not use the MCP server unless you accept the risk of software-commanded motion; never leave your CNC machine unattended or approach it while it is running; always wear appropriate PPE (eye and hearing protection, no loose clothing or jewellery).
+
We use [LunarSlicer](https://github.com/Snapmaker/LunarSlicer) for 3D slicing.

diff --git a/src/server/services/mcp/README.md b/src/server/services/mcp/README.md
index e909e1e44c..ea2d42489a 100644
--- a/src/server/services/mcp/README.md
+++ b/src/server/services/mcp/README.md
@@ -20,7 +20,7 @@ serves them, and `LUBAN_MCP_PORT` (env) overrides everything for one run.
| `mcpFfmpegPath`, `mcpCameraDevice`, `mcpCameraLastGood` | ffmpeg capture — DirectShow on Windows (device = friendly name), v4l2 on Linux (device = a `list_cameras` entry, preferably the stable `/dev/v4l/by-id/… (Name)` form; a bare `/dev/videoN` works but renumbers on replug). Device choice is sticky (last-good preferred); a vanished device is an error, never a silent substitution. |
| `mcpMaxJogDistance` | Per-call XY travel cap for direct moves, default 100 mm. `goto_work_origin` is exempt (fixed operator-set destination). |
| `mcpToolRegion` | Fractional box where the endmill images (fixed camera-to-spindle geometry); returned with every frame; settable via the `set_tool_region` tool. |
-| `mcpInstalledModules` | e.g. `["snapmaker-2.0-bracing-kit-module"]` — feeds effective work ranges in `get_machine_profile`. |
+| *(machine, toolheads, modules)* | **Not MCP keys.** `get_machine_profile` / `get_stored_state` read the machine, toolheads and installed add-on modules (bracing kit, quick-swap) from Luban's own **Machine Settings** (`userData/machine.json`, `state.machine`) on every call — change them in the app (it returns to its home page) and the MCP follows. `mcpInstalledModules` is gone (2026-09-05). |
| `mcpMqtt*` | Probe sensor feed (MQTT): `Host`, `Port` (default 8883 = TLS, 1883 = plain), `User`, `Pass`, `ClientId` (default = username + MAC bytes), `FeedToolsetter`, `FeedOvertravel`, `FeedProbe` (Adafruit IO feed key, or a full topic when it contains `/`), `Inverted` (comma-separated channels whose sensor idles HIGH and reads low on contact — the operator's CNC touch probe is normally open, so `"probe"`). Env `LUBAN_MCP_MQTT_HOST/PORT/USER/PASS/CLIENT_ID/FEED_TOOLSETTER/FEED_OVERTRAVEL/FEED_PROBE/INVERTED` override field-by-field. |
| `mcpProbeTransport` | Which probe feed backend: `mqtt` or `gpio`. Unset = auto: `mqtt`, unless only the GPIO side is configured. Env `LUBAN_MCP_PROBE_TRANSPORT` overrides. |
| `mcpGpio*` | Probe sensor feed (direct GPIO via Adafruit Blinka, default over U2IF — a Pi Pico as USB GPIO bridge): `PinToolsetter`, `PinOvertravel`, `PinProbe` (Blinka pin name with optional pull suffix, e.g. `GP6:up`, `GP7:down`, `GP8` = floating), `Inverted` (same semantics as the MQTT field — a pull-up NO switch idles `1`, so its channel goes here), `Python` (interpreter with `adafruit-blinka` installed, e.g. the venv's; default `python3`/`python`), `PollMs` (default 10, clamp 2–1000), `BlinkaEnv` (NAME=VALUE pairs handed to the monitor so Blinka picks the board — default `BLINKA_U2IF=1`; `BLINKA_MCP2221=1`, `BLINKA_FT232H=1`, `BLINKA_FORCEBOARD=…`, or `native` for on-board GPIO). Env `LUBAN_MCP_GPIO_PIN_TOOLSETTER/PIN_OVERTRAVEL/PIN_PROBE/INVERTED/PYTHON/POLL_MS/BLINKA_ENV` override field-by-field. All of it is editable on Settings → MCP Server, which also flags active env overrides. |
@@ -271,6 +271,29 @@ it with no decision point, when the operator had authorised "step 1" only. Laws:
an `ok` alone.
- Fleet: machine named "Snapmaker" @ 192.168.1.173 = the A350 CNC; "F350" @ .130 = the
3D printer. Same Luban profile identifier — distinguish by NAME/address, never profile.
+- Fleet, continued: a Snapmaker **Ray** exists but rarely comes online. Machine tokens are
+ also backed up in other configs in the Luban user data folder.
+- **Rotary stock (measured 2026-09-02, 71.2 mm probe, machine coords)**: square wooden stock
+ in the chuck, flat side up at B0. Top 207.7 at (170, 195) — physical 136.5 — rising ~+0.9
+ toward the free end (207.3–207.5 at Y250; 208.0–208.2 at Y140; 207.1/206.7 at Y270, 1 mm
+ from the chuck jaws — **jaws reach ~Y269**). Sides E≈205.4 / W≈133.5 (width ≈69.5,
+ centre X≈169.5); end face contact Y128.9 (exposed span ~Y130–300). The W side at Y140 was
+ flaky (twice lost the confirm re-contact — local chamfer/fibre?). Stock-top figures are
+ B-dependent (square stock rotates with B).
+- **Chuck fixed hole (probe_circle INSIDE mode, 8 points, rms 0.021, max residual 0.041)**:
+ centre (168.974, 290.969), hole − tip 3.734. Cross-feature constraint: air-blast post
+ diameter + hole diameter = **10.41 mm exactly** (tip-independent); the operator estimates
+ (post ~6, tip ~2.5, hole 5–9) were mutually inconsistent by ~1.8 — one caliper measurement
+ pins all three.
+- **Standing operator rules not enforced in code** (state them, don't infer around them):
+ the endmill is **always left in the spindle** — never assume an empty collet, even for
+ "safe" test moves; **home first is the default** before direct motion after (re)connecting
+ (G28 raises Z first and clears stale position state) unless the operator explicitly
+ confirms current Z and a clear path; **"Home" ALWAYS means machine home** (G28), moving to
+ work X0 Y0 is the distinct "Goto Work Origin"; quote every position with its coordinate
+ system; and **browser-approval delegation never carries forward** — if the operator lets
+ the agent click Approve for one bounded series of moves, that authority ends with that
+ series ("no approvals carry forwards in CNC work").
## Tool surface (33)
@@ -332,7 +355,9 @@ Full agent guidance in `.claude/skills/tool-change/SKILL.md`.
operator to close their copy. The build dirties `src/package.json` and
`MaterialTestGcodeParams.jsx` — revert before staging.
- **Stack**: stacked single-commit PRs `mcp/N-*`, each targeting the previous branch
- (#15 → #49 as of writing; origin = Snapmaker/Luban is NEVER pushed). Mid-stack changes:
+ (#15 → #73 as of 2026-09-04: tip `mcp/39-linux-mac-packaging`, next `mcp/40`; #70 stale-
+ heartbeat, #71 probe_sequence, #72 GPIO probe feed, #73 Linux/mac packaging; origin =
+ Snapmaker/Luban is NEVER pushed). Mid-stack changes:
amend + rebase the chain. commitlint enforces `Type: Sentence-case subject` (20–100
chars).
- **Credentials**: active gh account is `tyeth-ai-assisted` (no push). Per-command
@@ -342,6 +367,66 @@ Full agent guidance in `.claude/skills/tool-change/SKILL.md`.
- eslint judged against baseline (pre-existing errors in ConnectionManager/SstpHttpChannel
stay); `npx tsc -p tsconfig-server.json --noEmit` filtered to `services/mcp` must be
clean.
+- **Claude Code caches MCP tool schemas at session start**: after a server rebuild that adds
+ or changes tool arguments, the client strips the new args (`additionalProperties: false`)
+ until Claude Code restarts — or use a raw JSON-RPC helper
+ (`C:/dev/software/snapmaker/.tools/mcp-call.js`, or a urllib one-liner against
+ `http://127.0.0.1:40889/mcp` on the box) in the meantime.
+- **Where operator state lives** (per machine running Luban — it does NOT sync between
+ rigs): configstore `~/.snapmaker-luban.json` (`mcp*` keys incl. `mcpToolSetter`,
+ `mcpToolRegion`, `mcpSafeTraverseZ`, `mcpMaxJogDistance`); userData
+ (`%APPDATA%/snapmaker-luban` or `~/.config/snapmaker-luban`): `machine.json` (the app's
+ Machine Settings), `mcp-landmarks.json`, `mcp-camera-calibration.json`, `mcp-surveys/`.
+ While Luban runs, change state only through the MCP tools (`set_tool_setter_config`,
+ `set_landmark`, `set_tool_region`, `set_camera_calibration`) or the app UI — the
+ configstore file is rewritten on save. Migrating a second rig = replaying those tool calls
+ and copying `mcp-surveys/`.
+- **Startup facts**: cold services-ready was ~13 s → ~6 s post-reboot after the server bundle
+ (#57), ~0.7 s warm; the remaining cold cost is the keep-external packages. A Chromium HSTS
+ file can replace the userData directory ~11 s after launch (boot/quit guards rename it
+ aside, #63). Dev runs report app version 15.5.7 to the updater (Electron's own version —
+ benign).
+- **Assistant-tooling gotchas**: on Windows Git Bash, python passed through a Bash heredoc
+ gets its backslashes halved (a written `\r` arrives as a real CR) — build escapes with
+ `chr(92)` or use the editor tools, and always re-read the patched line. skill-creator's
+ scripts (validator/packager) read files in the locale codepage — run them with
+ `PYTHONUTF8=1`; the canonical skill-creator source is `anthropics/skills` (takes external
+ PRs), while `anthropics/claude-plugins-official` is a mirror that auto-closes external PRs;
+ the UTF-8 fix already has open PRs upstream (#1591 et al.) — don't add another.
+
+## Deployment notes: the Ubuntu MCP box (2026-09-04)
+
+The second rig (x86_64 Ubuntu 24.04, Celeron N4020, hostname `pi-iOTA-Flo-360`, user `pi`,
+LAN 192.168.1.153 — not a Raspberry Pi) runs the `.deb` with the GPIO probe transport and the
+toolhead camera. Everything a fresh install needs:
+
+- **Sensor bridge**: an Adafruit **KB2040** running U2IF (`239a:0105`, Blinka board id
+ `KB2040_U2IF`, pin names `D*`/`A*`). Wiring (operator, bump-tested 2026-09-04): probe
+ **A0** (idles driven HIGH → `inverted`), tool setter contact **D2** (idles LOW), setter
+ overtravel **D3** (idles LOW); all three lines are actively driven, so pulls are a
+ don't-care in operation — configured fail-safe (`A0:down`, `D2:up`, `D3:up`: a broken wire
+ reads *triggered*). Pushing the setter plunger ~1 s past contact trips overtravel.
+- **udev**: pip `hidapi` uses the **libusb** backend, so the rule must open the USB device,
+ not just hidraw — `/etc/udev/rules.d/99-u2if.rules`:
+ `SUBSYSTEM=="hidraw", ATTRS{idVendor}=="239a", ATTRS{idProduct}=="0105", MODE="0660", GROUP="plugdev", TAG+="uaccess"`
+ and the same line with `SUBSYSTEM=="usb"`; then `udevadm control --reload-rules &&
+ udevadm trigger`. Symptom without it: `OSError: open failed` from `hid.device.open`.
+- **Verify wiring** with `.venv/bin/python src/server/services/mcp/gpio_bumptest.py --seconds 90`
+ (reads the configstore, prints raw → idle/TRIGGERED, flags a resting-state-triggered
+ channel = wrong polarity).
+- **Cameras**: two are attached. The **toolhead camera is the Sonix "USB 2.0 Camera"**
+ (pinned by `/dev/v4l/by-id/usb-Sonix_…-video-index0`); at the homed position it looks
+ straight at the enclosure's aluminium extrusion, which reads as a near-field silver strut
+ on a dark textured background — that IS the toolhead view, not a stray camera. The
+ icspring camera (wide, warm view of the MDF wasteboard) is the other one. `/dev/videoN`
+ numbers reshuffle on replug; always pin the by-id entry.
+- **Launching**: Ubuntu 24.04 needs the AppArmor `userns` profile the `.deb` postinst
+ installs (see Installing); until then `snapmaker-luban --no-sandbox`. To launch into the
+ operator's GNOME/RDP session from ssh, borrow `DISPLAY`/`XAUTHORITY`/`WAYLAND_DISPLAY`
+ from a session process's `/proc//environ`.
+- **State**: a fresh box has NO operator state — replay it through the MCP tools (see
+ "Where operator state lives" above); this bit us on 2026-09-04 when an agent correctly
+ refused to run the tool setter because the reference was empty.
## Open threads
@@ -366,3 +451,14 @@ Full agent guidance in `.claude/skills/tool-change/SKILL.md`.
GPIO the `sensor_delay_ms` contact windows and release timeouts can collapse to near
zero — the defaults are still MQTT-sized, so tighten them per-call when running on
gpio.
+
+- **Procedure results only travel on the `start_gcode_job` response** — a client timeout
+ loses them (recovered once from the motion log; `probe_sequence` aborts also drop
+ completed results into the error text). Store the runner outcome on the job record.
+- **`traverse_xy` staged batch tool** (law-2-compliant XY transport twin of `move_z`): hops
+ currently need `submit_gcode_job` file jobs or `probe_sequence` hop steps.
+- **Console bug**: MCP gcode broadcasts leak into the Workspace console INPUT element with
+ raw ANSI codes.
+- **#60 install size**: bundle/asar the main process, squeeze the remaining server externals.
+- **`sensor_delay_ms` defaults are MQTT-sized** (200–300 ms); on the GPIO transport they can
+ drop to ~50 ms — per call for now, a transport-aware default later.
diff --git a/src/server/services/mcp/tools/landmarks.ts b/src/server/services/mcp/tools/landmarks.ts
index c60079973d..88086d3354 100644
--- a/src/server/services/mcp/tools/landmarks.ts
+++ b/src/server/services/mcp/tools/landmarks.ts
@@ -7,6 +7,7 @@ import { Landmark, landmarkStore } from '../landmarks';
import { probeFeedService } from '../probeFeed';
import { McpToolError, ToolRegistry } from '../registry';
import { getToolSetterConfig } from '../toolSetter';
+import { readAppMachineSettings } from './machine';
// Named scene landmarks (#50) and the stored-state overview (#53): operator
// knowledge captured once, surfaced every session, so no agent spends moves
@@ -125,7 +126,9 @@ export function registerLandmarkTools(registry: ToolRegistry): void {
device: config.get('mcpCameraDevice') || null,
lastGoodDevice: config.get('mcpCameraLastGood') || null,
},
- installedModules: config.get('mcpInstalledModules') || [],
+ // From Luban's Machine Settings (machine.json), never a private key.
+ machineSettings: readAppMachineSettings(),
+ installedModules: (readAppMachineSettings() || { modules: [] }).modules,
probeFeed: probeFeedService.status(),
toolSetter: getToolSetterConfig(),
};
diff --git a/src/server/services/mcp/tools/machine.ts b/src/server/services/mcp/tools/machine.ts
index bdaca1257b..16293a56f2 100644
--- a/src/server/services/mcp/tools/machine.ts
+++ b/src/server/services/mcp/tools/machine.ts
@@ -1,3 +1,5 @@
+import * as fs from 'fs-extra';
+import path from 'path';
import {
SnapmakerA150Machine,
SnapmakerA250Machine,
@@ -8,6 +10,8 @@ import {
SnapmakerOriginalMachine,
SnapmakerRayMachine,
} from '../../../../app/machines';
+
+import DataStorage from '../../../DataStorage';
import config from '../../configstore';
import { connectionManager } from '../../machine/ConnectionManager';
import { McpToolError, ToolRegistry } from '../registry';
@@ -44,6 +48,45 @@ function findMachine(identifier: string) {
return MACHINES.find((machine) => machine.identifier === identifier) || null;
}
+export interface AppMachineSettings {
+ /** Machine identifier as selected in Luban, e.g. "Snapmaker 2.0 A350". */
+ series: string | null;
+ /** Selected toolhead per function: printingToolhead / laserToolhead / cncToolhead. */
+ toolHead: { [kind: string]: string };
+ /** Installed add-on module identifiers, e.g. "snapmaker-2.0-bracing-kit-module". */
+ modules: string[];
+}
+
+/**
+ * The machine the OPERATOR selected in Luban's Machine Settings - series,
+ * toolheads and add-on modules (quick-swap kit, bracing kit). Read fresh from
+ * the app's own persisted store (userData/machine.json, state.machine) on
+ * every call, so a settings change - the app returns to its home page when
+ * the machine config changes - is honoured immediately. This is the single
+ * source of truth for what is installed; nothing is duplicated in the
+ * server configstore.
+ */
+export function readAppMachineSettings(): AppMachineSettings | null {
+ try {
+ const file = path.join(DataStorage.userDataDir, 'machine.json');
+ if (!fs.existsSync(file)) {
+ return null;
+ }
+ const store = fs.readJsonSync(file);
+ const machine = store && store.state && store.state.machine;
+ if (!machine || typeof machine !== 'object') {
+ return null;
+ }
+ return {
+ series: typeof machine.series === 'string' ? machine.series : null,
+ toolHead: machine.toolHead && typeof machine.toolHead === 'object' ? machine.toolHead : {},
+ modules: Array.isArray(machine.modules) ? machine.modules.map(String) : [],
+ };
+ } catch (err) {
+ return null;
+ }
+}
+
/**
* Build volume of a machine by identifier, or null when unknown.
*/
@@ -150,7 +193,7 @@ export function getPositionSnapshot(): PositionSnapshot {
const reportAgeMs = Date.now() - state.timestamp;
if (reportAgeMs > HEARTBEAT_STALE_MS) {
warnings.push(`STALE: the last heartbeat is ${(reportAgeMs / 1000).toFixed(0)}s old `
- + `(period ~1s) - the machine connection has likely dropped without the server `
+ + '(period ~1s) - the machine connection has likely dropped without the server '
+ 'noticing (observed live 2026-09-02). Do NOT trust this position; reconnect and '
+ 're-verify before any motion.');
}
@@ -188,7 +231,9 @@ export function registerMachineTools(registry: ToolRegistry): void {
registry.register({
name: 'get_machine_profile',
description: 'Machine profile: build volume, per-toolhead work ranges, and kinematics '
- + '(which element moves per axis). Defaults to the connected machine. Read-only.',
+ + '(which element moves per axis). Defaults to the connected machine, else the one '
+ + 'selected in Luban Machine Settings; installed add-on modules (bracing kit, quick-swap) '
+ + 'come from those same settings. Read-only.',
inputSchema: {
type: 'object',
properties: {
@@ -201,10 +246,11 @@ export function registerMachineTools(registry: ToolRegistry): void {
},
handler: async (args: { identifier?: string }) => {
const status = connectionManager.getConnectionStatus();
- const identifier = args.identifier || status.machineIdentifier;
+ const appSettings = readAppMachineSettings();
+ const identifier = args.identifier || status.machineIdentifier || (appSettings && appSettings.series) || '';
if (!identifier) {
- throw new McpToolError('No machine connected and no identifier given. '
- + `Known identifiers: ${MACHINES.map((m) => m.identifier).join(', ')}`);
+ throw new McpToolError('No machine connected, none selected in Luban Machine Settings, and no '
+ + `identifier given. Known identifiers: ${MACHINES.map((m) => m.identifier).join(', ')}`);
}
const machine = findMachine(identifier);
@@ -216,17 +262,14 @@ export function registerMachineTools(registry: ToolRegistry): void {
const state = connectionManager.getLatestMachineState();
// Add-on modules (quick-swap kit, bracing kit) translate the work
- // envelope by workRangeOffset. Which ones are physically installed
- // cannot be detected - the operator records it in configstore key
- // mcpInstalledModules (array or comma-separated identifiers).
+ // envelope by workRangeOffset. Which ones are installed cannot be
+ // detected from the machine - it is whatever the operator selected
+ // in Luban's Machine Settings (readAppMachineSettings).
const modules = (machine.metadata.modules || []).map((module) => ({
identifier: module.identifier,
workRangeOffset: module.workRangeOffset || null,
}));
- const installedRaw = config.get('mcpInstalledModules');
- const installedModules = (Array.isArray(installedRaw)
- ? installedRaw.map(String)
- : String(installedRaw || '').split(',').map((s) => s.trim()).filter(Boolean))
+ const installedModules = (appSettings ? appSettings.modules : [])
.filter((id) => modules.some((module) => module.identifier === id));
const netOffset = [0, 0, 0];
for (const module of modules) {
@@ -255,6 +298,8 @@ export function registerMachineTools(registry: ToolRegistry): void {
})),
modules,
installedModules,
+ installedModulesSource: appSettings ? 'Luban Machine Settings (machine.json)' : 'unavailable - machine.json not found',
+ machineSettings: appSettings,
netWorkRangeOffset: hasOffset ? netOffset : null,
// null means "not recorded" - do not guess kinematics.
kinematics: KINEMATICS_BY_IDENTIFIER[machine.identifier] || null,
From 1e4bec6ed1ad965e19f406cdfa7f61ee22e10f88 Mon Sep 17 00:00:00 2001
From: tyeth
Date: Sat, 5 Sep 2026 00:50:49 +0100
Subject: [PATCH 054/135] Feature: per-sensor enable switches, tolerant
unplugged bridge, optional LAN access
Settings -> MCP Server gains "Tool setter (contact + overtravel)" and
"Touch probe" switches (mcpToolSetterEnabled / mcpProbeToolEnabled, env
LUBAN_MCP_TOOLSETTER_ENABLED / LUBAN_MCP_PROBE_ENABLED, default on). A
disabled sensor's channel is unbound on every transport - no topic or pin
subscribed, no readings, no Workspace pill - and every procedure that
needs it refuses through assertChannelReady with a message naming the
setting; the feed is not even connected when only disabled sensors are
configured.
The operator will often run without the USB sensor bridge plugged in.
Blinka's "BLINKA_U2IF ... no compatible device found" (and hid "open
failed") is now classified as `bridge: not detected`; the service still
retries with backoff but logs each distinct error once and thins the
reconnect log after the third attempt, and get_probe_feed_status reports
`unavailable: true` so the UI shows unknown pills instead of an error
storm.
mcpAllowLan / LUBAN_MCP_ALLOW_LAN (default off) binds the server on every
interface but accepts only clients - and browser origins - on this
machine's own IPv4 subnets (IPv4-mapped IPv6 unwrapped, other IPv6
refused); confirm-page links then use the LAN address, preferring real
subnets over /32 VPN interfaces. There is no authentication, so the pane
shows a red warning and lists the LAN URLs. Verified the subnet gate
against this machine's real interfaces (same subnet incl. ::ffff: form
allowed; other subnets, public, IPv6, garbage refused). README config
table, architecture note and skills updated.
Co-Authored-By: Claude Fable 5.1
---
.claude/skills/cnc-probing/SKILL.md | 7 +-
.claude/skills/tool-change/SKILL.md | 6 +-
src/app/resources/i18n/en/resource.json | 8 ++
.../settings-modal/McpServer/index.tsx | 60 +++++++-
src/server/services/api/api-mcp.js | 34 ++++-
src/server/services/mcp/McpServer.ts | 71 ++++++++++
src/server/services/mcp/README.md | 13 +-
src/server/services/mcp/gpioFeed.ts | 23 ++-
src/server/services/mcp/index.ts | 86 +++++++++--
src/server/services/mcp/probeFeed.ts | 133 ++++++++++++++++--
src/server/services/mcp/probing.ts | 6 +-
11 files changed, 408 insertions(+), 39 deletions(-)
diff --git a/.claude/skills/cnc-probing/SKILL.md b/.claude/skills/cnc-probing/SKILL.md
index e2803933b9..edb5b5580b 100644
--- a/.claude/skills/cnc-probing/SKILL.md
+++ b/.claude/skills/cnc-probing/SKILL.md
@@ -114,7 +114,12 @@ readings are polled every 10 ms locally, so `sensor_delay_ms` can drop to
just slower than necessary. Before any sensor-gated run, sanity-check the
sensors: the Workspace -> Connection pills (Probe / Tool Setter / Setter
Overtravel) must all be green (yellow = no reading or feed down), and
-`get_probe_feed_status` must show the channel untriggered with a fresh age.
+`get_probe_feed_status` must show the channel untriggered with a fresh age. Two
+non-error states you may meet: `unavailable: true` / `bridge: not detected` means
+the USB sensor bridge is unplugged (the feed retries quietly - tell the operator);
+a tool refusing with "the touch probe / tool setter is disabled (Settings -> MCP
+Server)" means the operator switched that sensor off in the app - ask, never
+bypass. Sensors the operator has disabled have no pill at all.
## Circle probing
diff --git a/.claude/skills/tool-change/SKILL.md b/.claude/skills/tool-change/SKILL.md
index f6b79d1673..9129eb72e7 100644
--- a/.claude/skills/tool-change/SKILL.md
+++ b/.claude/skills/tool-change/SKILL.md
@@ -15,7 +15,11 @@ shifted exactly, without ever re-touching the stock.
- Probe feed connected (`get_probe_feed_status` to check; `connect_probe_feed`
if not - the feed auto-connects at start when configured) and the machine
- homed and idle. The tool setter's overtravel switch is a tripwire ONLY while
+ homed and idle. If the status shows `unavailable: true` / `bridge: not
+ detected`, the USB sensor bridge is unplugged - tell the operator, do not
+ work around it. If a tool refuses with "the tool setter is disabled
+ (Settings -> MCP Server)", the operator switched that sensor off in the app;
+ ask them to enable it - never proceed without the sensor. The tool setter's overtravel switch is a tripwire ONLY while
this procedure (or other MCP motion) is running: pushing the setter past
contact mid-run latches the alarm; by hand with the machine idle it just
flashes the Workspace pill. The Workspace -> Connection pills (Tool Setter /
diff --git a/src/app/resources/i18n/en/resource.json b/src/app/resources/i18n/en/resource.json
index b38584c046..99412006b1 100644
--- a/src/app/resources/i18n/en/resource.json
+++ b/src/app/resources/i18n/en/resource.json
@@ -1105,6 +1105,14 @@
"key-App/Settings/McpServer-External tool setter, overtravel and touch probe sensors report over this feed. Applies at the next feed connection.": "External tool setter, overtravel and touch probe sensors report over this feed. Applies at the next feed connection.",
"key-App/Settings/McpServer-Feed fields accept an Adafruit IO feed key or a full topic path.": "Feed fields accept an Adafruit IO feed key or a full topic path.",
"key-App/Settings/McpServer-Transport": "Transport",
+ "key-App/Settings/McpServer-LAN access is ON: hosts on this machine's own subnets are accepted too.": "LAN access is ON: hosts on this machine's own subnets are accepted too.",
+ "key-App/Settings/McpServer-Allow access from the local network (same subnet only; applies after restart)": "Allow access from the local network (same subnet only; applies after restart)",
+ "key-App/Settings/McpServer-WARNING: there is no authentication. Anyone on your local network can then command the machine. Only enable on a trusted network, and never leave the machine unattended.": "WARNING: there is no authentication. Anyone on your local network can then command the machine. Only enable on a trusted network, and never leave the machine unattended.",
+ "key-App/Settings/McpServer-LAN URLs:": "LAN URLs:",
+ "key-App/Settings/McpServer-Overridden by LUBAN_MCP_ALLOW_LAN": "overridden by LUBAN_MCP_ALLOW_LAN environment variable",
+ "key-App/Settings/McpServer-Tool setter (contact + overtravel sensors)": "Tool setter (contact + overtravel sensors)",
+ "key-App/Settings/McpServer-Touch probe": "Touch probe",
+ "key-App/Settings/McpServer-A disabled sensor is never bound: no pill, no readings, and procedures that need it refuse. If the USB sensor bridge is unplugged the feed just reports \"not detected\" and keeps retrying quietly - disable the sensors here when you know it will be absent.": "A disabled sensor is never bound: no pill, no readings, and procedures that need it refuse. If the USB sensor bridge is unplugged the feed just reports \"not detected\" and keeps retrying quietly — disable the sensors here when you know it will be absent.",
"key-App/Settings/McpServer-Auto": "Auto",
"key-App/Settings/McpServer-Active this session:": "Active this session:",
"key-App/Settings/McpServer-Overridden by LUBAN_MCP_PROBE_TRANSPORT": "overridden by LUBAN_MCP_PROBE_TRANSPORT environment variable",
diff --git a/src/app/ui/pages/global-modals/settings-modal/McpServer/index.tsx b/src/app/ui/pages/global-modals/settings-modal/McpServer/index.tsx
index aab21a1f93..a96ea302de 100644
--- a/src/app/ui/pages/global-modals/settings-modal/McpServer/index.tsx
+++ b/src/app/ui/pages/global-modals/settings-modal/McpServer/index.tsx
@@ -49,6 +49,12 @@ interface McpTransportSettings {
active: 'mqtt' | 'gpio';
}
+interface McpSensorSettings {
+ toolSetter: boolean;
+ probe: boolean;
+ envOverrides: string[];
+}
+
interface McpStatus {
running: boolean;
port: number | null;
@@ -57,8 +63,12 @@ interface McpStatus {
enabled: boolean;
port: number;
source: 'env' | 'config';
+ allowLan: boolean;
+ allowLanSource: 'env' | 'config' | 'default';
};
+ lanUrls: string[];
transport: McpTransportSettings;
+ sensors: McpSensorSettings;
mqtt: McpMqttSettings;
gpio: McpGpioSettings;
}
@@ -108,6 +118,9 @@ const McpServer: React.FC = () => {
const [status, setStatus] = useState(null);
const [enabled, setEnabled] = useState(false);
const [port, setPort] = useState('');
+ const [allowLan, setAllowLan] = useState(false);
+ const [toolSetterEnabled, setToolSetterEnabled] = useState(true);
+ const [probeEnabled, setProbeEnabled] = useState(true);
const [transport, setTransport] = useState('');
const [mqtt, setMqtt] = useState<{ [field: string]: string }>({});
const [inverted, setInverted] = useState<{ [channel: string]: boolean }>({});
@@ -123,6 +136,11 @@ const McpServer: React.FC = () => {
setStatus(body);
setEnabled(body.settings.enabled);
setPort(String(body.settings.port));
+ setAllowLan(!!body.settings.allowLan);
+ if (body.sensors) {
+ setToolSetterEnabled(body.sensors.toolSetter !== false);
+ setProbeEnabled(body.sensors.probe !== false);
+ }
setTransport(body.transport ? body.transport.stored : '');
const { inverted: mqttInvertedNames, ...mqttValues } = body.mqtt.values;
@@ -150,7 +168,15 @@ const McpServer: React.FC = () => {
}
const gpioUpdate: { [field: string]: string } = { ...gpio };
gpioUpdate.inverted = CHANNELS.filter((channel) => gpioInverted[channel]).join(',');
- await api.setMcpSettings({ enabled, port: value, transport, mqtt: mqttUpdate, gpio: gpioUpdate });
+ await api.setMcpSettings({
+ enabled,
+ port: value,
+ allowLan,
+ sensors: { toolSetter: toolSetterEnabled, probe: probeEnabled },
+ transport,
+ mqtt: mqttUpdate,
+ gpio: gpioUpdate,
+ });
};
useEffect(() => {
@@ -230,9 +256,30 @@ const McpServer: React.FC = () => {
className={styles['port-input']}
/>
- {i18n._('key-App/Settings/McpServer-Local agents connect at')} http://127.0.0.1:<port>/mcp. {i18n._('key-App/Settings/McpServer-Loopback only; never reachable from the network')}
+ {i18n._('key-App/Settings/McpServer-Local agents connect at')} http://127.0.0.1:<port>/mcp. {allowLan
+ ? i18n._('key-App/Settings/McpServer-LAN access is ON: hosts on this machine\'s own subnets are accepted too.')
+ : i18n._('key-App/Settings/McpServer-Loopback only; never reachable from the network')}
+
+ setAllowLan(checked)}
+ disabled={!enabled || !!(status && status.settings && status.settings.allowLanSource === 'env')}
+ />
+ {i18n._('key-App/Settings/McpServer-Allow access from the local network (same subnet only; applies after restart)')}
+
+ {allowLan && (
+
+ {i18n._('key-App/Settings/McpServer-WARNING: there is no authentication. Anyone on your local network can then command the machine. Only enable on a trusted network, and never leave the machine unattended.')}
+ {status && status.lanUrls && status.lanUrls.length > 0 && (
+
{i18n._('key-App/Settings/McpServer-External tool setter, overtravel and touch probe sensors report over this feed. Applies at the next feed connection.')}
+ {i18n._('key-App/Settings/McpServer-A disabled sensor is never bound: no pill, no readings, and procedures that need it refuse. If the USB sensor bridge is unplugged the feed just reports "not detected" and keeps retrying quietly - disable the sensors here when you know it will be absent.')}
+
{i18n._('key-App/Settings/McpServer-Transport')} !!(process.env[name] || '').trim()),
+ };
+}
+
function transportSettings() {
return {
// What the operator stored (may be empty = auto), and what is live.
@@ -110,7 +123,7 @@ function transportSettings() {
}
export const getStatus = (req, res) => {
- res.send({ ...getMcpStatus(), transport: transportSettings(), mqtt: mqttSettings(), gpio: gpioSettings() });
+ res.send({ ...getMcpStatus(), transport: transportSettings(), sensors: sensorSettings(), mqtt: mqttSettings(), gpio: gpioSettings() });
};
/**
@@ -142,7 +155,7 @@ export const clearAlarm = (req, res) => {
* omitted field is left unchanged (the pane omits an untouched password).
*/
export const updateSettings = (req, res) => {
- const { enabled, port, mqtt, gpio, transport } = req.body || {};
+ const { enabled, port, allowLan, sensors, mqtt, gpio, transport } = req.body || {};
if (port !== undefined) {
const value = Number(port);
@@ -155,6 +168,19 @@ export const updateSettings = (req, res) => {
if (enabled !== undefined) {
config.set('mcpEnabled', !!enabled);
}
+ if (allowLan !== undefined) {
+ // Applies at the next start (bind address). No authentication exists:
+ // the pane carries the warning; here we only persist the choice.
+ config.set('mcpAllowLan', !!allowLan);
+ }
+ if (sensors && typeof sensors === 'object') {
+ if (sensors.toolSetter !== undefined) {
+ config.set('mcpToolSetterEnabled', !!sensors.toolSetter);
+ }
+ if (sensors.probe !== undefined) {
+ config.set('mcpProbeToolEnabled', !!sensors.probe);
+ }
+ }
if (mqtt && typeof mqtt === 'object') {
for (const [field, key] of Object.entries(MQTT_FIELD_KEYS)) {
@@ -214,5 +240,5 @@ export const updateSettings = (req, res) => {
}
}
- res.send({ ...getMcpStatus(), transport: transportSettings(), mqtt: mqttSettings(), gpio: gpioSettings() });
+ res.send({ ...getMcpStatus(), transport: transportSettings(), sensors: sensorSettings(), mqtt: mqttSettings(), gpio: gpioSettings() });
};
diff --git a/src/server/services/mcp/McpServer.ts b/src/server/services/mcp/McpServer.ts
index 0c3715fc98..a09e68f2fe 100644
--- a/src/server/services/mcp/McpServer.ts
+++ b/src/server/services/mcp/McpServer.ts
@@ -1,3 +1,4 @@
+import os from 'os';
import http from 'http';
import logger from '../../lib/logger';
@@ -49,6 +50,76 @@ export function isLoopback(address: string | undefined): boolean {
return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1';
}
+function ipv4ToInt(address: string): number | null {
+ const parts = address.split('.');
+ if (parts.length !== 4) {
+ return null;
+ }
+ let value = 0;
+ for (const part of parts) {
+ const n = Number(part);
+ if (!Number.isInteger(n) || n < 0 || n > 255) {
+ return null;
+ }
+ value = (value * 256) + n;
+ }
+ return value;
+}
+
+/**
+ * IPv4 addresses of this machine's own non-internal interfaces, with their
+ * netmasks - the subnets an operator on "the local network" sits on.
+ */
+export function localSubnets(): { address: string; netmask: string }[] {
+ const result: { address: string; netmask: string }[] = [];
+ const interfaces = os.networkInterfaces();
+ for (const name of Object.keys(interfaces)) {
+ for (const iface of interfaces[name] || []) {
+ if (!iface.internal && (iface.family === 'IPv4' || (iface.family as unknown) === 4)) {
+ result.push({ address: iface.address, netmask: iface.netmask });
+ }
+ }
+ }
+ return result;
+}
+
+/**
+ * True when the remote address is on one of this machine's own IPv4 subnets
+ * (mcpAllowLan). IPv4-mapped IPv6 is unwrapped; other IPv6 is refused - the
+ * LAN option is deliberately narrow, not "anything routable".
+ */
+export function isLocalSubnetAddress(address: string | undefined): boolean {
+ if (!address) {
+ return false;
+ }
+ const plain = address.startsWith('::ffff:') ? address.slice(7) : address;
+ const remote = ipv4ToInt(plain);
+ if (remote === null) {
+ return false;
+ }
+ for (const subnet of localSubnets()) {
+ const local = ipv4ToInt(subnet.address);
+ const mask = ipv4ToInt(subnet.netmask);
+ if (local === null || mask === null) {
+ continue;
+ }
+ // eslint-disable-next-line no-bitwise
+ if (((remote & mask) >>> 0) === ((local & mask) >>> 0)) {
+ return true;
+ }
+ }
+ return false;
+}
+
+/** Browser origins acceptable in LAN mode: a host on one of our subnets. */
+export function isLocalSubnetOrigin(origin: string | undefined): boolean {
+ if (!origin) {
+ return true;
+ }
+ const match = origin.match(/^https?:\/\/([0-9.]+)(:\d+)?$/);
+ return !!match && isLocalSubnetAddress(match[1]);
+}
+
export class McpServer {
private registry: ToolRegistry;
diff --git a/src/server/services/mcp/README.md b/src/server/services/mcp/README.md
index ea2d42489a..2eb0d3e6e5 100644
--- a/src/server/services/mcp/README.md
+++ b/src/server/services/mcp/README.md
@@ -16,6 +16,8 @@ serves them, and `LUBAN_MCP_PORT` (env) overrides everything for one run.
| Key | Meaning |
|---|---|
| `mcpEnabled`, `mcpPort` | Start the server on 127.0.0.1:port (default 40889). Legacy: `mcpPort` alone enables when `mcpEnabled` was never written. |
+| `mcpAllowLan` | Default off = loopback only. On: bind every interface but accept only clients (and browser origins) on this machine's own IPv4 subnets; confirm-page links use the LAN address. **No authentication exists** — anyone on that subnet can command the machine; the pane warns in red. Env `LUBAN_MCP_ALLOW_LAN` overrides. Applies at the next start. |
+| `mcpToolSetterEnabled`, `mcpProbeToolEnabled` | Default on. Off = that sensor's channel is never bound on any transport (overtravel follows the tool setter): no pill, no readings, and procedures needing it refuse with a clear message. Use when the sensor or the USB bridge is not fitted. Env `LUBAN_MCP_TOOLSETTER_ENABLED` / `LUBAN_MCP_PROBE_ENABLED` override. |
| `mcpCameraUrl` | HTTP(S) snapshot URL; takes precedence over ffmpeg. |
| `mcpFfmpegPath`, `mcpCameraDevice`, `mcpCameraLastGood` | ffmpeg capture — DirectShow on Windows (device = friendly name), v4l2 on Linux (device = a `list_cameras` entry, preferably the stable `/dev/v4l/by-id/… (Name)` form; a bare `/dev/videoN` works but renumbers on replug). Device choice is sticky (last-good preferred); a vanished device is an error, never a silent substitution. |
| `mcpMaxJogDistance` | Per-call XY travel cap for direct moves, default 100 mm. `goto_work_origin` is exempt (fixed operator-set destination). |
@@ -68,7 +70,8 @@ A project-scope `.mcp.json` at the repo root points Claude Code sessions at
## Architecture
-Own `http.Server` bound strictly to loopback — NOT a route on Luban's Express app (whose
+Own `http.Server` bound to loopback by default (`mcpAllowLan` widens it to this machine's own IPv4
+subnets, with the same-subnet check on every request) — NOT a route on Luban's Express app (whose
`/api` carries the renderer session JWT and whose IP whitelist is LAN-wide). The MCP
transport is hand-rolled stateless Streamable HTTP (JSON-RPC over `POST /mcp`): Electron 15
embeds Node 16 and the official SDK needs ≥ 18. Zero added dependencies anywhere —
@@ -116,7 +119,13 @@ backends exist, selected by `mcpProbeTransport` / `LUBAN_MCP_PROBE_TRANSPORT`:
PICO_U2IF (board detect, pulls, heartbeats) and a stubbed Blinka (change detection,
fatal paths incl. unknown-pin reporting the board's available pins).
-The feed auto-connects at service start when fully configured.
+The feed auto-connects at service start when fully configured. **Tolerance**: if the sensor
+bridge is unplugged (Blinka: "BLINKA_U2IF … no compatible device found") the GPIO transport
+reports `bridge: not detected`, the service retries with backoff but logs each distinct error
+once (attempt logs thin out after the third), `get_probe_feed_status` shows
+`unavailable: true`, pills stay yellow, and every sensor-gated procedure refuses via
+`assertChannelReady`. Operators who know a sensor is absent switch it off instead
+(`mcpToolSetterEnabled` / `mcpProbeToolEnabled`): a disabled channel is unbound everywhere.
**Overtravel tripwire**: while a sensor-gated procedure is running (the tool setter /
probing runners declare expected contacts for their whole run) or MCP direct motion is in
diff --git a/src/server/services/mcp/gpioFeed.ts b/src/server/services/mcp/gpioFeed.ts
index 9a45422b27..ab0994c549 100644
--- a/src/server/services/mcp/gpioFeed.ts
+++ b/src/server/services/mcp/gpioFeed.ts
@@ -267,6 +267,8 @@ export class GpioProbeTransport extends EventEmitter implements ProbeTransport {
private boardId: string | null = null;
+ private bridgeMissing = false;
+
private stallTimer: NodeJS.Timeout | null = null;
private ended = false;
@@ -414,6 +416,7 @@ export class GpioProbeTransport extends EventEmitter implements ProbeTransport {
blinkaEnv: this.cfg.blinkaEnvText,
pollMs: this.cfg.pollMs,
board: this.boardId,
+ bridge: this.bridgeState(),
monitorPid: this.child ? this.child.pid : null,
configSources: this.cfg.sources,
};
@@ -448,7 +451,18 @@ export class GpioProbeTransport extends EventEmitter implements ProbeTransport {
return;
}
if (message.t === 'fatal') {
- this.lastFatal = String(message.error || 'unknown fatal error');
+ const raw = String(message.error || 'unknown fatal error');
+ // Blinka's wording when BLINKA_U2IF is set and no bridge is on USB,
+ // or the device node vanished mid-session: the operator simply has
+ // not plugged the sensor bridge in. Say that, not "import failed".
+ if (/no compatible device found|open failed|No such device|device disconnected/i.test(raw)) {
+ this.bridgeMissing = true;
+ this.lastFatal = 'sensor bridge not detected on USB (U2IF board unplugged?) - '
+ + 'the feed will keep retrying quietly; plug it in or disable the sensors in Settings';
+ } else {
+ this.bridgeMissing = false;
+ this.lastFatal = raw;
+ }
if (message.available) {
this.lastFatal += ` - available pins: ${(message.available as string[]).join(', ')}`;
}
@@ -501,6 +515,13 @@ export class GpioProbeTransport extends EventEmitter implements ProbeTransport {
}
}
+ private bridgeState(): 'connected' | 'not detected' | 'unknown' {
+ if (this.ready) {
+ return 'connected';
+ }
+ return this.bridgeMissing ? 'not detected' : 'unknown';
+ }
+
private detailSuffix(): string {
const detail = this.lastFatal || this.stderrTail.trim().split('\n').slice(-3).join(' | ');
return detail ? `: ${detail}` : '';
diff --git a/src/server/services/mcp/index.ts b/src/server/services/mcp/index.ts
index 47eca1c78d..cf7c07cd1a 100644
--- a/src/server/services/mcp/index.ts
+++ b/src/server/services/mcp/index.ts
@@ -3,7 +3,7 @@ import http from 'http';
import pkg from '../../../package.json';
import logger from '../../lib/logger';
import config from '../configstore';
-import { McpServer, isAllowedOrigin, isLoopback } from './McpServer';
+import { McpServer, isAllowedOrigin, isLocalSubnetAddress, isLocalSubnetOrigin, isLoopback, localSubnets } from './McpServer';
import { jobManager } from './jobs';
import { probeFeedService, resolveActiveProbeConfig } from './probeFeed';
import { ToolRegistry } from './registry';
@@ -20,11 +20,17 @@ import { registerToolSetterTools } from './tools/toolsetter';
const log = logger('service:mcp');
// Off by default. Enabled by setting a port, either through the environment
-// or the server configstore; loopback only, so reachable by local processes
-// but never from the LAN the machine itself sits on.
+// or the server configstore. Loopback only by default - reachable by local
+// processes but never from the LAN the machine sits on. mcpAllowLan /
+// LUBAN_MCP_ALLOW_LAN opts into listening on every interface, still refusing
+// any client that is not on one of this machine's own IPv4 subnets. There is
+// no authentication: anyone on that subnet can then command the machine, so
+// the Settings pane says so in red.
const PORT_ENV = 'LUBAN_MCP_PORT';
+const ALLOW_LAN_ENV = 'LUBAN_MCP_ALLOW_LAN';
const PORT_CONFIG_KEY = 'mcpPort';
const ENABLED_CONFIG_KEY = 'mcpEnabled';
+const ALLOW_LAN_CONFIG_KEY = 'mcpAllowLan';
const DEFAULT_PORT = 40889;
let httpServer: http.Server | null = null;
@@ -52,6 +58,21 @@ interface McpSettings {
enabled: boolean;
port: number;
source: 'env' | 'config';
+ /** Accept clients from this machine's own IPv4 subnets, not just loopback. */
+ allowLan: boolean;
+ allowLanSource: 'env' | 'config' | 'default';
+}
+
+function resolveAllowLan(): { allowLan: boolean; source: 'env' | 'config' | 'default' } {
+ const envRaw = process.env[ALLOW_LAN_ENV];
+ if (envRaw !== undefined && String(envRaw).trim() !== '') {
+ return { allowLan: ['1', 'true', 'yes', 'on'].includes(String(envRaw).trim().toLowerCase()), source: 'env' };
+ }
+ const configRaw = config.get(ALLOW_LAN_CONFIG_KEY);
+ if (configRaw !== undefined && configRaw !== null) {
+ return { allowLan: !!configRaw, source: 'config' };
+ }
+ return { allowLan: false, source: 'default' };
}
function resolveSettings(): McpSettings {
@@ -61,7 +82,8 @@ function resolveSettings(): McpSettings {
if (envPort === null) {
log.error(`Ignoring invalid ${PORT_ENV}: ${envRaw}`);
} else {
- return { enabled: true, port: envPort, source: 'env' };
+ const lan = resolveAllowLan();
+ return { enabled: true, port: envPort, source: 'env', allowLan: lan.allowLan, allowLanSource: lan.source };
}
}
@@ -72,7 +94,32 @@ function resolveSettings(): McpSettings {
const enabled = (enabledRaw === undefined || enabledRaw === null)
? configPort !== null
: !!enabledRaw;
- return { enabled, port: configPort || DEFAULT_PORT, source: 'config' };
+ const lan = resolveAllowLan();
+ return { enabled, port: configPort || DEFAULT_PORT, source: 'config', allowLan: lan.allowLan, allowLanSource: lan.source };
+}
+
+/**
+ * LAN interfaces in the order an operator would expect: real subnets first,
+ * point-to-point /32 addresses (VPN/tailnet interfaces) last - a /32 only
+ * ever matches the machine itself, so it is a poor address to hand out.
+ */
+function orderedLanSubnets(): { address: string; netmask: string }[] {
+ return [...localSubnets()].sort((a, b) => Number(a.netmask === '255.255.255.255') - Number(b.netmask === '255.255.255.255'));
+}
+
+/** Where a browser should open confirm pages: a LAN address when LAN mode is on, else loopback. */
+function publicBaseUrl(port: number, allowLan: boolean): string {
+ if (allowLan) {
+ const subnet = orderedLanSubnets()[0];
+ if (subnet) {
+ return `http://${subnet.address}:${port}`;
+ }
+ }
+ return `http://127.0.0.1:${port}`;
+}
+
+function lanUrls(port: number): string[] {
+ return orderedLanSubnets().map((subnet) => `http://${subnet.address}:${port}/mcp`);
}
/**
@@ -86,6 +133,9 @@ export function getMcpStatus() {
port: runningPort,
toolCount: registeredToolCount,
settings,
+ // LAN URLs an agent on the same subnet can use (only meaningful when
+ // allowLan is on AND the server is running with it).
+ lanUrls: settings.allowLan ? lanUrls(settings.port) : [],
// Sensor feed snapshot for the Workspace connection pills; live
// updates arrive over mcp:activity (tool 'probe_feed').
probeFeed: probeFeedService.status(),
@@ -110,13 +160,14 @@ export function startMcpService(socketServer?: McpBroadcaster): void {
const registry = new ToolRegistry();
registerStatusTools(registry);
registerMachineTools(registry);
- registerGcodeTools(registry, () => `http://127.0.0.1:${port}`);
+ const baseUrl = () => publicBaseUrl(port, settings.allowLan);
+ registerGcodeTools(registry, baseUrl);
registerCameraTools(registry);
registerCalibrationTools(registry);
registerLandmarkTools(registry);
registerProbeTools(registry);
- registerToolSetterTools(registry, () => `http://127.0.0.1:${port}`);
- registerProbingTools(registry, () => `http://127.0.0.1:${port}`);
+ registerToolSetterTools(registry, baseUrl);
+ registerProbingTools(registry, baseUrl);
registeredToolCount = registry.list().length;
broadcaster = socketServer || null;
@@ -130,9 +181,14 @@ export function startMcpService(socketServer?: McpBroadcaster): void {
const mcpServer = new McpServer(registry, 'snapmaker-luban', pkg.version, onActivity);
httpServer = http.createServer((req, res) => {
- // Same trust boundary for every route: local processes only, and no
- // browser contexts other than localhost or the app's own scheme.
- if (!isLoopback(req.socket.remoteAddress) || !isAllowedOrigin(req.headers.origin)) {
+ // Same trust boundary for every route: local processes only (plus, in
+ // LAN mode, hosts on this machine's own subnets), and no browser
+ // contexts other than localhost / the app's own scheme (or, in LAN
+ // mode, a same-subnet host).
+ const remote = req.socket.remoteAddress;
+ const addressOk = isLoopback(remote) || (settings.allowLan && isLocalSubnetAddress(remote));
+ const originOk = isAllowedOrigin(req.headers.origin) || (settings.allowLan && isLocalSubnetOrigin(req.headers.origin));
+ if (!addressOk || !originOk) {
res.writeHead(403, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'forbidden' }));
return;
@@ -152,9 +208,13 @@ export function startMcpService(socketServer?: McpBroadcaster): void {
httpServer = null;
runningPort = null;
});
- httpServer.listen(port, '127.0.0.1', () => {
+ const bindHost = settings.allowLan ? '0.0.0.0' : '127.0.0.1';
+ httpServer.listen(port, bindHost, () => {
runningPort = port;
- log.info(`MCP server listening at http://127.0.0.1:${port}/mcp`);
+ const reach = settings.allowLan
+ ? ` and on the local subnets: ${lanUrls(port).join(', ') || '(no LAN interface found)'}`
+ : ' (loopback only)';
+ log.info(`MCP server listening at http://127.0.0.1:${port}/mcp${reach}`);
});
// Arm the external probe feed (and its overtravel tripwire) without any
diff --git a/src/server/services/mcp/probeFeed.ts b/src/server/services/mcp/probeFeed.ts
index 7beb782742..369f045514 100644
--- a/src/server/services/mcp/probeFeed.ts
+++ b/src/server/services/mcp/probeFeed.ts
@@ -155,6 +155,42 @@ export function resolveProbeFeedConfig(): ProbeFeedConfig {
};
}
+/**
+ * Which sensors the operator has enabled (Settings -> MCP Server; env
+ * LUBAN_MCP_TOOLSETTER_ENABLED / LUBAN_MCP_PROBE_ENABLED override). Default
+ * on. A disabled sensor's channel is left UNBOUND on every transport: no
+ * topic/pin is subscribed, no readings, no pill, and every procedure that
+ * needs it refuses with a clear message. Overtravel belongs to the tool
+ * setter (same mechanism) and follows its flag.
+ */
+export interface SensorEnabled {
+ toolsetter: boolean;
+ overtravel: boolean;
+ probe: boolean;
+}
+
+function readEnabledFlag(env: string, key: string): boolean {
+ const envRaw = process.env[env];
+ if (envRaw !== undefined && String(envRaw).trim() !== '') {
+ return !['0', 'false', 'no', 'off'].includes(String(envRaw).trim().toLowerCase());
+ }
+ const configRaw = config.get(key);
+ if (configRaw === undefined || configRaw === null) {
+ return true;
+ }
+ return !(configRaw === false || ['0', 'false', 'no', 'off'].includes(String(configRaw).trim().toLowerCase()));
+}
+
+export function resolveSensorEnabled(): SensorEnabled {
+ const toolsetter = readEnabledFlag('LUBAN_MCP_TOOLSETTER_ENABLED', 'mcpToolSetterEnabled');
+ const probe = readEnabledFlag('LUBAN_MCP_PROBE_ENABLED', 'mcpProbeToolEnabled');
+ return { toolsetter, overtravel: toolsetter, probe };
+}
+
+export function sensorLabel(channel: ProbeChannel): string {
+ return channel === 'probe' ? 'touch probe' : 'tool setter';
+}
+
/**
* Which transport backend the probe feed uses. Explicit setting wins
* (LUBAN_MCP_PROBE_TRANSPORT env, then mcpProbeTransport config); with
@@ -184,24 +220,56 @@ export interface ActiveProbeConfig {
/** Per channel: the MQTT topic or GPIO pin label bound to it, if any. */
channels: { [channel in ProbeChannel]: string | null };
inverted: { [channel in ProbeChannel]: boolean };
+ /** Channels the operator switched off in Settings (never bound). */
+ disabled: ProbeChannel[];
/** Where the operator fixes a missing configuration, for error messages. */
settingsHint: string;
}
+/**
+ * Apply the sensor enable flags to a transport's channel bindings: a
+ * disabled channel is unbound, and "configured" is re-judged on what is
+ * left so a rig with only disabled sensors bound does not try to connect.
+ */
+function applySensorFlags(
+ channels: { [channel in ProbeChannel]: string | null },
+ missing: string[]
+): { channels: { [channel in ProbeChannel]: string | null }; missing: string[]; disabled: ProbeChannel[] } {
+ const enabled = resolveSensorEnabled();
+ const disabled = PROBE_CHANNELS.filter((channel) => !enabled[channel]);
+ const filtered = {} as { [channel in ProbeChannel]: string | null };
+ for (const channel of PROBE_CHANNELS) {
+ filtered[channel] = enabled[channel] ? channels[channel] : null;
+ }
+ const remaining = missing.filter((item) => !/^at least one (feed topic|pin)/.test(item));
+ if (!PROBE_CHANNELS.some((channel) => filtered[channel])) {
+ if (disabled.length === PROBE_CHANNELS.length) {
+ remaining.push('every sensor is disabled in Settings -> MCP Server');
+ } else if (PROBE_CHANNELS.some((channel) => channels[channel])) {
+ remaining.push('the only configured sensors are disabled in Settings -> MCP Server');
+ } else {
+ remaining.push(`at least one ${resolveProbeTransportKind() === 'gpio' ? 'pin' : 'feed topic'} for an enabled sensor`);
+ }
+ }
+ return { channels: filtered, missing: remaining, disabled };
+}
+
export function resolveActiveProbeConfig(): ActiveProbeConfig {
const kind = resolveProbeTransportKind();
if (kind === 'gpio') {
const cfg = resolveGpioFeedConfig();
- const channels = {} as { [channel in ProbeChannel]: string | null };
+ const bound = {} as { [channel in ProbeChannel]: string | null };
for (const channel of PROBE_CHANNELS) {
- channels[channel] = describePin(cfg.pins[channel]);
+ bound[channel] = describePin(cfg.pins[channel]);
}
+ const applied = applySensorFlags(bound, cfg.missing);
return {
kind,
- configured: cfg.configured,
- missing: cfg.missing,
- channels,
+ configured: applied.missing.length === 0,
+ missing: applied.missing,
+ channels: applied.channels,
inverted: cfg.inverted,
+ disabled: applied.disabled,
settingsHint: 'Set LUBAN_MCP_GPIO_PIN_TOOLSETTER/_OVERTRAVEL/_PROBE (Blinka pin name with an '
+ 'optional :up/:down/:float pull suffix, e.g. "GP6:up"), plus LUBAN_MCP_GPIO_PYTHON, '
+ '_INVERTED, _POLL_MS, _BLINKA_ENV as needed - or the matching mcpGpio* config keys '
@@ -209,12 +277,14 @@ export function resolveActiveProbeConfig(): ActiveProbeConfig {
};
}
const cfg = resolveProbeFeedConfig();
+ const applied = applySensorFlags(cfg.topics, cfg.missing);
return {
kind,
- configured: cfg.configured,
- missing: cfg.missing,
- channels: cfg.topics,
+ configured: applied.missing.length === 0,
+ missing: applied.missing,
+ channels: applied.channels,
inverted: cfg.inverted,
+ disabled: applied.disabled,
settingsHint: 'Set the MQTT fields on Settings -> MCP Server or the LUBAN_MCP_MQTT_* '
+ 'environment variables.',
};
@@ -390,11 +460,29 @@ class MqttProbeTransport extends EventEmitter implements ProbeTransport {
}
}
-/** Build a fresh transport for the kind from freshly resolved configuration. */
+/**
+ * Build a fresh transport for the kind from freshly resolved configuration,
+ * with disabled sensors' channels removed so they are never subscribed or
+ * polled.
+ */
function buildTransport(kind: ProbeTransportKind): ProbeTransport {
- return kind === 'gpio'
- ? new GpioProbeTransport(resolveGpioFeedConfig())
- : new MqttProbeTransport(resolveProbeFeedConfig());
+ const enabled = resolveSensorEnabled();
+ if (kind === 'gpio') {
+ const cfg = resolveGpioFeedConfig();
+ for (const channel of PROBE_CHANNELS) {
+ if (!enabled[channel]) {
+ cfg.pins[channel] = null;
+ }
+ }
+ return new GpioProbeTransport(cfg);
+ }
+ const cfg = resolveProbeFeedConfig();
+ for (const channel of PROBE_CHANNELS) {
+ if (!enabled[channel]) {
+ cfg.topics[channel] = null;
+ }
+ }
+ return new MqttProbeTransport(cfg);
}
export class ProbeFeedService {
@@ -553,6 +641,7 @@ export class ProbeFeedService {
for (const channel of PROBE_CHANNELS) {
const reading = this.readings.get(channel);
feeds[channel] = {
+ enabled: !cfg.disabled.includes(channel),
source: cfg.channels[channel],
inverted: cfg.inverted[channel],
last: reading ? {
@@ -569,6 +658,12 @@ export class ProbeFeedService {
missing: cfg.missing,
connected: this.isConnected(),
connecting: this.connecting,
+ disabledSensors: cfg.disabled,
+ // A feed that is configured but cannot reach its hardware (the
+ // USB sensor bridge unplugged, the broker down) is "unavailable":
+ // the UI shows unknown pills, procedures refuse, and the service
+ // keeps retrying quietly in the background.
+ unavailable: cfg.configured && !this.isConnected() && !this.connecting && this.reconnectAttempts > 0,
...(this.transport ? this.transport.describe() : describeTransportConfig(cfg.kind)),
feeds,
safetyTrip: this.trip,
@@ -589,8 +684,12 @@ export class ProbeFeedService {
transport.on('reading', (channel: ProbeChannel, value: string) => this.onReading(channel, value, false));
transport.on('refresh', (channel: ProbeChannel, value: string) => this.onReading(channel, value, true));
transport.on('error', (err: Error) => {
+ // An unplugged sensor bridge produces the same error on every
+ // retry - log a new message once, then stay quiet about repeats.
+ if (err.message !== this.lastError) {
+ log.error(`Probe feed error: ${err.message}`);
+ }
this.lastError = err.message;
- log.error(`Probe feed error: ${err.message}`);
});
transport.on('close', () => {
this.connecting = false;
@@ -613,6 +712,9 @@ export class ProbeFeedService {
mcpBroadcast('mcp:activity', { tool: 'probe_feed', phase: 'connected', transport: cfg.kind });
} catch (err) {
this.connecting = false;
+ if (err.message !== this.lastError) {
+ log.error(`Probe feed connect failed: ${err.message}`);
+ }
this.lastError = err.message;
throw err;
}
@@ -624,7 +726,10 @@ export class ProbeFeedService {
}
this.reconnectAttempts += 1;
const delay = Math.min(RECONNECT_BASE_MS * (2 ** Math.min(this.reconnectAttempts - 1, 4)), RECONNECT_MAX_MS);
- log.info(`Probe feed reconnect ${this.reconnectAttempts} in ${delay}ms`);
+ if (this.reconnectAttempts <= 3 || this.reconnectAttempts % 10 === 0) {
+ const why = this.lastError ? ` (last error: ${this.lastError})` : '';
+ log.info(`Probe feed reconnect ${this.reconnectAttempts} in ${delay}ms${why}`);
+ }
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
const cfg = resolveActiveProbeConfig();
diff --git a/src/server/services/mcp/probing.ts b/src/server/services/mcp/probing.ts
index 8f2e8b98b1..44d4c91ed4 100644
--- a/src/server/services/mcp/probing.ts
+++ b/src/server/services/mcp/probing.ts
@@ -1,5 +1,5 @@
import { connectionManager } from '../machine/ConnectionManager';
-import { ProbeChannel, probeFeedService } from './probeFeed';
+import { ProbeChannel, probeFeedService, resolveSensorEnabled, sensorLabel } from './probeFeed';
import { McpToolError } from './registry';
import { GcodeChannel, sendGcodeVisible } from './tools/camera';
import { assertFreshHeartbeat, getPositionSnapshot } from './tools/machine';
@@ -232,6 +232,10 @@ export async function senseReleaseAfter(
*/
export function assertChannelReady(channel: ProbeChannel, what: string): void {
probeFeedService.assertNoOvertravel();
+ if (!resolveSensorEnabled()[channel]) {
+ throw new McpToolError(`The ${sensorLabel(channel)} is disabled (Settings -> MCP Server -> Probe sensor feed). `
+ + `Ask the operator to enable it before a ${what} run.`);
+ }
if (!probeFeedService.isConnected()) {
throw new McpToolError('Probe feed is not connected - connect_probe_feed first. The overtravel '
+ `tripwire MUST be armed for a ${what} run.`);
From 3b6a479c89d2ec4991e5e4cc8cd616ef93c33d61 Mon Sep 17 00:00:00 2001
From: tyeth
Date: Sat, 5 Sep 2026 01:02:27 +0100
Subject: [PATCH 055/135] Improvement: job event log + long-poll status; even
survey grid
Agents had to grep the server log to learn how a job went: the record
never carried what happened, procedure results only travelled on the
start_gcode_job response (lost on a client timeout), and
get_gcode_job_status returned immediately so completion meant tight
polling. Every job now keeps an event log - state changes, the runner's
phase announcements and probe-feed readings/alarms broadcast while it is
the active job, gcode sent/replies, file-job progress every 5 % - and the
procedure outcome is stored as job.result. get_gcode_job_status returns
events (from since_event), result, terminal, and long-polls with wait_ms
(up to 120 s), returning as soon as the state turns terminal or new
events arrive, so one call replaces a polling loop.
survey_bed's grid was a fixed pitch plus a stub at the far edge: with
pitch 80 the rows ran 10, 90, 170, 250, 330 and then 340 - 80 mm jumps
and a 10 mm tail, on both axes (surveys 25c4f87a / b123d156). pitch_mm
is now a MAXIMUM: each axis span is divided into equal steps no larger
than it (min 20), so rows and columns are uniform and both edges are
covered; the response reports the effective step per axis and the
coordinates. cnc-probing skill: wait on jobs with get_gcode_job_status
wait_ms, never the server log.
Co-Authored-By: Claude Fable 5.1
---
.claude/skills/cnc-probing/SKILL.md | 14 +++
src/server/services/mcp/README.md | 12 ++-
src/server/services/mcp/index.ts | 3 +
src/server/services/mcp/jobs.ts | 80 ++++++++++++++
src/server/services/mcp/tools/gcode.ts | 132 +++++++++++++++++++----
src/server/services/mcp/tools/probing.ts | 37 ++++---
6 files changed, 236 insertions(+), 42 deletions(-)
diff --git a/.claude/skills/cnc-probing/SKILL.md b/.claude/skills/cnc-probing/SKILL.md
index edb5b5580b..39da965d53 100644
--- a/.claude/skills/cnc-probing/SKILL.md
+++ b/.claude/skills/cnc-probing/SKILL.md
@@ -121,6 +121,20 @@ a tool refusing with "the touch probe / tool setter is disabled (Settings -> MCP
Server)" means the operator switched that sensor off in the app - ask, never
bypass. Sensors the operator has disabled have no pill at all.
+## Waiting on a job or procedure
+
+Never read server logs to find out whether a job finished. `get_gcode_job_status` carries
+the whole story: `job.state` (+ `terminal`), the procedure `result` once stored, and
+`events` — state changes, the runner's phase announcements, gcode sent/replies while the
+job was active, file-job progress every 5 %. Long-poll it: `wait_ms: 60000` returns as soon
+as the state turns terminal or new events arrive past `since_event` (pass back
+`next_event_index`), so one call replaces a polling loop. `start_gcode_job` on a procedure
+still returns the result directly; if that call times out, the result is on the record.
+
+`survey_bed`'s `pitch_mm` is a MAXIMUM: each axis is divided evenly into steps no larger
+than it (min 20), so rows and columns are uniform and both edges are covered — no more 80 mm
+jumps followed by a 10 mm stub. Pick the pitch from what one frame covers.
+
## Circle probing
`probe_circle` measures a roughly-round vertical feature (post, boss, pin):
diff --git a/src/server/services/mcp/README.md b/src/server/services/mcp/README.md
index 2eb0d3e6e5..dc957f022b 100644
--- a/src/server/services/mcp/README.md
+++ b/src/server/services/mcp/README.md
@@ -309,7 +309,8 @@ it with no decision point, when the operator had authorised "step 1" only. Laws:
`get_connection_status` · `get_machine_profile` (kinematics, module offsets) ·
`get_position` (both frames, warnings on incoherent reporting) ·
`query_firmware_position` (raw M114) · `validate_gcode` · `submit_gcode_job` →
-`start_gcode_job` → `get_gcode_job_status` / `stop_gcode_job` · `move_z` (single or
+`start_gcode_job` → `get_gcode_job_status` (event log + stored result; long-poll with
+`wait_ms`/`since_event`) / `stop_gcode_job` · `move_z` (single or
`z_targets` batch) · `home` · `goto_work_origin` · `move_and_capture` · `list_cameras` ·
`capture_frame` (position-stamped, `frameId`, `expectedToolRegion`) · `set_tool_region` ·
`track_feature` (NCC between cached frames — use instead of eyeballing pixels) ·
@@ -461,9 +462,12 @@ toolhead camera. Everything a fresh install needs:
zero — the defaults are still MQTT-sized, so tighten them per-call when running on
gpio.
-- **Procedure results only travel on the `start_gcode_job` response** — a client timeout
- loses them (recovered once from the motion log; `probe_sequence` aborts also drop
- completed results into the error text). Store the runner outcome on the job record.
+- ~~Procedure results only travel on the `start_gcode_job` response~~ — done 2026-09-05: the
+ runner outcome is stored as `job.result`, every job carries an event log (state changes,
+ runner phases, gcode traffic while active, file-job progress), and `get_gcode_job_status`
+ long-polls (`wait_ms`, `since_event`) — agents no longer read server logs to learn how a
+ job went. Still open: `probe_sequence` aborts drop completed marches into the error text
+ (they are now in the events, but not structured).
- **`traverse_xy` staged batch tool** (law-2-compliant XY transport twin of `move_z`): hops
currently need `submit_gcode_job` file jobs or `probe_sequence` hop steps.
- **Console bug**: MCP gcode broadcasts leak into the Workspace console INPUT element with
diff --git a/src/server/services/mcp/index.ts b/src/server/services/mcp/index.ts
index cf7c07cd1a..676a583f8f 100644
--- a/src/server/services/mcp/index.ts
+++ b/src/server/services/mcp/index.ts
@@ -43,6 +43,9 @@ let broadcaster: McpBroadcaster | null = null;
* No-op until the service starts.
*/
export function mcpBroadcast(eventName: string, options?: object): void {
+ // The active job keeps its own copy of what happened (get_gcode_job_status
+ // events), so agents read the record instead of the server log.
+ jobManager.recordActivity(eventName, options);
broadcaster && broadcaster.broadcast(eventName, options);
}
diff --git a/src/server/services/mcp/jobs.ts b/src/server/services/mcp/jobs.ts
index 6b528922cf..e738305e2b 100644
--- a/src/server/services/mcp/jobs.ts
+++ b/src/server/services/mcp/jobs.ts
@@ -40,6 +40,20 @@ export type McpJobState =
*/
export type McpJobKind = 'file' | 'direct' | 'procedure';
+export interface JobEvent {
+ at: number;
+ /** State change ('submitted', 'approved', 'started', 'completed', ...), a runner phase, 'gcode', 'progress'. */
+ phase: string;
+ /** Which tool/source produced it. */
+ tool?: string;
+ note?: string;
+ [detail: string]: unknown;
+}
+
+const MAX_JOB_EVENTS = 400;
+
+export const TERMINAL_JOB_STATES: McpJobState[] = ['rejected', 'start_failed', 'stopped', 'completed'];
+
export interface McpJob {
id: string;
name: string;
@@ -56,6 +70,14 @@ export interface McpJob {
// Set when the job reaches a terminal state (completed / stopped).
endedAt: number | null;
error: string | null;
+ // Everything that happened to the job, in order: state changes, the
+ // runner's phase announcements, gcode sent/replies while it was the active
+ // job, file-job progress. Returned by get_gcode_job_status so an agent
+ // never has to read server logs to learn how a job went. Capped.
+ events: JobEvent[];
+ // Procedure outcome (tool setter / probe results), kept on the record so
+ // a client that timed out waiting on start_gcode_job can still read it.
+ result: object | null;
// Batch direct jobs: the operator approved this exact list; each
// start_gcode_job call executes ONE step, so captures can happen between
// steps and the series can be abandoned at any point.
@@ -82,6 +104,8 @@ function range(r: { min: number; max: number } | null): string {
}
export class JobManager {
+ private activeJob: McpJob | null = null;
+
private jobs = new Map();
private jobsDir: string | null = null;
@@ -115,15 +139,65 @@ export class JobManager {
startedAt: null,
endedAt: null,
error: null,
+ events: [],
+ result: null,
steps,
nextStep: steps ? 0 : undefined,
};
this.jobs.set(id, job);
this.prune();
+ this.appendEvent(job, 'submitted', { note: `${kind} job staged, awaiting human confirmation` });
log.info(`MCP job submitted: ${id} (${safeName}), awaiting human confirmation`);
return job;
}
+ /** Record something that happened to a job (state change, phase, gcode, progress). */
+ public appendEvent(job: McpJob, phase: string, detail: { [key: string]: unknown } = {}): void {
+ job.events.push({ at: Date.now(), phase, ...detail });
+ if (job.events.length > MAX_JOB_EVENTS) {
+ // Keep the head (submission/approval/start) and the most recent tail.
+ job.events.splice(20, job.events.length - MAX_JOB_EVENTS);
+ }
+ }
+
+ /**
+ * The job currently driving the machine (a procedure runner, a direct
+ * step, a running file job). Activity broadcast while it is active is
+ * attached to its event log - see recordActivity.
+ */
+ public setActive(job: McpJob | null): void {
+ this.activeJob = job;
+ }
+
+ public getActive(): McpJob | null {
+ return this.activeJob;
+ }
+
+ /**
+ * Hook for mcpBroadcast: phase-style tool activity (runner announcements,
+ * probe feed readings/alarms) and gcode traffic land on the active job's
+ * event log. Tool-call summaries (ok/duration) are not job events.
+ */
+ public recordActivity(eventName: string, options?: object): void {
+ const job = this.activeJob;
+ if (!job || !options) {
+ return;
+ }
+ const payload = options as { [key: string]: unknown };
+ if (eventName === 'mcp:activity' && payload.phase !== undefined) {
+ const { phase, ...rest } = payload;
+ this.appendEvent(job, String(phase), rest);
+ } else if (eventName === 'mcp:gcode') {
+ const gcode = payload.gcode !== undefined ? String(payload.gcode).slice(0, 300) : undefined;
+ const response = payload.response !== undefined ? String(payload.response).slice(0, 300) : undefined;
+ this.appendEvent(job, 'gcode', { tool: payload.tool, gcode, response });
+ }
+ }
+
+ public isTerminal(job: McpJob): boolean {
+ return TERMINAL_JOB_STATES.includes(job.state);
+ }
+
public get(id: string): McpJob | null {
return this.jobs.get(id) || null;
}
@@ -173,6 +247,10 @@ export class JobManager {
startedAt: job.startedAt,
endedAt: job.endedAt,
error: job.error,
+ terminal: this.isTerminal(job),
+ result: job.result,
+ eventCount: job.events.length,
+ lastEvent: job.events.length ? job.events[job.events.length - 1] : null,
totalSteps: job.steps ? job.steps.length : undefined,
nextStep: job.steps ? job.nextStep : undefined,
validation: job.validation,
@@ -232,6 +310,7 @@ export class JobManager {
job.confirmToken = crypto.randomBytes(4).toString('hex');
job.approvedAt = Date.now();
job.state = 'approved';
+ this.appendEvent(job, 'approved', { note: 'operator approved on the confirm page' });
log.info(`MCP job ${job.id} approved by operator`);
this.page(res, 200, this.approvedPage(job));
return;
@@ -239,6 +318,7 @@ export class JobManager {
if (req.method === 'POST' && action === 'reject') {
job.state = 'rejected';
job.confirmToken = null;
+ this.appendEvent(job, 'rejected', { note: 'operator rejected on the confirm page' });
log.info(`MCP job ${job.id} rejected by operator`);
this.page(res, 200, '
Rejected
The job will not run.
');
return;
diff --git a/src/server/services/mcp/tools/gcode.ts b/src/server/services/mcp/tools/gcode.ts
index 62d568d1ce..f84f435271 100644
--- a/src/server/services/mcp/tools/gcode.ts
+++ b/src/server/services/mcp/tools/gcode.ts
@@ -115,6 +115,10 @@ const log = logger('service:mcp:gcode-jobs');
// the heartbeat: once the job has been seen active, a debounced return to
// idle is completion. A job too short to ever show as active is completed
// after the heartbeat holds idle for a longer fallback window.
+const sleep = async (ms: number) => new Promise((resolve) => {
+ setTimeout(resolve, ms);
+});
+
const FILE_JOB_POLL_MS = 1000;
const FILE_JOB_IDLE_DEBOUNCE_POLLS = 3;
const FILE_JOB_NEVER_SEEN_ACTIVE_IDLE_POLLS = 20;
@@ -125,10 +129,17 @@ function watchFileJobCompletion(job: McpJob): void {
let sawActive = false;
let idleStreak = 0;
let unreadableStreak = 0;
+ let lastProgress = -1;
+ const release = () => {
+ if (jobManager.getActive() === job) {
+ jobManager.setActive(null);
+ }
+ };
const timer = setInterval(() => {
if (job.state !== 'started') {
// Stopped (or otherwise finalised) through another path.
clearInterval(timer);
+ release();
return;
}
const status = machineStatus();
@@ -139,7 +150,9 @@ function watchFileJobCompletion(job: McpJob): void {
clearInterval(timer);
job.error = 'Completion unverified: machine state became unreadable after the job '
+ 'started (connection lost?). The job may still be running on the machine.';
+ jobManager.appendEvent(job, 'completion_unverified', { note: job.error });
log.warn(`MCP file job ${job.id}: ${job.error}`);
+ release();
}
return;
}
@@ -147,6 +160,17 @@ function watchFileJobCompletion(job: McpJob): void {
if (FILE_JOB_ACTIVE_STATUSES.includes(status)) {
sawActive = true;
idleStreak = 0;
+ // Progress from the heartbeat, recorded every 5 % so the event
+ // log shows the job advancing without a reader having to poll.
+ const state = connectionManager.getLatestMachineState() as { gcodePrintingInfo?: { progress?: number } } | null;
+ const raw = state && state.gcodePrintingInfo ? Number(state.gcodePrintingInfo.progress) : NaN;
+ if (Number.isFinite(raw)) {
+ const percent = Math.round((raw <= 1 ? raw * 100 : raw));
+ if (percent >= lastProgress + 5) {
+ lastProgress = percent;
+ jobManager.appendEvent(job, 'progress', { percent, machineStatus: status });
+ }
+ }
return;
}
if (status === 'idle') {
@@ -156,8 +180,12 @@ function watchFileJobCompletion(job: McpJob): void {
clearInterval(timer);
job.state = 'completed';
job.endedAt = Date.now();
+ jobManager.appendEvent(job, 'completed', {
+ note: `heartbeat idle for ${idleStreak}s${sawActive ? '' : ' (job too short for an active heartbeat to be observed)'}`,
+ });
log.info(`MCP file job ${job.id} completed: heartbeat idle for ${idleStreak}s`
+ `${sawActive ? '' : ' (job too short for an active heartbeat to be observed)'}`);
+ release();
}
return;
}
@@ -283,10 +311,16 @@ export function registerGcodeTools(registry: ToolRegistry, getConfirmBaseUrl: ()
}
job.state = 'started';
job.startedAt = Date.now();
+ jobManager.appendEvent(job, 'started', { note: 'procedure runner started' });
+ jobManager.setActive(job);
try {
const outcome = await job.runner();
+ // On the record first: a client that timed out waiting here
+ // still finds the result in get_gcode_job_status.
+ job.result = outcome;
job.state = 'completed';
job.endedAt = Date.now();
+ jobManager.appendEvent(job, 'completed', { note: 'procedure finished; result stored on the job' });
return {
job: jobManager.describe(job),
result: outcome,
@@ -294,7 +328,11 @@ export function registerGcodeTools(registry: ToolRegistry, getConfirmBaseUrl: ()
} catch (err) {
job.state = 'start_failed';
job.error = err.message;
+ job.endedAt = Date.now();
+ jobManager.appendEvent(job, 'failed', { note: err.message });
throw err;
+ } finally {
+ jobManager.setActive(null);
}
}
@@ -324,23 +362,33 @@ export function registerGcodeTools(registry: ToolRegistry, getConfirmBaseUrl: ()
return trimmed.length > 0 && !trimmed.startsWith(';');
})
.join('\n');
- const executed = await sendGcodeVisible(channel as GcodeChannel, `direct:${job.name}`, executable);
- if (executed.result !== 0) {
- job.state = 'start_failed';
- job.error = `Controller rejected the move: ${executed.text || executed.result}`;
- throw new McpToolError(job.error);
- }
- const shouldWait = args.wait_until_moved !== undefined
- ? args.wait_until_moved !== false
- : job.waitUntilMoved !== false;
- const settle = !shouldWait
- ? {
- position: null,
- verified: false,
- warning: 'wait_until_moved was false: the move was accepted but not awaited - '
- + 'poll get_position (or query_firmware_position) before relying on position.',
+ jobManager.appendEvent(job, 'started', { note: isBatch ? `direct step ${job.nextStep + 1}/${job.steps.length}` : 'direct move' });
+ jobManager.setActive(job);
+ let settle;
+ try {
+ const executed = await sendGcodeVisible(channel as GcodeChannel, `direct:${job.name}`, executable);
+ if (executed.result !== 0) {
+ job.state = 'start_failed';
+ job.error = `Controller rejected the move: ${executed.text || executed.result}`;
+ job.endedAt = Date.now();
+ jobManager.appendEvent(job, 'failed', { note: job.error });
+ throw new McpToolError(job.error);
}
- : await waitForStableHeartbeat(issuedAt, parseZTarget(executable));
+ const shouldWait = args.wait_until_moved !== undefined
+ ? args.wait_until_moved !== false
+ : job.waitUntilMoved !== false;
+ settle = !shouldWait
+ ? {
+ position: null,
+ verified: false,
+ warning: 'wait_until_moved was false: the move was accepted but not awaited - '
+ + 'poll get_position (or query_firmware_position) before relying on position.',
+ }
+ : await waitForStableHeartbeat(issuedAt, parseZTarget(executable));
+ jobManager.appendEvent(job, 'settled', { position: settle.position, verified: settle.verified });
+ } finally {
+ jobManager.setActive(null);
+ }
if (isBatch) {
job.nextStep += 1;
if (job.nextStep < job.steps.length) {
@@ -361,6 +409,7 @@ export function registerGcodeTools(registry: ToolRegistry, getConfirmBaseUrl: ()
}
job.state = 'completed';
job.endedAt = Date.now();
+ jobManager.appendEvent(job, 'completed', { note: 'direct move(s) done' });
return {
job: jobManager.describe(job),
position: settle.position,
@@ -379,24 +428,32 @@ export function registerGcodeTools(registry: ToolRegistry, getConfirmBaseUrl: ()
if (uploadError) {
job.state = 'start_failed';
job.error = `Upload failed: ${uploadError}`;
+ job.endedAt = Date.now();
+ jobManager.appendEvent(job, 'failed', { note: job.error });
throw new McpToolError(job.error);
}
+ jobManager.appendEvent(job, 'uploaded', { note: `${job.name}.nc uploaded to the machine` });
const started = await channel.startGcodeJob();
if (!started.ok) {
job.state = 'start_failed';
job.error = `Start failed: ${started.text || started.code || 'unknown error'}`;
+ job.endedAt = Date.now();
+ jobManager.appendEvent(job, 'failed', { note: job.error });
throw new McpToolError(job.error);
}
job.state = 'started';
job.startedAt = Date.now();
+ jobManager.appendEvent(job, 'started', { note: 'machine interpreter running the file (door interlock applies)' });
+ jobManager.setActive(job);
watchFileJobCompletion(job);
return {
job: jobManager.describe(job),
- note: 'Job started. Poll get_gcode_job_status; the controller, its door interlock '
- + 'and the machine UI remain in control. The job is marked completed when the '
- + 'heartbeat settles back to idle.',
+ note: 'Job started. Poll get_gcode_job_status with wait_ms to long-poll for progress '
+ + 'events and completion (no need to read server logs); the controller, its door '
+ + 'interlock and the machine UI remain in control. The job is marked completed when '
+ + 'the heartbeat settles back to idle.',
};
},
});
@@ -563,23 +620,52 @@ export function registerGcodeTools(registry: ToolRegistry, getConfirmBaseUrl: ()
registry.register({
name: 'get_gcode_job_status',
- description: 'Job record plus live progress from the machine heartbeat. Read-only.',
+ description: 'Job record, its event log (state changes, runner phases, gcode traffic while '
+ + 'active, file-job progress), the stored procedure result, and live machine progress. '
+ + 'LONG-POLL: pass wait_ms (up to 120000) and it returns as soon as the job reaches a '
+ + 'terminal state or new events arrive past since_event - use this instead of tight '
+ + 'polling or reading server logs. Read-only.',
inputSchema: {
type: 'object',
properties: {
job_id: { type: 'string' },
+ wait_ms: {
+ type: 'number',
+ description: 'Block up to this long (0-120000) for a terminal state or new events. Default 0 = return now.',
+ },
+ since_event: {
+ type: 'number',
+ description: 'Return only events at index >= this (the previous response\'s next_event_index); '
+ + 'new events past it also end a wait early. Default 0 = all events.',
+ },
},
required: ['job_id'],
additionalProperties: false,
},
- handler: async (args: { job_id?: string }) => {
+ handler: async (args: { job_id?: string; wait_ms?: number; since_event?: number }) => {
const job = jobManager.get(String(args.job_id || ''));
if (!job) {
throw new McpToolError('Unknown job_id.');
}
+ const waitMs = Math.min(Math.max(Number(args.wait_ms) || 0, 0), 120000);
+ const since = Math.max(0, Math.floor(Number(args.since_event) || 0));
+ const startedWaiting = Date.now();
+ let timedOut = false;
+ while (!jobManager.isTerminal(job) && job.events.length <= since) {
+ if (Date.now() - startedWaiting >= waitMs) {
+ timedOut = waitMs > 0;
+ break;
+ }
+ await sleep(Math.min(250, waitMs - (Date.now() - startedWaiting)));
+ }
const state = connectionManager.getLatestMachineState();
return {
job: jobManager.describe(job),
+ result: job.result,
+ events: job.events.slice(since),
+ next_event_index: job.events.length,
+ waited_ms: Date.now() - startedWaiting,
+ timed_out: timedOut,
machineStatus: machineStatus(),
printingInfo: state ? ((state as { gcodePrintingInfo?: object }).gcodePrintingInfo || null) : null,
reportAgeMs: state ? Date.now() - state.timestamp : null,
@@ -611,6 +697,10 @@ export function registerGcodeTools(registry: ToolRegistry, getConfirmBaseUrl: ()
if (stopped.ok) {
job.state = 'stopped';
job.endedAt = Date.now();
+ jobManager.appendEvent(job, 'stopped', { note: `stop sent by the agent${stopped.text ? `: ${stopped.text}` : ''}` });
+ if (jobManager.getActive() === job) {
+ jobManager.setActive(null);
+ }
}
return {
ok: stopped.ok,
diff --git a/src/server/services/mcp/tools/probing.ts b/src/server/services/mcp/tools/probing.ts
index 6121ad66d0..db547c7d82 100644
--- a/src/server/services/mcp/tools/probing.ts
+++ b/src/server/services/mcp/tools/probing.ts
@@ -315,7 +315,7 @@ ${describeProbeCirclePlanAsGcode(plan)}`;
inputSchema: {
type: 'object',
properties: {
- pitch_mm: { type: 'number', description: 'Grid spacing, default 80 (40-160).' },
+ pitch_mm: { type: 'number', description: 'MAXIMUM grid spacing, default 80 (20-160). Each axis span is divided into equal steps no larger than this, so rows and columns are uniform and both edges are covered - no fixed-pitch stub at the far end.' },
margin_mm: { type: 'number', description: 'Inset from the default bounds, default 10.' },
x_min: { type: 'number', description: 'Machine-coord grid bounds. Defaults: margin..(size-margin).' },
x_max: { type: 'number', description: 'Set beyond the nominal size to cover reachable overtravel (e.g. the far-X column the camera angle otherwise misses - setup-specific, so state it explicitly).' },
@@ -357,14 +357,15 @@ ${describeProbeCirclePlanAsGcode(plan)}`;
if (!size) {
throw new McpToolError('Unknown machine size; cannot plan the grid.');
}
- const pitch = Math.min(Math.max(Number(args.pitch_mm) || 80, 40), 160);
+ const pitch = Math.min(Math.max(Number(args.pitch_mm) || 80, 20), 160);
const margin = Math.min(Math.max(Number(args.margin_mm) || 10, 0), 50);
// Serpentine at the current Z. Bounds are explicit (clamped to the
- // direct-move envelope) and BOTH endpoints are always covered - a
- // pitch that undershoots gets a final row/column at the far edge,
- // because what the camera sees at the extremes is setup-specific
- // and the far reach is often the only view of its region.
+ // direct-move envelope) and BOTH endpoints are always covered.
+ // Each axis is divided EVENLY into steps no larger than the pitch
+ // (operator, 2026-09-05: the old fixed pitch gave 80 mm jumps and
+ // then a 9-10 mm stub at the far edge - uneven coverage on both
+ // axes); the far reach is often the only view of its region.
const clampAxis = (value: number, max: number) => Math.min(Math.max(value, -25), max + 40);
const bounds = {
xMin: clampAxis(args.x_min !== undefined ? Number(args.x_min) : margin, size.x),
@@ -375,18 +376,20 @@ ${describeProbeCirclePlanAsGcode(plan)}`;
if (!(bounds.xMax > bounds.xMin) || !(bounds.yMax > bounds.yMin)) {
throw new McpToolError('Survey bounds are empty after clamping; check x/y min/max.');
}
- const axisPoints = (min: number, max: number): number[] => {
+ const axisPoints = (min: number, max: number): { points: number[]; step: number } => {
+ const span = max - min;
+ const intervals = Math.max(1, Math.ceil(span / pitch - 1e-9));
+ const step = span / intervals;
const points: number[] = [];
- for (let value = min; value <= max + 1e-9; value += pitch) {
- points.push(Number(value.toFixed(1)));
+ for (let i = 0; i <= intervals; i++) {
+ points.push(Number((min + (step * i)).toFixed(1)));
}
- if (points[points.length - 1] < max - 1) {
- points.push(Number(max.toFixed(1)));
- }
- return points;
+ return { points, step: Number(step.toFixed(2)) };
};
- const xs = axisPoints(bounds.xMin, bounds.xMax);
- const ys = axisPoints(bounds.yMin, bounds.yMax);
+ const xAxis = axisPoints(bounds.xMin, bounds.xMax);
+ const yAxis = axisPoints(bounds.yMin, bounds.yMax);
+ const xs = xAxis.points;
+ const ys = yAxis.points;
const waypoints: { x: number; y: number }[] = [];
ys.forEach((wy, row) => {
const ordered = row % 2 === 0 ? xs : [...xs].reverse();
@@ -394,7 +397,7 @@ ${describeProbeCirclePlanAsGcode(plan)}`;
});
const envelope = [
- `; BED SURVEY: ${waypoints.length} waypoints on a ${pitch} mm serpentine grid at CURRENT machine Z ${z.toFixed(1)}`,
+ `; BED SURVEY: ${waypoints.length} waypoints, serpentine grid step X ${xAxis.step} / Y ${yAxis.step} mm (max ${pitch}) at CURRENT machine Z ${z.toFixed(1)}`,
'; one frame captured per waypoint after the move settles; frames saved to disk with a',
'; machine-position index. Each line is sent individually. Aborts on the first capture failure.',
'G90',
@@ -447,7 +450,7 @@ ${describeProbeCirclePlanAsGcode(plan)}`;
return {
job: jobManager.describe(job),
waypoints: waypoints.length,
- grid: { pitch_mm: pitch, machine_z: z, columns: xs.length, rows: ys.length },
+ grid: { max_pitch_mm: pitch, step_x_mm: xAxis.step, step_y_mm: yAxis.step, xs, ys, machine_z: z, columns: xs.length, rows: ys.length },
confirm_url: `${getConfirmBaseUrl()}/confirm/${job.id}`,
next_step: 'Ask the operator to open confirm_url, check the Z clears everything on the '
+ 'bed (rotary included), and approve. start_gcode_job then drives the whole grid '
From 027134fca2b4ced4784d494ee45c24d5f4990ae0 Mon Sep 17 00:00:00 2001
From: tyeth
Date: Sat, 5 Sep 2026 01:08:19 +0100
Subject: [PATCH 056/135] Fix: origin-offset transient aborted a verified
march; procedures run detached
probe_sequence job 44abebd9bab3 (2026-09-05) walked its hop and guarded
descent with every step verifiably settled at machine (170, 199, 240) -
the controller echo read work (119, 77, -88) against this session's work
origin at machine (51, 122, 328) - then the march's position re-check
aborted with the machine "at" (119, 77, -88). The SSTP status poll
rebuilds originOffset from offsetX/Y/Z on every beat, and a beat inside
the move's G53...G54 window carries none; getPositionSnapshot fell
through `|| 0` and silently reframed machine coordinates as work
coordinates. A missing offset now reuses the last complete offset seen on
the connection (originOffsetSource: cached, with a warning; assumed-zero
only before any offset was ever reported, loudly), and the sequence
runner's re-check re-reads once after a heartbeat period before aborting,
reporting work/offset/source when it does.
start_gcode_job on a procedure ran the whole runner inside the request,
so tool setter and probe circuits (minutes) exceeded MCP client timeouts
- twice now - and a client giving up could not see the result. The
runner is detached: the outcome always lands on the job record; the call
waits wait_ms (default 25 s, max 120 s) and returns the result if it
arrived, otherwise a running status to long-poll with
get_gcode_job_status. Failures are recorded on the job and logged, never
an unhandled rejection. README machine facts + cnc-probing skill updated.
Co-Authored-By: Claude Fable 5.1
---
.claude/skills/cnc-probing/SKILL.md | 4 +-
src/server/services/mcp/README.md | 14 ++++-
src/server/services/mcp/probeSequence.ts | 38 ++++++++++---
src/server/services/mcp/tools/gcode.ts | 71 ++++++++++++++++++------
src/server/services/mcp/tools/machine.ts | 40 +++++++++++--
5 files changed, 132 insertions(+), 35 deletions(-)
diff --git a/.claude/skills/cnc-probing/SKILL.md b/.claude/skills/cnc-probing/SKILL.md
index 39da965d53..4d72a127c2 100644
--- a/.claude/skills/cnc-probing/SKILL.md
+++ b/.claude/skills/cnc-probing/SKILL.md
@@ -129,7 +129,9 @@ the whole story: `job.state` (+ `terminal`), the procedure `result` once stored,
job was active, file-job progress every 5 %. Long-poll it: `wait_ms: 60000` returns as soon
as the state turns terminal or new events arrive past `since_event` (pass back
`next_event_index`), so one call replaces a polling loop. `start_gcode_job` on a procedure
-still returns the result directly; if that call times out, the result is on the record.
+waits up to `wait_ms` (default 25 s) and returns the result if it arrived, otherwise a
+`running: true` status - the runner keeps going on the server; long-poll for the result,
+never resubmit. If your MCP client times out anyway, the result is still on the record.
`survey_bed`'s `pitch_mm` is a MAXIMUM: each axis is divided evenly into steps no larger
than it (min 20), so rows and columns are uniform and both edges are covered — no more 80 mm
diff --git a/src/server/services/mcp/README.md b/src/server/services/mcp/README.md
index dc957f022b..cde49946bb 100644
--- a/src/server/services/mcp/README.md
+++ b/src/server/services/mcp/README.md
@@ -251,6 +251,15 @@ it with no decision point, when the operator had authorised "step 1" only. Laws:
emergency stop; MCP direct single-command ops do NOT.
- Heartbeat period ~1 s; a settled-looking heartbeat can predate the motion (hence the
verified-settle contract). `query_firmware_position` (M114) is the authoritative check.
+- **Origin-offset transient (2026-09-05, job 44abebd9bab3)**: the SSTP status poll rebuilds
+ `originOffset` from `offsetX/Y/Z` on every beat, and a beat inside a move's `G53…G54`
+ window can carry none — `getPositionSnapshot` used to fall through to zero and reframe
+ machine coordinates as work coordinates ((170, 199, 240) read as (119, 77, −88)), which
+ aborted a probe_sequence march re-check after every step had verifiably settled. Now a
+ missing offset reuses the last complete one (`originOffsetSource: cached`, with a
+ warning), and position re-checks re-read once after a heartbeat period before aborting.
+ This session's work origin sat at machine (51, 122, 328) — negative offsets in the
+ heartbeat; `machine = work − originOffset` holds.
- Camera is **toolhead-mounted** (rides X/Z; the platform moves under it in Y): pixel→mm
calibration is keyed by machine Y AND Z. The board-viewing anchor pose is the pre-home
park (machine X0/Y0), not machine home. The **gold cylinder at machine Y≈176–340 is the
@@ -309,8 +318,9 @@ it with no decision point, when the operator had authorised "step 1" only. Laws:
`get_connection_status` · `get_machine_profile` (kinematics, module offsets) ·
`get_position` (both frames, warnings on incoherent reporting) ·
`query_firmware_position` (raw M114) · `validate_gcode` · `submit_gcode_job` →
-`start_gcode_job` → `get_gcode_job_status` (event log + stored result; long-poll with
-`wait_ms`/`since_event`) / `stop_gcode_job` · `move_z` (single or
+`start_gcode_job` (procedures run detached: returns the result if it arrives within
+`wait_ms`, default 25 s, else a `running` status) → `get_gcode_job_status` (event log +
+stored result; long-poll with `wait_ms`/`since_event`) / `stop_gcode_job` · `move_z` (single or
`z_targets` batch) · `home` · `goto_work_origin` · `move_and_capture` · `list_cameras` ·
`capture_frame` (position-stamped, `frameId`, `expectedToolRegion`) · `set_tool_region` ·
`track_feature` (NCC between cached frames — use instead of eyeballing pixels) ·
diff --git a/src/server/services/mcp/probeSequence.ts b/src/server/services/mcp/probeSequence.ts
index 9e59e77522..450c0369b5 100644
--- a/src/server/services/mcp/probeSequence.ts
+++ b/src/server/services/mcp/probeSequence.ts
@@ -227,7 +227,7 @@ export function describeProbeSequencePlanAsGcode(plan: ProbeSequencePlan): strin
lines.push(`G1 X${step.x.toFixed(3)} Y${step.y.toFixed(3)} F${TRAVEL_FEED}; hop`);
} else if (step.kind === 'descend') {
lines.push(`G1 Z${(step.z + DESCENT_GUARD_MM).toFixed(3)} F${TRAVEL_FEED}; descend fast to ${DESCENT_GUARD_MM} mm above target`);
- lines.push(`; ...guarded final approach (operator, 2026-09-02): 1 mm steps, sensor-checked after each -`);
+ lines.push('; ...guarded final approach (operator, 2026-09-02): 1 mm steps, sensor-checked after each -');
lines.push('; ANY contact during a descent aborts and latches the CRASH alarm.');
for (let gz = step.z + DESCENT_GUARD_MM - 1; gz > step.z - 1e-9; gz -= 1) {
const zz = Math.max(gz, step.z);
@@ -260,6 +260,10 @@ export function describeProbeSequencePlanAsGcode(plan: ProbeSequencePlan): strin
return lines.join('\n');
}
+const sleep = async (ms: number) => new Promise((resolve) => {
+ setTimeout(resolve, ms);
+});
+
export async function runProbeSequenceProcedure(plan: ProbeSequencePlan): Promise {
assertChannelReady('probe', 'probe sequence');
assertMachineReadyForProcedure();
@@ -319,14 +323,30 @@ export async function runProbeSequenceProcedure(plan: ProbeSequencePlan): Promis
announce(`descend-${stepIndex}`, `Z${step.z} (guarded final ${DESCENT_GUARD_MM} mm)`);
} else {
// Re-verify the walk matches the simulation before marching.
- const now = getPositionSnapshot().machine;
- if (now.x === null || now.y === null || now.z === null
- || Math.abs(now.x - step.start.x) > 0.5
- || Math.abs(now.y - step.start.y) > 0.5
- || Math.abs(now.z - step.start.z) > 0.5) {
- throw new ProcedureAbort(`March "${step.name}": machine at `
- + `(${now.x}, ${now.y}, ${now.z}) but the plan expects `
- + `(${step.start.x}, ${step.start.y}, ${step.start.z}).`);
+ // Every preceding move already verified its own arrival, so a
+ // mismatch here is either real drift or a transient heartbeat
+ // (a beat inside a G53...G54 window reporting no/zero origin
+ // offset - job 44abebd9bab3, 2026-09-05). Re-read once after a
+ // heartbeat period before believing it.
+ const expected = step.start;
+ const matches = (p: { x: number | null; y: number | null; z: number | null }) => (
+ p.x !== null && p.y !== null && p.z !== null
+ && Math.abs(p.x - expected.x) <= 0.5
+ && Math.abs(p.y - expected.y) <= 0.5
+ && Math.abs(p.z - expected.z) <= 0.5
+ );
+ let snapshot = getPositionSnapshot();
+ if (!matches(snapshot.machine)) {
+ const first = snapshot;
+ await sleep(1200);
+ snapshot = getPositionSnapshot();
+ if (!matches(snapshot.machine)) {
+ const fmt = (s: typeof snapshot) => `(${s.machine.x}, ${s.machine.y}, ${s.machine.z}) [work (${s.work.x}, ${s.work.y}, ${s.work.z}), offset (${s.originOffset.x}, ${s.originOffset.y}, ${s.originOffset.z}) from ${s.originOffsetSource}]`;
+ throw new ProcedureAbort(`March "${step.name}": machine at ${fmt(snapshot)} `
+ + `(first read ${fmt(first)}) but the plan expects `
+ + `(${expected.x}, ${expected.y}, ${expected.z}).`);
+ }
+ announce(`recheck-${stepIndex}`, 'position re-check passed on the second heartbeat (first read was transient)');
}
probeFeedService.setExpectedContact(['probe']);
const move = async (tool: string, s: number, feed: number) => {
diff --git a/src/server/services/mcp/tools/gcode.ts b/src/server/services/mcp/tools/gcode.ts
index f84f435271..f0eeeb8c02 100644
--- a/src/server/services/mcp/tools/gcode.ts
+++ b/src/server/services/mcp/tools/gcode.ts
@@ -119,6 +119,9 @@ const sleep = async (ms: number) => new Promise((resolve) => {
setTimeout(resolve, ms);
});
+// How long start_gcode_job waits for a procedure before handing off to
+// get_gcode_job_status long-polling (well inside typical MCP client timeouts).
+const PROCEDURE_START_WAIT_MS = 25000;
const FILE_JOB_POLL_MS = 1000;
const FILE_JOB_IDLE_DEBOUNCE_POLLS = 3;
const FILE_JOB_NEVER_SEEN_ACTIVE_IDLE_POLLS = 20;
@@ -266,6 +269,10 @@ export function registerGcodeTools(registry: ToolRegistry, getConfirmBaseUrl: ()
type: 'object',
properties: {
job_id: { type: 'string' },
+ wait_ms: {
+ type: 'number',
+ description: 'Procedure jobs only: how long to wait for the result before returning a running status (0-120000, default 25000). The runner continues either way; long-poll get_gcode_job_status.',
+ },
confirm_token: { type: 'string', description: 'One-time code from the operator.' },
wait_until_moved: {
type: 'boolean',
@@ -277,7 +284,7 @@ export function registerGcodeTools(registry: ToolRegistry, getConfirmBaseUrl: ()
required: ['job_id', 'confirm_token'],
additionalProperties: false,
},
- handler: async (args: { job_id?: string; confirm_token?: string; wait_until_moved?: boolean }) => {
+ handler: async (args: { job_id?: string; confirm_token?: string; wait_until_moved?: boolean; wait_ms?: number }) => {
probeFeedService.assertNoOvertravel();
const job = jobManager.get(String(args.job_id || ''));
if (!job) {
@@ -313,27 +320,55 @@ export function registerGcodeTools(registry: ToolRegistry, getConfirmBaseUrl: ()
job.startedAt = Date.now();
jobManager.appendEvent(job, 'started', { note: 'procedure runner started' });
jobManager.setActive(job);
- try {
- const outcome = await job.runner();
- // On the record first: a client that timed out waiting here
- // still finds the result in get_gcode_job_status.
- job.result = outcome;
- job.state = 'completed';
- job.endedAt = Date.now();
- jobManager.appendEvent(job, 'completed', { note: 'procedure finished; result stored on the job' });
+ // The runner is DETACHED from this request: a tool setter or
+ // probe circuit runs for minutes, longer than MCP clients wait
+ // (timeouts seen live 2026-09-04/05), and a client giving up
+ // must never abandon the machine mid-procedure or lose the
+ // result. The outcome lands on the job record; this call waits
+ // a bounded time and returns the result if it arrived, else a
+ // "running" status to long-poll with get_gcode_job_status.
+ const finished = job.runner()
+ .then((outcome) => {
+ job.result = outcome;
+ job.state = 'completed';
+ job.endedAt = Date.now();
+ jobManager.appendEvent(job, 'completed', { note: 'procedure finished; result stored on the job' });
+ return { ok: true as const, outcome };
+ })
+ .catch((err: Error) => {
+ job.state = 'start_failed';
+ job.error = err.message;
+ job.endedAt = Date.now();
+ jobManager.appendEvent(job, 'failed', { note: err.message });
+ log.error(`Procedure job ${job.id} failed: ${err.message}`);
+ return { ok: false as const, error: err.message };
+ })
+ .finally(() => {
+ if (jobManager.getActive() === job) {
+ jobManager.setActive(null);
+ }
+ });
+ const waitMs = Math.min(Math.max(Number(args.wait_ms) || PROCEDURE_START_WAIT_MS, 0), 120000);
+ const settledInTime = await Promise.race([
+ finished,
+ sleep(waitMs).then(() => null),
+ ]);
+ if (settledInTime === null) {
return {
job: jobManager.describe(job),
- result: outcome,
+ running: true,
+ note: `The procedure is still running after ${waitMs} ms and continues on the server. `
+ + 'Long-poll get_gcode_job_status (wait_ms up to 120000, since_event) for phases and '
+ + 'the result; do not resubmit.',
};
- } catch (err) {
- job.state = 'start_failed';
- job.error = err.message;
- job.endedAt = Date.now();
- jobManager.appendEvent(job, 'failed', { note: err.message });
- throw err;
- } finally {
- jobManager.setActive(null);
}
+ if (!settledInTime.ok) {
+ throw new McpToolError(settledInTime.error);
+ }
+ return {
+ job: jobManager.describe(job),
+ result: settledInTime.outcome,
+ };
}
if (job.kind === 'direct') {
diff --git a/src/server/services/mcp/tools/machine.ts b/src/server/services/mcp/tools/machine.ts
index 16293a56f2..a5935d9847 100644
--- a/src/server/services/mcp/tools/machine.ts
+++ b/src/server/services/mcp/tools/machine.ts
@@ -116,6 +116,7 @@ export interface PositionSnapshot {
work: { x: number | null; y: number | null; z: number | null };
machine: { x: number | null; y: number | null; z: number | null };
originOffset: { x: number; y: number; z: number };
+ originOffsetSource: 'heartbeat' | 'cached' | 'assumed-zero';
b: number | null;
isFourAxis: boolean;
isHomed: boolean | null;
@@ -153,6 +154,8 @@ export function assertFreshHeartbeat(what: string): void {
* Position from the latest heartbeat, shared by get_position and the
* capture tools. Throws McpToolError when unavailable.
*/
+let lastKnownOriginOffset: { x: number; y: number; z: number; at: number } | null = null;
+
export function getPositionSnapshot(): PositionSnapshot {
const status = connectionManager.getConnectionStatus();
if (!status.connected) {
@@ -174,11 +177,38 @@ export function getPositionSnapshot(): PositionSnapshot {
y: axisValue(pos.y),
z: axisValue(pos.z),
};
- const offset = {
- x: axisValue(originOffset.x) || 0,
- y: axisValue(originOffset.y) || 0,
- z: axisValue(originOffset.z) || 0,
+ const warnings: string[] = [];
+ // The SSTP status poll rebuilds originOffset from data.offsetX/Y/Z on
+ // every beat. A beat that lands inside a move's G53...G54 window (or any
+ // beat the firmware sends without offsets) used to fall through `|| 0`
+ // and silently reframe machine coordinates as work coordinates - which
+ // aborted a probe_sequence march re-check on 2026-09-05 (job
+ // 44abebd9bab3: settled at machine (170,199,240), re-check read
+ // (119,77,-88)). Missing offsets now reuse the last complete offset seen
+ // on this connection and say so; callers that verify position should
+ // re-read once when a check fails (see probeSequence).
+ const reported = {
+ x: axisValue(originOffset.x),
+ y: axisValue(originOffset.y),
+ z: axisValue(originOffset.z),
};
+ let offsetSource: 'heartbeat' | 'cached' | 'assumed-zero' = 'heartbeat';
+ let offset: { x: number; y: number; z: number };
+ if (reported.x !== null && reported.y !== null && reported.z !== null) {
+ offset = { x: reported.x, y: reported.y, z: reported.z };
+ lastKnownOriginOffset = { ...offset, at: state.timestamp };
+ } else if (lastKnownOriginOffset) {
+ offset = { x: lastKnownOriginOffset.x, y: lastKnownOriginOffset.y, z: lastKnownOriginOffset.z };
+ offsetSource = 'cached';
+ warnings.push('The latest heartbeat carried no work-origin offset; machine coordinates use the '
+ + `last complete offset (${offset.x}, ${offset.y}, ${offset.z}) seen ${((state.timestamp - lastKnownOriginOffset.at) / 1000).toFixed(1)}s earlier. `
+ + 'Re-read before trusting a position check.');
+ } else {
+ offset = { x: reported.x || 0, y: reported.y || 0, z: reported.z || 0 };
+ offsetSource = 'assumed-zero';
+ warnings.push('No work-origin offset has been reported on this connection yet; machine coordinates '
+ + 'ASSUME a zero offset and may be wrong - query_firmware_position and re-verify.');
+ }
const machine = {
x: work.x === null ? null : work.x - offset.x,
y: work.y === null ? null : work.y - offset.y,
@@ -189,7 +219,6 @@ export function getPositionSnapshot(): PositionSnapshot {
// reporting positions in an unselected workspace, so derived machine
// coordinates land outside the build volume (e.g. Z 656 on a 325 mm
// machine). Flag it rather than let an agent trust it.
- const warnings: string[] = [];
const reportAgeMs = Date.now() - state.timestamp;
if (reportAgeMs > HEARTBEAT_STALE_MS) {
warnings.push(`STALE: the last heartbeat is ${(reportAgeMs / 1000).toFixed(0)}s old `
@@ -217,6 +246,7 @@ export function getPositionSnapshot(): PositionSnapshot {
work,
machine,
originOffset: offset,
+ originOffsetSource: offsetSource,
b: axisValue(pos.b),
isFourAxis: !!pos.isFourAxis,
isHomed: (state as { isHomed?: boolean }).isHomed ?? null,
From 159073cfabeff8a7b8b0682c0b334f528db21c0e Mon Sep 17 00:00:00 2001
From: tyeth
Date: Sat, 5 Sep 2026 02:06:33 +0100
Subject: [PATCH 057/135] Feature: Surface flatness scans - probe_surface_path
and probe_surface_grid
Two staged touch-probe procedures measuring a TOP surface with many -Z
marches under ONE operator approval; every commanded move is enumerated
with numbers on the confirm page; all results in MACHINE coordinates.
- probe_surface_path: N stations along a straight line (start + end, or
direction + length; by count or maximum spacing). Per-station contact
XYZ / no_contact with confirm-pass spread, Z min/max/range, best-fit
line (slope mm per 100 mm and degrees, rise over the length), flatness
as residual peak-to-valley, text profile.
- probe_surface_grid: serpentine grid over a region (extents or centre +
size; by maximum pitch or counts, max 400 stations). zMatrix with its
coordinates, best-fit plane + residuals, min/max/range, ASCII height
map printed with +Y at the top.
The operator-authorised envelope (2026-09-05: "no more than 20mm z safe
delta from the top (within a horizontal change of 60mm)") is the ONLY
exception to motion law 2, valid inside these two procedures only and
between consecutive stations only:
- z_safe_delta_mm default 20, HARD CAP 20 (min 3): retract to the last
real contact + delta and hop horizontally at that height.
- max_hop_mm default 60, HARD CAP 60: every consecutive pair must be
within it - refused at staging naming the pair, never split silently.
- max_drop_mm default 40, cap 80: a march searches at most this far
below the previous real contact and never below floor_z_machine
(default start_z - max_drop, shown on the confirm page). Floor without
contact = station recorded no_contact, reference unchanged, scan
continues; the FIRST station finding nothing aborts.
- start_z_machine REQUIRED (measured or operator-stated): the approach
to station 1 is a full law-2 move - raise to the traverse height, hop
at gantry height, guarded 1 mm descent (contact = CRASH).
Caps are enforced by refusal at staging (surfaceScan.resolveEnvelope,
assertHopsWithin); mechanics at runtime (probeSurface.ts): hops through
moveMachineSettled with clearExpectedContact() in <= 10 mm sensor-checked
segments so a touch during a hop latches CRASH, marches with
setExpectedContact(['probe']), staged-position and per-march position
re-check with one re-read after 1.2 s, abort-raise to the traverse
height, phases via mcpBroadcast, structured result on job.result.
surfaceScan.ts is import-free so it compiles alone; 23 unit tests cover
station generation (path, serpentine grid incl. row turns), the 60 mm hop
refusal, floor computation, line/plane fits with residuals, the Z matrix
and the ASCII renderings. README gains the tools and a "Surface scans"
subsection; the cnc-probing skill gains "Surface flatness and height
maps".
Co-Authored-By: Claude Fable 5.1
---
.claude/skills/cnc-probing/SKILL.md | 53 +-
src/server/services/mcp/README.md | 45 +-
src/server/services/mcp/probeSurface.ts | 701 +++++++++++++++++++++++
src/server/services/mcp/surfaceScan.ts | 602 +++++++++++++++++++
src/server/services/mcp/tools/probing.ts | 167 ++++++
5 files changed, 1565 insertions(+), 3 deletions(-)
create mode 100644 src/server/services/mcp/probeSurface.ts
create mode 100644 src/server/services/mcp/surfaceScan.ts
diff --git a/.claude/skills/cnc-probing/SKILL.md b/.claude/skills/cnc-probing/SKILL.md
index 4d72a127c2..5b4b62ed1a 100644
--- a/.claude/skills/cnc-probing/SKILL.md
+++ b/.claude/skills/cnc-probing/SKILL.md
@@ -1,6 +1,6 @@
---
name: cnc-probing
-description: "Measure work with the spindle touch probe and the whole-bed camera survey via the Luban MCP tools (probe_point, survey_bed, run_tool_setter with accept_probe_contact) — including the motion laws written in the aftermath of a probe-destroying crash. Use whenever the user wants to probe stock, find surfaces/edges, survey the bed, or calibrate the touch probe."
+description: "Measure work with the spindle touch probe and the whole-bed camera survey via the Luban MCP tools (probe_point, probe_vector, probe_sequence, probe_circle, probe_surface_path/grid flatness scans, survey_bed, run_tool_setter with accept_probe_contact) — including the motion laws written in the aftermath of a probe-destroying crash. Use whenever the user wants to probe stock, find surfaces/edges, check flatness or map a surface height, survey the bed, or calibrate the touch probe."
---
# CNC probing: the probe, the survey, and the motion laws
@@ -150,6 +150,57 @@ out-of-round tip (the post-unbending health check). Repositioning between
points obeys law 2 in full: lift to the safe traverse height, hop, descend;
a probe touch during a hop or descent latches the CRASH alarm.
+## Surface flatness and height maps
+
+Two staged procedures measure a TOP surface with many −Z marches under ONE
+operator approval; every commanded move is enumerated on the confirm page,
+all numbers are machine coordinates, and every contact Z is TOOLHEAD Z (the
+surface is that minus the probe length).
+
+- `probe_surface_path` — N stations along a straight line (`start_x/start_y`
+ plus `end_x/end_y` or `dx/dy` + `length_mm`; sampled by `stations` count or a
+ MAXIMUM `spacing_mm`). Use it for "is this stock level along Y", "how much
+ does the board rise toward the free end", a rotary-mounted flat. Result:
+ per-station XYZ (or `no_contact`), Z min/max/range, best-fit line slope (mm
+ per 100 mm and degrees, rise over the length), flatness = residual
+ peak-to-valley, a text profile.
+- `probe_surface_grid` — a serpentine grid over a region (`x_min..y_max` or
+ `center_x/center_y` + `size_x_mm[/size_y_mm]`; sampled by a MAXIMUM `pitch_mm`
+ or `x_count/y_count`, max 400 stations). Use it for a wasteboard, a pocketed
+ box, a log — anything whose height varies in two directions. Result:
+ `zMatrix` (rows = ys ascending, cols = xs ascending, null = no contact),
+ best-fit plane (tilt X/Y) with per-point residuals and flatness, and a text
+ `heightMap` printed with +Y at the top like the bed seen from above.
+
+`start_z_machine` is REQUIRED for both: the toolhead machine Z at which the
+first march starts with the tip just above the surface — measured (an earlier
+`probe_point -Z`, a previous scan) or operator-stated, never inferred from a
+photo (law 3). The runner reaches it law-2 style: raise to the traverse height,
+hop at gantry height to station 1, then a guarded 1 mm descent where any
+contact latches CRASH.
+
+**The envelope (operator law, 2026-09-05)** — the ONLY exception to motion law
+2, valid inside these two procedures only, between consecutive stations only.
+The operator's words: "no more than 20mm z safe delta from the top (within a
+horizontal change of 60mm)".
+
+| Parameter | Default | Hard limit | Meaning |
+|---|---|---|---|
+| `z_safe_delta_mm` | 20 | **cap 20**, min 3 | After each station the probe retracts to LAST CONTACT + this and hops at that height. |
+| `max_hop_mm` | 60 | **cap 60** | Largest allowed distance between consecutive stations. A spacing/pitch that breaks it is REFUSED at staging, naming the pair — pick a finer pitch; nothing is split for you. |
+| `max_drop_mm` | 40 | cap 80 | How far below the previous real contact a station may search. Also bounded by `floor_z_machine` (default `start_z_machine − max_drop_mm`), the deepest Z the scan can ever command — shown on the confirm page. |
+
+Reaching the floor without contact records the station `no_contact` and the
+scan CONTINUES with the reference height unchanged (a pocket, a hole, an edge
+overshoot); the first station finding nothing aborts. Hops run in ≤ 10 mm
+sensor-checked segments expecting NO contact — a touch during a hop means the
+surface rose more than `z_safe_delta_mm` and latches the CRASH alarm (law 5).
+Completion and abort both raise to the traverse height. Never ask for the caps
+to be widened and never approximate a scan with `probe_sequence` hops at a
+"measured safe" height — that is exactly what law 2 forbids. On the GPIO
+transport, `sensor_delay_ms: 50` and `coarse_step_mm: 2` on a known-flat surface
+roughly halve the time per station.
+
## Bed survey
`survey_bed` at top gantry height: serpentine grid, one settled frame per
diff --git a/src/server/services/mcp/README.md b/src/server/services/mcp/README.md
index cde49946bb..4eead29636 100644
--- a/src/server/services/mcp/README.md
+++ b/src/server/services/mcp/README.md
@@ -95,6 +95,10 @@ mcp/
gpioFeed.ts GPIO backend: Blinka/U2IF python monitor subprocess (embedded
source, JSON lines: ready|reading|hb|fatal), stall watchdog
toolSetter.ts tool height measurement: config, envelope planner, staged runner
+ probing.ts shared sensor-gated motion engine (moveMachineSettled, senseAfter, guards)
+ probeTool.ts / probeVector.ts / probeSequence.ts / probeCircle.ts staged probe procedures
+ surfaceScan.ts pure station planning + flatness statistics (no imports; unit-tested alone)
+ probeSurface.ts probe_surface_path / probe_surface_grid plan builders + runner
tools/ status, machine, gcode, camera, calibration, probe, toolsetter
```
@@ -201,6 +205,35 @@ it with no decision point, when the operator had authorised "step 1" only. Laws:
configstore, or the backend APIs directly while the app runs; every guard lives in the
tools, so bypassing them bypasses all of it.
+### Surface scans — the one bounded exception to law 2 (operator-authorised 2026-09-05)
+
+`probe_surface_path` and `probe_surface_grid` measure a TOP surface with many −Z marches
+in one approved circuit. The operator's words: *"with the grid we need the point to point
+variation to not risk the probe toolhead so no more than 20mm z safe delta from the top
+(within a horizontal change of 60mm)"*. Hence, **inside these two procedures only, between
+consecutive stations only**, the probe retracts to `last contact + z_safe_delta_mm` and hops
+horizontally at that height instead of at the gantry. Nothing else inherits this. Bounds
+(`surfaceScan.ts`, refused at staging — never clamped or split silently):
+
+- `z_safe_delta_mm` default 20, **hard cap 20** (min 3) — the hop height above the last real
+ contact (`resolveEnvelope`).
+- `max_hop_mm` default 60, **hard cap 60** — every consecutive station pair must be within
+ it (`assertHopsWithin`); a spacing/pitch that violates it is refused naming the pair.
+- `max_drop_mm` default 40, cap 80 — a station's march may search at most this far below the
+ previous real contact, and never below `floor_z_machine` (default `start_z_machine −
+ max_drop_mm`, the deepest Z the scan can ever command — on the confirm page). Reaching the
+ floor without contact records the station `no_contact` and continues; the reference height
+ stays the last real contact. The FIRST station finding nothing aborts (no measured
+ reference to base the envelope on).
+- `start_z_machine` is REQUIRED — measured or operator-stated, never guessed. The approach to
+ station 1 is a full law-2 move: raise to the traverse height, hop, guarded 1 mm descent
+ (`probe_sequence` pattern; contact = CRASH). Completion and abort both raise to the
+ traverse height.
+- Runtime (`probeSurface.ts`): hops go through `moveMachineSettled` with
+ `clearExpectedContact()` in ≤ 10 mm sensor-checked segments, so a probe touch during a hop
+ latches CRASH (law 5); only marches run with `setExpectedContact(['probe'])`. The staged
+ position and every march start are re-checked (one re-read after ~1.2 s).
+
## Safety model (operator-defined, non-negotiable)
- **Compound motion and all cutting goes out as gcode FILES** through the same
@@ -313,7 +346,7 @@ it with no decision point, when the operator had authorised "step 1" only. Laws:
the agent click Approve for one bounded series of moves, that authority ends with that
series ("no approvals carry forwards in CNC work").
-## Tool surface (33)
+## Tool surface (40)
`get_connection_status` · `get_machine_profile` (kinematics, module offsets) ·
`get_position` (both frames, warnings on incoherent reporting) ·
@@ -344,7 +377,15 @@ and untriggered; `store_as_reference` locks the measured Z in as the new referen
`stay_at_trigger` / `start_from_current` support the touchscreen swap wizard) ·
`goto_tool_change_position` (two approved steps: Z up, then X/Y to the operator-set park) ·
`apply_tool_length_offset` (confirmed G92 shifting work-origin Z by the measured
-new−old tool length difference — flow A only).
+new−old tool length difference — flow A only) · **touch-probe procedures** (all staged,
+one approval per circuit, results in MACHINE coordinates): `probe_point` (one axis from the
+current position) · `probe_vector` (any downward/lateral unit vector) · `probe_sequence`
+(enumerated hop/descend/probe circuit, law-2 hops) · `probe_circle` (N radial marches +
+least-squares fit, outside or inside a hole) · `probe_surface_path` (N −Z stations along a
+line: per-station contact, best-fit line slope, flatness) · `probe_surface_grid` (serpentine
+−Z grid: Z matrix, best-fit plane + residuals, ASCII height map) — the two surface scans hop
+at `last contact + z_safe_delta_mm` (cap 20) within `max_hop_mm` (cap 60), see "Surface
+scans" above · `survey_bed` (camera grid at gantry height).
## Tool change workflows
diff --git a/src/server/services/mcp/probeSurface.ts b/src/server/services/mcp/probeSurface.ts
new file mode 100644
index 0000000000..6a55ab6d19
--- /dev/null
+++ b/src/server/services/mcp/probeSurface.ts
@@ -0,0 +1,701 @@
+/* eslint-disable camelcase */
+// MCP tool arguments are snake_case by convention (the plan builders take the
+// probe_surface_path / probe_surface_grid arguments verbatim).
+import { mcpBroadcast } from './index';
+import { probeFeedService } from './probeFeed';
+import { DESCENT_GUARD_MM } from './probeSequence';
+import {
+ COARSE_FEED,
+ FINE_FEED,
+ MAX_RETREAT_MM,
+ ProcedureAbort,
+ TRAVEL_FEED,
+ assertChannelReady,
+ assertMachineReadyForProcedure,
+ moveMachineSettled,
+ senseAfter,
+ senseReleaseAfter,
+ sleep,
+} from './probing';
+import { McpToolError } from './registry';
+import {
+ ContactSample,
+ HOP_SEGMENT_MM,
+ SurfacePlanError,
+ SurfaceStation,
+ assertHopsWithin,
+ buildZMatrix,
+ fitLine,
+ fitPlane,
+ hopSegments,
+ planGridStations,
+ planPathStations,
+ renderHeightMap,
+ renderPathProfile,
+ resolveEnvelope,
+ stationEnvelope,
+ summarizeZ,
+} from './surfaceScan';
+import { getMachineSizeByIdentifier, getPositionSnapshot, safeTraverseZ } from './tools/machine';
+import { connectionManager } from '../machine/ConnectionManager';
+
+// Top-surface scans with the spindle touch probe: N stations along a line
+// (probe_surface_path) or over a serpentine grid (probe_surface_grid), each
+// measured with a -Z sensor-gated march using the hardware-proven
+// coarse/release/fine/confirm mechanics of probe_vector / probe_sequence.
+// One operator approval covers the whole circuit; the confirm page
+// enumerates every commanded move with concrete numbers.
+//
+// THE LAW-2 EXCEPTION (operator-authorised, 2026-09-05). Motion law 2 says
+// every XY move over 1 mm happens at the safe traverse height. The operator's
+// words for these two procedures: "with the grid we need the point to point
+// variation to not risk the probe toolhead so no more than 20mm z safe delta
+// from the top (within a horizontal change of 60mm)". So, INSIDE THESE TWO
+// PROCEDURES ONLY, between CONSECUTIVE stations only, and only while the
+// horizontal hop is <= max_hop_mm (cap 60), the probe retracts to
+// lastContactZ + z_safe_delta_mm (cap 20) and hops AT THAT HEIGHT instead of
+// at the gantry. Nothing else inherits this: the approach to the FIRST
+// station is a full law-2 traverse (raise to the safe traverse height, hop,
+// guarded descent to the operator-stated start_z_machine), and the scan ends
+// raised at the traverse height. Caps are enforced by refusal at staging
+// (never clamped), the mechanics are enforced at runtime:
+//
+// - hops run with NO expected contact, through moveMachineSettled (crash
+// guard armed), in sensor-checked segments of <= HOP_SEGMENT_MM so a
+// graze is caught within one segment: contact during a hop = collision.
+// - each march may search down to max(lastContact - max_drop_mm, the
+// plan's absolute floor); reaching the floor without contact records the
+// station as no_contact (not an abort) and leaves the reference height
+// at the last REAL contact. The first station finding nothing aborts -
+// there would be no measured reference to base the envelope on.
+
+export type SurfaceScanKind = 'path' | 'grid';
+
+export interface ProbeSurfacePlan {
+ kind: SurfaceScanKind;
+ tool: 'probe_surface_path' | 'probe_surface_grid';
+ stations: SurfaceStation[];
+ /** Operator-stated toolhead machine Z at which the FIRST march starts (tip just above the surface). */
+ startZMachine: number;
+ /** Deepest toolhead Z any march may ever command (default start_z - max_drop). */
+ absoluteFloorZ: number;
+ zSafeDeltaMm: number;
+ maxHopMm: number;
+ maxDropMm: number;
+ /** Largest consecutive-station distance in this plan (already verified <= maxHopMm). */
+ worstHopMm: number;
+ /** Safe traverse height: approach to station 1 and the final raise. */
+ hopZ: number;
+ staged: { x: number; y: number; z: number };
+ coarseStepMm: number;
+ fineStepMm: number;
+ backoffMm: number;
+ sensorDelayMs: number;
+ confirmPasses: number;
+ path?: {
+ start: { x: number; y: number };
+ end: { x: number; y: number };
+ unit: { x: number; y: number };
+ lengthMm: number;
+ spacingMm: number;
+ };
+ grid?: {
+ xs: number[];
+ ys: number[];
+ pitchXMm: number;
+ pitchYMm: number;
+ bounds: { xMin: number; xMax: number; yMin: number; yMax: number };
+ };
+}
+
+interface CommonArgs {
+ start_z_machine?: unknown;
+ floor_z_machine?: unknown;
+ z_safe_delta_mm?: unknown;
+ max_hop_mm?: unknown;
+ max_drop_mm?: unknown;
+ coarse_step_mm?: number;
+ fine_step_mm?: number;
+ backoff_mm?: number;
+ sensor_delay_ms?: number;
+ confirm_passes?: number;
+}
+
+function toToolError(fn: () => T): T {
+ try {
+ return fn();
+ } catch (err) {
+ if (err instanceof SurfacePlanError) {
+ throw new McpToolError(err.message);
+ }
+ throw err;
+ }
+}
+
+/** Everything both scans share once the stations exist. */
+function finishPlan(
+ kind: SurfaceScanKind,
+ stations: SurfaceStation[],
+ args: CommonArgs
+): Omit {
+ const env = toToolError(() => resolveEnvelope(args));
+ const worstHopMm = toToolError(() => assertHopsWithin(stations, env.maxHopMm));
+
+ const hopZ = safeTraverseZ();
+ const startZ = Number(args.start_z_machine);
+ if (args.start_z_machine === undefined || !Number.isFinite(startZ)) {
+ throw new McpToolError('start_z_machine is REQUIRED: the toolhead machine Z at which the first -Z march '
+ + 'starts, with the probe tip just above the surface - a measured or operator-stated number, never a guess.');
+ }
+ if (startZ <= 0 || startZ > hopZ) {
+ throw new McpToolError(`start_z_machine ${startZ} must be in 0..${hopZ} (the safe traverse height).`);
+ }
+ const floorZ = args.floor_z_machine === undefined ? startZ - env.maxDropMm : Number(args.floor_z_machine);
+ if (!Number.isFinite(floorZ) || floorZ < 0) {
+ throw new McpToolError('floor_z_machine must be a finite machine Z >= 0.');
+ }
+ if (floorZ >= startZ) {
+ throw new McpToolError(`floor_z_machine ${floorZ} must be below start_z_machine ${startZ}.`);
+ }
+ if (startZ - floorZ > 150) {
+ throw new McpToolError(`start_z_machine - floor_z_machine = ${(startZ - floorZ).toFixed(1)} mm exceeds 150 mm.`);
+ }
+
+ const size = getMachineSizeByIdentifier(connectionManager.getConnectionStatus().machineIdentifier);
+ if (size) {
+ for (const st of stations) {
+ if (st.x < -25 || st.x > size.x + 40 || st.y < -25 || st.y > size.y + 40) {
+ throw new McpToolError(`Station ${st.label} (${st.x}, ${st.y}) is outside the machine envelope.`);
+ }
+ }
+ }
+
+ const position = getPositionSnapshot();
+ const { x, y, z } = position.machine;
+ if (x === null || y === null || z === null) {
+ throw new McpToolError('Current machine position unknown; cannot anchor the scan.');
+ }
+
+ return {
+ kind,
+ tool: kind === 'path' ? 'probe_surface_path' : 'probe_surface_grid',
+ stations,
+ startZMachine: Number(startZ.toFixed(3)),
+ absoluteFloorZ: Number(floorZ.toFixed(3)),
+ zSafeDeltaMm: env.zSafeDeltaMm,
+ maxHopMm: env.maxHopMm,
+ maxDropMm: env.maxDropMm,
+ worstHopMm,
+ hopZ,
+ staged: { x, y, z },
+ coarseStepMm: Math.min(Math.max(Number(args.coarse_step_mm) || 1, 0.2), 2),
+ fineStepMm: Math.min(Math.max(Number(args.fine_step_mm) || 0.1, 0.02), 0.5),
+ backoffMm: Math.min(Math.max(Number(args.backoff_mm) || 1, Number(args.fine_step_mm) || 0.1), 3),
+ sensorDelayMs: Math.min(Math.max(Number(args.sensor_delay_ms) || 300, 100), 10000),
+ confirmPasses: Math.min(Math.max(Math.round(Number(args.confirm_passes) || 3), 1), 10),
+ };
+}
+
+export function planProbeSurfacePath(args: CommonArgs & {
+ start_x?: unknown;
+ start_y?: unknown;
+ end_x?: unknown;
+ end_y?: unknown;
+ dx?: unknown;
+ dy?: unknown;
+ length_mm?: unknown;
+ stations?: unknown;
+ spacing_mm?: unknown;
+}): ProbeSurfacePlan {
+ const path = toToolError(() => planPathStations({
+ start_x: args.start_x,
+ start_y: args.start_y,
+ end_x: args.end_x,
+ end_y: args.end_y,
+ dx: args.dx,
+ dy: args.dy,
+ length_mm: args.length_mm,
+ stations: args.stations,
+ spacing_mm: args.spacing_mm,
+ }));
+ return {
+ ...finishPlan('path', path.stations, args),
+ path: { start: path.start, end: path.end, unit: path.unit, lengthMm: path.lengthMm, spacingMm: path.spacingMm },
+ };
+}
+
+export function planProbeSurfaceGrid(args: CommonArgs & {
+ x_min?: unknown;
+ x_max?: unknown;
+ y_min?: unknown;
+ y_max?: unknown;
+ center_x?: unknown;
+ center_y?: unknown;
+ size_x_mm?: unknown;
+ size_y_mm?: unknown;
+ pitch_mm?: unknown;
+ x_count?: unknown;
+ y_count?: unknown;
+}): ProbeSurfacePlan {
+ const grid = toToolError(() => planGridStations(args));
+ return {
+ ...finishPlan('grid', grid.stations, args),
+ grid: { xs: grid.xs, ys: grid.ys, pitchXMm: grid.pitchXMm, pitchYMm: grid.pitchYMm, bounds: grid.bounds },
+ };
+}
+
+/** Confirm-page gcode: the whole scan, every commanded move enumerated with its bounds. */
+export function describeProbeSurfacePlanAsGcode(plan: ProbeSurfacePlan): string {
+ const n = plan.stations.length;
+ const first = plan.stations[0];
+ let shape = `${n} stations`;
+ if (plan.kind === 'path' && plan.path) {
+ shape = `${n} stations along (${plan.path.start.x}, ${plan.path.start.y}) -> (${plan.path.end.x}, ${plan.path.end.y}), `
+ + `spacing ${plan.path.spacingMm} mm`;
+ } else if (plan.grid) {
+ shape = `${plan.grid.xs.length} x ${plan.grid.ys.length} = ${n} stations, pitch X ${plan.grid.pitchXMm} / Y ${plan.grid.pitchYMm} mm, `
+ + `X ${plan.grid.bounds.xMin}..${plan.grid.bounds.xMax}, Y ${plan.grid.bounds.yMin}..${plan.grid.bounds.yMax}, serpentine`;
+ }
+ const firstEnv = stationEnvelope(plan.startZMachine, true, plan.startZMachine, {
+ zSafeDeltaMm: plan.zSafeDeltaMm, maxDropMm: plan.maxDropMm, absoluteFloorZ: plan.absoluteFloorZ,
+ });
+ // The guarded descent starts at min(start_z + guard, traverse height): the
+ // runner never commands a Z above the height it is already at.
+ const guardTop = Math.min(plan.startZMachine + DESCENT_GUARD_MM, plan.hopZ);
+ const lines = [
+ `; SURFACE ${plan.kind.toUpperCase()} SCAN: ${shape}`,
+ '; every station = a -Z sensor-gated march on the probe channel (coarse to contact, release,',
+ `; ${plan.fineStepMm} mm fine approach, ${plan.confirmPasses} confirm pass(es), median); all coordinates MACHINE frame.`,
+ `; anchored at machine (${plan.staged.x.toFixed(2)}, ${plan.staged.y.toFixed(2)}, ${plan.staged.z.toFixed(2)})`
+ + ' - re-verified before any motion, and before EVERY march',
+ ';',
+ '; ENVELOPE (operator, 2026-09-05: "no more than 20mm z safe delta from the top (within a horizontal',
+ '; change of 60mm)") - the operator-authorised EXCEPTION to motion law 2, valid ONLY inside this',
+ '; procedure, ONLY between consecutive stations, ONLY for hops <= max_hop_mm:',
+ `; * between stations the probe retracts to LAST CONTACT + ${plan.zSafeDeltaMm} mm (z_safe_delta_mm, cap 20) and hops`,
+ `; horizontally AT THAT HEIGHT in <= ${HOP_SEGMENT_MM} mm sensor-checked segments - ANY probe contact during a hop`,
+ '; is a collision: CRASH alarm latches (job stop + connection close), operator clears it.',
+ `; * largest hop in this plan: ${plan.worstHopMm} mm (max_hop_mm ${plan.maxHopMm}, cap 60) - refused at staging otherwise.`,
+ `; * each march searches from the hop height down to max(last contact - ${plan.maxDropMm} mm, FLOOR Z${plan.absoluteFloorZ});`,
+ '; nothing by the floor = station recorded no_contact, reference height unchanged, scan continues',
+ '; (the FIRST station finding nothing aborts - no measured reference).',
+ `; * the deepest toolhead Z this scan can EVER command is Z${plan.absoluteFloorZ} (floor_z_machine).`,
+ '; The approach to station 1 and the final raise are full law-2 moves at the traverse height.',
+ '; overtravel feed trips -> job stop + connection close + latched alarm',
+ 'G90',
+ 'G53;',
+ `G1 Z${plan.hopZ.toFixed(3)} F${TRAVEL_FEED}; raise to the safe traverse height (law 2)`,
+ `G1 X${first.x.toFixed(3)} Y${first.y.toFixed(3)} F${TRAVEL_FEED}; hop at gantry height to station 1 "${first.label}"`,
+ `G1 Z${guardTop.toFixed(3)} F${TRAVEL_FEED}; descend fast to ${(guardTop - plan.startZMachine).toFixed(1)} mm above start_z_machine`,
+ '; ...guarded final approach: 1 mm steps, sensor-checked after each - ANY contact here aborts (CRASH).',
+ ];
+ for (let gz = guardTop - 1; gz > plan.startZMachine - 1e-9; gz -= 1) {
+ lines.push(`G1 Z${Math.max(gz, plan.startZMachine).toFixed(3)} F${COARSE_FEED}; guarded descent step`);
+ }
+ lines.push(`; --- station 1 "${first.label}" (${first.x}, ${first.y}): march -Z from Z${firstEnv.marchStartZ} to floor Z${firstEnv.floorZ} ---`);
+ let s = 0;
+ let k = 0;
+ while (firstEnv.travelMm - s > 1e-9) {
+ s = Math.min(s + plan.coarseStepMm, firstEnv.travelMm);
+ k += 1;
+ lines.push(`G1 Z${(firstEnv.marchStartZ - s).toFixed(3)} F${COARSE_FEED}; coarse ${k} - settle, check probe, stop at contact`);
+ }
+ lines.push(`; ...on contact: release, ${plan.fineStepMm} mm fine approach, ${plan.confirmPasses} confirm cycle(s) (lift ${plan.backoffMm} mm)`);
+ lines.push(`; retract to contact + ${plan.zSafeDeltaMm} mm (runtime number, never above Z${plan.hopZ})`);
+ for (let i = 1; i < n; i++) {
+ const prev = plan.stations[i - 1];
+ const st = plan.stations[i];
+ lines.push(`; --- station ${i + 1} "${st.label}" (${st.x}, ${st.y}): hop ${st.hopFromPreviousMm} mm at Z = last contact + ${plan.zSafeDeltaMm} ---`);
+ const segs = hopSegments({ x: prev.x, y: prev.y }, { x: st.x, y: st.y });
+ segs.forEach((seg, j) => {
+ lines.push(`G1 X${seg.x.toFixed(3)} Y${seg.y.toFixed(3)} F${TRAVEL_FEED}; hop segment ${j + 1}/${segs.length} - settle, probe must NOT be in contact`);
+ });
+ lines.push(`; march -Z in ${plan.coarseStepMm} mm steps from the hop height to max(last contact - ${plan.maxDropMm}, Z${plan.absoluteFloorZ})`);
+ lines.push(`G1 Z${plan.absoluteFloorZ.toFixed(3)} F${COARSE_FEED}; deepest allowed at this station (absolute floor) - no contact by here = no_contact`);
+ lines.push(`; ...on contact: release, fine, confirm; retract to contact + ${plan.zSafeDeltaMm} mm`);
+ }
+ lines.push(`G1 Z${plan.hopZ.toFixed(3)} F${TRAVEL_FEED}; finish at the safe traverse height (also on any abort)`);
+ lines.push('G54;');
+ return lines.join('\n');
+}
+
+export interface SurfaceStationResult {
+ index: number;
+ label: string;
+ x: number;
+ y: number;
+ s?: number;
+ row?: number;
+ col?: number;
+ status: 'contact' | 'no_contact';
+ /** Toolhead machine Z at contact (null when no_contact). */
+ z: number | null;
+ marchStartZ: number;
+ floorZ: number;
+ confirmPassContacts?: number[];
+ spreadMm?: number;
+}
+
+/**
+ * One -Z march from startZ down to floorZ at the station's XY: the exact
+ * probe_vector / probe_sequence mechanics along the -Z unit vector, except
+ * that reaching the floor without contact RETURNS null instead of aborting
+ * (a surface scan must record a missing station and carry on).
+ */
+async function marchDownZ(
+ plan: ProbeSurfacePlan,
+ station: SurfaceStation,
+ startZ: number,
+ floorZ: number,
+ announce: (phase: string, note?: string) => void
+): Promise<{ contactZ: number; passContacts: number[]; spreadMm: number } | null> {
+ const travel = Number((startZ - floorZ).toFixed(3));
+ const releaseTimeoutMs = Math.max(plan.sensorDelayMs * 4, 3500);
+ const zAt = (s: number) => Number((startZ - s).toFixed(3));
+ const move = async (tool: string, s: number, feed: number) => {
+ await moveMachineSettled(tool, { z: zAt(s) }, feed);
+ };
+ const tag = `${plan.tool}:${station.label}`;
+
+ let s = 0;
+ let coarseContactS: number | null = null;
+ while (travel - s > 1e-9) {
+ const t0 = Date.now();
+ s = Math.min(s + plan.coarseStepMm, travel);
+ await move(`${tag}:coarse`, s, COARSE_FEED);
+ const sensed = await senseAfter('probe', t0, plan.sensorDelayMs);
+ if (sensed.contact) {
+ coarseContactS = s;
+ announce(`coarse-contact-${station.label}`, `Z${zAt(s)}`);
+ break;
+ }
+ }
+ if (coarseContactS === null) {
+ return null;
+ }
+ let released = false;
+ while (s > 1e-9 && coarseContactS - s < MAX_RETREAT_MM + 1e-9) {
+ const t0 = Date.now();
+ s = Math.max(s - plan.coarseStepMm, 0);
+ await move(`${tag}:release`, s, COARSE_FEED);
+ const sensed = await senseReleaseAfter('probe', t0, releaseTimeoutMs);
+ if (!sensed.contact) {
+ released = true;
+ break;
+ }
+ }
+ if (!released) {
+ throw new ProcedureAbort(`Station "${station.label}": probe still triggered ${MAX_RETREAT_MM} mm back from first contact - stuck probe or feed fault.`);
+ }
+ let fineContactS: number | null = null;
+ while (travel - s > 1e-9) {
+ const t0 = Date.now();
+ s = Math.min(s + plan.fineStepMm, travel);
+ await move(`${tag}:fine`, s, FINE_FEED);
+ const sensed = await senseAfter('probe', t0, plan.sensorDelayMs);
+ if (sensed.contact) {
+ fineContactS = s;
+ break;
+ }
+ }
+ if (fineContactS === null) {
+ throw new ProcedureAbort(`Station "${station.label}": fine approach lost the contact.`);
+ }
+ const passContacts: number[] = [];
+ const cycleLimit = Math.min(fineContactS + Math.max(0.5, plan.backoffMm), travel);
+ let reference = fineContactS;
+ for (let pass = 1; pass <= plan.confirmPasses; pass++) {
+ const t0 = Date.now();
+ s = Math.max(reference - plan.backoffMm, 0);
+ await move(`${tag}:backoff`, s, FINE_FEED);
+ const lifted = await senseReleaseAfter('probe', t0, releaseTimeoutMs);
+ if (lifted.contact) {
+ throw new ProcedureAbort(`Station "${station.label}": hysteresis exceeds the backoff ${plan.backoffMm} mm.`);
+ }
+ let passContact: number | null = null;
+ while (cycleLimit - s > 1e-9) {
+ const t1 = Date.now();
+ s = Math.min(s + plan.fineStepMm, cycleLimit);
+ await move(`${tag}:confirm`, s, FINE_FEED);
+ const sensed = await senseAfter('probe', t1, plan.sensorDelayMs);
+ if (sensed.contact) {
+ passContact = s;
+ break;
+ }
+ }
+ if (passContact === null) {
+ throw new ProcedureAbort(`Station "${station.label}": confirm pass ${pass} lost the contact.`);
+ }
+ passContacts.push(zAt(passContact));
+ reference = passContact;
+ }
+ const sorted = [...passContacts].sort((a, b) => a - b);
+ const contactZ = sorted[Math.floor((sorted.length - 1) / 2)];
+ const spreadMm = Number((sorted[sorted.length - 1] - sorted[0]).toFixed(3));
+ return { contactZ, passContacts, spreadMm };
+}
+
+/** Structured procedure result (lands on job.result): stations, statistics, renderings. */
+function buildResult(plan: ProbeSurfacePlan, results: SurfaceStationResult[], phases: { phase: string; note?: string }[]): object {
+ const contacts: ContactSample[] = results
+ .filter((r): r is SurfaceStationResult & { z: number } => r.z !== null)
+ .map((r) => ({ x: r.x, y: r.y, z: r.z, s: r.s, label: r.label }));
+ const summary = summarizeZ(contacts);
+ const noContact = results.filter((r) => r.status === 'no_contact').map((r) => r.label);
+ const worstSpread = Math.max(0, ...results.map((r) => r.spreadMm || 0));
+ const common = {
+ kind: plan.kind,
+ tool: plan.tool,
+ stations: results,
+ contactCount: contacts.length,
+ noContactStations: noContact,
+ summary,
+ worstConfirmSpreadMm: Number(worstSpread.toFixed(3)),
+ envelope: {
+ zSafeDeltaMm: plan.zSafeDeltaMm,
+ maxHopMm: plan.maxHopMm,
+ worstHopMm: plan.worstHopMm,
+ maxDropMm: plan.maxDropMm,
+ absoluteFloorZ: plan.absoluteFloorZ,
+ startZMachine: plan.startZMachine,
+ },
+ coordinateNote: 'All Z values are TOOLHEAD machine Z at probe contact; the physical surface height is Z minus '
+ + 'the probe\'s effective length (measure it with run_tool_setter accept_probe_contact - never assume).',
+ phases,
+ warning: worstSpread > plan.fineStepMm + 1e-9
+ ? `Worst confirm spread ${worstSpread.toFixed(3)} mm exceeds one fine step - feed timing was unstable; `
+ + 'consider more confirm_passes or a longer sensor_delay_ms.'
+ : undefined,
+ };
+ if (plan.kind === 'path' && plan.path) {
+ const line = fitLine(contacts);
+ const profile = renderPathProfile(results.map((r) => ({ label: r.label, s: r.s || 0, z: r.z })));
+ return {
+ ...common,
+ path: plan.path,
+ lineFit: line,
+ profile,
+ note: summary
+ ? `Surface path: ${contacts.length}/${results.length} contacts, Z ${summary.zMin}..${summary.zMax} `
+ + `(range ${summary.zRange} mm)${line ? `; best-fit slope ${line.slopeMmPer100Mm} mm per 100 mm (${line.slopeDeg} deg), `
+ + `flatness about the line ${line.flatnessMm} mm (rms residual ${line.rmsResidualMm})` : ''}. MACHINE coordinates.`
+ : 'Surface path: no contacts.',
+ };
+ }
+ const grid = plan.grid as NonNullable;
+ const plane = fitPlane(contacts);
+ const zMatrix = buildZMatrix(grid.xs, grid.ys, results.map((r) => ({ row: r.row as number, col: r.col as number, z: r.z })));
+ return {
+ ...common,
+ grid: { xs: grid.xs, ys: grid.ys, pitchXMm: grid.pitchXMm, pitchYMm: grid.pitchYMm, bounds: grid.bounds },
+ zMatrix,
+ heightMap: renderHeightMap(grid.xs, grid.ys, zMatrix),
+ planeFit: plane,
+ note: summary
+ ? `Surface grid: ${contacts.length}/${results.length} contacts, Z ${summary.zMin}..${summary.zMax} `
+ + `(range ${summary.zRange} mm, highest ${summary.highest}, lowest ${summary.lowest})`
+ + `${plane ? `; best-fit plane tilt X ${plane.tiltXMmPer100Mm} / Y ${plane.tiltYMmPer100Mm} mm per 100 mm, `
+ + `flatness about the plane ${plane.flatnessMm} mm (rms residual ${plane.rmsResidualMm})` : ''}. `
+ + 'MACHINE coordinates; zMatrix rows = ys ascending, cols = xs ascending; heightMap prints +Y at the top.'
+ : 'Surface grid: no contacts.',
+ };
+}
+
+export async function runProbeSurfaceProcedure(plan: ProbeSurfacePlan): Promise {
+ assertChannelReady('probe', `surface ${plan.kind} scan`);
+ assertMachineReadyForProcedure();
+
+ const phases: { phase: string; note?: string }[] = [];
+ const announce = (phase: string, note?: string) => {
+ phases.push({ phase, note });
+ mcpBroadcast('mcp:activity', { tool: plan.tool, phase, note });
+ };
+
+ // Position checks: every preceding move verified its own arrival, so a
+ // mismatch is drift or a transient heartbeat (a beat inside a G53...G54
+ // window carrying no origin offset - job 44abebd9bab3, 2026-09-05).
+ // Re-read once after a heartbeat period before believing it.
+ const fmt = (sn: ReturnType) => `(${sn.machine.x}, ${sn.machine.y}, ${sn.machine.z}) `
+ + `[work (${sn.work.x}, ${sn.work.y}, ${sn.work.z}), offset (${sn.originOffset.x}, ${sn.originOffset.y}, ${sn.originOffset.z}) `
+ + `from ${sn.originOffsetSource}]`;
+ const expectPosition = async (
+ expected: { x: number; y: number; z: number },
+ what: string,
+ onMismatch: (message: string) => Error
+ ) => {
+ const matches = (p: { x: number | null; y: number | null; z: number | null }) => (
+ p.x !== null && p.y !== null && p.z !== null
+ && Math.abs(p.x - expected.x) <= 0.5
+ && Math.abs(p.y - expected.y) <= 0.5
+ && Math.abs(p.z - expected.z) <= 0.5
+ );
+ let snapshot = getPositionSnapshot();
+ if (matches(snapshot.machine)) {
+ return;
+ }
+ const firstRead = snapshot;
+ await sleep(1200);
+ snapshot = getPositionSnapshot();
+ if (!matches(snapshot.machine)) {
+ throw onMismatch(`${what}: machine at ${fmt(snapshot)} (first read ${fmt(firstRead)}) but the plan expects `
+ + `(${expected.x}, ${expected.y}, ${expected.z}).`);
+ }
+ announce('position-recheck', `${what}: passed on the second heartbeat (first read was transient)`);
+ };
+
+ await expectPosition(plan.staged, 'staged position', (message) => new McpToolError(
+ `The machine is not at the position this scan was staged from. ${message} Stage ${plan.tool} again.`
+ ));
+
+ const results: SurfaceStationResult[] = [];
+ // Reference height for the envelope: the last REAL contact (toolhead Z).
+ let reference: number | null = null;
+ const hopHeightFor = (ref: number) => Number(Math.min(ref + plan.zSafeDeltaMm, plan.hopZ).toFixed(3));
+
+ let stationIndex = 0;
+ try {
+ // Approach to station 1: full motion law 2 (raise, traverse at the
+ // gantry height, guarded descent to the operator-stated start Z).
+ const first = plan.stations[0];
+ probeFeedService.clearExpectedContact();
+ await moveMachineSettled(`${plan.tool}:raise`, { z: plan.hopZ }, TRAVEL_FEED);
+ await moveMachineSettled(`${plan.tool}:traverse`, { x: first.x, y: first.y }, TRAVEL_FEED);
+ announce('traverse', `(${first.x}, ${first.y}) at Z${plan.hopZ} (law 2)`);
+ const guardTop = plan.startZMachine + DESCENT_GUARD_MM;
+ const zNow = getPositionSnapshot().machine.z;
+ if (zNow !== null && zNow > guardTop + 1e-9) {
+ await moveMachineSettled(`${plan.tool}:descend`, { z: guardTop }, TRAVEL_FEED);
+ }
+ let gz = Math.min(zNow === null ? guardTop : Math.max(zNow, plan.startZMachine), guardTop);
+ while (gz - plan.startZMachine > 1e-9) {
+ const t0 = Date.now();
+ gz = Math.max(gz - 1, plan.startZMachine);
+ await moveMachineSettled(`${plan.tool}:descend-guard`, { z: gz }, COARSE_FEED);
+ const sensed = await senseAfter('probe', t0, plan.sensorDelayMs);
+ if (sensed.contact) {
+ throw new ProcedureAbort(`UNEXPECTED CONTACT during the guarded descent at Z${gz.toFixed(3)} - the surface `
+ + `is above start_z_machine ${plan.startZMachine}. Machine held.`);
+ }
+ }
+ announce('descend', `Z${plan.startZMachine} (guarded final ${DESCENT_GUARD_MM} mm)`);
+
+ let currentZ = plan.startZMachine;
+ for (const station of plan.stations) {
+ stationIndex = station.index;
+ const isFirst = station.index === 1;
+ if (!isFirst) {
+ // THE LAW-2 EXCEPTION: hop at lastContact + z_safe_delta, in
+ // sensor-checked segments, expecting NO contact. See the file
+ // header for the operator's authorisation and its bounds.
+ const previous = plan.stations[station.index - 2];
+ probeFeedService.clearExpectedContact();
+ const segs = hopSegments({ x: previous.x, y: previous.y }, { x: station.x, y: station.y });
+ for (let j = 0; j < segs.length; j++) {
+ const t0 = Date.now();
+ await moveMachineSettled(`${plan.tool}:hop:${station.label}`, { x: segs[j].x, y: segs[j].y }, TRAVEL_FEED);
+ const sensed = await senseAfter('probe', t0, plan.sensorDelayMs);
+ if (sensed.contact) {
+ throw new ProcedureAbort(`UNEXPECTED CONTACT during the hop to "${station.label}" at `
+ + `(${segs[j].x}, ${segs[j].y}, ${currentZ}) - the surface rises more than ${plan.zSafeDeltaMm} mm `
+ + 'above the last contact. Machine held.');
+ }
+ }
+ announce(`hop-${station.label}`, `${station.hopFromPreviousMm} mm to (${station.x}, ${station.y}) at Z${currentZ} `
+ + `(last contact + ${plan.zSafeDeltaMm})`);
+ }
+
+ const env = stationEnvelope(reference === null ? plan.startZMachine : reference, isFirst, plan.startZMachine, {
+ zSafeDeltaMm: plan.zSafeDeltaMm, maxDropMm: plan.maxDropMm, absoluteFloorZ: plan.absoluteFloorZ,
+ });
+ // The march starts where the walk left us (start_z for station 1,
+ // the hop height otherwise). Re-verify before trusting the sensor.
+ const marchStartZ = isFirst ? plan.startZMachine : currentZ;
+ await expectPosition({ x: station.x, y: station.y, z: marchStartZ }, `station "${station.label}"`,
+ (message) => new ProcedureAbort(message));
+
+ probeFeedService.setExpectedContact(['probe']);
+ const outcome = await marchDownZ(plan, station, marchStartZ, env.floorZ, announce);
+ let retractTo: number;
+ if (outcome === null) {
+ if (reference === null) {
+ throw new ProcedureAbort(`Station "${station.label}" (the first) found no surface between Z${marchStartZ} and the floor `
+ + `Z${env.floorZ} - no measured reference to base the envelope on. Check start_z_machine / floor_z_machine.`);
+ }
+ results.push({
+ index: station.index,
+ label: station.label,
+ x: station.x,
+ y: station.y,
+ s: station.s,
+ row: station.row,
+ col: station.col,
+ status: 'no_contact',
+ z: null,
+ marchStartZ,
+ floorZ: env.floorZ,
+ });
+ retractTo = hopHeightFor(reference);
+ announce(`no-contact-${station.label}`, `nothing between Z${marchStartZ} and the floor Z${env.floorZ}; reference stays Z${reference}`);
+ } else {
+ reference = outcome.contactZ;
+ results.push({
+ index: station.index,
+ label: station.label,
+ x: station.x,
+ y: station.y,
+ s: station.s,
+ row: station.row,
+ col: station.col,
+ status: 'contact',
+ z: outcome.contactZ,
+ marchStartZ,
+ floorZ: env.floorZ,
+ confirmPassContacts: outcome.passContacts,
+ spreadMm: outcome.spreadMm,
+ });
+ retractTo = hopHeightFor(outcome.contactZ);
+ announce(`measured-${station.label}`, `(${station.x}, ${station.y}, ${outcome.contactZ}) spread ${outcome.spreadMm}`);
+ }
+
+ // Retract to the hop height (contact still expected while leaving
+ // the surface), prove the probe released, then hand over to the
+ // crash guard for the hop.
+ const t0 = Date.now();
+ await moveMachineSettled(`${plan.tool}:retract:${station.label}`, { z: retractTo }, TRAVEL_FEED);
+ const released = await senseReleaseAfter('probe', t0, Math.max(plan.sensorDelayMs * 4, 3500));
+ if (released.contact) {
+ throw new ProcedureAbort(`Station "${station.label}": probe still triggered after retracting to Z${retractTo} - stuck probe or feed fault.`);
+ }
+ probeFeedService.clearExpectedContact();
+ currentZ = retractTo;
+ }
+
+ await moveMachineSettled(`${plan.tool}:final-raise`, { z: plan.hopZ }, TRAVEL_FEED);
+ announce('scan-complete', `${results.filter((r) => r.status === 'contact').length}/${results.length} contacts, raised to Z${plan.hopZ}`);
+ } catch (err) {
+ const isTrip = !!probeFeedService.getTrip();
+ if (!isTrip) {
+ try {
+ const reading = probeFeedService.getReading('probe');
+ if (!reading || !reading.triggered) {
+ probeFeedService.clearExpectedContact();
+ await moveMachineSettled(`${plan.tool}:abort-raise`, { z: plan.hopZ }, TRAVEL_FEED);
+ announce('abort-raised', `Z${plan.hopZ}`);
+ } else {
+ announce('abort-held', 'probe still triggered - holding position for the operator');
+ }
+ } catch (retreatErr) {
+ // Logged by the activity stream.
+ }
+ }
+ if (err instanceof ProcedureAbort) {
+ throw new McpToolError(`Surface ${plan.kind} scan aborted at station ${stationIndex}: ${err.message} `
+ + `Completed stations: ${JSON.stringify(results)} Phases: ${JSON.stringify(phases)}`);
+ }
+ throw err;
+ } finally {
+ probeFeedService.clearExpectedContact();
+ }
+
+ return buildResult(plan, results, phases);
+}
diff --git a/src/server/services/mcp/surfaceScan.ts b/src/server/services/mcp/surfaceScan.ts
new file mode 100644
index 0000000000..d47d87ac1a
--- /dev/null
+++ b/src/server/services/mcp/surfaceScan.ts
@@ -0,0 +1,602 @@
+/* eslint-disable camelcase */
+// MCP tool arguments are snake_case by convention (the planners take the
+// probe_surface_path / probe_surface_grid arguments verbatim).
+//
+// Pure planning and statistics for the top-surface scans (probe_surface_path
+// / probe_surface_grid). NO imports on purpose: this module has no machine,
+// feed or config dependency so it can be compiled alone and unit-tested under
+// plain node (see the development workflow in README.md). The machine-facing
+// plan builders and the runner live in probeSurface.ts.
+//
+// The safety envelope these helpers enforce is the operator's, verbatim
+// (2026-09-05): "with the grid we need the point to point variation to not
+// risk the probe toolhead so no more than 20mm z safe delta from the top
+// (within a horizontal change of 60mm)". Hence the two hard caps below - a
+// request beyond them is refused, never clamped silently.
+
+/** Operator-authorised ceiling for the between-station retract (mm above the last contact). */
+export const Z_SAFE_DELTA_MAX_MM = 20;
+export const Z_SAFE_DELTA_DEFAULT_MM = 20;
+export const Z_SAFE_DELTA_MIN_MM = 3;
+/** Operator-authorised ceiling for the horizontal distance between consecutive stations. */
+export const MAX_HOP_CAP_MM = 60;
+export const MAX_HOP_DEFAULT_MM = 60;
+/** How far below the previous contact one station's march may search (a pocket floor). */
+export const MAX_DROP_CAP_MM = 80;
+export const MAX_DROP_DEFAULT_MM = 40;
+/** Hops are executed in sensor-checked segments no longer than this. */
+export const HOP_SEGMENT_MM = 10;
+
+export interface SurfaceStation {
+ /** 1-based execution order. */
+ index: number;
+ /** Human key: "s3" on a path, "r2c4" on a grid (row = Y line, col = X line). */
+ label: string;
+ x: number;
+ y: number;
+ /** Path stations: distance along the path from the start (mm). */
+ s?: number;
+ /** Grid stations: zero-based row (Y index) / col (X index). */
+ row?: number;
+ col?: number;
+ /** Horizontal distance from the previous station (0 for the first). */
+ hopFromPreviousMm: number;
+}
+
+export class SurfacePlanError extends Error {}
+
+const round3 = (v: number): number => Number(v.toFixed(3));
+
+function requireFinite(value: unknown, name: string): number {
+ const n = Number(value);
+ if (!Number.isFinite(n)) {
+ throw new SurfacePlanError(`${name} must be a finite number.`);
+ }
+ return n;
+}
+
+/**
+ * Resolve the three envelope parameters against their hard caps. Anything
+ * above a cap is REFUSED (the operator picks the numbers, the code never
+ * widens them), anything below the sensible minimum too.
+ */
+export function resolveEnvelope(args: {
+ z_safe_delta_mm?: unknown;
+ max_hop_mm?: unknown;
+ max_drop_mm?: unknown;
+}): { zSafeDeltaMm: number; maxHopMm: number; maxDropMm: number } {
+ const zSafe = args.z_safe_delta_mm === undefined ? Z_SAFE_DELTA_DEFAULT_MM : requireFinite(args.z_safe_delta_mm, 'z_safe_delta_mm');
+ if (zSafe > Z_SAFE_DELTA_MAX_MM + 1e-9) {
+ throw new SurfacePlanError(`z_safe_delta_mm ${zSafe} exceeds the operator-authorised maximum of `
+ + `${Z_SAFE_DELTA_MAX_MM} mm (hop height above the last contact).`);
+ }
+ if (zSafe < Z_SAFE_DELTA_MIN_MM) {
+ throw new SurfacePlanError(`z_safe_delta_mm must be at least ${Z_SAFE_DELTA_MIN_MM} mm.`);
+ }
+ const maxHop = args.max_hop_mm === undefined ? MAX_HOP_DEFAULT_MM : requireFinite(args.max_hop_mm, 'max_hop_mm');
+ if (maxHop > MAX_HOP_CAP_MM + 1e-9) {
+ throw new SurfacePlanError(`max_hop_mm ${maxHop} exceeds the operator-authorised maximum of `
+ + `${MAX_HOP_CAP_MM} mm between consecutive stations.`);
+ }
+ if (maxHop < 1) {
+ throw new SurfacePlanError('max_hop_mm must be at least 1 mm.');
+ }
+ const maxDrop = args.max_drop_mm === undefined ? MAX_DROP_DEFAULT_MM : requireFinite(args.max_drop_mm, 'max_drop_mm');
+ if (maxDrop > MAX_DROP_CAP_MM + 1e-9) {
+ throw new SurfacePlanError(`max_drop_mm ${maxDrop} exceeds the cap of ${MAX_DROP_CAP_MM} mm below the previous contact.`);
+ }
+ if (maxDrop < 1) {
+ throw new SurfacePlanError('max_drop_mm must be at least 1 mm.');
+ }
+ return { zSafeDeltaMm: zSafe, maxHopMm: maxHop, maxDropMm: maxDrop };
+}
+
+/** Every consecutive pair must be within maxHopMm - refuse otherwise, naming the pair. */
+export function assertHopsWithin(stations: SurfaceStation[], maxHopMm: number): number {
+ let worst = 0;
+ for (let i = 1; i < stations.length; i++) {
+ const d = stations[i].hopFromPreviousMm;
+ worst = Math.max(worst, d);
+ if (d > maxHopMm + 1e-6) {
+ throw new SurfacePlanError(`Stations ${stations[i - 1].label} -> ${stations[i].label} are ${d.toFixed(2)} mm apart, `
+ + `over max_hop_mm ${maxHopMm}. The operator picks a closer spacing/pitch; the plan is never split silently.`);
+ }
+ }
+ return round3(worst);
+}
+
+/**
+ * Stations along a straight line. Either an end point or a direction +
+ * length defines the segment; either a station count or a spacing defines
+ * the sampling. Both ends are always stations.
+ */
+export function planPathStations(args: {
+ start_x: unknown;
+ start_y: unknown;
+ end_x?: unknown;
+ end_y?: unknown;
+ dx?: unknown;
+ dy?: unknown;
+ length_mm?: unknown;
+ stations?: unknown;
+ spacing_mm?: unknown;
+}): {
+ stations: SurfaceStation[];
+ start: { x: number; y: number };
+ end: { x: number; y: number };
+ unit: { x: number; y: number };
+ lengthMm: number;
+ spacingMm: number;
+} {
+ const sx = requireFinite(args.start_x, 'start_x');
+ const sy = requireFinite(args.start_y, 'start_y');
+ let ex: number;
+ let ey: number;
+ if (args.end_x !== undefined || args.end_y !== undefined) {
+ ex = requireFinite(args.end_x, 'end_x');
+ ey = requireFinite(args.end_y, 'end_y');
+ } else {
+ const dx = Number(args.dx) || 0;
+ const dy = Number(args.dy) || 0;
+ const norm = Math.hypot(dx, dy);
+ if (norm < 1e-9) {
+ throw new SurfacePlanError('Give either end_x/end_y or a direction dx/dy (at least one non-zero) with length_mm.');
+ }
+ const length = requireFinite(args.length_mm, 'length_mm');
+ if (length <= 0) {
+ throw new SurfacePlanError('length_mm must be positive.');
+ }
+ ex = sx + (dx / norm) * length;
+ ey = sy + (dy / norm) * length;
+ }
+ const lengthMm = Math.hypot(ex - sx, ey - sy);
+ if (lengthMm < 1) {
+ throw new SurfacePlanError('The path is under 1 mm long.');
+ }
+ if (lengthMm > 400) {
+ throw new SurfacePlanError('The path is over 400 mm long - longer than the bed.');
+ }
+ const unit = { x: (ex - sx) / lengthMm, y: (ey - sy) / lengthMm };
+
+ let count: number;
+ if (args.stations !== undefined) {
+ count = Math.round(requireFinite(args.stations, 'stations'));
+ if (count < 2 || count > 60) {
+ throw new SurfacePlanError('stations must be 2-60.');
+ }
+ } else if (args.spacing_mm !== undefined) {
+ const spacing = requireFinite(args.spacing_mm, 'spacing_mm');
+ if (spacing <= 0) {
+ throw new SurfacePlanError('spacing_mm must be positive.');
+ }
+ // Spacing is a MAXIMUM: the length is divided evenly into steps no
+ // larger than it, so both ends are covered (same rule as survey_bed).
+ count = Math.max(1, Math.ceil(lengthMm / spacing - 1e-9)) + 1;
+ if (count > 60) {
+ throw new SurfacePlanError(`spacing_mm ${spacing} over ${lengthMm.toFixed(1)} mm gives ${count} stations (max 60).`);
+ }
+ } else {
+ throw new SurfacePlanError('Give either stations (count) or spacing_mm.');
+ }
+ const spacingMm = lengthMm / (count - 1);
+ const stations: SurfaceStation[] = [];
+ for (let i = 0; i < count; i++) {
+ const s = spacingMm * i;
+ stations.push({
+ index: i + 1,
+ label: `s${i + 1}`,
+ x: round3(sx + unit.x * s),
+ y: round3(sy + unit.y * s),
+ s: round3(s),
+ hopFromPreviousMm: i === 0 ? 0 : round3(spacingMm),
+ });
+ }
+ return {
+ stations,
+ start: { x: round3(sx), y: round3(sy) },
+ end: { x: round3(ex), y: round3(ey) },
+ unit: { x: Number(unit.x.toFixed(6)), y: Number(unit.y.toFixed(6)) },
+ lengthMm: round3(lengthMm),
+ spacingMm: round3(spacingMm),
+ };
+}
+
+function axisLines(min: number, max: number, pitch: unknown, count: unknown, axis: string): { values: number[]; pitchMm: number } {
+ const span = max - min;
+ if (!(span > 0)) {
+ throw new SurfacePlanError(`${axis} extent is empty (min ${min}, max ${max}).`);
+ }
+ let intervals: number;
+ if (count !== undefined) {
+ const n = Math.round(requireFinite(count, `${axis}_count`));
+ if (n < 2 || n > 40) {
+ throw new SurfacePlanError(`${axis}_count must be 2-40.`);
+ }
+ intervals = n - 1;
+ } else if (pitch !== undefined) {
+ const p = requireFinite(pitch, 'pitch_mm');
+ if (p <= 0) {
+ throw new SurfacePlanError('pitch_mm must be positive.');
+ }
+ // Pitch is a MAXIMUM: even division, both edges covered.
+ intervals = Math.max(1, Math.ceil(span / p - 1e-9));
+ if (intervals + 1 > 40) {
+ throw new SurfacePlanError(`pitch_mm ${p} over the ${axis} extent ${span.toFixed(1)} mm gives ${intervals + 1} lines (max 40).`);
+ }
+ } else {
+ throw new SurfacePlanError(`Give pitch_mm or ${axis}_count.`);
+ }
+ const step = span / intervals;
+ const values: number[] = [];
+ for (let i = 0; i <= intervals; i++) {
+ values.push(round3(min + step * i));
+ }
+ return { values, pitchMm: round3(step) };
+}
+
+/**
+ * Serpentine grid: rows are lines of constant Y (ascending), each row walked
+ * along X in alternating direction so consecutive stations are always one
+ * pitch apart - including the row-to-row turn.
+ */
+export function planGridStations(args: {
+ x_min?: unknown;
+ x_max?: unknown;
+ y_min?: unknown;
+ y_max?: unknown;
+ center_x?: unknown;
+ center_y?: unknown;
+ size_x_mm?: unknown;
+ size_y_mm?: unknown;
+ pitch_mm?: unknown;
+ x_count?: unknown;
+ y_count?: unknown;
+}): {
+ stations: SurfaceStation[];
+ xs: number[];
+ ys: number[];
+ pitchXMm: number;
+ pitchYMm: number;
+ bounds: { xMin: number; xMax: number; yMin: number; yMax: number };
+} {
+ let xMin: number;
+ let xMax: number;
+ let yMin: number;
+ let yMax: number;
+ const hasExtents = args.x_min !== undefined || args.x_max !== undefined || args.y_min !== undefined || args.y_max !== undefined;
+ const hasCentre = args.center_x !== undefined || args.center_y !== undefined || args.size_x_mm !== undefined || args.size_y_mm !== undefined;
+ if (hasExtents && hasCentre) {
+ throw new SurfacePlanError('Give EITHER x_min/x_max/y_min/y_max OR center_x/center_y/size_x_mm/size_y_mm, not both.');
+ }
+ if (hasExtents) {
+ xMin = requireFinite(args.x_min, 'x_min');
+ xMax = requireFinite(args.x_max, 'x_max');
+ yMin = requireFinite(args.y_min, 'y_min');
+ yMax = requireFinite(args.y_max, 'y_max');
+ } else if (hasCentre) {
+ const cx = requireFinite(args.center_x, 'center_x');
+ const cy = requireFinite(args.center_y, 'center_y');
+ const sxm = requireFinite(args.size_x_mm, 'size_x_mm');
+ const sym = args.size_y_mm === undefined ? sxm : requireFinite(args.size_y_mm, 'size_y_mm');
+ if (sxm <= 0 || sym <= 0) {
+ throw new SurfacePlanError('size_x_mm / size_y_mm must be positive.');
+ }
+ xMin = cx - sxm / 2;
+ xMax = cx + sxm / 2;
+ yMin = cy - sym / 2;
+ yMax = cy + sym / 2;
+ } else {
+ throw new SurfacePlanError('Give the region: x_min/x_max/y_min/y_max, or center_x/center_y/size_x_mm[/size_y_mm].');
+ }
+ const xAxis = axisLines(xMin, xMax, args.pitch_mm, args.x_count, 'x');
+ const yAxis = axisLines(yMin, yMax, args.pitch_mm, args.y_count, 'y');
+ if (xAxis.values.length * yAxis.values.length > 400) {
+ throw new SurfacePlanError(`${xAxis.values.length} x ${yAxis.values.length} = ${xAxis.values.length * yAxis.values.length} stations (max 400).`);
+ }
+ const stations: SurfaceStation[] = [];
+ let previous: { x: number; y: number } | null = null;
+ yAxis.values.forEach((y, row) => {
+ const cols = xAxis.values.map((x, col) => ({ x, col }));
+ const ordered = row % 2 === 0 ? cols : [...cols].reverse();
+ for (const { x, col } of ordered) {
+ stations.push({
+ index: stations.length + 1,
+ label: `r${row + 1}c${col + 1}`,
+ x,
+ y,
+ row,
+ col,
+ hopFromPreviousMm: previous ? round3(Math.hypot(x - previous.x, y - previous.y)) : 0,
+ });
+ previous = { x, y };
+ }
+ });
+ return {
+ stations,
+ xs: xAxis.values,
+ ys: yAxis.values,
+ pitchXMm: xAxis.pitchMm,
+ pitchYMm: yAxis.pitchMm,
+ bounds: { xMin: round3(xMin), xMax: round3(xMax), yMin: round3(yMin), yMax: round3(yMax) },
+ };
+}
+
+/**
+ * Where a station's -Z march may search: from the hop height (reference +
+ * z_safe_delta) down to reference - max_drop, but never below the plan's
+ * absolute floor. `reference` is the last real contact (or start_z_machine
+ * for the first station, whose march starts AT start_z, not above it).
+ */
+export function stationEnvelope(
+ reference: number,
+ isFirst: boolean,
+ startZ: number,
+ env: { zSafeDeltaMm: number; maxDropMm: number; absoluteFloorZ: number }
+): { marchStartZ: number; floorZ: number; travelMm: number } {
+ const marchStartZ = isFirst ? startZ : round3(reference + env.zSafeDeltaMm);
+ const floorZ = round3(Math.max(reference - env.maxDropMm, env.absoluteFloorZ));
+ return { marchStartZ, floorZ, travelMm: round3(marchStartZ - floorZ) };
+}
+
+/** Split a hop into equal segments no longer than HOP_SEGMENT_MM (sensor-checked between). */
+export function hopSegments(
+ from: { x: number; y: number },
+ to: { x: number; y: number },
+ segmentMm: number = HOP_SEGMENT_MM
+): { x: number; y: number }[] {
+ const d = Math.hypot(to.x - from.x, to.y - from.y);
+ if (d < 1e-9) {
+ return [];
+ }
+ const n = Math.max(1, Math.ceil(d / segmentMm - 1e-9));
+ const out: { x: number; y: number }[] = [];
+ for (let i = 1; i <= n; i++) {
+ const t = i / n;
+ out.push(i === n
+ ? { x: to.x, y: to.y }
+ : { x: round3(from.x + (to.x - from.x) * t), y: round3(from.y + (to.y - from.y) * t) });
+ }
+ return out;
+}
+
+// ---------------------------------------------------------------- statistics
+
+export interface ContactSample {
+ x: number;
+ y: number;
+ z: number;
+ s?: number;
+ label: string;
+}
+
+export interface ZSummary {
+ count: number;
+ zMin: number;
+ zMax: number;
+ zRange: number;
+ zMean: number;
+ highest: string;
+ lowest: string;
+}
+
+export function summarizeZ(samples: ContactSample[]): ZSummary | null {
+ if (samples.length === 0) {
+ return null;
+ }
+ let lo = samples[0];
+ let hi = samples[0];
+ let sum = 0;
+ for (const p of samples) {
+ sum += p.z;
+ if (p.z < lo.z) {
+ lo = p;
+ }
+ if (p.z > hi.z) {
+ hi = p;
+ }
+ }
+ return {
+ count: samples.length,
+ zMin: round3(lo.z),
+ zMax: round3(hi.z),
+ zRange: round3(hi.z - lo.z),
+ zMean: round3(sum / samples.length),
+ highest: hi.label,
+ lowest: lo.label,
+ };
+}
+
+export interface LineFit {
+ /** z = intercept + slope * s */
+ intercept: number;
+ slope: number;
+ slopeMmPer100Mm: number;
+ slopeDeg: number;
+ /** end-to-end rise predicted by the fit over the sampled length. */
+ riseOverLengthMm: number;
+ residuals: { label: string; mm: number }[];
+ rmsResidualMm: number;
+ maxResidualMm: number;
+ /** Peak-to-valley of the residuals = flatness relative to the best-fit line. */
+ flatnessMm: number;
+}
+
+/** Least-squares line z(s) over path samples (needs >= 2 distinct s). */
+export function fitLine(samples: ContactSample[]): LineFit | null {
+ const pts = samples.filter((p) => p.s !== undefined);
+ if (pts.length < 2) {
+ return null;
+ }
+ const n = pts.length;
+ const meanS = pts.reduce((a, p) => a + (p.s as number), 0) / n;
+ const meanZ = pts.reduce((a, p) => a + p.z, 0) / n;
+ let sxx = 0;
+ let sxz = 0;
+ for (const p of pts) {
+ const ds = (p.s as number) - meanS;
+ sxx += ds * ds;
+ sxz += ds * (p.z - meanZ);
+ }
+ if (sxx < 1e-12) {
+ return null;
+ }
+ const slope = sxz / sxx;
+ const intercept = meanZ - slope * meanS;
+ const residuals = pts.map((p) => ({ label: p.label, mm: round3(p.z - (intercept + slope * (p.s as number))) }));
+ const rms = Math.sqrt(residuals.reduce((a, r) => a + r.mm * r.mm, 0) / n);
+ const resValues = residuals.map((r) => r.mm);
+ const sMin = Math.min(...pts.map((p) => p.s as number));
+ const sMax = Math.max(...pts.map((p) => p.s as number));
+ return {
+ intercept: Number(intercept.toFixed(4)),
+ slope: Number(slope.toFixed(6)),
+ slopeMmPer100Mm: round3(slope * 100),
+ slopeDeg: Number((Math.atan(slope) * 180 / Math.PI).toFixed(3)),
+ riseOverLengthMm: round3(slope * (sMax - sMin)),
+ residuals,
+ rmsResidualMm: Number(rms.toFixed(4)),
+ maxResidualMm: round3(Math.max(...resValues.map((r) => Math.abs(r)))),
+ flatnessMm: round3(Math.max(...resValues) - Math.min(...resValues)),
+ };
+}
+
+export interface PlaneFit {
+ /** z = a + b*x + c*y */
+ a: number;
+ b: number;
+ c: number;
+ tiltXMmPer100Mm: number;
+ tiltYMmPer100Mm: number;
+ tiltXDeg: number;
+ tiltYDeg: number;
+ residuals: { label: string; mm: number }[];
+ rmsResidualMm: number;
+ maxResidualMm: number;
+ /** Peak-to-valley of the residuals = flatness relative to the best-fit plane. */
+ flatnessMm: number;
+}
+
+/** Least-squares plane over >= 3 non-collinear samples (normal equations, 3x3). */
+export function fitPlane(samples: ContactSample[]): PlaneFit | null {
+ const n = samples.length;
+ if (n < 3) {
+ return null;
+ }
+ // Centre the data for conditioning.
+ const mx = samples.reduce((a, p) => a + p.x, 0) / n;
+ const my = samples.reduce((a, p) => a + p.y, 0) / n;
+ const mz = samples.reduce((a, p) => a + p.z, 0) / n;
+ let sxx = 0;
+ let sxy = 0;
+ let syy = 0;
+ let sxz = 0;
+ let syz = 0;
+ for (const p of samples) {
+ const dx = p.x - mx;
+ const dy = p.y - my;
+ const dz = p.z - mz;
+ sxx += dx * dx;
+ sxy += dx * dy;
+ syy += dy * dy;
+ sxz += dx * dz;
+ syz += dy * dz;
+ }
+ const det = sxx * syy - sxy * sxy;
+ if (Math.abs(det) < 1e-9) {
+ return null; // collinear in XY - no plane
+ }
+ const b = (sxz * syy - syz * sxy) / det;
+ const c = (syz * sxx - sxz * sxy) / det;
+ const a = mz - b * mx - c * my;
+ const residuals = samples.map((p) => ({ label: p.label, mm: round3(p.z - (a + b * p.x + c * p.y)) }));
+ const resValues = residuals.map((r) => r.mm);
+ const rms = Math.sqrt(resValues.reduce((acc, r) => acc + r * r, 0) / n);
+ return {
+ a: Number(a.toFixed(4)),
+ b: Number(b.toFixed(6)),
+ c: Number(c.toFixed(6)),
+ tiltXMmPer100Mm: round3(b * 100),
+ tiltYMmPer100Mm: round3(c * 100),
+ tiltXDeg: Number((Math.atan(b) * 180 / Math.PI).toFixed(3)),
+ tiltYDeg: Number((Math.atan(c) * 180 / Math.PI).toFixed(3)),
+ residuals,
+ rmsResidualMm: Number(rms.toFixed(4)),
+ maxResidualMm: round3(Math.max(...resValues.map((r) => Math.abs(r)))),
+ flatnessMm: round3(Math.max(...resValues) - Math.min(...resValues)),
+ };
+}
+
+/** rows = ys (ascending), cols = xs (ascending); null where no contact. */
+export function buildZMatrix(
+ xs: number[],
+ ys: number[],
+ cells: { row: number; col: number; z: number | null }[]
+): (number | null)[][] {
+ const matrix: (number | null)[][] = ys.map(() => xs.map(() => null as number | null));
+ for (const c of cells) {
+ if (c.row >= 0 && c.row < ys.length && c.col >= 0 && c.col < xs.length) {
+ matrix[c.row][c.col] = c.z === null ? null : round3(c.z);
+ }
+ }
+ return matrix;
+}
+
+/**
+ * Compact text height map: a numeric table of Z relative to the highest
+ * contact (0.000 = highest, negatives lower; " -- " = no contact), rows
+ * printed with the LARGEST Y first so the picture is oriented like the bed
+ * seen from above (+Y away), plus a one-character shade per cell.
+ */
+export function renderHeightMap(xs: number[], ys: number[], matrix: (number | null)[][]): string {
+ const values: number[] = [];
+ matrix.forEach((row) => row.forEach((z) => {
+ if (z !== null) {
+ values.push(z);
+ }
+ }));
+ if (values.length === 0) {
+ return 'no contacts';
+ }
+ const zMax = Math.max(...values);
+ const zMin = Math.min(...values);
+ const range = zMax - zMin;
+ const shades = '.:-=+*#%@';
+ const cellW = 8;
+ const pad = (text: string, w: number) => (text.length >= w ? text : ' '.repeat(w - text.length) + text);
+ const lines: string[] = [];
+ lines.push(`Z relative to the highest contact (${zMax.toFixed(3)}); range ${range.toFixed(3)} mm; rows = machine Y (top = +Y), cols = machine X`);
+ lines.push(`${pad('Y \\ X', 9)}${xs.map((x) => pad(x.toFixed(1), cellW)).join('')} shade`);
+ for (let r = ys.length - 1; r >= 0; r--) {
+ const cells = matrix[r].map((z) => (z === null ? pad('--', cellW) : pad((z - zMax).toFixed(3), cellW)));
+ const shade = matrix[r].map((z) => {
+ if (z === null) {
+ return '?';
+ }
+ const level = range < 1e-9 ? shades.length - 1 : Math.round(((z - zMin) / range) * (shades.length - 1));
+ return shades[Math.min(shades.length - 1, Math.max(0, level))];
+ }).join('');
+ lines.push(`${pad(ys[r].toFixed(1), 9)}${cells.join('')} ${shade}`);
+ }
+ lines.push(`shade: '.' = lowest (${zMin.toFixed(3)}) ... '@' = highest (${zMax.toFixed(3)}); '?' = no contact`);
+ return lines.join('\n');
+}
+
+/** Path rendering: one line per station with a bar proportional to height above the lowest. */
+export function renderPathProfile(samples: { label: string; s: number; z: number | null }[]): string {
+ const zs = samples.map((p) => p.z).filter((z): z is number => z !== null);
+ if (zs.length === 0) {
+ return 'no contacts';
+ }
+ const zMax = Math.max(...zs);
+ const zMin = Math.min(...zs);
+ const range = zMax - zMin;
+ const width = 30;
+ return samples.map((p) => {
+ const label = `${p.label.padEnd(4)} s=${p.s.toFixed(1).padStart(7)}`;
+ if (p.z === null) {
+ return `${label} no contact`;
+ }
+ const bars = range < 1e-9 ? width : Math.round(((p.z - zMin) / range) * width);
+ return `${label} z=${p.z.toFixed(3)} ${(p.z - zMax).toFixed(3).padStart(7)} |${'#'.repeat(bars)}`;
+ }).join('\n');
+}
diff --git a/src/server/services/mcp/tools/probing.ts b/src/server/services/mcp/tools/probing.ts
index db547c7d82..78adfeef30 100644
--- a/src/server/services/mcp/tools/probing.ts
+++ b/src/server/services/mcp/tools/probing.ts
@@ -11,6 +11,13 @@ import { jobManager } from '../jobs';
import { describeProbeCirclePlanAsGcode, planProbeCircle, runProbeCircleProcedure } from '../probeCircle';
import { describeProbePlanAsGcode, planProbePoint, runProbePointProcedure } from '../probeTool';
import { describeProbeSequencePlanAsGcode, planProbeSequence, runProbeSequenceProcedure } from '../probeSequence';
+import {
+ ProbeSurfacePlan,
+ describeProbeSurfacePlanAsGcode,
+ planProbeSurfaceGrid,
+ planProbeSurfacePath,
+ runProbeSurfaceProcedure,
+} from '../probeSurface';
import { describeProbeVectorPlanAsGcode, planProbeVector, runProbeVectorProcedure } from '../probeVector';
import { probeFeedService } from '../probeFeed';
import { TRAVEL_FEED, assertMachineReadyForProcedure, moveMachineSettled } from '../probing';
@@ -304,6 +311,166 @@ ${describeProbeCirclePlanAsGcode(plan)}`;
},
});
+ // Shared staging for the two top-surface scans. The envelope description
+ // is repeated in both tool descriptions on purpose: the MCP client caches
+ // schemas, and the operator-authorised law-2 exception must be visible
+ // wherever the tool is read.
+ const SURFACE_ENVELOPE_TEXT = 'ENVELOPE (operator-authorised 2026-09-05, the ONLY exception to motion law 2 - '
+ + 'valid only inside this procedure, only between consecutive stations): after each station the probe '
+ + 'retracts to LAST CONTACT + z_safe_delta_mm (default 20, HARD CAP 20) and hops horizontally AT THAT '
+ + 'HEIGHT to the next station, which must be within max_hop_mm (default 60, HARD CAP 60) - a wider '
+ + 'spacing/pitch is REFUSED at staging, never split silently. Hops run in <= 10 mm sensor-checked segments '
+ + 'expecting NO contact: a touch during a hop is a collision and latches the CRASH alarm. Each -Z march '
+ + 'searches from the hop height down to max(last contact - max_drop_mm (default 40, cap 80), '
+ + 'floor_z_machine (default start_z_machine - max_drop_mm)); reaching the floor without contact records '
+ + 'the station as no_contact and continues with the reference height unchanged (the first station finding '
+ + 'nothing aborts). The approach to the FIRST station is a full law-2 move: raise to the safe traverse '
+ + 'height, traverse, guarded 1 mm descent to start_z_machine (REQUIRED - a measured or operator-stated '
+ + 'toolhead machine Z with the tip just above the surface, never a guess). Ends raised at the traverse '
+ + 'height. All numbers MACHINE coordinates; Z values are toolhead Z at contact (surface = Z - probe length).';
+ const surfaceCommonProperties = {
+ start_z_machine: {
+ type: 'number',
+ description: 'REQUIRED. Toolhead machine Z where the first -Z march starts (probe tip just above the '
+ + 'surface) - measured (probe_point -Z, an earlier scan) or operator-stated. Reached by a guarded descent.',
+ },
+ floor_z_machine: {
+ type: 'number',
+ description: 'Absolute deepest toolhead machine Z any march may command. Default start_z_machine - '
+ + 'max_drop_mm. State it explicitly (lower) to scan into a deep pocket; must stay within 150 mm of start_z_machine.',
+ },
+ z_safe_delta_mm: {
+ type: 'number',
+ description: 'Retract above the last contact for the hop to the next station. Default 20, HARD CAP 20 '
+ + '(operator law), min 3. Above the cap = refused.',
+ },
+ max_hop_mm: {
+ type: 'number',
+ description: 'Largest allowed horizontal distance between consecutive stations. Default 60, HARD CAP 60 '
+ + '(operator law). A plan whose spacing/pitch exceeds it is refused at staging.',
+ },
+ max_drop_mm: {
+ type: 'number',
+ description: 'How far below the previous contact one station may search before recording no_contact. '
+ + 'Default 40, cap 80 (also bounded by floor_z_machine).',
+ },
+ coarse_step_mm: { type: 'number', description: 'Coarse -Z step for every march, default 1 (0.2-2). 2 is faster on a known-flat surface.' },
+ fine_step_mm: { type: 'number', description: 'Fine step, default 0.1 (0.02-0.5).' },
+ backoff_mm: { type: 'number', description: 'Confirm-cycle lift, default 1 (also the confirm re-contact window).' },
+ sensor_delay_ms: { type: 'number', description: 'Contact-check window per step, default 300 (GPIO transport: ~50 is enough).' },
+ confirm_passes: { type: 'number', description: 'Lift-and-retest cycles per station, default 3 (1-10).' },
+ reason: { type: 'string', description: 'Shown to the operator: what surface is being scanned and why.' },
+ };
+ const stageSurfaceScan = (plan: ProbeSurfacePlan, reason: string, label: string) => {
+ const envelope = `; reason: ${reason}
+${describeProbeSurfacePlanAsGcode(plan)}`;
+ const validation = validateGcode(envelope);
+ const job = jobManager.submit(
+ envelope,
+ `surface-${plan.kind} ${plan.stations.length}st ${label} - ${reason.slice(0, 40)}`,
+ 'cnc',
+ validation,
+ 'procedure'
+ );
+ job.runner = async () => runProbeSurfaceProcedure(plan);
+ return {
+ job: jobManager.describe(job),
+ plan,
+ confirm_url: `${getConfirmBaseUrl()}/confirm/${job.id}`,
+ next_step: 'Ask the operator to open confirm_url and review the WHOLE scan: the law-2 exception '
+ + `(hops at last contact + ${plan.zSafeDeltaMm} mm, largest hop ${plan.worstHopMm} mm), every station, `
+ + `the guarded descent to Z${plan.startZMachine} and the absolute floor Z${plan.absoluteFloorZ}. One approval `
+ + 'covers the circuit; their one-time code passed to start_gcode_job runs it (detached - long-poll '
+ + 'get_gcode_job_status for the result: per-station machine XYZ plus flatness statistics).',
+ };
+ };
+
+ registry.register({
+ name: 'probe_surface_path',
+ description: 'Stage a TOP-SURFACE FLATNESS scan along a straight line for human confirmation: N stations '
+ + 'from a start point to an end point (or direction + length), spaced by count or maximum spacing, '
+ + 'each measured with a -Z sensor-gated march of the spindle touch probe (coarse to contact, release, '
+ + 'fine, lift-and-retest confirm, median). Result per station: machine XYZ of contact or no_contact; '
+ + 'plus Z min/max/range, the best-fit line (slope in mm per 100 mm and degrees, rise over the length) '
+ + 'and flatness as residual peak-to-valley, and a text profile. Purpose: level/flatness of stock along a '
+ + `line, e.g. along a rotary-mounted board. ${SURFACE_ENVELOPE_TEXT}`,
+ inputSchema: {
+ type: 'object',
+ properties: {
+ start_x: { type: 'number', description: 'First station machine X.' },
+ start_y: { type: 'number', description: 'First station machine Y.' },
+ end_x: { type: 'number', description: 'Last station machine X (with end_y). Alternative: dx/dy + length_mm.' },
+ end_y: { type: 'number', description: 'Last station machine Y.' },
+ dx: { type: 'number', description: 'Path direction X component (with dy and length_mm) when end_x/end_y are not given. Magnitude ignored.' },
+ dy: { type: 'number', description: 'Path direction Y component.' },
+ length_mm: { type: 'number', description: 'Path length along dx/dy (1-400).' },
+ stations: { type: 'number', description: 'Station count including both ends (2-60). Alternative: spacing_mm.' },
+ spacing_mm: {
+ type: 'number',
+ description: 'MAXIMUM spacing: the length is divided evenly into steps no larger than this, both ends '
+ + 'covered. Must give consecutive stations within max_hop_mm or staging refuses.',
+ },
+ ...surfaceCommonProperties,
+ },
+ required: ['start_x', 'start_y', 'start_z_machine', 'reason'],
+ additionalProperties: false,
+ },
+ handler: async (args: { [key: string]: unknown }) => {
+ const reason = String(args.reason || '').trim();
+ if (!reason) {
+ throw new McpToolError('reason is required; it is shown to the operator.');
+ }
+ probeFeedService.assertNoOvertravel();
+ const plan = planProbeSurfacePath(args as Parameters[0]);
+ return stageSurfaceScan(plan, reason, `${plan.path ? plan.path.lengthMm : 0}mm`);
+ },
+ });
+
+ registry.register({
+ name: 'probe_surface_grid',
+ description: 'Stage a TOP-SURFACE HEIGHT MAP for human confirmation: a serpentine grid of -Z touches of the '
+ + 'spindle touch probe over a region (x/y extents, or centre + size; sampled by maximum pitch or by '
+ + 'x_count/y_count), every station a sensor-gated march (coarse to contact, release, fine, '
+ + 'lift-and-retest confirm, median). Result: per-station machine XYZ or no_contact, a zMatrix '
+ + '(rows = ys ascending, cols = xs ascending, null = no contact) with its coordinates, Z min/max/range, '
+ + 'the best-fit plane (tilt X/Y in mm per 100 mm and degrees) with per-point residuals and flatness '
+ + '(residual peak-to-valley), and a compact text height map (+Y at the top). Purpose: scan a pocketed '
+ + `box, a log, a wasteboard - anything with a top. ${SURFACE_ENVELOPE_TEXT}`,
+ inputSchema: {
+ type: 'object',
+ properties: {
+ x_min: { type: 'number', description: 'Region machine X minimum (with x_max/y_min/y_max). Alternative: center_x/center_y + size.' },
+ x_max: { type: 'number', description: 'Region machine X maximum.' },
+ y_min: { type: 'number', description: 'Region machine Y minimum.' },
+ y_max: { type: 'number', description: 'Region machine Y maximum.' },
+ center_x: { type: 'number', description: 'Region centre machine X (with center_y, size_x_mm[, size_y_mm]).' },
+ center_y: { type: 'number', description: 'Region centre machine Y.' },
+ size_x_mm: { type: 'number', description: 'Region width along X.' },
+ size_y_mm: { type: 'number', description: 'Region depth along Y (default = size_x_mm).' },
+ pitch_mm: {
+ type: 'number',
+ description: 'MAXIMUM grid pitch on both axes: each extent is divided evenly into steps no larger than '
+ + 'this, both edges covered. Must be within max_hop_mm (cap 60) or staging refuses - the operator '
+ + 'picks a finer pitch, the plan is never split.',
+ },
+ x_count: { type: 'number', description: 'Number of X lines (2-40) instead of pitch_mm for X.' },
+ y_count: { type: 'number', description: 'Number of Y lines (2-40) instead of pitch_mm for Y. Max 400 stations total.' },
+ ...surfaceCommonProperties,
+ },
+ required: ['start_z_machine', 'reason'],
+ additionalProperties: false,
+ },
+ handler: async (args: { [key: string]: unknown }) => {
+ const reason = String(args.reason || '').trim();
+ if (!reason) {
+ throw new McpToolError('reason is required; it is shown to the operator.');
+ }
+ probeFeedService.assertNoOvertravel();
+ const plan = planProbeSurfaceGrid(args as Parameters[0]);
+ return stageSurfaceScan(plan, reason, plan.grid ? `${plan.grid.xs.length}x${plan.grid.ys.length}` : '');
+ },
+ });
+
registry.register({
name: 'survey_bed',
description: 'Stage a whole-bed camera survey for human confirmation: a serpentine XY grid at '
From cc26ee2a33169d1b68787c95ba4efe0c90f68305 Mon Sep 17 00:00:00 2001
From: tyeth
Date: Sat, 5 Sep 2026 11:20:06 +0100
Subject: [PATCH 058/135] Fix: Console input box mirrored history once the
circular buffer filled
The Workspace console stored the input-box draft in logical slot 0 of
terminalHistory, a CircularArray(1000) shared with every console line.
Once 1000 entries accumulated, each push rotated the start index, so
slot 0 became the oldest console line and the input element started
mirroring console history - raw ANSI colour codes included (the ESC
byte is unprintable in an , leaving e.g.
"[35m14:52:00.108 [mcp:circle:confirm:az135] > G53[39m"). Verbose MCP
probing floods the buffer past 1000 lines quickly, which is why it
surfaced mid-procedure. Reproduced against the real CircularArray: the
draft survives to entry 1000; entry 1001 replaces it with "line 1".
The mcp:gcode broadcast itself was never coloured - the server sends
raw gcode; cli-color magenta is applied in the Console widget for
xterm rendering, where it belongs. Server-side log colouring untouched.
Fix: keep the draft out of the circular buffer. Terminal.jsx holds it
in a module-level inputDraft (same singleton pattern as its term /
fitAddon, so it still survives page-switch remounts) and the input is
now fully controlled; arrow-key recall goes through React state
instead of writing event.target.value directly. terminalHistory is
purely console lines: clear() no longer seeds a '' sentinel, the
replay loops start at index 0 (they previously skipped slot 0 and,
after rotation, silently dropped a real line), and a hasGreeted module
flag keeps the help text from re-printing after clear + page switch.
Co-Authored-By: Claude Fable 5
---
src/app/ui/widgets/Console/Console.jsx | 19 ++++++++------
src/app/ui/widgets/Console/Terminal.jsx | 35 ++++++++++++++-----------
2 files changed, 30 insertions(+), 24 deletions(-)
diff --git a/src/app/ui/widgets/Console/Console.jsx b/src/app/ui/widgets/Console/Console.jsx
index 10fc8282de..050fde04ca 100644
--- a/src/app/ui/widgets/Console/Console.jsx
+++ b/src/app/ui/widgets/Console/Console.jsx
@@ -21,6 +21,10 @@ import Terminal from './Terminal';
let pubsubTokens = [];
let unlisten = null;
+// Print the help/greeting only on the first ever mount - not again after the
+// operator clears the console and switches pages (history is legitimately
+// empty then).
+let hasGreeted = false;
function Console({ widgetId, widgetActions, minimized, isDefault, clearRenderStamp }) {
const {
connectionType,
@@ -384,13 +388,15 @@ function Console({ widgetId, widgetActions, minimized, isDefault, clearRenderSta
});
if (terminalHistory.getLength() === 0) {
- terminalHistory.push('');
- actions.getHelp();
- actions.greetings();
+ if (!hasGreeted) {
+ hasGreeted = true;
+ actions.getHelp();
+ actions.greetings();
+ }
} else {
const terminal = terminalRef.current;
const data = [];
- for (let i = 1; i < terminalHistory.getLength(); i++) {
+ for (let i = 0; i < terminalHistory.getLength(); i++) {
data.push(`\r${terminalHistory.get(i)}\r\n`);
}
terminal.write(data.join(''));
@@ -467,7 +473,7 @@ function Console({ widgetId, widgetActions, minimized, isDefault, clearRenderSta
if (terminal) {
terminal.clear(false);
const data = [];
- for (let i = 1; i < terminalHistory.getLength(); i++) {
+ for (let i = 0; i < terminalHistory.getLength(); i++) {
data.push(`\r${terminalHistory.get(i)}\r\n`);
}
terminal.write(data.join(''));
@@ -475,8 +481,6 @@ function Console({ widgetId, widgetActions, minimized, isDefault, clearRenderSta
}
}, [isDefault]);
- const inputValue = terminalHistory.getLength() > 0 ? terminalHistory.get(0) : '';
-
return (
diff --git a/src/app/ui/widgets/Console/Terminal.jsx b/src/app/ui/widgets/Console/Terminal.jsx
index ebe05bc5fd..b32d083905 100644
--- a/src/app/ui/widgets/Console/Terminal.jsx
+++ b/src/app/ui/widgets/Console/Terminal.jsx
@@ -14,16 +14,22 @@ const prompt = '> ';
let verticalScrollbar = null;
let term = null;
let fitAddon = null;
+// The unsent command draft. Module-level (like `term` above) so it survives
+// the widget remounting on page switches. It must NOT live in terminalHistory:
+// that is a circular buffer of console lines, and once full, each push rotates
+// the reserved slot away and the input box starts mirroring console history -
+// ANSI colour codes and all.
+let inputDraft = '';
-const TerminalWrapper = forwardRef(({ inputValue: inputValueProp, terminalHistory, onData, consoleHistory, isDefault }, ref) => {
- const [inputValue, setInputValue] = useState(inputValueProp);
+const TerminalWrapper = forwardRef(({ terminalHistory, onData, consoleHistory, isDefault }, ref) => {
+ const [inputValue, setInputValue] = useState(inputDraft);
const [inputHeight, setInputHeight] = useState(20);
const terminalContainer = useRef();
const input = useRef();
const actions = {
changeInputValue: (event) => {
setInputValue(event.target.value);
- terminalHistory.set(0, event.target.value);
+ inputDraft = event.target.value;
}
};
@@ -131,21 +137,22 @@ const TerminalWrapper = forwardRef(({ inputValue: inputValueProp, terminalHistor
onData(event.target.value);
// Reset the index to the last position of the location array
consoleHistory.push(event.target.value);
- event.target.value = '';
- setInputValue(event.target.value);
- terminalHistory.set(0, event.target.value);
+ setInputValue('');
+ inputDraft = '';
}
// Arrow Up
if (event.keyCode === 38) {
- event.target.value = consoleHistory.back() || '';
- terminalHistory.set(0, event.target.value);
+ const value = consoleHistory.back() || '';
+ setInputValue(value);
+ inputDraft = value;
}
// Arrow Down
if (event.keyCode === 40) {
- event.target.value = consoleHistory.forward() || '';
- terminalHistory.set(0, event.target.value);
+ const value = consoleHistory.forward() || '';
+ setInputValue(value);
+ inputDraft = value;
}
}
@@ -183,7 +190,6 @@ const TerminalWrapper = forwardRef(({ inputValue: inputValueProp, terminalHistor
term.clear();
if (isHistory) {
terminalHistory.clear();
- terminalHistory.push('');
}
}
}
@@ -216,8 +222,6 @@ const TerminalWrapper = forwardRef(({ inputValue: inputValueProp, terminalHistor
write
}));
- const command = terminalHistory.getLength() > 0 ? terminalHistory.get(0) : inputValue;
-
return (
{
setTerminalInput(event);
@@ -253,7 +257,6 @@ TerminalWrapper.propTypes = {
onData: PropTypes.func,
isDefault: PropTypes.bool,
terminalHistory: PropTypes.object.isRequired,
- consoleHistory: PropTypes.object.isRequired,
- inputValue: PropTypes.string.isRequired
+ consoleHistory: PropTypes.object.isRequired
};
export default TerminalWrapper;
From 3a601ea74c7ce9b58b7e6856c118d149d1e3c298 Mon Sep 17 00:00:00 2001
From: tyeth
Date: Sat, 5 Sep 2026 12:08:48 +0100
Subject: [PATCH 059/135] Fix: Position of record, either-frame heartbeat
checks, slow zone, timing diagnostics
Job 1db4902a4cd6 (surface scan, 2026-09-05) aborted at its station-4
position re-check after three clean stations. Both hop segments had been
echoed by the controller at their targets, but the re-check judged two
heartbeats: one lagging (previous segment's position) and one carrying
machine-frame coordinates - the HTTP channel sends G90 / G53; / G1 / G54;
as four requests and a status poll inside that window reports the G53
frame, with the offset still populated, MISSING, or read as (0, 0, 0).
The WiFi heartbeat is a 2 s poll, not ~1 s.
- positionOfRecord.ts: pure judgement helpers. matchFrame accepts a
report in either frame; judgeRecheck passes on a matching report or on
the engine's record while the report predates it, treats a report still
showing the pre-move position as stale (job de932e286afd: a 4 mm hop's
echo verified X174, the next beat - stamped 750 ms later - still read
X178 and, read twice with 1 ms of clock jitter, counted as two agreeing
beats), and aborts only when two beats >= 1 s apart agree the machine is
elsewhere; judgeOffsetReport treats a zero offset that contradicts the
cached non-zero one as a transient until it persists for 3 QUIET beats
(none while direct gcode is in flight) - job 70b2b8c675a6: all 84
settle-waits carried offset 0,0,0 (the stall root cause); job
42df7b9351b7: the same beat read Z320 as Z-8 before a descent. The
engine also keeps its own trusted offset (the one that last proved an
arrival) and judges echoes with it first. Verified on the 133-station
band-1 grid: settle-wait 0, zeroOffsetStreak 0, median station 13.5 s.
- probing.ts: every verified arrival becomes the position of record;
echo and settle checks are frame-aware; "stable" means two DISTINCT
beats; operator rule: a <= 1 mm move with no contradicting echo is
taken as arrived after a 400 ms grace when the heartbeat lags
(position-estimated event). Shared expectMachinePosition replaces the
per-runner re-read-once checks; knownMachinePosition() (record first)
drives runner descents, which abort if the toolhead is already below
the target; move_z waits accept either frame too.
- Operator law (2026-09-05): every procedure descent toward the work runs
in <= 5 mm segments (descendInSegments) with a lenient settle that never
waits on the heartbeat - a single long G1 cannot be stopped once sent.
Contact detection during descents is asynchronous: the probe feed's
crash guard latches CRASH the instant an unexpected channel fires in
flight and the next segment is refused; a serial senseAfter check is
opt-in (serialCheck) for manoeuvres that need a synchronous verdict.
Surface scans, probe_sequence, probe_circle and the tool setter's travel
(which clears its expected contacts for the descent) all use it.
- Slow zone for surface scans (jobs d8f6ec1b5c11 / cdbc29371b97): a
coarse step is executed whole before the runner sees the probe, so the
coarse ladder pressed the probe past contact by up to a full step. From
station 2 coarse steps stop slow_zone_mm (default 1) above the previous
contact and fine steps take over to slow_zone + 2 x coarse below it;
station 1 caps coarse at 1 mm unless expected_z_machine (a measured
neighbour) is given. coarse_step_mm is capped at 1 mm in EVERY probing
procedure (operator: never 2). Confirm pages print the zone; results
carry approach and worstPressMm. sensor_delay_ms floor 30 (was 100).
- Approval hand-off (operator request): start_gcode_job with
wait_for_approval_ms (<= 120 s) stays open until the operator clicks
approve on the confirm page and then starts the job with no code to
copy (approved: false, timed_out: true when they have not clicked yet;
call again). The click in the browser remains the only authority.
mcpApprovalHandoff agent|code (Settings -> MCP Server -> Job approval,
LUBAN_MCP_APPROVAL_HANDOFF) restores the relayed-code scheme.
- diagnostics.ts: event-loop stall ticker, heartbeat cadence / gaps /
frame flips / zero-offset beats, gcode exec/idle timing with a step
trace and slow_step events, sensor pipe latency (GPIO monitor stamps
ts), sense_overrun and settle-wait/settle-done events;
get_mcp_diagnostics tool (incl. originOffset cache/streak/trusted) and
/api/mcp field.
- Buffer sizes are settings: mcpJobEventLimit (default 2000, 400-100000)
and mcpDiagnosticsRecentLimit (default 40, 10-10000), env overrides,
editable in Settings -> MCP Server -> Diagnostic buffers; job events
page by seq so since_event / next_event_index survive trimming.
- README + skills (cnc-probing, tool-change, cnc-visual-alignment):
heartbeat frames, position-of-record rules, segmented descents, coarse
press / slow zone / 1 mm cap, approval hand-off, diagnostics, buffers
and the event-log budget for large grids.
Co-Authored-By: Claude Fable 5.1
---
.claude/skills/cnc-probing/SKILL.md | 71 ++-
.claude/skills/cnc-visual-alignment/SKILL.md | 2 +-
.claude/skills/tool-change/SKILL.md | 7 +-
src/app/resources/i18n/en/resource.json | 8 +
.../settings-modal/McpServer/index.tsx | 90 ++++
src/server/services/api/api-mcp.js | 82 +++-
src/server/services/mcp/README.md | 168 ++++++-
src/server/services/mcp/diagnostics.ts | 284 +++++++++++
src/server/services/mcp/gpioFeed.ts | 16 +-
src/server/services/mcp/index.ts | 5 +
src/server/services/mcp/jobs.ts | 88 +++-
src/server/services/mcp/positionOfRecord.ts | 352 ++++++++++++++
src/server/services/mcp/probeCircle.ts | 7 +-
src/server/services/mcp/probeFeed.ts | 12 +-
src/server/services/mcp/probeSequence.ts | 57 +--
src/server/services/mcp/probeSurface.ts | 229 ++++++---
src/server/services/mcp/probeTool.ts | 2 +-
src/server/services/mcp/probeTransport.ts | 10 +-
src/server/services/mcp/probeVector.ts | 8 +-
src/server/services/mcp/probing.ts | 459 ++++++++++++++++--
src/server/services/mcp/surfaceScan.ts | 38 ++
src/server/services/mcp/toolSetter.ts | 19 +-
src/server/services/mcp/tools/camera.ts | 92 +++-
src/server/services/mcp/tools/gcode.ts | 88 +++-
src/server/services/mcp/tools/machine.ts | 71 ++-
src/server/services/mcp/tools/probing.ts | 37 +-
src/server/services/mcp/tools/status.ts | 29 ++
27 files changed, 2088 insertions(+), 243 deletions(-)
create mode 100644 src/server/services/mcp/diagnostics.ts
create mode 100644 src/server/services/mcp/positionOfRecord.ts
diff --git a/.claude/skills/cnc-probing/SKILL.md b/.claude/skills/cnc-probing/SKILL.md
index 5b4b62ed1a..f2984693f8 100644
--- a/.claude/skills/cnc-probing/SKILL.md
+++ b/.claude/skills/cnc-probing/SKILL.md
@@ -55,8 +55,12 @@ height drove the probe into the rotary stock and destroyed it.
disconnect the probe feed while anything might move.
6. **Chat is not a motion gate — the staged job is.** Deliberate traverses
and descents go through `submit_gcode_job` / staged procedures, so the
- operator authorises the literal gcode with a one-time code from the
- confirm page. A "go" in chat is only permission to STAGE; interpretation
+ operator authorises the literal gcode by clicking approve on the confirm
+ page. After staging, call `start_gcode_job` with `wait_for_approval_ms`
+ (e.g. 110000): it starts the moment they click, with nothing to copy, and
+ returns `approved: false, timed_out: true` if they have not clicked yet -
+ call again, never restage. If the operator has hand-off disabled, they
+ relay the one-time code as `confirm_token` instead. A "go" in chat is only permission to STAGE; interpretation
of chat wording is exactly what fails (see law 1's violations). Direct
move tools are for small vision nudges only. Home (re-prove position)
before a traverse whenever position state has any doubt — including
@@ -132,6 +136,24 @@ as the state turns terminal or new events arrive past `since_event` (pass back
waits up to `wait_ms` (default 25 s) and returns the result if it arrived, otherwise a
`running: true` status - the runner keeps going on the server; long-poll for the result,
never resubmit. If your MCP client times out anyway, the result is still on the record.
+`next_event_index` is a sequence number, not an array index: the log keeps 2000 events and
+paging stays valid when it trims.
+
+When a procedure is slow or aborts, the evidence is already in its events - do not grep
+server logs. `gcode` events carry `execMs` (send to controller reply) and `idleMs` (previous
+reply to this send; engine + sensor window only, so > 750 ms inside a job also raises a
+`slow_step` event). `event_loop_stall`, `heartbeat_gap`, `heartbeat_frame_flip`,
+`sense_overrun` and `position-estimated` events name the server-side cause when there is
+one; `get_mcp_diagnostics` has the totals. The machine heartbeat is a 2 s poll: a
+`position-recheck` note saying the record or a machine-frame (G53 window) report was used
+is normal, not a fault - the runner verified every move against the controller's echo.
+Likewise a `get_position` warning that a zero work-origin offset was set aside (a
+G53-window beat reports offsets as 0,0,0 with machine coordinates in `pos`) is the server
+protecting you: machine coordinates stay right, `originOffsetSource` reads `cached`. Only a
+zero offset that persists for 3 beats is believed. If a scan aborts saying the toolhead is
+BELOW the descent target, do not re-stage from a lower `start_z_machine` - verify the
+position with `query_firmware_position` first; the check exists because one bad beat once
+read Z320 as Z-8.
`survey_bed`'s `pitch_mm` is a MAXIMUM: each axis is divided evenly into steps no larger
than it (min 20), so rows and columns are uniform and both edges are covered — no more 80 mm
@@ -176,8 +198,12 @@ surface is that minus the probe length).
first march starts with the tip just above the surface — measured (an earlier
`probe_point -Z`, a previous scan) or operator-stated, never inferred from a
photo (law 3). The runner reaches it law-2 style: raise to the traverse height,
-hop at gantry height to station 1, then a guarded 1 mm descent where any
-contact latches CRASH.
+hop at gantry height to station 1, then descend in ≤ 5 mm segments to 20 mm
+above `start_z_machine` under the asynchronous crash guard (a probe touch latches
+CRASH and the next segment is refused), then a guarded 1 mm descent with a serial
+sensor check after each step. Every procedure descent is segmented like this
+(operator law: a single long move toward the work cannot be stopped once sent),
+and the segments do not wait on the heartbeat - expect `position-estimated` notes.
**The envelope (operator law, 2026-09-05)** — the ONLY exception to motion law
2, valid inside these two procedures only, between consecutive stations only.
@@ -197,9 +223,40 @@ sensor-checked segments expecting NO contact — a touch during a hop means the
surface rose more than `z_safe_delta_mm` and latches the CRASH alarm (law 5).
Completion and abort both raise to the traverse height. Never ask for the caps
to be widened and never approximate a scan with `probe_sequence` hops at a
-"measured safe" height — that is exactly what law 2 forbids. On the GPIO
-transport, `sensor_delay_ms: 50` and `coarse_step_mm: 2` on a known-flat surface
-roughly halve the time per station.
+"measured safe" height — that is exactly what law 2 forbids.
+
+**Coarse press.** `coarse_step_mm` is also the worst-case press into the probe:
+the controller finishes a step before the runner sees the sensor. From station 2
+the runner uses the previous contact as the expected height and switches to fine
+steps `slow_zone_mm` (default 1) above it — press one fine step. Station 1 has no
+neighbour, so its coarse step is capped at 1 mm unless you pass
+`expected_z_machine` (a MEASURED neighbouring contact — a `probe_point -Z`, a
+`probe_sequence` centre, an earlier scan; never a guess, law 3). Each station
+result says `approach: slow-zone | coarse-contact` and `worstPressMm`. `coarse_step_mm`
+is capped at 1 mm in surface scans (operator law: never 2; 0.5–1). On the GPIO
+transport `sensor_delay_ms: 50` is ample.
+
+**Event-log budget — check it BEFORE staging a large scan.** The job keeps at most
+`mcpJobEventLimit` events (default 2000; `get_mcp_diagnostics` → `buffers` and the
+Settings pane show the live value). Beyond that the log keeps its first 20 events
+and the newest tail — `eventsTotal` > `eventCount` on the job tells you it trimmed.
+The procedure `result` (stations, fit, height map) is stored separately and is
+never trimmed; only the step-by-step evidence is. Measured cost (8-station path,
+`z_safe_delta_mm` 20, coarse 1, fine 0.1, 3 confirm passes: 763 events):
+
+| Item | Events |
+|---|---|
+| Fixed: approval, raise, traverse, 20-step guarded descent, final raise | ≈ 100 |
+| Per station (coarse ladder over the 20 mm retract ≈ 19 steps, ≈ 10 fine, 3 confirm cycles, hop segments, readings, diagnostics) | ≈ 90–120 |
+| Same with `z_safe_delta_mm` 5 on a known-flat surface (4 coarse steps) | ≈ 60 |
+
+So `events ≈ 100 + stations × 110` (use 120 to be safe): the default 2000 covers
+about 15 stations; a 5 × 5 grid needs ~3000, a 10 × 10 grid ~12 000, the 400-station
+maximum ~48 000. There is deliberately no MCP tool to change the limit: when the
+estimate exceeds the live value, ASK the operator to raise it (Settings → MCP Server →
+Diagnostic buffers, or `LUBAN_MCP_JOB_EVENT_LIMIT`, range 400–100 000, applied
+immediately) BEFORE you stage, and say the number you need. If they decline, stage
+anyway and read the result from `result`, not the events.
## Bed survey
diff --git a/.claude/skills/cnc-visual-alignment/SKILL.md b/.claude/skills/cnc-visual-alignment/SKILL.md
index 9a90a7f3b0..47430e8d73 100644
--- a/.claude/skills/cnc-visual-alignment/SKILL.md
+++ b/.claude/skills/cnc-visual-alignment/SKILL.md
@@ -33,7 +33,7 @@ better frames.
| Single guarded move | `move_and_capture` | ONE bounded XY move at current Z, settle, capture. No Z parameter by design. |
| Servo step | `visual_servo` | One clamped correction per call; the loop lives in you, not the tool. |
| Calibration store | `set_/get_/delete_camera_calibration` | 2×2 pixel-delta→mm matrix, keyed by the machine Y and Z it was derived at. |
-| Anything compound, and all Z | `validate_gcode`, `submit_gcode_job` → human confirm page → `start_gcode_job`, `get_gcode_job_status`, `stop_gcode_job` | Jobs run through the controller's own state machine and door interlock. The one-time confirm code exists so you cannot self-authorise motion — only the operator can. |
+| Anything compound, and all Z | `validate_gcode`, `submit_gcode_job` → human confirm page → `start_gcode_job`, `get_gcode_job_status`, `stop_gcode_job` | Jobs run through the controller's own state machine and door interlock. Only the operator's click on the confirm page authorises motion — call `start_gcode_job` with `wait_for_approval_ms` to start on that click, or pass the one-time code they relay as `confirm_token`. |
### Machine semantics you must not re-derive wrongly (verified on the A350)
diff --git a/.claude/skills/tool-change/SKILL.md b/.claude/skills/tool-change/SKILL.md
index 9129eb72e7..8cf10c8a42 100644
--- a/.claude/skills/tool-change/SKILL.md
+++ b/.claude/skills/tool-change/SKILL.md
@@ -28,8 +28,11 @@ shifted exactly, without ever re-touching the stock.
(`get_tool_setter_config`; the operator sets them once with
`set_tool_setter_config` — on this machine the park is Z at the homing
height, X at the far end, Y free).
-- Every motion step below stages a job the OPERATOR approves on a confirm page;
- the one-time code they give you goes to `start_gcode_job`.
+- Every motion step below stages a job the OPERATOR approves on a confirm page.
+ Call `start_gcode_job` with `wait_for_approval_ms` (e.g. 110000) right after
+ staging: their click starts it with nothing to copy (`approved: false` on
+ timeout means call again). If hand-off is disabled in their settings, the
+ one-time code they give you goes in as `confirm_token`.
## Two flows — ask which one the operator is using
diff --git a/src/app/resources/i18n/en/resource.json b/src/app/resources/i18n/en/resource.json
index 99412006b1..a3ae81c606 100644
--- a/src/app/resources/i18n/en/resource.json
+++ b/src/app/resources/i18n/en/resource.json
@@ -1133,6 +1133,14 @@
"key-App/Settings/McpServer-Normally-open sensor: idles at 1, reads 0 on contact": "Normally-open sensor: idles at 1, reads 0 on contact",
"key-App/Settings/McpServer-Loopback only; never reachable from the network": "Loopback only; never reachable from the network.",
"key-App/Settings/McpServer-MCP Server": "MCP Server",
+ "key-App/Settings/McpServer-Diagnostic buffers": "Diagnostic buffers",
+ "key-App/Settings/McpServer-Job approval": "Job approval",
+ "key-App/Settings/McpServer-Hand approval to the waiting agent (your click on the confirm page starts the job; no code to copy)": "Hand approval to the waiting agent (your click on the confirm page starts the job; no code to copy)",
+ "key-App/Settings/McpServer-Off: the confirm page shows a one-time code you must relay to the agent yourself. Either way only your click in the browser authorises motion.": "Off: the confirm page shows a one-time code you must relay to the agent yourself. Either way only your click in the browser authorises motion.",
+ "key-App/Settings/McpServer-Job event log (events kept per job)": "Job event log (events kept per job)",
+ "key-App/Settings/McpServer-Recent timing records (per diagnostics list)": "Recent timing records (per diagnostics list)",
+ "key-App/Settings/McpServer-Long procedures (surface scans, bed surveys) write several events per step; raise the job event limit so the whole record survives. Applies immediately; empty = default.": "Long procedures (surface scans, bed surveys) write several events per step; raise the job event limit so the whole record survives. Applies immediately; empty = default.",
+ "key-App/Settings/McpServer-Overridden by environment variable": "Overridden by environment variable",
"key-App/Settings/McpServer-MQTT client id": "MQTT client id",
"key-App/Settings/McpServer-MQTT host": "MQTT host",
"key-App/Settings/McpServer-MQTT password / key": "MQTT password / key",
diff --git a/src/app/ui/pages/global-modals/settings-modal/McpServer/index.tsx b/src/app/ui/pages/global-modals/settings-modal/McpServer/index.tsx
index a96ea302de..85a5f7750a 100644
--- a/src/app/ui/pages/global-modals/settings-modal/McpServer/index.tsx
+++ b/src/app/ui/pages/global-modals/settings-modal/McpServer/index.tsx
@@ -71,6 +71,18 @@ interface McpStatus {
sensors: McpSensorSettings;
mqtt: McpMqttSettings;
gpio: McpGpioSettings;
+ buffers?: {
+ jobEventLimit: number;
+ jobEventLimitRange: [number, number];
+ jobEventLimitSource: 'env' | 'config' | 'default';
+ diagnosticsRecentLimit: number;
+ diagnosticsRecentLimitRange: [number, number];
+ diagnosticsRecentLimitSource: 'env' | 'config' | 'default';
+ };
+ approval?: {
+ handoff: 'agent' | 'code';
+ source: 'env' | 'config' | 'default';
+ };
}
const MQTT_FIELDS: Array<{ name: keyof McpMqttSettings['values']; labelKey: string; placeholder?: string; channel?: string }> = [
@@ -128,6 +140,12 @@ const McpServer: React.FC = () => {
const [mqttPassTouched, setMqttPassTouched] = useState(false);
const [gpio, setGpio] = useState<{ [field: string]: string }>({});
const [gpioInverted, setGpioInverted] = useState<{ [channel: string]: boolean }>({});
+ // Diagnostic buffer sizes; '' = server default. Stored values only (an
+ // env override disables the field).
+ const [jobEventLimit, setJobEventLimit] = useState('');
+ const [diagnosticsRecentLimit, setDiagnosticsRecentLimit] = useState('');
+ // Job approval hand-off: true = a waiting agent starts on the operator's click.
+ const [approvalHandoffAgent, setApprovalHandoffAgent] = useState(true);
useEffect(() => {
api.getMcpStatus()
@@ -142,6 +160,13 @@ const McpServer: React.FC = () => {
setProbeEnabled(body.sensors.probe !== false);
}
setTransport(body.transport ? body.transport.stored : '');
+ if (body.buffers) {
+ setJobEventLimit(body.buffers.jobEventLimitSource === 'config' ? String(body.buffers.jobEventLimit) : '');
+ setDiagnosticsRecentLimit(body.buffers.diagnosticsRecentLimitSource === 'config' ? String(body.buffers.diagnosticsRecentLimit) : '');
+ }
+ if (body.approval) {
+ setApprovalHandoffAgent(body.approval.handoff !== 'code');
+ }
const { inverted: mqttInvertedNames, ...mqttValues } = body.mqtt.values;
setMqtt({ ...mqttValues });
@@ -176,6 +201,8 @@ const McpServer: React.FC = () => {
transport,
mqtt: mqttUpdate,
gpio: gpioUpdate,
+ buffers: { jobEventLimit, diagnosticsRecentLimit },
+ approvalHandoff: approvalHandoffAgent ? 'agent' : 'code',
});
};
@@ -282,6 +309,69 @@ const McpServer: React.FC = () => {
)}
+ setApprovalHandoffAgent(checked)}
+ disabled={!enabled || !!(status && status.approval && status.approval.source === 'env')}
+ />
+ {i18n._('key-App/Settings/McpServer-Hand approval to the waiting agent (your click on the confirm page starts the job; no code to copy)')}
+
+
+ {i18n._('key-App/Settings/McpServer-Off: the confirm page shows a one-time code you must relay to the agent yourself. Either way only your click in the browser authorises motion.')}
+ {status && status.approval && status.approval.source === 'env' ? ` — ${i18n._('key-App/Settings/McpServer-Overridden by environment variable')} LUBAN_MCP_APPROVAL_HANDOFF` : ''}
+
+ {i18n._('key-App/Settings/McpServer-Long procedures (surface scans, bed surveys) write several events per step; raise the job event limit so the whole record survives. Applies immediately; empty = default.')}
+
diff --git a/src/server/services/api/api-mcp.js b/src/server/services/api/api-mcp.js
index 2db98d33de..4ae202c751 100644
--- a/src/server/services/api/api-mcp.js
+++ b/src/server/services/api/api-mcp.js
@@ -1,6 +1,8 @@
import config from '../configstore';
import { getMcpStatus } from '../mcp';
+import { MAX_RECENT_LIMIT, MIN_RECENT_LIMIT, diagnosticsRecentLimit } from '../mcp/diagnostics';
import { DEFAULT_BLINKA_ENV, resolveGpioFeedConfig } from '../mcp/gpioFeed';
+import { MAX_JOB_EVENT_LIMIT, MIN_JOB_EVENT_LIMIT, approvalHandoff, jobEventLimit } from '../mcp/jobs';
import { probeFeedService, resolveProbeFeedConfig, resolveProbeTransportKind, resolveSensorEnabled } from '../mcp/probeFeed';
const ERR_BAD_REQUEST = 400;
@@ -113,6 +115,25 @@ function sensorSettings() {
};
}
+// Diagnostic buffer sizes (jobs.ts / diagnostics.ts). Env overrides win.
+function limitSource(envName, configKey) {
+ if (process.env[envName]) {
+ return 'env';
+ }
+ return config.get(configKey) ? 'config' : 'default';
+}
+
+function bufferSettings() {
+ return {
+ jobEventLimit: jobEventLimit(),
+ jobEventLimitRange: [MIN_JOB_EVENT_LIMIT, MAX_JOB_EVENT_LIMIT],
+ jobEventLimitSource: limitSource('LUBAN_MCP_JOB_EVENT_LIMIT', 'mcpJobEventLimit'),
+ diagnosticsRecentLimit: diagnosticsRecentLimit(),
+ diagnosticsRecentLimitRange: [MIN_RECENT_LIMIT, MAX_RECENT_LIMIT],
+ diagnosticsRecentLimitSource: limitSource('LUBAN_MCP_DIAGNOSTICS_RECENT_LIMIT', 'mcpDiagnosticsRecentLimit'),
+ };
+}
+
function transportSettings() {
return {
// What the operator stored (may be empty = auto), and what is live.
@@ -122,8 +143,29 @@ function transportSettings() {
};
}
+// Job approval hand-off (jobs.ts approvalHandoff): 'agent' lets a waiting
+// start_gcode_job start on the operator's click; 'code' requires the relayed code.
+function approvalSettings() {
+ return {
+ handoff: approvalHandoff(),
+ source: limitSource('LUBAN_MCP_APPROVAL_HANDOFF', 'mcpApprovalHandoff'),
+ };
+}
+
+function settingsPayload() {
+ return {
+ ...getMcpStatus(),
+ transport: transportSettings(),
+ sensors: sensorSettings(),
+ mqtt: mqttSettings(),
+ gpio: gpioSettings(),
+ buffers: bufferSettings(),
+ approval: approvalSettings(),
+ };
+}
+
export const getStatus = (req, res) => {
- res.send({ ...getMcpStatus(), transport: transportSettings(), sensors: sensorSettings(), mqtt: mqttSettings(), gpio: gpioSettings() });
+ res.send(settingsPayload());
};
/**
@@ -155,7 +197,7 @@ export const clearAlarm = (req, res) => {
* omitted field is left unchanged (the pane omits an untouched password).
*/
export const updateSettings = (req, res) => {
- const { enabled, port, allowLan, sensors, mqtt, gpio, transport } = req.body || {};
+ const { enabled, port, allowLan, sensors, mqtt, gpio, transport, buffers, approvalHandoff: handoff } = req.body || {};
if (port !== undefined) {
const value = Number(port);
@@ -173,6 +215,40 @@ export const updateSettings = (req, res) => {
// the pane carries the warning; here we only persist the choice.
config.set('mcpAllowLan', !!allowLan);
}
+ if (handoff !== undefined) {
+ const value = String(handoff).trim().toLowerCase();
+ if (value === '') {
+ config.unset('mcpApprovalHandoff'); // default: agent
+ } else if (value === 'agent' || value === 'code') {
+ config.set('mcpApprovalHandoff', value);
+ } else {
+ res.status(ERR_BAD_REQUEST).send({ msg: `Invalid approvalHandoff: ${handoff} (agent, code or empty)` });
+ return;
+ }
+ }
+ if (buffers && typeof buffers === 'object') {
+ // Diagnostic buffer sizes: applied immediately (read on every append).
+ const limits = [
+ ['jobEventLimit', 'mcpJobEventLimit', MIN_JOB_EVENT_LIMIT, MAX_JOB_EVENT_LIMIT],
+ ['diagnosticsRecentLimit', 'mcpDiagnosticsRecentLimit', MIN_RECENT_LIMIT, MAX_RECENT_LIMIT],
+ ];
+ for (const [field, key, min, max] of limits) {
+ if (buffers[field] === undefined) {
+ continue;
+ }
+ const value = String(buffers[field]).trim();
+ if (value === '') {
+ config.unset(key); // back to the default
+ continue;
+ }
+ const numeric = Number(value);
+ if (!Number.isInteger(numeric) || numeric < min || numeric > max) {
+ res.status(ERR_BAD_REQUEST).send({ msg: `Invalid ${field}: ${value} (${min}-${max})` });
+ return;
+ }
+ config.set(key, numeric);
+ }
+ }
if (sensors && typeof sensors === 'object') {
if (sensors.toolSetter !== undefined) {
config.set('mcpToolSetterEnabled', !!sensors.toolSetter);
@@ -240,5 +316,5 @@ export const updateSettings = (req, res) => {
}
}
- res.send({ ...getMcpStatus(), transport: transportSettings(), sensors: sensorSettings(), mqtt: mqttSettings(), gpio: gpioSettings() });
+ res.send(settingsPayload());
};
diff --git a/src/server/services/mcp/README.md b/src/server/services/mcp/README.md
index 4eead29636..dac9f2f7fc 100644
--- a/src/server/services/mcp/README.md
+++ b/src/server/services/mcp/README.md
@@ -189,10 +189,14 @@ it with no decision point, when the operator had authorised "step 1" only. Laws:
alarm button) clears either kind on the operator's explicit word. Motion is "in
flight" inside `moveMachineSettled` (every procedure move, since 2026-09-04) and
`move_and_capture`. Feed readings and all gcode traffic are logged server-side.
-6. The approved-code page re-shows the exact gcode next to the one-time code.
+6. The approved-code page re-shows the exact gcode next to the approval.
**Chat is not a motion gate — the staged job is** (operator, 2026-09-02): deliberate
- traverses/descents go through staged jobs so authorization is the one-time code against
- the literal gcode, not a model's reading of chat wording. A "go" in chat only permits
+ traverses/descents go through staged jobs so authorization is the operator's click on the
+ confirm page against the literal gcode, not a model's reading of chat wording. Since
+ 2026-09-05 (operator request) that click can be **handed straight to a waiting agent**:
+ `start_gcode_job` called with `wait_for_approval_ms` stays open until the click and starts
+ the job with no code to relay (times out `approved: false` after ≤ 120 s; call again). The
+ one-time code path remains, and Settings → MCP Server → Job approval can require it. A "go" in chat only permits
staging. Re-prove position (home) before a traverse when state is in any doubt,
including after any motion that wasn't part of the agreed sequence.
7. **Use tools for their purpose** — `move_and_capture` is a vision reposition, not a
@@ -234,6 +238,39 @@ horizontally at that height instead of at the gantry. Nothing else inherits this
latches CRASH (law 5); only marches run with `setExpectedContact(['probe'])`. The staged
position and every march start are re-checked (one re-read after ~1.2 s).
+**Descents are segmented (operator law, 2026-09-05).** A single long `G1` toward the work
+cannot be stopped once sent — a collision would be driven to the end of the move. Every
+procedure descent (surface scans and probe_sequence to the guard top, probe_circle to its
+probe height, the tool setter's travel to its start height) is therefore issued in
+`descendInSegments`: segments of **≤ 5 mm**, and **none of them waits on the heartbeat**
+(lenient settle: the controller's ok is the gate; a misframed or missing echo records the
+commanded Z as `estimated`). Contact detection during a descent is **asynchronous**: the
+probe feed's crash guard latches CRASH the instant an unexpected channel fires while motion
+is in flight (job stop + connection close), and every segment re-checks the latch before it
+is sent, so a hit ends the descent within one segment with no serial sensor wait between
+segments. A manoeuvre that needs a synchronous verdict passes `serialCheck` (a `senseAfter`
+window after each segment). The tool setter's travel clears its expected-contact set for
+the descent, so a setter hit above the start height is a collision, not a measurement. The
+1 mm guarded final approach and the coarse/fine ladders (which do sense serially, because
+contact there is the measurement) are unchanged. Upward moves stay single.
+
+**Coarse press and the slow zone (operator, 2026-09-05, job d8f6ec1b5c11).** A coarse step is
+executed whole by the controller before the runner sees the probe, so wherever the surface
+is found by a coarse step the probe is pressed past contact by up to a FULL coarse step
+(0.4 mm at station 1 with 2 mm steps; worst case the whole step). `coarse_step_mm` is
+therefore also the worst-case press. From station 2 the runner knows the expected contact
+(the previous station's Z), so — like `run_tool_setter`'s `slow_zone_mm` — coarse steps now
+stop `slow_zone_mm` (default 1, min 0.3) above it and fine steps take over, down to
+`slow_zone + 2 × coarse` below it (coarse resumes lower, so a pocket edge costs seconds).
+Press in the zone = one fine step. Station 1 has no neighbour: coarse is capped at 1 mm
+unless `expected_z_machine` (a MEASURED neighbouring contact, law 3) is given. The confirm
+page prints the zone; each station result carries `approach` (`slow-zone` |
+`coarse-contact`) and `worstPressMm`. Bonus: no coarse contact means no release-and-return
+ladder, ~5–8 s saved per station. **`coarse_step_mm` is capped at 1 mm everywhere in
+surface scans** (operator, job cdbc29371b97: "we never wanted 2mm"; range 0.5–1, default 1).
+Verified on the rerun (cdbc29371b97, 8/8, same numbers as d8f6): every station
+`slow-zone`, `worstPressMm 0.1`, 408 s vs 492 s.
+
## Safety model (operator-defined, non-negotiable)
- **Compound motion and all cutting goes out as gcode FILES** through the same
@@ -242,9 +279,13 @@ horizontally at that height instead of at the gantry. Nothing else inherits this
(`execute_code`) path is reserved for single guarded actions.
- **Human confirm page** (`/confirm/`, loopback + Origin-checked): shows validation,
extents, warnings, and for direct moves a banner stating the interlock does NOT apply.
- Approval mints a one-time code (15 min TTL) that is never returned over MCP — a model
- cannot self-authorise motion. Batch direct jobs: one approval covers an exact target
- list; each `start_gcode_job` call executes one step.
+ Approval mints a one-time code (15 min TTL). Only the operator's click authorises motion:
+ a model cannot approve its own job. By default (`mcpApprovalHandoff` = `agent`) an agent
+ already waiting in `start_gcode_job wait_for_approval_ms` is started by that click and the
+ page says "handed to the waiting agent" (the code is still printed small as a fallback);
+ with `code` (or `LUBAN_MCP_APPROVAL_HANDOFF=code`) the code must be relayed by hand as
+ before. Batch direct jobs: one approval covers an exact target list; each
+ `start_gcode_job` call executes one step.
- **Z policy**: no Z through XY tools, ever. `move_z` = per-move operator confirmation
showing current Z, target, delta, feed. Every request needs a `reason`.
- **Guards on every direct move**: machine idle, toolhead off (headStatus/headPower),
@@ -282,17 +323,41 @@ horizontally at that height instead of at the gantry. Nothing else inherits this
difference). XY holds; Z does not — that is why `move_z` exists on the direct path.
The job-concept split that matters: machine-interpreter (file) jobs get the door-detector
emergency stop; MCP direct single-command ops do NOT.
-- Heartbeat period ~1 s; a settled-looking heartbeat can predate the motion (hence the
- verified-settle contract). `query_firmware_position` (M114) is the authoritative check.
+- **Heartbeat period is 2 s on WiFi** (`workers/heartBeat.ts` polls `/api/v1/status` every
+ 2000 ms with a 3 s timeout — not the ~1 s assumed until 2026-09-05), and a beat's position
+ can be sampled before a synchronous move finished, so a settled-looking heartbeat can
+ predate the motion (hence the verified-settle contract). `query_firmware_position` (M114)
+ is the authoritative check. The HTTP channel sends each gcode line as its own request, so
+ the engine's `G90` / `G53;` / `G1 …` / `G54;` is four requests and a status poll can land
+ INSIDE the G53 window: that beat reports **machine coordinates in `pos`**, with the offset
+ either still populated (a "frame flip"; `diagnostics` counts `heartbeat_frame_flip`) or read
+ as **(0, 0, 0)** (`zeroOffsetBeats`; set aside by `judgeOffsetReport` until a zero offset
+ persists for 3 beats) or missing (`missingOffsetBeats`; the cached offset is used).
- **Origin-offset transient (2026-09-05, job 44abebd9bab3)**: the SSTP status poll rebuilds
`originOffset` from `offsetX/Y/Z` on every beat, and a beat inside a move's `G53…G54`
window can carry none — `getPositionSnapshot` used to fall through to zero and reframe
machine coordinates as work coordinates ((170, 199, 240) read as (119, 77, −88)), which
aborted a probe_sequence march re-check after every step had verifiably settled. Now a
missing offset reuses the last complete one (`originOffsetSource: cached`, with a
- warning), and position re-checks re-read once after a heartbeat period before aborting.
- This session's work origin sat at machine (51, 122, 328) — negative offsets in the
- heartbeat; `machine = work − originOffset` holds.
+ warning). This session's work origin sat at machine (51, 122, 328) — negative offsets in
+ the heartbeat; `machine = work − originOffset` holds.
+- **Position of record (2026-09-05, job 1db4902a4cd6)**: the same afternoon a surface scan
+ aborted at its station-4 re-check after three clean stations, on two bad beats in a row:
+ one lagging (still the previous hop segment's position) and one frame-flipped
+ (170, 207.571, 227.7 in `pos`, offset present → "machine (221, 329.6, 555.7)"), while the
+ controller had echoed both hop segments at their targets. Rules now (`positionOfRecord.ts`,
+ `probing.ts`):
+ - every verified arrival (controller echo, or settled heartbeat) is the engine's **position
+ of record**; any other gcode sent to the machine voids it;
+ - a status report is judged in **either frame** — `pos − offset` (normal) or raw `pos`
+ (G53-window beat) — for echoes, settle waits, `move_z` waits and re-checks;
+ - a re-check passes on a matching report in either frame, or on the record while the
+ latest report still predates it; it aborts only when **two distinct reports agree** the
+ machine is elsewhere (real drift), or nothing matched within 4.5 s;
+ - **operator rule**: a move of ≤ 1 mm that the controller accepted with no contradicting
+ echo is taken as arrived after a 400 ms grace if the heartbeat has not caught up
+ (`position-estimated` event) — inching is never paced by the 2 s poll. Larger moves
+ still wait for verification.
- Camera is **toolhead-mounted** (rides X/Z; the platform moves under it in Y): pixel→mm
calibration is keyed by machine Y AND Z. The board-viewing anchor pose is the pre-home
park (machine X0/Y0), not machine home. The **gold cylinder at machine Y≈176–340 is the
@@ -416,9 +481,11 @@ Full agent guidance in `.claude/skills/tool-change/SKILL.md`.
operator to close their copy. The build dirties `src/package.json` and
`MaterialTestGcodeParams.jsx` — revert before staging.
- **Stack**: stacked single-commit PRs `mcp/N-*`, each targeting the previous branch
- (#15 → #73 as of 2026-09-04: tip `mcp/39-linux-mac-packaging`, next `mcp/40`; #70 stale-
- heartbeat, #71 probe_sequence, #72 GPIO probe feed, #73 Linux/mac packaging; origin =
- Snapmaker/Luban is NEVER pushed). Mid-stack changes:
+ (#15 → #79 as of 2026-09-05, then `mcp/46-position-of-record`; next `mcp/47`. #70 stale-
+ heartbeat, #71 probe_sequence, #72 GPIO probe feed, #73 Linux/mac packaging, #74 machine
+ settings + docs, #75 sensor toggles + LAN, #76 job events + even survey, #77 offset
+ transient + detached procedures, #78 surface scans, #79 console input leak, mcp/46 position
+ of record + diagnostics; origin = Snapmaker/Luban is NEVER pushed). Mid-stack changes:
amend + rebase the chain. commitlint enforces `Type: Sentence-case subject` (20–100
chars).
- **Credentials**: active gh account is `tyeth-ai-assisted` (no push). Per-command
@@ -428,6 +495,79 @@ Full agent guidance in `.claude/skills/tool-change/SKILL.md`.
- eslint judged against baseline (pre-existing errors in ConnectionManager/SstpHttpChannel
stay); `npx tsc -p tsconfig-server.json --noEmit` filtered to `services/mcp` must be
clean.
+- **Timing diagnostics (`diagnostics.ts`, 2026-09-05)**: in job 1db4902a4cd6 the 0.1 mm fine
+ steps took ~370 ms at the controller plus a 100 ms sensor window, yet one step in four
+ idled 1.3–2.1 s between the controller's reply and the next send, and the resumptions fell
+ on a strict ~4 s grid — every echo matched, so the settle wait was NOT involved; something
+ periodic held the server's timers (event-loop block or CPU starvation on the Celeron box;
+ the renderer console and GNOME Remote Desktop are suspects). Evidence is now recorded as
+ **job events in sequence with the gcode traffic**: `event_loop_stall` (100 ms ticker > 250 ms
+ late), `heartbeat_gap` (> 4.5 s between beats), `heartbeat_frame_flip`, `slow_step` (> 750 ms
+ from the previous reply to the next send inside a job), `sense_overrun` (sensor window
+ finished > 200 ms late), `position-estimated`; every `gcode` event carries `execMs`
+ (send → reply) and `idleMs` (previous reply → this send); probe-feed readings carry
+ `pipeMs` (GPIO monitor detection → server). Totals via `get_mcp_diagnostics` (also in
+ GET `/api/mcp`). The job event log keeps 2000 events (was 400 — a scan overflowed it) and
+ pages by `seq`, so `since_event` / `next_event_index` stay valid after the cap trims the
+ middle. On GPIO the sensor lead over the controller reply was 5–144 ms on all 14 contacts
+ of that job: `sensor_delay_ms` 50 is ample there; the surface-scan floor is 30.
+ **Second run (job d8f6ec1b5c11, mcp/46 build)**: event loop clean (0 stalls, max lag 119 ms),
+ heartbeat mean 1.99 s with 0 gaps, sensor pipe < 3 ms — yet 56 `slow_step` events totalling
+ 159 s of 492 s, every echo matching, each long one (3.5–4.6 s = two beats) landing ~2 s
+ after a `heartbeat_frame_flip`, the short ones (1.1–1.6 s = one beat) every third
+ descend/coarse step. The step path has no await but two timers, so the send events now
+ carry the breakdown `replyToSenseEndMs` / `senseMs` / `senseWindowMs` / `senseEndToEngineMs`
+ / `engineMs` to say which segment holds the time. **Third run (cdbc29371b97)**: the whole
+ idle is `replyToSenseEndMs` (sense 50–52 ms, engine 0–1 ms) — i.e. between the controller's
+ reply and the sensor window opening, inside the move's return path, with every echo
+ matching in the work frame. The code there is synchronous plus promise resumptions, so the
+ send events now also carry a `trace` of wall-clock marks (`reply-returned`, `echo-match` /
+ `echo-miss` / `echo-absent`, `engine-exit`, `settled-exit`, `sense-start`, `sense-end`,
+ `engine-enter`) and a `settle-wait` / `settle-done` event fires whenever the heartbeat
+ fallback runs at all. **Fourth run (70b2b8c675a6, 11 stations, 587 s) named it**: all 84
+ `settle-wait` events carried `offset: 0,0,0` from the heartbeat. A G53-window beat reports
+ `offsetX/Y/Z` as **(0, 0, 0)** (not missing) with `pos` in machine coordinates, so the
+ work-frame echo matched neither frame and the engine waited one beat (two after a flip) —
+ ~180 s of that scan. The same beat, read by the runner before the descent to station 1,
+ turned a verified Z320 into "Z−8", skipped the fast descent and aborted the scan at the
+ station check (job 42df7b9351b7; GPT-5.6 agent's write-up
+ `ROTARY_SCAN_POSITION_RECHECK_ISSUE.md` on the box). Fix: `judgeOffsetReport`
+ (positionOfRecord.ts) — a zero offset that contradicts a non-zero offset seen on this
+ connection is a transient until reported on 3 distinct beats in a row (a real zero origin
+ persists); `getPositionSnapshot` uses the cached offset meanwhile (`originOffsetSource:
+ cached`, warning); runners take their current Z from `knownMachinePosition()` (the
+ position of record first) and abort if the toolhead is already BELOW the descent target;
+ diagnostics count `zeroOffsetBeats`; `get_mcp_diagnostics.originOffset` shows the cache and
+ streak. **Fifth run**: the descent fix held (`descend-from Z320 (record)`), but 28
+ `settle-wait`s remained, all zero-offset, stations 47–54 s — a scan stepping every second
+ keeps the poll inside G53 windows for beats in a row, so the 3-beat streak *accepted* the
+ zero (125 zero-offset beats). Two more changes: (1) only QUIET beats count towards
+ believing a zero — none while direct gcode is in flight or replied within 3 s
+ (`directGcodeQuiet`); a real touchscreen re-zero happens idle and is believed after ~6 s;
+ (2) the engine keeps its own **trusted offset** (the one that last proved an arrival via echo
+ or settled heartbeat) and judges echoes with it FIRST, the heartbeat's offset second
+ (`matchFrameWithOffsets`), so the echo path no longer depends on the per-beat value at all;
+ `settle-wait` events print `trustedOffset`, `get_mcp_diagnostics.originOffset.trustedByEngine`
+ shows it.
+- **Stale post-hop beat read as drift (job de932e286afd, 133-station grid, aborted at r2c10
+ after 29 contacts; GPT-5.6 agent's retest note)**: a 4 mm hop's echo verified X174 and set
+ the record; the next status report, stamped ~750 ms later, still showed the previous
+ station X178 (the poll samples the machine before its response is processed). The
+ re-check read that ONE beat twice 250 ms apart, and `reportTime` derived as
+ `Date.now() − reportAgeMs` differed by 1 ms between the reads, so "two distinct agreeing
+ beats" fired and the scan aborted. Fixes: snapshots carry the beat's own `reportedAt`;
+ two reports are distinct only ≥ 1 s apart; the record remembers `previousMachine`, and a
+ report still showing that position is stale by definition (never drift evidence — the
+ 4.5 s deadline still aborts if nothing ever catches up). Unit checks replay the abort.
+- **Buffer sizes** (Settings → MCP Server → Diagnostic buffers, or env): `mcpJobEventLimit` /
+ `LUBAN_MCP_JOB_EVENT_LIMIT` (default 2000, 400–100000 events per job) and
+ `mcpDiagnosticsRecentLimit` / `LUBAN_MCP_DIAGNOSTICS_RECENT_LIMIT` (default 40, 10–10000 per
+ list). Applied immediately. Measured (cdbc29371b97, 8-station path, z_safe_delta 20, coarse 1,
+ fine 0.1, 3 confirm passes): 763 events ≈ 100 fixed + ~85 per station; budget
+ `100 + stations × 120` — the default covers ~15 stations, a 10 × 10 grid needs ~12 000.
+ `job.result` is stored outside the log and is never trimmed; `eventsTotal > eventCount`
+ flags a trimmed log. The cnc-probing skill tells agents to ask the operator to raise the
+ limit before staging a scan that would overflow it (no MCP tool changes it, by design).
- **Claude Code caches MCP tool schemas at session start**: after a server rebuild that adds
or changes tool arguments, the client strips the new args (`additionalProperties: false`)
until Claude Code restarts — or use a raw JSON-RPC helper
diff --git a/src/server/services/mcp/diagnostics.ts b/src/server/services/mcp/diagnostics.ts
new file mode 100644
index 0000000000..df58abaafb
--- /dev/null
+++ b/src/server/services/mcp/diagnostics.ts
@@ -0,0 +1,284 @@
+import logger from '../../lib/logger';
+import config from '../configstore';
+import { connectionManager } from '../machine/ConnectionManager';
+import { mcpBroadcast } from './index';
+
+// Timing diagnostics for the sensor-gated motion engine.
+//
+// Why (2026-09-05, job 1db4902a4cd6): a surface scan's 0.1 mm fine steps took
+// ~370 ms at the controller and ~100 ms of sensor window, yet one step in
+// four sat idle for 1.3-2.1 s between the controller's reply and the next
+// send - and the resumptions fell on a strict ~4 s grid. Every controller
+// echo matched, so the engine's settle wait was NOT involved; something
+// periodic held the server's timers (event-loop block or CPU starvation on
+// the Celeron box). The server log could not say what. This module records
+// the evidence the next run needs, as job events (so an agent reading
+// get_gcode_job_status sees them in sequence with the gcode traffic) and as
+// counters in get_mcp_diagnostics / the Settings API:
+// - event-loop stalls: a 100 ms ticker whose lateness beyond 250 ms is a
+// stall (`event_loop_stall`);
+// - heartbeat cadence (the WiFi status poll runs every 2 s with a 3 s
+// timeout - not the ~1 s the docs assumed), gaps (`heartbeat_gap`),
+// beats carrying no origin offset, and beats whose raw position jumped by
+// exactly the offset (`heartbeat_frame_flip`: a status poll that landed
+// inside a move's G53 window and reported machine coordinates);
+// - gcode timing per command (exec ms, idle ms since the previous reply -
+// stamped on the gcode events by tools/camera.ts) and slow steps;
+// - sensor pipe latency (GPIO monitor timestamp -> server receipt).
+// None of this touches the machine. Costs: one 100 ms and one 250 ms timer.
+
+const log = logger('service:mcp:diag');
+
+const LOOP_TICK_MS = 100;
+export const LOOP_STALL_MS = 250;
+const HEARTBEAT_WATCH_MS = 250;
+export const HEARTBEAT_GAP_MS = 4500;
+// How many recent stalls / gaps / flips / slow idles each list keeps. Long
+// jobs need more: configstore mcpDiagnosticsRecentLimit (Settings -> MCP
+// Server) or LUBAN_MCP_DIAGNOSTICS_RECENT_LIMIT.
+export const DEFAULT_RECENT_LIMIT = 40;
+export const MIN_RECENT_LIMIT = 10;
+export const MAX_RECENT_LIMIT = 10000;
+
+export function diagnosticsRecentLimit(): number {
+ const env = process.env.LUBAN_MCP_DIAGNOSTICS_RECENT_LIMIT;
+ const raw = env !== undefined && String(env).trim() !== '' ? Number(env) : Number(config.get('mcpDiagnosticsRecentLimit'));
+ if (!Number.isFinite(raw) || raw <= 0) {
+ return DEFAULT_RECENT_LIMIT;
+ }
+ return Math.min(Math.max(Math.round(raw), MIN_RECENT_LIMIT), MAX_RECENT_LIMIT);
+}
+
+interface Stamp {
+ at: number;
+ ms: number;
+ note?: string;
+}
+
+interface LoopStats {
+ running: boolean;
+ since: number | null;
+ ticks: number;
+ maxLagMs: number;
+ stallCount: number;
+ stallTotalMs: number;
+ recentStalls: Stamp[];
+}
+
+interface HeartbeatStats {
+ beats: number;
+ lastAt: number | null;
+ minIntervalMs: number | null;
+ maxIntervalMs: number;
+ meanIntervalMs: number | null;
+ gapCount: number;
+ recentGaps: Stamp[];
+ missingOffsetBeats: number;
+ zeroOffsetBeats: number;
+ frameFlipBeats: number;
+ recentFrameFlips: Stamp[];
+}
+
+interface GcodeStats {
+ sent: number;
+ execMaxMs: number;
+ execMeanMs: number | null;
+ slowIdleCount: number;
+ recentSlowIdles: Stamp[];
+}
+
+interface SensorStats {
+ stamped: number;
+ pipeLatencyLastMs: number | null;
+ pipeLatencyMaxMs: number;
+ pipeLatencyMeanMs: number | null;
+}
+
+const loop: LoopStats = {
+ running: false, since: null, ticks: 0, maxLagMs: 0, stallCount: 0, stallTotalMs: 0, recentStalls: [],
+};
+const heartbeat: HeartbeatStats = {
+ beats: 0,
+ lastAt: null,
+ minIntervalMs: null,
+ maxIntervalMs: 0,
+ meanIntervalMs: null,
+ gapCount: 0,
+ recentGaps: [],
+ missingOffsetBeats: 0,
+ zeroOffsetBeats: 0,
+ frameFlipBeats: 0,
+ recentFrameFlips: [],
+};
+const gcode: GcodeStats = { sent: 0, execMaxMs: 0, execMeanMs: null, slowIdleCount: 0, recentSlowIdles: [] };
+const sensor: SensorStats = { stamped: 0, pipeLatencyLastMs: null, pipeLatencyMaxMs: 0, pipeLatencyMeanMs: null };
+
+let intervalSum = 0;
+let execSum = 0;
+let pipeSum = 0;
+let loopTimer: NodeJS.Timeout | null = null;
+let heartbeatTimer: NodeJS.Timeout | null = null;
+let lastRaw: { x: number; y: number; z: number } | null = null;
+
+function remember(list: Stamp[], stamp: Stamp): void {
+ list.push(stamp);
+ const limit = diagnosticsRecentLimit();
+ if (list.length > limit) {
+ list.splice(0, list.length - limit);
+ }
+}
+
+function num(value: unknown): number | null {
+ const n = Number(value);
+ return value === undefined || value === null || value === '' || !Number.isFinite(n) ? null : n;
+}
+
+function startLoopMonitor(): void {
+ let expected = Date.now() + LOOP_TICK_MS;
+ loop.running = true;
+ loop.since = Date.now();
+ loopTimer = setInterval(() => {
+ const now = Date.now();
+ const lag = now - expected;
+ expected = now + LOOP_TICK_MS;
+ loop.ticks += 1;
+ if (lag > loop.maxLagMs) {
+ loop.maxLagMs = lag;
+ }
+ if (lag > LOOP_STALL_MS) {
+ loop.stallCount += 1;
+ loop.stallTotalMs += lag;
+ remember(loop.recentStalls, { at: now, ms: lag });
+ const note = `server timers ran ${lag} ms late (event loop blocked or process starved of CPU)`;
+ log.warn(`event loop stall: ${note}`);
+ mcpBroadcast('mcp:activity', { tool: 'diagnostics', phase: 'event_loop_stall', ms: lag, note });
+ }
+ }, LOOP_TICK_MS);
+ loopTimer.unref();
+}
+
+function watchHeartbeat(): void {
+ const state = connectionManager.getLatestMachineState() as {
+ timestamp?: number;
+ pos?: { x?: unknown; y?: unknown; z?: unknown };
+ originOffset?: { x?: unknown; y?: unknown; z?: unknown };
+ } | null;
+ if (!state || !state.timestamp || state.timestamp === heartbeat.lastAt) {
+ return;
+ }
+ const at = state.timestamp;
+ if (heartbeat.lastAt !== null) {
+ const interval = at - heartbeat.lastAt;
+ heartbeat.beats += 1;
+ intervalSum += interval;
+ heartbeat.meanIntervalMs = Math.round(intervalSum / heartbeat.beats);
+ heartbeat.minIntervalMs = heartbeat.minIntervalMs === null ? interval : Math.min(heartbeat.minIntervalMs, interval);
+ heartbeat.maxIntervalMs = Math.max(heartbeat.maxIntervalMs, interval);
+ if (interval > HEARTBEAT_GAP_MS) {
+ heartbeat.gapCount += 1;
+ remember(heartbeat.recentGaps, { at, ms: interval });
+ const note = `${(interval / 1000).toFixed(1)} s between machine status reports (poll period 2 s)`;
+ log.warn(`heartbeat gap: ${note}`);
+ mcpBroadcast('mcp:activity', { tool: 'diagnostics', phase: 'heartbeat_gap', ms: interval, note });
+ }
+ } else {
+ heartbeat.beats = 1;
+ }
+ heartbeat.lastAt = at;
+
+ const pos = state.pos || {};
+ const off = state.originOffset || {};
+ const raw = { x: num(pos.x), y: num(pos.y), z: num(pos.z) };
+ const offset = { x: num(off.x), y: num(off.y), z: num(off.z) };
+ if (offset.x === null || offset.y === null || offset.z === null) {
+ heartbeat.missingOffsetBeats += 1;
+ } else if (offset.x === 0 && offset.y === 0 && offset.z === 0) {
+ // G53-window signature #2 (job 70b2b8c675a6): offsets read 0,0,0
+ // with pos in machine coordinates. Counted here; getPositionSnapshot
+ // sets such a beat aside (positionOfRecord.judgeOffsetReport).
+ heartbeat.zeroOffsetBeats += 1;
+ }
+ if (raw.x !== null && raw.y !== null && raw.z !== null) {
+ if (lastRaw && offset.x !== null && offset.y !== null && offset.z !== null) {
+ // A report in the other frame differs from the previous one by
+ // exactly the offset on every axis the offset is non-zero on -
+ // no real move does that on all axes at once.
+ const axes = (['x', 'y', 'z'] as const).filter((axis) => Math.abs(offset[axis] as number) > 0.5);
+ const delta = { x: raw.x - lastRaw.x, y: raw.y - lastRaw.y, z: raw.z - lastRaw.z };
+ const flipped = axes.length > 0 && (
+ axes.every((axis) => Math.abs(delta[axis] + (offset[axis] as number)) <= 0.5)
+ || axes.every((axis) => Math.abs(delta[axis] - (offset[axis] as number)) <= 0.5)
+ );
+ if (flipped) {
+ heartbeat.frameFlipBeats += 1;
+ const note = `status report jumped by the origin offset: raw (${raw.x}, ${raw.y}, ${raw.z}) after `
+ + `(${lastRaw.x}, ${lastRaw.y}, ${lastRaw.z}) with offset (${offset.x}, ${offset.y}, ${offset.z}) - a poll `
+ + 'inside a G53 window reporting machine coordinates, or the return from one';
+ remember(heartbeat.recentFrameFlips, { at, ms: 0, note });
+ log.info(`heartbeat frame flip: ${note}`);
+ mcpBroadcast('mcp:activity', { tool: 'diagnostics', phase: 'heartbeat_frame_flip', note });
+ }
+ }
+ lastRaw = { x: raw.x, y: raw.y, z: raw.z };
+ }
+}
+
+/** Called by sendGcodeVisible for every direct command. */
+export function recordGcodeTiming(tool: string, execMs: number, idleMs: number | null, slowIdle: boolean): void {
+ gcode.sent += 1;
+ execSum += execMs;
+ gcode.execMeanMs = Math.round(execSum / gcode.sent);
+ gcode.execMaxMs = Math.max(gcode.execMaxMs, execMs);
+ if (slowIdle && idleMs !== null) {
+ gcode.slowIdleCount += 1;
+ remember(gcode.recentSlowIdles, { at: Date.now(), ms: idleMs, note: tool });
+ }
+}
+
+/** Called by the GPIO transport for every reading the monitor timestamped. */
+export function recordSensorLatency(ms: number): void {
+ sensor.stamped += 1;
+ pipeSum += ms;
+ sensor.pipeLatencyLastMs = ms;
+ sensor.pipeLatencyMaxMs = Math.max(sensor.pipeLatencyMaxMs, ms);
+ sensor.pipeLatencyMeanMs = Math.round(pipeSum / sensor.stamped);
+}
+
+export function startDiagnostics(): void {
+ if (loopTimer) {
+ return;
+ }
+ startLoopMonitor();
+ heartbeatTimer = setInterval(watchHeartbeat, HEARTBEAT_WATCH_MS);
+ heartbeatTimer.unref();
+}
+
+export function stopDiagnostics(): void {
+ if (loopTimer) {
+ clearInterval(loopTimer);
+ loopTimer = null;
+ }
+ if (heartbeatTimer) {
+ clearInterval(heartbeatTimer);
+ heartbeatTimer = null;
+ }
+ loop.running = false;
+}
+
+export function diagnosticsSnapshot() {
+ return {
+ eventLoop: { ...loop, stallThresholdMs: LOOP_STALL_MS, tickMs: LOOP_TICK_MS },
+ heartbeat: { ...heartbeat, gapThresholdMs: HEARTBEAT_GAP_MS, pollPeriodMs: 2000 },
+ gcode: { ...gcode },
+ sensor: { ...sensor },
+ buffers: {
+ recentLimit: diagnosticsRecentLimit(),
+ recentLimitRange: [MIN_RECENT_LIMIT, MAX_RECENT_LIMIT],
+ note: 'Settings -> MCP Server (mcpDiagnosticsRecentLimit / LUBAN_MCP_DIAGNOSTICS_RECENT_LIMIT); the job event '
+ + 'log cap is mcpJobEventLimit / LUBAN_MCP_JOB_EVENT_LIMIT.',
+ },
+ note: 'Job events carry the same signals in sequence with the gcode traffic: event_loop_stall, '
+ + 'heartbeat_gap, heartbeat_frame_flip, slow_step, sense_overrun, position-estimated; gcode events '
+ + 'carry execMs (send -> controller reply) and idleMs (previous reply -> this send).',
+ };
+}
diff --git a/src/server/services/mcp/gpioFeed.ts b/src/server/services/mcp/gpioFeed.ts
index ab0994c549..749a5386d1 100644
--- a/src/server/services/mcp/gpioFeed.ts
+++ b/src/server/services/mcp/gpioFeed.ts
@@ -3,6 +3,7 @@ import { EventEmitter } from 'events';
import logger from '../../lib/logger';
import config from '../configstore';
+import { recordSensorLatency } from './diagnostics';
import { PROBE_CHANNELS, ProbeChannel, ProbeTransport } from './probeTransport';
const log = logger('service:mcp:gpio-feed');
@@ -232,11 +233,13 @@ def main():
values[channel] = value
if last.get(channel) != value:
last[channel] = value
- emit({'t': 'reading', 'channel': channel, 'value': value})
+ # ts: wall clock at detection, so the server can measure
+ # the pipe latency (same host, same clock).
+ emit({'t': 'reading', 'channel': channel, 'value': value, 'ts': time.time()})
now = time.monotonic()
if now >= next_hb:
next_hb = now + hb_s
- emit({'t': 'hb', 'values': values})
+ emit({'t': 'hb', 'values': values, 'ts': time.time()})
time.sleep(poll_s)
except Exception as err:
emit({'t': 'fatal', 'error': 'read loop failed: %s' % err})
@@ -437,7 +440,14 @@ export class GpioProbeTransport extends EventEmitter implements ProbeTransport {
if (message.t === 'reading') {
const channel = String(message.channel) as ProbeChannel;
if (PROBE_CHANNELS.includes(channel)) {
- this.emit('reading', channel, String(message.value));
+ // Monitor -> server pipe latency (diagnostics.ts): the monitor
+ // stamps wall-clock seconds at detection.
+ const sentAt = Number(message.ts) * 1000;
+ const meta = Number.isFinite(sentAt) && sentAt > 0 ? { sentAt } : undefined;
+ if (meta) {
+ recordSensorLatency(Math.max(0, Date.now() - sentAt));
+ }
+ this.emit('reading', channel, String(message.value), meta);
}
return;
}
diff --git a/src/server/services/mcp/index.ts b/src/server/services/mcp/index.ts
index 676a583f8f..640c5c6ff6 100644
--- a/src/server/services/mcp/index.ts
+++ b/src/server/services/mcp/index.ts
@@ -3,6 +3,7 @@ import http from 'http';
import pkg from '../../../package.json';
import logger from '../../lib/logger';
import config from '../configstore';
+import { diagnosticsSnapshot, startDiagnostics } from './diagnostics';
import { McpServer, isAllowedOrigin, isLocalSubnetAddress, isLocalSubnetOrigin, isLoopback, localSubnets } from './McpServer';
import { jobManager } from './jobs';
import { probeFeedService, resolveActiveProbeConfig } from './probeFeed';
@@ -142,6 +143,9 @@ export function getMcpStatus() {
// Sensor feed snapshot for the Workspace connection pills; live
// updates arrive over mcp:activity (tool 'probe_feed').
probeFeed: probeFeedService.status(),
+ // Timing evidence (event-loop stalls, heartbeat cadence, gcode
+ // pacing, sensor pipe latency) - diagnostics.ts.
+ diagnostics: diagnosticsSnapshot(),
};
}
@@ -219,6 +223,7 @@ export function startMcpService(socketServer?: McpBroadcaster): void {
: ' (loopback only)';
log.info(`MCP server listening at http://127.0.0.1:${port}/mcp${reach}`);
});
+ startDiagnostics();
// Arm the external probe feed (and its overtravel tripwire) without any
// agent involvement when it is fully configured. Failure is logged and
diff --git a/src/server/services/mcp/jobs.ts b/src/server/services/mcp/jobs.ts
index e738305e2b..42deca2e45 100644
--- a/src/server/services/mcp/jobs.ts
+++ b/src/server/services/mcp/jobs.ts
@@ -5,6 +5,7 @@ import path from 'path';
import DataStorage from '../../DataStorage';
import logger from '../../lib/logger';
+import config from '../configstore';
import { GcodeValidationReport } from './validator';
const log = logger('service:mcp:jobs');
@@ -42,6 +43,12 @@ export type McpJobKind = 'file' | 'direct' | 'procedure';
export interface JobEvent {
at: number;
+ /**
+ * Position in the job's full event stream (0-based, never reused). The
+ * in-memory log is capped, so `events[i]` is not `seq === i` on a long
+ * job; get_gcode_job_status pages by seq (since_event / next_event_index).
+ */
+ seq: number;
/** State change ('submitted', 'approved', 'started', 'completed', ...), a runner phase, 'gcode', 'progress'. */
phase: string;
/** Which tool/source produced it. */
@@ -50,7 +57,41 @@ export interface JobEvent {
[detail: string]: unknown;
}
-const MAX_JOB_EVENTS = 400;
+// A surface scan produces ~4 gcode events per 0.1 mm step; the old 400 cap
+// lost the first three stations of job 1db4902a4cd6 (2026-09-05) before
+// anyone could read them. Long jobs need more, so the cap is a setting:
+// configstore mcpJobEventLimit (Settings -> MCP Server) or the environment
+// LUBAN_MCP_JOB_EVENT_LIMIT. ~700 bytes per event.
+export const DEFAULT_JOB_EVENT_LIMIT = 2000;
+export const MIN_JOB_EVENT_LIMIT = 400;
+export const MAX_JOB_EVENT_LIMIT = 100000;
+
+/**
+ * How an approval reaches the agent. 'agent' (default; operator request
+ * 2026-09-05): start_gcode_job may long-poll with wait_for_approval_ms and the
+ * operator's click on the confirm page starts the job - no code to copy. 'code':
+ * the one-time code must be relayed by the operator (the original scheme).
+ * Either way a human click in a browser is the only thing that authorises
+ * motion; the code was never a secret from a process with loopback access.
+ * configstore mcpApprovalHandoff or LUBAN_MCP_APPROVAL_HANDOFF.
+ */
+export type ApprovalHandoff = 'agent' | 'code';
+
+export function approvalHandoff(): ApprovalHandoff {
+ const env = process.env.LUBAN_MCP_APPROVAL_HANDOFF;
+ const raw = env !== undefined && String(env).trim() !== '' ? env : config.get('mcpApprovalHandoff');
+ return String(raw || '').trim().toLowerCase() === 'code' ? 'code' : 'agent';
+}
+
+export function jobEventLimit(): number {
+ const raw = process.env.LUBAN_MCP_JOB_EVENT_LIMIT !== undefined && String(process.env.LUBAN_MCP_JOB_EVENT_LIMIT).trim() !== ''
+ ? Number(process.env.LUBAN_MCP_JOB_EVENT_LIMIT)
+ : Number(config.get('mcpJobEventLimit'));
+ if (!Number.isFinite(raw) || raw <= 0) {
+ return DEFAULT_JOB_EVENT_LIMIT;
+ }
+ return Math.min(Math.max(Math.round(raw), MIN_JOB_EVENT_LIMIT), MAX_JOB_EVENT_LIMIT);
+}
export const TERMINAL_JOB_STATES: McpJobState[] = ['rejected', 'start_failed', 'stopped', 'completed'];
@@ -66,6 +107,8 @@ export interface McpJob {
confirmToken: string | null;
approvedAt: number | null;
tokenUsed: boolean;
+ /** An agent is long-polling start_gcode_job for the approval (approval hand-off); the confirm page says so instead of showing a code to copy. */
+ agentWaiting: boolean;
startedAt: number | null;
// Set when the job reaches a terminal state (completed / stopped).
endedAt: number | null;
@@ -75,6 +118,8 @@ export interface McpJob {
// job, file-job progress. Returned by get_gcode_job_status so an agent
// never has to read server logs to learn how a job went. Capped.
events: JobEvent[];
+ /** Total events ever appended (next seq); events[] may hold fewer. */
+ eventSeq: number;
// Procedure outcome (tool setter / probe results), kept on the record so
// a client that timed out waiting on start_gcode_job can still read it.
result: object | null;
@@ -135,11 +180,13 @@ export class JobManager {
state: 'awaiting_confirmation',
confirmToken: null,
approvedAt: null,
+ agentWaiting: false,
tokenUsed: false,
startedAt: null,
endedAt: null,
error: null,
events: [],
+ eventSeq: 0,
result: null,
steps,
nextStep: steps ? 0 : undefined,
@@ -153,13 +200,21 @@ export class JobManager {
/** Record something that happened to a job (state change, phase, gcode, progress). */
public appendEvent(job: McpJob, phase: string, detail: { [key: string]: unknown } = {}): void {
- job.events.push({ at: Date.now(), phase, ...detail });
- if (job.events.length > MAX_JOB_EVENTS) {
+ const seq = job.eventSeq;
+ job.eventSeq += 1;
+ job.events.push({ at: Date.now(), seq, phase, ...detail });
+ const limit = jobEventLimit();
+ if (job.events.length > limit) {
// Keep the head (submission/approval/start) and the most recent tail.
- job.events.splice(20, job.events.length - MAX_JOB_EVENTS);
+ job.events.splice(20, job.events.length - limit);
}
}
+ /** Events with seq >= since, in order (the cap may have dropped some). */
+ public eventsSince(job: McpJob, since: number): JobEvent[] {
+ return job.events.filter((event) => event.seq >= since);
+ }
+
/**
* The job currently driving the machine (a procedure runner, a direct
* step, a running file job). Activity broadcast while it is active is
@@ -190,7 +245,15 @@ export class JobManager {
} else if (eventName === 'mcp:gcode') {
const gcode = payload.gcode !== undefined ? String(payload.gcode).slice(0, 300) : undefined;
const response = payload.response !== undefined ? String(payload.response).slice(0, 300) : undefined;
- this.appendEvent(job, 'gcode', { tool: payload.tool, gcode, response });
+ // Timing stamps from sendGcodeVisible: idleMs = previous reply ->
+ // this send (engine + sensor window), execMs = send -> reply.
+ const timing: { [key: string]: unknown } = {};
+ for (const key of ['idleMs', 'execMs', 'engineMs', 'replyToSenseEndMs', 'senseMs', 'senseWindowMs', 'senseKind', 'senseEndToEngineMs', 'trace']) {
+ if (payload[key] !== undefined && payload[key] !== null) {
+ timing[key] = payload[key];
+ }
+ }
+ this.appendEvent(job, 'gcode', { tool: payload.tool, gcode, response, ...timing });
}
}
@@ -250,6 +313,7 @@ export class JobManager {
terminal: this.isTerminal(job),
result: job.result,
eventCount: job.events.length,
+ eventsTotal: job.eventSeq,
lastEvent: job.events.length ? job.events[job.events.length - 1] : null,
totalSteps: job.steps ? job.steps.length : undefined,
nextStep: job.steps ? job.nextStep : undefined,
@@ -339,10 +403,20 @@ export class JobManager {
const approvedPreview = approvedLines.length > 80
? [...approvedLines.slice(0, 40), `... ${approvedLines.length - 80} lines elided ...`, ...approvedLines.slice(-40)].join('\n')
: approvedGcode;
+ // Approval hand-off (operator request 2026-09-05): when an agent is
+ // already waiting in start_gcode_job, the click IS the start - no code
+ // to copy. The code is still printed small in case the agent's wait
+ // timed out a moment ago (it stays valid for 15 minutes).
+ const handoff = job.agentWaiting && approvalHandoff() === 'agent'
+ ? `
+ Handed to the waiting agent. Your click starts
+ ${escapeHtml(job.name)} now - nothing to copy.
+ Fallback code if the agent reports it stopped waiting: ${job.confirmToken}
`
+ : `
Give this one-time code to the agent to start ${escapeHtml(job.name)}:
+
${job.confirmToken}
`;
return `
Approved
-
Give this one-time code to the agent to start ${escapeHtml(job.name)}:
-
${job.confirmToken}
+ ${handoff}
It expires 15 minutes after approval and works once
(a batch of moves: once per approved step).
This code will run exactly:
diff --git a/src/server/services/mcp/positionOfRecord.ts b/src/server/services/mcp/positionOfRecord.ts
new file mode 100644
index 0000000000..247af2d759
--- /dev/null
+++ b/src/server/services/mcp/positionOfRecord.ts
@@ -0,0 +1,352 @@
+// Position of record: what the sensor-gated motion engine KNOWS about where
+// the machine is, independent of the heartbeat.
+//
+// Why this exists (hardware, 2026-09-05, job 1db4902a4cd6): a surface scan
+// aborted at its station-4 position re-check after three clean stations. The
+// two hop segments had each been echoed by the controller at their commanded
+// positions, so the machine was exactly where the plan said - but the re-check
+// judged the heartbeat instead:
+// - beat 1 still showed the segment-1 position (the status poll runs on its
+// own ~1 s cadence and lags a synchronous move by up to a period or two);
+// - beat 2 carried (170, 207.571, 227.7) in its x/y/z fields with the origin
+// offset still populated. Those are the MACHINE coordinates of the move
+// just completed: the HTTP channel sends `G90`, `G53;`, `G1 ...`, `G54;`
+// as four separate requests, and a status poll landing inside that window
+// reports the G53 frame. Subtracting the offset again produced
+// (221, 329.6, 555.7) and the scan aborted.
+// The earlier fix (missing offsets reuse the last complete one) covered a
+// different transient. This module covers both, plus the lag:
+// 1. matchFrame() accepts a report in EITHER frame - work (the normal case)
+// or raw machine coordinates when the offset makes the two
+// distinguishable (the G53-window signature).
+// 2. The engine records every verified arrival (controller echo or settled
+// heartbeat), and re-checks let that record outrank a heartbeat that
+// predates it. Any other gcode sent to the machine invalidates it.
+// 3. Operator rule (2026-09-05): a move of <= 1 mm whose controller reply
+// carried no contradicting position may be taken as arrived when the
+// heartbeat is late, so inching is not paced by the status poll. Such a
+// position is recorded as `estimated`.
+// Position judgements stay pure functions here so they can be unit-tested
+// without a machine.
+
+export interface Xyz {
+ x: number;
+ y: number;
+ z: number;
+}
+
+export interface NullableXyz {
+ x: number | null;
+ y: number | null;
+ z: number | null;
+}
+
+export type Axis = 'x' | 'y' | 'z';
+export const AXES: readonly Axis[] = ['x', 'y', 'z'];
+
+/** Which reading of a status report agrees with an expected MACHINE position. */
+export type FrameMatch = 'work-frame' | 'machine-frame' | null;
+
+/**
+ * Judge one status report (raw x/y/z fields plus the origin offset it came
+ * with) against an expected machine position on the axes `expected` names.
+ * 'work-frame': machine = raw - offset matches (the documented convention).
+ * 'machine-frame': the raw fields themselves match and the offset is large
+ * enough on a compared axis to tell the two apart - a report taken while G53
+ * was selected. null: neither reading matches.
+ */
+export function matchFrame(
+ raw: NullableXyz,
+ offset: Xyz,
+ expected: Partial,
+ toleranceMm: number
+): FrameMatch {
+ const axes = AXES.filter((axis) => expected[axis] !== undefined);
+ if (!axes.length) {
+ return 'work-frame';
+ }
+ const within = (value: number | null, want: number) => value !== null && Math.abs(value - want) <= toleranceMm;
+ if (axes.every((axis) => within(raw[axis] === null ? null : (raw[axis] as number) - offset[axis], expected[axis] as number))) {
+ return 'work-frame';
+ }
+ const distinguishable = axes.some((axis) => Math.abs(offset[axis]) > toleranceMm);
+ if (distinguishable && axes.every((axis) => within(raw[axis], expected[axis] as number))) {
+ return 'machine-frame';
+ }
+ return null;
+}
+
+/** Machine coordinates implied by a report once its frame is known. */
+export function machineFromReport(raw: NullableXyz, offset: Xyz, frame: 'work-frame' | 'machine-frame'): NullableXyz {
+ const convert = (value: number | null, shift: number) => {
+ if (value === null) {
+ return null;
+ }
+ return frame === 'work-frame' ? value - shift : value;
+ };
+ return {
+ x: convert(raw.x, offset.x),
+ y: convert(raw.y, offset.y),
+ z: convert(raw.z, offset.z),
+ };
+}
+
+/** Whether a machine position lies within `toleranceMm` of `expected` on every axis. */
+export function nearMachine(position: NullableXyz, expected: Xyz, toleranceMm: number): boolean {
+ return AXES.every((axis) => position[axis] !== null && Math.abs((position[axis] as number) - expected[axis]) <= toleranceMm);
+}
+
+/**
+ * The commanded axes of a move laid over the position it started from. The
+ * result is a full machine position only when every uncommanded axis was
+ * known; otherwise null (an estimate cannot invent an axis).
+ */
+export function completeTarget(from: NullableXyz, target: Partial): Xyz | null {
+ const merged: NullableXyz = {
+ x: target.x !== undefined ? target.x : from.x,
+ y: target.y !== undefined ? target.y : from.y,
+ z: target.z !== undefined ? target.z : from.z,
+ };
+ if (merged.x === null || merged.y === null || merged.z === null) {
+ return null;
+ }
+ return { x: merged.x, y: merged.y, z: merged.z };
+}
+
+export type PositionSource = 'echo' | 'heartbeat' | 'estimated';
+
+export interface PositionOfRecord {
+ machine: Xyz;
+ source: PositionSource;
+ /** When the engine judged the move arrived (ms epoch). */
+ at: number;
+ /** Direct-gcode sequence number of the move; any later gcode voids the record. */
+ sequence: number;
+ tool: string;
+ /**
+ * Where the machine was before the move that set this record. A status
+ * report still showing it was sampled before the move finished, whatever
+ * its timestamp says (job de932e286afd, 2026-09-05: a 4 mm hop's echo
+ * verified X174, the next beat - stamped 750 ms later - still read X178).
+ */
+ previousMachine: Xyz | null;
+}
+
+let record: PositionOfRecord | null = null;
+
+export function setPositionOfRecord(machine: Xyz, source: PositionSource, sequence: number, tool: string): PositionOfRecord {
+ const previousMachine = record ? { ...record.machine } : null;
+ record = {
+ machine: { x: machine.x, y: machine.y, z: machine.z },
+ source,
+ at: Date.now(),
+ sequence,
+ tool,
+ previousMachine,
+ };
+ return record;
+}
+
+/**
+ * The record, or null when gcode has gone to the machine since it was taken
+ * (`currentSequence` is the direct-gcode counter now).
+ */
+export function getPositionOfRecord(currentSequence: number): PositionOfRecord | null {
+ if (!record || record.sequence !== currentSequence) {
+ return null;
+ }
+ return record;
+}
+
+export function clearPositionOfRecord(): void {
+ record = null;
+}
+
+/** One heartbeat as the re-check sees it. */
+export interface BeatObservation {
+ /** When the machine state was received (ms epoch). */
+ reportTime: number;
+ raw: NullableXyz;
+ offset: Xyz;
+}
+
+export type RecheckVerdict =
+ | { verdict: 'pass'; frame: 'work-frame' | 'machine-frame' }
+ | { verdict: 'pass'; frame: 'record'; record: PositionOfRecord }
+ | { verdict: 'drift' }
+ | { verdict: 'undecided'; stale?: boolean };
+
+/** Two reports count as distinct beats only this far apart (the poll runs every 2 s). */
+export const MIN_DISTINCT_BEAT_MS = 1000;
+
+/** True when a report shows the position the machine held BEFORE the recorded move (either frame). */
+export function reportShowsPreviousPosition(beat: BeatObservation, rec: PositionOfRecord | null, toleranceMm: number): boolean {
+ if (!rec || !rec.previousMachine) {
+ return false;
+ }
+ return matchFrame(beat.raw, beat.offset, rec.previousMachine, toleranceMm) !== null;
+}
+
+/**
+ * Pure decision for a position re-check, given the beats seen so far (oldest
+ * first) and the position of record if one is valid:
+ * - the newest beat matching in either frame passes;
+ * - a record that matches passes while the newest beat still predates it
+ * (the heartbeat has not caught up with a move the controller confirmed);
+ * - a beat still showing where the machine was BEFORE the recorded move is
+ * stale whatever its timestamp says (the poll samples the machine before
+ * the response is processed): never evidence of drift - keep reading;
+ * - two DISTINCT consecutive beats (>= MIN_DISTINCT_BEAT_MS apart - the same
+ * beat read twice once looked like two through 1 ms of clock jitter) that
+ * agree with each other yet match neither frame mean the machine really is
+ * elsewhere;
+ * - anything else is undecided: keep reading (the caller's deadline aborts).
+ */
+export function judgeRecheck(
+ beats: BeatObservation[],
+ expected: Xyz,
+ toleranceMm: number,
+ validRecord: PositionOfRecord | null
+): RecheckVerdict {
+ if (!beats.length) {
+ return { verdict: 'undecided' };
+ }
+ const newest = beats[beats.length - 1];
+ const frame = matchFrame(newest.raw, newest.offset, expected, toleranceMm);
+ if (frame) {
+ return { verdict: 'pass', frame };
+ }
+ if (validRecord && newest.reportTime <= validRecord.at && nearMachine(validRecord.machine, expected, toleranceMm)) {
+ return { verdict: 'pass', frame: 'record', record: validRecord };
+ }
+ if (reportShowsPreviousPosition(newest, validRecord, toleranceMm)) {
+ return { verdict: 'undecided', stale: true };
+ }
+ if (beats.length >= 2) {
+ const previous = beats[beats.length - 2];
+ const distinct = Math.abs(newest.reportTime - previous.reportTime) >= MIN_DISTINCT_BEAT_MS;
+ const same = JSON.stringify([previous.raw, previous.offset]) === JSON.stringify([newest.raw, newest.offset]);
+ if (distinct && same && !reportShowsPreviousPosition(previous, validRecord, toleranceMm)) {
+ return { verdict: 'drift' };
+ }
+ }
+ return { verdict: 'undecided' };
+}
+
+/**
+ * Try several candidate offsets against one report; the first that yields a
+ * frame match wins. Used by the engine so its echo check does not depend on
+ * whatever offset the latest heartbeat happened to carry.
+ */
+export function matchFrameWithOffsets(
+ raw: NullableXyz,
+ offsets: Xyz[],
+ expected: Partial,
+ toleranceMm: number
+): { frame: 'work-frame' | 'machine-frame'; offset: Xyz } | null {
+ const seen = new Set();
+ for (const offset of offsets) {
+ const key = `${offset.x},${offset.y},${offset.z}`;
+ if (seen.has(key)) {
+ continue;
+ }
+ seen.add(key);
+ const frame = matchFrame(raw, offset, expected, toleranceMm);
+ if (frame) {
+ return { frame, offset };
+ }
+ }
+ return null;
+}
+
+// The engine's own trusted work-origin offset: the offset that last made a
+// controller echo (or a settled heartbeat) agree with a commanded machine
+// target. Independent of the per-beat heartbeat value, which reads (0,0,0)
+// or goes missing inside G53 windows (jobs 70b2b8c675a6 / 42df7b9351b7 and
+// the 28 residual settle-waits of the run after them, all zero-offset).
+let trustedOffset: Xyz | null = null;
+
+export function setTrustedOffset(offset: Xyz): void {
+ trustedOffset = { x: offset.x, y: offset.y, z: offset.z };
+}
+
+export function getTrustedOffset(): Xyz | null {
+ return trustedOffset;
+}
+
+// Direct-gcode activity, so the snapshot can tell a G53-window beat (a
+// command is in flight, or replied within the last couple of seconds - the
+// status poll's data may predate its processing) from a quiet beat.
+let directGcodeInFlight = 0;
+let lastDirectGcodeReplyAt: number | null = null;
+
+export function noteDirectGcodeStart(): void {
+ directGcodeInFlight += 1;
+}
+
+export function noteDirectGcodeEnd(): void {
+ directGcodeInFlight = Math.max(0, directGcodeInFlight - 1);
+ lastDirectGcodeReplyAt = Date.now();
+}
+
+/** True when no direct gcode is in flight and none replied within `quietMs`. */
+export function directGcodeQuiet(quietMs: number): boolean {
+ if (directGcodeInFlight > 0) {
+ return false;
+ }
+ return lastDirectGcodeReplyAt === null || Date.now() - lastDirectGcodeReplyAt >= quietMs;
+}
+
+export type OffsetSource = 'heartbeat' | 'cached' | 'assumed-zero';
+
+export interface OffsetJudgement {
+ offset: Xyz;
+ source: OffsetSource;
+ /** True when a complete all-zero report was set aside as a G53-window transient. */
+ transientZero: boolean;
+ /** The cache the caller should keep after this report. */
+ cache: Xyz | null;
+}
+
+/** Zero offsets must be reported on this many DISTINCT consecutive beats before a non-zero cache is replaced. */
+export const ZERO_OFFSET_ACCEPT_BEATS = 3;
+
+/**
+ * Resolve the origin offset to use from one status report. Facts behind it
+ * (all 2026-09-05): a beat inside a move's G53 window reports offsetX/Y/Z
+ * as (0, 0, 0) with pos in machine coordinates (job 70b2b8c675a6: all 84
+ * settle-waits carried offset 0,0,0; job 42df7b9351b7: the same beat made
+ * a Z320 toolhead read as Z-8 and skipped the fast descent); a beat can
+ * also carry no offset at all (job 44abebd9bab3). A genuine zero offset
+ * (work origin at machine zero) is legal but PERSISTS, so a zero that
+ * contradicts a non-zero offset seen on this connection is believed only
+ * after `zeroStreak` distinct beats in a row reached ZERO_OFFSET_ACCEPT_BEATS.
+ */
+export function judgeOffsetReport(reported: NullableXyz, cached: Xyz | null, zeroStreak: number): OffsetJudgement {
+ const complete = reported.x !== null && reported.y !== null && reported.z !== null;
+ const allZero = complete && reported.x === 0 && reported.y === 0 && reported.z === 0;
+ const cachedNonZero = cached !== null && (cached.x !== 0 || cached.y !== 0 || cached.z !== 0);
+ if (complete && allZero && cachedNonZero && zeroStreak < ZERO_OFFSET_ACCEPT_BEATS) {
+ return { offset: { ...(cached as Xyz) }, source: 'cached', transientZero: true, cache: cached };
+ }
+ if (complete) {
+ const offset = { x: reported.x as number, y: reported.y as number, z: reported.z as number };
+ return { offset, source: 'heartbeat', transientZero: false, cache: offset };
+ }
+ if (cached) {
+ return { offset: { ...cached }, source: 'cached', transientZero: false, cache: cached };
+ }
+ return {
+ offset: { x: reported.x || 0, y: reported.y || 0, z: reported.z || 0 },
+ source: 'assumed-zero',
+ transientZero: false,
+ cache: null,
+ };
+}
+
+/** Human-readable both-frame reading of a report, for abort messages. */
+export function describeReport(raw: NullableXyz, offset: Xyz, offsetSource: string): string {
+ const w = machineFromReport(raw, offset, 'work-frame');
+ return `raw (${raw.x}, ${raw.y}, ${raw.z}) with offset (${offset.x}, ${offset.y}, ${offset.z}) from ${offsetSource}`
+ + ` -> as work-frame machine (${w.x}, ${w.y}, ${w.z}); as machine-frame (${raw.x}, ${raw.y}, ${raw.z})`;
+}
diff --git a/src/server/services/mcp/probeCircle.ts b/src/server/services/mcp/probeCircle.ts
index 8403e57cd5..1897506363 100644
--- a/src/server/services/mcp/probeCircle.ts
+++ b/src/server/services/mcp/probeCircle.ts
@@ -11,6 +11,7 @@ import {
TRAVEL_FEED,
assertChannelReady,
assertMachineReadyForProcedure,
+ descendInSegments,
moveMachineSettled,
senseAfter,
senseReleaseAfter,
@@ -216,7 +217,7 @@ export function describeProbeCirclePlanAsGcode(plan: ProbeCirclePlan): string {
if (!plan.inside) {
lines.push(`G1 Z${plan.hopZ.toFixed(3)} F${TRAVEL_FEED}; lift to safe traverse height`);
lines.push(`G1 X${point.startXY.x.toFixed(3)} Y${point.startXY.y.toFixed(3)} F${TRAVEL_FEED}; approach start`);
- lines.push(`G1 Z${(plan.probeZ + DESCENT_GUARD_MM).toFixed(3)} F${TRAVEL_FEED}; descend fast to ${DESCENT_GUARD_MM} mm above probing depth`);
+ lines.push(`G1 Z${(plan.probeZ + DESCENT_GUARD_MM).toFixed(3)} F${TRAVEL_FEED}; descend (<= 5 mm segments (crash guard armed)) to ${DESCENT_GUARD_MM} mm above probing depth`);
lines.push(`; ...guarded final approach: ${DESCENT_GUARD_MM} x 1 mm sensor-checked steps to Z${plan.probeZ.toFixed(3)} -`);
lines.push(`G1 Z${plan.probeZ.toFixed(3)} F${COARSE_FEED}; ANY contact during descent aborts + latches CRASH`);
}
@@ -349,7 +350,9 @@ export async function runProbeCircleProcedure(plan: ProbeCirclePlan): Promise 1e-9) {
const t0 = Date.now();
diff --git a/src/server/services/mcp/probeFeed.ts b/src/server/services/mcp/probeFeed.ts
index 369f045514..7e1b7869e8 100644
--- a/src/server/services/mcp/probeFeed.ts
+++ b/src/server/services/mcp/probeFeed.ts
@@ -7,7 +7,7 @@ import { connectionManager } from '../machine/ConnectionManager';
import { GpioProbeTransport, describePin, resolveGpioFeedConfig } from './gpioFeed';
import { mcpBroadcast } from './index';
import { MqttClient } from './mqtt';
-import { PROBE_CHANNELS, ProbeChannel, ProbeTransport, ProbeTransportKind } from './probeTransport';
+import { PROBE_CHANNELS, ProbeChannel, ProbeTransport, ProbeTransportKind, ReadingMeta } from './probeTransport';
import { McpToolError } from './registry';
const log = logger('service:mcp:probe-feed');
@@ -681,7 +681,7 @@ export class ProbeFeedService {
const transport = buildTransport(cfg.kind);
this.transport = transport;
- transport.on('reading', (channel: ProbeChannel, value: string) => this.onReading(channel, value, false));
+ transport.on('reading', (channel: ProbeChannel, value: string, meta?: ReadingMeta) => this.onReading(channel, value, false, meta));
transport.on('refresh', (channel: ProbeChannel, value: string) => this.onReading(channel, value, true));
transport.on('error', (err: Error) => {
// An unplugged sensor bridge produces the same error on every
@@ -743,7 +743,7 @@ export class ProbeFeedService {
}, delay);
}
- private onReading(channel: ProbeChannel, value: string, refreshOnly: boolean): void {
+ private onReading(channel: ProbeChannel, value: string, refreshOnly: boolean, meta?: ReadingMeta): void {
const cfg = this.activeConfig;
if (!cfg) {
return;
@@ -765,14 +765,18 @@ export class ProbeFeedService {
source: cfg.channels[channel] || channel,
};
this.readings.set(channel, reading);
+ // Transport -> server latency when the transport stamped the reading
+ // (GPIO monitor wall clock); part of the sensor-timing evidence.
+ const pipeMs = meta && meta.sentAt ? Math.max(0, reading.receivedAt - meta.sentAt) : undefined;
mcpBroadcast('mcp:activity', {
tool: 'probe_feed',
phase: 'reading',
channel,
value,
triggered: reading.triggered,
+ pipeMs,
});
- log.info(`reading ${channel}=${value} triggered=${reading.triggered}`);
+ log.info(`reading ${channel}=${value} triggered=${reading.triggered}${pipeMs === undefined ? '' : ` pipe=${pipeMs}ms`}`);
if (!this.trip && reading.triggered) {
if (channel === 'overtravel') {
// Operator decision (2026-09-04): the overtravel tripwire is
diff --git a/src/server/services/mcp/probeSequence.ts b/src/server/services/mcp/probeSequence.ts
index 450c0369b5..8b7191594e 100644
--- a/src/server/services/mcp/probeSequence.ts
+++ b/src/server/services/mcp/probeSequence.ts
@@ -10,7 +10,11 @@ import {
ProcedureAbort,
TRAVEL_FEED,
assertChannelReady,
+ RECHECK_TOLERANCE_MM,
assertMachineReadyForProcedure,
+ descendInSegments,
+ expectMachinePosition,
+ knownMachinePosition,
moveMachineSettled,
senseAfter,
senseReleaseAfter,
@@ -173,7 +177,7 @@ export function planProbeSequence(args: {
return {
steps,
- coarseStepMm: Math.min(Math.max(Number(args.coarse_step_mm) || 1, 0.2), 2),
+ coarseStepMm: Math.min(Math.max(Number(args.coarse_step_mm) || 1, 0.2), 1), // operator law 2026-09-05: never 2 mm
fineStepMm: Math.min(Math.max(Number(args.fine_step_mm) || 0.1, 0.02), 0.5),
backoffMm: Math.min(Math.max(Number(args.backoff_mm) || 1, Number(args.fine_step_mm) || 0.1), 3),
sensorDelayMs: Math.min(Math.max(Number(args.sensor_delay_ms) || 300, 100), 10000),
@@ -226,7 +230,7 @@ export function describeProbeSequencePlanAsGcode(plan: ProbeSequencePlan): strin
lines.push(`G1 Z${plan.hopZ.toFixed(3)} F${TRAVEL_FEED}; raise to traverse height (law 2)`);
lines.push(`G1 X${step.x.toFixed(3)} Y${step.y.toFixed(3)} F${TRAVEL_FEED}; hop`);
} else if (step.kind === 'descend') {
- lines.push(`G1 Z${(step.z + DESCENT_GUARD_MM).toFixed(3)} F${TRAVEL_FEED}; descend fast to ${DESCENT_GUARD_MM} mm above target`);
+ lines.push(`G1 Z${(step.z + DESCENT_GUARD_MM).toFixed(3)} F${TRAVEL_FEED}; descend (<= 5 mm segments (crash guard armed)) to ${DESCENT_GUARD_MM} mm above target`);
lines.push('; ...guarded final approach (operator, 2026-09-02): 1 mm steps, sensor-checked after each -');
lines.push('; ANY contact during a descent aborts and latches the CRASH alarm.');
for (let gz = step.z + DESCENT_GUARD_MM - 1; gz > step.z - 1e-9; gz -= 1) {
@@ -260,10 +264,6 @@ export function describeProbeSequencePlanAsGcode(plan: ProbeSequencePlan): strin
return lines.join('\n');
}
-const sleep = async (ms: number) => new Promise((resolve) => {
- setTimeout(resolve, ms);
-});
-
export async function runProbeSequenceProcedure(plan: ProbeSequencePlan): Promise {
assertChannelReady('probe', 'probe sequence');
assertMachineReadyForProcedure();
@@ -305,9 +305,18 @@ export async function runProbeSequenceProcedure(plan: ProbeSequencePlan): Promis
} else if (step.kind === 'descend') {
probeFeedService.clearExpectedContact();
const guardTop = step.z + DESCENT_GUARD_MM;
- const zNow = getPositionSnapshot().machine.z;
+ // Verified position (record) over a single heartbeat - see
+ // probing.knownMachinePosition (job 42df7b9351b7).
+ const known = knownMachinePosition();
+ const zNow = known.position.z;
+ if (zNow !== null && zNow < step.z - RECHECK_TOLERANCE_MM) {
+ throw new ProcedureAbort(`Descend step ${stepIndex}: the toolhead is at machine Z${zNow} (${known.source}), BELOW the `
+ + `planned descent target Z${step.z} - a descent never rises. Re-stage from a verified position.`);
+ }
if (zNow !== null && zNow > guardTop + 1e-9) {
- await moveMachineSettled(`seq:descend:${stepIndex}`, { z: guardTop }, TRAVEL_FEED);
+ // Operator law 2026-09-05: descents in <= 5 mm sensor-checked
+ // segments, never one long move toward the work.
+ await descendInSegments(`seq:descend:${stepIndex}`, zNow, guardTop, 'probe', plan.sensorDelayMs);
}
let gz = Math.min(zNow === null ? guardTop : Math.max(zNow, step.z), guardTop);
while (gz - step.z > 1e-9) {
@@ -323,30 +332,14 @@ export async function runProbeSequenceProcedure(plan: ProbeSequencePlan): Promis
announce(`descend-${stepIndex}`, `Z${step.z} (guarded final ${DESCENT_GUARD_MM} mm)`);
} else {
// Re-verify the walk matches the simulation before marching.
- // Every preceding move already verified its own arrival, so a
- // mismatch here is either real drift or a transient heartbeat
- // (a beat inside a G53...G54 window reporting no/zero origin
- // offset - job 44abebd9bab3, 2026-09-05). Re-read once after a
- // heartbeat period before believing it.
- const expected = step.start;
- const matches = (p: { x: number | null; y: number | null; z: number | null }) => (
- p.x !== null && p.y !== null && p.z !== null
- && Math.abs(p.x - expected.x) <= 0.5
- && Math.abs(p.y - expected.y) <= 0.5
- && Math.abs(p.z - expected.z) <= 0.5
- );
- let snapshot = getPositionSnapshot();
- if (!matches(snapshot.machine)) {
- const first = snapshot;
- await sleep(1200);
- snapshot = getPositionSnapshot();
- if (!matches(snapshot.machine)) {
- const fmt = (s: typeof snapshot) => `(${s.machine.x}, ${s.machine.y}, ${s.machine.z}) [work (${s.work.x}, ${s.work.y}, ${s.work.z}), offset (${s.originOffset.x}, ${s.originOffset.y}, ${s.originOffset.z}) from ${s.originOffsetSource}]`;
- throw new ProcedureAbort(`March "${step.name}": machine at ${fmt(snapshot)} `
- + `(first read ${fmt(first)}) but the plan expects `
- + `(${expected.x}, ${expected.y}, ${expected.z}).`);
- }
- announce(`recheck-${stepIndex}`, 'position re-check passed on the second heartbeat (first read was transient)');
+ // Every preceding move already verified its own arrival, so
+ // the engine's position of record and an either-frame reading
+ // of the heartbeat decide (positionOfRecord.ts; jobs
+ // 44abebd9bab3 and 1db4902a4cd6, 2026-09-05).
+ const check = await expectMachinePosition(step.start, `March "${step.name}"`,
+ (message) => new ProcedureAbort(message));
+ if (check.note) {
+ announce(`recheck-${stepIndex}`, check.note);
}
probeFeedService.setExpectedContact(['probe']);
const move = async (tool: string, s: number, feed: number) => {
diff --git a/src/server/services/mcp/probeSurface.ts b/src/server/services/mcp/probeSurface.ts
index 6a55ab6d19..83ae314ca2 100644
--- a/src/server/services/mcp/probeSurface.ts
+++ b/src/server/services/mcp/probeSurface.ts
@@ -11,11 +11,15 @@ import {
ProcedureAbort,
TRAVEL_FEED,
assertChannelReady,
+ RECHECK_TOLERANCE_MM,
+ DESCENT_SEGMENT_MM,
assertMachineReadyForProcedure,
+ descendInSegments,
+ expectMachinePosition,
+ knownMachinePosition,
moveMachineSettled,
senseAfter,
senseReleaseAfter,
- sleep,
} from './probing';
import { McpToolError } from './registry';
import {
@@ -25,6 +29,7 @@ import {
SurfaceStation,
assertHopsWithin,
buildZMatrix,
+ coarseStepFor,
fitLine,
fitPlane,
hopSegments,
@@ -33,6 +38,7 @@ import {
renderHeightMap,
renderPathProfile,
resolveEnvelope,
+ slowZoneFor,
stationEnvelope,
summarizeZ,
} from './surfaceScan';
@@ -92,6 +98,10 @@ export interface ProbeSurfacePlan {
backoffMm: number;
sensorDelayMs: number;
confirmPasses: number;
+ /** Coarse steps stop this far above the expected contact and fine steps take over (caps the press at one fine step). */
+ slowZoneMm: number;
+ /** Expected contact Z for station 1 (a measured neighbour); null = coarse capped at 1 mm there. */
+ expectedZMachine: number | null;
path?: {
start: { x: number; y: number };
end: { x: number; y: number };
@@ -119,6 +129,8 @@ interface CommonArgs {
backoff_mm?: number;
sensor_delay_ms?: number;
confirm_passes?: number;
+ slow_zone_mm?: number;
+ expected_z_machine?: unknown;
}
function toToolError(fn: () => T): T {
@@ -176,6 +188,18 @@ function finishPlan(
throw new McpToolError('Current machine position unknown; cannot anchor the scan.');
}
+ let expectedZ: number | null = null;
+ if (args.expected_z_machine !== undefined && args.expected_z_machine !== null && args.expected_z_machine !== '') {
+ expectedZ = Number(args.expected_z_machine);
+ if (!Number.isFinite(expectedZ)) {
+ throw new McpToolError('expected_z_machine must be a number (toolhead machine Z of a measured neighbouring contact).');
+ }
+ if (expectedZ > startZ + 1e-9 || expectedZ < floorZ - 1e-9) {
+ throw new McpToolError(`expected_z_machine ${expectedZ} must lie between floor_z_machine ${floorZ.toFixed(3)} and start_z_machine ${startZ.toFixed(3)}.`);
+ }
+ expectedZ = Number(expectedZ.toFixed(3));
+ }
+
return {
kind,
tool: kind === 'path' ? 'probe_surface_path' : 'probe_surface_grid',
@@ -188,11 +212,18 @@ function finishPlan(
worstHopMm,
hopZ,
staged: { x, y, z },
- coarseStepMm: Math.min(Math.max(Number(args.coarse_step_mm) || 1, 0.2), 2),
+ // Operator (2026-09-05, job cdbc29371b97): never 2 mm - the coarse
+ // step is also the press into the probe wherever it finds the
+ // surface. 1 mm max; 0.5 when the step cadence can carry it.
+ coarseStepMm: Math.min(Math.max(Number(args.coarse_step_mm) || 1, 0.5), 1),
fineStepMm: Math.min(Math.max(Number(args.fine_step_mm) || 0.1, 0.02), 0.5),
backoffMm: Math.min(Math.max(Number(args.backoff_mm) || 1, Number(args.fine_step_mm) || 0.1), 3),
- sensorDelayMs: Math.min(Math.max(Number(args.sensor_delay_ms) || 300, 100), 10000),
+ // Floor 30 ms: on the GPIO transport the trigger led the controller
+ // reply on every contact of jobs 1db4/d8f6 (tightest lead 5 ms).
+ sensorDelayMs: Math.min(Math.max(Number(args.sensor_delay_ms) || 300, 30), 10000),
confirmPasses: Math.min(Math.max(Math.round(Number(args.confirm_passes) || 3), 1), 10),
+ slowZoneMm: Math.min(Math.max(Number(args.slow_zone_mm) || 1, 0.3), env.zSafeDeltaMm),
+ expectedZMachine: expectedZ,
};
}
@@ -264,8 +295,13 @@ export function describeProbeSurfacePlanAsGcode(plan: ProbeSurfacePlan): string
const guardTop = Math.min(plan.startZMachine + DESCENT_GUARD_MM, plan.hopZ);
const lines = [
`; SURFACE ${plan.kind.toUpperCase()} SCAN: ${shape}`,
- '; every station = a -Z sensor-gated march on the probe channel (coarse to contact, release,',
- `; ${plan.fineStepMm} mm fine approach, ${plan.confirmPasses} confirm pass(es), median); all coordinates MACHINE frame.`,
+ '; every station = a -Z sensor-gated march on the probe channel (coarse towards the expected contact,',
+ `; ${plan.fineStepMm} mm fine steps inside the SLOW ZONE, ${plan.confirmPasses} confirm pass(es), median); all coordinates MACHINE frame.`,
+ `; SLOW ZONE (slow_zone_mm ${plan.slowZoneMm}): coarse steps stop ${plan.slowZoneMm} mm ABOVE the expected contact (the previous`,
+ `; station's contact; expected_z_machine ${plan.expectedZMachine === null ? 'not given' : `Z${plan.expectedZMachine}`} for station 1) and`,
+ `; ${plan.fineStepMm} mm steps take over down to ${(plan.slowZoneMm + 2 * plan.coarseStepMm).toFixed(1)} mm BELOW it (coarse resumes below that).`,
+ `; Worst press into the probe: ${plan.fineStepMm} mm where the surface lies in the zone; one coarse step (${plan.coarseStepMm} mm)`,
+ `; where it is higher; station 1 without expected_z_machine uses ${coarseStepFor(plan, plan.expectedZMachine !== null)} mm coarse steps (cap 1).`,
`; anchored at machine (${plan.staged.x.toFixed(2)}, ${plan.staged.y.toFixed(2)}, ${plan.staged.z.toFixed(2)})`
+ ' - re-verified before any motion, and before EVERY march',
';',
@@ -273,7 +309,7 @@ export function describeProbeSurfacePlanAsGcode(plan: ProbeSurfacePlan): string
'; change of 60mm)") - the operator-authorised EXCEPTION to motion law 2, valid ONLY inside this',
'; procedure, ONLY between consecutive stations, ONLY for hops <= max_hop_mm:',
`; * between stations the probe retracts to LAST CONTACT + ${plan.zSafeDeltaMm} mm (z_safe_delta_mm, cap 20) and hops`,
- `; horizontally AT THAT HEIGHT in <= ${HOP_SEGMENT_MM} mm sensor-checked segments - ANY probe contact during a hop`,
+ `; horizontally AT THAT HEIGHT in <= ${HOP_SEGMENT_MM} mm segments (crash guard armed) - ANY probe contact during a hop`,
'; is a collision: CRASH alarm latches (job stop + connection close), operator clears it.',
`; * largest hop in this plan: ${plan.worstHopMm} mm (max_hop_mm ${plan.maxHopMm}, cap 60) - refused at staging otherwise.`,
`; * each march searches from the hop height down to max(last contact - ${plan.maxDropMm} mm, FLOOR Z${plan.absoluteFloorZ});`,
@@ -286,21 +322,33 @@ export function describeProbeSurfacePlanAsGcode(plan: ProbeSurfacePlan): string
'G53;',
`G1 Z${plan.hopZ.toFixed(3)} F${TRAVEL_FEED}; raise to the safe traverse height (law 2)`,
`G1 X${first.x.toFixed(3)} Y${first.y.toFixed(3)} F${TRAVEL_FEED}; hop at gantry height to station 1 "${first.label}"`,
- `G1 Z${guardTop.toFixed(3)} F${TRAVEL_FEED}; descend fast to ${(guardTop - plan.startZMachine).toFixed(1)} mm above start_z_machine`,
+ `G1 Z${guardTop.toFixed(3)} F${TRAVEL_FEED}; descend to ${(guardTop - plan.startZMachine).toFixed(1)} mm above start_z_machine in <= ${DESCENT_SEGMENT_MM} mm segments (crash guard armed)`,
'; ...guarded final approach: 1 mm steps, sensor-checked after each - ANY contact here aborts (CRASH).',
];
for (let gz = guardTop - 1; gz > plan.startZMachine - 1e-9; gz -= 1) {
lines.push(`G1 Z${Math.max(gz, plan.startZMachine).toFixed(3)} F${COARSE_FEED}; guarded descent step`);
}
lines.push(`; --- station 1 "${first.label}" (${first.x}, ${first.y}): march -Z from Z${firstEnv.marchStartZ} to floor Z${firstEnv.floorZ} ---`);
+ const firstCoarse = coarseStepFor(plan, plan.expectedZMachine !== null);
+ const firstZone = slowZoneFor(firstEnv.marchStartZ, plan.expectedZMachine, plan.slowZoneMm, plan.coarseStepMm, firstEnv.travelMm);
let s = 0;
let k = 0;
while (firstEnv.travelMm - s > 1e-9) {
- s = Math.min(s + plan.coarseStepMm, firstEnv.travelMm);
+ if (firstZone && s >= firstZone.topS - 1e-9 && s < firstZone.bottomS - 1e-9) {
+ lines.push(`; ...slow zone Z${(firstEnv.marchStartZ - firstZone.topS).toFixed(3)} -> Z${(firstEnv.marchStartZ - firstZone.bottomS).toFixed(3)}: `
+ + `${plan.fineStepMm} mm steps at F${FINE_FEED}, check probe after each`);
+ s = firstZone.bottomS;
+ continue;
+ }
+ let next = Math.min(s + firstCoarse, firstEnv.travelMm);
+ if (firstZone && s < firstZone.topS - 1e-9 && next > firstZone.topS + 1e-9) {
+ next = firstZone.topS;
+ }
+ s = next;
k += 1;
lines.push(`G1 Z${(firstEnv.marchStartZ - s).toFixed(3)} F${COARSE_FEED}; coarse ${k} - settle, check probe, stop at contact`);
}
- lines.push(`; ...on contact: release, ${plan.fineStepMm} mm fine approach, ${plan.confirmPasses} confirm cycle(s) (lift ${plan.backoffMm} mm)`);
+ lines.push(`; ...on coarse contact: release, ${plan.fineStepMm} mm fine approach; then ${plan.confirmPasses} confirm cycle(s) (lift ${plan.backoffMm} mm)`);
lines.push(`; retract to contact + ${plan.zSafeDeltaMm} mm (runtime number, never above Z${plan.hopZ})`);
for (let i = 1; i < n; i++) {
const prev = plan.stations[i - 1];
@@ -310,7 +358,8 @@ export function describeProbeSurfacePlanAsGcode(plan: ProbeSurfacePlan): string
segs.forEach((seg, j) => {
lines.push(`G1 X${seg.x.toFixed(3)} Y${seg.y.toFixed(3)} F${TRAVEL_FEED}; hop segment ${j + 1}/${segs.length} - settle, probe must NOT be in contact`);
});
- lines.push(`; march -Z in ${plan.coarseStepMm} mm steps from the hop height to max(last contact - ${plan.maxDropMm}, Z${plan.absoluteFloorZ})`);
+ lines.push(`; march -Z in ${plan.coarseStepMm} mm steps from the hop height to last contact + ${plan.slowZoneMm}, then ${plan.fineStepMm} mm steps `
+ + `through the slow zone; coarse again below it to max(last contact - ${plan.maxDropMm}, Z${plan.absoluteFloorZ})`);
lines.push(`G1 Z${plan.absoluteFloorZ.toFixed(3)} F${COARSE_FEED}; deepest allowed at this station (absolute floor) - no contact by here = no_contact`);
lines.push(`; ...on contact: release, fine, confirm; retract to contact + ${plan.zSafeDeltaMm} mm`);
}
@@ -334,6 +383,10 @@ export interface SurfaceStationResult {
floorZ: number;
confirmPassContacts?: number[];
spreadMm?: number;
+ /** How the contact was found: fine steps inside the slow zone, or a coarse step (then release + fine). */
+ approach?: 'slow-zone' | 'coarse-contact';
+ /** Upper bound on how far the probe was pressed past contact by the step that found it. */
+ worstPressMm?: number;
}
/**
@@ -347,8 +400,9 @@ async function marchDownZ(
station: SurfaceStation,
startZ: number,
floorZ: number,
+ expectedContactZ: number | null,
announce: (phase: string, note?: string) => void
-): Promise<{ contactZ: number; passContacts: number[]; spreadMm: number } | null> {
+): Promise<{ contactZ: number; passContacts: number[]; spreadMm: number; approach: 'slow-zone' | 'coarse-contact'; worstPressMm: number } | null> {
const travel = Number((startZ - floorZ).toFixed(3));
const releaseTimeoutMs = Math.max(plan.sensorDelayMs * 4, 3500);
const zAt = (s: number) => Number((startZ - s).toFixed(3));
@@ -356,50 +410,81 @@ async function marchDownZ(
await moveMachineSettled(tool, { z: zAt(s) }, feed);
};
const tag = `${plan.tool}:${station.label}`;
+ const coarseStep = coarseStepFor(plan, expectedContactZ !== null);
+ const zone = slowZoneFor(startZ, expectedContactZ, plan.slowZoneMm, plan.coarseStepMm, travel);
+ if (zone) {
+ announce(`slow-zone-${station.label}`, `fine ${plan.fineStepMm} mm steps between Z${zAt(zone.topS)} and Z${zAt(zone.bottomS)} `
+ + `(expected contact Z${expectedContactZ} +${plan.slowZoneMm} / -${(plan.slowZoneMm + 2 * plan.coarseStepMm).toFixed(1)}); coarse ${coarseStep} mm elsewhere`);
+ } else {
+ announce(`coarse-ladder-${station.label}`, `${coarseStep} mm steps to contact (no expected contact to bound the press)`);
+ }
+ const inZone = (sv: number) => zone !== null && sv >= zone.topS - 1e-9 && sv < zone.bottomS - 1e-9;
let s = 0;
let coarseContactS: number | null = null;
+ let fineContactS: number | null = null;
while (travel - s > 1e-9) {
const t0 = Date.now();
- s = Math.min(s + plan.coarseStepMm, travel);
- await move(`${tag}:coarse`, s, COARSE_FEED);
+ const fine = inZone(s);
+ let next: number;
+ if (fine) {
+ next = Math.min(s + plan.fineStepMm, travel, (zone as { bottomS: number }).bottomS);
+ } else {
+ next = Math.min(s + coarseStep, travel);
+ if (zone && s < zone.topS - 1e-9 && next > zone.topS + 1e-9) {
+ next = zone.topS; // a coarse step never crosses into the slow zone
+ }
+ }
+ s = next;
+ await move(fine ? `${tag}:fine` : `${tag}:coarse`, s, fine ? FINE_FEED : COARSE_FEED);
const sensed = await senseAfter('probe', t0, plan.sensorDelayMs);
if (sensed.contact) {
- coarseContactS = s;
- announce(`coarse-contact-${station.label}`, `Z${zAt(s)}`);
+ if (fine) {
+ fineContactS = s;
+ announce(`fine-contact-${station.label}`, `Z${zAt(s)} inside the slow zone (press <= ${plan.fineStepMm} mm)`);
+ } else {
+ coarseContactS = s;
+ announce(`coarse-contact-${station.label}`, `Z${zAt(s)} (press <= ${coarseStep} mm)`);
+ }
break;
}
}
- if (coarseContactS === null) {
+ if (coarseContactS === null && fineContactS === null) {
return null;
}
- let released = false;
- while (s > 1e-9 && coarseContactS - s < MAX_RETREAT_MM + 1e-9) {
- const t0 = Date.now();
- s = Math.max(s - plan.coarseStepMm, 0);
- await move(`${tag}:release`, s, COARSE_FEED);
- const sensed = await senseReleaseAfter('probe', t0, releaseTimeoutMs);
- if (!sensed.contact) {
- released = true;
- break;
+ const approach: 'slow-zone' | 'coarse-contact' = fineContactS !== null ? 'slow-zone' : 'coarse-contact';
+ const worstPressMm = fineContactS !== null ? plan.fineStepMm : coarseStep;
+ if (fineContactS === null) {
+ // Coarse contact (surface above the slow zone, or no zone): release
+ // by coarse steps until the probe reads clear, then fine-step back.
+ const contactS = coarseContactS as number;
+ let released = false;
+ while (s > 1e-9 && contactS - s < MAX_RETREAT_MM + 1e-9) {
+ const t0 = Date.now();
+ s = Math.max(s - coarseStep, 0);
+ await move(`${tag}:release`, s, COARSE_FEED);
+ const sensed = await senseReleaseAfter('probe', t0, releaseTimeoutMs);
+ if (!sensed.contact) {
+ released = true;
+ break;
+ }
}
- }
- if (!released) {
- throw new ProcedureAbort(`Station "${station.label}": probe still triggered ${MAX_RETREAT_MM} mm back from first contact - stuck probe or feed fault.`);
- }
- let fineContactS: number | null = null;
- while (travel - s > 1e-9) {
- const t0 = Date.now();
- s = Math.min(s + plan.fineStepMm, travel);
- await move(`${tag}:fine`, s, FINE_FEED);
- const sensed = await senseAfter('probe', t0, plan.sensorDelayMs);
- if (sensed.contact) {
- fineContactS = s;
- break;
+ if (!released) {
+ throw new ProcedureAbort(`Station "${station.label}": probe still triggered ${MAX_RETREAT_MM} mm back from first contact - stuck probe or feed fault.`);
+ }
+ while (travel - s > 1e-9) {
+ const t0 = Date.now();
+ s = Math.min(s + plan.fineStepMm, travel);
+ await move(`${tag}:fine`, s, FINE_FEED);
+ const sensed = await senseAfter('probe', t0, plan.sensorDelayMs);
+ if (sensed.contact) {
+ fineContactS = s;
+ break;
+ }
+ }
+ if (fineContactS === null) {
+ throw new ProcedureAbort(`Station "${station.label}": fine approach lost the contact.`);
}
- }
- if (fineContactS === null) {
- throw new ProcedureAbort(`Station "${station.label}": fine approach lost the contact.`);
}
const passContacts: number[] = [];
const cycleLimit = Math.min(fineContactS + Math.max(0.5, plan.backoffMm), travel);
@@ -432,7 +517,7 @@ async function marchDownZ(
const sorted = [...passContacts].sort((a, b) => a - b);
const contactZ = sorted[Math.floor((sorted.length - 1) / 2)];
const spreadMm = Number((sorted[sorted.length - 1] - sorted[0]).toFixed(3));
- return { contactZ, passContacts, spreadMm };
+ return { contactZ, passContacts, spreadMm, approach, worstPressMm };
}
/** Structured procedure result (lands on job.result): stations, statistics, renderings. */
@@ -511,36 +596,20 @@ export async function runProbeSurfaceProcedure(plan: ProbeSurfacePlan): Promise<
mcpBroadcast('mcp:activity', { tool: plan.tool, phase, note });
};
- // Position checks: every preceding move verified its own arrival, so a
- // mismatch is drift or a transient heartbeat (a beat inside a G53...G54
- // window carrying no origin offset - job 44abebd9bab3, 2026-09-05).
- // Re-read once after a heartbeat period before believing it.
- const fmt = (sn: ReturnType) => `(${sn.machine.x}, ${sn.machine.y}, ${sn.machine.z}) `
- + `[work (${sn.work.x}, ${sn.work.y}, ${sn.work.z}), offset (${sn.originOffset.x}, ${sn.originOffset.y}, ${sn.originOffset.z}) `
- + `from ${sn.originOffsetSource}]`;
+ // Position checks: every preceding move verified its own arrival, so the
+ // engine's position of record and an either-frame reading of the
+ // heartbeat decide (positionOfRecord.ts). Job 1db4902a4cd6 (2026-09-05)
+ // aborted here on one lagging beat and one G53-window beat while the
+ // controller had echoed both hop segments at their targets.
const expectPosition = async (
expected: { x: number; y: number; z: number },
what: string,
onMismatch: (message: string) => Error
) => {
- const matches = (p: { x: number | null; y: number | null; z: number | null }) => (
- p.x !== null && p.y !== null && p.z !== null
- && Math.abs(p.x - expected.x) <= 0.5
- && Math.abs(p.y - expected.y) <= 0.5
- && Math.abs(p.z - expected.z) <= 0.5
- );
- let snapshot = getPositionSnapshot();
- if (matches(snapshot.machine)) {
- return;
- }
- const firstRead = snapshot;
- await sleep(1200);
- snapshot = getPositionSnapshot();
- if (!matches(snapshot.machine)) {
- throw onMismatch(`${what}: machine at ${fmt(snapshot)} (first read ${fmt(firstRead)}) but the plan expects `
- + `(${expected.x}, ${expected.y}, ${expected.z}).`);
+ const check = await expectMachinePosition(expected, what, onMismatch);
+ if (check.note) {
+ announce('position-recheck', `${what}: ${check.note}`);
}
- announce('position-recheck', `${what}: passed on the second heartbeat (first read was transient)`);
};
await expectPosition(plan.staged, 'staged position', (message) => new McpToolError(
@@ -562,9 +631,21 @@ export async function runProbeSurfaceProcedure(plan: ProbeSurfacePlan): Promise<
await moveMachineSettled(`${plan.tool}:traverse`, { x: first.x, y: first.y }, TRAVEL_FEED);
announce('traverse', `(${first.x}, ${first.y}) at Z${plan.hopZ} (law 2)`);
const guardTop = plan.startZMachine + DESCENT_GUARD_MM;
- const zNow = getPositionSnapshot().machine.z;
- if (zNow !== null && zNow > guardTop + 1e-9) {
- await moveMachineSettled(`${plan.tool}:descend`, { z: guardTop }, TRAVEL_FEED);
+ // The verified position (traverse echo) decides the descent, never a
+ // single heartbeat - job 42df7b9351b7 (2026-09-05) read a zero-offset
+ // beat as Z-8 here, skipped this move and aborted at the station check.
+ const known = knownMachinePosition();
+ const zNow = known.position.z;
+ if (zNow === null) {
+ throw new ProcedureAbort('Machine Z unknown before the descent to station 1 - refusing to guess.');
+ }
+ if (zNow < plan.startZMachine - RECHECK_TOLERANCE_MM) {
+ throw new ProcedureAbort(`The toolhead is at machine Z${zNow} (${known.source}), BELOW start_z_machine Z${plan.startZMachine} `
+ + '- the approach to station 1 must descend, never rise into the surface. Re-stage from a verified position.');
+ }
+ announce('descend-from', `Z${zNow} (${known.source}) to guard top Z${guardTop} in <= ${DESCENT_SEGMENT_MM} mm segments (crash guard armed)`);
+ if (zNow > guardTop + 1e-9) {
+ await descendInSegments(`${plan.tool}:descend`, zNow, guardTop, 'probe', plan.sensorDelayMs);
}
let gz = Math.min(zNow === null ? guardTop : Math.max(zNow, plan.startZMachine), guardTop);
while (gz - plan.startZMachine > 1e-9) {
@@ -614,7 +695,10 @@ export async function runProbeSurfaceProcedure(plan: ProbeSurfacePlan): Promise<
(message) => new ProcedureAbort(message));
probeFeedService.setExpectedContact(['probe']);
- const outcome = await marchDownZ(plan, station, marchStartZ, env.floorZ, announce);
+ // Expected contact for the slow zone: the previous real contact,
+ // or the caller's expected_z_machine for station 1.
+ const expectedContact = isFirst ? plan.expectedZMachine : reference;
+ const outcome = await marchDownZ(plan, station, marchStartZ, env.floorZ, expectedContact, announce);
let retractTo: number;
if (outcome === null) {
if (reference === null) {
@@ -652,9 +736,12 @@ export async function runProbeSurfaceProcedure(plan: ProbeSurfacePlan): Promise<
floorZ: env.floorZ,
confirmPassContacts: outcome.passContacts,
spreadMm: outcome.spreadMm,
+ approach: outcome.approach,
+ worstPressMm: outcome.worstPressMm,
});
retractTo = hopHeightFor(outcome.contactZ);
- announce(`measured-${station.label}`, `(${station.x}, ${station.y}, ${outcome.contactZ}) spread ${outcome.spreadMm}`);
+ announce(`measured-${station.label}`, `(${station.x}, ${station.y}, ${outcome.contactZ}) spread ${outcome.spreadMm}, `
+ + `${outcome.approach}, press <= ${outcome.worstPressMm} mm`);
}
// Retract to the hop height (contact still expected while leaving
diff --git a/src/server/services/mcp/probeTool.ts b/src/server/services/mcp/probeTool.ts
index 2fd43c8657..756b911131 100644
--- a/src/server/services/mcp/probeTool.ts
+++ b/src/server/services/mcp/probeTool.ts
@@ -95,7 +95,7 @@ export function planProbePoint(args: {
start: { x, y, z },
maxTravelMm,
limitCoord: Number(limitCoord.toFixed(3)),
- coarseStepMm: Math.min(Math.max(Number(args.coarse_step_mm) || 1, 0.2), 2),
+ coarseStepMm: Math.min(Math.max(Number(args.coarse_step_mm) || 1, 0.2), 1), // operator law 2026-09-05: never 2 mm
fineStepMm: Math.min(Math.max(Number(args.fine_step_mm) || 0.1, 0.02), 0.5),
backoffMm: Math.min(Math.max(Number(args.backoff_mm) || 1, Number(args.fine_step_mm) || 0.1), 3),
sensorDelayMs: Math.min(Math.max(Number(args.sensor_delay_ms) || 300, 100), 10000),
diff --git a/src/server/services/mcp/probeTransport.ts b/src/server/services/mcp/probeTransport.ts
index 547fb6f1a3..c6d0b48fe5 100644
--- a/src/server/services/mcp/probeTransport.ts
+++ b/src/server/services/mcp/probeTransport.ts
@@ -14,7 +14,9 @@ export type ProbeTransportKind = 'mqtt' | 'gpio';
* the overtravel/crash latch, reconnection). Implementations extend
* EventEmitter and emit:
*
- * 'reading' (channel: ProbeChannel, value: string) - the sensor CHANGED
+ * 'reading' (channel: ProbeChannel, value: string, meta?: ReadingMeta) - the sensor CHANGED
+ * (meta.sentAt: the transport's own wall-clock ms at detection, when it has one,
+ * so the server can measure pipe latency)
* 'refresh' (channel: ProbeChannel, value: string) - polled, unchanged
* (freshness only; MQTT never emits it, polling GPIO does)
* 'error' (err: Error) - after connect() resolved; connect failures reject
@@ -23,6 +25,12 @@ export type ProbeTransportKind = 'mqtt' | 'gpio';
* A closed transport is not reusable - the service builds a new one from
* freshly resolved configuration on every (re)connect.
*/
+/** Optional per-reading timing from a transport that stamps its readings. */
+export interface ReadingMeta {
+ /** Wall-clock ms (same host clock) when the transport detected the change. */
+ sentAt?: number;
+}
+
export interface ProbeTransport {
connect(): Promise;
end(): void;
diff --git a/src/server/services/mcp/probeVector.ts b/src/server/services/mcp/probeVector.ts
index 7b44b5f137..7910edeafd 100644
--- a/src/server/services/mcp/probeVector.ts
+++ b/src/server/services/mcp/probeVector.ts
@@ -115,7 +115,7 @@ export function planProbeVector(args: {
y: Number((start.y + unit.y * travel).toFixed(3)),
z: Number((start.z + unit.z * travel).toFixed(3)),
},
- coarseStepMm: Math.min(Math.max(Number(args.coarse_step_mm) || 1, 0.2), 2),
+ coarseStepMm: Math.min(Math.max(Number(args.coarse_step_mm) || 1, 0.2), 1), // operator law 2026-09-05: never 2 mm
fineStepMm: Math.min(Math.max(Number(args.fine_step_mm) || 0.1, 0.02), 0.5),
backoffMm: Math.min(Math.max(Number(args.backoff_mm) || 1, Number(args.fine_step_mm) || 0.1), 3),
sensorDelayMs: Math.min(Math.max(Number(args.sensor_delay_ms) || 300, 100), 10000),
@@ -152,9 +152,9 @@ export function describeProbeVectorPlanAsGcode(plan: ProbeVectorPlan): string {
const dir = `(${plan.unit.x}, ${plan.unit.y}, ${plan.unit.z})`;
const lines = [
'; TOUCH PROBE VECTOR MEASUREMENT (server-driven, sensor-gated on the probe channel)',
- `; march along unit direction ${dir} from the staging position, max travel ${plan.maxTravelMm} mm`
- + (plan.maxTravelMm < plan.requestedTravelMm
- ? ` (requested ${plan.requestedTravelMm}, clamped to the machine envelope)` : ''),
+ `; march along unit direction ${dir} from the staging position, max travel ${plan.maxTravelMm} mm${
+ plan.maxTravelMm < plan.requestedTravelMm
+ ? ` (requested ${plan.requestedTravelMm}, clamped to the machine envelope)` : ''}`,
'; EVERY LINE IS SENT INDIVIDUALLY: after each move settles, the probe feed is checked',
'; before the next line. The march stops at first contact; running the full ladder',
`; without contact ABORTS at (${plan.limit.x}, ${plan.limit.y}, ${plan.limit.z}).`,
diff --git a/src/server/services/mcp/probing.ts b/src/server/services/mcp/probing.ts
index 44d4c91ed4..106e691a1c 100644
--- a/src/server/services/mcp/probing.ts
+++ b/src/server/services/mcp/probing.ts
@@ -1,8 +1,25 @@
import { connectionManager } from '../machine/ConnectionManager';
+import { mcpBroadcast } from './index';
+import {
+ AXES,
+ BeatObservation,
+ NullableXyz,
+ PositionSource,
+ Xyz,
+ completeTarget,
+ describeReport,
+ getPositionOfRecord,
+ getTrustedOffset,
+ judgeRecheck,
+ machineFromReport,
+ matchFrameWithOffsets,
+ setPositionOfRecord,
+ setTrustedOffset,
+} from './positionOfRecord';
import { ProbeChannel, probeFeedService, resolveSensorEnabled, sensorLabel } from './probeFeed';
import { McpToolError } from './registry';
-import { GcodeChannel, sendGcodeVisible } from './tools/camera';
-import { assertFreshHeartbeat, getPositionSnapshot } from './tools/machine';
+import { GcodeChannel, currentGcodeSequence, sendGcodeVisible } from './tools/camera';
+import { PositionSnapshot, assertFreshHeartbeat, getPositionSnapshot } from './tools/machine';
// The shared sensor-gated motion engine: settled single moves on the direct
// path, contact/release sensing against a probe feed channel, and the
@@ -17,6 +34,52 @@ const SETTLE_TIMEOUT_MS = 30000;
const SETTLE_POLL_MS = 250;
export const SETTLE_TOLERANCE_MM = 0.15;
export const MAX_RETREAT_MM = 5; // still triggered after this much retreat = stuck sensor
+const SENSE_OVERRUN_MS = 200;
+
+export interface SenseTiming {
+ kind: 'contact' | 'release';
+ windowMs: number;
+ elapsedMs: number;
+ endedAt: number;
+ contact: boolean;
+}
+
+/** The most recent sensor wait (window asked for, time it took, when it ended) - stamped on the next send event. */
+let lastSense: SenseTiming | null = null;
+
+export function getLastSense(): SenseTiming | null {
+ return lastSense;
+}
+
+/**
+ * Step trace: wall-clock marks at every boundary between one controller
+ * reply and the next send, attached to that send's event. Job cdbc29371b97
+ * (2026-09-05) placed the whole 1-4.5 s idle between the reply and the
+ * start of the sensor wait - a stretch of synchronous code and promise
+ * resumptions with no timer in it - so each boundary is stamped:
+ * reply-returned (sendGcodeVisible handed back), echo-match / echo-miss,
+ * engine-exit, settled-exit (moveMachineSettled returned), sense-start,
+ * sense-end, then the runner enters the engine again.
+ */
+const stepTrace: { label: string; at: number }[] = [];
+
+export function traceMark(label: string): void {
+ stepTrace.push({ label, at: Date.now() });
+ if (stepTrace.length > 24) {
+ stepTrace.splice(0, stepTrace.length - 24);
+ }
+}
+
+/** Marks since the previous send as "label+ms" relative to `since`, then reset. */
+export function takeStepTrace(since: number | null): string | undefined {
+ if (!stepTrace.length) {
+ return undefined;
+ }
+ const base = since === null ? stepTrace[0].at : since;
+ const text = stepTrace.map((mark) => `${mark.label}+${mark.at - base}`).join(' ');
+ stepTrace.length = 0;
+ return text;
+}
export class ProcedureAbort extends Error {}
@@ -42,21 +105,52 @@ export async function sleep(ms: number): Promise {
});
}
+// Operator rule (2026-09-05): a move of <= INCH_ESTIMATE_MM whose controller
+// reply carried no contradicting position may be taken as arrived when the
+// heartbeat has not caught up within INCH_GRACE_MS - inching must not be paced
+// by the 2 s status poll. Recorded as an `estimated` position of record.
+export const INCH_ESTIMATE_MM = 1;
+const INCH_GRACE_MS = 400;
+const INCH_GRACE_POLL_MS = 100;
+const ECHO_TOLERANCE_MM = 0.02;
+
+function parseEcho(text: string | undefined): NullableXyz | null {
+ const echo = String(text || '').match(/X:(-?\d+(?:\.\d+)?)\s+Y:(-?\d+(?:\.\d+)?)\s+Z:(-?\d+(?:\.\d+)?)/);
+ return echo ? { x: Number(echo[1]), y: Number(echo[2]), z: Number(echo[3]) } : null;
+}
+
/**
* Issue one absolute machine-frame move and block until it verifiably
* completed. Fast path: the HTTP channel executes gcode synchronously and its
- * reply echoes the completed position in WORK coordinates - an exact match
- * (0.02 mm) is proof of arrival with no heartbeat wait. Fallback: heartbeat
- * settle (report newer than issue, machine idle, axes at target; moves larger
- * than 3x the tolerance accept the first at-target beat, smaller ones require
- * a stable double-beat since a stale beat could pass). The overtravel latch
- * is re-checked before the move and on every poll.
+ * reply echoes the completed position - an exact match (0.02 mm) in EITHER
+ * frame (work coordinates normally; raw machine coordinates when the reply
+ * was framed inside the G53 window - positionOfRecord.ts) is proof of
+ * arrival with no heartbeat wait. Fallback: heartbeat settle (report newer
+ * than issue, machine idle, axes at target in either frame; moves larger than
+ * 3x the tolerance accept the first at-target beat, smaller ones require two
+ * DISTINCT agreeing beats since a stale beat could pass). Moves of <= 1 mm
+ * with no contradicting echo are estimated after a short grace (operator
+ * rule above). Every verified arrival becomes the position of record. The
+ * overtravel latch is re-checked before the move and on every poll.
*/
+export interface MoveOptions {
+ /**
+ * Descent segments (operator law 2026-09-05): the controller's ok is
+ * enough - never wait on the heartbeat when the echo is missing or
+ * misframed; record the commanded position as `estimated` and move on.
+ * Each segment is <= DESCENT_SEGMENT_MM and sensor-checked by the caller.
+ */
+ lenient?: boolean;
+}
+
async function moveMachineSettledUnguarded(
tool: string,
target: { x?: number; y?: number; z?: number },
- feed: number
+ feed: number,
+ options: MoveOptions = {}
): Promise {
+ const enteredAt = Date.now();
+ traceMark('engine-enter');
probeFeedService.assertNoOvertravel();
const channel = getDirectChannel();
const words = [
@@ -67,71 +161,250 @@ async function moveMachineSettledUnguarded(
const gcode = `G90\nG53;\nG1 ${words} F${feed};\nG54;`;
const before = getPositionSnapshot();
- const bigMove = (['x', 'y', 'z'] as const).every((axis) => {
- const want = target[axis];
- if (want === undefined) {
- return true;
- }
- const from = before.machine[axis];
- return from !== null && Math.abs(want - from) > SETTLE_TOLERANCE_MM * 3;
- });
+ // Where the move starts: the engine's own record while it is current (no
+ // other gcode since it was taken), else the heartbeat.
+ const record = getPositionOfRecord(currentGcodeSequence());
+ const from: NullableXyz = record ? record.machine : before.machine;
+ const deltas = AXES
+ .filter((axis) => target[axis] !== undefined)
+ .map((axis) => (from[axis] === null ? null : Math.abs((target[axis] as number) - (from[axis] as number))));
+ const bigMove = deltas.every((d) => d !== null && d > SETTLE_TOLERANCE_MM * 3);
+ const inchMove = deltas.every((d) => d !== null && d <= INCH_ESTIMATE_MM);
const issuedAt = Date.now();
- const executed = await sendGcodeVisible(channel, tool, gcode);
+ // Timing stamps for the send event (diagnostics): how long the engine
+ // took from entry to send, and what the last sensor wait cost - so an
+ // idle gap between steps can be attributed to the runner, the sensor
+ // window, or the engine (job d8f6ec1b5c11: 1-4 s idles, echo matched,
+ // event loop idle - unexplained without this).
+ const executed = await sendGcodeVisible(channel, tool, gcode, { enteredAt, lastSense, trace: takeStepTrace(null) });
+ traceMark('reply-returned');
if (executed.result !== 0) {
throw new ProcedureAbort(`Controller rejected the move: ${executed.text || executed.result}`);
}
+ const arrived = (machine: NullableXyz, source: PositionSource) => {
+ const full = completeTarget(machine, target);
+ if (full) {
+ setPositionOfRecord(full, source, executed.sequence, tool);
+ }
+ traceMark(`engine-exit:${source}`);
+ };
- const echo = String(executed.text || '').match(/X:(-?\d+(?:\.\d+)?)\s+Y:(-?\d+(?:\.\d+)?)\s+Z:(-?\d+(?:\.\d+)?)/);
+ // Offsets to judge the echo with: the engine's own trusted offset (the
+ // one that last proved an arrival) before whatever the latest heartbeat
+ // carried - which reads 0,0,0 inside G53 windows and cost 28 settle-waits
+ // in the run after the snapshot fix.
+ const offsetCandidates: Xyz[] = [];
+ const trusted = getTrustedOffset();
+ if (trusted) {
+ offsetCandidates.push(trusted);
+ }
+ offsetCandidates.push(before.originOffset);
+ const echo = parseEcho(executed.text);
+ let echoContradicts = false;
if (echo) {
- const offset = before.originOffset;
- const echoMachine = {
- x: Number(echo[1]) - offset.x,
- y: Number(echo[2]) - offset.y,
- z: Number(echo[3]) - offset.z,
- };
- const exact = (['x', 'y', 'z'] as const).every((axis) => {
- const want = target[axis];
- return want === undefined || Math.abs(echoMachine[axis] - want) <= 0.02;
- });
- if (exact) {
+ const match = matchFrameWithOffsets(echo, offsetCandidates, target, ECHO_TOLERANCE_MM);
+ if (match) {
+ traceMark(`echo-match:${match.frame}`);
+ if (match.frame === 'work-frame') {
+ setTrustedOffset(match.offset);
+ }
+ arrived(machineFromReport(echo, match.offset, match.frame), 'echo');
+ return;
+ }
+ traceMark('echo-miss');
+ echoContradicts = true;
+ } else {
+ traceMark('echo-absent');
+ }
+
+ if (options.lenient) {
+ const estimate = completeTarget(from, target);
+ if (estimate) {
+ setPositionOfRecord(estimate, 'estimated', executed.sequence, tool);
+ traceMark('engine-exit:estimated-lenient');
+ mcpBroadcast('mcp:activity', {
+ tool,
+ phase: 'position-estimated',
+ note: `${words}: descent segment - controller accepted, ${echo ? 'echo misframed' : 'no echo'}; not waiting on the `
+ + 'heartbeat (operator rule 2026-09-05), commanded position recorded as estimated',
+ });
return;
}
}
+ // Reaching here means the controller's reply did not prove arrival. Say
+ // so on the job record with the evidence (job cdbc29371b97 placed 1-4 s
+ // idles between the reply and the sensor window; this is the only
+ // heartbeat wait in that stretch, so it must announce itself).
+ mcpBroadcast('mcp:activity', {
+ tool,
+ phase: 'settle-wait',
+ note: `${words}: ${echo ? 'echo did not match either frame' : 'no position echo'} - waiting on the heartbeat`,
+ echo: echo ? `${echo.x},${echo.y},${echo.z}` : null,
+ offset: `${before.originOffset.x},${before.originOffset.y},${before.originOffset.z}`,
+ offsetSource: before.originOffsetSource,
+ trustedOffset: trusted ? `${trusted.x},${trusted.y},${trusted.z}` : null,
+ target: words,
+ inchMove,
+ bigMove,
+ });
+ const settleStartedAt = Date.now();
const deadline = issuedAt + SETTLE_TIMEOUT_MS;
+ let graceUntil: number | null = inchMove && !echoContradicts ? issuedAt + INCH_GRACE_MS : null;
let previous: string | null = null;
+ let previousReportTime: number | null = null;
while (Date.now() < deadline) {
- await sleep(SETTLE_POLL_MS);
+ await sleep(graceUntil !== null ? INCH_GRACE_POLL_MS : SETTLE_POLL_MS);
probeFeedService.assertNoOvertravel();
- let now;
+ let now: PositionSnapshot;
try {
now = getPositionSnapshot();
} catch (err) {
continue;
}
- const reportTime = Date.now() - now.reportAgeMs;
+ const reportTime = now.reportedAt;
const fingerprint = JSON.stringify([now.work, now.originOffset]);
- const stable = fingerprint === previous;
- previous = fingerprint;
- if (reportTime <= issuedAt || now.machineStatus !== 'idle') {
- continue;
+ // "Stable" = two DISTINCT beats agreeing (the poll here is faster than
+ // the 2 s heartbeat, so the same beat read twice proves nothing).
+ const stable = fingerprint === previous && previousReportTime !== null && previousReportTime !== reportTime;
+ if (previousReportTime !== reportTime) {
+ previous = fingerprint;
+ previousReportTime = reportTime;
}
- if (!bigMove && !stable) {
- continue;
+ const fresh = reportTime > issuedAt && now.machineStatus === 'idle';
+ if (fresh && (bigMove || stable)) {
+ const beatOffsets = trusted ? [now.originOffset, trusted] : [now.originOffset];
+ const match = matchFrameWithOffsets(now.work, beatOffsets, target, SETTLE_TOLERANCE_MM);
+ if (match) {
+ mcpBroadcast('mcp:activity', {
+ tool,
+ phase: 'settle-done',
+ note: `${words}: heartbeat (${match.frame}) confirmed arrival after ${Date.now() - settleStartedAt} ms`,
+ });
+ if (match.frame === 'work-frame') {
+ setTrustedOffset(match.offset);
+ }
+ arrived(machineFromReport(now.work, match.offset, match.frame), 'heartbeat');
+ return;
+ }
}
- const atTarget = (['x', 'y', 'z'] as const).every((axis) => {
- const want = target[axis];
- const have = now.machine[axis];
- return want === undefined || (have !== null && Math.abs(have - want) <= SETTLE_TOLERANCE_MM);
- });
- if (atTarget) {
- return;
+ if (graceUntil !== null && Date.now() >= graceUntil) {
+ graceUntil = null;
+ // No post-issue report at all yet: the status poll is simply
+ // behind a move this small. A fresh report that disagrees is NOT
+ // estimated over - fall through to the full settle wait.
+ const estimate = fresh ? null : completeTarget(from, target);
+ if (estimate) {
+ setPositionOfRecord(estimate, 'estimated', executed.sequence, tool);
+ mcpBroadcast('mcp:activity', {
+ tool,
+ phase: 'position-estimated',
+ note: `${words}: controller accepted a <= ${INCH_ESTIMATE_MM} mm move with no position echo and the `
+ + `latest status report is ${Date.now() - reportTime} ms old - taking the commanded position as `
+ + 'arrived (operator rule 2026-09-05)',
+ });
+ return;
+ }
}
}
throw new ProcedureAbort(`Timed out waiting for the heartbeat to verify the move to ${words}.`);
}
+/**
+ * Where the engine knows the machine to be: its position of record while it
+ * is current (the last verified arrival, no other gcode since), else the
+ * heartbeat snapshot. Runners must use THIS, not a bare snapshot, to decide
+ * their next move: job 42df7b9351b7 (2026-09-05) read a single zero-offset
+ * beat as machine Z-8 while the traverse echo had just verified Z320, skipped
+ * the fast descent and aborted at the station check.
+ */
+export function knownMachinePosition(): { position: NullableXyz; source: 'record' | 'heartbeat'; snapshot: PositionSnapshot } {
+ const snapshot = getPositionSnapshot();
+ const record = getPositionOfRecord(currentGcodeSequence());
+ if (record) {
+ return { position: { ...record.machine }, source: 'record', snapshot };
+ }
+ return { position: snapshot.machine, source: 'heartbeat', snapshot };
+}
+
+export const RECHECK_TOLERANCE_MM = 0.5;
+const RECHECK_MAX_MS = 4500; // more than two 2 s heartbeat periods
+const RECHECK_POLL_MS = 250;
+
+export interface PositionCheck {
+ /** What settled it: a heartbeat in one of the two frames, or the engine's record. */
+ frame: 'work-frame' | 'machine-frame' | 'record';
+ waitedMs: number;
+ /** Set when the check did not pass on the first, plainly-framed read - worth announcing. */
+ note: string | null;
+}
+
+/**
+ * Verify the machine is at `expected` (machine coordinates) before trusting
+ * the sensor. Decision (positionOfRecord.judgeRecheck): the latest heartbeat
+ * matching in either frame passes; the position of record passes while the
+ * heartbeat still predates it; two distinct heartbeats agreeing on somewhere
+ * else abort at once (real drift); otherwise keep reading for up to
+ * RECHECK_MAX_MS. Replaces the per-runner "re-read once after 1.2 s" checks
+ * that aborted job 1db4902a4cd6 on one stale beat and one G53-window beat.
+ */
+export async function expectMachinePosition(
+ expected: Xyz,
+ what: string,
+ onMismatch: (message: string) => Error,
+ toleranceMm: number = RECHECK_TOLERANCE_MM
+): Promise {
+ const record = getPositionOfRecord(currentGcodeSequence());
+ const startedAt = Date.now();
+ const beats: BeatObservation[] = [];
+ let first: PositionSnapshot | null = null;
+ for (;;) {
+ const snapshot = getPositionSnapshot();
+ if (!first) {
+ first = snapshot;
+ }
+ const reportTime = snapshot.reportedAt;
+ beats.push({ reportTime, raw: snapshot.work, offset: snapshot.originOffset });
+ if (beats.length > 3) {
+ beats.shift();
+ }
+ const verdict = judgeRecheck(beats, expected, toleranceMm, record);
+ const waitedMs = Date.now() - startedAt;
+ if (verdict.verdict === 'pass') {
+ if (verdict.frame === 'record') {
+ const r = verdict.record;
+ return {
+ frame: 'record',
+ waitedMs,
+ note: `latest status report (${Math.round(Date.now() - reportTime)} ms old) predates the last verified `
+ + `move; position of record (${r.machine.x}, ${r.machine.y}, ${r.machine.z}) from ${r.tool} `
+ + `(${r.source}) used`,
+ };
+ }
+ let note: string | null = null;
+ if (verdict.frame === 'machine-frame') {
+ note = 'status report carried machine-frame coordinates (G53 window) - accepted as such';
+ } else if (waitedMs > 0) {
+ note = `passed after ${waitedMs} ms (earlier reads were transient)`;
+ }
+ return { frame: verdict.frame, waitedMs, note };
+ }
+ if (verdict.verdict === 'drift' || waitedMs >= RECHECK_MAX_MS) {
+ const why = verdict.verdict === 'drift'
+ ? 'two consecutive status reports agree the machine is elsewhere'
+ : `no status report matched within ${RECHECK_MAX_MS} ms`;
+ const recordText = record
+ ? `Position of record: (${record.machine.x}, ${record.machine.y}, ${record.machine.z}) from ${record.tool} (${record.source}).`
+ : 'No position of record.';
+ throw onMismatch(`${what}: the plan expects machine (${expected.x}, ${expected.y}, ${expected.z}) but ${why}. `
+ + `Latest: ${describeReport(snapshot.work, snapshot.originOffset, snapshot.originOffsetSource)}. `
+ + `First read: ${describeReport(first.work, first.originOffset, first.originOffsetSource)}. ${recordText}`);
+ }
+ await sleep(RECHECK_POLL_MS);
+ }
+}
+
/**
* moveMachineSettledUnguarded inside the motion-in-flight bracket: while the
* move runs, a contact sensor the procedure did NOT declare as expected
@@ -142,33 +415,45 @@ async function moveMachineSettledUnguarded(
export async function moveMachineSettled(
tool: string,
target: { x?: number; y?: number; z?: number },
- feed: number
+ feed: number,
+ options: MoveOptions = {}
): Promise {
probeFeedService.motionBegin();
try {
- await moveMachineSettledUnguarded(tool, target, feed);
+ await moveMachineSettledUnguarded(tool, target, feed, options);
+ traceMark('settled-exit');
} finally {
probeFeedService.motionEnd();
}
}
+
/**
* CONTACT detection after a settled step: give the sensor's report a short
* window to arrive (early-exit on a fresh reading), then judge from the LAST
* KNOWN state - the sensor publishes on change, so silence means unchanged.
* A late contact message merely costs one extra step (self-correcting).
*/
+function noteSense(kind: 'contact' | 'release', windowMs: number, startedAt: number, contact: boolean): void {
+ const endedAt = Date.now();
+ lastSense = { kind, windowMs, elapsedMs: endedAt - startedAt, endedAt, contact };
+ traceMark(`sense-end:${kind}${contact ? ':contact' : ''}`);
+}
+
export async function senseAfter(
channels: ProbeChannel | ProbeChannel[],
stepIssuedAt: number,
delayMs: number
): Promise {
const list = Array.isArray(channels) ? channels : [channels];
- const deadline = Date.now() + delayMs;
+ const startedAt = Date.now();
+ traceMark('sense-start');
+ const deadline = startedAt + delayMs;
for (;;) {
for (const channel of list) {
const state = probeFeedService.getReading(channel);
if (state && state.triggered) {
+ noteSense('contact', delayMs, startedAt, true);
return {
contact: true,
reading: { value: state.value, receivedAt: state.receivedAt },
@@ -177,6 +462,18 @@ export async function senseAfter(
}
}
if (Date.now() >= deadline) {
+ noteSense('contact', delayMs, startedAt, false);
+ // Diagnostics: the window is timer-paced; finishing well past it
+ // means the server's timers ran late (diagnostics.ts).
+ const overrun = Date.now() - deadline;
+ if (overrun > SENSE_OVERRUN_MS) {
+ mcpBroadcast('mcp:activity', {
+ tool: 'diagnostics',
+ phase: 'sense_overrun',
+ ms: overrun,
+ note: `sensor window of ${delayMs} ms finished ${overrun} ms late - timers delayed (event loop or CPU)`,
+ });
+ }
const state = probeFeedService.getReading(list[0]);
return {
contact: false,
@@ -201,11 +498,14 @@ export async function senseReleaseAfter(
timeoutMs: number
): Promise {
const list = Array.isArray(channels) ? channels : [channels];
- const deadline = Date.now() + timeoutMs;
+ const startedAt = Date.now();
+ traceMark('sense-start:release');
+ const deadline = startedAt + timeoutMs;
for (;;) {
const states = list.map((channel) => ({ channel, state: probeFeedService.getReading(channel) }));
const stillTriggered = states.find((s) => s.state && s.state.triggered);
if (!stillTriggered) {
+ noteSense('release', timeoutMs, startedAt, false);
const first = states[0].state;
return {
contact: false,
@@ -214,6 +514,7 @@ export async function senseReleaseAfter(
};
}
if (Date.now() >= deadline) {
+ noteSense('release', timeoutMs, startedAt, true);
return {
contact: true,
reading: stillTriggered.state
@@ -268,3 +569,61 @@ export function assertMachineReadyForProcedure(): void {
throw new McpToolError('Toolhead appears to be on; refusing to run the procedure.');
}
}
+
+/** Longest single Z move toward the work a procedure may issue (operator law 2026-09-05). */
+export const DESCENT_SEGMENT_MM = 5;
+
+/**
+ * Descend from `fromZ` to `toZ` (machine Z) in segments of at most
+ * DESCENT_SEGMENT_MM. Operator law (2026-09-05): a single long G1 toward the
+ * work cannot be stopped once sent - a collision would be driven to the end
+ * of the move - so every descent is chopped, and NO segment waits on the
+ * heartbeat (lenient settle: the controller's ok is the gate).
+ *
+ * Contact detection during the descent is ASYNCHRONOUS (operator, same day):
+ * the probe feed's crash guard already latches the CRASH alarm the moment a
+ * channel the procedure did not declare as expected fires while motion is
+ * in flight (probeFeed.onReading -> tripSafety: job stop + connection close),
+ * and every segment re-checks the latch before it is sent, so a hit ends the
+ * descent within one segment (<= 5 mm) without a serial sensor wait between
+ * segments. Callers MUST have cleared the expected-contact set for the
+ * channels a hit would arrive on (a descent expects no contact). Where a
+ * manoeuvre needs a synchronous verdict as well, `serialCheck` adds a
+ * senseAfter window after every segment and aborts on contact.
+ * Upward moves are a single move: they leave the work.
+ */
+export async function descendInSegments(
+ tool: string,
+ fromZ: number,
+ toZ: number,
+ channels: ProbeChannel | ProbeChannel[],
+ sensorDelayMs: number,
+ options: { feed?: number; serialCheck?: boolean } = {}
+): Promise<{ segments: number }> {
+ const feed = options.feed === undefined ? TRAVEL_FEED : options.feed;
+ if (toZ >= fromZ - 1e-9) {
+ await moveMachineSettled(tool, { z: Number(toZ.toFixed(3)) }, feed);
+ return { segments: 1 };
+ }
+ let z = fromZ;
+ let segments = 0;
+ while (z - toZ > 1e-9) {
+ z = Math.max(Number((z - DESCENT_SEGMENT_MM).toFixed(3)), toZ);
+ segments += 1;
+ const t0 = Date.now();
+ // assertNoOvertravel() inside the engine refuses this segment if the
+ // crash guard latched during the previous one.
+ await moveMachineSettled(tool, { z }, feed, { lenient: true });
+ if (options.serialCheck) {
+ const sensed = await senseAfter(channels, t0, sensorDelayMs);
+ if (sensed.contact) {
+ throw new ProcedureAbort(`UNEXPECTED CONTACT (${sensed.channel}) during the descent at Z${z.toFixed(3)} - something is `
+ + 'where the plan says nothing should be. Machine held.');
+ }
+ }
+ }
+ // The latch may have fired on the LAST segment with nothing sent after
+ // it: surface it here rather than at the caller's next move.
+ probeFeedService.assertNoOvertravel();
+ return { segments };
+}
diff --git a/src/server/services/mcp/surfaceScan.ts b/src/server/services/mcp/surfaceScan.ts
index d47d87ac1a..993f0f7ce2 100644
--- a/src/server/services/mcp/surfaceScan.ts
+++ b/src/server/services/mcp/surfaceScan.ts
@@ -338,6 +338,44 @@ export function stationEnvelope(
return { marchStartZ, floorZ, travelMm: round3(marchStartZ - floorZ) };
}
+/**
+ * Where the fine steps take over on a march, given the expected contact
+ * (previous station's Z, or expected_z_machine for station 1): the SLOW ZONE
+ * runs from expected + slow_zone_mm down to expected - (slow_zone_mm + 2 x
+ * coarse), expressed in s (mm below the march start). Coarse steps never
+ * cross into it; below it coarse resumes (a pocket edge costs seconds, not
+ * minutes). null = no expected contact, or the zone lies wholly outside the
+ * march: coarse all the way (capped at 1 mm by coarseStepFor).
+ *
+ * Why (operator, 2026-09-05, job d8f6ec1b5c11): a coarse step is executed
+ * whole by the controller before the runner sees the probe, so the coarse
+ * ladder presses the probe past contact by up to a FULL coarse step (0.4 mm
+ * at station 1 with 2 mm steps, worst case 2 mm) - the same problem
+ * run_tool_setter's slow_zone_mm already solves.
+ */
+export function slowZoneFor(
+ startZ: number,
+ expectedContactZ: number | null,
+ slowZoneMm: number,
+ coarseStepMm: number,
+ travelMm: number
+): { topS: number; bottomS: number } | null {
+ if (expectedContactZ === null) {
+ return null;
+ }
+ const topS = Math.max(0, round3(startZ - (expectedContactZ + slowZoneMm)));
+ const bottomS = Math.min(travelMm, round3(startZ - (expectedContactZ - (slowZoneMm + 2 * coarseStepMm))));
+ if (topS >= travelMm - 1e-9 || bottomS <= topS + 1e-9) {
+ return null;
+ }
+ return { topS, bottomS };
+}
+
+/** Coarse step actually used on a march: capped at 1 mm when nothing bounds the press. */
+export function coarseStepFor(plan: { coarseStepMm: number }, hasExpectedContact: boolean): number {
+ return hasExpectedContact ? plan.coarseStepMm : Math.min(plan.coarseStepMm, 1);
+}
+
/** Split a hop into equal segments no longer than HOP_SEGMENT_MM (sensor-checked between). */
export function hopSegments(
from: { x: number; y: number },
diff --git a/src/server/services/mcp/toolSetter.ts b/src/server/services/mcp/toolSetter.ts
index 058126605e..1130f4292c 100644
--- a/src/server/services/mcp/toolSetter.ts
+++ b/src/server/services/mcp/toolSetter.ts
@@ -13,6 +13,7 @@ import {
TRAVEL_FEED,
assertChannelReady,
assertMachineReadyForProcedure,
+ descendInSegments,
moveMachineSettled,
senseAfter,
senseReleaseAfter,
@@ -199,7 +200,7 @@ export function planToolSetterRun(args: {
throw new McpToolError('bit_length_mm must be the approximate protrusion of the fitted bit in mm '
+ '(0-300), as stated by the operator.');
}
- const coarseStepMm = Math.min(Math.max(Number(args.coarse_step_mm) || 1, 0.2), 2);
+ const coarseStepMm = Math.min(Math.max(Number(args.coarse_step_mm) || 1, 0.2), 1); // operator law 2026-09-05: never 2 mm
const fineStepMm = Math.min(Math.max(Number(args.fine_step_mm) || 0.1, 0.02), 0.5);
const backoffMm = Math.min(Math.max(Number(args.backoff_mm) || 0.3, fineStepMm), 2);
// 200ms default is tuned to the operator's local-broker latency; the
@@ -270,7 +271,7 @@ export function describePlanAsGcode(plan: ToolSetterPlan): string {
} else {
lines.push(
`G0 X${c.centerX.toFixed(3)} Y${c.centerY.toFixed(3)}; XY to setter centre at current (post-home) Z`,
- `G1 Z${plan.startZ.toFixed(3)} F${TRAVEL_FEED}; travel to start height`,
+ `G1 Z${plan.startZ.toFixed(3)} F${TRAVEL_FEED}; descend to start height in <= 5 mm segments under the crash guard (a hit before it aborts)`,
);
}
let z = plan.startZ;
@@ -374,8 +375,18 @@ export async function runToolSetterProcedure(plan: ToolSetterPlan): Promise {
- mcpBroadcast('mcp:gcode', { tool, gcode });
- gcodeLog.info(`[${tool}] > ${gcode.replace(/\r?\n/g, ' | ')}`);
+// Direct-gcode sequence: every command through this gate bumps it, so the
+// motion engine can tell whether its position of record is still current
+// (positionOfRecord.ts). The timing stamps feed diagnostics.ts: idle time
+// between the controller's previous reply and the next send is engine +
+// sensor window only, so a long one inside a job means late timers.
+let gcodeSequence = 0;
+let lastReplyAt: number | null = null;
+const SLOW_IDLE_MS = 750;
+const SLOW_IDLE_IGNORE_MS = 15000; // beyond this it is a human/agent pause, not pacing
+
+export function currentGcodeSequence(): number {
+ return gcodeSequence;
+}
+
+export interface SentGcode {
+ result: number;
+ text?: string;
+ /** Value of currentGcodeSequence() for this command. */
+ sequence: number;
+ /** Send -> controller reply. */
+ execMs: number;
+}
+
+/**
+ * Optional timing the motion engine attaches to a send so an idle gap can be
+ * attributed: enteredAt = when the engine was entered for this move;
+ * lastSense = the sensor wait that preceded it (probing.ts lastSense).
+ */
+export interface SendTiming {
+ enteredAt?: number;
+ lastSense?: { kind: string; windowMs: number; elapsedMs: number; endedAt: number; contact: boolean } | null;
+ /** probing.ts step trace: "label+ms" marks since the previous reply. */
+ trace?: string;
+}
+
+export async function sendGcodeVisible(channel: GcodeChannel, tool: string, gcode: string, timing?: SendTiming): Promise {
+ gcodeSequence += 1;
+ const sequence = gcodeSequence;
+ const sentAt = Date.now();
+ const idleMs = lastReplyAt === null ? null : sentAt - lastReplyAt;
+ // Breakdown of the idle gap (previous reply -> this send):
+ // replyToSenseEndMs: previous reply -> end of the runner's sensor wait
+ // senseMs / senseWindowMs: that wait's actual vs requested length
+ // senseEndToEngineMs: sensor wait end -> engine entry (runner logic)
+ // engineMs: engine entry -> send (overtravel check, snapshot, record)
+ const breakdown: { [key: string]: number | string | boolean } = {};
+ if (timing && timing.enteredAt) {
+ breakdown.engineMs = sentAt - timing.enteredAt;
+ const sense = timing.lastSense;
+ if (sense && lastReplyAt !== null && sense.endedAt >= lastReplyAt) {
+ breakdown.replyToSenseEndMs = sense.endedAt - lastReplyAt;
+ breakdown.senseMs = sense.elapsedMs;
+ breakdown.senseWindowMs = sense.windowMs;
+ breakdown.senseKind = sense.kind;
+ breakdown.senseEndToEngineMs = timing.enteredAt - sense.endedAt;
+ }
+ if (timing.trace) {
+ breakdown.trace = timing.trace;
+ }
+ }
+ mcpBroadcast('mcp:gcode', { tool, gcode, idleMs, ...breakdown });
+ const breakdownText = Object.keys(breakdown).length
+ ? ` [${Object.keys(breakdown).map((key) => `${key}=${breakdown[key]}`).join(' ')}]`
+ : '';
+ gcodeLog.info(`[${tool}] > ${gcode.replace(/\r?\n/g, ' | ')}${idleMs === null ? '' : ` (idle ${idleMs}ms)`}${breakdownText}`);
+ const slowIdle = idleMs !== null && idleMs > SLOW_IDLE_MS && idleMs < SLOW_IDLE_IGNORE_MS && jobManager.getActive() !== null;
+ if (slowIdle) {
+ mcpBroadcast('mcp:activity', {
+ tool: 'diagnostics',
+ phase: 'slow_step',
+ ms: idleMs,
+ note: `${tool}: ${idleMs} ms from the controller's previous reply to this send (engine + sensor window only - `
+ + 'compare event_loop_stall / sense_overrun events around it)',
+ });
+ }
// Crash-guard bracket: the HTTP channel executes synchronously, so the
// await spans the motion window - a contact-sensor trigger inside it
// that no procedure expects is treated as a collision (probeFeed).
probeFeedService.motionBegin();
+ noteDirectGcodeStart();
let executed;
try {
executed = await channel.executeGcode(gcode);
} finally {
probeFeedService.motionEnd();
+ noteDirectGcodeEnd();
}
+ const execMs = Date.now() - sentAt;
+ lastReplyAt = Date.now();
const response = executed.text || (executed.result === 0 ? 'ok' : `result=${executed.result}`);
- mcpBroadcast('mcp:gcode', { tool, response });
- gcodeLog.info(`[${tool}] < ${String(response).replace(/\r?\n/g, ' | ')}`);
- return executed;
+ mcpBroadcast('mcp:gcode', { tool, response, execMs });
+ gcodeLog.info(`[${tool}] < ${String(response).replace(/\r?\n/g, ' | ')} (exec ${execMs}ms)`);
+ recordGcodeTiming(tool, execMs, idleMs, slowIdle);
+ return { ...executed, sequence, execMs };
}
async function sleep(ms: number): Promise {
diff --git a/src/server/services/mcp/tools/gcode.ts b/src/server/services/mcp/tools/gcode.ts
index f0eeeb8c02..5c92e8990f 100644
--- a/src/server/services/mcp/tools/gcode.ts
+++ b/src/server/services/mcp/tools/gcode.ts
@@ -4,7 +4,8 @@ import * as fs from 'fs-extra';
import logger from '../../../lib/logger';
import { connectionManager } from '../../machine/ConnectionManager';
-import { McpJob, jobManager } from '../jobs';
+import { McpJob, approvalHandoff, jobManager } from '../jobs';
+import { matchFrame } from '../positionOfRecord';
import { probeFeedService } from '../probeFeed';
import { McpToolError, ToolRegistry } from '../registry';
import { validateGcode } from '../validator';
@@ -71,8 +72,13 @@ async function waitForStableHeartbeat(
continue;
}
if (expect) {
- const reportedZ = expect.frame === 'work' ? now.work.z : now.machine.z;
- if (reportedZ === null || Math.abs(reportedZ - expect.z) > 0.15) {
+ // Machine-frame targets accept a report in either frame: a beat
+ // inside the move's G53 window carries machine coordinates with
+ // the offset still populated (positionOfRecord.ts).
+ const atTarget = expect.frame === 'work'
+ ? (now.work.z !== null && Math.abs(now.work.z - expect.z) <= 0.15)
+ : matchFrame(now.work, now.originOffset, { z: expect.z }, 0.15) !== null;
+ if (!atTarget) {
continue; // settled, but not AT the target yet - keep waiting
}
}
@@ -254,8 +260,9 @@ export function registerGcodeTools(registry: ToolRegistry, getConfirmBaseUrl: ()
return {
job: jobManager.describe(job),
confirm_url: `${getConfirmBaseUrl()}/confirm/${job.id}`,
- next_step: 'Ask the operator to open confirm_url in a browser, review, and approve. '
- + 'They will receive a one-time code to give you for start_gcode_job.',
+ next_step: 'Ask the operator to open confirm_url in a browser, review, and approve. Then either call '
+ + 'start_gcode_job with wait_for_approval_ms (their click starts it - nothing to copy) or pass the '
+ + 'one-time code they relay as confirm_token.',
};
},
});
@@ -264,7 +271,11 @@ export function registerGcodeTools(registry: ToolRegistry, getConfirmBaseUrl: ()
name: 'start_gcode_job',
description: 'Start an approved job: uploads the file to the machine through the same '
+ 'prepare/start path as "Start on Luban" (door interlock applies) and starts it. '
- + 'Requires the one-time code the operator received when approving.',
+ + 'Authorisation is the operator\'s click on the confirm page. EITHER pass the one-time code they '
+ + 'relay (confirm_token) OR call with wait_for_approval_ms right after staging: the call stays open '
+ + 'until they approve (then the job starts at once - nothing to copy), reject, or the wait expires '
+ + '(returns approved: false, timed_out: true - call again to keep waiting; approval is never lost). '
+ + 'The hand-off can be disabled in Settings -> MCP Server, in which case only confirm_token works.',
inputSchema: {
type: 'object',
properties: {
@@ -273,7 +284,12 @@ export function registerGcodeTools(registry: ToolRegistry, getConfirmBaseUrl: ()
type: 'number',
description: 'Procedure jobs only: how long to wait for the result before returning a running status (0-120000, default 25000). The runner continues either way; long-poll get_gcode_job_status.',
},
- confirm_token: { type: 'string', description: 'One-time code from the operator.' },
+ confirm_token: { type: 'string', description: 'One-time code from the operator (not needed with wait_for_approval_ms).' },
+ wait_for_approval_ms: {
+ type: 'number',
+ description: 'Wait this long (1-120000, e.g. 110000) for the operator to approve on the confirm page, then start '
+ + 'without a code. Times out with approved: false if they have not clicked yet - call again.',
+ },
wait_until_moved: {
type: 'boolean',
description: 'Direct jobs only. Default true: block until the heartbeat verifiably '
@@ -281,16 +297,62 @@ export function registerGcodeTools(registry: ToolRegistry, getConfirmBaseUrl: ()
+ 'the command, with position_verified: false - poll get_position afterwards.',
},
},
- required: ['job_id', 'confirm_token'],
+ required: ['job_id'],
additionalProperties: false,
},
- handler: async (args: { job_id?: string; confirm_token?: string; wait_until_moved?: boolean; wait_ms?: number }) => {
+ handler: async (args: { job_id?: string; confirm_token?: string; wait_for_approval_ms?: number; wait_until_moved?: boolean; wait_ms?: number }) => {
probeFeedService.assertNoOvertravel();
const job = jobManager.get(String(args.job_id || ''));
if (!job) {
throw new McpToolError('Unknown job_id.');
}
+ // Approval hand-off (operator request 2026-09-05): with no code
+ // given, wait for the operator's click on the confirm page and
+ // use the job's own token. The click remains the only authority;
+ // this only removes the copying.
+ let token = String(args.confirm_token || '');
+ if (!token) {
+ const waitMs = Math.min(Math.max(Number(args.wait_for_approval_ms) || 0, 0), 120000);
+ if (waitMs <= 0) {
+ throw new McpToolError('Provide confirm_token (the operator\'s one-time code) or wait_for_approval_ms '
+ + '(1-120000) to wait for the operator to approve on the confirm page.');
+ }
+ if (approvalHandoff() !== 'agent') {
+ throw new McpToolError('Approval hand-off to agents is disabled in Settings -> MCP Server: the operator '
+ + 'must give you the one-time code from the confirm page (confirm_token).');
+ }
+ if (job.state === 'awaiting_confirmation') {
+ job.agentWaiting = true;
+ jobManager.appendEvent(job, 'agent_waiting', { note: `agent waiting up to ${waitMs} ms for the operator's approval` });
+ const startedWaiting = Date.now();
+ try {
+ while (job.state === 'awaiting_confirmation' && Date.now() - startedWaiting < waitMs) {
+ await sleep(250);
+ }
+ } finally {
+ job.agentWaiting = false;
+ }
+ }
+ if (job.state === 'awaiting_confirmation') {
+ return {
+ job: jobManager.describe(job),
+ approved: false,
+ timed_out: true,
+ note: `Not approved within ${waitMs} ms. The confirm page stays valid - call start_gcode_job again with `
+ + 'wait_for_approval_ms to keep waiting, or ask the operator whether they intend to approve.',
+ };
+ }
+ if (job.state === 'rejected') {
+ throw new McpToolError('The operator rejected this job on the confirm page. Do not restage the same motion without asking why.');
+ }
+ if (!job.confirmToken) {
+ throw new McpToolError(`Job is ${job.state}; nothing to start.`);
+ }
+ token = job.confirmToken;
+ jobManager.appendEvent(job, 'approval_handed_off', { note: 'operator approved on the confirm page; the waiting agent starts the job (no code relayed)' });
+ }
+
// Connectivity, heartbeat freshness and idleness are checked
// before the token is consumed, so an offline attempt does not
// waste an approval.
@@ -303,7 +365,7 @@ export function registerGcodeTools(registry: ToolRegistry, getConfirmBaseUrl: ()
// Consumed from here on, success or not - a failed start needs a
// fresh human approval, not a retry loop.
- const verdict = jobManager.consumeToken(job, String(args.confirm_token || ''));
+ const verdict = jobManager.consumeToken(job, token);
if (!verdict.ok) {
throw new McpToolError(verdict.reason || 'Confirmation failed.');
}
@@ -686,7 +748,7 @@ export function registerGcodeTools(registry: ToolRegistry, getConfirmBaseUrl: ()
const since = Math.max(0, Math.floor(Number(args.since_event) || 0));
const startedWaiting = Date.now();
let timedOut = false;
- while (!jobManager.isTerminal(job) && job.events.length <= since) {
+ while (!jobManager.isTerminal(job) && job.eventSeq <= since) {
if (Date.now() - startedWaiting >= waitMs) {
timedOut = waitMs > 0;
break;
@@ -697,8 +759,8 @@ export function registerGcodeTools(registry: ToolRegistry, getConfirmBaseUrl: ()
return {
job: jobManager.describe(job),
result: job.result,
- events: job.events.slice(since),
- next_event_index: job.events.length,
+ events: jobManager.eventsSince(job, since),
+ next_event_index: job.eventSeq,
waited_ms: Date.now() - startedWaiting,
timed_out: timedOut,
machineStatus: machineStatus(),
diff --git a/src/server/services/mcp/tools/machine.ts b/src/server/services/mcp/tools/machine.ts
index a5935d9847..59af7f27c2 100644
--- a/src/server/services/mcp/tools/machine.ts
+++ b/src/server/services/mcp/tools/machine.ts
@@ -14,6 +14,7 @@ import {
import DataStorage from '../../../DataStorage';
import config from '../../configstore';
import { connectionManager } from '../../machine/ConnectionManager';
+import { ZERO_OFFSET_ACCEPT_BEATS, directGcodeQuiet, judgeOffsetReport } from '../positionOfRecord';
import { McpToolError, ToolRegistry } from '../registry';
const MACHINES = [
@@ -122,6 +123,8 @@ export interface PositionSnapshot {
isHomed: boolean | null;
machineStatus: string | null;
reportAgeMs: number;
+ /** The beat's own timestamp (ms epoch) - compare beats with this, never with Date.now() - reportAgeMs (1 ms jitter made one beat look like two). */
+ reportedAt: number;
convention: string;
warnings: string[];
}
@@ -155,6 +158,22 @@ export function assertFreshHeartbeat(what: string): void {
* capture tools. Throws McpToolError when unavailable.
*/
let lastKnownOriginOffset: { x: number; y: number; z: number; at: number } | null = null;
+// Zero-offset transient tracking (see getPositionSnapshot / judgeOffsetReport).
+let zeroOffsetStreak = 0;
+let zeroOffsetSeenAt: number | null = null;
+let zeroOffsetBeats = 0;
+const ZERO_OFFSET_QUIET_MS = 3000;
+
+/** Diagnostics: how the origin offset is currently being resolved. */
+export function originOffsetDiagnostics() {
+ return {
+ lastKnownOriginOffset,
+ zeroOffsetStreak,
+ zeroOffsetAcceptBeats: ZERO_OFFSET_ACCEPT_BEATS,
+ /** Snapshots that set a zero-offset report aside as a G53-window transient. */
+ zeroOffsetTransientsSeen: zeroOffsetBeats,
+ };
+}
export function getPositionSnapshot(): PositionSnapshot {
const status = connectionManager.getConnectionStatus();
@@ -192,20 +211,49 @@ export function getPositionSnapshot(): PositionSnapshot {
y: axisValue(originOffset.y),
z: axisValue(originOffset.z),
};
- let offsetSource: 'heartbeat' | 'cached' | 'assumed-zero' = 'heartbeat';
- let offset: { x: number; y: number; z: number };
- if (reported.x !== null && reported.y !== null && reported.z !== null) {
- offset = { x: reported.x, y: reported.y, z: reported.z };
- lastKnownOriginOffset = { ...offset, at: state.timestamp };
- } else if (lastKnownOriginOffset) {
- offset = { x: lastKnownOriginOffset.x, y: lastKnownOriginOffset.y, z: lastKnownOriginOffset.z };
- offsetSource = 'cached';
+ // Zero-offset transient (G53-window beat: offsets read 0,0,0 and pos is
+ // in machine coordinates) - see positionOfRecord.judgeOffsetReport. A
+ // zero that contradicts the cached non-zero offset counts one streak per
+ // DISTINCT beat and is believed only once the streak reaches
+ // ZERO_OFFSET_ACCEPT_BEATS.
+ // Only QUIET beats count towards believing a zero offset: while direct
+ // gcode is in flight (or replied within the last ZERO_OFFSET_QUIET_MS) a
+ // zero is the G53-window artefact by construction - a scan stepping
+ // every second produced runs of them and the 3-beat streak accepted the
+ // zero 28 times in one run (2026-09-05). A real re-zero from the
+ // touchscreen happens with the machine idle and is believed after 3
+ // quiet beats (~6 s).
+ const reportedAllZero = reported.x === 0 && reported.y === 0 && reported.z === 0;
+ if (reportedAllZero) {
+ if (zeroOffsetSeenAt !== state.timestamp) {
+ zeroOffsetSeenAt = state.timestamp;
+ if (directGcodeQuiet(ZERO_OFFSET_QUIET_MS)) {
+ zeroOffsetStreak += 1;
+ } else {
+ zeroOffsetStreak = 0;
+ }
+ }
+ } else {
+ zeroOffsetStreak = 0;
+ zeroOffsetSeenAt = null;
+ }
+ const cached = lastKnownOriginOffset ? { x: lastKnownOriginOffset.x, y: lastKnownOriginOffset.y, z: lastKnownOriginOffset.z } : null;
+ const judged = judgeOffsetReport(reported, cached, zeroOffsetStreak);
+ const offset = judged.offset;
+ const offsetSource = judged.source;
+ if (judged.cache && (judged.source === 'heartbeat')) {
+ lastKnownOriginOffset = { ...judged.cache, at: state.timestamp };
+ }
+ if (judged.transientZero) {
+ zeroOffsetBeats += 1;
+ warnings.push('The latest heartbeat reports a zero work-origin offset while this connection has seen '
+ + `(${offset.x}, ${offset.y}, ${offset.z}) - a G53-window beat (pos is then in machine coordinates); `
+ + `using the last complete offset (zero would be believed after ${ZERO_OFFSET_ACCEPT_BEATS} consecutive beats).`);
+ } else if (judged.source === 'cached' && lastKnownOriginOffset) {
warnings.push('The latest heartbeat carried no work-origin offset; machine coordinates use the '
+ `last complete offset (${offset.x}, ${offset.y}, ${offset.z}) seen ${((state.timestamp - lastKnownOriginOffset.at) / 1000).toFixed(1)}s earlier. `
+ 'Re-read before trusting a position check.');
- } else {
- offset = { x: reported.x || 0, y: reported.y || 0, z: reported.z || 0 };
- offsetSource = 'assumed-zero';
+ } else if (judged.source === 'assumed-zero') {
warnings.push('No work-origin offset has been reported on this connection yet; machine coordinates '
+ 'ASSUME a zero offset and may be wrong - query_firmware_position and re-verify.');
}
@@ -252,6 +300,7 @@ export function getPositionSnapshot(): PositionSnapshot {
isHomed: (state as { isHomed?: boolean }).isHomed ?? null,
machineStatus: (state as { status?: string }).status || null,
reportAgeMs,
+ reportedAt: state.timestamp,
convention: 'machine = work - originOffset; heartbeat reports work coordinates',
warnings,
};
diff --git a/src/server/services/mcp/tools/probing.ts b/src/server/services/mcp/tools/probing.ts
index 78adfeef30..0d7d7d5cc4 100644
--- a/src/server/services/mcp/tools/probing.ts
+++ b/src/server/services/mcp/tools/probing.ts
@@ -50,7 +50,7 @@ export function registerProbingTools(registry: ToolRegistry, getConfirmBaseUrl:
type: 'number',
description: 'REQUIRED hard travel limit (1-150): the march aborts here without contact.',
},
- coarse_step_mm: { type: 'number', description: 'Coarse step, default 1 (0.2-2).' },
+ coarse_step_mm: { type: 'number', description: 'Coarse step, default 1 (0.2-1; never larger - the coarse step is also the press into the probe).' },
fine_step_mm: { type: 'number', description: 'Fine step, default 0.1 (0.02-0.5).' },
backoff_mm: { type: 'number', description: 'Confirm-cycle lift, default 1 (also the confirm re-contact window).' },
sensor_delay_ms: { type: 'number', description: 'Contact-check window per step, default 300.' },
@@ -111,7 +111,7 @@ ${describeProbePlanAsGcode(plan)}`;
description: 'REQUIRED hard travel limit (1-150) along the vector: the march aborts '
+ 'there without contact. Clamped so the whole segment stays in the machine envelope.',
},
- coarse_step_mm: { type: 'number', description: 'Coarse step, default 1 (0.2-2).' },
+ coarse_step_mm: { type: 'number', description: 'Coarse step, default 1 (0.2-1; never larger - the coarse step is also the press into the probe).' },
fine_step_mm: { type: 'number', description: 'Fine step, default 0.1 (0.02-0.5).' },
backoff_mm: { type: 'number', description: 'Confirm-cycle lift, default 1 (also the confirm re-contact window).' },
sensor_delay_ms: { type: 'number', description: 'Contact-check window per step, default 300.' },
@@ -354,11 +354,34 @@ ${describeProbeCirclePlanAsGcode(plan)}`;
description: 'How far below the previous contact one station may search before recording no_contact. '
+ 'Default 40, cap 80 (also bounded by floor_z_machine).',
},
- coarse_step_mm: { type: 'number', description: 'Coarse -Z step for every march, default 1 (0.2-2). 2 is faster on a known-flat surface.' },
+ coarse_step_mm: {
+ type: 'number',
+ description: 'Coarse -Z step for every march, default 1, range 0.5-1 (operator law: never larger - the '
+ + 'coarse step is ALSO the worst-case press into the probe wherever the surface is found by a coarse '
+ + 'step, because the controller finishes the step before the runner sees the sensor; inside the slow '
+ + 'zone the press is one fine step instead). Values above 1 are clamped to 1.',
+ },
fine_step_mm: { type: 'number', description: 'Fine step, default 0.1 (0.02-0.5).' },
backoff_mm: { type: 'number', description: 'Confirm-cycle lift, default 1 (also the confirm re-contact window).' },
- sensor_delay_ms: { type: 'number', description: 'Contact-check window per step, default 300 (GPIO transport: ~50 is enough).' },
+ sensor_delay_ms: {
+ type: 'number',
+ description: 'Contact-check window per step, default 300, floor 30 (GPIO transport: 50 is ample - the trigger led '
+ + 'the controller reply on every contact measured).',
+ },
confirm_passes: { type: 'number', description: 'Lift-and-retest cycles per station, default 3 (1-10).' },
+ slow_zone_mm: {
+ type: 'number',
+ description: 'Coarse steps stop this far ABOVE the expected contact (the previous station\'s Z; '
+ + 'expected_z_machine for station 1) and fine steps take over, down to slow_zone + 2 x coarse below it '
+ + '(coarse resumes lower). Caps the press into the probe at one fine step where the surface is where '
+ + 'expected. Default 1, min 0.3, max z_safe_delta_mm.',
+ },
+ expected_z_machine: {
+ type: 'number',
+ description: 'Optional: toolhead machine Z of a MEASURED neighbouring contact (probe_point -Z, a probe_sequence '
+ + 'centre, an earlier scan) so station 1 gets the slow zone too. Never inferred (law 3). Without it '
+ + 'station 1 uses coarse steps capped at 1 mm.',
+ },
reason: { type: 'string', description: 'Shown to the operator: what surface is being scanned and why.' },
};
const stageSurfaceScan = (plan: ProbeSurfacePlan, reason: string, label: string) => {
@@ -389,8 +412,8 @@ ${describeProbeSurfacePlanAsGcode(plan)}`;
name: 'probe_surface_path',
description: 'Stage a TOP-SURFACE FLATNESS scan along a straight line for human confirmation: N stations '
+ 'from a start point to an end point (or direction + length), spaced by count or maximum spacing, '
- + 'each measured with a -Z sensor-gated march of the spindle touch probe (coarse to contact, release, '
- + 'fine, lift-and-retest confirm, median). Result per station: machine XYZ of contact or no_contact; '
+ + 'each measured with a -Z sensor-gated march of the spindle touch probe (coarse towards the expected contact, fine steps in a slow zone, '
+ + 'lift-and-retest confirm, median). Result per station: machine XYZ of contact or no_contact; '
+ 'plus Z min/max/range, the best-fit line (slope in mm per 100 mm and degrees, rise over the length) '
+ 'and flatness as residual peak-to-valley, and a text profile. Purpose: level/flatness of stock along a '
+ `line, e.g. along a rotary-mounted board. ${SURFACE_ENVELOPE_TEXT}`,
@@ -430,7 +453,7 @@ ${describeProbeSurfacePlanAsGcode(plan)}`;
name: 'probe_surface_grid',
description: 'Stage a TOP-SURFACE HEIGHT MAP for human confirmation: a serpentine grid of -Z touches of the '
+ 'spindle touch probe over a region (x/y extents, or centre + size; sampled by maximum pitch or by '
- + 'x_count/y_count), every station a sensor-gated march (coarse to contact, release, fine, '
+ + 'x_count/y_count), every station a sensor-gated march (coarse towards the expected contact, fine steps in a slow zone, '
+ 'lift-and-retest confirm, median). Result: per-station machine XYZ or no_contact, a zMatrix '
+ '(rows = ys ascending, cols = xs ascending, null = no contact) with its coordinates, Z min/max/range, '
+ 'the best-fit plane (tilt X/Y in mm per 100 mm and degrees) with per-point residuals and flatness '
diff --git a/src/server/services/mcp/tools/status.ts b/src/server/services/mcp/tools/status.ts
index a1c441cfd6..19e0e12bc8 100644
--- a/src/server/services/mcp/tools/status.ts
+++ b/src/server/services/mcp/tools/status.ts
@@ -1,5 +1,10 @@
import { connectionManager } from '../../machine/ConnectionManager';
+import { diagnosticsSnapshot } from '../diagnostics';
+import { getPositionOfRecord, getTrustedOffset } from '../positionOfRecord';
+import { probeFeedService } from '../probeFeed';
import { ToolRegistry } from '../registry';
+import { currentGcodeSequence } from './camera';
+import { originOffsetDiagnostics } from './machine';
// Seed tool: read-only report of the machine connection. Proves the bridge
// from the MCP endpoint to ConnectionManager; every later tool (#8-#13)
@@ -18,4 +23,28 @@ export function registerStatusTools(registry: ToolRegistry): void {
return connectionManager.getConnectionStatus();
},
});
+
+ registry.register({
+ name: 'get_mcp_diagnostics',
+ description: 'Timing evidence for slow or aborted procedures, read-only: server event-loop stalls, '
+ + 'machine heartbeat cadence/gaps/frame flips, direct-gcode pacing (exec and idle ms), sensor pipe '
+ + 'latency, the probe feed status and the motion engine\'s current position of record. The same '
+ + 'signals appear as job events (event_loop_stall, heartbeat_gap, heartbeat_frame_flip, slow_step, '
+ + 'sense_overrun, position-estimated, and idleMs/execMs on gcode events) so read '
+ + 'get_gcode_job_status first and use this for the totals.',
+ inputSchema: {
+ type: 'object',
+ properties: {},
+ additionalProperties: false,
+ },
+ handler: async () => {
+ return {
+ ...diagnosticsSnapshot(),
+ probeFeed: probeFeedService.status(),
+ positionOfRecord: getPositionOfRecord(currentGcodeSequence()),
+ originOffset: { ...originOffsetDiagnostics(), trustedByEngine: getTrustedOffset() },
+ gcodeSequence: currentGcodeSequence(),
+ };
+ },
+ });
}
From f7cea5119a292e5e045e7cdb74983a97a6e08e3f Mon Sep 17 00:00:00 2001
From: tyeth
Date: Sun, 6 Sep 2026 00:04:24 +0100
Subject: [PATCH 060/135] Feature: probe_program composite survey, job timing
breakdown, first-station floor
Operator request after the four-face rotary survey (18 approvals, ~75 min):
show where each procedure's time goes, and make the whole survey ONE
approved operation.
- probe_program (probeProgram.ts, programRefs.ts): an ordered list of
operations - rotate_b (absolute B on the direct path, refused unless the
toolhead is at/above the safe traverse height, verified by M114 or the
heartbeat's b), surface_path, surface_grid and sequence with their
standalone arguments - staged once, approved on ONE confirm page that
enumerates every op's envelope and the B schedule, run by one runner that
hands the machine from op to op (each ends raised at the traverse
height). Numbers an op cannot know at staging are REFERENCES to earlier
results ({from: "c90.top.z", plus: 7, between: [200, 240]}) with
operator-approved bounds required (law 3): the page shows the bounds and
a preview at the mid-point, the runner refuses the op outside them,
stops the program raised and keeps earlier results under result.ops.
on_fail: skip records a failure and continues; rotations always stop.
- jobTiming.ts (pure): per command kind the count, feeds, distance,
controller time, motion time (distance / feed), per-batch overhead
(~270 ms measured: four HTTP lines per batch), idle and sensor windows;
per station the wall time and kind breakdown; approval wait, run time,
median station; waits. Attached to completed procedure results as
result.timing; get_job_timing returns it for running and failed jobs.
- Surface scans: station 1 searches down to an explicit floor_z_machine
(job d7ac9247838e aborted because only start_z - max_drop was honoured).
- docs/COMPOSITE_PROBE_PROGRAM.md: the on-box GPT-5.6 agent's brief with
its implemented items marked; README timing table and levers; cnc-probing
skill: programs, references, result.timing.
Co-Authored-By: Claude Fable 5.1
---
.claude/skills/cnc-probing/SKILL.md | 27 ++
src/server/services/mcp/README.md | 48 ++-
.../mcp/docs/COMPOSITE_PROBE_PROGRAM.md | 76 ++++
src/server/services/mcp/jobTiming.ts | 332 +++++++++++++++++
src/server/services/mcp/probeProgram.ts | 345 ++++++++++++++++++
src/server/services/mcp/probeSurface.ts | 23 +-
src/server/services/mcp/probing.ts | 54 ++-
src/server/services/mcp/programRefs.ts | 136 +++++++
src/server/services/mcp/tools/gcode.ts | 36 +-
src/server/services/mcp/tools/probing.ts | 70 ++++
10 files changed, 1139 insertions(+), 8 deletions(-)
create mode 100644 src/server/services/mcp/docs/COMPOSITE_PROBE_PROGRAM.md
create mode 100644 src/server/services/mcp/jobTiming.ts
create mode 100644 src/server/services/mcp/probeProgram.ts
create mode 100644 src/server/services/mcp/programRefs.ts
diff --git a/.claude/skills/cnc-probing/SKILL.md b/.claude/skills/cnc-probing/SKILL.md
index f2984693f8..e271fd8a3b 100644
--- a/.claude/skills/cnc-probing/SKILL.md
+++ b/.claude/skills/cnc-probing/SKILL.md
@@ -236,6 +236,33 @@ result says `approach: slow-zone | coarse-contact` and `worstPressMm`. `coarse_s
is capped at 1 mm in surface scans (operator law: never 2; 0.5–1). On the GPIO
transport `sensor_delay_ms: 50` is ample.
+## Whole-stock programs (one approval)
+
+`probe_program` strings operations into ONE approved job: `rotate_b` (absolute B;
+the runner refuses it unless the toolhead is at/above the traverse height),
+`surface_path`, `surface_grid` and `sequence` with their usual arguments. Where a
+later op needs a number only an earlier op can measure, pass a REFERENCE with
+operator-approved bounds - `expected_z_machine: {"from": "c90.top.z", "between":
+[200, 240]}` (sequence op `c90`, probe named `top`), `start_z_machine: {"from":
+"c90.top.z", "plus": 7, "between": [205, 250]}`, a sequence `descend` `z: {"from":
+"ns90.summary.zMean", "minus": 7, "between": [...]}`. Bounds are required (law 3):
+the confirm page shows them, and the runner refuses the op outside them, stops the
+program raised and keeps earlier results under `result.ops`. Order ops so every
+reference points backwards; `on_fail: "skip"` lets an overtravel-type op fail
+without ending the program. A four-face survey is `rotate_b 90 → centre sequence →
+N-S path → W-E path → sides sequence → rotate_b 180 → …`; budget the event log
+(≈ 100 + stations × 120 per scan op) before staging and use
+`wait_for_approval_ms` to start.
+
+**Speed.** Read `result.timing` (or `get_job_timing`) after a scan instead of mining
+events: per command kind it gives count, feeds, distance, controller time vs motion
+time vs overhead, idle and sensor windows, plus per-station wall time. The coarse walk
+down from the hop height is the biggest cost (~19 of 30 commands per station at
+`z_safe_delta_mm` 20), so on stock known to vary < 5 mm between stations use
+`z_safe_delta_mm: 5`, `confirm_passes: 2` and `sensor_delay_ms: 30` on GPIO (an
+11-station path ~400 s → ~170 s). Never raise the coarse feed yourself: impact speed is
+the operator's call.
+
**Event-log budget — check it BEFORE staging a large scan.** The job keeps at most
`mcpJobEventLimit` events (default 2000; `get_mcp_diagnostics` → `buffers` and the
Settings pane show the live value). Beyond that the log keeps its first 20 events
diff --git a/src/server/services/mcp/README.md b/src/server/services/mcp/README.md
index dac9f2f7fc..cc12928d84 100644
--- a/src/server/services/mcp/README.md
+++ b/src/server/services/mcp/README.md
@@ -271,6 +271,52 @@ surface scans** (operator, job cdbc29371b97: "we never wanted 2mm"; range 0.5–
Verified on the rerun (cdbc29371b97, 8/8, same numbers as d8f6): every station
`slow-zone`, `worstPressMm 0.1`, 408 s vs 492 s.
+**Where the time goes (measured 2026-09-05, on-box analysis of the four-face survey).** Every
+command batch costs ~270 ms over its motion time on the WiFi channel (four HTTP lines per
+batch); motion time is distance / feed, so short steps are dominated by that overhead.
+
+| Step | Feed | Motion | Measured per command |
+|---|---|---|---|
+| Coarse 1 mm | F100 | 600 ms | ~874 ms |
+| Fine 0.1 mm | F60 | 100 ms | ~365 ms |
+| Backoff 0.3 mm | F60 | 300 ms | ~574 ms |
+| Retract 20 mm | F600 | 2000 ms | ~2280 ms |
+| Descent segment 5 mm | F600 | 500 ms | ~834 ms |
+| Raise 120 mm | F600 | 12 s | ~12.4 s |
+
+An 11-station path at 402 s split as: 179 s coarse steps, 51 s fine, 35 s confirm, 26 s
+retracts, 20 s hops, 18 s station-1 guard band, 17 s backoffs, 14 s initial descent, 16 s
+traverse + final raise, 25 s of sensor windows; ~31.5 s per station of which 19 of the 30
+commands are the coarse walk back down from the 20 mm hop height. Levers, no code needed:
+`z_safe_delta_mm` 20 → 5 (~145 s per scan; the hop guard still catches a surface rising > 5 mm),
+`confirm_passes` 2 (~19 s), `sensor_delay_ms` 30 (~8 s); coarse feed F100 → F300 (~80 s) is an
+operator call on impact speed. Code levers: `get_job_timing` / `result.timing` now compute this
+breakdown from any job's events (per kind: count, feeds, distance, controller vs motion vs
+overhead, idle, sensor windows; per station; waits). One-line `G53 G1 …` (three lines per
+batch, no workspace switch, ~100 s per scan) is NOT used: it is unverified on this firmware and
+an unsupported inline G53 would execute in WORK coordinates. Station 1 of a surface scan now searches down to an explicit `floor_z_machine` (job
+d7ac9247838e aborted because only start − max_drop was honoured).
+
+**`probe_program` — one approval for a whole survey (2026-09-06).** An ordered list of
+operations, staged once and approved on ONE confirm page that enumerates every op's envelope
+and the B rotation schedule, run by one runner that hands the machine from op to op (each ends
+raised at the traverse height): `rotate_b` (absolute B on the direct path, refused unless the
+toolhead is at/above the safe traverse height, verified by the M114 in the same batch or the
+heartbeat's `b`), `surface_path`, `surface_grid` and `sequence` with their standalone
+arguments. Numbers an op cannot know at staging are **references** to earlier results —
+`{from: "c90.top.z", plus: 7, between: [200, 240]}` (a sequence probe by name, `.z` shorthand
+for `contactMachine.z`) or `{from: "ns90.summary.zMean", minus: 7, between: [...]}` — with
+operator-approved **bounds required** (law 3): the page shows the bounds and a preview plan at
+the mid-point, and the runner refuses the op if the resolved value falls outside them,
+stopping the program raised and keeping every earlier result under `result.ops`. `on_fail:
+"skip"` records a failure and continues (rotations always stop). Each op result is the
+standalone tool's result object. The four-face survey that took 18 approvals is one program:
+`rotate_b 90 → sequence (centre) → surface_path N–S (expected from the centre) → surface_path
+W–E → sequence (sides) → rotate_b 180 → …`. Still to do from the brief
+([docs/COMPOSITE_PROBE_PROGRAM.md](docs/COMPOSITE_PROBE_PROGRAM.md)): multi-segment paths in
+one op (stage two path ops for now), tip diameter as configuration, the stock-geometry
+reduction (section size, centring, yaw), live progress on the confirm page.
+
## Safety model (operator-defined, non-negotiable)
- **Compound motion and all cutting goes out as gcode FILES** through the same
@@ -481,7 +527,7 @@ Full agent guidance in `.claude/skills/tool-change/SKILL.md`.
operator to close their copy. The build dirties `src/package.json` and
`MaterialTestGcodeParams.jsx` — revert before staging.
- **Stack**: stacked single-commit PRs `mcp/N-*`, each targeting the previous branch
- (#15 → #79 as of 2026-09-05, then `mcp/46-position-of-record`; next `mcp/47`. #70 stale-
+ (#15 → #79 as of 2026-09-05, then `mcp/46-position-of-record` (#80) and `mcp/47-timing-inline-g53`; next `mcp/48`. #70 stale-
heartbeat, #71 probe_sequence, #72 GPIO probe feed, #73 Linux/mac packaging, #74 machine
settings + docs, #75 sensor toggles + LAN, #76 job events + even survey, #77 offset
transient + detached procedures, #78 surface scans, #79 console input leak, mcp/46 position
diff --git a/src/server/services/mcp/docs/COMPOSITE_PROBE_PROGRAM.md b/src/server/services/mcp/docs/COMPOSITE_PROBE_PROGRAM.md
new file mode 100644
index 0000000000..511ae87586
--- /dev/null
+++ b/src/server/services/mcp/docs/COMPOSITE_PROBE_PROGRAM.md
@@ -0,0 +1,76 @@
+
+
+# Proposal: one approved operation for a multi-rotation stock survey
+
+Goal: stage ONCE, operator approves ONCE, runner performs: for B in [0, 90, 180, 270]:
+rotate -> centre probe -> N-S top scan (overtravel past the end) -> W-E top scan (both edges);
+plus horizontal side + end marches at two rotations. Today this is 18 approvals (~75 min wall).
+
+## What the tooling lacks today (each item is a concrete gap hit on 2026-09-05)
+1. **A composite procedure job (`probe_program`)**: ordered `ops[]`, each op one of
+ `rotate_b | surface_path | surface_grid | sequence` with its own envelope; ONE confirm page
+ enumerating every op's envelope (extents, floors, hop heights, B targets); ONE runner;
+ partial results kept per op on abort. Existing runners (probeSurface, probeSequence) become
+ callable op executors sharing the position-of-record and the crash guard.
+2. **B rotation inside a procedure** (`rotate_b` op): today only a file job can move B (Z returns
+ to top, door interlock, separate approval). Needs: direct guarded `G0 B` via the same
+ path as moveMachineSettled, verified-settle on B (M114 B within 0.01 of target - heartbeat
+ `b` lags ~1 beat), precondition toolhead Z >= traverse Z (320) or XY outside a declared
+ sweep radius, and the confirm page saying "stock WILL rotate to B90/180/270".
+3. **Run-time references between ops**: `expected_z_machine: {from: "centre_b90"}`,
+ `start_z_machine: {from: "centre_b90", plus: 7}`, side-march Z `{from: "ns_b180.zMean",
+ minus: 7}`. Staging cannot know the value, so each reference carries operator-approved
+ BOUNDS (`between: [195, 235]`); the runner refuses the op if the resolved value falls
+ outside, raises, and stops. The confirm page shows the bounds, not a number.
+4. **First-station search window**: station 1 aborts unless a surface lies within max_drop_mm
+ of start_z even when floor_z_machine is explicitly lower (d7ac9247838e). Add
+ `first_station_search_mm` (bounded by the floor) or honour the explicit floor for station 1
+ only when no expected_z is given. With (3) this is mostly moot but still a footgun.
+5. **Multi-segment paths in one op**: `segments: [{start, end}, ...]` sharing a reference, so
+ W-E from the measured centre outward to both edges is one op (today two jobs, or an
+ off-stock first station aborts - 1dc39a8210a4).
+6. **Tip radius as configuration** (`probeTipDiameterMm` in the tool-setter config): side and
+ end marches report contact centre AND corrected face; width/thickness/end position come out
+ corrected. Unpinned today (~2.5 mm per README, inconsistent).
+7. **Stock-geometry reduction in the result**: faces keyed by B; per face: mean/slope/flatness;
+ across faces: section dimensions (opposite-face pair means), axis height, centring offsets,
+ yaw and pitch of the stock centreline (from side pairs and pair-mean slopes), end squareness.
+ All the arithmetic done by hand in REPORT-four-face-scan-2026-09-05.md.
+8. **Event budget**: a full program is ~6,000-8,000 events at today's verbosity (537 batches x
+ 2 events per 11-station scan + readings). Either per-op event logs, or `mcpJobEventLimit`
+ default 10,000, or drop the per-batch `response` payload behind a verbosity flag. Summaries
+ and per-op results must never be trimmed.
+9. **Abort semantics**: any op failure (no contact at station 1, hop-guard contact, crash
+ alarm, B settle failure) -> raise to traverse Z, mark the op failed, stop the program, keep
+ all earlier op results. Optional `on_fail: skip|stop` per op for overtravel-type ops.
+10. **Confirm page for long programs**: live progress (op k of n, station, ETA from the timing
+ table), the B schedule, total extents, and the token TTL is irrelevant once
+ wait_for_approval_ms hands off - but the page should keep working as a monitor for the
+ ~40-70 min run.
+11. **Speed knobs exposed per op** (from the timing analysis): z_safe_delta_mm (use 5), coarse
+ feed (F100 -> F300, operator-gated default), single G53 window per march, confirm_passes 2,
+ sensor_delay 30. With these the full program is ~35-40 min instead of ~75.
+
+## Sketch of the request
+```json
+{"name":"four-face survey","ops":[
+ {"id":"rot90","kind":"rotate_b","b":90,"require_z_at_least":320},
+ {"id":"c90","kind":"sequence","steps":[{"kind":"hop","x":170,"y":199},{"kind":"descend","z":235},
+ {"kind":"probe","name":"top","dz":-1,"max_travel_mm":40}]},
+ {"id":"ns90","kind":"surface_path","start_x":170,"start_y":262,"end_x":170,"end_y":120,"spacing_mm":15,
+ "expected_z_machine":{"from":"c90.top.z","between":[195,235]},"start_z_machine":{"from":"c90.top.z","plus":7},
+ "max_drop_mm":10,"z_safe_delta_mm":5},
+ {"id":"we90","kind":"surface_path","segments":[{"start":[170,199],"end":[115,199]},{"start":[170,199],"end":[225,199]}],
+ "spacing_mm":14,"expected_z_machine":{"from":"c90.top.z"},"start_z_machine":{"from":"c90.top.z","plus":7}},
+ {"id":"sides90","kind":"sequence","steps":[{"kind":"hop","x":118,"y":150},{"kind":"descend","z":{"from":"c90.top.z","minus":7,"between":[195,230]}},
+ {"kind":"probe","name":"west_y150","dx":1,"max_travel_mm":25}, "..."]},
+ {"id":"rot180","kind":"rotate_b","b":180}, "..."]}
+```
+Laws preserved: the confirm page is still the single motion gate (law 6); every XY move is at
+traverse height or inside the approved station envelope (law 2); every number is measured,
+operator-stated, or a bounded reference to a measurement made earlier in the same approved
+program (law 3); rotations are enumerated on the page (law 1's "no inferred approvals").
diff --git a/src/server/services/mcp/jobTiming.ts b/src/server/services/mcp/jobTiming.ts
new file mode 100644
index 0000000000..70acb69339
--- /dev/null
+++ b/src/server/services/mcp/jobTiming.ts
@@ -0,0 +1,332 @@
+import { JobEvent } from './jobs';
+
+// Where a procedure's time went, computed from its own event log (pure, so an
+// agent gets the breakdown from get_gcode_job_status / get_job_timing instead
+// of mining events by hand - operator request 2026-09-05 after the four-face
+// scan analysis). Every direct command is one `gcode` send event (tool,
+// gcode, idleMs, senseMs...) followed by its reply event (execMs, response);
+// the runner's phase events (hop-