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.
- plotdeck/__init__.py +4 -0
- plotdeck/__main__.py +6 -0
- plotdeck/app.py +55 -0
- plotdeck/core/__init__.py +4 -0
- plotdeck/core/controller.py +561 -0
- plotdeck/core/gcode_parser.py +404 -0
- plotdeck/core/grbl_protocol.py +278 -0
- plotdeck/core/status.py +148 -0
- plotdeck/core/streamer.py +328 -0
- plotdeck/core/transport.py +375 -0
- plotdeck/ui/__init__.py +1 -0
- plotdeck/ui/connection_panel.py +139 -0
- plotdeck/ui/console.py +171 -0
- plotdeck/ui/dialogs.py +317 -0
- plotdeck/ui/editor.py +544 -0
- plotdeck/ui/job_panel.py +130 -0
- plotdeck/ui/jog_panel.py +222 -0
- plotdeck/ui/main_window.py +1116 -0
- plotdeck/ui/manual.py +156 -0
- plotdeck/ui/status_panel.py +244 -0
- plotdeck/ui/theme.py +329 -0
- plotdeck/ui/visualizer.py +374 -0
- plotdeck/utils/__init__.py +1 -0
- plotdeck/utils/logging_setup.py +42 -0
- plotdeck/utils/settings.py +86 -0
- plotdeck-1.0.0.dist-info/METADATA +227 -0
- plotdeck-1.0.0.dist-info/RECORD +30 -0
- plotdeck-1.0.0.dist-info/WHEEL +5 -0
- plotdeck-1.0.0.dist-info/entry_points.txt +5 -0
- plotdeck-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -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)
|