9.3 KiB
CoAP Hello (UDP) — Rust MVP
A minimal CoAP server over UDP in Rust, built on coap-lite
(the RFC 7252 binary codec) and a tokio UdpSocket.
CoAP is "HTTP semantics on a UDP datagram" — the same methods, paths and query
strings, but each exchange is one compact binary datagram. Response codes are
written class.detail: 2.05 Content, 2.01 Created, 4.00 Bad Request,
4.04 Not Found, 4.05 Method Not Allowed.
coap-lite does message encoding only — the socket, the read loop and the
routing are all in src/main.rs, so nothing about the UDP layer is hidden.
Run
cargo run --release
Listens on udp://0.0.0.0:5683 (CoAP's IANA-assigned port).
Routes
| Method | Path | Response |
|---|---|---|
| GET | /hello |
2.05 Hello, World! |
| GET | /hello?name=Imran |
2.05 Hello, Imran! |
| POST | /hello |
2.01 Hello, <payload>! |
| GET | /health |
2.05 {"status":"ok","uptime":12} |
POST /hello takes either a raw text payload (Imran) or JSON
({"name":"Imran"}). An empty payload returns 4.00. An unknown path returns
4.04; a known path with the wrong method returns 4.05.
Check it in a browser
Browsers speak HTTP over TCP; this server speaks CoAP over UDP, so coap://
URLs cannot be opened directly. src/bin/bridge.rs is a small HTTP -> CoAP
proxy that closes the gap. Run both:
cargo run --release --bin coap-hello-udp
cargo run --release --bin bridge
Every response shows the CoAP code mapped onto an HTTP status, with the raw
CoAP code returned verbatim in an X-Coap-Code header.
These are now ordinary URLs — clickable, curl-able, bookmarkable:
| URL | Response |
|---|---|
http://localhost:8080/ |
the CoAP reference page |
http://localhost:8080/hello |
Hello, World! |
http://localhost:8080/hello?name=Imran |
Hello, Imran! |
http://localhost:8080/health |
{"status":"ok","uptime":12} |
POST needs a body, so use curl:
curl -X POST -d "Imran" http://localhost:8080/hello
The bridge is a development convenience: a deliberately small HTTP/1.1 subset
with no keep-alive, no chunked bodies and no TLS. Do not expose it. Set
HTTP_PORT if 8080 is taken.
Try it from the CLI
With the bundled client:
cargo run --bin client -- get "/hello?name=Imran"
cargo run --bin client -- post /hello "Imran"
Or with any other CoAP client, e.g. coap-cli:
coap get "coap://localhost:5683/hello?name=Imran"
Streaming media
The server also streams two static files, loaded into memory at startup from
MEDIA_DIR (default media/):
| Route | File | Format |
|---|---|---|
GET /audio |
media/audio.wav |
PCM WAV, mono, 16-bit, 16 kHz |
GET /image |
media/image.rgb565 |
raw RGB565 little-endian, 320×240 (153,600 B) |
A file that is missing or in the wrong format is reported at startup and its
route answers 4.04 with the reason; the rest of the server still runs.
Neither file fits in one datagram, so they go out with block-wise transfer
(Block2, RFC 7959): the client asks for block 0, 1, 2 … and each reply carries
1024 bytes. The server keeps no per-client state — every request names the
block it wants, so it just slices the in-memory file. Every block carries an
ETag (so the client can tell the file did not change mid-transfer), block 0
carries Size2 (the total size), and a block past the end gets 4.02.
Make test media
mkdir -p media
ffmpeg -f lavfi -i "sine=frequency=440:duration=10" -ac 1 -ar 16000 -c:a pcm_s16le media/audio.wav
ffmpeg -f lavfi -i "testsrc=size=320x240" -frames:v 1 -f rawvideo -pix_fmt rgb565le media/image.rgb565
Or convert your own files:
ffmpeg -i input.mp3 -ac 1 -ar 16000 -c:a pcm_s16le media/audio.wav
ffmpeg -i photo.png -vf scale=320:240 -f rawvideo -pix_fmt rgb565le media/image.rgb565
media/ is gitignored.
Play it
src/bin/stream.rs downloads a file block by block and pipes it into ffplay
(part of ffmpeg), which opens the window:
cargo run --release --bin stream -- image
cargo run --release --bin stream -- audio
The image opens in a 320×240 window that stays until you close it; the audio plays with a waveform window. Closing the window stops the download.
| Flag | Effect |
|---|---|
--out FILE |
Also save the received bytes |
--no-play |
Don't start ffplay (download only) |
--timeout-ms N |
First retransmission timeout (default 2000, doubles, 4 retries) |
Measured on localhost:
| File | Blocks | Download, --no-play |
Retransmits |
|---|---|---|---|
| image | 150 | under 1 s | 0 |
| audio | 313 | 0.16 s | 0 |
Each block reply is 1050 B on the wire (26 B of CoAP header and options plus 1024 B of data). When playing, blocks are written to ffplay as they arrive and a full pipe slows the download down, but ffplay buffers several seconds ahead: a 10 s clip finishes downloading after about 2.4 s while it plays.
To see one block byte by byte:
./coap-inspect.py get /image --block 0
To test streaming end to end (generates its own media and server):
./test-stream.sh
Limits: at most 65,536 blocks per file (about 67 MB, roughly 35 minutes of this audio), download only, and no encryption.
Test
cargo test
Unit tests cover the greeting, payload parsing (text + JSON), query decoding, block selection, WAV and RGB565 validation, and the Block2/ETag/Size2 reply.
Layout
| Path | What it is |
|---|---|
src/main.rs |
The server: socket, read loop, route table, helpers. |
src/lib.rs |
Shared CoAP client, including block-wise downloads. |
src/bin/client.rs |
CLI client for smoke tests. |
src/bin/bridge.rs |
HTTP -> CoAP proxy so a browser can reach the server. |
src/bin/stream.rs |
Streams /audio or /image into an ffplay window. |
media/ |
Audio and image files the server streams (gitignored). |
coap-inspect.py |
Sends one request and shows both packets byte by byte. |
test-stream.sh |
End-to-end streaming test with its own server and media. |
static/index.html |
CoAP reference page: routes, codes, copyable commands. |
nodejs/ |
The earlier Node.js version, kept for reference. |
Config
| Env var | Default | Used by |
|---|---|---|
COAP_PORT |
5683 |
server, client, bridge |
COAP_HOST |
0.0.0.0 |
server, client, bridge |
HTTP_PORT |
8080 |
bridge |
MEDIA_DIR |
media |
server |
Design notes
- One task, no per-request spawn. Handling a datagram is pure CPU work with
no
.awaitinside, so it runs inline on the receive loop — no task-spawn overhead, and replies keep their arrival order. Spawn per datagram once a handler does real I/O (a database, an upstream call). - A fixed 1152-byte receive buffer, reused across requests, so the steady state allocates only for the response body. 1152 is the RFC 7252 guidance for staying inside an unfragmented IPv6 datagram.
- Hand-rolled JSON field read.
json_string_fieldhandles a flat{"name":"..."}and nothing more; it exists to avoid aserde_jsondependency for one field. Swap it out the moment bodies get real structure. - Lenient percent-decoding. RFC 7252 §6.4 says the client decodes before
splitting a URI into options, so a strict client sends
name=Md Imranalready decoded. Not all clients do, so the server decodes again — meaning a literal%in a name would be misread. Droppercent_decodeif your clients are known-conformant.
Next steps
- DTLS. Plaintext CoAP has no encryption or authentication. Production uses
coaps://on port 5684 — seewebrtc-dtlsor a gateway likelibcoap. - Block-wise upload (Block1, RFC 7959). Downloads already use Block2 (see
Streaming media);
coap-lite'sBlockHandlercan reassemble uploads. - Observe (RFC 7641) for server-pushed updates;
coap-liteprovidesSubjectandcreate_notification. - Deduplication. A CoAP server should track recent message IDs and replay the cached response, since UDP clients retransmit. Fine to skip for idempotent greetings, needed once a POST has side effects.