Skip to content

GCode Host Module

The gcode_host module is the central CNC command scheduler of the pendant firmware. Every outbound G-code or ESP command — whether typed interactively, triggered by a macro, or streamed from a file — is ordered, paced, and acknowledged through this module. It enforces flow control, serialises concurrent command sources, and manages the full lifecycle of streaming jobs.


DocumentTopic
gcode_host_architecture.mdCore + domain flow split, CMake wiring, testing scope
gcode_host_streaming_flow.mdState machine detail, pause/resume/abort semantics, flow gates
esp3d_message_priority_system.mdNormal vs high-priority message routing
connection_management.mdTransport lifecycle, connection status
Core_Platform_&_Infrastructure.mdESP3DClient base class, message system

The gcode_host module sits between all command producers (UI screens, macros, HTTP/WebSocket handlers, the command subsystem) and the single active output transport (Serial, USB-Serial, Bluetooth SPP/BLE, Socket, or WebSocket). It is the sole owner of the outbound CNC channel.

process(msg)

dispatch to _outputClient

bytes

ok / error / status

RX messages

Output Transports - one active

Serial UART

USB Serial

BT SPP / BLE

Socket / WebSocket

Producers

UI Screens

jog / status / macros

ESP3DCommands

ESP700 / ESP701

HTTP / WebSocket

handlers

Macro Manager

SD / Flash scripts

ESP3DGCodeHostService

(gcode_host)

CNC Firmware

FluidNC / grblHAL / GRBL


The module is split into a target-independent core and a per-firmware-family flow implementation. The split is fixed at compile time by CMake — there is no runtime polymorphism.

Core - main/modules/gcode_host/

gcodeHostFlow*()

esp3dGcodeHandler.*

Handler - main/target/firmware/

FluidNC handler

grblHAL handler

GRBL handler

Flow - main/target/family/modules/gcode_host/

CNC flow - target/cnc/

ok credit + planner/RX gates

None flow - target/none/

Permissive passthrough

3D flow - target/3dprinter/ ⬜

Stub until Marlin

ESP3DGCodeHostService

Queues · State machine · File I/O

Timeout · Purge · ESP700 API

ESP3DXStream

Transport orchestrator

Layer responsibilities:

LayerPathRole
Coremain/modules/gcode_host/Queues, FreeRTOS task, file I/O, state machine, _forwardUrgent, ESP700/701 API
CNC flowmain/target/cnc/modules/gcode_host/ok credit + planner-buffer/RX-window gates — active today
None flowmain/target/none/modules/gcode_host/Permissive passthrough for TARGET_FW_NONE
3D flow ⬜main/target/3dprinter/modules/gcode_host/Stub until Marlin checksummed streaming is implemented
Handlermain/target/cnc/<fw>/Parse status reports, maintain buffer caches, hasAck(), isRealTimeCommand()

main/modules/gcode_host/
├── esp3d_gcode_host_service.h ← ESP3DGCodeHostService, ESP3DGcodeStream, ESP3DPurgeCommand
├── esp3d_gcode_host_service.cpp ← Main service implementation + FreeRTOS task
├── esp3d_gcode_host_types.h ← Enums: stream type, stream state, host state, errors
├── esp3d_x_stream.h ← ESP3DXStream declaration
└── esp3d_x_stream.cpp ← Transport orchestrator + streamTask
main/target/cnc/modules/gcode_host/
└── esp3d_gcode_host_flow.cpp ← CNC flow gate logic (ok credit + planner + RX window)
main/target/none/modules/gcode_host/
└── esp3d_gcode_host_flow.cpp ← Permissive stub (TARGET_FW_NONE)
main/target/cnc/<firmware>/
├── esp3d_gcode_handler_service.h ← Handler interface
└── esp3d_gcode_handler_service.cpp ← Status parsing, buffer cache, firmware-specific logic

Each queued item — whether a single command, a multi-command batch, or a file path — is a heap-allocated ESP3DGcodeStream node:

