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 _ => 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 {
46 #[arg(short = 'a', long = "all")]
48 all: bool,
49 },
50
51 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 #[arg(long = "depth")]
61 depth: Option<usize>,
62 },
63
64 Health(HealthCli),
66
67 Dump {
69 #[command(flatten)]
70 tio: TioOpts,
71
72 #[arg(short = 'd', long = "data")]
74 data: bool,
75
76 #[arg(short = 'm', long = "meta")]
78 meta: bool,
79
80 #[arg(long = "depth")]
82 depth: Option<usize>,
83 },
84
85 #[command(args_conflicts_with_subcommands = true)]
87 Log {
88 #[command(flatten)]
89 tio: TioOpts,
90
91 #[command(subcommand)]
92 subcommands: Option<LogSubcommands>,
93
94 #[arg(short = 'f', default_value_t = default_log_path())]
96 file: String,
97
98 #[arg(short = 'u')]
100 unbuffered: bool,
101
102 #[arg(long)]
104 raw: bool,
105
106 #[arg(long = "depth")]
108 depth: Option<usize>,
109
110 #[arg(long, value_parser = humantime::parse_duration)]
112 duration: Option<std::time::Duration>,
113 },
114
115 #[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 #[arg(value_hint = ValueHint::Other)]
126 rpc_name: Option<String>,
127
128 #[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 #[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 #[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 #[arg(short = 'd', long)]
157 debug: bool,
158 },
159
160 #[command(alias = "firmware-upgrade")]
162 Upgrade {
163 #[command(flatten)]
164 tio: TioOpts,
165
166 #[arg(value_hint = ValueHint::FilePath, value_parser = parse_existing_file)]
168 firmware_path: PathBuf,
169
170 #[arg(short = 'y', long = "yes")]
172 yes: bool,
173 },
174
175 Proxy(ProxyCli),
177
178 Test(TestCli),
180
181 #[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 {
208 #[command(flatten)]
209 tio: TioOpts,
210 },
211 Dump {
213 #[command(flatten)]
214 tio: TioOpts,
215
216 #[arg(value_hint = ValueHint::Other)]
218 rpc_name: String,
219
220 #[arg(long)]
222 capture: bool,
223 },
224}
225
226#[derive(Subcommand, Debug)]
227pub enum LogSubcommands {
228 #[command(args_conflicts_with_subcommands = true)]
230 Meta {
231 #[command(flatten)]
232 tio: TioOpts,
233
234 #[command(subcommand)]
235 subcommands: Option<MetaSubcommands>,
236
237 #[arg(short = 'f', default_value = "meta.tio")]
239 file: String,
240 },
241
242 Dump {
244 #[arg(value_hint = ValueHint::FilePath, required = true, num_args = 1..)]
246 files: Vec<String>,
247
248 #[arg(short = 'd', long = "data")]
250 data: bool,
251
252 #[arg(short = 'm', long = "meta")]
254 meta: bool,
255
256 #[arg(short = 's', long = "sensor", default_value = "/")]
258 sensor: String,
259
260 #[arg(long = "depth")]
262 depth: Option<usize>,
263 },
264
265 Inspect {
267 #[arg(value_hint = ValueHint::FilePath, required = true, num_args = 1..)]
269 files: Vec<String>,
270 },
271
272 Csv {
274 #[arg(value_hint = ValueHint::FilePath)]
276 args: Vec<String>,
277
278 #[arg(short = 's')]
280 sensor: Option<String>,
281
282 #[arg(short = 'o')]
284 output: Option<String>,
285 },
286
287 #[command(alias = "hdf5")]
289 Hdf {
290 #[arg(value_hint = ValueHint::FilePath, required = true, num_args = 1..)]
292 files: Vec<String>,
293
294 #[arg(short = 'o')]
296 output: Option<String>,
297
298 #[arg(short = 'g', long = "glob")]
300 filter: Option<String>,
301
302 #[arg(short = 'c', long = "compress")]
304 compress: bool,
305
306 #[arg(short = 'd', long)]
308 debug: bool,
309
310 #[arg(short = 'l', long = "split", default_value = "none")]
312 split_level: SplitLevel,
313
314 #[arg(short = 'p', long = "policy", default_value = "continuous")]
316 split_policy: SplitPolicy,
317 },
318}
319
320#[derive(Subcommand, Debug)]
321pub enum MetaSubcommands {
322 Reroute {
324 #[arg(value_hint = ValueHint::FilePath)]
326 input: String,
327
328 #[arg(short = 's', long = "sensor")]
330 route: String,
331
332 #[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#[derive(ValueEnum, Clone, Debug, Default)]
346pub enum SplitPolicy {
347 #[default]
349 Continuous,
350 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#[derive(ValueEnum, Clone, Debug, Default)]
366pub enum SplitLevel {
367 #[default]
369 None,
370 Stream,
372 Device,
374 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 #[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 #[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 #[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 #[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 #[arg(short = 'q', long = "quiet")]
442 quiet: bool,
443
444 #[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 #[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 #[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 #[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 #[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 #[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 #[arg(long = "frequency", default_value = "10", value_parser = nonneg_f64)]
525 frequency: f64,
526
527 #[arg(long = "amplitude", default_value = "1", value_parser = nonneg_f64)]
529 amplitude: f64,
530
531 #[arg(long = "noise", default_value = ".01", value_parser = nonneg_f64)]
533 noise: f64,
534
535 #[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 #[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 #[arg(value_hint = ValueHint::Url)]
561 sensor_url: Option<String>,
562
563 #[arg(short = 'p', long = "port", default_value = "7855")]
565 port: u16,
566
567 #[arg(short = 'k', long)]
569 kick_slow: bool,
570
571 #[arg(
573 short = 's',
574 long = "subtree",
575 default_value = "/",
576 value_parser = parse_device_route,
577 )]
578 subtree: DeviceRoute,
579
580 #[arg(short = 'v', long)]
582 verbose: bool,
583
584 #[arg(short = 'd', long)]
586 debug: bool,
587
588 #[arg(short = 't', long = "timestamp", default_value = "%T%.3f ")]
590 timestamp_format: String,
591
592 #[arg(short = 'T', long = "timeout", default_value = "30")]
594 reconnect_timeout: u64,
595
596 #[arg(long)]
598 dump: bool,
599
600 #[arg(long)]
602 dump_data: bool,
603
604 #[arg(long)]
606 dump_meta: bool,
607
608 #[arg(long)]
610 dump_hb: bool,
611
612 #[arg(short = 'a', long = "auto", hide = true)]
614 auto: bool,
615
616 #[arg(short = 'e', long = "enumerate", name = "enum", hide = true)]
618 enumerate: bool,
619}
620
621#[derive(Subcommand, Debug)]
622pub enum ProxySubcommands {
623 Nmea {
625 #[command(flatten)]
626 tio: TioOpts,
627
628 #[arg(short = 'p', long = "port", default_value = "7800")]
630 tcp_port: u16,
631 },
632}