agentic-numerical-control 0.1.6 → 0.2.0
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/README.md +45 -44
- package/dist/engines/cncjs/LICENSES.txt +2593 -0
- package/dist/engines/cncjs/cncjs-headless.cjs +67988 -0
- package/dist/engines/cncjs/manifest.json +195 -0
- package/dist/engines/kiri/LICENSES.txt +1097 -0
- package/dist/engines/kiri/bin/dom-stub.mjs +30 -0
- package/dist/engines/kiri/bin/kiri-main.js +127342 -0
- package/dist/engines/kiri/bin/kiri-pool.js +83963 -0
- package/dist/engines/kiri/bin/kiri-work.js +136718 -0
- package/dist/engines/kiri/bin/runner.mjs +241 -0
- package/dist/engines/kiri/bin/worker-shim.mjs +32 -0
- package/dist/engines/kiri/manifest.json +356 -0
- package/dist/engines/kiri/wasm/manifold.wasm +0 -0
- package/dist/jobs.html +137 -0
- package/dist/machine.html +164 -0
- package/dist/server/analysis.js +356 -0
- package/dist/server/cam.js +592 -0
- package/dist/server/cncjs.js +194 -75
- 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 +577 -52
- package/dist/server/settings-import.js +237 -0
- package/dist/setup.html +156 -0
- package/dist/viewer.html +296 -265
- package/package.json +9 -5
package/dist/server/cncjs.js
CHANGED
|
@@ -1,20 +1,18 @@
|
|
|
1
|
-
// Link to
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
import {
|
|
1
|
+
// Link to ANC's own headless CNCjs (engines/cncjs, built into
|
|
2
|
+
// dist/engines/cncjs) over its socket.io API, with a cached view of controller
|
|
3
|
+
// state. ANC always starts and owns this engine as a child process on a free
|
|
4
|
+
// loopback port; it never looks for, adopts or controls any other CNCjs.
|
|
5
|
+
import { fork } from "node:child_process";
|
|
6
6
|
import fs from "node:fs";
|
|
7
|
-
import os from "node:os";
|
|
8
7
|
import path from "node:path";
|
|
9
|
-
import { createRequire } from "node:module";
|
|
10
8
|
import jwt from "jsonwebtoken";
|
|
11
9
|
import io from "socket.io-client";
|
|
12
|
-
const require = createRequire(import.meta.url);
|
|
13
|
-
export const CNCJS_URL = process.env.CNCJS_URL ?? "http://127.0.0.1:8000";
|
|
14
10
|
import { STATE_DIR } from "./paths.js";
|
|
15
11
|
import { ROOT } from "./product.js";
|
|
12
|
+
import { programHash } from "./program.js";
|
|
13
|
+
/** ANC's CNCjs config (CNCjs's .cncrc format). Not the user's ~/.cncrc. */
|
|
16
14
|
const MANAGED_CONFIG = path.join(STATE_DIR, "cncrc");
|
|
17
|
-
const
|
|
15
|
+
const ENGINE_DIR = path.join(ROOT, "dist", "engines", "cncjs");
|
|
18
16
|
/** Telemetry older than this is shown as stale rather than live. */
|
|
19
17
|
const STALE_MS = 3000;
|
|
20
18
|
/** Wait this long after a failed connect before trying again. */
|
|
@@ -23,26 +21,83 @@ const MOVING_STATES = new Set(["Run", "Jog", "Home"]);
|
|
|
23
21
|
class CNCjsLink {
|
|
24
22
|
socket = null;
|
|
25
23
|
child = null;
|
|
26
|
-
|
|
24
|
+
url = null;
|
|
25
|
+
version = null;
|
|
26
|
+
/** The engine's last "Error opening serial port" log line, per port. */
|
|
27
|
+
openErrors = new Map();
|
|
27
28
|
connected = false;
|
|
28
29
|
error = null;
|
|
29
30
|
starting = null;
|
|
30
31
|
lastFailure = 0;
|
|
31
32
|
port = null;
|
|
32
33
|
controller = null;
|
|
34
|
+
baudrate = null;
|
|
33
35
|
status = null;
|
|
34
36
|
parserState = null;
|
|
35
37
|
workflow = "idle";
|
|
36
38
|
sender = null;
|
|
37
39
|
alarm = null;
|
|
40
|
+
settings = null;
|
|
41
|
+
/** Firmware name from the controller's startup line ("Grbl", "GrblHAL"). */
|
|
42
|
+
firmware = null;
|
|
43
|
+
/** Grbl's $# parameters: saved zeros (G54-G59, G28, G30, G92), tool length offset. */
|
|
44
|
+
parameters = null;
|
|
45
|
+
onMachineListeners = [];
|
|
38
46
|
gcodeName = null;
|
|
47
|
+
gcodeHash = null;
|
|
39
48
|
telemetryAt = 0;
|
|
40
49
|
ports = [];
|
|
41
50
|
onLoadListeners = [];
|
|
51
|
+
onRunEndListeners = [];
|
|
52
|
+
// The sender as it stood during the run: CNCjs rewinds it to zero when the
|
|
53
|
+
// workflow stops, so the outcome is read from this, not the live sender.
|
|
54
|
+
runSender = null;
|
|
42
55
|
onGcodeLoad(fn) {
|
|
43
56
|
this.onLoadListeners.push(fn);
|
|
44
57
|
}
|
|
45
|
-
|
|
58
|
+
onRunEnd(fn) {
|
|
59
|
+
this.onRunEndListeners.push(fn);
|
|
60
|
+
}
|
|
61
|
+
/** Called when the controller reports its settings, parameters or an alarm. */
|
|
62
|
+
onMachine(fn) {
|
|
63
|
+
this.onMachineListeners.push(fn);
|
|
64
|
+
}
|
|
65
|
+
reportMachine() {
|
|
66
|
+
if (!this.port)
|
|
67
|
+
return;
|
|
68
|
+
const report = {
|
|
69
|
+
port: this.port,
|
|
70
|
+
controller: this.controller,
|
|
71
|
+
firmware: this.firmware,
|
|
72
|
+
version: this.settings?.version ?? null,
|
|
73
|
+
baudrate: this.baudrate,
|
|
74
|
+
settings: this.settings?.values ?? null,
|
|
75
|
+
parameters: this.parameters,
|
|
76
|
+
alarm: this.alarm,
|
|
77
|
+
};
|
|
78
|
+
for (const fn of this.onMachineListeners)
|
|
79
|
+
fn(report);
|
|
80
|
+
}
|
|
81
|
+
/** Called when the workflow leaves running/paused: finished, stopped, or halted by an alarm. */
|
|
82
|
+
endRun() {
|
|
83
|
+
const s = this.runSender;
|
|
84
|
+
this.runSender = null;
|
|
85
|
+
if (!s || !s.name)
|
|
86
|
+
return;
|
|
87
|
+
const finished = s.finishTime > 0 || (s.total > 0 && s.received >= s.total);
|
|
88
|
+
const alarmed = this.status?.activeState === "Alarm" || this.alarm !== null;
|
|
89
|
+
const run = {
|
|
90
|
+
name: s.name,
|
|
91
|
+
outcome: finished ? "complete" : alarmed ? "alarm" : "stopped",
|
|
92
|
+
received: s.received,
|
|
93
|
+
total: s.total,
|
|
94
|
+
elapsedMs: s.startTime ? (s.finishTime || Date.now()) - s.startTime : s.elapsedTime,
|
|
95
|
+
alarm: alarmed ? this.alarm : null,
|
|
96
|
+
};
|
|
97
|
+
for (const fn of this.onRunEndListeners)
|
|
98
|
+
fn(run);
|
|
99
|
+
}
|
|
100
|
+
/** Starts the engine if needed and connects to it. Safe to call repeatedly. */
|
|
46
101
|
ensure() {
|
|
47
102
|
if (this.connected)
|
|
48
103
|
return Promise.resolve();
|
|
@@ -56,16 +111,9 @@ class CNCjsLink {
|
|
|
56
111
|
}
|
|
57
112
|
async start() {
|
|
58
113
|
try {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
? MANAGED_CONFIG
|
|
63
|
-
: USER_CONFIG;
|
|
64
|
-
if (!(await reachable(CNCJS_URL))) {
|
|
65
|
-
await this.launchBundled();
|
|
66
|
-
configFile = MANAGED_CONFIG;
|
|
67
|
-
}
|
|
68
|
-
await this.connect(accessToken(configFile));
|
|
114
|
+
if (!this.child)
|
|
115
|
+
await this.launch();
|
|
116
|
+
await this.connect(accessToken());
|
|
69
117
|
this.error = null;
|
|
70
118
|
}
|
|
71
119
|
catch (e) {
|
|
@@ -74,47 +122,80 @@ class CNCjsLink {
|
|
|
74
122
|
throw e;
|
|
75
123
|
}
|
|
76
124
|
}
|
|
77
|
-
|
|
78
|
-
const
|
|
79
|
-
if (!
|
|
80
|
-
|
|
125
|
+
launch() {
|
|
126
|
+
const manifestPath = path.join(ENGINE_DIR, "manifest.json");
|
|
127
|
+
if (!fs.existsSync(manifestPath)) {
|
|
128
|
+
return Promise.reject(new Error(`The built-in CNCjs is missing (${ENGINE_DIR}). Run npm run build.`));
|
|
81
129
|
}
|
|
130
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf-8"));
|
|
82
131
|
fs.mkdirSync(STATE_DIR, { recursive: true });
|
|
83
|
-
const bin = require.resolve("cncjs/bin/cncjs");
|
|
84
|
-
const port = url.port || "8000";
|
|
85
|
-
console.error(`[anc] Starting bundled CNCjs on 127.0.0.1:${port} (config ${MANAGED_CONFIG})`);
|
|
86
132
|
// The preload adds the GRBL-SIM port (sim/) alongside real serial ports.
|
|
87
133
|
const simHook = path.join(ROOT, "sim", "virtual-port.cjs");
|
|
88
|
-
|
|
89
|
-
|
|
134
|
+
const child = fork(path.join(ENGINE_DIR, manifest.entry), [], {
|
|
135
|
+
execArgv: ["--require", simHook],
|
|
136
|
+
env: { ...process.env, ANC_CNCJS_CONFIG: MANAGED_CONFIG, ANC_CNCJS_PORT: process.env.ANC_CNCJS_PORT ?? "0" },
|
|
137
|
+
stdio: ["ignore", "pipe", "pipe", "ipc"],
|
|
90
138
|
windowsHide: true,
|
|
91
139
|
});
|
|
92
|
-
this.child
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
140
|
+
this.child = child;
|
|
141
|
+
// CNCjs logs to stdout; pass on its warnings and errors, and keep the
|
|
142
|
+
// reason a serial port failed to open (its error object doesn't survive
|
|
143
|
+
// socket.io).
|
|
144
|
+
const onLog = (d) => {
|
|
145
|
+
for (const line of d.toString().split(/\r?\n/)) {
|
|
146
|
+
const plain = line.replace(/\x1b\[[0-9;]*m/g, "");
|
|
147
|
+
const m = /Error opening serial port "([^"]+)":\s*(.*)/.exec(plain);
|
|
148
|
+
if (m)
|
|
149
|
+
this.openErrors.set(m[1], m[2].replace(/^\[?(Error:\s*)+/, "").replace(/\]$/, "").trim());
|
|
150
|
+
if (/ - (error|warn) /.test(plain) || (plain && !/ - \w+ /.test(plain)))
|
|
151
|
+
process.stderr.write(`[cncjs] ${plain}\n`);
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
child.stdout?.on("data", onLog);
|
|
155
|
+
child.stderr?.on("data", onLog);
|
|
156
|
+
child.on("exit", (code) => {
|
|
157
|
+
console.error(`[anc] CNCjs engine exited (${code})`);
|
|
158
|
+
if (this.child === child) {
|
|
159
|
+
this.child = null;
|
|
160
|
+
this.url = null;
|
|
161
|
+
this.connected = false;
|
|
162
|
+
this.socket?.close();
|
|
163
|
+
this.socket = null;
|
|
164
|
+
this.resetPort();
|
|
165
|
+
}
|
|
96
166
|
});
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
167
|
+
process.once("exit", () => child.kill());
|
|
168
|
+
return new Promise((resolve, reject) => {
|
|
169
|
+
const timer = setTimeout(() => {
|
|
170
|
+
child.kill();
|
|
171
|
+
reject(new Error("The built-in CNCjs did not start within 30 seconds."));
|
|
172
|
+
}, 30000);
|
|
173
|
+
child.on("message", (m) => {
|
|
174
|
+
if (m?.type === "ready") {
|
|
175
|
+
clearTimeout(timer);
|
|
176
|
+
this.url = `http://127.0.0.1:${m.port}`;
|
|
177
|
+
this.version = m.version ?? manifest.upstream?.version ?? null;
|
|
178
|
+
console.error(`[anc] CNCjs ${this.version} engine on ${this.url}`);
|
|
179
|
+
resolve();
|
|
180
|
+
}
|
|
181
|
+
else if (m?.type === "error") {
|
|
182
|
+
clearTimeout(timer);
|
|
183
|
+
reject(new Error(`The built-in CNCjs could not start: ${m.message}`));
|
|
184
|
+
}
|
|
103
185
|
});
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
}
|
|
110
|
-
throw new Error("The bundled CNCjs did not start within 30 seconds.");
|
|
186
|
+
child.on("exit", (code) => {
|
|
187
|
+
clearTimeout(timer);
|
|
188
|
+
reject(new Error(`The built-in CNCjs stopped while starting (exit ${code}).`));
|
|
189
|
+
});
|
|
190
|
+
});
|
|
111
191
|
}
|
|
112
192
|
connect(token) {
|
|
193
|
+
const url = this.url;
|
|
113
194
|
return new Promise((resolve, reject) => {
|
|
114
|
-
const socket = io(
|
|
195
|
+
const socket = io(url, { query: `token=${token}`, reconnection: true });
|
|
115
196
|
const timer = setTimeout(() => {
|
|
116
197
|
socket.close();
|
|
117
|
-
reject(new Error(`Timed out connecting to CNCjs at ${
|
|
198
|
+
reject(new Error(`Timed out connecting to the built-in CNCjs at ${url}`));
|
|
118
199
|
}, 10000);
|
|
119
200
|
socket.on("connect", () => {
|
|
120
201
|
clearTimeout(timer);
|
|
@@ -133,6 +214,19 @@ class CNCjsLink {
|
|
|
133
214
|
this.listen(socket);
|
|
134
215
|
});
|
|
135
216
|
}
|
|
217
|
+
resetPort() {
|
|
218
|
+
if (this.workflow !== "idle")
|
|
219
|
+
this.endRun();
|
|
220
|
+
this.port = null;
|
|
221
|
+
this.controller = null;
|
|
222
|
+
this.baudrate = null;
|
|
223
|
+
this.status = null;
|
|
224
|
+
this.settings = null;
|
|
225
|
+
this.firmware = null;
|
|
226
|
+
this.parameters = null;
|
|
227
|
+
this.sender = null;
|
|
228
|
+
this.workflow = "idle";
|
|
229
|
+
}
|
|
136
230
|
listen(socket) {
|
|
137
231
|
socket.on("serialport:list", (ports) => {
|
|
138
232
|
this.ports = ports ?? [];
|
|
@@ -140,13 +234,23 @@ class CNCjsLink {
|
|
|
140
234
|
socket.on("serialport:open", (o) => {
|
|
141
235
|
this.port = o?.port ?? null;
|
|
142
236
|
this.controller = o?.controllerType ?? null;
|
|
237
|
+
this.baudrate = typeof o?.baudrate === "number" ? o.baudrate : null;
|
|
143
238
|
});
|
|
144
|
-
socket.on("serialport:close", () =>
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
239
|
+
socket.on("serialport:close", () => this.resetPort());
|
|
240
|
+
// Grbl's $$ settings (travel, rapid rates, homing), which CNCjs asks for on connect.
|
|
241
|
+
socket.on("controller:settings", (_type, s) => {
|
|
242
|
+
const values = s?.settings && typeof s.settings === "object" ? s.settings : null;
|
|
243
|
+
if (values) {
|
|
244
|
+
const first = !this.settings;
|
|
245
|
+
this.settings = { version: typeof s.version === "string" ? s.version : null, values };
|
|
246
|
+
const params = s?.parameters && typeof s.parameters === "object" ? s.parameters : null;
|
|
247
|
+
if (params && Object.keys(params).length)
|
|
248
|
+
this.parameters = params;
|
|
249
|
+
this.reportMachine();
|
|
250
|
+
// Ask once for the saved zeros ($#); Grbl answers at once and nothing moves.
|
|
251
|
+
if (first && this.workflow === "idle")
|
|
252
|
+
this.socket?.emit("command", this.port, "gcode", "$#");
|
|
253
|
+
}
|
|
150
254
|
});
|
|
151
255
|
socket.on("controller:state", (type, state) => {
|
|
152
256
|
this.controller = type;
|
|
@@ -161,27 +265,43 @@ class CNCjsLink {
|
|
|
161
265
|
});
|
|
162
266
|
socket.on("serialport:read", (data) => {
|
|
163
267
|
const m = /ALARM:(\d+)(.*)/.exec(data ?? "");
|
|
164
|
-
if (m)
|
|
268
|
+
if (m) {
|
|
165
269
|
this.alarm = `ALARM:${m[1]}${m[2]}`.trim();
|
|
270
|
+
this.reportMachine();
|
|
271
|
+
}
|
|
272
|
+
const startup = /^(Grbl\w*)\s+v?\d/i.exec(data ?? "");
|
|
273
|
+
if (startup)
|
|
274
|
+
this.firmware = startup[1];
|
|
166
275
|
});
|
|
167
276
|
socket.on("workflow:state", (s) => {
|
|
277
|
+
const wasRunning = this.workflow !== "idle";
|
|
168
278
|
this.workflow = s;
|
|
279
|
+
if (wasRunning && s === "idle")
|
|
280
|
+
this.endRun();
|
|
169
281
|
});
|
|
170
282
|
socket.on("sender:status", (s) => {
|
|
171
283
|
this.sender = s;
|
|
284
|
+
if (this.workflow !== "idle" && (s.sent > 0 || s.finishTime > 0))
|
|
285
|
+
this.runSender = { ...s };
|
|
172
286
|
});
|
|
173
287
|
socket.on("gcode:load", (name, gcode) => {
|
|
174
288
|
this.gcodeName = name;
|
|
175
289
|
// CNCjs appends its own "%wait" dwell line; drop it from the preview.
|
|
176
290
|
const program = gcode.replace(/\n%wait ; Wait for the planner to empty\s*$/, "");
|
|
291
|
+
this.gcodeHash = programHash(program);
|
|
177
292
|
for (const fn of this.onLoadListeners)
|
|
178
293
|
fn(name, program);
|
|
179
294
|
});
|
|
180
295
|
socket.on("gcode:unload", () => {
|
|
181
296
|
this.gcodeName = null;
|
|
297
|
+
this.gcodeHash = null;
|
|
182
298
|
this.sender = null;
|
|
183
299
|
});
|
|
184
300
|
}
|
|
301
|
+
/** The controller's own settings ($$ for Grbl), or null before it has reported them. */
|
|
302
|
+
controllerSettings() {
|
|
303
|
+
return this.port ? this.settings : null;
|
|
304
|
+
}
|
|
185
305
|
snapshot() {
|
|
186
306
|
const age = Date.now() - this.telemetryAt;
|
|
187
307
|
// CNCjs emits controller:state only when the state changes, so a quiet
|
|
@@ -202,9 +322,10 @@ class CNCjsLink {
|
|
|
202
322
|
return {
|
|
203
323
|
link,
|
|
204
324
|
state,
|
|
205
|
-
cncjs: {
|
|
325
|
+
cncjs: { version: this.version, connected: this.connected, error: this.error },
|
|
206
326
|
port: this.port,
|
|
207
327
|
controller: this.controller,
|
|
328
|
+
baudrate: this.baudrate,
|
|
208
329
|
activeState: this.status?.activeState ?? null,
|
|
209
330
|
workflow: this.workflow,
|
|
210
331
|
mpos: toVec(this.status?.mpos),
|
|
@@ -213,6 +334,7 @@ class CNCjsLink {
|
|
|
213
334
|
alarm: this.alarm,
|
|
214
335
|
sender: this.sender,
|
|
215
336
|
loadedFile: this.gcodeName,
|
|
337
|
+
loadedHash: this.gcodeHash,
|
|
216
338
|
telemetryAt: this.telemetryAt ? new Date(this.telemetryAt).toISOString() : null,
|
|
217
339
|
};
|
|
218
340
|
}
|
|
@@ -224,12 +346,13 @@ class CNCjsLink {
|
|
|
224
346
|
}
|
|
225
347
|
async openPort(port, baudrate, controllerType) {
|
|
226
348
|
await this.ensure();
|
|
349
|
+
this.openErrors.delete(port);
|
|
227
350
|
await new Promise((resolve, reject) => {
|
|
228
351
|
this.socket.emit("open", port, { controllerType, baudrate }, (err) => {
|
|
229
|
-
if (err)
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
352
|
+
if (!err)
|
|
353
|
+
return resolve();
|
|
354
|
+
// The error object doesn't survive socket.io; the engine logs its message.
|
|
355
|
+
setTimeout(() => reject(new Error(openErrorMessage(port, this.openErrors.get(port) ?? err?.message ?? null))), 200);
|
|
233
356
|
});
|
|
234
357
|
});
|
|
235
358
|
}
|
|
@@ -262,28 +385,24 @@ class CNCjsLink {
|
|
|
262
385
|
return this.port;
|
|
263
386
|
}
|
|
264
387
|
}
|
|
265
|
-
function accessToken(
|
|
266
|
-
if (process.env.CNCJS_TOKEN)
|
|
267
|
-
return process.env.CNCJS_TOKEN;
|
|
388
|
+
function accessToken() {
|
|
268
389
|
let secret;
|
|
269
390
|
try {
|
|
270
|
-
secret = JSON.parse(fs.readFileSync(
|
|
391
|
+
secret = JSON.parse(fs.readFileSync(MANAGED_CONFIG, "utf-8")).secret;
|
|
271
392
|
}
|
|
272
393
|
catch {
|
|
273
|
-
throw new Error(`Could not read the CNCjs secret from ${
|
|
394
|
+
throw new Error(`Could not read the CNCjs secret from ${MANAGED_CONFIG}.`);
|
|
274
395
|
}
|
|
275
396
|
if (!secret)
|
|
276
|
-
throw new Error(`${
|
|
397
|
+
throw new Error(`${MANAGED_CONFIG} has no "secret".`);
|
|
277
398
|
return jwt.sign({ id: "", name: "anc" }, secret, { expiresIn: "30d" });
|
|
278
399
|
}
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
return
|
|
283
|
-
}
|
|
284
|
-
catch {
|
|
285
|
-
return false;
|
|
400
|
+
/** Says plainly when another program holds the port, rather than sharing it. */
|
|
401
|
+
export function openErrorMessage(port, detail) {
|
|
402
|
+
if (detail && /access denied|resource busy|cannot lock|ebusy|in use/i.test(detail)) {
|
|
403
|
+
return `${port} is in use by another program (another CNCjs, a G-code sender, or a serial monitor). Close that program and try again. (${detail})`;
|
|
286
404
|
}
|
|
405
|
+
return `Could not open ${port}${detail ? `: ${detail}` : "."}`;
|
|
287
406
|
}
|
|
288
407
|
function toVec(v) {
|
|
289
408
|
if (!v)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// How each job file last ran on this computer, so the Jobs page can show
|
|
2
|
+
// Complete or Stopped for files CNCjs no longer has loaded. CNCjs itself only
|
|
3
|
+
// knows the program loaded right now. Kept beside the app's other state.
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { STATE_DIR } from "./paths.js";
|
|
7
|
+
const FILE = path.join(STATE_DIR, "job-history.json");
|
|
8
|
+
let runs = null;
|
|
9
|
+
function load() {
|
|
10
|
+
if (runs)
|
|
11
|
+
return runs;
|
|
12
|
+
try {
|
|
13
|
+
runs = JSON.parse(fs.readFileSync(FILE, "utf-8"));
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
runs = {};
|
|
17
|
+
}
|
|
18
|
+
return runs;
|
|
19
|
+
}
|
|
20
|
+
function save() {
|
|
21
|
+
try {
|
|
22
|
+
fs.mkdirSync(STATE_DIR, { recursive: true });
|
|
23
|
+
fs.writeFileSync(FILE, JSON.stringify(runs, null, 2));
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
// History is a convenience; a write failure must never affect a run.
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export function lastRun(name) {
|
|
30
|
+
return load()[name] ?? null;
|
|
31
|
+
}
|
|
32
|
+
export function recordRun(run) {
|
|
33
|
+
load()[run.name] = {
|
|
34
|
+
outcome: run.outcome,
|
|
35
|
+
endedAt: new Date().toISOString(),
|
|
36
|
+
elapsedMs: run.elapsedMs,
|
|
37
|
+
received: run.received,
|
|
38
|
+
total: run.total,
|
|
39
|
+
alarm: run.alarm,
|
|
40
|
+
};
|
|
41
|
+
save();
|
|
42
|
+
}
|
|
43
|
+
export function forgetRun(name) {
|
|
44
|
+
if (!(name in load()))
|
|
45
|
+
return;
|
|
46
|
+
delete runs[name];
|
|
47
|
+
save();
|
|
48
|
+
}
|
package/dist/server/jobs.js
CHANGED
|
@@ -4,6 +4,8 @@ import fsSync from "node:fs";
|
|
|
4
4
|
import fs from "node:fs/promises";
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { JOBS_DIR, ROOT } from "./product.js";
|
|
7
|
+
import { programHash } from "./program.js";
|
|
8
|
+
import { analyzeGcode } from "./analysis.js";
|
|
7
9
|
import { buildToolpath, packToolpath } from "./toolpath.js";
|
|
8
10
|
const GCODE_EXT = /\.(nc|gcode|gc|ngc|tap|cnc|txt)$/i;
|
|
9
11
|
let current = null;
|
|
@@ -29,6 +31,49 @@ export async function listJobFiles() {
|
|
|
29
31
|
.map((e) => e.name)
|
|
30
32
|
.sort();
|
|
31
33
|
}
|
|
34
|
+
// Files over this are listed without parsing; opening them still works.
|
|
35
|
+
const INFO_MAX_BYTES = 4 * 1024 * 1024;
|
|
36
|
+
const infoCache = new Map();
|
|
37
|
+
export async function jobFileInfo(file, machine, machineKey = "") {
|
|
38
|
+
const resolved = jobPath(file);
|
|
39
|
+
const stat = await fs.stat(resolved);
|
|
40
|
+
const key = `${stat.size}:${stat.mtimeMs}:${machineKey}`;
|
|
41
|
+
const cached = infoCache.get(file);
|
|
42
|
+
if (cached?.key === key)
|
|
43
|
+
return cached.info;
|
|
44
|
+
const info = {
|
|
45
|
+
file,
|
|
46
|
+
bytes: stat.size,
|
|
47
|
+
modified: stat.mtime.toISOString(),
|
|
48
|
+
lineCount: null,
|
|
49
|
+
units: null,
|
|
50
|
+
bounds: null,
|
|
51
|
+
check: null,
|
|
52
|
+
};
|
|
53
|
+
if (stat.size <= INFO_MAX_BYTES) {
|
|
54
|
+
try {
|
|
55
|
+
const gcode = await fs.readFile(resolved, "utf-8");
|
|
56
|
+
const tp = buildToolpath(gcode);
|
|
57
|
+
info.lineCount = tp.lines.length;
|
|
58
|
+
info.units = tp.units;
|
|
59
|
+
info.bounds = tp.bounds;
|
|
60
|
+
const a = analyzeGcode(gcode, machine);
|
|
61
|
+
const problems = a.checks.filter((c) => c.severity === "problem").length;
|
|
62
|
+
info.check = {
|
|
63
|
+
estimatedSeconds: a.time.totalSeconds,
|
|
64
|
+
tools: a.tools,
|
|
65
|
+
problems,
|
|
66
|
+
toCheck: a.checks.length - problems,
|
|
67
|
+
fit: a.fit.status,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
// Listed without details; anc_show_job reports why it can't be opened.
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
infoCache.set(file, { key, info });
|
|
75
|
+
return info;
|
|
76
|
+
}
|
|
32
77
|
function load(name, source, gcode) {
|
|
33
78
|
const toolpath = buildToolpath(gcode);
|
|
34
79
|
current = {
|
|
@@ -36,21 +81,32 @@ function load(name, source, gcode) {
|
|
|
36
81
|
source,
|
|
37
82
|
revision: ++revision,
|
|
38
83
|
gcode,
|
|
84
|
+
hash: programHash(gcode),
|
|
39
85
|
toolpath,
|
|
40
86
|
packed: packToolpath(toolpath),
|
|
41
87
|
loadedAt: new Date().toISOString(),
|
|
42
88
|
};
|
|
43
89
|
return current;
|
|
44
90
|
}
|
|
45
|
-
/**
|
|
46
|
-
|
|
47
|
-
await ensureJobsDir();
|
|
91
|
+
/** Resolves a name to a G-code file directly in the jobs directory; anything else is refused. */
|
|
92
|
+
function jobPath(file) {
|
|
48
93
|
const resolved = path.resolve(JOBS_DIR, file);
|
|
49
94
|
if (path.dirname(resolved) !== JOBS_DIR || !GCODE_EXT.test(resolved)) {
|
|
50
95
|
throw new Error(`"${file}" is not a G-code file in the Agentic Numerical Control jobs folder (${JOBS_DIR}).`);
|
|
51
96
|
}
|
|
97
|
+
return resolved;
|
|
98
|
+
}
|
|
99
|
+
export async function loadJobFile(file) {
|
|
100
|
+
await ensureJobsDir();
|
|
101
|
+
const resolved = jobPath(file);
|
|
52
102
|
return load(path.basename(resolved), "file", await fs.readFile(resolved, "utf-8"));
|
|
53
103
|
}
|
|
104
|
+
/** Deletes a file from the jobs directory. The job already shown stays shown until another is opened. */
|
|
105
|
+
export async function deleteJobFile(file) {
|
|
106
|
+
const resolved = jobPath(file);
|
|
107
|
+
await fs.unlink(resolved);
|
|
108
|
+
return path.basename(resolved);
|
|
109
|
+
}
|
|
54
110
|
export function loadJobText(name, gcode) {
|
|
55
111
|
return load(name, "inline", gcode);
|
|
56
112
|
}
|
|
@@ -79,6 +135,7 @@ export function jobSummary(job) {
|
|
|
79
135
|
name: job.name,
|
|
80
136
|
source: job.source,
|
|
81
137
|
revision: job.revision,
|
|
138
|
+
hash: job.hash,
|
|
82
139
|
loadedAt: job.loadedAt,
|
|
83
140
|
lineCount: tp.lines.length,
|
|
84
141
|
segmentCount: tp.motions.length,
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// The machines this computer has talked to, for the Machines page: what each
|
|
2
|
+
// controller reported about itself the last time it connected (firmware,
|
|
3
|
+
// travel, speeds, homing, saved zeros), how much it has run, and the name the
|
|
4
|
+
// person gave it. Grbl doesn't report a make or model, so the name is theirs.
|
|
5
|
+
// Kept beside the app's other state, one entry per port.
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { STATE_DIR } from "./paths.js";
|
|
9
|
+
const FILE = path.join(STATE_DIR, "machines.json");
|
|
10
|
+
let machines = null;
|
|
11
|
+
function load() {
|
|
12
|
+
if (machines)
|
|
13
|
+
return machines;
|
|
14
|
+
try {
|
|
15
|
+
machines = JSON.parse(fs.readFileSync(FILE, "utf-8"));
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
machines = {};
|
|
19
|
+
}
|
|
20
|
+
return machines;
|
|
21
|
+
}
|
|
22
|
+
function save() {
|
|
23
|
+
try {
|
|
24
|
+
fs.mkdirSync(STATE_DIR, { recursive: true });
|
|
25
|
+
fs.writeFileSync(FILE, JSON.stringify(machines, null, 2));
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
// A convenience; a write failure must never affect the machine.
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function entry(port) {
|
|
32
|
+
const all = load();
|
|
33
|
+
const now = new Date().toISOString();
|
|
34
|
+
return (all[port] ??= {
|
|
35
|
+
port,
|
|
36
|
+
name: null,
|
|
37
|
+
controller: null,
|
|
38
|
+
firmware: null,
|
|
39
|
+
version: null,
|
|
40
|
+
baudrate: null,
|
|
41
|
+
settings: null,
|
|
42
|
+
parameters: null,
|
|
43
|
+
firstSeen: now,
|
|
44
|
+
lastSeen: now,
|
|
45
|
+
jobsRun: 0,
|
|
46
|
+
cuttingMs: 0,
|
|
47
|
+
lastAlarm: null,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
/** Records what the controller reported; keeps earlier values it didn't repeat. */
|
|
51
|
+
export function recordMachine(report) {
|
|
52
|
+
const m = entry(report.port);
|
|
53
|
+
m.lastSeen = new Date().toISOString();
|
|
54
|
+
m.controller = report.controller ?? m.controller;
|
|
55
|
+
m.firmware = report.firmware ?? m.firmware;
|
|
56
|
+
m.version = report.version ?? m.version;
|
|
57
|
+
m.baudrate = report.baudrate ?? m.baudrate;
|
|
58
|
+
if (report.settings && Object.keys(report.settings).length)
|
|
59
|
+
m.settings = report.settings;
|
|
60
|
+
if (report.parameters && Object.keys(report.parameters).length)
|
|
61
|
+
m.parameters = report.parameters;
|
|
62
|
+
if (report.alarm && report.alarm !== m.lastAlarm?.alarm)
|
|
63
|
+
m.lastAlarm = { alarm: report.alarm, at: m.lastSeen };
|
|
64
|
+
save();
|
|
65
|
+
}
|
|
66
|
+
/** Counts a finished or stopped run against the machine it ran on. */
|
|
67
|
+
export function recordMachineRun(port, run) {
|
|
68
|
+
if (!port)
|
|
69
|
+
return;
|
|
70
|
+
const m = entry(port);
|
|
71
|
+
m.jobsRun++;
|
|
72
|
+
m.cuttingMs += Math.max(0, run.elapsedMs);
|
|
73
|
+
if (run.alarm)
|
|
74
|
+
m.lastAlarm = { alarm: run.alarm, at: new Date().toISOString() };
|
|
75
|
+
save();
|
|
76
|
+
}
|
|
77
|
+
export function listMachines() {
|
|
78
|
+
return Object.values(load()).sort((a, b) => b.lastSeen.localeCompare(a.lastSeen));
|
|
79
|
+
}
|
|
80
|
+
export function renameMachine(port, name) {
|
|
81
|
+
const all = load();
|
|
82
|
+
const m = all[port];
|
|
83
|
+
if (!m)
|
|
84
|
+
throw new Error(`No machine has connected on ${port}.`);
|
|
85
|
+
m.name = name.trim().slice(0, 60) || null;
|
|
86
|
+
save();
|
|
87
|
+
return m;
|
|
88
|
+
}
|
|
89
|
+
/** The name to show: the person's, else what the port says it is. */
|
|
90
|
+
export function machineName(m) {
|
|
91
|
+
if (m.name)
|
|
92
|
+
return m.name;
|
|
93
|
+
if (m.port === "GRBL-SIM")
|
|
94
|
+
return "grblHAL simulator";
|
|
95
|
+
return `${m.firmware ?? m.controller ?? "Machine"} on ${m.port}`;
|
|
96
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
/**
|
|
3
|
+
* Identifies a G-code program by its content, so a start can be tied to the
|
|
4
|
+
* exact program the person was looking at rather than to a file name. Line
|
|
5
|
+
* endings, trailing spaces and blank lines don't change what the machine does,
|
|
6
|
+
* so they don't change the hash either.
|
|
7
|
+
*/
|
|
8
|
+
export function programHash(gcode) {
|
|
9
|
+
const lines = gcode
|
|
10
|
+
.split(/\r?\n/)
|
|
11
|
+
.map((l) => l.trimEnd())
|
|
12
|
+
.filter((l) => l !== "");
|
|
13
|
+
return createHash("sha256").update(lines.join("\n")).digest("hex");
|
|
14
|
+
}
|