Skip to content

WebSocket Server Module

The websocket_server module provides the ESP3D-X pendant with two independent WebSocket endpoints hosted on the same HTTP port. It is a server-side transport — distinct from the outbound websocket_client.md used for CNC connectivity. Together the two endpoints allow a remote browser-based WebUI and external tooling (PC utilities, file managers) to communicate with the pendant simultaneously over WiFi.

Build guard: the two services are independently controlled by compile-time flags.

  • ESP3D_WEBUI_SERVER_FEATURE → enables ESP3DWebUiService (/ws)
  • ESP3D_WS_SERVER_SERVICE_FEATURE → enables ESP3DWsDataService (/wsdata)

Both require WiFi and the HTTP server (ESP3DHttpService) to be running. See Network_&_Web_Services.md for feature compatibility constraints.


ESP3DHttpService (esp_httpd)
│
├── /ws URI handler ──► ESP3DWebUiService (subprotocol: webui-v3)
│ │
│ inherits from
│ │
└── /wsdata URI handler ──► ESP3DWsDataService (subprotocol: esp3d-v1)
│
inherits from
│
ESP3DWsService (base class)
│
┌─────────┴──────────┐
│ │
esp3dCommands ESP3DAuthenticationService
(message routing) (session management)
EndpointClassSubprotocolFrame modesFile transfer
/wsESP3DWebUiServicewebui-v3TEXT (commands, notifications) · BINARY (raw CNC stream)No
/wsdataESP3DWsDataServiceesp3d-v1TEXT (ESP commands, G-code) · BINARY (V1 upload/download)Yes

class ESP3DWsService
Public:
begin(config) → bool // allocate client pool, record server handle
handle() // periodic zombie purge
end() // disconnect all clients, free memory
http_handler(req) → esp_err_t // single URI handler entry point
process(msg) // outbound: broadcast or unicast message
onOpen(req) → esp_err_t // virtual — called on WS upgrade
onMessage(req) → esp_err_t // virtual — called on DATA frame
onClose(fd) → esp_err_t // virtual — called on disconnect
pushMsgToRxQueue(...) → bool // inbound: authenticate + dispatch to command pipeline
pushMsgTxt(fd, msg) → esp_err_t // unicast text frame
pushMsgBin(fd, msg) → esp_err_t // unicast binary frame
BroadcastTxt(msg) → esp_err_t // text to all connected clients
BroadcastBin(msg) → esp_err_t // binary to all connected clients
clientsConnected() → uint
getClientSocketId(i) → int // fd of slot i, or -1 (mutex-protected snapshot)
purgeZombieClients() // drop stale lwIP fds
revoke(fd) // [ESP500]LOGOUT: stop broadcast data to fd (AUTHENTICATION)
Protected:
processTextFrame(fd, buf, len) // buffer accumulation + line-flush to rx queue
socketLevel(fd, has_session) // per-line / per-frame level (AUTHENTICATION)
setHttpSession(fd, id) / getHttpSession(fd, out) // /ws: HTTP session the socket was opened with
setAuthorized(fd, bool) // outbound filtering state
checkSessions() // once a second from handle()
class ESP3DWebUiService : public ESP3DWsService
process(msg) // override: broadcast/unicast to WebUI clients
pushNotification(msg) // wrap as NOTIFICATION:<msg> TEXT frame, send with pushText()
pushText(msg) // TEXT to every /ws client (session checked per recipient)
handle() // base handle() + close sockets whose HTTP session is gone
onOpen(req) // override: check HTTP session, register client, send currentID/activeID frames
onMessage(req) // override: accept TEXT commands + forward raw binary stream
Private:
hasValidSession(fd) // AUTHENTICATION: true if the HTTP session is valid, else closes fd

Global instance: esp3dWsWebUiService

