Skip to content

CNC GRBL Module

The cnc_grbl module is the GRBL 1.1 firmware integration layer for the PiBot CNC pendant. It implements the ESP3DGCodeHandlerService interface specifically for the GRBL 1.1 protocol, handling bidirectional communication with a connected GRBL controller over any transport (serial UART, USB serial, Bluetooth SPP/BLE, or TCP socket).

The module is responsible for:

  • Protocol parsing — decoding every response type produced by GRBL 1.1 (status reports, ok acknowledgements, error/alarm codes, settings, parser-state, probe results, firmware version)
  • Command emission — sending G-code and GRBL realtime bytes through the unified message pipeline with correct priority and formatting
  • Connection lifecycle — managing a 4-phase startup sequence that configures GRBL and populates the UI observables
  • Status polling — issuing periodic ? queries because GRBL has no firmware-side auto-reporting
  • Flow-control integration — exposing planner-block and RX-buffer capacity to the streaming flow-control gates in cnc_gcode_host_flow
  • Message history — maintaining a thread-safe ring buffer of firmware INFO/ERROR messages for display in the firmware-status screen

This module is one of three CNC target implementations. For the companion variants see cnc_fluidnc.md and cnc_grblhal.md. The abstract (null) interface that every target must satisfy is in main/target/none/esp3d_gcode_handler_service.h.


CNC Firmware Integration

Core Platform

UI Framework (LVGL - Core 1)

reads

reads

writes

sendGcode → dispatch

routes

flow gate

queries capacity

reads

sends bytes

RX bytes

Communication Transports

Serial / USB Serial

BT Serial / BLE

TCP Socket Client

grbl_module screens

(status, jog, probe, files, change_tool)

Shared CNC screens

(main, settings, macros, jog, status)

ESP3DValues

(observable store)

ESP3DCommands

(dispatch)

ESP3DSettings

(NVS)

ESP3DGCodeHostService

(gcode_host)

gcodeHostFlow

(cnc_gcode_host_flow)

ESP3DGCodeHandlerService

(cnc_grbl - this module)

stores

addStream macros

publishes state

queries capacity

1

0..*

ESP3DGCodeHandlerService

-_startup_commands_phase uint8_t

-_startup_reset_generation atomic_uint32

-_stream_planner_blocks uint8_t

-_stream_rx_bytes uint16_t

-_stream_rx_buffer_size uint16_t

-_message_history deque<FirmwareMessage>

-_message_history_mutex pthread_mutex_t

+begin() : bool

+handle()

+end()

+sendGcode(data, origin, requestId, cmdType, priority) : bool

+processCommand(data) : bool

+getType(data) : ESP3DDataType

+hasAck(command) : bool

+sendStartupCommands() : bool

+sendPingCommand() : bool

+resetStartupCommandsSent()

+updateReportingInterval(interval) : bool

+runMacroFile(path) : bool

+getStreamPlannerBlocksFree() : uint8_t

+getStreamRxBytesFree() : uint16_t

+getStreamRxBufferSize() : uint16_t

+getStreamMaxInFlight() : uint8_t

+addMessageToHistory(type, content)

+getMessageHistory() : vector<FirmwareMessage>

+clearMessageHistory()

FirmwareMessage

+type FirmwareMessageType

+content string

ESP3DGCodeHostService

+addStream(path, auth, asMacro) : bool

+process(msg)

gcodeHostFlow

+gcodeHostFlowCanSendLine(len) : bool

+gcodeHostFlowOnAck()

+gcodeHostFlowOnLineSent(len)

ESP3DValues

+set_value(index, value)

+get_value(index) : const char


FileRole
main/target/cnc/grbl/esp3d_gcode_handler_service.hPublic interface, type definitions, GRBL realtime command macros
main/target/cnc/grbl/esp3d_gcode_handler_service.cppFull implementation of all parsing, dispatch, and lifecycle logic

The global singleton is esp3dGcodeHandler (defined in the .cpp).


