Skip to main content

twinleaf_tools/
tio_cli.rs

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