class ESP3DWsDataService : public ESP3DWsService
onOpen(req) // override: register client, send welcome TEXT frame
onMessage(req) // override: route TEXT → base processTextFrame,
// BINARY → V1 opcode dispatch
onClose(fd) // override: abortTransfer() then base onClose
Private:
_transfer // ESP3DWsTransferInfo — single in-progress transfer slot
handleStatusRequest()
handleUploadStart()
handleUploadPacket()
handleUploadEnd()
handleDownloadStart()
handleDownloadPacketAck()
handleDownloadEndAck()
handleNak()
handleCommand()
refuseGuestFrame() // AUTHENTICATION: status L reply to a guest binary frame
sendFrame()
sendDownloadPacket()
abortTransfer()

Global instance: esp3dWsServerDataService


Passed to ESP3DWsService::begin() to initialise a service instance.

struct ESP3DWebSocketConfig {
httpd_handle_t server_handle; // shared esp_httpd handle from ESP3DHttpService
uint max_clients; // pool size; each slot allocates a per-client receive buffer
esp3dSocketType type; // websocket_server_webui or websocket_server_data
};

One slot per connected client, allocated as a flat array at begin() time.

struct ESP3DWebSocketInfos {
int socket_id; // lwIP fd; -1 = slot free
struct sockaddr_storage source_addr; // IPv4-in-IPv6 address filled on connect
char *buffer; // per-client receive accumulation buffer
uint buf_position; // current write position in buffer
// Only present when ESP3D_AUTHENTICATION_FEATURE is enabled:
char session_id[25]; // /wsdata: session OWNED by the socket (pwd= login), cleared on close
char http_session_id[25]; // /ws: HTTP cookie session the socket was opened with,
// REFERENCED (never cleared on close); the socket is
// closed as soon as that session is no longer valid
bool authorized; // /wsdata outbound filtering: broadcast data only when true
uint32_t last_rx_ms; // last received line / frame (stream inactivity timeout)
};

authorized is a cached copy, updated on every received line or frame, because the send path (process(), also used by the WebSocket log backend from the logging task) must never call the authentication service: that service logs while holding its mutex, so the call could deadlock.

Memory: each slot performs two heap allocations at begin() — the array element and the receive buffer. These happen at startup before the heap fragments. The ESP32 in Bluetooth mode can have as little as 10 KB free; prefer small max_clients values. See the project’s docs/guides/esp32_memory_constraints.md guide.

Owned by ESP3DWsDataService. Tracks the single active file transfer.

struct ESP3DWsTransferInfo {
ESP3DWsTransferState state; // idle | uploading | downloading
int client_fd; // fd of the transferring client (-1 = none)
FILE *file; // open file handle
uint32_t total_size; // declared by client (upload) or from stat (download)
uint32_t processed_bytes; // bytes written (upload) or sent (download)
uint32_t last_packet_id; // last successfully handled packet id
char full_path[512]; // ESP3D_WS_TRANSFER_PATH_MAX — path + '/' + filename
};

Files: main/modules/websocket_server/esp3d_ws_service.h/.cpp

The base class provides all infrastructure shared by both endpoints.

begin() validates the config, allocates _max_clients slots and their receive buffers, and marks the service started. end() triggers close on every connected client, clears authentication sessions for this instance’s client type, and frees all allocations. end() is idempotent — it is safe to call on a stopped service.

_clients[] is used by three tasks: the httpd task (onOpen() / onMessage() / onClose()), the network services task (handle() → purgeZombieClients(), closeClients()) and the command dispatch task (process() → BroadcastTxt(), pushMsgTxt() from HTTP handlers). It is protected by _clients_mutex, a plain non-recursive pthread mutex:

  • every read or change of a slot happens under the mutex;
  • network calls (httpd_ws_send_frame_async, httpd_sess_trigger_close, httpd_ws_get_fd_info, getpeername) are never made while holding it: the slot state (fd) is copied under the lock, the send or close happens unlocked, then the lock is taken again to revalidate or release;
  • broadcasts take each slot’s fd under the lock and send outside it; onClose() checks socket_id == fd under the lock before freeing a slot, so a recycled fd can never free the wrong session;
  • no callback called under the mutex re-enters the class.

Use getClientSocketId(index) when only the fd is needed: a raw ESP3DWebSocketInfos* from getClientInfos() is not safe once the lock is released. The authentication service is always called without this lock (it has its own).

