diff --git a/Cargo.lock b/Cargo.lock index 8e1b83c..4996167 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -163,9 +163,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chrono" -version = "0.4.42" +version = "0.4.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ "iana-time-zone", "js-sys", @@ -196,6 +196,15 @@ dependencies = [ "strsim", ] +[[package]] +name = "clap_complete" +version = "4.5.66" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c757a3b7e39161a4e56f9365141ada2a6c915a8622c408ab6bb4b5d047371031" +dependencies = [ + "clap", +] + [[package]] name = "clap_derive" version = "4.5.49" @@ -412,6 +421,27 @@ dependencies = [ "powerfmt", ] +[[package]] +name = "dirs-next" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" +dependencies = [ + "cfg-if", + "dirs-sys-next", +] + +[[package]] +name = "dirs-sys-next" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + [[package]] name = "dispatch2" version = "0.3.0" @@ -468,6 +498,17 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "glob" version = "0.3.3" @@ -569,9 +610,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "iana-time-zone" -version = "0.1.64" +version = "0.1.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" dependencies = [ "android_system_properties", "core-foundation-sys", @@ -708,6 +749,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "libredox" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +dependencies = [ + "bitflags 2.10.0", + "libc", +] + [[package]] name = "libudev" version = "0.3.0" @@ -1140,6 +1191,17 @@ dependencies = [ "bitflags 2.10.0", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom", + "libredox", + "thiserror 1.0.69", +] + [[package]] name = "regex" version = "1.12.2" @@ -1356,13 +1418,33 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.17", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -1491,6 +1573,7 @@ version = "1.6.2" dependencies = [ "crc", "crossbeam", + "dirs-next", "glob", "hdf5-metno", "mio", @@ -1507,6 +1590,7 @@ version = "1.6.2" dependencies = [ "chrono", "clap", + "clap_complete", "crossbeam", "indicatif", "memmap2", @@ -1525,7 +1609,7 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4064ed685c487dbc25bd3f0e9548f2e34bab9d18cefc700f9ec2dba74ba1138e" dependencies = [ - "thiserror", + "thiserror 2.0.17", ] [[package]] diff --git a/twinleaf-tools/Cargo.toml b/twinleaf-tools/Cargo.toml index 6e75831..148053e 100644 --- a/twinleaf-tools/Cargo.toml +++ b/twinleaf-tools/Cargo.toml @@ -7,13 +7,13 @@ description = "Tools for the Twinleaf I/O protocol for reading data from Twinlea homepage = "https://twinleaf.com" repository = "https://github.com/twinleaf/twinleaf-rust" readme = "README.md" +build = "build.rs" [features] default = [] hdf5 = ["twinleaf/hdf5"] [dependencies] -chrono = "0.4.38" crossbeam = "0.8.4" serialport = "4.5.1" toml_edit = {version = "0.22.24", features = ["parse"]} @@ -24,4 +24,12 @@ ratatui = "0.29.0" tui-prompts = "0.5.2" welch-sde = "0.1.0" memmap2 = "0.9.9" -indicatif = "0.18.3" \ No newline at end of file +indicatif = "0.18.3" +clap_complete = "4.5.66" +chrono = "0.4.44" + +[build-dependencies] +clap = { version = "4.5.51", features = ["derive"] } +clap_complete = "4.5.66" +twinleaf = {version = "1.3.4", path = "../twinleaf" } +chrono = "0.4.44" diff --git a/twinleaf-tools/build.rs b/twinleaf-tools/build.rs new file mode 100644 index 0000000..10ccecc --- /dev/null +++ b/twinleaf-tools/build.rs @@ -0,0 +1,25 @@ +use std::{env, io}; +use clap::{CommandFactory}; +use clap_complete::{generate_to, Shell}; + +include!("src/lib.rs"); + +fn main() -> Result<(), io::Error> { + let outdir = match env::var_os("OUT_DIR") { + None => return Ok(()), + Some(outdir) => outdir, + }; + + let mut proxy_cmd = ProxyCli::command(); + let mut tool_cmd = TioToolCli::command(); + let mut monitor_cmd = MonitorCli::command(); + let mut health_cmd = HealthCli::command(); + for &shell in Shell::value_variants() { + generate_to(shell, &mut proxy_cmd, "tio-proxy", &outdir)?; + generate_to(shell, &mut tool_cmd, "tio-tool", &outdir)?; + generate_to(shell, &mut monitor_cmd, "tio-monitor", &outdir)?; + generate_to(shell, &mut health_cmd, "tio-health", &outdir)?; + } + + Ok(()) +} diff --git a/twinleaf-tools/src/bin/tio-health.rs b/twinleaf-tools/src/bin/tio-health.rs index 8b1b39b..a457ea6 100644 --- a/twinleaf-tools/src/bin/tio-health.rs +++ b/twinleaf-tools/src/bin/tio-health.rs @@ -20,7 +20,6 @@ use ratatui::{ use std::{ collections::{BTreeMap, HashMap, VecDeque}, io, - num::ParseFloatError, time::{Duration, Instant, SystemTime}, }; use twinleaf::{ @@ -31,134 +30,7 @@ use twinleaf::{ proto::{identifiers::StreamKey, DeviceRoute}, }, }; -use twinleaf_tools::TioOpts; - -#[derive(Parser, Debug, Clone)] -#[command( - name = "tio-health", - version, - about = "Live timing & rate diagnostics for TIO (Twinleaf) devices" -)] -struct Cli { - #[command(flatten)] - tio: TioOpts, - - /// Time window in seconds for calculating sample rate - #[arg( - long = "rate-window", - default_value = "5", - value_name = "SECONDS", - value_parser = clap::value_parser!(u64).range(1..), - )] - rate_window: u64, - - /// Time window in seconds for calculating jitter statistics - #[arg( - long = "jitter-window", - default_value = "10", - value_name = "SECONDS", - value_parser = clap::value_parser!(u64).range(1..), - help = "Seconds for jitter calculation window (>= 1)" - )] - jitter_window: u64, - - /// PPM threshold for yellow warning indicators - #[arg( - long = "ppm-warn", - default_value = "100", - value_name = "PPM", - value_parser = nonneg_f64, - help = "Warning threshold in parts per million (>= 0)" - )] - ppm_warn: f64, - - /// PPM threshold for red error indicators - #[arg( - long = "ppm-err", - default_value = "200", - value_name = "PPM", - value_parser = nonneg_f64, - help = "Error threshold in parts per million (>= 0)" - )] - ppm_err: f64, - - /// Filter to only show specific stream IDs (comma-separated) - #[arg( - long = "streams", - value_delimiter = ',', - value_name = "IDS", - value_parser = clap::value_parser!(u8), - help = "Comma-separated stream IDs to monitor (e.g., 0,1,5)" - )] - streams: Option>, - - /// Suppress the footer help text - #[arg(short = 'q', long = "quiet")] - quiet: bool, - - /// UI refresh rate for animations and stale detection (data updates are immediate) - #[arg( - long = "fps", - default_value = "30", - value_name = "FPS", - value_parser = clap::value_parser!(u64).range(1..=60), - help = "UI refresh rate for heartbeat animation and stale detection (1–60)" - )] - fps: u64, - - /// Time in milliseconds before marking a stream as stale - #[arg( - long = "stale-ms", - default_value = "2000", - value_name = "MS", - value_parser = clap::value_parser!(u64).range(1..), - help = "Mark streams as stale after this many milliseconds without data (>= 1)" - )] - stale_ms: u64, - - /// Maximum number of events to keep in the event log - #[arg( - short = 'n', - long = "event-log-size", - default_value = "100", - value_name = "N", - value_parser = clap::value_parser!(u64).range(1..), - help = "Maximum number of events to keep in history (>= 1)" - )] - event_log_size: u64, - - /// Number of event lines to display on screen - #[arg( - long = "event-display-lines", - default_value = "8", - value_name = "LINES", - value_parser = clap::value_parser!(u16).range(3..), - help = "Number of event lines to show (>= 3)" - )] - event_display_lines: u16, - - /// Only show warning and error events in the log - #[arg(short = 'w', long = "warnings-only")] - warnings_only: bool, -} - -impl Cli { - fn rate_window_dur(&self) -> Duration { - Duration::from_secs(self.rate_window) - } - fn stale_dur(&self) -> Duration { - Duration::from_millis(self.stale_ms) - } -} - -fn nonneg_f64(s: &str) -> Result { - let v: f64 = s.parse().map_err(|e: ParseFloatError| e.to_string())?; - if v < 0.0 { - Err("must be ≥ 0".into()) - } else { - Ok(v) - } -} +use twinleaf_tools::HealthCli; #[derive(Default)] struct DeviceState { @@ -520,7 +392,7 @@ fn draw_ui( event_log: &VecDeque, event_scroll_offset: usize, show_heartbeat: bool, - cli: &Cli, + cli: &HealthCli, ) -> io::Result<()> { let now = Instant::now(); let rate_window = cli.rate_window_dur(); @@ -707,7 +579,7 @@ enum DataMsg { } fn main() { - let cli = Cli::parse(); + let cli = HealthCli::parse(); let mut terminal = ratatui::init(); let proxy = tio::proxy::Interface::new(&cli.tio.root); @@ -870,6 +742,9 @@ fn main() { ); needs_redraw = true; } + TreeEvent::Device { route: _, event: DeviceEvent::NewHash(_) } => { + (); + } } } diff --git a/twinleaf-tools/src/bin/tio-monitor.rs b/twinleaf-tools/src/bin/tio-monitor.rs index 2801ea6..b0e28cc 100644 --- a/twinleaf-tools/src/bin/tio-monitor.rs +++ b/twinleaf-tools/src/bin/tio-monitor.rs @@ -3,7 +3,7 @@ // Build: cargo run --release -- [options] use std::{ - collections::{BTreeMap, HashMap, HashSet}, + collections::{BTreeMap, HashMap, HashSet, VecDeque}, fs::File, io::{self, Read}, str::FromStr, @@ -15,18 +15,18 @@ use crossbeam::channel::{self, Sender}; use ratatui::{ crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}, layout::{Constraint, Direction, Layout, Rect}, - prelude::Backend, + prelude::{Backend, Stylize}, style::{Color, Modifier, Style}, symbols, text::{Line, Span}, - widgets::{Axis, Block, Borders, Chart, Dataset, GraphType, Paragraph}, + widgets::{Axis, Block, Borders, Chart, Dataset, GraphType, List, Paragraph}, Frame, Terminal, }; use toml_edit::{DocumentMut, InlineTable, Value}; use tui_prompts::{State, TextState}; use twinleaf::{ data::{AlignedWindow, Buffer, ColumnBatch, ColumnData, DeviceFullMetadata, Sample}, - device::{util, DeviceEvent, DeviceTree, RpcClient, TreeEvent, TreeItem}, + device::{util, DeviceEvent, DeviceTree, RpcClient, RpcList, TreeEvent, TreeItem}, tio::{ self, proto::{ @@ -35,22 +35,9 @@ use twinleaf::{ }, }, }; -use twinleaf_tools::TioOpts; +use twinleaf_tools::MonitorCli; use welch_sde::{Build, SpectralDensity}; -#[derive(Parser, Debug)] -#[command(name = "tio-monitor", version, about = "Display live sensor data")] -struct Cli { - #[command(flatten)] - tio: TioOpts, - #[arg(short = 'a', long = "all")] - all: bool, - #[arg(long = "fps", default_value_t = 20)] - fps: u32, - #[arg(short = 'c', long = "colors")] - colors: Option, -} - #[derive(Debug, Clone)] pub enum NavPos { EmptyDevice { @@ -336,7 +323,11 @@ pub enum Mode { pub enum Action { Quit, SetMode(Mode), + AutoCompleteTab, + AutoCompleteBack, + NewCommandString, SubmitCommand, + AcceptCompletion, NavUp, NavDown, NavLeft, @@ -395,6 +386,7 @@ impl Default for ViewConfig { #[derive(Debug)] pub struct RpcReq { pub route: DeviceRoute, + pub meta: Option, pub method: String, pub arg: Option, } @@ -405,9 +397,12 @@ pub struct RpcResp { } fn exec_rpc(client: &RpcClient, req: &RpcReq) -> Result { - let meta: u16 = client - .rpc(&req.route, "rpc.info", &req.method) - .map_err(|_| format!("Unknown RPC: {}", req.method))?; + let meta = match req.meta { + Some(m) => m, + None => client + .rpc(&req.route, "rpc.info", &req.method) + .map_err(|_| format!("Unknown RPC: {}", req.method))? + }; let spec = util::parse_rpc_spec(meta, req.method.clone()); @@ -434,7 +429,6 @@ fn exec_rpc(client: &RpcClient, req: &RpcReq) -> Result { pub struct App { pub all: bool, pub parent_route: DeviceRoute, - pub mode: Mode, pub view: ViewConfig, @@ -447,14 +441,25 @@ pub struct App { pub device_metadata: HashMap, pub window_aligned: Option, + pub footer_height: u16, + pub rpcs: HashMap, + pub suggested_rpcs: VecDeque, + pub suggested_rpcs_len: usize, + pub suggested_rpcs_ind: usize, + pub input_state: TextState<'static>, + pub current_completion: String, pub cmd_history: Vec, pub history_ptr: Option, + pub present_command: String, pub last_rpc_result: Option<(String, Color)>, + pub last_rpc_command: String, pub blink_state: bool, pub last_blink: Instant, } +const RPCLIST_MAX_LEN: usize = 12; + impl App { pub fn new(all: bool, parent_route: &DeviceRoute) -> Self { Self { @@ -469,10 +474,18 @@ impl App { last: BTreeMap::new(), device_metadata: HashMap::new(), window_aligned: None, + footer_height: 0, + rpcs: HashMap::new(), + suggested_rpcs: VecDeque::from(vec![String::new()]), + suggested_rpcs_len: 1, + suggested_rpcs_ind: 0, input_state: TextState::default(), + current_completion: String::new(), cmd_history: Vec::new(), history_ptr: None, + present_command: String::new(), last_rpc_result: None, + last_rpc_command: String::new(), blink_state: true, last_blink: Instant::now(), } @@ -482,16 +495,21 @@ impl App { match action { Action::Quit => return true, Action::SetMode(Mode::Command) => { - self.mode = Mode::Command; self.input_state = TextState::default(); self.input_state.focus(); self.history_ptr = None; + self.update_command_list(); + self.mode = Mode::Command; } Action::SetMode(Mode::Normal) => { self.mode = Mode::Normal; self.input_state.blur(); - } + }, + Action::AutoCompleteTab=> {self.tab_complete()}, + Action::AutoCompleteBack => {self.tab_back_complete()}, + Action::NewCommandString => {self.update_command_list()} Action::SubmitCommand => self.submit_command(rpc_tx), + Action::AcceptCompletion => self.accept_completion(), Action::HistoryNavigate(dir) => self.navigate_history(dir), Action::NavUp => { self.view.follow_selection = true; @@ -563,11 +581,87 @@ impl App { false } - fn submit_command(&mut self, rpc_tx: &Sender) { + fn complete_command(&mut self) { + let rpc = self.suggested_rpcs[self.suggested_rpcs_ind].clone(); + self.current_completion = match rpc.get(self.input_state.value().len()..) { + Some(s) => s.to_string(), + None => String::new(), + }; + self.input_state.focus(); + self.input_state.move_end(); + } + + fn tab_complete(&mut self) { + let max = std::cmp::min(RPCLIST_MAX_LEN, (self.footer_height - 5).into()); + self.suggested_rpcs_ind = match (self.suggested_rpcs_ind, self.suggested_rpcs_len) { + (i, l) if i == l-1 => 0, + (i, l) if l <= max => i+1, + (i, _) if i < max / 2 => i+1, + (i, _) => { // middle of wrapped list, move list instead of index + let front = self.suggested_rpcs.pop_front().unwrap(); + self.suggested_rpcs.push_back(front); + i + }, + }; + self.complete_command(); + } + + fn tab_back_complete(&mut self) { + let max = std::cmp::min(RPCLIST_MAX_LEN, (self.footer_height - 5).into()); + self.suggested_rpcs_ind = match (self.suggested_rpcs_ind, self.suggested_rpcs_len) { + (0, l) if l <= max => l-1, + (0, _) => { // 0, move list instead of index + let back = self.suggested_rpcs.pop_back().unwrap(); + self.suggested_rpcs.push_front(back); + 0 + }, + (i, _) => i-1, + }; + self.complete_command(); + } + + fn update_command_list(&mut self) { + self.suggested_rpcs_ind = 0; + self.current_completion = String::new(); let line = self.input_state.value().to_string(); - if line.trim().is_empty() { - return; + let mut rpc_cache = Vec::new(); + if !line.is_empty() { + if let Some(l) = self.rpcs.get(&self.current_route()) { + for (name, _) in &l.list { + rpc_cache.push(name.to_string()); + } + } + } + + self.suggested_rpcs = rpc_cache + .iter() + .filter(|word: &&String| word.to_string().starts_with(&line)) + .map(String::clone) + .collect(); + self.suggested_rpcs_len = self.suggested_rpcs.len(); + if !(1..=RPCLIST_MAX_LEN).contains(&self.suggested_rpcs_len) { + self.suggested_rpcs.push_back(String::new()); + self.suggested_rpcs_len += 1; + } + self.complete_command(); + } + + fn accept_completion(&mut self) { + let complete_command = format!("{}{}", self.input_state.value().to_string(), self.current_completion); + self.input_state = TextState::new().with_value(complete_command); + self.input_state.focus(); + self.input_state.move_end(); + self.update_command_list(); + } + + fn submit_command(&mut self, rpc_tx: &Sender) { + // Completion mode: accept completion, don't submit + if !self.current_completion.is_empty() { + return self.accept_completion(); } + // No completion: enter command + let line = self.input_state.value().to_string(); + if line.trim().is_empty() { return; } if self.cmd_history.last() != Some(&line) { self.cmd_history.push(line.clone()); } @@ -575,6 +669,7 @@ impl App { let mut parts = line.split_whitespace(); if let Some(method) = parts.next() { + self.last_rpc_command = method.to_string(); let remainder: Vec<&str> = parts.collect(); let arg = if remainder.is_empty() { None @@ -582,23 +677,37 @@ impl App { Some(remainder.join(" ")) }; let route = self.current_route(); + let meta = match self.rpcs.get(&route) { + Some(l) => l.map.get(method), + None => None, + }; let _ = rpc_tx.send(RpcReq { route: route.clone(), + meta: meta.copied(), method: method.to_string(), arg, }); self.last_rpc_result = Some((format!("Sent to {}...", route), Color::Yellow)); self.input_state = TextState::default(); self.input_state.focus(); + self.update_command_list(); + self.present_command = String::new(); } } + fn update_rpclists(&mut self, list: RpcList) { + self.rpcs.insert(list.route.clone(), list); + } + fn navigate_history(&mut self, dir: i8) { if self.cmd_history.is_empty() { return; } let new_ptr = match (self.history_ptr, dir) { - (None, -1) => Some(self.cmd_history.len() - 1), + (None, -1) => { + self.present_command = self.input_state.value().to_string(); + Some(self.cmd_history.len() - 1) + }, (Some(i), -1) => Some(i.saturating_sub(1)), (Some(i), 1) => { if i + 1 >= self.cmd_history.len() { @@ -615,9 +724,10 @@ impl App { self.input_state.focus(); self.input_state.move_end(); } else { - self.input_state = TextState::default(); + self.input_state = TextState::new().with_value(self.present_command.clone()); self.input_state.focus(); } + self.update_command_list(); } pub fn visible_routes(&self) -> Vec { @@ -680,6 +790,11 @@ impl App { } } + pub fn rpc_list_len(&self) -> u16 { + let length: usize = self.suggested_rpcs_len; + std::cmp::min(length, RPCLIST_MAX_LEN).try_into().unwrap() + } + pub fn current_pos(&self) -> Option<&NavPos> { self.nav_items.get(self.nav.idx) } @@ -702,12 +817,19 @@ impl App { self.visible_routes().len() } - pub fn handle_event(&mut self, event: TreeEvent) { + pub fn handle_event(&mut self, event: TreeEvent, cache_req_tx: &Sender) { match event { TreeEvent::RouteDiscovered(route) => { self.discovered_routes.insert(route.clone()); + let _ = cache_req_tx.send(route.clone()); self.device_status.entry(route).or_default(); } + TreeEvent::Device { + route, + event: DeviceEvent::NewHash(_hash), + } => { + let _ = cache_req_tx.send(route); + } TreeEvent::Device { route, event: DeviceEvent::Heartbeat { .. }, @@ -718,10 +840,13 @@ impl App { route, event: DeviceEvent::Status(status), } => { - let dev_status = self.device_status.entry(route).or_default(); + let dev_status = self.device_status.entry(route.clone()).or_default(); match status { ProxyStatus::SensorDisconnected => dev_status.connected = false, - ProxyStatus::SensorReconnected => dev_status.connected = true, + ProxyStatus::SensorReconnected => { + //let _ = cache_req_tx.send(route); + dev_status.connected = true; + } _ => {} } } @@ -874,25 +999,33 @@ fn get_action(ev: Event, app: &mut App) -> Option { match app.mode { Mode::Command => match k.code { KeyCode::Esc => Some(Action::SetMode(Mode::Normal)), + KeyCode::Tab => Some(Action::AutoCompleteTab), + KeyCode::BackTab => Some(Action::AutoCompleteBack), KeyCode::Up => Some(Action::HistoryNavigate(-1)), KeyCode::Down => Some(Action::HistoryNavigate(1)), + KeyCode::Right if !app.current_completion.is_empty() => { + Some(Action::AcceptCompletion) + }, + KeyCode::Right => { + app.input_state.handle_key_event(k); + None + }, + KeyCode::Left => { + app.current_completion = String::new(); + app.input_state.handle_key_event(k); + None + }, KeyCode::Enter => Some(Action::SubmitCommand), _ => { app.input_state.handle_key_event(k); - None + Some(Action::NewCommandString) } }, Mode::Normal => match k.code { KeyCode::Char(':') => Some(Action::SetMode(Mode::Command)), KeyCode::Char('q') => Some(Action::Quit), KeyCode::Char('c') if k.modifiers == KeyModifiers::CONTROL => Some(Action::Quit), - KeyCode::Esc => { - if app.view.show_plot { - Some(Action::ClosePlot) - } else { - Some(Action::Quit) - } - } + KeyCode::Esc => Some(Action::ClosePlot), KeyCode::Up => Some(Action::NavUp), KeyCode::Down => Some(Action::NavDown), KeyCode::Left => Some(Action::NavLeft), @@ -924,22 +1057,34 @@ fn get_action(ev: Event, app: &mut App) -> Option { fn draw_ui(terminal: &mut Terminal, app: &mut App) -> io::Result<()> { terminal.draw(|f| { let size = f.area(); + let height = size.height; + let (main_area, footer_area) = { - let footer_h = if app.mode == Mode::Command { - 4 + let (main_constraint, footer_constraint) = if app.mode == Mode::Command { + if height >= 18 { + (Constraint::Min(10), Constraint::Length(5 + app.rpc_list_len())) + } else if height >= 12 { + (Constraint::Min(2), Constraint::Length(8)) + } else if height >= 5 { + (Constraint::Min(2), Constraint::Length(3)) + } else { + (Constraint::Min(0), Constraint::Length(2)) + } } else if app.view.show_footer { - 6 + (Constraint::Min(10), Constraint::Length(6)) } else { - 2 + (Constraint::Min(10), Constraint::Length(2)) }; let chunks = Layout::default() .direction(Direction::Vertical) - .constraints([Constraint::Min(10), Constraint::Length(footer_h)]) + .constraints([main_constraint, footer_constraint]) .split(size); (chunks[0], Some(chunks[1])) }; - let (left, right) = if app.view.show_plot { + let (left, right) = if app.mode == Mode::Command && height < 3 { + (None, None) + } else if app.view.show_plot { let chunks = Layout::default() .direction(Direction::Horizontal) .constraints([ @@ -947,18 +1092,14 @@ fn draw_ui(terminal: &mut Terminal, app: &mut App) -> io::Result< Constraint::Percentage(app.view.plot_width_percent), ]) .split(main_area); - (chunks[0], Some(chunks[1])) + (Some(chunks[0]), Some(chunks[1])) } else { - (main_area, None) + (Some(main_area), None) }; - render_monitor_panel(f, app, left, Instant::now()); - if let Some(r) = right { - render_graphics_panel(f, app, r); - } - if let Some(foot) = footer_area { - render_footer(f, app, foot); - } + if let Some(l) = left { render_monitor_panel(f, app, l, Instant::now()); } + if let Some(r) = right { render_graphics_panel(f, app, r); } + if let Some(foot) = footer_area { render_footer(f, app, foot); } })?; Ok(()) } @@ -1167,50 +1308,85 @@ fn build_left_lines(app: &mut App, now: Instant) -> (Vec>, HashMap } fn render_footer(f: &mut Frame, app: &mut App, area: Rect) { + app.footer_height = area.height; // how many lines the footer has to work with if app.mode == Mode::Command { let chunks = Layout::default() .direction(Direction::Vertical) - .constraints([Constraint::Length(1), Constraint::Min(1)]) + .constraints([ + Constraint::Max(app.rpc_list_len() + 2), + Constraint::Length(std::cmp::min(1, app.footer_height - 1)), + Constraint::Length(if app.footer_height > 2 {2} else {1}), + ]) .split(area); - if let Some((msg, color)) = &app.last_rpc_result { + if app.footer_height > 3 { + let rpcs: Vec = app.suggested_rpcs.iter() + .map(|v| Span::raw(v.clone())) + .enumerate() + .map(|(i, v)| if i == app.suggested_rpcs_ind {v.bold()} else {v}) + .collect(); + + let rpc_block = Block::default() + .borders(Borders::ALL) + .title(Line::from(" RPCs ").left_aligned()) + .title(Line::from(" ↑ Shift+Tab | Tab ↓ ").right_aligned()); f.render_widget( - Paragraph::new(msg.as_str()) - .style(Style::default().fg(*color).add_modifier(Modifier::BOLD)), - chunks[0], + List::new(rpcs).block(rpc_block), chunks[0], ); } + if app.footer_height > 1 { + if let Some((msg, color)) = &app.last_rpc_result { + f.render_widget( + Paragraph::new(msg.as_str()) + .style(Style::default().fg(*color).add_modifier(Modifier::BOLD)), + chunks[1], + ); + } + } + let target_route = app.current_route(); - let val = app.input_state.value(); - let cursor_idx = app.input_state.position().min(val.len()); + let user_input = app.input_state.value(); + let cursor_idx = app.input_state.position().min(user_input.len()); let mut spans = vec![ Span::styled( format!("[{}] ", target_route), Style::default().fg(Color::Blue), ), - Span::raw(&val[0..cursor_idx]), + Span::raw(&user_input[0..cursor_idx]), ]; - if cursor_idx < val.len() { + if cursor_idx < user_input.len() { spans.push(Span::styled( - &val[cursor_idx..cursor_idx + 1], + &user_input[cursor_idx..cursor_idx + 1], if app.blink_state { Style::default().bg(Color::White).fg(Color::Black) } else { Style::default() }, )); - spans.push(Span::raw(&val[cursor_idx + 1..])); + spans.push(Span::raw(&user_input[cursor_idx + 1..])); } else if app.blink_state { spans.push(Span::styled(" ", Style::default().bg(Color::White))); + if !app.current_completion.is_empty() { + spans.push(Span::styled(&app.current_completion[1..], + Style::default().fg(Color::Gray))); + } + } else { + spans.push(Span::styled(&app.current_completion, + Style::default().fg(Color::Gray))); } - let block = Block::default() - .borders(Borders::TOP) - .title(" Command Mode "); - f.render_widget(Paragraph::new(Line::from(spans)).block(block), chunks[1]); + let block = if app.footer_height < 3 { + Block::default() + } else { + Block::default().borders(Borders::TOP) + .title(Line::from(" Command Mode ").left_aligned()) + .title(Line::from(" ").right_aligned()) + }; + + f.render_widget(Paragraph::new(Line::from(spans)).block(block), chunks[2]); return; } @@ -1588,8 +1764,9 @@ fn get_num(it: &InlineTable, k: &str) -> Option { it.get(k) .and_then(|v| v.as_float().or(v.as_integer().map(|i| i as f64))) } + fn main() { - let cli = Cli::parse(); + let cli = MonitorCli::parse(); let proxy = tio::proxy::Interface::new(&cli.tio.root); let parent_route: DeviceRoute = cli.tio.parse_route(); @@ -1611,11 +1788,25 @@ fn main() { } }); + + // Cache thread + let (cache_req_tx, cache_req_rx) = channel::bounded::(1); + let (cache_resp_tx, cache_resp_rx) = channel::bounded::(1); + let cache_client = RpcClient::open(&proxy, parent_route.clone()) + .expect("Failed to open RPC client"); + std::thread::spawn(move || { + while let Ok(req) = cache_req_rx.recv() { + let Ok(list) = cache_client.rpc_list(&req) else { return }; + if cache_resp_tx.send(list).is_err() { return }; + } + }); + // RPC thread + let rpc_client = RpcClient::open(&proxy, parent_route.clone()) + .expect("Failed to open RPC client"); let (rpc_tx, rpc_rx) = channel::bounded::(1); let (rpc_resp_tx, rpc_resp_rx) = channel::bounded::(1); - let rpc_client = - RpcClient::open(&proxy, parent_route.clone()).expect("Failed to open RPC client"); + std::thread::spawn(move || { while let Ok(req) = rpc_rx.recv() { let result = exec_rpc(&rpc_client, &req); @@ -1630,7 +1821,7 @@ fn main() { std::thread::spawn(move || loop { if let Ok(ev) = event::read() { if key_tx.send(ev).is_err() { - break; + return; } } }); @@ -1660,7 +1851,7 @@ fn main() { app.handle_sample(sample, route, &mut buffer); } Ok(TreeItem::Event(event)) => { - app.handle_event(event); + app.handle_event(event, &cache_req_tx); } Err(_) => break 'main, } @@ -1676,10 +1867,16 @@ fn main() { } } + recv(cache_resp_rx) -> list => { + if let Ok(list) = list { + app.update_rpclists(list); + } + } + recv(rpc_resp_rx) -> res => { if let Ok(res) = res { let (msg, col) = match res.result { - Ok(s) => (format!("OK: {}", s), Color::Green), + Ok(s) => (format!("{}: {}", app.last_rpc_command, s), Color::Green), Err(s) => (format!("ERR: {}", s), Color::Red), }; app.last_rpc_result = Some((msg, col)); diff --git a/twinleaf-tools/src/bin/tio-proxy.rs b/twinleaf-tools/src/bin/tio-proxy.rs index 794d198..78fdeeb 100644 --- a/twinleaf-tools/src/bin/tio-proxy.rs +++ b/twinleaf-tools/src/bin/tio-proxy.rs @@ -10,71 +10,7 @@ use std::process::ExitCode; use std::time::Duration; use tio::{proto, proxy}; use twinleaf::tio; - -#[derive(Parser, Debug)] -#[command( - name = "tio-proxy", - version, - about = "Multiplexes access to a sensor, exposing the functionality of tio::proxy via TCP" -)] -struct Cli { - /// Sensor URL (e.g., tcp://localhost, serial:///dev/ttyUSB0) - /// Required unless --auto or --enum is specified - sensor_url: Option, - - /// TCP port to listen on for clients - #[arg(short = 'p', long = "port", default_value = "7855")] - port: u16, - - /// Kick off slow clients instead of dropping traffic - #[arg(short = 'k', long)] - kick_slow: bool, - - /// Sensor subtree to look at - #[arg(short = 's', long = "subtree", default_value = "/")] - subtree: String, - - /// Verbose output - #[arg(short = 'v', long)] - verbose: bool, - - /// Debugging output - #[arg(short = 'd', long)] - debug: bool, - - /// Timestamp format - #[arg(short = 't', long = "timestamp", default_value = "%T%.3f ")] - timestamp_format: String, - - /// Time limit for sensor reconnection attempts (seconds) - #[arg(short = 'T', long = "timeout", default_value = "30")] - reconnect_timeout: u64, - - /// Dump packet traffic except sample data/metadata or heartbeats - #[arg(long)] - dump: bool, - - /// Dump sample data traffic - #[arg(long)] - dump_data: bool, - - /// Dump sample metadata traffic - #[arg(long)] - dump_meta: bool, - - /// Dump heartbeat traffic - #[arg(long)] - dump_hb: bool, - - /// Automatically connect to a USB sensor if there is a single device - #[arg(short = 'a', long = "auto")] - auto: bool, - - /// Enumerate all serial devices, then quit - #[arg(short = 'e', long = "enumerate", name = "enum")] - enumerate: bool, -} - +use twinleaf_tools::ProxyCli; // Unfortunately we cannot access USB details via the serialport module, so // we are stuck guessing based on VID/PID. This returns a vector of possible // serial ports. @@ -154,7 +90,7 @@ fn create_listener_thread( } fn main() -> ExitCode { - let cli = Cli::parse(); + let cli = ProxyCli::parse(); macro_rules! die { ($f:expr,$($a:tt)*) => { diff --git a/twinleaf-tools/src/bin/tio-tool.rs b/twinleaf-tools/src/bin/tio-tool.rs index 72f54df..b06d492 100644 --- a/twinleaf-tools/src/bin/tio-tool.rs +++ b/twinleaf-tools/src/bin/tio-tool.rs @@ -1,307 +1,32 @@ -use clap::{Parser, Subcommand, ValueEnum}; -use tio::proto::DeviceRoute; -use tio::proxy; -use tio::util; -use twinleaf::data::DeviceDataParser; -use twinleaf::device::{Device, DeviceTree}; -use twinleaf::tio; -use twinleaf_tools::TioOpts; - use std::collections::HashMap; use std::fs::File; use std::fs::OpenOptions; use std::io::prelude::*; use std::process::ExitCode; -#[derive(Parser, Debug)] -#[command( - name = "tio-tool", - version, - about = "Twinleaf sensor management and data logging tool" -)] -struct Cli { - #[command(subcommand)] - command: Commands, -} - -/// Controls how runs are organized in the HDF5 output -#[derive(ValueEnum, Clone, Debug, Default)] -enum SplitLevel { - /// No run splitting - flat structure: /{route}/{stream}/{datasets} - #[default] - None, - /// Each stream has independent run counter - Stream, - /// All streams on a device share run counter - Device, - /// All streams globally share run counter - Global, -} - -#[cfg(feature = "hdf5")] -impl From for twinleaf::data::export::RunSplitLevel { - fn from(level: SplitLevel) -> Self { - match level { - SplitLevel::None => Self::None, - SplitLevel::Stream => Self::PerStream, - SplitLevel::Device => Self::PerDevice, - SplitLevel::Global => Self::Global, - } - } -} - -/// Controls when discontinuities trigger run splits -#[derive(ValueEnum, Clone, Debug, Default)] -enum SplitPolicy { - /// Split on any discontinuity (gaps, rate changes, etc.) - #[default] - Continuous, - /// Only split when time goes backward (allows gaps) - Monotonic, -} - -#[cfg(feature = "hdf5")] -impl From for twinleaf::data::export::SplitPolicy { - fn from(policy: SplitPolicy) -> Self { - match policy { - SplitPolicy::Continuous => Self::Continuous, - SplitPolicy::Monotonic => Self::Monotonic, - } - } -} - -#[derive(Subcommand, Debug)] -enum Commands { - /// List available RPCs on the device - RpcList { - #[command(flatten)] - tio: TioOpts, - }, - - /// Execute an RPC on the device - Rpc { - #[command(flatten)] - tio: TioOpts, - - /// RPC name to execute - rpc_name: String, - - /// RPC argument value - #[arg( - allow_negative_numbers = true, - value_name = "ARG", - help_heading = "RPC Arguments" - )] - rpc_arg: Option, - - /// RPC request type (one of: u8, u16, u32, u64, i8, i16, i32, i64, f32, f64, string) - #[arg(short = 't', long = "req-type", help_heading = "Type Options")] - req_type: Option, - - /// RPC reply type (one of: u8, u16, u32, u64, i8, i16, i32, i64, f32, f64, string) - #[arg(short = 'T', long = "rep-type", help_heading = "Type Options")] - rep_type: Option, - - /// Enable debug output - #[arg(short = 'd', long)] - debug: bool, - }, - - /// Dump RPC data from the device - RpcDump { - #[command(flatten)] - tio: TioOpts, - - /// RPC name to dump - rpc_name: String, - - /// Trigger a capture before dumping - #[arg(long)] - capture: bool, - }, - - /// Dump data from a live device - Dump { - #[command(flatten)] - tio: TioOpts, - - /// Show parsed data samples - #[arg(short = 'd', long = "data")] - data: bool, - - /// Show metadata on boundaries - #[arg(short = 'm', long = "meta")] - meta: bool, - - /// Routing depth limit (default: unlimited) - #[arg(long = "depth")] - depth: Option, - }, - - /// Log samples to a file (includes metadata by default) - Log { - #[command(flatten)] - tio: TioOpts, - - /// Output log file path - #[arg(short = 'f', default_value_t = default_log_path())] - file: String, - - /// Unbuffered output (flush every packet) - #[arg(short = 'u')] - unbuffered: bool, - - /// Raw mode: skip metadata request and dump all packets - #[arg(long)] - raw: bool, - - /// Routing depth (only used in --raw mode) - #[arg(long = "depth")] - depth: Option, - }, - - /// Log metadata to a file - LogMetadata { - #[command(flatten)] - tio: TioOpts, - - /// Output metadata file path - #[arg(short = 'f', default_value = "meta.tio")] - file: String, - }, - - /// Dump data from binary log file(s) - LogDump { - /// Input log file(s) - files: Vec, - - /// Show parsed data samples - #[arg(short = 'd', long = "data")] - data: bool, - - /// Show metadata on boundaries - #[arg(short = 'm', long = "meta")] - meta: bool, - - /// Sensor path in the sensor tree (e.g., /, /0, /0/1) - #[arg(short = 's', long = "sensor", default_value = "/")] - sensor: String, - - /// Routing depth limit (default: unlimited) - #[arg(long = "depth")] - depth: Option, - }, - - /// Dump parsed data from binary log file(s) [DEPRECATED: use log-dump -d] - #[command(hide = true)] - LogDataDump { - /// Input log file(s) - files: Vec, - }, - - /// Convert binary log data to CSV - LogCsv { - /// Stream ID (e.g., 1) or Name (e.g., "vector", "field") - stream: String, - - /// Input log file(s) - files: Vec, - - /// Sensor route in the device tree (default: /) - #[arg(short = 's')] - sensor: Option, - - /// External metadata file path (optional) - #[arg(short = 'm')] - metadata: Option, - - /// Output filename prefix - #[arg(short = 'o')] - output: Option, - }, - - /// Convert binary log files to HDF5 format - LogHdf { - /// Input log file(s) - files: Vec, - - /// Output file path (defaults to input filename with .h5 extension) - #[arg(short = 'o')] - output: Option, - - /// Filter streams using a glob pattern (e.g. "/*/vector") - #[arg(short = 'g', long = "glob")] - filter: Option, - - /// Enable deflate compression (saves space, slows down write significantly) - #[arg(short = 'c', long = "compress")] - compress: bool, - - /// Enable debug output for glob matching - #[arg(short = 'd', long)] - debug: bool, - - /// How to organize runs in the output (none=flat, stream=per-stream, device=per-device, global=all-shared) - #[arg(short = 'l', long = "split", default_value = "none")] - split_level: SplitLevel, - - /// When to detect discontinuities (continuous=any gap, monotonic=only time backward) - #[arg(short = 'p', long = "policy", default_value = "continuous")] - split_policy: SplitPolicy, - }, - - /// Upgrade device firmware - FirmwareUpgrade { - #[command(flatten)] - tio: TioOpts, - - /// Input firmware image path - firmware_path: String, - }, - - /// Dump data samples from the device [DEPRECATED: use dump -d -s ] - #[command(hide = true)] - DataDump { - #[command(flatten)] - tio: TioOpts, - }, - - /// Dump data samples from all devices in the tree [DEPRECATED: use dump -a -d] - #[command(hide = true)] - DataDumpAll { - #[command(flatten)] - tio: TioOpts, - }, - - /// Dump device metadata [DEPRECATED: use dump -m -s ] - #[command(hide = true)] - MetaDump { - #[command(flatten)] - tio: TioOpts, - }, -} - -fn default_log_path() -> String { - chrono::Local::now() - .format("log.%Y%m%d-%H%M%S.tio") - .to_string() -} +use clap::{Parser}; +use tio::proto::DeviceRoute; +use tio::proxy; +use tio::util; +use twinleaf::data::DeviceDataParser; +use twinleaf::device::{Device, DeviceTree, RpcClient}; +use twinleaf::tio; +use twinleaf_tools::TioOpts; +use twinleaf_tools::{TioToolCli, SplitLevel, SplitPolicy, Commands}; fn list_rpcs(tio: &TioOpts) -> Result<(), ()> { let proxy = proxy::Interface::new(&tio.root); let route = tio.parse_route(); - let device = proxy.device_rpc(route).unwrap(); - - let nrpcs: u16 = device.get("rpc.listinfo").map_err(|e| { - eprintln!("Failed to get RPC count: {:?}", e); + let rpc_client = RpcClient::open(&proxy, route.clone()).expect("Failed to open RPC client"); + let rpcs = rpc_client.rpc_list(&route).map_err(|e| { + eprintln!("RPC list failed: {:?}", e); })?; - for id in 0..nrpcs { - let (meta, name): (u16, String) = device.rpc("rpc.listinfo", id).map_err(|e| { - eprintln!("Failed to get RPC {}: {:?}", id, e); - })?; - - let spec = twinleaf::device::util::parse_rpc_spec(meta, name); + for (name, _) in rpcs.list { + let spec = twinleaf::device::util::parse_rpc_spec( + *rpcs.map.get(&name).unwrap(), + name.to_string() + ); println!( "{} {}({})", spec.perm_str(), @@ -1207,7 +932,7 @@ fn firmware_upgrade(tio: &TioOpts, firmware_path: String) -> Result<(), ()> { } fn main() -> ExitCode { - let cli = Cli::parse(); + let cli = TioToolCli::parse(); let result = match cli.command { Commands::RpcList { tio } => list_rpcs(&tio), diff --git a/twinleaf-tools/src/health_cli.rs b/twinleaf-tools/src/health_cli.rs new file mode 100644 index 0000000..3f59a58 --- /dev/null +++ b/twinleaf-tools/src/health_cli.rs @@ -0,0 +1,128 @@ +use std::num::ParseFloatError; + +#[derive(Parser, Debug, Clone)] +#[command( + name = "tio-health", + version, + about = "Live timing & rate diagnostics for TIO (Twinleaf) devices" +)] +pub struct HealthCli { + #[command(flatten)] + pub tio: TioOpts, + + /// Time window in seconds for calculating sample rate + #[arg( + long = "rate-window", + default_value = "5", + value_name = "SECONDS", + value_parser = clap::value_parser!(u64).range(1..), + )] + pub rate_window: u64, + + /// Time window in seconds for calculating jitter statistics + #[arg( + long = "jitter-window", + default_value = "10", + value_name = "SECONDS", + value_parser = clap::value_parser!(u64).range(1..), + help = "Seconds for jitter calculation window (>= 1)" + )] + pub jitter_window: u64, + + /// PPM threshold for yellow warning indicators + #[arg( + long = "ppm-warn", + default_value = "100", + value_name = "PPM", + value_parser = nonneg_f64, + help = "Warning threshold in parts per million (>= 0)" + )] + pub ppm_warn: f64, + + /// PPM threshold for red error indicators + #[arg( + long = "ppm-err", + default_value = "200", + value_name = "PPM", + value_parser = nonneg_f64, + help = "Error threshold in parts per million (>= 0)" + )] + pub ppm_err: f64, + + /// Filter to only show specific stream IDs (comma-separated) + #[arg( + long = "streams", + value_delimiter = ',', + value_name = "IDS", + value_parser = clap::value_parser!(u8), + help = "Comma-separated stream IDs to monitor (e.g., 0,1,5)" + )] + pub streams: Option>, + + /// Suppress the footer help text + #[arg(short = 'q', long = "quiet")] + pub quiet: bool, + + /// UI refresh rate for animations and stale detection (data updates are immediate) + #[arg( + long = "fps", + default_value = "30", + value_name = "FPS", + value_parser = clap::value_parser!(u64).range(1..=60), + help = "UI refresh rate for heartbeat animation and stale detection (1–60)" + )] + pub fps: u64, + + /// Time in milliseconds before marking a stream as stale + #[arg( + long = "stale-ms", + default_value = "2000", + value_name = "MS", + value_parser = clap::value_parser!(u64).range(1..), + help = "Mark streams as stale after this many milliseconds without data (>= 1)" + )] + pub stale_ms: u64, + + /// Maximum number of events to keep in the event log + #[arg( + short = 'n', + long = "event-log-size", + default_value = "100", + value_name = "N", + value_parser = clap::value_parser!(u64).range(1..), + help = "Maximum number of events to keep in history (>= 1)" + )] + pub event_log_size: u64, + + /// Number of event lines to display on screen + #[arg( + long = "event-display-lines", + default_value = "8", + value_name = "LINES", + value_parser = clap::value_parser!(u16).range(3..), + help = "Number of event lines to show (>= 3)" + )] + pub event_display_lines: u16, + + /// Only show warning and error events in the log + #[arg(short = 'w', long = "warnings-only")] + pub warnings_only: bool, +} + +impl HealthCli { + pub fn rate_window_dur(&self) -> Duration { + Duration::from_secs(self.rate_window) + } + pub fn stale_dur(&self) -> Duration { + Duration::from_millis(self.stale_ms) + } +} + +fn nonneg_f64(s: &str) -> Result { + let v: f64 = s.parse().map_err(|e: ParseFloatError| e.to_string())?; + if v < 0.0 { + Err("must be ≥ 0".into()) + } else { + Ok(v) + } +} diff --git a/twinleaf-tools/src/lib.rs b/twinleaf-tools/src/lib.rs index 9a3029f..3af4b97 100644 --- a/twinleaf-tools/src/lib.rs +++ b/twinleaf-tools/src/lib.rs @@ -1,8 +1,12 @@ -use clap::Parser; use tio::proto::DeviceRoute; use tio::util; use twinleaf::tio; +use clap::Parser; +include!("proxy_cli.rs"); +include!("tool_cli.rs"); +include!("monitor_cli.rs"); +include!("health_cli.rs"); #[derive(Parser, Debug, Clone)] pub struct TioOpts { /// Sensor root address (e.g., tcp://localhost, serial:///dev/ttyUSB0) diff --git a/twinleaf-tools/src/monitor_cli.rs b/twinleaf-tools/src/monitor_cli.rs new file mode 100644 index 0000000..ad287ba --- /dev/null +++ b/twinleaf-tools/src/monitor_cli.rs @@ -0,0 +1,12 @@ +#[derive(Parser, Debug)] +#[command(name = "tio-monitor", version, about = "Display live sensor data")] +pub struct MonitorCli { + #[command(flatten)] + pub tio: TioOpts, + #[arg(short = 'a', long = "all")] + pub all: bool, + #[arg(long = "fps", default_value_t = 20)] + pub fps: u32, + #[arg(short = 'c', long = "colors")] + pub colors: Option, +} diff --git a/twinleaf-tools/src/proxy_cli.rs b/twinleaf-tools/src/proxy_cli.rs new file mode 100644 index 0000000..b441415 --- /dev/null +++ b/twinleaf-tools/src/proxy_cli.rs @@ -0,0 +1,62 @@ +#[derive(Parser, Debug)] +#[command( + name = "tio-proxy", + version, + about = "Multiplexes access to a sensor, exposing the functionality of tio::proxy via TCP" +)] +pub struct ProxyCli { + /// Sensor URL (e.g., tcp://localhost, serial:///dev/ttyUSB0) + /// Required unless --auto or --enum is specified + pub sensor_url: Option, + + /// TCP port to listen on for clients + #[arg(short = 'p', long = "port", default_value = "7855")] + pub port: u16, + + /// Kick off slow clients instead of dropping traffic + #[arg(short = 'k', long)] + pub kick_slow: bool, + + /// Sensor subtree to look at + #[arg(short = 's', long = "subtree", default_value = "/")] + pub subtree: String, + + /// Verbose output + #[arg(short = 'v', long)] + pub verbose: bool, + + /// Debugging output + #[arg(short = 'd', long)] + pub debug: bool, + + /// Timestamp format + #[arg(short = 't', long = "timestamp", default_value = "%T%.3f ")] + pub timestamp_format: String, + + /// Time limit for sensor reconnection attempts (seconds) + #[arg(short = 'T', long = "timeout", default_value = "30")] + pub reconnect_timeout: u64, + + /// Dump packet traffic except sample data/metadata or heartbeats + #[arg(long)] + pub dump: bool, + + /// Dump sample data traffic + #[arg(long)] + pub dump_data: bool, + + /// Dump sample metadata traffic + #[arg(long)] + pub dump_meta: bool, + + /// Dump heartbeat traffic + #[arg(long)] + pub dump_hb: bool, + + #[arg(short = 'a', long = "auto")] + pub auto: bool, + + /// Enumerate all serial devices, then quit + #[arg(short = 'e', long = "enumerate", name = "enum")] + pub enumerate: bool, +} diff --git a/twinleaf-tools/src/tool_cli.rs b/twinleaf-tools/src/tool_cli.rs new file mode 100644 index 0000000..c373b0a --- /dev/null +++ b/twinleaf-tools/src/tool_cli.rs @@ -0,0 +1,276 @@ +use std::time::Duration; +use clap::{Subcommand, ValueEnum}; + +#[derive(Parser, Debug)] +#[command( + name = "tio-tool", + version, + about = "Twinleaf sensor management and data logging tool" +)] +pub struct TioToolCli { + #[command(subcommand)] + pub command: Commands, +} + +#[derive(Subcommand, Debug)] +pub enum Commands { + /// List available RPCs on the device + RpcList { + #[command(flatten)] + tio: TioOpts, + }, + + /// Execute an RPC on the device + Rpc { + #[command(flatten)] + tio: TioOpts, + + /// RPC name to execute + rpc_name: String, + + /// RPC argument value + #[arg( + allow_negative_numbers = true, + value_name = "ARG", + help_heading = "RPC Arguments" + )] + rpc_arg: Option, + + /// RPC request type (one of: u8, u16, u32, u64, i8, i16, i32, i64, f32, f64, string) + #[arg(short = 't', long = "req-type", help_heading = "Type Options")] + req_type: Option, + + /// RPC reply type (one of: u8, u16, u32, u64, i8, i16, i32, i64, f32, f64, string) + #[arg(short = 'T', long = "rep-type", help_heading = "Type Options")] + rep_type: Option, + + /// Enable debug output + #[arg(short = 'd', long)] + debug: bool, + }, + + /// Dump RPC data from the device + RpcDump { + #[command(flatten)] + tio: TioOpts, + + /// RPC name to dump + rpc_name: String, + + /// Trigger a capture before dumping + #[arg(long)] + capture: bool, + }, + + /// Dump data from a live device + Dump { + #[command(flatten)] + tio: TioOpts, + + /// Show parsed data samples + #[arg(short = 'd', long = "data")] + data: bool, + + /// Show metadata on boundaries + #[arg(short = 'm', long = "meta")] + meta: bool, + + /// Routing depth limit (default: unlimited) + #[arg(long = "depth")] + depth: Option, + }, + + /// Log samples to a file (includes metadata by default) + Log { + #[command(flatten)] + tio: TioOpts, + + /// Output log file path + #[arg(short = 'f', default_value_t = default_log_path())] + file: String, + + /// Unbuffered output (flush every packet) + #[arg(short = 'u')] + unbuffered: bool, + + /// Raw mode: skip metadata request and dump all packets + #[arg(long)] + raw: bool, + + /// Routing depth (only used in --raw mode) + #[arg(long = "depth")] + depth: Option, + }, + + /// Log metadata to a file + LogMetadata { + #[command(flatten)] + tio: TioOpts, + + /// Output metadata file path + #[arg(short = 'f', default_value = "meta.tio")] + file: String, + }, + + /// Dump data from binary log file(s) + LogDump { + /// Input log file(s) + files: Vec, + + /// Show parsed data samples + #[arg(short = 'd', long = "data")] + data: bool, + + /// Show metadata on boundaries + #[arg(short = 'm', long = "meta")] + meta: bool, + + /// Sensor path in the sensor tree (e.g., /, /0, /0/1) + #[arg(short = 's', long = "sensor", default_value = "/")] + sensor: String, + + /// Routing depth limit (default: unlimited) + #[arg(long = "depth")] + depth: Option, + }, + + /// Dump parsed data from binary log file(s) [DEPRECATED: use log-dump -d] + #[command(hide = true)] + LogDataDump { + /// Input log file(s) + files: Vec, + }, + + /// Convert binary log data to CSV + LogCsv { + /// Stream ID (e.g., 1) or Name (e.g., "vector", "field") + stream: String, + + /// Input log file(s) + files: Vec, + + /// Sensor route in the device tree (default: /) + #[arg(short = 's')] + sensor: Option, + + /// External metadata file path (optional) + #[arg(short = 'm')] + metadata: Option, + + /// Output filename prefix + #[arg(short = 'o')] + output: Option, + }, + + /// Convert binary log files to HDF5 format + LogHdf { + /// Input log file(s) + files: Vec, + + /// Output file path (defaults to input filename with .h5 extension) + #[arg(short = 'o')] + output: Option, + + /// Filter streams using a glob pattern (e.g. "/*/vector") + #[arg(short = 'g', long = "glob")] + filter: Option, + + /// Enable deflate compression (saves space, slows down write significantly) + #[arg(short = 'c', long = "compress")] + compress: bool, + + /// Enable debug output for glob matching + #[arg(short = 'd', long)] + debug: bool, + + /// How to organize runs in the output (none=flat, stream=per-stream, device=per-device, global=all-shared) + #[arg(short = 'l', long = "split", default_value = "none")] + split_level: SplitLevel, + + /// When to detect discontinuities (continuous=any gap, monotonic=only time backward) + #[arg(short = 'p', long = "policy", default_value = "continuous")] + split_policy: SplitPolicy, + }, + + /// Upgrade device firmware + FirmwareUpgrade { + #[command(flatten)] + tio: TioOpts, + + /// Input firmware image path + firmware_path: String, + }, + + /// Dump data samples from the device [DEPRECATED: use dump -d -s ] + #[command(hide = true)] + DataDump { + #[command(flatten)] + tio: TioOpts, + }, + + /// Dump data samples from all devices in the tree [DEPRECATED: use dump -a -d] + #[command(hide = true)] + DataDumpAll { + #[command(flatten)] + tio: TioOpts, + }, + + /// Dump device metadata [DEPRECATED: use dump -m -s ] + #[command(hide = true)] + MetaDump { + #[command(flatten)] + tio: TioOpts, + }, +} + +fn default_log_path() -> String { + chrono::Local::now() + .format("log.%Y%m%d-%H%M%S.tio") + .to_string() +} + +/// Controls when discontinuities trigger run splits +#[derive(ValueEnum, Clone, Debug, Default)] +pub enum SplitPolicy { + /// Split on any discontinuity (gaps, rate changes, etc.) + #[default] + Continuous, + /// Only split when time goes backward (allows gaps) + Monotonic, +} + +#[cfg(feature = "hdf5")] +impl From for twinleaf::data::export::SplitPolicy { + fn from(policy: SplitPolicy) -> Self { + match policy { + SplitPolicy::Continuous => Self::Continuous, + SplitPolicy::Monotonic => Self::Monotonic, + } + } +} + +/// Controls how runs are organized in the HDF5 output +#[derive(ValueEnum, Clone, Debug, Default)] +pub enum SplitLevel { + /// No run splitting - flat structure: /{route}/{stream}/{datasets} + #[default] + None, + /// Each stream has independent run counter + Stream, + /// All streams on a device share run counter + Device, + /// All streams globally share run counter + Global, +} + +#[cfg(feature = "hdf5")] +impl From for twinleaf::data::export::RunSplitLevel { + fn from(level: SplitLevel) -> Self { + match level { + SplitLevel::None => Self::None, + SplitLevel::Stream => Self::PerStream, + SplitLevel::Device => Self::PerDevice, + SplitLevel::Global => Self::Global, + } + } +} diff --git a/twinleaf/Cargo.toml b/twinleaf/Cargo.toml index 22e6205..2dce5b2 100644 --- a/twinleaf/Cargo.toml +++ b/twinleaf/Cargo.toml @@ -19,6 +19,7 @@ crc = "3.2" num_enum = "0.7" glob = "0.3.2" hdf5 = { package = "hdf5-metno", version = "0.11.0", features = ["static", "zlib", "blosc"], optional = true} +dirs-next = "2.0.0" [dependencies.mio] version = "1.0" diff --git a/twinleaf/src/device/device.rs b/twinleaf/src/device/device.rs index 9d352ef..f4a96b6 100644 --- a/twinleaf/src/device/device.rs +++ b/twinleaf/src/device/device.rs @@ -45,6 +45,8 @@ pub enum DeviceEvent { }, MetadataReady(DeviceFullMetadata), + + NewHash(u32), } pub enum DeviceItem { @@ -108,6 +110,15 @@ impl Device { self.event_queue .push_back(DeviceEvent::Heartbeat { session_id }); } + tio::proto::Payload::Settings(set) => { + match set.name.as_str() { + "rpc.hash" => { + let hash = u32::from_le_bytes(set.reply.clone().try_into().unwrap()); + self.event_queue.push_back(DeviceEvent::NewHash(hash)); + }, + _ => {}, + } + } tio::proto::Payload::RpcReply(rep) => { if rep.id == 7855 { self.n_reqs -= 1 diff --git a/twinleaf/src/device/mod.rs b/twinleaf/src/device/mod.rs index ec5a6c7..e82b1a9 100644 --- a/twinleaf/src/device/mod.rs +++ b/twinleaf/src/device/mod.rs @@ -4,5 +4,5 @@ mod tree; pub mod util; pub use device::{Device, DeviceEvent, DeviceItem}; -pub use rpc::{RpcClient, RpcDataKind, RpcMeta, RpcRegistry, RpcValue}; +pub use rpc::{RpcClient, RpcList, RpcDataKind, RpcMeta, RpcRegistry, RpcValue}; pub use tree::{DeviceTree, TreeEvent, TreeItem}; diff --git a/twinleaf/src/device/rpc.rs b/twinleaf/src/device/rpc.rs index 0a661ec..52674b7 100644 --- a/twinleaf/src/device/rpc.rs +++ b/twinleaf/src/device/rpc.rs @@ -2,6 +2,11 @@ use crate::device::util; use crate::tio::{proto::DeviceRoute, proxy, util as tio_util}; use std::collections::{BTreeMap, HashMap}; +// File I/O for cacheing +use std::io::{self, BufRead, Write}; +use std::fs; +use dirs_next::cache_dir; + #[derive(Debug, Clone)] pub enum RpcValue { Unit, @@ -256,6 +261,39 @@ impl RpcRegistry { } } +impl From for RpcListError { + fn from(e: io::Error) -> Self { + RpcListError::CacheFileError(e) + } +} + +#[derive(Debug)] +pub enum RpcListError { + CacheDirError, + CacheCreateError, + + DevNameRpcError, + RpcHashError, + + NumRpcsError, + RpcListError, + CacheWriteError, + RemoveBadCacheError, + + CacheReadError, + InvalidCacheError, + + CacheFileError(io::Error), +} + +#[derive(Debug)] +pub struct RpcList { + pub route: DeviceRoute, + pub hash: u32, + pub list: Vec<(String, u16)>, + pub map: HashMap, +} + pub struct RpcClient { port: proxy::Port, root_route: DeviceRoute, @@ -263,7 +301,7 @@ pub struct RpcClient { impl RpcClient { pub fn new(port: proxy::Port, root_route: DeviceRoute) -> Self { - Self { port, root_route } + Self { port, root_route, } } pub fn open(proxy: &proxy::Interface, route: DeviceRoute) -> Result { @@ -319,11 +357,93 @@ impl RpcClient { self.rpc(route, name, ()) } - pub fn get>( - &self, - route: &DeviceRoute, + pub fn get>( &self, route: &DeviceRoute, name: &str, ) -> Result { self.rpc(route, name, ()) } + + fn read_rpc_cache(&self, route: &DeviceRoute, hash: u32, file: fs::File) -> Result { + let mut list: Vec<(String, u16)> = Vec::new(); + let mut map: HashMap = HashMap::new(); + let reader = io::BufReader::new(file); + + for line in reader.lines() { + let line = line.map_err(|_| RpcListError::CacheReadError)?; + let (meta, name) = line.split_once(' ').ok_or(RpcListError::InvalidCacheError)?; + let meta_hex = u16::from_str_radix(meta, 16).map_err(|_| + RpcListError::InvalidCacheError)?; + let name_string = name.trim().to_string(); + list.push((name_string.clone(), meta_hex)); + map.insert(name_string, meta_hex); + } + + Ok(RpcList { route: route.clone(), hash, list, map }) + } + + fn write_rpc_cache(&self, route: &DeviceRoute, hash: u32, file: fs::File) -> Result { + let mut list: Vec<(String, u16)> = Vec::new(); + let mut map: HashMap = HashMap::new(); + let mut writer = io::BufWriter::new(file); + + let nrpcs: u16 = self.get(route, "rpc.listinfo").map_err(|_| RpcListError::NumRpcsError)?; + + for id in 0..nrpcs { + let (meta, name): (u16, String) = self.rpc(route, "rpc.listinfo", id).map_err(|_| + RpcListError::RpcListError)?; + writeln!(writer, "{:04x} {}", meta, name).map_err(|_| + RpcListError::CacheWriteError)?; + list.push((name.clone(), meta)); + map.insert(name, meta); + } + + Ok(RpcList { route: route.clone(), hash, list, map }) + } + + pub fn rpc_list(&self, route: &DeviceRoute) -> Result { + // Get/create cache directory + let cache_parent_dir = cache_dir().ok_or(RpcListError::CacheDirError)?; + let tl_cache_dir = cache_parent_dir.join("twinleaf"); + fs::create_dir_all(&tl_cache_dir).map_err(|_| RpcListError::CacheDirError)?; + + // Get cache file path + let dev_name: String = self.get(route, "dev.name").map_err(|_| RpcListError::DevNameRpcError)?; + let hash: u32 = self.get(route, "rpc.hash").map_err(|_| RpcListError::RpcHashError)?; + let base_name = format!("{}.{:x}.rpcs", dev_name, hash); + let file_path = tl_cache_dir.join(&base_name); + let metadata = fs::metadata(&file_path).map_err(|e| RpcListError::CacheFileError(e))?; + if metadata.len() == 0 { + fs::remove_file(&file_path)? + }; + + let cache_file = fs::File::open(&file_path); + + // TODO: write more identifying info to cache file? + // Date created, firmware version, validation hash, etc. + // Maybe on an ignored line at the top + + match cache_file { + Ok(file) => { + self.read_rpc_cache(route, hash, file) + } + + Err(err) if err.kind() == io::ErrorKind::NotFound => { + let cache_file = fs::File::create(&file_path).map_err(|_| + RpcListError::CacheCreateError)?; + + // Try to write, and if we fail, remove the file we created + self.write_rpc_cache(route, hash, cache_file).map_err(|orig_error| { + match fs::remove_file(file_path) { + Ok(_) => orig_error, + Err(_) => RpcListError::RemoveBadCacheError, + } + }) + }, + + // TODO: what other io errors to handle? + // + Err(other_err) => Err(RpcListError::CacheFileError(other_err)), + } + } } + diff --git a/twinleaf/src/device/tree.rs b/twinleaf/src/device/tree.rs index 7c2fffb..07ed75c 100644 --- a/twinleaf/src/device/tree.rs +++ b/twinleaf/src/device/tree.rs @@ -122,6 +122,19 @@ impl DeviceTree { event: super::device::DeviceEvent::Heartbeat { session_id }, }); } + tio::proto::Payload::Settings(set) => { + match set.name.as_str() { + "rpc.hash" => { + let hash = u32::from_le_bytes(set.reply.clone().try_into().unwrap()); + self.event_queue.push_back(TreeEvent::Device { + route: absolute_route.clone(), + event: super::device::DeviceEvent::NewHash(hash), + }); + }, + _ => {}, + } + + } tio::proto::Payload::RpcReply(rep) => { if rep.id == 7855 { if let Some(count) = self.n_reqs.get_mut(&absolute_route) { diff --git a/twinleaf/src/tio/proto.rs b/twinleaf/src/tio/proto.rs index 1b121fd..6a92361 100644 --- a/twinleaf/src/tio/proto.rs +++ b/twinleaf/src/tio/proto.rs @@ -48,6 +48,14 @@ pub enum HeartbeatPayload { Any(Vec), } +#[derive(Debug, Clone)] +pub struct SettingsPayload { + pub name_len: u8, + pub flags: u8, + pub name: String, + pub reply: Vec, +} + #[derive(Debug, Clone, Copy, PartialEq)] #[repr(u8)] #[derive(FromPrimitive, IntoPrimitive)] @@ -138,6 +146,7 @@ pub enum Payload { LegacyStreamUpdate(LegacyStreamInfoPayload), LegacyStreamData(LegacyStreamDataPayload), Metadata(MetadataPayload), + Settings(SettingsPayload), StreamData(StreamDataPayload), ProxyStatus(ProxyStatusPayload), RpcUpdate(RpcUpdatePayload), @@ -154,6 +163,7 @@ pub struct Packet { #[derive(Debug)] pub enum Error { NeedMore, + BadName, Text(String), CRC32(Vec), PacketTooBig(Vec), @@ -180,6 +190,7 @@ enum TioPktType { Reserved0 = 9, Reserved1 = 10, Metadata = 11, + Settings = 12, Reserved2 = 13, ProxyStatus = 64, RpcUpdate = 65, @@ -347,6 +358,37 @@ impl HeartbeatPayload { } } +impl SettingsPayload { + fn deserialize(raw: &[u8], full_data: &[u8]) -> Result { + if raw.len() < 2 { + return Err(too_small(full_data)); + } + let name_len = raw[0]; + let flags = raw[1]; + let content = &raw[2..]; + + if content.len() < name_len.into() { + return Err(too_small(full_data)); + } + let name = String::from_utf8(content[..name_len.into()].to_vec()).map_err(|_| Error::BadName)?; + let reply = (&content[name_len.into()..]).to_vec(); + let pl = SettingsPayload { name_len, flags, name, reply }; + Ok(pl) + } + fn serialize(&self) -> Result, ()> { + let payload_size: usize = 2 + self.name_len as usize + self.reply.len(); + if payload_size > TIO_PACKET_MAX_PAYLOAD_SIZE { + return Err(()); + } + let mut ret = TioPktHdr::serialize_new(TioPktType::Settings, 0, payload_size as u16); + ret.extend(self.name_len.to_le_bytes()); + ret.extend(self.flags.to_le_bytes()); + ret.extend(self.name.clone().into_bytes()); + ret.extend(self.reply.clone()); + Ok(ret) + } +} + impl StreamDataPayload { fn deserialize(raw: &[u8], full_data: &[u8]) -> Result { if raw.len() < 5 { @@ -474,6 +516,7 @@ impl Payload { Payload::RpcError(p) => p.serialize(), Payload::Heartbeat(p) => p.serialize(), Payload::Metadata(p) => p.serialize(), + Payload::Settings(p) => p.serialize(), Payload::LegacyStreamData(p) => p.serialize(), Payload::StreamData(p) => p.serialize(), Payload::ProxyStatus(p) => p.serialize(), @@ -536,6 +579,10 @@ impl Payload { raw_payload, full_data, )?)), + TioPktType::Settings => Ok(Payload::Settings(SettingsPayload::deserialize( + raw_payload, + full_data, + )?)), TioPktType::ProxyStatus => Ok(Payload::ProxyStatus(ProxyStatusPayload::deserialize( raw_payload, full_data,