Virtual gearing, trainer-speed blend, and cadence decode

Gears are expressed as an offset to the commanded gradient, leaving the
physics on the route's true gradient so shifting changes effort, not speed.
Neutral gear commands exactly the route gradient, so an un-shifted ride is
unchanged.

Cadence is not in FTMS on this trainer but is on its Zwift channel, decoded
against captured frames. The undeclared FTMS trailing bytes were ruled out:
wheel RPM restated at a fixed 73.8x speed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 15:33:28 +02:00
co-authored by Claude Opus 5
parent 3a2a787b7d
commit 57eb5e809b
48 changed files with 57737 additions and 431 deletions
+444 -222
View File
@@ -1,23 +1,42 @@
//! Device discovery and connection state (FR-1, FR-9.19.3).
//!
//! `crates/ble` is not written yet, so this is a **mock scanner**: a scripted
//! set of peripherals that appear over a few seconds, with RSSI that drifts and
//! connection state machines that take realistic time to settle. It exists so
//! the connection screen can be built and judged today.
//! A real BLE scanner. A background task drives `bikecontrol_ble::scan` and
//! publishes its results on a `watch` channel; [`DeviceRegistry`] — which lives
//! inside the app's synchronous `Mutex` and therefore may never `.await` —
//! reads that channel, merges in the trainer supervisor's status, and produces
//! the `DeviceInfo` list the UI renders.
//!
//! The important behaviour it models — and the reason it is not just a static
//! list — is that **BLE connection and FTMS control acquisition are separate
//! steps** (FR-9.3). A trainer goes `Connecting → Connected → Controlling`, and
//! it can sit at `Connected` indefinitely if the control point is refused.
//! ```text
//! scan task ──watch<ScanSnapshot>──┐
//! ├──► DeviceRegistry::poll ──► DeviceInfo[]
//! trainer ──watch<TrainerStatus>─┘
//! ```
//!
//! Swapping in the real scanner means replacing [`DeviceRegistry::poll`] and
//! the two request methods with `btleplug` calls; the `DeviceInfo` the UI
//! renders does not change.
//! The behaviour that matters, and the reason `control_acquired` is a field
//! rather than a state: **BLE connection and FTMS control acquisition are
//! separate steps** (FR-9.3). A trainer goes `Connecting → Connected →
//! Controlling`, and it can sit at `Connected` indefinitely if the control
//! point is refused.
use std::collections::HashSet;
use std::time::Duration;
use bikecontrol_ble::scan::{self, DiscoveredDevice, ScanKind, ZWIFT_SERVICE};
use bikecontrol_ble::uuids;
use bikecontrol_core::types::ConnectionState;
use serde::{Deserialize, Serialize};
use tokio::sync::watch;
use uuid::Uuid;
use crate::trainer::{TrainerHandle, TrainerStatus};
/// One pass of the scanner. Long enough for a trainer to advertise, short
/// enough that the list feels live.
const SCAN_WINDOW: Duration = Duration::from_millis(2500);
/// Poll interval while scanning is switched off.
const IDLE_POLL: Duration = Duration::from_millis(400);
/// Heart Rate Service, so an HRM in the room is labelled rather than "unknown".
const HEART_RATE_SERVICE: Uuid = Uuid::from_u128(0x0000180d_0000_1000_8000_00805f9b34fb);
/// What we think a peripheral is, from its advertised services and
/// manufacturer data (FR-1.2).
@@ -33,13 +52,14 @@ pub enum DeviceKind {
Unknown,
}
#[derive(Debug, Clone, Serialize)]
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DeviceInfo {
pub id: String,
pub name: String,
pub address: String,
/// dBm. Roughly 40 (touching) to 95 (barely there).
/// dBm. Roughly 40 (touching) to 95 (barely there). 0 when the adapter
/// did not report one.
pub rssi: i16,
pub kind: DeviceKind,
pub state: ConnectionState,
@@ -61,273 +81,475 @@ pub struct PollResult {
pub changed: bool,
/// Devices whose connection state settled this tick.
pub transitions: Vec<DeviceInfo>,
/// Trainer status, when it changed since the last poll. The caller turns
/// this into user-facing notices (FR-1.8, FR-9.4).
pub trainer_changed: Option<TrainerStatus>,
}
/// A scripted peripheral in the mock environment.
struct Simulated {
info: DeviceInfo,
/// Ticks after scan start before it shows up. Models A-4: the trainer only
/// advertises once you pedal, the Click once you press a button.
appears_after: u32,
/// Ticks remaining in the current transition, and where it lands.
pending: Option<(u32, ConnectionState, bool)>,
visible: bool,
/// What the scan task publishes.
#[derive(Debug, Clone, Default)]
pub struct ScanSnapshot {
pub devices: Vec<DiscoveredDevice>,
/// Adapter-level failure — no radio, BlueZ down. Surfaced verbatim.
pub error: Option<String>,
/// Bumped every completed pass, so `poll` can tell "same devices" from
/// "scanner has not run yet".
pub generation: u64,
}
pub struct DeviceRegistry {
devices: Vec<Simulated>,
trainer: TrainerHandle,
scan_rx: watch::Receiver<ScanSnapshot>,
scan_on: watch::Sender<bool>,
forgotten: HashSet<String>,
remembered: HashSet<String>,
/// The list published last tick, for change detection.
published: Vec<DeviceInfo>,
last_trainer: TrainerStatus,
pub scanning: bool,
ticks: u32,
rng: u64,
}
/// How long each mock transition takes, in registry ticks (2 Hz).
const CONNECT_TICKS: u32 = 3;
const CONTROL_TICKS: u32 = 3;
impl Default for DeviceRegistry {
fn default() -> Self {
Self::new()
}
/// Scanning was switched off by *us*, to get out of the way of a connect —
/// not by the rider. Only a suspension is resumed automatically (FR-1.12).
scan_suspended: bool,
/// The most recent adapter error, so the UI can say why the list is empty.
pub error: Option<String>,
}
impl DeviceRegistry {
pub fn new() -> Self {
pub fn new(trainer: TrainerHandle) -> Self {
let (scan_on, scan_on_rx) = watch::channel(false);
let (scan_tx, scan_rx) = watch::channel(ScanSnapshot::default());
tauri::async_runtime::spawn(scan_loop(scan_on_rx, scan_tx));
Self {
devices: catalogue(),
last_trainer: trainer.status(),
trainer,
scan_rx,
scan_on,
forgotten: HashSet::new(),
remembered: HashSet::new(),
published: Vec::new(),
scanning: false,
ticks: 0,
rng: 0xDEAD_BEEF_CAFE_F00D,
scan_suspended: false,
error: None,
}
}
fn rand(&mut self) -> f32 {
let mut x = self.rng;
x ^= x >> 12;
x ^= x << 25;
x ^= x >> 27;
self.rng = x;
((x.wrapping_mul(0x2545_F491_4F6C_DD1D) >> 40) as f32) / (1 << 24) as f32
}
/// Rider-initiated. Cancels any suspension: an explicit request outranks
/// our own bookkeeping in both directions.
pub fn start_scan(&mut self) {
self.scanning = true;
self.ticks = 0;
for d in &mut self.devices {
if !matches!(d.info.state, ConnectionState::Connected | ConnectionState::Controlling) {
d.info.state = ConnectionState::Scanning;
}
}
self.scan_suspended = false;
self.set_scanning(true);
}
pub fn stop_scan(&mut self) {
self.scanning = false;
for d in &mut self.devices {
if d.info.state == ConnectionState::Scanning {
d.info.state = ConnectionState::Idle;
}
}
self.scan_suspended = false;
self.set_scanning(false);
}
/// Advance the mock. Reports whether the list changed at all, and which
/// devices crossed a connection-state boundary this tick.
fn set_scanning(&mut self, on: bool) {
self.scanning = on;
let _ = self.scan_on.send(on);
}
/// Rebuild the device list from the scanner and the trainer supervisor.
pub fn poll(&mut self) -> PollResult {
let mut changed = false;
let mut transitions = Vec::new();
if self.scanning {
self.ticks += 1;
for i in 0..self.devices.len() {
let appears = self.devices[i].appears_after;
if !self.devices[i].visible && self.ticks >= appears {
self.devices[i].visible = true;
changed = true;
}
if self.devices[i].visible {
let jitter = (self.rand() * 6.0) as i16 - 3;
let base = self.devices[i].info.rssi;
let next = (base + jitter).clamp(-95, -38);
if next != base {
self.devices[i].info.rssi = next;
changed = true;
}
let trainer = self.trainer.status();
let trainer_changed = (trainer != self.last_trainer).then(|| trainer.clone());
self.last_trainer = trainer.clone();
// The scan is switched off for the duration of a connect so that it and
// `find_peripheral` do not fight over the one adapter — and nothing else
// ever turns it back on. A disconnect, or a connect that failed, would
// otherwise leave the list frozen on a snapshot taken before the attempt
// and the rider with no way to find anything but the Scan button
// (FR-1.12, NFR-7).
if should_resume_scan(self.scan_suspended, &trainer) {
self.scan_suspended = false;
self.set_scanning(true);
}
let next = self.build(&trainer);
let transitions = state_transitions(&self.published, &next);
let changed = next != self.published;
self.published = next;
PollResult { changed, transitions, trainer_changed }
}
/// Merge the scan snapshot with the trainer's live status.
fn build(&mut self, trainer: &TrainerStatus) -> Vec<DeviceInfo> {
let snapshot = self.scan_rx.borrow().clone();
self.error = snapshot.error.clone();
let mut out: Vec<DeviceInfo> = Vec::with_capacity(snapshot.devices.len() + 1);
for d in &snapshot.devices {
let id = d.address.clone();
if self.forgotten.contains(&id) {
continue;
}
out.push(DeviceInfo {
kind: classify(d),
name: d.label(),
address: d.address.clone(),
rssi: d.rssi.unwrap_or(0),
state: if self.scanning {
ConnectionState::Scanning
} else {
ConnectionState::Idle
},
control_acquired: false,
services: d.services.iter().map(|u| describe_service(*u)).collect(),
remembered: self.remembered.contains(&id),
battery_pct: None,
unlock_expires_in_s: None,
error: None,
id,
});
}
// A connected peripheral usually stops appearing in scan results, and
// the trainer must not vanish from the list the moment it is in use.
if let Some(address) = trainer.address.clone() {
let existing = out.iter().position(|d| d.id == address);
let idx = match existing {
Some(i) => i,
None => {
out.push(DeviceInfo {
id: address.clone(),
name: trainer.name.clone().unwrap_or_else(|| "Trainer".into()),
address,
rssi: 0,
kind: DeviceKind::Trainer,
state: ConnectionState::Idle,
control_acquired: false,
services: vec![describe_service(uuids::FITNESS_MACHINE_SERVICE)],
remembered: true,
battery_pct: None,
unlock_expires_in_s: None,
error: None,
});
out.len() - 1
}
};
let device = &mut out[idx];
device.kind = DeviceKind::Trainer;
device.state = trainer.state.clone();
device.control_acquired = trainer.control_acquired;
device.remembered = true;
device.error = trainer.error.clone().or_else(|| {
trainer
.stale
.then(|| "Connected but sending no data — pedal to wake it".to_string())
});
if let Some(name) = &trainer.name {
device.name = name.clone();
}
}
for d in &mut self.devices {
if let Some((remaining, target, control)) = d.pending.take() {
if remaining <= 1 {
d.info.state = target.clone();
d.info.control_acquired = control;
if target == ConnectionState::Connected && d.info.kind == DeviceKind::Trainer {
// Connected, now go after the FTMS control point.
d.pending =
Some((CONTROL_TICKS, ConnectionState::Controlling, true));
}
transitions.push(d.info.clone());
changed = true;
} else {
d.pending = Some((remaining - 1, target, control));
}
}
}
PollResult { changed, transitions }
// Trainers first, then by signal strength: the thing the rider is
// looking for should not be below an unnamed peripheral.
out.sort_by(|a, b| {
(a.kind != DeviceKind::Trainer)
.cmp(&(b.kind != DeviceKind::Trainer))
.then(b.rssi.cmp(&a.rssi))
.then(a.id.cmp(&b.id))
});
out
}
pub fn list(&self) -> Vec<DeviceInfo> {
self.devices
.iter()
.filter(|d| d.visible && !self.forgotten.contains(&d.info.id))
.map(|d| d.info.clone())
.collect()
self.published.clone()
}
pub fn get(&self, id: &str) -> Option<DeviceInfo> {
self.devices.iter().find(|d| d.info.id == id).map(|d| d.info.clone())
self.published.iter().find(|d| d.id == id).cloned()
}
pub fn connect(&mut self, id: &str) -> Result<DeviceInfo, String> {
let device = self
.devices
.iter_mut()
.find(|d| d.info.id == id)
.get(id)
.ok_or_else(|| format!("no such device: {id}"))?;
if device.info.state == ConnectionState::Controlling {
return Err(format!("{} is already connected", device.info.name));
if device.kind != DeviceKind::Trainer {
return Err(format!(
"{} is not a trainer. Zwift Click support is Phase 3 (REQUIREMENTS.md §5.3).",
device.name
));
}
device.info.error = None;
device.info.state = ConnectionState::Connecting;
device.info.remembered = true;
device.pending = Some((CONNECT_TICKS, ConnectionState::Connected, false));
Ok(device.info.clone())
// A second click on an already-connected trainer must not tear the
// working session down and start over. Connecting takes ~8 s against
// this hardware, which is easily long enough for an impatient rider to
// click again and destroy the connection they were waiting for.
let status = self.trainer.status();
if status.address.as_deref() == Some(device.address.as_str()) {
match status.state {
ConnectionState::Connecting | ConnectionState::Scanning => {
return Err(format!("Already connecting to {}", device.name))
}
ConnectionState::Reconnecting => {
return Err(format!("Reconnecting to {} — hold on.", device.name))
}
ConnectionState::Connected | ConnectionState::Controlling => {
return Err(format!(
"{} is already connected. Disconnect first to start over.",
device.name
))
}
ConnectionState::Idle | ConnectionState::Lost { .. } => {}
}
}
// Our own scan and the client's `find_peripheral` would otherwise fight
// over the one adapter. Suspended, not stopped: `poll` puts it back as
// soon as the trainer is no longer attached (FR-1.12).
self.set_scanning(false);
self.scan_suspended = true;
self.remembered.insert(id.to_string());
self.forgotten.remove(id);
self.trainer
.connect(scan::TrainerSelector::Address(device.address.clone()));
let mut info = device;
info.state = ConnectionState::Connecting;
info.control_acquired = false;
info.error = None;
info.remembered = true;
Ok(info)
}
pub fn disconnect(&mut self, id: &str) -> Result<DeviceInfo, String> {
let device = self
.devices
.iter_mut()
.find(|d| d.info.id == id)
let mut device = self
.get(id)
.ok_or_else(|| format!("no such device: {id}"))?;
device.pending = None;
device.info.control_acquired = false;
device.info.state = if self.scanning { ConnectionState::Scanning } else { ConnectionState::Idle };
Ok(device.info.clone())
if device.kind == DeviceKind::Trainer {
// SAF-2 runs inside the supervisor before the link drops.
self.trainer.disconnect();
}
device.state = ConnectionState::Idle;
device.control_acquired = false;
Ok(device)
}
pub fn forget(&mut self, id: &str) -> Result<(), String> {
let device = self
.devices
.iter_mut()
.find(|d| d.info.id == id)
.ok_or_else(|| format!("no such device: {id}"))?;
device.pending = None;
device.info.remembered = false;
device.info.control_acquired = false;
device.info.state = ConnectionState::Idle;
device.visible = false;
let device = self.get(id).ok_or_else(|| format!("no such device: {id}"))?;
if device.kind == DeviceKind::Trainer && device.control_acquired {
self.trainer.disconnect();
}
self.remembered.remove(id);
self.forgotten.insert(id.to_string());
self.published.retain(|d| d.id != id);
Ok(())
}
/// True once a trainer is connected *and* controllable — the precondition
/// for a real ride (FR-2.1).
pub fn trainer_controllable(&self) -> bool {
self.devices
.iter()
.any(|d| d.info.kind == DeviceKind::Trainer && d.info.control_acquired)
self.trainer.status().controllable()
}
pub fn trainer_status(&self) -> TrainerStatus {
self.trainer.status()
}
}
fn device(
id: &str,
name: &str,
address: &str,
rssi: i16,
kind: DeviceKind,
services: &[&str],
appears_after: u32,
) -> Simulated {
Simulated {
info: DeviceInfo {
/// FR-1.2. FTMS is checked first: the D100 advertises the Zwift custom service
/// too, so "is a Zwift device" is not enough to call something a Click.
fn classify(d: &DiscoveredDevice) -> DeviceKind {
if d.is_fitness_machine() {
return DeviceKind::Trainer;
}
if d.services.contains(&HEART_RATE_SERVICE) {
return DeviceKind::HeartRate;
}
if d.services.contains(&ZWIFT_SERVICE) || d.is_zwift_device() {
// Splitting left pod from right needs the Zwift manufacturer-data type
// byte, which is Phase 3 and unverified against this hardware. Guessing
// would put a wrong label on the connection screen, so it stays Unknown
// and the service UUID is listed instead.
return DeviceKind::Unknown;
}
DeviceKind::Unknown
}
fn describe_service(uuid: Uuid) -> String {
match uuids::well_known_name(uuid) {
Some(name) => format!("{uuid} ({name})"),
None if uuid == ZWIFT_SERVICE => format!("{uuid} (Zwift custom)"),
None => uuid.to_string(),
}
}
/// May a scan we suspended for a connect be switched back on?
///
/// FR-1.12. Split out from [`DeviceRegistry::poll`] so the rule is checkable
/// without a radio. Only a suspension of *ours* is resumed — a rider who
/// pressed Stop scan meant it.
fn should_resume_scan(suspended: bool, trainer: &TrainerStatus) -> bool {
suspended && !trainer.is_attached()
}
/// Devices whose connection state or control acquisition changed (FR-1.7).
fn state_transitions(before: &[DeviceInfo], after: &[DeviceInfo]) -> Vec<DeviceInfo> {
after
.iter()
.filter(|d| match before.iter().find(|p| p.id == d.id) {
None => d.state != ConnectionState::Scanning && d.state != ConnectionState::Idle,
Some(prev) => prev.state != d.state || prev.control_acquired != d.control_acquired,
})
.cloned()
.collect()
}
/// Drive the radio. Runs for the life of the process; a failure to get an
/// adapter is reported through the snapshot rather than killing the task, so
/// plugging a dongle in later recovers on its own (NFR-4).
async fn scan_loop(mut on: watch::Receiver<bool>, tx: watch::Sender<ScanSnapshot>) {
let mut generation = 0u64;
loop {
if !*on.borrow() {
// Wait to be switched on rather than spinning.
if on.changed().await.is_err() {
return;
}
continue;
}
let adapter = match scan::default_adapter().await {
Ok(a) => a,
Err(e) => {
tracing::warn!(error = %e, "no Bluetooth adapter");
generation += 1;
let _ = tx.send(ScanSnapshot {
devices: Vec::new(),
error: Some(format!("{e}. Check the radio is on and BlueZ is running.")),
generation,
});
tokio::time::sleep(Duration::from_secs(2)).await;
continue;
}
};
// FR-1.1 lists every peripheral, not only fitness machines: a trainer
// is not obliged to advertise FTMS, and the rider needs to see what is
// in the room to know the scan is working at all.
let result = scan::scan(&adapter, SCAN_WINDOW, ScanKind::All).await;
generation += 1;
let snapshot = match result {
Ok(devices) => ScanSnapshot { devices, error: None, generation },
Err(e) => {
tracing::warn!(error = %e, "scan failed");
ScanSnapshot {
devices: Vec::new(),
error: Some(e.to_string()),
generation,
}
}
};
let _ = tx.send(snapshot);
tokio::time::sleep(IDLE_POLL).await;
}
}
#[cfg(test)]
mod tests {
use super::*;
fn info(id: &str, state: ConnectionState, control: bool) -> DeviceInfo {
DeviceInfo {
id: id.into(),
name: name.into(),
address: address.into(),
rssi,
kind,
state: ConnectionState::Idle,
control_acquired: false,
services: services.iter().map(|s| s.to_string()).collect(),
name: id.into(),
address: id.into(),
rssi: -50,
kind: DeviceKind::Trainer,
state,
control_acquired: control,
services: Vec::new(),
remembered: false,
battery_pct: match kind {
DeviceKind::ClickLeft => Some(78),
DeviceKind::ClickRight => Some(64),
DeviceKind::HeartRate => Some(91),
_ => None,
},
unlock_expires_in_s: match kind {
DeviceKind::ClickLeft => Some(0),
DeviceKind::ClickRight => Some(41_400),
_ => None,
},
battery_pct: None,
unlock_expires_in_s: None,
error: None,
},
appears_after,
pending: None,
visible: false,
}
}
#[test]
fn acquiring_control_is_a_transition_even_though_the_state_is_unchanged() {
// FR-9.3: Connected → Controlling *and* Connected-with-control are both
// events the UI must see.
let before = vec![info("t", ConnectionState::Connected, false)];
let after = vec![info("t", ConnectionState::Connected, true)];
let t = state_transitions(&before, &after);
assert_eq!(t.len(), 1);
assert!(t[0].control_acquired);
}
#[test]
fn a_device_merely_appearing_in_a_scan_is_not_a_transition() {
let after = vec![info("t", ConnectionState::Scanning, false)];
assert!(state_transitions(&[], &after).is_empty());
let after = vec![info("t", ConnectionState::Idle, false)];
assert!(state_transitions(&[], &after).is_empty());
}
#[test]
fn a_device_that_appears_already_connected_is_a_transition() {
let after = vec![info("t", ConnectionState::Controlling, true)];
assert_eq!(state_transitions(&[], &after).len(), 1);
}
#[test]
fn a_lost_link_is_reported() {
let before = vec![info("t", ConnectionState::Controlling, true)];
let after = vec![info("t", ConnectionState::Lost { reason: "gone".into() }, false)];
let t = state_transitions(&before, &after);
assert_eq!(t.len(), 1);
assert!(!t[0].control_acquired);
}
#[test]
fn an_unchanged_list_produces_no_transitions() {
let list = vec![info("t", ConnectionState::Controlling, true)];
assert!(state_transitions(&list, &list).is_empty());
}
#[test]
fn a_scan_suspended_for_a_connect_comes_back_when_the_connect_is_over() {
// FR-1.12. Connecting switches the scan off so it does not fight
// `find_peripheral` over the one adapter, and before this nothing ever
// switched it back on: a disconnect or a failed connect left the device
// list frozen on a snapshot taken before the attempt.
let idle = TrainerStatus::default();
let lost = TrainerStatus {
state: ConnectionState::Lost { reason: "gone".into() },
..TrainerStatus::default()
};
assert!(should_resume_scan(true, &idle));
assert!(should_resume_scan(true, &lost));
// Still in progress, or in use: leave the adapter alone.
let connecting = TrainerStatus {
state: ConnectionState::Connecting,
..TrainerStatus::default()
};
let reconnecting = TrainerStatus {
state: ConnectionState::Reconnecting,
..TrainerStatus::default()
};
let riding = TrainerStatus {
state: ConnectionState::Controlling,
control_acquired: true,
..TrainerStatus::default()
};
assert!(!should_resume_scan(true, &connecting));
assert!(!should_resume_scan(true, &reconnecting));
assert!(!should_resume_scan(true, &riding));
// The rider pressed Stop scan. That is not ours to undo.
assert!(!should_resume_scan(false, &idle));
}
#[test]
fn well_known_services_are_named_and_zwift_is_recognised() {
let ftms = describe_service(uuids::FITNESS_MACHINE_SERVICE);
assert!(ftms.contains("Fitness Machine"), "{ftms}");
let zwift = describe_service(ZWIFT_SERVICE);
assert!(zwift.contains("Zwift"), "{zwift}");
}
}
/// The mock environment. Timings are in registry ticks (2 Hz), so the trainer
/// takes ~2 s to appear and the pods ~46 s — long enough that the "wake it by
/// pedalling" prompt (FR-1.8) is actually visible.
fn catalogue() -> Vec<Simulated> {
vec![
device(
"d100-1",
"Van Rysel D100",
"E4:2B:11:9A:03:7C",
-54,
DeviceKind::Trainer,
&["0x1826 Fitness Machine", "0x180A Device Information"],
4,
),
device(
"click-l",
"Zwift Click (left)",
"C0:1A:77:12:4E:01",
-63,
DeviceKind::ClickLeft,
&["00000001-19CA-4651-86E5-FA29DCDD09D1"],
9,
),
device(
"click-r",
"Zwift Click (right)",
"C0:1A:77:12:4E:02",
-61,
DeviceKind::ClickRight,
&["00000001-19CA-4651-86E5-FA29DCDD09D1"],
11,
),
device(
"hrm-1",
"Wahoo TICKR",
"D9:44:0B:31:88:2A",
-71,
DeviceKind::HeartRate,
&["0x180D Heart Rate"],
14,
),
device(
"unknown-1",
"(unnamed peripheral)",
"7F:22:C4:08:19:E3",
-88,
DeviceKind::Unknown,
&[],
17,
),
]
}