Initial working implementation (MVP)

This commit is contained in:
Martino Ferrari
2026-04-11 21:11:15 +02:00
parent dca378b5ef
commit 1a39b3a54e
35 changed files with 7457 additions and 147 deletions
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "rmon-common"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
[dependencies]
serde.workspace = true
bincode.workspace = true
chrono.workspace = true
thiserror.workspace = true
anyhow.workspace = true
tokio-util.workspace = true
bytes.workspace = true
[dev-dependencies]
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
+2
View File
@@ -0,0 +1,2 @@
pub mod signal;
pub mod protocol;
+210
View File
@@ -0,0 +1,210 @@
use serde::{Deserialize, Serialize};
use crate::signal::{SignalId, SignalInfo, Sample, StorageMode, SourceType};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ClientMessage {
Handshake {
protocol_version: u16,
client_version: String,
},
ListSignals,
Subscribe {
ids: Vec<SignalId>,
from_ns: Option<i64>,
},
Unsubscribe {
ids: Vec<SignalId>,
},
QueryHistory {
id: SignalId,
from_ns: i64,
to_ns: i64,
max_points: u32,
},
StartRecording {
ids: Vec<SignalId>,
mode: StorageMode,
},
StopRecording {
ids: Vec<SignalId>,
},
GetStatus,
GetConfig,
UpdateConfig {
config_toml: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum AgentMessage {
HandshakeAck {
protocol_version: u16,
agent_version: String,
accepted: bool,
reject_reason: Option<String>,
},
SignalList {
signals: Vec<SignalInfo>,
},
DataBatch {
batches: Vec<SignalBatch>,
},
HistoryChunk {
id: SignalId,
samples: Vec<Sample>,
done: bool,
},
RecordingStatus {
id: SignalId,
recording: bool,
mode: Option<StorageMode>,
},
StatusReport {
agent_version: String,
hostname: String,
uptime_secs: u64,
sources: Vec<SourceStatus>,
},
ConfigReport {
config_toml: String,
},
Error {
context: String,
message: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SignalBatch {
pub id: SignalId,
pub samples: Vec<Sample>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SourceStatus {
pub name: String,
pub source_type: SourceType,
pub connected: bool,
pub last_sample_ns: Option<i64>,
pub error: Option<String>,
}
pub const PROTOCOL_VERSION: u16 = 1;
use tokio_util::codec::{Decoder, Encoder};
use bytes::{BytesMut, Buf, BufMut};
use std::io;
pub struct BincodeCodec<T> {
_phantom: std::marker::PhantomData<T>,
}
impl<T> Default for BincodeCodec<T> {
fn default() -> Self {
Self { _phantom: std::marker::PhantomData }
}
}
impl<T> Decoder for BincodeCodec<T>
where
for<'a> T: Deserialize<'a>,
{
type Item = T;
type Error = io::Error;
fn decode(&mut self, src: &mut BytesMut) -> Result<Option<Self::Item>, Self::Error> {
if src.len() < 4 {
return Ok(None);
}
let mut length_bytes = [0u8; 4];
length_bytes.copy_from_slice(&src[..4]);
let length = u32::from_le_bytes(length_bytes) as usize;
if length > 16 * 1024 * 1024 {
return Err(io::Error::new(io::ErrorKind::InvalidData, "Frame too large"));
}
if src.len() < 4 + length {
src.reserve(4 + length - src.len());
return Ok(None);
}
src.advance(4);
let payload = src.split_to(length);
let item = bincode::deserialize(&payload)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
Ok(Some(item))
}
}
impl<T> Encoder<T> for BincodeCodec<T>
where
T: Serialize,
{
type Error = io::Error;
fn encode(&mut self, item: T, dst: &mut BytesMut) -> Result<(), Self::Error> {
let payload = bincode::serialize(&item)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
let length = payload.len() as u32;
dst.reserve(4 + payload.len());
dst.put_u32_le(length);
dst.put_slice(&payload);
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use bytes::BytesMut;
use tokio_util::codec::{Encoder, Decoder};
#[test]
fn test_codec_handshake_roundtrip() {
let mut codec = BincodeCodec::<ClientMessage>::default();
let msg = ClientMessage::Handshake {
protocol_version: PROTOCOL_VERSION,
client_version: "1.0.0".to_string(),
};
let mut buf = BytesMut::new();
codec.encode(msg.clone(), &mut buf).unwrap();
let mut decoder = BincodeCodec::<ClientMessage>::default();
let decoded = decoder.decode(&mut buf).unwrap().unwrap();
if let (ClientMessage::Handshake { protocol_version: v1, .. },
ClientMessage::Handshake { protocol_version: v2, .. }) = (msg, decoded) {
assert_eq!(v1, v2);
} else {
panic!("Decoded message mismatch");
}
}
#[test]
fn test_codec_frame_limit() {
let mut buf = BytesMut::new();
buf.put_u32_le(20 * 1024 * 1024); // Exceeds 16MB limit
buf.put_bytes(0, 10);
let mut decoder = BincodeCodec::<ClientMessage>::default();
let res = decoder.decode(&mut buf);
assert!(res.is_err());
}
#[test]
fn test_all_message_variants() {
// Just ensure they can be serialized
let m1 = ClientMessage::ListSignals;
let m2 = ClientMessage::GetStatus;
let m3 = ClientMessage::GetConfig;
let _ = bincode::serialize(&m1).unwrap();
let _ = bincode::serialize(&m2).unwrap();
let _ = bincode::serialize(&m3).unwrap();
}
}
+66
View File
@@ -0,0 +1,66 @@
use serde::{Deserialize, Serialize};
use std::time::Duration;
pub type SignalId = String;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SignalInfo {
pub id: SignalId,
pub label: String,
pub unit: String,
pub scale: f64,
pub offset: f64,
pub sampling_period: Option<Duration>,
pub source_type: SourceType,
pub path: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum SourceType {
Csv,
Udp,
Epics,
Custom(String),
}
#[derive(Debug, Copy, Clone, Serialize, Deserialize, PartialEq)]
pub struct Sample {
/// Nanoseconds since Unix epoch (UTC).
pub timestamp: i64,
pub value: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum StorageMode {
Continuous,
Circular { window: Duration },
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sample_equality() {
let s1 = Sample { timestamp: 100, value: 42.0 };
let s2 = Sample { timestamp: 100, value: 42.0 };
let s3 = Sample { timestamp: 101, value: 42.0 };
assert_eq!(s1, s2);
assert_ne!(s1, s3);
}
#[test]
fn test_signal_info_creation() {
let info = SignalInfo {
id: "test://sig".to_string(),
label: "Test".to_string(),
unit: "V".to_string(),
scale: 1.0,
offset: 0.0,
sampling_period: None,
source_type: SourceType::Csv,
path: vec!["a".to_string()],
};
assert_eq!(info.id, "test://sig");
}
}