Skip to content

cnc_grblhal Module

The cnc_grblhal module is the grblHAL-specific implementation of the CNC firmware integration layer. It provides full bidirectional communication with a grblHAL controller: parsing its status reports and bracketed responses, managing the multi-phase startup sequence, implementing the MPG token-based access-control state machine, and updating the observable value system so the UI reacts in real time.

This is one of three CNC firmware targets in the project (cnc_grbl, cnc_fluidnc, and cnc_grblhal). All three share the same abstract interface declared in the none_target stub and are consumed identically by the GCode host streaming engine and the grblhal_module UI screens.


grblHAL UI Screens

cnc_grblhal Module

Core Platform

Transport Layer

raw bytes

raw bytes

raw bytes

raw bytes

incoming data

classified data

set_value

subscriptions

subscriptions

subscriptions

subscriptions

subscriptions

subscriptions

dispatch messages

outgoing data

outgoing data

outgoing data

outgoing data

polling interval

MPG enabled flag

sendGcode calls

flow-control metrics

Serial / USB-Serial

Bluetooth SPP / BLE

Socket Client

WebSocket Client

ESP3DCommands

dispatch / canSendData

ESP3DValues

observable state

ESP3DSettings

NVS config

GCode Host Service

streaming engine

ESP3DGCodeHandlerService

esp3dGcodeHandler

MPG State Machine

DISCONNECTED → UNKNOWN

→ PASSIVE → ACTIVE

Startup Sequence

Phases 0-9

Protocol Parser

processStatus / processCommand

getType

Homing Queries

$22 / $44-$49

Message History

pthread-mutex-protected deque

Status Screen

Jog Screen

Files Screen

Probe Screen

Change Tool Screen

Firmware Status Screen


main/target/cnc/grblhal/
├── esp3d_gcode_handler_service.h — class definition, enums, realtime command constants
└── esp3d_gcode_handler_service.cpp — full implementation

The module exposes a single global singleton:

extern ESP3DGCodeHandlerService esp3dGcodeHandler;

grblHAL extends the classic GRBL text protocol with additional response tokens, realtime command bytes, and bracketed information blocks.

Emitted as <State|field1:val1|field2:val2|...> — triggered by ? (poll) or automatically when auto-reporting ($481) is enabled.

FieldExampleDescription
State<IdleMachine state: Idle, Run, Hold, Jog, Alarm, …
MPos:MPos:0.000,0.000,0.000Machine position (up to 6 axes)
WPos:WPos:0.000,0.000,0.000Work position (up to 6 axes)
WCO:WCO:0.000,0.000,0.000Work coordinate offset
FS:FS:500,0Feed rate and spindle speed
Ov:Ov:100,100,100Feed / rapid / spindle overrides (%)
Pn:Pn:XYActive input pins
A:A:FMAccessory states
Bf:Bf:35,1024Buffer: planner blocks free, RX bytes free
SD:SD:50.5,file.ncSD job progress and filename
Ln:Ln:1500Current executing line number
H:H:1,7Homed state and axis bitmask
TLR:TLR:0Tool length reference set flag
MPG:MPG:1MPG token state (grblHAL-specific)
AR:AR:200Auto-reporting interval in ms
PatternDescription
[VER:...]Firmware version from $I
[OPT:flags,blocks,bytes]Build options and buffer sizes from $I
[AXS:N:names]Axis count and name string from $I
[BOARD:...]Board name from $I
[NEWOPT:...]Extended options — MPG and SD tags parsed
[GC:...]Parser state from $G
[PRB:...]Probe result
[FILE:...]SD file entry from $F flat listing
[MSG:ERR:...]Firmware error message
[MSG:INFO:...]Firmware info message
[MSG:Pgm End]Program end notification
[MSG:Homed...]Homing complete notification

grblHAL uses numeric codes: error:N (codes 1–88, 253) and ALARM:N (codes 1–21). The module maintains internal lookup tables (grblhal_error_text / grblhal_alarm_text) and formats both a compact status (e.g., "ERROR:3") and a human-readable description for the UI.


All single-byte realtime commands are defined as string constants in the header. They bypass flow-control and never need a newline or ok acknowledgment.

