Skip to content

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:

# code.py / app.py
__chumicro_skip_factories__ = ("sockets_factory",)

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_bytes defaults to 16384 (16 KB). Inbound messages larger than this trigger WhenOversized policy. The parser runs a three-tier inbound size model (mirrors chumicro-mqtt):
  • Tier 1, frames ≤ payload_buffer_size (256 B): reuse the steady-state parse buffer; delivery still materializes one bytes snapshot of the payload per frame.
  • Tier 2, frames between payload_buffer_size and max_payload_bytes: one-shot bytearray(payload_length), freed after delivery.
  • Tier 3, frames > max_payload_bytes: rolling discard, no allocation beyond the steady-state buffer. The bytes are gone but reported_length is surfaced. WhenOversized policy decides whether to stay connected (DROP_SILENT / DROP_WITH_EVENT, matching chumicro-mqtt / chumicro-requests) or close with 1009 (DISCONNECT).
  • max_tx_queue_size defaults to 8 outbound messages. Enqueueing past the cap raises WebSocketBackpressureError. 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_tick defaults to 1024 bytes. recv_budget_per_tick also defaults to 1024. A single recv_into fills 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. Use chumicro-ntp to set the clock first.
  • CA pinning is required. Build the ssl_context with chumicro_sockets.ssl_context_with_ca(pem) and pass it through connector_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.