Files
rmon/docs/agent-spec.md
T
2026-04-11 21:11:15 +02:00

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/agent by 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 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:

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}.