Skip to main content

twinleaf_tools/
tio_cli.rs

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