Cleanup before network implementation
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -21,6 +21,9 @@ analysis/.venv/
|
|||||||
# Data — raw CSVs committed, processed outputs not
|
# Data — raw CSVs committed, processed outputs not
|
||||||
data/**/*_processed.csv
|
data/**/*_processed.csv
|
||||||
|
|
||||||
|
# Self-signed dev TLS material — regenerate with `make certs`
|
||||||
|
certs/
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.swp
|
*.swp
|
||||||
|
|||||||
112
CLAUDE.md
Normal file
112
CLAUDE.md
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
# quic_ecs_dt — Project Guide for Claude
|
||||||
|
|
||||||
|
## What & why
|
||||||
|
|
||||||
|
Source repo for **"QUIC + ECS as Complementary Transport and Runtime Substrates for Industrial Digital Twins"** — UCAmI 2026 (Plantevin & Francillette, UQAC). Third paper in a sequence; the first two are at IEEE SWC 2026:
|
||||||
|
|
||||||
|
- `plantevin2026ecs` — ECS as runtime substrate for industrial DT (200k assets @ 114 Hz on Pi 5).
|
||||||
|
- `plantevin2026quic` — QUIC partial reliability for DT sensor streams (94% P99 reduction vs TCP at 5% loss).
|
||||||
|
|
||||||
|
**UCAmI hypothesis (the composition question):** prior work shows ECS and QUIC each work as substrates *independently*. Does integrating real QUIC traffic into a Bevy ECS ingest path introduce coupling that degrades either one's claimed properties? The paper argues no, and measures it.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Three-tier QUIC ↔ ECS bridge, headless Bevy runtime:
|
||||||
|
|
||||||
|
| Tier | QUIC primitive | Use case | Channel cap |
|
||||||
|
|------|----------------|----------|-------------|
|
||||||
|
| T1 | Unreliable datagrams (RFC 9221) | High-freq ephemeral telemetry; drops OK | 1024, lossy backpressure |
|
||||||
|
| T2 | Unidirectional streams | Ordered threshold events; reliable | 512, fully drained |
|
||||||
|
| T3 | Bidirectional streams | Actuator commands w/ ACK | 256, fully drained |
|
||||||
|
|
||||||
|
QUIC server runs on a dedicated OS thread with a Tokio multi-thread runtime; pushes decoded `QuicMessage` (UUID + stream_id + f64 + ts + seq) into `tokio::sync::mpsc` per tier; Bevy `IngestSystem` drains in `PreUpdate`. Pattern is in [substrate/src/transport/ecs.rs](substrate/src/transport/ecs.rs).
|
||||||
|
|
||||||
|
**Target hardware:** CM5 (BCM2712, Cortex-A76, 4 GB) as DT runtime; M4 Max as traffic generator; 1 Gbps direct Ethernet. Both rigs are in hand.
|
||||||
|
|
||||||
|
## Repo map
|
||||||
|
|
||||||
|
```
|
||||||
|
quic_ecs_dt/
|
||||||
|
├── paper/ Quarto + LNCS source — single index.qmd, refs in references.bib
|
||||||
|
├── substrate/ Rust crate: Bevy 0.18 + Quinn 0.11 + rustls 0.23 + Tokio
|
||||||
|
│ └── src/
|
||||||
|
│ ├── main.rs App::new, MinimalPlugins, EcsQuicTransportPlugin
|
||||||
|
│ ├── config.rs figment chain: defaults → config.toml → APP_* env
|
||||||
|
│ └── transport/
|
||||||
|
│ ├── mod.rs QuicMessage struct
|
||||||
|
│ ├── ecs.rs Plugin: tokio thread + 3 mpsc + PreUpdate ingest
|
||||||
|
│ └── server.rs run_substrate_server (EMPTY STUB)
|
||||||
|
├── simulator/ Rust crate: stub today; will be Quinn client + Bevy sensor generators
|
||||||
|
├── data/ (created by M6) loopback/, two_machine/ — raw CSVs committed, *_processed ignored
|
||||||
|
├── Cargo.toml workspace
|
||||||
|
└── Makefile render, preview, build, build-cm5, deploy-cm5
|
||||||
|
```
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
| Area | State |
|
||||||
|
|------|-------|
|
||||||
|
| `AppConfig` figment loader (defaults → TOML → env) | Done — [substrate/src/config.rs:42](substrate/src/config.rs#L42) |
|
||||||
|
| 3-tier MPSC bridge scaffolding (Tokio thread + Bevy plugin) | Done — [substrate/src/transport/ecs.rs](substrate/src/transport/ecs.rs) |
|
||||||
|
| `QuicMessage` struct (no codec yet) | Defined — [substrate/src/transport/mod.rs:4](substrate/src/transport/mod.rs#L4) |
|
||||||
|
| Quinn server (accept loop, demux, decode) | **Empty stub** — [substrate/src/transport/server.rs:4](substrate/src/transport/server.rs#L4) |
|
||||||
|
| TLS / self-signed cert | Done (M1) — `certs/server.{crt,key}` via `make certs`, gitignored |
|
||||||
|
| Wire codec for `QuicMessage` (38 B fixed LE) | Done (M1) — [substrate/src/transport/mod.rs:35](substrate/src/transport/mod.rs#L35); 4 unit tests passing |
|
||||||
|
| `tracing-subscriber` init w/ `RUST_LOG` | Done (M1) — [substrate/src/main.rs:8-12](substrate/src/main.rs#L8-L12) |
|
||||||
|
| ECS components (`RawSensorData`) + 5 systems (Ingest/Sim/Export/FaultInjection/Diagnostics) | Missing — placeholder at [substrate/src/transport/ecs.rs:26](substrate/src/transport/ecs.rs#L26) |
|
||||||
|
| VictoriaMetrics + Grafana export | Missing |
|
||||||
|
| Simulator (Quinn client + sensor generators) | `Hello, world!` — [simulator/src/main.rs](simulator/src/main.rs) |
|
||||||
|
| `config.toml` at repo root | Done (M1) — [config.toml](config.toml); loaded by [substrate/src/main.rs:9](substrate/src/main.rs#L9) |
|
||||||
|
| Benchmark harness (sweep + CSV writer) | Missing |
|
||||||
|
| CM5 cross-compile / deploy | Wired in [Makefile:30](Makefile#L30); not exercised |
|
||||||
|
|
||||||
|
`cargo run -p substrate` boots, prints the loaded config, and idles on the (still-empty) Quinn server. `MinimalPlugins` busy-loops the ECS schedule by default — expected, will gate to `tick_rate_hz` in M4.
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
Each milestone has one verification gate. Update Status here as we go.
|
||||||
|
|
||||||
|
- **M1 — Wire codec & root config.** ✅ Done 2026-05-04. Hand-rolled little-endian codec on `QuicMessage` (38 B fixed: 16 UUID + 2 stream_id + 8 f64 + 8 ts_us + 4 seq) with roundtrip + layout + length-error tests; `config.toml` at repo root; dev TLS via `make certs`; structured `tracing-subscriber` init reads `RUST_LOG` (default `info`).
|
||||||
|
- **M2 — Quinn server + self-signed TLS.** Fill [substrate/src/transport/server.rs](substrate/src/transport/server.rs): `Endpoint::server`, accept loop, demux T1=datagrams / T2=uni / T3=bi, push into matching `mpsc::Sender`. Use `rcgen` for a dev cert at boot. *Verify:* a Quinn smoke client connects, server logs handshake.
|
||||||
|
- **M3 — Simulator client.** Replace [simulator/src/main.rs](simulator/src/main.rs) with a Bevy app: Quinn client, N synthetic devices, configurable per-tier rates. *Verify:* end-to-end loopback drains messages on all three tiers.
|
||||||
|
- **M4 — ECS world.** Define `RawSensorData` and the 5 systems the paper names (`FaultInjectionSystem`, `IngestSystem`, `SimulationSystem`, `ExportSystem`, `DiagnosticsSystem`). Wire `IngestSystem` into the existing `PreUpdate` slot. *Verify:* with 10k simulated devices, entity count stabilizes; `DiagnosticsSystem` logs steady tick rate.
|
||||||
|
- **M5 — Observability (VictoriaMetrics + Grafana).** Substrate exposes Prometheus-format `/metrics` (use `metrics` + `metrics-exporter-prometheus`): tick rate, RSS, per-tier P50/P99/P999, channel depth, drop count. Commit a Grafana dashboard JSON. *Verify:* `curl :PORT/metrics` returns labeled samples; dashboard renders against VM.
|
||||||
|
- **M6 — Benchmark harness.** Sweep `entity_count ∈ {10k, 50k, 100k, 200k}` × `loss_rate ∈ {0%, 1%, 5%}` with 2k warmup + 5k measurement ticks. Loss via `tc netem` or in-app injection. Writes `data/loopback/final_table.csv`. *Verify:* one full sweep on M4 Max produces a CSV the Quarto figures consume.
|
||||||
|
- **M7 — CM5 cross-compile & deploy.** Exercise [Makefile:30](Makefile#L30) (`build-cm5`, `deploy-cm5`); set real `CM5_HOST`. *Verify:* binary runs on CM5 with a feed from M4 Max over 1 Gbps Ethernet.
|
||||||
|
- **M8 — Two-machine run + paper render.** Sweep with simulator on M4 Max → substrate on CM5; populate `data/two_machine/final_table.csv`; `make render` produces a PDF. **Update §Evaluation prose to reflect actual numbers.** Current paper figures (241 Hz, 64 µs / 15.8 ms P99, 2.6 µs jitter, 1.02 MB/1k, R²=0.9999) are **aspirational placeholders** — they may move and the conclusions may shift; that's expected.
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- **Rust:** edition 2024; workspace at root with `simulator` + `substrate`; `opt-level=1` dev, `opt-level=3` for deps.
|
||||||
|
- **Pinned crates:** Bevy 0.18, Quinn 0.11, rustls 0.23, Tokio 1 (full), figment 0.10 (toml + env), uuid 1.23 (v4), serde 1.
|
||||||
|
- **Config:** `figment` chain — defaults in [substrate/src/config.rs:25](substrate/src/config.rs#L25) → `config.toml` → env `APP_*` (double-underscore for nesting, e.g. `APP_NETWORK__SERVER_PORT=9000`).
|
||||||
|
- **Bevy:** headless — `MinimalPlugins` only; do not pull rendering plugins.
|
||||||
|
- **Tokio↔Bevy:** keep the dedicated-thread + mpsc pattern in [substrate/src/transport/ecs.rs:49](substrate/src/transport/ecs.rs#L49); do not block the ECS schedule on async work.
|
||||||
|
- **Paper:** Quarto + LNCS template ([paper/_extensions/template.tex](paper/_extensions/template.tex), [paper/_quarto.yml](paper/_quarto.yml)). **Never commit `llncs.cls` or `splncs04.bst`** — CTAN licensing; download per [README.md:25-34](README.md#L25-L34).
|
||||||
|
- **Data:** raw CSVs under `data/` are committed; `*_processed.csv` is gitignored. Paper figures consume `data/loopback/final_table.csv` and `data/two_machine/final_table.csv`.
|
||||||
|
- **Build artifacts:** `target/`, `paper/_output/`, `paper/figures/`, `paper/.quarto/`, `paper/index.tex` all gitignored.
|
||||||
|
|
||||||
|
## Run / verify
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make certs # generate certs/server.{crt,key} (ECDSA P-256, SAN: localhost/cm5.local/127.0.0.1/::1)
|
||||||
|
make build # cargo build --release (native, depends on certs)
|
||||||
|
make build-cm5 # aarch64 cross-build for the CM5 (depends on certs)
|
||||||
|
make deploy-cm5 # scp to $CM5_HOST (set in env or override Makefile var)
|
||||||
|
make render # build the paper PDF
|
||||||
|
make preview # live-reload paper preview at :4848
|
||||||
|
make clean # cargo clean + drop generated paper outputs
|
||||||
|
```
|
||||||
|
|
||||||
|
`certs/` is gitignored; `make build` regenerates the dev cert if missing. From the repo root: `cargo run -p substrate` boots, prints the loaded `AppConfig`, and idles. `config.toml` and cert paths are resolved relative to the cwd — always launch from the repo root.
|
||||||
|
|
||||||
|
## Key references
|
||||||
|
|
||||||
|
- Prior self-citations: `plantevin2026ecs`, `plantevin2026quic` (both IEEE SWC 2026, "to appear").
|
||||||
|
- QUIC: RFC 9000 (core), RFC 9221 (unreliable datagrams).
|
||||||
|
- DT foundations: Tao et al. 2019; Grieves & Vickers 2017; Minerva et al. 2020.
|
||||||
|
- ECS: Nystrom 2014, *Game Programming Patterns*.
|
||||||
|
- Mixed-reliability transport: Peeck et al. (W2RP for DDS).
|
||||||
|
- DT sync metrics: Çakır et al. 2023 (Twin Alignment Ratio); Bellavista et al. 2023 (ODTE).
|
||||||
|
- Industrial QUIC/IIoT: Fernández et al. 2021; Boeding et al. 2025.
|
||||||
|
- Full bibliography: [paper/references.bib](paper/references.bib).
|
||||||
24
Makefile
24
Makefile
@@ -8,7 +8,7 @@
|
|||||||
# make clean — remove generated outputs
|
# make clean — remove generated outputs
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
.PHONY: render preview build build-cm5 clean
|
.PHONY: render preview build build-cm5 clean certs
|
||||||
|
|
||||||
VENV := $(HOME)/.venv/quic_ecs
|
VENV := $(HOME)/.venv/quic_ecs
|
||||||
PYTHON := $(VENV)/bin/python
|
PYTHON := $(VENV)/bin/python
|
||||||
@@ -16,6 +16,22 @@ CM5_HOST ?= 192.168.1.x
|
|||||||
CM5_USER ?= pi
|
CM5_USER ?= pi
|
||||||
CM5_BIN_DIR ?= /home/pi/quic_ecs_dt
|
CM5_BIN_DIR ?= /home/pi/quic_ecs_dt
|
||||||
|
|
||||||
|
# Self-signed dev TLS for the QUIC server (regenerate with `make certs`).
|
||||||
|
# SAN covers loopback, ::1, and cm5.local for the two-machine setup.
|
||||||
|
CERT_DIR := certs
|
||||||
|
CERT_FILE := $(CERT_DIR)/server.crt
|
||||||
|
KEY_FILE := $(CERT_DIR)/server.key
|
||||||
|
|
||||||
|
certs: $(CERT_FILE)
|
||||||
|
|
||||||
|
$(CERT_FILE):
|
||||||
|
mkdir -p $(CERT_DIR)
|
||||||
|
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 \
|
||||||
|
-keyout $(KEY_FILE) -out $(CERT_FILE) \
|
||||||
|
-days 3650 -nodes \
|
||||||
|
-subj "/CN=localhost/O=quic_ecs_dt-dev/OU=substrate" \
|
||||||
|
-addext "subjectAltName=DNS:localhost,DNS:cm5.local,IP:127.0.0.1,IP:::1"
|
||||||
|
|
||||||
# Paper
|
# Paper
|
||||||
render:
|
render:
|
||||||
cd paper && quarto render index.qmd
|
cd paper && quarto render index.qmd
|
||||||
@@ -23,11 +39,11 @@ render:
|
|||||||
preview:
|
preview:
|
||||||
cd paper && quarto preview index.qmd --port 4848 --no-browser
|
cd paper && quarto preview index.qmd --port 4848 --no-browser
|
||||||
|
|
||||||
# Rust build
|
# Rust build (depends on dev cert so `cargo run` boots out of the box)
|
||||||
build:
|
build: $(CERT_FILE)
|
||||||
cargo build --release
|
cargo build --release
|
||||||
|
|
||||||
build-cm5:
|
build-cm5: $(CERT_FILE)
|
||||||
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc \
|
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc \
|
||||||
cargo build --release --target aarch64-unknown-linux-gnu
|
cargo build --release --target aarch64-unknown-linux-gnu
|
||||||
|
|
||||||
|
|||||||
18
config.toml
Normal file
18
config.toml
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# quic_ecs_dt — substrate runtime config
|
||||||
|
#
|
||||||
|
# Resolution order (figment chain in substrate/src/config.rs):
|
||||||
|
# 1. compile-time defaults
|
||||||
|
# 2. this file
|
||||||
|
# 3. APP_* env vars (e.g. APP_NETWORK__SERVER_PORT=9001)
|
||||||
|
#
|
||||||
|
# All paths are resolved relative to the cwd at launch — run from the repo root.
|
||||||
|
|
||||||
|
[network]
|
||||||
|
server_port = 9000
|
||||||
|
server_interface = "0.0.0.0"
|
||||||
|
server_cert = "certs/server.crt"
|
||||||
|
server_key = "certs/server.key"
|
||||||
|
|
||||||
|
[simulation]
|
||||||
|
tick_rate_hz = 60
|
||||||
|
max_entities = 10000
|
||||||
@@ -7,7 +7,8 @@ edition = "2024"
|
|||||||
bevy = "0.18"
|
bevy = "0.18"
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
tracing-subscriber = "0.3"
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
quinn = { version = "0.11" }
|
quinn = { version = "0.11" }
|
||||||
rustls = { version = "0.23" }
|
rustls = { version = "0.23" }
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ pub struct QuicConfig {
|
|||||||
pub server_port: u16,
|
pub server_port: u16,
|
||||||
pub server_interface: String,
|
pub server_interface: String,
|
||||||
pub server_cert: String,
|
pub server_cert: String,
|
||||||
|
pub server_key: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for AppConfig {
|
impl Default for AppConfig {
|
||||||
@@ -28,7 +29,8 @@ impl Default for AppConfig {
|
|||||||
network : QuicConfig {
|
network : QuicConfig {
|
||||||
server_port: 9000,
|
server_port: 9000,
|
||||||
server_interface: "0.0.0.0".to_string(),
|
server_interface: "0.0.0.0".to_string(),
|
||||||
server_cert: "cert.pem".to_string(),
|
server_cert: "certs/server.crt".to_string(),
|
||||||
|
server_key: "certs/server.key".to_string(),
|
||||||
},
|
},
|
||||||
simulation: SimulationConfig {
|
simulation: SimulationConfig {
|
||||||
tick_rate_hz: 60,
|
tick_rate_hz: 60,
|
||||||
|
|||||||
@@ -1,17 +1,23 @@
|
|||||||
mod transport;
|
mod transport;
|
||||||
mod config;
|
mod config;
|
||||||
|
|
||||||
use std::sync::Arc;
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
|
use tracing_subscriber::EnvFilter;
|
||||||
use crate::config::AppConfig;
|
use crate::config::AppConfig;
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(
|
||||||
|
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")),
|
||||||
|
)
|
||||||
|
.init();
|
||||||
|
|
||||||
let config = AppConfig::load("config.toml").expect("Failed to load config");
|
let config = AppConfig::load("config.toml").expect("Failed to load config");
|
||||||
println!("{:?}", config);
|
tracing::info!(?config, "substrate starting");
|
||||||
|
|
||||||
App::new()
|
App::new()
|
||||||
.insert_resource(config)
|
.insert_resource(config)
|
||||||
.add_plugins(MinimalPlugins)
|
.add_plugins(MinimalPlugins)
|
||||||
.add_plugins(transport::ecs::EcsQuicTransportPlugin{})
|
.add_plugins(transport::ecs::EcsQuicTransportPlugin {})
|
||||||
.run();
|
.run();
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
use std::sync::{Arc, Mutex};
|
use std::sync::Mutex;
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use crate::config::QuicConfig;
|
|
||||||
use crate::transport::QuicMessage;
|
use crate::transport::QuicMessage;
|
||||||
use crate::transport::server::run_substrate_server;
|
use crate::transport::server::run_substrate_server;
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,126 @@
|
|||||||
pub mod ecs;
|
pub mod ecs;
|
||||||
mod server;
|
mod server;
|
||||||
|
|
||||||
|
/// One sensor sample on the wire.
|
||||||
|
///
|
||||||
|
/// Fixed 38-byte little-endian layout — same on x86_64 and aarch64 (the two
|
||||||
|
/// evaluation hosts), so encode/decode is effectively a memcpy.
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// offset size field
|
||||||
|
/// ------ ---- --------------------------
|
||||||
|
/// 0 16 device_id (UUID)
|
||||||
|
/// 16 2 data_stream_id (u16)
|
||||||
|
/// 18 8 raw_value (f64)
|
||||||
|
/// 26 8 timestamp_us (u64)
|
||||||
|
/// 34 4 sequence_number (u32)
|
||||||
|
/// ```
|
||||||
#[derive(Debug, Clone, Default, Copy, PartialEq)]
|
#[derive(Debug, Clone, Default, Copy, PartialEq)]
|
||||||
pub struct QuicMessage{
|
pub struct QuicMessage {
|
||||||
pub device_id: uuid::Uuid,
|
pub device_id: uuid::Uuid,
|
||||||
pub data_stream_id: u16,
|
pub data_stream_id: u16,
|
||||||
pub raw_value: f64,
|
pub raw_value: f64,
|
||||||
pub timestamp_us: u64,
|
pub timestamp_us: u64,
|
||||||
pub sequence_number: u32,
|
pub sequence_number: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum WireError {
|
||||||
|
#[error("expected exactly {expected} bytes, got {got}")]
|
||||||
|
BadLength { expected: usize, got: usize },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl QuicMessage {
|
||||||
|
/// Bytes on the wire — fixed-size, no length prefix.
|
||||||
|
pub const WIRE_SIZE: usize = 38;
|
||||||
|
|
||||||
|
pub fn encode_to(&self, buf: &mut [u8]) -> Result<(), WireError> {
|
||||||
|
if buf.len() != Self::WIRE_SIZE {
|
||||||
|
return Err(WireError::BadLength {
|
||||||
|
expected: Self::WIRE_SIZE,
|
||||||
|
got: buf.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
buf[0..16].copy_from_slice(self.device_id.as_bytes());
|
||||||
|
buf[16..18].copy_from_slice(&self.data_stream_id.to_le_bytes());
|
||||||
|
buf[18..26].copy_from_slice(&self.raw_value.to_le_bytes());
|
||||||
|
buf[26..34].copy_from_slice(&self.timestamp_us.to_le_bytes());
|
||||||
|
buf[34..38].copy_from_slice(&self.sequence_number.to_le_bytes());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn to_bytes(&self) -> [u8; Self::WIRE_SIZE] {
|
||||||
|
let mut buf = [0u8; Self::WIRE_SIZE];
|
||||||
|
self.encode_to(&mut buf).expect("WIRE_SIZE buffer is exactly sized");
|
||||||
|
buf
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decode(buf: &[u8]) -> Result<Self, WireError> {
|
||||||
|
if buf.len() != Self::WIRE_SIZE {
|
||||||
|
return Err(WireError::BadLength {
|
||||||
|
expected: Self::WIRE_SIZE,
|
||||||
|
got: buf.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let mut id_bytes = [0u8; 16];
|
||||||
|
id_bytes.copy_from_slice(&buf[0..16]);
|
||||||
|
Ok(Self {
|
||||||
|
device_id: uuid::Uuid::from_bytes(id_bytes),
|
||||||
|
data_stream_id: u16::from_le_bytes(buf[16..18].try_into().unwrap()),
|
||||||
|
raw_value: f64::from_le_bytes(buf[18..26].try_into().unwrap()),
|
||||||
|
timestamp_us: u64::from_le_bytes(buf[26..34].try_into().unwrap()),
|
||||||
|
sequence_number: u32::from_le_bytes(buf[34..38].try_into().unwrap()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wire_size_matches_fields() {
|
||||||
|
assert_eq!(QuicMessage::WIRE_SIZE, 16 + 2 + 8 + 8 + 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn roundtrip_preserves_all_fields() {
|
||||||
|
let msg = QuicMessage {
|
||||||
|
device_id: uuid::Uuid::from_u128(0x0123456789abcdef_fedcba9876543210),
|
||||||
|
data_stream_id: 0xBEEF,
|
||||||
|
raw_value: -273.15,
|
||||||
|
timestamp_us: 1_700_000_000_000_001,
|
||||||
|
sequence_number: 42,
|
||||||
|
};
|
||||||
|
let bytes = msg.to_bytes();
|
||||||
|
assert_eq!(bytes.len(), QuicMessage::WIRE_SIZE);
|
||||||
|
let decoded = QuicMessage::decode(&bytes).unwrap();
|
||||||
|
assert_eq!(msg, decoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decode_rejects_wrong_length() {
|
||||||
|
assert!(matches!(
|
||||||
|
QuicMessage::decode(&[0u8; 37]),
|
||||||
|
Err(WireError::BadLength { expected: 38, got: 37 })
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
QuicMessage::decode(&[0u8; 39]),
|
||||||
|
Err(WireError::BadLength { expected: 38, got: 39 })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encode_layout_is_little_endian() {
|
||||||
|
let msg = QuicMessage {
|
||||||
|
device_id: uuid::Uuid::nil(),
|
||||||
|
data_stream_id: 0x0102,
|
||||||
|
raw_value: 0.0,
|
||||||
|
timestamp_us: 0,
|
||||||
|
sequence_number: 0x04030201,
|
||||||
|
};
|
||||||
|
let bytes = msg.to_bytes();
|
||||||
|
assert_eq!(&bytes[16..18], &[0x02, 0x01]);
|
||||||
|
assert_eq!(&bytes[34..38], &[0x01, 0x02, 0x03, 0x04]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user