Skip to content

Serial Client Module

The Serial Client module (main/modules/serial/) implements the UART-based hardware serial transport for CNC machine communication. It is the primary CNC link in the default pendant configuration (serial/USB CNC + WiFi remote), where the pendant communicates with the CNC controller (FluidNC, grbl, grblHAL) over a direct UART connection.

The module provides a full-duplex, line-oriented UART channel, a bidirectional message queue (using the shared ESP3DClient infrastructure), an automatic connection handshake, a periodic ping/inactivity-timeout mechanism, and run-time controls for baud-rate change and RX/TX pin swap.

Build-model note: Only one CNC transport is active at a time. When SOCKET_CLIENT_SERVICE is OFF (the default), serial is the CNC link. See connection_management.md for the full transport-selection model and the feature resource matrix for memory constraints.


UI / Display

ESP-IDF UART Driver

CNC Integration

Core Platform

Serial Client Module

esp3d_serial_config_t

esp3d_serial_config.h

ESP3DSerialClient

esp3d_serial_client.h/.cpp

esp3d_serial_rx_task

FreeRTOS task

ESP3DClient (base)

esp3d_client.h

ESP3DCommands

esp3d_commands.h

ESP3DSettings / NVS

esp3d_settings.h

ESP3DValues

esp3d_values.h

ESP3DGCodeHandlerService

esp3d_gcode_handler_service.h

ESP3DGCodeHostService

esp3d_gcode_host_service.h

uart_driver / GPIO

ESP3DValues:

connection_status

server_status


FileRole
main/modules/serial/esp3d_serial_config.hConfiguration struct esp3d_serial_config_t
main/modules/serial/esp3d_serial_client.hClass declaration ESP3DSerialClient
main/modules/serial/esp3d_serial_client.cppImplementation + FreeRTOS task

Defined in esp3d_serial_config.h. Groups all UART driver parameters into a single struct so the client can be (re-)configured without scattering constants through the code.

typedef struct {
uart_port_t port; // UART_NUM_0 / UART_NUM_1 / UART_NUM_2
gpio_num_t rx_pin; // Physical RX GPIO
gpio_num_t tx_pin; // Physical TX GPIO
gpio_num_t rts_pin; // RTS GPIO (GPIO_NUM_NC if unused)
gpio_num_t cts_pin; // CTS GPIO (GPIO_NUM_NC if unused)
bool swap_rx_tx; // Runtime pin-swap flag
uart_config_t uart_config; // ESP-IDF standard UART config (baud, bits, parity…)
size_t rx_buffer_size; // Driver RX ring-buffer (bytes)
size_t tx_buffer_size; // Driver TX ring-buffer (bytes)
uint16_t rx_flush_timeout; // ms — flush partial line if no new byte arrives
uint32_t task_priority; // FreeRTOS priority for the RX task
uint32_t task_stack_size; // RX task stack in bytes
BaseType_t task_core; // Core affinity (0 or 1)
} esp3d_serial_config_t;

The single global instance esp3dSerialConfig is populated at compile time from board-specific macros (UART_PORT_IDX, UART_RX_PIN, UART_TX_PIN, etc.) defined via serial_def.h → board_config.h. loadSettings() then overrides baud_rate and swap_rx_tx at runtime from NVS.


Declared in esp3d_serial_client.h, inherits from ESP3DClient (see esp3d_core.md).

ESP3DClient

+addRxData(msg)

+addTxData(msg)

+popRx() : ESP3DMessage

+popTx() : ESP3DMessage

+clearRxQueue()

+clearTxQueue()

+getRxMsgsCount() : size_t

+getTxMsgsCount() : size_t

+enqueueTxByPriority(msg) : bool

+setRxMaxSize(max)

+setTxMaxSize(max)

#purgeRxByOrigin()

#purgeTxByOrigin()

ESP3DSerialClient

-_config esp3d_serial_config_t

-_xHandle TaskHandle_t

-_started bool

-_active bool

-_data uint8_t

-_buffer uint8_t

-_bufferPos size_t

-_init_command_sent bool

-_ping_command_sent bool

-_tx_mutex pthread_mutex_t

-_rx_mutex pthread_mutex_t

+configure(config) : bool

+begin() : bool

+handle()

+end()