FieldTypeDescription
iduint64_tCreation timestamp in ms — also used as job duration base
requestIduintOrigin tag for purgeByRequestId() (0 = untagged, never purged)
totalSizeuint64_tByte length of payload string or file size
processedSizeuint64_tBytes confirmed processed (used for progress reporting)
cursorPosuint64_tCurrent read position in payload or file
typeESP3DGcodeHostStreamTypeStream classification (see table below)
stateESP3DGcodeStreamStateCurrent state machine state
auth_typeESP3DAuthenticationLevelAuthentication context carried from origin
activeboolSet by stream selection; non-active streams are held without processing
dataStreamchar*Heap-allocated payload string or filesystem path
realtimeboolRealtime command byte (from ESP3DMessage::realtime): sent bare, no \n, no flow credit, no ack wait (a14)
originESP3DClientTypeOrigin client of a command stream (no_client for files and internal scripts): lets a local buffer overflow purge only that source (a14)

_addStream() / _addStreams() take the realtime flag and the origin. An allocation failure (heap exhausted, for example under a flood of client input) drops the command with an error log; uncaught, the exception used to abort() the host task.

Used in the sequenced purge overload for ordered post-purge commands with inter-command delays:

FieldTypeDescription
cmdconst char*Command string to send as high-priority after purge
delay_msuint32_tDelay applied after sending this command (0 = none)
TypeSourceQueueDescription
single_commandUI / API_scriptsOne G-code or ESP command
multiple_commandsAPI_scriptsNewline/semicolon-separated command batch
fs_scriptMacro_scriptsFlash filesystem file, executed immediately (priority)
sd_scriptMacro_scriptsSD card file, executed immediately (priority)
fs_streamESP700_streamsFlash file job streamed line-by-line
sd_streamESP700_streamsSD card job streamed line-by-line

The distinction between *_script and *_stream is set in _add_stream() by the executeFirst flag: scripts join the priority _scripts queue; streams join the _streams (main job) queue.


The service maintains two independent lists, each protected by its own mutex:

takes precedence

fallback when _scripts empty

_streams - long-running file job queue

fs_stream - job 1

fs_stream - job 2

_scripts - immediate / priority queue

single_command

multiple_commands

fs_script

sd_script

_handle_stream_selection()

Active stream

_current_stream_ptr

Selection rules (evaluated every handle() tick):

  1. If _scripts is non-empty, its front entry becomes the active stream.
  2. If _scripts is empty and _streams has an entry, it becomes active.
  3. Only file streams (fs_stream, sd_stream) can be preempted by an incoming script; command streams run to completion uninterrupted.
  4. A file stream can only be preempted while in ready_to_read_cursor or paused state.
  5. When all scripts drain and a paused main stream has _resume_pending set, stream selection triggers the job restart (start state) automatically.

Maximum queue depth: 50 entries (ESP3D_MAX_STREAM_SIZE) per queue independently; excess additions are dropped with a rate-limited error log (at most once per 2 s).


Each ESP3DGcodeStream advances through the following states, driven by _handle_stream_states() up to 4 steps per handle() tick (configurable via ESP3D_GCODE_HOST_MAX_STATE_STEPS_PER_HANDLE):

stream enqueued

file opened / command ready

open failed

no pending request

pause requested (main stream only)

abort requested (main stream only)

G-code line read

ESP command detected

cursor == totalSize

read failure

ack needed AND at in-flight cap

ack not needed OR credits available

flow gate closed - yield + save line

no connection / alloc failure

ok received and credits available

timeout 10 s

continue reading

single_command done

alloc failure

pause script queued or skipped

resume notification received

resume script queued, _resume_pending set

stop script queued or skipped

cleanup and user notification

stream node freed

start

ready_to_read_cursor

error

read_cursor

pause

abort

send_gcode_command

send_esp_command

end

wait_for_ack

paused

resume

Blocking states — the state machine does not chain further steps in the same tick when it lands in one of these: wait_for_ack, paused, pause, error, end, abort


handle() is called every ESP3D_GCODE_HOST_TASK_TICK_MS milliseconds (default 10 ms) from the dedicated FreeRTOS task (esp3d_gcode_host_task, Core 0, priority 14):

esp3dGcodeHandler.handle()_handle_stream_states() - up to 4 steps_handle_msgs() - RX queue_handle_stream_selection()_handle_notifications()handle()gcode_host_taskesp3dGcodeHandler.handle()_handle_stream_states() - up to 4 steps_handle_msgs() - RX queue_handle_stream_selection()_handle_notifications()handle()gcode_host_taskloop[every 10 ms]callcheck pause/resume/abort FreeRTOS notifications (indices 1 / 2 / 3)select active stream (scripts before streams)drain RX queue - ok / error / status from firmwareadvance state machine (max 4 steps, stop on blocking state)firmware handler tick

