initial commit
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
# 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"]
|
||||
```
|
||||
|
||||
## 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}`.
|
||||
Reference in New Issue
Block a user