+process(msg)

+flush()

+readSerial()

+pushMsgToRxQueue(msg, size) : bool

+isEndChar(ch) : bool

+started() : bool

+canSendData() : bool

+isActive() : bool

+reconnect()

+disconnect()

+change_baud_rate(baud) : esp_err_t

+swap_rx_tx(swap) : esp_err_t

+resetInitCommand()

+isInitCommandSent() : bool

+markInitCommandSent()

+resetTaskHandle()

#loadSettings()

-sendInitCommand()

-sendPingCommand()

-_configure_uart_pins()

MemberTypePurpose
_startedboolUART driver installed and RX task running
_activeboolProcessing enabled. false after disconnect(), true after reconnect()
_init_command_sentboolInit handshake sent to CNC; reset on timeout/reconnect/disconnect
_ping_command_sentboolPing dispatched (informational; guards ping tracking)
_datauint8_t*Raw read buffer — direct output of uart_read_bytes()
_bufferuint8_t*Line assembly buffer — accumulates bytes until \n/\r or buffer full
_bufferPossize_tCurrent write position in _buffer
_configptrPointer to active config struct (points to esp3dSerialConfig)
_xHandleTaskHandle_tHandle of esp3d_serial_rx_task; cleared by the task before self-delete

CNC Controlleresp3d_serial_rx_taskESP-IDF UART DriverESP3DSerialClientESP3DX::begin()CNC Controlleresp3d_serial_rx_taskESP-IDF UART DriverESP3DSerialClientESP3DX::begin()Release BT memory if BT unusedalt[isActive()]loop[while started()]Wait up to 500 ms for task to exitbegin()loadSettings() → NVS baud/swapmalloc(_data, _buffer)pthread_mutex_init(rx, tx)uart_driver_install()uart_param_config()_configure_uart_pins()_started=true, _active=trueesp3d_task_create_pinned()readSerial()uart_read_bytes()raw bytesassemble lines → pushMsgToRxQueue()wait(ESP3D_MINIMAL_WAIT)end()_started=falseclearRxQueue(), clearTxQueue()uart_driver_delete()free(_data, _buffer), destroy mutexes
  1. BT memory release — if neither ESP3D_BT_SERIAL_FEATURE nor ESP3D_BT_BLE_FEATURE is selected, releases BT controller memory (recovers ~70 KB DRAM).
  2. Queue sizing — RX max 4 KB (2 KB for non-CNC outputs), TX max 4 KB (to buffer a full [ESP0] help output in one pass at 115 200 baud).
  3. loadSettings() — reads esp3d_baud_rate and esp3d_swap_rx_tx from NVS; falls back to defaults on invalid values.
  4. Buffer allocation — two malloc() blocks of rx_buffer_size bytes each; aborts on failure, logging free heap and largest free block.
  5. Mutex init — separate pthread_mutex_t for RX queue and TX queue.
  6. UART driver install — uart_driver_install() with doubled ring-buffer for headroom.
  7. Pin config — _configure_uart_pins() handles optional swap.
  8. Task creation — esp3d_task_create_pinned() on the configured core.
  9. Initial status — sets connection_status = "T" (connecting) via ESP3DValues if serial is the output client.

Gracefully shuts down in reverse order: signals the task by clearing _started, waits up to 500 ms for the task to self-delete (the task calls resetTaskHandle() before vTaskDelete(NULL)), force-deletes if it stalls, destroys mutexes (nulling the base-class pointer to prevent stale-mutex UB on re-entry), uninstalls the UART driver, then frees buffers.

Actiondisconnect()reconnect()
_activefalsetrue
_init_command_senttrue (suppress re-init)false (allow re-init)
UART RX ring-bufferflushedflushed
Client RX queueclearedcleared
Client TX queueclearednot cleared
connection_status"?""T" (connecting)
Immediate ping—yes (via sendPingCommand())

disconnect() clears the TX queue to prevent stale stream/script traffic from timing out on an unattended target. reconnect() is called from the UI retry button — it flushes stale RX data then sends a ping immediately so the CNC responds without waiting up to 10 s for the periodic ping timer.


begin()

valid data received\nsendInitCommand() fires\nGCode handler confirms identity

inactivity timeout 30 s\n_init_command_sent reset

