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>
130 lines
4.3 KiB
Rust
130 lines
4.3 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 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,
|
|
pub lap: u32,
|
|
pub laps: Vec<LapSummary>,
|
|
pub profile: Option<ProfileView>,
|
|
/// Which backend is driving the ride: `"ftms"` (the real trainer) or
|
|
/// `"mock"` (the synthetic rider, only reachable via `BIKECONTROL_DEMO`).
|
|
pub source: &'static str,
|
|
/// 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>,
|
|
}
|