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,148 @@
1
+ """Parser for GRBL ``<...>`` status reports."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from contextlib import suppress
6
+ from dataclasses import dataclass, field, replace
7
+
8
+ from .grbl_protocol import state_family
9
+
10
+ Triple = tuple[float, float, float]
11
+
12
+
13
+ def _triple(value: str) -> Triple | None:
14
+ parts = value.split(",")
15
+ if len(parts) != 3:
16
+ return None
17
+ try:
18
+ return (float(parts[0]), float(parts[1]), float(parts[2]))
19
+ except ValueError:
20
+ return None
21
+
22
+
23
+ def _pair(value: str) -> tuple[float, float] | None:
24
+ parts = value.split(",")
25
+ if len(parts) != 2:
26
+ return None
27
+ try:
28
+ return (float(parts[0]), float(parts[1]))
29
+ except ValueError:
30
+ return None
31
+
32
+
33
+ @dataclass
34
+ class MachineStatus:
35
+ """Latest known machine state.
36
+
37
+ ``wco`` is tracked across reports because GRBL only includes ``WCO:``
38
+ intermittently; ``wpos`` can therefore still be derived when only
39
+ ``MPos`` arrives.
40
+ """
41
+
42
+ state: str = "Unknown"
43
+ substate: str | None = None
44
+ mpos: Triple | None = None
45
+ wpos: Triple | None = None
46
+ wco: Triple | None = None
47
+ feed: float | None = None
48
+ spindle: float | None = None
49
+ buffer: tuple[int, int] | None = None
50
+ overrides: tuple[int, int, int] | None = None
51
+ pins: str = ""
52
+ raw: str = ""
53
+ extra: dict[str, str] = field(default_factory=dict)
54
+
55
+ @property
56
+ def family(self) -> str:
57
+ return state_family(self.state)
58
+
59
+ @property
60
+ def is_idle(self) -> bool:
61
+ return self.family == "idle"
62
+
63
+ @property
64
+ def is_alarm(self) -> bool:
65
+ return self.family == "alarm"
66
+
67
+ @property
68
+ def buffer_fill(self) -> int:
69
+ return self.buffer[0] if self.buffer else 0
70
+
71
+ @property
72
+ def buffer_size(self) -> int:
73
+ return self.buffer[1] if self.buffer else 0
74
+
75
+ def copy(self) -> MachineStatus:
76
+ return replace(self, extra=dict(self.extra))
77
+
78
+
79
+ def parse_status(raw: str, previous: MachineStatus | None = None) -> MachineStatus:
80
+ """Parse one ``<...>`` report.
81
+
82
+ Unknown fields are preserved in ``extra`` rather than rejected, so a
83
+ firmware extension never breaks the UI.
84
+ """
85
+ text = raw.strip()
86
+ if text.startswith("<") and text.endswith(">"):
87
+ text = text[1:-1]
88
+
89
+ status = MachineStatus(raw=raw)
90
+ if previous is not None:
91
+ status.wco = previous.wco
92
+
93
+ for index, chunk in enumerate(text.split("|")):
94
+ if not chunk:
95
+ continue
96
+ if index == 0:
97
+ state, _, substate = chunk.partition(":")
98
+ status.state = state
99
+ status.substate = substate or None
100
+ continue
101
+
102
+ key, sep, value = chunk.partition(":")
103
+ if not sep:
104
+ status.extra[chunk] = ""
105
+ continue
106
+
107
+ if key == "MPos":
108
+ status.mpos = _triple(value)
109
+ elif key == "WPos":
110
+ status.wpos = _triple(value)
111
+ elif key == "WCO":
112
+ status.wco = _triple(value)
113
+ elif key == "FS":
114
+ pair = _pair(value)
115
+ if pair:
116
+ status.feed, status.spindle = pair
117
+ elif key == "F":
118
+ with suppress(ValueError):
119
+ status.feed = float(value)
120
+ elif key == "Bf":
121
+ parts = value.split(",")
122
+ if len(parts) == 2:
123
+ with suppress(ValueError):
124
+ status.buffer = (int(parts[0]), int(parts[1]))
125
+ elif key == "Ov":
126
+ parts = value.split(",")
127
+ if len(parts) == 3:
128
+ with suppress(ValueError):
129
+ status.overrides = (int(parts[0]), int(parts[1]), int(parts[2]))
130
+ elif key == "Pn":
131
+ status.pins = value
132
+ else:
133
+ status.extra[key] = value
134
+
135
+ # GRBL omits WCO while the offset is zero and only sends it intermittently
136
+ # afterwards. Treat a missing offset as zero when we have no history.
137
+ if status.mpos is not None and status.wpos is None and status.wco is None:
138
+ status.wco = (0.0, 0.0, 0.0)
139
+
140
+ # Derive the missing pair using the current work-coordinate offset.
141
+ if status.mpos is not None and status.wco is not None:
142
+ status.wpos = tuple(a - b for a, b in zip(status.mpos, status.wco, strict=True)) # type: ignore[assignment]
143
+ elif status.wpos is not None and status.wco is not None and status.mpos is None:
144
+ status.mpos = tuple(a + b for a, b in zip(status.wpos, status.wco, strict=True)) # type: ignore[assignment]
145
+ elif status.mpos is not None and status.wpos is not None and status.wco is None:
146
+ status.wco = tuple(a - b for a, b in zip(status.mpos, status.wpos, strict=True)) # type: ignore[assignment]
147
+
148
+ return status
@@ -0,0 +1,328 @@
1
+ """Character-counting G-code streamer.
2
+
3
+ GRBL has a 127-byte RX buffer and acknowledges every line with ``ok`` or
4
+ ``error:N``. This class only sends a line if it still fits, which keeps the
5
+ controller from overrunning the machine.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import threading
11
+ from collections import deque
12
+ from collections.abc import Callable, Iterable
13
+ from contextlib import suppress
14
+ from enum import Enum
15
+
16
+ from .gcode_parser import ProgramLine
17
+ from .grbl_protocol import RX_BUFFER_SIZE, RealTime
18
+ from .transport import Transport, TransportError
19
+
20
+
21
+ class StreamerState(str, Enum):
22
+ IDLE = "idle"
23
+ RUNNING = "running"
24
+ PAUSED = "paused"
25
+ COMPLETED = "completed"
26
+ STOPPED = "stopped"
27
+ ERROR = "error"
28
+
29
+
30
+ class Streamer:
31
+ """Send a parsed program to GRBL without overflowing the planner buffer."""
32
+
33
+ def __init__(
34
+ self,
35
+ transport: Transport,
36
+ *,
37
+ on_progress: Callable[[int, int, int], None] | None = None,
38
+ on_state: Callable[[StreamerState], None] | None = None,
39
+ on_error: Callable[[ProgramLine, int, str], None] | None = None,
40
+ on_finished: Callable[[str], None] | None = None,
41
+ on_tool_change: Callable[[ProgramLine], None] | None = None,
42
+ ) -> None:
43
+ self.transport = transport
44
+ self.on_progress = on_progress
45
+ self.on_state = on_state
46
+ self.on_error = on_error
47
+ self.on_finished = on_finished
48
+ self.on_tool_change = on_tool_change
49
+
50
+ self._program: list[ProgramLine] = []
51
+ self._index = 0
52
+ self._sent = 0
53
+ self._pending: deque[int] = deque()
54
+ self._pending_bytes = 0
55
+ self._inflight: deque[ProgramLine] = deque()
56
+ self._state = StreamerState.IDLE
57
+ self._error: tuple[ProgramLine, int] | None = None
58
+ self._tool_change: ProgramLine | None = None
59
+ self._lock = threading.RLock()
60
+
61
+ # ------------------------------------------------------------------ #
62
+ # State
63
+ # ------------------------------------------------------------------ #
64
+ @property
65
+ def state(self) -> StreamerState:
66
+ return self._state
67
+
68
+ @property
69
+ def program(self) -> list[ProgramLine]:
70
+ return list(self._program)
71
+
72
+ @property
73
+ def total(self) -> int:
74
+ return len(self._program)
75
+
76
+ @property
77
+ def sent(self) -> int:
78
+ return self._sent
79
+
80
+ @property
81
+ def current_line(self) -> int:
82
+ if self._inflight:
83
+ return self._inflight[0].source_line
84
+ return self._program[self._index].source_line if self._index < len(self._program) else 0
85
+
86
+ @property
87
+ def error(self) -> tuple[ProgramLine, int] | None:
88
+ return self._error
89
+
90
+ @property
91
+ def awaiting_tool_change(self) -> ProgramLine | None:
92
+ return self._tool_change
93
+
94
+ @property
95
+ def is_active(self) -> bool:
96
+ return self._state in (StreamerState.RUNNING, StreamerState.PAUSED)
97
+
98
+ @property
99
+ def pending_bytes(self) -> int:
100
+ return self._pending_bytes
101
+
102
+ # ------------------------------------------------------------------ #
103
+ # Job control
104
+ # ------------------------------------------------------------------ #
105
+ def load(self, program: Iterable[ProgramLine]) -> None:
106
+ with self._lock:
107
+ if self._state in (StreamerState.RUNNING, StreamerState.PAUSED):
108
+ raise RuntimeError("Cannot load a new program while a job is active.")
109
+ self._program = list(program)
110
+ self._reset_counters()
111
+
112
+ def _reset_counters(self) -> None:
113
+ self._index = 0
114
+ self._sent = 0
115
+ self._pending.clear()
116
+ self._pending_bytes = 0
117
+ self._inflight.clear()
118
+ self._error = None
119
+ self._tool_change = None
120
+ self._state = StreamerState.IDLE
121
+
122
+ def start(self, from_source_line: int | None = None) -> bool:
123
+ with self._lock:
124
+ if not self._program:
125
+ return False
126
+ if self._state in (StreamerState.RUNNING, StreamerState.PAUSED):
127
+ return False
128
+ self._reset_counters()
129
+ if from_source_line is not None:
130
+ self._index = self._index_for_line(from_source_line)
131
+ self._sent = self._index
132
+ self._state = StreamerState.RUNNING
133
+ self._emit_state()
134
+ self._emit_progress()
135
+ self._pump()
136
+ return True
137
+
138
+ def _index_for_line(self, source_line: int) -> int:
139
+ for index, line in enumerate(self._program):
140
+ if line.source_line >= source_line:
141
+ return index
142
+ return len(self._program)
143
+
144
+ def pause(self) -> None:
145
+ with self._lock:
146
+ if self._state is not StreamerState.RUNNING:
147
+ return
148
+ self._send_realtime(RealTime.FEED_HOLD)
149
+ self._state = StreamerState.PAUSED
150
+ self._emit_state()
151
+
152
+ def resume(self) -> None:
153
+ with self._lock:
154
+ if self._state is not StreamerState.PAUSED:
155
+ return
156
+ if self._error is not None or self._tool_change is not None:
157
+ return
158
+ self._send_realtime(RealTime.CYCLE_START)
159
+ self._state = StreamerState.RUNNING
160
+ self._emit_state()
161
+ self._pump()
162
+
163
+ def stop(self, soft_reset: bool = True) -> None:
164
+ with self._lock:
165
+ if self._state is StreamerState.IDLE and not self._program:
166
+ return
167
+ if self.is_active:
168
+ self._send_realtime(RealTime.FEED_HOLD)
169
+ if soft_reset:
170
+ self._send_realtime(RealTime.SOFT_RESET)
171
+ self._pending.clear()
172
+ self._pending_bytes = 0
173
+ self._inflight.clear()
174
+ self._error = None
175
+ self._tool_change = None
176
+ was_active = self.is_active
177
+ self._state = StreamerState.STOPPED
178
+ self._emit_state()
179
+ if was_active:
180
+ self._emit_finished("stopped")
181
+
182
+ def abort(self, reason: str = "alarm") -> None:
183
+ """Halt without sending bytes (used for alarms and lost connections)."""
184
+ with self._lock:
185
+ was_active = self.is_active
186
+ self._pending.clear()
187
+ self._pending_bytes = 0
188
+ self._inflight.clear()
189
+ self._tool_change = None
190
+ self._state = StreamerState.ERROR
191
+ self._emit_state()
192
+ if was_active:
193
+ self._emit_finished(reason)
194
+
195
+ # ------------------------------------------------------------------ #
196
+ # GRBL responses
197
+ # ------------------------------------------------------------------ #
198
+ def handle_ok(self) -> None:
199
+ with self._lock:
200
+ if not self._inflight:
201
+ return # a manual command or a command sent outside the job
202
+ self._acknowledge()
203
+ if self._state is StreamerState.RUNNING:
204
+ self._pump()
205
+ elif self._state is StreamerState.PAUSED and not self._inflight:
206
+ pass
207
+ self._emit_progress()
208
+
209
+ def handle_error(self, code: int, detail: str = "") -> None:
210
+ with self._lock:
211
+ if not self._inflight:
212
+ return
213
+ line = self._inflight.popleft()
214
+ self._pop_pending()
215
+ self._error = (line, code)
216
+ self._state = StreamerState.PAUSED
217
+ # Stop motion promptly rather than letting the planner run dry.
218
+ self._send_realtime(RealTime.FEED_HOLD)
219
+ self._emit_state()
220
+ self._emit_progress()
221
+ if self.on_error is not None:
222
+ self.on_error(line, code, detail)
223
+
224
+ def skip_errored_line(self) -> None:
225
+ """Continue the job after the user reviewed an error."""
226
+ with self._lock:
227
+ if self._error is None:
228
+ return
229
+ self._error = None
230
+ self._state = StreamerState.RUNNING
231
+ self._send_realtime(RealTime.CYCLE_START)
232
+ self._emit_state()
233
+ self._pump()
234
+
235
+ def complete_tool_change(self) -> None:
236
+ with self._lock:
237
+ if self._tool_change is None:
238
+ return
239
+ self._tool_change = None
240
+ if self._state is StreamerState.PAUSED:
241
+ self._send_realtime(RealTime.CYCLE_START)
242
+ self._state = StreamerState.RUNNING
243
+ self._emit_state()
244
+ self._pump()
245
+
246
+ def cancel_tool_change(self) -> None:
247
+ with self._lock:
248
+ if self._tool_change is None:
249
+ return
250
+ self._tool_change = None
251
+ self.stop(soft_reset=False)
252
+
253
+ # ------------------------------------------------------------------ #
254
+ # Internals
255
+ # ------------------------------------------------------------------ #
256
+ def _pump(self) -> None:
257
+ while self._state is StreamerState.RUNNING and self._index < len(self._program):
258
+ line = self._program[self._index]
259
+
260
+ if line.is_tool_change:
261
+ if self._pending:
262
+ return # let the planner drain before pausing for the tool change
263
+ self._tool_change = line
264
+ self._index += 1
265
+ self._send_realtime(RealTime.FEED_HOLD)
266
+ self._state = StreamerState.PAUSED
267
+ self._emit_state()
268
+ if self.on_tool_change is not None:
269
+ self.on_tool_change(line)
270
+ return
271
+
272
+ need = len(line.text.encode("utf-8")) + 1
273
+ if self._pending_bytes + need > RX_BUFFER_SIZE:
274
+ return # buffer full; wait for an ok
275
+
276
+ try:
277
+ self.transport.send_line(line.text)
278
+ except TransportError as exc:
279
+ self._state = StreamerState.ERROR
280
+ self._emit_state()
281
+ if self.on_error is not None:
282
+ self.on_error(line, -1, str(exc))
283
+ self._emit_finished("error")
284
+ return
285
+
286
+ self._pending.append(need)
287
+ self._pending_bytes += need
288
+ self._inflight.append(line)
289
+ self._index += 1
290
+ self._emit_progress()
291
+
292
+ if (
293
+ self._state is StreamerState.RUNNING
294
+ and self._index >= len(self._program)
295
+ and not self._pending
296
+ ):
297
+ self._state = StreamerState.COMPLETED
298
+ self._emit_state()
299
+ self._emit_finished("completed")
300
+
301
+ def _acknowledge(self) -> None:
302
+ if not self._inflight:
303
+ return
304
+ self._inflight.popleft()
305
+ self._pop_pending()
306
+ self._sent += 1
307
+
308
+ def _pop_pending(self) -> None:
309
+ if self._pending:
310
+ self._pending_bytes -= self._pending.popleft()
311
+ if self._pending_bytes < 0:
312
+ self._pending_bytes = 0
313
+
314
+ def _send_realtime(self, byte: RealTime) -> None:
315
+ with suppress(TransportError):
316
+ self.transport.send_realtime(byte.value)
317
+
318
+ def _emit_state(self) -> None:
319
+ if self.on_state is not None:
320
+ self.on_state(self._state)
321
+
322
+ def _emit_progress(self) -> None:
323
+ if self.on_progress is not None:
324
+ self.on_progress(self._sent, len(self._program), self.current_line)
325
+
326
+ def _emit_finished(self, reason: str) -> None:
327
+ if self.on_finished is not None:
328
+ self.on_finished(reason)