Realtime commands are single-byte values sent without a newline and without waiting for an ok. They are processed immediately by GRBL’s interrupt handler.

GRBL Realtime Commands

Core

? Status report 0x3F

! Feed hold 0x21

~ Cycle start 0x7E

Ctrl-X Soft reset 0x18

Extended

0x83 Safety door toggle

0x84 Safety door

0x85 Jog cancel

Feed Overrides

0x90 100%

0x91 +10%

0x92 -10%

0x93 +1%

0x94 -1%

Rapid Overrides

0x95 100%

0x96 50%

0x97 25%

Spindle Overrides

0x99 100%

0x9A +10%

0x9B -10%

0x9C +1%

0x9D -1%

0x9E Stop

Coolant Overrides

0xA0 Flood toggle

0xA1 Mist toggle

All constants are defined as string-literal macros (GRBL_RT_*) in the header and are passed directly to sendGcode() with cmdType = ESP3DCommandType::realtime.


All commands — realtime or normal — travel through the same unified pipeline. Priority and command type are orthogonal: a realtime command can be either high-priority (e.g., emergency stop) or normal-priority (e.g., ? status query that must respect queue order).

Output TransportgcodeHostFlowESP3DGCodeHostServiceESP3DCommands::dispatch()sendGcode()Screen / HandlerOutput TransportgcodeHostFlowESP3DGCodeHostServiceESP3DCommands::dispatch()sendGcode()Screen / Handleralt[priority == high][priority == normal]data, cmdType, priorityDetermine needsNewline\nrealtime=false, normal=append newline if missingAllocate ESP3DMessage\norigin to stream, set prioritydispatch(msg)process(msg)Forward immediately bypass flowgcodeHostFlowCanSendLine(len)Check in-flight cap max 4\nCheck planner blocks\nCheck RX windowSend when gates clearRX responseasync processCommand(response)

sendGcode() uses a 256-byte stack buffer (no heap allocation, no VLA) when a newline must be appended. Commands longer than 254 bytes are rejected with an error log. This satisfies the embedded memory safety rules: single bounded allocation, no malloc, no std::string in the hot path.


On first connection (or reconnect), sendStartupCommands() runs a sequential 4-phase initialization. Each call advances one phase per invocation to avoid blocking the LVGL task.

begin() called

status report, banner or $I reply

ok received

ok received

ok received

status parsed

resetStartupCommandsSent()\ntransport reconnect or Grbl banner

Wait for the controller\nserver_status == C

Phase 0 - Report mask\n$10=2\nWPos + Bf in every status

Phase 1 - Settings dump\n$$\nparses $22 homing enable, $13 report unit

Phase 2 - Parser state\n$G\nresolves unit G20/G21, WCS, M-codes

Phase 3 - First status\n?\npopulates positions + firmware state

startup_sent = true\nPolling active

a14 changes:

  • handle() first applies a pending controller session reset (gcodeHostService.consumeControllerSessionReset()) before any startup command of the tick is queued, so the purge can never drop the replayed startup sequence. The sequence then starts only once server_status is C (status report, welcome banner or $I reply), as in connection_management.md: nothing is sent blind to a silent line, where the commands would wait forever for their ok.
  • $10=2 (was $10=3): WPos plus buffer. Every reported value is rounded to 3 decimals, so a position computed from two rounded values (MPos − WCO) can be off by 0.001, for example right after G92 X0 on a half-micron position. The work position is the one zeroed and worked with, so it is reported exactly; MPos is computed (WPos + WCO).
  • $13 in the $$ dump gives the unit of the positions in status reports (report_unit), independent of G20/G21. Outgoing G-code is not parsed: the UI re-sends $G after its own modal commands and when the jog screen opens.
  • Grbl banner = new session: the controller restarted (power-up or soft reset) and discarded its buffer. The handler calls gcodeHostService.requestControllerSessionReset() (the host drops its in-flight state and any running job; applied on the host task, purge first), then resetStartupCommandsSent().

