pub mod ecs; pub mod server; pub mod state; 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`. /// /// 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 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 sensor_id: u16, pub raw_value: f64, pub timestamp_us: u64, pub sequence_number: u32, pub sensor_type: u8, } #[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 = 39; 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.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(()) } 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 { 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), 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, } impl T1Sender { pub fn new(inner: mpsc::Sender) -> 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, } impl T2Sender { pub fn new(inner: mpsc::Sender) -> Self { Self { inner } } pub async fn send( &self, msg: QuicMessage, ) -> Result<(), mpsc::error::SendError> { 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, } #[derive(Clone)] pub struct T3Sender { inner: mpsc::Sender, } impl T3Sender { pub fn new(inner: mpsc::Sender) -> Self { Self { inner } } pub async fn send( &self, inbound: T3Inbound, ) -> Result<(), mpsc::error::SendError> { 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)] mod tests { use super::*; #[test] fn wire_size_matches_fields() { 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), 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; 38]), Err(WireError::BadLength { expected: 39, got: 38 }) )); assert!(matches!( QuicMessage::decode(&[0u8; 40]), Err(WireError::BadLength { expected: 39, got: 40 }) )); } #[test] fn encode_layout_is_little_endian() { let msg = QuicMessage { device_id: uuid::Uuid::nil(), 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); } }