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/ui/manual.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""In-app user manual rendered from USER_MANUAL.md."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from PySide6.QtCore import QUrl
|
|
9
|
+
from PySide6.QtGui import (
|
|
10
|
+
QColor,
|
|
11
|
+
QDesktopServices,
|
|
12
|
+
QPalette,
|
|
13
|
+
QTextCursor,
|
|
14
|
+
QTextDocument,
|
|
15
|
+
)
|
|
16
|
+
from PySide6.QtWidgets import (
|
|
17
|
+
QDialog,
|
|
18
|
+
QHBoxLayout,
|
|
19
|
+
QLineEdit,
|
|
20
|
+
QPushButton,
|
|
21
|
+
QTextBrowser,
|
|
22
|
+
QVBoxLayout,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
from .theme import tokens
|
|
26
|
+
|
|
27
|
+
MISSING_MANUAL = """# PlotDeck User Manual
|
|
28
|
+
|
|
29
|
+
The manual file (`USER_MANUAL.md`) could not be found in this installation.
|
|
30
|
+
|
|
31
|
+
From a source checkout, it lives in the project root next to `README.md`.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def manual_path() -> Path | None:
|
|
36
|
+
"""Find USER_MANUAL.md in a source checkout or a frozen application."""
|
|
37
|
+
candidates: list[Path] = []
|
|
38
|
+
bundle = getattr(sys, "_MEIPASS", None)
|
|
39
|
+
if bundle:
|
|
40
|
+
candidates.append(Path(bundle) / "USER_MANUAL.md")
|
|
41
|
+
candidates.append(Path(__file__).resolve().parents[3] / "USER_MANUAL.md")
|
|
42
|
+
candidates.append(Path(__file__).resolve().parent / "resources" / "USER_MANUAL.md")
|
|
43
|
+
candidates.append(Path.cwd() / "USER_MANUAL.md")
|
|
44
|
+
for candidate in candidates:
|
|
45
|
+
if candidate.is_file():
|
|
46
|
+
return candidate
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def manual_text() -> str:
|
|
51
|
+
path = manual_path()
|
|
52
|
+
if path is None:
|
|
53
|
+
return MISSING_MANUAL
|
|
54
|
+
try:
|
|
55
|
+
return path.read_text(encoding="utf-8", errors="replace")
|
|
56
|
+
except OSError:
|
|
57
|
+
return MISSING_MANUAL
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class ManualDialog(QDialog):
|
|
61
|
+
"""Non-modal, searchable viewer for the user manual."""
|
|
62
|
+
|
|
63
|
+
def __init__(self, token: dict[str, str] | None = None, parent=None) -> None: # noqa: ANN001
|
|
64
|
+
super().__init__(parent)
|
|
65
|
+
self._token = token or tokens()
|
|
66
|
+
self.setWindowTitle("PlotDeck User Manual")
|
|
67
|
+
self.resize(940, 760)
|
|
68
|
+
self.setMinimumSize(620, 480)
|
|
69
|
+
|
|
70
|
+
self.browser = QTextBrowser()
|
|
71
|
+
self.browser.setOpenExternalLinks(False)
|
|
72
|
+
self.browser.setOpenLinks(False)
|
|
73
|
+
self.browser.anchorClicked.connect(self._open_link)
|
|
74
|
+
self.browser.setMarkdown(manual_text())
|
|
75
|
+
self.browser.setStyleSheet(
|
|
76
|
+
f"QTextBrowser {{ background: {self._token['editor']};"
|
|
77
|
+
f" color: {self._token['text']};"
|
|
78
|
+
f" border: 1px solid {self._token['border']}; border-radius: 6px;"
|
|
79
|
+
" padding: 10px; }"
|
|
80
|
+
)
|
|
81
|
+
palette = self.browser.palette()
|
|
82
|
+
palette.setColor(QPalette.ColorRole.Link, QColor(self._token["accent"]))
|
|
83
|
+
self.browser.setPalette(palette)
|
|
84
|
+
self._search = QLineEdit()
|
|
85
|
+
self._search.setPlaceholderText("Find in manual…")
|
|
86
|
+
self._search.setClearButtonEnabled(True)
|
|
87
|
+
self._search.returnPressed.connect(self._find_next)
|
|
88
|
+
previous = QPushButton("Previous")
|
|
89
|
+
next_button = QPushButton("Next")
|
|
90
|
+
previous.clicked.connect(self._find_previous)
|
|
91
|
+
next_button.clicked.connect(self._find_next)
|
|
92
|
+
|
|
93
|
+
top = QHBoxLayout()
|
|
94
|
+
top.addWidget(self._search, 1)
|
|
95
|
+
top.addWidget(previous)
|
|
96
|
+
top.addWidget(next_button)
|
|
97
|
+
|
|
98
|
+
close = QPushButton("Close")
|
|
99
|
+
close.clicked.connect(self.accept)
|
|
100
|
+
open_external = QPushButton("Open in browser")
|
|
101
|
+
open_external.setToolTip("Open the markdown file in your default Markdown viewer")
|
|
102
|
+
open_external.clicked.connect(self._open_external)
|
|
103
|
+
|
|
104
|
+
bottom = QHBoxLayout()
|
|
105
|
+
bottom.addStretch(1)
|
|
106
|
+
bottom.addWidget(open_external)
|
|
107
|
+
bottom.addWidget(close)
|
|
108
|
+
|
|
109
|
+
layout = QVBoxLayout(self)
|
|
110
|
+
layout.setContentsMargins(12, 12, 12, 10)
|
|
111
|
+
layout.setSpacing(8)
|
|
112
|
+
layout.addLayout(top)
|
|
113
|
+
layout.addWidget(self.browser, 1)
|
|
114
|
+
layout.addLayout(bottom)
|
|
115
|
+
|
|
116
|
+
# ------------------------------------------------------------------ #
|
|
117
|
+
def _find_next(self) -> None:
|
|
118
|
+
text = self._search.text()
|
|
119
|
+
if not text:
|
|
120
|
+
return
|
|
121
|
+
if not self.browser.find(text):
|
|
122
|
+
self.browser.moveCursor(QTextCursor.MoveOperation.Start)
|
|
123
|
+
if not self.browser.find(text):
|
|
124
|
+
self._search.setStyleSheet(f"color: {self._token['alarm']};")
|
|
125
|
+
return
|
|
126
|
+
self._search.setStyleSheet("")
|
|
127
|
+
|
|
128
|
+
def _find_previous(self) -> None:
|
|
129
|
+
text = self._search.text()
|
|
130
|
+
if not text:
|
|
131
|
+
return
|
|
132
|
+
if not self.browser.find(text, QTextDocument.FindFlag.FindBackward):
|
|
133
|
+
cursor = self.browser.textCursor()
|
|
134
|
+
cursor.movePosition(QTextCursor.MoveOperation.End)
|
|
135
|
+
self.browser.setTextCursor(cursor)
|
|
136
|
+
self.browser.find(text, QTextDocument.FindFlag.FindBackward)
|
|
137
|
+
|
|
138
|
+
def _open_link(self, url: QUrl) -> None:
|
|
139
|
+
anchor = url.toString()
|
|
140
|
+
if anchor.startswith("#"):
|
|
141
|
+
self.browser.scrollToAnchor(anchor[1:])
|
|
142
|
+
return
|
|
143
|
+
if url.scheme() in ("http", "https", "mailto"):
|
|
144
|
+
QDesktopServices.openUrl(url)
|
|
145
|
+
return
|
|
146
|
+
# Relative links such as README.md live next to the manual.
|
|
147
|
+
path = manual_path()
|
|
148
|
+
if path is not None:
|
|
149
|
+
target = path.parent / url.toLocalFile() if url.isLocalFile() else path.parent / anchor
|
|
150
|
+
if target.is_file():
|
|
151
|
+
QDesktopServices.openUrl(QUrl.fromLocalFile(str(target)))
|
|
152
|
+
|
|
153
|
+
def _open_external(self) -> None:
|
|
154
|
+
path = manual_path()
|
|
155
|
+
if path is not None:
|
|
156
|
+
QDesktopServices.openUrl(QUrl.fromLocalFile(str(path)))
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""Position readout, state badge and override controls."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from PySide6.QtCore import Qt, Signal
|
|
6
|
+
from PySide6.QtWidgets import (
|
|
7
|
+
QComboBox,
|
|
8
|
+
QHBoxLayout,
|
|
9
|
+
QLabel,
|
|
10
|
+
QPushButton,
|
|
11
|
+
QSlider,
|
|
12
|
+
QVBoxLayout,
|
|
13
|
+
QWidget,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
from ..core.grbl_protocol import state_family
|
|
17
|
+
from ..core.status import MachineStatus
|
|
18
|
+
from .theme import state_color, tokens
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class StateBadge(QLabel):
|
|
22
|
+
def __init__(self, token: dict[str, str]) -> None:
|
|
23
|
+
super().__init__("OFFLINE")
|
|
24
|
+
self._token = token
|
|
25
|
+
self._state = "Unknown"
|
|
26
|
+
self._connected = False
|
|
27
|
+
self.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
28
|
+
self.setMinimumWidth(86)
|
|
29
|
+
self.set_state("Unknown", False)
|
|
30
|
+
|
|
31
|
+
def refresh(self) -> None:
|
|
32
|
+
self.set_state(self._state, self._connected)
|
|
33
|
+
|
|
34
|
+
def set_state(self, state: str, connected: bool = True) -> None:
|
|
35
|
+
self._state = state
|
|
36
|
+
self._connected = connected
|
|
37
|
+
family = state_family(state) if connected else "offline"
|
|
38
|
+
color = state_color(self._token, family)
|
|
39
|
+
label = state.split(":")[0].upper() if connected else "OFFLINE"
|
|
40
|
+
if label == "RUN":
|
|
41
|
+
label = "RUN"
|
|
42
|
+
self.setText(label)
|
|
43
|
+
self.setStyleSheet(
|
|
44
|
+
f"background: {color}; color: white; border-radius: 6px;"
|
|
45
|
+
"padding: 4px 10px; font-weight: 700; font-size: 11px;"
|
|
46
|
+
)
|
|
47
|
+
self.setToolTip(state if connected else "Not connected")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class DroRow(QWidget):
|
|
51
|
+
zeroRequested = Signal(str)
|
|
52
|
+
|
|
53
|
+
def __init__(self, axis: str, token: dict[str, str], parent=None) -> None: # noqa: ANN001
|
|
54
|
+
super().__init__(parent)
|
|
55
|
+
self._axis = axis
|
|
56
|
+
self._token = token
|
|
57
|
+
axis_label = QLabel(axis)
|
|
58
|
+
axis_label.setObjectName("DroAxis")
|
|
59
|
+
axis_label.setFixedWidth(18)
|
|
60
|
+
self._value = QLabel("0.000")
|
|
61
|
+
self._value.setObjectName("DroValue")
|
|
62
|
+
self._secondary = QLabel("MPos 0.000")
|
|
63
|
+
self._secondary.setObjectName("DroSecondary")
|
|
64
|
+
zero = QPushButton("0")
|
|
65
|
+
self._zero = zero
|
|
66
|
+
zero.setFixedSize(24, 24)
|
|
67
|
+
zero.setToolTip(f"Zero {axis}")
|
|
68
|
+
zero.clicked.connect(lambda: self.zeroRequested.emit(self._axis))
|
|
69
|
+
|
|
70
|
+
layout = QHBoxLayout(self)
|
|
71
|
+
layout.setContentsMargins(0, 2, 0, 2)
|
|
72
|
+
layout.setSpacing(6)
|
|
73
|
+
layout.addWidget(axis_label)
|
|
74
|
+
layout.addWidget(self._value, 1)
|
|
75
|
+
text_column = QVBoxLayout()
|
|
76
|
+
text_column.setSpacing(0)
|
|
77
|
+
text_column.addWidget(self._secondary)
|
|
78
|
+
layout.addLayout(text_column)
|
|
79
|
+
layout.addWidget(zero)
|
|
80
|
+
|
|
81
|
+
def set_zero_enabled(self, enabled: bool) -> bool:
|
|
82
|
+
self._zero.setEnabled(enabled)
|
|
83
|
+
return enabled
|
|
84
|
+
|
|
85
|
+
def set_values(self, work: float | None, machine: float | None, units: str = "mm") -> None:
|
|
86
|
+
work_text = "---" if work is None else f"{work:.3f}"
|
|
87
|
+
machine_text = "---" if machine is None else f"{machine:.3f}"
|
|
88
|
+
self._value.setText(work_text)
|
|
89
|
+
self._secondary.setText(f"MPos {machine_text} {units}")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class OverrideSlider(QWidget):
|
|
93
|
+
valueChanged = Signal(int)
|
|
94
|
+
|
|
95
|
+
def __init__(self, label: str, minimum: int, maximum: int, token: dict[str, str]) -> None:
|
|
96
|
+
super().__init__()
|
|
97
|
+
self._label = QLabel(label)
|
|
98
|
+
self._label.setObjectName("Muted")
|
|
99
|
+
self._label.setFixedWidth(56)
|
|
100
|
+
self._slider = QSlider(Qt.Orientation.Horizontal)
|
|
101
|
+
self._slider.setRange(minimum, maximum)
|
|
102
|
+
self._slider.setValue(100)
|
|
103
|
+
self._value = QLabel("100%")
|
|
104
|
+
self._value.setFixedWidth(42)
|
|
105
|
+
self._value.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
|
|
106
|
+
self._token = token
|
|
107
|
+
layout = QHBoxLayout(self)
|
|
108
|
+
layout.setContentsMargins(0, 1, 0, 1)
|
|
109
|
+
layout.setSpacing(6)
|
|
110
|
+
layout.addWidget(self._label)
|
|
111
|
+
layout.addWidget(self._slider, 1)
|
|
112
|
+
layout.addWidget(self._value)
|
|
113
|
+
self._slider.valueChanged.connect(self._on_change)
|
|
114
|
+
self._slider.sliderReleased.connect(self._emit_value)
|
|
115
|
+
|
|
116
|
+
def _on_change(self, value: int) -> None:
|
|
117
|
+
self._value.setText(f"{value}%")
|
|
118
|
+
|
|
119
|
+
def _emit_value(self) -> None:
|
|
120
|
+
self.valueChanged.emit(self._slider.value())
|
|
121
|
+
|
|
122
|
+
def reset(self) -> None:
|
|
123
|
+
self._slider.setValue(100)
|
|
124
|
+
|
|
125
|
+
def value(self) -> int:
|
|
126
|
+
return self._slider.value()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class StatusPanel(QWidget):
|
|
130
|
+
zeroRequested = Signal(str)
|
|
131
|
+
feedOverrideChanged = Signal(int)
|
|
132
|
+
spindleOverrideChanged = Signal(int)
|
|
133
|
+
rapidOverrideChanged = Signal(int)
|
|
134
|
+
|
|
135
|
+
def __init__(self, token: dict[str, str] | None = None, parent=None) -> None: # noqa: ANN001
|
|
136
|
+
super().__init__(parent)
|
|
137
|
+
self._token = token or tokens()
|
|
138
|
+
self.setObjectName("Panel")
|
|
139
|
+
|
|
140
|
+
self.badge = StateBadge(self._token)
|
|
141
|
+
header = QHBoxLayout()
|
|
142
|
+
header.addWidget(self.badge)
|
|
143
|
+
self._firmware = QLabel("not connected")
|
|
144
|
+
self._firmware.setObjectName("Muted")
|
|
145
|
+
header.addWidget(self._firmware, 1, Qt.AlignmentFlag.AlignRight)
|
|
146
|
+
|
|
147
|
+
self.rows = {axis: DroRow(axis, self._token) for axis in ("X", "Y", "Z")}
|
|
148
|
+
for row in self.rows.values():
|
|
149
|
+
row.zeroRequested.connect(self.zeroRequested)
|
|
150
|
+
|
|
151
|
+
self._feed = QLabel("F 0")
|
|
152
|
+
self._feed.setObjectName("Muted")
|
|
153
|
+
self._spindle = QLabel("S 0")
|
|
154
|
+
self._spindle.setObjectName("Muted")
|
|
155
|
+
self._buffer = QLabel("Bf 0/127")
|
|
156
|
+
self._buffer.setObjectName("Muted")
|
|
157
|
+
footer = QHBoxLayout()
|
|
158
|
+
footer.addWidget(self._feed)
|
|
159
|
+
footer.addWidget(self._spindle)
|
|
160
|
+
footer.addStretch(1)
|
|
161
|
+
footer.addWidget(self._buffer)
|
|
162
|
+
|
|
163
|
+
self.feed_override = OverrideSlider("Feed", 10, 200, self._token)
|
|
164
|
+
self.spindle_override = OverrideSlider("Spindle", 10, 200, self._token)
|
|
165
|
+
self._rapid = QComboBox()
|
|
166
|
+
self._rapid.addItems(["100%", "50%", "25%"])
|
|
167
|
+
rapid_row = QHBoxLayout()
|
|
168
|
+
rapid_label = QLabel("Rapid")
|
|
169
|
+
rapid_label.setObjectName("Muted")
|
|
170
|
+
rapid_label.setFixedWidth(56)
|
|
171
|
+
rapid_row.addWidget(rapid_label)
|
|
172
|
+
rapid_row.addWidget(self._rapid, 1)
|
|
173
|
+
|
|
174
|
+
overrides_header = QLabel("OVERRIDES")
|
|
175
|
+
overrides_header.setObjectName("SectionHeader")
|
|
176
|
+
|
|
177
|
+
self.feed_override.valueChanged.connect(self.feedOverrideChanged)
|
|
178
|
+
self.spindle_override.valueChanged.connect(self.spindleOverrideChanged)
|
|
179
|
+
self._rapid.currentIndexChanged.connect(self._rapid_changed)
|
|
180
|
+
|
|
181
|
+
layout = QVBoxLayout(self)
|
|
182
|
+
layout.setContentsMargins(10, 8, 10, 8)
|
|
183
|
+
layout.setSpacing(4)
|
|
184
|
+
layout.addLayout(header)
|
|
185
|
+
position_header = QLabel("POSITION")
|
|
186
|
+
position_header.setObjectName("SectionHeader")
|
|
187
|
+
layout.addSpacing(4)
|
|
188
|
+
layout.addWidget(position_header)
|
|
189
|
+
for row in self.rows.values():
|
|
190
|
+
layout.addWidget(row)
|
|
191
|
+
layout.addLayout(footer)
|
|
192
|
+
layout.addSpacing(8)
|
|
193
|
+
layout.addWidget(overrides_header)
|
|
194
|
+
layout.addWidget(self.feed_override)
|
|
195
|
+
layout.addWidget(self.spindle_override)
|
|
196
|
+
layout.addLayout(rapid_row)
|
|
197
|
+
layout.addStretch(1)
|
|
198
|
+
|
|
199
|
+
def set_token(self, token: dict[str, str]) -> None:
|
|
200
|
+
self._token = token
|
|
201
|
+
self.badge._token = token
|
|
202
|
+
self.badge.refresh()
|
|
203
|
+
|
|
204
|
+
def set_interactive(self, connected: bool, family: str, job_active: bool) -> None:
|
|
205
|
+
overrides = connected and family in ("idle", "run", "hold")
|
|
206
|
+
self.feed_override.setEnabled(overrides)
|
|
207
|
+
self.spindle_override.setEnabled(overrides)
|
|
208
|
+
self._rapid.setEnabled(overrides)
|
|
209
|
+
zero = connected and family == "idle" and not job_active
|
|
210
|
+
for row in self.rows.values():
|
|
211
|
+
row.set_zero_enabled(zero)
|
|
212
|
+
|
|
213
|
+
def _rapid_changed(self, index: int) -> None:
|
|
214
|
+
self.rapidOverrideChanged.emit((100, 50, 25)[index])
|
|
215
|
+
|
|
216
|
+
def update_status(self, status: MachineStatus, units: str = "mm") -> None:
|
|
217
|
+
self.badge.set_state(status.state, True)
|
|
218
|
+
for index, axis in enumerate(("X", "Y", "Z")):
|
|
219
|
+
work = status.wpos[index] if status.wpos else None
|
|
220
|
+
machine = status.mpos[index] if status.mpos else None
|
|
221
|
+
self.rows[axis].set_values(work, machine, units)
|
|
222
|
+
self._feed.setText(f"F {status.feed:.0f}" if status.feed is not None else "F --")
|
|
223
|
+
self._spindle.setText(f"S {status.spindle:.0f}" if status.spindle is not None else "S --")
|
|
224
|
+
if status.buffer:
|
|
225
|
+
self._buffer.setText(f"Bf {status.buffer[0]}/{status.buffer[1]}")
|
|
226
|
+
self._buffer.setToolTip(
|
|
227
|
+
f"RX buffer {status.buffer[0]} of {status.buffer[1]} bytes used"
|
|
228
|
+
)
|
|
229
|
+
else:
|
|
230
|
+
self._buffer.setText("Bf --")
|
|
231
|
+
if status.pins:
|
|
232
|
+
self._buffer.setToolTip(f"RX buffer | triggered pins: {status.pins}")
|
|
233
|
+
|
|
234
|
+
def set_connected(self, connected: bool, firmware: str = "") -> None:
|
|
235
|
+
if not connected:
|
|
236
|
+
self.badge.set_state("Unknown", False)
|
|
237
|
+
self._firmware.setText("not connected")
|
|
238
|
+
for row in self.rows.values():
|
|
239
|
+
row.set_values(None, None)
|
|
240
|
+
self._feed.setText("F --")
|
|
241
|
+
self._spindle.setText("S --")
|
|
242
|
+
self._buffer.setText("Bf --")
|
|
243
|
+
else:
|
|
244
|
+
self._firmware.setText(f"GRBL {firmware}" if firmware else "GRBL")
|