ConstantBytePurpose
GRBL_RT_STATUS_REPORT0x3F (?)Poll status report
GRBL_RT_FEED_HOLD0x21 (!)Feed hold
GRBL_RT_CYCLE_START0x7E (~)Cycle start / resume
GRBL_RT_SOFT_RESET0x18Ctrl-X soft reset
GRBL_RT_SAFETY_DOOR_TOGGLE0x83Safety door toggle
GRBL_RT_JOG_CANCEL0x85Jog cancel
GRBL_RT_FEED_100_PERCENT…GRBL_RT_SPINDLE_STOP0x90–0x9EFeed / spindle overrides
GRBL_RT_COOLANT_FLOOD_TOGGLE0xA0Flood coolant toggle
GRBL_RT_COOLANT_MIST_TOGGLE0xA1Mist coolant toggle
ConstantBytePurpose
GRBLHAL_RT_STATUS_REPORT_ALL0x87Full status report including |MPG: and |AR:
GRBLHAL_RT_MPG_MODE_TOGGLE0x8BToggle MPG token (toggle — not a query)
GRBLHAL_RT_AUTO_REPORTING_TOGGLE0x8CToggle auto-reporting (toggle — not a query)
GRBLHAL_RT_TOOL_ACK0xA3Acknowledge tool change (M6 flow)

⚠️ Toggle semantics: 0x8B and 0x8C are toggles, not set/clear commands. The module reads the current state via 0x87 before sending either, to avoid accidentally inverting the controller’s configured state.


grblHAL supports MPG (Manual Pulse Generator) mode where multiple senders share the serial bus. Only the sender holding the token (|MPG:1) may send normal (non-realtime) commands. The module implements a four-state machine to negotiate and maintain the token.

Stateserver_status ValueMeaning
DISCONNECTED"?"No transport connection
UNKNOWN"T"Transport connected; MPG token state not yet determined
PASSIVE"R"Token held by another sender; acquisition in progress
ACTIVE"C"Token held by this pendant; full control

For non-MPG transports (Socket Client, WebSocket, BT BLE), token acquisition is suppressed — the pendant is treated as the sole sender and enters ACTIVE directly.

Transport connects\n(resetStartupCommandsSent)

MPG:1 received (another sender holds token)\nor MPG:0 received (MPG mode present)

No MPG field while UNKNOWN\n(no MPG mode / sole sender)

2000ms timeout with no response

MPG:1 received after sending 0x8B\n(token acquired)

no-response watchdog\nmax(300ms, 3x reporting interval)

MPG:0 received\n(token lost)

no-response watchdog\nmax(300ms, 3x reporting interval)

DISCONNECTED

UNKNOWN

PASSIVE

ACTIVE

While in PASSIVE, _tickMpgStateMachine() sends 0x8B every 300 ms until |MPG:1 is observed in an incoming status report. The watchdog resets to UNKNOWN if no response is received for max(300 ms, 3 × reporting interval) (a14: a fixed 300 ms fired between two <…> reports when the reporting interval was 500 ms or more, and re-ran the startup sequence in a loop, resending $I every cycle; esp3d_polling_interval_ms is kept current by updateReportingInterval()).

grblHALPendantgrblHALPendantPASSIVE entered, acquire timer resetACTIVE enteredloop[every 300ms until MPG:1]0x87 (full status report)<Idle|MPos:...|MPG:0>0x8B (toggle MPG token)<Idle|MPos:...|MPG:1>

All normal (non-realtime) commands pass through canSendNormalCommand():

bool canSendNormalCommand() const {
if (!_mpg_enabled) { return true; } // MPG disabled → always allow
return _mpg_state == MpgState::ACTIVE; // MPG enabled → token required
}

Safety commands (?, 0x18, !, ~, 0x85) always bypass the gate regardless of MPG state.


On each new transport connection the handler runs a phased startup to identify the controller, configure auto-reporting, and query homing settings. The sequence is resilient to both serial (no welcome message on port open) and TCP/BT (welcome message on connect) transports.

Serial/USB-serial/USB CDC

TCP/BT + MPG OFF

TCP/BT + MPG ON

State settled PASSIVE/ACTIVE

Still UNKNOWN

Settled

Not yet ACTIVE

ACTIVE, AR unknown

ACTIVE, AR known

Yes - skip $376

No - send $376

