basic interactions work

This commit is contained in:
2026-09-28 23:34:04 +06:00
parent b8d3a44d22
commit 4060f64b98
8 changed files with 1034 additions and 38 deletions

1
.gitignore vendored
View File

@@ -1,2 +1,3 @@
target/ target/
nodejs/node_modules/ nodejs/node_modules/
media/

View File

@@ -6,7 +6,7 @@ default-run = "coap-hello-udp"
[dependencies] [dependencies]
coap-lite = "0.13.3" coap-lite = "0.13.3"
tokio = { version = "1.53.1", features = ["rt-multi-thread", "net", "macros", "signal", "time", "io-util"] } tokio = { version = "1.53.1", features = ["rt-multi-thread", "net", "macros", "signal", "time", "io-util", "process"] }
[profile.release] [profile.release]
opt-level = 3 opt-level = 3

110
README.md
View File

@@ -86,23 +86,122 @@ Or with any other CoAP client, e.g. [`coap-cli`](https://www.npmjs.com/package/c
coap get "coap://localhost:5683/hello?name=Imran" 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 ## Test
```bash ```bash
cargo test cargo test
``` ```
Five unit tests cover the greeting, payload parsing (text + JSON) and query Unit tests cover the greeting, payload parsing (text + JSON), query decoding,
decoding. block selection, WAV and RGB565 validation, and the Block2/ETag/Size2 reply.
## Layout ## Layout
| Path | What it is | | Path | What it is |
|---------------------|---------------------------------------------------------| |---------------------|---------------------------------------------------------|
| `src/main.rs` | The server: socket, read loop, route table, helpers. | | `src/main.rs` | The server: socket, read loop, route table, helpers. |
| `src/lib.rs` | Shared CoAP client used by the CLI and the bridge. | | `src/lib.rs` | Shared CoAP client, including block-wise downloads. |
| `src/bin/client.rs` | CLI client for smoke tests. | | `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/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. | | `static/index.html` | CoAP reference page: routes, codes, copyable commands. |
| `nodejs/` | The earlier Node.js version, kept for reference. | | `nodejs/` | The earlier Node.js version, kept for reference. |
@@ -113,6 +212,7 @@ decoding.
| `COAP_PORT` | `5683` | server, client, bridge | | `COAP_PORT` | `5683` | server, client, bridge |
| `COAP_HOST` | `0.0.0.0` | server, client, bridge | | `COAP_HOST` | `0.0.0.0` | server, client, bridge |
| `HTTP_PORT` | `8080` | bridge | | `HTTP_PORT` | `8080` | bridge |
| `MEDIA_DIR` | `media` | server |
## Design notes ## Design notes
@@ -136,8 +236,8 @@ decoding.
- **DTLS.** Plaintext CoAP has no encryption or authentication. Production uses - **DTLS.** Plaintext CoAP has no encryption or authentication. Production uses
`coaps://` on port 5684 — see `webrtc-dtls` or a gateway like `libcoap`. `coaps://` on port 5684 — see `webrtc-dtls` or a gateway like `libcoap`.
- **Block-wise transfer** (RFC 7959) for payloads past ~1 KB; `coap-lite` ships - **Block-wise upload** (Block1, RFC 7959). Downloads already use Block2 (see
a `BlockHandler` for this. Streaming media); `coap-lite`'s `BlockHandler` can reassemble uploads.
- **Observe** (RFC 7641) for server-pushed updates; `coap-lite` provides - **Observe** (RFC 7641) for server-pushed updates; `coap-lite` provides
`Subject` and `create_notification`. `Subject` and `create_notification`.
- **Deduplication.** A CoAP server should track recent message IDs and replay - **Deduplication.** A CoAP server should track recent message IDs and replay

View File

@@ -4,6 +4,7 @@
./coap-inspect.py get "/hello?name=Imran" ./coap-inspect.py get "/hello?name=Imran"
./coap-inspect.py post /hello Imran ./coap-inspect.py post /hello Imran
./coap-inspect.py get /health ./coap-inspect.py get /health
./coap-inspect.py get /image --block 3 (one Block2 block of a stream)
No CoAP library: the packet is built and decoded right here, so every byte on No CoAP library: the packet is built and decoded right here, so every byte on
screen is accounted for. COAP_HOST / COAP_PORT retarget it (127.0.0.1:5683). screen is accounted for. COAP_HOST / COAP_PORT retarget it (127.0.0.1:5683).
@@ -19,13 +20,15 @@ METHODS = {"get": 1, "post": 2, "put": 3, "delete": 4}
CODES = { CODES = {
"0.00": "Empty", "0.01": "GET", "0.02": "POST", "0.03": "PUT", "0.04": "DELETE", "0.00": "Empty", "0.01": "GET", "0.02": "POST", "0.03": "PUT", "0.04": "DELETE",
"2.01": "Created", "2.02": "Deleted", "2.03": "Valid", "2.04": "Changed", "2.05": "Content", "2.01": "Created", "2.02": "Deleted", "2.03": "Valid", "2.04": "Changed", "2.05": "Content",
"4.00": "Bad Request", "4.04": "Not Found", "4.05": "Method Not Allowed", "4.00": "Bad Request", "4.02": "Bad Option", "4.04": "Not Found", "4.05": "Method Not Allowed",
"5.00": "Internal Server Error", "5.00": "Internal Server Error",
} }
OPTIONS = {3: "Uri-Host", 7: "Uri-Port", 11: "Uri-Path", 12: "Content-Format", 15: "Uri-Query", 17: "Accept"} OPTIONS = {3: "Uri-Host", 4: "ETag", 7: "Uri-Port", 11: "Uri-Path", 12: "Content-Format", 15: "Uri-Query",
FORMATS = {0: "text/plain", 50: "application/json"} 17: "Accept", 23: "Block2", 27: "Block1", 28: "Size2", 60: "Size1"}
FORMATS = {0: "text/plain", 42: "application/octet-stream", 50: "application/json"}
TTY = sys.stdout.isatty() TTY = sys.stdout.isatty()
DUMP_LIMIT = 128 # bytes shown in the hex and binary views
def bold(text): def bold(text):
@@ -46,7 +49,7 @@ def encode_option(delta, value):
return bytes([(d << 4) | l]) + d_ext + l_ext + value return bytes([(d << 4) | l]) + d_ext + l_ext + value
def build_request(method, path, query, payload): def build_request(method, path, query, payload, block=None):
token = os.urandom(4) token = os.urandom(4)
header = bytes([(1 << 6) | (0 << 4) | len(token), METHODS[method]]) # ver 1, CON, TKL, code header = bytes([(1 << 6) | (0 << 4) | len(token), METHODS[method]]) # ver 1, CON, TKL, code
header += random.randrange(1 << 16).to_bytes(2, "big") + token # message id, token header += random.randrange(1 << 16).to_bytes(2, "big") + token # message id, token
@@ -54,6 +57,9 @@ def build_request(method, path, query, payload):
# Options must be in ascending number order; each stores the gap from the previous one. # Options must be in ascending number order; each stores the gap from the previous one.
options = [(11, s.encode()) for s in path.split("/") if s] options = [(11, s.encode()) for s in path.split("/") if s]
options += [(15, q.encode()) for q in query.split("&") if q] options += [(15, q.encode()) for q in query.split("&") if q]
if block is not None: # Block2: num << 4 | more << 3 | SZX, SZX 6 = 1024 B
value = block << 4 | 6
options.append((23, value.to_bytes((value.bit_length() + 7) // 8, "big")))
body, last = b"", 0 body, last = b"", 0
for number, value in options: for number, value in options:
body += encode_option(number - last, value) body += encode_option(number - last, value)
@@ -90,8 +96,12 @@ def decode(pkt):
if pkt[i] == 0xFF: if pkt[i] == 0xFF:
body = pkt[i + 1:] body = pkt[i + 1:]
rows.append((i, i + 1, "payload marker", "0xff -> the rest is the body")) rows.append((i, i + 1, "payload marker", "0xff -> the rest is the body"))
rows.append((i + 1, len(pkt), "payload", try:
f"{len(body)} bytes: {body.decode('utf-8', 'replace')}")) text = body.decode("utf-8")
shown = f"{len(body)} bytes: {text}" if text.isprintable() else None
except UnicodeDecodeError:
shown = None
rows.append((i + 1, len(pkt), "payload", shown or f"{len(body)} bytes of binary data"))
break break
start = i start = i
@@ -112,6 +122,13 @@ def decode(pkt):
if number in (12, 17): if number in (12, 17):
n = int.from_bytes(value, "big") if value else 0 n = int.from_bytes(value, "big") if value else 0
shown = f"{n} = {FORMATS.get(n, 'unknown format')}" shown = f"{n} = {FORMATS.get(n, 'unknown format')}"
elif number in (23, 27):
n = int.from_bytes(value, "big") if value else 0
shown = f"num {n >> 4}, more {bool(n >> 3 & 1)}, size {1 << ((n & 7) + 4)}"
elif number in (28, 60):
shown = f"{int.from_bytes(value, 'big') if value else 0} B total"
elif number == 4:
shown = f"0x{value.hex()}"
else: else:
shown = f'"{value.decode("utf-8", "replace")}"' shown = f'"{value.decode("utf-8", "replace")}"'
rows.append((start, i, OPTIONS.get(number, f"option {number}"), rows.append((start, i, OPTIONS.get(number, f"option {number}"),
@@ -124,18 +141,22 @@ def show(title, pkt):
print(bold(f"\n{title} ({len(pkt)} bytes)")) print(bold(f"\n{title} ({len(pkt)} bytes)"))
print(bold("\n hex + ascii")) print(bold("\n hex + ascii"))
for off in range(0, len(pkt), 16): for off in range(0, min(len(pkt), DUMP_LIMIT), 16):
chunk = pkt[off:off + 16] chunk = pkt[off:min(off + 16, DUMP_LIMIT)]
hexes = " ".join(f"{b:02x}" for b in chunk) hexes = " ".join(f"{b:02x}" for b in chunk)
text = "".join(chr(b) if 32 <= b < 127 else "." for b in chunk) text = "".join(chr(b) if 32 <= b < 127 else "." for b in chunk)
print(f" {off:04x} {hexes:<47} |{text}|") print(f" {off:04x} {hexes:<47} |{text}|")
if len(pkt) > DUMP_LIMIT:
print(f" ... {len(pkt) - DUMP_LIMIT} more bytes not shown")
print(bold("\n binary")) print(bold("\n binary"))
for off in range(0, len(pkt), 4): for off in range(0, min(len(pkt), DUMP_LIMIT), 4):
chunk = pkt[off:off + 4] chunk = pkt[off:min(off + 4, DUMP_LIMIT)]
bits = " ".join(f"{b:08b}" for b in chunk) bits = " ".join(f"{b:08b}" for b in chunk)
text = "".join(chr(b) if 32 <= b < 127 else "." for b in chunk) text = "".join(chr(b) if 32 <= b < 127 else "." for b in chunk)
print(f" {off:04x} {bits:<35} |{text}|") print(f" {off:04x} {bits:<35} |{text}|")
if len(pkt) > DUMP_LIMIT:
print(f" ... {len(pkt) - DUMP_LIMIT} more bytes not shown")
rows, meta = decode(pkt) rows, meta = decode(pkt)
print(bold("\n fields")) print(bold("\n fields"))
@@ -151,6 +172,15 @@ def show(title, pkt):
def main(): def main():
args = sys.argv[1:] args = sys.argv[1:]
block = None
if "--block" in args:
i = args.index("--block")
try:
block = int(args[i + 1])
except (IndexError, ValueError):
print("--block needs a block number")
return 2
del args[i:i + 2]
if not args or args[0].lower() not in METHODS: if not args or args[0].lower() not in METHODS:
print(__doc__.strip()) print(__doc__.strip())
return 2 return 2
@@ -162,7 +192,7 @@ def main():
host = os.environ.get("COAP_HOST", "127.0.0.1") host = os.environ.get("COAP_HOST", "127.0.0.1")
port = int(os.environ.get("COAP_PORT", "5683")) port = int(os.environ.get("COAP_PORT", "5683"))
request = build_request(method, path, query, payload) request = build_request(method, path, query, payload, block)
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) # SOCK_DGRAM = UDP sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) # SOCK_DGRAM = UDP
sock.settimeout(3) sock.settimeout(3)

178
src/bin/stream.rs Normal file
View File

@@ -0,0 +1,178 @@
//! Stream a media file from the CoAP server and play it in an ffplay window.
//!
//! cargo run --release --bin stream -- image
//! cargo run --release --bin stream -- audio
//! cargo run --release --bin stream -- audio --no-play --out copy.wav
//!
//! The file arrives as 1024-byte Block2 blocks over UDP. Each block is written
//! to ffplay's stdin as soon as it lands; when the player's pipe is full the
//! write waits, so blocks are requested at playback speed instead of flooded.
use std::io::{self, IsTerminal, Write};
use std::process::Stdio;
use std::time::Duration;
use coap_hello_udp::{BlockFetcher, Error, FetchOptions, TransferStats};
use tokio::io::AsyncWriteExt;
use tokio::process::{Child, Command};
const USAGE: &str = "usage: stream audio|image [--out FILE] [--no-play] [--timeout-ms N]";
const IMAGE_BYTES: u64 = 320 * 240 * 2;
#[derive(Clone, Copy, PartialEq)]
enum Media {
Audio,
Image,
}
impl Media {
fn path(self) -> &'static str {
match self {
Media::Audio => "/audio",
Media::Image => "/image",
}
}
/// ffplay arguments for reading this media from stdin.
fn player_args(self) -> &'static [&'static str] {
match self {
Media::Audio => &["-autoexit", "-showmode", "waves", "-window_title", "CoAP audio", "-f", "wav"],
Media::Image => &[
"-f", "rawvideo", "-pixel_format", "rgb565le", "-video_size", "320x240",
"-window_title", "CoAP image",
],
}
}
}
struct Args {
media: Media,
out: Option<String>,
play: bool,
timeout: Duration,
}
fn parse_args() -> Result<Args, String> {
let mut args = std::env::args().skip(1);
let media = match args.next().as_deref() {
Some("audio") => Media::Audio,
Some("image") => Media::Image,
_ => return Err(USAGE.into()),
};
let mut parsed = Args { media, out: None, play: true, timeout: FetchOptions::default().timeout };
while let Some(arg) = args.next() {
match arg.as_str() {
"--out" => parsed.out = Some(args.next().ok_or(USAGE)?),
"--no-play" => parsed.play = false,
"--timeout-ms" => {
let ms = args.next().and_then(|v| v.parse().ok()).ok_or(USAGE)?;
parsed.timeout = Duration::from_millis(ms);
}
_ => return Err(USAGE.into()),
}
}
Ok(parsed)
}
#[tokio::main]
async fn main() -> Result<(), Error> {
let args = match parse_args() {
Ok(args) => args,
Err(usage) => {
eprintln!("{usage}");
std::process::exit(2);
}
};
let host = std::env::var("COAP_HOST").unwrap_or_else(|_| "127.0.0.1".into());
let port = std::env::var("COAP_PORT").unwrap_or_else(|_| "5683".into());
let path = args.media.path();
let mut player = if args.play { Some(spawn_player(args.media)?) } else { None };
let mut player_stdin = player.as_mut().and_then(|p| p.stdin.take());
let mut out = match &args.out {
Some(file) => Some(io::BufWriter::new(std::fs::File::create(file)?)),
None => None,
};
let options = FetchOptions { timeout: args.timeout, ..FetchOptions::default() };
let mut fetcher = BlockFetcher::connect(&host, &port, path, options).await?;
eprintln!("streaming coap://{host}:{port}{path}");
let show_progress = io::stderr().is_terminal();
let mut stopped_by_player = false;
while let Some(block) = fetcher.next_block().await? {
if let Some(file) = out.as_mut() {
file.write_all(&block)?;
}
if let Some(stdin) = player_stdin.as_mut()
&& stdin.write_all(&block).await.is_err()
{
// The player window was closed.
stopped_by_player = true;
break;
}
if show_progress {
let stats = fetcher.stats();
let total = stats.total.map(|t| format!(" / {t}")).unwrap_or_default();
eprint!("\r {}{total} B, {} blocks, {} retransmits", stats.bytes, stats.blocks, stats.retransmits);
}
}
if let Some(mut file) = out {
file.flush()?;
}
let stats = fetcher.stats();
if show_progress {
eprintln!();
}
print_summary(&stats, stopped_by_player);
if args.media == Media::Image
&& let Some(total) = stats.total
&& total != IMAGE_BYTES
{
eprintln!("warning: server sent {total} B, a 320x240 RGB565 image is {IMAGE_BYTES} B");
}
// Closing stdin tells ffplay the stream has ended.
drop(player_stdin);
if let Some(player) = player.as_mut() {
if !stopped_by_player && args.media == Media::Image {
eprintln!("close the image window to exit");
}
player.wait().await?;
}
Ok(())
}
fn spawn_player(media: Media) -> Result<Child, String> {
Command::new("ffplay")
.args(["-hide_banner", "-loglevel", "error"])
.args(media.player_args())
.args(["-i", "pipe:0"])
.stdin(Stdio::piped())
.kill_on_drop(true)
.spawn()
.map_err(|e| match e.kind() {
io::ErrorKind::NotFound => {
"ffplay not found: install ffmpeg, or use --no-play --out FILE".to_string()
}
_ => format!("cannot start ffplay: {e}"),
})
}
fn print_summary(stats: &TransferStats, stopped_by_player: bool) {
let secs = stats.elapsed.as_secs_f64();
let rate = if secs > 0.0 { stats.bytes as f64 / 1024.0 / secs } else { 0.0 };
let etag = match &stats.etag {
Some(tag) => tag.iter().map(|b| format!("{b:02x}")).collect(),
None => "none".to_string(),
};
let what = if stopped_by_player { "stopped by player after" } else { "received" };
eprintln!(
"{what} {} B in {} blocks, {secs:.2} s ({rate:.0} KiB/s), {} retransmits, ETag {etag}",
stats.bytes, stats.blocks, stats.retransmits
);
}

View File

@@ -1,11 +1,14 @@
//! Shared CoAP client used by the CLI (`src/bin/client.rs`) and the HTTP //! Shared CoAP client used by the CLI (`src/bin/client.rs`), the HTTP bridge
//! bridge (`src/bin/bridge.rs`). //! (`src/bin/bridge.rs`) and the media streamer (`src/bin/stream.rs`).
use std::time::{Duration, SystemTime, UNIX_EPOCH}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use coap_lite::block_handler::BlockValue;
use coap_lite::{CoapOption, MessageClass, MessageType, Packet, RequestType}; use coap_lite::{CoapOption, MessageClass, MessageType, Packet, RequestType};
use tokio::net::UdpSocket; use tokio::net::UdpSocket;
pub type Error = Box<dyn std::error::Error + Send + Sync>;
/// What a CoAP server sent back. /// What a CoAP server sent back.
pub struct CoapReply { pub struct CoapReply {
/// Response code in CoAP's `class.detail` form, e.g. `"2.05"`. /// Response code in CoAP's `class.detail` form, e.g. `"2.05"`.
@@ -46,24 +49,24 @@ pub fn parse_method(name: &str) -> Result<RequestType, String> {
} }
} }
/// Send one confirmable CoAP request and wait for the reply. /// Build a confirmable request.
/// ///
/// A CoAP URI is carried as repeated options rather than one string: each path /// A CoAP URI is carried as repeated options rather than one string: each path
/// segment becomes its own Uri-Path option, each `k=v` its own Uri-Query. /// segment becomes its own Uri-Path option, each `k=v` its own Uri-Query.
pub async fn request( fn request_packet(
host: &str,
port: &str,
method: RequestType, method: RequestType,
path: &str, path: &str,
query: Option<&str>, query: Option<&str>,
payload: &[u8], payload: &[u8],
) -> Result<CoapReply, Box<dyn std::error::Error + Send + Sync>> { message_id: u16,
token: &[u8],
) -> Packet {
let mut packet = Packet::new(); let mut packet = Packet::new();
packet.header.set_version(1); packet.header.set_version(1);
packet.header.set_type(MessageType::Confirmable); packet.header.set_type(MessageType::Confirmable);
packet.header.code = MessageClass::Request(method); packet.header.code = MessageClass::Request(method);
packet.header.message_id = nonce() as u16; packet.header.message_id = message_id;
packet.set_token(nonce().to_be_bytes()[..4].to_vec()); packet.set_token(token.to_vec());
for segment in path.split('/').filter(|s| !s.is_empty()) { for segment in path.split('/').filter(|s| !s.is_empty()) {
packet.add_option(CoapOption::UriPath, segment.as_bytes().to_vec()); packet.add_option(CoapOption::UriPath, segment.as_bytes().to_vec());
@@ -74,6 +77,25 @@ pub async fn request(
if !payload.is_empty() { if !payload.is_empty() {
packet.payload = payload.to_vec(); packet.payload = payload.to_vec();
} }
packet
}
/// A response code in `class.detail` form, e.g. `"2.05"`.
fn code_string(packet: &Packet) -> String {
let raw: u8 = packet.header.code.into();
format!("{}.{:02}", raw >> 5, raw & 0x1f)
}
/// Send one confirmable CoAP request and wait for the reply.
pub async fn request(
host: &str,
port: &str,
method: RequestType,
path: &str,
query: Option<&str>,
payload: &[u8],
) -> Result<CoapReply, Error> {
let packet = request_packet(method, path, query, payload, nonce() as u16, &nonce().to_be_bytes());
let socket = UdpSocket::bind("0.0.0.0:0").await?; let socket = UdpSocket::bind("0.0.0.0:0").await?;
socket.connect(format!("{host}:{port}")).await?; socket.connect(format!("{host}:{port}")).await?;
@@ -88,14 +110,188 @@ pub async fn request(
}; };
let response = Packet::from_bytes(&buf[..len])?; let response = Packet::from_bytes(&buf[..len])?;
let raw: u8 = response.header.code.into();
Ok(CoapReply { Ok(CoapReply {
code: format!("{}.{:02}", raw >> 5, raw & 0x1f), code: code_string(&response),
payload: String::from_utf8_lossy(&response.payload).into_owned(), payload: String::from_utf8_lossy(&response.payload).into_owned(),
}) })
} }
/// Settings for a block-wise download. Defaults follow RFC 7252 §4.8.
#[derive(Clone, Copy)]
pub struct FetchOptions {
/// How long to wait for a reply before retransmitting; doubles each retry.
pub timeout: Duration,
pub max_retransmit: u32,
/// Block size to ask for. The server may answer with a smaller one.
pub block_size: usize,
}
impl Default for FetchOptions {
fn default() -> Self {
Self { timeout: Duration::from_secs(2), max_retransmit: 4, block_size: 1024 }
}
}
/// How a block-wise download went so far.
#[derive(Clone, Default)]
pub struct TransferStats {
pub blocks: usize,
pub bytes: usize,
pub retransmits: u32,
pub elapsed: Duration,
/// Total size the server announced in Size2, if any.
pub total: Option<u64>,
pub etag: Option<Vec<u8>>,
}
/// Downloads one resource with Block2 (RFC 7959), a block at a time.
///
/// Blocks are requested one after another on a single socket (NSTART = 1).
/// The caller pulls them with [`BlockFetcher::next_block`], so a slow consumer
/// — a media player whose pipe is full — sets the pace of the whole transfer.
pub struct BlockFetcher {
socket: UdpSocket,
path: String,
options: FetchOptions,
token: [u8; 4],
message_id: u16,
next_num: usize,
block_size: usize,
done: bool,
started: Instant,
stats: TransferStats,
}
impl BlockFetcher {
pub async fn connect(
host: &str,
port: &str,
path: &str,
options: FetchOptions,
) -> Result<Self, Error> {
let socket = UdpSocket::bind("0.0.0.0:0").await?;
socket.connect(format!("{host}:{port}")).await?;
Ok(Self {
socket,
path: path.to_string(),
options,
token: nonce().to_be_bytes(),
message_id: nonce() as u16,
next_num: 0,
block_size: options.block_size,
done: false,
started: Instant::now(),
stats: TransferStats::default(),
})
}
pub fn stats(&self) -> TransferStats {
TransferStats { elapsed: self.started.elapsed(), ..self.stats.clone() }
}
/// Fetch the next block. `Ok(None)` once the last block has arrived.
pub async fn next_block(&mut self) -> Result<Option<Vec<u8>>, Error> {
if self.done {
return Ok(None);
}
let requested_size = self.block_size;
let block2 = BlockValue::new(self.next_num, false, requested_size)
.map_err(|e| format!("cannot request block {}: {e:?}", self.next_num))?;
self.message_id = self.message_id.wrapping_add(1);
let mut packet =
request_packet(RequestType::Get, &self.path, None, &[], self.message_id, &self.token);
packet.add_option_as(CoapOption::Block2, block2);
let response = self.exchange(&packet.to_bytes()?).await?;
let code = code_string(&response);
if code != "2.05" {
return Err(format!("{code} {}", String::from_utf8_lossy(&response.payload)).into());
}
let etag = response.get_first_option(CoapOption::ETag).cloned();
if self.stats.blocks == 0 {
self.stats.etag = etag;
self.stats.total = response
.get_first_option(CoapOption::Size2)
.map(|v| v.iter().fold(0u64, |n, &b| n << 8 | u64::from(b)));
} else if etag != self.stats.etag {
return Err("the resource changed during the transfer (ETag differs)".into());
}
match response.get_first_option_as::<BlockValue>(CoapOption::Block2) {
// The whole resource fitted in one message.
None if self.next_num == 0 => self.done = true,
None => return Err("server stopped sending Block2 mid-transfer".into()),
Some(Err(_)) => return Err("malformed Block2 option in the response".into()),
Some(Ok(block)) => {
let num = usize::from(block.num);
// The server may shrink the block size and renumber; the byte
// offset must still be the one we asked for.
if num * block.size() != self.next_num * requested_size {
return Err(format!(
"asked for byte {} but got block {num} of size {}",
self.next_num * requested_size,
block.size()
)
.into());
}
if block.more && response.payload.len() != block.size() {
return Err(format!(
"block {num} is {} B, expected {}",
response.payload.len(),
block.size()
)
.into());
}
self.block_size = block.size();
self.next_num = num + 1;
self.done = !block.more;
}
}
self.stats.blocks += 1;
self.stats.bytes += response.payload.len();
Ok(Some(response.payload))
}
/// Send a confirmable request and wait for the reply carrying the same
/// message ID and token, retransmitting with exponential back-off.
async fn exchange(&mut self, request: &[u8]) -> Result<Packet, Error> {
let mut timeout = self.options.timeout;
let mut buf = [0u8; 1152];
for attempt in 0..=self.options.max_retransmit {
if attempt > 0 {
self.stats.retransmits += 1;
}
self.socket.send(request).await?;
let deadline = tokio::time::Instant::now() + timeout;
loop {
let len = match tokio::time::timeout_at(deadline, self.socket.recv(&mut buf)).await {
Ok(result) => result?,
Err(_) => break,
};
// Late replies to an earlier block, or anything unparsable, are not ours.
let Ok(reply) = Packet::from_bytes(&buf[..len]) else { continue };
if reply.header.message_id == self.message_id && reply.get_token() == self.token {
return Ok(reply);
}
}
timeout *= 2;
}
Err(format!(
"no reply for block {} after {} retransmissions",
self.next_num, self.options.max_retransmit
)
.into())
}
}
fn nonce() -> u32 { fn nonce() -> u32 {
SystemTime::now() SystemTime::now()
.duration_since(UNIX_EPOCH) .duration_since(UNIX_EPOCH)

View File

@@ -4,10 +4,15 @@
//! and query strings, but each request/response is a single datagram carrying a //! and query strings, but each request/response is a single datagram carrying a
//! compact binary header. `coap-lite` handles that binary codec; the UDP socket //! compact binary header. `coap-lite` handles that binary codec; the UDP socket
//! and the routing are right here so nothing is hidden. //! and the routing are right here so nothing is hidden.
//!
//! It also streams two static media files with block-wise transfer (RFC 7959):
//! `GET /audio` (16 kHz mono 16-bit WAV) and `GET /image` (320x240 RGB565).
use std::net::SocketAddr; use std::net::SocketAddr;
use std::path::Path;
use std::time::Instant; use std::time::Instant;
use coap_lite::block_handler::BlockValue;
use coap_lite::{ use coap_lite::{
CoapOption, CoapRequest, CoapResponse, ContentFormat, Packet, RequestType, CoapOption, CoapRequest, CoapResponse, ContentFormat, Packet, RequestType,
ResponseType, ResponseType,
@@ -19,19 +24,83 @@ use tokio::net::UdpSocket;
/// transfer (RFC 7959). /// transfer (RFC 7959).
const MAX_DATAGRAM: usize = 1152; const MAX_DATAGRAM: usize = 1152;
/// Largest block we serve. 1024 B (SZX 6) is the biggest RFC 7959 block size
/// that still fits one datagram once the header and options are added.
const MAX_BLOCK: usize = 1024;
/// coap-lite stores the block number as a u16, capping a resource at 65,536
/// blocks.
const MAX_MEDIA_BYTES: usize = (u16::MAX as usize + 1) * MAX_BLOCK;
const IMAGE_WIDTH: usize = 320;
const IMAGE_HEIGHT: usize = 240;
const IMAGE_BYTES: usize = IMAGE_WIDTH * IMAGE_HEIGHT * 2;
/// Everything a request handler can read. Built once, before the receive loop.
struct State {
started: Instant,
media: Media,
}
/// Static media, loaded into memory at startup so serving a block is a slice
/// of a buffer, never file I/O on the receive loop.
struct Media {
audio: Result<Resource, String>,
image: Result<Resource, String>,
}
struct Resource {
bytes: Vec<u8>,
etag: [u8; 8],
}
impl Resource {
fn load(path: &Path, validate: fn(&[u8]) -> Result<(), String>) -> Result<Self, String> {
let bytes = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?;
if bytes.len() > MAX_MEDIA_BYTES {
return Err(format!(
"{}: {} B exceeds the {MAX_MEDIA_BYTES} B block-wise limit",
path.display(),
bytes.len()
));
}
validate(&bytes).map_err(|e| format!("{}: {e}", path.display()))?;
Ok(Self { etag: etag(&bytes), bytes })
}
}
#[tokio::main] #[tokio::main]
async fn main() -> std::io::Result<()> { async fn main() -> std::io::Result<()> {
let host = std::env::var("COAP_HOST").unwrap_or_else(|_| "0.0.0.0".into()); let host = std::env::var("COAP_HOST").unwrap_or_else(|_| "0.0.0.0".into());
let port = std::env::var("COAP_PORT").unwrap_or_else(|_| "5683".into()); let port = std::env::var("COAP_PORT").unwrap_or_else(|_| "5683".into());
let media_dir = std::env::var("MEDIA_DIR").unwrap_or_else(|_| "media".into());
let addr = format!("{host}:{port}"); let addr = format!("{host}:{port}");
let media_dir = Path::new(&media_dir);
let state = State {
started: Instant::now(),
media: Media {
audio: Resource::load(&media_dir.join("audio.wav"), validate_wav),
image: Resource::load(&media_dir.join("image.rgb565"), validate_image),
},
};
let socket = UdpSocket::bind(&addr).await?; let socket = UdpSocket::bind(&addr).await?;
let started = Instant::now();
println!("CoAP server (UDP) listening on coap://{addr}"); println!("CoAP server (UDP) listening on coap://{addr}");
println!(" GET /hello?name=Imran"); println!(" GET /hello?name=Imran");
println!(" POST /hello (payload: Imran)"); println!(" POST /hello (payload: Imran)");
println!(" GET /health"); println!(" GET /health");
for (path, resource) in [("/audio", &state.media.audio), ("/image", &state.media.image)] {
match resource {
Ok(r) => println!(
" GET {path} ({} B in {} blocks)",
r.bytes.len(),
r.bytes.len().div_ceil(MAX_BLOCK)
),
Err(e) => eprintln!(" GET {path} unavailable: {e}"),
}
}
let mut buf = [0u8; MAX_DATAGRAM]; let mut buf = [0u8; MAX_DATAGRAM];
@@ -50,7 +119,7 @@ async fn main() -> std::io::Result<()> {
// Handling is pure CPU work with no awaits, so doing it inline // Handling is pure CPU work with no awaits, so doing it inline
// keeps the hot path allocation-light and ordered. Spawn a task // keeps the hot path allocation-light and ordered. Spawn a task
// here instead once a handler needs to do real I/O. // here instead once a handler needs to do real I/O.
if let Some(reply) = handle(&buf[..len], peer, started) if let Some(reply) = handle(&buf[..len], peer, &state)
&& let Err(e) = socket.send_to(&reply, peer).await && let Err(e) = socket.send_to(&reply, peer).await
{ {
eprintln!("send error to {peer}: {e}"); eprintln!("send error to {peer}: {e}");
@@ -62,7 +131,7 @@ async fn main() -> std::io::Result<()> {
/// Decode one datagram, route it, and encode the reply. /// Decode one datagram, route it, and encode the reply.
/// Returns `None` when no reply should be sent (unparsable or non-request). /// Returns `None` when no reply should be sent (unparsable or non-request).
fn handle(datagram: &[u8], peer: SocketAddr, started: Instant) -> Option<Vec<u8>> { fn handle(datagram: &[u8], peer: SocketAddr, state: &State) -> Option<Vec<u8>> {
let packet = match Packet::from_bytes(datagram) { let packet = match Packet::from_bytes(datagram) {
Ok(p) => p, Ok(p) => p,
Err(e) => { Err(e) => {
@@ -78,9 +147,13 @@ fn handle(datagram: &[u8], peer: SocketAddr, started: Instant) -> Option<Vec<u8>
let path = format!("/{}", request.get_path()); let path = format!("/{}", request.get_path());
let method = *request.get_method(); let method = *request.get_method();
println!("{method:?} {}{} from {peer}", path, query_string(&request.message));
route(method, &path, &mut request, &mut response, started); // Media streams log once per transfer in `serve_media`, not once per block.
if !matches!((method, path.as_str()), (RequestType::Get, "/audio" | "/image")) {
println!("{method:?} {}{} from {peer}", path, query_string(&request.message));
}
route(method, &path, &mut request, &mut response, state);
match response.message.to_bytes() { match response.message.to_bytes() {
Ok(bytes) => Some(bytes), Ok(bytes) => Some(bytes),
@@ -96,7 +169,7 @@ fn route(
path: &str, path: &str,
request: &mut CoapRequest<SocketAddr>, request: &mut CoapRequest<SocketAddr>,
response: &mut CoapResponse, response: &mut CoapResponse,
started: Instant, state: &State,
) { ) {
match (method, path) { match (method, path) {
(RequestType::Get, "/hello") => { (RequestType::Get, "/hello") => {
@@ -127,13 +200,16 @@ fn route(
(RequestType::Get, "/health") => { (RequestType::Get, "/health") => {
let body = format!( let body = format!(
r#"{{"status":"ok","uptime":{}}}"#, r#"{{"status":"ok","uptime":{}}}"#,
started.elapsed().as_secs() state.started.elapsed().as_secs()
); );
reply(response, ResponseType::Content, ContentFormat::ApplicationJSON, body); reply(response, ResponseType::Content, ContentFormat::ApplicationJSON, body);
} }
(RequestType::Get, "/audio") => serve_media(request, response, &state.media.audio, path),
(RequestType::Get, "/image") => serve_media(request, response, &state.media.image, path),
// Path exists but the method doesn't: 4.05, per RFC 7252. // Path exists but the method doesn't: 4.05, per RFC 7252.
(_, "/hello") | (_, "/health") => reply( (_, "/hello" | "/health" | "/audio" | "/image") => reply(
response, response,
ResponseType::MethodNotAllowed, ResponseType::MethodNotAllowed,
ContentFormat::TextPlain, ContentFormat::TextPlain,
@@ -149,6 +225,179 @@ fn route(
} }
} }
/// Serve one block of a static resource (RFC 7959 Block2).
///
/// Stateless: every request names the block it wants, so nothing is cached per
/// client, and a lost or repeated request simply gets the same block again.
fn serve_media(
request: &CoapRequest<SocketAddr>,
response: &mut CoapResponse,
resource: &Result<Resource, String>,
path: &str,
) {
let resource = match resource {
Ok(r) => r,
Err(e) => {
return reply(
response,
ResponseType::NotFound,
ContentFormat::TextPlain,
format!("Not Found: {path} is unavailable ({e})"),
);
}
};
let requested = match request.message.get_first_option_as::<BlockValue>(CoapOption::Block2) {
None => None,
Some(Ok(b)) => Some((usize::from(b.num), b.size())),
Some(Err(_)) => {
return reply(
response,
ResponseType::BadOption,
ContentFormat::TextPlain,
"Bad Option: malformed Block2".into(),
);
}
};
let len = resource.bytes.len();
let block = match select_block(requested, len) {
Ok(b) => b,
Err(e) => {
return reply(response, ResponseType::BadOption, ContentFormat::TextPlain, format!("Bad Option: {e}"));
}
};
// `select_block` only yields sizes and numbers a BlockValue can hold.
let Ok(block2) = BlockValue::new(block.num, block.more, block.size) else {
return reply(
response,
ResponseType::InternalServerError,
ContentFormat::TextPlain,
format!("cannot encode block {} of size {}", block.num, block.size),
);
};
let peer = request.source.map(|p| p.to_string()).unwrap_or_default();
if block.num == 0 {
println!(
"GET {path} from {peer}: {len} B in {} blocks of {} B",
len.div_ceil(block.size),
block.size
);
} else if !block.more {
println!("GET {path} from {peer}: sent last block {}", block.num);
}
response.set_status(ResponseType::Content);
let message = &mut response.message;
message.set_content_format(ContentFormat::ApplicationOctetStream);
message.add_option(CoapOption::ETag, resource.etag.to_vec());
message.add_option_as(CoapOption::Block2, block2);
if block.num == 0 {
// Total size up front, so the client can show progress (RFC 7959 §4).
message.add_option(CoapOption::Size2, uint_option(len as u32));
}
message.payload = resource.bytes[block.start..block.end].to_vec();
}
/// The byte range one Block2 request gets.
#[derive(Debug, PartialEq)]
struct Block {
num: usize,
size: usize,
start: usize,
end: usize,
more: bool,
}
/// Pick the block for a request's `(num, size)`; no Block2 means block 0.
fn select_block(requested: Option<(usize, usize)>, len: usize) -> Result<Block, String> {
let (num, size) = requested.unwrap_or((0, MAX_BLOCK));
// A server may answer with a smaller block than asked for. Renumber so the
// client still gets the byte offset it asked for (RFC 7959 §2.4).
let offset = num * size;
let size = size.min(MAX_BLOCK);
let num = offset / size;
let start = num * size;
if start >= len {
return Err(format!("block {num} of size {size} is past the end of a {len} B resource"));
}
let end = (start + size).min(len);
Ok(Block { num, size, start, end, more: end < len })
}
/// Accept only PCM WAV at 16 kHz, mono, 16-bit. Walks the RIFF chunks instead
/// of assuming a 44-byte header, since encoders like ffmpeg add a LIST chunk.
fn validate_wav(bytes: &[u8]) -> Result<(), String> {
if bytes.len() < 12 || &bytes[0..4] != b"RIFF" || &bytes[8..12] != b"WAVE" {
return Err("not a RIFF/WAVE file".into());
}
let mut format = None;
let mut has_data = false;
let mut at = 12;
while at + 8 <= bytes.len() {
let id = &bytes[at..at + 4];
let size = u32::from_le_bytes([bytes[at + 4], bytes[at + 5], bytes[at + 6], bytes[at + 7]]) as usize;
let body = &bytes[at + 8..(at + 8 + size).min(bytes.len())];
match id {
b"fmt " if body.len() >= 16 => {
let u16_at = |i: usize| u16::from_le_bytes([body[i], body[i + 1]]);
let rate = u32::from_le_bytes([body[4], body[5], body[6], body[7]]);
format = Some((u16_at(0), u16_at(2), rate, u16_at(14)));
}
b"data" => has_data = true,
_ => {}
}
// Chunks are padded to an even length.
at += 8 + size + size % 2;
}
match format {
None => Err("no fmt chunk".into()),
Some((1, 1, 16_000, 16)) if has_data => Ok(()),
Some((1, 1, 16_000, 16)) => Err("no data chunk".into()),
Some((codec, channels, rate, bits)) => Err(format!(
"need PCM mono 16 kHz 16-bit, got format {codec}, {channels} channel(s), {rate} Hz, {bits}-bit"
)),
}
}
/// Raw RGB565 has no header, so the exact size is the only possible check.
fn validate_image(bytes: &[u8]) -> Result<(), String> {
if bytes.len() == IMAGE_BYTES {
Ok(())
} else {
Err(format!(
"need exactly {IMAGE_BYTES} B for {IMAGE_WIDTH}x{IMAGE_HEIGHT} RGB565, got {}",
bytes.len()
))
}
}
/// FNV-1a 64-bit hash of the content, sent as the ETag so a client can check
/// that every block came from the same version of the file.
fn etag(bytes: &[u8]) -> [u8; 8] {
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
for &b in bytes {
hash ^= u64::from(b);
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
}
hash.to_be_bytes()
}
/// Encode a CoAP uint option: big-endian with leading zero bytes dropped.
fn uint_option(value: u32) -> Vec<u8> {
let bytes = value.to_be_bytes();
let skip = bytes.iter().take_while(|&&b| b == 0).count();
bytes[skip..].to_vec()
}
fn greet(name: Option<&str>) -> String { fn greet(name: Option<&str>) -> String {
let name = name.map(str::trim).filter(|n| !n.is_empty()).unwrap_or("World"); let name = name.map(str::trim).filter(|n| !n.is_empty()).unwrap_or("World");
format!("Hello, {name}!") format!("Hello, {name}!")
@@ -254,6 +503,7 @@ fn percent_decode(input: &str) -> String {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use coap_lite::{MessageClass, MessageType};
#[test] #[test]
fn greets_world_when_no_name() { fn greets_world_when_no_name() {
@@ -269,7 +519,6 @@ mod tests {
} }
#[test] #[test]
fn reads_name_from_text_and_json_payloads() { fn reads_name_from_text_and_json_payloads() {
assert_eq!(name_from_payload(b"Imran").as_deref(), Some("Imran")); assert_eq!(name_from_payload(b"Imran").as_deref(), Some("Imran"));
assert_eq!( assert_eq!(
@@ -306,4 +555,164 @@ mod tests {
assert_eq!(query_param(&packet, "missing"), None); assert_eq!(query_param(&packet, "missing"), None);
assert_eq!(query_string(&packet), "?name=Md%20Imran&lang=en"); assert_eq!(query_string(&packet), "?name=Md%20Imran&lang=en");
} }
// 10 s of 16 kHz mono 16-bit audio as written by ffmpeg, header included.
const AUDIO_BYTES: usize = 320_078;
#[test]
fn serves_block_zero_when_no_block2_is_sent() {
assert_eq!(
select_block(None, IMAGE_BYTES),
Ok(Block { num: 0, size: 1024, start: 0, end: 1024, more: true })
);
}
#[test]
fn serves_a_middle_block() {
assert_eq!(
select_block(Some((5, 1024)), AUDIO_BYTES),
Ok(Block { num: 5, size: 1024, start: 5120, end: 6144, more: true })
);
}
#[test]
fn last_audio_block_is_partial() {
assert_eq!(
select_block(Some((312, 1024)), AUDIO_BYTES),
Ok(Block { num: 312, size: 1024, start: 319_488, end: AUDIO_BYTES, more: false })
);
assert!(select_block(Some((313, 1024)), AUDIO_BYTES).is_err());
}
#[test]
fn image_ends_exactly_on_a_block_boundary() {
assert_eq!(
select_block(Some((149, 1024)), IMAGE_BYTES),
Ok(Block { num: 149, size: 1024, start: 152_576, end: IMAGE_BYTES, more: false })
);
assert!(select_block(Some((150, 1024)), IMAGE_BYTES).is_err());
}
#[test]
fn oversized_block_request_is_renumbered() {
// Block 1 of 2048 B starts at byte 2048, which is block 2 of 1024 B.
assert_eq!(
select_block(Some((1, 2048)), IMAGE_BYTES),
Ok(Block { num: 2, size: 1024, start: 2048, end: 3072, more: true })
);
}
#[test]
fn smaller_block_size_is_honoured() {
assert_eq!(
select_block(Some((3, 256)), IMAGE_BYTES),
Ok(Block { num: 3, size: 256, start: 768, end: 1024, more: true })
);
}
fn wav(channels: u16, rate: u32, bits: u16, list_chunk: bool) -> Vec<u8> {
let block_align = channels * bits / 8;
let mut body = b"WAVE".to_vec();
body.extend(b"fmt ");
body.extend(16u32.to_le_bytes());
body.extend(1u16.to_le_bytes());
body.extend(channels.to_le_bytes());
body.extend(rate.to_le_bytes());
body.extend((rate * u32::from(block_align)).to_le_bytes());
body.extend(block_align.to_le_bytes());
body.extend(bits.to_le_bytes());
if list_chunk {
// Odd-sized, so the parser has to skip the padding byte.
body.extend(b"LIST");
body.extend(3u32.to_le_bytes());
body.extend(b"abc\0");
}
body.extend(b"data");
body.extend(4u32.to_le_bytes());
body.extend([0u8; 4]);
let mut file = b"RIFF".to_vec();
file.extend((body.len() as u32).to_le_bytes());
file.extend(body);
file
}
#[test]
fn accepts_16khz_mono_16bit_wav() {
assert_eq!(validate_wav(&wav(1, 16_000, 16, false)), Ok(()));
assert_eq!(validate_wav(&wav(1, 16_000, 16, true)), Ok(()));
}
#[test]
fn rejects_other_wav_formats() {
assert!(validate_wav(&wav(2, 16_000, 16, false)).is_err());
assert!(validate_wav(&wav(1, 44_100, 16, false)).is_err());
assert!(validate_wav(&wav(1, 16_000, 8, false)).is_err());
assert!(validate_wav(b"definitely not a wav file").is_err());
}
#[test]
fn image_must_be_exactly_320x240_rgb565() {
assert_eq!(validate_image(&vec![0; 153_600]), Ok(()));
assert!(validate_image(&vec![0; 153_599]).is_err());
}
#[test]
fn etag_is_deterministic_and_content_sensitive() {
assert_eq!(etag(b"frame"), etag(b"frame"));
assert_ne!(etag(b"frame"), etag(b"framf"));
}
#[test]
fn encodes_uint_options_without_leading_zeros() {
assert_eq!(uint_option(153_600), vec![0x02, 0x58, 0x00]);
assert_eq!(uint_option(0), Vec::<u8>::new());
}
fn media_request(block: Option<BlockValue>) -> CoapRequest<SocketAddr> {
let mut packet = Packet::new();
packet.header.set_type(MessageType::Confirmable);
packet.header.code = MessageClass::Request(RequestType::Get);
if let Some(block) = block {
packet.add_option_as(CoapOption::Block2, block);
}
CoapRequest::from_packet(packet, "127.0.0.1:9".parse().unwrap())
}
#[test]
fn media_blocks_carry_block2_etag_and_size2() {
let bytes = vec![7u8; 3000];
let resource = Ok(Resource { etag: etag(&bytes), bytes });
let request = media_request(None);
let mut first = CoapResponse::new(&request.message).unwrap();
serve_media(&request, &mut first, &resource, "/audio");
let block = first.message.get_first_option_as::<BlockValue>(CoapOption::Block2).unwrap().unwrap();
assert_eq!(u8::from(first.message.header.code), 0x45); // 2.05
assert_eq!((block.num, block.more, block.size()), (0, true, 1024));
assert_eq!(first.message.get_first_option(CoapOption::Size2), Some(&vec![0x0b, 0xb8]));
assert_eq!(first.message.payload.len(), 1024);
let request = media_request(Some(BlockValue::new(2, false, 1024).unwrap()));
let mut last = CoapResponse::new(&request.message).unwrap();
serve_media(&request, &mut last, &resource, "/audio");
let block = last.message.get_first_option_as::<BlockValue>(CoapOption::Block2).unwrap().unwrap();
assert_eq!((block.num, block.more), (2, false));
assert_eq!(last.message.payload.len(), 3000 - 2048);
assert_eq!(
last.message.get_first_option(CoapOption::ETag),
first.message.get_first_option(CoapOption::ETag)
);
assert!(last.message.get_option(CoapOption::Size2).is_none());
}
#[test]
fn block_past_the_end_is_bad_option() {
let bytes = vec![0u8; 1024];
let resource = Ok(Resource { etag: etag(&bytes), bytes });
let request = media_request(Some(BlockValue::new(1, false, 1024).unwrap()));
let mut response = CoapResponse::new(&request.message).unwrap();
serve_media(&request, &mut response, &resource, "/image");
assert_eq!(u8::from(response.message.header.code), 0x82); // 4.02
}
} }

82
test-stream.sh Executable file
View File

@@ -0,0 +1,82 @@
#!/usr/bin/env bash
# End-to-end test of block-wise media streaming over CoAP/UDP. Self-contained:
# generates test media, starts its own server, streams both files back and
# compares them byte for byte.
# ./test-stream.sh (needs: cargo build --release, ffmpeg, python3)
# COAP_PORT picks the test server's port (default 5699).
set -u
cd "$(dirname "$0")"
PORT=${COAP_PORT:-5699}
DIR=$(mktemp -d)
pass=0; fail=0
server_pid=
cleanup() { [[ -n $server_pid ]] && kill "$server_pid" 2>/dev/null; rm -rf "$DIR"; }
trap cleanup EXIT
ok() { printf ' ok %s\n' "$1"; ((pass++)); }
bad() { printf ' FAIL %s\n' "$1"; [[ -n ${2:-} ]] && printf '%s\n' "$2" | sed 's/^/ /'; ((fail++)); }
for bin in coap-hello-udp stream client; do
[[ -x target/release/$bin ]] || { echo "build first: cargo build --release"; exit 1; }
done
command -v ffmpeg >/dev/null || { echo "ffmpeg is needed to generate test media"; exit 1; }
ffmpeg -hide_banner -loglevel error -f lavfi -i "sine=frequency=440:duration=10" \
-ac 1 -ar 16000 -c:a pcm_s16le "$DIR/audio.wav"
ffmpeg -hide_banner -loglevel error -f lavfi -i "testsrc=size=320x240" \
-frames:v 1 -f rawvideo -pix_fmt rgb565le "$DIR/image.rgb565"
MEDIA_DIR=$DIR COAP_PORT=$PORT target/release/coap-hello-udp >"$DIR/server.log" 2>&1 &
server_pid=$!
for _ in $(seq 50); do
ss -lun | grep -q ":$PORT " && break
sleep 0.1
done
ss -lun | grep -q ":$PORT " || { echo "server did not start:"; cat "$DIR/server.log"; exit 1; }
echo "block-wise streaming tests -> udp://127.0.0.1:$PORT"
check_stream() { # check_stream <audio|image> <source file>
local media=$1 src=$2 out="$DIR/received.$1" log
local size; size=$(stat -c %s "$src")
local blocks=$(( (size + 1023) / 1024 ))
if ! log=$(COAP_PORT=$PORT target/release/stream "$media" --no-play --out "$out" 2>&1); then
bad "stream $media" "$log"; return
fi
if cmp -s "$src" "$out"; then
ok "stream $media: $size B received byte-identical"
else
bad "stream $media: received file differs from the source"
fi
if grep -q "received $size B in $blocks blocks" <<<"$log"; then
ok "stream $media: $blocks blocks, $(grep -o '[0-9]* retransmits' <<<"$log")"
else
bad "stream $media: expected $size B in $blocks blocks" "$log"
fi
}
check_stream image "$DIR/image.rgb565"
check_stream audio "$DIR/audio.wav"
reply=$(COAP_PORT=$PORT python3 coap-inspect.py get /image --block 150 2>&1)
if grep -q "4.02 Bad Option" <<<"$reply"; then
ok "block 150 of a 150-block image -> 4.02 Bad Option"
else
bad "block past the end -> 4.02" "$(tail -4 <<<"$reply")"
fi
reply=$(COAP_PORT=$PORT target/release/client post /image x 2>&1)
if [[ $reply == "<- 4.05 "* ]]; then ok "POST /image -> 4.05"; else bad "POST /image -> 4.05" "$reply"; fi
if out=$(COAP_PORT=$PORT ./test-coap.sh 2>&1); then
ok "test-coap.sh on the same server: $(tail -1 <<<"$out")"
else
bad "test-coap.sh on the same server" "$(grep FAIL <<<"$out")"
fi
echo "$pass passed, $fail failed"
[[ $fail -eq 0 ]]