Skip to content

cnc_fluidnc

cnc_fluidnc is the FluidNC-specific CNC firmware integration layer. It implements ESP3DGCodeHandlerService — the single point of contact between the generic gcode_host core and the FluidNC firmware protocol.

Its responsibilities are:

  • Classifying and routing incoming firmware bytes (status, ok, error, alarm, messages, file entries, configuration values).
  • Parsing <State|MPos:|WPos:|WCO:|FS:|Ov:|Pn:|Bf:|SD:…> status reports and pushing decoded values into ESP3DValues.
  • Managing a 6-phase startup sequence (polling interval, planner block size, report mask, SD card probe, report unit and parser state, initial status query), started only once the controller is confirmed.
  • Scheduling per-axis homing-capability queries ($/axes/x/homing/cycle, $/axes/x/homing/allow_single_axis) one command per UI tick to avoid flooding the transport.
  • Maintaining a thread-safe message history ring buffer for [MSG:ERR:] and [MSG:INFO:] firmware messages consumed by the firmware_status_screen.
  • Providing flow-control parameters (planner_blocks_free, rx_bytes_free, max_in_flight) consumed by cnc_gcode_host_flow on every TX gate check.

Source files:

  • main/target/cnc/fluidnc/esp3d_gcode_handler_service.h
  • main/target/cnc/fluidnc/esp3d_gcode_handler_service.cpp

Global instance: esp3dGcodeHandler (used by the gcode host task and the UI layer).


cnc_fluidnc sits between the generic gcode_host core and the FluidNC firmware, translating in both directions. It is selected at compile time via CMake — exactly one CNC handler is compiled per firmware target.

Core - ESP3DValues

cnc_fluidnc (this module)

gcode_host (core)

processCommand / getType / hasAck

set_value

getStreamPlannerBlocksFree

getStreamRxBytesFree

gcodeHostFlowCanSendLine

sendGcode → dispatch

UART / TCP / BT / BLE

raw bytes

subscriptions

UI - FluidNC Screens

firmware_status_screen

jog_screen

status_screen

probe_screen

files_screen

Communication Transports

serial_client

usb_serial

bluetooth_serial

bluetooth_ble

socket_client

websocket_client

ESP3DGCodeHostService

esp3d_gcode_host_service.cpp

ESP3DXStream

esp3d_x_stream.cpp

cnc_gcode_host_flow

esp3d_gcode_host_flow.cpp

ESP3DGCodeHandlerService

esp3dGcodeHandler

esp3dXValues

FluidNC Firmware


CMake selects exactly one esp3d_gcode_handler_service.cpp per build via the TARGET_FW_* option. The handler contract (method names and signatures) is the same across all targets; only the parsing logic differs.

CMake targetHandlerFirmware
TARGET_FW_FLUIDNCmain/target/cnc/fluidnc/FluidNC (this module)
TARGET_FW_GRBLmain/target/cnc/grbl/grbl
TARGET_FW_GRBLHALmain/target/cnc/grblhal/grblHAL
TARGET_FW_NONEmain/target/none/Permissive passthrough

enum class ESP3DCommandType : int8_t {
unknown = -1, // auto-detect from content
normal = 0, // standard G-code / $ command, appends \n, expects ok
realtime = 1, // single-byte realtime, no \n, no ACK expected
};
enum class FirmwareMessageType : uint8_t {
INFO = 0,
ERR = 1,
};

Stored in the ring buffer returned by getMessageHistory().

struct FirmwareMessage {
FirmwareMessageType type;
std::string content;
};
constexpr size_t MAX_FIRMWARE_MESSAGE_HISTORY = 30;

Bitmask returned by parseMCodes() for detecting active spindle/coolant states from [GC:] parser state.

enum class MCodeMask : uint8_t {
M3 = (1 << 0), // Spindle CW
M4 = (1 << 1), // Spindle CCW
M5 = (1 << 2), // Spindle OFF
M7 = (1 << 3), // Mist coolant
M8 = (1 << 4), // Flood coolant
M9 = (1 << 5), // Coolant OFF
};

Single-character string literals for use with sendGcode(..., ESP3DCommandType::realtime). Defined in the header so all FluidNC-related modules share the same constants without a separate include.