Thread-Safe Reset (Generation Counter Pattern)

Section titled “Thread-Safe Reset (Generation Counter Pattern)”

Any task (serial RX, BT connect, LVGL reconnect handler) may call resetStartupCommandsSent() at any time. The function only atomically increments a generation counter. The actual reset (_startup_commands_sent = false, _startup_commands_phase = 0) is applied by the host task at the start of sendStartupCommands(), between phase transitions, so the phase machine never observes a half-reset state.

esp3dGcodeHandlerGCode Host TaskAny Task RX or LVGLesp3dGcodeHandlerGCode Host TaskAny Task RX or LVGLresetStartupCommandsSent()\n_startup_reset_generation++sendStartupCommands() next tickgen != _startup_reset_applied\napplyStartupReset()\nphase=0, sent=falseSend Phase 0 $10=2

Before dispatching a received line, the gcode host calls getType() to classify it. This determines whether an ok credit is released, whether the line routes to processCommand(), etc.

yes

no

yes

no

yes

no

yes

no

yes

no

yes

no

yes

no

yes

no

yes

no

Received line

Strip leading whitespace

Empty or CRLF?

empty_line

Starts with angle bracket?

response to processStatus

Anchored ok at start?

ack - releases flow credit

Semicolon hash or paren?

comment

M or G or T followed by number?

gcode

Starts with dollar sign?

esp_command

Single realtime char?

gcode

error colon or ALARM colon?

error

Grbl / MSG / GC / PRB / VER / OPT?

response

unknown

Note on ok detection: getType() uses an anchored check — ptr[0]=='o' && ptr[1]=='k' followed by a terminator check — not strstr. A substring match would incorrectly classify filenames or other lines containing “ok” as ACKs, corrupting the streaming flow-control credit accounting.

yes

no

yes

no

yes

no

yes

no

yes

no

yes

yes

no

yes

no

yes

no

yes

no

yes

no

yes

no

processCommand data

Starts with ok?

return true

isStatus?

processStatus

Starts with Grbl space?

Set connection_status=C

Set server_status=C

Extract fw_version

requestControllerSessionReset

resetStartupCommandsSent

Starts with error colon?

Lookup grbl_error_text N

Set firmware_status=ERROR:N

Set last_error_status=text

addMessageToHistory ERR

Starts with ALARM colon?

Lookup grbl_alarm_text N

Set firmware_status=ALARM:N

addMessageToHistory ERR

Dollar N equals value?

Is dollar 22 equals homing?

Set homing_cycle_x/y/z

homing_single_x/y/z=0

Starts with bracket GC colon?

Set parser_state

Extract G20/G21 to current_unit

Starts with bracket MSG colon?

addMessageToHistory INFO

If Pgm End set job_status=completed

Starts with bracket PRB colon?

Set probe_status

Starts with bracket VER colon?

Set fw_version

Set target_fw_info

Starts with bracket OPT colon?

Set planner_blocks

Set _stream_rx_buffer_size

forwardToScreen - check M117

A GRBL status report has the form:

<State|MPos:x,y,z|WCO:x,y,z|FS:feed,spindle|Ov:fo,ro,so|Bf:blocks,bytes|Pn:pins|A:acc>

WCO is only included periodically by GRBL; WPos is inferred when absent using stored WCO values.

yes

no

yes

yes

no

yes

no

yes

yes

yes

yes

no

yes

yes

Status report string

Extract state

between angle brackets and first pipe

Set firmware_status

Set server_status=C

Set connection_status=C if serial transport

Pipe MPos colon present?

Parse MPos coords into mpos_data

mpos_data.present = false

Pipe WPos colon present?

Parse WPos coords into wpos_data

Pipe WCO colon in message?

Parse WCO into wco_data

Load stored WCO from ESP3DValues

Derive missing positions

WPos = MPos - WCO

MPos = WPos + WCO

Dispatch MPos to position_mx/my/mz/ma/mb/mc