A flat array of ESP3DWebSocketInfos with socket ids initialised to -1 (free). addClient() claims the first free slot and records the peer IP address via getpeername(). onClose() resets the slot back to free. When getFreeClientIndex() finds no free slot, the new connection is rejected immediately via httpd_sess_trigger_close().

purgeZombieClients() calls httpd_ws_get_fd_info() for every occupied slot. Any fd no longer marked HTTPD_WS_CLIENT_WEBSOCKET is removed via onClose(). Called from handle() periodically and opportunistically before every unicast or broadcast send, preventing stale fds from accumulating in the pool.

http_handler() is the single URI callback registered with esp_httpd. Dispatch logic:

  • HTTP_GET with a valid Upgrade: websocket header → onOpen()
  • HTTP_GET without that header → 400 Bad Request (rejects plain HTTP access to the WS URI)
  • Any other method (the IDF DATA event for established connections) → onMessage()

onMessage() probes the pending frame type by calling httpd_ws_recv_frame() with length 0, retrying in order: TEXT, BINARY, PING, PONG, CLOSE. On IDF 5.4, ESP_ERR_INVALID_STATE is returned when the probed type does not match — the loop tries each type until one succeeds. An empty content_len is rejected with ESP_FAIL to avoid the tight callback loop seen on IDF 5.4. Frames larger than ESP3D_WS_RECV_FRAME_BUF_SIZE fall back to a heap allocation with an OOM log if the allocation fails.

processTextFrame() copies bytes into the per-client buffer one at a time, under the clients mutex. On \n, \r, or when the buffer is full, it flushes the accumulated content to pushMsgToRxQueue() and resets the position. This allows commands to span multiple WebSocket frames as long as each line is terminated. ESP3DWebUiService::onMessage() now uses the same function instead of its own copy of the loop.

pushMsgTxt() and pushMsgBin() use httpd_ws_send_frame_async(). A failed send logs a warning but does not close the socket — transient send failures under concurrent HTTP upload load must not tear down the WebUI connection.

BroadcastTxt() / BroadcastBin() iterate all occupied slots and call the matching unicast sender. An ignore fd parameter (default -1) excludes one connection, typically used to prevent echoing a message back to its sender.


pushMsgToRxQueue() — Inbound message routing

Section titled “pushMsgToRxQueue() — Inbound message routing”

This is the boundary between the WebSocket transport and the firmware command pipeline.

Incoming TEXT line (flushed from processTextFrame)
│
▼
ESP3DClient::rxAdmit() — low-memory admission; a refused line is dropped
│
▼
[ESP3D_AUTHENTICATION_FEATURE enabled] — checked on EVERY line
socketLevel(fd) → session valid? level (inactivity timer restarted)
session gone? guest, slot session cleared
no session → extract pwd= param → getAuthenticatedLevel()
guest?
not an ESP command → send AUTH_REQUIRED_MSG, return false
ESP command → dispatched (each command checks its own level;
this is how a guest logs in or reads [ESP500])
not guest and no session → create session id + createRecord()
(ERROR_MSG if it fails), setAuthorized(true)
pwd= on an ESP command with a session → authorized follows that level
[no auth feature] → authentication_level = admin
│
▼
ESP3DClient::newMsg()
Set: origin = authClientType()
webui_websocket (for /ws)
websocket_server (for /wsdata)
target = stream
type = unique
request_id.id = socket fd
│
is_esp_command(msg)?
yes → target = command
│
▼
esp3dCommands.process(msg)

Responses return asynchronously through process(). A message addressed to one fd (msg->request_id.id, the answer to its own command) is always sent to it. A broadcast (request_id.id == 0) goes, with authentication, only to sockets that are authorized and have received something within the stream timeout (getStreamSessionTimeout(), [ESP510]STREAM=, 0 = no expiry); without authentication it is BroadcastTxt(). The filter only takes the slots lock, never the authentication service.

handle() calls checkSessions() once a second: a socket whose record is gone (evicted, cleared elsewhere) loses authorized. revoke(fd) (called by [ESP500]LOGOUT) drops it at once.

