agentic-numerical-control 0.1.5 → 0.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +44 -44
- package/dist/jobs.html +137 -0
- package/dist/machine.html +164 -0
- package/dist/server/analysis.js +356 -0
- package/dist/server/autostart.js +18 -18
- package/dist/server/cncjs.js +96 -1
- package/dist/server/history.js +48 -0
- package/dist/server/jobs.js +60 -3
- package/dist/server/machines.js +96 -0
- package/dist/server/program.js +14 -0
- package/dist/server/server.js +365 -52
- package/dist/setup.html +156 -0
- package/dist/viewer.html +299 -265
- package/package.json +73 -73
- package/product.json +8 -8
- package/samples/demo-pocket-profile.nc +130 -130
- package/sim/virtual-port.cjs +134 -134
package/dist/server/server.js
CHANGED
|
@@ -1,16 +1,31 @@
|
|
|
1
1
|
import { RESOURCE_MIME_TYPE, registerAppResource, registerAppTool, } from "@modelcontextprotocol/ext-apps/server";
|
|
2
2
|
import { McpServer, } from "@modelcontextprotocol/server";
|
|
3
3
|
import fs from "node:fs/promises";
|
|
4
|
+
import os from "node:os";
|
|
4
5
|
import path from "node:path";
|
|
5
6
|
import { z } from "zod";
|
|
6
|
-
import { currentJob, jobSummary, jobsDir, listJobFiles, loadJobFile, loadJobFromCncjs, loadJobText, parsedLineAt, } from "./jobs.js";
|
|
7
|
-
import { ROOT, VERSION } from "./product.js";
|
|
7
|
+
import { currentJob, deleteJobFile, jobFileInfo, jobSummary, jobsDir, listJobFiles, loadJobFile, loadJobFromCncjs, loadJobText, parsedLineAt, } from "./jobs.js";
|
|
8
|
+
import { PRODUCT, ROOT, VERSION } from "./product.js";
|
|
8
9
|
import { CNCJS_URL, cncjs } from "./cncjs.js";
|
|
10
|
+
import { forgetRun, lastRun, recordRun } from "./history.js";
|
|
11
|
+
import { analyzeGcode } from "./analysis.js";
|
|
12
|
+
import { listMachines, machineName, recordMachine, recordMachineRun, renameMachine } from "./machines.js";
|
|
9
13
|
// The viewer follows whatever program CNCjs has loaded, including files
|
|
10
14
|
// loaded from the CNCjs interface itself.
|
|
11
15
|
cncjs.onGcodeLoad((name, gcode) => loadJobFromCncjs(name, gcode));
|
|
12
|
-
|
|
13
|
-
|
|
16
|
+
cncjs.onRunEnd(recordRun);
|
|
17
|
+
cncjs.onRunEnd((run) => recordMachineRun(cncjs.snapshot().port, run));
|
|
18
|
+
cncjs.onMachine(recordMachine);
|
|
19
|
+
// Each widget is one bundled HTML file in dist/ (see app/).
|
|
20
|
+
const widgetUri = (name) => `ui://anc/${name}.html`;
|
|
21
|
+
const VIEWER_URI = widgetUri("viewer");
|
|
22
|
+
const JOBS_URI = widgetUri("jobs");
|
|
23
|
+
const MACHINE_URI = widgetUri("machine");
|
|
24
|
+
const WIDGETS = [
|
|
25
|
+
{ name: "viewer", description: "Agentic Numerical Control toolpath viewer" },
|
|
26
|
+
{ name: "jobs", description: "Agentic Numerical Control jobs list" },
|
|
27
|
+
{ name: "machine", description: "Agentic Numerical Control machine connection" },
|
|
28
|
+
];
|
|
14
29
|
// Base64 grows by 4/3: 384 KiB of binary is ~512 KiB per tool result, which
|
|
15
30
|
// keeps each relayed message under the cloud WebSocket's 1 MiB limit.
|
|
16
31
|
const CHUNK_BYTES = 384 * 1024;
|
|
@@ -20,11 +35,102 @@ function json(value) {
|
|
|
20
35
|
// been reported to strip structuredContent for local servers.
|
|
21
36
|
return { content: [{ type: "text", text: JSON.stringify(value) }] };
|
|
22
37
|
}
|
|
38
|
+
/** Checks the job against the connected machine's own settings and current zero, when there is one. */
|
|
39
|
+
function machineContext() {
|
|
40
|
+
const m = cncjs.snapshot();
|
|
41
|
+
const settings = cncjs.controllerSettings();
|
|
42
|
+
const context = {
|
|
43
|
+
controller: m.port ? m.controller : null,
|
|
44
|
+
settings: settings?.values ?? null,
|
|
45
|
+
version: settings?.version ?? null,
|
|
46
|
+
mpos: m.mpos,
|
|
47
|
+
wpos: m.wpos,
|
|
48
|
+
};
|
|
49
|
+
// What the check depends on: the machine, its settings and its current zero.
|
|
50
|
+
const offset = m.mpos && m.wpos ? [m.mpos.x - m.wpos.x, m.mpos.y - m.wpos.y, m.mpos.z - m.wpos.z].map((n) => n.toFixed(2)).join(",") : "";
|
|
51
|
+
const key = `${context.controller ?? ""}|${settings ? Object.keys(settings.values).length : 0}|${offset}`;
|
|
52
|
+
return { context, key };
|
|
53
|
+
}
|
|
54
|
+
function analyzeJob(job) {
|
|
55
|
+
return analyzeGcode(job.gcode, machineContext().context);
|
|
56
|
+
}
|
|
57
|
+
let boundsCache = null;
|
|
58
|
+
function jobBounds(job) {
|
|
59
|
+
if (boundsCache?.revision !== job.revision)
|
|
60
|
+
boundsCache = { revision: job.revision, bounds: analyzeGcode(job.gcode).bounds };
|
|
61
|
+
return boundsCache.bounds;
|
|
62
|
+
}
|
|
63
|
+
/** A machine record in plain terms: Grbl's numbered settings become named values. */
|
|
64
|
+
function describeMachine(m, live) {
|
|
65
|
+
const n = (k) => {
|
|
66
|
+
const v = Number(m.settings?.[`$${k}`]);
|
|
67
|
+
return Number.isFinite(v) ? v : null;
|
|
68
|
+
};
|
|
69
|
+
const flag = (k) => (n(k) === null ? null : (n(k) & 1) === 1);
|
|
70
|
+
const xyz = (a) => (n(a) === null ? null : { x: n(a), y: n(a + 1), z: n(a + 2) });
|
|
71
|
+
const p = m.parameters ?? {};
|
|
72
|
+
// CNCjs passes $# values on as text: {x: "-212.400", ...} and "0.000" for TLO.
|
|
73
|
+
const num = (v) => (v === undefined || v === null || v === "" || !Number.isFinite(Number(v)) ? null : Number(v));
|
|
74
|
+
const point = (v) => {
|
|
75
|
+
const o = v;
|
|
76
|
+
return o && typeof o === "object" ? { x: num(o.x), y: num(o.y), z: num(o.z) } : null;
|
|
77
|
+
};
|
|
78
|
+
const zeros = Object.fromEntries(["G54", "G55", "G56", "G57", "G58", "G59"].filter((k) => point(p[k])).map((k) => [k, point(p[k])]));
|
|
79
|
+
const dirMask = n(23);
|
|
80
|
+
const live_ = live && live.mpos && live.wpos ? live : null;
|
|
81
|
+
return {
|
|
82
|
+
port: m.port,
|
|
83
|
+
name: machineName(m),
|
|
84
|
+
named: !!m.name,
|
|
85
|
+
simulator: m.port === "GRBL-SIM",
|
|
86
|
+
connected: !!live,
|
|
87
|
+
state: live ? (live.activeState ?? live.state) : null,
|
|
88
|
+
controller: m.controller,
|
|
89
|
+
firmware: m.firmware,
|
|
90
|
+
version: m.version,
|
|
91
|
+
baudrate: m.baudrate,
|
|
92
|
+
// Settings are null until the controller has reported them (Grbl only).
|
|
93
|
+
travel: xyz(130),
|
|
94
|
+
maxRate: xyz(110),
|
|
95
|
+
acceleration: xyz(120),
|
|
96
|
+
stepsPerMm: xyz(100),
|
|
97
|
+
homing: flag(22),
|
|
98
|
+
// Which end each axis homes to: Grbl homes towards plus unless $23 sets its bit.
|
|
99
|
+
homesTowardsMinus: dirMask === null ? null : { x: !!(dirMask & 1), y: !!(dirMask & 2), z: !!(dirMask & 4) },
|
|
100
|
+
softLimits: flag(20),
|
|
101
|
+
hardLimits: flag(21),
|
|
102
|
+
spindle: n(30) === null ? null : { min: n(31), max: n(30) },
|
|
103
|
+
laserMode: flag(32),
|
|
104
|
+
zeros,
|
|
105
|
+
activeZero: live?.wcs ?? null,
|
|
106
|
+
// Where the current zero is in machine coordinates, while connected.
|
|
107
|
+
workOffset: live_
|
|
108
|
+
? { x: live_.mpos.x - live_.wpos.x, y: live_.mpos.y - live_.wpos.y, z: live_.mpos.z - live_.wpos.z }
|
|
109
|
+
: null,
|
|
110
|
+
toolLengthOffset: num(p.TLO),
|
|
111
|
+
jobsRun: m.jobsRun,
|
|
112
|
+
cuttingMs: m.cuttingMs,
|
|
113
|
+
lastAlarm: m.lastAlarm,
|
|
114
|
+
firstSeen: m.firstSeen,
|
|
115
|
+
lastSeen: live ? new Date().toISOString() : m.lastSeen,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
23
118
|
function fail(message) {
|
|
24
119
|
return { content: [{ type: "text", text: message }], isError: true };
|
|
25
120
|
}
|
|
26
121
|
export function createServer() {
|
|
27
|
-
|
|
122
|
+
// The icon assistants show for the connector, in light and dark versions.
|
|
123
|
+
const icon = (theme) => [
|
|
124
|
+
{ src: `${PRODUCT.siteUrl}/icons/icon-${theme}.svg`, mimeType: "image/svg+xml", sizes: ["any"], theme },
|
|
125
|
+
{ src: `${PRODUCT.siteUrl}/icons/icon-${theme}-512.png`, mimeType: "image/png", sizes: ["512x512"], theme },
|
|
126
|
+
];
|
|
127
|
+
const server = new McpServer({
|
|
128
|
+
name: "agentic-numerical-control",
|
|
129
|
+
title: PRODUCT.displayName,
|
|
130
|
+
version: VERSION,
|
|
131
|
+
websiteUrl: PRODUCT.siteUrl,
|
|
132
|
+
icons: [...icon("light"), ...icon("dark")],
|
|
133
|
+
});
|
|
28
134
|
registerAppTool(server, "anc_show_job", {
|
|
29
135
|
title: "Show CNC job",
|
|
30
136
|
description: "Open the Agentic Numerical Control 3D toolpath viewer for a G-code job. Pass `file` (a name from anc_list_jobs) or `gcode` text. " +
|
|
@@ -50,25 +156,166 @@ export function createServer() {
|
|
|
50
156
|
if (!job) {
|
|
51
157
|
return fail("No job is loaded yet. Pass `file` or `gcode`; anc_list_jobs lists the files.");
|
|
52
158
|
}
|
|
53
|
-
|
|
159
|
+
const a = analyzeJob(job);
|
|
160
|
+
return json({
|
|
161
|
+
job: jobSummary(job),
|
|
162
|
+
// Headline only; anc_analyze_job has the full check.
|
|
163
|
+
check: {
|
|
164
|
+
estimatedMinutes: Math.max(1, Math.round(a.time.totalSeconds / 60)),
|
|
165
|
+
tools: a.tools,
|
|
166
|
+
fit: a.fit.status,
|
|
167
|
+
problems: a.checks.filter((c) => c.severity === "problem").map((c) => c.message),
|
|
168
|
+
toCheck: a.checks.filter((c) => c.severity === "check").length,
|
|
169
|
+
},
|
|
170
|
+
});
|
|
54
171
|
}
|
|
55
172
|
catch (e) {
|
|
56
173
|
return fail(e.message);
|
|
57
174
|
}
|
|
58
175
|
});
|
|
59
|
-
server
|
|
176
|
+
registerAppTool(server, "anc_list_jobs", {
|
|
60
177
|
title: "List CNC job files",
|
|
61
|
-
description: "List G-code files in the Agentic Numerical Control jobs folder that anc_show_job can open
|
|
178
|
+
description: "List G-code files in the Agentic Numerical Control jobs folder that anc_show_job can open, " +
|
|
179
|
+
"with each file's size, line count, dimensions and how it last ran. Shows them as a list the person can open from.",
|
|
180
|
+
inputSchema: z.object({}),
|
|
181
|
+
annotations: { readOnlyHint: true },
|
|
182
|
+
_meta: {
|
|
183
|
+
ui: { resourceUri: JOBS_URI },
|
|
184
|
+
"openai/toolInvocation/invoking": "Listing jobs…",
|
|
185
|
+
"openai/toolInvocation/invoked": "Listed jobs",
|
|
186
|
+
},
|
|
187
|
+
}, async () => {
|
|
188
|
+
try {
|
|
189
|
+
const files = await listJobFiles();
|
|
190
|
+
const mc = machineContext();
|
|
191
|
+
// `jobs` adds how each file last ran on this computer and what's in it;
|
|
192
|
+
// `files` stays for older pages.
|
|
193
|
+
const jobs = await Promise.all(files.map(async (file) => ({ ...(await jobFileInfo(file, mc.context, mc.key).catch(() => ({ file }))), lastRun: lastRun(file) })));
|
|
194
|
+
return json({ folder: jobsDir(), files, jobs });
|
|
195
|
+
}
|
|
196
|
+
catch (e) {
|
|
197
|
+
return fail(e.message);
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
registerAppTool(server, "anc_get_status", {
|
|
201
|
+
title: "Get CNC status",
|
|
202
|
+
description: "What's happening now: whether the machine is connected and its state (Idle, Run, Hold, Alarm), its position, " +
|
|
203
|
+
"the job shown in the viewer, whether CNCjs has that same program loaded, run progress, and how this job last ran. Reads only.",
|
|
204
|
+
inputSchema: z.object({}),
|
|
205
|
+
annotations: { readOnlyHint: true },
|
|
206
|
+
_meta: { ui: { visibility: ["model", "app"] } },
|
|
207
|
+
}, async () => {
|
|
208
|
+
const job = currentJob();
|
|
209
|
+
const m = cncjs.snapshot();
|
|
210
|
+
const sameProgram = !!job && !!m.loadedHash && m.loadedHash === job.hash;
|
|
211
|
+
const sender = sameProgram ? m.sender : null;
|
|
212
|
+
return json({
|
|
213
|
+
machine: {
|
|
214
|
+
connected: !!m.port,
|
|
215
|
+
port: m.port,
|
|
216
|
+
controller: m.controller,
|
|
217
|
+
link: m.link,
|
|
218
|
+
state: m.activeState ?? m.state,
|
|
219
|
+
alarm: m.alarm,
|
|
220
|
+
workflow: m.workflow,
|
|
221
|
+
workPosition: m.wpos,
|
|
222
|
+
machinePosition: m.mpos,
|
|
223
|
+
coordinateSystem: m.wcs,
|
|
224
|
+
},
|
|
225
|
+
job: job ? { name: job.name, lineCount: job.toolpath.lines.length } : null,
|
|
226
|
+
cncjs: { loadedFile: m.loadedFile, sameProgramAsViewer: sameProgram },
|
|
227
|
+
// Lines acknowledged by the controller, not finished moves: the planner may still be cutting them.
|
|
228
|
+
progress: sender
|
|
229
|
+
? { sentLines: sender.sent, acknowledgedLines: sender.received, totalLines: sender.total }
|
|
230
|
+
: null,
|
|
231
|
+
lastRun: job ? lastRun(job.name) : null,
|
|
232
|
+
canStart: m.link === "live" && m.activeState === "Idle" && m.workflow === "idle" && sameProgram
|
|
233
|
+
? "Ready. The person starts it with the Start button in the viewer (two presses)."
|
|
234
|
+
: null,
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
registerAppTool(server, "anc_analyze_job", {
|
|
238
|
+
title: "Check CNC job",
|
|
239
|
+
description: "Check a G-code job before running it: size, run-time estimate, tools and tool changes, steps, deepest cut, feed rates, " +
|
|
240
|
+
"whether it fits the machine's travel from the current zero, and problems the controller would refuse or that look risky " +
|
|
241
|
+
"(drilling cycles on Grbl, cutter compensation, cutting with the spindle off, rapids below Z 0). " +
|
|
242
|
+
"Pass `file` or `gcode` like anc_show_job, or neither for the job already shown. Opens the viewer; never moves the machine.",
|
|
243
|
+
inputSchema: z.object({
|
|
244
|
+
file: z.string().optional().describe("G-code file name in the Agentic Numerical Control jobs folder"),
|
|
245
|
+
gcode: z.string().optional().describe("G-code program text to check"),
|
|
246
|
+
name: z.string().optional().describe("Display name when passing gcode text"),
|
|
247
|
+
}),
|
|
248
|
+
annotations: { readOnlyHint: true },
|
|
249
|
+
_meta: { ui: { resourceUri: VIEWER_URI } },
|
|
250
|
+
}, async ({ file, gcode, name }) => {
|
|
251
|
+
try {
|
|
252
|
+
const job = file
|
|
253
|
+
? await loadJobFile(file)
|
|
254
|
+
: gcode
|
|
255
|
+
? loadJobText(name ?? "Untitled job", gcode)
|
|
256
|
+
: currentJob();
|
|
257
|
+
if (!job)
|
|
258
|
+
return fail("No job is loaded yet. Pass `file` or `gcode`; anc_list_jobs lists the files.");
|
|
259
|
+
return json({ job: jobSummary(job), analysis: analyzeJob(job) });
|
|
260
|
+
}
|
|
261
|
+
catch (e) {
|
|
262
|
+
return fail(e.message);
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
registerAppTool(server, "anc_list_machines", {
|
|
266
|
+
title: "List CNC machines",
|
|
267
|
+
description: "The machines this computer has connected to, with what each controller reported the last time it connected: " +
|
|
268
|
+
"firmware, port, work area (travel), maximum speeds and acceleration, homing and limits, spindle range, saved zeros (G54-G59), " +
|
|
269
|
+
"jobs run and the last alarm. Reads only.",
|
|
62
270
|
inputSchema: z.object({}),
|
|
271
|
+
annotations: { readOnlyHint: true },
|
|
272
|
+
_meta: { ui: { visibility: ["model", "app"] } },
|
|
63
273
|
}, async () => {
|
|
274
|
+
const now = cncjs.snapshot();
|
|
275
|
+
// The job shown, in mm from its zero, for the work area picture.
|
|
276
|
+
const job = currentJob();
|
|
277
|
+
const bounds = job ? jobBounds(job) : null;
|
|
278
|
+
return json({
|
|
279
|
+
computer: os.hostname(),
|
|
280
|
+
job: job && bounds ? { name: job.name, min: bounds.min, max: bounds.max } : null,
|
|
281
|
+
machines: listMachines().map((m) => describeMachine(m, now.port === m.port ? now : null)),
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
registerAppTool(server, "anc_rename_machine", {
|
|
285
|
+
title: "Name a CNC machine",
|
|
286
|
+
description: "Give a machine a name, such as its make and model. Grbl doesn't report one. An empty name goes back to the port's name.",
|
|
287
|
+
inputSchema: z.object({ port: z.string(), name: z.string().max(60) }),
|
|
288
|
+
_meta: { ui: { visibility: ["model", "app"] } },
|
|
289
|
+
}, async ({ port, name }) => {
|
|
290
|
+
try {
|
|
291
|
+
const m = renameMachine(port, name);
|
|
292
|
+
return json({ port: m.port, name: machineName(m) });
|
|
293
|
+
}
|
|
294
|
+
catch (e) {
|
|
295
|
+
return fail(e.message);
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
// ---- App-only tools: the viewer and the Jobs page call these directly, without a model turn.
|
|
299
|
+
registerAppTool(server, "anc_delete_job", {
|
|
300
|
+
title: "Delete job file",
|
|
301
|
+
description: "Delete a G-code file from the Agentic Numerical Control jobs folder.",
|
|
302
|
+
inputSchema: z.object({ file: z.string() }),
|
|
303
|
+
annotations: { destructiveHint: true },
|
|
304
|
+
_meta: { ui: { visibility: ["app"] } },
|
|
305
|
+
}, async ({ file }) => {
|
|
64
306
|
try {
|
|
65
|
-
|
|
307
|
+
const m = cncjs.snapshot();
|
|
308
|
+
if (m.loadedFile === file && m.workflow !== "idle") {
|
|
309
|
+
return fail(`"${file}" is ${m.workflow} in CNCjs. Stop it before deleting it.`);
|
|
310
|
+
}
|
|
311
|
+
const deleted = await deleteJobFile(file);
|
|
312
|
+
forgetRun(deleted);
|
|
313
|
+
return json({ deleted });
|
|
66
314
|
}
|
|
67
315
|
catch (e) {
|
|
68
316
|
return fail(e.message);
|
|
69
317
|
}
|
|
70
318
|
});
|
|
71
|
-
// ---- App-only tools: the viewer polls these directly, without a model turn.
|
|
72
319
|
registerAppTool(server, "anc_snapshot", {
|
|
73
320
|
title: "Viewer snapshot",
|
|
74
321
|
description: "Current job identity and machine link state for the Agentic Numerical Control viewer.",
|
|
@@ -98,7 +345,9 @@ export function createServer() {
|
|
|
98
345
|
: 0,
|
|
99
346
|
}
|
|
100
347
|
: null,
|
|
348
|
+
machineName: machine.port ? machineName(listMachines().find((r) => r.port === machine.port) ?? { name: null, port: machine.port, firmware: null, controller: machine.controller }) : null,
|
|
101
349
|
cncjsUrl: CNCJS_URL,
|
|
350
|
+
version: VERSION,
|
|
102
351
|
serverTime: new Date().toISOString(),
|
|
103
352
|
});
|
|
104
353
|
});
|
|
@@ -145,11 +394,44 @@ export function createServer() {
|
|
|
145
394
|
}
|
|
146
395
|
return json({ revision, start, lines: job.toolpath.lines.slice(start, start + count) });
|
|
147
396
|
});
|
|
397
|
+
registerAppTool(server, "anc_get_analysis", {
|
|
398
|
+
title: "Get job check",
|
|
399
|
+
description: "The job check for the current job, with program lines mapped to the viewer's timeline.",
|
|
400
|
+
inputSchema: z.object({ revision: z.number().int() }),
|
|
401
|
+
_meta: { ui: { visibility: ["app"] } },
|
|
402
|
+
}, async ({ revision }) => {
|
|
403
|
+
const job = currentJob();
|
|
404
|
+
if (!job || job.revision !== revision)
|
|
405
|
+
return fail(`Job revision ${revision} is no longer current.`);
|
|
406
|
+
const a = analyzeJob(job);
|
|
407
|
+
// The viewer's cursor counts parsed (non-blank) lines; map file lines onto it.
|
|
408
|
+
const raw = job.gcode.split(/\r?\n/);
|
|
409
|
+
const cursorAt = new Uint32Array(raw.length + 1);
|
|
410
|
+
for (let i = 0; i < raw.length; i++)
|
|
411
|
+
cursorAt[i + 1] = cursorAt[i] + (raw[i].trim() !== "" ? 1 : 0);
|
|
412
|
+
const at = (line) => cursorAt[Math.min(Math.max(line, 0), raw.length)];
|
|
413
|
+
return json({
|
|
414
|
+
revision,
|
|
415
|
+
analysis: {
|
|
416
|
+
...a,
|
|
417
|
+
steps: a.steps.map((st) => ({ ...st, at: at(st.firstLine) })),
|
|
418
|
+
checks: a.checks.map((c) => ({ ...c, at: c.lines.map(at) })),
|
|
419
|
+
runs: a.runs.map((r) => ({ ...r, at: at(r.line) })),
|
|
420
|
+
},
|
|
421
|
+
});
|
|
422
|
+
});
|
|
148
423
|
// ---- Machine link and run control (shared by Claude and the viewer).
|
|
149
|
-
server
|
|
424
|
+
registerAppTool(server, "anc_list_ports", {
|
|
150
425
|
title: "List machine ports",
|
|
151
|
-
description: "List serial ports CNCjs can see, to find the CNC controller."
|
|
426
|
+
description: "List serial ports CNCjs can see, to find the CNC controller. Shows them so the person can pick one and connect, " +
|
|
427
|
+
"or, once connected, the machine's state and position.",
|
|
152
428
|
inputSchema: z.object({}),
|
|
429
|
+
annotations: { readOnlyHint: true },
|
|
430
|
+
_meta: {
|
|
431
|
+
ui: { resourceUri: MACHINE_URI },
|
|
432
|
+
"openai/toolInvocation/invoking": "Looking for the machine…",
|
|
433
|
+
"openai/toolInvocation/invoked": "Machine ports listed",
|
|
434
|
+
},
|
|
153
435
|
}, async () => {
|
|
154
436
|
try {
|
|
155
437
|
return json({ ports: await cncjs.listPorts(), machine: cncjs.snapshot() });
|
|
@@ -158,7 +440,7 @@ export function createServer() {
|
|
|
158
440
|
return fail(e.message);
|
|
159
441
|
}
|
|
160
442
|
});
|
|
161
|
-
server
|
|
443
|
+
registerAppTool(server, "anc_connect_machine", {
|
|
162
444
|
title: "Connect machine",
|
|
163
445
|
description: "Open the CNC controller's serial port in CNCjs. This connects to the real machine; only call it when the user asks to connect.",
|
|
164
446
|
inputSchema: z.object({
|
|
@@ -166,21 +448,26 @@ export function createServer() {
|
|
|
166
448
|
baudrate: z.number().int().default(115200),
|
|
167
449
|
controller: z.enum(["Grbl", "Marlin", "Smoothie", "TinyG", "g2core"]).default("Grbl"),
|
|
168
450
|
}),
|
|
451
|
+
_meta: { ui: { resourceUri: MACHINE_URI } },
|
|
169
452
|
}, async ({ port, baudrate, controller }) => {
|
|
170
453
|
try {
|
|
171
454
|
await cncjs.openPort(port, baudrate, controller);
|
|
172
|
-
return json({ machine: cncjs.snapshot() });
|
|
455
|
+
return json({ ports: await cncjs.listPorts(), machine: cncjs.snapshot() });
|
|
173
456
|
}
|
|
174
457
|
catch (e) {
|
|
175
458
|
return fail(e.message);
|
|
176
459
|
}
|
|
177
460
|
});
|
|
178
|
-
server
|
|
461
|
+
registerAppTool(server, "anc_disconnect_machine", {
|
|
179
462
|
title: "Disconnect machine",
|
|
180
463
|
description: "Close the machine's serial port in CNCjs.",
|
|
181
464
|
inputSchema: z.object({}),
|
|
465
|
+
_meta: { ui: { visibility: ["model", "app"] } },
|
|
182
466
|
}, async () => {
|
|
183
467
|
try {
|
|
468
|
+
const busy = cncjs.snapshot().workflow;
|
|
469
|
+
if (busy !== "idle")
|
|
470
|
+
return fail(`A job is ${busy}; stop it before disconnecting.`);
|
|
184
471
|
await cncjs.closePort();
|
|
185
472
|
return json({ machine: cncjs.snapshot() });
|
|
186
473
|
}
|
|
@@ -209,64 +496,90 @@ export function createServer() {
|
|
|
209
496
|
}
|
|
210
497
|
});
|
|
211
498
|
registerAppTool(server, "anc_job_control", {
|
|
212
|
-
title: "
|
|
213
|
-
description: "
|
|
214
|
-
"
|
|
499
|
+
title: "Pause or stop CNC job",
|
|
500
|
+
description: "Pause, stop, feed hold or soft reset the job running in CNCjs. " +
|
|
501
|
+
"Starting, resuming, homing and unlocking are only done from the viewer's own buttons, so the person is at the screen when the machine moves. " +
|
|
215
502
|
"Soft reset is not an emergency stop; the physical E-stop is.",
|
|
216
503
|
inputSchema: z.object({
|
|
217
|
-
action: z.enum(["
|
|
504
|
+
action: z.enum(["pause", "stop", "feedhold", "reset"]),
|
|
218
505
|
}),
|
|
219
506
|
_meta: { ui: { visibility: ["model", "app"] } },
|
|
220
507
|
}, async ({ action }) => {
|
|
508
|
+
try {
|
|
509
|
+
const command = { pause: "gcode:pause", stop: "gcode:stop", feedhold: "feedhold", reset: "reset" }[action];
|
|
510
|
+
if (action === "stop")
|
|
511
|
+
await cncjs.command(command, { force: true });
|
|
512
|
+
else
|
|
513
|
+
await cncjs.command(command);
|
|
514
|
+
return json({ sent: action, machine: cncjs.snapshot() });
|
|
515
|
+
}
|
|
516
|
+
catch (e) {
|
|
517
|
+
return fail(e.message);
|
|
518
|
+
}
|
|
519
|
+
});
|
|
520
|
+
// Everything that sets the machine moving (or lets it move again, after an
|
|
521
|
+
// alarm) is app-only: the model can't call it, only the viewer's buttons can.
|
|
522
|
+
// Start also names the program the viewer showed, by content, and is refused
|
|
523
|
+
// unless that is exactly what CNCjs has loaded.
|
|
524
|
+
registerAppTool(server, "anc_run_control", {
|
|
525
|
+
title: "Start, resume or home",
|
|
526
|
+
description: "Start or resume the job loaded in CNCjs, send cycle start, unlock ($X) or home ($H). Called by the viewer's buttons after the person confirms.",
|
|
527
|
+
inputSchema: z.object({
|
|
528
|
+
action: z.enum(["start", "resume", "cyclestart", "unlock", "home"]),
|
|
529
|
+
hash: z.string().optional().describe("For start: the hash of the job the viewer is showing"),
|
|
530
|
+
}),
|
|
531
|
+
_meta: { ui: { visibility: ["app"] } },
|
|
532
|
+
}, async ({ action, hash }) => {
|
|
221
533
|
try {
|
|
222
534
|
const m = cncjs.snapshot();
|
|
535
|
+
if (m.link !== "live")
|
|
536
|
+
return fail(`Not sent: machine status is not live (${m.state}).`);
|
|
223
537
|
if (action === "start") {
|
|
224
538
|
const job = currentJob();
|
|
225
|
-
if (m.link !== "live")
|
|
226
|
-
return fail(`Not starting: machine status is not live (${m.state}).`);
|
|
227
539
|
if (m.activeState !== "Idle")
|
|
228
540
|
return fail(`Not starting: machine is ${m.activeState}, not Idle.`);
|
|
229
541
|
if (m.workflow !== "idle")
|
|
230
542
|
return fail(`Not starting: CNCjs workflow is ${m.workflow}.`);
|
|
231
|
-
if (!m.loadedFile)
|
|
543
|
+
if (!m.loadedFile || !m.loadedHash)
|
|
232
544
|
return fail("Not starting: no program is loaded in CNCjs.");
|
|
233
|
-
if (job
|
|
234
|
-
return fail(
|
|
545
|
+
if (!job)
|
|
546
|
+
return fail("Not starting: no job is shown in the viewer.");
|
|
547
|
+
if (!hash || hash !== job.hash) {
|
|
548
|
+
return fail("Not starting: the job shown has changed since you pressed Start. Check it and press Start again.");
|
|
549
|
+
}
|
|
550
|
+
if (m.loadedHash !== job.hash) {
|
|
551
|
+
return fail(`Not starting: CNCjs has a different program loaded from the one shown ("${m.loadedFile}"). Load this job into CNCjs first.`);
|
|
235
552
|
}
|
|
236
553
|
}
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
home: "homing",
|
|
246
|
-
reset: "reset",
|
|
247
|
-
}[action];
|
|
248
|
-
if (action === "stop")
|
|
249
|
-
await cncjs.command(command, { force: true });
|
|
250
|
-
else
|
|
251
|
-
await cncjs.command(command);
|
|
554
|
+
if (action === "resume" && m.workflow !== "paused")
|
|
555
|
+
return fail(`Not resuming: the job is ${m.workflow}, not paused.`);
|
|
556
|
+
if (action === "cyclestart" && m.activeState !== "Hold")
|
|
557
|
+
return fail(`Not sent: machine is ${m.activeState}, not on hold.`);
|
|
558
|
+
if (action === "home" && m.workflow !== "idle")
|
|
559
|
+
return fail(`Not homing: CNCjs workflow is ${m.workflow}.`);
|
|
560
|
+
const command = { start: "gcode:start", resume: "gcode:resume", cyclestart: "cyclestart", unlock: "unlock", home: "homing" }[action];
|
|
561
|
+
await cncjs.command(command);
|
|
252
562
|
return json({ sent: action, machine: cncjs.snapshot() });
|
|
253
563
|
}
|
|
254
564
|
catch (e) {
|
|
255
565
|
return fail(e.message);
|
|
256
566
|
}
|
|
257
567
|
});
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
568
|
+
for (const widget of WIDGETS) {
|
|
569
|
+
const uri = widgetUri(widget.name);
|
|
570
|
+
registerAppResource(server, uri, uri, { mimeType: RESOURCE_MIME_TYPE, description: widget.description }, async () => ({
|
|
571
|
+
contents: [
|
|
572
|
+
{
|
|
573
|
+
uri,
|
|
574
|
+
mimeType: RESOURCE_MIME_TYPE,
|
|
575
|
+
text: await fs.readFile(path.join(ROOT, "dist", `${widget.name}.html`), "utf-8"),
|
|
576
|
+
// Widgets are self-contained (one bundled file) and only talk to
|
|
577
|
+
// this server through the host bridge: no outside origins at all.
|
|
578
|
+
// Hosts default border styling differently, so say what we want.
|
|
579
|
+
_meta: { ui: { csp: { connectDomains: [], resourceDomains: [] }, prefersBorder: true } },
|
|
580
|
+
},
|
|
581
|
+
],
|
|
582
|
+
}));
|
|
583
|
+
}
|
|
271
584
|
return server;
|
|
272
585
|
}
|