Initial working implementation (MVP)
This commit is contained in:
@@ -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"] }
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod signal;
|
||||
pub mod protocol;
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user