MacroByteAction
GRBL_RT_STATUS_REPORT0x3F (?)Request status report
GRBL_RT_FEED_HOLD0x21 (!)Feed hold
GRBL_RT_CYCLE_START0x7E (~)Resume / cycle start
GRBL_RT_SOFT_RESET0x18Ctrl-X soft reset
GRBL_RT_JOG_CANCEL0x85Cancel jog
GRBL_RT_SAFETY_DOOR_TOGGLE0x83Safety door toggle
GRBL_RT_FEED_100_PERCENT0x90Feed override 100%
GRBL_RT_FEED_INC_10 … GRBL_RT_FEED_DEC_10x91–0x94Feed ±10% / ±1%
GRBL_RT_RAPID_100_PERCENT … GRBL_RT_RAPID_25_PERCENT0x95–0x97Rapid 100 / 50 / 25%
GRBL_RT_SPINDLE_100_PERCENT … GRBL_RT_SPINDLE_STOP0x99–0x9ESpindle overrides
GRBL_RT_COOLANT_FLOOD_TOGGLE0xA0Flood toggle
GRBL_RT_COOLANT_MIST_TOGGLE0xA1Mist toggle

Class Reference — ESP3DGCodeHandlerService

Section titled “Class Reference — ESP3DGCodeHandlerService”

ESP3DGCodeHandlerService

-_started bool

-_startup_commands_sent bool

-_startup_commands_phase uint8_t

-_startup_reset_generation atomic<uint32_t>

-_startup_reset_applied uint32_t

-_homing_query_axes_total uint8_t

-_homing_query_axis_idx uint8_t

-_homing_query_step uint8_t

-_homing_queries_sent bool

-_stream_planner_blocks uint8_t

-_stream_rx_bytes uint16_t

-_ignore_invalid_statement_count uint8_t

-_message_history deque<FirmwareMessage>

-_message_history_mutex pthread_mutex_t

-_reset_before_resume atomic<ResetBeforeResume>

-_tool_changer_scan atomic<ToolChangerScan>

-_tool_changer_found bool

+begin() : bool

+handle() : void

+end() : void

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

+runMacroFile(absolute_path) : bool

+getType(data) : ESP3DDataType

+hasAck(command) : bool

+hasMultiLineReport(data) : bool

+processCommand(data) : bool

+forwardToScreen(command) : bool

+isRealTimeCommand(cmd) : bool

+updateReportingInterval(interval) : bool

+getInitCommand() : const char

+sendStartupCommands() : bool

+sendPingCommand() : bool

+resetStartupCommandsSent() : void

+requestResetBeforeResume() : void

+requestToolChangerScan() : void

+sendHomingQueries(axis_count) : bool

+getStreamMaxInFlight() : uint8_t

+getStreamPlannerBlocksFree() : uint8_t

+getStreamRxBytesFree() : uint16_t

+getStreamRxBufferSize() : uint16_t

+parseMCodes(gc_state) : uint8_t

+is_mcode_set(mcodes, mask) : bool

+extractWCS(parser_state) : const char*

+extractValue(parser_state, prefix) : const char*

+addMessageToHistory(type, content) : void

+getMessageHistory() : vector<FirmwareMessage>

+clearMessageHistory() : void

+getMessageCount() : size_t

+started() : bool

-scanToolChangerLine(data) : bool

-resetToolChangerScan() : void

-tickHomingQueries() : void

-applyStartupReset() : void

-isStatus(data) : bool

-processStatus(data) : bool

-extract_value_between(src, start, end, out, max) : bool


FluidNCgcodeHostServiceesp3dGcodeHandlerESP3DX::begin()FluidNCgcodeHostServiceesp3dGcodeHandlerESP3DX::begin()Reads esp3d_polling_interval from NVS\n_started = trueloop[Every gcode_host task tick(~10 ms)]_started = false\nClears message historybegin()handle()sendStartupCommands() [phases 0-5]tickHomingQueries() [one cmd/tick]raw bytesgetType(data)processCommand(data)set_value() on ESP3DValuesend()

sendStartupCommands() advances through 6 phases, one per call from handle(). Each phase sends exactly one command and returns immediately — it does not block.

yes

no

begin() called

_started = true

server_status == C?

(status report, banner or $I reply)

Phase 0

$Report/Interval=N

(skip if polling disabled)

Phase 1

$planner_blocks

Phase 2

$10=2

(WPos + buffer report mask)

Phase 3