New Transport Connection

Phase 0

Detect transport type

Set status C

Send 0x87 → Phase 7

Send 0x87 → Phase 7

Enter UNKNOWN

400ms listen → Phase 1

Phase 1

400ms elapsed?

Phase 8 - MPG ON

Wait for ACTIVE state

Send 0x87 → Phase 2

Phase 2

Wait MPG state settle

Phase 7

Wait for AR field

or 500ms timeout

Phase 3

Send $I

Phase 4

Wait for AXS: response

Phase 5

Axis count ≤ 3?

Phase 9

Send $G + $13

Mark startup done

updateReportingInterval

Phase 6

Wait for $376 response

Startup Complete

PhaseActionCompletion Signal
0Detect transport; send 0x87 or enter MPG listen windowImmediate
1(MPG) Wait 400 ms listen window_mpg_state_ts elapsed
2(MPG) Wait for MPG state to settle after 0x87_mpg_state != UNKNOWN
7Wait for |AR: field in full status report_ar_status_known or 500 ms timeout
8(MPG) Wait for ACTIVE state before token-gated queries_mpg_state == ACTIVE
3Send $IImmediate
4Wait for [AXS:] response_fw_info_received
5Send $376 (axis mode) if >3 axes, otherwise skipImmediate
6Wait for $376 response_axis_mode_received
9Send $G and $13 (report unit); mark startup complete; configure $481Immediate

a14 changes:

  • handle() first applies a pending controller session reset (gcodeHostService.consumeControllerSessionReset()) before any startup command of the tick is queued, so the purge never drops the replayed sequence. The welcome message (new controller session: the controller discarded its buffer) calls gcodeHostService.requestControllerSessionReset() before the startup replay; see CNC_Firmware_Integration.md, “Lost Lines, Input Overflow and Resets”.
  • The USB CDC device transport (usb_cdc_device) is handled like serial / USB serial: grblHAL is already running, no welcome on port open.
  • $13 ($13= reply → report_unit, 0 mm / 1 inch) gives the unit of the positions in status reports, 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.

grblHAL can emit status reports automatically at a fixed interval ($481, in ms) without requiring explicit ? polls. The module reads and writes this setting via updateReportingInterval().

grblHALHandlergrblHALHandlerPhase 7: send 0x87 to learn AR state_auto_reporting_active = truePhase 9 complete - configure reportingAR already active → skip 0x8C toggle_auto_reporting_active = truealt[AR was OFF]0x87<Idle|...|AR:200>$481=2000x8C (enable toggle)

If the polling interval NVS setting is 0, the module does not touch $481 or 0x8C — the controller’s own NVS configuration is authoritative.

When |Bf: is missing from the reports, the module sends $10=2 once (was $10=3; one-shot, allowed again after a controller reset): WPos plus buffer. Reported values are rounded to 3 decimals, so WPos computed as MPos − WCO can be off by 0.001 (for example right after G92 X0); the work position is reported exactly and MPos is computed (WPos + WCO).


sendGcode() is the single entry point for all outgoing data. It applies MPG token gating, constructs an ESP3DMessage, and dispatches it through ESP3DCommands.

realtime cmdType

normal cmdType

No

Yes

Yes - use stack buffer

No

Caller

sendGcode

Bypass MPG gate

canSendNormalCommand?

Blocked - return false

Needs newline?

Copy to 256-byte

stack buffer + newline

Use data directly

Create ESP3DMessage

set priority, request_id

esp3dCommands.dispatch

Active Transport

Buffer constraint: The stack buffer is fixed at 256 bytes (kSendBufferSize). Commands exceeding 254 payload bytes are rejected — well above any realistic pendant command length.


Every line received from grblHAL is classified before routing:

Yes

No

Yes

No

Yes

No

Yes

No

Emergency match

Normal

No

Yes

No

Yes

No

Yes

No

Yes

No

Incoming line

Empty / whitespace?

empty_line

Starts with < ends with >?

response

Anchored 'ok'?

ack

; # or ( prefix?

comment

M/G/T + digit sequence?

emergency_command

gcode

$ prefix?

esp_command

Single-byte realtime?

gcode

error: or ALARM: prefix?

error

Known bracket token?

response

unknown