a14: handle() first calls consumeControllerSessionReset() (see §11b). The tick duration per phase is logged at debug level (temporary instrumentation, 2026-11-01).

Why RX before states? An ok received in _handle_msgs() releases wait_for_ack so the chain ready → read → send can complete in the same tick — critical for GRBL single-line streaming throughput over serial/USB/BT.


The CNC flow implementation (target/cnc/modules/gcode_host/esp3d_gcode_host_flow.cpp) gates every G-code line through four concurrent checks before allowing transmission:

No

Yes

No

Yes

No

Yes

No

Yes

gcodeHostFlowCanSendLine(line_len)

pending_ok < max_in_flight?

planner blocks > threshold?

inflight_bytes + wire_len ≤ rx_buffer_size?

wire_len ≤ Bf-bytes snapshot?

✅ can send

🚫 yield to ready_to_read_cursor

(save line - scripts can run to refresh Bf)

GateMechanismThreshold
ok creditpending_ok counter (lines sent minus oks received)getStreamMaxInFlight() — 4 on FluidNC / grblHAL
Planner buffer`Bf:blocks
RX window (hard)Local ring of in-flight wire-byte lengths; pushed on send, popped on ok or errorgetStreamRxBufferSize() — grblHAL: 1024 B, FluidNC: 256 B
RX bytes snapshotLast `Bf:bytes

Flow API (one implementation compiled per build)

Section titled “Flow API (one implementation compiled per build)”
FunctionCalled when
gcodeHostFlowReset()Stream start, abort, clear-all, error
gcodeHostFlowCanSendLine(len)Before TX in send_gcode_command state
gcodeHostFlowOnLineSent(cmd)After successful dispatch — increments pending_ok, pushes RX ring
gcodeHostFlowOnAck()On RX ok — decrements pending_ok, pops RX ring
gcodeHostFlowOnNack()On RX error:N — decrements pending_ok, pops RX ring
gcodeHostFlowAtInFlightCap()After send — decides whether to enter wait_for_ack
gcodeHostFlowPendingOk()Query current in-flight credit count

When the gate refuses a line in send_gcode_command, the stream does not park there — that state is non-interruptible, so queued scripts (the GRBL ? status poller that refreshes |Bf:|) would never run and the planner gauge would never reopen the gate. Instead:

  1. The already-read line is saved: _flow_pending_line, _flow_pending_stream, _flow_pending_cursor.
  2. The stream yields back to ready_to_read_cursor (interruptible — scripts can run).
  3. On re-entry to read_cursor, the saved line is restored and the stream jumps directly back to send_gcode_command without re-reading from file.
  4. On pause/abort while a line is pending, cursorPos is rewound to _flow_pending_cursor so that resume re-reads the line correctly (consistent with _openFile / fseek on resume).

Both wait_for_ack and flow-gate-closed states share the same watchdog rule, independent of the user-set polling interval:

  • After ESP3D_WAIT_ACK_KEEPALIVE_MS (2 s) of silence: a realtime ? is sent at high priority (no flow credit consumed) — a live controller always answers ? even in Hold or dwell.
  • After ESP3D_COMMAND_TIMEOUT (10 s) total silence: flow reset, stream set to error state (time_out), communication_lost displayed on the status bar.
  • Status reports reset the timeout (a14). CNC <…> reports are typed response, not status; they are now recognised (same test as the handlers’ isStatus()) and reset the timeout, otherwise a command holding its ok for more than 10 s ($H homing, long G4, feed hold) still ended in time_out although the keepalive ? was answered.

The service routes messages based on ESP3DMessagePriority immediately inside process():

Yes

No

process(msg)

msg->priority == high?

_forwardUrgent(msg)

Re-target to _outputClient

Enqueue at TX front

(no flow, no ack wait)

addRxData(msg)

Normal RX queue

Stream state machine + flow control

High-priority (urgent) messages bypass entirely:

  • The RX queue and stream state machine
  • All CNC flow control gates
  • ACK waiting and timeout tracking

