An interactive GRBL g-code streamer for the terminal. Streams using GRBL's
character-counting (buffer-fill) protocol to keep the planner fed on fast jobs,
with a live status line, real-time overrides, an MDI mode, and configurable
display — all in a single small package with one dependency (pyserial).
The recommended way is pipx (or uv tool), which
installs into an isolated environment and puts a gcgo command on your PATH —
this also avoids the "externally-managed-environment" error on Raspberry Pi OS
and other recent distros:
pipx install git+https://github.com/chamnit/gcgo
# or, once published to PyPI:
pipx install gcgo
From source (development):
git clone https://github.com/chamnit/gcgo
cd gcgo
python -m gcgo /dev/ttyACM0 # or: pip install -e .
Linux serial permissions: you must be in the dialout group to open the
port, otherwise you'll get a permission error:
sudo usermod -aG dialout $USER # then log out and back in
gcgo # auto-detect the port (prompts if there's more than one)
gcgo /dev/ttyACM0 # specify the port
gcgo -b 115200 ... # override the baud rate (default 115200)
At the gcgo> prompt:
load <file> Load a g-code file
run Stream the loaded file
mdi Enter MDI mode (send g-code/$-commands directly to GRBL)
settings Show GRBL $$ settings in readable form
(caPy: settings GROUP for one group, settings all for every setting)
params Show GRBL $# coordinate parameters
unlock Unlock GRBL alarm state ($X)
home Run homing cycle ($H)
check Toggle check mode ($C)
config Configure status fields, poll rate, units, and stream keys
reset Soft-reset GRBL (Ctrl-X); restores overrides to 100%
status Query GRBL status (?)
ports List available serial ports
ls / cd List files / change directory
help Show the command list
quit / exit Exit
Tab completes commands and file paths. Command history persists between runs.
load a file, then run. While streaming, a live status line at the bottom
shows state, position, feed, spindle, overrides, and pins, while sent g-code
and GRBL messages scroll above. Single keypresses send real-time commands —
by default:
! feed hold ~ cycle start / resume
+ feed +10% - feed -10% 0 feed reset 100% q stop
q (or Ctrl-C) performs a true stop: it feed-holds, then soft-resets to halt
motion and flush GRBL's buffers — stopping only the stream would leave GRBL
running its already-buffered moves.
config manages everything, persisted to ~/.config/gcgo/config.json
(~/Library/Application Support/gcgo/ on macOS):
config Show all settings
config fields <name> on|off Toggle a status-line field
config rate <seconds> Status query interval (0 disables)
config units <mm|inch> Display units for positions and feed
config after <keep|clear> Keep or unload the file after a completed run
config keys <action> <char> Bind a streaming real-time key (or 'off' to disable)
Units note: gcgo keeps the controller reporting in mm (it sends $13=0 on
connect and re-asserts it if you change $13 in MDI) and converts to the
configured display units itself, so displayed values always match their labels.
This also works on firmware with no $13, such as caPy, which reports mm only.
gcgo is split so the GRBL "brain" is shared across platforms:
gcgo/core/— platform-agnostic logic (protocol/streaming pump, status, config, gcode, clock). No platform imports; runs on CPython and MicroPython.gcgo/ports/— adapter contracts (e.g. the byte-levelTransport).gcgo/desktop/— CPython adapters: pyserial transport, terminal display, termios/readline keyboard, paths.gcgo/frontends/— front-ends that drive the core (terminal today).gcgo/micropython/—machine.UARTtransport + a minimal serial-console front-end.
Streaming is a single-threaded non-blocking pump(), so the same core drops
into a desktop loop or an MCU loop unchanged.
Continuous jogging uses caPy's jog mode (bare $J, then U/V/S/T
lines; see caPy's doc/jog/README.md), or short $J= lines on GRBL (below).
gcgo/core/jog.py's JogSession is the only code that talks to the
controller; input devices never write to the wire. An input module reads its
device and calls one of:
jog.vel("gamepad", vx, vy, vz) # mm/s, every time the device is read
jog.step("Z", 0.1, feed=300) # handwheel detents, step buttons
jog.target(x=0, y=0, feed=3000) # go-to (machine coordinates)
jog.target(x=px, vx=pv) # a moving target (follower), every reading
jog.release("gamepad") # device goneand the driver loop calls jog.tick() each pass. The session enters jog mode
when asked (begin()) and leaves on end(). It sends only the newest
velocity, as a U line naming every moving axis, and repeats it inside caPy's
per-axis dead-man (read from $jog.keepalive and $jog.cover on entry). So a
stick never disturbs a step or target on another axis: a stick on X/Y and a
handwheel on Z run together. Each input owns the axes it pushes, and an input
or moving target that stops refreshing is zeroed after its TTL. On a caPy
without U (before 907d191) it falls back to V, and steps and targets wait
until no velocity is live.
On GRBL, which has no jog mode, the bare $J is answered error:3 and the
session switches itself to short $J= lines; the inputs don't change:
- a velocity goes out as
$J=G91blocks of 50 ms of motion each, with at most 200 ms queued, so GRBL can only reach the speed it can stop within that queue (roughly 0.4 × acceleration, in mm/s) - letting go, an axis reversing, or an input that goes quiet sends
0x85, which brakes and flushes GRBL's planner - steps are
$J=G91lines, and targets (moving ones included) are$J=G53go-tos to the newest position, paced like a velocity - GRBL accepts and then discards a
$J=that arrives while a cancel is braking, so after a0x85nothing goes out until fresh status reports show it out of Jog; a line still unanswered when the0x85went out gets a second0x85once it is answered R(rate) scales speeds in gcgo, andC(coordinated) doesn't apply
Following. gcgo/core/follow.py's Follower makes the machine follow a
point that an input moves:
f = Follower(jog) # axes="XY" by default
f.start(status.mpos) # take hold: offsets are from here
f.move(dx, dy, feed=1000) # every reading; it is also the heartbeat
f.end(feed=1000) # let go: a last go-to to the final pointIt estimates the point's velocity from the readings (smoothed over 50 ms, on the input's own clock when the reading carries one) and sends moving targets, so caPy extrapolates between readings instead of chasing the last one. A point at rest becomes a single go-to; a follower that stops reading is braked by the moving target's TTL.
gcgo/core/jogmap.py holds the shared feel: stick_to_velocity (radial
deadband, square-law magnitude, direction kept) and a shuttle-ring table. The
web UI has two jog inputs:
- Virtual sticks (X/Y and Z): speed grows with the drag, up to FEED; let go to stop.
- Follow pad: press and drag, and the machine follows the pointer in X/Y
(a
Follower), up to FEED, and stops where you let go. The pad edge is 10 × STEP mm from where the drag began, and a ring shows where the machine is.
The first drag enters jog mode, and the server leaves jog mode after 3 s at rest or when MDI or a run needs the machine. Both work on caPy and GRBL.
Gamepad (Linux, desktop web server): python -m gcgo.desktop.webmain <port> --gamepad [DEV] [--gamepad-feed 3000]. The bindings are in
gcgo/core/gamepad.py (GamepadJog, no I/O), and the reader is in
gcgo/desktop/gamepad_linux.py (the kernel joystick API, standard library only,
replug-safe). Xbox-style layout:
| control | does |
|---|---|
| left stick | X/Y velocity: direction kept, speed by deflection (square law) |
| right stick | Z velocity, at half the X/Y feed |
| d-pad / Y, A | step X/Y / Z+, Z- by the step size |
| X | cycle the step size (0.01, 0.1, 1, 10 mm) |
| LB / RB | slower / faster speed range (5, 20, 50, 100 % of the feed) |
| B | stop: brake and leave jog mode (the sticks need centring before they jog again) |
Local UI wheel (OLED pendant): each encoder detent is a jog.step(), on
caPy and GRBL alike. Detents add up and land exactly, and a wheel spun faster
than the axis can follow drops detents once the axis is about 2 detents (at
least 2 mm) behind, so the axis stops when the hand stops.
Every input has the same shape: an object whose poll(jog) is called on
every driver pass (serve(..., inputs=[...])). A different device needs only a
reader that returns the same snapshot (axes, buttons, pressed).
caPy publishes its wire definitions as CSV (doc/csv in the caPy repo). After
pulling caPy, run
python3 tools/sync_capy.py # reads ../caPy/doc/csv
It regenerates the error/alarm text gcgo shows (gcgo/core/codes.py for the
terminal, static/codes.json for the web console) and what each setting is
(static/settings.json: caPy's kind, range, default and meaning, and GRBL's
$N names, for the web settings editor, which reads the values themselves
from the controller), and fails, naming them, if caPy now sends a machine
state or status-report field gcgo doesn't handle.
python3 -m pytest
The tests run the real Streamer over a fake serial port, with a clock the
tests advance by hand, so they need no hardware and don't sleep. The jog tests
cover both caPy's jog mode and the GRBL fallback. The Linux
gamepad reader tests use a virtual pad through /dev/uinput and skip
themselves where that isn't writable.
gcgo's core runs on MicroPython 1.2x. A board can act as a standalone GRBL sender: it talks to the GRBL controller over a UART and gives you a serial console.
Deploy and run (using mpremote):
mpremote connect /dev/ttyACM0 cp -r gcgo : # copy the package to the board
mpremote connect /dev/ttyACM0 exec "from gcgo.micropython.main import start; start(uart_id=1, baud=115200)"
uart_id/pins are board-specific — pass what your board needs (e.g.
start(1, tx=4, rx=5) on many ESP32s). Wire the board's UART TX/RX/GND to the
GRBL controller's serial pins.
G-code is streamed from a file on demand (nothing is loaded into RAM), so file size isn't limited by memory — put jobs on an SD card or flash.
A WiFi board can serve a browser UI with no display or keyboard: live DRO,
jog pad with selectable step/feed, work-zero, all feed/rapid/spindle
overrides and coolant toggles, a sent/received console with MDI, file
management (browse, make folders, upload, download, run/stop, delete), and a
separate settings page (units, poll rate, after-run, which override groups to
show, and an editor for the controller's own settings: caPy's $settings,
grouped, with dropdowns for choices and ranges checked before sending, or
GRBL's $$; caPy changes are kept with Save to flash, $save). The whole stack is a dependency-free uasyncio HTTP + WebSocket server
sharing the same core. Verified on a Raspberry Pi Pico W with GRBL on UART0
(GP16/17) and a hardware reset line on GP18, through level shifters:
mpremote connect /dev/ttyACM0 cp -r gcgo :
# put WIFI_SSID / WIFI_PASS in a secrets.py on the board, then:
mpremote connect /dev/ttyACM0 exec "from gcgo.micropython.webmain import start; start()"
It prints the board's IP; open http://<board-ip>/. The desktop build serves
the identical UI via python -m gcgo.desktop.webmain <port>.
Wiring note: on an ATmega Arduino, the USB port and pins 0/1 are the same UART — don't drive the board from USB and the Pico's UART at once (bus contention). Use one at a time.
Boards: developed and verified on the Pico W (RP2040). The Pico 2 W
(RP2350) is pin-compatible and uses the same CYW43439 WiFi chip, so the wiring
and the deploy steps above are unchanged — just flash the Pico 2 W MicroPython
build. It has more SRAM (≈520 KB vs 264 KB), so even more headroom. The code
uses no pulled-down GPIO inputs, so the RP2350 input-pulldown erratum (E9)
doesn't apply to this wiring. Any MicroPython board with WiFi, a UART, and
asyncio (e.g. ESP32) should work; pass board-specific UART/pins to start().
- Python 3.11+ and
pyserial(desktop), or MicroPython 1.2x (board) - For the web pendant: a WiFi MicroPython board (Pico W or Pico 2 W; ESP32 also works)
- A GRBL 1.1 controller (e.g. an Arduino running GRBL) or a GRBL-compatible one such as caPy
MIT — see LICENSE.