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