Skip to content

Socket Client Module

The Socket Client module (main/modules/socket_client/) implements the WiFi TCP transport for the pendant’s CNC controller link. It is one of four independent CNC communication transports — alongside Serial, BT Serial, and BT BLE — and is used exclusively when SOCKET_CLIENT_SERVICE is enabled at build time.

When active, the module establishes a persistent raw TCP connection (Telnet-style) to a CNC controller running FluidNC, grbl, or grblHAL. It handles all connection lifecycle events — WiFi not ready, host unreachable, half-open connections, graceful reconnection — within a single self-healing FreeRTOS task, without requiring the network layer to restart it on each reconnect.

Build exclusivity: SOCKET_CLIENT_SERVICE and SOCKET_SERVER_SERVICE / WebUI / SSDP / WS services are mutually exclusive. See cmake/sanity_check.cmake and feature_resource_matrix.md before changing transport policy.


Hardware / OS

CNC Integration

Core Infrastructure

Socket Client Module (this module)

Network Layer

begin after 650 ms defer

mode check

getLocalIp

host, port, enabled

inherits

spawns

drives

connectToServer

TCP I/O

pushMsgToRxQueue

process

sendGcode

process

set_value server_status

UI update

ESP3DNetwork

ESP3DWifiClient

ESP3DNetworkServices

ESP3DSocketClient

esp3d_socket_client_rx_task

Connection State Machine

esp3dCommands

ESP3DValues

ESP3DSettings / NVS

ESP3DClient base

ESP3DGCodeHandlerService

ESP3DGCodeHostService

lwIP BSD Sockets

FreeRTOS

CNC Controller

FluidNC / grbl / grblHAL

UI / Screens

ESP3DClient

#_rx_queue deque

#_tx_queue deque

+addRxData(msg)

+addTxData(msg)

+popRx() : ESP3DMessage

+popTx() : ESP3DMessage

+clearRxQueue()

+clearTxQueue()

+enqueueTxByPriority(msg)

+getRxMsgsCount() : size_t

+getTxMsgsCount() : size_t

ESP3DSocketClient

-_state ESP3DSocketClientState

-_socket int

-_host char[64]

-_port uint32_t

-_started bool

-_isRunning bool

-_xHandle TaskHandle_t

-_tx_mutex pthread_mutex_t

-_rx_mutex pthread_mutex_t

-_data char

-_buffer char

-_bufferPos size_t

-_rxTimeout uint64_t

-_lastDataReceived uint64_t

-_lastPingTime uint64_t

-_init_command_sent bool

-_requires_explicit_connect bool

-_consecutive_reset_count uint8_t

+begin() : bool

+end() : void

+handle() : void

+process(msg) : void

+flush() : void

+connect() : bool

+disconnect() : void

+stopAutoConnect() : void

+connectToServer() : bool

+readSocket() : void

+sendInitCommand() : void

+isConnected() : bool

+isRunning() : bool

+canSendData() : bool

+started() : bool

+resetTaskHandle() : void

-sendToSocket(data, len) : bool

-closeSocket() : void

-isEndChar(ch) : bool

-pushMsgToRxQueue(msg, size) : bool

«enumeration»

ESP3DSocketClientState

disconnected

connecting

connected

error

ESP3DMessage

+data uint8_t

+size size_t

+origin ESP3DClientType

+target ESP3DClientType

+authentication_level

+request_id ESP3DRequest

+type ESP3DMessageType

+priority ESP3DMessagePriority


The TCP connection lifecycle is governed by the ESP3DSocketClientState enum. Transitions are driven exclusively by the RX task loop.

begin() / task spawned

connectToServer() called\n(WiFi STA ready + IP present)

WiFi STA not ready\nor no IP yet\n(5 s retry wait)

TCP handshake OK\n(select() writable + SO_ERROR == 0)

TCP handshake failed\nor timed out (10 s)\nor _isRunning = false

