Link layer¶
ww_link is the thin protocol that sits between ww_framer and the application messages
(ww_client_server requests/events). It answers the questions that the framer
deliberately leaves open:
- who is on the other end — versions, API hash, buffer sizes are exchanged before any data flows;
- is the peer still alive — periodic pings and a peer timeout;
- how does a session end — an explicit
Disconnectwith a reason, instead of silence; - what goes on which sub-channel — user data vs. link control messages.
It is no_std, allocation-free and does no IO of its own: it only defines messages and how they are encoded.
Driving the framer, timers and the actual medium is left to the event loop on each side
(wire_weaver_client on the host, ww_device on the device).
Note
As with the framer, you don't need this to use WireWeaver: connect with wire_weaver_client and things work.
Read on if you are implementing a new transport, porting the device side or looking at raw frames.
Where it sits¶
┌───────────────────────────────────────────┐
│ user API (generated code) │
├───────────────────────────────────────────┤
│ ww_client_server Request / Event bytes │
├───────────────────────────────────────────┤
│ ww_link Data / Ping / Setup … │ ◄── this page
├───────────────────────────────────────────┤
│ ww_framer messages ⇄ frames │
├───────────────────────────────────────────┤
│ medium USB, UART, CAN, UDP … │
└───────────────────────────────────────────┘
ww_link uses the framer's user_kind field to tag each message. Values 0..=2 are data channels and take the
compact 1-byte head; everything above is a control message and costs one extra head byte, which is fine because
control traffic is rare. Only Data0 is used today; Data1 and Data2 are reserved for a later use, not free for
applications, and a device drops them.
What you get¶
Framer configuration¶
ww_link messages are independent of how they are framed — the protocol logic only deals with
(kind, payload) pairs, and each medium picks the ww_framer head / checksum / tail /
frame prefix that suits it. ww_link names them per medium, as a Framing (one type
with all four) and as separate aliases:
| Medium | Framing |
Prefix | Checksum | Why |
|---|---|---|---|---|
| USB | UsbFraming |
none | none | USB checks every packet, retries, keeps them in order; frames of a previous session are told apart by the setup token |
| USB, diagnostic | UsbSeqFraming |
SeqPrefix |
none | counts lost, duplicated or reordered packets (a working USB stack has none); the device turns it on, the host follows its interface protocol |
| UDP | UdpFraming |
SeqPrefix |
CRC-32 on every message, prefix and head folded in | datagrams get lost, duplicated and reordered, nothing is retried; the UDP checksum is a 16-bit sum, optional over IPv4, and Ethernet's CRC only covers each hop; ShrinkWrap is dense, so a flipped bit often still parses |
| RTT | (RttHead / RttChecksum / RttTail) |
none | none | RAM rings read by the probe, nothing corrupted or lost; a byte stream, so only Full messages |
| WebSocket | (no framer) | TCP below keeps order and retries, TLS (when used) authenticates every record |
use ww_link::{FramingRx, FramingTx, UdpFraming, UsbFraming};
type UsbTx<'a> = FramingTx<'a, UsbFraming>; // ww_framer::Tx<'a, UsbHead, UsbChecksum, UsbTail, UsbPrefix>
type UdpRx<'a> = FramingRx<'a, UdpFraming>; // ww_framer::FramedRx<'a, UdpHead, UdpChecksum, UdpTail, UdpPrefix>
FramingTxOwned / FramingRxOwned are the std variants (TxOwned / FramedRxOwned of ww_framer). On the
device, a PacketSink / PacketSource of ww_device names its medium's Framing, and FramedTx / FramedRx frame
in it. The CRC-32 of UDP is ww_link::LinkCrc32, computed by ww_block::crc32_update: one implementation for links
and block devices, with the table a firmware picks once through ww_block's
crc-table-* features (bitwise without one, hosts get crc-table-slice16). Frame size is whatever the medium
dictates and is passed in when creating the framer (e.g., USB max packet size). Both ends of a link must agree on the
framing: USB tells it in the interface protocol (USB_INTERFACE_PROTOCOL, 0x37, or USB_INTERFACE_PROTOCOL_SEQ,
0x38, for UsbSeqFraming), every other medium has one.
Messages¶
pub enum Message<'i> {
Data { channel: u8, bytes: &'i [u8] }, // channel 0..=2
Nop,
GetDeviceInfo { token: u64 },
DeviceInfo(DeviceInfo<'i>), // DeviceInfo::token echoes it
LinkSetup(LinkSetup<'i>), // LinkSetup::token
LinkReady { token: u64 },
Ping,
GetStats,
Stats(&'i [u8]),
Loopback { repeat: u32, seq: u32, data: &'i [u8] },
Disconnect(DisconnectReason),
}
| Message | Direction | Purpose |
|---|---|---|
Data |
both | Application bytes, opaque to the link. Channel 0 carries ww_client_server; 1 and 2 are free for future use. |
Nop |
both | Ignored by the receiver. Sent first after connecting so that a lost first packet (e.g., USB toggle mismatch) doesn't eat something important. |
GetDeviceInfo |
host → device | Start of link setup, with the host's setup token (u64 LE). The device starts a new session in its transmit framer. |
DeviceInfo |
device → host | Link/API/user versions, API hash, max message length the device accepts, requested frame accumulation window, the token. |
LinkSetup |
host → device | Host user API version, max message length the host accepts, the token. |
LinkReady |
device → host | Setup accepted, Data may flow; the token (u64 LE). |
Ping |
both | Keep-alive, sent when nothing else was sent for PING_INTERVAL_MS (3 s). |
GetStats / Stats |
host ↔ device | Reserved for link statistics, payload not defined yet. |
Loopback |
both | Test helper: the peer echoes data back repeat times with increasing seq. |
Disconnect |
both | Session over; carries a DisconnectReason (RequestByUser, CommanderDropped, IncompatibleVersion, ApplicationCrash, …). |
Every message has a Kind (#[repr(u8)], Message::kind(), Kind::from_repr()) which is exactly the framer
user_kind it travels under.
Encoding and decoding¶
// sending: Message → (user_kind, payload) → framer
let mut scratch = [0u8; 128];
let (user_kind, payload) = Message::Ping.encode(&mut scratch)?;
tx.write(user_kind, payload)?;
// receiving: framer → (user_kind, payload) → Message
if let Some((user_kind, payload)) = rx.message() {
match Message::decode(user_kind, payload)? {
Message::Data { channel: 0, bytes } => { /* hand to ww_client_server */ }
Message::Ping => {}
Message::Disconnect(reason) => { /* tear down */ }
_ => {}
}
}
Data, Stats and Loopback payloads are borrowed straight from the framer buffer — no copies. DeviceInfo and
LinkSetup are ShrinkWrap structs and are deserialized on demand; with the std feature DeviceInfoOwned /
LinkSetupOwned are available too.
Errors are deliberately small:
Error |
When |
|---|---|
UnknownKind(u8) |
user_kind is not a Kind — ignore, likely a newer peer |
Malformed(Kind) |
payload didn't deserialize — a leftover from a previous session or version mismatch |
ScratchTooSmall |
encode was given a buffer too small for DeviceInfo / LinkSetup |
Constants¶
| Constant | Value | Meaning |
|---|---|---|
PING_INTERVAL_MS |
3000 | Send Ping if nothing else went out for this long |
PEER_TIMEOUT_MS |
10 000 | Consider the peer gone if nothing was received for this long |
Also re-exported for convenience: DisconnectReason, CompactVersion, FullVersion, ApiHashPair
(and *Owned variants under std).
Session lifecycle¶
host device
──── ──────
Nop ───────────►
GetDeviceInfo { token } ───────────► (new session in the transmit framer)
◄─────────── DeviceInfo { versions, hash, max_len, acc_time, token }
(check link and user API compatibility)
LinkSetup { version, max_len, token } ──►
◄─────────── LinkReady { token }
── link up ──────────────────────────────────────
Data / Ping ◄──────────► Data / Ping
── either side ──────────────────────────────────
Disconnect(reason) ───────────►
◄─────────── OR: Disconnect(reason)
Rules the reference event loop follows (and a new transport should too):
GetDeviceInfois retried every 50 ms, up to 5 times; then link setup is considered failed.- The host refuses the connection if
DeviceInfo.user_api_versionis not protocol-compatible with the generated client API (see ww_version). A device may likewise reply withDisconnect(IncompatibleVersion)afterLinkSetup. Dataaccumulates into the current frame forpacket_accumulation_time_us(as requested by the device) before being flushed, never longer; an urgent request is flushed at once, see latency and priority. Control messages are flushed immediately.- The host drops every message until the
DeviceInfocarrying its token, see Setup token. Datareceived beforeLinkReadyandDisconnectreceived while not connected are ignored — they are stragglers from a previous session.- Nothing received for
PEER_TIMEOUT_MS→ the host disconnects with an error. - Each side sends
Disconnecton the way out when it can, but must not rely on the peer doing so.
Setup token¶
A new session can start while the previous one still has bytes on the way: an answer already in the device's USB IN
buffer for a host that went away, the rest of a split message, a reply to a GetDeviceInfo the host already
re-sent. Without anything to tell them apart, an old DeviceInfo would be taken for the new one. So the host puts a
random, non-zero 64-bit token into GetDeviceInfo and LinkSetup (one per connection, the same on retries),
the device echoes it in DeviceInfo and LinkReady, and:
- the host drops everything that arrives before the
DeviceInfowith its token, and aLinkReadywith another one; a wrong match needs a stale reply to carry the same 64-bit value (1 in 1.8·10¹⁹); - the device takes
LinkSetuponly with the token of the lastGetDeviceInfo; - on
GetDeviceInfothe device starts a new session in its transmit framer (Transmit::NewSession,MessageTx::new_session,Tx::new_sessioninww_framer): a frame being filled and a half-sent message are dropped, the reply starts in a fresh frame, and with a frame prefix the epoch goes up, so that frames of the old session still on the way are dropped by the host's framer; - there is no USB reset on connect (slow, re-enumerates, kicks other tools off the device).
A peer built before the token sends none (the field reads as 0): a host answers such a DeviceInfo with
Error::IncompatibleLinkVersion ("without setup token"), a device answers such a GetDeviceInfo or LinkSetup
with Disconnect(IncompatibleVersion), so a mismatch is a clear error, not a setup timeout. The host also checks
DeviceInfo::dev_link_version against its own LINK_VERSION.
How the host runs it¶
ww_link itself is just messages. The host side (wire_weaver_client) drives them with two small
sans-IO state machines — one per direction — that contain all the protocol logic but do no IO, own no
timers and hold no channels. A thin per-transport wrapper supplies those.
commands frames
Commander ───────────────► TxCore ──► Send / Flush ──► framer ──► medium
▲ │
ToTx │ │ ToRx (plain enums, ferried by the wrapper)
│ ▼
responses, streams ◄──── RxCore ◄── (kind, payload) ◄── framer ◄── medium
Tx handles every command, drives link setup (Nop → GetDeviceInfo with retries → LinkSetup), decides when to flush the accumulated frame, sends pings and allocates request IDs. Rx decodes messages, does the version check, routes responses and stream data to waiting callers, times out requests and detects a silent peer.
Both are fed inputs (Command, Message, Timer, ...) with an explicit now, and hand back outputs
(Send, Flush, Connect, Exit) plus a few messages for the other half:
- rx → tx:
DeviceInfoaccepted,LinkReady, request IDFreed,PeerGone - tx → rx:
Expectthis response, subscribe to a stream,TransportUp,Stop
Why two halves¶
A device is usually half-duplex: it reads a packet, answers, and does not read again until the answer is written out. If the host behaves the same way — awaiting a write while not reading — both sides eventually block on each other as soon as a burst of requests produces more replies than the host has receive buffers for. It shows up as a stall followed by a write timeout, and is hard to reproduce on real hardware.
Running rx and tx as independent tasks removes the cycle: the host always drains what the device writes, so
the device always gets back to reading, so host writes always complete. Backpressure falls out for free — a slow
device just means the tx task is parked in a write, so the command channel fills up and
Commander::send().await waits. No watermarks, no prioritised selects.
What the split buys¶
- Testable without a runtime or hardware. Both cores are exercised in unit tests by hand-advancing time and
ferrying
ToTx/ToRxbetween them; the half-duplex deadlock has a regression test against a mock device. - One protocol implementation, any wrapper. The async USB wrapper is ~200 lines of plumbing. A blocking
transport is two
std::threads: tx doesblocking_recvon commands plus a timer, rx does a read with timeout. Noselect!at all. - Framing is the wrapper's choice. Cores speak
(kind, payload); USB usesU2Headwithout a checksum, UDP adds a frame counter and a CRC-32, another medium can pick differently or skip framing if it already delivers whole messages. - Same shape as the device side. The device uses the same sans-IO core + message transport + thin wrapper layering, see below.
How the device runs it¶
The device side lives in ww_device (no_std, no alloc) and follows the same layering as the host, but keeps the
event loop in user code, so that any other async source (UART data, sensors, timers) can be awaited right
next to the link:
Host (wire_weaver_client) |
Device (ww_device) |
|---|---|
TxCore / RxCore sans-IO state machines |
DeviceLink sans-IO state machine |
MessageTx / MessageRx + nusb + framer |
MessageTx / MessageRx, FramedTx / FramedRx over packets, StreamTx / StreamRx over bytes |
worker with tokio::select!, two tasks |
user loop with Server::wait() / Server::handle(), or run() |
| — | blocking::Server for devices without async |
DeviceLink answers GetDeviceInfo (a new framer session, then Nop flushed alone, then DeviceInfo with
the host's token), checks the token and the host version on LinkSetup and
replies LinkReady or Disconnect(IncompatibleVersion), runs the frame accumulation window and pings, and
declares the host gone after the peer timeout. GetDeviceInfo while the link is up means the host application
restarted without disconnecting: the old session is dropped and a new one starts. Control replies are queued as
a few flags (no queue, no allocations) and handed out by poll_transmit(); link state changes come out of
poll_event() as LinkEvent::Up / Down(reason).
Server (async) splits each step in two:
wait()is cancel-safe and only waits — for a received message, the core's next deadline, or the medium coming up. A received message stays in the framer's buffer, so the returned value borrows nothing.handle(ready, &mut backend)runs to completion: link setup,process_bytes()on the backend, writing replies, pings and flushes.
loop {
match select3(server.wait(), uart_rx.wait_read(), ticker.next()).await {
Either3::First(ready) => {
if let Some(event) = server.handle(ready, &mut state).await {
// LinkEvent::Up / Down, e.g., to drive an LED
}
}
Either3::Second(chunk) => {
_ = stream_data_ser().uart_rx_send(&chunk, &mut server.sink()).await;
}
Either3::Third(_) => { /* periodic updates through server.sink() */ }
}
}
server.run(&mut backend) is that loop with nothing else in it. Writes are awaited inline: that is safe since
the host reads independently of writing (see Why two halves), and avoids mutexes. Backend
handlers should not block for long though, as nothing is received meanwhile — use deferred replies instead.
If the backend fails to process a request entirely (it could not even serialize an error), a generic error
event (ResponseSerFailed, err_seq = u32::MAX) is sent back, so that the host does not wait for a timeout.
blocking::Server is the same for devices without an async runtime: received packets are pushed in with
on_packet() (e.g., from a USB interrupt), time is advanced with poll(now), frames go out through a blocking
PacketSink. Backends generated with use_async = false implement WireWeaverApiBackend.
Time is a plain ww_device::Instant (µs) provided by the caller; the async server takes a small Clock trait
(EmbassyClock with the embassy-time feature). wire_weaver_usb_embassy only adds the USB class, packet IO
for the endpoints and usb_init(), which returns the UsbDevice to run and the Server.
Features¶
| Feature | Effect |
|---|---|
| (none) | no_std, borrowed Tx/Rx, borrowed DeviceInfo/LinkSetup. |
std |
Adds DeviceInfoOwned/LinkSetupOwned (+ make_owned()), owned version types. Used by wire_weaver_client. |
defmt |
defmt::Format on Kind, Error, DeviceInfo, LinkSetup for embedded logging. |
Framer features large and very_large are enabled unconditionally, so messages up to 16 MiB can be described by
the head; actual limits are negotiated via dev_max_message_len / host_max_message_len.
Relation to other crates¶
- ww_framer — packs
ww_linkmessages into frames;ww_linkonly picks its parameters. wire_weaver_client— host event loop: two sans-IO state machines (tx: commands, setup retries, accumulation, ping, seq allocation; rx: decoding, version check, dispatcher, peer timeout) run as independent tasks so that receiving is never blocked by a write.ww_device— device side: sans-IODeviceLink, async and blocking servers, see above.wire_weaver_usb_embassy— USB class and packet IO for embassy-usb on top ofww_device.- USB, RTT, UDP — transports that carry frames; WebSocket carries
ww_linkmessages without the framer.