Typical urgent commands: feed hold !, cycle start ~, soft reset 0x18, jog cancel 0x85, probe/estop flows, keepalive ? injected by the watchdog, and post-purge cancel commands injected by _sendHighPriorityCommand().

See esp3d_message_priority_system.md for the full priority routing reference.


Pause, resume, and abort are signalled via FreeRTOS task notifications on dedicated indices (pause=1, resume=2, abort=3; index 0 is the general wake signal). They target the main stream (_current_main_stream_ptr) only — not arbitrary scripts.

fs_stream job_scripts queuegcode_host_taskUI Threadfs_stream job_scripts queuegcode_host_taskUI ThreadApplied in ready_to_read_cursoronly when current == main stream.Script (e.g. ? poller) runs to completion first.opt[_pause_script configured]opt[_resume_script configured]_handle_stream_selection detects_scripts empty + _resume_pending→ triggers start (file reopen + fseek)opt[_stop_script configured]pause() - xTaskNotifyGiveIndexed(1)state = paused_add_stream(pause_script)script runs to completionresume() - xTaskNotifyGiveIndexed(2)state = resume → back to paused, _resume_pending = true_add_stream(resume_script)state = start → ready_to_read_cursorabort() - xTaskNotifyGiveIndexed(3)state = abort → end_add_stream(stop_script)

Key invariants:

RuleReason
Pending pause/abort deferred while a script is activePrevents trapping a ? polling script in pause state (re-injecting _pause_script every tick)
_resume_pending delays job restart until _scripts drainsPrevents job lines going out before the resume script (e.g. motion before spindle restart)
Stale requests discarded when main stream endsMust not fire on the next job
Second resume while _resume_pending ignoredNo double script injection
Pause while already paused is ignoredNo re-queuing of pause scripts
Pause during resume script cancels _resume_pendingJob stays paused instead of bouncing
abortAll() drops all queued _streams entries behind the current jobScripts are kept (stop script must still run)

The purge functions atomically clear queued commands and optionally inject a high-priority command afterward. They gate the host task using _emergency_stopping + _waitHandleIdle(timeout) (50 ms for the purges, 500 ms for resetAll()) before mutating the queues. On timeout the purge is refused, never forced: the lists stay untouched and only the command after the purge is sent. See gcode_host_streaming_flow.md, “Purges from another task”:

RX / TX / _scripts queueshandle() tick_emergency_stoppingCaller - e.g. jog_screenRX / TX / _scripts queueshandle() tick_emergency_stoppingCaller - e.g. jog_screenopt[commandAfterPurge set]_emergency_stopping = true_waitHandleIdle(timeout) - waits for in-progress tick to finish (false = purge refused)purgeRxByOrigin / purgeRxByRequestIdpurgeTxByOrigin / purgeTxByRequestIderase matching _scripts entriesclear _current_stream_ptr before free (no dangling pointer)_sendHighPriorityCommand(cmd)_emergency_stopping = false
MethodFilterTypical use case
purgeByOrigin(origin, cmd?)ESP3DClientType originCancel all commands from a specific input source
purgeByRequestId(id, cmd?)uint requestIdCancel commands tagged with a known ID (e.g. all jog moves)
purgeByRequestId(id, cmds[], n)uint requestId + sequenced post-purge commandsSmooth jog stop with progressive deceleration and inter-command delays

Request ID 0 is untagged and is never matched by purgeByRequestId. The stream->id field is a creation timestamp — it is not a request ID and must not be compared as one.


11b. Reset, Overflow and Session API (a14)

Section titled “11b. Reset, Overflow and Session API (a14)”
MethodThreadRole
resetAll()any (UI)Drops everything the host holds: queued commands and jobs (current one included), flow-control state, RX queue; clears the input overflow latch. Called with a controller soft reset (Restart, Recover, reset before resume): the controller discards its buffer, so no ok would ever come for the line in flight. Uses _waitHandleIdle(500); refused on timeout (the soft reset still reaches the controller, and its banner drops the host state on the host task)
inputOverflowLatched()anyesp3d_rx_input_closed(): set by an input overflow, cleared only by resetAll(); the status indicators show an error while set
requestControllerSessionReset()target RX taskThe welcome banner arrived (soft reset from any source, or controller reboot): sets an atomic flag
consumeControllerSessionReset()host taskApplies a pending session reset (drops the host state; local message if a job was aborted). Called at the top of handle() and by the handler’s sendStartupCommands() before it queues anything, so the replayed startup sequence is never dropped

