6.3 KiB
RMon Agent Specification
Binary
- Build target:
x86_64-unknown-linux-musl(primary),aarch64-unknown-linux-musl(secondary) - Static: fully static binary, no dynamic library dependencies whatsoever
- Runtime requirements: Linux kernel ≥ 3.2, no root, no installed packages
- Deployment: single file, placed at
~/.rmon/agentby the UI or manually by the user
Build command:
cargo build --release --target x86_64-unknown-linux-musl -p rmon-agent
Cross-compilation for ARM:
cargo build --release --target aarch64-unknown-linux-musl -p rmon-agent
The .cargo/config.toml in rmon-agent/ sets [profile.release] with strip = true and lto = true to minimize binary size.
CLI
rmon-agent [OPTIONS]
Options:
--config <path> Config file path (default: ~/.rmon/config.toml)
--port <port> TCP listen port (default: 7891)
--bind <addr> Bind address (default: 127.0.0.1 — localhost only, relies on SSH tunnel)
--log <level> Log level: error|warn|info|debug (default: info)
--log-file <path> Log to file instead of stderr (default: stderr)
-v, --version Print version and exit
-h, --help Print help and exit
The agent always binds to localhost only (127.0.0.1). Remote access is exclusively through the SSH tunnel. Never expose the agent port on a public interface.
Configuration File
TOML format. The agent watches the config file and reloads source definitions on change (without restarting).
[storage]
# Base directory for recorded data
data_dir = "~/.rmon/data"
# Default storage mode for all signals unless overridden per source
# "continuous" or "circular"
default_mode = "circular"
# For circular mode: how much history to keep in memory (and optionally on disk)
default_window = "2h"
[[sources]]
type = "udp"
name = "imu_stream"
bind = "0.0.0.0:9000"
# Signals received in each UDP packet (fixed binary layout)
[[sources.signals]]
id = "accel_x"
label = "Accelerometer X"
unit = "m/s²"
scale = 0.001
offset = 0.0
byte_offset = 0 # position in the UDP payload
byte_type = "f32_le" # type at that offset: f32_le, f32_be, f64_le, f64_be, i16_le, ...
path = ["imu", "accelerometer"]
[[sources.signals]]
id = "accel_y"
label = "Accelerometer Y"
unit = "m/s²"
scale = 0.001
offset = 0.0
byte_offset = 4
byte_type = "f32_le"
path = ["imu", "accelerometer"]
# UDP timestamp: how to find the timestamp in the packet
[sources.timestamp]
byte_offset = 24
byte_type = "u64_le"
format = "microseconds_since_epoch"
# Other format options:
# "nanoseconds_since_epoch"
# "seconds_since_epoch"
# "milliseconds_relative" (relative to agent start; converted to absolute)
[[sources]]
type = "csv"
name = "coolant_temps"
path = "/mnt/shared/data/coolant.csv"
# How often to poll the file for new rows (default: 1s)
poll_interval = "500ms"
# Timestamp column
[sources.timestamp]
column = "Time"
format = "%Y.%m.%d %H:%M:%S"
[[sources.signals]]
id = "inlet"
column = "T_inlet"
label = "Coolant Inlet Temperature"
unit = "°C"
scale = 1.0
offset = 0.0
path = ["temperatures", "coolant"]
[[sources]]
type = "epics"
name = "vacuum"
# EPICS Channel Access address (if not using broadcast)
addr = "192.168.1.50:5064"
[[sources.signals]]
id = "chamber_pressure"
pv = "VAC:CHAMBER:PRESS"
label = "Chamber Pressure"
unit = "mbar"
scale = 1.0
offset = 0.0
path = ["vacuum"]
Remote Configuration
The agent allows the UI to read and update its configuration file (~/.rmon/config.toml) over the protocol.
ClientMessage::GetConfig: Agent reads the file from disk and sendsAgentMessage::ConfigReport { config_toml }.ClientMessage::UpdateConfig { config_toml }: Agent writes the provided string to~/.rmon/config.toml. The agent's file watcher detects this change and reloads the sources.
Source Adapter Trait
All data sources implement:
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, tx: mpsc::Sender<(SignalId, Sample)>) -> anyhow::Result<()>;
}
New source types are added by implementing this trait and registering in sources/mod.rs.
Storage Engine
Circular Buffer (in-memory)
Per-signal VecDeque<Sample> bounded by window duration. New samples evict old ones when the oldest sample is older than now - window. Thread-safe via tokio::sync::RwLock.
Historical queries on circular buffers are served from this in-memory structure.
Continuous Storage (on-disk)
Append-only binary log: sequence of fixed-size (i64, f64) records (16 bytes each, little-endian). One file per signal per recording session.
File path: {data_dir}/{source_name}/{signal_id}/{session_start_ns}.bin
Historical queries on continuous storage use binary search on the file (records are sorted by timestamp) to find the start offset, then stream records to the client.
Concurrency Model
main thread
└── tokio runtime
├── listener task (accepts TCP connections)
├── per-source task (one per DataSource::run)
│ └── sends to dispatcher channel
├── dispatcher task (fans out samples to storage + all sessions)
│ ├── storage writer tasks (one per signal being recorded)
│ └── per-session sender tasks (one per connected client)
└── per-session reader task (handles incoming ClientMessage requests)
The dispatcher uses a tokio::sync::broadcast channel. Each session's sender task has its own broadcast::Receiver. Lagged receivers are detected and reported as a warning (UI display gap, not a crash).
Signal ID Namespacing
The full SignalId used in the protocol is constructed as:
{source_type}://{source_name}/{signal_local_id}
Example: udp://imu_stream/accel_x
The signal_local_id is the id field from the config. Source names must be unique within one agent config.
Logging
Uses the tracing crate. Output format: structured text to stderr (or log file if --log-file is set).
No println! in production code; use tracing::{info, warn, error, debug}.