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,1116 @@
|
|
|
1
|
+
"""PlotDeck main window."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
from contextlib import suppress
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from PySide6.QtCore import QObject, QSettings, Qt, QTimer, Signal
|
|
12
|
+
from PySide6.QtGui import QAction, QActionGroup, QKeySequence, QShortcut
|
|
13
|
+
from PySide6.QtWidgets import (
|
|
14
|
+
QApplication,
|
|
15
|
+
QDialog,
|
|
16
|
+
QDockWidget,
|
|
17
|
+
QFileDialog,
|
|
18
|
+
QInputDialog,
|
|
19
|
+
QLabel,
|
|
20
|
+
QMainWindow,
|
|
21
|
+
QMessageBox,
|
|
22
|
+
QSizePolicy,
|
|
23
|
+
QSplitter,
|
|
24
|
+
QToolBar,
|
|
25
|
+
QVBoxLayout,
|
|
26
|
+
QWidget,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
from .. import APP_NAME
|
|
30
|
+
from ..core.controller import MachineController, SettingsEntry
|
|
31
|
+
from ..core.gcode_parser import ProgramLine, Toolpath, build_toolpath
|
|
32
|
+
from ..core.grbl_protocol import ResponseKind
|
|
33
|
+
from ..core.status import MachineStatus
|
|
34
|
+
from ..core.streamer import StreamerState
|
|
35
|
+
from ..utils.settings import AppSettings
|
|
36
|
+
from .connection_panel import ConnectionPanel
|
|
37
|
+
from .console import Console
|
|
38
|
+
from .dialogs import PreflightDialog, ProbeDialog, SettingsDialog, ToolChangeDialog
|
|
39
|
+
from .editor import FindBar, GcodeEditor
|
|
40
|
+
from .job_panel import JobPanel
|
|
41
|
+
from .jog_panel import JogPanel
|
|
42
|
+
from .manual import ManualDialog
|
|
43
|
+
from .status_panel import StatusPanel
|
|
44
|
+
from .theme import build_stylesheet, is_dark_system, tokens
|
|
45
|
+
from .visualizer import Visualizer
|
|
46
|
+
|
|
47
|
+
GCODE_FILTER = "G-code (*.nc *.gcode *.ngc *.tap *.txt *.cnc);;All files (*)"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ControllerBridge(QObject):
|
|
51
|
+
line = Signal(object, str)
|
|
52
|
+
sent = Signal(str)
|
|
53
|
+
status = Signal(object)
|
|
54
|
+
connected = Signal(str)
|
|
55
|
+
disconnected = Signal(str)
|
|
56
|
+
alarm = Signal(int, str)
|
|
57
|
+
message = Signal(str)
|
|
58
|
+
commandError = Signal(int, str)
|
|
59
|
+
streamError = Signal(object, int, str)
|
|
60
|
+
progress = Signal(int, int, int)
|
|
61
|
+
jobFinished = Signal(str)
|
|
62
|
+
toolChange = Signal(object)
|
|
63
|
+
streamState = Signal(object)
|
|
64
|
+
settings = Signal(object)
|
|
65
|
+
portError = Signal(str)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class MainWindow(QMainWindow):
|
|
69
|
+
def __init__(
|
|
70
|
+
self,
|
|
71
|
+
controller: MachineController | None = None,
|
|
72
|
+
app_settings: AppSettings | None = None,
|
|
73
|
+
) -> None:
|
|
74
|
+
super().__init__()
|
|
75
|
+
self.app_settings = app_settings or AppSettings()
|
|
76
|
+
self.controller = controller or MachineController()
|
|
77
|
+
self._settings: QSettings = self.app_settings._settings
|
|
78
|
+
|
|
79
|
+
self._current_path: Path | None = None
|
|
80
|
+
self._program: list[ProgramLine] = []
|
|
81
|
+
self._toolpath: Toolpath | None = None
|
|
82
|
+
self._limits: tuple[float, float, float] | None = None
|
|
83
|
+
self._status = MachineStatus()
|
|
84
|
+
self._connected = False
|
|
85
|
+
self._stream_state = StreamerState.IDLE
|
|
86
|
+
self._last_progress_line = 0
|
|
87
|
+
self._last_editor_update = 0.0
|
|
88
|
+
self._wcs = 1
|
|
89
|
+
self._homed = False
|
|
90
|
+
self._z_zeroed = False
|
|
91
|
+
self._dry_run = False
|
|
92
|
+
self._alarm_open = False
|
|
93
|
+
self._caffeinate: subprocess.Popen | None = None
|
|
94
|
+
self._settings_dialog: SettingsDialog | None = None
|
|
95
|
+
self._manual_dialog: ManualDialog | None = None
|
|
96
|
+
self._dark = self.app_settings.dark_theme(is_dark_system())
|
|
97
|
+
|
|
98
|
+
self.setWindowTitle(APP_NAME)
|
|
99
|
+
self.resize(1440, 900)
|
|
100
|
+
self.setMinimumSize(1100, 700)
|
|
101
|
+
self.setAcceptDrops(True)
|
|
102
|
+
|
|
103
|
+
self.bridge = ControllerBridge()
|
|
104
|
+
self._build_ui()
|
|
105
|
+
self._build_toolbar()
|
|
106
|
+
self._build_menus()
|
|
107
|
+
self._build_statusbar()
|
|
108
|
+
self._wire_controller()
|
|
109
|
+
self._wire_ui()
|
|
110
|
+
self._refresh_ports()
|
|
111
|
+
self._rebuild_recent_menu()
|
|
112
|
+
self._update_actions()
|
|
113
|
+
self._restore_window()
|
|
114
|
+
|
|
115
|
+
self.apply_theme(self._dark, persist=False)
|
|
116
|
+
self.editor.document().modificationChanged.connect(lambda _: self._update_title())
|
|
117
|
+
self._update_title()
|
|
118
|
+
|
|
119
|
+
# ------------------------------------------------------------------ #
|
|
120
|
+
# Construction
|
|
121
|
+
# ------------------------------------------------------------------ #
|
|
122
|
+
def _build_ui(self) -> None:
|
|
123
|
+
self.editor = GcodeEditor()
|
|
124
|
+
self.editor.setPlaceholderText("Open a G-code file (⌘O), drop one here, or start typing…")
|
|
125
|
+
self.console = Console()
|
|
126
|
+
self.find_bar = FindBar(self.editor)
|
|
127
|
+
self.status_panel = StatusPanel()
|
|
128
|
+
self.jog_panel = JogPanel()
|
|
129
|
+
self.visualizer = Visualizer()
|
|
130
|
+
self.job_panel = JobPanel()
|
|
131
|
+
|
|
132
|
+
central = QWidget()
|
|
133
|
+
layout = QVBoxLayout(central)
|
|
134
|
+
layout.setContentsMargins(0, 0, 0, 0)
|
|
135
|
+
layout.setSpacing(0)
|
|
136
|
+
layout.addWidget(self.find_bar)
|
|
137
|
+
splitter = QSplitter(Qt.Orientation.Vertical)
|
|
138
|
+
splitter.addWidget(self.editor)
|
|
139
|
+
splitter.addWidget(self.console)
|
|
140
|
+
splitter.setStretchFactor(0, 3)
|
|
141
|
+
splitter.setStretchFactor(1, 2)
|
|
142
|
+
splitter.setSizes([560, 340])
|
|
143
|
+
layout.addWidget(splitter, 1)
|
|
144
|
+
self.setCentralWidget(central)
|
|
145
|
+
|
|
146
|
+
left = QWidget()
|
|
147
|
+
left_layout = QVBoxLayout(left)
|
|
148
|
+
left_layout.setContentsMargins(0, 0, 0, 0)
|
|
149
|
+
left_layout.setSpacing(1)
|
|
150
|
+
left_layout.addWidget(self.status_panel)
|
|
151
|
+
left_layout.addWidget(self.jog_panel)
|
|
152
|
+
left_layout.addStretch(1)
|
|
153
|
+
|
|
154
|
+
self.dock_machine = QDockWidget("Machine", self)
|
|
155
|
+
self.dock_machine.setObjectName("MachineDock")
|
|
156
|
+
self.dock_machine.setWidget(left)
|
|
157
|
+
self.dock_machine.setMinimumWidth(300)
|
|
158
|
+
|
|
159
|
+
self.dock_toolpath = QDockWidget("Toolpath", self)
|
|
160
|
+
self.dock_toolpath.setObjectName("ToolpathDock")
|
|
161
|
+
self.dock_toolpath.setWidget(self.visualizer)
|
|
162
|
+
self.dock_toolpath.setMinimumWidth(340)
|
|
163
|
+
|
|
164
|
+
self.dock_job = QDockWidget("Job", self)
|
|
165
|
+
self.dock_job.setObjectName("JobDock")
|
|
166
|
+
self.dock_job.setWidget(self.job_panel)
|
|
167
|
+
self.dock_job.setMinimumHeight(140)
|
|
168
|
+
|
|
169
|
+
self.addDockWidget(Qt.DockWidgetArea.LeftDockWidgetArea, self.dock_machine)
|
|
170
|
+
self.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, self.dock_toolpath)
|
|
171
|
+
self.splitDockWidget(self.dock_toolpath, self.dock_job, Qt.Orientation.Vertical)
|
|
172
|
+
self.resizeDocks([self.dock_machine], [310], Qt.Orientation.Horizontal)
|
|
173
|
+
self.resizeDocks([self.dock_toolpath, self.dock_job], [620, 200], Qt.Orientation.Vertical)
|
|
174
|
+
|
|
175
|
+
def _build_toolbar(self) -> None:
|
|
176
|
+
toolbar = QToolBar("Main")
|
|
177
|
+
toolbar.setObjectName("MainToolbar")
|
|
178
|
+
toolbar.setMovable(False)
|
|
179
|
+
toolbar.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextOnly)
|
|
180
|
+
self.addToolBar(toolbar)
|
|
181
|
+
|
|
182
|
+
self.connection_panel = ConnectionPanel()
|
|
183
|
+
toolbar.addWidget(self.connection_panel)
|
|
184
|
+
toolbar.addSeparator()
|
|
185
|
+
|
|
186
|
+
self.action_run = self._action("Run", self._run, "Ctrl+R", "Run the loaded file (⌘R)")
|
|
187
|
+
self.action_pause = self._action(
|
|
188
|
+
"Pause", self._toggle_pause, None, "Feed hold / cycle start"
|
|
189
|
+
)
|
|
190
|
+
self.action_stop = self._action(
|
|
191
|
+
"Stop", self._stop, QKeySequence("Ctrl+."), "Stop the job (⌘.)"
|
|
192
|
+
)
|
|
193
|
+
toolbar.addAction(self.action_run)
|
|
194
|
+
toolbar.addAction(self.action_pause)
|
|
195
|
+
toolbar.addAction(self.action_stop)
|
|
196
|
+
toolbar.addSeparator()
|
|
197
|
+
|
|
198
|
+
self.action_home = self._action("Home", self._home, None, "Run the homing cycle ($H)")
|
|
199
|
+
self.action_unlock = self._action("Unlock", self._unlock, None, "Clear an alarm ($X)")
|
|
200
|
+
self.action_reset = self._action(
|
|
201
|
+
"Reset", self._soft_reset, QKeySequence("Ctrl+Shift+."), "Soft reset (Ctrl-X)"
|
|
202
|
+
)
|
|
203
|
+
toolbar.addAction(self.action_home)
|
|
204
|
+
toolbar.addAction(self.action_unlock)
|
|
205
|
+
toolbar.addAction(self.action_reset)
|
|
206
|
+
|
|
207
|
+
spacer = QWidget()
|
|
208
|
+
spacer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
|
|
209
|
+
toolbar.addWidget(spacer)
|
|
210
|
+
self.action_manual = self._action(
|
|
211
|
+
"Manual", self._show_manual, "F1", "Open the PlotDeck user manual"
|
|
212
|
+
)
|
|
213
|
+
toolbar.addAction(self.action_manual)
|
|
214
|
+
toolbar.addSeparator()
|
|
215
|
+
|
|
216
|
+
self.firmware_label = QLabel("GRBL")
|
|
217
|
+
self.firmware_label.setObjectName("Muted")
|
|
218
|
+
toolbar.addWidget(self.firmware_label)
|
|
219
|
+
|
|
220
|
+
stop_button = toolbar.widgetForAction(self.action_stop)
|
|
221
|
+
if stop_button is not None:
|
|
222
|
+
stop_button.setObjectName("Danger")
|
|
223
|
+
|
|
224
|
+
def _build_menus(self) -> None:
|
|
225
|
+
menu = self.menuBar()
|
|
226
|
+
|
|
227
|
+
file_menu = menu.addMenu("&File")
|
|
228
|
+
file_menu.addAction(self._action("New", self._new_file, QKeySequence.StandardKey.New))
|
|
229
|
+
file_menu.addAction(self._action("Open…", self._open_file, QKeySequence.StandardKey.Open))
|
|
230
|
+
self.recent_menu = file_menu.addMenu("Open Recent")
|
|
231
|
+
file_menu.addSeparator()
|
|
232
|
+
file_menu.addAction(self._action("Save", self._save, QKeySequence.StandardKey.Save))
|
|
233
|
+
file_menu.addAction(
|
|
234
|
+
self._action("Save As…", self._save_as, QKeySequence.StandardKey.SaveAs)
|
|
235
|
+
)
|
|
236
|
+
file_menu.addSeparator()
|
|
237
|
+
file_menu.addAction(self._action("Close", self.close, QKeySequence.StandardKey.Close))
|
|
238
|
+
file_menu.addAction("Quit", self.close, QKeySequence.StandardKey.Quit)
|
|
239
|
+
|
|
240
|
+
edit_menu = menu.addMenu("&Edit")
|
|
241
|
+
edit_menu.addAction(self._action("Undo", self.editor.undo, QKeySequence.StandardKey.Undo))
|
|
242
|
+
edit_menu.addAction(self._action("Redo", self.editor.redo, QKeySequence.StandardKey.Redo))
|
|
243
|
+
edit_menu.addSeparator()
|
|
244
|
+
edit_menu.addAction(self._action("Find…", self._show_find, QKeySequence.StandardKey.Find))
|
|
245
|
+
edit_menu.addAction(
|
|
246
|
+
self._action("Replace…", self._show_replace, QKeySequence("Ctrl+Alt+F"))
|
|
247
|
+
)
|
|
248
|
+
edit_menu.addAction(self._action("Go to Line…", self._go_to_line, QKeySequence("Ctrl+L")))
|
|
249
|
+
edit_menu.addSeparator()
|
|
250
|
+
edit_menu.addAction(
|
|
251
|
+
self._action("Toggle Comment", self._toggle_comment, QKeySequence("Ctrl+/"))
|
|
252
|
+
)
|
|
253
|
+
edit_menu.addAction("Uppercase Selection", self.editor.uppercase_selection)
|
|
254
|
+
edit_menu.addAction("Strip Comments", self._strip_comments)
|
|
255
|
+
edit_menu.addAction("Renumber Lines", self.editor.renumber)
|
|
256
|
+
transform = edit_menu.addMenu("Transform")
|
|
257
|
+
transform.addAction("Offset…", self._transform_offset)
|
|
258
|
+
transform.addAction("Scale…", self._transform_scale)
|
|
259
|
+
transform.addAction("Rotate…", self._transform_rotate)
|
|
260
|
+
|
|
261
|
+
view_menu = menu.addMenu("&View")
|
|
262
|
+
view_menu.addAction(self.dock_machine.toggleViewAction())
|
|
263
|
+
view_menu.addAction(self.dock_toolpath.toggleViewAction())
|
|
264
|
+
view_menu.addAction(self.dock_job.toggleViewAction())
|
|
265
|
+
view_menu.addSeparator()
|
|
266
|
+
view_menu.addAction(
|
|
267
|
+
self._action(
|
|
268
|
+
"Zoom In",
|
|
269
|
+
self.visualizer.zoom_in,
|
|
270
|
+
QKeySequence.StandardKey.ZoomIn,
|
|
271
|
+
"Zoom into the toolpath",
|
|
272
|
+
)
|
|
273
|
+
)
|
|
274
|
+
view_menu.addAction(
|
|
275
|
+
self._action(
|
|
276
|
+
"Zoom Out",
|
|
277
|
+
self.visualizer.zoom_out,
|
|
278
|
+
QKeySequence.StandardKey.ZoomOut,
|
|
279
|
+
"Zoom out of the toolpath",
|
|
280
|
+
)
|
|
281
|
+
)
|
|
282
|
+
view_menu.addAction(self._action("Fit Toolpath", self.visualizer.fit, "Ctrl+0"))
|
|
283
|
+
theme_menu = view_menu.addMenu("Theme")
|
|
284
|
+
self._theme_group = QActionGroup(self)
|
|
285
|
+
dark_action = QAction("Dark", self, checkable=True)
|
|
286
|
+
light_action = QAction("Light", self, checkable=True)
|
|
287
|
+
self._theme_group.addAction(dark_action)
|
|
288
|
+
self._theme_group.addAction(light_action)
|
|
289
|
+
dark_action.setChecked(self._dark)
|
|
290
|
+
light_action.setChecked(not self._dark)
|
|
291
|
+
dark_action.triggered.connect(lambda: self.apply_theme(True))
|
|
292
|
+
light_action.triggered.connect(lambda: self.apply_theme(False))
|
|
293
|
+
theme_menu.addActions([dark_action, light_action])
|
|
294
|
+
view_menu.addSeparator()
|
|
295
|
+
view_menu.addAction(self._action("Focus Console", self.console.focus_input, "Ctrl+`"))
|
|
296
|
+
|
|
297
|
+
machine_menu = menu.addMenu("&Machine")
|
|
298
|
+
machine_menu.addAction(
|
|
299
|
+
self._action("Connect / Disconnect", self._toggle_connection, "Ctrl+K")
|
|
300
|
+
)
|
|
301
|
+
machine_menu.addSeparator()
|
|
302
|
+
machine_menu.addAction(self.action_run)
|
|
303
|
+
machine_menu.addAction(self.action_pause)
|
|
304
|
+
machine_menu.addAction(self.action_stop)
|
|
305
|
+
machine_menu.addAction(
|
|
306
|
+
self._action("Run From Cursor", self._run_from_cursor, "Ctrl+Shift+R")
|
|
307
|
+
)
|
|
308
|
+
machine_menu.addSeparator()
|
|
309
|
+
machine_menu.addAction(self.action_home)
|
|
310
|
+
machine_menu.addAction(self.action_unlock)
|
|
311
|
+
machine_menu.addAction(self.action_reset)
|
|
312
|
+
machine_menu.addSeparator()
|
|
313
|
+
machine_menu.addAction("Cancel Jog", self.controller.jog_cancel)
|
|
314
|
+
machine_menu.addAction("Probe Z…", self._probe_dialog)
|
|
315
|
+
machine_menu.addAction("Spindle On…", self._spindle_on)
|
|
316
|
+
machine_menu.addAction("Spindle Off", self.controller.spindle_off)
|
|
317
|
+
machine_menu.addSeparator()
|
|
318
|
+
machine_menu.addAction("GRBL Settings…", self._show_settings)
|
|
319
|
+
|
|
320
|
+
help_menu = menu.addMenu("&Help")
|
|
321
|
+
help_menu.addAction(self.action_manual)
|
|
322
|
+
help_menu.addAction("Keyboard Shortcuts", self._show_shortcuts)
|
|
323
|
+
help_menu.addAction("About PlotDeck", self._about)
|
|
324
|
+
|
|
325
|
+
fit_shortcut = QShortcut(QKeySequence("F"), self.visualizer)
|
|
326
|
+
fit_shortcut.setContext(Qt.ShortcutContext.WidgetWithChildrenShortcut)
|
|
327
|
+
fit_shortcut.activated.connect(self.visualizer.fit)
|
|
328
|
+
|
|
329
|
+
def _build_statusbar(self) -> None:
|
|
330
|
+
bar = self.statusBar()
|
|
331
|
+
self._status_connection = QLabel("Offline")
|
|
332
|
+
self._status_port = QLabel("")
|
|
333
|
+
self._status_modal = QLabel("G21 · G90 · G54")
|
|
334
|
+
self._status_cursor = QLabel("Ln 1, Col 1")
|
|
335
|
+
self._status_notice = QLabel("")
|
|
336
|
+
for label in (
|
|
337
|
+
self._status_connection,
|
|
338
|
+
self._status_port,
|
|
339
|
+
self._status_cursor,
|
|
340
|
+
self._status_modal,
|
|
341
|
+
self._status_notice,
|
|
342
|
+
):
|
|
343
|
+
label.setObjectName("Muted")
|
|
344
|
+
bar.addWidget(self._status_connection)
|
|
345
|
+
bar.addWidget(self._status_port)
|
|
346
|
+
bar.addPermanentWidget(self._status_notice, 1)
|
|
347
|
+
bar.addPermanentWidget(self._status_cursor)
|
|
348
|
+
bar.addPermanentWidget(self._status_modal)
|
|
349
|
+
|
|
350
|
+
def _wire_controller(self) -> None:
|
|
351
|
+
controller = self.controller
|
|
352
|
+
bridge = self.bridge
|
|
353
|
+
controller.on_line = bridge.line.emit
|
|
354
|
+
controller.on_sent = bridge.sent.emit
|
|
355
|
+
controller.on_status = bridge.status.emit
|
|
356
|
+
controller.on_connected = bridge.connected.emit
|
|
357
|
+
controller.on_disconnected = bridge.disconnected.emit
|
|
358
|
+
controller.on_alarm = bridge.alarm.emit
|
|
359
|
+
controller.on_message = bridge.message.emit
|
|
360
|
+
controller.on_command_error = bridge.commandError.emit
|
|
361
|
+
controller.on_stream_error = bridge.streamError.emit
|
|
362
|
+
controller.on_progress = bridge.progress.emit
|
|
363
|
+
controller.on_job_finished = bridge.jobFinished.emit
|
|
364
|
+
controller.on_tool_change = bridge.toolChange.emit
|
|
365
|
+
controller.on_stream_state = bridge.streamState.emit
|
|
366
|
+
controller.on_settings = bridge.settings.emit
|
|
367
|
+
controller.on_port_error = bridge.portError.emit
|
|
368
|
+
|
|
369
|
+
bridge.line.connect(self._on_line)
|
|
370
|
+
bridge.sent.connect(lambda text: self.console.append("sent", text))
|
|
371
|
+
bridge.status.connect(self._on_status)
|
|
372
|
+
bridge.connected.connect(self._on_connected)
|
|
373
|
+
bridge.disconnected.connect(self._on_disconnected)
|
|
374
|
+
bridge.alarm.connect(self._on_alarm)
|
|
375
|
+
bridge.message.connect(self._on_message)
|
|
376
|
+
bridge.commandError.connect(self._on_command_error)
|
|
377
|
+
bridge.streamError.connect(self._on_stream_error)
|
|
378
|
+
bridge.progress.connect(self._on_progress)
|
|
379
|
+
bridge.jobFinished.connect(self._on_job_finished)
|
|
380
|
+
bridge.toolChange.connect(self._on_tool_change)
|
|
381
|
+
bridge.streamState.connect(self._on_stream_state)
|
|
382
|
+
bridge.settings.connect(self._on_settings)
|
|
383
|
+
bridge.portError.connect(self._on_port_error)
|
|
384
|
+
|
|
385
|
+
def _wire_ui(self) -> None:
|
|
386
|
+
self.connection_panel.connectRequested.connect(self._connect)
|
|
387
|
+
self.connection_panel.disconnectRequested.connect(self.controller.disconnect)
|
|
388
|
+
self.console.commandSubmitted.connect(self._send_command)
|
|
389
|
+
self.jog_panel.jogRequested.connect(self.controller.jog)
|
|
390
|
+
self.jog_panel.cancelRequested.connect(self.controller.jog_cancel)
|
|
391
|
+
self.jog_panel.homeRequested.connect(self._home)
|
|
392
|
+
self.jog_panel.unlockRequested.connect(self._unlock)
|
|
393
|
+
self.jog_panel.zeroRequested.connect(self._zero_axis)
|
|
394
|
+
self.jog_panel.gotoZeroRequested.connect(lambda: self.controller.goto_zero("XY"))
|
|
395
|
+
self.jog_panel.wcsChanged.connect(self._change_wcs)
|
|
396
|
+
self.status_panel.zeroRequested.connect(self._zero_axis)
|
|
397
|
+
self.status_panel.feedOverrideChanged.connect(
|
|
398
|
+
lambda value: self.controller.set_override("feed", value)
|
|
399
|
+
)
|
|
400
|
+
self.status_panel.spindleOverrideChanged.connect(
|
|
401
|
+
lambda value: self.controller.set_override("spindle", value)
|
|
402
|
+
)
|
|
403
|
+
self.status_panel.rapidOverrideChanged.connect(
|
|
404
|
+
lambda value: self.controller.set_override("rapid", value)
|
|
405
|
+
)
|
|
406
|
+
self.editor.cursorLineChanged.connect(self._update_cursor_label)
|
|
407
|
+
|
|
408
|
+
# ------------------------------------------------------------------ #
|
|
409
|
+
# Helpers
|
|
410
|
+
# ------------------------------------------------------------------ #
|
|
411
|
+
def _action(
|
|
412
|
+
self,
|
|
413
|
+
text: str,
|
|
414
|
+
slot, # noqa: ANN001
|
|
415
|
+
shortcut=None, # noqa: ANN001
|
|
416
|
+
tip: str = "",
|
|
417
|
+
) -> QAction:
|
|
418
|
+
action = QAction(text, self)
|
|
419
|
+
if shortcut is not None:
|
|
420
|
+
action.setShortcut(
|
|
421
|
+
QKeySequence(shortcut) if not isinstance(shortcut, QKeySequence) else shortcut
|
|
422
|
+
)
|
|
423
|
+
if tip:
|
|
424
|
+
action.setStatusTip(tip)
|
|
425
|
+
action.setToolTip(tip)
|
|
426
|
+
action.triggered.connect(slot)
|
|
427
|
+
return action
|
|
428
|
+
|
|
429
|
+
def _update_title(self) -> None:
|
|
430
|
+
name = self._current_path.name if self._current_path else "Untitled"
|
|
431
|
+
modified = self.editor.document().isModified()
|
|
432
|
+
self.setWindowTitle(f"{APP_NAME} — {name}{' *' if modified else ''}")
|
|
433
|
+
|
|
434
|
+
def _update_cursor_label(self, line: int) -> None:
|
|
435
|
+
column = self.editor.textCursor().positionInBlock() + 1
|
|
436
|
+
self._status_cursor.setText(f"Ln {line}, Col {column}")
|
|
437
|
+
|
|
438
|
+
def _refresh_ports(self) -> None:
|
|
439
|
+
self.connection_panel.refresh_ports()
|
|
440
|
+
last = self.app_settings.last_port()
|
|
441
|
+
if last:
|
|
442
|
+
self.connection_panel.select_port(last)
|
|
443
|
+
self.connection_panel.set_baud(self.app_settings.last_baud())
|
|
444
|
+
|
|
445
|
+
# ------------------------------------------------------------------ #
|
|
446
|
+
# Files
|
|
447
|
+
# ------------------------------------------------------------------ #
|
|
448
|
+
def _maybe_save(self) -> bool:
|
|
449
|
+
if not self.editor.document().isModified():
|
|
450
|
+
return True
|
|
451
|
+
box = QMessageBox(self)
|
|
452
|
+
box.setWindowTitle("Unsaved changes")
|
|
453
|
+
box.setText("Save changes before continuing?")
|
|
454
|
+
box.setStandardButtons(
|
|
455
|
+
QMessageBox.StandardButton.Save
|
|
456
|
+
| QMessageBox.StandardButton.Discard
|
|
457
|
+
| QMessageBox.StandardButton.Cancel
|
|
458
|
+
)
|
|
459
|
+
box.setDefaultButton(QMessageBox.StandardButton.Save)
|
|
460
|
+
result = box.exec()
|
|
461
|
+
if result == QMessageBox.StandardButton.Save:
|
|
462
|
+
return self._save()
|
|
463
|
+
return result == QMessageBox.StandardButton.Discard
|
|
464
|
+
|
|
465
|
+
def _new_file(self) -> None:
|
|
466
|
+
if not self._maybe_save():
|
|
467
|
+
return
|
|
468
|
+
self.editor.set_source_text("")
|
|
469
|
+
self._current_path = None
|
|
470
|
+
self._program = []
|
|
471
|
+
self._toolpath = None
|
|
472
|
+
self.visualizer.clear()
|
|
473
|
+
self.job_panel.reset()
|
|
474
|
+
self.editor.document().setModified(False)
|
|
475
|
+
self._update_title()
|
|
476
|
+
self._update_actions()
|
|
477
|
+
|
|
478
|
+
def _open_file(self) -> None:
|
|
479
|
+
if not self._maybe_save():
|
|
480
|
+
return
|
|
481
|
+
path, _ = QFileDialog.getOpenFileName(self, "Open G-code", str(Path.home()), GCODE_FILTER)
|
|
482
|
+
if path:
|
|
483
|
+
self.open_path(path)
|
|
484
|
+
|
|
485
|
+
def open_path(self, path: str) -> None:
|
|
486
|
+
try:
|
|
487
|
+
text = Path(path).read_text(encoding="utf-8", errors="replace")
|
|
488
|
+
except OSError as exc:
|
|
489
|
+
QMessageBox.critical(self, "Could not open file", str(exc))
|
|
490
|
+
return
|
|
491
|
+
self._current_path = Path(path)
|
|
492
|
+
self.editor.set_source_text(text)
|
|
493
|
+
self.editor.document().setModified(False)
|
|
494
|
+
self.app_settings.add_recent_file(str(self._current_path))
|
|
495
|
+
self._rebuild_recent_menu()
|
|
496
|
+
self._update_title()
|
|
497
|
+
self._refresh_program()
|
|
498
|
+
self.job_panel.reset()
|
|
499
|
+
self.console.log(f"Loaded {self._current_path.name} ({len(self._program)} lines)")
|
|
500
|
+
self._update_actions()
|
|
501
|
+
|
|
502
|
+
def _save(self) -> bool:
|
|
503
|
+
if self._current_path is None:
|
|
504
|
+
return self._save_as()
|
|
505
|
+
try:
|
|
506
|
+
self._current_path.write_text(self.editor.source_text(), encoding="utf-8")
|
|
507
|
+
except OSError as exc:
|
|
508
|
+
QMessageBox.critical(self, "Could not save file", str(exc))
|
|
509
|
+
return False
|
|
510
|
+
self.editor.document().setModified(False)
|
|
511
|
+
self.app_settings.add_recent_file(str(self._current_path))
|
|
512
|
+
self._rebuild_recent_menu()
|
|
513
|
+
self._update_title()
|
|
514
|
+
return True
|
|
515
|
+
|
|
516
|
+
def _save_as(self) -> bool:
|
|
517
|
+
path, _ = QFileDialog.getSaveFileName(
|
|
518
|
+
self,
|
|
519
|
+
"Save G-code",
|
|
520
|
+
str(self._current_path or Path.home() / "job.nc"),
|
|
521
|
+
GCODE_FILTER,
|
|
522
|
+
)
|
|
523
|
+
if not path:
|
|
524
|
+
return False
|
|
525
|
+
self._current_path = Path(path)
|
|
526
|
+
return self._save()
|
|
527
|
+
|
|
528
|
+
def _rebuild_recent_menu(self) -> None:
|
|
529
|
+
self.recent_menu.clear()
|
|
530
|
+
files = self.app_settings.recent_files()
|
|
531
|
+
if not files:
|
|
532
|
+
action = self.recent_menu.addAction("No recent files")
|
|
533
|
+
action.setEnabled(False)
|
|
534
|
+
return
|
|
535
|
+
for path in files:
|
|
536
|
+
action = self.recent_menu.addAction(Path(path).name)
|
|
537
|
+
action.setToolTip(path)
|
|
538
|
+
action.triggered.connect(lambda _=False, p=path: self.open_path(p))
|
|
539
|
+
self.recent_menu.addSeparator()
|
|
540
|
+
self.recent_menu.addAction("Clear Menu", self.app_settings.clear_recent_files)
|
|
541
|
+
|
|
542
|
+
def _refresh_program(self) -> None:
|
|
543
|
+
QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
|
|
544
|
+
try:
|
|
545
|
+
self._program = self.editor.program()
|
|
546
|
+
self.visualizer.set_program(self._program, limits=self._limits)
|
|
547
|
+
self._toolpath = self.visualizer.toolpath
|
|
548
|
+
if self._program:
|
|
549
|
+
self._status_notice.setText(
|
|
550
|
+
f"{len(self._program):,} lines · "
|
|
551
|
+
f"{self._toolpath.total_length:.0f} mm of motion"
|
|
552
|
+
)
|
|
553
|
+
finally:
|
|
554
|
+
QApplication.restoreOverrideCursor()
|
|
555
|
+
self._update_actions()
|
|
556
|
+
|
|
557
|
+
def _toggle_comment(self) -> None:
|
|
558
|
+
self.editor.toggle_comment()
|
|
559
|
+
self.editor.document().setModified(True)
|
|
560
|
+
self._refresh_program()
|
|
561
|
+
|
|
562
|
+
def _strip_comments(self) -> None:
|
|
563
|
+
self.editor.strip_comments()
|
|
564
|
+
self._refresh_program()
|
|
565
|
+
|
|
566
|
+
def _transform_offset(self) -> None:
|
|
567
|
+
text, ok = QInputDialog.getText(
|
|
568
|
+
self, "Offset", "dx, dy, dz in mm (for example: 10, 0, -1)", text="0, 0, 0"
|
|
569
|
+
)
|
|
570
|
+
if not ok:
|
|
571
|
+
return
|
|
572
|
+
try:
|
|
573
|
+
dx, dy, dz = (float(part.strip()) for part in text.split(","))
|
|
574
|
+
except ValueError:
|
|
575
|
+
QMessageBox.warning(self, "Offset", "Enter three numbers separated by commas.")
|
|
576
|
+
return
|
|
577
|
+
self.editor.transform(dx=dx, dy=dy, dz=dz)
|
|
578
|
+
self._after_transform()
|
|
579
|
+
|
|
580
|
+
def _transform_scale(self) -> None:
|
|
581
|
+
factor, ok = QInputDialog.getDouble(self, "Scale", "Scale factor", 1.0, 0.01, 100.0, 3)
|
|
582
|
+
if ok:
|
|
583
|
+
self.editor.transform(scale=factor)
|
|
584
|
+
self._after_transform()
|
|
585
|
+
|
|
586
|
+
def _transform_rotate(self) -> None:
|
|
587
|
+
angle, ok = QInputDialog.getDouble(
|
|
588
|
+
self, "Rotate", "Rotation around Z (degrees)", 0.0, -360.0, 360.0, 2
|
|
589
|
+
)
|
|
590
|
+
if ok:
|
|
591
|
+
self.editor.transform(rotate=angle)
|
|
592
|
+
self._after_transform()
|
|
593
|
+
|
|
594
|
+
def _after_transform(self) -> None:
|
|
595
|
+
self.editor.document().setModified(True)
|
|
596
|
+
self._refresh_program()
|
|
597
|
+
self._update_title()
|
|
598
|
+
|
|
599
|
+
def _show_find(self) -> None:
|
|
600
|
+
self.find_bar.activate(replace=False)
|
|
601
|
+
|
|
602
|
+
def _show_replace(self) -> None:
|
|
603
|
+
self.find_bar.activate(replace=True)
|
|
604
|
+
|
|
605
|
+
def _go_to_line(self) -> None:
|
|
606
|
+
line, ok = QInputDialog.getInt(
|
|
607
|
+
self,
|
|
608
|
+
"Go to line",
|
|
609
|
+
"Line number",
|
|
610
|
+
self.editor.current_line(),
|
|
611
|
+
1,
|
|
612
|
+
max(1, self.editor.blockCount()),
|
|
613
|
+
)
|
|
614
|
+
if ok:
|
|
615
|
+
self.editor.go_to_line(line)
|
|
616
|
+
|
|
617
|
+
# ------------------------------------------------------------------ #
|
|
618
|
+
# Connection
|
|
619
|
+
# ------------------------------------------------------------------ #
|
|
620
|
+
def _connect(self, port: str, baud: int, demo: bool) -> None:
|
|
621
|
+
self.app_settings.set_last_port(port)
|
|
622
|
+
self.app_settings.set_last_baud(baud)
|
|
623
|
+
self._status_connection.setText("Connecting…")
|
|
624
|
+
self._status_notice.setText(
|
|
625
|
+
"Waiting for the GRBL banner (the Uno resets when the port opens)…"
|
|
626
|
+
)
|
|
627
|
+
self.controller.connect(port, baud, demo)
|
|
628
|
+
|
|
629
|
+
def _toggle_connection(self) -> None:
|
|
630
|
+
if self._connected:
|
|
631
|
+
self.controller.disconnect()
|
|
632
|
+
else:
|
|
633
|
+
self.connection_panel.toggle()
|
|
634
|
+
|
|
635
|
+
def _on_connected(self, version: str) -> None:
|
|
636
|
+
self._connected = True
|
|
637
|
+
self._homed = False
|
|
638
|
+
self._z_zeroed = False
|
|
639
|
+
self._alarm_open = False
|
|
640
|
+
self._status_connection.setText("Connected")
|
|
641
|
+
self._status_port.setText(
|
|
642
|
+
f"{self.app_settings.last_port()} · {self.app_settings.last_baud()} baud"
|
|
643
|
+
)
|
|
644
|
+
self.connection_panel.set_connected(True, self.app_settings.last_port())
|
|
645
|
+
self.status_panel.set_connected(True, version)
|
|
646
|
+
self.firmware_label.setText(f"GRBL {version}" if version else "GRBL")
|
|
647
|
+
self.console.log(f"Connected: GRBL {version}")
|
|
648
|
+
self._status_notice.setText(f"Connected to GRBL {version}")
|
|
649
|
+
self._update_actions()
|
|
650
|
+
QTimer.singleShot(350, self._request_settings_silently)
|
|
651
|
+
|
|
652
|
+
def _on_disconnected(self, reason: str) -> None:
|
|
653
|
+
self._connected = False
|
|
654
|
+
self.status_panel.set_connected(False)
|
|
655
|
+
self.connection_panel.set_connected(False)
|
|
656
|
+
self._status_connection.setText("Offline")
|
|
657
|
+
self._status_port.setText("")
|
|
658
|
+
self._status_notice.setText(reason)
|
|
659
|
+
self.console.log(f"Disconnected: {reason}")
|
|
660
|
+
if self._last_progress_line and self._stream_state in (
|
|
661
|
+
StreamerState.RUNNING,
|
|
662
|
+
StreamerState.PAUSED,
|
|
663
|
+
):
|
|
664
|
+
self.editor.mark_error(self._last_progress_line)
|
|
665
|
+
self.console.log(
|
|
666
|
+
f"Job interrupted near line {self._last_progress_line}. "
|
|
667
|
+
"Re-home before resuming from that line."
|
|
668
|
+
)
|
|
669
|
+
self._stream_state = StreamerState.IDLE
|
|
670
|
+
self._update_actions()
|
|
671
|
+
|
|
672
|
+
def _on_port_error(self, message: str) -> None:
|
|
673
|
+
self.connection_panel.set_error(message)
|
|
674
|
+
self.console.append("error", message)
|
|
675
|
+
self._status_connection.setText("Offline")
|
|
676
|
+
self._status_notice.setText(message)
|
|
677
|
+
|
|
678
|
+
def _request_settings_silently(self) -> None:
|
|
679
|
+
if self._connected and not (
|
|
680
|
+
self.controller.streamer and self.controller.streamer.is_active
|
|
681
|
+
):
|
|
682
|
+
self.controller.request_settings()
|
|
683
|
+
|
|
684
|
+
# ------------------------------------------------------------------ #
|
|
685
|
+
# Running jobs
|
|
686
|
+
# ------------------------------------------------------------------ #
|
|
687
|
+
def _run(self) -> None:
|
|
688
|
+
self._start_job()
|
|
689
|
+
|
|
690
|
+
def _run_from_cursor(self) -> None:
|
|
691
|
+
self._start_job(from_line=self.editor.current_line())
|
|
692
|
+
|
|
693
|
+
def _start_job(self, from_line: int | None = None) -> None:
|
|
694
|
+
if not self._connected:
|
|
695
|
+
QMessageBox.information(self, "Not connected", "Connect to the machine first.")
|
|
696
|
+
return
|
|
697
|
+
streamer = self.controller.streamer
|
|
698
|
+
if streamer is not None and streamer.is_active:
|
|
699
|
+
return
|
|
700
|
+
if self._status.is_alarm:
|
|
701
|
+
QMessageBox.warning(
|
|
702
|
+
self,
|
|
703
|
+
"Alarm active",
|
|
704
|
+
"Clear the alarm (Unlock) and home the machine before running a job.",
|
|
705
|
+
)
|
|
706
|
+
return
|
|
707
|
+
program = self.editor.program()
|
|
708
|
+
if not program:
|
|
709
|
+
QMessageBox.information(self, "Nothing to run", "The file has no executable lines.")
|
|
710
|
+
return
|
|
711
|
+
|
|
712
|
+
self._refresh_program()
|
|
713
|
+
toolpath = build_toolpath(program, limits=self._limits)
|
|
714
|
+
warnings = self._collect_warnings(program, toolpath, from_line)
|
|
715
|
+
dialog = PreflightDialog(
|
|
716
|
+
self._current_path.name if self._current_path else "Untitled",
|
|
717
|
+
len(program),
|
|
718
|
+
toolpath,
|
|
719
|
+
warnings,
|
|
720
|
+
self,
|
|
721
|
+
)
|
|
722
|
+
if dialog.exec() != QDialog.DialogCode.Accepted:
|
|
723
|
+
return
|
|
724
|
+
|
|
725
|
+
dry_run = dialog.choice == "dry_run"
|
|
726
|
+
if dry_run and not self.controller.send_line("$C"):
|
|
727
|
+
QMessageBox.warning(self, "Dry run", "Could not enable GRBL check mode.")
|
|
728
|
+
return
|
|
729
|
+
started = self.controller.run_program(program, from_line)
|
|
730
|
+
if not started:
|
|
731
|
+
if dry_run:
|
|
732
|
+
self.controller.send_line("$C")
|
|
733
|
+
QMessageBox.warning(
|
|
734
|
+
self,
|
|
735
|
+
"Could not start",
|
|
736
|
+
"The job could not start. Check the connection and machine state.",
|
|
737
|
+
)
|
|
738
|
+
return
|
|
739
|
+
|
|
740
|
+
self._dry_run = dry_run
|
|
741
|
+
self._keep_awake(True)
|
|
742
|
+
self._last_progress_line = from_line or 0
|
|
743
|
+
self._last_editor_update = 0.0
|
|
744
|
+
self.job_panel.begin(len(program), toolpath.estimated_seconds)
|
|
745
|
+
self.editor.clear_markers()
|
|
746
|
+
self.visualizer.set_progress_line(0, force=True)
|
|
747
|
+
self._status_notice.setText("Dry run started (check mode)" if dry_run else "Job running")
|
|
748
|
+
self._update_actions()
|
|
749
|
+
|
|
750
|
+
def _collect_warnings(
|
|
751
|
+
self, program: list[ProgramLine], toolpath: Toolpath, from_line: int | None
|
|
752
|
+
) -> list[str]:
|
|
753
|
+
warnings: list[str] = []
|
|
754
|
+
if not self._homed:
|
|
755
|
+
warnings.append(
|
|
756
|
+
"The machine has not been homed in this session. Soft limits and "
|
|
757
|
+
"work coordinates may not be where you expect."
|
|
758
|
+
)
|
|
759
|
+
if not self._z_zeroed:
|
|
760
|
+
warnings.append("Z zero has not been set in this session.")
|
|
761
|
+
if self.editor.document().isModified():
|
|
762
|
+
warnings.append("The file has unsaved changes; the editor contents will run.")
|
|
763
|
+
long_lines = sum(1 for line in program if line.too_long)
|
|
764
|
+
if long_lines:
|
|
765
|
+
warnings.append(
|
|
766
|
+
f"{long_lines} line(s) exceed GRBL's 80-character limit and may be rejected."
|
|
767
|
+
)
|
|
768
|
+
warnings.extend(toolpath.warnings)
|
|
769
|
+
if toolpath.tools:
|
|
770
|
+
warnings.append(
|
|
771
|
+
"This file has tool changes (M6). They are intercepted and will pause the job."
|
|
772
|
+
)
|
|
773
|
+
if from_line:
|
|
774
|
+
warnings.append(f"Starting from line {from_line}.")
|
|
775
|
+
warnings.append("Verify the spindle is running and the workpiece is secure.")
|
|
776
|
+
return warnings
|
|
777
|
+
|
|
778
|
+
def _toggle_pause(self) -> None:
|
|
779
|
+
streamer = self.controller.streamer
|
|
780
|
+
if streamer is None:
|
|
781
|
+
return
|
|
782
|
+
if streamer.state is StreamerState.PAUSED:
|
|
783
|
+
if streamer.error is not None or streamer.awaiting_tool_change is not None:
|
|
784
|
+
return
|
|
785
|
+
self.controller.resume()
|
|
786
|
+
elif streamer.state is StreamerState.RUNNING:
|
|
787
|
+
self.controller.pause()
|
|
788
|
+
|
|
789
|
+
def _stop(self) -> None:
|
|
790
|
+
self.controller.stop()
|
|
791
|
+
self._status_notice.setText("Stopping…")
|
|
792
|
+
if self._dry_run:
|
|
793
|
+
QTimer.singleShot(400, self._disable_check_mode)
|
|
794
|
+
|
|
795
|
+
def _soft_reset(self) -> None:
|
|
796
|
+
streamer = self.controller.streamer
|
|
797
|
+
active = streamer is not None and streamer.is_active
|
|
798
|
+
if active:
|
|
799
|
+
box = QMessageBox.question(
|
|
800
|
+
self,
|
|
801
|
+
"Soft reset",
|
|
802
|
+
"Reset the controller now? The job will be aborted and position " "may be lost.",
|
|
803
|
+
)
|
|
804
|
+
if box != QMessageBox.StandardButton.Yes:
|
|
805
|
+
return
|
|
806
|
+
self.controller.reset()
|
|
807
|
+
self.editor.mark_all_done()
|
|
808
|
+
|
|
809
|
+
def _home(self) -> None:
|
|
810
|
+
if self.controller.home():
|
|
811
|
+
self._homed = True
|
|
812
|
+
self.console.log("Homing cycle requested")
|
|
813
|
+
self._status_notice.setText("Homing…")
|
|
814
|
+
|
|
815
|
+
def _unlock(self) -> None:
|
|
816
|
+
if self.controller.unlock():
|
|
817
|
+
self._status_notice.setText("Alarm unlock requested")
|
|
818
|
+
|
|
819
|
+
def _zero_axis(self, axis: str) -> None:
|
|
820
|
+
if self.controller.zero_axis(axis, self._wcs):
|
|
821
|
+
if axis.upper() == "Z":
|
|
822
|
+
self._z_zeroed = True
|
|
823
|
+
self._status_notice.setText(f"Work zero set for {axis}")
|
|
824
|
+
self.console.log(f"Zeroed {axis} in G5{self._wcs + 3}")
|
|
825
|
+
|
|
826
|
+
def _change_wcs(self, index: int) -> None:
|
|
827
|
+
self._wcs = index
|
|
828
|
+
self.controller.goto_wcs(index)
|
|
829
|
+
self._status_modal.setText(f"G21 · G90 · G5{index + 3}")
|
|
830
|
+
|
|
831
|
+
def _probe_dialog(self) -> None:
|
|
832
|
+
if not self._connected:
|
|
833
|
+
QMessageBox.information(self, "Not connected", "Connect to the machine first.")
|
|
834
|
+
return
|
|
835
|
+
dialog = ProbeDialog(self)
|
|
836
|
+
if dialog.exec() != QDialog.DialogCode.Accepted:
|
|
837
|
+
return
|
|
838
|
+
thickness, feed, travel, retract = dialog.values()
|
|
839
|
+
if self.controller.probe_z(thickness, feed, travel, retract):
|
|
840
|
+
self._z_zeroed = True
|
|
841
|
+
self._status_notice.setText("Probing Z…")
|
|
842
|
+
self.console.log(
|
|
843
|
+
f"Probing Z (plate {thickness} mm, feed {feed} mm/min, travel {travel} mm)"
|
|
844
|
+
)
|
|
845
|
+
else:
|
|
846
|
+
QMessageBox.warning(self, "Probe", "Could not start the probe cycle.")
|
|
847
|
+
|
|
848
|
+
def _spindle_on(self) -> None:
|
|
849
|
+
if not self._connected:
|
|
850
|
+
return
|
|
851
|
+
rpm, ok = QInputDialog.getDouble(self, "Spindle", "RPM", 1000, 1, 60000, 0)
|
|
852
|
+
if ok:
|
|
853
|
+
self.controller.set_spindle(rpm)
|
|
854
|
+
|
|
855
|
+
# ------------------------------------------------------------------ #
|
|
856
|
+
# Controller events
|
|
857
|
+
# ------------------------------------------------------------------ #
|
|
858
|
+
def _on_line(self, kind: ResponseKind, text: str) -> None:
|
|
859
|
+
self.console.append(kind, text)
|
|
860
|
+
|
|
861
|
+
def _on_status(self, status: MachineStatus) -> None:
|
|
862
|
+
self._status = status
|
|
863
|
+
self.status_panel.update_status(status)
|
|
864
|
+
active = self._stream_state in (StreamerState.RUNNING, StreamerState.PAUSED)
|
|
865
|
+
self.jog_panel.set_machine(status, self._connected, active)
|
|
866
|
+
if status.wpos:
|
|
867
|
+
self.visualizer.set_position(*status.wpos)
|
|
868
|
+
if status.is_idle and self._alarm_open:
|
|
869
|
+
self._alarm_open = False
|
|
870
|
+
pins = f" · pins {status.pins}" if status.pins else ""
|
|
871
|
+
self._status_modal.setText(f"G5{self._wcs + 3} · {status.state}{pins}")
|
|
872
|
+
self._update_actions()
|
|
873
|
+
|
|
874
|
+
def _on_progress(self, sent: int, total: int, line: int) -> None:
|
|
875
|
+
self.job_panel.update_progress(sent, total, line)
|
|
876
|
+
if line:
|
|
877
|
+
self._last_progress_line = line
|
|
878
|
+
self.visualizer.set_progress_line(line)
|
|
879
|
+
now = time.monotonic()
|
|
880
|
+
if now - self._last_editor_update > 0.12:
|
|
881
|
+
self._last_editor_update = now
|
|
882
|
+
self.editor.set_execution_line(line)
|
|
883
|
+
|
|
884
|
+
def _on_stream_state(self, state: StreamerState) -> None:
|
|
885
|
+
self._stream_state = state
|
|
886
|
+
if state is StreamerState.PAUSED:
|
|
887
|
+
self.action_pause.setText("Resume")
|
|
888
|
+
else:
|
|
889
|
+
self.action_pause.setText("Pause")
|
|
890
|
+
active = state in (StreamerState.RUNNING, StreamerState.PAUSED)
|
|
891
|
+
self.jog_panel.set_machine(self._status, self._connected, active)
|
|
892
|
+
self._update_actions()
|
|
893
|
+
|
|
894
|
+
def _on_job_finished(self, reason: str) -> None:
|
|
895
|
+
self._keep_awake(False)
|
|
896
|
+
self.job_panel.finish(reason)
|
|
897
|
+
if reason == "completed":
|
|
898
|
+
self.editor.mark_all_done()
|
|
899
|
+
self.visualizer.set_progress_line(self._last_progress_line, force=True)
|
|
900
|
+
self._status_notice.setText("Job completed")
|
|
901
|
+
elif reason == "stopped":
|
|
902
|
+
self._status_notice.setText("Job stopped")
|
|
903
|
+
elif reason == "alarm":
|
|
904
|
+
self._status_notice.setText("Job halted by alarm")
|
|
905
|
+
elif reason == "disconnected":
|
|
906
|
+
self._status_notice.setText("Job interrupted: connection lost")
|
|
907
|
+
self._stream_state = StreamerState.IDLE
|
|
908
|
+
if self._dry_run:
|
|
909
|
+
QTimer.singleShot(350, self._disable_check_mode)
|
|
910
|
+
self._update_actions()
|
|
911
|
+
|
|
912
|
+
def _disable_check_mode(self) -> None:
|
|
913
|
+
if self._dry_run:
|
|
914
|
+
self.controller.send_line("$C")
|
|
915
|
+
self._dry_run = False
|
|
916
|
+
|
|
917
|
+
def _on_stream_error(self, line: ProgramLine, code: int, detail: str) -> None:
|
|
918
|
+
self.editor.mark_error(line.source_line)
|
|
919
|
+
self.editor.go_to_line(line.source_line)
|
|
920
|
+
self.console.append("error", f"error:{code} on line {line.source_line}: {detail}")
|
|
921
|
+
box = QMessageBox(self)
|
|
922
|
+
box.setIcon(QMessageBox.Icon.Warning)
|
|
923
|
+
box.setWindowTitle("G-code error")
|
|
924
|
+
box.setText(f"error:{code} on line {line.source_line}")
|
|
925
|
+
box.setInformativeText(f"{detail}\n\n{line.text}")
|
|
926
|
+
skip = box.addButton("Skip line", QMessageBox.ButtonRole.AcceptRole)
|
|
927
|
+
abort = box.addButton("Abort job", QMessageBox.ButtonRole.DestructiveRole)
|
|
928
|
+
box.setDefaultButton(abort)
|
|
929
|
+
box.exec()
|
|
930
|
+
if box.clickedButton() is skip:
|
|
931
|
+
self.controller.skip_error()
|
|
932
|
+
else:
|
|
933
|
+
self.controller.stop()
|
|
934
|
+
|
|
935
|
+
def _on_tool_change(self, line: ProgramLine) -> None:
|
|
936
|
+
self._status_notice.setText(f"Tool change at line {line.source_line}")
|
|
937
|
+
dialog = ToolChangeDialog(line.tool_number, self)
|
|
938
|
+
if dialog.exec() == QDialog.DialogCode.Accepted:
|
|
939
|
+
self.controller.complete_tool_change()
|
|
940
|
+
else:
|
|
941
|
+
self.controller.cancel_tool_change()
|
|
942
|
+
|
|
943
|
+
def _on_alarm(self, code: int, detail: str) -> None:
|
|
944
|
+
self._alarm_open = True
|
|
945
|
+
self._z_zeroed = False
|
|
946
|
+
self._status_notice.setText(f"ALARM:{code} {detail}")
|
|
947
|
+
box = QMessageBox(self)
|
|
948
|
+
box.setIcon(QMessageBox.Icon.Critical)
|
|
949
|
+
box.setWindowTitle(f"ALARM:{code}")
|
|
950
|
+
box.setText(detail)
|
|
951
|
+
box.setInformativeText(
|
|
952
|
+
"Motion is locked until the alarm is cleared. Unlock if the position is "
|
|
953
|
+
"still valid, otherwise home the machine."
|
|
954
|
+
)
|
|
955
|
+
unlock = box.addButton("Unlock ($X)", QMessageBox.ButtonRole.ActionRole)
|
|
956
|
+
home = box.addButton("Home ($H)", QMessageBox.ButtonRole.ActionRole)
|
|
957
|
+
box.addButton(QMessageBox.StandardButton.Close)
|
|
958
|
+
box.exec()
|
|
959
|
+
if box.clickedButton() is unlock:
|
|
960
|
+
self.controller.unlock()
|
|
961
|
+
elif box.clickedButton() is home:
|
|
962
|
+
self._home()
|
|
963
|
+
|
|
964
|
+
def _on_command_error(self, code: int, detail: str) -> None:
|
|
965
|
+
self.console.append("error", f"error:{code} {detail}")
|
|
966
|
+
self._status_notice.setText(f"error:{code}: {detail}")
|
|
967
|
+
|
|
968
|
+
def _on_message(self, text: str) -> None:
|
|
969
|
+
if text:
|
|
970
|
+
self.console.log(text)
|
|
971
|
+
self._status_notice.setText(text)
|
|
972
|
+
|
|
973
|
+
def _on_settings(self, entries: list[SettingsEntry]) -> None:
|
|
974
|
+
values = {entry.number: entry for entry in entries}
|
|
975
|
+
limits = []
|
|
976
|
+
for number in (130, 131, 132):
|
|
977
|
+
entry = values.get(number)
|
|
978
|
+
if entry is not None:
|
|
979
|
+
with suppress(ValueError):
|
|
980
|
+
limits.append(float(entry.value))
|
|
981
|
+
if len(limits) == 3:
|
|
982
|
+
new_limits = (limits[0], limits[1], limits[2])
|
|
983
|
+
streamer = self.controller.streamer
|
|
984
|
+
if new_limits != self._limits:
|
|
985
|
+
self._limits = new_limits
|
|
986
|
+
if self._program and not (streamer and streamer.is_active):
|
|
987
|
+
self._refresh_program()
|
|
988
|
+
if self._settings_dialog is not None and self._settings_dialog.isVisible():
|
|
989
|
+
self._settings_dialog.set_settings(entries)
|
|
990
|
+
|
|
991
|
+
def _show_settings(self) -> None:
|
|
992
|
+
if not self._connected:
|
|
993
|
+
QMessageBox.information(self, "Not connected", "Connect to the machine first.")
|
|
994
|
+
return
|
|
995
|
+
if self._settings_dialog is None:
|
|
996
|
+
self._settings_dialog = SettingsDialog(parent=self)
|
|
997
|
+
self._settings_dialog.refreshRequested.connect(self.controller.request_settings)
|
|
998
|
+
self._settings_dialog.writeRequested.connect(self.controller.set_setting)
|
|
999
|
+
self._settings_dialog.show()
|
|
1000
|
+
self._settings_dialog.raise_()
|
|
1001
|
+
self.controller.request_settings()
|
|
1002
|
+
|
|
1003
|
+
def _send_command(self, text: str) -> None:
|
|
1004
|
+
if not self.controller.send_line(text):
|
|
1005
|
+
self.console.log("Command was not sent (not connected or job active).")
|
|
1006
|
+
|
|
1007
|
+
# ------------------------------------------------------------------ #
|
|
1008
|
+
# Theme / persistence
|
|
1009
|
+
# ------------------------------------------------------------------ #
|
|
1010
|
+
def apply_theme(self, dark: bool, persist: bool = True) -> None:
|
|
1011
|
+
self._dark = dark
|
|
1012
|
+
token = tokens(dark)
|
|
1013
|
+
app = QApplication.instance()
|
|
1014
|
+
if isinstance(app, QApplication):
|
|
1015
|
+
app.setStyleSheet(build_stylesheet(token))
|
|
1016
|
+
self.editor.set_token(token)
|
|
1017
|
+
self.console.set_token(token)
|
|
1018
|
+
self.visualizer.set_token(token)
|
|
1019
|
+
self.status_panel.set_token(token)
|
|
1020
|
+
if persist:
|
|
1021
|
+
self.app_settings.set_dark_theme(dark)
|
|
1022
|
+
|
|
1023
|
+
def _restore_window(self) -> None:
|
|
1024
|
+
geometry = self.app_settings.geometry()
|
|
1025
|
+
if geometry is not None:
|
|
1026
|
+
self.restoreGeometry(geometry)
|
|
1027
|
+
state = self.app_settings.window_state()
|
|
1028
|
+
if state is not None:
|
|
1029
|
+
self.restoreState(state)
|
|
1030
|
+
|
|
1031
|
+
def _keep_awake(self, enabled: bool) -> None:
|
|
1032
|
+
if sys.platform != "darwin":
|
|
1033
|
+
return
|
|
1034
|
+
if enabled and self._caffeinate is None:
|
|
1035
|
+
with suppress(OSError):
|
|
1036
|
+
self._caffeinate = subprocess.Popen(
|
|
1037
|
+
["caffeinate", "-i"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
|
|
1038
|
+
)
|
|
1039
|
+
self._status_notice.setText("Mac kept awake while the job runs")
|
|
1040
|
+
elif not enabled and self._caffeinate is not None:
|
|
1041
|
+
self._caffeinate.terminate()
|
|
1042
|
+
self._caffeinate = None
|
|
1043
|
+
|
|
1044
|
+
def closeEvent(self, event) -> None: # noqa: N802, ANN001
|
|
1045
|
+
if not self._maybe_save():
|
|
1046
|
+
event.ignore()
|
|
1047
|
+
return
|
|
1048
|
+
self.app_settings.save_geometry(self.saveGeometry())
|
|
1049
|
+
self.app_settings.save_window_state(self.saveState())
|
|
1050
|
+
self.app_settings.sync()
|
|
1051
|
+
self._keep_awake(False)
|
|
1052
|
+
self.controller.disconnect()
|
|
1053
|
+
super().closeEvent(event)
|
|
1054
|
+
|
|
1055
|
+
# ------------------------------------------------------------------ #
|
|
1056
|
+
# Enablement
|
|
1057
|
+
# ------------------------------------------------------------------ #
|
|
1058
|
+
def _update_actions(self) -> None:
|
|
1059
|
+
streamer = self.controller.streamer
|
|
1060
|
+
active = streamer is not None and streamer.is_active
|
|
1061
|
+
family = self._status.family if self._connected else "offline"
|
|
1062
|
+
paused = streamer is not None and streamer.state is StreamerState.PAUSED
|
|
1063
|
+
error = streamer is not None and streamer.error is not None
|
|
1064
|
+
tool_change = streamer is not None and streamer.awaiting_tool_change is not None
|
|
1065
|
+
|
|
1066
|
+
self.action_run.setEnabled(
|
|
1067
|
+
self._connected and family == "idle" and not active and bool(self._program)
|
|
1068
|
+
)
|
|
1069
|
+
self.action_pause.setText("Resume" if paused else "Pause")
|
|
1070
|
+
self.action_pause.setEnabled(bool(active and not error and not tool_change))
|
|
1071
|
+
self.action_stop.setEnabled(self._connected and (active or family in ("hold", "alarm")))
|
|
1072
|
+
self.action_home.setEnabled(self._connected and not active and family in ("idle", "alarm"))
|
|
1073
|
+
self.action_unlock.setEnabled(self._connected and family == "alarm")
|
|
1074
|
+
self.action_reset.setEnabled(self._connected)
|
|
1075
|
+
self.console.set_enabled(self._connected)
|
|
1076
|
+
self.status_panel.set_interactive(self._connected, family, bool(active))
|
|
1077
|
+
|
|
1078
|
+
# ------------------------------------------------------------------ #
|
|
1079
|
+
# Misc
|
|
1080
|
+
# ------------------------------------------------------------------ #
|
|
1081
|
+
def _show_manual(self) -> None:
|
|
1082
|
+
if self._manual_dialog is None:
|
|
1083
|
+
self._manual_dialog = ManualDialog(parent=self)
|
|
1084
|
+
self._manual_dialog.show()
|
|
1085
|
+
self._manual_dialog.raise_()
|
|
1086
|
+
self._manual_dialog.activateWindow()
|
|
1087
|
+
self._manual_dialog.browser.setFocus()
|
|
1088
|
+
|
|
1089
|
+
def _show_shortcuts(self) -> None:
|
|
1090
|
+
QMessageBox.information(
|
|
1091
|
+
self,
|
|
1092
|
+
"Keyboard shortcuts",
|
|
1093
|
+
"⌘O Open · ⌘S Save · ⌘F Find · ⌘L Go to line\n"
|
|
1094
|
+
"⌘R Run · ⌘. Stop · ⌘⇧. Soft reset\n"
|
|
1095
|
+
"⌘/ Toggle comment · ⌘K Connect · ⌘0 Fit toolpath\n"
|
|
1096
|
+
"Jog panel: arrows X/Y · PgUp/PgDn Z · [ ] step · Space cancel jog",
|
|
1097
|
+
)
|
|
1098
|
+
|
|
1099
|
+
def _about(self) -> None:
|
|
1100
|
+
QMessageBox.about(
|
|
1101
|
+
self,
|
|
1102
|
+
f"About {APP_NAME}",
|
|
1103
|
+
f"{APP_NAME}\n\nA G-code sender and editor for GRBL 1.1 on "
|
|
1104
|
+
"Arduino Uno.\n\nKeep a hand near the power switch and wear eye "
|
|
1105
|
+
"protection. Software stop is not an emergency stop.",
|
|
1106
|
+
)
|
|
1107
|
+
|
|
1108
|
+
def dragEnterEvent(self, event) -> None: # noqa: N802, ANN001
|
|
1109
|
+
if event.mimeData().hasUrls():
|
|
1110
|
+
event.acceptProposedAction()
|
|
1111
|
+
|
|
1112
|
+
def dropEvent(self, event) -> None: # noqa: N802, ANN001
|
|
1113
|
+
urls = event.mimeData().urls()
|
|
1114
|
+
if urls and urls[0].isLocalFile() and self._maybe_save():
|
|
1115
|
+
self.open_path(urls[0].toLocalFile())
|
|
1116
|
+
event.acceptProposedAction()
|