disconnect() called

30 s timeout with no init sent\nstuck on T forced to question mark

reconnect() called

end()

end()

end()

Stopped

Connecting

connection_status = 'T'

Connected

connection_status = 'C'

Disconnected

connection_status = '?'

connection_status = 'T'\nimmediate ping sent

Status codes used in ESP3DValuesIndex::connection_status:

CodeMeaning
"T"Connecting / trying
"C"Connected and identified
"?"Disconnected / unknown
"U"Uninitialised (before begin())

ISR fills ring buffer

uart_read_bytes 10 ms timeout

byte loop

complete line

starts with DISCONNECT

starts with SCANNING

valid line

ESP3DMessage

handle on LVGL task

UART Hardware RX

ESP-IDF UART Driver

esp3d_serial_rx_task

readSerial()

Line assembly in _buffer

accumulate until newline or buffer full

BT noise filter

ESP3D_DISPLAY_FEATURE only

connection_status = question mark

reset init flags

Silently discarded

pushMsgToRxQueue()

RX Queue

ESP3DClient deque

max 4 KB

esp3dCommands.process(msg)

ESP3DGCodeHandlerService

parse responses

update display values

ESP3DRenderingClient

if target = all_clients

process msg

handle on LVGL task

false after disconnect

true

GCode Host / UI command

enqueueTxByPriority

high priority to front of queue

TX Queue

ESP3DClient deque

max 4 KB

canSendData

_started AND _active

TX held

wait for reconnect

uart_write_bytes()

UART Hardware TX

Key design points:

  • The RX task calls readSerial() which blocks for up to 10 ms inside uart_read_bytes(). No extra vTaskDelay is added to minimise ACK latency during GCode streaming.
  • The TX drain runs in handle() on the LVGL task (Core 1), gated by canSendData(). This prevents TX from draining while _active == false.
  • The RX drain in handle() is intentionally not gated by _active so incoming firmware responses are still processed during a disconnect event.
  • When the RX queue is full, pushMsgToRxQueue() evicts old messages by processing them immediately (esp3dCommands.process()) to make room, rather than silently dropping the incoming message.

This mechanism is compiled only when ESP3D_DISPLAY_FEATURE is enabled and only runs when serial (or uart_ext) is the active output client.

ESP3DValuesesp3dGcodeHandlerreadSerial() loopESP3DValuesesp3dGcodeHandlerreadSerial() looplastActivity initialised to millis() on first calllastPing initialised to millis() on first callalt[age > 10 000 ms AND pingAge > 10 000ms]alt[_init_command_sent == true was connected][init was never sent stuck on T]alt[age > 30 000 ms inactivity timeout]loop[every readSerial() invocation]Valid data received updates lastActivityBT DISCONNECT noise resets init flags and statusage = now - lastActivitypingAge = now - lastPingsendPingCommand()lastPing = nowconnection_status = "?"server_status = "?"_init_command_sent = falseconnection_status = "?"lastActivity = now rearm for next cycle

Constants:

ConstantValuePurpose
ESP3D_PING_INTERVAL10 000 msPeriod between automatic pings when idle
ESP3D_INACTIVITY_TIMEOUT30 000 msDeclare disconnection after this many ms with no valid data

BT module SCANNING lines do not update lastActivity, so the ping/timeout cycle operates correctly even when a Bluetooth bridge module is attached and producing periodic output.


CNC ControllerESP3DGCodeHandlerServiceESP3DSerialClientCNC ControllerESP3DGCodeHandlerServiceESP3DSerialClientFirst valid data received from CNCGCode handler parses response\nsets connection_status = 'C'Handshake bypassed for TARGET_IS_NONE buildsalt[command is non-empty][command is empty]alt[_init_command_sent == false][already sent]sendInitCommand()getInitCommand()e.g. dollar-I newline for FluidNC or empty for none targetbuild ESP3DMessage\norigin=serial type=uniqueaddTxData(msg)_init_command_sent = trueresetStartupCommandsSent()command via UART TXidentity response_init_command_sent = trueno-op
UART DriverESP3DSerialClientSettings ScreenUART DriverESP3DSerialClientSettings Screenchange_baud_rate(new_baud)reset _init_command_sent and _ping_command_sentpthread_mutex_lock rx_mutex and tx_mutexflush()uart_set_baudrate(port, new_baud)_config.uart_config.baud_rate = new_baudpthread_mutex_unlock tx_mutex and rx_mutexuart_get_baudrate() verify actual rate

