pygrbl-streamer 0.0.1__tar.gz
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.
- pygrbl_streamer-0.0.1/LICENSE +21 -0
- pygrbl_streamer-0.0.1/PKG-INFO +175 -0
- pygrbl_streamer-0.0.1/pyproject.toml +32 -0
- pygrbl_streamer-0.0.1/readme.md +159 -0
- pygrbl_streamer-0.0.1/setup.cfg +4 -0
- pygrbl_streamer-0.0.1/src/pygrbl_streamer/__init__.py +6 -0
- pygrbl_streamer-0.0.1/src/pygrbl_streamer/py.typed +0 -0
- pygrbl_streamer-0.0.1/src/pygrbl_streamer/streamer.py +740 -0
- pygrbl_streamer-0.0.1/src/pygrbl_streamer.egg-info/PKG-INFO +175 -0
- pygrbl_streamer-0.0.1/src/pygrbl_streamer.egg-info/SOURCES.txt +11 -0
- pygrbl_streamer-0.0.1/src/pygrbl_streamer.egg-info/dependency_links.txt +1 -0
- pygrbl_streamer-0.0.1/src/pygrbl_streamer.egg-info/requires.txt +1 -0
- pygrbl_streamer-0.0.1/src/pygrbl_streamer.egg-info/top_level.txt +1 -0
|
@@ -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,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,32 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pygrbl_streamer"
|
|
7
|
+
version = "0.0.1"
|
|
8
|
+
description = "Thread-safe, fault-tolerant G-code streamer for GRBL controllers."
|
|
9
|
+
readme = "readme.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [
|
|
14
|
+
{ name = "Beltrán Offerrall" },
|
|
15
|
+
]
|
|
16
|
+
keywords = ["grbl", "gcode", "cnc", "laser", "serial", "streaming"]
|
|
17
|
+
|
|
18
|
+
dependencies = [
|
|
19
|
+
"pyserial>=3.5",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Homepage = "https://github.com/offerrall/PyGrbl_Streamer"
|
|
24
|
+
Repository = "https://github.com/offerrall/PyGrbl_Streamer"
|
|
25
|
+
Issues = "https://github.com/offerrall/PyGrbl_Streamer/issues"
|
|
26
|
+
|
|
27
|
+
[tool.setuptools]
|
|
28
|
+
package-dir = {"" = "src"}
|
|
29
|
+
packages = ["pygrbl_streamer"]
|
|
30
|
+
|
|
31
|
+
[tool.setuptools.package-data]
|
|
32
|
+
pygrbl_streamer = ["py.typed"]
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# PyGrbl_Streamer
|
|
2
|
+
|
|
3
|
+
Robust, source-agnostic G-code streamer for GRBL controllers over serial.
|
|
4
|
+
|
|
5
|
+
> **v0.0.1** — Complete rewrite. The API is not compatible with previous internal versions and may change before 0.1.0.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- Stream from any source: lists, generators, files, network — `stream()` accepts any iterable of commands
|
|
10
|
+
- Constant memory and instant start: files of any size are read lazily in a single pass — no preloading, no counting pass
|
|
11
|
+
- Zero-cost progress: file progress is derived from bytes consumed vs file size, accurate to within a few commands
|
|
12
|
+
- Character-counting streaming protocol against GRBL's 128-byte RX buffer
|
|
13
|
+
- Clean connect/disconnect lifecycle — threads are joined, nothing hangs
|
|
14
|
+
- Physical disconnection detection with automatic reconnect support
|
|
15
|
+
- Real-time job control: pause, resume, stop
|
|
16
|
+
- Event callbacks for progress, state changes, alarms, errors, raw I/O, and internal diagnostics
|
|
17
|
+
- Every blocking wait is bounded by a timeout
|
|
18
|
+
- Lightweight: runs multiple machines concurrently on a Raspberry Pi
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install pygrbl_streamer
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Requires Python 3.10+ and `pyserial`.
|
|
27
|
+
|
|
28
|
+
## Quick start
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
from pygrbl_streamer import GrblStreamer
|
|
32
|
+
|
|
33
|
+
g = GrblStreamer(port='/dev/ttyUSB0') # 'COM3' on Windows
|
|
34
|
+
g.progress_callback = lambda pct, cmd: print(f'{pct}%')
|
|
35
|
+
|
|
36
|
+
g.connect()
|
|
37
|
+
g.send_file('job.gcode') # any size, constant memory, starts instantly
|
|
38
|
+
g.disconnect()
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Streaming from any source
|
|
42
|
+
|
|
43
|
+
`stream()` consumes commands lazily from any iterable. Your application decides where the G-code comes from:
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
def square(size=10, power=300, feed=1000):
|
|
47
|
+
yield 'G90 G21'
|
|
48
|
+
yield f'M4 S{power}'
|
|
49
|
+
yield f'G1 X{size} F{feed}'
|
|
50
|
+
yield f'G1 Y{size}'
|
|
51
|
+
yield 'G1 X0'
|
|
52
|
+
yield 'G1 Y0'
|
|
53
|
+
yield 'M5'
|
|
54
|
+
|
|
55
|
+
g.stream(square(), total=7)
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Chain chunks back-to-back without stopping the machine between them:
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
g.stream(chunk_1, wait_for_idle=False)
|
|
62
|
+
g.stream(chunk_2, wait_for_idle=False)
|
|
63
|
+
g.stream(final_chunk) # only the last chunk waits for Idle
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Progress reporting
|
|
67
|
+
|
|
68
|
+
`progress_callback(percent, command)` fires on *acknowledged* commands. The percentage source, in order of precedence:
|
|
69
|
+
|
|
70
|
+
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).
|
|
71
|
+
2. **`total`** — pass the command count to `stream()` for exact 0–100%.
|
|
72
|
+
3. **Heartbeat** — with neither, the callback fires every 100 acked commands with `percent=-1`.
|
|
73
|
+
|
|
74
|
+
## Job control
|
|
75
|
+
|
|
76
|
+
Streaming calls are blocking. Run them in a thread to control the job from elsewhere:
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
import threading
|
|
80
|
+
|
|
81
|
+
threading.Thread(target=g.send_file, args=('job.gcode',)).start()
|
|
82
|
+
|
|
83
|
+
g.pause() # immediate feed hold (!)
|
|
84
|
+
g.resume() # cycle start (~)
|
|
85
|
+
g.stop() # abort: feed hold + soft reset
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## API overview
|
|
89
|
+
|
|
90
|
+
| Method | Description |
|
|
91
|
+
|---|---|
|
|
92
|
+
| `connect()` / `disconnect()` | open/close the session; safe to call repeatedly |
|
|
93
|
+
| `stream(commands, total=None, ...)` | stream any iterable of commands |
|
|
94
|
+
| `send_file(path, ...)` | stream a file lazily; same options as `stream()` |
|
|
95
|
+
| `command(cmd)` | send one command interactively, wait for ok/error |
|
|
96
|
+
| `pause()` / `resume()` / `stop()` | real-time job control |
|
|
97
|
+
| `unlock()` / `home()` | `$X` / `$H` |
|
|
98
|
+
| `reconnect(retries, delay)` | retry loop after a physical disconnect |
|
|
99
|
+
|
|
100
|
+
## Callbacks
|
|
101
|
+
|
|
102
|
+
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.
|
|
103
|
+
|
|
104
|
+
| Callback | Signature | Fires on |
|
|
105
|
+
|---|---|---|
|
|
106
|
+
| `progress_callback` | `(percent, command)` | acknowledged command progress (`-1` for unbounded streams) |
|
|
107
|
+
| `state_callback` | `(state)` | state machine transitions |
|
|
108
|
+
| `alarm_callback` | `(line)` | GRBL `ALARM:n` |
|
|
109
|
+
| `error_callback` | `(line)` | GRBL `error:n` or internal errors |
|
|
110
|
+
| `send_callback` / `receive_callback` | `(data)` | raw serial traffic |
|
|
111
|
+
| `disconnect_callback` | `(reason)` | physical disconnection |
|
|
112
|
+
| `log_callback` | `(level, message)` | internal diagnostics (`'debug'`/`'info'`/`'warning'`) |
|
|
113
|
+
|
|
114
|
+
### Logging integration
|
|
115
|
+
|
|
116
|
+
The library imposes no logging framework. Wire the callbacks to Python's standard `logging` in your application:
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
import logging
|
|
120
|
+
log = logging.getLogger('laser1')
|
|
121
|
+
|
|
122
|
+
g.log_callback = lambda lv, m: getattr(log, lv)(m)
|
|
123
|
+
g.error_callback = lambda l: log.warning('GRBL error: %s', l)
|
|
124
|
+
g.alarm_callback = lambda l: log.error('ALARM: %s', l)
|
|
125
|
+
g.disconnect_callback = lambda r: log.critical('disconnected: %s', r)
|
|
126
|
+
g.receive_callback = lambda l: log.debug('<< %s', l)
|
|
127
|
+
g.send_callback = lambda d: log.debug('>> %s', d.strip())
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## States
|
|
131
|
+
|
|
132
|
+
`DISCONNECTED → CONNECTING → IDLE ⇄ STREAMING ⇄ PAUSED`, plus `ALARM`.
|
|
133
|
+
|
|
134
|
+
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.
|
|
135
|
+
|
|
136
|
+
## Compatibility
|
|
137
|
+
|
|
138
|
+
Works with any GRBL 1.1 (or compatible, e.g. grblHAL) controller: diode laser engravers, CNC routers, pen plotters, drag-knife cutters.
|
|
139
|
+
|
|
140
|
+
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).
|
|
141
|
+
|
|
142
|
+
I use this library daily in production, driving several lasers concurrently from a Raspberry Pi 4. Tested so far on:
|
|
143
|
+
|
|
144
|
+
- Acmer P1S
|
|
145
|
+
- Acmer P2
|
|
146
|
+
- Longer Ray5 20W
|
|
147
|
+
- AtomStack A24 Pro
|
|
148
|
+
|
|
149
|
+
Reports of it working (or not) on other machines are welcome via issues.
|
|
150
|
+
|
|
151
|
+
## Safety notes
|
|
152
|
+
|
|
153
|
+
- Laser users: verify `$32=1` (laser mode) so the beam is disabled during feed hold.
|
|
154
|
+
- Commands longer than GRBL's RX buffer (127 chars) are skipped with an error event instead of deadlocking the stream.
|
|
155
|
+
- This library streams G-code; it does not validate it. Garbage in, garbage out.
|
|
156
|
+
|
|
157
|
+
## License
|
|
158
|
+
|
|
159
|
+
MIT
|
|
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,11 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
pyproject.toml
|
|
3
|
+
readme.md
|
|
4
|
+
src/pygrbl_streamer/__init__.py
|
|
5
|
+
src/pygrbl_streamer/py.typed
|
|
6
|
+
src/pygrbl_streamer/streamer.py
|
|
7
|
+
src/pygrbl_streamer.egg-info/PKG-INFO
|
|
8
|
+
src/pygrbl_streamer.egg-info/SOURCES.txt
|
|
9
|
+
src/pygrbl_streamer.egg-info/dependency_links.txt
|
|
10
|
+
src/pygrbl_streamer.egg-info/requires.txt
|
|
11
|
+
src/pygrbl_streamer.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pyserial>=3.5
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pygrbl_streamer
|