//! Hand-rolled argument parsing. //! //! Deliberately dependency-free: the probe is a Phase 0 diagnostic tool that //! has to build and run on whatever machine is next to the trainer, and it does //! not need an argument parser to do four subcommands. use std::time::Duration; use anyhow::{anyhow, bail, Result}; use bikecontrol_core::types::ControlTarget; pub const USAGE: &str = "\ probe — Van Rysel D100 / FTMS protocol discovery (REQUIREMENTS.md Phase 0) USAGE: probe [OPTIONS] SUBCOMMANDS: scan List BLE peripherals: name, address, RSSI, advertised services inspect Connect and dump every service, characteristic and capability monitor Stream Indoor Bike Data as raw hex alongside decoded fields set Take control and apply a target, then reset the trainer to zero zwift Talk to Zwift's custom service: handshake, then log every frame TARGET (for `set`): gradient= SetTargetInclination (0x03), e.g. gradient=4.5 sim= SetIndoorBikeSimulation (0x11) — this is what answers A-1 resistance= SetTargetResistanceLevel (0x04), e.g. resistance=30 power= SetTargetPower (0x05), e.g. power=200 OPTIONS: --secs scan/monitor duration, or how long `set` holds the target (default: scan 6, monitor 30, set 15, zwift 60) --all `scan`: list every peripheral, not just fitness machines --name use in place of to match on advertised name --no-handshake `zwift`: subscribe and listen without writing RideOn --buttons `zwift`: collapse the ~10 Hz button stream to one line per press and release, for mapping bits to physical buttons -v, --verbose debug-level logging, including every raw BLE frame (NFR-8) -h, --help this text ADDR is the address as printed by `scan` (on Linux, AA:BB:CC:DD:EE:FF). SAFETY: `set` always finishes by zeroing the gradient, dropping resistance to the trainer's minimum and issuing Reset + Stop (SAF-2), including on Ctrl-C. `zwift` is read-mostly: the only thing it ever writes is the RideOn handshake. The Click must be unlocked in the free Zwift app first — pair it there, hold it for ~30 s, then quit Zwift. The unlock lasts about a day (REQUIREMENTS.md §2.3). "; #[derive(Debug, PartialEq)] pub enum Command { Help, Scan { duration: Duration, all: bool, }, Inspect { device: Device, }, Monitor { device: Device, duration: Duration, }, Set { device: Device, target: ControlTarget, /// True for `sim=`, which forces op code `0x11`. simulation: bool, hold: Duration, }, /// Phase 3 / TASK-0: exercise Zwift's custom service on whatever advertises /// it — a Click, or the trainer itself. Zwift { device: Device, duration: Duration, /// True to listen only, writing nothing at all. no_handshake: bool, /// True to print one line per button state change instead of every /// frame — the mode for mapping bits to physical buttons. buttons_only: bool, }, } /// How the user identified the trainer. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Device { Address(String), Name(String), } #[derive(Debug, PartialEq)] pub struct Args { pub command: Command, pub verbose: bool, } pub fn parse>(argv: I) -> Result { let mut args: Vec = argv.into_iter().collect(); let mut verbose = false; let mut secs: Option = None; let mut all = false; let mut no_handshake = false; let mut buttons_only = false; let mut name: Option = None; let mut help = false; let mut positional: Vec = Vec::new(); let mut i = 0; while i < args.len() { let arg = std::mem::take(&mut args[i]); match arg.as_str() { "-h" | "--help" | "help" => help = true, "-v" | "--verbose" => verbose = true, "--all" => all = true, "--no-handshake" => no_handshake = true, "--buttons" => buttons_only = true, "--secs" | "--seconds" => { i += 1; let v = args .get(i) .ok_or_else(|| anyhow!("--secs needs a value"))? .clone(); secs = Some( v.parse() .map_err(|_| anyhow!("--secs expects a whole number of seconds, got {v:?}"))?, ); } "--name" => { i += 1; name = Some( args.get(i) .ok_or_else(|| anyhow!("--name needs a value"))? .clone(), ); } other if other.starts_with('-') => bail!("unknown option {other:?}"), other => positional.push(other.to_string()), } i += 1; } if help || positional.is_empty() { return Ok(Args { command: Command::Help, verbose, }); } let device = |positional: &[String], index: usize| -> Result { if let Some(n) = &name { return Ok(Device::Name(n.clone())); } positional .get(index) .map(|a| Device::Address(a.clone())) .ok_or_else(|| anyhow!("this subcommand needs an address (or --name )")) }; let command = match positional[0].as_str() { "scan" => Command::Scan { duration: Duration::from_secs(secs.unwrap_or(6)), all, }, "inspect" => Command::Inspect { device: device(&positional, 1)?, }, "monitor" => Command::Monitor { device: device(&positional, 1)?, duration: Duration::from_secs(secs.unwrap_or(30)), }, "set" => { // With --name the address slot is absent, so the target may be at // index 1 or 2. let target_arg = if name.is_some() && positional.len() == 2 { positional[1].clone() } else { positional .get(2) .cloned() .ok_or_else(|| anyhow!("`set` needs a target, e.g. gradient=4.5"))? }; let (target, simulation) = parse_target(&target_arg)?; Command::Set { device: device(&positional, 1)?, target, simulation, hold: Duration::from_secs(secs.unwrap_or(15)), } } "zwift" => Command::Zwift { device: device(&positional, 1)?, duration: Duration::from_secs(secs.unwrap_or(60)), no_handshake, buttons_only, }, other => bail!("unknown subcommand {other:?} — run `probe --help`"), }; Ok(Args { command, verbose }) } /// Parse `gradient=4.5`, `resistance=30`, `power=200` or `sim=4.5`. /// /// Returns the target and whether simulation mode (`0x11`) was requested. pub fn parse_target(s: &str) -> Result<(ControlTarget, bool)> { let (key, value) = s .split_once('=') .or_else(|| s.split_once(':')) .ok_or_else(|| anyhow!("target must look like `gradient=4.5`, got {s:?}"))?; let key = key.trim().to_lowercase(); let value = value.trim(); match key.as_str() { "gradient" | "grade" | "incline" | "inclination" => { let pct: f32 = value .parse() .map_err(|_| anyhow!("gradient must be a number of percent, got {value:?}"))?; Ok((ControlTarget::Gradient { percent: pct }, false)) } "sim" | "simulation" | "simgrade" => { let pct: f32 = value .parse() .map_err(|_| anyhow!("sim grade must be a number of percent, got {value:?}"))?; Ok((ControlTarget::Gradient { percent: pct }, true)) } "resistance" | "res" | "level" => { let level: i16 = value .parse() .map_err(|_| anyhow!("resistance must be a whole number, got {value:?}"))?; Ok((ControlTarget::Resistance { level }, false)) } "power" | "watts" | "erg" => { let watts: u16 = value .parse() .map_err(|_| anyhow!("power must be a whole number of watts, got {value:?}"))?; Ok((ControlTarget::Power { watts }, false)) } other => bail!("unknown target channel {other:?} — use gradient, sim, resistance or power"), } } #[cfg(test)] mod tests { use super::*; fn args(v: &[&str]) -> Result { parse(v.iter().map(|s| s.to_string())) } #[test] fn no_arguments_prints_help() { assert_eq!(args(&[]).unwrap().command, Command::Help); assert_eq!(args(&["--help"]).unwrap().command, Command::Help); assert_eq!(args(&["scan", "-h"]).unwrap().command, Command::Help); } #[test] fn scan_defaults_and_flags() { assert_eq!( args(&["scan"]).unwrap().command, Command::Scan { duration: Duration::from_secs(6), all: false } ); assert_eq!( args(&["scan", "--all", "--secs", "12"]).unwrap().command, Command::Scan { duration: Duration::from_secs(12), all: true } ); } #[test] fn verbose_is_recognised_anywhere() { assert!(args(&["-v", "scan"]).unwrap().verbose); assert!(args(&["scan", "--verbose"]).unwrap().verbose); assert!(!args(&["scan"]).unwrap().verbose); } #[test] fn inspect_and_monitor_take_an_address() { assert_eq!( args(&["inspect", "AA:BB:CC:DD:EE:FF"]).unwrap().command, Command::Inspect { device: Device::Address("AA:BB:CC:DD:EE:FF".into()) } ); assert_eq!( args(&["monitor", "AA:BB:CC:DD:EE:FF", "--secs", "5"]) .unwrap() .command, Command::Monitor { device: Device::Address("AA:BB:CC:DD:EE:FF".into()), duration: Duration::from_secs(5) } ); } #[test] fn name_substitutes_for_an_address() { assert_eq!( args(&["inspect", "--name", "D100"]).unwrap().command, Command::Inspect { device: Device::Name("D100".into()) } ); assert_eq!( args(&["set", "--name", "D100", "power=150"]).unwrap().command, Command::Set { device: Device::Name("D100".into()), target: ControlTarget::Power { watts: 150 }, simulation: false, hold: Duration::from_secs(15), } ); } #[test] fn set_parses_every_channel() { let cmd = args(&["set", "aa:bb", "gradient=4.5"]).unwrap().command; assert_eq!( cmd, Command::Set { device: Device::Address("aa:bb".into()), target: ControlTarget::Gradient { percent: 4.5 }, simulation: false, hold: Duration::from_secs(15), } ); let cmd = args(&["set", "aa:bb", "sim=-3.0", "--secs", "4"]) .unwrap() .command; assert_eq!( cmd, Command::Set { device: Device::Address("aa:bb".into()), target: ControlTarget::Gradient { percent: -3.0 }, simulation: true, hold: Duration::from_secs(4), } ); } #[test] fn target_parsing_covers_aliases_and_signs() { assert_eq!( parse_target("grade=-7.5").unwrap(), (ControlTarget::Gradient { percent: -7.5 }, false) ); assert_eq!( parse_target("res=30").unwrap(), (ControlTarget::Resistance { level: 30 }, false) ); assert_eq!( parse_target("watts=250").unwrap(), (ControlTarget::Power { watts: 250 }, false) ); assert_eq!( parse_target("SIM=6").unwrap(), (ControlTarget::Gradient { percent: 6.0 }, true) ); // Colon works too, for shells that dislike `=`. assert_eq!( parse_target("power:100").unwrap(), (ControlTarget::Power { watts: 100 }, false) ); } #[test] fn target_parsing_rejects_nonsense() { assert!(parse_target("gradient").is_err()); assert!(parse_target("gradient=uphill").is_err()); assert!(parse_target("torque=5").is_err()); assert!(parse_target("power=-50").is_err(), "power is unsigned"); assert!(parse_target("resistance=1.5").is_err(), "resistance is integral"); } #[test] fn missing_and_unknown_arguments_are_errors() { assert!(args(&["inspect"]).is_err()); assert!(args(&["set", "aa:bb"]).is_err()); assert!(args(&["scan", "--secs"]).is_err()); assert!(args(&["scan", "--secs", "soon"]).is_err()); assert!(args(&["frobnicate"]).is_err()); assert!(args(&["scan", "--wat"]).is_err()); } }