recv() = 0 (graceful FIN)\nor ECONNRESET / ENOTCONN / EPIPE\nor inactivity timeout (30 s)\nor end() called

end() / _isRunning = false

end() / _isRunning = false

disconnected

connecting

connected

TCP_NODELAY enabled

Init command sent ($I\n)

Ping every 10 s when idle

Inactivity close after 30 s

ECONNRESET backoff: 3 s normal,

10 s after 3 consecutive resets

connectToServer() guards against

premature getaddrinfo() calls


UI / ValuesGCodeHandleresp3dCommandshandle()RX QueuereadSocket()lwIP / BSD SocketCNC ControllerUI / ValuesGCodeHandleresp3dCommandshandle()RX QueuereadSocket()lwIP / BSD SocketCNC Controllerloop[Every ~10 ms (RX task loop)]recv(fd, _data, 1024, 0) non-blocking(data from TCP stream)GCode response byteslen > 0Assemble into _buffer\nsplit on \n or \rpushMsgToRxQueue(line)popRx()ESP3DMessage*process(msg)interpret responseupdate positions / status CNC ControllerlwIP / BSD SocketsendToSocket()handle()TX Queueprocess(msg)GCodeHandler / UICNC ControllerlwIP / BSD SocketsendToSocket()handle()TX Queueprocess(msg)GCodeHandler / UIloop[Every ~10 ms (state == connected)]new ESP3DMessage (GCode line)enqueueTxByPriority(msg)\nhigh priority → front\nnormal → backpopTx()ESP3DMessage*sendToSocket(data, len)send() write loop\nhandles partial sends\nEAGAIN up to 5 sTCP bytesdeleteMsg(msg)

The RX task (esp3d_socket_client_rx_task) never exits unless end() is called. It implements all retry and reconnect logic internally, requiring no external orchestration.

No

Yes

No

Yes

No

Yes

Task start

_isRunning?

vTaskDelete self

isConnected?

connectToServer

Success?

handle

drain RX → cmds

drain TX → socket

Wait 5 s

10 ms slices, checks _isRunning

readSocket

recv + line assembly

ping + inactivity check

wait 10 ms

No

Yes

Yes

No

No / timeout

Yes

connectToServer called

WiFi STA mode

and local IP present?

return false

closeSocket if open

Plain IPv4?

inet_aton

Skip getaddrinfo

no heap, no DNS

getaddrinfo

hostname resolution

free result immediately

socket AF_INET SOCK_STREAM

fcntl O_NONBLOCK

connect returns EINPROGRESS

select loop (100 ms slices)

up to 10 s, checks _isRunning

Writable and SO_ERROR == 0?

closeSocket, return false

setsockopt TCP_NODELAY

state = connected

reset timestamps

clear TX / RX queues

set server_status = T

sendInitCommand

enqueue init cmd to TX

return true


The module maintains a two-tier watchdog to detect dead TCP connections that the lwIP stack cannot detect on its own (e.g. silent power cuts where no TCP FIN/RST is sent):

Data flowing normallyt+0sCNC sends statusreport_lastDataReceived =now_lastPingTime = nowData stopst+10sNo data forSOCKET_PING_INTERVAL_MSsend ping character_lastPingTime = nowt+10s+FluidNC respondswith status_lastDataReceived =now - connectionhealthyPing unansweredt+20sStill no datasend second pingt+30sSOCKET_INACTIVITY_TIMEOUT_MSreachedcloseSocketstate = disconnectedserver_status = ?self-healing retryConnection health timeline (normal CNC auto-report every 200 ms)
ConstantValuePurpose
SOCKET_PING_INTERVAL_MS10 000 msSend ? if no data received for this duration
SOCKET_INACTIVITY_TIMEOUT_MS30 000 msClose connection if still no data after pings
RX_FLUSH_TIMEOUT_MS1 500 msFlush incomplete line buffer (prompts without \n)
SEND_EAGAIN_TIMEOUT_MS5 000 msMax time to retry a blocked send() (WiFi TX congestion)
RESET_BACKOFF_THRESHOLD3Consecutive resets before triggering longer backoff
RESET_BACKOFF_LONG_MS10 000 msExtended backoff to let remote Telnet server close stale session

