From 0dcec5fc3898d10e8fed4f09b2c11346cda0f1bb Mon Sep 17 00:00:00 2001 From: Tom Kornack Date: Wed, 14 Jan 2026 12:57:13 -0500 Subject: [PATCH 01/28] chore: Increment version to 1.6.2 --- Cargo.lock | 4 ++-- twinleaf-tools/Cargo.toml | 2 +- twinleaf/Cargo.toml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9d9b708..8e1b83c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1487,7 +1487,7 @@ dependencies = [ [[package]] name = "twinleaf" -version = "1.6.0" +version = "1.6.2" dependencies = [ "crc", "crossbeam", @@ -1503,7 +1503,7 @@ dependencies = [ [[package]] name = "twinleaf-tools" -version = "1.6.1" +version = "1.6.2" dependencies = [ "chrono", "clap", diff --git a/twinleaf-tools/Cargo.toml b/twinleaf-tools/Cargo.toml index 3506d6f..6e75831 100644 --- a/twinleaf-tools/Cargo.toml +++ b/twinleaf-tools/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "twinleaf-tools" -version = "1.6.1" +version = "1.6.2" edition = "2021" license = "MIT" description = "Tools for the Twinleaf I/O protocol for reading data from Twinleaf quantum sensors." diff --git a/twinleaf/Cargo.toml b/twinleaf/Cargo.toml index de50b7d..22e6205 100644 --- a/twinleaf/Cargo.toml +++ b/twinleaf/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "twinleaf" -version = "1.6.0" +version = "1.6.2" edition = "2021" license = "MIT" description = "Library for working with the Twinleaf I/O protocol and Twinleaf quantum sensors." From 3b49cf9d28e631bb5e4134a47aaf02d03f5a9968 Mon Sep 17 00:00:00 2001 From: Chris Rui Zhao Date: Fri, 6 Feb 2026 16:36:01 -0500 Subject: [PATCH 02/28] Feat: RPC list cache in device::RpcClient --- Cargo.lock | 78 +++++++++++++++++++++++++- twinleaf/Cargo.toml | 1 + twinleaf/src/device/rpc.rs | 109 ++++++++++++++++++++++++++++++++++++- 3 files changed, 183 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8e1b83c..a279006 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -412,6 +412,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 +489,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" @@ -708,6 +740,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 +1182,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 +1409,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 +1564,7 @@ version = "1.6.2" dependencies = [ "crc", "crossbeam", + "dirs-next", "glob", "hdf5-metno", "mio", @@ -1525,7 +1599,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/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/rpc.rs b/twinleaf/src/device/rpc.rs index 0a661ec..c6f3307 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, @@ -261,6 +266,33 @@ pub struct RpcClient { root_route: DeviceRoute, } +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), +} + +type RpcList = Vec<(u16, String)>; + impl RpcClient { pub fn new(port: proxy::Port, root_route: DeviceRoute) -> Self { Self { port, root_route } @@ -319,11 +351,82 @@ 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, file: fs::File) -> Result { + let mut list: Vec<(u16, String)> = Vec::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((meta_hex, name_string)); + } + + return Ok(list); + } + + fn write_rpc_cache(&self, route: &DeviceRoute, file: fs::File) -> Result { + let mut list: Vec<(u16, String)> = Vec::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((meta, name)); + } + + return Ok(list); + } + + 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 what cache name + let dev_name: String = self.get(route, "dev.name").map_err(|_| RpcListError::DevNameRpcError)?; + let rpc_hash: u32 = self.get(route, "rpc.hash").map_err(|_| RpcListError::RpcHashError)?; + let base_name = format!("{}.{:x}.rpcs", dev_name, rpc_hash); + let file_path = tl_cache_dir.join(&base_name); + + let cache_file = fs::File::open(&file_path); + + // TODO: write more identifying info to cache file? + // Date created, firmware version, etc. + // Maybe on an ignored line at the top + + match cache_file { + Ok(file) => self.read_rpc_cache(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 + let list = self.write_rpc_cache(route, cache_file).map_err(|orig_error| { + match fs::remove_file(file_path) { + Ok(_) => orig_error, + Err(_) => RpcListError::RemoveBadCacheError, + } + })?; + Ok(list) + }, + + // TODO: what other io errors to handle? + Err(other_err) => Err(RpcListError::CacheFileError(other_err)), + } + } } From f69e804436c021c10acdc788c015e967e59c5466 Mon Sep 17 00:00:00 2001 From: Chris Rui Zhao Date: Fri, 6 Feb 2026 16:39:27 -0500 Subject: [PATCH 03/28] Feat: use RpcClient::rpc_list (with cache) for tio-tool rpc-list --- twinleaf-tools/src/bin/tio-tool.rs | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/twinleaf-tools/src/bin/tio-tool.rs b/twinleaf-tools/src/bin/tio-tool.rs index 72f54df..2bd2cf6 100644 --- a/twinleaf-tools/src/bin/tio-tool.rs +++ b/twinleaf-tools/src/bin/tio-tool.rs @@ -3,7 +3,7 @@ use tio::proto::DeviceRoute; use tio::proxy; use tio::util; use twinleaf::data::DeviceDataParser; -use twinleaf::device::{Device, DeviceTree}; +use twinleaf::device::{Device, DeviceTree, RpcClient}; use twinleaf::tio; use twinleaf_tools::TioOpts; @@ -290,18 +290,13 @@ fn default_log_path() -> String { 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 (meta, name) in &rpcs { + let spec = twinleaf::device::util::parse_rpc_spec(*meta, name.to_string()); println!( "{} {}({})", spec.perm_str(), From d55de48b38f32ab1bf88a146e13da30cccbf6e8e Mon Sep 17 00:00:00 2001 From: Chris Rui Zhao Date: Fri, 6 Feb 2026 16:36:01 -0500 Subject: [PATCH 04/28] Feat: RPC list cache in device::RpcClient --- Cargo.lock | 78 +++++++++++++++++++++++++- twinleaf/Cargo.toml | 1 + twinleaf/src/device/rpc.rs | 109 ++++++++++++++++++++++++++++++++++++- 3 files changed, 183 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8e1b83c..a279006 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -412,6 +412,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 +489,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" @@ -708,6 +740,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 +1182,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 +1409,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 +1564,7 @@ version = "1.6.2" dependencies = [ "crc", "crossbeam", + "dirs-next", "glob", "hdf5-metno", "mio", @@ -1525,7 +1599,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/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/rpc.rs b/twinleaf/src/device/rpc.rs index 0a661ec..c6f3307 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, @@ -261,6 +266,33 @@ pub struct RpcClient { root_route: DeviceRoute, } +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), +} + +type RpcList = Vec<(u16, String)>; + impl RpcClient { pub fn new(port: proxy::Port, root_route: DeviceRoute) -> Self { Self { port, root_route } @@ -319,11 +351,82 @@ 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, file: fs::File) -> Result { + let mut list: Vec<(u16, String)> = Vec::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((meta_hex, name_string)); + } + + return Ok(list); + } + + fn write_rpc_cache(&self, route: &DeviceRoute, file: fs::File) -> Result { + let mut list: Vec<(u16, String)> = Vec::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((meta, name)); + } + + return Ok(list); + } + + 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 what cache name + let dev_name: String = self.get(route, "dev.name").map_err(|_| RpcListError::DevNameRpcError)?; + let rpc_hash: u32 = self.get(route, "rpc.hash").map_err(|_| RpcListError::RpcHashError)?; + let base_name = format!("{}.{:x}.rpcs", dev_name, rpc_hash); + let file_path = tl_cache_dir.join(&base_name); + + let cache_file = fs::File::open(&file_path); + + // TODO: write more identifying info to cache file? + // Date created, firmware version, etc. + // Maybe on an ignored line at the top + + match cache_file { + Ok(file) => self.read_rpc_cache(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 + let list = self.write_rpc_cache(route, cache_file).map_err(|orig_error| { + match fs::remove_file(file_path) { + Ok(_) => orig_error, + Err(_) => RpcListError::RemoveBadCacheError, + } + })?; + Ok(list) + }, + + // TODO: what other io errors to handle? + Err(other_err) => Err(RpcListError::CacheFileError(other_err)), + } + } } From de273005cd6a4c5f86f3bc385bd56a90be4271de Mon Sep 17 00:00:00 2001 From: Chris Rui Zhao Date: Fri, 6 Feb 2026 16:39:27 -0500 Subject: [PATCH 05/28] Feat: use RpcClient::rpc_list (with cache) for tio-tool rpc-list --- twinleaf-tools/src/bin/tio-tool.rs | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/twinleaf-tools/src/bin/tio-tool.rs b/twinleaf-tools/src/bin/tio-tool.rs index 72f54df..2bd2cf6 100644 --- a/twinleaf-tools/src/bin/tio-tool.rs +++ b/twinleaf-tools/src/bin/tio-tool.rs @@ -3,7 +3,7 @@ use tio::proto::DeviceRoute; use tio::proxy; use tio::util; use twinleaf::data::DeviceDataParser; -use twinleaf::device::{Device, DeviceTree}; +use twinleaf::device::{Device, DeviceTree, RpcClient}; use twinleaf::tio; use twinleaf_tools::TioOpts; @@ -290,18 +290,13 @@ fn default_log_path() -> String { 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 (meta, name) in &rpcs { + let spec = twinleaf::device::util::parse_rpc_spec(*meta, name.to_string()); println!( "{} {}({})", spec.perm_str(), From 6ab72e802fc4692cd8e1a340a8710bac2dc7176c Mon Sep 17 00:00:00 2001 From: Chris Rui Zhao Date: Fri, 6 Feb 2026 17:15:43 -0500 Subject: [PATCH 06/28] Feat: use cache RPC list with tio-monitor for tab completion --- twinleaf-tools/src/bin/tio-monitor.rs | 75 +++++++++++++++++++++++---- 1 file changed, 64 insertions(+), 11 deletions(-) diff --git a/twinleaf-tools/src/bin/tio-monitor.rs b/twinleaf-tools/src/bin/tio-monitor.rs index 2801ea6..bd68182 100644 --- a/twinleaf-tools/src/bin/tio-monitor.rs +++ b/twinleaf-tools/src/bin/tio-monitor.rs @@ -8,6 +8,7 @@ use std::{ io::{self, Read}, str::FromStr, time::{Duration, Instant}, + iter::Cycle }; use clap::Parser; @@ -19,7 +20,7 @@ use ratatui::{ 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}; @@ -336,6 +337,8 @@ pub enum Mode { pub enum Action { Quit, SetMode(Mode), + AutoCompleteCommand, + NewCommandString, SubmitCommand, NavUp, NavDown, @@ -434,7 +437,9 @@ fn exec_rpc(client: &RpcClient, req: &RpcReq) -> Result { pub struct App { pub all: bool, pub parent_route: DeviceRoute, - + pub rpcs: Vec<(u16, String)>, + pub suggested_rpcs: Cycle>, + pub suggested_rpcs_len: usize, pub mode: Mode, pub view: ViewConfig, @@ -456,10 +461,13 @@ pub struct App { } impl App { - pub fn new(all: bool, parent_route: &DeviceRoute) -> Self { + pub fn new(all: bool, parent_route: &DeviceRoute, rpcs: &Result, ()>) -> Self { Self { all, parent_route: parent_route.clone(), + rpcs: rpcs.clone().expect("Failed to obtain cache list"), + suggested_rpcs: Vec::new().into_iter().cycle(), + suggested_rpcs_len: 0, mode: Mode::Normal, view: ViewConfig::default(), nav: Nav::default(), @@ -490,7 +498,9 @@ impl App { Action::SetMode(Mode::Normal) => { self.mode = Mode::Normal; self.input_state.blur(); - } + }, + Action::AutoCompleteCommand => {self.complete_command()}, + Action::NewCommandString => {self.update_command_list()} Action::SubmitCommand => self.submit_command(rpc_tx), Action::HistoryNavigate(dir) => self.navigate_history(dir), Action::NavUp => { @@ -563,6 +573,32 @@ impl App { false } + fn complete_command(&mut self) { + match self.suggested_rpcs.next().clone(){ + Some(value) => { + self.input_state = TextState::new().with_value(value); + self.input_state.focus(); + self.input_state.move_end() + } + None => return + }; + } + + fn update_command_list(&mut self){ + let line = self.input_state.value().to_string(); + let mut rpc_cache: Vec = Vec::new(); + + for (_meta, name) in self.rpcs.clone(){rpc_cache.push(name)} + let completions: Vec = rpc_cache + .iter() + .filter(|word: &&String| word.to_string().starts_with(&line)) + .map(String::clone) + .collect(); + + self.suggested_rpcs_len = completions.len(); + self.suggested_rpcs = completions.into_iter().cycle(); + } + fn submit_command(&mut self, rpc_tx: &Sender) { let line = self.input_state.value().to_string(); if line.trim().is_empty() { @@ -874,12 +910,15 @@ 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::AutoCompleteCommand), KeyCode::Up => Some(Action::HistoryNavigate(-1)), KeyCode::Down => Some(Action::HistoryNavigate(1)), - KeyCode::Enter => Some(Action::SubmitCommand), + KeyCode::Enter => { + Some(Action::SubmitCommand) + } _ => { app.input_state.handle_key_event(k); - None + Some(Action::NewCommandString) } }, Mode::Normal => match k.code { @@ -926,7 +965,7 @@ fn draw_ui(terminal: &mut Terminal, app: &mut App) -> io::Result< let size = f.area(); let (main_area, footer_area) = { let footer_h = if app.mode == Mode::Command { - 4 + 10 } else if app.view.show_footer { 6 } else { @@ -1170,14 +1209,22 @@ fn render_footer(f: &mut Frame, app: &mut App, area: Rect) { if app.mode == Mode::Command { let chunks = Layout::default() .direction(Direction::Vertical) - .constraints([Constraint::Length(1), Constraint::Min(1)]) + .constraints([Constraint::Length(7), Constraint::Length(1), Constraint::Min(1)]) .split(area); + let rpcs: Vec = app.suggested_rpcs.clone().take(app.suggested_rpcs_len).collect(); + let rpc_block = Block::default() + .borders(Borders::ALL) + .title(" RPCList "); + f.render_widget( + List::new(rpcs).block(rpc_block), chunks[0], + ); + 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[0], + chunks[1], ); } @@ -1210,7 +1257,7 @@ fn render_footer(f: &mut Frame, app: &mut App, area: Rect) { let block = Block::default() .borders(Borders::TOP) .title(" Command Mode "); - f.render_widget(Paragraph::new(Line::from(spans)).block(block), chunks[1]); + f.render_widget(Paragraph::new(Line::from(spans)).block(block), chunks[2]); return; } @@ -1616,6 +1663,12 @@ fn main() { 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"); + + // Get RPC list from cache or device + let rpcs = rpc_client.rpc_list(&parent_route).map_err(|e| { + eprintln!("RPC list failed: {:?}", e); + }); + std::thread::spawn(move || { while let Ok(req) = rpc_rx.recv() { let result = exec_rpc(&rpc_client, &req); @@ -1636,7 +1689,7 @@ fn main() { }); // App state - let mut app = App::new(cli.all, &parent_route); + let mut app = App::new(cli.all, &parent_route, &rpcs); if let Some(path) = &cli.colors { if let Ok(theme) = load_theme(path) { app.view.theme = theme; From 82c2d8253932aa9a46cadf0aa834b7af2e6b5e62 Mon Sep 17 00:00:00 2001 From: Chris Rui Zhao Date: Fri, 6 Feb 2026 17:15:43 -0500 Subject: [PATCH 07/28] Feat: use cache RPC list with tio-monitor for tab completion --- twinleaf-tools/src/bin/tio-monitor.rs | 75 +++++++++++++++++++++++---- 1 file changed, 64 insertions(+), 11 deletions(-) diff --git a/twinleaf-tools/src/bin/tio-monitor.rs b/twinleaf-tools/src/bin/tio-monitor.rs index 2801ea6..bd68182 100644 --- a/twinleaf-tools/src/bin/tio-monitor.rs +++ b/twinleaf-tools/src/bin/tio-monitor.rs @@ -8,6 +8,7 @@ use std::{ io::{self, Read}, str::FromStr, time::{Duration, Instant}, + iter::Cycle }; use clap::Parser; @@ -19,7 +20,7 @@ use ratatui::{ 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}; @@ -336,6 +337,8 @@ pub enum Mode { pub enum Action { Quit, SetMode(Mode), + AutoCompleteCommand, + NewCommandString, SubmitCommand, NavUp, NavDown, @@ -434,7 +437,9 @@ fn exec_rpc(client: &RpcClient, req: &RpcReq) -> Result { pub struct App { pub all: bool, pub parent_route: DeviceRoute, - + pub rpcs: Vec<(u16, String)>, + pub suggested_rpcs: Cycle>, + pub suggested_rpcs_len: usize, pub mode: Mode, pub view: ViewConfig, @@ -456,10 +461,13 @@ pub struct App { } impl App { - pub fn new(all: bool, parent_route: &DeviceRoute) -> Self { + pub fn new(all: bool, parent_route: &DeviceRoute, rpcs: &Result, ()>) -> Self { Self { all, parent_route: parent_route.clone(), + rpcs: rpcs.clone().expect("Failed to obtain cache list"), + suggested_rpcs: Vec::new().into_iter().cycle(), + suggested_rpcs_len: 0, mode: Mode::Normal, view: ViewConfig::default(), nav: Nav::default(), @@ -490,7 +498,9 @@ impl App { Action::SetMode(Mode::Normal) => { self.mode = Mode::Normal; self.input_state.blur(); - } + }, + Action::AutoCompleteCommand => {self.complete_command()}, + Action::NewCommandString => {self.update_command_list()} Action::SubmitCommand => self.submit_command(rpc_tx), Action::HistoryNavigate(dir) => self.navigate_history(dir), Action::NavUp => { @@ -563,6 +573,32 @@ impl App { false } + fn complete_command(&mut self) { + match self.suggested_rpcs.next().clone(){ + Some(value) => { + self.input_state = TextState::new().with_value(value); + self.input_state.focus(); + self.input_state.move_end() + } + None => return + }; + } + + fn update_command_list(&mut self){ + let line = self.input_state.value().to_string(); + let mut rpc_cache: Vec = Vec::new(); + + for (_meta, name) in self.rpcs.clone(){rpc_cache.push(name)} + let completions: Vec = rpc_cache + .iter() + .filter(|word: &&String| word.to_string().starts_with(&line)) + .map(String::clone) + .collect(); + + self.suggested_rpcs_len = completions.len(); + self.suggested_rpcs = completions.into_iter().cycle(); + } + fn submit_command(&mut self, rpc_tx: &Sender) { let line = self.input_state.value().to_string(); if line.trim().is_empty() { @@ -874,12 +910,15 @@ 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::AutoCompleteCommand), KeyCode::Up => Some(Action::HistoryNavigate(-1)), KeyCode::Down => Some(Action::HistoryNavigate(1)), - KeyCode::Enter => Some(Action::SubmitCommand), + KeyCode::Enter => { + Some(Action::SubmitCommand) + } _ => { app.input_state.handle_key_event(k); - None + Some(Action::NewCommandString) } }, Mode::Normal => match k.code { @@ -926,7 +965,7 @@ fn draw_ui(terminal: &mut Terminal, app: &mut App) -> io::Result< let size = f.area(); let (main_area, footer_area) = { let footer_h = if app.mode == Mode::Command { - 4 + 10 } else if app.view.show_footer { 6 } else { @@ -1170,14 +1209,22 @@ fn render_footer(f: &mut Frame, app: &mut App, area: Rect) { if app.mode == Mode::Command { let chunks = Layout::default() .direction(Direction::Vertical) - .constraints([Constraint::Length(1), Constraint::Min(1)]) + .constraints([Constraint::Length(7), Constraint::Length(1), Constraint::Min(1)]) .split(area); + let rpcs: Vec = app.suggested_rpcs.clone().take(app.suggested_rpcs_len).collect(); + let rpc_block = Block::default() + .borders(Borders::ALL) + .title(" RPCList "); + f.render_widget( + List::new(rpcs).block(rpc_block), chunks[0], + ); + 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[0], + chunks[1], ); } @@ -1210,7 +1257,7 @@ fn render_footer(f: &mut Frame, app: &mut App, area: Rect) { let block = Block::default() .borders(Borders::TOP) .title(" Command Mode "); - f.render_widget(Paragraph::new(Line::from(spans)).block(block), chunks[1]); + f.render_widget(Paragraph::new(Line::from(spans)).block(block), chunks[2]); return; } @@ -1616,6 +1663,12 @@ fn main() { 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"); + + // Get RPC list from cache or device + let rpcs = rpc_client.rpc_list(&parent_route).map_err(|e| { + eprintln!("RPC list failed: {:?}", e); + }); + std::thread::spawn(move || { while let Ok(req) = rpc_rx.recv() { let result = exec_rpc(&rpc_client, &req); @@ -1636,7 +1689,7 @@ fn main() { }); // App state - let mut app = App::new(cli.all, &parent_route); + let mut app = App::new(cli.all, &parent_route, &rpcs); if let Some(path) = &cli.colors { if let Ok(theme) = load_theme(path) { app.view.theme = theme; From 3fbee4777fc57ed6069c9cdc7ba314f720b20f92 Mon Sep 17 00:00:00 2001 From: Chris Rui Zhao Date: Fri, 6 Feb 2026 17:48:43 -0500 Subject: [PATCH 08/28] Feat: tio-monitor RPC suggestion box dynamic sizing --- twinleaf-tools/src/bin/tio-monitor.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/twinleaf-tools/src/bin/tio-monitor.rs b/twinleaf-tools/src/bin/tio-monitor.rs index bd68182..7278aae 100644 --- a/twinleaf-tools/src/bin/tio-monitor.rs +++ b/twinleaf-tools/src/bin/tio-monitor.rs @@ -452,6 +452,7 @@ pub struct App { pub device_metadata: HashMap, pub window_aligned: Option, + pub incomplete_input: TextState<'static>, pub input_state: TextState<'static>, pub cmd_history: Vec, pub history_ptr: Option, @@ -477,6 +478,7 @@ impl App { last: BTreeMap::new(), device_metadata: HashMap::new(), window_aligned: None, + incomplete_input: TextState::default(), input_state: TextState::default(), cmd_history: Vec::new(), history_ptr: None, @@ -585,6 +587,7 @@ impl App { } fn update_command_list(&mut self){ + self.incomplete_input = self.input_state.clone(); let line = self.input_state.value().to_string(); let mut rpc_cache: Vec = Vec::new(); @@ -716,6 +719,11 @@ impl App { } } + pub fn rpc_list_len(&self) -> u16 { + let length: u16 = self.suggested_rpcs_len.try_into().unwrap(); + if length > 10 { 10 } else { length } + } + pub fn current_pos(&self) -> Option<&NavPos> { self.nav_items.get(self.nav.idx) } @@ -965,7 +973,7 @@ fn draw_ui(terminal: &mut Terminal, app: &mut App) -> io::Result< let size = f.area(); let (main_area, footer_area) = { let footer_h = if app.mode == Mode::Command { - 10 + 5 + app.rpc_list_len() } else if app.view.show_footer { 6 } else { @@ -1209,7 +1217,7 @@ fn render_footer(f: &mut Frame, app: &mut App, area: Rect) { if app.mode == Mode::Command { let chunks = Layout::default() .direction(Direction::Vertical) - .constraints([Constraint::Length(7), Constraint::Length(1), Constraint::Min(1)]) + .constraints([Constraint::Length(app.rpc_list_len() + 2), Constraint::Length(1), Constraint::Min(1)]) .split(area); let rpcs: Vec = app.suggested_rpcs.clone().take(app.suggested_rpcs_len).collect(); From 75732c89298dc14d695b5db7d23610112b2d09ae Mon Sep 17 00:00:00 2001 From: Chris Rui Zhao Date: Fri, 6 Feb 2026 17:48:43 -0500 Subject: [PATCH 09/28] Feat: tio-monitor RPC suggestion box dynamic sizing --- twinleaf-tools/src/bin/tio-monitor.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/twinleaf-tools/src/bin/tio-monitor.rs b/twinleaf-tools/src/bin/tio-monitor.rs index bd68182..7278aae 100644 --- a/twinleaf-tools/src/bin/tio-monitor.rs +++ b/twinleaf-tools/src/bin/tio-monitor.rs @@ -452,6 +452,7 @@ pub struct App { pub device_metadata: HashMap, pub window_aligned: Option, + pub incomplete_input: TextState<'static>, pub input_state: TextState<'static>, pub cmd_history: Vec, pub history_ptr: Option, @@ -477,6 +478,7 @@ impl App { last: BTreeMap::new(), device_metadata: HashMap::new(), window_aligned: None, + incomplete_input: TextState::default(), input_state: TextState::default(), cmd_history: Vec::new(), history_ptr: None, @@ -585,6 +587,7 @@ impl App { } fn update_command_list(&mut self){ + self.incomplete_input = self.input_state.clone(); let line = self.input_state.value().to_string(); let mut rpc_cache: Vec = Vec::new(); @@ -716,6 +719,11 @@ impl App { } } + pub fn rpc_list_len(&self) -> u16 { + let length: u16 = self.suggested_rpcs_len.try_into().unwrap(); + if length > 10 { 10 } else { length } + } + pub fn current_pos(&self) -> Option<&NavPos> { self.nav_items.get(self.nav.idx) } @@ -965,7 +973,7 @@ fn draw_ui(terminal: &mut Terminal, app: &mut App) -> io::Result< let size = f.area(); let (main_area, footer_area) = { let footer_h = if app.mode == Mode::Command { - 10 + 5 + app.rpc_list_len() } else if app.view.show_footer { 6 } else { @@ -1209,7 +1217,7 @@ fn render_footer(f: &mut Frame, app: &mut App, area: Rect) { if app.mode == Mode::Command { let chunks = Layout::default() .direction(Direction::Vertical) - .constraints([Constraint::Length(7), Constraint::Length(1), Constraint::Min(1)]) + .constraints([Constraint::Length(app.rpc_list_len() + 2), Constraint::Length(1), Constraint::Min(1)]) .split(area); let rpcs: Vec = app.suggested_rpcs.clone().take(app.suggested_rpcs_len).collect(); From bec8ff1a7c6c3d36bb3ebcb18308d53fcef40a18 Mon Sep 17 00:00:00 2001 From: Chris Rui Zhao Date: Tue, 10 Feb 2026 11:41:52 -0500 Subject: [PATCH 10/28] Feat: Better tio-monitor RPC tab completion --- twinleaf-tools/src/bin/tio-monitor.rs | 153 +++++++++++++++++++------- 1 file changed, 112 insertions(+), 41 deletions(-) diff --git a/twinleaf-tools/src/bin/tio-monitor.rs b/twinleaf-tools/src/bin/tio-monitor.rs index 7278aae..f58b7e1 100644 --- a/twinleaf-tools/src/bin/tio-monitor.rs +++ b/twinleaf-tools/src/bin/tio-monitor.rs @@ -3,12 +3,11 @@ // Build: cargo run --release -- [options] use std::{ - collections::{BTreeMap, HashMap, HashSet}, + collections::{BTreeMap, HashMap, HashSet, VecDeque}, fs::File, io::{self, Read}, str::FromStr, time::{Duration, Instant}, - iter::Cycle }; use clap::Parser; @@ -16,7 +15,7 @@ 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}, @@ -338,8 +337,10 @@ pub enum Action { Quit, SetMode(Mode), AutoCompleteCommand, + AutoCompleteScrollback, NewCommandString, SubmitCommand, + AcceptCompletion, NavUp, NavDown, NavLeft, @@ -438,8 +439,9 @@ pub struct App { pub all: bool, pub parent_route: DeviceRoute, pub rpcs: Vec<(u16, String)>, - pub suggested_rpcs: Cycle>, + pub suggested_rpcs: VecDeque, pub suggested_rpcs_len: usize, + pub suggested_rpcs_ind: usize, pub mode: Mode, pub view: ViewConfig, @@ -452,23 +454,28 @@ pub struct App { pub device_metadata: HashMap, pub window_aligned: Option, - pub incomplete_input: TextState<'static>, pub input_state: TextState<'static>, + pub current_completion: String, pub cmd_history: Vec, pub history_ptr: Option, 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; +const RPCLIST_MIDDLE: usize = 6; + impl App { pub fn new(all: bool, parent_route: &DeviceRoute, rpcs: &Result, ()>) -> Self { Self { all, parent_route: parent_route.clone(), rpcs: rpcs.clone().expect("Failed to obtain cache list"), - suggested_rpcs: Vec::new().into_iter().cycle(), - suggested_rpcs_len: 0, + suggested_rpcs: VecDeque::from(vec![String::new()]), + suggested_rpcs_len: 1, + suggested_rpcs_ind: 0, mode: Mode::Normal, view: ViewConfig::default(), nav: Nav::default(), @@ -478,11 +485,12 @@ impl App { last: BTreeMap::new(), device_metadata: HashMap::new(), window_aligned: None, - incomplete_input: TextState::default(), input_state: TextState::default(), + current_completion: String::new(), cmd_history: Vec::new(), history_ptr: None, last_rpc_result: None, + last_rpc_command: String::new(), blink_state: true, last_blink: Instant::now(), } @@ -495,15 +503,19 @@ impl App { self.mode = Mode::Command; self.input_state = TextState::default(); self.input_state.focus(); + self.current_completion = String::new(); self.history_ptr = None; + self.suggested_rpcs = VecDeque::from(vec![String::new()]); } Action::SetMode(Mode::Normal) => { self.mode = Mode::Normal; self.input_state.blur(); }, Action::AutoCompleteCommand => {self.complete_command()}, + Action::AutoCompleteScrollback => {self.scroll_back_command()}, 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; @@ -576,37 +588,76 @@ impl App { } fn complete_command(&mut self) { - match self.suggested_rpcs.next().clone(){ - Some(value) => { - self.input_state = TextState::new().with_value(value); - self.input_state.focus(); - self.input_state.move_end() - } - None => return + self.suggested_rpcs_ind = match (self.suggested_rpcs_ind, self.suggested_rpcs_len) { + (i, l) if i == l-1 => 0, + (i, 0..RPCLIST_MAX_LEN) => i+1, + (i @ 0..RPCLIST_MIDDLE, _) => 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 + }, + }; + + let rpc = self.suggested_rpcs[self.suggested_rpcs_ind].clone(); + self.current_completion = rpc[self.input_state.value().len()..].to_string(); + self.input_state.focus(); + self.input_state.move_end(); + } + + fn scroll_back_command(&mut self) { + self.suggested_rpcs_ind = match (self.suggested_rpcs_ind, self.suggested_rpcs_len) { + (0, l @ 0..RPCLIST_MAX_LEN) => 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, }; + + let rpc = self.suggested_rpcs[self.suggested_rpcs_ind].clone(); + self.current_completion = rpc[self.input_state.value().len()..].to_string(); + self.input_state.focus(); + self.input_state.move_end(); } - fn update_command_list(&mut self){ - self.incomplete_input = self.input_state.clone(); + fn update_command_list(&mut self) { + self.suggested_rpcs_ind = 0; + self.current_completion = String::new(); let line = self.input_state.value().to_string(); - let mut rpc_cache: Vec = Vec::new(); + let mut rpc_cache = vec![line.clone()]; + if !line.is_empty() { + for (_, name) in self.rpcs.clone() { + rpc_cache.push(name); + } + } - for (_meta, name) in self.rpcs.clone(){rpc_cache.push(name)} - let completions: Vec = rpc_cache + 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(); + self.complete_command(); + } - self.suggested_rpcs_len = completions.len(); - self.suggested_rpcs = completions.into_iter().cycle(); + 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.current_completion = String::new(); } fn submit_command(&mut self, rpc_tx: &Sender) { - let line = self.input_state.value().to_string(); - if line.trim().is_empty() { - return; + // 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()); } @@ -614,6 +665,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 @@ -629,6 +681,7 @@ impl App { self.last_rpc_result = Some((format!("Sent to {}...", route), Color::Yellow)); self.input_state = TextState::default(); self.input_state.focus(); + self.update_command_list(); } } @@ -649,6 +702,7 @@ impl App { _ => self.history_ptr, }; self.history_ptr = new_ptr; + self.current_completion = String::new(); if let Some(i) = new_ptr { self.input_state = TextState::new().with_value(self.cmd_history[i].clone()); self.input_state.focus(); @@ -720,8 +774,8 @@ impl App { } pub fn rpc_list_len(&self) -> u16 { - let length: u16 = self.suggested_rpcs_len.try_into().unwrap(); - if length > 10 { 10 } else { length } + let length: usize = self.suggested_rpcs_len; + std::cmp::min(length, RPCLIST_MAX_LEN).try_into().unwrap() } pub fn current_pos(&self) -> Option<&NavPos> { @@ -919,11 +973,10 @@ fn get_action(ev: Event, app: &mut App) -> Option { Mode::Command => match k.code { KeyCode::Esc => Some(Action::SetMode(Mode::Normal)), KeyCode::Tab => Some(Action::AutoCompleteCommand), + KeyCode::BackTab => Some(Action::AutoCompleteScrollback), KeyCode::Up => Some(Action::HistoryNavigate(-1)), KeyCode::Down => Some(Action::HistoryNavigate(1)), - KeyCode::Enter => { - Some(Action::SubmitCommand) - } + KeyCode::Enter => Some(Action::SubmitCommand), _ => { app.input_state.handle_key_event(k); Some(Action::NewCommandString) @@ -972,7 +1025,7 @@ fn draw_ui(terminal: &mut Terminal, app: &mut App) -> io::Result< terminal.draw(|f| { let size = f.area(); let (main_area, footer_area) = { - let footer_h = if app.mode == Mode::Command { + let footer_h: u16 = if app.mode == Mode::Command { 5 + app.rpc_list_len() } else if app.view.show_footer { 6 @@ -1220,10 +1273,19 @@ fn render_footer(f: &mut Frame, app: &mut App, area: Rect) { .constraints([Constraint::Length(app.rpc_list_len() + 2), Constraint::Length(1), Constraint::Min(1)]) .split(area); - let rpcs: Vec = app.suggested_rpcs.clone().take(app.suggested_rpcs_len).collect(); + // Ensure non-empty box on startup + if app.suggested_rpcs.is_empty() { app.suggested_rpcs.push_back(String::new()); } + let rpcs: Vec = app.suggested_rpcs.iter() + .map(|v| Span::raw(v.clone())) + .map(|v| if v == Span::raw(app.input_state.value()) {v.underlined().bold()} else {v}) + .enumerate() + .map(|(i, v)| if i == app.suggested_rpcs_ind {v.bold()} else {v}) + .collect(); + let rpc_block = Block::default() .borders(Borders::ALL) - .title(" RPCList "); + .title(Line::from(" RPCs ").left_aligned()) + .title(Line::from(" ↑ Shift+Tab | Tab ↓ ").right_aligned()); f.render_widget( List::new(rpcs).block(rpc_block), chunks[0], ); @@ -1237,34 +1299,43 @@ fn render_footer(f: &mut Frame, app: &mut App, area: Rect) { } 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 "); + .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; } @@ -1740,7 +1811,7 @@ fn main() { 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)); From beea85c576f4dc5283a4333899259d469efa85a1 Mon Sep 17 00:00:00 2001 From: Chris Rui Zhao Date: Tue, 10 Feb 2026 11:41:52 -0500 Subject: [PATCH 11/28] Feat: Better tio-monitor RPC tab completion --- twinleaf-tools/src/bin/tio-monitor.rs | 153 +++++++++++++++++++------- 1 file changed, 112 insertions(+), 41 deletions(-) diff --git a/twinleaf-tools/src/bin/tio-monitor.rs b/twinleaf-tools/src/bin/tio-monitor.rs index 7278aae..f58b7e1 100644 --- a/twinleaf-tools/src/bin/tio-monitor.rs +++ b/twinleaf-tools/src/bin/tio-monitor.rs @@ -3,12 +3,11 @@ // Build: cargo run --release -- [options] use std::{ - collections::{BTreeMap, HashMap, HashSet}, + collections::{BTreeMap, HashMap, HashSet, VecDeque}, fs::File, io::{self, Read}, str::FromStr, time::{Duration, Instant}, - iter::Cycle }; use clap::Parser; @@ -16,7 +15,7 @@ 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}, @@ -338,8 +337,10 @@ pub enum Action { Quit, SetMode(Mode), AutoCompleteCommand, + AutoCompleteScrollback, NewCommandString, SubmitCommand, + AcceptCompletion, NavUp, NavDown, NavLeft, @@ -438,8 +439,9 @@ pub struct App { pub all: bool, pub parent_route: DeviceRoute, pub rpcs: Vec<(u16, String)>, - pub suggested_rpcs: Cycle>, + pub suggested_rpcs: VecDeque, pub suggested_rpcs_len: usize, + pub suggested_rpcs_ind: usize, pub mode: Mode, pub view: ViewConfig, @@ -452,23 +454,28 @@ pub struct App { pub device_metadata: HashMap, pub window_aligned: Option, - pub incomplete_input: TextState<'static>, pub input_state: TextState<'static>, + pub current_completion: String, pub cmd_history: Vec, pub history_ptr: Option, 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; +const RPCLIST_MIDDLE: usize = 6; + impl App { pub fn new(all: bool, parent_route: &DeviceRoute, rpcs: &Result, ()>) -> Self { Self { all, parent_route: parent_route.clone(), rpcs: rpcs.clone().expect("Failed to obtain cache list"), - suggested_rpcs: Vec::new().into_iter().cycle(), - suggested_rpcs_len: 0, + suggested_rpcs: VecDeque::from(vec![String::new()]), + suggested_rpcs_len: 1, + suggested_rpcs_ind: 0, mode: Mode::Normal, view: ViewConfig::default(), nav: Nav::default(), @@ -478,11 +485,12 @@ impl App { last: BTreeMap::new(), device_metadata: HashMap::new(), window_aligned: None, - incomplete_input: TextState::default(), input_state: TextState::default(), + current_completion: String::new(), cmd_history: Vec::new(), history_ptr: None, last_rpc_result: None, + last_rpc_command: String::new(), blink_state: true, last_blink: Instant::now(), } @@ -495,15 +503,19 @@ impl App { self.mode = Mode::Command; self.input_state = TextState::default(); self.input_state.focus(); + self.current_completion = String::new(); self.history_ptr = None; + self.suggested_rpcs = VecDeque::from(vec![String::new()]); } Action::SetMode(Mode::Normal) => { self.mode = Mode::Normal; self.input_state.blur(); }, Action::AutoCompleteCommand => {self.complete_command()}, + Action::AutoCompleteScrollback => {self.scroll_back_command()}, 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; @@ -576,37 +588,76 @@ impl App { } fn complete_command(&mut self) { - match self.suggested_rpcs.next().clone(){ - Some(value) => { - self.input_state = TextState::new().with_value(value); - self.input_state.focus(); - self.input_state.move_end() - } - None => return + self.suggested_rpcs_ind = match (self.suggested_rpcs_ind, self.suggested_rpcs_len) { + (i, l) if i == l-1 => 0, + (i, 0..RPCLIST_MAX_LEN) => i+1, + (i @ 0..RPCLIST_MIDDLE, _) => 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 + }, + }; + + let rpc = self.suggested_rpcs[self.suggested_rpcs_ind].clone(); + self.current_completion = rpc[self.input_state.value().len()..].to_string(); + self.input_state.focus(); + self.input_state.move_end(); + } + + fn scroll_back_command(&mut self) { + self.suggested_rpcs_ind = match (self.suggested_rpcs_ind, self.suggested_rpcs_len) { + (0, l @ 0..RPCLIST_MAX_LEN) => 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, }; + + let rpc = self.suggested_rpcs[self.suggested_rpcs_ind].clone(); + self.current_completion = rpc[self.input_state.value().len()..].to_string(); + self.input_state.focus(); + self.input_state.move_end(); } - fn update_command_list(&mut self){ - self.incomplete_input = self.input_state.clone(); + fn update_command_list(&mut self) { + self.suggested_rpcs_ind = 0; + self.current_completion = String::new(); let line = self.input_state.value().to_string(); - let mut rpc_cache: Vec = Vec::new(); + let mut rpc_cache = vec![line.clone()]; + if !line.is_empty() { + for (_, name) in self.rpcs.clone() { + rpc_cache.push(name); + } + } - for (_meta, name) in self.rpcs.clone(){rpc_cache.push(name)} - let completions: Vec = rpc_cache + 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(); + self.complete_command(); + } - self.suggested_rpcs_len = completions.len(); - self.suggested_rpcs = completions.into_iter().cycle(); + 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.current_completion = String::new(); } fn submit_command(&mut self, rpc_tx: &Sender) { - let line = self.input_state.value().to_string(); - if line.trim().is_empty() { - return; + // 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()); } @@ -614,6 +665,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 @@ -629,6 +681,7 @@ impl App { self.last_rpc_result = Some((format!("Sent to {}...", route), Color::Yellow)); self.input_state = TextState::default(); self.input_state.focus(); + self.update_command_list(); } } @@ -649,6 +702,7 @@ impl App { _ => self.history_ptr, }; self.history_ptr = new_ptr; + self.current_completion = String::new(); if let Some(i) = new_ptr { self.input_state = TextState::new().with_value(self.cmd_history[i].clone()); self.input_state.focus(); @@ -720,8 +774,8 @@ impl App { } pub fn rpc_list_len(&self) -> u16 { - let length: u16 = self.suggested_rpcs_len.try_into().unwrap(); - if length > 10 { 10 } else { length } + let length: usize = self.suggested_rpcs_len; + std::cmp::min(length, RPCLIST_MAX_LEN).try_into().unwrap() } pub fn current_pos(&self) -> Option<&NavPos> { @@ -919,11 +973,10 @@ fn get_action(ev: Event, app: &mut App) -> Option { Mode::Command => match k.code { KeyCode::Esc => Some(Action::SetMode(Mode::Normal)), KeyCode::Tab => Some(Action::AutoCompleteCommand), + KeyCode::BackTab => Some(Action::AutoCompleteScrollback), KeyCode::Up => Some(Action::HistoryNavigate(-1)), KeyCode::Down => Some(Action::HistoryNavigate(1)), - KeyCode::Enter => { - Some(Action::SubmitCommand) - } + KeyCode::Enter => Some(Action::SubmitCommand), _ => { app.input_state.handle_key_event(k); Some(Action::NewCommandString) @@ -972,7 +1025,7 @@ fn draw_ui(terminal: &mut Terminal, app: &mut App) -> io::Result< terminal.draw(|f| { let size = f.area(); let (main_area, footer_area) = { - let footer_h = if app.mode == Mode::Command { + let footer_h: u16 = if app.mode == Mode::Command { 5 + app.rpc_list_len() } else if app.view.show_footer { 6 @@ -1220,10 +1273,19 @@ fn render_footer(f: &mut Frame, app: &mut App, area: Rect) { .constraints([Constraint::Length(app.rpc_list_len() + 2), Constraint::Length(1), Constraint::Min(1)]) .split(area); - let rpcs: Vec = app.suggested_rpcs.clone().take(app.suggested_rpcs_len).collect(); + // Ensure non-empty box on startup + if app.suggested_rpcs.is_empty() { app.suggested_rpcs.push_back(String::new()); } + let rpcs: Vec = app.suggested_rpcs.iter() + .map(|v| Span::raw(v.clone())) + .map(|v| if v == Span::raw(app.input_state.value()) {v.underlined().bold()} else {v}) + .enumerate() + .map(|(i, v)| if i == app.suggested_rpcs_ind {v.bold()} else {v}) + .collect(); + let rpc_block = Block::default() .borders(Borders::ALL) - .title(" RPCList "); + .title(Line::from(" RPCs ").left_aligned()) + .title(Line::from(" ↑ Shift+Tab | Tab ↓ ").right_aligned()); f.render_widget( List::new(rpcs).block(rpc_block), chunks[0], ); @@ -1237,34 +1299,43 @@ fn render_footer(f: &mut Frame, app: &mut App, area: Rect) { } 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 "); + .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; } @@ -1740,7 +1811,7 @@ fn main() { 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)); From 7ac4491e5df77c89516728872ee36d5e8bdedf83 Mon Sep 17 00:00:00 2001 From: Chris Rui Zhao Date: Tue, 10 Feb 2026 15:56:31 -0500 Subject: [PATCH 12/28] Fix: Readable layout if terminal shrinks --- twinleaf-tools/Cargo.toml | 2 +- twinleaf-tools/src/bin/tio-monitor.rs | 169 +++++++++++++++----------- 2 files changed, 99 insertions(+), 72 deletions(-) diff --git a/twinleaf-tools/Cargo.toml b/twinleaf-tools/Cargo.toml index 6e75831..434c6ad 100644 --- a/twinleaf-tools/Cargo.toml +++ b/twinleaf-tools/Cargo.toml @@ -24,4 +24,4 @@ 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" diff --git a/twinleaf-tools/src/bin/tio-monitor.rs b/twinleaf-tools/src/bin/tio-monitor.rs index f58b7e1..defbf2e 100644 --- a/twinleaf-tools/src/bin/tio-monitor.rs +++ b/twinleaf-tools/src/bin/tio-monitor.rs @@ -336,8 +336,8 @@ pub enum Mode { pub enum Action { Quit, SetMode(Mode), - AutoCompleteCommand, - AutoCompleteScrollback, + AutoCompleteTab, + AutoCompleteBack, NewCommandString, SubmitCommand, AcceptCompletion, @@ -438,10 +438,6 @@ fn exec_rpc(client: &RpcClient, req: &RpcReq) -> Result { pub struct App { pub all: bool, pub parent_route: DeviceRoute, - pub rpcs: Vec<(u16, String)>, - pub suggested_rpcs: VecDeque, - pub suggested_rpcs_len: usize, - pub suggested_rpcs_ind: usize, pub mode: Mode, pub view: ViewConfig, @@ -454,6 +450,12 @@ pub struct App { pub device_metadata: HashMap, pub window_aligned: Option, + pub footer_height: u16, + pub rpcs: Vec<(u16, String)>, + 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, @@ -465,17 +467,12 @@ pub struct App { } const RPCLIST_MAX_LEN: usize = 12; -const RPCLIST_MIDDLE: usize = 6; impl App { pub fn new(all: bool, parent_route: &DeviceRoute, rpcs: &Result, ()>) -> Self { Self { all, parent_route: parent_route.clone(), - rpcs: rpcs.clone().expect("Failed to obtain cache list"), - suggested_rpcs: VecDeque::from(vec![String::new()]), - suggested_rpcs_len: 1, - suggested_rpcs_ind: 0, mode: Mode::Normal, view: ViewConfig::default(), nav: Nav::default(), @@ -485,6 +482,11 @@ impl App { last: BTreeMap::new(), device_metadata: HashMap::new(), window_aligned: None, + footer_height: 0, + rpcs: rpcs.clone().expect("Failed to obtain cache list"), + 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(), @@ -511,8 +513,8 @@ impl App { self.mode = Mode::Normal; self.input_state.blur(); }, - Action::AutoCompleteCommand => {self.complete_command()}, - Action::AutoCompleteScrollback => {self.scroll_back_command()}, + 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(), @@ -588,26 +590,34 @@ impl App { } 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, 0..RPCLIST_MAX_LEN) => i+1, - (i @ 0..RPCLIST_MIDDLE, _) => i+1, + (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 }, }; - - let rpc = self.suggested_rpcs[self.suggested_rpcs_ind].clone(); - self.current_completion = rpc[self.input_state.value().len()..].to_string(); - self.input_state.focus(); - self.input_state.move_end(); + self.complete_command(); } - fn scroll_back_command(&mut self) { + 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 @ 0..RPCLIST_MAX_LEN) => l-1, + (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); @@ -615,18 +625,14 @@ impl App { }, (i, _) => i-1, }; - - let rpc = self.suggested_rpcs[self.suggested_rpcs_ind].clone(); - self.current_completion = rpc[self.input_state.value().len()..].to_string(); - self.input_state.focus(); - self.input_state.move_end(); + 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(); - let mut rpc_cache = vec![line.clone()]; + let mut rpc_cache = Vec::new(); if !line.is_empty() { for (_, name) in self.rpcs.clone() { rpc_cache.push(name); @@ -639,6 +645,10 @@ impl App { .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(); } @@ -647,7 +657,7 @@ impl App { self.input_state = TextState::new().with_value(complete_command); self.input_state.focus(); self.input_state.move_end(); - self.current_completion = String::new(); + self.update_command_list(); } fn submit_command(&mut self, rpc_tx: &Sender) { @@ -972,8 +982,8 @@ 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::AutoCompleteCommand), - KeyCode::BackTab => Some(Action::AutoCompleteScrollback), + KeyCode::Tab => Some(Action::AutoCompleteTab), + KeyCode::BackTab => Some(Action::AutoCompleteBack), KeyCode::Up => Some(Action::HistoryNavigate(-1)), KeyCode::Down => Some(Action::HistoryNavigate(1)), KeyCode::Enter => Some(Action::SubmitCommand), @@ -1024,22 +1034,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: u16 = if app.mode == Mode::Command { - 5 + app.rpc_list_len() + 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([ @@ -1047,18 +1069,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(()) } @@ -1267,37 +1285,43 @@ 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(app.rpc_list_len() + 2), 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); - // Ensure non-empty box on startup - if app.suggested_rpcs.is_empty() { app.suggested_rpcs.push_back(String::new()); } - let rpcs: Vec = app.suggested_rpcs.iter() - .map(|v| Span::raw(v.clone())) - .map(|v| if v == Span::raw(app.input_state.value()) {v.underlined().bold()} else {v}) - .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( - List::new(rpcs).block(rpc_block), chunks[0], - ); + 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(); - if let Some((msg, color)) = &app.last_rpc_result { + 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[1], + 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 user_input = app.input_state.value(); let cursor_idx = app.input_state.position().min(user_input.len()); @@ -1331,10 +1355,13 @@ fn render_footer(f: &mut Frame, app: &mut App, area: Rect) { Style::default().fg(Color::Gray))); } - let block = Block::default() - .borders(Borders::TOP) - .title(Line::from(" Command Mode ").left_aligned()) - .title(Line::from(" ").right_aligned()); + 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; From 4fca4c86a981c078f031f6cbcbfd36fe30905644 Mon Sep 17 00:00:00 2001 From: Chris Rui Zhao Date: Tue, 10 Feb 2026 15:56:31 -0500 Subject: [PATCH 13/28] Fix: Readable layout if terminal shrinks --- twinleaf-tools/Cargo.toml | 2 +- twinleaf-tools/src/bin/tio-monitor.rs | 169 +++++++++++++++----------- 2 files changed, 99 insertions(+), 72 deletions(-) diff --git a/twinleaf-tools/Cargo.toml b/twinleaf-tools/Cargo.toml index 6e75831..434c6ad 100644 --- a/twinleaf-tools/Cargo.toml +++ b/twinleaf-tools/Cargo.toml @@ -24,4 +24,4 @@ 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" diff --git a/twinleaf-tools/src/bin/tio-monitor.rs b/twinleaf-tools/src/bin/tio-monitor.rs index f58b7e1..defbf2e 100644 --- a/twinleaf-tools/src/bin/tio-monitor.rs +++ b/twinleaf-tools/src/bin/tio-monitor.rs @@ -336,8 +336,8 @@ pub enum Mode { pub enum Action { Quit, SetMode(Mode), - AutoCompleteCommand, - AutoCompleteScrollback, + AutoCompleteTab, + AutoCompleteBack, NewCommandString, SubmitCommand, AcceptCompletion, @@ -438,10 +438,6 @@ fn exec_rpc(client: &RpcClient, req: &RpcReq) -> Result { pub struct App { pub all: bool, pub parent_route: DeviceRoute, - pub rpcs: Vec<(u16, String)>, - pub suggested_rpcs: VecDeque, - pub suggested_rpcs_len: usize, - pub suggested_rpcs_ind: usize, pub mode: Mode, pub view: ViewConfig, @@ -454,6 +450,12 @@ pub struct App { pub device_metadata: HashMap, pub window_aligned: Option, + pub footer_height: u16, + pub rpcs: Vec<(u16, String)>, + 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, @@ -465,17 +467,12 @@ pub struct App { } const RPCLIST_MAX_LEN: usize = 12; -const RPCLIST_MIDDLE: usize = 6; impl App { pub fn new(all: bool, parent_route: &DeviceRoute, rpcs: &Result, ()>) -> Self { Self { all, parent_route: parent_route.clone(), - rpcs: rpcs.clone().expect("Failed to obtain cache list"), - suggested_rpcs: VecDeque::from(vec![String::new()]), - suggested_rpcs_len: 1, - suggested_rpcs_ind: 0, mode: Mode::Normal, view: ViewConfig::default(), nav: Nav::default(), @@ -485,6 +482,11 @@ impl App { last: BTreeMap::new(), device_metadata: HashMap::new(), window_aligned: None, + footer_height: 0, + rpcs: rpcs.clone().expect("Failed to obtain cache list"), + 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(), @@ -511,8 +513,8 @@ impl App { self.mode = Mode::Normal; self.input_state.blur(); }, - Action::AutoCompleteCommand => {self.complete_command()}, - Action::AutoCompleteScrollback => {self.scroll_back_command()}, + 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(), @@ -588,26 +590,34 @@ impl App { } 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, 0..RPCLIST_MAX_LEN) => i+1, - (i @ 0..RPCLIST_MIDDLE, _) => i+1, + (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 }, }; - - let rpc = self.suggested_rpcs[self.suggested_rpcs_ind].clone(); - self.current_completion = rpc[self.input_state.value().len()..].to_string(); - self.input_state.focus(); - self.input_state.move_end(); + self.complete_command(); } - fn scroll_back_command(&mut self) { + 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 @ 0..RPCLIST_MAX_LEN) => l-1, + (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); @@ -615,18 +625,14 @@ impl App { }, (i, _) => i-1, }; - - let rpc = self.suggested_rpcs[self.suggested_rpcs_ind].clone(); - self.current_completion = rpc[self.input_state.value().len()..].to_string(); - self.input_state.focus(); - self.input_state.move_end(); + 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(); - let mut rpc_cache = vec![line.clone()]; + let mut rpc_cache = Vec::new(); if !line.is_empty() { for (_, name) in self.rpcs.clone() { rpc_cache.push(name); @@ -639,6 +645,10 @@ impl App { .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(); } @@ -647,7 +657,7 @@ impl App { self.input_state = TextState::new().with_value(complete_command); self.input_state.focus(); self.input_state.move_end(); - self.current_completion = String::new(); + self.update_command_list(); } fn submit_command(&mut self, rpc_tx: &Sender) { @@ -972,8 +982,8 @@ 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::AutoCompleteCommand), - KeyCode::BackTab => Some(Action::AutoCompleteScrollback), + KeyCode::Tab => Some(Action::AutoCompleteTab), + KeyCode::BackTab => Some(Action::AutoCompleteBack), KeyCode::Up => Some(Action::HistoryNavigate(-1)), KeyCode::Down => Some(Action::HistoryNavigate(1)), KeyCode::Enter => Some(Action::SubmitCommand), @@ -1024,22 +1034,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: u16 = if app.mode == Mode::Command { - 5 + app.rpc_list_len() + 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([ @@ -1047,18 +1069,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(()) } @@ -1267,37 +1285,43 @@ 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(app.rpc_list_len() + 2), 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); - // Ensure non-empty box on startup - if app.suggested_rpcs.is_empty() { app.suggested_rpcs.push_back(String::new()); } - let rpcs: Vec = app.suggested_rpcs.iter() - .map(|v| Span::raw(v.clone())) - .map(|v| if v == Span::raw(app.input_state.value()) {v.underlined().bold()} else {v}) - .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( - List::new(rpcs).block(rpc_block), chunks[0], - ); + 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(); - if let Some((msg, color)) = &app.last_rpc_result { + 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[1], + 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 user_input = app.input_state.value(); let cursor_idx = app.input_state.position().min(user_input.len()); @@ -1331,10 +1355,13 @@ fn render_footer(f: &mut Frame, app: &mut App, area: Rect) { Style::default().fg(Color::Gray))); } - let block = Block::default() - .borders(Borders::TOP) - .title(Line::from(" Command Mode ").left_aligned()) - .title(Line::from(" ").right_aligned()); + 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; From 26f1bb07f54a91acb697d851247f1759fb4eeaf4 Mon Sep 17 00:00:00 2001 From: Chris Rui Zhao Date: Tue, 10 Feb 2026 15:56:55 -0500 Subject: [PATCH 14/28] Feat: Remember present command when navigating history --- twinleaf-tools/src/bin/tio-monitor.rs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/twinleaf-tools/src/bin/tio-monitor.rs b/twinleaf-tools/src/bin/tio-monitor.rs index defbf2e..24edd6d 100644 --- a/twinleaf-tools/src/bin/tio-monitor.rs +++ b/twinleaf-tools/src/bin/tio-monitor.rs @@ -460,6 +460,7 @@ pub struct App { 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, @@ -491,6 +492,7 @@ impl App { 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, @@ -692,15 +694,19 @@ impl App { self.input_state = TextState::default(); self.input_state.focus(); self.update_command_list(); + self.present_command = String::new(); } - } +} 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() { @@ -712,15 +718,15 @@ impl App { _ => self.history_ptr, }; self.history_ptr = new_ptr; - self.current_completion = String::new(); if let Some(i) = new_ptr { self.input_state = TextState::new().with_value(self.cmd_history[i].clone()); 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 { From d05662f7e8d5c726944b8d950348c6f1115fd7c9 Mon Sep 17 00:00:00 2001 From: Chris Rui Zhao Date: Tue, 10 Feb 2026 15:56:55 -0500 Subject: [PATCH 15/28] Feat: Remember present command when navigating history --- twinleaf-tools/src/bin/tio-monitor.rs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/twinleaf-tools/src/bin/tio-monitor.rs b/twinleaf-tools/src/bin/tio-monitor.rs index defbf2e..24edd6d 100644 --- a/twinleaf-tools/src/bin/tio-monitor.rs +++ b/twinleaf-tools/src/bin/tio-monitor.rs @@ -460,6 +460,7 @@ pub struct App { 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, @@ -491,6 +492,7 @@ impl App { 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, @@ -692,15 +694,19 @@ impl App { self.input_state = TextState::default(); self.input_state.focus(); self.update_command_list(); + self.present_command = String::new(); } - } +} 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() { @@ -712,15 +718,15 @@ impl App { _ => self.history_ptr, }; self.history_ptr = new_ptr; - self.current_completion = String::new(); if let Some(i) = new_ptr { self.input_state = TextState::new().with_value(self.cmd_history[i].clone()); 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 { From 1010311c7483872c12ec94b2b2cdf24bc6e245be Mon Sep 17 00:00:00 2001 From: Chris Rui Zhao Date: Tue, 10 Feb 2026 15:57:17 -0500 Subject: [PATCH 16/28] Feat: Right arrow to accept completion, Esc no longer quits program --- twinleaf-tools/src/bin/tio-monitor.rs | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/twinleaf-tools/src/bin/tio-monitor.rs b/twinleaf-tools/src/bin/tio-monitor.rs index 24edd6d..19c1235 100644 --- a/twinleaf-tools/src/bin/tio-monitor.rs +++ b/twinleaf-tools/src/bin/tio-monitor.rs @@ -992,6 +992,9 @@ fn get_action(ev: Event, app: &mut App) -> Option { 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::Enter => Some(Action::SubmitCommand), _ => { app.input_state.handle_key_event(k); @@ -1002,13 +1005,7 @@ fn get_action(ev: Event, app: &mut App) -> Option { 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), From 7ecbca5b43c366784705dae8afca0f10dfa7ce31 Mon Sep 17 00:00:00 2001 From: Chris Rui Zhao Date: Tue, 10 Feb 2026 15:57:17 -0500 Subject: [PATCH 17/28] Feat: Right arrow to accept completion, Esc no longer quits program --- twinleaf-tools/src/bin/tio-monitor.rs | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/twinleaf-tools/src/bin/tio-monitor.rs b/twinleaf-tools/src/bin/tio-monitor.rs index 24edd6d..19c1235 100644 --- a/twinleaf-tools/src/bin/tio-monitor.rs +++ b/twinleaf-tools/src/bin/tio-monitor.rs @@ -992,6 +992,9 @@ fn get_action(ev: Event, app: &mut App) -> Option { 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::Enter => Some(Action::SubmitCommand), _ => { app.input_state.handle_key_event(k); @@ -1002,13 +1005,7 @@ fn get_action(ev: Event, app: &mut App) -> Option { 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), From 3a76346dc920f47e25ab58102384a25c82adc2a2 Mon Sep 17 00:00:00 2001 From: Chris Rui Zhao Date: Tue, 10 Feb 2026 17:24:10 -0500 Subject: [PATCH 18/28] Feat: Move rpc list read to thread to avoid freezing while writing cache --- twinleaf-tools/src/bin/tio-monitor.rs | 34 ++++++++++++++++++--------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/twinleaf-tools/src/bin/tio-monitor.rs b/twinleaf-tools/src/bin/tio-monitor.rs index 19c1235..0fe4382 100644 --- a/twinleaf-tools/src/bin/tio-monitor.rs +++ b/twinleaf-tools/src/bin/tio-monitor.rs @@ -470,7 +470,7 @@ pub struct App { const RPCLIST_MAX_LEN: usize = 12; impl App { - pub fn new(all: bool, parent_route: &DeviceRoute, rpcs: &Result, ()>) -> Self { + pub fn new(all: bool, parent_route: &DeviceRoute) -> Self { Self { all, parent_route: parent_route.clone(), @@ -484,7 +484,7 @@ impl App { device_metadata: HashMap::new(), window_aligned: None, footer_height: 0, - rpcs: rpcs.clone().expect("Failed to obtain cache list"), + rpcs: Vec::new(), suggested_rpcs: VecDeque::from(vec![String::new()]), suggested_rpcs_len: 1, suggested_rpcs_ind: 0, @@ -1767,16 +1767,22 @@ fn main() { } }); + + // Cache thread + let (cache_tx, cache_rx) = channel::unbounded(); + let cache_route = parent_route.clone(); + let cache_client = RpcClient::open(&proxy, cache_route) + .expect("Failed to open RPC client"); + std::thread::spawn(move || { + let Ok(list) = cache_client.rpc_list(cache_client.root_route()) else { return }; + if cache_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"); - - // Get RPC list from cache or device - let rpcs = rpc_client.rpc_list(&parent_route).map_err(|e| { - eprintln!("RPC list failed: {:?}", e); - }); std::thread::spawn(move || { while let Ok(req) = rpc_rx.recv() { @@ -1792,13 +1798,13 @@ fn main() { std::thread::spawn(move || loop { if let Ok(ev) = event::read() { if key_tx.send(ev).is_err() { - break; + return; } } }); // App state - let mut app = App::new(cli.all, &parent_route, &rpcs); + let mut app = App::new(cli.all, &parent_route); if let Some(path) = &cli.colors { if let Ok(theme) = load_theme(path) { app.view.theme = theme; @@ -1838,6 +1844,12 @@ fn main() { } } + recv(cache_rx) -> list => { + if let Ok(list) = list { + app.rpcs = list; + } + } + recv(rpc_resp_rx) -> res => { if let Ok(res) = res { let (msg, col) = match res.result { From 69eed0ccf3fa7407387b58aa73a5e5b991bf5f07 Mon Sep 17 00:00:00 2001 From: Chris Rui Zhao Date: Tue, 10 Feb 2026 17:24:10 -0500 Subject: [PATCH 19/28] Feat: Move rpc list read to thread to avoid freezing while writing cache --- twinleaf-tools/src/bin/tio-monitor.rs | 34 ++++++++++++++++++--------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/twinleaf-tools/src/bin/tio-monitor.rs b/twinleaf-tools/src/bin/tio-monitor.rs index 19c1235..0fe4382 100644 --- a/twinleaf-tools/src/bin/tio-monitor.rs +++ b/twinleaf-tools/src/bin/tio-monitor.rs @@ -470,7 +470,7 @@ pub struct App { const RPCLIST_MAX_LEN: usize = 12; impl App { - pub fn new(all: bool, parent_route: &DeviceRoute, rpcs: &Result, ()>) -> Self { + pub fn new(all: bool, parent_route: &DeviceRoute) -> Self { Self { all, parent_route: parent_route.clone(), @@ -484,7 +484,7 @@ impl App { device_metadata: HashMap::new(), window_aligned: None, footer_height: 0, - rpcs: rpcs.clone().expect("Failed to obtain cache list"), + rpcs: Vec::new(), suggested_rpcs: VecDeque::from(vec![String::new()]), suggested_rpcs_len: 1, suggested_rpcs_ind: 0, @@ -1767,16 +1767,22 @@ fn main() { } }); + + // Cache thread + let (cache_tx, cache_rx) = channel::unbounded(); + let cache_route = parent_route.clone(); + let cache_client = RpcClient::open(&proxy, cache_route) + .expect("Failed to open RPC client"); + std::thread::spawn(move || { + let Ok(list) = cache_client.rpc_list(cache_client.root_route()) else { return }; + if cache_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"); - - // Get RPC list from cache or device - let rpcs = rpc_client.rpc_list(&parent_route).map_err(|e| { - eprintln!("RPC list failed: {:?}", e); - }); std::thread::spawn(move || { while let Ok(req) = rpc_rx.recv() { @@ -1792,13 +1798,13 @@ fn main() { std::thread::spawn(move || loop { if let Ok(ev) = event::read() { if key_tx.send(ev).is_err() { - break; + return; } } }); // App state - let mut app = App::new(cli.all, &parent_route, &rpcs); + let mut app = App::new(cli.all, &parent_route); if let Some(path) = &cli.colors { if let Ok(theme) = load_theme(path) { app.view.theme = theme; @@ -1838,6 +1844,12 @@ fn main() { } } + recv(cache_rx) -> list => { + if let Ok(list) = list { + app.rpcs = list; + } + } + recv(rpc_resp_rx) -> res => { if let Ok(res) = res { let (msg, col) = match res.result { From d5c39966cd0c32ec61f02cb50aeb837db683be7d Mon Sep 17 00:00:00 2001 From: Chris Rui Zhao Date: Wed, 25 Feb 2026 12:40:04 -0500 Subject: [PATCH 20/28] Feat (monitor): App holds hashmap for multiple route completion --- twinleaf-tools/src/bin/tio-monitor.rs | 39 ++++++++++++++++----------- twinleaf-tools/src/bin/tio-tool.rs | 2 +- twinleaf/src/device/mod.rs | 2 +- twinleaf/src/device/rpc.rs | 39 ++++++++++++++++----------- 4 files changed, 50 insertions(+), 32 deletions(-) diff --git a/twinleaf-tools/src/bin/tio-monitor.rs b/twinleaf-tools/src/bin/tio-monitor.rs index d4ce020..c4ebfa5 100644 --- a/twinleaf-tools/src/bin/tio-monitor.rs +++ b/twinleaf-tools/src/bin/tio-monitor.rs @@ -26,7 +26,7 @@ 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::{ @@ -438,7 +438,7 @@ pub struct App { pub window_aligned: Option, pub footer_height: u16, - pub rpcs: Vec<(u16, String)>, + pub rpcs: HashMap, pub suggested_rpcs: VecDeque, pub suggested_rpcs_len: usize, pub suggested_rpcs_ind: usize, @@ -471,7 +471,7 @@ impl App { device_metadata: HashMap::new(), window_aligned: None, footer_height: 0, - rpcs: Vec::new(), + rpcs: HashMap::new(), suggested_rpcs: VecDeque::from(vec![String::new()]), suggested_rpcs_len: 1, suggested_rpcs_ind: 0, @@ -623,8 +623,10 @@ impl App { let line = self.input_state.value().to_string(); let mut rpc_cache = Vec::new(); if !line.is_empty() { - for (_, name) in self.rpcs.clone() { - rpc_cache.push(name); + if let Some(l) = self.rpcs.get(&self.current_route()) { + for (_, name) in l.list.clone() { + rpc_cache.push(name); + } } } @@ -683,7 +685,11 @@ impl App { 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() { @@ -803,10 +809,11 @@ 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 { @@ -1757,13 +1764,15 @@ fn main() { // Cache thread - let (cache_tx, cache_rx) = channel::unbounded(); - let cache_route = parent_route.clone(); - let cache_client = RpcClient::open(&proxy, cache_route) + 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 || { - let Ok(list) = cache_client.rpc_list(cache_client.root_route()) else { return }; - if cache_tx.send(list).is_err() { return }; + 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 @@ -1816,7 +1825,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, } @@ -1832,9 +1841,9 @@ fn main() { } } - recv(cache_rx) -> list => { + recv(cache_resp_rx) -> list => { if let Ok(list) = list { - app.rpcs = list; + app.update_rpclists(list); } } diff --git a/twinleaf-tools/src/bin/tio-tool.rs b/twinleaf-tools/src/bin/tio-tool.rs index 2bd2cf6..0160a70 100644 --- a/twinleaf-tools/src/bin/tio-tool.rs +++ b/twinleaf-tools/src/bin/tio-tool.rs @@ -295,7 +295,7 @@ fn list_rpcs(tio: &TioOpts) -> Result<(), ()> { eprintln!("RPC list failed: {:?}", e); })?; - for (meta, name) in &rpcs { + for (meta, name) in &rpcs.list { let spec = twinleaf::device::util::parse_rpc_spec(*meta, name.to_string()); println!( "{} {}({})", 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 c6f3307..6979560 100644 --- a/twinleaf/src/device/rpc.rs +++ b/twinleaf/src/device/rpc.rs @@ -261,11 +261,6 @@ impl RpcRegistry { } } -pub struct RpcClient { - port: proxy::Port, - root_route: DeviceRoute, -} - impl From for RpcListError { fn from(e: io::Error) -> Self { RpcListError::CacheFileError(e) @@ -291,11 +286,21 @@ pub enum RpcListError { CacheFileError(io::Error), } -type RpcList = Vec<(u16, String)>; +#[derive(Debug)] +pub struct RpcList { + pub route: DeviceRoute, + pub hash: u32, + pub list: Vec<(u16, String)>, +} + +pub struct RpcClient { + port: proxy::Port, + root_route: DeviceRoute, +} 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 { @@ -357,7 +362,7 @@ impl RpcClient { self.rpc(route, name, ()) } - fn read_rpc_cache(&self, file: fs::File) -> Result { + fn read_rpc_cache(&self, file: fs::File) -> Result, RpcListError> { let mut list: Vec<(u16, String)> = Vec::new(); let reader = io::BufReader::new(file); @@ -373,7 +378,7 @@ impl RpcClient { return Ok(list); } - fn write_rpc_cache(&self, route: &DeviceRoute, file: fs::File) -> Result { + fn write_rpc_cache(&self, route: &DeviceRoute, file: fs::File) -> Result, RpcListError> { let mut list: Vec<(u16, String)> = Vec::new(); let mut writer = io::BufWriter::new(file); @@ -396,20 +401,23 @@ impl RpcClient { let tl_cache_dir = cache_parent_dir.join("twinleaf"); fs::create_dir_all(&tl_cache_dir).map_err(|_| RpcListError::CacheDirError)?; - // Get what cache name + // Get cache file path let dev_name: String = self.get(route, "dev.name").map_err(|_| RpcListError::DevNameRpcError)?; - let rpc_hash: u32 = self.get(route, "rpc.hash").map_err(|_| RpcListError::RpcHashError)?; - let base_name = format!("{}.{:x}.rpcs", dev_name, rpc_hash); + 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 cache_file = fs::File::open(&file_path); // TODO: write more identifying info to cache file? - // Date created, firmware version, etc. + // Date created, firmware version, validation hash, etc. // Maybe on an ignored line at the top match cache_file { - Ok(file) => self.read_rpc_cache(file), + Ok(file) => { + let list = self.read_rpc_cache(file)?; + Ok(RpcList { route: route.clone(), hash, list } ) + } Err(err) if err.kind() == io::ErrorKind::NotFound => { let cache_file = fs::File::create(&file_path).map_err(|_| @@ -422,7 +430,7 @@ impl RpcClient { Err(_) => RpcListError::RemoveBadCacheError, } })?; - Ok(list) + Ok(RpcList{ route: route.clone(), hash, list} ) }, // TODO: what other io errors to handle? @@ -430,3 +438,4 @@ impl RpcClient { } } } + From 48af82994bb6a69e7c0b15cf9883d0243a14d844 Mon Sep 17 00:00:00 2001 From: Chris Rui Zhao Date: Wed, 25 Feb 2026 12:54:42 -0500 Subject: [PATCH 21/28] Fix: reset suggestion box size on new command mode --- twinleaf-tools/src/bin/tio-monitor.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/twinleaf-tools/src/bin/tio-monitor.rs b/twinleaf-tools/src/bin/tio-monitor.rs index c4ebfa5..b90206e 100644 --- a/twinleaf-tools/src/bin/tio-monitor.rs +++ b/twinleaf-tools/src/bin/tio-monitor.rs @@ -491,12 +491,11 @@ 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.current_completion = String::new(); self.history_ptr = None; - self.suggested_rpcs = VecDeque::from(vec![String::new()]); + self.update_command_list(); + self.mode = Mode::Command; } Action::SetMode(Mode::Normal) => { self.mode = Mode::Normal; From 17aa33cab1c2677fb69642e516eb98e5a40574f1 Mon Sep 17 00:00:00 2001 From: Chris Rui Zhao Date: Wed, 25 Feb 2026 14:56:09 -0500 Subject: [PATCH 22/28] Feat: Store hashmap for rpcs to not have to request their info if we have a cache --- twinleaf-tools/src/bin/tio-monitor.rs | 19 +++++++++++----- twinleaf-tools/src/bin/tio-tool.rs | 7 ++++-- twinleaf/src/device/rpc.rs | 31 +++++++++++++++------------ 3 files changed, 36 insertions(+), 21 deletions(-) diff --git a/twinleaf-tools/src/bin/tio-monitor.rs b/twinleaf-tools/src/bin/tio-monitor.rs index b90206e..ba616b7 100644 --- a/twinleaf-tools/src/bin/tio-monitor.rs +++ b/twinleaf-tools/src/bin/tio-monitor.rs @@ -386,6 +386,7 @@ impl Default for ViewConfig { #[derive(Debug)] pub struct RpcReq { pub route: DeviceRoute, + pub meta: Option, pub method: String, pub arg: Option, } @@ -396,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()); @@ -623,8 +627,8 @@ impl App { 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.clone() { - rpc_cache.push(name); + for (name, _) in &l.list { + rpc_cache.push(name.to_string()); } } } @@ -673,8 +677,13 @@ 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, }); diff --git a/twinleaf-tools/src/bin/tio-tool.rs b/twinleaf-tools/src/bin/tio-tool.rs index 0160a70..ae31f22 100644 --- a/twinleaf-tools/src/bin/tio-tool.rs +++ b/twinleaf-tools/src/bin/tio-tool.rs @@ -295,8 +295,11 @@ fn list_rpcs(tio: &TioOpts) -> Result<(), ()> { eprintln!("RPC list failed: {:?}", e); })?; - for (meta, name) in &rpcs.list { - let spec = twinleaf::device::util::parse_rpc_spec(*meta, name.to_string()); + 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(), diff --git a/twinleaf/src/device/rpc.rs b/twinleaf/src/device/rpc.rs index 6979560..05642cf 100644 --- a/twinleaf/src/device/rpc.rs +++ b/twinleaf/src/device/rpc.rs @@ -290,7 +290,8 @@ pub enum RpcListError { pub struct RpcList { pub route: DeviceRoute, pub hash: u32, - pub list: Vec<(u16, String)>, + pub list: Vec<(String, u16)>, + pub map: HashMap, } pub struct RpcClient { @@ -362,8 +363,9 @@ impl RpcClient { self.rpc(route, name, ()) } - fn read_rpc_cache(&self, file: fs::File) -> Result, RpcListError> { - let mut list: Vec<(u16, String)> = Vec::new(); + 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() { @@ -372,14 +374,16 @@ impl RpcClient { let meta_hex = u16::from_str_radix(meta, 16).map_err(|_| RpcListError::InvalidCacheError)?; let name_string = name.trim().to_string(); - list.push((meta_hex, name_string)); + list.push((name_string.clone(), meta_hex)); + map.insert(name_string, meta_hex); } - return Ok(list); + return Ok(RpcList { route: route.clone(), hash, list, map }); } - fn write_rpc_cache(&self, route: &DeviceRoute, file: fs::File) -> Result, RpcListError> { - let mut list: Vec<(u16, String)> = Vec::new(); + 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)?; @@ -389,10 +393,11 @@ impl RpcClient { RpcListError::RpcListError)?; writeln!(writer, "{:04x} {}", meta, name).map_err(|_| RpcListError::CacheWriteError)?; - list.push((meta, name)); + list.push((name.clone(), meta)); + map.insert(name, meta); } - return Ok(list); + return Ok(RpcList { route: route.clone(), hash, list, map }); } pub fn rpc_list(&self, route: &DeviceRoute) -> Result { @@ -415,8 +420,7 @@ impl RpcClient { match cache_file { Ok(file) => { - let list = self.read_rpc_cache(file)?; - Ok(RpcList { route: route.clone(), hash, list } ) + self.read_rpc_cache(route, hash, file) } Err(err) if err.kind() == io::ErrorKind::NotFound => { @@ -424,13 +428,12 @@ impl RpcClient { RpcListError::CacheCreateError)?; // Try to write, and if we fail, remove the file we created - let list = self.write_rpc_cache(route, cache_file).map_err(|orig_error| { + self.write_rpc_cache(route, hash, cache_file).map_err(|orig_error| { match fs::remove_file(file_path) { Ok(_) => orig_error, Err(_) => RpcListError::RemoveBadCacheError, } - })?; - Ok(RpcList{ route: route.clone(), hash, list} ) + }) }, // TODO: what other io errors to handle? From adf688c1cec5514229ff5f9a04e4943369874aa2 Mon Sep 17 00:00:00 2001 From: Chris Rui Zhao Date: Wed, 25 Feb 2026 15:21:23 -0500 Subject: [PATCH 23/28] Fix: Left and right arrow keys work in command mode --- twinleaf-tools/src/bin/tio-monitor.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/twinleaf-tools/src/bin/tio-monitor.rs b/twinleaf-tools/src/bin/tio-monitor.rs index ba616b7..19cc17b 100644 --- a/twinleaf-tools/src/bin/tio-monitor.rs +++ b/twinleaf-tools/src/bin/tio-monitor.rs @@ -997,6 +997,15 @@ fn get_action(ev: Event, app: &mut App) -> Option { 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); From 99f2683db1677ff1661e5b0a0c8b31cc188db28c Mon Sep 17 00:00:00 2001 From: Chris Rui Zhao Date: Thu, 26 Feb 2026 17:07:56 -0500 Subject: [PATCH 24/28] Fix (cache-rpc): Remove empty cache file instead of reading nothing --- twinleaf/src/device/rpc.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/twinleaf/src/device/rpc.rs b/twinleaf/src/device/rpc.rs index 05642cf..52674b7 100644 --- a/twinleaf/src/device/rpc.rs +++ b/twinleaf/src/device/rpc.rs @@ -378,7 +378,7 @@ impl RpcClient { map.insert(name_string, meta_hex); } - return Ok(RpcList { route: route.clone(), hash, list, map }); + Ok(RpcList { route: route.clone(), hash, list, map }) } fn write_rpc_cache(&self, route: &DeviceRoute, hash: u32, file: fs::File) -> Result { @@ -397,7 +397,7 @@ impl RpcClient { map.insert(name, meta); } - return Ok(RpcList { route: route.clone(), hash, list, map }); + Ok(RpcList { route: route.clone(), hash, list, map }) } pub fn rpc_list(&self, route: &DeviceRoute) -> Result { @@ -411,6 +411,10 @@ impl RpcClient { 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); @@ -437,6 +441,7 @@ impl RpcClient { }, // TODO: what other io errors to handle? + // Err(other_err) => Err(RpcListError::CacheFileError(other_err)), } } From 7c351609b663a50a4b572d960dde5ccec4ddee1d Mon Sep 17 00:00:00 2001 From: Chris Rui Zhao Date: Thu, 26 Feb 2026 17:20:58 -0500 Subject: [PATCH 25/28] Feat (proxy): Accept settings payload for cache invalidation --- twinleaf/src/device/device.rs | 11 ++++++++ twinleaf/src/device/tree.rs | 13 ++++++++++ twinleaf/src/tio/proto.rs | 47 +++++++++++++++++++++++++++++++++++ 3 files changed, 71 insertions(+) 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/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, From 8199ec223b365d67ca59f60cf43a8edce1502f46 Mon Sep 17 00:00:00 2001 From: Chris Rui Zhao Date: Thu, 26 Feb 2026 17:26:14 -0500 Subject: [PATCH 26/28] Feat: Update tio-monitor and tio-health to accept new settings deviceevent --- twinleaf-tools/src/bin/tio-health.rs | 3 +++ twinleaf-tools/src/bin/tio-monitor.rs | 13 +++++++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/twinleaf-tools/src/bin/tio-health.rs b/twinleaf-tools/src/bin/tio-health.rs index 8b1b39b..3ae92a3 100644 --- a/twinleaf-tools/src/bin/tio-health.rs +++ b/twinleaf-tools/src/bin/tio-health.rs @@ -870,6 +870,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 19cc17b..b0e28cc 100644 --- a/twinleaf-tools/src/bin/tio-monitor.rs +++ b/twinleaf-tools/src/bin/tio-monitor.rs @@ -824,6 +824,12 @@ impl App { 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 { .. }, @@ -834,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; + } _ => {} } } From fab6d815e959402d334fa178f30a50bf12e5bde1 Mon Sep 17 00:00:00 2001 From: Ilysia Krzywonos Date: Fri, 27 Feb 2026 10:23:51 -0500 Subject: [PATCH 27/28] Feat: Basic shell completion for twinleaf-tools --- Cargo.lock | 8 +- twinleaf-tools/Cargo.toml | 3 +- twinleaf-tools/build.rs | 14 +- twinleaf-tools/src/bin/tio-health.rs | 134 +------- twinleaf-tools/src/bin/tio-proxy.rs | 67 +--- twinleaf-tools/src/bin/tio-tool.rs | 291 +---------------- twinleaf-tools/src/lib.rs | 468 ++++++++++++++++++++++++++- 7 files changed, 497 insertions(+), 488 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a512e1a..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", @@ -610,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", diff --git a/twinleaf-tools/Cargo.toml b/twinleaf-tools/Cargo.toml index 8f2e9fd..148053e 100644 --- a/twinleaf-tools/Cargo.toml +++ b/twinleaf-tools/Cargo.toml @@ -14,7 +14,6 @@ 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"]} @@ -27,8 +26,10 @@ welch-sde = "0.1.0" memmap2 = "0.9.9" 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 index f6846ae..10ccecc 100644 --- a/twinleaf-tools/build.rs +++ b/twinleaf-tools/build.rs @@ -1,6 +1,6 @@ -use clap::{CommandFactory, ValueEnum}; -use clap_complete::{generate_to, Shell}; use std::{env, io}; +use clap::{CommandFactory}; +use clap_complete::{generate_to, Shell}; include!("src/lib.rs"); @@ -10,9 +10,15 @@ fn main() -> Result<(), io::Error> { Some(outdir) => outdir, }; - let mut cmd = MonitorCli::command(); + 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 cmd, "tio-monitor", &outdir)?; + 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 3ae92a3..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); diff --git a/twinleaf-tools/src/bin/tio-proxy.rs b/twinleaf-tools/src/bin/tio-proxy.rs index 794d198..abe379a 100644 --- a/twinleaf-tools/src/bin/tio-proxy.rs +++ b/twinleaf-tools/src/bin/tio-proxy.rs @@ -10,70 +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 @@ -154,7 +91,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 ae31f22..b06d492 100644 --- a/twinleaf-tools/src/bin/tio-tool.rs +++ b/twinleaf-tools/src/bin/tio-tool.rs @@ -1,4 +1,10 @@ -use clap::{Parser, Subcommand, ValueEnum}; +use std::collections::HashMap; +use std::fs::File; +use std::fs::OpenOptions; +use std::io::prelude::*; +use std::process::ExitCode; + +use clap::{Parser}; use tio::proto::DeviceRoute; use tio::proxy; use tio::util; @@ -6,286 +12,7 @@ use twinleaf::data::DeviceDataParser; use twinleaf::device::{Device, DeviceTree, RpcClient}; 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 twinleaf_tools::{TioToolCli, SplitLevel, SplitPolicy, Commands}; fn list_rpcs(tio: &TioOpts) -> Result<(), ()> { let proxy = proxy::Interface::new(&tio.root); @@ -1205,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/lib.rs b/twinleaf-tools/src/lib.rs index 048591e..d527624 100644 --- a/twinleaf-tools/src/lib.rs +++ b/twinleaf-tools/src/lib.rs @@ -1,4 +1,6 @@ -use clap::Parser; +use std::time::Duration; +use std::num::ParseFloatError; +use clap::{Parser, Subcommand, ValueEnum}; use tio::proto::DeviceRoute; use tio::util; use twinleaf::tio; @@ -30,6 +32,343 @@ impl TioOpts { } } +#[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, +} + +#[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, + } + } +} + #[derive(Parser, Debug)] #[command(name = "tio-monitor", version, about = "Display live sensor data")] pub struct MonitorCli { @@ -42,3 +381,130 @@ pub struct MonitorCli { #[arg(short = 'c', long = "colors")] pub colors: Option, } + +#[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) + } +} From 896787cd129ee8e14e81d86d5bf404f8d640e34e Mon Sep 17 00:00:00 2001 From: Ilysia Krzywonos Date: Fri, 27 Feb 2026 12:11:37 -0500 Subject: [PATCH 28/28] Refactor CLIs to individual files :) --- twinleaf-tools/src/bin/tio-proxy.rs | 1 - twinleaf-tools/src/health_cli.rs | 128 ++++++++ twinleaf-tools/src/lib.rs | 485 +--------------------------- twinleaf-tools/src/monitor_cli.rs | 12 + twinleaf-tools/src/proxy_cli.rs | 62 ++++ twinleaf-tools/src/tool_cli.rs | 276 ++++++++++++++++ 6 files changed, 483 insertions(+), 481 deletions(-) create mode 100644 twinleaf-tools/src/health_cli.rs create mode 100644 twinleaf-tools/src/monitor_cli.rs create mode 100644 twinleaf-tools/src/proxy_cli.rs create mode 100644 twinleaf-tools/src/tool_cli.rs diff --git a/twinleaf-tools/src/bin/tio-proxy.rs b/twinleaf-tools/src/bin/tio-proxy.rs index abe379a..78fdeeb 100644 --- a/twinleaf-tools/src/bin/tio-proxy.rs +++ b/twinleaf-tools/src/bin/tio-proxy.rs @@ -11,7 +11,6 @@ use std::time::Duration; use tio::{proto, proxy}; use twinleaf::tio; 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. 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 d527624..3af4b97 100644 --- a/twinleaf-tools/src/lib.rs +++ b/twinleaf-tools/src/lib.rs @@ -1,10 +1,12 @@ -use std::time::Duration; -use std::num::ParseFloatError; -use clap::{Parser, Subcommand, ValueEnum}; 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) @@ -31,480 +33,3 @@ impl TioOpts { DeviceRoute::from_str(&self.route_path).unwrap_or_else(|_| DeviceRoute::root()) } } - -#[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, -} - -#[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, - } - } -} - -#[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, -} - -#[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/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, + } + } +}