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

204 lines
6.3 KiB
Markdown

# 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 <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).
```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<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}`.