plotdeck 1.0.0__py3-none-any.whl

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.
@@ -0,0 +1,404 @@
1
+ """G-code tokenizer, cleaner and toolpath extractor.
2
+
3
+ The parser is deliberately small: PlotDeck is a sender, not a CAM package, so
4
+ it only needs enough modal understanding to draw an accurate preview and
5
+ estimate job time.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import math
11
+ import re
12
+ from dataclasses import dataclass, field
13
+
14
+ MAX_LINE_LENGTH = 80
15
+ RAPID_RATE_MM_MIN = 3000.0
16
+ ARC_SEGMENTS = 48
17
+
18
+ WORD_RE = re.compile(r"([A-Z])\s*([-+]?(?:\d+(?:\.\d*)?|\.\d+))")
19
+ M6_RE = re.compile(r"(?:^|\s)M0?6(?:\D|$)")
20
+ TOOL_RE = re.compile(r"(?:^|\s)T(\d+)(?:\D|$)")
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class ProgramLine:
25
+ """A single executable line, with its original 1-based line number."""
26
+
27
+ text: str
28
+ source_line: int
29
+ length: int = 0
30
+
31
+ def __post_init__(self) -> None:
32
+ if not self.length:
33
+ object.__setattr__(self, "length", len(self.text))
34
+
35
+ @property
36
+ def too_long(self) -> bool:
37
+ return self.length > MAX_LINE_LENGTH
38
+
39
+ @property
40
+ def is_tool_change(self) -> bool:
41
+ if M6_RE.search(self.text):
42
+ return True
43
+ # A standalone tool-select line (T2 with no motion) is also a change.
44
+ if TOOL_RE.search(self.text):
45
+ words = WORD_RE.findall(self.text)
46
+ return bool(words) and all(letter in {"T", "N"} for letter, _ in words)
47
+ return False
48
+
49
+ @property
50
+ def tool_number(self) -> int | None:
51
+ match = TOOL_RE.search(self.text)
52
+ return int(match.group(1)) if match else None
53
+
54
+
55
+ def strip_comments(line: str) -> str:
56
+ """Remove ``;`` and ``(...)`` comments, respecting commented semicolons."""
57
+ out: list[str] = []
58
+ depth = 0
59
+ for char in line:
60
+ if depth:
61
+ if char == ")":
62
+ depth -= 1
63
+ continue
64
+ if char == "(":
65
+ depth += 1
66
+ continue
67
+ if char == ";":
68
+ break
69
+ out.append(char)
70
+ return "".join(out)
71
+
72
+
73
+ def clean_line(line: str) -> str:
74
+ """Clean a source line into something GRBL will accept."""
75
+ text = strip_comments(line).strip()
76
+ if not text or text.startswith("%"):
77
+ return ""
78
+ text = re.sub(r"\s+", " ", text.upper())
79
+ return text
80
+
81
+
82
+ def tokenize(text: str) -> list[tuple[str, float]]:
83
+ """Split a cleaned line into ``(letter, value)`` words."""
84
+ return [(match.group(1), float(match.group(2))) for match in WORD_RE.finditer(text)]
85
+
86
+
87
+ def parse_program(source: str) -> list[ProgramLine]:
88
+ """Clean *source* and return the executable lines with source numbers."""
89
+ program: list[ProgramLine] = []
90
+ for index, raw in enumerate(source.splitlines(), start=1):
91
+ text = clean_line(raw)
92
+ if text:
93
+ program.append(ProgramLine(text=text, source_line=index))
94
+ return program
95
+
96
+
97
+ def line_count(source: str) -> int:
98
+ return len(source.splitlines())
99
+
100
+
101
+ # --------------------------------------------------------------------------- #
102
+ # Toolpath model
103
+ # --------------------------------------------------------------------------- #
104
+ Point = tuple[float, float, float]
105
+
106
+
107
+ @dataclass
108
+ class Segment:
109
+ kind: str # "rapid" | "feed" | "arc_cw" | "arc_ccw"
110
+ start: Point
111
+ end: Point
112
+ source_line: int
113
+ points: list[Point] = field(default_factory=list)
114
+
115
+ @property
116
+ def length(self) -> float:
117
+ if len(self.points) > 1:
118
+ return sum(
119
+ math.dist(self.points[i], self.points[i + 1]) for i in range(len(self.points) - 1)
120
+ )
121
+ return math.dist(self.start, self.end)
122
+
123
+
124
+ @dataclass
125
+ class Toolpath:
126
+ segments: list[Segment] = field(default_factory=list)
127
+ bounds: tuple[Point, Point] | None = None
128
+ estimated_seconds: float = 0.0
129
+ tools: list[int] = field(default_factory=list)
130
+ min_z: float | None = None
131
+ max_z: float | None = None
132
+ warnings: list[str] = field(default_factory=list)
133
+ unsupported: set[str] = field(default_factory=set)
134
+
135
+ @property
136
+ def total_length(self) -> float:
137
+ return sum(segment.length for segment in self.segments)
138
+
139
+
140
+ class _Modal:
141
+ def __init__(self) -> None:
142
+ self.units = 1.0 # multiplier to millimetres
143
+ self.absolute = True
144
+ self.motion = 0
145
+ self.plane = "XY"
146
+ self.position: Point = (0.0, 0.0, 0.0)
147
+ self.feed: float | None = None
148
+
149
+
150
+ def _plane_axes(plane: str) -> tuple[int, int, int]:
151
+ if plane == "ZX":
152
+ return 2, 0, 1
153
+ if plane == "YZ":
154
+ return 1, 2, 0
155
+ return 0, 1, 2
156
+
157
+
158
+ def _arc_points(
159
+ start: Point,
160
+ end: Point,
161
+ center: Point,
162
+ clockwise: bool,
163
+ plane: str,
164
+ ) -> list[Point]:
165
+ u, v, w = _plane_axes(plane)
166
+ su, sv = start[u], start[v]
167
+ eu, ev = end[u], end[v]
168
+ cu, cv = center[u], center[v]
169
+
170
+ radius = math.hypot(su - cu, sv - cv)
171
+ start_angle = math.atan2(sv - cv, su - cu)
172
+ end_angle = math.atan2(ev - cv, eu - cu)
173
+
174
+ if clockwise:
175
+ while end_angle >= start_angle:
176
+ end_angle -= 2 * math.pi
177
+ else:
178
+ while end_angle <= start_angle:
179
+ end_angle += 2 * math.pi
180
+
181
+ sweep = end_angle - start_angle
182
+ count = max(4, min(ARC_SEGMENTS, int(abs(sweep) / (math.pi / 24)) + 1))
183
+
184
+ points: list[Point] = []
185
+ for step in range(count + 1):
186
+ fraction = step / count
187
+ angle = start_angle + sweep * fraction
188
+ point = list(start)
189
+ point[u] = cu + radius * math.cos(angle)
190
+ point[v] = cv + radius * math.sin(angle)
191
+ point[w] = start[w] + (end[w] - start[w]) * fraction
192
+ points.append((point[0], point[1], point[2]))
193
+ points[-1] = end
194
+ return points
195
+
196
+
197
+ def _arc_points_from_radius(
198
+ start: Point, end: Point, radius: float, clockwise: bool, plane: str
199
+ ) -> list[Point] | None:
200
+ u, v, _ = _plane_axes(plane)
201
+ chord = math.dist((start[u], start[v]), (end[u], end[v]))
202
+ if chord == 0 or chord > 2 * abs(radius):
203
+ return None
204
+ half = chord / 2
205
+ height = math.sqrt(max(0.0, radius * radius - half * half))
206
+ mx = (start[u] + end[u]) / 2
207
+ my = (start[v] + end[v]) / 2
208
+ dx = end[u] - start[u]
209
+ dy = end[v] - start[v]
210
+ length = math.hypot(dx, dy) or 1.0
211
+ nx, ny = -dy / length, dx / length
212
+
213
+ # Positive R selects the minor arc; negative selects the major arc.
214
+ minor = radius >= 0
215
+ candidates = [(mx + nx * height, my + ny * height), (mx - nx * height, my - ny * height)]
216
+ center = candidates[0]
217
+ for candidate in candidates:
218
+ cangle = math.atan2(start[v] - candidate[1], start[u] - candidate[0])
219
+ eangle = math.atan2(end[v] - candidate[1], end[u] - candidate[0])
220
+ sweep = eangle - cangle
221
+ sweep = -(sweep % (2 * math.pi)) if clockwise else sweep % (2 * math.pi)
222
+ if (abs(sweep) <= math.pi) == minor:
223
+ center = candidate
224
+ break
225
+ cx, cy = center
226
+ full_center = list(start)
227
+ full_center[u] = cx
228
+ full_center[v] = cy
229
+ return _arc_points(start, end, tuple(full_center), clockwise, plane) # type: ignore[arg-type]
230
+
231
+
232
+ def _segment_length(points: list[Point]) -> float:
233
+ return sum(math.dist(points[i], points[i + 1]) for i in range(len(points) - 1))
234
+
235
+
236
+ def build_toolpath(program: list[ProgramLine], limits: Point | None = None) -> Toolpath:
237
+ """Extract drawable segments and job statistics from *program*."""
238
+ model = _Modal()
239
+ path = Toolpath()
240
+ tools: set[int] = set()
241
+ seen_z: list[float] = []
242
+
243
+ for line in program:
244
+ if line.is_tool_change:
245
+ tool = line.tool_number
246
+ if tool is not None:
247
+ tools.add(tool)
248
+ continue
249
+
250
+ if line.too_long:
251
+ path.warnings.append(f"Line {line.source_line} is longer than 80 characters.")
252
+
253
+ words = tokenize(line.text)
254
+ values: dict[str, float] = {}
255
+ for letter, value in words:
256
+ values[letter] = value
257
+
258
+ # Update modal state before motion. A block can carry several G words
259
+ # (for example "G20 G90"), so process all of them in order.
260
+ g_codes = [value for letter, value in words if letter == "G"]
261
+ for code in g_codes:
262
+ if code == 20:
263
+ model.units = 25.4
264
+ elif code == 21:
265
+ model.units = 1.0
266
+ elif code == 90:
267
+ model.absolute = True
268
+ elif code == 91:
269
+ model.absolute = False
270
+ elif code in (0, 1, 2, 3):
271
+ model.motion = int(code)
272
+ elif code == 17:
273
+ model.plane = "XY"
274
+ elif code == 18:
275
+ model.plane = "ZX"
276
+ elif code == 19:
277
+ model.plane = "YZ"
278
+ if "F" in values:
279
+ model.feed = values["F"] * model.units
280
+
281
+ if any(code in (4, 10, 28, 30, 53, 92) for code in g_codes) and not any(
282
+ code in (0, 1, 2, 3) for code in g_codes
283
+ ):
284
+ # Dwells and coordinate-setting commands do not produce a segment.
285
+ continue
286
+ if model.motion not in (0, 1, 2, 3):
287
+ continue
288
+ if not any(axis in values for axis in ("X", "Y", "Z", "I", "J")):
289
+ continue
290
+
291
+ target = list(model.position)
292
+ for index, axis in enumerate(("X", "Y", "Z")):
293
+ if axis in values:
294
+ value = values[axis] * model.units
295
+ target[index] = value if model.absolute else model.position[index] + value
296
+ end = (target[0], target[1], target[2])
297
+ start = model.position
298
+ points: list[Point] | None
299
+
300
+ if model.motion == 0:
301
+ kind = "rapid"
302
+ points = [start, end]
303
+ elif model.motion == 1:
304
+ kind = "feed"
305
+ points = [start, end]
306
+ else:
307
+ clockwise = model.motion == 2
308
+ kind = "arc_cw" if clockwise else "arc_ccw"
309
+ plane = model.plane
310
+ u, v, _ = _plane_axes(plane)
311
+ center = list(start)
312
+ center_letters = {"XY": ("I", "J"), "ZX": ("K", "I"), "YZ": ("J", "K")}[plane]
313
+ u_word, v_word = center_letters
314
+ if u_word in values or v_word in values:
315
+ center[u] = start[u] + values.get(u_word, 0.0) * model.units
316
+ center[v] = start[v] + values.get(v_word, 0.0) * model.units
317
+ points = _arc_points(start, end, tuple(center), clockwise, plane) # type: ignore[arg-type]
318
+ elif "R" in values:
319
+ points = _arc_points_from_radius(
320
+ start, end, values["R"] * model.units, clockwise, plane
321
+ )
322
+ else:
323
+ path.unsupported.add("arc without I/J/R")
324
+ points = None
325
+ if points is None:
326
+ kind = "feed"
327
+ points = [start, end]
328
+
329
+ segment = Segment(
330
+ kind=kind,
331
+ start=start,
332
+ end=end,
333
+ source_line=line.source_line,
334
+ points=points,
335
+ )
336
+ path.segments.append(segment)
337
+
338
+ rate = RAPID_RATE_MM_MIN if kind == "rapid" else (model.feed or 0.0)
339
+ if rate > 0:
340
+ path.estimated_seconds += segment.length / rate * 60.0
341
+
342
+ model.position = end
343
+ seen_z.extend((start[2], end[2]))
344
+
345
+ if path.segments:
346
+ starts = [segment.start for segment in path.segments]
347
+ ends = [segment.end for segment in path.segments]
348
+ all_points = starts + ends
349
+ low = tuple(min(point[i] for point in all_points) for i in range(3))
350
+ high = tuple(max(point[i] for point in all_points) for i in range(3))
351
+ path.bounds = (low, high) # type: ignore[assignment]
352
+ if limits and any(high[i] > limits[i] for i in range(3)):
353
+ path.warnings.append("Toolpath extends beyond the configured machine travel.")
354
+ if seen_z:
355
+ path.min_z = min(seen_z)
356
+ path.max_z = max(seen_z)
357
+ path.tools = sorted(tools)
358
+ return path
359
+
360
+
361
+ def transform_lines(
362
+ lines: list[ProgramLine],
363
+ dx: float = 0.0,
364
+ dy: float = 0.0,
365
+ dz: float = 0.0,
366
+ scale: float = 1.0,
367
+ rotate: float = 0.0,
368
+ ) -> list[str]:
369
+ """Offset/scale/rotate axis words, returning new text lines."""
370
+ if rotate:
371
+ cos_a = math.cos(math.radians(rotate))
372
+ sin_a = math.sin(math.radians(rotate))
373
+ result: list[str] = []
374
+ for line in lines:
375
+ words = tokenize(line.text)
376
+ values = {letter: value for letter, value in words}
377
+ if not any(axis in values for axis in ("X", "Y", "Z")):
378
+ result.append(line.text)
379
+ continue
380
+
381
+ x = values.get("X", 0.0) * scale
382
+ y = values.get("Y", 0.0) * scale
383
+ z = values.get("Z", 0.0) * scale
384
+ if rotate:
385
+ x, y = x * cos_a - y * sin_a, x * sin_a + y * cos_a
386
+ x += dx
387
+ y += dy
388
+ z += dz
389
+
390
+ def fmt(value: float) -> str:
391
+ text = f"{value:.4f}".rstrip("0").rstrip(".")
392
+ return "0" if text in ("-0", "") else text
393
+
394
+ out = line.text
395
+ for axis, value in (("X", x), ("Y", y), ("Z", z)):
396
+ if axis in values:
397
+ out = re.sub(
398
+ rf"{axis}\s*[-+]?(?:\d+(?:\.\d*)?|\.\d+)",
399
+ f"{axis}{fmt(value)}",
400
+ out,
401
+ count=1,
402
+ )
403
+ result.append(out)
404
+ return result
@@ -0,0 +1,278 @@
1
+ """GRBL 1.1 protocol constants, response classification and lookup tables.
2
+
3
+ Pure Python; no Qt or serial imports. Everything the rest of the application
4
+ needs to know about the wire protocol lives here.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from dataclasses import dataclass
11
+ from enum import Enum
12
+
13
+ # --------------------------------------------------------------------------- #
14
+ # Wire constants
15
+ # --------------------------------------------------------------------------- #
16
+ BAUD_RATE = 115200
17
+ RX_BUFFER_SIZE = 127 # usable bytes, not 128
18
+ MAX_LINE_LENGTH = 80
19
+
20
+ STATUS_INTERVAL_MS = 200
21
+ UNO_RESET_DELAY_S = 2.0
22
+
23
+
24
+ class RealTime(bytes, Enum):
25
+ """Single-byte commands that bypass the line queue and never get an ``ok``."""
26
+
27
+ STATUS = b"?"
28
+ FEED_HOLD = b"!"
29
+ CYCLE_START = b"~"
30
+ SOFT_RESET = b"\x18"
31
+ SAFETY_DOOR = b"\x84"
32
+ JOG_CANCEL = b"\x85"
33
+
34
+ FEED_OVERRIDE_RESET = b"\x90"
35
+ FEED_OVERRIDE_PLUS_10 = b"\x91"
36
+ FEED_OVERRIDE_MINUS_10 = b"\x92"
37
+ FEED_OVERRIDE_PLUS_1 = b"\x93"
38
+ FEED_OVERRIDE_MINUS_1 = b"\x94"
39
+
40
+ RAPID_OVERRIDE_100 = b"\x95"
41
+ RAPID_OVERRIDE_50 = b"\x96"
42
+ RAPID_OVERRIDE_25 = b"\x97"
43
+
44
+ SPINDLE_OVERRIDE_RESET = b"\x99"
45
+ SPINDLE_OVERRIDE_PLUS_10 = b"\x9a"
46
+ SPINDLE_OVERRIDE_MINUS_10 = b"\x9b"
47
+ SPINDLE_OVERRIDE_PLUS_1 = b"\x9c"
48
+ SPINDLE_OVERRIDE_MINUS_1 = b"\x9d"
49
+ SPINDLE_STOP = b"\x9e"
50
+
51
+
52
+ class ResponseKind(str, Enum):
53
+ OK = "ok"
54
+ ERROR = "error"
55
+ ALARM = "alarm"
56
+ STATUS = "status"
57
+ MESSAGE = "message"
58
+ BANNER = "banner"
59
+ SETTING = "setting"
60
+ OTHER = "other"
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class Response:
65
+ kind: ResponseKind
66
+ text: str
67
+ code: int | None = None
68
+ detail: str = ""
69
+
70
+ @property
71
+ def is_terminal(self) -> bool:
72
+ """True for responses that acknowledge exactly one queued line."""
73
+ return self.kind in (ResponseKind.OK, ResponseKind.ERROR)
74
+
75
+
76
+ _BANNER_RE = re.compile(r"^Grbl\s+([0-9]+\.[0-9]+[a-zA-Z]?)\b")
77
+ _ERROR_RE = re.compile(r"^error:(\d+)")
78
+ _ALARM_RE = re.compile(r"^ALARM:(\d+)")
79
+ _SETTING_RE = re.compile(r"^\$(\d+)=([-0-9.]+)")
80
+
81
+
82
+ def classify(line: str) -> Response:
83
+ """Turn one raw line from GRBL into a :class:`Response`."""
84
+ text = line.strip()
85
+ if not text:
86
+ return Response(ResponseKind.OTHER, line)
87
+
88
+ if text == "ok":
89
+ return Response(ResponseKind.OK, text)
90
+
91
+ match = _ERROR_RE.match(text)
92
+ if match:
93
+ code = int(match.group(1))
94
+ return Response(ResponseKind.ERROR, text, code, error_text(code))
95
+
96
+ match = _ALARM_RE.match(text)
97
+ if match:
98
+ code = int(match.group(1))
99
+ return Response(ResponseKind.ALARM, text, code, alarm_text(code))
100
+
101
+ if text.startswith("<") and text.endswith(">"):
102
+ return Response(ResponseKind.STATUS, text)
103
+
104
+ if text.startswith("[") and text.endswith("]"):
105
+ return Response(ResponseKind.MESSAGE, text, detail=message_text(text))
106
+
107
+ match = _BANNER_RE.match(text)
108
+ if match:
109
+ return Response(ResponseKind.BANNER, text, detail=match.group(1))
110
+
111
+ match = _SETTING_RE.match(text)
112
+ if match:
113
+ return Response(ResponseKind.SETTING, text, int(match.group(1)), match.group(2))
114
+
115
+ return Response(ResponseKind.OTHER, text)
116
+
117
+
118
+ def message_text(raw: str) -> str:
119
+ """Extract the human readable payload from a ``[TYPE:...]`` message."""
120
+ body = raw.strip()
121
+ if body.startswith("["):
122
+ body = body[1:]
123
+ if body.endswith("]"):
124
+ body = body[:-1]
125
+ if ":" in body:
126
+ tag, _, value = body.partition(":")
127
+ if tag in {"MSG", "GC", "PRB", "HLP", "OPT", "VER", "echo"}:
128
+ return value
129
+ return body
130
+
131
+
132
+ def banner_version(raw: str) -> str | None:
133
+ """Return the firmware version from a welcome banner, if it is one."""
134
+ match = _BANNER_RE.match(raw.strip())
135
+ return match.group(1) if match else None
136
+
137
+
138
+ # --------------------------------------------------------------------------- #
139
+ # Error / alarm lookup tables (GRBL 1.1)
140
+ # --------------------------------------------------------------------------- #
141
+ ERROR_MESSAGES: dict[int, str] = {
142
+ 1: "G-code word is missing its letter.",
143
+ 2: "Numeric value is missing or malformed.",
144
+ 3: "Unsupported or unknown '$' system command.",
145
+ 4: "Negative value received where a positive value is required.",
146
+ 5: "Homing cycle is not enabled in settings ($22).",
147
+ 6: "Step pulse time must be greater than 3 microseconds.",
148
+ 7: "EEPROM read failed; settings restored to defaults. Reset required.",
149
+ 8: "'$' command can only be used while the machine is Idle.",
150
+ 9: "G-code commands are locked out during an alarm or jog.",
151
+ 10: "Soft limits require homing to be enabled ($22).",
152
+ 11: "Maximum characters per line exceeded (80).",
153
+ 12: "Value exceeds the maximum allowed (compile-time option).",
154
+ 13: "Value exceeds the maximum allowed (compile-time option).",
155
+ 14: "Value exceeds the maximum allowed (compile-time option).",
156
+ 15: "Value exceeds the maximum allowed (compile-time option).",
157
+ 16: "Value exceeds the maximum allowed (compile-time option).",
158
+ 17: "Value exceeds the maximum allowed (compile-time option).",
159
+ 18: "Value exceeds the maximum allowed (compile-time option).",
160
+ 19: "Value exceeds the maximum allowed (compile-time option).",
161
+ 20: "Unsupported or invalid G-code command in block.",
162
+ 21: "More than one G-code command from the same modal group in block.",
163
+ 22: "Feed rate has not been set or is undefined.",
164
+ 23: "G-code command requires an integer value.",
165
+ 24: "Two commands that require the same axis words were detected in block.",
166
+ 25: "A G-code word was repeated in the block.",
167
+ 26: "A G-code command is missing required axis or parameter words.",
168
+ 27: "Numeric value is out of range.",
169
+ 28: "Command value is not valid for the current state.",
170
+ 29: "Unsupported coordinate system or axis word.",
171
+ 30: "Command requires a valid value that was not supplied.",
172
+ 31: "Command is not allowed in the current machine state.",
173
+ 32: "Command is not allowed in the current machine state (laser mode).",
174
+ 33: "Motion command target is invalid.",
175
+ 34: "Arc radius value is invalid.",
176
+ 35: "Arc endpoint is invalid for the defined radius.",
177
+ 36: "Feed rate is required for this command.",
178
+ 37: "Command is not allowed in the current machine state.",
179
+ }
180
+
181
+ ALARM_MESSAGES: dict[int, str] = {
182
+ 1: "Hard limit triggered. Position is likely lost - re-home before continuing.",
183
+ 2: "Soft limit exceeded. Target is outside machine travel; unlock to recover.",
184
+ 3: "Reset while in motion. Position is likely lost - re-home before continuing.",
185
+ 4: "Probe failed: probe is not in the expected initial state.",
186
+ 5: "Probe failed: no contact with the workpiece within the travel distance.",
187
+ 6: "Homing failed: pull-off failed to clear the limit switch.",
188
+ 7: "Homing failed: limit switch not found within the search distance.",
189
+ 8: "Homing failed: limit switch triggered during the search.",
190
+ 9: "Homing failed: could not find the limit switch.",
191
+ 10: "Homing failed: could not clear the limit switch.",
192
+ }
193
+
194
+
195
+ def error_text(code: int | None) -> str:
196
+ if code is None:
197
+ return "Unknown error."
198
+ return ERROR_MESSAGES.get(code, f"Unknown error {code}.")
199
+
200
+
201
+ def alarm_text(code: int | None) -> str:
202
+ if code is None:
203
+ return "Unknown alarm."
204
+ return ALARM_MESSAGES.get(code, f"Unknown alarm {code}.")
205
+
206
+
207
+ # --------------------------------------------------------------------------- #
208
+ # Machine states
209
+ # --------------------------------------------------------------------------- #
210
+ IDLE_STATES = {"Idle"}
211
+ RUN_STATES = {"Run", "Jog", "Home", "Check", "Sleep"}
212
+ HOLD_STATES = {
213
+ "Hold",
214
+ "Hold:0",
215
+ "Hold:1",
216
+ "Door",
217
+ "Door:0",
218
+ "Door:1",
219
+ "Door:2",
220
+ "Door:3",
221
+ }
222
+ ALARM_STATES = {"Alarm"}
223
+
224
+
225
+ def state_family(state: str) -> str:
226
+ """Collapse a GRBL state into one of: idle, run, hold, alarm, offline."""
227
+ if state in ALARM_STATES:
228
+ return "alarm"
229
+ if state in IDLE_STATES:
230
+ return "idle"
231
+ if state in HOLD_STATES:
232
+ return "hold"
233
+ if state in RUN_STATES:
234
+ return "run"
235
+ return "offline"
236
+
237
+
238
+ # --------------------------------------------------------------------------- #
239
+ # GRBL settings metadata (for the settings editor / limits)
240
+ # --------------------------------------------------------------------------- #
241
+ SETTING_INFO: dict[int, tuple[str, str]] = {
242
+ 0: ("Step pulse, microseconds", "10"),
243
+ 1: ("Step idle delay, milliseconds", "25"),
244
+ 2: ("Step port invert, mask", "0"),
245
+ 3: ("Direction port invert, mask", "0"),
246
+ 4: ("Step enable invert, bool", "0"),
247
+ 5: ("Limit pins invert, bool", "0"),
248
+ 6: ("Probe pin invert, bool", "0"),
249
+ 10: ("Status report, mask", "3"),
250
+ 11: ("Junction deviation, mm", "0.010"),
251
+ 12: ("Arc tolerance, mm", "0.002"),
252
+ 13: ("Report inches, bool", "0"),
253
+ 20: ("Soft limits, bool", "0"),
254
+ 21: ("Hard limits, bool", "0"),
255
+ 22: ("Homing cycle, bool", "1"),
256
+ 23: ("Homing dir invert, mask", "0"),
257
+ 24: ("Homing feed, mm/min", "25.000"),
258
+ 25: ("Homing seek, mm/min", "500.000"),
259
+ 26: ("Homing debounce, milliseconds", "250"),
260
+ 27: ("Homing pull-off, mm", "1.000"),
261
+ 30: ("Max spindle speed, RPM", "1000"),
262
+ 31: ("Min spindle speed, RPM", "0"),
263
+ 32: ("Laser mode, bool", "0"),
264
+ 100: ("X steps/mm", "250.000"),
265
+ 101: ("Y steps/mm", "250.000"),
266
+ 102: ("Z steps/mm", "250.000"),
267
+ 110: ("X max rate, mm/min", "500.000"),
268
+ 111: ("Y max rate, mm/min", "500.000"),
269
+ 112: ("Z max rate, mm/min", "500.000"),
270
+ 120: ("X acceleration, mm/sec^2", "10.000"),
271
+ 121: ("Y acceleration, mm/sec^2", "10.000"),
272
+ 122: ("Z acceleration, mm/sec^2", "10.000"),
273
+ 130: ("X max travel, mm", "200.000"),
274
+ 131: ("Y max travel, mm", "200.000"),
275
+ 132: ("Z max travel, mm", "200.000"),
276
+ }
277
+
278
+ LIMIT_SETTINGS = (130, 131, 132)