$SDCARD

(SD card probe)

Phase 4

$report_inches + $G

(report unit, parser state - G20/G21, WCS)

Phase 5

? (realtime status)

_startup_commands_sent = true

Startup complete

Heap milestone logged

Wait for the controller (a14): 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 (a status report, the welcome banner or the $I reply confirmed the controller), as described in connection_management.md. Nothing is sent blind to a silent line, where the commands would wait forever for their ok.

$10=2 (a14, was $10=3): WPos plus buffer state (|Bf: is needed by the flow control). The report rounds every value to 3 decimals, so a position computed from two rounded values (MPos − WCO) can be off by 0.001 (for example after G92 X0 on a half-micron position). The work position is the one zeroed and worked with, so it is the one reported exactly; MPos is computed (WPos + WCO) and may show the ±0.001 instead.

$report_inches (a14): queries the unit of the positions in status reports ($/report_inches=true|false, stored in report_unit), which is independent of the G20/G21 work unit. See inch_mm_units_reference.md.

Welcome banner = new session (a14): the Grbl …[FluidNC banner means FluidNC restarted its session (soft reset or reboot): session settings such as $Report/Interval are lost. The handler calls gcodeHostService.requestControllerSessionReset() (the controller discarded its buffer, so the host drops its in-flight state and any running job; applied on the host task, purge first), then resetStartupCommandsSent() to replay the startup sequence and homing queries, and resetToolChangerScan(). See CNC_Firmware_Integration.md, “Lost Lines, Input Overflow and Resets”.

Reset handshake (§2.14): any task (transport RX, LVGL reconnect) calls resetStartupCommandsSent(), which atomically increments _startup_reset_generation. The host task consumes the counter in sendStartupCommands() between two phases, so the phase machine never observes a half-reset state. The actual reset is applied by applyStartupReset() on the host task only.


Once the startup sequence is done (its last phase sends ?, so the status report that follows schedules them) and the axis count is known, sendHomingQueries() schedules per-axis homing-capability queries. tickHomingQueries() drains them one command per handle() tick to avoid saturating the transport.

last axis

processStatus() detects axis_count > 0

sendHomingQueries(axis_count)

arms ignore window: _ignore_invalid_statement_count = axis_count * 2

_homing_query_axes_total = axis_count

tickHomingQueries() - called each handle() tick

step 0: $/axes/x/homing/cycle

step 1: $/axes/x/homing/allow_single_axis

advance axis index

All axes done

_homing_queries_sent = true

Heap milestone logged

FluidNC builds that do not have per-axis homing settings respond with error:3 / [MSG:ERR: Invalid $ statement]. The _ignore_invalid_statement_count window absorbs exactly axis_count * 2 such responses (one per command, two commands per axis) without forwarding them to the UI as real errors.

The [MSG:ERR: ...] lines sent by FluidNC when no SD card is inserted are always ignored (substring match, so any error code is covered): sdmmc_card_init failed code 0x..., Failed to mount device, filesystem error: SD Card is inaccessible: ESP_.... They are not firmware errors, so they neither set firmware_status to ERROR:... / last_error_status nor add an entry to the message history. The list is sd_absent_errors[] in the [MSG:ERR: branch of the FluidNC handler.


Incoming Data Classification — getType()

Section titled “Incoming Data Classification — getType()”

getType() is called by the gcode host on every received line before processCommand(). It returns an ESP3DDataType value that governs flow-control credit release (ack → release one in-flight slot) and routing.

Response patternESP3DDataType returned
<…> (status report)response
ok (anchored)ack
error:Nerror
ALARM:Nerror
[MSG:…], [GC:…], [PRB:…], [VER:…], Grbl …response
/SDCARD:…response
[DIR:…], [FILE:…], [/sd/…] or [/sd …]response
G…, M…, T… (with numeric code)gcode
$…esp_command
?, !, ~, \x18 (single char)gcode
[ESPxxx]esp_command
! (emergency gcode)emergency_command
[ESP701]emergency_command
;, #, (comment
blank lineempty_line
otherwiseunknown

Ack anchoring: only a line whose first two bytes are ok followed by whitespace, newline, or \0 releases a flow-control credit. A substring match (e.g. a filename containing “ok”) must not release a credit.


Incoming Data Processing — processCommand()

Section titled “Incoming Data Processing — processCommand()”

processCommand() is called for every line that was not classified as an ack. It returns true if the line was recognized and consumed.

dump line

no

yes

no

yes

no

yes

no

yes

no

yes

no

yes

no

yes

no

yes

no

yes

no

yes

no

yes

no

yes

no

yes

no

yes

no

yes

no

yes

no

matched

no match

data line received

starts with 'ok'?

isStatus() = '<...>'?

starts with 'Grbl ...[FluidNC'?

starts with 'error:'?

starts with 'ALARM:'?

starts with '$' and contains '='?

starts with '[GC:'?

starts with '[MSG:Homed'?

starts with '[MSG:ERR:'?

starts with '[MSG:Machine:'?

starts with '[MSG:INFO:'?

starts with '[DIR:', '[/sd/' or '[/sd '?

starts with '[FILE:'?

starts with '[PRB:'?

starts with '[VER:'?

starts with '/SDCARD:'?

forwardToScreen() - M117

return false

$CD scan running?

scanToolChangerLine()

return true

return true

processStatus()

update 20+ ESP3DValues

set connection_status='C'

set server_status='C'

requestControllerSessionReset()

resetStartupCommandsSent()

resetToolChangerScan()

decode error code

set firmware_status, last_error_status

addMessageToHistory(ERR)

decode alarm code

set firmware_status, last_error_status

addMessageToHistory(ERR)

parse planner_blocks

homing/cycle, allow_single_axis

report_inches -> report_unit

set parser_state

extract G20/G21 -> current_unit

send '?' realtime

check ignore window

set firmware_status, last_error_status

addMessageToHistory(ERR)

set board_name

extract Machine name if present

set board_name

addMessageToHistory(INFO)

set_value_wait(firmware_file_entry)

filter by allowed extensions

set firmware_file_entry if accepted

set probe_status

extract 'FluidNC vX.Y.Z'

set fw_version, target_fw_info

resetToolChangerScan()

server_status='C' (+ connection_status on serial)

set fw_has_sd='1'

set status_bar_label (M117)


The FluidNC status report format is <State|field:value|…>. processStatus() dissects it and pushes each decoded field into ESP3DValues.

raw:

1. State -> firmware_status

connection_status / server_status -> 'C'

2. MPos: -> position_mx/my/mz/ma/mb/mc

3. WPos: -> position_wx/wy/wz/wa/wb/wc

4. WCO: -> wco_x/y/z/a/b/c (persistent)

5. Compute missing positions

WPos = MPos - WCO / MPos = WPos + WCO

6. FS: -> feed_rate, spindle_speed

(or F: and S: separately)

7. Ov: -> feed_override, rapid_override, spindle_override

8. Pn: -> pin_states

9. A: -> accessory_states

10. Bf: -> buffer_blocks, buffer_bytes

_stream_planner_blocks, _stream_rx_bytes updated

11. SD: -> job_progress, job_filename, job_status

12. Ln: -> job_current_line

13. axis_count -> sendHomingQueries() (once)

Position precision (a14): positions keep the firmware report precision: 3 decimals in mm, 4 in inch (report_unit = 1). WCO too, since WPos is computed from it and a rounded WCO would shift WPos.

Pins (a14): |Pn: is only reported while a pin is active. Without the field, pin_states is cleared once (not on every report), so the UI drops stale pins.

Reset before resume tracking: see Reset Before Resume below.

WCO persistence: FluidNC only includes WCO: in every N-th status report (the report mask is set by $10=2). When absent, processStatus() reloads the last stored WCO values from ESP3DValues and uses them to derive the missing position type. This means the UI always has both MPos and WPos even when only one is present in the raw message.


All outgoing commands (both realtime and normal) pass through sendGcode(). The method allocates a single ESP3DMessage, sets its priority, appends a newline for normal commands (using a fixed 256-byte stack buffer — no heap allocation), and dispatches to esp3dCommands.dispatch().

yes

no

yes

no

yes

no

sendGcode(data, origin, requestId, cmdType, priority)

data non-empty?

canSendData()?

ESP3DClient::newMsg(origin -> stream, admin)

newMsgPtr->priority = priority

normal cmd

missing newline?

copy to 256-byte stack buf

append newline

setDataContent direct

esp3dCommands.dispatch(msg)

return false

sendGcode() also sets msg->realtime from the command type it knows, so the gcode host does not have to re-derive it. Realtime ? is a single byte with no newline: FluidNC sends no ok for it and answers at once even in Hold (the old note about ok stacked up during a hold no longer applies).

Priority vs command type:

cmdTypepriorityUse case
realtimenormal? status poll — respects queue order
realtimehigh! feed hold, \x85 jog cancel — bypass host queue
normalnormalStandard G-code and $ commands (default)
normalhighFuture use (bypass host, normal formatting)

A cancelled sequence held with ! (for example STOP during a probe or a tool change) must never restart on ~. The screen calls requestResetBeforeResume() (LVGL task), which arms an atomic state:

StateSet byMeaning
nonedefault; a soft reset dispatched; the controller left Hold another wayNothing pending
armedrequestResetBeforeResume()Waiting for the HOLD status (RUN is accepted: deceleration just after !)
hold_seenprocessStatus() (RX task)The controller is held

While the state is not none, a cycle start ~ sent through sendGcode() is replaced by gcodeHostService.resetAll() and a soft reset 0x18 (the controller discards its buffer, so the line in flight would never be acknowledged). Any soft reset actually dispatched clears the state; on a dispatch failure it stays armed. Any status other than HOLD (or RUN before the hold) clears it, so a stale flag can never turn a later resume into a reset. Callers: probe and change tool screens of every CNC firmware.

requestToolChangerScan() (LVGL task) sends $CD once per controller session to learn whether a spindle has a tool changer. The result is in the fw_tool_changer value:

ValueMeaning
?Unknown (no scan yet, scan aborted, new session)
sScan pending (the change tool screen keeps M6T disabled until the answer; a dump takes a few seconds)
0No tool changer
1A spindle has a non-empty atc: or m6_macro:

The scan state (idle → sent → dump_seen → idle) is atomic, written from both tasks. While it runs, scanToolChangerLine() sees every line first: config dump lines (key: / key: value, top level or indented) are consumed; the closing ok ends the scan only once the dump started (an earlier ok belongs to an older command); error: or 10 s without the end (TOOL_CHANGER_SCAN_TIMEOUT_MS) gives ?, and the screen then falls back to detecting the missing tool changer after M6T. resetToolChangerScan() (banner, $I reply) sets ? again, since the configuration may have changed. UX flow: change_tool_screen.md.


The cnc_gcode_host_flow module calls the following methods on every TX gate check. They must return cached integers — no string parsing is allowed on the hot path.

MethodReturnsUpdated by
getStreamMaxInFlight()4 (compile-time constant)—
getStreamPlannerBlocksFree()_stream_planner_blocks (uint8_t)processStatus() via `
getStreamRxBytesFree()_stream_rx_bytes (uint16_t)processStatus() via `
getStreamRxBufferSize()256 (FluidNC max line length)—

|Bf:b,r| is clamped before narrowing: a malformed value > 255 or > 65535 is clamped to the type maximum rather than wrapping.


The ring buffer accumulates [MSG:ERR:] and [MSG:INFO:] lines received from the firmware. It is consumed by firmware_status_screen via getMessageHistory().

firmware_status_screen (LVGL task)ESP3DValuesesp3dGcodeHandlerGcode Host Task (RX path)firmware_status_screen (LVGL task)ESP3DValuesesp3dGcodeHandlerGcode Host Task (RX path)processCommand("[MSG:ERR:Homing fail]")addMessageToHistory(ERR, "Homing fail")\n_message_history.push_back()\nring buffer: pop_front if > 30set_value(message_history, "Homing fail")subscription callback (LVGL task)getMessageHistory() - returns snapshot copy under mutexre-render message list

Thread safety: _message_history is protected by _message_history_mutex. addMessageToHistory() locks the mutex for the deque mutation, then releases it before calling set_value() (which acquires the ESP3DValues mutex) to prevent lock-ordering deadlocks. getMessageHistory() returns a snapshot std::vector — callers never iterate the live deque.

Out of memory (a14): building the message string and pushing it into the deque are wrapped in try / catch (std::bad_alloc). On OOM in the RX task the message is dropped and the previous history kept, with an error log giving the free heap and the largest free block, instead of abort() resetting the board.


FluidNC reports errors as error:N and alarms as ALARM:N. processCommand() maps numeric codes to short English text via fluidnc_error_text() and fluidnc_alarm_text() (file-scope static functions, not exported).

Error codes (selected):

CodeText
1Expected command letter
8Command requires idle state
9G-code lock
60Failed to mount device
110Authentication failed
130Jog cancelled
152Configuration is invalid

Alarm codes:

CodeText
1Hard limit triggered
3Reset while in motion
4Probe fail (initial state)
6Homing fail (reset)
9Homing fail (no limit switch)
14Unhomed

Full tables are in the .cpp file. Unrecognized codes fall through to "Unknown error" / "Unknown alarm".


These helpers parse the [GC:] modal state string received after $G. They are static methods usable from any FluidNC UI screen without requiring a handler instance.

Returns a uint8_t bitmask of active M-codes. Use with is_mcode_set().

const char* ps = esp3dXValues.get_value(ESP3DValuesIndex::parser_state);
uint8_t m = ESP3DGCodeHandlerService::parseMCodes(ps);
bool spindle_on = ESP3DGCodeHandlerService::is_mcode_set(m, MCodeMask::M3)
|| ESP3DGCodeHandlerService::is_mcode_set(m, MCodeMask::M4);

Returns a pointer to a static 4-byte buffer ("G54" … "G59", "G28", "G30", "G92"). Defaults to "G54" if not found. The last matching G-code wins (handles edge cases where the parser state lists multiple WCS codes).

Returns a pointer to a static 12-byte buffer containing the numeric value following the given prefix character ('F' for feed rate, 'S' for spindle speed, 'T' for tool number). Returns "0" if not found.

Static buffer warning: all three helpers write into a single static char buffer. They are not reentrant and must only be called from the LVGL task (Core 1).


FluidNC has a firmware-resident SD card. Macros stored on it are run by sending $SD/Run=<absolute_path>. This contrasts with grbl (no firmware SD — macros are streamed from pendant SD via the gcode host) and grblHAL (similar firmware-SD model).

bool ESP3DGCodeHandlerService::runMacroFile(const std::string& absolute_macro_path) {
std::string command = "$SD/Run=" + absolute_macro_path;
return sendGcode(command.c_str(), ESP3DClientType::system, {.id = 0},
ESP3DCommandType::normal);
}

Called by fluidnc_module_files when the user confirms a job launch from the files screen.


main/target/cnc/fluidnc/esp3d_target_settings_defs.inc gained these NVS settings (INI section [pendant]):

SettingNVS keyDefaultRole
esp3d_jog_show_all_axestgt_all_axes0Settings list shows the jog settings of all 6 axes (1) or only of the detected axes (0; 3 when not connected yet)
esp3d_jog_position_unittgt_jog_pos_u01: jog steps / feedrates and their labels follow the report unit ($13 / report_inches) instead of G20/G21, and every $J carries that unit word ($J=G21G91… / $J=G20G91…). The probe follows the same unit (label “Commands in position unit”); its unit word is modal, so the work unit and distance mode are restored after a successful probe. 0: classic, no unit word on linear axes. Rotary axes always get G21
esp3d_x/y/z_probe_paramstgt_prb_x/y/zmm;0;30;5;300;in;0;1.2;0.2;12 (Z: mm;10;-50;3;200;in;0.4;-2;0.12;8)Per-axis probe offset;max_travel;retract;feed_rate, one section per work unit, edited from the probe screen only
esp3d_a/b/c_probe_paramstgt_prb_a/b/c0;360;10;100Rotary axes: degrees, no unit section
esp3d_jog_wheel_sensitivityjog_wheel_sens0ESP3D_HARDWARE_ENCODER_FEATURE only. 0 = classic handwheel jog; 1–100 = feed follows the wheel speed, next step above a speed threshold (see handwheel_jog.md)

The jog step and feedrate lists now have one section per work unit, for example mm;0.01;0.1;1;10;50;100;in;0.001;0.01;0.1;1;2;4 and mm;100;500;1000;2000;5000;in;4;20;40;80;200 (see jog_unit_lists.md). Full unit logic: inch_mm_units_reference.md.


processStatus() and processCommand() together write the following values. All downstream UI screens subscribe to these via esp3dXValues.subscribe().

ESP3DValuesIndexWritten whenSource field
firmware_statusEvery status report<State>
connection_statusOn <…>, Grbl …[FluidNC banner, or $I reply on a serial / UART Ext link—
server_statusOn <…>, Grbl …[FluidNC banner, or $I reply—
report_unit$/report_inches= reply0 mm, 1 inch
fw_tool_changer$CD scan?, s, 0, 1
position_mx/my/mz/ma/mb/mcStatus reportMPos:
position_wx/wy/wz/wa/wb/wcStatus reportWPos: or computed
wco_x/y/z/a/b/cStatus report (when WCO: present)WCO:
axis_countFirst status report with positionsderived
feed_rateStatus reportFS: or F:
spindle_speedStatus reportFS: or S:
feed_overrideStatus reportOv: field 0
rapid_overrideStatus reportOv: field 1
spindle_overrideStatus reportOv: field 2
pin_statesStatus reportPn:
accessory_statesStatus reportA:
buffer_blocksStatus reportBf: field 0
buffer_bytesStatus reportBf: field 1
job_progressStatus reportSD: percent
job_filenameStatus reportSD: filename (strips /sd/ prefix)
job_statusStatus reportderived from SD progress
job_current_lineStatus reportLn:
parser_state[GC:] responsefull cleaned string
current_unit[GC:] responseG20=inch("1"), G21=mm("0")
probe_status[PRB:] responsex,y,z:success
fw_version[VER:] responseFluidNC vX.Y.Z
target_fw_info[VER:] responsesame as fw_version
fw_has_sd/SDCARD: response"1"
board_name[MSG:Machine:] / [MSG:INFO:Machine …]machine name string
last_error_statuserror:N / ALARM:N / [MSG:ERR:]error text
firmware_file_entry[DIR:], [FILE:], [/sd/] / [/sd ] (end marker: older FluidNC sends [/sd/ Free:…], recent versions [/sd Free:…])raw firmware line, with set_value_wait()
planner_blocks$/planner_blocks=N responsevalue string
homing_cycle_x/y/z/a/b/c$/axes/x/homing/cycle response"0" / "1"
homing_single_x/y/z/a/b/c$/axes/x/homing/allow_single_axis"0" / "1"
message_history[MSG:ERR:] / [MSG:INFO:]notification trigger only
status_bar_labelM117 commandmessage text

The handler interface is identical across all three CNC targets. Firmware-specific differences are isolated to each esp3d_gcode_handler_service.cpp.

ModuleDirectoryFirmwareSD model
cnc_fluidnc (this module)main/target/cnc/fluidnc/FluidNCFirmware SD — $SD/Run=
cnc_grblmain/target/cnc/grbl/grblNo firmware SD — stream from pendant
cnc_grblhalmain/target/cnc/grblhal/grblHALFirmware SD — firmware run command

DependencyRole
gcode_hostCalls getType(), processCommand(), hasAck(), sendStartupCommands(), flow-control getters
cnc_gcode_host_flowReads _stream_planner_blocks / _stream_rx_bytes for TX gating
ESP3DValuesObservable value system — receives all parsed firmware state
ESP3DCommandscanSendData() guard; dispatch() for outgoing messages
ESP3DSettingsReads esp3d_polling_interval and esp3d_files_extensions from NVS
fluidnc_module_filesCalls runMacroFile() for SD job launch
firmware_status_screenConsumes getMessageHistory()
fluidnc_module screensSubscribe to values written by this module

  • Host task only for mutations. _startup_commands_phase, _homing_query_*, and applyStartupReset() are written exclusively on the gcode host task. resetStartupCommandsSent() is the only method safe to call from other tasks (it only increments an std::atomic).
  • No blocking in handle(). sendStartupCommands() and tickHomingQueries() send one command per call and return immediately — they never spin or wait for a response.
  • No heap allocation in sendGcode() hot path. The newline-appending buffer is a 256-byte array on the stack. Commands longer than 254 bytes are rejected with an error log.
  • _stream_planner_blocks / _stream_rx_bytes are cache-only. Updated from |Bf:| in processStatus() and read from gcodeHostFlowCanSendLine() without string parsing.
  • Message history mutex must not be held when calling set_value(). addMessageToHistory() always releases _message_history_mutex before notifying ESP3DValues to avoid a lock-ordering deadlock with the ESP3DValues internal mutex.
  • Static parser buffers are not reentrant. extractWCS(), extractValue(), and parseMCodes() write into static char buffers. They must only be called from the LVGL task (Core 1) or a single-threaded context.