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.
@@ -1,20 +1,18 @@
1
- // Link to CNCjs over its socket.io API, with a cached view of controller
2
- // state. Agentic Numerical Control uses an existing CNCjs at CNCJS_URL when one answers there;
3
- // otherwise it starts the CNCjs bundled in node_modules on that address,
4
- // with its own config file in .anc/.
5
- import { spawn } from "node:child_process";
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 USER_CONFIG = process.env.CNCJS_CONFIG ?? path.join(os.homedir(), ".cncrc");
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
- managed = false;
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
- /** Connects (starting a bundled CNCjs if needed). Safe to call repeatedly. */
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
- // An already-running CNCjs is either the user's (~/.cncrc) or one Agentic Numerical Control
60
- // started earlier and outlived (.anc/cncrc).
61
- let configFile = this.managed || (!fs.existsSync(USER_CONFIG) && fs.existsSync(MANAGED_CONFIG))
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
- async launchBundled() {
78
- const url = new URL(CNCJS_URL);
79
- if (!["127.0.0.1", "localhost"].includes(url.hostname)) {
80
- throw new Error(`No CNCjs answered at ${CNCJS_URL}, and Agentic Numerical Control only starts its own CNCjs on this computer.`);
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
- this.child = spawn(process.execPath, ["--require", simHook, bin, "-H", "127.0.0.1", "-p", port, "-c", MANAGED_CONFIG], {
89
- stdio: ["ignore", "ignore", "pipe"],
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.stderr?.on("data", (d) => process.stderr.write(`[cncjs] ${d}`));
93
- this.child.on("exit", (code) => {
94
- console.error(`[anc] Bundled CNCjs exited (${code})`);
95
- this.child = null;
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
- this.managed = true;
98
- process.once("exit", () => this.child?.kill());
99
- for (const sig of ["SIGINT", "SIGTERM"]) {
100
- process.once(sig, () => {
101
- this.child?.kill();
102
- process.exit(0);
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
- for (let i = 0; i < 60; i++) {
106
- if (await reachable(CNCJS_URL))
107
- return;
108
- await sleep(500);
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(CNCJS_URL, { query: `token=${token}`, reconnection: true });
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 ${CNCJS_URL}`));
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
- this.port = null;
146
- this.controller = null;
147
- this.status = null;
148
- this.sender = null;
149
- this.workflow = "idle";
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: { url: CNCJS_URL, managed: this.managed, connected: this.connected, error: this.error },
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
- reject(new Error(`CNCjs could not open ${port}: ${JSON.stringify(err)}`));
231
- else
232
- resolve();
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(configFile) {
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(configFile, "utf-8")).secret;
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 ${configFile}. Set CNCJS_CONFIG or CNCJS_TOKEN.`);
394
+ throw new Error(`Could not read the CNCjs secret from ${MANAGED_CONFIG}.`);
274
395
  }
275
396
  if (!secret)
276
- throw new Error(`${configFile} has no "secret".`);
397
+ throw new Error(`${MANAGED_CONFIG} has no "secret".`);
277
398
  return jwt.sign({ id: "", name: "anc" }, secret, { expiresIn: "30d" });
278
399
  }
279
- async function reachable(url) {
280
- try {
281
- const res = await fetch(url, { signal: AbortSignal.timeout(1500) });
282
- return res.status < 500;
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
+ }
@@ -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
- /** Reads a file from the jobs directory only; paths outside it are refused. */
46
- export async function loadJobFile(file) {
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
+ }