ok anchoring: The check requires ok at position 0 followed by whitespace or null. A substring match would incorrectly classify filenames containing “ok” as acknowledgments, corrupting streaming flow-control credits.


Status reports may carry MPos, WPos, and WCO in any combination. The module derives the missing set and dispatches all three to ESP3DValues.

WPos missing

MPos missing

Both present

Status report

Parse MPos / WPos / WCO

up to 6 axes each

Load stored WCO from values

if WCO absent from report

Missing positions?

WPos = MPos minus WCO

capped to min axis count

MPos = WPos plus WCO

capped to min axis count

No derivation needed

Dispatch to ESP3DValues

position_mx/my/mz/ma/mb/mc

position_wx/wy/wz/wa/wb/wc

wco_x/y/z/a/b/c

Update axis_count

Schedule homing queries

Positions and WCO keep the firmware report precision (a14): 3 decimals in mm, 4 in inch (report_unit = 1). |Pn: is only reported while a pin is active; without it pin_states is cleared once, so the UI drops stale pins.


After the first status report that reveals the axis count, the module schedules a one-time sequential query of homing settings:

grblHALHandlergrblHALHandlersendHomingQueries(N)\nArm error:3 ignore window = N*2loop[for axis 0 to N-1]_computeHomingValues()\nPublish homing_cycle_* / homing_single_* to ESP3DValues$22$22=3 - _homing_enable_flags = 0x03$44+i$44+i=<bitmask>

_computeHomingValues() derives per-axis observables:

  • homing_cycle_X = "1" if the axis bit appears in any $44–$49 bitmask AND bit 0 of $22 is set
  • homing_single_X = "1" if homing_cycle_X is true AND bit 1 of $22 is set

grblHAL’s $F / $F+ produces a flat listing terminated by a plain ok (no [/sd/] end marker like FluidNC). The module arms a flag on dispatch and intercepts the terminating ok to send a synthetic sentinel to the files screen.

FilesScreengrblHALHandlerFilesScreengrblHALHandler_sd_listing_active = false$F (_sd_listing_active = true)[FILE:/dir/part.nc SIZE:12345]firmware_file_entry = "[FILE:...]"[FILE:/dir/tool.nc SIZE:8765]firmware_file_entry = "[FILE:...]"okfirmware_file_entry = "[FILE_LIST_END]"

Mount-point prefixes are stripped on forward: /sd/dir/x → dir/x, /dir/x → dir/x.


Error and info messages from grblHAL are accumulated in a bounded std::deque<FirmwareMessage> (max 30 entries). Access is protected by _message_history_mutex because both the RX task (writer) and the LVGL firmware status screen (reader) run concurrently.

struct FirmwareMessage {
FirmwareMessageType type; // ERR or INFO
std::string content;
};

getMessageHistory() returns a snapshot copy taken under the mutex — the live deque is never exposed to callers that may hold it across LVGL frames.

Out of memory (a14): the string build 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, instead of abort() resetting the board.

12. Reset Before Resume and Target Settings (a14)

Section titled “12. Reset Before Resume and Target Settings (a14)”
  • requestResetBeforeResume() (probe and tool change STOP) arms the state machine shared with the FluidNC and grbl targets: while armed, a cycle start ~ sent through sendGcode() becomes gcodeHostService.resetAll() + soft reset 0x18. Any status other than HOLD (or RUN just after !), including TOOL, clears it. See cnc_fluidnc.md.
  • sendGcode() sets msg->realtime from the command type it knows.
  • $F file entries and the [FILE_LIST_END] marker are published with set_value_wait().
  • New target settings (same keys and defaults as FluidNC, see cnc_fluidnc.md): esp3d_jog_show_all_axes, esp3d_jog_position_unit, esp3d_<axis>_probe_params, jog lists with mm;…;in;… sections, esp3d_jog_wheel_sensitivity (encoder builds).

File: main/target/cnc/grblhal/esp3d_gcode_handler_service.h
Global instance: esp3dGcodeHandler