Dispatch WPos to position_wx/wy/wz/wa/wb/wc

Dispatch WCO to wco_x/y/z/a/b/c if present in message

Pipe FS colon present?

Set feed_rate and spindle_speed

Pipe F colon and Pipe S colon separately?

Set separately

Pipe Ov colon present?

Set feed_override

rapid_override

spindle_override

Pipe Bf colon present?

Set buffer_blocks and buffer_bytes

Update _stream_planner_blocks

Update _stream_rx_bytes for flow control

Pipe Pn colon present?

Set pin_states

Clear pin_states once

Pipe A colon present?

Set accessory_states

Pipe Ln colon present?

Set job_current_line

Set axis_count from max detected axes


GRBL has no firmware-side handshake beyond ok. The pendant implements a 3-gate flow-control model via cnc_gcode_host_flow to avoid overrunning the controller’s RX ring buffer and planner queue.

yes

no

yes

no

yes

no

yes

no

Attempt to send line

length N bytes

Pending oks

>= max_in_flight 4?

Block - in-flight cap

Planner blocks free

<= threshold 1?

Block - planner full

inflight_bytes + N+1

> rx_buffer_size 128?

Block - RX hard cap

N+1 > rx_bytes_free

from Bf field?

Block - RX bytes

Send line

gcodeHostFlowOnLineSent

Receive ok

gcodeHostFlowOnAck

pending_ok--

inflight_bytes -= line_len

MethodDefaultSourceUpdated
getStreamMaxInFlight()4HardcodedNever
getStreamPlannerBlocksFree()15|Bf:blocks,…|Every status report
getStreamRxBytesFree()0|Bf:…,bytes|Every status report
getStreamRxBufferSize()128[OPT:flags,blocks,rx]Once at startup via $I

The [OPT:] response from $I (sent via getInitCommand()) refines the RX buffer size from the board’s actual compile-time value. The flow-control gates use this as a hard ceiling regardless of what |Bf:| reports.


Reset Before Resume and Position Precision (a14)

Section titled “Reset Before Resume and Position Precision (a14)”
  • requestResetBeforeResume() arms a state machine shared with the FluidNC and grblHAL targets: while armed, a cycle start ~ sent through sendGcode() becomes gcodeHostService.resetAll() + soft reset 0x18, so a cancelled sequence held with ! (probe or tool change STOP) can never resume. See cnc_fluidnc.md for the states.
  • sendGcode() sets msg->realtime from the command type it knows, so the gcode host does not re-derive it.
  • Positions (and WCO) keep the firmware report precision: 3 decimals in mm, 4 in inch (report_unit = 1).
  • Target settings added in esp3d_target_settings_defs.inc: esp3d_jog_show_all_axes, esp3d_jog_position_unit, per-axis probe parameters esp3d_<axis>_probe_params, jog lists with mm;…;in;… sections, and esp3d_jog_wheel_sensitivity (encoder builds). Same keys and defaults as FluidNC: see cnc_fluidnc.md.

GRBL does not auto-report; the pendant must poll. After startup completes, handle() sends a ? command at the configured interval.

sendGcode realtime normal priorityesp3d_hal::millis()esp3dGcodeHandlerhandle() every host loop ticksendGcode realtime normal priorityesp3d_hal::millis()esp3dGcodeHandlerhandle() every host loop tickalt[interval elapsed]handle()sendStartupCommands() if not donemillis()nownow - last_ping >= polling_interval?GRBL_RT_STATUS_REPORTdispatchedlast_ping = now

The polling interval is stored in NVS (esp3d_polling_interval) and updated at runtime via updateReportingInterval(). Realtime ? commands use normal priority so they respect the outgoing queue order and do not jump ahead of user G-code.


GRBL 1.1 reports numeric codes only (error:N, ALARM:N). Human-readable text lives entirely on the pendant side.

