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,375 @@
|
|
|
1
|
+
"""Serial transport layer.
|
|
2
|
+
|
|
3
|
+
The transport is swappable: :class:`SerialTransport` talks to a real port,
|
|
4
|
+
while :class:`MockTransport` provides a self-contained demo machine. Both
|
|
5
|
+
deliver complete lines through plain callbacks, which keeps this module free
|
|
6
|
+
of Qt imports.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
from abc import ABC, abstractmethod
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
from contextlib import suppress
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from .grbl_protocol import BAUD_RATE, UNO_RESET_DELAY_S
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TransportError(RuntimeError):
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Transport(ABC):
|
|
26
|
+
"""Common interface for anything that can exchange lines with GRBL."""
|
|
27
|
+
|
|
28
|
+
on_line: Callable[[str], None] | None = None
|
|
29
|
+
on_error: Callable[[str], None] | None = None
|
|
30
|
+
on_closed: Callable[[str], None] | None = None
|
|
31
|
+
on_open: Callable[[], None] | None = None
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
@abstractmethod
|
|
35
|
+
def is_open(self) -> bool: ...
|
|
36
|
+
|
|
37
|
+
@abstractmethod
|
|
38
|
+
def open(self) -> None: ...
|
|
39
|
+
|
|
40
|
+
@abstractmethod
|
|
41
|
+
def close(self) -> None: ...
|
|
42
|
+
|
|
43
|
+
@abstractmethod
|
|
44
|
+
def write(self, data: bytes) -> None: ...
|
|
45
|
+
|
|
46
|
+
# Convenience helpers ---------------------------------------------------
|
|
47
|
+
def send_line(self, line: str) -> None:
|
|
48
|
+
self.write(line.encode("utf-8") + b"\n")
|
|
49
|
+
|
|
50
|
+
def send_realtime(self, byte: bytes) -> None:
|
|
51
|
+
self.write(byte)
|
|
52
|
+
|
|
53
|
+
def _emit_line(self, line: str) -> None:
|
|
54
|
+
callback = self.on_line
|
|
55
|
+
if callback is not None:
|
|
56
|
+
# Never let a consumer exception kill the reader thread.
|
|
57
|
+
with suppress(Exception):
|
|
58
|
+
callback(line)
|
|
59
|
+
|
|
60
|
+
def _emit_error(self, message: str) -> None:
|
|
61
|
+
callback = self.on_error
|
|
62
|
+
if callback is not None:
|
|
63
|
+
with suppress(Exception):
|
|
64
|
+
callback(message)
|
|
65
|
+
|
|
66
|
+
def _emit_closed(self, reason: str) -> None:
|
|
67
|
+
callback = self.on_closed
|
|
68
|
+
if callback is not None:
|
|
69
|
+
with suppress(Exception):
|
|
70
|
+
callback(reason)
|
|
71
|
+
|
|
72
|
+
def _emit_open(self) -> None:
|
|
73
|
+
callback = self.on_open
|
|
74
|
+
if callback is not None:
|
|
75
|
+
with suppress(Exception):
|
|
76
|
+
callback()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class SerialTransport(Transport):
|
|
80
|
+
"""pyserial transport on a background thread.
|
|
81
|
+
|
|
82
|
+
The Uno resets when the port opens, so the reader waits
|
|
83
|
+
:data:`UNO_RESET_DELAY_S` before flushing and asking for a status report.
|
|
84
|
+
The GRBL welcome banner then tells the controller the link is really up.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
def __init__(
|
|
88
|
+
self,
|
|
89
|
+
port: str,
|
|
90
|
+
baud: int = BAUD_RATE,
|
|
91
|
+
reset_delay: float = UNO_RESET_DELAY_S,
|
|
92
|
+
) -> None:
|
|
93
|
+
self.port = port
|
|
94
|
+
self.baud = baud
|
|
95
|
+
self.reset_delay = reset_delay
|
|
96
|
+
self._serial: Any = None
|
|
97
|
+
self._thread: threading.Thread | None = None
|
|
98
|
+
self._stop = threading.Event()
|
|
99
|
+
self._write_lock = threading.Lock()
|
|
100
|
+
self._open = False
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def is_open(self) -> bool:
|
|
104
|
+
return self._open
|
|
105
|
+
|
|
106
|
+
def open(self) -> None:
|
|
107
|
+
if self._thread is not None and self._thread.is_alive():
|
|
108
|
+
return
|
|
109
|
+
self._stop.clear()
|
|
110
|
+
self._thread = threading.Thread(target=self._run, name=f"serial-{self.port}", daemon=True)
|
|
111
|
+
self._thread.start()
|
|
112
|
+
|
|
113
|
+
def _run(self) -> None:
|
|
114
|
+
import serial
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
self._serial = serial.Serial(self.port, self.baud, timeout=0.05)
|
|
118
|
+
except Exception as exc: # serial.SerialException and OSError
|
|
119
|
+
self._open = False
|
|
120
|
+
self._emit_error(f"Could not open {self.port}: {exc}")
|
|
121
|
+
self._emit_closed(str(exc))
|
|
122
|
+
return
|
|
123
|
+
|
|
124
|
+
self._open = True
|
|
125
|
+
try:
|
|
126
|
+
time.sleep(self.reset_delay)
|
|
127
|
+
if self._stop.is_set():
|
|
128
|
+
return
|
|
129
|
+
with self._write_lock:
|
|
130
|
+
self._serial.write(b"\r\n\r\n")
|
|
131
|
+
self._serial.flush()
|
|
132
|
+
# Start status polling only after the Uno has rebooted, so the
|
|
133
|
+
# first '?' does not land in the bootloader.
|
|
134
|
+
self._emit_open()
|
|
135
|
+
except Exception as exc: # noqa: BLE001
|
|
136
|
+
self._emit_error(str(exc))
|
|
137
|
+
|
|
138
|
+
while not self._stop.is_set():
|
|
139
|
+
try:
|
|
140
|
+
data = self._serial.readline()
|
|
141
|
+
except Exception as exc: # unplugged or closed
|
|
142
|
+
if not self._stop.is_set():
|
|
143
|
+
self._emit_error(f"Serial connection lost: {exc}")
|
|
144
|
+
self._emit_closed(str(exc))
|
|
145
|
+
break
|
|
146
|
+
if not data:
|
|
147
|
+
continue
|
|
148
|
+
decoded = data.decode("utf-8", errors="replace").strip("\r\n ")
|
|
149
|
+
if decoded:
|
|
150
|
+
self._emit_line(decoded)
|
|
151
|
+
|
|
152
|
+
def close(self) -> None:
|
|
153
|
+
self._stop.set()
|
|
154
|
+
thread = self._thread
|
|
155
|
+
if thread is not None and thread is not threading.current_thread():
|
|
156
|
+
thread.join(timeout=1.5)
|
|
157
|
+
self._thread = None
|
|
158
|
+
self._open = False
|
|
159
|
+
serial_port = self._serial
|
|
160
|
+
self._serial = None
|
|
161
|
+
if serial_port is not None:
|
|
162
|
+
with suppress(Exception):
|
|
163
|
+
serial_port.close()
|
|
164
|
+
|
|
165
|
+
def write(self, data: bytes) -> None:
|
|
166
|
+
serial_port = self._serial
|
|
167
|
+
if not self._open or serial_port is None:
|
|
168
|
+
raise TransportError("Serial port is not open.")
|
|
169
|
+
with self._write_lock:
|
|
170
|
+
try:
|
|
171
|
+
serial_port.write(data)
|
|
172
|
+
serial_port.flush()
|
|
173
|
+
except Exception as exc: # noqa: BLE001
|
|
174
|
+
self._emit_error(f"Write failed: {exc}")
|
|
175
|
+
raise TransportError(str(exc)) from exc
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class MockTransport(Transport):
|
|
179
|
+
"""A tiny GRBL stand-in so the UI can be explored without hardware.
|
|
180
|
+
|
|
181
|
+
It implements the same character-counting contract as the real firmware:
|
|
182
|
+
lines are queued and acknowledged one at a time, status reports work, and
|
|
183
|
+
``!``/``~`` change the state.
|
|
184
|
+
"""
|
|
185
|
+
|
|
186
|
+
def __init__(self, line_delay: float = 0.004) -> None:
|
|
187
|
+
self._delay = line_delay
|
|
188
|
+
self._open = False
|
|
189
|
+
self._state = "Idle"
|
|
190
|
+
self._pos = [0.0, 0.0, 0.0]
|
|
191
|
+
self._feed = 0.0
|
|
192
|
+
self._spindle = 0.0
|
|
193
|
+
self._settings = {
|
|
194
|
+
0: "10",
|
|
195
|
+
1: "25",
|
|
196
|
+
2: "0",
|
|
197
|
+
3: "0",
|
|
198
|
+
4: "0",
|
|
199
|
+
5: "0",
|
|
200
|
+
6: "0",
|
|
201
|
+
10: "3",
|
|
202
|
+
11: "0.010",
|
|
203
|
+
12: "0.002",
|
|
204
|
+
13: "0",
|
|
205
|
+
20: "0",
|
|
206
|
+
21: "0",
|
|
207
|
+
22: "1",
|
|
208
|
+
23: "0",
|
|
209
|
+
24: "25.000",
|
|
210
|
+
25: "500.000",
|
|
211
|
+
26: "250",
|
|
212
|
+
27: "1.000",
|
|
213
|
+
30: "1000",
|
|
214
|
+
31: "0",
|
|
215
|
+
32: "0",
|
|
216
|
+
100: "250.000",
|
|
217
|
+
101: "250.000",
|
|
218
|
+
102: "250.000",
|
|
219
|
+
110: "500.000",
|
|
220
|
+
111: "500.000",
|
|
221
|
+
112: "500.000",
|
|
222
|
+
120: "10.000",
|
|
223
|
+
121: "10.000",
|
|
224
|
+
122: "10.000",
|
|
225
|
+
130: "200.000",
|
|
226
|
+
131: "200.000",
|
|
227
|
+
132: "200.000",
|
|
228
|
+
}
|
|
229
|
+
self._thread: threading.Thread | None = None
|
|
230
|
+
self._worker: threading.Thread | None = None
|
|
231
|
+
self._stop = threading.Event()
|
|
232
|
+
self._lock = threading.Lock()
|
|
233
|
+
self._pending: list[int] = []
|
|
234
|
+
self._lines: list[str] = []
|
|
235
|
+
|
|
236
|
+
@property
|
|
237
|
+
def is_open(self) -> bool:
|
|
238
|
+
return self._open
|
|
239
|
+
|
|
240
|
+
def open(self) -> None:
|
|
241
|
+
self._open = True
|
|
242
|
+
self._stop.clear()
|
|
243
|
+
self._thread = threading.Thread(target=self._run, name="mock-grbl", daemon=True)
|
|
244
|
+
self._thread.start()
|
|
245
|
+
self._worker = threading.Thread(
|
|
246
|
+
target=self._process_lines, name="mock-grbl-lines", daemon=True
|
|
247
|
+
)
|
|
248
|
+
self._worker.start()
|
|
249
|
+
|
|
250
|
+
def _run(self) -> None:
|
|
251
|
+
time.sleep(0.2)
|
|
252
|
+
self._emit_open()
|
|
253
|
+
self._emit_line("Grbl 1.1h ['$' for help]")
|
|
254
|
+
while not self._stop.is_set():
|
|
255
|
+
time.sleep(0.05)
|
|
256
|
+
|
|
257
|
+
def _process_lines(self) -> None:
|
|
258
|
+
# Line commands are acknowledged asynchronously, like real firmware.
|
|
259
|
+
while not self._stop.is_set():
|
|
260
|
+
with self._lock:
|
|
261
|
+
line = self._lines.pop(0) if self._lines else None
|
|
262
|
+
if line is None:
|
|
263
|
+
time.sleep(0.002)
|
|
264
|
+
continue
|
|
265
|
+
self._dispatch(line)
|
|
266
|
+
time.sleep(self._delay)
|
|
267
|
+
|
|
268
|
+
def close(self) -> None:
|
|
269
|
+
self._stop.set()
|
|
270
|
+
thread = self._thread
|
|
271
|
+
if thread is not None and thread is not threading.current_thread():
|
|
272
|
+
thread.join(timeout=1.0)
|
|
273
|
+
worker = self._worker
|
|
274
|
+
if worker is not None and worker is not threading.current_thread():
|
|
275
|
+
worker.join(timeout=1.0)
|
|
276
|
+
self._thread = None
|
|
277
|
+
self._worker = None
|
|
278
|
+
if self._open:
|
|
279
|
+
self._open = False
|
|
280
|
+
self._emit_closed("Disconnected")
|
|
281
|
+
|
|
282
|
+
def write(self, data: bytes) -> None:
|
|
283
|
+
if not self._open:
|
|
284
|
+
raise TransportError("Mock transport is not open.")
|
|
285
|
+
with self._lock:
|
|
286
|
+
for byte in data:
|
|
287
|
+
self._handle_byte(byte)
|
|
288
|
+
|
|
289
|
+
def _handle_byte(self, byte: int) -> None:
|
|
290
|
+
if byte == 0x3F: # '?'
|
|
291
|
+
self._emit_status()
|
|
292
|
+
elif byte == 0x21: # '!'
|
|
293
|
+
self._state = "Hold:0"
|
|
294
|
+
elif byte == 0x7E: # '~'
|
|
295
|
+
self._state = "Idle"
|
|
296
|
+
elif byte == 0x18: # soft reset
|
|
297
|
+
self._state = "Idle"
|
|
298
|
+
self._feed = 0.0
|
|
299
|
+
self._pending.clear()
|
|
300
|
+
self._lines.clear()
|
|
301
|
+
threading.Timer(0.05, lambda: self._emit_line("Grbl 1.1h ['$' for help]")).start()
|
|
302
|
+
elif byte == 0x85:
|
|
303
|
+
self._state = "Idle"
|
|
304
|
+
elif byte in (0x0A, 0x0D):
|
|
305
|
+
line = bytes(self._pending).decode("utf-8", errors="replace").strip()
|
|
306
|
+
self._pending.clear()
|
|
307
|
+
if line:
|
|
308
|
+
self._lines.append(line)
|
|
309
|
+
else:
|
|
310
|
+
self._pending.append(byte)
|
|
311
|
+
|
|
312
|
+
def _emit_status(self) -> None:
|
|
313
|
+
pos = "MPos:{:.3f},{:.3f},{:.3f}".format(*self._pos)
|
|
314
|
+
self._emit_line(
|
|
315
|
+
f"<{self._state}|{pos}|FS:{self._feed:.0f},{self._spindle:.0f}"
|
|
316
|
+
f"|Bf:0,127|Ov:100,100,100>"
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
# A line is dispatched when its newline arrives; handled in write().
|
|
320
|
+
def _dispatch(self, line: str) -> None:
|
|
321
|
+
text = line.strip()
|
|
322
|
+
if not text:
|
|
323
|
+
return
|
|
324
|
+
if text == "?":
|
|
325
|
+
self._emit_status()
|
|
326
|
+
return
|
|
327
|
+
if text == "$$":
|
|
328
|
+
for key in sorted(self._settings):
|
|
329
|
+
self._emit_line(f"${key}={self._settings[key]}")
|
|
330
|
+
self._emit_line("ok")
|
|
331
|
+
return
|
|
332
|
+
if text == "$I":
|
|
333
|
+
self._emit_line("[VER:1.1h.20190825:]")
|
|
334
|
+
self._emit_line("[OPT:V,15,128]")
|
|
335
|
+
self._emit_line("ok")
|
|
336
|
+
return
|
|
337
|
+
if text.startswith("$J="):
|
|
338
|
+
self._emit_line("ok")
|
|
339
|
+
return
|
|
340
|
+
if text == "$H":
|
|
341
|
+
self._state = "Home"
|
|
342
|
+
self._pos = [0.0, 0.0, 0.0]
|
|
343
|
+
self._state = "Idle"
|
|
344
|
+
self._emit_line("ok")
|
|
345
|
+
return
|
|
346
|
+
if text in ("$X", "$C", "$G", "$#"):
|
|
347
|
+
self._emit_line("ok")
|
|
348
|
+
return
|
|
349
|
+
if text.startswith("G0") or text.startswith("G1"):
|
|
350
|
+
import re
|
|
351
|
+
|
|
352
|
+
for axis, value in re.findall(r"([XYZ])([-+]?\d*\.?\d+)", text):
|
|
353
|
+
index = "XYZ".index(axis)
|
|
354
|
+
self._pos[index] = float(value)
|
|
355
|
+
self._emit_line("ok")
|
|
356
|
+
return
|
|
357
|
+
if text.startswith("M3") or text.startswith("M5"):
|
|
358
|
+
self._emit_line("ok")
|
|
359
|
+
return
|
|
360
|
+
self._emit_line("ok")
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def list_serial_ports() -> list[tuple[str, str]]:
|
|
364
|
+
"""Return USB-looking serial ports as ``(device, description)``."""
|
|
365
|
+
try:
|
|
366
|
+
from serial.tools import list_ports
|
|
367
|
+
except ImportError:
|
|
368
|
+
return []
|
|
369
|
+
ports: list[tuple[str, str]] = []
|
|
370
|
+
for info in list_ports.comports():
|
|
371
|
+
device = info.device
|
|
372
|
+
if any(token in device for token in ("usbmodem", "usbserial", "wchusbserial")):
|
|
373
|
+
ports.append((device, info.description or device))
|
|
374
|
+
ports.sort()
|
|
375
|
+
return ports
|
plotdeck/ui/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""PySide6 user interface for PlotDeck."""
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Connection controls shown in the main toolbar."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from PySide6.QtCore import Signal
|
|
6
|
+
from PySide6.QtWidgets import (
|
|
7
|
+
QComboBox,
|
|
8
|
+
QHBoxLayout,
|
|
9
|
+
QLabel,
|
|
10
|
+
QPushButton,
|
|
11
|
+
QToolButton,
|
|
12
|
+
QWidget,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
from ..core.controller import MachineController
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ConnectionPanel(QWidget):
|
|
19
|
+
connectRequested = Signal(str, int, bool)
|
|
20
|
+
disconnectRequested = Signal()
|
|
21
|
+
|
|
22
|
+
def __init__(self, parent=None) -> None: # noqa: ANN001
|
|
23
|
+
super().__init__(parent)
|
|
24
|
+
self.setObjectName("Panel")
|
|
25
|
+
self._connected = False
|
|
26
|
+
self._connecting = False
|
|
27
|
+
|
|
28
|
+
self._port = QComboBox()
|
|
29
|
+
self._port.setMinimumWidth(200)
|
|
30
|
+
self._port.setToolTip("Serial port")
|
|
31
|
+
refresh = QToolButton()
|
|
32
|
+
refresh.setText("⟳")
|
|
33
|
+
refresh.setToolTip("Refresh ports")
|
|
34
|
+
refresh.clicked.connect(self.refresh_ports)
|
|
35
|
+
|
|
36
|
+
self._baud = QComboBox()
|
|
37
|
+
self._baud.setEditable(True)
|
|
38
|
+
for baud in ("9600", "57600", "115200", "230400", "250000"):
|
|
39
|
+
self._baud.addItem(baud)
|
|
40
|
+
self._baud.setCurrentText("115200")
|
|
41
|
+
self._baud.setFixedWidth(90)
|
|
42
|
+
self._baud.setToolTip("Baud rate")
|
|
43
|
+
|
|
44
|
+
self._connect = QPushButton("Connect")
|
|
45
|
+
self._connect.setObjectName("Primary")
|
|
46
|
+
self._connect.setMinimumWidth(96)
|
|
47
|
+
self._connect.clicked.connect(self.toggle)
|
|
48
|
+
|
|
49
|
+
demo = QToolButton()
|
|
50
|
+
demo.setText("Demo")
|
|
51
|
+
demo.setToolTip("Connect to a simulated GRBL machine (no hardware)")
|
|
52
|
+
demo.clicked.connect(self._demo)
|
|
53
|
+
|
|
54
|
+
self._status = QLabel("")
|
|
55
|
+
self._status.setObjectName("Muted")
|
|
56
|
+
|
|
57
|
+
layout = QHBoxLayout(self)
|
|
58
|
+
layout.setContentsMargins(0, 0, 0, 0)
|
|
59
|
+
layout.setSpacing(5)
|
|
60
|
+
layout.addWidget(QLabel("Port"))
|
|
61
|
+
layout.addWidget(self._port)
|
|
62
|
+
layout.addWidget(refresh)
|
|
63
|
+
layout.addWidget(QLabel("Baud"))
|
|
64
|
+
layout.addWidget(self._baud)
|
|
65
|
+
layout.addWidget(self._connect)
|
|
66
|
+
layout.addWidget(demo)
|
|
67
|
+
layout.addWidget(self._status)
|
|
68
|
+
|
|
69
|
+
self.refresh_ports()
|
|
70
|
+
|
|
71
|
+
def refresh_ports(self) -> None:
|
|
72
|
+
current = self._port.currentData()
|
|
73
|
+
self._port.clear()
|
|
74
|
+
ports = MachineController.available_ports()
|
|
75
|
+
for device, description in ports:
|
|
76
|
+
label = device if description == device else f"{device} · {description}"
|
|
77
|
+
self._port.addItem(label, device)
|
|
78
|
+
if not ports:
|
|
79
|
+
self._port.addItem("No USB serial ports found", "")
|
|
80
|
+
if current:
|
|
81
|
+
index = self._port.findData(current)
|
|
82
|
+
if index >= 0:
|
|
83
|
+
self._port.setCurrentIndex(index)
|
|
84
|
+
|
|
85
|
+
def select_port(self, port: str) -> None:
|
|
86
|
+
if not port:
|
|
87
|
+
return
|
|
88
|
+
index = self._port.findData(port)
|
|
89
|
+
if index >= 0:
|
|
90
|
+
self._port.setCurrentIndex(index)
|
|
91
|
+
elif port.upper() != "DEMO":
|
|
92
|
+
self._port.insertItem(0, port, port)
|
|
93
|
+
self._port.setCurrentIndex(0)
|
|
94
|
+
|
|
95
|
+
def baud(self) -> int:
|
|
96
|
+
try:
|
|
97
|
+
return int(self._baud.currentText())
|
|
98
|
+
except ValueError:
|
|
99
|
+
return 115200
|
|
100
|
+
|
|
101
|
+
def set_baud(self, baud: int) -> None:
|
|
102
|
+
self._baud.setCurrentText(str(baud))
|
|
103
|
+
|
|
104
|
+
def toggle(self) -> None:
|
|
105
|
+
if self._connected:
|
|
106
|
+
self._connecting = True
|
|
107
|
+
self._connect.setEnabled(False)
|
|
108
|
+
self._connect.setText("Disconnecting…")
|
|
109
|
+
self.disconnectRequested.emit()
|
|
110
|
+
return
|
|
111
|
+
port = self._port.currentData()
|
|
112
|
+
if not port:
|
|
113
|
+
self._status.setText("No port selected")
|
|
114
|
+
return
|
|
115
|
+
self._connecting = True
|
|
116
|
+
self._connect.setEnabled(False)
|
|
117
|
+
self._connect.setText("Connecting…")
|
|
118
|
+
self.connectRequested.emit(port, self.baud(), False)
|
|
119
|
+
|
|
120
|
+
def _demo(self) -> None:
|
|
121
|
+
self._connecting = True
|
|
122
|
+
self._connect.setEnabled(False)
|
|
123
|
+
self._connect.setText("Connecting…")
|
|
124
|
+
self.connectRequested.emit("DEMO", self.baud(), True)
|
|
125
|
+
|
|
126
|
+
def set_connected(self, connected: bool, port: str = "") -> None:
|
|
127
|
+
self._connected = connected
|
|
128
|
+
self._connecting = False
|
|
129
|
+
self._connect.setEnabled(True)
|
|
130
|
+
self._connect.setText("Disconnect" if connected else "Connect")
|
|
131
|
+
self._status.setText(port if connected else "")
|
|
132
|
+
self._port.setEnabled(not connected)
|
|
133
|
+
self._baud.setEnabled(not connected)
|
|
134
|
+
|
|
135
|
+
def set_error(self, message: str) -> None:
|
|
136
|
+
self._connecting = False
|
|
137
|
+
self._connect.setEnabled(True)
|
|
138
|
+
self._connect.setText("Connect")
|
|
139
|
+
self._status.setText(message)
|
plotdeck/ui/console.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Serial console with colour-coded traffic and command history."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from PySide6.QtCore import Qt, Signal
|
|
6
|
+
from PySide6.QtGui import QColor, QKeyEvent, QTextCharFormat, QTextCursor
|
|
7
|
+
from PySide6.QtWidgets import (
|
|
8
|
+
QCheckBox,
|
|
9
|
+
QHBoxLayout,
|
|
10
|
+
QLabel,
|
|
11
|
+
QLineEdit,
|
|
12
|
+
QPlainTextEdit,
|
|
13
|
+
QPushButton,
|
|
14
|
+
QVBoxLayout,
|
|
15
|
+
QWidget,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
from ..core.grbl_protocol import ResponseKind
|
|
19
|
+
from .theme import MONO_FONT, tokens
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ConsoleInput(QLineEdit):
|
|
23
|
+
submitted = Signal(str)
|
|
24
|
+
|
|
25
|
+
def __init__(self, parent=None) -> None: # noqa: ANN001
|
|
26
|
+
super().__init__(parent)
|
|
27
|
+
self._history: list[str] = []
|
|
28
|
+
self._index = 0
|
|
29
|
+
self.setPlaceholderText("Send a command (↑/↓ for history)")
|
|
30
|
+
self.setClearButtonEnabled(True)
|
|
31
|
+
self.returnPressed.connect(self._submit)
|
|
32
|
+
|
|
33
|
+
def _submit(self) -> None:
|
|
34
|
+
text = self.text().strip()
|
|
35
|
+
if not text:
|
|
36
|
+
return
|
|
37
|
+
self._history.append(text)
|
|
38
|
+
self._history = self._history[-200:]
|
|
39
|
+
self._index = len(self._history)
|
|
40
|
+
self.submitted.emit(text)
|
|
41
|
+
self.clear()
|
|
42
|
+
|
|
43
|
+
def keyPressEvent(self, event: QKeyEvent) -> None: # noqa: N802
|
|
44
|
+
if event.key() == Qt.Key.Key_Up and self._history:
|
|
45
|
+
self._index = max(0, self._index - 1)
|
|
46
|
+
self.setText(self._history[self._index])
|
|
47
|
+
return
|
|
48
|
+
if event.key() == Qt.Key.Key_Down and self._history:
|
|
49
|
+
self._index = min(len(self._history), self._index + 1)
|
|
50
|
+
self.setText("" if self._index == len(self._history) else self._history[self._index])
|
|
51
|
+
return
|
|
52
|
+
super().keyPressEvent(event)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Console(QWidget):
|
|
56
|
+
commandSubmitted = Signal(str)
|
|
57
|
+
|
|
58
|
+
def __init__(self, token: dict[str, str] | None = None, parent=None) -> None: # noqa: ANN001
|
|
59
|
+
super().__init__(parent)
|
|
60
|
+
self._token = token or tokens()
|
|
61
|
+
self._hide_status = True
|
|
62
|
+
self.setObjectName("Panel")
|
|
63
|
+
|
|
64
|
+
self._view = QPlainTextEdit()
|
|
65
|
+
self._view.setReadOnly(True)
|
|
66
|
+
self._view.setMaximumBlockCount(5000)
|
|
67
|
+
self._view.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap)
|
|
68
|
+
font = self._view.font()
|
|
69
|
+
font.setFamily(MONO_FONT.split(",")[0].replace('"', "").strip())
|
|
70
|
+
font.setPointSize(12)
|
|
71
|
+
self._view.setFont(font)
|
|
72
|
+
|
|
73
|
+
self._input = ConsoleInput()
|
|
74
|
+
self._input.submitted.connect(self._on_submit)
|
|
75
|
+
self._autoscroll = QCheckBox("Autoscroll")
|
|
76
|
+
self._autoscroll.setChecked(True)
|
|
77
|
+
self._hide_polls = QCheckBox("Hide status polls")
|
|
78
|
+
self._hide_polls.setChecked(True)
|
|
79
|
+
self._hide_polls.toggled.connect(self._set_hide_status)
|
|
80
|
+
clear = QPushButton("Clear")
|
|
81
|
+
clear.clicked.connect(self._view.clear)
|
|
82
|
+
self._count_label = QLabel("")
|
|
83
|
+
self._count_label.setObjectName("Muted")
|
|
84
|
+
|
|
85
|
+
controls = QHBoxLayout()
|
|
86
|
+
controls.setContentsMargins(0, 0, 0, 0)
|
|
87
|
+
controls.addWidget(self._autoscroll)
|
|
88
|
+
controls.addWidget(self._hide_polls)
|
|
89
|
+
controls.addWidget(self._count_label, 1)
|
|
90
|
+
controls.addWidget(clear)
|
|
91
|
+
|
|
92
|
+
layout = QVBoxLayout(self)
|
|
93
|
+
layout.setContentsMargins(0, 0, 0, 0)
|
|
94
|
+
layout.setSpacing(4)
|
|
95
|
+
layout.addLayout(controls)
|
|
96
|
+
layout.addWidget(self._view, 1)
|
|
97
|
+
layout.addWidget(self._input)
|
|
98
|
+
|
|
99
|
+
self._formats = {
|
|
100
|
+
"sent": self._format(self._token["accent"]),
|
|
101
|
+
"ok": self._format(self._token["idle"]),
|
|
102
|
+
"error": self._format(self._token["alarm"], bold=True),
|
|
103
|
+
"alarm": self._format(self._token["alarm"], bold=True),
|
|
104
|
+
"message": self._format(self._token["muted"]),
|
|
105
|
+
"status": self._format(self._token["muted"]),
|
|
106
|
+
"other": self._format(self._token["text"]),
|
|
107
|
+
"banner": self._format(self._token["hold"], bold=True),
|
|
108
|
+
}
|
|
109
|
+
self._line_count = 0
|
|
110
|
+
|
|
111
|
+
@staticmethod
|
|
112
|
+
def _format(color: str, bold: bool = False) -> QTextCharFormat:
|
|
113
|
+
style = QTextCharFormat()
|
|
114
|
+
style.setForeground(QColor(color))
|
|
115
|
+
if bold:
|
|
116
|
+
style.setFontWeight(700)
|
|
117
|
+
return style
|
|
118
|
+
|
|
119
|
+
def set_token(self, token: dict[str, str]) -> None:
|
|
120
|
+
self._token = token
|
|
121
|
+
self._formats.update(
|
|
122
|
+
{
|
|
123
|
+
"sent": self._format(token["accent"]),
|
|
124
|
+
"ok": self._format(token["idle"]),
|
|
125
|
+
"error": self._format(token["alarm"], bold=True),
|
|
126
|
+
"alarm": self._format(token["alarm"], bold=True),
|
|
127
|
+
"message": self._format(token["muted"]),
|
|
128
|
+
"status": self._format(token["muted"]),
|
|
129
|
+
"other": self._format(token["text"]),
|
|
130
|
+
"banner": self._format(token["hold"], bold=True),
|
|
131
|
+
}
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
def _set_hide_status(self, value: bool) -> None:
|
|
135
|
+
self._hide_status = value
|
|
136
|
+
|
|
137
|
+
def append(self, kind: ResponseKind | str, text: str) -> None:
|
|
138
|
+
key = kind.value if isinstance(kind, ResponseKind) else kind
|
|
139
|
+
if key == "status" and self._hide_status:
|
|
140
|
+
return
|
|
141
|
+
if key == "other" and not text.strip():
|
|
142
|
+
return
|
|
143
|
+
if key == "banner":
|
|
144
|
+
text = text or "(reset)"
|
|
145
|
+
prefix = ">" if key == "sent" else "<"
|
|
146
|
+
self._insert(f"{prefix} {text}", key)
|
|
147
|
+
self._line_count += 1
|
|
148
|
+
if self._line_count % 5 == 0:
|
|
149
|
+
self._count_label.setText(f"{self._line_count} lines")
|
|
150
|
+
|
|
151
|
+
def _insert(self, text: str, key: str) -> None:
|
|
152
|
+
bar = self._view.verticalScrollBar()
|
|
153
|
+
at_bottom = bar.value() >= bar.maximum() - 4
|
|
154
|
+
cursor = self._view.textCursor()
|
|
155
|
+
cursor.movePosition(QTextCursor.MoveOperation.End)
|
|
156
|
+
cursor.insertText(text + "\n", self._formats.get(key, self._formats["other"]))
|
|
157
|
+
if self._autoscroll.isChecked() or at_bottom:
|
|
158
|
+
bar.setValue(bar.maximum())
|
|
159
|
+
|
|
160
|
+
def _on_submit(self, text: str) -> None:
|
|
161
|
+
self.append("sent", text)
|
|
162
|
+
self.commandSubmitted.emit(text)
|
|
163
|
+
|
|
164
|
+
def set_enabled(self, enabled: bool) -> None:
|
|
165
|
+
self._input.setEnabled(enabled)
|
|
166
|
+
|
|
167
|
+
def focus_input(self) -> None:
|
|
168
|
+
self._input.setFocus()
|
|
169
|
+
|
|
170
|
+
def log(self, text: str) -> None:
|
|
171
|
+
self.append("message", text)
|