MethodDescription
begin()Reads esp3d_polling_interval and esp3d_mpg_enabled from NVS; sets _started = true
handle()Called every host-task tick; drives sendStartupCommands(), _tickMpgStateMachine(), tickHomingQueries()
end()Resets all state; clears message history; resets MPG and homing state
MethodDescription
sendGcode(data, origin, requestId, cmdType, priority)Unified send pipeline with MPG gating, newline injection, and message dispatch
sendPingCommand()Sends ? at normal priority
sendStartupCommands()Drives the multi-phase startup state machine
sendHomingQueries(axis_count)Arms the one-shot homing query sequence
updateReportingInterval(ms)Saves to NVS; sends $481=N and conditionally toggles 0x8C
runMacroFile(path)Runs an SD macro by sending $F=<absolute_path>
MethodDescription
getType(data)Classifies a received line into ESP3DDataType
processCommand(data)Handles bracketed responses, errors, alarms, settings, and ok
processStatus(data)Parses <...> status reports; updates all position and state observables
hasAck(command)Returns true for non-realtime commands (they generate ok)
hasMultiLineReport(data)Always returns false (grblHAL uses single-line reports)
forwardToScreen(command)Routes M117 messages to status_bar_label observable
MethodDescription
getMpgState()Returns current MpgState
isMpgEnabled()Returns NVS setting
canSendNormalCommand()true if MPG disabled or state is ACTIVE
resetStartupCommandsSent()Thread-safe: bumps generation counter to request startup reset from any task

Streaming Flow Control (consumed by GCode Host)

Section titled “Streaming Flow Control (consumed by GCode Host)”
MethodReturnDescription
getStreamMaxInFlight()4Max simultaneous in-flight lines
getStreamPlannerBlocksFree()uint8_tLive value from |Bf: planner field
getStreamRxBytesFree()uint16_tLive value from |Bf: RX bytes field
getStreamRxBufferSize()1024grblHAL serial RX ring size (hard cap)
MethodDescription
addMessageToHistory(type, content)Appends to bounded deque; notifies message_history observable
getMessageHistory()Returns snapshot copy under mutex
clearMessageHistory()Clears deque and notifies observable
getMessageCount()Returns current count under mutex
MethodDescription
isRealTimeCommand(cmd)true for all single-byte realtime commands including 0x87, 0x8B, 0x8C
isSafetyCommand(cmd)true for ?, 0x18, !, ~, 0x85
parseMCodes(gc_state)Returns bitmask of active M-codes (M3/M4/M5/M7/M8/M9)
extractWCS(parser_state)Extracts current work coordinate system (G54–G59, G28, G30, G92)
extractValue(parser_state, prefix)Extracts a numeric value with the given letter prefix

enum class MpgState : uint8_t {
DISCONNECTED = 0, // No transport — server_status "?"
UNKNOWN = 1, // State not yet determined — server_status "T"
PASSIVE = 2, // Token held by another sender — server_status "R"
ACTIVE = 3, // Token held by this pendant — server_status "C"
};
enum class ESP3DCommandType : int8_t {
unknown = -1, // Auto-detect: realtime if single byte, else normal
normal = 0, // Requires newline and ok ACK; subject to MPG gate
realtime = 1, // Single byte; no newline, no ACK; always passes gate
};
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
};
enum class FirmwareMessageType : uint8_t {
INFO = 0,
ERR = 1
};

The following ESP3DValuesIndex entries are written by this module and consumed by the grblHAL UI screens:

ObservableSourceDescription
server_statusMPG state machine, welcomeConnection status: "?" / "T" / "R" / "C"
firmware_statusprocessStatus, errors, alarmsController state or "ERROR:N" / "ALARM:N"
last_error_statuserrors, alarmsHuman-readable error/alarm text
position_mx/my/mz/ma/mb/mcprocessStatusMachine positions (up to 6 axes)
position_wx/wy/wz/wa/wb/wcprocessStatusWork positions (up to 6 axes)
wco_x/y/z/a/b/cprocessStatusWork coordinate offsets
axis_countprocessStatus, [AXS:]Number of axes detected
axis_names$376 response"XYZABC" or "XYZUVW"
feed_rateprocessStatus |FS: / |F:Current feed rate
spindle_speedprocessStatus |FS: / |S:Current spindle speed
feed_overrideprocessStatus |Ov:Feed override %
rapid_overrideprocessStatus |Ov:Rapid override %
spindle_overrideprocessStatus |Ov:Spindle override %
pin_statesprocessStatus |Pn:Active input pins string
accessory_statesprocessStatus |A:Accessory states string
homed_stateprocessStatus |H:Homed state and axis bitmask
tool_length_refprocessStatus |TLR:Tool length reference flag
buffer_blocksprocessStatus |Bf:Planner blocks free
buffer_bytesprocessStatus |Bf:RX bytes free
planner_blocksprocessCommand [OPT:]Total planner buffer size
job_progressprocessStatus |SD:SD job progress %
job_filenameprocessStatus |SD:SD job filename
job_statusprocessStatus"Processing" / "Complete" / "Idle"
job_current_lineprocessStatus |Ln:Current executing line number
fw_versionprocessCommand [VER:]Firmware version string
target_fw_infoprocessCommand [VER:]Same as fw_version
board_nameprocessCommand [BOARD:]Board name string
fw_has_sdprocessCommand [NEWOPT:]"1" if SD card present
fw_has_mpgprocessCommand [NEWOPT:]"1" if MPG mode available
parser_stateprocessCommand [GC:]Full parser state string
current_unitprocessCommand [GC:]"0" = mm (G21), "1" = inch (G20)
probe_statusprocessCommand [PRB:]Probe result data
firmware_file_entryprocessCommand [FILE:] / okSD listing entry or [FILE_LIST_END]
message_historyaddMessageToHistoryMost recent message content
homing_cycle_x/y/z/a/b/c_computeHomingValues"1" if axis is homeable
homing_single_x/y/z/a/b/c_computeHomingValues"1" if single-axis homing is enabled
status_bar_labelforwardToScreen (M117)Screen status bar text

Setting IndexTypeDescription
esp3d_polling_intervaluint32_tAuto-report interval in ms; 0 = leave controller config untouched
esp3d_mpg_enabledbyte0 = MPG disabled; non-zero = MPG state machine active

Featurecnc_grblcnc_fluidnccnc_grblhal
MPG token machineNoNoYes (4 states, 0x8B)
Auto-reporting ($481)NoNoYes ($481 + 0x8C)
Extended realtimeNoNoYes (0x87, 0x8B, 0x8C, 0xA3)
SD listing protocolPOSIX scan (pendant-side)$F with [/sd/] end marker$F flat listing, plain ok end
Axis mode query ($376)NoNoYes (ABC vs UVW)
Axis info ([AXS:])NoNoYes
Extended options ([NEWOPT:])NoNoYes
Error codes1–381–381–88, 253
Alarm codes1–91–91–21
Homing setting queriesNoNoYes ($22, $44–$49)
Welcome detection"Grbl" prefix"Grbl" prefix"Grbl" prefix
Startup phasesSimpleSimple10-phase sequence

ResourceProtectionAccessed By
_message_historypthread_mutex_t _message_history_mutexRX tasks (writer), LVGL screen (reader)
_startup_reset_generationstd::atomic<uint32_t>Any task (writer), host task (reader)
All other stateHost task only (single-writer)handle() is called from one task

The startup reset uses a generation counter pattern: callers increment the atomic counter from any task via resetStartupCommandsSent(); the host task compares _startup_reset_generation against _startup_reset_applied at the top of sendStartupCommands() and applies the reset between phases — the phase machine never observes a half-reset state.


Following the project’s ESP32 memory constraints:

  • All send buffers use fixed-size stack arrays (kSendBufferSize = 256)
  • No VLA or unbounded std::string in hot paths
  • _message_history is bounded to MAX_FIRMWARE_MESSAGE_HISTORY = 30 entries
  • getMessageHistory() returns a snapshot copy — the live deque is never exposed to callers that might hold it across LVGL frames

DocumentDescription
gcode_host_architecture.mdCore GCode host architecture — how sendGcode() and flow-control metrics are consumed
gcode_host_streaming_flow.mdStreaming state machine, flow control, scheduler
connection_management.mdTransport lifecycle and server_status state machine
esp3d_message_priority_system.mdMessage priority system used by sendGcode()
screens_architecture.mdUI screen system — how grblHAL screens subscribe to observables
features.mdFeature × SKU matrix including grblHAL target
feature_resource_matrix.mdFeature compatibility and resource constraints
esp32_memory_constraints.mdHeap, fragmentation, and allocation rules
esp3d_log_guide.mdLogging macros and debug workflow