Resetting _init_command_sent ensures the handshake is replayed at the new baud rate once the CNC responds.

Some cable configurations have RX and TX physically crossed. swap_rx_tx(bool) re-routes the GPIO signal assignments via esp_rom_gpio_connect_out/in_signal() without reinstalling the UART driver. The setting is persisted to NVS by the Settings screen.

normal

swapped

swap_rx_tx == false

TX signal to tx_pin

RX signal to rx_pin

swap_rx_tx == true

TX signal to rx_pin

RX signal to tx_pin

gpio_reset_pin both physical pins

esp_rom_gpio_connect signals

gpio_set_pull_mode PULLUP on logical RX

_bufferPos reset to 0

_configure_uart_pins() is called by both begin() and swap_rx_tx() so pin setup is always consistent.


UART Driver Buffers

UART RX ring buffer

rx_buffer_size x 2

UART TX ring buffer

tx_buffer_size

Heap DRAM

_data

rx_buffer_size bytes

raw uart_read_bytes output

_buffer

rx_buffer_size bytes

line assembly workspace

_rx_mutex

pthread_mutex_t

_tx_mutex

pthread_mutex_t

RX Queue deque

max 4 KB total payload

TX Queue deque

max 4 KB total payload

Allocation rules (per ESP32 memory constraints):

  • Both _data and _buffer are malloc()’d in begin(). If either fails, begin() logs total free heap and largest free block, then returns false.
  • Buffers are free()’d in end() before the function returns.
  • rx_buffer_size comes from UART_RX_BUFFER_SIZE * 2 (board config); the UART driver ring-buffer is doubled again (rx_buffer_size * 2) for driver headroom.
  • TX queue max is 4 KB so the full [ESP0] help output (~2700 bytes) fits in one pass. At 115 200 baud the UART drains ~115 bytes per 10 ms tick; a 1 KB limit causes livelock where each drain cycle frees less than the next entry adds.
  • See esp32_memory_constraints.md for heap, fragmentation, and WiFi-vs-BT RAM trade-offs that affect sizing decisions.

  • Own answers sent at once. Received messages go through processRxMsg(), and process() first calls sendOwnAnswer(): a long answer to a command received on this link ([ESP400], [ESP0], [ESP740]…) is written at once instead of overflowing the TX queue. The transport write is factored into writeTxMsg(), shared with the handle() TX drain.
  • Low-memory admission and lost lines. Each received line first goes through rxAdmit() (a refused line is dropped on purpose); allocation failures call esp3d_rx_line_lost(), and a line not sent to the CNC calls esp3d_tx_line_lost() (its ok will never come, S21). See CNC_Firmware_Integration.md, “Lost Lines, Input Overflow and Resets”.
  • Two roles. isCncLink() is true when serial / UART Ext is the CNC output: controller responses, no authentication. Otherwise UART0 is a command console: with authentication (and serial authentication not disabled) its level is kept by the client and checked on every line through checkCommandChannelLine(); a guest may only send ESP commands, gets answers to its own commands only (no machine stream) and a refusal text otherwise; logout() serves [ESP500]LOGOUT. A (re)started console starts as guest. See esp3d_x_stream_authentication.md, section 2.2.
  • UART overflow detection (input overflow S7). Without flow control the driver loses bytes inside lines when its RX ring is full, which could reach the CNC as truncated but valid G-code. A UART event queue (UART_EVENT_QUEUE_SIZE = 20) is installed only to see UART_FIFO_OVF / UART_BUFFER_FULL; readSerial() drains it without blocking every loop, and checkUartOverflow() also catches a ring found full when the events were dropped. Reaction: the buffered data is flushed, the current line and the rest of the broken line up to the next end char are dropped, and the loss is reported as a local buffer overflow of this source (on the CNC link the flow is reset and the streams stop with an error). Overflow events during a pause concern purged data and are not a loss.
  • Complete writes. uart_write_bytes() may return a partial count when the TX ring is full; the remainder used to be dropped, truncating G-code mid-word (S1000 → S1). The client now loops until the whole message is written, sleeping one real tick when there is no progress and giving up after about 500 ms of a stalled TX buffer.
  • Boot ping. As soon as the gcode host task runs, a ping is sent (like reconnect()), so a controller that is already running answers at once and the startup sequence, which waits for server_status C, does not wait for the first 10 s ping. The init command ($I) is written straight to the TX queue, not through the gcode host, because it is sent when host commands may be stuck waiting for an ok; its ok is unexpected for the host, which never lets such an ack end a command not sent yet.
  • Robust begin(). No ESP_ERROR_CHECK (a failure must not abort()); any failure after the driver install rolls back through end() so a later begin() starts clean.
  • Rate-limited drop report. RX messages dropped by pushMsgToRxQueue() are reported once per second with a count (one error log per line under a flood kept the core 0 task busy until the task watchdog fired, 2026-10-20).
  • The reported client type is uart_ext when it is the output client, serial otherwise.

