Initial working implementation (MVP)
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
[package]
|
||||
name = "rmon-agent"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
tokio.workspace = true
|
||||
tokio-util.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
serde.workspace = true
|
||||
chrono.workspace = true
|
||||
toml.workspace = true
|
||||
bincode.workspace = true
|
||||
regex.workspace = true
|
||||
notify.workspace = true
|
||||
clap.workspace = true
|
||||
futures.workspace = true
|
||||
bytes.workspace = true
|
||||
itertools.workspace = true
|
||||
byteorder.workspace = true
|
||||
async-trait.workspace = true
|
||||
rmon-common.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
futures.workspace = true
|
||||
toml.workspace = true
|
||||
anyhow.workspace = true
|
||||
tempfile.workspace = true
|
||||
|
||||
[profile.release]
|
||||
strip = true
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
@@ -0,0 +1,86 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
pub struct Config {
|
||||
pub storage: StorageConfig,
|
||||
pub sources: Vec<SourceConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
pub struct StorageConfig {
|
||||
pub data_dir: PathBuf,
|
||||
pub default_mode: String, // "continuous" or "circular"
|
||||
pub default_window: String, // e.g. "2h"
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
pub struct SourceConfig {
|
||||
#[serde(rename = "type")]
|
||||
pub source_type: String,
|
||||
pub name: String,
|
||||
pub bind: Option<String>,
|
||||
pub path: Option<PathBuf>,
|
||||
pub addr: Option<String>,
|
||||
pub command: Option<String>,
|
||||
pub poll_interval: Option<String>,
|
||||
pub timestamp: TimestampConfig,
|
||||
pub signals: Vec<SignalSourceConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
pub struct TimestampConfig {
|
||||
pub byte_offset: Option<usize>,
|
||||
pub byte_type: Option<String>,
|
||||
pub column: Option<String>,
|
||||
pub format: String,
|
||||
pub regex: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
pub struct SignalSourceConfig {
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
pub unit: String,
|
||||
pub scale: f64,
|
||||
pub offset: f64,
|
||||
pub byte_offset: Option<usize>,
|
||||
pub byte_type: Option<String>,
|
||||
pub column: Option<String>,
|
||||
pub pv: Option<String>,
|
||||
pub regex: Option<String>,
|
||||
pub path: Vec<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_config_parsing() {
|
||||
let toml_str = r#"
|
||||
[storage]
|
||||
data_dir = "/tmp/data"
|
||||
default_mode = "circular"
|
||||
default_window = "1h"
|
||||
|
||||
[[sources]]
|
||||
type = "udp"
|
||||
name = "test"
|
||||
bind = "127.0.0.1:9000"
|
||||
[sources.timestamp]
|
||||
format = "none"
|
||||
[[sources.signals]]
|
||||
id = "s1"
|
||||
label = "L1"
|
||||
unit = "V"
|
||||
scale = 1.0
|
||||
offset = 0.0
|
||||
path = ["p1"]
|
||||
"#;
|
||||
let config: Config = toml::from_str(toml_str).unwrap();
|
||||
assert_eq!(config.storage.data_dir.to_str().unwrap(), "/tmp/data");
|
||||
assert_eq!(config.sources.len(), 1);
|
||||
assert_eq!(config.sources[0].source_type, "udp");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
pub mod config;
|
||||
pub mod sources;
|
||||
pub mod storage;
|
||||
pub mod server;
|
||||
|
||||
use anyhow::Result;
|
||||
use std::sync::Arc;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::{broadcast, mpsc, RwLock};
|
||||
use std::collections::HashMap;
|
||||
use crate::config::Config;
|
||||
use crate::sources::{DataSource, UdpSource, CsvSource, ShellSource};
|
||||
use crate::storage::CircularBuffer;
|
||||
use crate::server::SharedState;
|
||||
use std::time::Duration;
|
||||
use rmon_common::signal::{SignalId, SignalInfo, Sample};
|
||||
use std::path::PathBuf;
|
||||
use notify::{Watcher, RecursiveMode, Config as NotifyConfig};
|
||||
use tokio::time::sleep;
|
||||
|
||||
pub async fn run_agent(initial_config: Config, addr: &str, config_path: PathBuf) -> Result<()> {
|
||||
tracing::info!("Starting RMon Agent core on {}...", addr);
|
||||
|
||||
let (reload_tx, _reload_rx) = broadcast::channel::<()>(10);
|
||||
let (tx, _rx) = broadcast::channel(10000);
|
||||
let (sample_tx, mut sample_rx) = mpsc::channel::<(SignalId, Sample)>(1000);
|
||||
|
||||
let signals_lock = Arc::new(RwLock::new(HashMap::<SignalId, SignalInfo>::new()));
|
||||
let storage_lock = Arc::new(RwLock::new(HashMap::<SignalId, Arc<CircularBuffer>>::new()));
|
||||
|
||||
let shared_state = Arc::new(SharedState {
|
||||
signals: signals_lock.clone(),
|
||||
storage: storage_lock.clone(),
|
||||
tx: tx.clone(),
|
||||
reload_tx: reload_tx.clone(),
|
||||
config_path: config_path.clone(),
|
||||
});
|
||||
|
||||
let config_path_clone = config_path.clone();
|
||||
let reload_tx_clone = reload_tx.clone();
|
||||
|
||||
// File watcher for hot-reload
|
||||
std::thread::spawn(move || {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
if let Ok(mut watcher) = notify::RecommendedWatcher::new(tx, NotifyConfig::default()) {
|
||||
let _ = watcher.watch(&config_path_clone, RecursiveMode::NonRecursive);
|
||||
for res in rx {
|
||||
if let Ok(_) = res {
|
||||
tracing::info!("File watcher detected config change");
|
||||
let _ = reload_tx_clone.send(());
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Start dispatcher task
|
||||
let storage_dispatch = storage_lock.clone();
|
||||
let tx_dispatch = tx.clone();
|
||||
let data_dir = initial_config.storage.data_dir.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut continuous_writers: HashMap<SignalId, crate::storage::ContinuousStorage> = HashMap::new();
|
||||
// Move session_start to be determined per signal or source if needed,
|
||||
// but keep a process-level one as a default for consistent grouping.
|
||||
let session_start = chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0);
|
||||
|
||||
while let Some((id, sample)) = sample_rx.recv().await {
|
||||
let buf = {
|
||||
let storage = storage_dispatch.read().await;
|
||||
storage.get(&id).cloned()
|
||||
};
|
||||
|
||||
if let Some(buf) = buf {
|
||||
buf.push(sample).await;
|
||||
}
|
||||
|
||||
if !continuous_writers.contains_key(&id) {
|
||||
let source_name = id.split("://").nth(1).and_then(|s| s.split('/').next()).unwrap_or("unknown");
|
||||
if let Ok(writer) = crate::storage::ContinuousStorage::create(
|
||||
data_dir.clone(),
|
||||
source_name,
|
||||
&id,
|
||||
session_start
|
||||
) {
|
||||
continuous_writers.insert(id.clone(), writer);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(writer) = continuous_writers.get_mut(&id) {
|
||||
let _ = writer.append(sample);
|
||||
let _ = writer.flush();
|
||||
}
|
||||
|
||||
let _ = tx_dispatch.send((id, sample));
|
||||
}
|
||||
});
|
||||
|
||||
// Start TCP server
|
||||
let server_state = shared_state.clone();
|
||||
let server_addr = addr.to_string();
|
||||
tokio::spawn(async move {
|
||||
match TcpListener::bind(&server_addr).await {
|
||||
Ok(listener) => {
|
||||
tracing::info!("Server listening on {}", server_addr);
|
||||
loop {
|
||||
if let Ok((stream, _)) = listener.accept().await {
|
||||
let state = server_state.clone();
|
||||
tokio::spawn(async move {
|
||||
let _ = server::handle_session(stream, state).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::error!("Failed to bind server: {}", e),
|
||||
}
|
||||
});
|
||||
|
||||
let mut current_config = initial_config;
|
||||
let mut reload_rx = reload_tx.subscribe();
|
||||
|
||||
loop {
|
||||
tracing::info!("Initializing data sources...");
|
||||
let mut new_signals = HashMap::new();
|
||||
let mut new_storage = HashMap::new();
|
||||
let mut sources: Vec<Box<dyn DataSource>> = Vec::new();
|
||||
|
||||
for source_config in ¤t_config.sources {
|
||||
let source: Box<dyn DataSource> = match source_config.source_type.as_str() {
|
||||
"udp" => Box::new(UdpSource::new(source_config.clone())),
|
||||
"csv" => Box::new(CsvSource::new(source_config.clone())),
|
||||
"shell" => Box::new(ShellSource::new(source_config.clone())),
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
for sig in source.signals() {
|
||||
new_signals.insert(sig.id.clone(), sig.clone());
|
||||
new_storage.insert(sig.id.clone(), Arc::new(CircularBuffer::new(Duration::from_secs(3600))));
|
||||
}
|
||||
sources.push(source);
|
||||
}
|
||||
|
||||
tracing::info!("Updating shared signal map ({} signals)", new_signals.len());
|
||||
{
|
||||
let mut sigs = signals_lock.write().await;
|
||||
*sigs = new_signals;
|
||||
let mut stors = storage_lock.write().await;
|
||||
*stors = new_storage;
|
||||
}
|
||||
|
||||
let (stop_tx, _) = broadcast::channel::<()>(1);
|
||||
|
||||
for source in sources {
|
||||
let s_tx = sample_tx.clone();
|
||||
let mut s_stop = stop_tx.subscribe();
|
||||
tokio::spawn(async move {
|
||||
tokio::select! {
|
||||
res = source.run(s_tx) => {
|
||||
if let Err(e) = res {
|
||||
tracing::error!("Source failed: {}", e);
|
||||
}
|
||||
}
|
||||
_ = s_stop.recv() => {
|
||||
tracing::debug!("Source stopped by reload");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let _ = reload_rx.recv().await;
|
||||
tracing::info!("Reload signal received, refreshing config...");
|
||||
drop(stop_tx); // Stop sources
|
||||
|
||||
sleep(Duration::from_millis(200)).await;
|
||||
if let Ok(content) = std::fs::read_to_string(&config_path) {
|
||||
match toml::from_str::<Config>(&content) {
|
||||
Ok(new_cfg) => {
|
||||
tracing::info!("Config reloaded successfully");
|
||||
current_config = new_cfg;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to parse updated config: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::StorageConfig;
|
||||
|
||||
#[test]
|
||||
fn test_agent_config_struct() {
|
||||
let c = Config {
|
||||
storage: StorageConfig {
|
||||
data_dir: PathBuf::from("/tmp"),
|
||||
default_mode: "c".to_string(),
|
||||
default_window: "1".to_string(),
|
||||
},
|
||||
sources: vec![],
|
||||
};
|
||||
assert_eq!(c.sources.len(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_dispatcher_routing() {
|
||||
let (tx, mut rx) = broadcast::channel::<(SignalId, Sample)>(10);
|
||||
let (_stx, _srx) = mpsc::channel::<(SignalId, Sample)>(10);
|
||||
let msg = ("s1".to_string(), Sample { timestamp: 1, value: 1.0 });
|
||||
tx.send(msg.clone()).unwrap();
|
||||
let received = rx.recv().await.unwrap();
|
||||
assert_eq!(received.0, "s1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_source_factory_logic() {
|
||||
let t = "udp";
|
||||
assert_eq!(t, "udp");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
use anyhow::Result;
|
||||
use rmon_agent::config::Config;
|
||||
use rmon_agent::run_agent;
|
||||
use std::path::PathBuf;
|
||||
use std::fs;
|
||||
use clap::Parser;
|
||||
use tracing_subscriber::prelude::*;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(author, version, about, long_about = None)]
|
||||
struct Args {
|
||||
/// Bind address
|
||||
#[arg(short, long, default_value = "127.0.0.1")]
|
||||
bind: String,
|
||||
|
||||
/// Port to listen on
|
||||
#[arg(short, long, default_value_t = 7891)]
|
||||
port: u16,
|
||||
|
||||
/// Path to config file
|
||||
#[arg(short, long)]
|
||||
config: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
|
||||
let rmon_dir = PathBuf::from(&home).join(".rmon");
|
||||
let _ = fs::create_dir_all(&rmon_dir);
|
||||
|
||||
let log_path = rmon_dir.join("agent.log");
|
||||
|
||||
// Open in append mode
|
||||
let file = fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&log_path)?;
|
||||
|
||||
tracing_subscriber::registry()
|
||||
.with(tracing_subscriber::EnvFilter::from_default_env().add_directive(tracing::Level::INFO.into()))
|
||||
.with(tracing_subscriber::fmt::layer().with_writer(std::io::stderr))
|
||||
.with(tracing_subscriber::fmt::layer().with_writer(file).with_ansi(false))
|
||||
.init();
|
||||
|
||||
tracing::info!("--- RMon Agent Starting ---");
|
||||
tracing::info!("Log file: {:?}", log_path);
|
||||
|
||||
let args = Args::parse();
|
||||
let config_path = args.config.unwrap_or_else(|| rmon_dir.join("config.toml"));
|
||||
|
||||
let config: Config = if config_path.exists() {
|
||||
tracing::info!("Loading config from {:?}", config_path);
|
||||
let content = fs::read_to_string(&config_path)?;
|
||||
toml::from_str(&content)?
|
||||
} else {
|
||||
tracing::info!("Using default configuration");
|
||||
let default_config = r#"
|
||||
[storage]
|
||||
data_dir = "/tmp/rmon-data"
|
||||
default_mode = "circular"
|
||||
default_window = "1h"
|
||||
|
||||
[[sources]]
|
||||
type = "shell"
|
||||
name = "system"
|
||||
command = "echo \"cpu_temp: $(cat /sys/class/thermal/thermal_zone0/temp 2>/dev/null | awk '{print $1/1000}' || echo 0)\""
|
||||
poll_interval = "1s"
|
||||
[sources.timestamp]
|
||||
format = "none"
|
||||
[[sources.signals]]
|
||||
id = "cpu_temp"
|
||||
label = "CPU Temperature"
|
||||
unit = "C"
|
||||
scale = 1.0
|
||||
offset = 0.0
|
||||
regex = "cpu_temp: ([0-9.]+)"
|
||||
path = ["system", "thermal"]
|
||||
"#;
|
||||
if let Some(parent) = config_path.parent() {
|
||||
let _ = fs::create_dir_all(parent);
|
||||
}
|
||||
let _ = fs::write(&config_path, default_config);
|
||||
toml::from_str(default_config)?
|
||||
};
|
||||
|
||||
let addr = format!("{}:{}", args.bind, args.port);
|
||||
tracing::info!("Binding to {}", addr);
|
||||
run_agent(config, &addr, config_path).await
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod session;
|
||||
pub use self::session::{handle_session, SharedState};
|
||||
@@ -0,0 +1,250 @@
|
||||
use anyhow::Result;
|
||||
use tokio::net::TcpStream;
|
||||
use tokio_util::codec::Framed;
|
||||
use futures::{StreamExt, SinkExt};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{broadcast, RwLock};
|
||||
use rmon_common::protocol::{ClientMessage, AgentMessage, BincodeCodec, PROTOCOL_VERSION, SignalBatch};
|
||||
use rmon_common::signal::{SignalId, SignalInfo, Sample};
|
||||
use crate::storage::CircularBuffer;
|
||||
use std::collections::HashMap;
|
||||
use bytes::BytesMut;
|
||||
use tokio_util::codec::{Decoder, Encoder};
|
||||
use std::io;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// A custom codec that decodes ClientMessage and encodes AgentMessage
|
||||
pub struct AgentCodec;
|
||||
|
||||
impl Decoder for AgentCodec {
|
||||
type Item = ClientMessage;
|
||||
type Error = io::Error;
|
||||
|
||||
fn decode(&mut self, src: &mut BytesMut) -> Result<Option<Self::Item>, Self::Error> {
|
||||
let mut codec = BincodeCodec::<ClientMessage>::default();
|
||||
codec.decode(src)
|
||||
}
|
||||
}
|
||||
|
||||
impl Encoder<AgentMessage> for AgentCodec {
|
||||
type Error = io::Error;
|
||||
|
||||
fn encode(&mut self, item: AgentMessage, dst: &mut BytesMut) -> Result<(), Self::Error> {
|
||||
let mut codec = BincodeCodec::<AgentMessage>::default();
|
||||
codec.encode(item, dst)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SharedState {
|
||||
pub signals: Arc<RwLock<HashMap<SignalId, SignalInfo>>>,
|
||||
pub storage: Arc<RwLock<HashMap<SignalId, Arc<CircularBuffer>>>>,
|
||||
pub tx: broadcast::Sender<(SignalId, Sample)>,
|
||||
pub reload_tx: broadcast::Sender<()>,
|
||||
pub config_path: PathBuf,
|
||||
}
|
||||
|
||||
pub async fn handle_session(
|
||||
stream: TcpStream,
|
||||
state: Arc<SharedState>,
|
||||
) -> Result<()> {
|
||||
let addr = stream.peer_addr().map(|a| a.to_string()).unwrap_or_else(|_| "unknown".to_string());
|
||||
tracing::info!("[{}] Session established", addr);
|
||||
|
||||
let mut framed = Framed::new(stream, AgentCodec);
|
||||
|
||||
// 1. Handshake
|
||||
match framed.next().await {
|
||||
Some(Ok(ClientMessage::Handshake { protocol_version, client_version })) => {
|
||||
if protocol_version == PROTOCOL_VERSION {
|
||||
tracing::info!("[{}] Handshake ok (client: {})", addr, client_version);
|
||||
framed.send(AgentMessage::HandshakeAck {
|
||||
protocol_version: PROTOCOL_VERSION,
|
||||
agent_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
accepted: true,
|
||||
reject_reason: None,
|
||||
}).await?;
|
||||
} else {
|
||||
tracing::warn!("[{}] Handshake failed: version mismatch", addr);
|
||||
let _ = framed.send(AgentMessage::HandshakeAck {
|
||||
protocol_version: PROTOCOL_VERSION,
|
||||
agent_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
accepted: false,
|
||||
reject_reason: Some("Version mismatch".to_string()),
|
||||
}).await;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Some(Ok(msg)) => {
|
||||
tracing::warn!("[{}] Expected handshake, got {:?}", addr, msg);
|
||||
return Ok(());
|
||||
}
|
||||
_ => {
|
||||
tracing::warn!("[{}] Handshake failed or connection closed", addr);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let mut rx = state.tx.subscribe();
|
||||
let mut subscriptions = Vec::<SignalId>::new();
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
msg_res = framed.next() => {
|
||||
match msg_res {
|
||||
Some(Ok(msg)) => {
|
||||
match msg {
|
||||
ClientMessage::ListSignals => {
|
||||
let signals: Vec<SignalInfo> = {
|
||||
let sigs = state.signals.read().await;
|
||||
sigs.values().cloned().collect()
|
||||
};
|
||||
tracing::info!("[{}] Sending signal list ({} signals)", addr, signals.len());
|
||||
framed.send(AgentMessage::SignalList { signals }).await?;
|
||||
}
|
||||
ClientMessage::GetConfig => {
|
||||
tracing::info!("[{}] Fetching config...", addr);
|
||||
match std::fs::read_to_string(&state.config_path) {
|
||||
Ok(config_toml) => {
|
||||
framed.send(AgentMessage::ConfigReport { config_toml }).await?;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("[{}] Config read failed: {}", addr, e);
|
||||
framed.send(AgentMessage::Error { context: "GetConfig".to_string(), message: e.to_string() }).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
ClientMessage::UpdateConfig { config_toml } => {
|
||||
tracing::info!("[{}] Updating config...", addr);
|
||||
if let Err(e) = std::fs::write(&state.config_path, &config_toml) {
|
||||
tracing::error!("[{}] Config write failed: {}", addr, e);
|
||||
framed.send(AgentMessage::Error { context: "UpdateConfig".to_string(), message: e.to_string() }).await?;
|
||||
} else {
|
||||
let _ = state.reload_tx.send(());
|
||||
}
|
||||
}
|
||||
ClientMessage::Subscribe { ids, .. } => {
|
||||
tracing::info!("[{}] Subscribing to {:?}", addr, ids);
|
||||
subscriptions.extend(ids);
|
||||
}
|
||||
ClientMessage::Unsubscribe { ids } => {
|
||||
tracing::info!("[{}] Unsubscribing from {:?}", addr, ids);
|
||||
subscriptions.retain(|id| !ids.contains(id));
|
||||
}
|
||||
_ => {
|
||||
tracing::debug!("[{}] Unhandled client message: {:?}", addr, msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
tracing::error!("[{}] Protocol error: {}", addr, e);
|
||||
break;
|
||||
}
|
||||
None => {
|
||||
tracing::info!("[{}] Connection closed by client", addr);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
sample_res = rx.recv() => {
|
||||
if let Ok((id, sample)) = sample_res {
|
||||
if subscriptions.contains(&id) {
|
||||
if let Err(e) = framed.send(AgentMessage::DataBatch {
|
||||
batches: vec![SignalBatch { id, samples: vec![sample] }]
|
||||
}).await {
|
||||
tracing::error!("[{}] Failed to send data: {}", addr, e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!("[{}] Session terminated", addr);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tokio::sync::broadcast;
|
||||
use rmon_common::protocol::PROTOCOL_VERSION;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_shared_state_init() {
|
||||
let (tx, _) = broadcast::channel(10);
|
||||
let (rtx, _) = broadcast::channel(1);
|
||||
let dir = tempdir().unwrap();
|
||||
let config_path = dir.path().join("config.toml");
|
||||
|
||||
let state = SharedState {
|
||||
signals: Arc::new(RwLock::new(HashMap::new())),
|
||||
storage: Arc::new(RwLock::new(HashMap::new())),
|
||||
tx,
|
||||
reload_tx: rtx,
|
||||
config_path,
|
||||
};
|
||||
|
||||
assert!(state.signals.read().await.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_codec_defaults() {
|
||||
let _ = AgentCodec;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_subscription_list() {
|
||||
let mut subs = Vec::<SignalId>::new();
|
||||
subs.push("s1".to_string());
|
||||
subs.push("s2".to_string());
|
||||
assert!(subs.contains(&"s1".to_string()));
|
||||
subs.retain(|id| id != "s1");
|
||||
assert!(!subs.contains(&"s1".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_shared_state_fields() {
|
||||
let (tx, _) = broadcast::channel(1);
|
||||
let (rtx, _) = broadcast::channel(1);
|
||||
let s = SharedState {
|
||||
signals: Arc::new(RwLock::new(HashMap::new())),
|
||||
storage: Arc::new(RwLock::new(HashMap::new())),
|
||||
tx, reload_tx: rtx, config_path: PathBuf::from("."),
|
||||
};
|
||||
assert!(s.config_path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_agent_message_variants() {
|
||||
let m1 = AgentMessage::HandshakeAck {
|
||||
protocol_version: PROTOCOL_VERSION,
|
||||
agent_version: "1".to_string(),
|
||||
accepted: true,
|
||||
reject_reason: None,
|
||||
};
|
||||
let _ = bincode::serialize(&m1).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_status_report_serialization() {
|
||||
use rmon_common::protocol::SourceStatus;
|
||||
use rmon_common::signal::SourceType;
|
||||
let report = AgentMessage::StatusReport {
|
||||
agent_version: "1".to_string(),
|
||||
hostname: "h".to_string(),
|
||||
uptime_secs: 10,
|
||||
sources: vec![SourceStatus {
|
||||
name: "s".to_string(),
|
||||
source_type: SourceType::Udp,
|
||||
connected: true,
|
||||
last_sample_ns: None,
|
||||
error: None,
|
||||
}],
|
||||
};
|
||||
let _ = bincode::serialize(&report).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
use anyhow::Result;
|
||||
use rmon_common::signal::{SignalId, SignalInfo, Sample, SourceType};
|
||||
use tokio::sync::mpsc;
|
||||
use async_trait::async_trait;
|
||||
use crate::sources::datasource::DataSource;
|
||||
use crate::config::SourceConfig;
|
||||
use std::time::Duration;
|
||||
use tokio::time::sleep;
|
||||
use std::fs::File;
|
||||
use std::io::{BufRead, BufReader, Seek, SeekFrom};
|
||||
use chrono::{DateTime, Utc, NaiveDateTime};
|
||||
|
||||
pub struct CsvSource {
|
||||
name: String,
|
||||
signals: Vec<SignalInfo>,
|
||||
config: SourceConfig,
|
||||
}
|
||||
|
||||
impl CsvSource {
|
||||
pub fn new(config: SourceConfig) -> Self {
|
||||
let signals = config.signals.iter().map(|s| {
|
||||
SignalInfo {
|
||||
id: format!("csv://{}/{}", config.name, s.id),
|
||||
label: s.label.clone(),
|
||||
unit: s.unit.clone(),
|
||||
scale: s.scale,
|
||||
offset: s.offset,
|
||||
sampling_period: None,
|
||||
source_type: SourceType::Csv,
|
||||
path: s.path.clone(),
|
||||
}
|
||||
}).collect();
|
||||
|
||||
Self {
|
||||
name: config.name.clone(),
|
||||
signals,
|
||||
config,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_timestamp(&self, value: &str) -> i64 {
|
||||
let value = value.trim();
|
||||
match self.config.timestamp.format.as_str() {
|
||||
"nanoseconds_since_epoch" => value.parse::<i64>().unwrap_or(0),
|
||||
"milliseconds_since_epoch" => value.parse::<i64>().unwrap_or(0) * 1_000_000,
|
||||
"seconds_since_epoch" => value.parse::<f64>().map(|v| (v * 1e9) as i64).unwrap_or(0),
|
||||
"none" => Utc::now().timestamp_nanos_opt().unwrap_or(0),
|
||||
format_str => {
|
||||
if let Ok(naive) = NaiveDateTime::parse_from_str(value, format_str) {
|
||||
naive.and_utc().timestamp_nanos_opt().unwrap_or(0)
|
||||
} else if let Ok(dt) = DateTime::parse_from_rfc3339(value) {
|
||||
dt.timestamp_nanos_opt().unwrap_or(0)
|
||||
} else {
|
||||
Utc::now().timestamp_nanos_opt().unwrap_or(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl DataSource for CsvSource {
|
||||
fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
fn signals(&self) -> Vec<SignalInfo> {
|
||||
self.signals.clone()
|
||||
}
|
||||
|
||||
async fn run(self: Box<Self>, tx: mpsc::Sender<(SignalId, Sample)>) -> Result<()> {
|
||||
let path = self.config.path.clone().ok_or_else(|| anyhow::anyhow!("CSV path missing"))?;
|
||||
let poll_interval = if let Some(ref interval_str) = self.config.poll_interval {
|
||||
if interval_str.ends_with("ms") {
|
||||
Duration::from_millis(interval_str.trim_end_matches("ms").parse().unwrap_or(100))
|
||||
} else {
|
||||
Duration::from_secs(interval_str.trim_end_matches("s").parse().unwrap_or(1))
|
||||
}
|
||||
} else {
|
||||
Duration::from_millis(500)
|
||||
};
|
||||
|
||||
let mut pos = 0u64;
|
||||
|
||||
loop {
|
||||
if let Ok(mut file) = File::open(&path) {
|
||||
let len = file.metadata()?.len();
|
||||
|
||||
if len < pos {
|
||||
tracing::info!("CSV file truncated or rotated, resetting position");
|
||||
pos = 0;
|
||||
}
|
||||
|
||||
if len > pos {
|
||||
file.seek(SeekFrom::Start(pos))?;
|
||||
let mut reader = BufReader::new(file);
|
||||
let mut line = String::new();
|
||||
|
||||
while reader.read_line(&mut line)? > 0 {
|
||||
let bytes_read = line.len() as u64;
|
||||
let trimmed = line.trim();
|
||||
|
||||
if !trimmed.is_empty() {
|
||||
// If we are at start of file, skip the header
|
||||
if pos == 0 {
|
||||
pos += bytes_read;
|
||||
line.clear();
|
||||
continue;
|
||||
}
|
||||
|
||||
let parts: Vec<&str> = trimmed.split(',').collect();
|
||||
|
||||
// 1. Get timestamp
|
||||
let timestamp = if let Some(col_idx) = self.config.timestamp.column.as_ref().and_then(|c| c.parse::<usize>().ok()) {
|
||||
if let Some(val) = parts.get(col_idx) {
|
||||
self.parse_timestamp(val)
|
||||
} else {
|
||||
Utc::now().timestamp_nanos_opt().unwrap_or(0)
|
||||
}
|
||||
} else {
|
||||
Utc::now().timestamp_nanos_opt().unwrap_or(0)
|
||||
};
|
||||
|
||||
// 2. Map signals
|
||||
for signal_config in &self.config.signals {
|
||||
if let Some(col_idx) = signal_config.column.as_ref().and_then(|c| c.parse::<usize>().ok()) {
|
||||
if let Some(raw_str) = parts.get(col_idx) {
|
||||
if let Ok(raw_val) = raw_str.trim().parse::<f64>() {
|
||||
let value = raw_val * signal_config.scale + signal_config.offset;
|
||||
let id = format!("csv://{}/{}", self.name, signal_config.id);
|
||||
let _ = tx.send((id, Sample { timestamp, value })).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
pos += bytes_read;
|
||||
line.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
sleep(poll_interval).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::{SourceConfig, TimestampConfig, SignalSourceConfig};
|
||||
use std::io::Write;
|
||||
use tempfile::tempdir;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn test_csv_metadata() {
|
||||
let config = SourceConfig {
|
||||
source_type: "csv".to_string(),
|
||||
name: "tc".to_string(),
|
||||
bind: None, path: Some(PathBuf::from("test.csv")), addr: None, command: None, poll_interval: None,
|
||||
timestamp: TimestampConfig { byte_offset: None, byte_type: None, column: None, format: "none".to_string(), regex: None },
|
||||
signals: vec![SignalSourceConfig {
|
||||
id: "s1".to_string(), label: "L1".to_string(), unit: "V".to_string(), scale: 1.0, offset: 0.0,
|
||||
byte_offset: None, byte_type: None, column: None, pv: None, regex: None, path: vec![],
|
||||
}],
|
||||
};
|
||||
let source = CsvSource::new(config);
|
||||
assert_eq!(source.name(), "tc");
|
||||
assert_eq!(source.signals()[0].id, "csv://tc/s1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_csv_batch_parsing() {
|
||||
let dir = tempdir().unwrap();
|
||||
let file_path = dir.path().join("test_batch.csv");
|
||||
let mut file = std::fs::File::create(&file_path).unwrap();
|
||||
|
||||
// Write header and two lines immediately
|
||||
writeln!(file, "time,val1").unwrap();
|
||||
writeln!(file, "100,10.0").unwrap();
|
||||
writeln!(file, "200,20.0").unwrap();
|
||||
file.sync_all().unwrap();
|
||||
|
||||
let config = SourceConfig {
|
||||
source_type: "csv".to_string(),
|
||||
name: "test_batch".to_string(),
|
||||
bind: None,
|
||||
path: Some(file_path),
|
||||
addr: None,
|
||||
command: None,
|
||||
poll_interval: Some("10ms".to_string()),
|
||||
timestamp: TimestampConfig {
|
||||
byte_offset: None, byte_type: None,
|
||||
column: Some("0".to_string()),
|
||||
format: "nanoseconds_since_epoch".to_string(),
|
||||
regex: None
|
||||
},
|
||||
signals: vec![
|
||||
SignalSourceConfig {
|
||||
id: "s1".to_string(), label: "L1".to_string(), unit: "V".to_string(), scale: 1.0, offset: 0.0,
|
||||
byte_offset: None, byte_type: None, column: Some("1".to_string()), pv: None, regex: None, path: vec![],
|
||||
}
|
||||
],
|
||||
};
|
||||
|
||||
let source = Box::new(CsvSource::new(config));
|
||||
let (tx, mut rx) = mpsc::channel(10);
|
||||
tokio::spawn(async move { let _ = source.run(tx).await; });
|
||||
|
||||
// Should receive BOTH lines from initial load
|
||||
let msg1 = tokio::time::timeout(Duration::from_secs(1), rx.recv()).await.unwrap().unwrap();
|
||||
assert_eq!(msg1.1.value, 10.0);
|
||||
|
||||
let msg2 = tokio::time::timeout(Duration::from_secs(1), rx.recv()).await.unwrap().unwrap();
|
||||
assert_eq!(msg2.1.value, 20.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
use anyhow::Result;
|
||||
use rmon_common::signal::{SignalId, SignalInfo, Sample};
|
||||
use tokio::sync::mpsc;
|
||||
use async_trait::async_trait;
|
||||
|
||||
#[async_trait]
|
||||
pub trait DataSource: Send + 'static {
|
||||
/// Human-readable name (from config `name` field).
|
||||
fn name(&self) -> &str;
|
||||
|
||||
/// Full list of signals this source provides.
|
||||
fn signals(&self) -> Vec<SignalInfo>;
|
||||
|
||||
/// Start producing samples. Sends to `tx` until the returned future resolves or `tx` is dropped.
|
||||
/// This runs in its own tokio task.
|
||||
async fn run(self: Box<Self>, tx: mpsc::Sender<(SignalId, Sample)>) -> Result<()>;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
pub mod datasource;
|
||||
pub mod udp;
|
||||
pub mod csv;
|
||||
pub mod shell;
|
||||
|
||||
pub use self::datasource::DataSource;
|
||||
pub use self::udp::UdpSource;
|
||||
pub use self::csv::CsvSource;
|
||||
pub use self::shell::ShellSource;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::config::{SourceConfig, TimestampConfig};
|
||||
|
||||
#[test]
|
||||
fn test_source_types() {
|
||||
let sc = SourceConfig {
|
||||
source_type: "csv".to_string(),
|
||||
name: "n".to_string(),
|
||||
bind: None, path: None, addr: None, command: None, poll_interval: None,
|
||||
timestamp: TimestampConfig { byte_offset: None, byte_type: None, column: None, format: "n".to_string(), regex: None },
|
||||
signals: vec![],
|
||||
};
|
||||
assert_eq!(sc.source_type, "csv");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
use anyhow::Result;
|
||||
use rmon_common::signal::{SignalId, SignalInfo, Sample, SourceType};
|
||||
use tokio::sync::mpsc;
|
||||
use async_trait::async_trait;
|
||||
use crate::sources::datasource::DataSource;
|
||||
use crate::config::SourceConfig;
|
||||
use std::time::Duration;
|
||||
use tokio::time::sleep;
|
||||
use tokio::process::Command;
|
||||
use regex::Regex;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
pub struct ShellSource {
|
||||
name: String,
|
||||
signals: Vec<SignalInfo>,
|
||||
config: SourceConfig,
|
||||
}
|
||||
|
||||
impl ShellSource {
|
||||
pub fn new(config: SourceConfig) -> Self {
|
||||
let signals = config.signals.iter().map(|s| {
|
||||
SignalInfo {
|
||||
id: format!("shell://{}/{}", config.name, s.id),
|
||||
label: s.label.clone(),
|
||||
unit: s.unit.clone(),
|
||||
scale: s.scale,
|
||||
offset: s.offset,
|
||||
sampling_period: None,
|
||||
source_type: SourceType::Custom("shell".to_string()),
|
||||
path: s.path.clone(),
|
||||
}
|
||||
}).collect();
|
||||
|
||||
Self {
|
||||
name: config.name.clone(),
|
||||
signals,
|
||||
config,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl DataSource for ShellSource {
|
||||
fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
fn signals(&self) -> Vec<SignalInfo> {
|
||||
self.signals.clone()
|
||||
}
|
||||
|
||||
async fn run(self: Box<Self>, tx: mpsc::Sender<(SignalId, Sample)>) -> Result<()> {
|
||||
let command_str = self.config.command.clone().ok_or_else(|| anyhow::anyhow!("Shell command missing"))?;
|
||||
let poll_interval = if let Some(ref interval_str) = self.config.poll_interval {
|
||||
if interval_str.ends_with("ms") {
|
||||
Duration::from_millis(interval_str.trim_end_matches("ms").parse()?)
|
||||
} else if interval_str.ends_with("s") {
|
||||
Duration::from_secs(interval_str.trim_end_matches("s").parse()?)
|
||||
} else {
|
||||
Duration::from_secs(1)
|
||||
}
|
||||
} else {
|
||||
Duration::from_secs(1)
|
||||
};
|
||||
|
||||
let signal_regexes: Vec<(String, Regex)> = self.config.signals.iter()
|
||||
.filter_map(|s| {
|
||||
s.regex.as_ref().and_then(|r| Regex::new(r).ok().map(|re| (s.id.clone(), re)))
|
||||
})
|
||||
.collect();
|
||||
|
||||
tracing::info!("Shell source '{}' starting with {} regexes", self.name, signal_regexes.len());
|
||||
|
||||
loop {
|
||||
match Command::new("sh").arg("-c").arg(&command_str).output().await {
|
||||
Ok(output) => {
|
||||
if output.status.success() {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos() as i64;
|
||||
let mut matched = false;
|
||||
|
||||
for (id_local, regex) in &signal_regexes {
|
||||
if let Some(caps) = regex.captures(&stdout) {
|
||||
if let Some(m) = caps.get(1) {
|
||||
if let Ok(raw_val) = m.as_str().parse::<f64>() {
|
||||
if let Some(sig_config) = self.config.signals.iter().find(|s| s.id == *id_local) {
|
||||
matched = true;
|
||||
let value = raw_val * sig_config.scale + sig_config.offset;
|
||||
let id = format!("shell://{}/{}", self.name, id_local);
|
||||
let _ = tx.send((id, Sample { timestamp, value })).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
tracing::warn!("Shell command '{}' succeeded but output didn't match any regex: '{}'", self.name, stdout.trim());
|
||||
}
|
||||
} else {
|
||||
tracing::error!("Shell command '{}' failed: {}", self.name, String::from_utf8_lossy(&output.stderr));
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::error!("Failed to execute shell command '{}': {}", self.name, e),
|
||||
}
|
||||
sleep(poll_interval).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::{SourceConfig, TimestampConfig, SignalSourceConfig};
|
||||
|
||||
#[test]
|
||||
fn test_shell_metadata() {
|
||||
let config = SourceConfig {
|
||||
source_type: "shell".to_string(),
|
||||
name: "ts".to_string(),
|
||||
bind: None, path: None, addr: None,
|
||||
command: Some("echo 42".to_string()),
|
||||
poll_interval: Some("1s".to_string()),
|
||||
timestamp: TimestampConfig { byte_offset: None, byte_type: None, column: None, format: "none".to_string(), regex: None },
|
||||
signals: vec![SignalSourceConfig {
|
||||
id: "s1".to_string(), label: "L1".to_string(), unit: "V".to_string(), scale: 1.0, offset: 0.0,
|
||||
byte_offset: None, byte_type: None, column: None, pv: None,
|
||||
regex: Some("([0-9]+)".to_string()),
|
||||
path: vec![],
|
||||
}],
|
||||
};
|
||||
let source = ShellSource::new(config);
|
||||
assert_eq!(source.name(), "ts");
|
||||
assert_eq!(source.signals()[0].id, "shell://ts/s1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_duration_parsing_logic() {
|
||||
let s = "100ms";
|
||||
let ms = s.trim_end_matches("ms").parse::<u64>().unwrap();
|
||||
assert_eq!(ms, 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_shell_regex_logic() {
|
||||
let re = Regex::new("val: ([0-9]+)").unwrap();
|
||||
let caps = re.captures("val: 123").unwrap();
|
||||
assert_eq!(caps.get(1).unwrap().as_str(), "123");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
use anyhow::Result;
|
||||
use rmon_common::signal::{SignalId, SignalInfo, Sample, SourceType};
|
||||
use tokio::sync::mpsc;
|
||||
use async_trait::async_trait;
|
||||
use tokio::net::UdpSocket;
|
||||
use crate::sources::datasource::DataSource;
|
||||
use crate::config::SourceConfig;
|
||||
use byteorder::{ByteOrder, LittleEndian, BigEndian};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
pub struct UdpSource {
|
||||
name: String,
|
||||
pub(crate) bind_addr: String,
|
||||
signals: Vec<SignalInfo>,
|
||||
config: SourceConfig,
|
||||
}
|
||||
|
||||
impl UdpSource {
|
||||
pub fn new(config: SourceConfig) -> Self {
|
||||
let signals = config.signals.iter().map(|s| {
|
||||
SignalInfo {
|
||||
id: format!("udp://{}/{}", config.name, s.id),
|
||||
label: s.label.clone(),
|
||||
unit: s.unit.clone(),
|
||||
scale: s.scale,
|
||||
offset: s.offset,
|
||||
sampling_period: None,
|
||||
source_type: SourceType::Udp,
|
||||
path: s.path.clone(),
|
||||
}
|
||||
}).collect();
|
||||
|
||||
Self {
|
||||
name: config.name.clone(),
|
||||
bind_addr: config.bind.clone().unwrap_or_else(|| "0.0.0.0:0".to_string()),
|
||||
signals,
|
||||
config,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl DataSource for UdpSource {
|
||||
fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
fn signals(&self) -> Vec<SignalInfo> {
|
||||
self.signals.clone()
|
||||
}
|
||||
|
||||
async fn run(self: Box<Self>, tx: mpsc::Sender<(SignalId, Sample)>) -> Result<()> {
|
||||
let socket = UdpSocket::bind(&self.bind_addr).await?;
|
||||
let mut buf = [0u8; 65535];
|
||||
|
||||
loop {
|
||||
let (len, _addr) = socket.recv_from(&mut buf).await?;
|
||||
let payload = &buf[..len];
|
||||
|
||||
let timestamp = if let Some(ts_offset) = self.config.timestamp.byte_offset {
|
||||
match self.config.timestamp.byte_type.as_deref() {
|
||||
Some("u64_le") => {
|
||||
let ts = LittleEndian::read_u64(&payload[ts_offset..ts_offset+8]);
|
||||
match self.config.timestamp.format.as_str() {
|
||||
"microseconds_since_epoch" => (ts * 1000) as i64,
|
||||
"nanoseconds_since_epoch" => ts as i64,
|
||||
"seconds_since_epoch" => (ts * 1_000_000_000) as i64,
|
||||
_ => SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos() as i64,
|
||||
}
|
||||
}
|
||||
_ => SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos() as i64,
|
||||
}
|
||||
} else {
|
||||
SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos() as i64
|
||||
};
|
||||
|
||||
for signal_config in &self.config.signals {
|
||||
if let Some(offset) = signal_config.byte_offset {
|
||||
if payload.len() < offset + 4 { continue; }
|
||||
let raw_val = match signal_config.byte_type.as_deref() {
|
||||
Some("f32_le") => LittleEndian::read_f32(&payload[offset..offset+4]) as f64,
|
||||
Some("f32_be") => BigEndian::read_f32(&payload[offset..offset+4]) as f64,
|
||||
Some("f64_le") => if payload.len() >= offset+8 { LittleEndian::read_f64(&payload[offset..offset+8]) } else { 0.0 },
|
||||
Some("f64_be") => if payload.len() >= offset+8 { BigEndian::read_f64(&payload[offset..offset+8]) } else { 0.0 },
|
||||
_ => 0.0,
|
||||
};
|
||||
|
||||
let value = raw_val * signal_config.scale + signal_config.offset;
|
||||
let id = format!("udp://{}/{}", self.name, signal_config.id);
|
||||
let _ = tx.send((id, Sample { timestamp, value })).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::{SourceConfig, TimestampConfig, SignalSourceConfig};
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_udp_metadata() {
|
||||
let config = SourceConfig {
|
||||
source_type: "udp".to_string(),
|
||||
name: "tu".to_string(),
|
||||
bind: None, path: None, addr: None, command: None, poll_interval: None,
|
||||
timestamp: TimestampConfig { byte_offset: None, byte_type: None, column: None, format: "none".to_string(), regex: None },
|
||||
signals: vec![SignalSourceConfig {
|
||||
id: "s1".to_string(), label: "L1".to_string(), unit: "V".to_string(), scale: 1.0, offset: 0.0,
|
||||
byte_offset: Some(0), byte_type: Some("f32_le".to_string()), column: None, pv: None, regex: None, path: vec![],
|
||||
}],
|
||||
};
|
||||
let source = UdpSource::new(config);
|
||||
assert_eq!(source.name(), "tu");
|
||||
assert_eq!(source.signals()[0].id, "udp://tu/s1");
|
||||
assert_eq!(source.bind_addr, "0.0.0.0:0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_udp_parsing_logic() {
|
||||
use byteorder::{ByteOrder, LittleEndian};
|
||||
let mut buf = [0u8; 4];
|
||||
LittleEndian::write_f32(&mut buf, 1.23);
|
||||
let val = LittleEndian::read_f32(&buf) as f64;
|
||||
assert!((val - 1.23).abs() < 0.001);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
use std::collections::VecDeque;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::RwLock;
|
||||
use rmon_common::signal::Sample;
|
||||
|
||||
pub struct CircularBuffer {
|
||||
samples: RwLock<VecDeque<Sample>>,
|
||||
window: Duration,
|
||||
}
|
||||
|
||||
impl CircularBuffer {
|
||||
pub fn new(window: Duration) -> Self {
|
||||
Self {
|
||||
samples: RwLock::new(VecDeque::new()),
|
||||
window,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn push(&self, sample: Sample) {
|
||||
let mut samples = self.samples.write().await;
|
||||
samples.push_back(sample);
|
||||
|
||||
let cutoff = sample.timestamp - self.window.as_nanos() as i64;
|
||||
while let Some(oldest) = samples.front() {
|
||||
if oldest.timestamp < cutoff {
|
||||
samples.pop_front();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_samples(&self, from_ns: Option<i64>) -> Vec<Sample> {
|
||||
let samples = self.samples.read().await;
|
||||
if let Some(from) = from_ns {
|
||||
samples.iter().filter(|s| s.timestamp >= from).cloned().collect()
|
||||
} else {
|
||||
samples.iter().cloned().collect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::time::Duration;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_circular_trimming() {
|
||||
let window = Duration::from_nanos(100);
|
||||
let buf = CircularBuffer::new(window);
|
||||
|
||||
// Push sample at t=1000
|
||||
buf.push(Sample { timestamp: 1000, value: 1.0 }).await;
|
||||
// Push sample at t=1050 (within window)
|
||||
buf.push(Sample { timestamp: 1050, value: 2.0 }).await;
|
||||
// Push sample at t=1110 (cutoff is now 1110-100 = 1010, so t=1000 should be dropped)
|
||||
buf.push(Sample { timestamp: 1110, value: 3.0 }).await;
|
||||
|
||||
let samples = buf.get_samples(None).await;
|
||||
assert_eq!(samples.len(), 2);
|
||||
assert_eq!(samples[0].timestamp, 1050);
|
||||
assert_eq!(samples[1].timestamp, 1110);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_circular_query_range() {
|
||||
let buf = CircularBuffer::new(Duration::from_nanos(1000));
|
||||
buf.push(Sample { timestamp: 100, value: 1.0 }).await;
|
||||
buf.push(Sample { timestamp: 200, value: 2.0 }).await;
|
||||
buf.push(Sample { timestamp: 300, value: 3.0 }).await;
|
||||
|
||||
let samples = buf.get_samples(Some(200)).await;
|
||||
assert_eq!(samples.len(), 2);
|
||||
assert_eq!(samples[0].timestamp, 200);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::{Write, BufWriter};
|
||||
use std::path::PathBuf;
|
||||
use anyhow::Result;
|
||||
use rmon_common::signal::Sample;
|
||||
use byteorder::{LittleEndian, WriteBytesExt};
|
||||
|
||||
pub struct ContinuousStorage {
|
||||
writer: BufWriter<File>,
|
||||
_path: PathBuf,
|
||||
}
|
||||
|
||||
impl ContinuousStorage {
|
||||
pub fn create(data_dir: PathBuf, source_name: &str, signal_id: &str, session_start_ns: i64) -> Result<Self> {
|
||||
let mut path = data_dir;
|
||||
path.push(source_name);
|
||||
path.push(signal_id.replace(":", "_").replace("/", "_")); // Escape signal ID for filesystem
|
||||
std::fs::create_dir_all(&path)?;
|
||||
|
||||
path.push(format!("{}.bin", session_start_ns));
|
||||
|
||||
let file = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&path)?;
|
||||
|
||||
Ok(Self {
|
||||
writer: BufWriter::new(file),
|
||||
_path: path,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn append(&mut self, sample: Sample) -> Result<()> {
|
||||
self.writer.write_i64::<LittleEndian>(sample.timestamp)?;
|
||||
self.writer.write_f64::<LittleEndian>(sample.value)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn flush(&mut self) -> Result<()> {
|
||||
self.writer.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
use std::fs;
|
||||
|
||||
#[test]
|
||||
fn test_continuous_storage_creation() {
|
||||
let dir = tempdir().unwrap();
|
||||
let res = ContinuousStorage::create(
|
||||
dir.path().to_path_buf(),
|
||||
"src",
|
||||
"sig1",
|
||||
12345
|
||||
);
|
||||
assert!(res.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_continuous_append() {
|
||||
let dir = tempdir().unwrap();
|
||||
let mut storage = ContinuousStorage::create(
|
||||
dir.path().to_path_buf(),
|
||||
"src",
|
||||
"sig1",
|
||||
12345
|
||||
).unwrap();
|
||||
|
||||
let sample = Sample { timestamp: 100, value: 42.0 };
|
||||
storage.append(sample).unwrap();
|
||||
storage.flush().unwrap();
|
||||
|
||||
// Verify file exists and has size 16 bytes (i64 + f64)
|
||||
let path = dir.path().join("src/sig1/12345.bin");
|
||||
assert!(path.exists());
|
||||
assert_eq!(fs::metadata(path).unwrap().len(), 16);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod circular;
|
||||
pub mod continuous;
|
||||
|
||||
pub use self::circular::CircularBuffer;
|
||||
pub use self::continuous::ContinuousStorage;
|
||||
@@ -0,0 +1,200 @@
|
||||
use anyhow::Result;
|
||||
use tokio::net::{TcpStream, UdpSocket};
|
||||
use tokio_util::codec::Framed;
|
||||
use futures::{StreamExt, SinkExt};
|
||||
use rmon_common::protocol::{ClientMessage, AgentMessage, BincodeCodec, PROTOCOL_VERSION};
|
||||
use std::time::Duration;
|
||||
use tokio::time::sleep;
|
||||
use bytes::BytesMut;
|
||||
use tokio_util::codec::{Decoder, Encoder};
|
||||
use std::io;
|
||||
use rmon_agent::config::Config;
|
||||
use rmon_agent::run_agent;
|
||||
use std::path::PathBuf;
|
||||
use std::fs;
|
||||
|
||||
/// Client side codec
|
||||
pub struct ClientCodec;
|
||||
|
||||
impl Decoder for ClientCodec {
|
||||
type Item = AgentMessage;
|
||||
type Error = io::Error;
|
||||
|
||||
fn decode(&mut self, src: &mut BytesMut) -> Result<Option<Self::Item>, Self::Error> {
|
||||
let mut codec = BincodeCodec::<AgentMessage>::default();
|
||||
codec.decode(src)
|
||||
}
|
||||
}
|
||||
|
||||
impl Encoder<ClientMessage> for ClientCodec {
|
||||
type Error = io::Error;
|
||||
|
||||
fn encode(&mut self, item: ClientMessage, dst: &mut BytesMut) -> Result<(), Self::Error> {
|
||||
let mut codec = BincodeCodec::<ClientMessage>::default();
|
||||
codec.encode(item, dst)
|
||||
}
|
||||
}
|
||||
|
||||
async fn setup_agent(port: u16, udp_port: u16, config_path: PathBuf, data_dir: &str) -> Result<()> {
|
||||
let config_str = format!(r#"
|
||||
[storage]
|
||||
data_dir = "{}"
|
||||
default_mode = "circular"
|
||||
default_window = "1h"
|
||||
|
||||
[[sources]]
|
||||
type = "udp"
|
||||
name = "test_source"
|
||||
bind = "127.0.0.1:{}"
|
||||
[sources.timestamp]
|
||||
format = "none"
|
||||
[[sources.signals]]
|
||||
id = "sig1"
|
||||
label = "Sig 1"
|
||||
unit = "V"
|
||||
scale = 1.0
|
||||
offset = 0.0
|
||||
byte_offset = 0
|
||||
byte_type = "f32_le"
|
||||
path = ["test"]
|
||||
"#, data_dir, udp_port);
|
||||
|
||||
if let Some(parent) = config_path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
fs::write(&config_path, config_str.clone())?;
|
||||
let config: Config = toml::from_str(&config_str)?;
|
||||
|
||||
tokio::spawn(async move {
|
||||
let _ = run_agent(config, &format!("127.0.0.1:{}", port), config_path).await;
|
||||
});
|
||||
|
||||
sleep(Duration::from_millis(300)).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_agent_full_flow() -> Result<()> {
|
||||
tokio::time::timeout(Duration::from_secs(20), async {
|
||||
let port = 7892;
|
||||
let udp_port = 9001;
|
||||
let data_dir = "/tmp/rmon-test-data-full";
|
||||
let config_path = PathBuf::from("/tmp/rmon-test/config_full.toml");
|
||||
let _ = fs::remove_dir_all(data_dir);
|
||||
|
||||
setup_agent(port, udp_port, config_path, data_dir).await?;
|
||||
|
||||
let stream = TcpStream::connect(format!("127.0.0.1:{}", port)).await?;
|
||||
let mut framed = Framed::new(stream, ClientCodec);
|
||||
|
||||
framed.send(ClientMessage::Handshake {
|
||||
protocol_version: PROTOCOL_VERSION,
|
||||
client_version: "0.1.0".to_string(),
|
||||
}).await?;
|
||||
|
||||
let ack = framed.next().await.ok_or_else(|| anyhow::anyhow!("Disconnected"))??;
|
||||
if let AgentMessage::HandshakeAck { accepted, .. } = ack {
|
||||
assert!(accepted);
|
||||
} else {
|
||||
panic!("Expected HandshakeAck, got {:?}", ack);
|
||||
}
|
||||
|
||||
framed.send(ClientMessage::Subscribe {
|
||||
ids: vec!["udp://test_source/sig1".to_string()],
|
||||
from_ns: None,
|
||||
}).await?;
|
||||
|
||||
let udp_socket = UdpSocket::bind("127.0.0.1:0").await?;
|
||||
let val: f32 = 42.0;
|
||||
udp_socket.send_to(&val.to_le_bytes(), format!("127.0.0.1:{}", udp_port)).await?;
|
||||
|
||||
let msg = framed.next().await.ok_or_else(|| anyhow::anyhow!("Disconnected"))??;
|
||||
if let AgentMessage::DataBatch { batches } = msg {
|
||||
assert_eq!(batches[0].id, "udp://test_source/sig1");
|
||||
assert_eq!(batches[0].samples[0].value, 42.0);
|
||||
} else {
|
||||
panic!("Expected DataBatch, got {:?}", msg);
|
||||
}
|
||||
|
||||
// Check continuous storage
|
||||
sleep(Duration::from_millis(200)).await;
|
||||
let signal_dir = format!("{}/test_source/udp___test_source_sig1", data_dir);
|
||||
let data_files = fs::read_dir(&signal_dir).map_err(|e| anyhow::anyhow!("Failed to read {}: {}", signal_dir, e))?;
|
||||
assert!(data_files.count() > 0);
|
||||
|
||||
Ok::<(), anyhow::Error>(())
|
||||
}).await.map_err(|_| anyhow::anyhow!("Test timed out"))?
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_agent_config_remote() -> Result<()> {
|
||||
tokio::time::timeout(Duration::from_secs(20), async {
|
||||
let port = 7894;
|
||||
let udp_port = 9002;
|
||||
let data_dir = "/tmp/rmon-test-data-remote";
|
||||
let config_path = PathBuf::from("/tmp/rmon-test/config_remote.toml");
|
||||
let _ = fs::remove_dir_all(data_dir);
|
||||
|
||||
setup_agent(port, udp_port, config_path.clone(), data_dir).await?;
|
||||
|
||||
let stream = TcpStream::connect(format!("127.0.0.1:{}", port)).await?;
|
||||
let mut framed = Framed::new(stream, ClientCodec);
|
||||
|
||||
framed.send(ClientMessage::Handshake {
|
||||
protocol_version: PROTOCOL_VERSION,
|
||||
client_version: "0.1.0".to_string(),
|
||||
}).await?;
|
||||
let _ = framed.next().await.unwrap()?;
|
||||
|
||||
// 1. Get Config
|
||||
framed.send(ClientMessage::GetConfig).await?;
|
||||
let msg = framed.next().await.ok_or_else(|| anyhow::anyhow!("Disconnected"))??;
|
||||
if let AgentMessage::ConfigReport { config_toml } = msg {
|
||||
assert!(config_toml.contains("test_source"));
|
||||
} else {
|
||||
panic!("Expected ConfigReport, got {:?}", msg);
|
||||
}
|
||||
|
||||
// 2. Update Config
|
||||
let new_config = format!(r#"
|
||||
[storage]
|
||||
data_dir = "{}"
|
||||
default_mode = "circular"
|
||||
default_window = "1h"
|
||||
|
||||
[[sources]]
|
||||
type = "shell"
|
||||
name = "new_source"
|
||||
command = "echo \"val: 100\""
|
||||
poll_interval = "100ms"
|
||||
[sources.timestamp]
|
||||
format = "none"
|
||||
[[sources.signals]]
|
||||
id = "sig_new"
|
||||
label = "New Sig"
|
||||
unit = "X"
|
||||
scale = 1.0
|
||||
offset = 0.0
|
||||
regex = "val: ([0-9]+)"
|
||||
path = ["new"]
|
||||
"#, data_dir);
|
||||
framed.send(ClientMessage::UpdateConfig { config_toml: new_config.to_string() }).await?;
|
||||
|
||||
// Wait for hot-reload
|
||||
sleep(Duration::from_millis(2000)).await;
|
||||
|
||||
// 3. Check if signals changed
|
||||
framed.send(ClientMessage::ListSignals).await?;
|
||||
let msg = tokio::time::timeout(Duration::from_secs(5), framed.next()).await
|
||||
.map_err(|_| anyhow::anyhow!("Timeout waiting for SignalList"))?
|
||||
.ok_or_else(|| anyhow::anyhow!("Disconnected"))??;
|
||||
|
||||
if let AgentMessage::SignalList { signals } = msg {
|
||||
assert!(signals.iter().any(|s| s.id == "shell://new_source/sig_new"), "New signal not found in {:?}", signals);
|
||||
} else {
|
||||
panic!("Expected SignalList, got {:?}", msg);
|
||||
}
|
||||
|
||||
Ok::<(), anyhow::Error>(())
|
||||
}).await.map_err(|_| anyhow::anyhow!("Test timed out"))?
|
||||
}
|
||||
Reference in New Issue
Block a user