Files
BikeControl/src-tauri/src/events.rs
T
dtourolleandClaude Opus 5 7b511db3dc Ride the drivetrain, command the load in watts
Speed now comes from the drivetrain and the load from the road, which is
the way round a bike actually works.

Speed is cadence x development, filtered lightly. Power, not cadence,
decides whether the rider is driving it: on a direct-drive trainer the
flywheel keeps the cranks turning after they stop, so cadence alone reads
a healthy 80 rpm for someone doing nothing. Below 15 W the speed runs
down to whatever the gradient sustains on no power - zero uphill, a real
freewheeling speed on a descent. Stopping on a 3.5% climb used to settle
at 22 km/h and stay there, because the model wanted to decelerate and a
blend toward the flywheel speed outvoted it; that blend is gone.

The D100 sends no cadence over FTMS - it is a rebadged Magene T110 with
cadence disabled in firmware (qdomyos-zwift#3282) - so it is inferred
from wheel speed, which one sprocket and no freewheel make exact. Its
Zwift channel does carry cadence, and is now greeted with RideOn and
subscribed on every notifying characteristic, so a measured value is used
where one arrives.

The load is commanded as power, not gradient. The trainer declares
50-600 W in 1 W steps against 0-6% inclination in 0.1% steps refusing
negatives, and whether it acts on 0x11 at all is still unconfirmed. Its
power target is a ceiling rather than a setpoint, which is very nearly
what a road is: exceed it and the surplus becomes speed. Gravity travels
on the same channel as watts, so nothing is lost by leaving 0x11 alone.
LoadChannel keeps the gradient path selectable and tested.

Virtual shifting reaches the trainer for the first time. The physics
load model was written but never called, and a paddle press both shifted
a gear in Rust and nudged the gradient in the webview - the shift
silently, the tilt visibly, so the paddles looked like a gradient trim.

Also: a fixed 12 W drivetrain loss, held as a power because that is how
it presents; crank length, so a gear can be reported as the force it puts
under the foot; gear and pedal force on the ride screen; a drag-race
profile for testing gearing on the flat.

Two readout bugs fixed on the way. The rolling windows were trimmed by
timestamp but fed on a fixed timer, so every second spent on the ride
screen before starting pushed samples at t=0 that could never expire -
speed read a fraction of the truth for the first 45 s. And the headline
speed was a 45 s mean, which took most of a minute to show a gear change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 18:21:08 +02:00

142 lines
4.5 KiB
Rust

//! Event channel from Rust to the webview.
//!
//! The frontend is a *view* (§4.3): it never computes ride state, it renders
//! what arrives here. Every event name is declared once, in this module, and
//! mirrored in `ui/src/lib/events.ts`.
use bikecontrol_core::types::{ConnectionState, ControlMode, ControlTarget};
use serde::Serialize;
use crate::devices::DeviceInfo;
use crate::profile_view::ProfileView;
use crate::trainer::TrainerStatus;
/// `RideSnapshot`, pushed at [`crate::engine::TICK_HZ`].
pub const RIDE_SNAPSHOT: &str = "ride://snapshot";
/// Low-frequency ride state: status, mode, targets, laps, loaded profile.
pub const RIDE_STATE: &str = "ride://state";
/// A lap marker was inserted (FR-3.19 / FR-8.7).
pub const RIDE_LAP: &str = "ride://lap";
/// The ride ended and its activity was written (FR-9.13). Carries a
/// `crate::recording::RideSummary`.
pub const RIDE_SUMMARY: &str = "ride://summary";
/// The full device list changed (FR-9.1).
pub const DEVICES_UPDATED: &str = "devices://updated";
/// One device changed connection or control state (FR-1.7, FR-9.3).
pub const DEVICE_CONNECTION: &str = "devices://connection";
/// User-facing message: confirmation, warning or error (FR-9.2).
pub const APP_NOTICE: &str = "app://notice";
/// Acknowledgement that an input registered, so the UI can flash (FR-9.9).
pub const INPUT_ACK: &str = "app://input-ack";
/// A Zwift Click button edge. The webview routes these to the same intents as
/// the equivalent keypress, so controller and keyboard cannot drift apart.
pub const CONTROLLER_INPUT: &str = "controller://input";
/// Controller link state and battery.
pub const CONTROLLER_STATUS: &str = "controller://status";
/// Ride lifecycle, mirroring `bikecontrol_core::session::RideStatus` but
/// serialisable across the IPC boundary.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum RideStatus {
Idle,
Running,
Paused,
Finished,
}
/// Everything the ride screen needs that is *not* in a `RideSnapshot`.
/// Emitted on change, not on a timer.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RideState {
pub status: RideStatus,
pub mode: ControlMode,
pub target: Option<ControlTarget>,
/// Manual gradient trim on top of the profile's base gradient (FR-4.2).
pub gradient_offset_pct: f32,
pub manual_gradient_pct: f32,
pub resistance_level: i16,
pub power_target_w: u16,
/// Selected virtual gear, one-based, and how many there are (FR-4.1).
pub gear: usize,
pub gear_count: usize,
pub lap: u32,
pub laps: Vec<LapSummary>,
pub profile: Option<ProfileView>,
/// Trainer link, so the ride screen can say when the numbers stopped being
/// real rather than quietly showing zeros (FR-1.8, FR-9.3).
pub trainer: TrainerStatus,
}
#[derive(Debug, Clone, Copy, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LapSummary {
pub index: u32,
pub elapsed_ms: u64,
pub distance_m: f64,
pub avg_power_w: f32,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ConnectionEvent {
pub device_id: String,
pub state: ConnectionState,
/// FTMS control point acquired. Connected is *not* controllable (FR-9.3).
pub control_acquired: bool,
pub error: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum NoticeLevel {
Info,
Warn,
Error,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Notice {
pub level: NoticeLevel,
pub message: String,
}
impl Notice {
pub fn info(message: impl Into<String>) -> Self {
Self {
level: NoticeLevel::Info,
message: message.into(),
}
}
pub fn warn(message: impl Into<String>) -> Self {
Self {
level: NoticeLevel::Warn,
message: message.into(),
}
}
pub fn error(message: impl Into<String>) -> Self {
Self {
level: NoticeLevel::Error,
message: message.into(),
}
}
}
/// Confirms an intent was accepted, so the UI can flash the control (FR-9.9).
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct InputAck {
pub action: String,
pub detail: Option<String>,
}
/// The full device list.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DeviceList {
pub scanning: bool,
pub devices: Vec<DeviceInfo>,
}