246 lines
9.3 KiB
Markdown
246 lines
9.3 KiB
Markdown
# CoAP Hello (UDP) — Rust MVP
|
||
|
||
A minimal CoAP server over UDP in Rust, built on [`coap-lite`](https://crates.io/crates/coap-lite)
|
||
(the RFC 7252 binary codec) and a [`tokio`](https://tokio.rs) `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
|
||
|
||
```bash
|
||
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:
|
||
|
||
```bash
|
||
cargo run --release --bin coap-hello-udp
|
||
```
|
||
|
||
```bash
|
||
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:
|
||
|
||
```bash
|
||
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:
|
||
|
||
```bash
|
||
cargo run --bin client -- get "/hello?name=Imran"
|
||
```
|
||
|
||
```bash
|
||
cargo run --bin client -- post /hello "Imran"
|
||
```
|
||
|
||
Or with any other CoAP client, e.g. [`coap-cli`](https://www.npmjs.com/package/coap-cli):
|
||
|
||
```bash
|
||
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
|
||
|
||
```bash
|
||
mkdir -p media
|
||
```
|
||
|
||
```bash
|
||
ffmpeg -f lavfi -i "sine=frequency=440:duration=10" -ac 1 -ar 16000 -c:a pcm_s16le media/audio.wav
|
||
```
|
||
|
||
```bash
|
||
ffmpeg -f lavfi -i "testsrc=size=320x240" -frames:v 1 -f rawvideo -pix_fmt rgb565le media/image.rgb565
|
||
```
|
||
|
||
Or convert your own files:
|
||
|
||
```bash
|
||
ffmpeg -i input.mp3 -ac 1 -ar 16000 -c:a pcm_s16le media/audio.wav
|
||
```
|
||
|
||
```bash
|
||
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:
|
||
|
||
```bash
|
||
cargo run --release --bin stream -- image
|
||
```
|
||
|
||
```bash
|
||
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:
|
||
|
||
```bash
|
||
./coap-inspect.py get /image --block 0
|
||
```
|
||
|
||
To test streaming end to end (generates its own media and server):
|
||
|
||
```bash
|
||
./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
|
||
|
||
```bash
|
||
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 `.await` inside, 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_field` handles a flat
|
||
`{"name":"..."}` and nothing more; it exists to avoid a `serde_json`
|
||
dependency 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 Imran`
|
||
already decoded. Not all clients do, so the server decodes again — meaning a
|
||
literal `%` in a name would be misread. Drop `percent_decode` if your clients
|
||
are known-conformant.
|
||
|
||
## Next steps
|
||
|
||
- **DTLS.** Plaintext CoAP has no encryption or authentication. Production uses
|
||
`coaps://` on port 5684 — see `webrtc-dtls` or a gateway like `libcoap`.
|
||
- **Block-wise upload** (Block1, RFC 7959). Downloads already use Block2 (see
|
||
Streaming media); `coap-lite`'s `BlockHandler` can reassemble uploads.
|
||
- **Observe** (RFC 7641) for server-pushed updates; `coap-lite` provides
|
||
`Subject` and `create_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.
|