# 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/agent` by the UI or manually by the user Build command: ```sh cargo build --release --target x86_64-unknown-linux-musl -p rmon-agent ``` Cross-compilation for ARM: ```sh 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 Config file path (default: ~/.rmon/config.toml) --port TCP listen port (default: 7891) --bind Bind address (default: 127.0.0.1 — localhost only, relies on SSH tunnel) --log Log level: error|warn|info|debug (default: info) --log-file 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). ```toml [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 sends `AgentMessage::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: ```rust 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; /// 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` 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}`.