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
plotdeck/__init__.py
ADDED
plotdeck/__main__.py
ADDED
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,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()
|