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 ADDED
@@ -0,0 +1,4 @@
1
+ """PlotDeck - a G-code sender and editor for GRBL-controlled CNC routers."""
2
+
3
+ __version__ = "1.0.0"
4
+ APP_NAME = "PlotDeck"
plotdeck/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Allow ``python -m plotdeck``."""
2
+
3
+ from .app import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
plotdeck/app.py ADDED
@@ -0,0 +1,55 @@
1
+ """QApplication bootstrap."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import sys
7
+ import traceback
8
+
9
+ from PySide6.QtWidgets import QApplication, QMessageBox
10
+
11
+ from . import APP_NAME, __version__
12
+ from .ui.main_window import MainWindow
13
+ from .utils.logging_setup import setup_logging
14
+ from .utils.settings import AppSettings
15
+
16
+
17
+ def _install_exception_hook(logger: logging.Logger) -> None:
18
+ def hook(exc_type, exc_value, exc_tb): # noqa: ANN001
19
+ if issubclass(exc_type, KeyboardInterrupt):
20
+ sys.__excepthook__(exc_type, exc_value, exc_tb)
21
+ return
22
+ trace = "".join(traceback.format_exception(exc_type, exc_value, exc_tb))
23
+ logger.error("Unhandled exception:\n%s", trace)
24
+ app = QApplication.instance()
25
+ if app is not None:
26
+ QMessageBox.critical(
27
+ None,
28
+ "PlotDeck crashed",
29
+ "An unexpected error occurred. The traceback was written to "
30
+ "~/Library/Logs/PlotDeck/plotdeck.log.\n\n"
31
+ f"{exc_type.__name__}: {exc_value}",
32
+ )
33
+
34
+ sys.excepthook = hook
35
+
36
+
37
+ def main(argv: list[str] | None = None) -> int:
38
+ logger = setup_logging()
39
+ logger.info("Starting %s %s", APP_NAME, __version__)
40
+ _install_exception_hook(logger)
41
+
42
+ app = QApplication(argv if argv is not None else sys.argv)
43
+ app.setApplicationName(APP_NAME)
44
+ app.setApplicationDisplayName(APP_NAME)
45
+ app.setOrganizationName("PlotDeck")
46
+ app.setApplicationVersion(__version__)
47
+
48
+ settings = AppSettings()
49
+ window = MainWindow(app_settings=settings)
50
+ window.show()
51
+ return app.exec()
52
+
53
+
54
+ if __name__ == "__main__":
55
+ raise SystemExit(main())
@@ -0,0 +1,4 @@
1
+ """Core layer: transport, protocol, streaming and controller logic.
2
+
3
+ Nothing in this package imports Qt, so it can be tested headless.
4
+ """
@@ -0,0 +1,561 @@
1
+ """MachineController - the GUI-free orchestration layer.
2
+
3
+ The controller owns the transport, the streamer and the latest machine status.
4
+ It never imports Qt; the UI subscribes through plain callbacks (wrapped in a
5
+ small QObject bridge in the UI layer) so the whole core can be unit-tested
6
+ without an event loop.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import threading
12
+ from collections.abc import Callable
13
+ from contextlib import suppress
14
+ from dataclasses import dataclass
15
+
16
+ from .gcode_parser import ProgramLine, clean_line, parse_program
17
+ from .grbl_protocol import (
18
+ BAUD_RATE,
19
+ STATUS_INTERVAL_MS,
20
+ RealTime,
21
+ ResponseKind,
22
+ alarm_text,
23
+ banner_version,
24
+ classify,
25
+ error_text,
26
+ )
27
+ from .status import MachineStatus, parse_status
28
+ from .streamer import Streamer, StreamerState
29
+ from .transport import MockTransport, SerialTransport, Transport, TransportError, list_serial_ports
30
+
31
+ LineCallback = Callable[[ResponseKind, str], None]
32
+ StatusCallback = Callable[[MachineStatus], None]
33
+ ErrorCallback = Callable[[ProgramLine, int, str], None]
34
+
35
+
36
+ @dataclass
37
+ class SettingsEntry:
38
+ number: int
39
+ value: str
40
+ description: str = ""
41
+
42
+
43
+ class MachineController:
44
+ """Connect, stream, jog and query a GRBL machine."""
45
+
46
+ def __init__(
47
+ self,
48
+ transport_factory: Callable[[str, int], Transport] | None = None,
49
+ ) -> None:
50
+ self._transport_factory = transport_factory or (
51
+ lambda port, baud: SerialTransport(port, baud)
52
+ )
53
+ self._transport: Transport | None = None
54
+ self._streamer: Streamer | None = None
55
+ self._poll_thread: threading.Thread | None = None
56
+ self._poll_stop = threading.Event()
57
+ self._lock = threading.RLock()
58
+ self._connected = False
59
+ self._firmware = ""
60
+ self._status = MachineStatus()
61
+ self._settings: list[SettingsEntry] = []
62
+ self._collecting_settings = False
63
+ self._collecting_info = False
64
+ self._demo = False
65
+ self._manual_acks = 0
66
+
67
+ # UI callbacks -----------------------------------------------------
68
+ self.on_line: LineCallback | None = None
69
+ self.on_sent: Callable[[str], None] | None = None
70
+ self.on_status: StatusCallback | None = None
71
+ self.on_connected: Callable[[str], None] | None = None
72
+ self.on_disconnected: Callable[[str], None] | None = None
73
+ self.on_alarm: Callable[[int, str], None] | None = None
74
+ self.on_message: Callable[[str], None] | None = None
75
+ self.on_command_error: Callable[[int, str], None] | None = None
76
+ self.on_stream_error: ErrorCallback | None = None
77
+ self.on_progress: Callable[[int, int, int], None] | None = None
78
+ self.on_job_finished: Callable[[str], None] | None = None
79
+ self.on_tool_change: Callable[[ProgramLine], None] | None = None
80
+ self.on_stream_state: Callable[[StreamerState], None] | None = None
81
+ self.on_settings: Callable[[list[SettingsEntry]], None] | None = None
82
+ self.on_port_error: Callable[[str], None] | None = None
83
+
84
+ # ------------------------------------------------------------------ #
85
+ # Properties
86
+ # ------------------------------------------------------------------ #
87
+ @property
88
+ def connected(self) -> bool:
89
+ return self._connected
90
+
91
+ @property
92
+ def firmware(self) -> str:
93
+ return self._firmware
94
+
95
+ @property
96
+ def status(self) -> MachineStatus:
97
+ return self._status
98
+
99
+ @property
100
+ def streamer(self) -> Streamer | None:
101
+ return self._streamer
102
+
103
+ @property
104
+ def settings(self) -> list[SettingsEntry]:
105
+ return list(self._settings)
106
+
107
+ @property
108
+ def demo_mode(self) -> bool:
109
+ return self._demo
110
+
111
+ @staticmethod
112
+ def available_ports() -> list[tuple[str, str]]:
113
+ return list_serial_ports()
114
+
115
+ # ------------------------------------------------------------------ #
116
+ # Connection
117
+ # ------------------------------------------------------------------ #
118
+ def connect(self, port: str, baud: int = BAUD_RATE, demo: bool = False) -> None:
119
+ self.disconnect()
120
+ self._demo = demo or port.upper() == "DEMO"
121
+ self._connected = False
122
+ self._firmware = ""
123
+ self._manual_acks = 0
124
+ transport = MockTransport() if self._demo else self._transport_factory(port, baud)
125
+ transport.on_line = self._handle_line
126
+ transport.on_error = self._handle_port_error
127
+ transport.on_closed = self._handle_closed
128
+ transport.on_open = self._handle_open
129
+ self._transport = transport
130
+
131
+ streamer = Streamer(
132
+ transport,
133
+ on_progress=self._stream_progress,
134
+ on_state=self._stream_state,
135
+ on_error=self._stream_error,
136
+ on_finished=self._job_finished,
137
+ on_tool_change=self._tool_change,
138
+ )
139
+ self._streamer = streamer
140
+ try:
141
+ transport.open()
142
+ except Exception as exc: # noqa: BLE001
143
+ self._handle_port_error(str(exc))
144
+ self._handle_closed(str(exc))
145
+
146
+ def disconnect(self) -> None:
147
+ self._poll_stop.set()
148
+ transport = self._transport
149
+ self._transport = None
150
+ if self._streamer is not None and self._streamer.is_active:
151
+ self._streamer.abort("disconnected")
152
+ self._streamer = None
153
+ self._connected = False
154
+ self._collecting_settings = False
155
+ self._collecting_info = False
156
+ self._manual_acks = 0
157
+ if transport is not None:
158
+ with suppress(Exception):
159
+ transport.close()
160
+ self._emit_disconnected("Disconnected")
161
+
162
+ def _handle_open(self) -> None:
163
+ self._poll_stop.clear()
164
+ self._poll_thread = threading.Thread(
165
+ target=self._poll_loop, name="grbl-status", daemon=True
166
+ )
167
+ self._poll_thread.start()
168
+
169
+ def _handle_port_error(self, message: str) -> None:
170
+ if self.on_port_error is not None:
171
+ self.on_port_error(message)
172
+
173
+ def _handle_closed(self, reason: str) -> None:
174
+ self._poll_stop.set()
175
+ if self._streamer is not None and self._streamer.is_active:
176
+ self._streamer.abort("disconnected")
177
+ was_connected = self._connected
178
+ self._connected = False
179
+ if was_connected:
180
+ self._emit_disconnected(reason)
181
+
182
+ def _emit_disconnected(self, reason: str) -> None:
183
+ if self.on_disconnected is not None:
184
+ self.on_disconnected(reason)
185
+
186
+ # ------------------------------------------------------------------ #
187
+ # Polling
188
+ # ------------------------------------------------------------------ #
189
+ def _poll_loop(self) -> None:
190
+ interval = max(0.05, STATUS_INTERVAL_MS / 1000.0)
191
+ while not self._poll_stop.wait(interval):
192
+ transport = self._transport
193
+ if transport is None or not transport.is_open:
194
+ continue
195
+ try:
196
+ transport.send_realtime(RealTime.STATUS.value)
197
+ except TransportError:
198
+ continue
199
+
200
+ def request_status(self) -> None:
201
+ self._write_realtime(RealTime.STATUS)
202
+
203
+ # ------------------------------------------------------------------ #
204
+ # Incoming data
205
+ # ------------------------------------------------------------------ #
206
+ def _handle_line(self, raw: str) -> None:
207
+ if not raw:
208
+ return
209
+ response = classify(raw)
210
+ self._emit_line(response.kind, raw)
211
+
212
+ if response.kind is ResponseKind.BANNER:
213
+ version = banner_version(raw) or ""
214
+ self._manual_acks = 0
215
+ self._firmware = version
216
+ if self._streamer is not None and self._streamer.is_active:
217
+ self._streamer.abort("reset")
218
+ self._connected = True
219
+ if self.on_connected is not None:
220
+ self.on_connected(version)
221
+ return
222
+
223
+ if response.kind is ResponseKind.STATUS:
224
+ self._status = parse_status(raw, self._status)
225
+ if self.on_status is not None:
226
+ self.on_status(self._status)
227
+ return
228
+
229
+ if response.kind is ResponseKind.OK:
230
+ manual = self._manual_acks > 0
231
+ if manual:
232
+ self._manual_acks -= 1
233
+ if self._collecting_info:
234
+ self._collecting_info = False
235
+ if self._collecting_settings:
236
+ self._collecting_settings = False
237
+ entries = self._settings
238
+ if self.on_settings is not None:
239
+ self.on_settings(entries)
240
+ return
241
+ if manual:
242
+ return
243
+ if self._streamer is not None:
244
+ self._streamer.handle_ok()
245
+ return
246
+
247
+ if response.kind is ResponseKind.ERROR:
248
+ code = response.code or 0
249
+ if self._manual_acks > 0:
250
+ self._manual_acks -= 1
251
+ if self.on_command_error is not None:
252
+ self.on_command_error(code, error_text(code))
253
+ elif self._streamer is not None and self._streamer.is_active:
254
+ self._streamer.handle_error(code, error_text(code))
255
+ elif self.on_command_error is not None:
256
+ self.on_command_error(code, error_text(code))
257
+ return
258
+
259
+ if response.kind is ResponseKind.ALARM:
260
+ code = response.code or 0
261
+ self._manual_acks = 0
262
+ if self._streamer is not None:
263
+ self._streamer.abort("alarm")
264
+ if self.on_alarm is not None:
265
+ self.on_alarm(code, alarm_text(code))
266
+ return
267
+
268
+ if response.kind is ResponseKind.SETTING:
269
+ if self._collecting_settings:
270
+ assert response.code is not None
271
+ from .grbl_protocol import SETTING_INFO
272
+
273
+ description = SETTING_INFO.get(response.code, ("", ""))[0]
274
+ self._settings.append(SettingsEntry(response.code, response.detail, description))
275
+ return
276
+
277
+ if response.kind is ResponseKind.MESSAGE:
278
+ if self.on_message is not None:
279
+ self.on_message(response.detail)
280
+ return
281
+
282
+ def _emit_line(self, kind: ResponseKind, text: str) -> None:
283
+ if self.on_line is not None:
284
+ self.on_line(kind, text)
285
+
286
+ # ------------------------------------------------------------------ #
287
+ # Streamer callbacks
288
+ # ------------------------------------------------------------------ #
289
+ def _stream_progress(self, sent: int, total: int, line: int) -> None:
290
+ if self.on_progress is not None:
291
+ self.on_progress(sent, total, line)
292
+
293
+ def _stream_state(self, state: StreamerState) -> None:
294
+ if self.on_stream_state is not None:
295
+ self.on_stream_state(state)
296
+
297
+ def _stream_error(self, line: ProgramLine, code: int, detail: str) -> None:
298
+ if self.on_stream_error is not None:
299
+ self.on_stream_error(line, code, detail)
300
+
301
+ def _job_finished(self, reason: str) -> None:
302
+ if self.on_job_finished is not None:
303
+ self.on_job_finished(reason)
304
+
305
+ def _tool_change(self, line: ProgramLine) -> None:
306
+ if self.on_tool_change is not None:
307
+ self.on_tool_change(line)
308
+
309
+ @property
310
+ def _active_streamer(self) -> Streamer | None:
311
+ streamer = self._streamer
312
+ if streamer is not None and streamer.is_active:
313
+ return streamer
314
+ return None
315
+
316
+ # ------------------------------------------------------------------ #
317
+ # Commands
318
+ # ------------------------------------------------------------------ #
319
+ def send_line(self, text: str) -> bool:
320
+ """Send one manual line. Manual lines are not buffer-accounted."""
321
+ if self._active_streamer is not None:
322
+ self._emit_line(ResponseKind.OTHER, "[MSG:Cannot send commands while a job is active]")
323
+ return False
324
+ cleaned = clean_line(text)
325
+ if not cleaned:
326
+ return False
327
+ realtime = {
328
+ "?": RealTime.STATUS,
329
+ "!": RealTime.FEED_HOLD,
330
+ "~": RealTime.CYCLE_START,
331
+ }.get(cleaned)
332
+ if realtime is not None:
333
+ # Real-time bytes never produce an ok; counting one here would
334
+ # make the next job's acknowledgement disappear.
335
+ if self.on_sent is not None:
336
+ self.on_sent(cleaned)
337
+ return self._write_realtime(realtime)
338
+ return self._write_line(cleaned)
339
+
340
+ def _write_line(self, text: str) -> bool:
341
+ transport = self._transport
342
+ if transport is None or not transport.is_open:
343
+ if self.on_port_error is not None:
344
+ self.on_port_error("Not connected.")
345
+ return False
346
+ try:
347
+ transport.send_line(text)
348
+ except TransportError as exc:
349
+ self._handle_port_error(str(exc))
350
+ return False
351
+ self._manual_acks += 1
352
+ if self.on_sent is not None:
353
+ self.on_sent(text)
354
+ return True
355
+
356
+ def _write_realtime(self, command: RealTime) -> bool:
357
+ transport = self._transport
358
+ if transport is None or not transport.is_open:
359
+ return False
360
+ try:
361
+ transport.send_realtime(command.value)
362
+ return True
363
+ except TransportError:
364
+ return False
365
+
366
+ def run_program(self, program: list[ProgramLine], from_line: int | None = None) -> bool:
367
+ if not self._connected or self._streamer is None:
368
+ return False
369
+ if self._status.is_alarm:
370
+ return False
371
+ if self._streamer.is_active:
372
+ return False
373
+ self._streamer.load(program)
374
+ return self._streamer.start(from_line)
375
+
376
+ def run_text(self, source: str, from_line: int | None = None) -> bool:
377
+ return self.run_program(parse_program(source), from_line)
378
+
379
+ def pause(self) -> None:
380
+ if self._streamer is not None:
381
+ self._streamer.pause()
382
+
383
+ def resume(self) -> None:
384
+ if self._streamer is not None:
385
+ self._streamer.resume()
386
+
387
+ def stop(self) -> None:
388
+ if self._streamer is not None and self._streamer.is_active:
389
+ self._streamer.stop()
390
+ else:
391
+ self._write_realtime(RealTime.SOFT_RESET)
392
+
393
+ def hold(self) -> None:
394
+ self._write_realtime(RealTime.FEED_HOLD)
395
+
396
+ def cycle_start(self) -> None:
397
+ self._write_realtime(RealTime.CYCLE_START)
398
+
399
+ def reset(self) -> None:
400
+ if self._streamer is not None and self._streamer.is_active:
401
+ self._streamer.stop()
402
+ return
403
+ self._write_realtime(RealTime.SOFT_RESET)
404
+
405
+ def jog_cancel(self) -> None:
406
+ self._write_realtime(RealTime.JOG_CANCEL)
407
+
408
+ def home(self) -> bool:
409
+ return self._write_line("$H")
410
+
411
+ def unlock(self) -> bool:
412
+ return self._write_line("$X")
413
+
414
+ def jog(self, axis: str, distance: float, feed: float = 1000.0) -> bool:
415
+ if self._active_streamer is not None:
416
+ return False
417
+ axis = axis.upper()
418
+ if axis not in "XYZ":
419
+ return False
420
+ return self._write_line(f"$J=G91 {axis}{distance:.4f} F{feed:.0f}")
421
+
422
+ def zero_axis(self, axis: str, wcs: int = 1) -> bool:
423
+ axis = axis.upper()
424
+ if axis not in "XYZ":
425
+ return False
426
+ return self._write_line(f"G10 L20 P{wcs} {axis}0")
427
+
428
+ def goto_zero(self, axes: str = "XY") -> bool:
429
+ axes = "".join(char for char in axes.upper() if char in "XYZ")
430
+ if not axes:
431
+ return False
432
+ return self._write_line("G90 G0 " + " ".join(f"{axis}0" for axis in axes))
433
+
434
+ def goto_wcs(self, wcs: int) -> bool:
435
+ if wcs < 1 or wcs > 6:
436
+ return False
437
+ return self._write_line("G54" if wcs == 1 else f"G5{wcs + 3}")
438
+
439
+ def set_spindle(self, rpm: float, clockwise: bool = True) -> bool:
440
+ return self._write_line(f"M3 S{rpm:.0f}" if clockwise else f"M4 S{rpm:.0f}")
441
+
442
+ def spindle_off(self) -> bool:
443
+ return self._write_line("M5")
444
+
445
+ def request_settings(self) -> bool:
446
+ if self._active_streamer is not None:
447
+ return False
448
+ self._settings = []
449
+ self._collecting_settings = True
450
+ if not self._write_line("$$"):
451
+ self._collecting_settings = False
452
+ return False
453
+ return True
454
+
455
+ def request_info(self) -> bool:
456
+ self._collecting_info = True
457
+ if not self._write_line("$I"):
458
+ self._collecting_info = False
459
+ return False
460
+ return True
461
+
462
+ def set_setting(self, number: int, value: str) -> bool:
463
+ return self._write_line(f"${number}={value}")
464
+
465
+ def set_check_mode(self, enabled: bool) -> bool:
466
+ """Toggle GRBL check mode ($C). Only valid while idle."""
467
+ _ = enabled
468
+ return self._write_line("$C")
469
+
470
+ def probe_z(
471
+ self,
472
+ plate_thickness: float,
473
+ feed: float = 50.0,
474
+ travel: float = 30.0,
475
+ retract: float = 5.0,
476
+ ) -> bool:
477
+ """Probe down, set Z work zero to the plate thickness, and retract.
478
+
479
+ The commands are queued together; they total well under the 127-byte
480
+ RX buffer and only run while the machine is idle.
481
+ """
482
+ if not self._connected or self._status.is_alarm:
483
+ return False
484
+ steps = [
485
+ f"G91 G38.2 Z-{abs(travel):.3f} F{abs(feed):.0f}",
486
+ f"G10 L20 P1 Z{plate_thickness:.3f}",
487
+ f"G91 G0 Z{abs(retract):.3f}",
488
+ "G90",
489
+ ]
490
+ sent = 0
491
+ for step in steps:
492
+ if not self._write_line(step):
493
+ break
494
+ sent += 1
495
+ return sent == len(steps)
496
+
497
+ def set_override(self, kind: str, percent: int) -> None:
498
+ commands = self._override_commands(kind, percent)
499
+ for command in commands:
500
+ self._write_realtime(command)
501
+
502
+ @staticmethod
503
+ def _override_commands(kind: str, percent: int) -> list[RealTime]:
504
+ if kind == "feed":
505
+ return MachineController._steps(
506
+ percent,
507
+ 100,
508
+ RealTime.FEED_OVERRIDE_RESET,
509
+ RealTime.FEED_OVERRIDE_PLUS_10,
510
+ RealTime.FEED_OVERRIDE_MINUS_10,
511
+ RealTime.FEED_OVERRIDE_PLUS_1,
512
+ RealTime.FEED_OVERRIDE_MINUS_1,
513
+ )
514
+ if kind == "spindle":
515
+ return MachineController._steps(
516
+ percent,
517
+ 100,
518
+ RealTime.SPINDLE_OVERRIDE_RESET,
519
+ RealTime.SPINDLE_OVERRIDE_PLUS_10,
520
+ RealTime.SPINDLE_OVERRIDE_MINUS_10,
521
+ RealTime.SPINDLE_OVERRIDE_PLUS_1,
522
+ RealTime.SPINDLE_OVERRIDE_MINUS_1,
523
+ )
524
+ rapid = {
525
+ 25: RealTime.RAPID_OVERRIDE_25,
526
+ 50: RealTime.RAPID_OVERRIDE_50,
527
+ 100: RealTime.RAPID_OVERRIDE_100,
528
+ }
529
+ return [rapid.get(percent, RealTime.RAPID_OVERRIDE_100)]
530
+
531
+ @staticmethod
532
+ def _steps(
533
+ percent: int,
534
+ baseline: int,
535
+ reset: RealTime,
536
+ plus_ten: RealTime,
537
+ minus_ten: RealTime,
538
+ plus_one: RealTime,
539
+ minus_one: RealTime,
540
+ ) -> list[RealTime]:
541
+ delta = max(10, min(200, percent)) - baseline
542
+ commands = [reset]
543
+ tens, ones = divmod(abs(delta), 10)
544
+ commands.extend([plus_ten if delta > 0 else minus_ten] * tens)
545
+ commands.extend([plus_one if delta > 0 else minus_one] * ones)
546
+ return commands
547
+
548
+ def soft_reset(self) -> None:
549
+ self._write_realtime(RealTime.SOFT_RESET)
550
+
551
+ def skip_error(self) -> None:
552
+ if self._streamer is not None:
553
+ self._streamer.skip_errored_line()
554
+
555
+ def complete_tool_change(self) -> None:
556
+ if self._streamer is not None:
557
+ self._streamer.complete_tool_change()
558
+
559
+ def cancel_tool_change(self) -> None:
560
+ if self._streamer is not None:
561
+ self._streamer.cancel_tool_change()