FlagEffect on this module
ESP3D_DISPLAY_FEATUREEnables BT-noise filtering, ping/timeout loop, and connection_status / server_status updates via ESP3DValues
ESP3D_BT_SERIAL_FEATURE / ESP3D_BT_BLE_FEATUREWhen both absent, begin() calls esp_bt_controller_mem_release(ESP_BT_MODE_BTDM) to recover BT RAM
ESP3D_UART_EXT_FEATURERX message origin / target set to uart_ext when that client is the active output client
ESP3D_DISABLE_SERIAL_AUTHENTICATION_FEATUREForces authentication_level = admin on all incoming RX messages
TARGET_IS_NONESkips swap_rx_tx NVS read; getInitCommand() returns "" (handshake bypassed)
CONFIG_UART_ISR_IN_IRAMSets ESP_INTR_FLAG_IRAM for the UART driver ISR to avoid cache-miss stalls during flash operations

process(msg)

uart_write_bytes

UART RX

set_value

getInitCommand

resetStartupCommandsSent

sendPingCommand

parsed responses

baud_rate

swap_rx_tx

dispatch

dispatch

ESP3DSerialClient

esp3dCommands

RX dispatch

CNC Controller

FluidNC / grbl / grblHAL

ESP3DValues

connection_status

server_status

UIManager / Screen callbacks

connection status display

ESP3DGCodeHandlerService

ESP3DGCodeHostService

streaming / flow control

ESP3DSettings NVS

ESP3DRenderingClient

CollaboratorInteraction
esp3d_core.md — ESP3DClientBase class — dual-deque message queues, message factory (newMsg, setDataContent, deleteMsg), mutex helpers, priority enqueue
esp3d_commands.md — ESP3DCommandsprocess(msg) called for each dequeued RX message; getOutputClient() checked to decide routing and noise filtering
gcode_host.md — ESP3DGCodeHandlerServicegetInitCommand(), sendPingCommand(), resetStartupCommandsSent() — target-firmware-specific hooks
values.md — ESP3DValuesset_value(connection_status / server_status) for UI feedback; thread-safe get_value() overload used from the RX task
ESP3DSettingsreadUint32(esp3d_baud_rate), readByte(esp3d_swap_rx_tx) from NVS on loadSettings()
ESP3DRenderingClientReceives CNC responses when serial is the output client (message target = all_clients)
bluetooth_serialSibling transport module — same ESP3DClient base and connection-status model
socket_client.mdSibling transport module — same ESP3DClient base and connection-status model
usb_serial.mdSibling transport module — same ESP3DClient base and connection-status model
gcode_host.mdStreaming flow-control details, in-flight cap mechanism, and startup command sequence
CNC_Firmware_Integration.mdOverall CNC firmware integration overview
Communication_Transports.mdParent module — all four transport modules in context

// esp3d_serial_client.cpp — definition
ESP3DSerialClient serialClient;
// esp3d_serial_client.h — declaration for other translation units
extern ESP3DSerialClient serialClient;

serialClient is a file-scope singleton. Other modules (GCode host, commands dispatcher, UI settings screen) reference it via the extern declaration. The board configuration singleton esp3dSerialConfig is defined in the same .cpp file and handed to the client via configure(&esp3dSerialConfig) inside loadSettings().