begin() is called by ESP3DNetworkServices after a 650 ms defer once WiFi STA acquires an IP. It can also be triggered directly by the [ESP134] ON command.

Yes

No

No

Yes

No

Yes

Yes

No

Yes

No

Yes

No

Yes

No

begin called

Atomic begin lock

already set?

return false

end - cleanup any previous state

Configured output client

== socket_client?

return true, no-op

esp3d_socket_client_on == ON?

set server_status = ?

return true

_requires_explicit_connect?

set server_status = ?

return true

readString esp3d_socket_client_address

host empty?

set server_status = ?

return true

readUint32 esp3d_socket_client_port

port == 0?

set server_status = ?

return true

malloc _data 1025 bytes

malloc _buffer 1025 bytes

esp3d_task_create_pinned

esp3d_socket_client_rx

8192 bytes stack

Core 0 / priority 5

pdPASS?

_isRunning = true, return true

free buffers, return false

end() is idempotent and safe to call from any task context, including from within the RX task itself (self-call path, e.g. when [ESP134] OFF arrives over the TCP socket).

Yes

No

Yes

No

Yes

No

end called

Called from

RX task itself?

selfCall

_isRunning = false

_init_command_sent = false

Skip shutdown and wait

cannot join our own task

free _data

free _buffer

reset port / host / state

_isRunning = false

Socket open and

state == connected?

shutdown SHUT_RDWR

unblocks blocking recv in RX task

flush / clearRxQueue / clearTxQueue

Wait up to 2 s for RX task exit

10 ms slices, _xHandle goes NULL

_xHandle == NULL?

closeSocket

log_e: force delete

vTaskDelete(_xHandle)

_xHandle = NULL

closeSocket

Mutex safety: Mutexes are initialised once in the constructor and destroyed only in the destructor. begin() and end() must not re-initialise or destroy them — the RX task may hold a mutex while end() runs from another context, and pthread_mutex_destroy() on a held mutex causes a deadlock (Interrupt WDT on CPU1).


Received bytes are assembled into lines, wrapped in ESP3DMessage, and routed according to whether the socket client is the configured output transport:

Conditiontarget fieldEffect
Socket client IS the output clientESP3DClientType::all_clientsBroadcasts the response to all consumers (serial terminal, WebUI, etc.) — mirrors the serial client’s behaviour
Socket client is NOT the output clientESP3DClientType::streamRoutes only to the GCode host, avoiding duplicate forwarding
  • Authentication level: set to admin — WiFi LAN is treated as a trusted network.
  • request_id.id: set to the socket file descriptor so the command router can correlate replies.

When the RX queue is full, the module evicts (processes) old messages to make room rather than silently dropping incoming data:

Yes

No - queue full

Yes

No

Yes

No

pushMsgToRxQueue

addRxData success?

done

Compute bytes needed

getRxMsgsCount > 0

and freed < needed?

popRx oldest message

esp3dCommands.process

evicted message

addRxData retry

deleteMsg, log error


Setting IndexTypeDescription
esp3d_socket_client_onuint8_tEnable flag: 1 = ON, 0 = OFF
esp3d_socket_client_addressstring (max 63 chars)Target CNC controller IP address or hostname
esp3d_socket_client_portuint32_tTarget TCP port (typically 23 for Telnet / FluidNC)

Settings are read from NVS via ESP3DSettings on each begin() call. They can be changed at runtime via the Settings screen (settings_list_screen) or [ESP134] commands and take effect on the next connect() call.

