WebSocket Client Module
Section titled “WebSocket Client Module”Scope:
main/modules/websocket_client/+components/esp_websocket_client/— outbound WebSocket transport for CNC communication Build flag:ESP3D_WS_CLIENT_SERVICE_FEATURELast updated: 2026-09-03
Overview
Section titled “Overview”The WebSocket client module provides an outbound WebSocket (WS/WSS) transport that connects the pendant directly to a CNC controller running FluidNC or grblHAL. It is one of four independent CNC transports (Serial, BT Serial, BT BLE, WebSocket/TCP Socket) and is gated at build time by ESP3D_WS_CLIENT_SERVICE_FEATURE.
The module is mutually exclusive with the TCP Socket Client (ESP3D_SOCKET_CLIENT_FEATURE) — both fulfil the same outbound-TCP-to-CNC role; only one may be compiled in at a time (cmake/sanity_check.cmake enforces this).
| Transport | Source files | CNC link | Notes |
|---|---|---|---|
| Serial UART | esp3d_serial_client.* | UART | Always available |
| BT Serial (SPP) | esp3d_bt_serial_client.* | Classic BT | Mutually exclusive with WiFi |
| BT BLE (GATT) | esp3d_bt_ble_client.* | BLE | Mutually exclusive with WiFi |
| Socket Client (TCP) | esp3d_socket_client.* | WiFi TCP | Mutually exclusive with this module |
| WebSocket Client | esp3d_websocket_client.* | WiFi WS/WSS | This module |
See connection_management.md for the high-level connection flow and status observable semantics, and feature_resource_matrix.md §2 for the full build-exclusion matrix.
File Layout
Section titled “File Layout”main/modules/websocket_client/├── esp3d_websocket_client.h — Class + enum declarations; extern singleton└── esp3d_websocket_client.cpp — Full implementation; RX task; event handler
components/esp_websocket_client/├── esp_websocket_client.c — Vendored IDF WS transport (TCP + RFC 6455 framing)└── include/esp_websocket_client.h — API used by this moduleGlobal singleton (defined in esp3d_websocket_client.cpp):
ESP3DWebsocketClient esp3dWebsocketClient;Class Hierarchy
Section titled “Class Hierarchy”ESP3DClient (esp3d_client.h — queue management, message lifecycle, mutex helpers) └── ESP3DWebsocketClient (esp3d_websocket_client.h/.cpp)ESP3DClient supplies:
addRxData()/popRx()/getRxMsgsCount()/clearRxQueue()addTxData()/popTx()/getTxMsgsCount()/clearTxQueue()enqueueTxByPriority()— high-priority → front of deque, normal → backsetRxMutex()/setTxMutex()— queue thread-safety hooksnewMsg()/deleteMsg()/setDataContent()—ESP3DMessagelifecycle
Internal Types
Section titled “Internal Types”WsChunk (anonymous namespace, .cpp)
Section titled “WsChunk (anonymous namespace, .cpp)”Fixed-size struct passed through the FreeRTOS chunk queue from the WS event callback to the RX task. Avoids dynamic allocation in the ISR-context callback.
struct WsChunk { uint16_t len; uint8_t data[ESP3D_WS_CLIENT_CHUNK_MAX]; // 128 bytes (tasks_def.h)};ESP3DWebsocketClientState (uint8_t enum, .h)
Section titled “ESP3DWebsocketClientState (uint8_t enum, .h)”enum class ESP3DWebsocketClientState : uint8_t { disconnected = 0, connected,};ESP3DWebSocketSubprotocol (uint8_t enum, .h)
Section titled “ESP3DWebSocketSubprotocol (uint8_t enum, .h)”enum class ESP3DWebSocketSubprotocol : uint8_t { none = 0, // No Sec-WebSocket-Protocol header webui_v3 = 1, // "webui-v3" — FluidNC WebUI v3 binary machine stream esp3d_v1 = 2, // "esp3d-v1" arduino = 3, // "arduino"};Key Private Fields
Section titled “Key Private Fields”| Field | Type | Purpose |
|---|---|---|
_ws_client | esp_websocket_client* | Handle to the IDF WebSocket transport |
_state | ESP3DWebsocketClientState | Connected / disconnected |
_host[64] | char[] | CNC hostname or IP (scheme-prefix stripped) |
_path[32] | char[] | WS path (e.g. /ws, default /) |
_uri[128] | char[] | Assembled URI (ws[s]://host:port/path) |
_port | uint32_t | CNC WebSocket port |
_started | bool | True between begin() success and end() |
_isRunning | bool | Controls the RX task loop |
_xHandle | TaskHandle_t | RX task handle; set to nullptr on task exit |
_chunk_queue | QueueHandle_t | ISR-safe queue: event callback → RX task |
_buffer | char* | Line-assembly buffer (heap, allocated in begin()) |
_buffer_pos | size_t | Write cursor in _buffer |
_rx_timeout_ms | uint64_t | Timestamp of last chunk received (drives flush timer) |
_last_data_received_ms | uint64_t | Timestamp of last CNC data (drives inactivity check) |
_last_ping_time_ms | uint64_t | Timestamp of last ping (throttles ?\n sends) |
_init_command_sent | bool | Prevents duplicate GCode init command per connection |
_requires_explicit_connect | bool | When set, begin() skips auto-start |
_subprotocol | ESP3DWebSocketSubprotocol | Active subprotocol selection |
Build Configuration
Section titled “Build Configuration”Enable in CMakeLists.txt:
option(WS_CLIENT_SERVICE "WebSocket client to CNC" OFF)cmake/features.cmake converts this to the ESP3D_WS_CLIENT_SERVICE_FEATURE preprocessor define.
cmake/sanity_check.cmake forbids combining:
WS_CLIENT_SERVICE+SOCKET_CLIENT_SERVICE— two competing CNC-over-WiFi transports.WS_CLIENT_SERVICE+ any Bluetooth transport — hardware mutual exclusion (no PSRAM on target boards).
Lifecycle
Section titled “Lifecycle”Connection State Machine
Section titled “Connection State Machine”[firmware start / end()] │ ▼ Unconfigured │ ├── feature OFF or no addr/port ──────► Disabled (begin() → true, no-op) │ ├── _requires_explicit_connect set ───► WaitingConnect │ │ │ connect() called │ │ └── all settings present ───────────────────┤ ▼ Starting │ chunk queue + buffer allocated esp_websocket_client init + start OK RX task spawned │ ▼ Running ◄──────────────────────────┐ (RX task loop) │ │ │ WEBSOCKET_EVENT_CONNECTED │ │ ▼ │ Connected │ │ │ DISCONNECT / ERROR / CLOSED │ │ inactivity timeout (30 s) ────┴─────────────────────────────► Running │ end() called ▼ Stopped ──► begin() re-called ──► Startingbegin() — Step by Step
Section titled “begin() — Step by Step”- Read
esp3d_socket_client_on— returntrue(no-op) if disabled. - Check
_requires_explicit_connect— returntrue(waiting) if set. - Read
_host,_port,_path,_subprotocolfromESP3DSettings. - Detect and strip
wss:///ws://prefix from_host; record theuse_wssflag. - Ensure
_pathhas a leading/; assemble_uri = ws[s]://host:port/path. - Set RX/TX queue byte budgets:
setRxMaxSize(ESP3D_WS_CLIENT_QUEUE_MAX_BYTES)/setTxMaxSize(...). xQueueCreate(_chunk_queue)— depth 24 ×sizeof(WsChunk).malloc(_buffer)—ESP3D_WS_CLIENT_RX_BUFFER_SIZE(1 024) + 1 bytes.- Build
esp_websocket_client_config_t(URI,buffer_size=2048,network_timeout_ms=10000, internal task stack 4 096 / priority 5,skip_cert_common_name_check=truefor WSS, subprotocol string). esp_websocket_client_init()+esp_websocket_register_events(ANY, handler, this).esp_websocket_client_start()— begins TCP + WS handshake asynchronously.- Spawn
esp3d_ws_client_rx_taskpinned toESP3D_SOCKET_TASK_CORE(core 0).
end() — Self-Call Handling
Section titled “end() — Self-Call Handling”end() detects whether it is running on the RX task itself (e.g. [ESP134]OFF arrives over WebSocket → esp3dCommands.process() → disconnect() → end() — all on the RX task):
Self-call path: Sets _isRunning = false, stops and destroys _ws_client, then returns. The RX task loop sees _isRunning == false, calls resetTaskHandle() (_xHandle = nullptr), and self-deletes. Resource cleanup (_chunk_queue, _buffer) runs after the guard loop, which exits immediately once _xHandle is null.
External caller path:
- Set
_isRunning = false; stop + destroy_ws_client. - Clear
_started; flush and clear RX/TX queues; reset state fields. - Poll for
_xHandle → nullptrup to 2 000 ms (10 ms slices). - Force-delete the task with
vTaskDelete()if still alive (last resort, logged withesp3d_log_e). - Free
_chunk_queueand_buffer.
Mutex lifecycle:
_tx_mutexand_rx_mutexare initialised in the constructor and destroyed in the destructor — never inbegin()/end(). Re-initialising while the RX task holds a lock would causepthread_mutex_destroy()to deadlock.
Data Flow
Section titled “Data Flow”Inbound (CNC → Pendant)
Section titled “Inbound (CNC → Pendant)”CNC Controller │ WebSocket text or binary frame ▼esp_websocket_client (IDF internal task) │ WEBSOCKET_EVENT_DATA callback ▼esp3d_ws_cnc_ws_event_handler() │ ├── text frame ──► WsChunk{len, data} ──► xQueueSend(_chunk_queue) ──────────────────┐ │ │ └── binary frame (webui_v3 only) ──► feedIncomingByte() directly ──────────────────┐│ ││esp3d_ws_client_rx_task (RX task loop) ││ │ drainIncomingChunks() ││ │ └── xQueueReceive(_chunk_queue) ◄────────────────────────────────────────────── ┘│ │ └── feedIncomingByte(byte) ◄───────────────────────────────────────────────┘ │ └── _buffer[_buffer_pos++] = byte │ on '\n' / '\r' / buffer full: │ └── pushMsgToRxQueue(_buffer, _buffer_pos) │ └── ESP3DMessage* → addRxData() │ │ handle() │ └── popRx() → esp3dCommands.process(msg) ▼esp3dCommandsBinary shortcut (webui_v3 only): Binary frames bypass the chunk queue and feed feedIncomingByte() directly inside the event callback. This treats FluidNC’s binary machine stream as a plain text-line stream without an extra copy through the queue.
Outbound (Pendant → CNC)
Section titled “Outbound (Pendant → CNC)”UI Screen / GCode Host │ process(ESP3DMessage*) ▼ESP3DWebsocketClient::process() │ enqueueTxByPriority(msg) │ high priority → addFrontTxData() (front of deque) │ normal → addTxData() (back of deque) ▼TX deque (ESP3DClient) │ handle() [called from RX task loop] │ └── popTx() → sendWsText(data, len) ▼esp_websocket_client_send_text() │ WebSocket text frame ▼CNC ControllerConnection Event Handling
Section titled “Connection Event Handling”esp3d_ws_cnc_ws_event_handler is a free function declared as friend in the class, registered as the WS event callback via esp_websocket_register_events(). It delegates to ESP3DWebsocketClient::onWebsocketEvent() through the handler_args pointer:
| WS Event | State | Side effects |
|---|---|---|
WEBSOCKET_EVENT_CONNECTED | → connected | server_status = "T"; reset ping/data timers; _init_command_sent = false; call sendInitCommand(); call esp3dGcodeHandler.resetStartupCommandsSent() |
WEBSOCKET_EVENT_DISCONNECTED | → disconnected | server_status = "?"; _init_command_sent = false |
WEBSOCKET_EVENT_CLOSED | → disconnected | server_status = "?"; _init_command_sent = false |
WEBSOCKET_EVENT_ERROR | → disconnected | server_status = "?"; _init_command_sent = false |
WEBSOCKET_EVENT_DATA | — | Text → chunk queue; binary (webui_v3) → feedIncomingByte() directly |
Keepalive and Inactivity
Section titled “Keepalive and Inactivity”tickRxFlushAndPing() is called from drainIncomingChunks() after draining the chunk queue on every RX task iteration:
tick │ ├── rx_timeout_ms != 0 AND elapsed > 1 500 ms AND buffer_pos > 0? │ YES → pushMsgToRxQueue(partial buffer); reset _buffer_pos, _rx_timeout_ms │ ├── connected AND last_data_received > 10 000 ms AND last_ping > 10 000 ms? │ YES → sendWsText("?\n", 2); update _last_ping_time_ms │ └── connected AND last_data_received > 30 000 ms? YES → esp_websocket_client_stop() state = disconnected; server_status = "?"; reset _last_data_received_ms, _last_ping_time_ms| Timer | Threshold | Action |
|---|---|---|
| RX flush | 1 500 ms | Flush partial line buffer to prevent stale partial messages |
| CNC ping | 10 000 ms | Send ?\n GCode status poll to keep the link alive |
| Inactivity cutoff | 30 000 ms | Stop the WS client; transition to disconnected |
Subprotocol Support
Section titled “Subprotocol Support”Configured via ESP3DSettingIndex::esp3d_ws_client_subprotocol; affects the HTTP Upgrade handshake header and the frame-handling path inside onWebsocketEvent():
| Value | Sec-WebSocket-Protocol | Binary frame handling |
|---|---|---|
none (0) | Not sent | Ignored |
webui_v3 (1) | webui-v3 | Decoded as byte stream → feedIncomingByte() |
esp3d_v1 (2) | esp3d-v1 | Ignored (text frames only) |
arduino (3) | arduino | Ignored (text frames only) |
The webui_v3 path enables compatibility with FluidNC’s WebUI v3 binary machine stream. See websockets_protocol.md for the full binary framing specification (V1 opcodes, message layout).
TLS / WSS Support
Section titled “TLS / WSS Support”The host field in settings accepts an optional scheme prefix:
| Stored value | Transport | Certificate validation |
|---|---|---|
192.168.1.100 | Plain ws:// | — |
ws://192.168.1.100 | Plain ws:// (prefix stripped) | — |
wss://192.168.1.100 | Encrypted wss:// | Skipped (skip_cert_common_name_check = true) |
Certificate validation is intentionally skipped — CNC controllers on a LAN typically use self-signed certificates. Full CA verification is on the roadmap (docs/roadmap/ws_client_and_auth_roadmap.md).
Memory Layout
Section titled “Memory Layout”Values from tasks_def.h (pibot_pendant_v1_0 variant; other boards use the same defaults):
| Resource | Size | Allocation |
|---|---|---|
_buffer (line assembly) | ESP3D_WS_CLIENT_RX_BUFFER_SIZE + 1 = 1 025 B | malloc() in begin() |
_chunk_queue | 24 slots × sizeof(WsChunk) ≈ 3 120 B | xQueueCreate() in begin() |
WsChunk.data | ESP3D_WS_CLIENT_CHUNK_MAX = 128 B per slot | Inside the FreeRTOS queue buffer |
| RX / TX deques | Up to ESP3D_WS_CLIENT_QUEUE_MAX_BYTES = 2 048 B each | ESP3DClient deque (heap) |
| RX task stack | ESP3D_WS_CLIENT_TASK_SIZE = 8 192 B | FreeRTOS task |
| IDF WS internal buffer | 2 048 B (cfg.buffer_size) | IDF component internal |
RX queue eviction: When addRxData() fails (queue full), the oldest message is dropped to make room; the incoming message is retried once. If the retry also fails, the incoming message is dropped with esp3d_log_e. This is the same back-pressure pattern as bt_serial, bt_ble, and usb_serial. The eviction loop calls ESP3DClient::deleteMsg() without esp3dCommands.process() — safe from both the event callback and the RX task contexts.
For the overall heap fragmentation budget see esp32_memory_constraints.md.
Integration Points
Section titled “Integration Points”Settings Keys Read on begin()
Section titled “Settings Keys Read on begin()”ESP3DSettingIndex | Content |
|---|---|
esp3d_socket_client_on | Feature enable/disable byte |
esp3d_socket_client_address | CNC host string (optional ws:///wss:// prefix) |
esp3d_socket_client_port | CNC port (uint32) |
esp3d_ws_client_path | WS path string (default /) |
esp3d_ws_client_subprotocol | ESP3DWebSocketSubprotocol byte |
Observable Value Updated
Section titled “Observable Value Updated”ESP3DValuesIndex | Value | When |
|---|---|---|
server_status | "T" | WEBSOCKET_EVENT_CONNECTED |
server_status | "?" | Disconnect / error / inactivity / disconnect() / stopAutoConnect() |
Full token semantics ("U", "T", "C", "?", "A") are documented in connection_management.md.
GCode Handler
Section titled “GCode Handler”On WEBSOCKET_EVENT_CONNECTED:
esp3dGcodeHandler.getInitCommand()— firmware-specific init string (e.g.$I\nfor grbl/grblHAL).esp3dGcodeHandler.resetStartupCommandsSent()— restarts the startup sequence for the new connection.
sendInitCommand() enqueues the init string as an ESP3DMessage in the TX queue. It is guarded by _init_command_sent to ensure it fires exactly once per WS connection.
Public API
Section titled “Public API”// Lifecycle — called by ESP3DNetworkServicesbool begin(); // Read settings, allocate resources, start WS client + RX taskvoid end(); // Stop WS client, wait for RX task exit, free all resources
// Explicit connection control ([ESP134] ON / OFF)bool connect(); // Clear _requires_explicit_connect, then call begin()void disconnect(); // end() + server_status = "?"void stopAutoConnect(); // end() + set _requires_explicit_connect = true
// Init command — sent once per connectionvoid sendInitCommand(); // Enqueue GCode init string in TX queue (guarded by _init_command_sent)
// Called from the RX task loopvoid drainIncomingChunks(); // Dequeue WsChunks → feedIncomingByte() → RX queuevoid handle(); // Dispatch one RX msg to commands; send one TX msg over WS
// GCode / command pipelinevoid process(ESP3DMessage* msg); // Priority-aware enqueue into TX queuevoid flush(); // No-op — satisfies ESP3DClient interface contract
// State accessorsbool started(); // true after begin() succeeds, before end()bool canSendData(); // started && state == connected && _ws_client != nullptrbool isConnected(); // state == ESP3DWebsocketClientState::connectedbool isRunning(); // RX task loop is activevoid resetTaskHandle(); // RX task calls this on exit: _xHandle = nullptr
const char* host(); // Configured CNC host (scheme-prefix stripped)uint32_t port(); // Configured CNC portTask and Concurrency Model
Section titled “Task and Concurrency Model”Core 0 ESP3D_SOCKET_TASK_CORE (core 0) IDF internal task──────────────────────────── ────────────────────────────────────── ──────────────────────────────────networkTask esp3d_ws_client_rx_task esp_websocket_client_task └── ESP3DNetworkServices └── drainIncomingChunks() └── TCP + RFC 6455 framing ├── begin() └── xQueueReceive(_chunk_queue) └── WEBSOCKET_EVENT_* └── end() └── feedIncomingByte() └── esp3d_ws_cnc_ws_event_handler └── tickRxFlushAndPing() ├── xQueueSend(WsChunk) └── handle() └── feedIncomingByte() └── esp3dCommands.process(msg) (webui_v3 binary only)
Core 1 (LVGL)──────────────────────────tft_ui_task └── ESP3DValues::handle() └── UI callbacks on server_status changeThread-safety mechanisms:
| Resource | Protection |
|---|---|
_rx_queue (RX deque) | pthread_mutex_t _rx_mutex (via ESP3DClient::setRxMutex()) |
_tx_queue (TX deque) | pthread_mutex_t _tx_mutex (via ESP3DClient::setTxMutex()) |
_chunk_queue | FreeRTOS queue — ISR-safe by design |
_buffer / _buffer_pos | Single logical writer (RX task + event callback for binary) — no additional mutex |
_ws_client pointer | Null-checked before every use; written only under _started guard |
_state enum | Written from WS event callback (IDF task); read from RX task — single-word load, no torn reads on Xtensa |
LVGL constraint:
esp3dCommands.process()runs on the RX task, not on the LVGL task. Commands that update the UI must useesp3dXValues.set_value(). The observable system dispatches the result to the LVGL task viaESP3DValues::handle(). Directlv_obj_*calls from the RX task are forbidden. See screens_architecture.md.
a14 Changes
Section titled “a14 Changes”- Own answers sent at once. Received messages go through
processRxMsg(), andprocess()first callssendOwnAnswer(): 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 intowriteTxMsg(), shared with thehandle()TX drain. - Low-memory admission and lost lines. Each received line first goes through
rxAdmit()(a refused line is dropped on purpose); allocation failures callesp3d_rx_line_lost(), and a line not sent to the CNC callsesp3d_tx_line_lost()(itsokwill never come, S21). See CNC_Firmware_Integration.md, “Lost Lines, Input Overflow and Resets”. - One RX task for the line buffer (H3). Binary frames (only meaningful for the
webui-v3subprotocol, ignored otherwise) now go through_chunk_queuelike text frames, sofeedIncomingByte()and the line buffer are only touched by the RX task (drainIncomingChunks). Feeding binary bytes directly on theesp_websocketstack task raced with the drain and could corrupt the buffer.xQueueSend(..., 0)is non-blocking with no allocation; on a full queue the rest of the frame is dropped with a log. - Bare realtime ping (no
\n), as for the socket client (S20). - On a full RX queue the oldest message is dropped, never processed from this task (same pattern as the other clients).
Error Handling
Section titled “Error Handling”| Condition | Response |
|---|---|
Feature disabled (esp3d_socket_client_on OFF) | begin() → true (deliberate no-op); server_status = "?" |
| No host or port configured | begin() → true (no-op); server_status = "?" |
_requires_explicit_connect set | begin() → true (waits for connect()); server_status = "?" |
| Settings read error | begin() → false; error logged with esp3d_log_e |
xQueueCreate() fails | begin() → false; no resource leak |
malloc(_buffer) fails | Queue freed; begin() → false |
esp_websocket_client_init() returns null | Resources freed; begin() → false |
esp_websocket_client_start() returns error | Client destroyed, resources freed; begin() → false |
| Task creation fails | Client stopped + destroyed, resources freed; begin() → false |
addRxData() queue full | Oldest msg evicted; retry once; if still full, incoming msg dropped (esp3d_log_e) |
sendWsText() while not connected | Returns false; handle() deletes the TX message |
| Chunk queue full in event callback | Current WS frame truncated; esp3d_log_e emitted; partial data discarded |
| No CNC data for 30 s | esp_websocket_client_stop(); state → disconnected; server_status = "?" |
RX task alive after 2 s in end() | vTaskDelete(_xHandle); esp3d_log_e emitted — last resort only |