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:
+14
-2
@@ -348,9 +348,21 @@ async fn setup_session(
|
||||
peripheral: Peripheral,
|
||||
asked_for: Option<PodId>,
|
||||
) -> Result<(Session, Notifications), FtmsError> {
|
||||
if !peripheral.is_connected().await.unwrap_or(false) {
|
||||
peripheral.connect().await?;
|
||||
// Always ride a link we opened ourselves. Finding the peripheral already
|
||||
// connected is not a shortcut worth taking — it means someone else left it
|
||||
// that way, and after a shutdown that ran out of budget that someone is
|
||||
// usually our own previous run (§7.1, SAF-9). An inherited half-closed
|
||||
// session answers the handshake and streams battery every five seconds
|
||||
// while never delivering a single button, which reads as broken hardware
|
||||
// and is the single most expensive failure this project has had.
|
||||
//
|
||||
// Dropping it first costs one round trip on a path that already takes
|
||||
// seconds, and buys the guarantee that every connection starts identical.
|
||||
if peripheral.is_connected().await.unwrap_or(false) {
|
||||
tracing::debug!("click: dropping a pre-existing link before connecting");
|
||||
let _ = tokio::time::timeout(DISCONNECT_TIMEOUT, peripheral.disconnect()).await;
|
||||
}
|
||||
peripheral.connect().await?;
|
||||
peripheral.discover_services().await?;
|
||||
|
||||
// A Click v2 carries 0xFC82; the trainer carries 00000001-19CA-…. Both hold
|
||||
|
||||
@@ -1335,9 +1335,14 @@ async fn connect_session(
|
||||
|
||||
let _ = state_tx.send(ConnectionState::Connecting);
|
||||
|
||||
if !peripheral.is_connected().await.unwrap_or(false) {
|
||||
peripheral.connect().await?;
|
||||
// Never adopt a link we did not open. A peripheral found already connected
|
||||
// usually means a previous run's shutdown ran out of budget before closing
|
||||
// it, and an inherited half-closed session reports some frames and not
|
||||
// others rather than failing outright (§7.1, SAF-9).
|
||||
if peripheral.is_connected().await.unwrap_or(false) {
|
||||
let _ = tokio::time::timeout(DISCONNECT_TIMEOUT, peripheral.disconnect()).await;
|
||||
}
|
||||
peripheral.connect().await?;
|
||||
|
||||
// From here on a failure leaves a live GATT link behind, and a peripheral
|
||||
// that accepts one connection at a time (A-3) would stay unavailable to the
|
||||
|
||||
@@ -399,9 +399,14 @@ async fn open_session(
|
||||
}
|
||||
|
||||
async fn setup_session(peripheral: Peripheral) -> Result<(Session, Notifications), FtmsError> {
|
||||
if !peripheral.is_connected().await.unwrap_or(false) {
|
||||
peripheral.connect().await?;
|
||||
// Never adopt a link we did not open. A peripheral found already connected
|
||||
// usually means a previous run's shutdown ran out of budget before closing
|
||||
// it, and an inherited half-closed session reports some frames and not
|
||||
// others rather than failing outright (§7.1, SAF-9).
|
||||
if peripheral.is_connected().await.unwrap_or(false) {
|
||||
let _ = tokio::time::timeout(DISCONNECT_TIMEOUT, peripheral.disconnect()).await;
|
||||
}
|
||||
peripheral.connect().await?;
|
||||
peripheral.discover_services().await?;
|
||||
|
||||
let chars = peripheral.characteristics();
|
||||
|
||||
Reference in New Issue
Block a user