Core ride logic, FTMS client, FIT encoder and probe CLI
Adds backing state for Resistance and Erg control modes, which had no value to hold and so could never satisfy FR-4.3/FR-4.6. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,365 @@
|
||||
//! 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 <SUBCOMMAND> [OPTIONS]
|
||||
|
||||
SUBCOMMANDS:
|
||||
scan List BLE peripherals: name, address, RSSI, advertised services
|
||||
inspect <ADDR> Connect and dump every service, characteristic and capability
|
||||
monitor <ADDR> Stream Indoor Bike Data as raw hex alongside decoded fields
|
||||
set <ADDR> <TARGET> Take control and apply a target, then reset the trainer to zero
|
||||
|
||||
TARGET (for `set`):
|
||||
gradient=<PCT> SetTargetInclination (0x03), e.g. gradient=4.5
|
||||
sim=<PCT> SetIndoorBikeSimulation (0x11) — this is what answers A-1
|
||||
resistance=<LEVEL> SetTargetResistanceLevel (0x04), e.g. resistance=30
|
||||
power=<WATTS> SetTargetPower (0x05), e.g. power=200
|
||||
|
||||
OPTIONS:
|
||||
--secs <N> scan/monitor duration, or how long `set` holds the target (default:
|
||||
scan 6, monitor 30, set 15)
|
||||
--all `scan`: list every peripheral, not just fitness machines
|
||||
--name <SUBSTR> use in place of <ADDR> to match on advertised name
|
||||
-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.
|
||||
";
|
||||
|
||||
#[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,
|
||||
},
|
||||
}
|
||||
|
||||
/// 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<I: IntoIterator<Item = String>>(argv: I) -> Result<Args> {
|
||||
let mut args: Vec<String> = argv.into_iter().collect();
|
||||
|
||||
let mut verbose = false;
|
||||
let mut secs: Option<u64> = None;
|
||||
let mut all = false;
|
||||
let mut name: Option<String> = None;
|
||||
let mut help = false;
|
||||
let mut positional: Vec<String> = 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,
|
||||
"--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<Device> {
|
||||
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 <SUBSTR>)"))
|
||||
};
|
||||
|
||||
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)),
|
||||
}
|
||||
}
|
||||
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<Args> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user