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
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
// Reads a G-code program the way a careful machinist would before running it:
|
|
2
|
+
// how big it is, how long it takes, which tools it uses and where they change,
|
|
3
|
+
// whether it fits the machine from the current zero, and anything the
|
|
4
|
+
// controller won't do or that looks risky. Everything here is an estimate from
|
|
5
|
+
// the program text and the controller's own settings; nothing touches the machine.
|
|
6
|
+
const AXES = ["x", "y", "z"];
|
|
7
|
+
const ASSUMED_RAPID = 1000; // mm/min, a cautious hobby-machine figure
|
|
8
|
+
const LINE_SAMPLES = 5;
|
|
9
|
+
const round = (n, d = 2) => Math.round(n * 10 ** d) / 10 ** d;
|
|
10
|
+
function parseWords(code) {
|
|
11
|
+
const g = [];
|
|
12
|
+
const m = [];
|
|
13
|
+
const v = {};
|
|
14
|
+
for (const [, letter, num] of code.matchAll(/([A-Z])\s*([-+]?(?:\d+\.?\d*|\.\d+))/g)) {
|
|
15
|
+
const n = Number(num);
|
|
16
|
+
if (letter === "G")
|
|
17
|
+
g.push(n);
|
|
18
|
+
else if (letter === "M")
|
|
19
|
+
m.push(n);
|
|
20
|
+
else
|
|
21
|
+
v[letter] = n;
|
|
22
|
+
}
|
|
23
|
+
return { g, m, v };
|
|
24
|
+
}
|
|
25
|
+
/** Grbl and grblHAL both report as "Grbl" in CNCjs; grblHAL says so in its version line. */
|
|
26
|
+
function controllerKind(machine) {
|
|
27
|
+
if (!machine?.controller)
|
|
28
|
+
return "unknown";
|
|
29
|
+
if (!/grbl/i.test(machine.controller))
|
|
30
|
+
return "other";
|
|
31
|
+
return /hal/i.test(machine.version ?? "") ? "grblhal" : "grbl";
|
|
32
|
+
}
|
|
33
|
+
export function analyzeGcode(gcode, machine) {
|
|
34
|
+
const settings = machine?.settings ?? null;
|
|
35
|
+
const setting = (n) => {
|
|
36
|
+
const raw = settings?.[`$${n}`];
|
|
37
|
+
const val = raw === undefined ? NaN : Number(raw);
|
|
38
|
+
return Number.isFinite(val) ? val : null;
|
|
39
|
+
};
|
|
40
|
+
const rapid = { x: setting(110), y: setting(111), z: setting(112) };
|
|
41
|
+
const rapidKnown = rapid.x !== null && rapid.y !== null && rapid.z !== null;
|
|
42
|
+
const found = new Map();
|
|
43
|
+
const flag = (code, severity, message, line) => {
|
|
44
|
+
const c = found.get(code) ?? { severity, message, lines: [] };
|
|
45
|
+
if (c.lines.length < LINE_SAMPLES && c.lines[c.lines.length - 1] !== line)
|
|
46
|
+
c.lines.push(line);
|
|
47
|
+
found.set(code, c);
|
|
48
|
+
};
|
|
49
|
+
let scale = 1; // inch programs are converted to mm
|
|
50
|
+
let units = "mm";
|
|
51
|
+
let absolute = true;
|
|
52
|
+
let plane = 17;
|
|
53
|
+
let motion = 0;
|
|
54
|
+
let feed = null;
|
|
55
|
+
let spindleOn = false;
|
|
56
|
+
let pos = { x: 0, y: 0, z: 0 };
|
|
57
|
+
let moved = false;
|
|
58
|
+
let pendingTool = null;
|
|
59
|
+
let pendingLabel = null;
|
|
60
|
+
let tool = null;
|
|
61
|
+
let lastComment = null;
|
|
62
|
+
const min = { x: Infinity, y: Infinity, z: Infinity };
|
|
63
|
+
const max = { x: -Infinity, y: -Infinity, z: -Infinity };
|
|
64
|
+
const feeds = new Set();
|
|
65
|
+
const speeds = new Set();
|
|
66
|
+
const tools = [];
|
|
67
|
+
let toolChanges = 0;
|
|
68
|
+
let cutLength = 0;
|
|
69
|
+
let rapidLength = 0;
|
|
70
|
+
let cuttingSeconds = 0;
|
|
71
|
+
let rapidSeconds = 0;
|
|
72
|
+
const runs = [];
|
|
73
|
+
const steps = [];
|
|
74
|
+
let step = null;
|
|
75
|
+
const openStep = (line) => {
|
|
76
|
+
step = { tool, label: pendingLabel, firstLine: line, lastLine: line, seconds: 0, cutLength: 0, minZ: null, feedRange: null };
|
|
77
|
+
steps.push(step);
|
|
78
|
+
};
|
|
79
|
+
const include = (p) => {
|
|
80
|
+
for (const a of AXES) {
|
|
81
|
+
if (p[a] < min[a])
|
|
82
|
+
min[a] = p[a];
|
|
83
|
+
if (p[a] > max[a])
|
|
84
|
+
max[a] = p[a];
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
const lines = gcode.split(/\r?\n/);
|
|
88
|
+
for (let i = 0; i < lines.length; i++) {
|
|
89
|
+
const lineNo = i + 1;
|
|
90
|
+
const raw = lines[i];
|
|
91
|
+
const comments = [...raw.matchAll(/\(([^)]*)\)|;(.*)$/g)].map((c) => (c[1] ?? c[2] ?? "").trim()).filter(Boolean);
|
|
92
|
+
if (comments.length)
|
|
93
|
+
lastComment = comments.join(" ");
|
|
94
|
+
const code = raw.replace(/\([^)]*\)/g, " ").replace(/;.*$/, "").toUpperCase().replace(/^\s*N\d+/, "");
|
|
95
|
+
if (!code.trim() || code.trim().startsWith("%"))
|
|
96
|
+
continue;
|
|
97
|
+
const { g, m, v } = parseWords(code);
|
|
98
|
+
for (const n of g) {
|
|
99
|
+
if (n === 20) {
|
|
100
|
+
scale = 25.4;
|
|
101
|
+
units = "inch";
|
|
102
|
+
}
|
|
103
|
+
else if (n === 21) {
|
|
104
|
+
scale = 1;
|
|
105
|
+
units = "mm";
|
|
106
|
+
}
|
|
107
|
+
else if (n === 90)
|
|
108
|
+
absolute = true;
|
|
109
|
+
else if (n === 91)
|
|
110
|
+
absolute = false;
|
|
111
|
+
else if (n === 17 || n === 18 || n === 19)
|
|
112
|
+
plane = n;
|
|
113
|
+
else if (n >= 0 && n <= 3 && Number.isInteger(n))
|
|
114
|
+
motion = n;
|
|
115
|
+
else if (n >= 81 && n <= 89) {
|
|
116
|
+
const kind = controllerKind(machine);
|
|
117
|
+
if (kind === "grbl")
|
|
118
|
+
flag("canned-cycle", "problem", "Uses drilling cycles (G81–G89), which Grbl doesn't support. Post the program with cycles expanded to plain moves.", lineNo);
|
|
119
|
+
else if (kind !== "grblhal")
|
|
120
|
+
flag("canned-cycle", "check", "Uses drilling cycles (G81–G89). Plain Grbl doesn't support them; grblHAL does.", lineNo);
|
|
121
|
+
}
|
|
122
|
+
else if (n === 41 || n === 42) {
|
|
123
|
+
flag("cutter-comp", "problem", "Uses cutter radius compensation (G41/G42), which Grbl doesn't support. Post the program with compensation in the computer.", lineNo);
|
|
124
|
+
}
|
|
125
|
+
else if (n === 28 || n === 30) {
|
|
126
|
+
flag("stored-position", "check", "Moves to a stored position (G28/G30). Check where it is set before running.", lineNo);
|
|
127
|
+
}
|
|
128
|
+
else if (n === 53) {
|
|
129
|
+
flag("machine-coords", "check", "Has moves in machine coordinates (G53), which ignore your zero.", lineNo);
|
|
130
|
+
}
|
|
131
|
+
else if (n >= 38 && n < 39) {
|
|
132
|
+
flag("probe", "check", "Probes (G38). Make sure the probe is connected.", lineNo);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (v.F !== undefined)
|
|
136
|
+
feed = v.F * scale;
|
|
137
|
+
if (v.S !== undefined && v.S > 0)
|
|
138
|
+
speeds.add(v.S);
|
|
139
|
+
if (v.T !== undefined) {
|
|
140
|
+
pendingTool = v.T;
|
|
141
|
+
// CAM comments usually repeat the tool number ("T1 6 mm flat end mill").
|
|
142
|
+
pendingLabel = lastComment?.replace(/^T\d+\s*[:·-]?\s*/i, "") || null;
|
|
143
|
+
}
|
|
144
|
+
for (const n of m) {
|
|
145
|
+
if (n === 3 || n === 4)
|
|
146
|
+
spindleOn = true;
|
|
147
|
+
else if (n === 5)
|
|
148
|
+
spindleOn = false;
|
|
149
|
+
else if (n === 6) {
|
|
150
|
+
const next = pendingTool;
|
|
151
|
+
if (next !== null && next !== tool) {
|
|
152
|
+
if (tool !== null || steps.length)
|
|
153
|
+
toolChanges++;
|
|
154
|
+
tool = next;
|
|
155
|
+
if (!tools.includes(next))
|
|
156
|
+
tools.push(next);
|
|
157
|
+
step = null; // the next move opens a step for the new tool
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
// A T word with no M6 still names the tool, for programs that rely on the sender.
|
|
162
|
+
if (pendingTool !== null && tool === null && !m.includes(6)) {
|
|
163
|
+
tool = pendingTool;
|
|
164
|
+
if (!tools.includes(tool))
|
|
165
|
+
tools.push(tool);
|
|
166
|
+
}
|
|
167
|
+
// G28/G30/G53 moves don't land at the program's own coordinates; skip them.
|
|
168
|
+
if (g.some((n) => n === 28 || n === 30 || n === 53 || n === 92 || n === 10))
|
|
169
|
+
continue;
|
|
170
|
+
const hasAxis = v.X !== undefined || v.Y !== undefined || v.Z !== undefined;
|
|
171
|
+
if (!hasAxis)
|
|
172
|
+
continue;
|
|
173
|
+
const target = { ...pos };
|
|
174
|
+
for (const a of AXES) {
|
|
175
|
+
const val = v[a.toUpperCase()];
|
|
176
|
+
if (val === undefined)
|
|
177
|
+
continue;
|
|
178
|
+
target[a] = absolute ? val * scale : pos[a] + val * scale;
|
|
179
|
+
}
|
|
180
|
+
if (!step)
|
|
181
|
+
openStep(lineNo);
|
|
182
|
+
const s = step;
|
|
183
|
+
const runFeed = motion === 0 ? null : feed;
|
|
184
|
+
const lastRun = runs[runs.length - 1];
|
|
185
|
+
if (!lastRun || lastRun.feed !== runFeed || lastRun.tool !== tool)
|
|
186
|
+
runs.push({ line: lineNo, feed: runFeed, tool });
|
|
187
|
+
s.lastLine = lineNo;
|
|
188
|
+
const dx = target.x - pos.x;
|
|
189
|
+
const dy = target.y - pos.y;
|
|
190
|
+
const dz = target.z - pos.z;
|
|
191
|
+
if (motion === 0) {
|
|
192
|
+
const len = Math.hypot(dx, dy, dz);
|
|
193
|
+
rapidLength += len;
|
|
194
|
+
const t = rapidKnown
|
|
195
|
+
? Math.max(Math.abs(dx) / rapid.x, Math.abs(dy) / rapid.y, Math.abs(dz) / rapid.z) * 60
|
|
196
|
+
: (len / ASSUMED_RAPID) * 60;
|
|
197
|
+
rapidSeconds += t;
|
|
198
|
+
s.seconds += t;
|
|
199
|
+
if (moved && target.z < 0 && (dx !== 0 || dy !== 0 || dz < 0)) {
|
|
200
|
+
flag("rapid-in-stock", "check", "Has rapid moves below Z 0, which may plunge or travel through the stock at full speed.", lineNo);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
else {
|
|
204
|
+
let len = Math.hypot(dx, dy, dz);
|
|
205
|
+
if ((motion === 2 || motion === 3) && plane === 17) {
|
|
206
|
+
const cx = v.I !== undefined ? pos.x + v.I * scale : null;
|
|
207
|
+
const cy = v.J !== undefined ? pos.y + v.J * scale : null;
|
|
208
|
+
if (cx !== null || cy !== null || v.R !== undefined) {
|
|
209
|
+
const ccx = cx ?? pos.x;
|
|
210
|
+
const ccy = cy ?? pos.y;
|
|
211
|
+
const r = v.R !== undefined ? Math.abs(v.R * scale) : Math.hypot(pos.x - ccx, pos.y - ccy);
|
|
212
|
+
let sweep;
|
|
213
|
+
if (v.R !== undefined) {
|
|
214
|
+
const chord = Math.min(Math.hypot(dx, dy), 2 * r);
|
|
215
|
+
sweep = 2 * Math.asin(chord / (2 * r || 1));
|
|
216
|
+
if (v.R < 0)
|
|
217
|
+
sweep = 2 * Math.PI - sweep;
|
|
218
|
+
}
|
|
219
|
+
else {
|
|
220
|
+
const a0 = Math.atan2(pos.y - ccy, pos.x - ccx);
|
|
221
|
+
const a1 = Math.atan2(target.y - ccy, target.x - ccx);
|
|
222
|
+
sweep = motion === 2 ? a0 - a1 : a1 - a0;
|
|
223
|
+
while (sweep <= 1e-9)
|
|
224
|
+
sweep += 2 * Math.PI;
|
|
225
|
+
// Arc bounds: include the circle's extreme points the sweep passes.
|
|
226
|
+
for (let k = 0; k < 4; k++) {
|
|
227
|
+
const ang = (k * Math.PI) / 2;
|
|
228
|
+
const rel = motion === 2 ? a0 - ang : ang - a0;
|
|
229
|
+
let d = rel % (2 * Math.PI);
|
|
230
|
+
if (d < 0)
|
|
231
|
+
d += 2 * Math.PI;
|
|
232
|
+
if (d < sweep)
|
|
233
|
+
include({ x: ccx + r * Math.cos(ang), y: ccy + r * Math.sin(ang), z: target.z });
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
len = Math.hypot(r * sweep, dz);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
cutLength += len;
|
|
240
|
+
s.cutLength += len;
|
|
241
|
+
if (feed === null || feed <= 0) {
|
|
242
|
+
flag("no-feed", "problem", "Has a cutting move before any feed rate (F) is set. Grbl refuses it.", lineNo);
|
|
243
|
+
}
|
|
244
|
+
else {
|
|
245
|
+
feeds.add(round(feed, 1));
|
|
246
|
+
const t = (len / feed) * 60;
|
|
247
|
+
cuttingSeconds += t;
|
|
248
|
+
s.seconds += t;
|
|
249
|
+
const f = feed;
|
|
250
|
+
s.feedRange = s.feedRange ? [Math.min(s.feedRange[0], f), Math.max(s.feedRange[1], f)] : [f, f];
|
|
251
|
+
}
|
|
252
|
+
if (!spindleOn && Math.min(pos.z, target.z) < 0) {
|
|
253
|
+
flag("spindle-off", "problem", "Cuts below Z 0 with the spindle off (no M3 before it).", lineNo);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
include(pos);
|
|
257
|
+
include(target);
|
|
258
|
+
const lowZ = Math.min(pos.z, target.z);
|
|
259
|
+
s.minZ = s.minZ === null ? lowZ : Math.min(s.minZ, lowZ);
|
|
260
|
+
pos = target;
|
|
261
|
+
moved = true;
|
|
262
|
+
}
|
|
263
|
+
if (toolChanges > 0) {
|
|
264
|
+
found.set("tool-changes", {
|
|
265
|
+
severity: "check",
|
|
266
|
+
message: `Changes tool ${toolChanges} time${toolChanges === 1 ? "" : "s"}. Grbl doesn't stop for M6 by itself; CNCjs's tool change setting has to pause the job so you can swap the tool and set its length.`,
|
|
267
|
+
lines: steps.slice(1).map((s) => s.firstLine).slice(0, LINE_SAMPLES),
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
const bounds = Number.isFinite(min.x) ? { min: roundVec(min), max: roundVec(max) } : null;
|
|
271
|
+
const fit = fitCheck(bounds, machine, setting);
|
|
272
|
+
if (fit.status === "over") {
|
|
273
|
+
for (const a of fit.axes) {
|
|
274
|
+
found.set(`travel-${a.axis}`, {
|
|
275
|
+
severity: "problem",
|
|
276
|
+
message: `Goes ${round(a.over, 1)} mm past the ${a.axis.toUpperCase()} travel. From the current zero it runs past the machine's ${a.direction} ${a.axis.toUpperCase()} end; move the zero or the stock.`,
|
|
277
|
+
lines: [],
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
const checks = [...found.entries()]
|
|
282
|
+
.map(([code, c]) => ({ code, ...c }))
|
|
283
|
+
.sort((a, b) => (a.severity === b.severity ? 0 : a.severity === "problem" ? -1 : 1));
|
|
284
|
+
const feedList = [...feeds].sort((a, b) => a - b);
|
|
285
|
+
return {
|
|
286
|
+
units,
|
|
287
|
+
bounds,
|
|
288
|
+
size: bounds ? roundVec({ x: bounds.max.x - bounds.min.x, y: bounds.max.y - bounds.min.y, z: bounds.max.z - bounds.min.z }) : null,
|
|
289
|
+
deepestZ: bounds ? bounds.min.z : null,
|
|
290
|
+
time: {
|
|
291
|
+
totalSeconds: Math.round(cuttingSeconds + rapidSeconds),
|
|
292
|
+
cuttingSeconds: Math.round(cuttingSeconds),
|
|
293
|
+
rapidSeconds: Math.round(rapidSeconds),
|
|
294
|
+
rapidRate: rapidKnown ? "machine" : "assumed",
|
|
295
|
+
},
|
|
296
|
+
cutLength: round(cutLength, 1),
|
|
297
|
+
rapidLength: round(rapidLength, 1),
|
|
298
|
+
feedRange: feedList.length ? [feedList[0], feedList[feedList.length - 1]] : null,
|
|
299
|
+
spindleSpeeds: [...speeds].sort((a, b) => a - b),
|
|
300
|
+
tools,
|
|
301
|
+
toolChanges,
|
|
302
|
+
steps: steps.map((s) => ({
|
|
303
|
+
...s,
|
|
304
|
+
seconds: Math.round(s.seconds),
|
|
305
|
+
cutLength: round(s.cutLength, 1),
|
|
306
|
+
minZ: s.minZ === null ? null : round(s.minZ, 3),
|
|
307
|
+
feedRange: s.feedRange ? [round(s.feedRange[0], 1), round(s.feedRange[1], 1)] : null,
|
|
308
|
+
})),
|
|
309
|
+
fit,
|
|
310
|
+
checks,
|
|
311
|
+
runs,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
function roundVec(p) {
|
|
315
|
+
return { x: round(p.x, 3), y: round(p.y, 3), z: round(p.z, 3) };
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* Whether the program stays inside the machine's travel from the current
|
|
319
|
+
* work zero. Grbl's machine coordinates run from 0 at the homed corner into
|
|
320
|
+
* minus by $130-$132, unless $23 flips an axis to home towards minus. Without
|
|
321
|
+
* homing ($22 off) machine coordinates mean nothing, so the fit is unknown.
|
|
322
|
+
*/
|
|
323
|
+
function fitCheck(bounds, machine, setting) {
|
|
324
|
+
const unknown = (reason) => ({ status: "unknown", axes: [], reason });
|
|
325
|
+
if (!bounds)
|
|
326
|
+
return unknown("The program has no moves.");
|
|
327
|
+
if (!machine?.controller)
|
|
328
|
+
return unknown("No machine is connected.");
|
|
329
|
+
if (!/grbl/i.test(machine.controller))
|
|
330
|
+
return unknown("Only Grbl machines report their travel.");
|
|
331
|
+
if (!machine.mpos || !machine.wpos)
|
|
332
|
+
return unknown("The machine hasn't reported its position yet.");
|
|
333
|
+
const travel = { x: setting(130), y: setting(131), z: setting(132) };
|
|
334
|
+
if (travel.x === null || travel.y === null || travel.z === null)
|
|
335
|
+
return unknown("The machine hasn't reported its travel settings ($130–$132).");
|
|
336
|
+
if (!((setting(22) ?? 0) & 1))
|
|
337
|
+
return unknown("Homing is off ($22), so the machine doesn't know where its travel ends.");
|
|
338
|
+
const dirMask = setting(23) ?? 0;
|
|
339
|
+
const axes = [];
|
|
340
|
+
AXES.forEach((a, bit) => {
|
|
341
|
+
const offset = machine.mpos[a] - machine.wpos[a];
|
|
342
|
+
const towardsMinus = (dirMask >> bit) & 1;
|
|
343
|
+
const lo = towardsMinus ? 0 : -travel[a];
|
|
344
|
+
const hi = towardsMinus ? travel[a] : 0;
|
|
345
|
+
const from = bounds.min[a] + offset;
|
|
346
|
+
const to = bounds.max[a] + offset;
|
|
347
|
+
const tolerance = 0.01;
|
|
348
|
+
if (from < lo - tolerance)
|
|
349
|
+
axes.push({ axis: a, over: lo - from, direction: "minus" });
|
|
350
|
+
if (to > hi + tolerance)
|
|
351
|
+
axes.push({ axis: a, over: to - hi, direction: "plus" });
|
|
352
|
+
});
|
|
353
|
+
return axes.length
|
|
354
|
+
? { status: "over", axes, reason: "From the current zero, the program leaves the machine's travel." }
|
|
355
|
+
: { status: "fits", axes, reason: "From the current zero, the program stays inside the machine's travel." };
|
|
356
|
+
}
|
package/dist/server/autostart.js
CHANGED
|
@@ -40,14 +40,14 @@ export function enableAutostart(cliPath) {
|
|
|
40
40
|
if (process.platform === "darwin") {
|
|
41
41
|
const plist = path.join(os.homedir(), "Library", "LaunchAgents", `${LABEL}.plist`);
|
|
42
42
|
fs.mkdirSync(path.dirname(plist), { recursive: true });
|
|
43
|
-
fs.writeFileSync(plist, `<?xml version="1.0" encoding="UTF-8"?>
|
|
44
|
-
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
45
|
-
<plist version="1.0"><dict>
|
|
46
|
-
<key>Label</key><string>${LABEL}</string>
|
|
47
|
-
<key>ProgramArguments</key><array>${cmd.map((c) => `<string>${c}</string>`).join("")}</array>
|
|
48
|
-
<key>RunAtLoad</key><true/>
|
|
49
|
-
<key>KeepAlive</key><true/>
|
|
50
|
-
</dict></plist>
|
|
43
|
+
fs.writeFileSync(plist, `<?xml version="1.0" encoding="UTF-8"?>
|
|
44
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
45
|
+
<plist version="1.0"><dict>
|
|
46
|
+
<key>Label</key><string>${LABEL}</string>
|
|
47
|
+
<key>ProgramArguments</key><array>${cmd.map((c) => `<string>${c}</string>`).join("")}</array>
|
|
48
|
+
<key>RunAtLoad</key><true/>
|
|
49
|
+
<key>KeepAlive</key><true/>
|
|
50
|
+
</dict></plist>
|
|
51
51
|
`);
|
|
52
52
|
quiet("launchctl", ["unload", plist]);
|
|
53
53
|
execFileSync("launchctl", ["load", "-w", plist], { stdio: "ignore" });
|
|
@@ -55,16 +55,16 @@ export function enableAutostart(cliPath) {
|
|
|
55
55
|
}
|
|
56
56
|
const unit = path.join(os.homedir(), ".config", "systemd", "user", `${PRODUCT.name}.service`);
|
|
57
57
|
fs.mkdirSync(path.dirname(unit), { recursive: true });
|
|
58
|
-
fs.writeFileSync(unit, `[Unit]
|
|
59
|
-
Description=${PRODUCT.displayName} agent
|
|
60
|
-
|
|
61
|
-
[Service]
|
|
62
|
-
ExecStart=${cmd.map((c) => `"${c}"`).join(" ")}
|
|
63
|
-
Restart=always
|
|
64
|
-
RestartSec=5
|
|
65
|
-
|
|
66
|
-
[Install]
|
|
67
|
-
WantedBy=default.target
|
|
58
|
+
fs.writeFileSync(unit, `[Unit]
|
|
59
|
+
Description=${PRODUCT.displayName} agent
|
|
60
|
+
|
|
61
|
+
[Service]
|
|
62
|
+
ExecStart=${cmd.map((c) => `"${c}"`).join(" ")}
|
|
63
|
+
Restart=always
|
|
64
|
+
RestartSec=5
|
|
65
|
+
|
|
66
|
+
[Install]
|
|
67
|
+
WantedBy=default.target
|
|
68
68
|
`);
|
|
69
69
|
execFileSync("systemctl", ["--user", "daemon-reload"], { stdio: "ignore" });
|
|
70
70
|
execFileSync("systemctl", ["--user", "enable", "--now", `${PRODUCT.name}.service`], { stdio: "ignore" });
|
package/dist/server/cncjs.js
CHANGED
|
@@ -13,6 +13,7 @@ const require = createRequire(import.meta.url);
|
|
|
13
13
|
export const CNCJS_URL = process.env.CNCJS_URL ?? "http://127.0.0.1:8000";
|
|
14
14
|
import { STATE_DIR } from "./paths.js";
|
|
15
15
|
import { ROOT } from "./product.js";
|
|
16
|
+
import { programHash } from "./program.js";
|
|
16
17
|
const MANAGED_CONFIG = path.join(STATE_DIR, "cncrc");
|
|
17
18
|
const USER_CONFIG = process.env.CNCJS_CONFIG ?? path.join(os.homedir(), ".cncrc");
|
|
18
19
|
/** Telemetry older than this is shown as stale rather than live. */
|
|
@@ -30,18 +31,72 @@ class CNCjsLink {
|
|
|
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
|
}
|
|
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
|
+
}
|
|
45
100
|
/** Connects (starting a bundled CNCjs if needed). Safe to call repeatedly. */
|
|
46
101
|
ensure() {
|
|
47
102
|
if (this.connected)
|
|
@@ -140,14 +195,36 @@ class CNCjsLink {
|
|
|
140
195
|
socket.on("serialport:open", (o) => {
|
|
141
196
|
this.port = o?.port ?? null;
|
|
142
197
|
this.controller = o?.controllerType ?? null;
|
|
198
|
+
this.baudrate = typeof o?.baudrate === "number" ? o.baudrate : null;
|
|
143
199
|
});
|
|
144
200
|
socket.on("serialport:close", () => {
|
|
201
|
+
if (this.workflow !== "idle")
|
|
202
|
+
this.endRun();
|
|
145
203
|
this.port = null;
|
|
146
204
|
this.controller = null;
|
|
205
|
+
this.baudrate = null;
|
|
147
206
|
this.status = null;
|
|
207
|
+
this.settings = null;
|
|
208
|
+
this.firmware = null;
|
|
209
|
+
this.parameters = null;
|
|
148
210
|
this.sender = null;
|
|
149
211
|
this.workflow = "idle";
|
|
150
212
|
});
|
|
213
|
+
// Grbl's $$ settings (travel, rapid rates, homing), which CNCjs asks for on connect.
|
|
214
|
+
socket.on("controller:settings", (_type, s) => {
|
|
215
|
+
const values = s?.settings && typeof s.settings === "object" ? s.settings : null;
|
|
216
|
+
if (values) {
|
|
217
|
+
const first = !this.settings;
|
|
218
|
+
this.settings = { version: typeof s.version === "string" ? s.version : null, values };
|
|
219
|
+
const params = s?.parameters && typeof s.parameters === "object" ? s.parameters : null;
|
|
220
|
+
if (params && Object.keys(params).length)
|
|
221
|
+
this.parameters = params;
|
|
222
|
+
this.reportMachine();
|
|
223
|
+
// Ask once for the saved zeros ($#); Grbl answers at once and nothing moves.
|
|
224
|
+
if (first && this.workflow === "idle")
|
|
225
|
+
this.socket?.emit("command", this.port, "gcode", "$#");
|
|
226
|
+
}
|
|
227
|
+
});
|
|
151
228
|
socket.on("controller:state", (type, state) => {
|
|
152
229
|
this.controller = type;
|
|
153
230
|
this.status = state?.status ?? this.status;
|
|
@@ -161,27 +238,43 @@ class CNCjsLink {
|
|
|
161
238
|
});
|
|
162
239
|
socket.on("serialport:read", (data) => {
|
|
163
240
|
const m = /ALARM:(\d+)(.*)/.exec(data ?? "");
|
|
164
|
-
if (m)
|
|
241
|
+
if (m) {
|
|
165
242
|
this.alarm = `ALARM:${m[1]}${m[2]}`.trim();
|
|
243
|
+
this.reportMachine();
|
|
244
|
+
}
|
|
245
|
+
const startup = /^(Grbl\w*)\s+v?\d/i.exec(data ?? "");
|
|
246
|
+
if (startup)
|
|
247
|
+
this.firmware = startup[1];
|
|
166
248
|
});
|
|
167
249
|
socket.on("workflow:state", (s) => {
|
|
250
|
+
const wasRunning = this.workflow !== "idle";
|
|
168
251
|
this.workflow = s;
|
|
252
|
+
if (wasRunning && s === "idle")
|
|
253
|
+
this.endRun();
|
|
169
254
|
});
|
|
170
255
|
socket.on("sender:status", (s) => {
|
|
171
256
|
this.sender = s;
|
|
257
|
+
if (this.workflow !== "idle" && (s.sent > 0 || s.finishTime > 0))
|
|
258
|
+
this.runSender = { ...s };
|
|
172
259
|
});
|
|
173
260
|
socket.on("gcode:load", (name, gcode) => {
|
|
174
261
|
this.gcodeName = name;
|
|
175
262
|
// CNCjs appends its own "%wait" dwell line; drop it from the preview.
|
|
176
263
|
const program = gcode.replace(/\n%wait ; Wait for the planner to empty\s*$/, "");
|
|
264
|
+
this.gcodeHash = programHash(program);
|
|
177
265
|
for (const fn of this.onLoadListeners)
|
|
178
266
|
fn(name, program);
|
|
179
267
|
});
|
|
180
268
|
socket.on("gcode:unload", () => {
|
|
181
269
|
this.gcodeName = null;
|
|
270
|
+
this.gcodeHash = null;
|
|
182
271
|
this.sender = null;
|
|
183
272
|
});
|
|
184
273
|
}
|
|
274
|
+
/** The controller's own settings ($$ for Grbl), or null before it has reported them. */
|
|
275
|
+
controllerSettings() {
|
|
276
|
+
return this.port ? this.settings : null;
|
|
277
|
+
}
|
|
185
278
|
snapshot() {
|
|
186
279
|
const age = Date.now() - this.telemetryAt;
|
|
187
280
|
// CNCjs emits controller:state only when the state changes, so a quiet
|
|
@@ -205,6 +298,7 @@ class CNCjsLink {
|
|
|
205
298
|
cncjs: { url: CNCJS_URL, managed: this.managed, connected: this.connected, error: this.error },
|
|
206
299
|
port: this.port,
|
|
207
300
|
controller: this.controller,
|
|
301
|
+
baudrate: this.baudrate,
|
|
208
302
|
activeState: this.status?.activeState ?? null,
|
|
209
303
|
workflow: this.workflow,
|
|
210
304
|
mpos: toVec(this.status?.mpos),
|
|
@@ -213,6 +307,7 @@ class CNCjsLink {
|
|
|
213
307
|
alarm: this.alarm,
|
|
214
308
|
sender: this.sender,
|
|
215
309
|
loadedFile: this.gcodeName,
|
|
310
|
+
loadedHash: this.gcodeHash,
|
|
216
311
|
telemetryAt: this.telemetryAt ? new Date(this.telemetryAt).toISOString() : null,
|
|
217
312
|
};
|
|
218
313
|
}
|
|
@@ -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
|
+
}
|