ConstantTypical ValueDescription
ESP3D_SOCKET_CLIENT_TASK_SIZE8192 bytesRX task stack — larger than socket server (4096) because getaddrinfo()/DNS needs ~3–4 KB of lwIP stack depth
ESP3D_SOCKET_TASK_PRIORITY5FreeRTOS task priority (shared with socket server)
ESP3D_SOCKET_TASK_CORE0 (Core 0)Pinned to Core 0; LVGL/UI runs on Core 1
ESP3D_SOCKET_CLIENT_QUEUE_MAX_BYTES2048 bytesTX and RX queue byte caps
ESP3D_NETSERVICE_SOCKET_CLIENT_DEFER_MS650 msDelay before begin() is called after WiFi STA acquires an IP
# CMakeLists.txt
option(SOCKET_CLIENT_SERVICE "Enable TCP socket client (WiFi CNC)" OFF)

When ON, cmake/sanity_check.cmake forbids SSDP_SERVICE, WEBUI_SERVER, WS_SERVER_SERVICE, and WS_CLIENT_SERVICE from being active simultaneously. See feature_resource_matrix.md for the full compatibility matrix.


MethodDescription
begin()Read settings, allocate buffers, spawn RX task. Called by ESP3DNetworkServices or [ESP134] ON. Returns true on success or configured no-op. Atomic flag prevents concurrent reentry.
end()Tear down connection, stop RX task, free all buffers. Idempotent. Safe from any task context including the RX task itself.
handle()Drain one RX message → esp3dCommands; drain one TX message → socket. Single-caller design: must be called only from the RX task loop.
process(msg)Enqueue an outgoing message into the TX queue (priority-aware). Called by the command router to forward GCode to the CNC controller.
flush()No-op (API compatibility with ESP3DClient). TX is drained in handle().
connect()Clear _requires_explicit_connect and call begin(). Triggered by [ESP134] ON.
disconnect()Call end() and set server_status = '?'. Triggered by [ESP134] OFF.
stopAutoConnect()Stop any active connection and set _requires_explicit_connect = true. Called from wifi_scan_screen when SSID/password changes to prevent auto-reconnect to the wrong network.
connectToServer()Resolve host (fast IPv4 path via inet_aton, fallback to getaddrinfo), create non-blocking socket, wait for TCP handshake with 100 ms select slices. Internal — called from RX task loop.
readSocket()recv() → line assembly → pushMsgToRxQueue. Also handles ping keep-alive and inactivity timeout. Internal.
sendInitCommand()Enqueue the GCode handler init command (e.g. $I\n) once per TCP connection to trigger firmware identification. Internal.
isConnected()true when _state == ESP3DSocketClientState::connected.
isRunning()true while the RX task loop should keep running.
canSendData()true when started, connected, and the socket fd is valid.
started()true between a successful begin() and the next end().
resetTaskHandle()Called by the RX task immediately before vTaskDelete(NULL) to null _xHandle.
extern ESP3DSocketClient esp3dSocketClient;

Defined in esp3d_socket_client.cpp. Referenced by:

  • ESP3DNetworkServices — calls begin() / end() on network transitions
  • [ESP134] command handler — for connect() / disconnect() / stopAutoConnect()
  • wifi_scan_screen — calls stopAutoConnect() on SSID/password change
  • settings_list_screen — reads host() / port() for display

The socket client writes to ESP3DValuesIndex::server_status to drive the connection indicator in the UI:

ValueSet byMeaning
"T"connectToServer()Trying — TCP link up, waiting for firmware identification ($I response)
"C"GCode handlerConnected — firmware identified, fully operational
"?"begin() / end() / error pathsUnknown / disconnected — no active connection, or waiting for configuration

