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/core/status.py
ADDED
|
@@ -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)
|