First test kinda working

This commit is contained in:
Valère Plantevin
2026-05-12 11:21:40 -04:00
parent cac6c9ac02
commit d3f09ee062
36 changed files with 3903 additions and 102 deletions

View File

@@ -1,27 +1,100 @@
pub mod ecs;
mod server;
pub mod server;
pub mod state;
/// One sensor sample on the wire.
use tokio::sync::{mpsc, oneshot};
/// Logical type of a sensor reading. Travels in `QuicMessage::sensor_type`
/// so the substrate (and any downstream dashboard) knows which units / range
/// / visualisation applies to the `raw_value`.
///
/// Fixed 38-byte little-endian layout — same on x86_64 and aarch64 (the two
/// Forward compat: unknown discriminants decode as `Generic`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
#[repr(u8)]
pub enum SensorType {
#[default]
Generic = 0,
Temperature = 1,
Humidity = 2,
Pressure = 3,
Voltage = 4,
Current = 5,
}
impl SensorType {
pub fn from_u8(b: u8) -> Self {
match b {
1 => Self::Temperature,
2 => Self::Humidity,
3 => Self::Pressure,
4 => Self::Voltage,
5 => Self::Current,
_ => Self::Generic,
}
}
pub fn as_u8(self) -> u8 {
self as u8
}
/// Lowercase label used as a Prometheus label value.
pub fn label_str(self) -> &'static str {
match self {
Self::Generic => "generic",
Self::Temperature => "temperature",
Self::Humidity => "humidity",
Self::Pressure => "pressure",
Self::Voltage => "voltage",
Self::Current => "current",
}
}
/// SI / engineering unit string for Grafana axis labels.
pub fn unit_str(self) -> &'static str {
match self {
Self::Generic => "",
Self::Temperature => "°C",
Self::Humidity => "%",
Self::Pressure => "hPa",
Self::Voltage => "V",
Self::Current => "A",
}
}
}
/// One sample (T1/T2 sensor reading or T3 actuator command/ack) on the wire.
///
/// Fixed 39-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)
/// 16 2 sensor_id (u16)
/// 18 8 raw_value (f64)
/// 26 8 timestamp_us (u64)
/// 34 4 sequence_number (u32)
/// 38 1 sensor_type (u8 — `SensorType` discriminant)
/// ```
///
/// Field semantics:
/// - `device_id` — UUID of the originating device (or target, for T3 commands).
/// - `sensor_id` — logical sensor/actuator on that device (per-device index).
/// - `raw_value` — sensor reading (T1/T2) or actuator setpoint/feedback (T3).
/// - `timestamp_us` — capture time on the device clock for T1/T2; server-side
/// ack time on T3 replies.
/// - `sequence_number` — monotonic counter per `(device_id, sensor_id)` for
/// T1/T2; correlation id linking T3 command and ack.
/// - `sensor_type` — `SensorType` discriminant, decoded via `SensorType::from_u8`.
#[derive(Debug, Clone, Default, Copy, PartialEq)]
pub struct QuicMessage {
pub device_id: uuid::Uuid,
pub data_stream_id: u16,
pub sensor_id: u16,
pub raw_value: f64,
pub timestamp_us: u64,
pub sequence_number: u32,
pub sensor_type: u8,
}
#[derive(Debug, thiserror::Error)]
@@ -32,7 +105,7 @@ pub enum WireError {
impl QuicMessage {
/// Bytes on the wire — fixed-size, no length prefix.
pub const WIRE_SIZE: usize = 38;
pub const WIRE_SIZE: usize = 39;
pub fn encode_to(&self, buf: &mut [u8]) -> Result<(), WireError> {
if buf.len() != Self::WIRE_SIZE {
@@ -42,10 +115,11 @@ impl QuicMessage {
});
}
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[16..18].copy_from_slice(&self.sensor_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());
buf[38] = self.sensor_type;
Ok(())
}
@@ -66,12 +140,113 @@ impl QuicMessage {
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()),
sensor_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()),
sensor_type: buf[38],
})
}
/// Convenience accessor — decodes `sensor_type` to the typed enum.
pub fn typ(&self) -> SensorType {
SensorType::from_u8(self.sensor_type)
}
}
// --- Per-tier bridge senders -----------------------------------------------
//
// Three newtypes encode the paper's tier semantics into the type system so
// the demux can't mix them up:
//
// * T1 (datagrams) — lossy; `try_send` drops on full
// * T2 (uni streams) — reliable, ordered; `send().await` backpressures
// * T3 (bi streams) — reliable command + per-command oneshot reply
/// Tier 1 — high-frequency telemetry over QUIC datagrams. Full channel drops.
#[derive(Clone)]
pub struct T1Sender {
inner: mpsc::Sender<QuicMessage>,
}
impl T1Sender {
pub fn new(inner: mpsc::Sender<QuicMessage>) -> Self {
Self { inner }
}
/// Returns `true` if queued, `false` if dropped (channel full or closed).
pub fn send_lossy(&self, msg: QuicMessage) -> bool {
self.inner.try_send(msg).is_ok()
}
/// Currently queued messages — used for channel-depth gauges.
pub fn depth(&self) -> usize {
self.inner.max_capacity().saturating_sub(self.inner.capacity())
}
pub fn capacity(&self) -> usize {
self.inner.max_capacity()
}
}
/// Tier 2 — ordered events over a QUIC unidirectional stream. Awaits on full.
#[derive(Clone)]
pub struct T2Sender {
inner: mpsc::Sender<QuicMessage>,
}
impl T2Sender {
pub fn new(inner: mpsc::Sender<QuicMessage>) -> Self {
Self { inner }
}
pub async fn send(
&self,
msg: QuicMessage,
) -> Result<(), mpsc::error::SendError<QuicMessage>> {
self.inner.send(msg).await
}
pub fn depth(&self) -> usize {
self.inner.max_capacity().saturating_sub(self.inner.capacity())
}
pub fn capacity(&self) -> usize {
self.inner.max_capacity()
}
}
/// Tier 3 — actuator command on a QUIC bidirectional stream, paired with a
/// `oneshot` channel the ECS uses to write the ack back over the same stream.
pub struct T3Inbound {
pub command: QuicMessage,
pub reply: oneshot::Sender<QuicMessage>,
}
#[derive(Clone)]
pub struct T3Sender {
inner: mpsc::Sender<T3Inbound>,
}
impl T3Sender {
pub fn new(inner: mpsc::Sender<T3Inbound>) -> Self {
Self { inner }
}
pub async fn send(
&self,
inbound: T3Inbound,
) -> Result<(), mpsc::error::SendError<T3Inbound>> {
self.inner.send(inbound).await
}
pub fn depth(&self) -> usize {
self.inner.max_capacity().saturating_sub(self.inner.capacity())
}
pub fn capacity(&self) -> usize {
self.inner.max_capacity()
}
}
#[cfg(test)]
@@ -80,33 +255,35 @@ mod tests {
#[test]
fn wire_size_matches_fields() {
assert_eq!(QuicMessage::WIRE_SIZE, 16 + 2 + 8 + 8 + 4);
assert_eq!(QuicMessage::WIRE_SIZE, 16 + 2 + 8 + 8 + 4 + 1);
}
#[test]
fn roundtrip_preserves_all_fields() {
let msg = QuicMessage {
device_id: uuid::Uuid::from_u128(0x0123456789abcdef_fedcba9876543210),
data_stream_id: 0xBEEF,
sensor_id: 0xBEEF,
raw_value: -273.15,
timestamp_us: 1_700_000_000_000_001,
sequence_number: 42,
sensor_type: SensorType::Temperature.as_u8(),
};
let bytes = msg.to_bytes();
assert_eq!(bytes.len(), QuicMessage::WIRE_SIZE);
let decoded = QuicMessage::decode(&bytes).unwrap();
assert_eq!(msg, decoded);
assert_eq!(decoded.typ(), SensorType::Temperature);
}
#[test]
fn decode_rejects_wrong_length() {
assert!(matches!(
QuicMessage::decode(&[0u8; 37]),
Err(WireError::BadLength { expected: 38, got: 37 })
QuicMessage::decode(&[0u8; 38]),
Err(WireError::BadLength { expected: 39, got: 38 })
));
assert!(matches!(
QuicMessage::decode(&[0u8; 39]),
Err(WireError::BadLength { expected: 38, got: 39 })
QuicMessage::decode(&[0u8; 40]),
Err(WireError::BadLength { expected: 39, got: 40 })
));
}
@@ -114,13 +291,22 @@ mod tests {
fn encode_layout_is_little_endian() {
let msg = QuicMessage {
device_id: uuid::Uuid::nil(),
data_stream_id: 0x0102,
sensor_id: 0x0102,
raw_value: 0.0,
timestamp_us: 0,
sequence_number: 0x04030201,
sensor_type: SensorType::Humidity.as_u8(),
};
let bytes = msg.to_bytes();
assert_eq!(&bytes[16..18], &[0x02, 0x01]);
assert_eq!(&bytes[34..38], &[0x01, 0x02, 0x03, 0x04]);
assert_eq!(bytes[38], SensorType::Humidity.as_u8());
}
#[test]
fn unknown_sensor_type_decodes_as_generic() {
assert_eq!(SensorType::from_u8(0), SensorType::Generic);
assert_eq!(SensorType::from_u8(99), SensorType::Generic);
assert_eq!(SensorType::from_u8(255), SensorType::Generic);
}
}