See connection_management.md for the full status lifecycle across all transports.


  • 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”.
  • Bare realtime ping. The ? keepalive is sent as a single byte without \n: with a newline the controller strips the ? and answers the leftover empty line with an ok / error:1 that the gcode host took for the reply to its own pending command (S20, flow desync).
  • A write error no longer sets _isRunning = false directly: readSocket() detects the broken connection on the next recv().
  • Debug logs show a printable copy of the bytes sent to the CNC (non-printable bytes as \xNN) and their origin.
  • server_status / connection_status from the network layers follow ESP3DNetwork::radioIsCncLink() (network.md); the connection status screen can fall back to the server scan when the saved server is not reached within 20 s (connection_status_screen.md).

Module drives

Module depends on

ESP3DNetworkServices

begin / end lifecycle

ESP3DWifiClient

getLocalIp guard

ESP3DNetwork

getMode guard

ESP3DSettings

NVS reads

esp3dCommands

route messages

GCodeHandlerService

init command

ESP3DValues

server_status

esp3d_hal

millis / wait

ESP3DSocketClient

GCodeHostService

stream task

UI Screens

connection_status

settings_list

ModuleInteraction
ESP3DNetworkServicesCalls begin() after WiFi IP acquired (650 ms defer); calls end() on network teardown
ESP3DWifiClientgetLocalIp() checked in connectToServer() to guard against premature getaddrinfo() before lwIP is ready
ESP3DNetworkgetMode() checked to confirm WiFi STA mode is active before attempting TCP connect
ESP3DSettingsNVS reads for host address, port, and enable flag during begin()
esp3dCommandsReceives parsed CNC responses via process(); getOutputClient() determines message broadcast scope
GCodeHandlerServiceProvides getInitCommand() for post-connect handshake; resetStartupCommandsSent() restarts the startup sequence on reconnect
GCodeHostServiceSends GCode lines to the socket client via process(); flow-controlled by the GCode streaming state machine
ESP3DValuesset_value(server_status, ...) drives the UI connection indicator
connection_status_screenSubscribes to server_status to show transport state in the UI
settings_list_screenReads host/port from settings and calls stopAutoConnect() when they are changed

This module follows the project-wide ESP32 memory rules (see esp32_memory_constraints.md):

  • Dynamic buffers: Two malloc() calls in begin(), each 1025 bytes — _data (raw recv() destination) and _buffer (line-assembly accumulator). Both are checked for failure with logging of free heap and largest free block.
  • Queue byte caps: TX and RX queues are capped at ESP3D_SOCKET_CLIENT_QUEUE_MAX_BYTES (2048 bytes) to bound worst-case heap reservation.
  • Stack size: 8192 bytes, versus 4096 for the socket server. The extra space is required because getaddrinfo() / DNS resolution uses ~3–4 KB of lwIP internal stack depth.
  • Fast path for plain IPs: inet_aton() is attempted before getaddrinfo(). For direct IP addresses (the common CNC Telnet target), this avoids all heap allocation and DNS round-trip entirely.
  • getaddrinfo() result: Freed immediately after copying the resolved address — no heap reference escapes the connect path.
  • No allocation in hot path: readSocket() reuses the statically-allocated _data and _buffer on every recv iteration.

ResourceProtection mechanism
_tx_queue_tx_mutex (pthread non-recursive mutex, initialised in constructor, destroyed in destructor)
_rx_queue_rx_mutex (same pattern)
_socket fdInvalidated to FREE_SOCKET_HANDLE before close(). sendToSocket() re-validates the fd on each loop iteration to detect concurrent closure by end() or WiFi teardown
begin() reentrystd::atomic_flag — RAII BeginLockRelease ensures the flag is cleared on all exit paths
_isRunningWritten by end(), read by the RX task loop — single-bit boolean; read is atomic on all ESP32 targets
_stateWritten only by the RX task; read from other tasks for status display only (value is 8-bit and reads are observationally safe for display purposes)

Single-caller design: handle(), readSocket(), and sendToSocket() are called exclusively from the RX task. This eliminates the need for a write mutex on the TX drain path and guarantees GCode bytes from two messages can never interleave on the wire.