See esp3d_x_stream_authentication.md for the full model.


File: main/modules/websocket_server/esp3d_webui_service.h Guard: ESP3D_WEBUI_SERVER_FEATURE

Extends the base class for the browser-facing endpoint.

MethodPurpose
onOpen()With authentication, first requires a valid, non-guest HTTP session from the ESPSESSIONID cookie (/ws carries the machine stream); otherwise the upgrade is refused. The check runs before the previous client is replaced, so a guest can never kick out an authenticated page. Then registers the client, stores the cookie session with setHttpSession(), and sends currentID and activeID synchronisation frames so browsers can coordinate which tab holds the active session.
onMessage()Accepts TEXT commands from the browser (delegated to processTextFrame()); forwards inbound BINARY frames as raw CNC stream bytes without any modification. A PING checks the session the socket was opened with (not the id named in the PING); if it is gone the socket is closed, otherwise the PING counts as activity and the answer gives the time that was left.
process(msg)Receives responses from the firmware pipeline; broadcasts when msg->request_id.id == 0, unicasts to the specific fd otherwise. With authentication the session is checked per recipient (hasValidSession()) at the moment the data leaves, so there is no window between a logout or expiry and the end of the stream.
pushText(msg)TEXT to every /ws client, with the same per-recipient session check (BroadcastTxt() without authentication). Used by pushNotification() and by the HTTP upload error path (pushError()).
pushNotification(msg)Wraps msg as a NOTIFICATION:<msg> TEXT frame and sends it with pushText(). Called by the [ESP600] command handler.
handle()Base handle(), then (authentication) once a second closes every socket whose HTTP session is gone: WebUI logout, [ESP510]HTTP= timeout reached. hasValidSession() is read-only: neither outgoing data nor this check keeps a session alive.

For the full /ws text-frame vocabulary (currentID, activeID, PING/PONG, NOTIFICATION, ERROR, SENSOR) see docs/architecture/websockets_protocol.md §1.


File: main/modules/websocket_server/esp3d_ws_data_service.h Guard: ESP3D_WS_SERVER_SERVICE_FEATURE

Extends the base class to add the ESP3D Binary Protocol V1 for file upload and download. TEXT frames on this socket continue to carry ESP commands and G-code via the inherited processTextFrame() path — both frame types are multiplexed on the same connection.

ConstantValueDescription
ESP3D_WS_TRANSFER_PACKET_SIZE1024Maximum data payload bytes per binary frame
ESP3D_WS_TRANSFER_PATH_MAX512Maximum file path length in transfer frames
WS_BINARY_PROTOCOL_V11Protocol revision byte returned in RS idle response
ESP3D_WS_DATA_URL"/wsdata"Registered URI
ESP3D_WS_DATA_SUBPROTOCOL"esp3d-v1"WebSocket subprotocol negotiated on this endpoint

All V1 binary frames share a fixed 4-byte header:

Offset Size Content
0 2 Opcode — 2 ASCII bytes (e.g. 'S','R')
2 2 Payload length — little-endian uint16
4 N Payload (N bytes, value from the length field)
OpcodeDirectionMeaning
SRclient → ESPStatus request
RSESP → clientStatus response
SUclient → ESPStart upload (path + filename + total size)
USESP → clientUpload start ACK
UPclient → ESPUpload data packet (4-byte packet id + data)
PUESP → clientUpload packet ACK (status byte + 4-byte packet id)
EUclient → ESPEnd of upload
UEESP → clientUpload end ACK
SDclient → ESPStart download (path + filename)
DSESP → clientDownload start ACK + file size
DPESP → clientDownload data packet (4-byte packet id + data)
PDclient → ESPDownload packet ACK
EDESP → clientEnd of download
DEclient → ESPDownload end ACK
NKeitherNAK / retransmit request
CMclient → ESPCommand byte — A (0x41) = abort
ByteHexUsed inMeaning
O0x4FUS, PU, UE, DS, RSidle / ok
E0x45US, PU, UE, DS, RSerror
B0x42US, DS, RSbusy (another transfer in progress)
A0x41UE, RSaborted
U0x55RS payloadupload in progress
D0x44RS payloaddownload in progress
L0x4CRS, US, UE, DS, PUlogin required (authentication enabled, socket not authenticated), same as ESP3D