_waitHandleIdle(timeout_ms = 50) returns false when the tick is still running at timeout, and the caller must then not touch the lists or stream pointers (a forced resetAll() caused a LoadProhibited in _handle_stream_selection(), 2026-11-01). Called from the host task itself it returns true at once. It waits one real FreeRTOS tick (10 ms at CONFIG_FREERTOS_HZ=100): a shorter wait is a pure yield, and from LVGL (priority 15) it never let the host task (14, same core) finish its tick.

Input overflow: when the low-memory guard refused incoming messages, the queued commands can no longer be trusted. The host drops everything, job included; during a job it also sends a feed hold (the controller still executes its buffered moves; Restart then soft resets it). It is notified once per episode (heap figures after the purge tell a flood from a leak), the status is re-published so the indicators show the error, and the critical alert opens (firmware_status.md). While the input is closed, lines already queued by a transport are dropped, and pendant normal commands are blocked too (the acks of the purged lines still in the controller would be credited to them); only CNC responses and realtime commands pass.

Local buffer overflow (case B): _purgeOrigin(origin) purges the RX / TX messages and queued scripts of one source only, with a local notice; the rest keeps running. _cncResponsesLost() handles lost CNC responses or lines not sent to the CNC (esp3d_tx_line_lost, S21): flow reset and streams stopped with an error. An isolated input line dropped under low memory is reported at most once every 10 s. Refused-message and full-script-list logs are rate-limited (1 per second with a count). Full model: CNC_Firmware_Integration.md, “Lost Lines, Input Overflow and Resets”.


12. Output Transport Selection (ESP3DXStream)

Section titled “12. Output Transport Selection (ESP3DXStream)”

ESP3DXStream runs as the tftStream FreeRTOS task (Core 0). It starts gcodeHostService and then starts exactly one output transport client based on the NVS-persisted output client setting:

serial

usb_serial

bt_serial

bt_ble

socket_client

websocket_client

usb_cdc_device

ESP3DXStream::begin()

getOutputClient() from NVS

serialClient.begin()

usbSerialClient.begin()

btSerialClient.begin()

btBleClient.begin()

esp3dSocketClient.begin()

or deferred to network services on WiFi STA

esp3dWebsocketClient.begin()

or deferred to network services on WiFi STA

usbCdcDeviceClient.begin()

(own task, USB_CDC_DEVICE_FEATURE)

The USB CDC device client (a14) is started here as the CNC link whatever [ESP935] says (that setting is for the command-channel role); it runs its own task, so handle() does not poll it.

handle() in ESP3DXStream polls all active transport clients once per tick (except esp3dSocketClient, which is driven entirely by its own RX task to avoid interleaved writes on the TCP wire).

See connection_management.md for transport lifecycle details.


Firmware responses are classified by esp3dGcodeHandler.getType() in _parseResponse():

Response classAction in host
ack (ok)gcodeHostFlowOnAck() — release one credit; exit wait_for_ack when credits are free; end single_command when pending_ok == 0. Only a command already sent (wait_for_ack) can be ended by an ack (a14): an unexpected ok (for example the reply to the transport’s $I) used to end the next single command before it was sent, which was then lost
status (<...>, typed response)Refresh timeout; handler updates planner/RX cache from |Bf:|
error (error:N)gcodeHostFlowOnNack() — release one credit; set current stream and main stream to error (command_rejected); no automatic resend
OtherIgnored by gcode host (logged as “useless response”)

An error: with no credits pending (out-of-query) is shown on the status bar only and does not affect stream state.


Realtime Classification of Client Input (a14)

Section titled “Realtime Classification of Client Input (a14)”

Text received from a client (serial console, telnet, WebUI…) carries no command type; the host classifies it once, and the type is then carried by stream->realtime, never re-derived. A single byte is realtime when it is a known realtime command or any byte in 0x80–0xFF: the grbl 1.1 protocol (also grblHAL and FluidNC) reserves the extended-ASCII range for realtime commands and the controller throws away the unknown ones. Sent with a \n, the controller would strip the byte and answer the empty line with an ok the host takes for its own pending command (desync, S20; seen with a parasitic 0xFE on the USB serial console at boot). Control characters 0x00–0x1F are not reclassified (firmware-specific meaning). A realtime command queued with normal priority (for example the socket client ? ping) also goes out bare, with no line credit and no ack wait.


