pygrbl-streamer 0.0.1__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,6 @@
1
+ """Robust, source-agnostic G-code streamer for GRBL controllers."""
2
+
3
+ from .streamer import GrblStreamer, State
4
+
5
+ __version__ = "0.0.1"
6
+ __all__ = ["GrblStreamer", "State"]
File without changes
@@ -0,0 +1,740 @@
1
+ import os
2
+ import serial
3
+ import threading
4
+ import queue
5
+ import time
6
+ import re
7
+ from enum import Enum, auto
8
+ from collections import deque
9
+ from typing import Iterable, Iterator
10
+
11
+
12
+ class State(Enum):
13
+ DISCONNECTED = auto()
14
+ CONNECTING = auto()
15
+ IDLE = auto()
16
+ STREAMING = auto()
17
+ PAUSED = auto()
18
+ ALARM = auto()
19
+
20
+
21
+ class _FileSource:
22
+ """
23
+ Lazily iterates a G-code file while tracking byte-based progress.
24
+
25
+ Because stream() pulls commands only when GRBL's 127-byte RX buffer has
26
+ room, the file read position trails the acknowledged position by just a
27
+ few commands, making bytes-read an excellent progress approximation at
28
+ zero cost (no counting pass over the file).
29
+ """
30
+
31
+ def __init__(self, path: str):
32
+ self.path = path
33
+ self.size = os.path.getsize(path) or 1
34
+ self.read = 0
35
+
36
+ def __iter__(self) -> Iterator[str]:
37
+ self.read = 0
38
+ with open(self.path, 'rb') as f:
39
+ for raw in f:
40
+ self.read += len(raw)
41
+ yield raw.decode('utf-8', errors='ignore')
42
+
43
+ def percent(self) -> int:
44
+ # Cap at 99: the 100% event is emitted by stream() on true completion.
45
+ return min(99, int(self.read * 100 / self.size))
46
+
47
+
48
+ class GrblStreamer:
49
+ """Thread-safe, fault-tolerant streamer for GRBL controllers."""
50
+
51
+ RX_BUFFER = 128 # GRBL serial RX buffer size, in characters
52
+ RX_MARGIN = 1 # safety margin kept free in the RX buffer
53
+ STATUS_INTERVAL = 0.3 # '?' status polling period while streaming (s)
54
+ READ_TIMEOUT = 0.1 # serial read timeout; keeps the reader thread responsive (s)
55
+
56
+ _STATUS_RE = re.compile(r'^<(\w+)')
57
+ _PAREN_COMMENT_RE = re.compile(r'\([^)]*\)')
58
+ _CRITICAL_EVENTS = frozenset(('alarm', 'error', 'disconnect', 'state'))
59
+
60
+ def __init__(self, port: str, baudrate: int = 115200,
61
+ auto_unlock: bool = True):
62
+ self.port = port
63
+ self.baudrate = baudrate
64
+ # Send $X after connecting. Never applied mid-job: auto-unlocking a
65
+ # laser/CNC in the middle of a program is a safety hazard.
66
+ self.auto_unlock = auto_unlock
67
+
68
+ self.serial: serial.Serial | None = None
69
+ self.state = State.DISCONNECTED
70
+ self.last_status: dict = {} # latest parsed <...> report: {'state','raw','time'}
71
+
72
+ self._state_lock = threading.Lock()
73
+ self._write_lock = threading.Lock()
74
+ self._running = threading.Event()
75
+ self._abort = threading.Event()
76
+ self._paused = threading.Event()
77
+ self._banner = threading.Event()
78
+
79
+ self._read_thread: threading.Thread | None = None
80
+ self._cb_thread: threading.Thread | None = None
81
+
82
+ self._ack_queue: queue.Queue = queue.Queue() # protocol acks only: 'ok' / 'error:N'
83
+ self._event_queue: queue.Queue = queue.Queue(500) # events dispatched to user callbacks
84
+
85
+ # ------------------------------------------------------------------
86
+ # Callbacks: override in a subclass or assign as instance attributes.
87
+ # All callbacks run on a dedicated thread, so a slow or faulty callback
88
+ # can never stall or crash the serial communication.
89
+ # ------------------------------------------------------------------
90
+ def progress_callback(self, percent: int, command: str):
91
+ """percent is 0-100 when measurable, -1 for unbounded streams."""
92
+ def state_callback(self, state: State): pass
93
+ def alarm_callback(self, line: str): pass
94
+ def error_callback(self, line: str): pass
95
+ def send_callback(self, data: str): pass
96
+ def receive_callback(self, data: str): pass
97
+ def disconnect_callback(self, reason: str): pass
98
+ def log_callback(self, level: str, message: str):
99
+ """Internal diagnostics the other callbacks don't cover (init quirks,
100
+ swallowed exceptions...). level is 'debug'|'info'|'warning'.
101
+ Typical wiring: g.log_callback = lambda lv, m: getattr(log, lv)(m)"""
102
+
103
+ # ------------------------------------------------------------------
104
+ # Connection lifecycle
105
+ # ------------------------------------------------------------------
106
+ def connect(self, reset: bool = True):
107
+ """
108
+ Open the serial port, start worker threads, and initialize GRBL.
109
+
110
+ Connecting IS taking control: by default the controller is
111
+ soft-reset (any orphaned job is stopped, beam off), verified to be
112
+ a responsive GRBL device, and optionally unlocked. Raises
113
+ SerialException if the port cannot be opened, is held by another
114
+ process, or the device does not respond. Never leaves a
115
+ half-initialized session behind.
116
+ """
117
+ self.disconnect() # guarantee a clean slate even if a session was open
118
+ self._set_state(State.CONNECTING)
119
+ try:
120
+ s = serial.Serial()
121
+ s.port = self.port
122
+ s.baudrate = self.baudrate
123
+ s.bytesize = serial.EIGHTBITS
124
+ s.parity = serial.PARITY_NONE
125
+ s.stopbits = serial.STOPBITS_ONE
126
+ s.timeout = self.READ_TIMEOUT # bounded reads -> reader thread can exit
127
+ s.write_timeout = 1.0
128
+ s.dtr = False # suppress Arduino auto-reset on open
129
+ s.rts = False
130
+ s.exclusive = True # POSIX: fail if another process holds
131
+ # the port (Windows enforces this natively)
132
+ s.open()
133
+ s.reset_input_buffer()
134
+ s.reset_output_buffer()
135
+ self.serial = s
136
+ except (serial.SerialException, OSError):
137
+ self.serial = None
138
+ self._set_state(State.DISCONNECTED)
139
+ raise
140
+
141
+ self._abort.clear()
142
+ self._paused.clear()
143
+ self._running.set()
144
+ self._read_thread = threading.Thread(target=self._read_loop, daemon=True,
145
+ name='grbl-read')
146
+ self._read_thread.start()
147
+ self._cb_thread = threading.Thread(target=self._callback_loop, daemon=True,
148
+ name='grbl-callbacks')
149
+ self._cb_thread.start()
150
+
151
+ try:
152
+ if reset:
153
+ # Soft-reset and wait for the "Grbl X.Xx ['$' for help]" banner
154
+ # instead of sleeping blindly for a fixed interval.
155
+ self._banner.clear()
156
+ self._write(b'\x18')
157
+ if not self._banner.wait(timeout=6):
158
+ # Some GRBL clones do not emit a banner; proceed anyway.
159
+ self._emit('log', ('warning', 'No GRBL banner after soft reset'))
160
+ time.sleep(0.2)
161
+ self._drain(self._ack_queue)
162
+
163
+ # Verify the device actually talks GRBL. Catches boards whose USB
164
+ # port enumerates while the machine is powered off, and non-GRBL
165
+ # devices on the wrong port: better to fail here than 30 s into a job.
166
+ if not self._banner.is_set():
167
+ t0 = time.time()
168
+ self.realtime(b'?')
169
+ while time.time() - t0 < 2.0:
170
+ if self._banner.is_set() or self.last_status.get('time', 0) >= t0:
171
+ break
172
+ time.sleep(0.1)
173
+ else:
174
+ raise serial.SerialException(
175
+ 'Port opened but device is not responding '
176
+ '(machine powered off, or not a GRBL controller?)')
177
+
178
+ if self.auto_unlock:
179
+ self.unlock()
180
+ except (serial.SerialException, OSError):
181
+ # Initialization failed after the port opened: never leave
182
+ # threads running against a half-initialized session.
183
+ self.disconnect()
184
+ raise
185
+
186
+ self._set_state(State.IDLE)
187
+
188
+ def disconnect(self):
189
+ """Stop worker threads (joined, not abandoned) and close the port.
190
+ Idempotent: safe to call at any time, in any state."""
191
+ self._abort.set()
192
+ self._running.clear()
193
+
194
+ if self._cb_thread and self._cb_thread.is_alive():
195
+ self._event_queue.put(None) # sentinel terminates the callback thread
196
+
197
+ current = threading.current_thread()
198
+ for t in (self._read_thread, self._cb_thread):
199
+ if t and t.is_alive() and t is not current:
200
+ t.join(timeout=2)
201
+ self._read_thread = None
202
+ self._cb_thread = None
203
+
204
+ if self.serial:
205
+ try:
206
+ self.serial.reset_output_buffer()
207
+ self.serial.reset_input_buffer()
208
+ self.serial.close()
209
+ except Exception:
210
+ pass
211
+ self.serial = None
212
+
213
+ self._drain(self._ack_queue)
214
+ self._drain(self._event_queue)
215
+ with self._state_lock:
216
+ self.state = State.DISCONNECTED
217
+
218
+ def reconnect(self, retries: int = 5, delay: float = 2.0) -> bool:
219
+ """Attempt to reconnect. Intended for use after a physical disconnect."""
220
+ for _ in range(retries):
221
+ try:
222
+ self.connect()
223
+ return True
224
+ except (serial.SerialException, OSError):
225
+ time.sleep(delay)
226
+ return False
227
+
228
+ @property
229
+ def is_connected(self) -> bool:
230
+ return self.state not in (State.DISCONNECTED, State.CONNECTING)
231
+
232
+ # Context-manager support: with GrblStreamer(...) as g: ...
233
+ def __enter__(self):
234
+ if not self.is_connected:
235
+ self.connect()
236
+ return self
237
+
238
+ def __exit__(self, *exc):
239
+ self.disconnect()
240
+
241
+ # ------------------------------------------------------------------
242
+ # Reader thread
243
+ # ------------------------------------------------------------------
244
+ def _read_loop(self):
245
+ buf = bytearray()
246
+ while self._running.is_set():
247
+ try:
248
+ # Read whatever is available (at least 1 byte). Avoids the
249
+ # up-to-100 ms latency of waiting for a fixed-size block.
250
+ chunk = self.serial.read(self.serial.in_waiting or 1)
251
+ except (serial.SerialException, OSError, AttributeError) as e:
252
+ # Port vanished (USB unplugged, power loss, etc.)
253
+ self._handle_disconnect(f'DEVICE_DISCONNECTED: {e}')
254
+ return
255
+ if not chunk:
256
+ continue
257
+ buf += chunk
258
+ while b'\n' in buf:
259
+ raw, _, rest = buf.partition(b'\n')
260
+ buf = bytearray(rest)
261
+ line = raw.decode('utf-8', errors='ignore').strip()
262
+ if line:
263
+ self._process_line(line)
264
+
265
+ def _process_line(self, line: str):
266
+ """Classify an incoming line and route it to the proper channel."""
267
+ self._emit('receive', line)
268
+
269
+ if line == 'ok' or line.startswith('error:') or line.startswith('error '):
270
+ if line != 'ok':
271
+ self._emit('error', line)
272
+ self._ack_queue.put(line) # ONLY protocol acks enter this queue
273
+
274
+ elif line.startswith('<') and line.endswith('>'):
275
+ # Real-time status report, e.g. <Idle|MPos:0.000,...>
276
+ m = self._STATUS_RE.match(line)
277
+ if m:
278
+ self.last_status = {'state': m.group(1), 'raw': line,
279
+ 'time': time.time()}
280
+
281
+ elif line.startswith('ALARM'):
282
+ # An alarm mid-job aborts the job. We deliberately do NOT
283
+ # auto-unlock: clearing an alarm on a laser/CNC without operator
284
+ # confirmation is unsafe. The user must call unlock() explicitly.
285
+ self._abort.set()
286
+ self._set_state(State.ALARM)
287
+ self._emit('alarm', line)
288
+
289
+ elif line.startswith('Grbl'):
290
+ self._banner.set()
291
+ if self.state in (State.STREAMING, State.PAUSED) and not self._abort.is_set():
292
+ # The controller rebooted mid-job (brownout, EMI, watchdog):
293
+ # its buffer is gone and our accounting is void. Abort now
294
+ # instead of waiting for the 30 s ack timeout. (_abort guard:
295
+ # the reset triggered by stop() is expected, not an error.)
296
+ self._abort.set()
297
+ self._emit('error', 'CONTROLLER_RESET: GRBL rebooted mid-job')
298
+ # [MSG:...], $N settings, etc. are still delivered via receive_callback.
299
+
300
+ def _handle_disconnect(self, reason: str):
301
+ self._abort.set()
302
+ self._running.clear()
303
+ try:
304
+ if self.serial:
305
+ self.serial.close()
306
+ except Exception:
307
+ pass
308
+ self.serial = None
309
+ self._set_state(State.DISCONNECTED)
310
+ self._emit('disconnect', reason)
311
+
312
+ # ------------------------------------------------------------------
313
+ # Callback dispatcher thread
314
+ # ------------------------------------------------------------------
315
+ def _callback_loop(self):
316
+ while True:
317
+ item = self._event_queue.get()
318
+ if item is None: # shutdown sentinel
319
+ return
320
+ etype, data = item
321
+ try:
322
+ if etype == 'progress':
323
+ self.progress_callback(*data)
324
+ elif etype == 'state':
325
+ self.state_callback(data)
326
+ elif etype == 'alarm':
327
+ self.alarm_callback(data)
328
+ elif etype == 'error':
329
+ self.error_callback(data)
330
+ elif etype == 'send':
331
+ self.send_callback(data)
332
+ elif etype == 'receive':
333
+ self.receive_callback(data)
334
+ elif etype == 'disconnect':
335
+ self.disconnect_callback(data)
336
+ elif etype == 'log':
337
+ self.log_callback(*data)
338
+ except Exception as e:
339
+ # A user callback raised; report it (guarded against loops:
340
+ # a faulty log_callback is never reported through itself).
341
+ if etype != 'log':
342
+ self._emit('log', ('warning', f'{etype}_callback raised: {e!r}'))
343
+
344
+ # ------------------------------------------------------------------
345
+ # Writing (always lock-protected: streaming, real-time commands and
346
+ # user calls cannot interleave bytes on the wire)
347
+ # ------------------------------------------------------------------
348
+ def _write(self, data: bytes):
349
+ with self._write_lock:
350
+ if not (self.serial and self.serial.is_open):
351
+ raise serial.SerialException('Port is not open')
352
+ self.serial.write(data)
353
+ self._emit('send', data.decode('utf-8', errors='ignore'))
354
+
355
+ def write_line(self, text: str):
356
+ self._write((text.rstrip('\r\n') + '\n').encode())
357
+
358
+ def realtime(self, char: bytes):
359
+ """Send a GRBL real-time command (?, !, ~, 0x18).
360
+ These bypass the RX buffer and produce no 'ok' response."""
361
+ self._write(char)
362
+
363
+ def command(self, cmd: str, timeout: float = 5.0) -> bool:
364
+ """Send a single command and wait for its ok/error response.
365
+ For interactive use outside of a streaming job."""
366
+ if self.state == State.STREAMING:
367
+ raise RuntimeError('A streaming job is in progress')
368
+ self._drain(self._ack_queue)
369
+ self.write_line(cmd)
370
+ try:
371
+ return self._ack_queue.get(timeout=timeout) == 'ok'
372
+ except queue.Empty:
373
+ return False
374
+
375
+ def unlock(self) -> bool:
376
+ """$X: clear the alarm lock."""
377
+ if self.state == State.STREAMING:
378
+ # Draining the ack queue mid-job would corrupt buffer accounting.
379
+ raise RuntimeError('A streaming job is in progress')
380
+ self._drain(self._ack_queue)
381
+ self.write_line('$X')
382
+ try:
383
+ ok = self._ack_queue.get(timeout=3) == 'ok'
384
+ except queue.Empty:
385
+ ok = False
386
+ if ok and self.state == State.ALARM:
387
+ self._set_state(State.IDLE)
388
+ return ok
389
+
390
+ def home(self, timeout: float = 60.0) -> bool:
391
+ """$H: run the homing cycle (may take a while)."""
392
+ return self.command('$H', timeout=timeout)
393
+
394
+ # ------------------------------------------------------------------
395
+ # Job control
396
+ # ------------------------------------------------------------------
397
+ def pause(self):
398
+ if self.state == State.STREAMING:
399
+ self.realtime(b'!') # immediate feed hold
400
+ self._paused.set()
401
+ self._set_state(State.PAUSED)
402
+
403
+ def resume(self):
404
+ if self.state == State.PAUSED:
405
+ self.realtime(b'~') # cycle start / resume
406
+ self._paused.clear()
407
+ self._set_state(State.STREAMING)
408
+
409
+ def stop(self):
410
+ """Abort the job: feed hold followed by soft reset (flushes GRBL's buffer)."""
411
+ self._abort.set()
412
+ self._paused.clear()
413
+ try:
414
+ self.realtime(b'!')
415
+ time.sleep(0.3)
416
+ self.realtime(b'\x18')
417
+ except (serial.SerialException, OSError):
418
+ return
419
+ time.sleep(0.5)
420
+ self._drain(self._ack_queue)
421
+ # A soft reset during motion leaves GRBL in an alarm state by design.
422
+ if self.state is not State.DISCONNECTED:
423
+ self._set_state(State.ALARM)
424
+
425
+ # ------------------------------------------------------------------
426
+ # Deterministic recovery: the machine is the single source of truth
427
+ # ------------------------------------------------------------------
428
+ def sync(self, timeout: float = 2.0) -> State:
429
+ """
430
+ Re-derive the session state from the device itself, overriding any
431
+ internal bookkeeping. Sends a real-time '?' and maps the fresh
432
+ status report onto State.
433
+
434
+ Mapping: Alarm/Hold/Door -> ALARM (a held machine needs an explicit
435
+ reset(); blindly resuming motion on a remote laser is unsafe);
436
+ anything else (Idle/Run/Jog/Home/...) -> IDLE. While a stream()
437
+ owns the session, the current state is returned untouched.
438
+
439
+ If the device does not answer within timeout, the session is torn
440
+ down (disconnect_callback fires) and DISCONNECTED is returned.
441
+ Never raises: the return value IS the truth, whatever happened.
442
+ """
443
+ if not (self.serial and self.serial.is_open):
444
+ return State.DISCONNECTED
445
+ if self.state in (State.STREAMING, State.PAUSED):
446
+ return self.state
447
+
448
+ t0 = time.time()
449
+ try:
450
+ self.realtime(b'?')
451
+ except (serial.SerialException, OSError) as e:
452
+ self._handle_disconnect(f'DEVICE_DISCONNECTED: {e}')
453
+ return State.DISCONNECTED
454
+
455
+ while time.time() - t0 < timeout:
456
+ if self.last_status.get('time', 0) >= t0:
457
+ st = self.last_status['state']
458
+ if st in ('Alarm', 'Hold', 'Door'):
459
+ self._set_state(State.ALARM)
460
+ else:
461
+ self._set_state(State.IDLE)
462
+ return self.state
463
+ time.sleep(0.05)
464
+
465
+ self._handle_disconnect('DEVICE_UNRESPONSIVE: no status report')
466
+ return State.DISCONNECTED
467
+
468
+ def reset(self, unlock: bool = True) -> bool:
469
+ """
470
+ Deterministic recovery: bring the session to a known-good IDLE from
471
+ ANY condition -- mid-job, alarm, feed hold, or plain unknown.
472
+
473
+ Sequence: abort any running stream, soft-reset the controller
474
+ (flushes its buffer and planner), drain stale acks, optionally
475
+ unlock, then sync() against the device. Idempotent: call it
476
+ whenever in doubt, as many times as you like.
477
+
478
+ Returns True if the session ended in IDLE. False means the device
479
+ is unreachable (disconnect_callback fired): escalate to reconnect().
480
+ Never raises and never leaves the session half-initialized.
481
+ """
482
+ if not (self.serial and self.serial.is_open):
483
+ return False
484
+
485
+ # Abort any running stream and wait for its thread to release the job
486
+ self._abort.set()
487
+ self._paused.clear()
488
+ deadline = time.time() + 2.0
489
+ while self.state in (State.STREAMING, State.PAUSED) and time.time() < deadline:
490
+ time.sleep(0.05)
491
+
492
+ try:
493
+ self._banner.clear()
494
+ self.realtime(b'\x18')
495
+ self._banner.wait(timeout=4.0)
496
+ time.sleep(0.2)
497
+ self._drain(self._ack_queue)
498
+ if unlock:
499
+ self.write_line('$X')
500
+ try:
501
+ self._ack_queue.get(timeout=3)
502
+ except queue.Empty:
503
+ pass
504
+ except (serial.SerialException, OSError) as e:
505
+ self._handle_disconnect(f'DEVICE_DISCONNECTED: {e}')
506
+ return False
507
+
508
+ return self.sync() == State.IDLE
509
+
510
+ # ------------------------------------------------------------------
511
+ # Streaming core (character-counting protocol)
512
+ # ------------------------------------------------------------------
513
+ def stream(self, commands: Iterable[str], total: int | None = None,
514
+ completion_timeout: float = 600, ack_timeout: float = 30,
515
+ stop_on_error: bool = False, wait_for_idle: bool = True) -> bool:
516
+ """
517
+ Stream any iterable of G-code commands: a list, a generator, lines
518
+ arriving from a network socket... The source is consumed lazily, so
519
+ arbitrarily large jobs run in constant memory.
520
+
521
+ Progress reporting, in order of precedence:
522
+ 1. If the source exposes a percent() method (e.g. the internal
523
+ file source used by send_file), it is the progress authority.
524
+ 2. Otherwise, if total is given, progress is acked/total.
525
+ 3. Otherwise, a heartbeat fires every 100 acked commands with
526
+ percent=-1.
527
+
528
+ Args:
529
+ commands: iterable of command strings. Comments and blank lines
530
+ are stripped automatically.
531
+ total: number of commands, if known.
532
+ completion_timeout: max seconds to wait for the machine to reach
533
+ Idle after the last command is acknowledged.
534
+ ack_timeout: max seconds to wait for a single ok/error.
535
+ stop_on_error: abort the job on the first GRBL error response.
536
+ wait_for_idle: if False, return as soon as all commands are
537
+ acknowledged, without waiting for motion to finish. Useful
538
+ for chaining chunks back-to-back.
539
+
540
+ Returns True on successful completion. Blocking: run it in its own
541
+ thread if the UI must stay responsive.
542
+ """
543
+ if self.state != State.IDLE:
544
+ raise RuntimeError(f'Cannot stream while in state {self.state.name}')
545
+ if total == 0:
546
+ self._emit('progress', (100, 'empty_job'))
547
+ return True
548
+
549
+ self._abort.clear()
550
+ self._paused.clear()
551
+ self._drain(self._ack_queue) # critical: stale 'ok's would corrupt counting
552
+ self._set_state(State.STREAMING)
553
+
554
+ # Source-provided progress (duck-typed), e.g. _FileSource.percent
555
+ percent_fn = getattr(commands, 'percent', None)
556
+
557
+ pending = deque() # byte counts of commands currently in GRBL's buffer
558
+ acked = 0
559
+ last_poll = 0.0
560
+ last_mark = -1 if (percent_fn or total) else 0
561
+ max_len = self.RX_BUFFER - self.RX_MARGIN
562
+ ok = True
563
+
564
+ try:
565
+ for cmd in self._clean(commands):
566
+ # Cooperative pause point
567
+ while self._paused.is_set() and not self._abort.is_set():
568
+ time.sleep(0.05)
569
+ if self._abort.is_set():
570
+ ok = False
571
+ break
572
+
573
+ need = len(cmd) + 1 # +1 for the trailing '\n'
574
+ if need > max_len:
575
+ # A single command larger than GRBL's RX buffer can never
576
+ # fit; skipping it (with an error event) beats deadlocking.
577
+ self._emit('error', f'COMMAND_TOO_LONG ({need} bytes): {cmd[:40]}...')
578
+ if stop_on_error:
579
+ ok = False
580
+ break
581
+ continue
582
+
583
+ # Wait for room in GRBL's RX buffer
584
+ while sum(pending) + need > max_len:
585
+ resp = self._wait_ack(ack_timeout)
586
+ if resp is None:
587
+ raise TimeoutError('GRBL is not responding (ack timeout)')
588
+ if pending:
589
+ pending.popleft()
590
+ acked += 1
591
+ if resp != 'ok' and stop_on_error:
592
+ self._abort.set()
593
+ last_mark = self._report(acked, total, percent_fn, cmd, last_mark)
594
+ if self._abort.is_set():
595
+ break
596
+ if self._abort.is_set():
597
+ ok = False
598
+ break
599
+
600
+ self.write_line(cmd)
601
+ pending.append(need)
602
+
603
+ # Real-time '?' status polling (consumes no RX buffer space)
604
+ now = time.time()
605
+ if now - last_poll > self.STATUS_INTERVAL:
606
+ self.realtime(b'?')
607
+ last_poll = now
608
+
609
+ # Drain the remaining acknowledgements
610
+ while pending and not self._abort.is_set():
611
+ resp = self._wait_ack(ack_timeout)
612
+ if resp is None:
613
+ raise TimeoutError('GRBL stopped responding while finishing')
614
+ pending.popleft()
615
+ acked += 1
616
+ last_mark = self._report(acked, total, percent_fn, '', last_mark)
617
+
618
+ # All commands acked; optionally wait for motion to finish
619
+ if ok and wait_for_idle and not self._abort.is_set():
620
+ ok = self._wait_idle(completion_timeout)
621
+
622
+ except (serial.SerialException, OSError) as e:
623
+ self._handle_disconnect(f'DEVICE_DISCONNECTED: {e}')
624
+ return False
625
+ except TimeoutError as e:
626
+ self._emit('error', str(e))
627
+ ok = False
628
+ finally:
629
+ if self.state in (State.STREAMING, State.PAUSED):
630
+ self._set_state(State.IDLE)
631
+
632
+ if ok:
633
+ self._emit('progress', (100, 'completed'))
634
+ return ok
635
+
636
+ def send_file(self, file_path: str, **kwargs) -> bool:
637
+ """
638
+ Stream a G-code file of any size. Single lazy pass, constant memory.
639
+
640
+ Progress is derived from bytes consumed vs file size (instant via
641
+ os.path.getsize): there is NO counting pass over the file, so even
642
+ multi-GB jobs start immediately. The approximation trails the truth
643
+ by only the handful of commands that fit in GRBL's 127-byte buffer.
644
+
645
+ Accepts the same keyword arguments as stream() (total is implicit).
646
+ """
647
+ return self.stream(_FileSource(file_path), **kwargs)
648
+
649
+ # ------------------------------------------------------------------
650
+ # Internal helpers
651
+ # ------------------------------------------------------------------
652
+ @classmethod
653
+ def _clean(cls, commands: Iterable[str]) -> Iterator[str]:
654
+ """Lazily strip comments and blank lines from a command source."""
655
+ for line in commands:
656
+ line = cls._PAREN_COMMENT_RE.sub('', line) # parenthesized comments
657
+ line = line.split(';', 1)[0].strip() # semicolon comments
658
+ if line:
659
+ yield line
660
+
661
+ def _wait_ack(self, timeout: float) -> str | None:
662
+ """Wait for one 'ok'/'error:N' with a hard timeout.
663
+ Abortable and sensitive to disconnection; never hangs."""
664
+ deadline = time.time() + timeout
665
+ while time.time() < deadline:
666
+ if self._abort.is_set() or self.state == State.DISCONNECTED:
667
+ return None
668
+ try:
669
+ return self._ack_queue.get(timeout=0.2)
670
+ except queue.Empty:
671
+ continue
672
+ return None
673
+
674
+ def _wait_idle(self, timeout: float) -> bool:
675
+ """Poll status until GRBL reports Idle (job physically complete)."""
676
+ deadline = time.time() + timeout
677
+ while time.time() < deadline:
678
+ if self._abort.is_set() or self.state == State.DISCONNECTED:
679
+ return False
680
+ try:
681
+ self.realtime(b'?')
682
+ except (serial.SerialException, OSError):
683
+ return False
684
+ time.sleep(0.4)
685
+ st = self.last_status.get('state', '')
686
+ fresh = time.time() - self.last_status.get('time', 0) < 2.0
687
+ if fresh and st == 'Idle':
688
+ return True
689
+ if fresh and st.startswith('Alarm'):
690
+ return False
691
+ return False
692
+
693
+ def _report(self, acked: int, total: int | None, percent_fn,
694
+ cmd: str, last_mark: int) -> int:
695
+ """Emit progress. Authority order: source percent() > acked/total >
696
+ heartbeat (percent=-1) every 100 acknowledged commands."""
697
+ if percent_fn:
698
+ pct = percent_fn()
699
+ if pct != last_mark:
700
+ self._emit('progress', (pct, cmd))
701
+ return pct
702
+ if total:
703
+ pct = int(acked * 100 / total)
704
+ if pct != last_mark and pct < 100:
705
+ self._emit('progress', (pct, cmd))
706
+ return pct
707
+ if acked - last_mark >= 100:
708
+ self._emit('progress', (-1, cmd))
709
+ return acked
710
+ return last_mark
711
+
712
+ @staticmethod
713
+ def _drain(q: queue.Queue):
714
+ """Discard all items currently in a queue."""
715
+ try:
716
+ while True:
717
+ q.get_nowait()
718
+ except queue.Empty:
719
+ pass
720
+
721
+ def _set_state(self, st: State):
722
+ with self._state_lock:
723
+ if self.state == st:
724
+ return
725
+ self.state = st
726
+ self._emit('state', st)
727
+
728
+ def _emit(self, etype: str, data):
729
+ """Queue an event for the callback thread. Low-value events (raw
730
+ traffic, progress) are dropped if the queue is full; safety-relevant
731
+ events evict the oldest item instead — they are never lost."""
732
+ try:
733
+ self._event_queue.put_nowait((etype, data))
734
+ except queue.Full:
735
+ if etype in self._CRITICAL_EVENTS:
736
+ try:
737
+ self._event_queue.get_nowait()
738
+ self._event_queue.put_nowait((etype, data))
739
+ except (queue.Empty, queue.Full):
740
+ pass
@@ -0,0 +1,175 @@
1
+ Metadata-Version: 2.4
2
+ Name: pygrbl_streamer
3
+ Version: 0.0.1
4
+ Summary: Thread-safe, fault-tolerant G-code streamer for GRBL controllers.
5
+ Author: Beltrán Offerrall
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/offerrall/PyGrbl_Streamer
8
+ Project-URL: Repository, https://github.com/offerrall/PyGrbl_Streamer
9
+ Project-URL: Issues, https://github.com/offerrall/PyGrbl_Streamer/issues
10
+ Keywords: grbl,gcode,cnc,laser,serial,streaming
11
+ Requires-Python: >=3.10
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Requires-Dist: pyserial>=3.5
15
+ Dynamic: license-file
16
+
17
+ # PyGrbl_Streamer
18
+
19
+ Robust, source-agnostic G-code streamer for GRBL controllers over serial.
20
+
21
+ > **v0.0.1** — Complete rewrite. The API is not compatible with previous internal versions and may change before 0.1.0.
22
+
23
+ ## Features
24
+
25
+ - Stream from any source: lists, generators, files, network — `stream()` accepts any iterable of commands
26
+ - Constant memory and instant start: files of any size are read lazily in a single pass — no preloading, no counting pass
27
+ - Zero-cost progress: file progress is derived from bytes consumed vs file size, accurate to within a few commands
28
+ - Character-counting streaming protocol against GRBL's 128-byte RX buffer
29
+ - Clean connect/disconnect lifecycle — threads are joined, nothing hangs
30
+ - Physical disconnection detection with automatic reconnect support
31
+ - Real-time job control: pause, resume, stop
32
+ - Event callbacks for progress, state changes, alarms, errors, raw I/O, and internal diagnostics
33
+ - Every blocking wait is bounded by a timeout
34
+ - Lightweight: runs multiple machines concurrently on a Raspberry Pi
35
+
36
+ ## Installation
37
+
38
+ ```bash
39
+ pip install pygrbl_streamer
40
+ ```
41
+
42
+ Requires Python 3.10+ and `pyserial`.
43
+
44
+ ## Quick start
45
+
46
+ ```python
47
+ from pygrbl_streamer import GrblStreamer
48
+
49
+ g = GrblStreamer(port='/dev/ttyUSB0') # 'COM3' on Windows
50
+ g.progress_callback = lambda pct, cmd: print(f'{pct}%')
51
+
52
+ g.connect()
53
+ g.send_file('job.gcode') # any size, constant memory, starts instantly
54
+ g.disconnect()
55
+ ```
56
+
57
+ ## Streaming from any source
58
+
59
+ `stream()` consumes commands lazily from any iterable. Your application decides where the G-code comes from:
60
+
61
+ ```python
62
+ def square(size=10, power=300, feed=1000):
63
+ yield 'G90 G21'
64
+ yield f'M4 S{power}'
65
+ yield f'G1 X{size} F{feed}'
66
+ yield f'G1 Y{size}'
67
+ yield 'G1 X0'
68
+ yield 'G1 Y0'
69
+ yield 'M5'
70
+
71
+ g.stream(square(), total=7)
72
+ ```
73
+
74
+ Chain chunks back-to-back without stopping the machine between them:
75
+
76
+ ```python
77
+ g.stream(chunk_1, wait_for_idle=False)
78
+ g.stream(chunk_2, wait_for_idle=False)
79
+ g.stream(final_chunk) # only the last chunk waits for Idle
80
+ ```
81
+
82
+ ## Progress reporting
83
+
84
+ `progress_callback(percent, command)` fires on *acknowledged* commands. The percentage source, in order of precedence:
85
+
86
+ 1. **Source-provided** — if your iterable exposes a `percent()` method returning 0–100, it is the authority. `send_file()` uses this internally (bytes read vs file size).
87
+ 2. **`total`** — pass the command count to `stream()` for exact 0–100%.
88
+ 3. **Heartbeat** — with neither, the callback fires every 100 acked commands with `percent=-1`.
89
+
90
+ ## Job control
91
+
92
+ Streaming calls are blocking. Run them in a thread to control the job from elsewhere:
93
+
94
+ ```python
95
+ import threading
96
+
97
+ threading.Thread(target=g.send_file, args=('job.gcode',)).start()
98
+
99
+ g.pause() # immediate feed hold (!)
100
+ g.resume() # cycle start (~)
101
+ g.stop() # abort: feed hold + soft reset
102
+ ```
103
+
104
+ ## API overview
105
+
106
+ | Method | Description |
107
+ |---|---|
108
+ | `connect()` / `disconnect()` | open/close the session; safe to call repeatedly |
109
+ | `stream(commands, total=None, ...)` | stream any iterable of commands |
110
+ | `send_file(path, ...)` | stream a file lazily; same options as `stream()` |
111
+ | `command(cmd)` | send one command interactively, wait for ok/error |
112
+ | `pause()` / `resume()` / `stop()` | real-time job control |
113
+ | `unlock()` / `home()` | `$X` / `$H` |
114
+ | `reconnect(retries, delay)` | retry loop after a physical disconnect |
115
+
116
+ ## Callbacks
117
+
118
+ Assign as attributes or override in a subclass. All callbacks run on a dedicated thread and can never block serial communication. If one of your callbacks raises, the exception is reported through `log_callback` instead of being silently swallowed.
119
+
120
+ | Callback | Signature | Fires on |
121
+ |---|---|---|
122
+ | `progress_callback` | `(percent, command)` | acknowledged command progress (`-1` for unbounded streams) |
123
+ | `state_callback` | `(state)` | state machine transitions |
124
+ | `alarm_callback` | `(line)` | GRBL `ALARM:n` |
125
+ | `error_callback` | `(line)` | GRBL `error:n` or internal errors |
126
+ | `send_callback` / `receive_callback` | `(data)` | raw serial traffic |
127
+ | `disconnect_callback` | `(reason)` | physical disconnection |
128
+ | `log_callback` | `(level, message)` | internal diagnostics (`'debug'`/`'info'`/`'warning'`) |
129
+
130
+ ### Logging integration
131
+
132
+ The library imposes no logging framework. Wire the callbacks to Python's standard `logging` in your application:
133
+
134
+ ```python
135
+ import logging
136
+ log = logging.getLogger('laser1')
137
+
138
+ g.log_callback = lambda lv, m: getattr(log, lv)(m)
139
+ g.error_callback = lambda l: log.warning('GRBL error: %s', l)
140
+ g.alarm_callback = lambda l: log.error('ALARM: %s', l)
141
+ g.disconnect_callback = lambda r: log.critical('disconnected: %s', r)
142
+ g.receive_callback = lambda l: log.debug('<< %s', l)
143
+ g.send_callback = lambda d: log.debug('>> %s', d.strip())
144
+ ```
145
+
146
+ ## States
147
+
148
+ `DISCONNECTED → CONNECTING → IDLE ⇄ STREAMING ⇄ PAUSED`, plus `ALARM`.
149
+
150
+ An alarm aborts the running job and is **never cleared automatically** — call `unlock()` explicitly. After `stop()`, machine position is untrusted: run `home()` before the next job.
151
+
152
+ ## Compatibility
153
+
154
+ Works with any GRBL 1.1 (or compatible, e.g. grblHAL) controller: diode laser engravers, CNC routers, pen plotters, drag-knife cutters.
155
+
156
+ Not supported: Ruida-based CO2 lasers, galvo fiber lasers (EZCad/BJJCZ controllers — entirely different protocol), and Marlin-based machines (no character-counting buffer or real-time commands).
157
+
158
+ I use this library daily in production, driving several lasers concurrently from a Raspberry Pi 4. Tested so far on:
159
+
160
+ - Acmer P1S
161
+ - Acmer P2
162
+ - Longer Ray5 20W
163
+ - AtomStack A24 Pro
164
+
165
+ Reports of it working (or not) on other machines are welcome via issues.
166
+
167
+ ## Safety notes
168
+
169
+ - Laser users: verify `$32=1` (laser mode) so the beam is disabled during feed hold.
170
+ - Commands longer than GRBL's RX buffer (127 chars) are skipped with an error event instead of deadlocking the stream.
171
+ - This library streams G-code; it does not validate it. Garbage in, garbage out.
172
+
173
+ ## License
174
+
175
+ MIT
@@ -0,0 +1,8 @@
1
+ pygrbl_streamer/__init__.py,sha256=jyBCSl16lCt4LrB5BNnNyw9Yz3mRNNkPV2jeZhQcQ_8,170
2
+ pygrbl_streamer/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ pygrbl_streamer/streamer.py,sha256=aNWp868Cn93EzUD-shsNblRzO3QGq7LGx4GR0NdkVQk,31301
4
+ pygrbl_streamer-0.0.1.dist-info/licenses/LICENSE,sha256=liUk2BzmZqiPFhkGw2uyovf3B5FiFIAH5GIBCUnnpe0,1096
5
+ pygrbl_streamer-0.0.1.dist-info/METADATA,sha256=_jb_XQO_mVIhWtClHovbqy7kcFJ8rk5gcv8se-UReFg,6729
6
+ pygrbl_streamer-0.0.1.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
7
+ pygrbl_streamer-0.0.1.dist-info/top_level.txt,sha256=xwvviklulHPViQY0PA0vO0-2-ECLq2klS4SM1Oea_H0,16
8
+ pygrbl_streamer-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Beltrán Offerrall
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ pygrbl_streamer