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.
@@ -0,0 +1,42 @@
1
+ """Logging configuration: rotating file plus console."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import sys
7
+ from logging.handlers import RotatingFileHandler
8
+ from pathlib import Path
9
+
10
+
11
+ def log_directory() -> Path:
12
+ path = Path.home() / "Library" / "Logs" / "PlotDeck"
13
+ path.mkdir(parents=True, exist_ok=True)
14
+ return path
15
+
16
+
17
+ def setup_logging(level: int = logging.INFO) -> logging.Logger:
18
+ logger = logging.getLogger("plotdeck")
19
+ if logger.handlers:
20
+ return logger
21
+ logger.setLevel(level)
22
+ formatter = logging.Formatter(
23
+ "%(asctime)s %(levelname)-7s %(name)s: %(message)s", "%Y-%m-%d %H:%M:%S"
24
+ )
25
+
26
+ handler: logging.Handler
27
+ try:
28
+ handler = RotatingFileHandler(
29
+ log_directory() / "plotdeck.log", maxBytes=1_000_000, backupCount=3
30
+ )
31
+ except OSError:
32
+ handler = logging.StreamHandler(sys.stderr)
33
+ handler.setFormatter(formatter)
34
+ logger.addHandler(handler)
35
+
36
+ if sys.stderr is not None:
37
+ console = logging.StreamHandler(sys.stderr)
38
+ console.setFormatter(formatter)
39
+ console.setLevel(logging.WARNING)
40
+ logger.addHandler(console)
41
+
42
+ return logger
@@ -0,0 +1,86 @@
1
+ """Persistent application settings backed by QSettings."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from PySide6.QtCore import QByteArray, QSettings
6
+
7
+
8
+ class AppSettings:
9
+ def __init__(self, path: str | None = None) -> None:
10
+ if path:
11
+ self._settings = QSettings(path, QSettings.Format.IniFormat)
12
+ else:
13
+ self._settings = QSettings("PlotDeck", "PlotDeck")
14
+
15
+ def value(self, key: str, default=None, kind=None): # noqa: ANN001, ANN201
16
+ if kind is not None:
17
+ return self._settings.value(key, default, type=kind)
18
+ return self._settings.value(key, default)
19
+
20
+ def set_value(self, key: str, value) -> None: # noqa: ANN001
21
+ self._settings.setValue(key, value)
22
+
23
+ def sync(self) -> None:
24
+ self._settings.sync()
25
+
26
+ # -- recent files --------------------------------------------------- #
27
+ def recent_files(self) -> list[str]:
28
+ value: object = self._settings.value("files/recent", [])
29
+ if isinstance(value, str):
30
+ value = [value]
31
+ if not isinstance(value, (list, tuple)):
32
+ return []
33
+ return [str(item) for item in value if item]
34
+
35
+ def add_recent_file(self, path: str) -> None:
36
+ files = [item for item in self.recent_files() if item != path]
37
+ files.insert(0, path)
38
+ self._settings.setValue("files/recent", files[:8])
39
+
40
+ def clear_recent_files(self) -> None:
41
+ self._settings.setValue("files/recent", [])
42
+
43
+ # -- window --------------------------------------------------------- #
44
+ def geometry(self) -> QByteArray | None:
45
+ value = self._settings.value("window/geometry")
46
+ return value if isinstance(value, QByteArray) else None
47
+
48
+ def save_geometry(self, geometry: QByteArray) -> None:
49
+ self._settings.setValue("window/geometry", geometry)
50
+
51
+ def window_state(self) -> QByteArray | None:
52
+ value = self._settings.value("window/state")
53
+ return value if isinstance(value, QByteArray) else None
54
+
55
+ def save_window_state(self, state: QByteArray) -> None:
56
+ self._settings.setValue("window/state", state)
57
+
58
+ # -- preferences ---------------------------------------------------- #
59
+ def dark_theme(self, fallback: bool = True) -> bool:
60
+ value = self._settings.value("ui/dark", fallback)
61
+ return str(value).lower() in ("1", "true", "yes")
62
+
63
+ def set_dark_theme(self, dark: bool) -> None:
64
+ self._settings.setValue("ui/dark", dark)
65
+
66
+ def last_port(self) -> str:
67
+ return str(self._settings.value("machine/port", ""))
68
+
69
+ def set_last_port(self, port: str) -> None:
70
+ self._settings.setValue("machine/port", port)
71
+
72
+ def last_baud(self) -> int:
73
+ try:
74
+ return int(str(self._settings.value("machine/baud", 115200)))
75
+ except (TypeError, ValueError):
76
+ return 115200
77
+
78
+ def set_last_baud(self, baud: int) -> None:
79
+ self._settings.setValue("machine/baud", baud)
80
+
81
+ def auto_reconnect(self) -> bool:
82
+ return str(self._settings.value("machine/auto_reconnect", False)).lower() in (
83
+ "1",
84
+ "true",
85
+ "yes",
86
+ )
@@ -0,0 +1,227 @@
1
+ Metadata-Version: 2.4
2
+ Name: plotdeck
3
+ Version: 1.0.0
4
+ Summary: G-code sender and editor for GRBL-based CNC routers
5
+ License: MIT
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: PySide6>=6.6
9
+ Requires-Dist: pyserial>=3.5
10
+ Requires-Dist: pyqtgraph>=0.13
11
+ Requires-Dist: numpy>=1.24
12
+ Provides-Extra: 3d
13
+ Requires-Dist: PyOpenGL>=3.1; extra == "3d"
14
+ Provides-Extra: dev
15
+ Requires-Dist: pytest>=8; extra == "dev"
16
+ Requires-Dist: pytest-qt>=4.4; extra == "dev"
17
+ Requires-Dist: ruff>=0.5; extra == "dev"
18
+ Requires-Dist: black>=24; extra == "dev"
19
+ Requires-Dist: mypy>=1.10; extra == "dev"
20
+ Requires-Dist: pyinstaller>=6; extra == "dev"
21
+
22
+ # PlotDeck
23
+
24
+ A G-code sender and editor for small CNC routers running **GRBL 1.1** on an
25
+ Arduino Uno. macOS desktop app, Python + PySide6.
26
+
27
+ PlotDeck connects over USB serial, lets you edit and preview a G-code file,
28
+ streams it with GRBL's character-counting flow control, and gives you manual
29
+ control (jog, home, zero, overrides) with live position and toolpath feedback.
30
+
31
+ **New here? Read [USER_MANUAL.md](USER_MANUAL.md), or open it inside the app
32
+ from the Manual toolbar button or Help → User Manual (F1).**
33
+
34
+ ![PlotDeck screenshot](images/PlotDeck.png)
35
+
36
+ ## Features
37
+
38
+ - **Connection** — USB port discovery, 115200 baud default, GRBL banner
39
+ detection (the Uno resets when the port opens), 5 Hz status polling, and a
40
+ **Demo** mode that runs a simulated machine with no hardware.
41
+ - **Streaming** — 127-byte character-counting sender, pause/resume, safe stop
42
+ (feed hold + soft reset), start from a line, and GRBL check mode (`$C`) dry
43
+ runs.
44
+ - **Editor** — line numbers, G/M/axis/feed syntax colours, current-line and
45
+ executed-line markers, error markers, find/replace, go-to-line, toggle
46
+ comment, strip comments, renumber, and offset/scale/rotate transforms.
47
+ - **Visualizer** — 2D toolpath with executed (green) and remaining (dashed)
48
+ paths, live tool position, scroll/button zoom, pan, fit-to-view, bounding
49
+ box and time estimate. Optional 3D view with orbit and zoom when PyOpenGL
50
+ is installed.
51
+ - **Machine control** — jog pad with step chips and keyboard control, work
52
+ zeroing per axis, G54–G59 selection, homing, unlock, jog cancel, feed /
53
+ rapid / spindle overrides, spindle on/off, and a Z-probe wizard.
54
+ - **Safety workflows** — pre-flight summary (time, bounds, depth, tools,
55
+ warnings), `M6` interception with a tool-change pause, `error:N` pauses the
56
+ job instead of blindly continuing, alarm dialog with unlock/home recovery,
57
+ and automatic job-interrupted reporting on disconnect.
58
+ - **Settings** — editable `$$` table with descriptions, plus persistence of
59
+ recent files, port, theme and window layout via QSettings.
60
+ - **Help** — the full user manual is bundled and rendered in-app from the
61
+ **Manual** toolbar button or **Help → User Manual** (F1), with search and links.
62
+
63
+ ## Requirements
64
+
65
+ - macOS (only platform tested), Python 3.10 or newer
66
+ - Arduino Uno with GRBL 1.1 and a CNC shield (for real hardware)
67
+ - Python packages: PySide6, pyserial, pyqtgraph, numpy
68
+ - Optional: PyOpenGL (3D view), PyInstaller (packaging)
69
+
70
+ ## Setup
71
+
72
+ ```bash
73
+ bash scripts/setup_env.sh # creates .venv and installs .[dev,3d]
74
+ source .venv/bin/activate
75
+ ```
76
+
77
+ Or manually:
78
+
79
+ ```bash
80
+ python3 -m venv .venv
81
+ source .venv/bin/activate
82
+ python -m pip install -e ".[dev,3d]"
83
+ ```
84
+
85
+ ## Run
86
+
87
+ ```bash
88
+ python -m plotdeck
89
+ ```
90
+
91
+ Pick a `/dev/cu.usbmodem*` port (or `/dev/cu.usbserial*` for CH340 boards) and
92
+ press **Connect**. Click **Demo** to explore the interface without hardware.
93
+
94
+ ## Typical workflow
95
+
96
+ 1. Connect and wait for the state badge to read **IDLE**.
97
+ 2. Home the machine, jog to the workpiece origin, and zero X/Y (and Z, or use
98
+ **Machine → Probe Z…** with a touch plate).
99
+ 3. Open a G-code file (⌘O or drag and drop). Check the toolpath preview.
100
+ 4. Press **Run**. The pre-flight dialog shows the time estimate, bounding box,
101
+ tools and warnings; **Dry run** uses GRBL check mode.
102
+ 5. Watch progress in the Job panel; the editor follows the executing line.
103
+ **Pause** sends feed hold, **Stop** (⌘.) sends feed hold then soft reset.
104
+ 6. If GRBL returns `error:N`, the job pauses and asks whether to skip the line
105
+ or abort. Insert tool changes when the M6 dialog appears.
106
+
107
+ > **Tool changes:** PlotDeck pauses and prompts for `M6`, but it does not
108
+ > automatically stop a manually switched spindle or retract Z. Turn the spindle
109
+ > off and raise Z before reaching in, then re-probe Z if the tool length
110
+ > changed. Only an automatic spindle relay wired to GRBL can be stopped by
111
+ > GRBL itself.
112
+
113
+ ## Keyboard shortcuts
114
+
115
+ | Action | Shortcut |
116
+ | --- | --- |
117
+ | Open / Save | ⌘O / ⌘S |
118
+ | Find / Go to line | ⌘F / ⌘L |
119
+ | Run / Run from cursor | ⌘R / ⌘⇧R |
120
+ | Pause / Resume | toolbar (feed hold / cycle start) |
121
+ | Stop | ⌘. |
122
+ | Soft reset | ⌘⇧. |
123
+ | Toggle comment | ⌘/ |
124
+ | Connect / Disconnect | ⌘K |
125
+ | Fit toolpath | ⌘0 (or `F` with the preview focused) |
126
+ | Jog | arrows (X/Y), PgUp/PgDn (Z), `[` `]` step, Space cancel |
127
+
128
+ Jog keys only act while the jog panel has focus. The console accepts commands
129
+ when idle; during a job line commands are refused (real-time commands such as
130
+ `?` or `!` still work from a serial terminal, but the app controls them).
131
+
132
+ ## Tests
133
+
134
+ ```bash
135
+ source .venv/bin/activate
136
+ QT_QPA_PLATFORM=offscreen pytest -q
137
+ ruff check src tests
138
+ black --check src tests
139
+ mypy
140
+ ```
141
+
142
+ The suite drives the core against an in-process GRBL simulator
143
+ (`tests/fake_grbl.py`) that enforces the 127-byte buffer, and includes headless
144
+ UI smoke tests (`pytest-qt`). CI runs the same checks on macOS.
145
+
146
+ ## Packaging
147
+
148
+ ```bash
149
+ bash packaging/build_app.sh
150
+ ```
151
+
152
+ The built `dist/PlotDeck.app` was launched headlessly during development to
153
+ confirm the bundle starts (PySide6, pyqtgraph and the Qt platform plugins are
154
+ collected; PyOpenGL is included when installed).
155
+
156
+ This produces `dist/PlotDeck.app` via `packaging/plotdeck.spec`, and a `.dmg`
157
+ when `create-dmg` is installed. For distribution outside your own Mac, sign and
158
+ notarize with your Apple Developer identity:
159
+
160
+ ```bash
161
+ codesign --deep --options runtime --sign "Developer ID Application: …" dist/PlotDeck.app
162
+ xcrun notarytool submit … --wait
163
+ xcrun stapler staple dist/PlotDeck.app
164
+ ```
165
+
166
+ ## Architecture
167
+
168
+ ```
169
+ src/plotdeck/
170
+ ├── core/ # no Qt imports; unit-testable headless
171
+ │ ├── transport.py # Transport ABC, SerialTransport thread, MockTransport
172
+ │ ├── grbl_protocol.py # real-time bytes, response classifier, error tables
173
+ │ ├── status.py # <...> status reports → MachineStatus
174
+ │ ├── gcode_parser.py # cleaning, tokenizing, toolpath + time estimate
175
+ │ ├── streamer.py # character-counting job state machine
176
+ │ └── controller.py # MachineController orchestration
177
+ ├── ui/ # PySide6 widgets
178
+ │ ├── main_window.py # layout, menus, shortcuts, state-driven enablement
179
+ │ ├── editor.py # editor, gutter, highlighter, find bar
180
+ │ ├── console.py # colour-coded console + history
181
+ │ ├── status_panel.py # DRO, state badge, override sliders
182
+ │ ├── jog_panel.py # jog pad, zeroing, WCS
183
+ │ ├── visualizer.py # 2D/3D toolpath preview
184
+ │ ├── job_panel.py # progress and timing
185
+ │ ├── dialogs.py # pre-flight, tool change, probe, settings
186
+ │ └── theme.py # colour tokens and QSS
187
+ └── utils/ # settings (QSettings) and logging
188
+ ```
189
+
190
+ The UI never touches the serial port. Controller callbacks are marshalled to
191
+ the main thread through a small `QObject` signal bridge. The controller and
192
+ streamer are plain Python, so tests and CLI use do not need a Qt event loop.
193
+
194
+ ## GRBL notes
195
+
196
+ - Buffer size is 127 usable bytes; lines are counted as `len(line) + 1`.
197
+ - Real-time bytes (`?`, `!`, `~`, `0x18`, `0x85`, override bytes) bypass the
198
+ queue and never produce `ok`.
199
+ - Manual commands are acknowledged separately from the job stream, so a `$C`
200
+ or `$H` ok cannot corrupt buffer accounting.
201
+ - `M6` is never forwarded (GRBL would return `error:20`); the streamer pauses,
202
+ the UI prompts, then the job resumes after the tool change.
203
+ - On soft reset the streamer clears its queue and pending bytes and the app
204
+ re-syncs from the next status report.
205
+
206
+ ## Limitations / not implemented
207
+
208
+ - Only GRBL 1.1 is supported; Marlin/FluidNC are out of scope.
209
+ - No CAM: PlotDeck sends G-code produced elsewhere.
210
+ - No auto-reconnect, macros editor, tool/material library, or height-map
211
+ leveling (post-1.0 in the plan).
212
+ - The 3D view requires `PyOpenGL` (`pip install -e ".[3d]"`); without it the
213
+ 3D button reports that the view is unavailable.
214
+ - Clicking a toolpath segment does not jump to its line yet.
215
+ - Find/replace is literal text, not regular expressions; hover documentation
216
+ and G/M-code autocomplete are not implemented.
217
+ - Continuous (hold-to-jog) motion and saved macros are not implemented.
218
+ - The app icon is optional; drop `packaging/icon.icns` in place before building
219
+ if you want one.
220
+
221
+ ## Safety
222
+
223
+ This software controls a machine that can injure you. Wear eye protection,
224
+ secure the workpiece, know where the power switch is, and keep a hand near it
225
+ for the first cut. A software stop is **not** an emergency stop — wire a
226
+ hardware e-stop that cuts motor and spindle power. Test with the tool
227
+ disconnected or raised before cutting material.
@@ -0,0 +1,30 @@
1
+ plotdeck/__init__.py,sha256=AZ-Q2t-x-RS3PbS-Xg0I9lltIBwjjonU2F-zNxp5R3I,122
2
+ plotdeck/__main__.py,sha256=vJbhcKnmxY8kShIvxgFrx0qJmthcuRrWhXVxnnyDeLQ,116
3
+ plotdeck/app.py,sha256=VXMg-yy6qprG8-yknp4svMrhOs6FvGCbIRa-gTfqkhU,1685
4
+ plotdeck/core/__init__.py,sha256=xgj92ejegspz_ak7ly5D5CjudKkKD2zS-eboaM3Fpyw,139
5
+ plotdeck/core/controller.py,sha256=NQqcbUW_wuWjTzkUymMiqgpyX5EkMZ_iBqBlKKo4k-o,19975
6
+ plotdeck/core/gcode_parser.py,sha256=H53FF2OOzdcORM64ixUA3klYwvuZ0aKrFvNRXcDVUJs,13171
7
+ plotdeck/core/grbl_protocol.py,sha256=wXDfZ81n8MemskZB1JMKfoajSLKIAX1sqaAa0XmtvtY,9914
8
+ plotdeck/core/status.py,sha256=IaUjegCEnM__MS8by-U-yV26DwsJOfCqMI0NXvVORdo,4669
9
+ plotdeck/core/streamer.py,sha256=dZICcJrmV91tqQn06nDO14-eMdNdPwsrbnU_UOU5qbw,11400
10
+ plotdeck/core/transport.py,sha256=SWyrpvGKWyd02xXaujrkz85VfB9z3d04GYh_aTpnN9Q,12009
11
+ plotdeck/ui/__init__.py,sha256=yO2gsCHcUqbaMZd8ErO_eQyGcCaGwkOIAGH8NID7zFY,43
12
+ plotdeck/ui/connection_panel.py,sha256=AhZn7aUaz5PMxU5TeKIo2F6S23k9q0pISrhY4aDf2ag,4575
13
+ plotdeck/ui/console.py,sha256=4Uk4YjAqq5C_fSb3275krMhc9K5Uy_o7KoXJViS_AWI,6180
14
+ plotdeck/ui/dialogs.py,sha256=nN0ejrBB7Nqpj5RLXDx8p4uxebvmX6mNem083B7EyMU,11295
15
+ plotdeck/ui/editor.py,sha256=mNbqYsOX2xlqP2FACUiMB2KmeyjKb2HA8zgNxU8jifE,20710
16
+ plotdeck/ui/job_panel.py,sha256=dSJI9sAYNWP9rIRq8QtrMN_Cb_YYaqX767Hbhl8Vnck,4261
17
+ plotdeck/ui/jog_panel.py,sha256=jrTHZdwt2NsE1B90umct0rctGG92g27eByS3k980nzU,8294
18
+ plotdeck/ui/main_window.py,sha256=I33ZB63hqmHUt-_UnJSioLNyv4kM7MwjyVGIT0DE4b8,45979
19
+ plotdeck/ui/manual.py,sha256=Dwx06j0V4ak_jzS9qp6-luyIqPFu6zsIAAGxcZDfkLA,5376
20
+ plotdeck/ui/status_panel.py,sha256=Fz4VIJUPFMhwkWrlCWan6iCRHt5cVAITOfpi3chTWK8,9237
21
+ plotdeck/ui/theme.py,sha256=QQ0CwZivMQ9p6NSac80QfnHy1mpuduxl7oB_A3myr1o,8859
22
+ plotdeck/ui/visualizer.py,sha256=1PVOncOiGdpenCKylO_E6mOBKzKlhSGnnhdgOiwvpYk,15158
23
+ plotdeck/utils/__init__.py,sha256=_OATcBYijAEZ6ERdNUd5mWTBF7r00LYaiiaYNM_ePgg,50
24
+ plotdeck/utils/logging_setup.py,sha256=CO82UzKy7Ed7voWil3Ys1LNCJWqu1mf_cozCViHXUaM,1173
25
+ plotdeck/utils/settings.py,sha256=UgdOd1--rdlRCa7tMDBDdI_Mr6z3SBl0fX_CvnogOkk,3135
26
+ plotdeck-1.0.0.dist-info/METADATA,sha256=RlzTNDbYeqixaXupj-VqM2s6QEkURlyQZ5pnDJEFgZc,9435
27
+ plotdeck-1.0.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
28
+ plotdeck-1.0.0.dist-info/entry_points.txt,sha256=ZXkaRP720CiCm1PCMd7Dat6nsf9F5Q4WCvKUvN2Hyyw,95
29
+ plotdeck-1.0.0.dist-info/top_level.txt,sha256=EEd8ps6Xn96Ix0_-hZ9wip-iWuqdn018E1X7iUWLWPQ,9
30
+ plotdeck-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,5 @@
1
+ [console_scripts]
2
+ plotdeck = plotdeck.app:main
3
+
4
+ [gui_scripts]
5
+ plotdeck-gui = plotdeck.app:main
@@ -0,0 +1 @@
1
+ plotdeck