For single_command and multiple_commands types, commands are separated by an unescaped ; or \n. Characters that must appear literally are escaped before reaching _add_stream():

To send literallyWrite in the command string
;\;
\\\
\; (two chars)\\\;

Any other \x sequence is kept verbatim (backslash preserved). Escapes are resolved once in _add_stream() before the string is stored in dataStream. File paths (fs_stream, sd_stream) are never escaped or split.


All constants are overridable per-board in bsp/tasks_def.h:

ConstantDefaultEffect
ESP3D_GCODE_HOST_TASK_TICK_MS10 msDelay between handle() calls. Do not lower below the FreeRTOS tick period (CONFIG_FREERTOS_HZ=100 → 10 ms minimum; lower values round to vTaskDelay(0), turning the priority-14 host task into a busy loop that starves LVGL on Core 1)
ESP3D_GCODE_HOST_MAX_STATE_STEPS_PER_HANDLE4State machine steps per tick. Preferred throughput lever — 4 ≈ 100 lines/s; 8 ≈ 200 lines/s at no extra wakeup cost
ESP3D_COMMAND_TIMEOUT10 000 msNo-response timeout → stream error + communication_lost
ESP3D_WAIT_ACK_KEEPALIVE_MS2 000 msSilence before sending keepalive ? (both in wait_for_ack and flow-gate-closed)
ESP3D_MAX_STREAM_SIZE50Maximum entries per queue (_scripts and _streams independently)
STREAM_CHUNK_SIZEBoard-specificFile read buffer size — PSRAM (heap_caps_malloc(MALLOC_CAP_SPIRAM)) when ESP3D_PSRAM_FEATURE is set, otherwise DRAM

CodeNameCause
0no_error—
1time_outNo firmware response within ESP3D_COMMAND_TIMEOUT
2data_sendTransport send failure
4ack_numberACK sequence mismatch
5memory_allocationmalloc returned nullptr
10unknowUnclassified error
11file_not_foundFile does not exist in filesystem
12file_systemFilesystem access or stat error
13empty_fileFile size reported as 0
14access_deniedFile open denied
15cursor_out_of_rangefseek to cursor position failed
16list_fullQueue at ESP3D_MAX_STREAM_SIZE capacity
17abortedStream aborted by user request
18command_too_longLine exceeds 255 bytes
19no_connectionNo output client configured at send time
20command_rejectedFirmware returned error:N (negative ack of one line)

gcode_host Module

begin + handle

begin

gcodeHostFlow*()

open / read / close

set_value

translate

sendMSG

handle + classify RX

dispatch

process to client

RX messages

ESP3DXStream

streamTask · Core 0

ESP3DGCodeHostService

gcode_host_task · Core 0

gcodeHostFlow*

CNC / None / 3D stub

ESP3DCommands

ESP3DGlobalFileSystem

ESP3DValues

job_status · job_progress

job_duration · status_bar_label

Translation Service

Notifications Service

ESP3DGCodeHandlerService

per firmware target

Active Transport Client

serial / USB / BT / socket


  • PSRAM: when ESP3D_PSRAM_FEATURE is defined, the file read buffer is allocated from PSRAM via heap_caps_malloc(MALLOC_CAP_SPIRAM). Without PSRAM, a fixed DRAM array of STREAM_CHUNK_SIZE bytes is used.
  • CMake flow selection: cmake/features.cmake adds the correct include path (-I main/target/<family>/modules/gcode_host) so exactly one esp3d_gcode_host_flow.cpp is compiled per target family. See gcode_host_architecture.md for the full CMake mapping.
  • FreeRTOS notification slots: CONFIG_FREERTOS_TASK_NOTIFICATION_ARRAY_ENTRIES must be ≥ 4 (wake=0, pause=1, resume=2, abort=3). A static_assert inside _handle_notifications() enforces this at compile time.
  • Memory constraints: see esp32_memory_constraints.md for heap fragmentation guidance relevant to stream node and message allocations.
  • Logging: flow instrumentation uses esp3d_log (standard verbose, always present in builds). See esp3d_log_guide.md for log levels and backend configuration.