CodeText
1Expected command letter
2Bad number format
3Invalid statement
4Value < 0
5Setting disabled
6Value < 3 usec
7EEPROM read fail. Using defaults
8Not idle
9G-code lock
10Homing not enabled
11Line overflow
12Step rate > 30kHz
13Check Door
14Line length exceeded
15Travel exceeded
16Invalid jog command
17Setting disabled (laser requires PWM)
20Unsupported command
21Modal group violation
22Undefined feed rate
23Command value not integer
24Axis command conflict
25Word repeated
26No axis words
27Invalid line number
28Value word missing
29Unsupported coordinate system
30G53 invalid motion mode
31Axis words not allowed
32G2/G3 arcs need axis words
33Invalid motion target
34Arc radius error
35G2/G3 arcs need offset word
36Unused value words
37G43.1 dynamic tool length offset axis error
38Tool number > max
CodeText
1Hard limit triggered
2Motion target exceeds travel
3Reset while in motion
4Probe fail (initial state)
5Probe fail (no contact)
6Homing fail (reset)
7Homing fail (door opened)
8Homing fail (pull-off)
9Homing fail (no limit switch)

Malformed responses (no numeric code after error: or ALARM:) are stored as raw text rather than silently mapped to code 0.


The module keeps a bounded ring buffer of firmware messages ([MSG:…], error:N, ALARM:N) for the firmware-status screen.

INFO

ERR

ERR

outside lock

getMessageHistory()

snapshot copy under lock

[MSG:text]

addMessageToHistory

Lock _message_history_mutex

Deque push_back

Pop front if size > 30

Unlock

error:N

ALARM:N

esp3dXValues.set_value

message_history content

Notifies LVGL screen

FirmwareStatusScreen

LVGL Core 1

  • Capacity: 30 messages (oldest evicted when full)
  • Thread safety: pthread_mutex_t protects the deque. set_value() is called outside the mutex because it acquires the ESP3DValues mutex internally — holding both simultaneously would risk deadlock.
  • Consumer API: getMessageHistory() returns a std::vector snapshot (copied under lock) so callers never iterate a live deque.
  • Out of memory (a14): building the string and the deque push are wrapped in try / catch (std::bad_alloc). On OOM in the RX task the message is dropped and the previous history kept (error log with free heap and largest block), instead of abort() resetting the board.

The module provides static helpers used by the grbl_module UI screens:

StaticUtilities

+parseMCodes(gc_state) : uint8_t

+is_mcode_set(mcodes, mask) : bool

+extractWCS(parser_state) : const char

+extractValue(parser_state, prefix) : const char

+isRealTimeCommand(cmd) : bool

«enumeration»

MCodeMask

M3 = 0x01 Spindle CW

M4 = 0x02 Spindle CCW

M5 = 0x04 Spindle OFF

M7 = 0x08 Mist coolant

M8 = 0x10 Flood coolant

M9 = 0x20 Coolant OFF

HelperPurpose
parseMCodes(gc_state)Returns a bitmask of active M-codes from the [GC:] parser state string
is_mcode_set(mcodes, mask)Tests a single M-code bit
extractWCS(parser_state)Extracts the active work coordinate system (G54–G59, G28, G30, G92)
extractValue(parser_state, prefix)Extracts a numeric value by prefix char (e.g. 'F', 'S', 'T')
isRealTimeCommand(cmd)Returns true for single-byte realtime bytes (0x3F, 0x21, 0x7E, 0x18, 0x83–0x85, 0x90–0x9E, 0xA0–0xA1)

GRBL has no firmware SD card. Macros are files stored on the pendant SD and streamed by the gcode host.

gcodeHostServiceesp3dGcodeHandlermacrosScreen / UIgcodeHostServiceesp3dGcodeHandlermacrosScreen / UIrunMacroFile("/macros/mymacro.nc")Prepend "/sd" to pathaddStream("/sd/macros/mymacro.nc", admin, executeAsMacro=true)streaming begins at front of script queue

Setting executeAsMacro = true places the stream at the front of the script queue so it runs before any queued job.


