One pod is the whole controller
🚴 Build and Test BikeControl / Workspace tests (push) Failing after 2s
🚴 Build and Test BikeControl / Android compile check (push) Skipped

Measured on the hardware: connected on its own, the `−` pod delivers all
ten buttons. Its own paddle on bit 8 and its D-pad on bits 0-3, and the
`+` paddle on bit 12 with the face buttons on 4-7 relayed from its twin —
445 frames, one characteristic, one link, with the `+` pod never
connected at all.

§2.3.1 already recorded that a pod relays its twin's paddle when both are
connected. What was not known is that it does so when it is the *only*
one connected, and that changes the shape of the problem: the second link
is not carrying anything the first one does not already have.

So the `+` pod is no longer connected while the `−` pod is there to speak
for it, and if it did connect first — it is whichever one the rider
happened to wake — the housekeeping sweep closes that link once the `−`
pod arrives. `auto` is left alone, so it is picked up again on its next
advertisement if the `−` pod goes away. It remains a real fallback for a
rider who only has that half, or whose `−` pod is asleep.

This deletes the configuration the bug lives in rather than working
around it. Both pods connected is the state where the `+` press arrives
twice — which is the only reason the pair-merge in `Buttons` exists — and
it is also the state where the `−` pod stops reporting its own paddle,
which is what an evening went into. One link removes both, halves the
connections and reconnect paths, and makes the swap machinery moot in the
normal case.

The merge and swap logic stay for now. They are still correct if both
pods do end up connected, and they should not be torn out until this has
ridden a few sessions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 22:21:40 +02:00
co-authored by Claude Opus 5
parent ff94b78375
commit 3f23643e86
+44
View File
@@ -621,6 +621,29 @@ async fn run(
status_tx.send_modify(|s| s.get_mut(pod).reset_link(PodState::Searching)); status_tx.send_modify(|s| s.get_mut(pod).reset_link(PodState::Searching));
} }
Cmd::Seen { pod, address } => { Cmd::Seen { pod, address } => {
// One link is the whole controller.
//
// The `` pod relays its twin: connected on its own it
// delivers its own paddle and D-pad *and* the `+`
// paddle and face buttons — all ten buttons, measured
// over 445 frames on one characteristic (§2.3.1). So
// the `+` pod is not connected while the `` pod is
// there to speak for it. It stays a fallback for the
// case where the `` pod is absent or the rider only
// owns that half.
//
// Connecting both is what the pair-merge in `Buttons`
// exists to paper over, and it is also the
// configuration in which the `` pod stops reporting
// its own paddle — the failure that cost an evening.
// Not opening the second link removes both.
if pod == PodId::Plus && !slot_mut(&mut minus, &mut plus, PodId::Minus).idle()
{
tracing::debug!(
"controller: + pod seen but the pod is already speaking for it"
);
continue;
}
let slot = slot_mut(&mut minus, &mut plus, pod); let slot = slot_mut(&mut minus, &mut plus, pod);
if !slot.auto || !slot.idle() { if !slot.auto || !slot.idle() {
continue; continue;
@@ -715,6 +738,27 @@ async fn run(
} }
_ = housekeeping.tick() => { _ = housekeeping.tick() => {
// Converge on one link. The `+` pod may have connected first —
// it is the one the rider happened to wake — and once the ``
// pod is up it speaks for both, so the second link is redundant
// and is exactly the configuration that breaks the `` paddle.
// Dropping it leaves `auto` alone, so if the `` pod later goes
// away the `+` pod is picked up again on its next advertisement.
if !minus.idle() && plus.client.is_some() {
tracing::info!(
"controller: pod is up and relays the pair; closing the redundant + link"
);
forget(PodId::Plus, &mut buttons, &input_tx);
plus.generation += 1;
plus.events = None;
plus.last_seen = None;
plus.connected_at = None;
if let Some(client) = plus.client.take() {
tokio::spawn(async move { client.shutdown().await });
}
status_tx.send_modify(|s| s.get_mut(PodId::Plus).reset_link(PodState::Idle));
}
// A link that is plainly alive and has never carried a button // A link that is plainly alive and has never carried a button
// is the wedged session from §7.1, and no amount of waiting // is the wedged session from §7.1, and no amount of waiting
// fixes it — the pod answers the handshake and streams battery // fixes it — the pod answers the handshake and streams battery