WebSockets
WebSockets let the browser and Arduino send messages over the same connection.
Install aWOT 4.0.0 through Arduino Library Manager and use the explicit
<WsChannel.h> header. This complete ESP32 sketch serves a browser page and
broadcasts each message to a room of up to four clients. Set your WiFi
credentials, upload it, and open the IP address printed at 115200 baud.
#include <WiFi.h>
#include <aWOT.h>
#include <WsChannel.h>
WiFiServer server(80);
App app;
WsChannel<WiFiClient, 4, 1> sockets;
P(page) =
"<!doctype html><meta charset='utf-8'><title>aWOT WebSockets</title>"
"<p id='status'>Connecting...</p><input id='message'>"
"<button id='send' disabled>Send</button><pre id='log'></pre>"
"<script>"
"const status=document.getElementById('status');"
"const input=document.getElementById('message');"
"const button=document.getElementById('send');"
"const log=document.getElementById('log');"
"const ws=new WebSocket('ws://'+location.host+'/ws');"
"ws.binaryType='arraybuffer';"
"ws.onopen=()=>{status.textContent='Connected';button.disabled=false;};"
"ws.onclose=()=>{status.textContent='Disconnected';button.disabled=true;};"
"ws.onerror=()=>{status.textContent='Connection error';};"
"ws.onmessage=e=>{log.textContent+=(typeof e.data==='string'?e.data:"
"'Binary: '+Array.from(new Uint8Array(e.data)).join(','))+'\\n';};"
"button.onclick=()=>{"
"if(ws.readyState!==WebSocket.OPEN)return;"
"if(new TextEncoder().encode(input.value).length>256){"
"status.textContent='Use at most 256 UTF-8 bytes';return;}"
"ws.send(input.value);input.value='';};"
"</script>";
void indexHandler(Request& req, Response& res) {
res.set("Content-Type", "text/html; charset=utf-8");
res.printP(page);
}
void websocketHandler(Request& req, Response& res) {
if (!sockets.upgrade(req, res)) res.sendStatus(400);
}
void onConnect(int socketId) {
sockets.join(socketId, "chat");
sockets.send(socketId, "Welcome!");
}
void onDisconnect(int socketId) {
Serial.print("Disconnected slot: ");
Serial.println(socketId);
}
void onMessage(WsMessage& msg) {
sockets.to("chat", msg.data(), msg.length(), msg.isBinary());
}
void setup() {
Serial.begin(115200);
WiFi.begin("YOUR_SSID", "YOUR_PASSWORD");
while (WiFi.status() != WL_CONNECTED) delay(500);
Serial.println(WiFi.localIP());
if (sockets.defineRoom("chat") < 0) {
Serial.println("No room capacity");
while (true) delay(1000);
}
sockets.onConnect(onConnect);
sockets.onDisconnect(onDisconnect);
sockets.onMessage(onMessage);
app.get("/", indexHandler);
app.get("/ws", websocketHandler);
server.begin();
}
void loop() {
WiFiClient client = server.available();
if (client.connected()) {
char upgrade[16], connection[64], key[25], version[4];
Request::Header headers[] = {
{"Upgrade", upgrade},
{"Connection", connection},
{"Sec-WebSocket-Key", key},
{"Sec-WebSocket-Version", version}};
App::ProcessResult result = app.process(&client, headers);
if (!result.responseOpen) client.stop();
}
sockets.poll();
}
Upgrade and connection ownership
The four Request::Header entries capture the handshake fields for this
request. Array capacities include the terminating NUL. Their names and buffers
remain alive throughout process(), and the library clears values before and
after processing. Use separate capture storage for simultaneous requests.
upgrade() requires GET, HTTP/1.1, an empty/consumed request body, the WebSocket
13 handshake, and a free channel slot. It returns false for an invalid
handshake, insufficient capacity, or a write failure.
On success, the channel writes the 101 response and copies the concrete
WiFiClient handle. The copy refers to the same underlying connection. The
ProcessResult.responseOpen check leaves that connection under channel
management; calling client.stop() on the original handle would also close
it. Other requests use ordinary responses and are closed explicitly by this
sketch. Use the connection guide if you also
want HTTP keep-alive routes.
The channel’s client template argument must match the exact concrete type
passed to process(). upgrade() casts req.client() to that type. Do not
use process(Stream*) for an upgrade: its adapter exists only until processing
returns. Keep the channel and its callbacks alive while connections are active.
Avoid handing a channel-managed connection back to the HTTP parser.
Polling, messages, and rooms
Call poll() frequently, including when no new HTTP client is available. It
collects partial frame bytes, calls message callbacks, replies to control
frames, detects disconnects, and handles heartbeat pings. Blocking handlers or
long delays postpone all of those tasks.
Text and binary messages can arrive in several fragments. The channel assembles
them in its receive buffer and calls onMessage once after the final fragment.
Ping, pong, and close frames are handled between fragments. Text is checked for
valid UTF-8 as a complete message, so a character can span fragments.
WsMessage is a Stream, so you can read it byte by byte or pass it to a parser.
Its data() pointer and length() describe the complete received payload,
even after reads. They are valid only during the callback; copy the bytes if
you need them later. Payloads are not NUL-terminated and binary messages can
contain zero bytes. The room broadcast therefore uses the explicit length and
preserves the binary/text flag.
send(socketId, ...) targets one active client, broadcast(...) targets all
active clients, and to("chat", ...) targets active members of the named room.
Define a room before using its name. defineRoom() returns its index and
reuses it for equal, case-sensitive name contents, even when the table is full.
It returns -1 for a null name or when a new name exceeds MAX_ROOMS; an empty
name is valid. findRoom(name) returns an index or -1 without defining a room.
Room names are borrowed and must remain alive and unchanged while the channel
exists; this sketch uses a permanent string literal.
join(socketId, room), leave(socketId, room), and inRoom(socketId, room)
accept a registered name or a numeric index, as does to(). Save the index
returned by defineRoom() or findRoom() to avoid repeated name lookups:
sockets.to(roomIdx, msg.data(), msg.length(), msg.isBinary()) targets the
same room. Numeric indices in 0..MAX_ROOMS-1 work even without registered
names. Null or unknown names make sends and membership changes no-ops and
make inRoom() return false. One client can belong to several rooms, and new
connections start with no memberships. MAX_ROOMS defaults to eight and must
be in 1..32; this sketch reserves one room. SSE channels use the same room
API with independent memberships.
Socket IDs are reusable slot indices. connected(id) and count() report
active slots; poll() updates them after transport disconnects. Call
close(id) to send code 1000 and stop a connection, or supply another supported
close code. onDisconnect runs when a slot is deactivated, including write
failures. The sending methods return void, retry positive short writes, and
stop a client if a write fails.
Capacity, masking, and heartbeat
Each slot reserves a 256-byte message buffer by default, plus a separate
125-byte control buffer. WS_RX_BUFFER_SIZE limits the complete incoming
message, including all fragments; messages larger than that close with code
1009. The browser page limits UTF-8 byte length, which can be greater than the
number of displayed characters. Incoming frames must be masked. Browser
WebSocket clients handle masking; custom clients must supply it. Protocol
errors, including an unexpected continuation or a new data message before the
previous one finishes, close with 1002; invalid UTF-8 closes with 1007. Control
frames must be unfragmented and contain at most 125 payload bytes. Compression
extensions are unsupported. This sketch serves plain ws://; it does not add
TLS for wss://.
The channel sends a ping every 5000 ms when none is outstanding, and stops a
client if its pong has not arrived within 10000 ms. Browsers answer ping frames
without JavaScript involvement; these control frames are outside the
browser WebSocket API.
Change WS_RX_BUFFER_SIZE,
WS_PING_INTERVAL_MS, and WS_PING_TIMEOUT_MS before including the channel
header, consistently across translation units that instantiate the same
channel. Zero does not disable heartbeat. Outgoing frames use explicit lengths
and are not constrained by the receive capacity.
An Uno has much less RAM than an ESP32. For Uno with Ethernet, start with the
library’s SerialBridge example:
it uses WsChannel<EthernetClient, 1, 1> and a 64-byte message limit, plus the
125-byte control buffer. With the Ethernet 2.x library, server.accept()
transfers accepted clients out of the
server’s pending list, so later HTTP acceptance does not reuse an upgraded
socket. See the Ethernet implementation
for the difference between accept() and available().
See the complete WsChannel reference and WsMessage reference for signatures and callbacks.