With authentication, every binary frame goes through the same session gate as TEXT (socketLevel()) before any dispatch, so a guest never reaches a handler. An accepted frame restarts the session inactivity timeout, so a long upload or download never expires halfway. A guest frame is answered by refuseGuestFrame(), with the same mapping as ESP3D:

RequestReply
SRRS L (no version byte: a refusal, not a capability probe)
SU / EU / SDUS / UE / DS L
UPPU L + packet id (0xFFFFFFFF when missing)
PD, DE, NK, CM, unknownno reply, ignored

If the guest is the owner of the running transfer (it lost its session: logout, wrong password, timeout), the transfer is aborted instead of keeping the file and the busy state.

Only one transfer is active at a time. A second client attempting to start while one is in progress receives B (busy) in the ACK.

┌─────────────────────────────────────┐
│ idle │
│ SR → RS(O + version byte = 1) │
└──────┬───────────────┬──────────────┘
│ │
SU received SD received
send US(O) send DS(O) + file size
│ │
┌────────▼──┐ ┌───────▼────────┐
│ uploading │ │ downloading │
│ │ │ │
│ UP → PU │ │ PD → send DP │
│ (O or E) │ │ │
└─────┬─────┘ └──────┬─────────┘
│ │
EU / CM(A) / error / disconnect │
────────────────────────────► │
│
ED / CM(A) / error / disconnect
──────────────────────────────►
idle

abortTransfer() closes the open file handle, resets _transfer.state to idle, and clears _transfer.client_fd. It is invoked from:

  • onClose() — client disconnects while a transfer is in progress
  • handleCommand() — client sends CM A (abort command)
  • the binary session gate — the transfer owner lost its session

At the end of an upload, webUiPsramCache.invalidate(full_path) drops a stale cached copy when the file is served from the WebUI PSRAM cache (no-op otherwise, see http_service.md).

For the complete frame-by-frame sequence, RS payload layouts, capability probe handshake, and testing instructions see docs/architecture/websockets_protocol.md §2.


Client ESP3DHttpService / ESP3DWsService
│ │
│── HTTP GET /ws (Upgrade: websocket) ──►│
│ │ http_handler() → onOpen()
│ │ addClient(fd)
│ │ getpeername() → record peer IP
│◄── 101 Switching Protocols ────────────│
│◄── TEXT: welcome frame ────────────────│ WebUiService: currentID + activeID
│ │ DataService: "Welcome to ESP3D-X V…"
│ │
│── TEXT: [ESP720]\n ────────────────────►│
│ │ onMessage() → processTextFrame()
│ │ buffer → flush on \n
│ │ pushMsgToRxQueue()
│ │ authenticate / look up session
│ │ esp3dCommands.process(msg)
│◄── TEXT: response ─────────────────────│ process(response_msg) → pushMsgTxt(fd)
│ │
│── BINARY: SR (DataService only) ───────►│
│◄── BINARY: RS(O, version=1) ───────────│
│ │
│── WS CLOSE ────────────────────────────►│
│ │ onClose(fd)
│ │ abortTransfer() if uploading/downloading
│ │ clearSession(session_id) [if auth enabled]
│ │ free slot (_clients[i].socket_id = -1)

