313 lines
9.5 KiB
Rust
313 lines
9.5 KiB
Rust
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<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),
|
|
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)]
|
|
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);
|
|
}
|
|
}
|