First test kinda working
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user