When ESP3D_AUTHENTICATION_FEATURE is enabled:

  1. /ws (WebUI): the socket is accepted only with a valid, non-guest HTTP cookie session. It references that session (http_session_id) and is closed as soon as the session is no longer valid (checked on every send and once a second). Logging out of the WebUI or reaching [ESP510]HTTP= therefore closes the stream.
  2. /wsdata: a client logs in with pwd=<password> in a line. pushMsgToRxQueue() gets the level with getAuthenticatedLevel() and on success calls createRecord(); the session id is stored in ESP3DWebSocketInfos::session_id.
  3. The level is checked on every line and binary frame (socketLevel()), not only the first one. A session that is gone (inactivity timeout [ESP510]STREAM=, logout, wrong password) makes the socket a guest again, and a new pwd= opens a new session. A guest may only send ESP commands.
  4. Broadcast data goes only to authorized sockets (authorized flag, stream timeout); answers to a socket’s own commands are always sent. [ESP500]LOGOUT calls revoke(fd).
  5. onClose() calls clearSession(session_id) for the owned session. end() calls clearSessions(authClientType()) to purge residual records when the service stops, without touching the other service’s sessions. The /ws HTTP session is never cleared there (it belongs to the WebUI pages).
  6. The two services register sessions under distinct ESP3DClientType values — webui_websocket for /ws and websocket_server for /wsdata — so their records are independent and never collide.

Full model: esp3d_x_stream_authentication.md.


ESP3DWsService does not open its own TCP socket. It borrows the httpd_handle_t from ESP3DHttpService and registers URI handlers against it. ESP3DHttpService::begin() constructs the ESP3DWebSocketConfig structs and calls begin() on each WebSocket service after the HTTP server is up.

ESP3DHttpService::begin()
│
├── httpd_register_uri_handler(/ws) → esp3dWsWebUiService.http_handler
└── httpd_register_uri_handler(/wsdata) → esp3dWsServerDataService.http_handler

Practical consequences:

  • Both WebSocket endpoints share the same TCP port as HTTP (default 80).
  • Stopping ESP3DHttpService implicitly terminates all WebSocket connections.
  • WebSocket connections count against esp_httpd’s max_open_sockets, shared with HTTP clients.

CMake optionC defineEffect
WEBUI_SERVER ONESP3D_WEBUI_SERVER_FEATURECompiles ESP3DWebUiService, registers /ws handler
WS_SERVER_SERVICE ONESP3D_WS_SERVER_SERVICE_FEATURECompiles ESP3DWsDataService, registers /wsdata handler
SOCKET_CLIENT_SERVICE ON—Forbidden with either WS server option — cmake/sanity_check.cmake will error

sanity_check.cmake rejects builds that combine WS_SERVER_SERVICE or WEBUI_SERVER with SOCKET_CLIENT_SERVICE. The two CNC-over-WiFi transport models (TCP socket client vs. WebSocket server) cannot safely share the lwIP stack on a no-PSRAM board.


SymbolDefaultDescription
ESP3D_WS_RX_BUFFER_SIZEsdkconfigPer-client text accumulation buffer, allocated once per slot at begin()
ESP3D_WS_RECV_FRAME_BUF_SIZEsdkconfigStatic receive frame buffer; frames larger than this fall back to heap allocation
ESP3D_WS_TRANSFER_PACKET_SIZE1024Binary upload/download payload bytes per packet
ESP3D_WS_TRANSFER_PATH_MAX512Maximum file path length in transfer frames
ESP3D_WS_DATA_URL"/wsdata"URI registered for the data endpoint
ESP3D_WS_DATA_SUBPROTOCOL"esp3d-v1"WebSocket subprotocol negotiated on /wsdata

DocumentRelationship
websocket_client.mdOutbound WebSocket client — connects pendant to a remote CNC WebSocket server; sibling transport module
Network_&_Web_Services.mdParent module overview covering all network transports and feature flag compatibility
network.mdESP3DNetwork orchestrator that starts the HTTP service and therefore these WS endpoints
wifi.mdWiFi client/AP — prerequisite for any WebSocket server operation
socket_server.mdRaw TCP socket server — alternative to WebSocket for tool connectivity without HTTP overhead
esp3d_core.mdESP3DClient / ESP3DMessage types used throughout the inbound routing path
esp3d_commands.mdesp3dCommands.process() — the command dispatcher called from pushMsgToRxQueue()
docs/architecture/websockets_protocol.mdAuthoritative frame-by-frame V1 binary protocol reference, /ws text vocabulary, capability probe sequence, and testing instructions — read before implementing a client