User Guide¶
Overview¶
chumicro-websockets is a non-blocking WebSocket (RFC 6455) client + server built on chumicro-sockets and chumicro-timing. Two top-level classes: WebSocketClient for outbound ws:// / wss:// connections, and WebSocketServer for inbound. Both follow the runner pattern from chumicro-runner (check(now_ms) / handle(now_ms)), so an LED can keep blinking through the opening handshake, frame I/O, control-frame interleave, and the close handshake.
Getting started with generators¶
The receive-stream surface reads inbound messages as a linear loop: message = yield from client.next_message() waits for the next message, hands it back, and loops until the session closes. Register the client (it does the frame I/O each tick) and the consumer generator side by side.
from chumicro_runner import Runner
from chumicro_sockets.sockets_factory import connector_factory
from chumicro_websockets import WebSocketClient
client = WebSocketClient(transport_factory=connector_factory(radio=wifi.adapter.radio))
client.connect("ws://api.example.com/stream", timeout_ms=10_000)
def receive():
while True:
message = yield from client.next_message() # InboundMessage
if message is None:
break # session closed
print(message.text if message.is_text else message.data)
runner = Runner()
runner.add(client)
handle = runner.add_generator(receive())
runner.run_until(handle)
The first next_message() call switches inbound delivery from the on_text / on_binary callbacks to a bounded queue the generator drains (drop-oldest); pick one inbound surface per client. See examples/receive_stream.py.
Getting started with a service (client)¶
The check / handle service shape suits callback-style consumers and any client you drive from your own tick loop.
from chumicro_websockets import WebSocketClient, WebSocketState
from chumicro_sockets.sockets_factory import connector_factory
from chumicro_timing import ticks_ms
from chumicro_wifi import wifi
client = WebSocketClient(
transport_factory=connector_factory(radio=wifi.adapter.radio),
)
client.on_text = lambda text: print(f"got: {text}")
client.on_close = lambda code, reason: print(f"closed {code} {reason}")
client.connect("ws://api.example.com/stream", timeout_ms=10000)
while client.state != WebSocketState.CLOSED:
now = ticks_ms()
if client.check(now):
client.handle(now)
if client.state == WebSocketState.OPEN and want_to_send_now:
client.send_text("hello")
want_to_send_now = False
Getting started with a service (server)¶
from chumicro_websockets import WebSocketServer
from chumicro_sockets.sockets_factory import listener_factory
from chumicro_timing import ticks_ms
from chumicro_wifi import WifiConfig, WifiService
wifi = WifiService(WifiConfig(ssid="home-wifi", password="s3cret"))
def on_connection(connection):
connection.on_text = lambda text: connection.send_text(f"echo: {text}")
connection.on_close = lambda code, reason: print(f"client gone: {code}")
server = WebSocketServer(
listener_factory=listener_factory(
"0.0.0.0", 8765, radio=wifi.adapter.radio,
),
on_connection=on_connection,
max_connections=2,
)
while True:
now = ticks_ms()
if server.check(now):
server.handle(now)
listener_factory= defers the bind to the first handle() tick, so construction touches no socket. An already-open listening socket works too: pass it as listener= instead (exactly one of the two).
Runner pattern¶
Both WebSocketClient and WebSocketServer implement the runner contract (check(now_ms) / handle(now_ms)). Register them with chumicro_runner.Runner and they get ticked alongside your other services:
from chumicro_runner import Runner
runner = Runner()
runner.add(websocket_client) # has check + handle
runner.add_periodic(led_blink, period_ms=500)
runner.add(sensor_service)
while True:
runner.tick()
check(now_ms) -> bool reports whether work is pending; handle(now_ms)
does at most one tick of progress, capped by recv_budget_per_tick and
send_budget_per_tick.
Callbacks¶
All callbacks default to no-op functions and fire from inside handle(),
never from a thread or interrupt.
Client (WebSocketClient)¶
| Callback | Fired when |
|---|---|
on_open() |
The opening handshake completes; state is now OPEN. |
on_text(text: str) |
A complete text message has been received and UTF-8-validated. |
on_binary(data: bytes) |
A complete binary message has been received. |
on_ping(payload: bytes) |
The server sent a PING; the client has already auto-queued the PONG echo. |
on_pong(payload: bytes) |
The server replied to one of our PINGs. |
on_close(code: int, reason: str) |
The connection has reached CLOSED (graceful or abnormal). |
on_oversized(reported_length: int) |
An inbound message exceeded max_message_bytes; WhenOversized policy decided what to do. |
Server (Connection)¶
The user wires callbacks inside on_connection(connection), which fires
once per accepted connection at the moment its handshake completes:
def on_connection(connection):
connection.on_text = ...
connection.on_binary = ...
connection.on_close = ...
connection.on_oversized = ...
Same shape as the client's callbacks; semantically identical.
For a broadcast, iterate server.connections (a tuple snapshot of the
live Connection objects) and call send_text / send_binary on each;
server.connection_count is the cheap size check.
Bring your own transport¶
WebSocketClient and WebSocketServer don't care which library produces their sockets. The transport_factory you pass to the client (and the listener or listener_factory you hand to the server) return any object matching the SocketConnector contract on the client side or the listener contract on the server side. The connector advances DNS / TCP / TLS across multiple ticks; once connector.state == "ready", the underlying socket must expose the four-method TCP contract:
| Method | Contract |
|---|---|
recv_into(buffer, nbytes) -> int |
Reads up to nbytes into buffer (a memoryview). Raises OSError(EAGAIN \| EWOULDBLOCK) on no data, returns 0 on peer-close, otherwise returns bytes written. |
send(payload) -> int |
Sends payload (a bytes). Raises OSError(EAGAIN \| EWOULDBLOCK) when the send buffer is full, otherwise returns bytes sent (may be partial). |
close() -> None |
Releases the connection. |
setblocking(flag) -> None |
Best-effort. Absence is tolerated. |
chumicro_sockets.connector is one valid producer. See chumicro_sockets._connector.SocketConnector for the connector contract (tick(now_ms), state, socket, io_*, next_deadline, cancel). Any tick-driven state machine with that surface works as a custom factory.
If you supply your own factory and want chumicro_sockets dropped from the deploy entirely, add a module-level constant to your entrypoint and the chumicro-workspace deployer will filter the default factory out of the import graph:
Family form ("sockets_factory", matches every chumicro_*.sockets_factory) or exact path ("chumicro_sockets.sockets_factory"). An unmatched entry fails the deploy with a typo message rather than silently shipping the default. Calling WebSocketClient.from_config(...) when chumicro_sockets.sockets_factory is missing (either skipped at deploy time or not installed by circup / mip) raises RuntimeError naming the bypass kwarg.
chumicro-websockets adopts cleanly on its own: pass your own transport_factory (client) or listener / listener_factory (server), set ticks= to your clock, drive it from a plain while loop instead of chumicro_runner, and run host tests through an injected transport with no board attached.
Memory notes¶
The library is sized for the minimum supported board class (256 KB MCU RAM, 2 MB physical / ~800 KB usable flash):
max_message_bytesdefaults to16384(16 KB). Inbound messages larger than this triggerWhenOversizedpolicy. The parser runs a three-tier inbound size model (mirrorschumicro-mqtt):- Tier 1, frames ≤
payload_buffer_size(256 B): reuse the steady-state parse buffer; delivery still materializes onebytessnapshot of the payload per frame. - Tier 2, frames between
payload_buffer_sizeandmax_payload_bytes: one-shotbytearray(payload_length), freed after delivery. - Tier 3, frames >
max_payload_bytes: rolling discard, no allocation beyond the steady-state buffer. The bytes are gone butreported_lengthis surfaced.WhenOversizedpolicy decides whether to stay connected (DROP_SILENT/DROP_WITH_EVENT, matchingchumicro-mqtt/chumicro-requests) or close with 1009 (DISCONNECT). max_tx_queue_sizedefaults to8outbound messages. Enqueueing past the cap raisesWebSocketBackpressureError. System-driven frames (auto-pong, close handshake) bypass the cap into 8 slots of headroom; a CLOSE always fits, and an auto-pong that would fill the last slot under a ping flood is dropped (RFC 6455 §5.5.3 allows answering only the most recent ping) rather than evicting a queued frame.send_budget_per_tickdefaults to1024bytes.recv_budget_per_tickalso defaults to1024. A singlerecv_intofills a 512 B scratch buffer, so a tick reads the budget in 512-byte chunks (1024 B/tick at the default) until the budget is spent or the socket has no more data; a 16 KB message takes ~16 ticks to drain end-to-end, well within LED-blink latency.- The frame parser is one-shot per frame: parsed payload moves out of the parser into the message reassembly buffer in the client / connection, then the parser resets to header-reading. No held references to old frame bytes.
Per-tick knobs¶
| Knob | Default | Why |
|---|---|---|
recv_budget_per_tick |
1024 |
LED-friendly inbound drain. |
send_budget_per_tick |
1024 |
LED-friendly outbound drain. |
max_message_bytes |
16384 |
16 KB cap on assembled inbound messages. |
max_tx_queue_size |
8 |
Bounded TX queue. |
when_oversized |
WhenOversized.DROP_WITH_EVENT |
Drop the oversized message, fire on_oversized(reported_length), stay connected. DISCONNECT closes with 1009 instead. |
ping_interval_ms |
None (disabled) |
Optional client-side keep-alive ping cadence. |
pong_timeout_ms |
30000 |
Close after 30 s without PONG to a PING. |
handshake_timeout_ms |
10000 |
Total opening-handshake budget. |
close_timeout_ms |
5000 |
Wait window for peer's CLOSE before forcing TCP teardown. |
Platform notes¶
| Runtime | Client (ws:// + wss://) |
Server (ws://) |
Server (wss://) |
|---|---|---|---|
| CPython | ✅ | ✅ | ✅ |
| MicroPython | ✅ | ✅ | ✅ |
| CircuitPython on ESP32 family (S2 / S3) | ✅ | ✅ | ✅ |
| CircuitPython on Pi Pico W (rp2) | ✅ | ✅ | ❌ (raises UnsupportedSSLConfigError) |
TLS (wss://)¶
wss:// client connections reuse chumicro_sockets.connector(tls=True) + chumicro_sockets.ssl_context_with_ca, with the same live-board constraints HTTPS clients have:
- Device RTC must be set before
wss://. mbedTLS rejects every cert as "validity starts in the future" if the RTC is at boot default. Usechumicro-ntpto set the clock first. - CA pinning is required. Build the
ssl_contextwithchumicro_sockets.ssl_context_with_ca(pem)and pass it throughconnector_factory(radio=..., ssl_context=ctx). - Pi Pico W needs flash deploy mode for
wss://: RAM-mode leaves <50 KB free for the mbedTLS handshake.
Examples¶
| Example | What it shows |
|---|---|
client.py |
Wifi-capable board (CP or MP) connecting to a remote ws:// echo server. |
server.py |
Wifi-capable board (CP or MP) accepting inbound websocket connections. |
receive_stream.py |
The next_message stream surface: a generator drains inbound messages linearly under runner.add_generator, with the session ticking alongside. |