Never ride a link we did not open, and put a shifter on the other pod
Three changes, all from the same evening on the hardware. **Never adopt an existing link.** `setup_session` skipped connecting when it found the peripheral already connected, which is not a shortcut: it means someone else left it that way, and after a shutdown that ran out of budget that someone is our own previous run. The inherited session answers the handshake and streams battery every five seconds while never delivering a button, which reads as broken hardware — it was diagnosed as a dead pod, a wrong bit map, mis-filed pods and a lapsed Zwift unlock before anyone looked at what the last run failed to close. Any pre-existing link is now dropped first, in all three actors, so every connection starts identical. **A watchdog for the same state, should it arise another way.** Deliberately narrow: a link that is plainly alive — frames arriving inside STALE_AFTER — and has never carried a button since it came up is recycled after 150 s. Not "the rider has not shifted lately", which is normal and would strand a pod that only advertises while awake. A link that has delivered even one press is exempt for its lifetime. **`Y` on the `+` pod shifts down.** Shifting down lived entirely on the `−` pod's paddle, so one pod was a single point of failure for half the drivetrain — and with no on-screen gear control on Android, a rider whose left pod goes quiet is stuck in whatever gear they were in, mid-interval, with no way out. This is RISK-9's documented mitigation and it should have been there from the start. Applied in Rust beside the paddles so a shift behaves the same wherever it comes from; removed from the webview so it cannot fire twice. Mode cycling keeps the `m` key. Verified on the tablet: `Y` moved the gear 12 -> 9, and the pods reconnected cleanly with no stale link to purge. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -59,6 +59,17 @@ use tokio::sync::{mpsc, oneshot, watch};
|
||||
/// explains, and the silence is logged when it fires so the next ride's log can
|
||||
/// replace this guess with the pods' real cadence.
|
||||
const STALE_AFTER: Duration = Duration::from_secs(180);
|
||||
/// How long a *live* link may go without ever carrying a button before we stop
|
||||
/// believing in it.
|
||||
///
|
||||
/// This is not "the rider has not shifted lately" — that is normal, and tearing
|
||||
/// down a working link over it would strand a pod that only advertises while
|
||||
/// awake. It is the narrower and much stranger case from §7.1: frames arriving
|
||||
/// steadily, battery every five seconds, and not one press since the link came
|
||||
/// up. A healthy pod proves itself with its first button and is then never
|
||||
/// touched by this; a wedged one never does, and recycling costs a few seconds
|
||||
/// against a pod that is otherwise useless for the whole ride.
|
||||
const NO_INPUT_AFTER: Duration = Duration::from_secs(150);
|
||||
/// Upper bound on closing the controller links at exit. Shorter than the
|
||||
/// trainer's: there is no reset sequence here, only an unsubscribe and a
|
||||
/// disconnect, and this budget is spent on the same window close (NFR-9).
|
||||
@@ -514,6 +525,11 @@ struct Slot {
|
||||
cancel: Option<oneshot::Sender<()>>,
|
||||
generation: u64,
|
||||
last_seen: Option<tokio::time::Instant>,
|
||||
/// When the current link was established, and whether it has ever carried a
|
||||
/// button. Together these are the signature of the wedged session in §7.1:
|
||||
/// frames arriving, and not one press among them.
|
||||
connected_at: Option<tokio::time::Instant>,
|
||||
buttons_this_link: u32,
|
||||
/// May this pod be connected the moment the scan sees it?
|
||||
///
|
||||
/// True until the rider disconnects it by hand, because a pod that
|
||||
@@ -528,6 +544,8 @@ impl Default for Slot {
|
||||
Self {
|
||||
client: None,
|
||||
events: None,
|
||||
connected_at: None,
|
||||
buttons_this_link: 0,
|
||||
cancel: None,
|
||||
generation: 0,
|
||||
last_seen: None,
|
||||
@@ -697,6 +715,39 @@ async fn run(
|
||||
}
|
||||
|
||||
_ = housekeeping.tick() => {
|
||||
// A link that is plainly alive and has never carried a button
|
||||
// is the wedged session from §7.1, and no amount of waiting
|
||||
// fixes it — the pod answers the handshake and streams battery
|
||||
// for the rest of the ride while ignoring every press. Recycle
|
||||
// it. `connect_pod` now drops any pre-existing link first, so
|
||||
// the replacement starts genuinely clean.
|
||||
//
|
||||
// Deliberately narrow: a link that has delivered even one press
|
||||
// is exempt for life, so a rider who simply is not shifting is
|
||||
// never disturbed.
|
||||
for id in PodId::BOTH {
|
||||
let slot = slot_mut(&mut minus, &mut plus, id);
|
||||
let alive = slot.client.is_some()
|
||||
&& slot.last_seen.is_some_and(|t| t.elapsed() < STALE_AFTER);
|
||||
let mute = slot.buttons_this_link == 0
|
||||
&& slot.connected_at.is_some_and(|t| t.elapsed() > NO_INPUT_AFTER);
|
||||
if alive && mute && slot.auto {
|
||||
tracing::warn!(
|
||||
pod = id.as_str(),
|
||||
"controller: link is alive but has never carried a button; \
|
||||
recycling it (see REQUIREMENTS §7.1)"
|
||||
);
|
||||
slot.generation += 1;
|
||||
slot.connected_at = None;
|
||||
if let Some(client) = slot.client.take() {
|
||||
slot.events = None;
|
||||
tokio::spawn(async move { client.shutdown().await });
|
||||
}
|
||||
status_tx.send_modify(|s| {
|
||||
s.get_mut(id).state = PodState::Reconnecting;
|
||||
});
|
||||
}
|
||||
}
|
||||
for (id, slot) in [(PodId::Minus, &minus), (PodId::Plus, &plus)] {
|
||||
let silence = slot.last_seen.map(|t| t.elapsed());
|
||||
let silent = slot.client.is_some()
|
||||
@@ -796,6 +847,10 @@ fn apply_attempt(attempt: Attempt, slot: &mut Slot, status_tx: &watch::Sender<Co
|
||||
slot.events = Some(client.events());
|
||||
slot.client = Some(client);
|
||||
slot.last_seen = Some(tokio::time::Instant::now());
|
||||
// A fresh link starts over: whatever the last one proved says
|
||||
// nothing about this one.
|
||||
slot.connected_at = Some(tokio::time::Instant::now());
|
||||
slot.buttons_this_link = 0;
|
||||
status_tx.send_modify(|s| {
|
||||
let p = s.get_mut(pod);
|
||||
p.state = PodState::Connected;
|
||||
@@ -880,6 +935,11 @@ fn handle_event(
|
||||
}
|
||||
Ok(event) => {
|
||||
slot.last_seen = Some(tokio::time::Instant::now());
|
||||
if matches!(event, ClickEvent::Button { .. }) {
|
||||
// This link has now proven it carries input, which puts it
|
||||
// beyond the no-input watchdog for as long as it lasts.
|
||||
slot.buttons_this_link = slot.buttons_this_link.saturating_add(1);
|
||||
}
|
||||
// A frame is proof the link is up, and it is the *only* proof that
|
||||
// ever arrives — the pod does not announce that it has started
|
||||
// talking again. Without this, the staleness sweep below was a one-
|
||||
@@ -994,6 +1054,7 @@ fn apply(
|
||||
pressed,
|
||||
"controller: button"
|
||||
);
|
||||
|
||||
// One press is one press, however many pods reported it.
|
||||
let Some(pressed) = buttons.edge(pod, button, pressed) else {
|
||||
return;
|
||||
|
||||
@@ -323,6 +323,18 @@ pub fn spawn_controller_loop(app: AppHandle) {
|
||||
let delta = match input.button {
|
||||
"plus" => 1i32,
|
||||
"minus" => -1,
|
||||
// The + pod's `Y`, as a second shift-down.
|
||||
//
|
||||
// Shifting down otherwise lives entirely on the
|
||||
// `−` pod's paddle, which makes one pod a single
|
||||
// point of failure for half the drivetrain — and
|
||||
// a rider stuck in top gear mid-interval has no
|
||||
// way out, because there is no on-screen gear
|
||||
// control (FR-3.19 is unimplemented on Android).
|
||||
// This is RISK-9's documented mitigation: put a
|
||||
// shift on the right pod so the ride survives
|
||||
// the left one misbehaving.
|
||||
"y" => -1,
|
||||
_ => 0,
|
||||
};
|
||||
if delta != 0 {
|
||||
|
||||
Reference in New Issue
Block a user