The grbl_module registers its own screen router createScreen() in main/display/cnc/grbl/screens/esp3d_screen_type.cpp. All screen creation for the GRBL variant passes through this function. Screens are shared with the other CNC variants where possible (see screens_architecture.md).

createScreen type

main → mainScreen

status → statusScreen

jog → jogScreen

probe → probeScreen GRBL-specific

files → filesScreen pendant SD with file_scan_task

change_tool → changeToolScreen GRBL-specific

macros → macrosScreen

settings → settingsScreen

settings_list → settingsListScreen

information → informationScreen

splash → splashScreen

languages → languagesScreen

baudrate → baudrateScreen

connection_status → connectionStatusScreen

polling → pollingScreen

scan_bt / wifi_scan / server_scan conditional on build flags

ScreenFileKey difference from siblings
filesScreengrbl/screens/files_screen.cppScans pendant SD using file_scan_task + dirent; no firmware file listing
probeScreengrbl/screens/probe_screen.cppReads probe_status value set by [PRB:] parser
changeToolScreengrbl/screens/change_tool_screen.cppTool change via ignore or probe sequence; no firmware-side tool table

Featurecnc_grbl (this module)cnc_fluidnccnc_grblhal
Status reportingPoll ? at intervalAuto-report (no polling needed)Poll ? at interval
SD card filesPendant SD onlyFirmware SD (FluidNC web FS)Firmware SD (grblHAL)
HomingAll-axes $H only via $22Per-axis configurablePer-axis configurable
Error formaterror:N numericerror:N sameerror:N same
Alarm formatALARM:N numeric[MSG:ERR …] rich formatALARM:N same
Parser state query$G yields [GC:…]$G yields [GC:…]$G yields [GC:…]
RX buffer capacity128 B default, refined from [OPT:]Larger WebSocket framingConfigurable
Startup init command$I via getInitCommand()$I$I
Multi-line reportsNot usedNot usedUsed for some responses
Macro executionStreamed from pendant SDRun command to firmware SDRun command to firmware SD

This module writes to ESP3DValues on every status parse cycle. See values.md for the full observable store reference.

ESP3DValuesIndexSet byTrigger
firmware_statusprocessStatus, processCommandState from status report, error, alarm
server_statusprocessStatus, banner, $I replyValid status, Grbl banner or $I reply
connection_statusprocessStatus, banner, $I replySerial / UART Ext transport confirmed
report_unitprocessCommand$13= in the $$ dump (0 mm, 1 inch)
position_mx/my/mz/ma/mb/mcprocessStatus|MPos: in status
position_wx/wy/wz/wa/wb/wcprocessStatus|WPos: or derived
wco_x/y/z/a/b/cprocessStatus|WCO: when present in message
axis_countprocessStatusMax axes detected in positions
feed_rateprocessStatus|FS: or |F:
spindle_speedprocessStatus|FS: or |S:
feed_overrideprocessStatus|Ov: field 0
rapid_overrideprocessStatus|Ov: field 1
spindle_overrideprocessStatus|Ov: field 2
buffer_blocksprocessStatus|Bf: field 0
buffer_bytesprocessStatus|Bf: field 1
pin_statesprocessStatus|Pn:; cleared once when the field is absent (all pins released)
accessory_statesprocessStatus|A:
job_current_lineprocessStatus|Ln:
fw_versionprocessCommand[VER:] or Grbl banner
target_fw_infoprocessCommand[VER:] or Grbl banner
planner_blocksprocessCommand[OPT:]
parser_stateprocessCommand[GC:]
current_unitprocessCommandG20/G21 in [GC:]
homing_cycle_x/y/zprocessCommand$22= setting
homing_single_x/y/zprocessCommand$22= always "0" for GRBL
probe_statusprocessCommand[PRB:]
last_error_statusprocessCommanderror:N or ALARM:N
job_statusprocessCommand[MSG:Pgm End]
message_historyaddMessageToHistoryAny INFO/ERR message
status_bar_labelforwardToScreenM117 command