Skip to main content

twinleaf_tools/
tio_cli.rs

1use clap::{builder::ValueHint, Subcommand, ValueEnum};
2use clap_complete::Shell;
3
4#[derive(Parser, Debug)]
5#[command(
6    name = "tio",
7    version,
8    about = "Twinleaf sensor management and data logging tool", 
9)]
10pub struct TioCli {
11    #[command(subcommand)]
12    pub command: Commands,
13}
14
15#[derive(Subcommand, Debug)]
16pub enum Commands {
17	Proxy(ProxyCli),
18    ///Live sensor data and plot display
19	Monitor {
20		#[command(flatten)]
21		tio: TioOpts,
22		#[arg(short = 'a', long = "all")]
23		all: bool,
24		#[arg(long = "fps", default_value_t = 20)]
25		fps: u32,
26		#[arg(short = 'c', long = "colors")]
27		colors: Option<String>,
28	},
29	Health(HealthCli),
30    ///Bridge Twinleaf sensor data to NMEA TCP stream
31    NmeaProxy{
32        #[command(flatten)]
33        tio: TioOpts,
34
35        #[arg(
36            short = 'p',
37            long = "port",
38            default_value = "7800",
39            help = "TCP port to listen on"
40        )]
41        tcp_port: u16,
42    },
43
44    #[command(args_conflicts_with_subcommands = true)]
45    /// Execute an RPC on the device. See "tio rpc --help" for more options
46    Rpc {
47        #[command(flatten)]
48        tio: TioOpts,
49
50        #[command(subcommand)]
51        subcommands: Option<RPCSubcommands>,
52
53        /// RPC name to execute
54        #[arg(value_hint = ValueHint::Other)]
55        rpc_name: Option<String>,
56
57        /// RPC argument value
58        #[arg(
59            allow_negative_numbers = true,
60            value_name = "ARG",
61            value_hint = ValueHint::Other,
62            help_heading = "RPC Arguments"
63        )]
64        rpc_arg: Option<String>,
65
66        /// RPC request type (one of: u8, u16, u32, u64, i8, i16, i32, i64, f32, f64, string)
67        #[arg(short = 't', long = "req-type", help_heading = "Type Options")]
68        req_type: Option<String>,
69
70        /// RPC reply type (one of: u8, u16, u32, u64, i8, i16, i32, i64, f32, f64, string)
71        #[arg(short = 'T', long = "rep-type", help_heading = "Type Options")]
72        rep_type: Option<String>,
73
74        /// Enable debug output
75        #[arg(short = 'd', long)]
76        debug: bool,
77    },
78
79    #[command(args_conflicts_with_subcommands = true)]
80    /// Log samples to a file (includes metadata by default) See "tio log --help" for more options
81    Log {
82        #[command(flatten)]
83        tio: TioOpts,
84
85        #[command(subcommand)]
86        subcommands: Option<LogSubcommands>,
87
88        /// Output log file path
89        #[arg(short = 'f', default_value_t = default_log_path())]
90        file: String,
91
92        /// Unbuffered output (flush every packet)
93        #[arg(short = 'u')]
94        unbuffered: bool,
95
96        /// Raw mode: skip metadata request and dump all packets
97        #[arg(long)]
98        raw: bool,
99
100        /// Routing depth (only used in --raw mode)
101        #[arg(long = "depth")]
102        depth: Option<usize>,
103    },
104    /// Reroute metadata packets in a metadata file
105    MetaReroute {
106        /// Input metadata file path
107        input: String,
108
109        /// New device route (e.g., /0/1)
110        #[arg(short = 's', long = "sensor")]
111        route: String,
112
113        /// Output metadata file path (defaults to <input>_rerouted.tio)
114        #[arg(short = 'o', long = "output")]
115        output: Option<String>,
116    },
117    #[command(args_conflicts_with_subcommands = true)]
118    /// Dump data from a live device
119    Dump {
120        #[command(flatten)]
121        tio: TioOpts,
122
123        #[command(subcommand)]
124        subcommands: Option<DumpSubcommands>,
125
126        /// Show parsed data samples
127        #[arg(short = 'd', long = "data")]
128        data: bool,
129
130        /// Show metadata on boundaries
131        #[arg(short = 'm', long = "meta")]
132        meta: bool,
133
134        /// Routing depth limit (default: unlimited)
135        #[arg(long = "depth")]
136        depth: Option<usize>,
137    },
138    /// Upgrade device firmware
139    FirmwareUpgrade {
140        #[command(flatten)]
141        tio: TioOpts,
142
143        /// Input firmware image path
144        #[arg(value_hint = ValueHint::FilePath)]
145        firmware_path: String,
146
147        /// Skip confirmation prompt
148        #[arg(short = 'y', long = "yes")]
149        yes: bool,
150    },
151    /// Generate shell completions for tio
152    #[command(long_about = "\
153Generate shell completions for tio.
154
155Add one of these lines to your shell's config file:
156
157  Bash (~/.bashrc):
158    eval \"$(tio completions bash)\"
159
160  Zsh (~/.zshrc):
161    eval \"$(tio completions zsh)\"
162
163  Fish (~/.config/fish/config.fish):
164    tio completions fish | source
165
166  PowerShell ($PROFILE):
167    tio completions powershell | Invoke-Expression")]
168    Completions {
169        #[arg(value_enum)]
170        shell: Shell,
171    },
172}
173
174#[derive(Subcommand, Debug)]
175pub enum RPCSubcommands{
176    /// List available RPCs on the device
177    List {
178        #[command(flatten)]
179        tio: TioOpts,
180    },
181    /// Dump RPC data from the device
182    Dump {
183        #[command(flatten)]
184        tio: TioOpts,
185
186        /// RPC name to dump
187        #[arg(value_hint = ValueHint::Other)]
188        rpc_name: String,
189
190        /// Trigger a capture before dumping
191        #[arg(long)]
192        capture: bool,
193    },
194}
195
196#[derive(Subcommand, Debug)]
197pub enum LogSubcommands{
198    /// Log metadata to a file
199    Metadata {
200        #[command(flatten)]
201        tio: TioOpts,
202
203        /// Output metadata file path
204        #[arg(short = 'f', default_value = "meta.tio")]
205        file: String,
206    },
207
208    /// Dump data from binary log file(s)
209    Dump {
210        /// Input log file(s)
211        files: Vec<String>,
212
213        /// Show parsed data samples
214        #[arg(short = 'd', long = "data")]
215        data: bool,
216
217        /// Show metadata on boundaries
218        #[arg(short = 'm', long = "meta")]
219        meta: bool,
220
221        /// Sensor path in the sensor tree (e.g., /, /0, /0/1)
222        #[arg(short = 's', long = "sensor", default_value = "/")]
223        sensor: String,
224
225        /// Routing depth limit (default: unlimited)
226        #[arg(long = "depth")]
227        depth: Option<usize>,
228    },
229
230    /// Dump parsed data from binary log file(s) [DEPRECATED: use log-dump -d]
231    #[command(hide = true)]
232    DataDump {
233        /// Input log file(s)
234        files: Vec<String>,
235    },
236
237    /// Convert binary log data to CSV
238    Csv {
239        /// Stream ID/name and input .tio files (order-independent)
240        args: Vec<String>,
241
242        /// Sensor route in the device tree (default: /)
243        #[arg(short = 's')]
244        sensor: Option<String>,
245
246        /// Output filename prefix
247        #[arg(short = 'o')]
248        output: Option<String>,
249    },
250
251    /// Convert binary log files to HDF5 format
252    Hdf {
253        /// Input log file(s)
254        files: Vec<String>,
255
256        /// Output file path (defaults to input filename with .h5 extension)
257        #[arg(short = 'o')]
258        output: Option<String>,
259
260        /// Filter streams using a glob pattern (e.g. "/*/vector")
261        #[arg(short = 'g', long = "glob")]
262        filter: Option<String>,
263
264        /// Enable deflate compression (saves space, slows down write significantly)
265        #[arg(short = 'c', long = "compress")]
266        compress: bool,
267
268        /// Enable debug output for glob matching
269        #[arg(short = 'd', long)]
270        debug: bool,
271
272        /// How to organize runs in the output (none=flat, stream=per-stream, device=per-device, global=all-shared)
273        #[arg(short = 'l', long = "split", default_value = "none")]
274        split_level: SplitLevel,
275
276        /// When to detect discontinuities (continuous=any gap, monotonic=only time backward)
277        #[arg(short = 'p', long = "policy", default_value = "continuous")]
278        split_policy: SplitPolicy,
279    },
280}
281
282#[derive(Subcommand, Debug)]
283pub enum DumpSubcommands{
284    /// Dump data samples from the device [DEPRECATED: use dump -d -s <ROUTE>]
285    #[command(hide = true)]
286    Data {
287        #[command(flatten)]
288        tio: TioOpts,
289    },
290
291    /// Dump data samples from all devices in the tree [DEPRECATED: use dump -a -d]
292    #[command(hide = true)]
293    DataAll {
294        #[command(flatten)]
295        tio: TioOpts,
296    },
297
298    /// Dump device metadata [DEPRECATED: use dump -m -s <ROUTE>]
299    #[command(hide = true)]
300    Meta {
301        #[command(flatten)]
302        tio: TioOpts,
303    },
304}
305
306fn default_log_path() -> String {
307    chrono::Local::now()
308        .format("log.%Y%m%d-%H%M%S.tio")
309        .to_string()
310}
311
312/// Controls when discontinuities trigger run splits
313#[derive(ValueEnum, Clone, Debug, Default)]
314pub enum SplitPolicy {
315    /// Split on any discontinuity (gaps, rate changes, etc.)
316    #[default]
317    Continuous,
318    /// Only split when time goes backward (allows gaps)
319    Monotonic,
320}
321
322#[cfg(feature = "hdf5")]
323impl From<SplitPolicy> for twinleaf::data::export::SplitPolicy {
324    fn from(policy: SplitPolicy) -> Self {
325        match policy {
326            SplitPolicy::Continuous => Self::Continuous,
327            SplitPolicy::Monotonic => Self::Monotonic,
328        }
329    }
330}
331
332/// Controls how runs are organized in the HDF5 output
333#[derive(ValueEnum, Clone, Debug, Default)]
334pub enum SplitLevel {
335    /// No run splitting - flat structure: /{route}/{stream}/{datasets}
336    #[default]
337    None,
338    /// Each stream has independent run counter
339    Stream,
340    /// All streams on a device share run counter
341    Device,
342    /// All streams globally share run counter
343    Global,
344}
345
346#[cfg(feature = "hdf5")]
347impl From<SplitLevel> for twinleaf::data::export::RunSplitLevel {
348    fn from(level: SplitLevel) -> Self {
349        match level {
350            SplitLevel::None => Self::None,
351            SplitLevel::Stream => Self::PerStream,
352            SplitLevel::Device => Self::PerDevice,
353            SplitLevel::Global => Self::Global,
354        }
355    }
356}
357
358
359#[derive(Parser, Debug, Clone)]
360#[command(
361    name = "tio-health",
362    version,
363    about = "Live timing & rate diagnostics for TIO (Twinleaf) devices"
364)]
365pub struct HealthCli {
366    #[command(flatten)]
367    tio: TioOpts,
368
369    /// Time window in seconds for calculating jitter statistics
370    #[arg(
371        long = "jitter-window",
372        default_value = "10",
373        value_name = "SECONDS",
374        value_parser = clap::value_parser!(u64).range(1..),
375        help = "Seconds for jitter calculation window (>= 1)"
376    )]
377    jitter_window: u64,
378
379    /// PPM threshold for yellow warning indicators
380    #[arg(
381        long = "ppm-warn",
382        default_value = "100",
383        value_name = "PPM",
384        value_parser = nonneg_f64,
385        help = "Warning threshold in parts per million (>= 0)"
386    )]
387    ppm_warn: f64,
388
389    /// PPM threshold for red error indicators
390    #[arg(
391        long = "ppm-err",
392        default_value = "200",
393        value_name = "PPM",
394        value_parser = nonneg_f64,
395        help = "Error threshold in parts per million (>= 0)"
396    )]
397    ppm_err: f64,
398
399    /// Filter to only show specific stream IDs (comma-separated)
400    #[arg(
401        long = "streams",
402        value_delimiter = ',',
403        value_name = "IDS",
404        value_parser = clap::value_parser!(u8),
405        help = "Comma-separated stream IDs to monitor (e.g., 0,1,5)"
406    )]
407    streams: Option<Vec<u8>>,
408
409    /// Suppress the footer help text
410    #[arg(short = 'q', long = "quiet")]
411    quiet: bool,
412
413    /// UI refresh rate for animations and stale detection (data updates are immediate)
414    #[arg(
415        long = "fps",
416        default_value = "30",
417        value_name = "FPS",
418        value_parser = clap::value_parser!(u64).range(1..=60),
419        help = "UI refresh rate for heartbeat animation and stale detection (1–60)"
420    )]
421    fps: u64,
422
423    /// Time in milliseconds before marking a stream as stale
424    #[arg(
425        long = "stale-ms",
426        default_value = "2000",
427        value_name = "MS",
428        value_parser = clap::value_parser!(u64).range(1..),
429        help = "Mark streams as stale after this many milliseconds without data (>= 1)"
430    )]
431    stale_ms: u64,
432
433    /// Maximum number of events to keep in the event log
434    #[arg(
435        short = 'n',
436        long = "event-log-size",
437        default_value = "100",
438        value_name = "N",
439        value_parser = clap::value_parser!(u64).range(1..),
440        help = "Maximum number of events to keep in history (>= 1)"
441    )]
442    event_log_size: u64,
443
444    /// Number of event lines to display on screen
445    #[arg(
446        long = "event-display-lines",
447        default_value = "8",
448        value_name = "LINES",
449        value_parser = clap::value_parser!(u16).range(3..),
450        help = "Number of event lines to show (>= 3)"
451    )]
452    event_display_lines: u16,
453
454    /// Only show warning and error events in the log
455    #[arg(short = 'w', long = "warnings-only")]
456    warnings_only: bool,
457}
458
459impl HealthCli {
460    fn stale_dur(&self) -> Duration {
461        Duration::from_millis(self.stale_ms)
462    }
463}
464
465fn nonneg_f64(s: &str) -> Result<f64, String> {
466    let v: f64 = s
467        .parse()
468        .map_err(|e: std::num::ParseFloatError| e.to_string())?;
469    if v < 0.0 {
470        Err("must be ≥ 0".into())
471    } else {
472        Ok(v)
473    }
474}
475
476#[derive(Parser, Debug)]
477#[command(
478    name = "tio-proxy",
479    version,
480    about = "Multiplexes access to a sensor, exposing the functionality of tio::proxy via TCP"
481)]
482pub struct ProxyCli {
483    /// Sensor URL (e.g., tcp://localhost, serial:///dev/ttyUSB0)
484    /// Required unless --auto or --enum is specified
485    sensor_url: Option<String>,
486
487    /// TCP port to listen on for clients
488    #[arg(short = 'p', long = "port", default_value = "7855")]
489    port: u16,
490
491    /// Kick off slow clients instead of dropping traffic
492    #[arg(short = 'k', long)]
493    kick_slow: bool,
494
495    /// Sensor subtree to look at
496    #[arg(short = 's', long = "subtree", default_value = "/")]
497    subtree: String,
498
499    /// Verbose output
500    #[arg(short = 'v', long)]
501    verbose: bool,
502
503    /// Debugging output
504    #[arg(short = 'd', long)]
505    debug: bool,
506
507    /// Timestamp format
508    #[arg(short = 't', long = "timestamp", default_value = "%T%.3f ")]
509    timestamp_format: String,
510
511    /// Time limit for sensor reconnection attempts (seconds)
512    #[arg(short = 'T', long = "timeout", default_value = "30")]
513    reconnect_timeout: u64,
514
515    /// Dump packet traffic except sample data/metadata or heartbeats
516    #[arg(long)]
517    dump: bool,
518
519    /// Dump sample data traffic
520    #[arg(long)]
521    dump_data: bool,
522
523    /// Dump sample metadata traffic
524    #[arg(long)]
525    dump_meta: bool,
526
527    /// Dump heartbeat traffic
528    #[arg(long)]
529    dump_hb: bool,
530
531    #[arg(short = 'a', long = "auto")]
532    auto: bool,
533
534    /// Enumerate all serial devices, then quit
535    #[arg(short = 'e', long = "enumerate", name = "enum")]
536    enumerate: bool,
537}