89920e782dfcddb8e7f1d839f291cddbda3797a2
12
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
8964a0fd74 |
Pair the pod that works
Both pods, same bench, minutes apart, no writes: − pod buttons stop at ~51 s, every session; flag 0 -> 1; 2 key offers + pod 133 s, 78 paddle edges, flag never flipped, no key offer at all So the `−` pod is not the controller. It relays more when it works — all ten buttons, its twin's included — but "when it works" is under a minute without a Zwift blessing in the last day, and the `+` pod alone is a complete shifter: its paddle shifts up, `Y` shifts down, both already mapped. Shifting is what a ride cannot do without. `take_plus_pod` becomes `take_minus_pod` — the same rule with the pods exchanged — and the housekeeping, the connect guard and the no-pod-named default follow it. Opening both is still what stops the `−` pod reporting its own paddle, so it is still one link, just the other one. The UI stops telling riders to press the pod that dies: the panel asks for the `+` pod, the tile prefers it, and the `−` pod's row says what it actually offers — all ten buttons, for about fifty seconds. This settles A-2 from the other end too. The keep-alive that "removes the daily unlock, but only for the right controller" removes nothing. The right controller never needed it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
92ce4ba2ae |
Refuse to join the pair, rather than undoing it a second later
Connecting both pods is the configuration in which the `−` pod stops reporting its own paddle (§2.3.1). `Cmd::Seen` has always known that and declined; an explicit `Cmd::Connect` walked straight past it, and housekeeping then closed the redundant link a second or so later — which looks like the app handling the case and is not the same thing. On the tablet, 2026-08-27 15:55:39, the pair was joined for 1.4 s and the `−` pod did not report another press for the rest of the session, across two fresh links and an app reinstall. So the rule now lives on both paths. `auto` is still armed by the refused request, so the fallback stands: the moment the `−` pod goes away, the `+` pod is taken on its next advertisement. And the UI stops offering the trap. The `+` pod's "Connect anyway" sat next to a working controller, which put the one action that breaks shifting a single tap from a rider hunting for a way to fix shifting. It now reads "Held in reserve", which is what that pod is. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
53e7cd05fc |
Recycle a pod link that goes mute, not only one born mute
A link can wedge *after* working. On the tablet, 2026-08-27: the − pod connected at 15:46:29, carried sixty presses, and at 15:47:19 stopped sending button frames altogether while still streaming battery every five seconds. The link was up, the pod was answering, and not one press arrived for the rest of the ride. The §7.1 detector could not see it. Its test was `buttons_this_link == 0` — a link that had ever carried a button was exempt, on the reasoning that a healthy pod proves itself once and should never be disturbed again. Exempt for life turned out to mean dead for the ride. So the clock runs from the last press rather than from the connect, and falls back to the connect for a link that never carried one. The cost of being wrong is unchanged and still real — a rider who genuinely has not shifted for NO_INPUT_AFTER loses shifting for the few seconds a reconnect takes — which is why the window stays longer than any climb's worth of steady pedalling. Automatic recovery is deliberately slow, because the supervisor cannot tell a wedged pod from a rider who is not shifting. The rider can, so the shifter tile's action while connected is now Reconnect: drop the link and take it again, immediately, instead of waiting out the window. Not the both-pods failure, which was the first suspicion and would have been the better story — connecting both is what stops the − pod reporting its own paddle. The log rules it out: one `pod connected`, for the − pod, and no redundant link ever closed. Every `plus` in it is the − pod relaying its twin's paddle over the mesh, which is the pair working as designed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
bbd11757ee |
Give up the radio for the link, not for the search
Two failures on the tablet, one of them mine from an hour ago. The real one first. A trainer that drops mid-ride could not get back: its supervisor reconnects on its own (FR-1.10) and those attempts never pass through `DeviceRegistry::connect`, so nothing suspended the device list for them. On Android a GATT link that is discovering services while a scan is running is killed by the platform, and the log has it exactly — a reconnect at 13:07:18 dying with `Disconnected while discovering services`, inside a list-scan session opened at 13:07:02. My first fix was to suspend the list whenever the trainer was mid-connect. That was drawn around the wrong thing. `Connecting` covers the 15 s *search*, `Reconnecting` covers the backoff between attempts, and against an asleep trainer those alternate for the whole of RECONNECT_ATTEMPTS — so the list scan went off the air for minutes and every other device starved with it. A pod that dropped could never be seen again, which is what "the pods disconnect after 40 seconds" was. The window that matters is narrower than either: connect, then discover services. `scan::gatt_setup` marks it — an RAII guard taken by the trainer, pod and heart rate paths the moment their search returns a peripheral — and the device list yields only for that. Measured on the tablet: 1.2 s of yielding for a heart rate connect, then straight back to one session per 21 s. Also: the "+ pod seen; the − pod speaks for the pair" line is logged once per run of refusals rather than once per sighting. The device list republishes several times a second and every pass re-reported a visible pod — 274 identical lines in five minutes, burying the connect failures the log was being read for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
7497a5d602 |
Reconnect to remembered hardware instead of pairing every launch
`remembered` was a HashSet inside DeviceRegistry, so it lasted exactly as long as the process. Every launch started from nothing: find the trainer, press Connect, find the strap, press Connect, and only then ride. FR-1.5 has been a Should since the beginning and was never actually true. It is a file now — devices.json in the app data directory, written when a link actually comes up rather than when Connect is pressed. A Connect the hardware then refuses is not a pairing, and writing one down would mean a trainer the rider gave up on getting chased on every launch afterwards. Forgetting is recorded too, in its own list: absent means never seen, forgotten means the rider looked at this device and said no, and auto-connect has to keep honouring that on the next launch as well. The file is advisory — a corrupt one costs auto-connect, never a ride. Auto-connect is driven by the scan rather than fired once at startup. The hardware is asleep at startup — a trainer wakes when the cranks turn, a strap when it is put on (A-4) — so a remembered device is reconnected the moment it advertises, through the same path the rider's own click takes, scan suspension included. Bounded by AUTO_ATTEMPTS on an AUTO_RETRY cooldown and cleared when the link comes up or the rider connects by hand: an app that never stops trying can never honestly say it has stopped (FR-1.11). A device disconnected by hand is left alone for the rest of the session, since a disconnect that undoes itself two ticks later is not a disconnect. Pods now prefer the pod we know. Every Click advertises the same name and the same type byte, so before this a rider whose partner was warming up in the next room got whichever pod woke first. With nothing of that kind remembered anything still goes, or there could never be a first pairing. And the pair is one pod, not two. Confirmed on this hardware 2026-08-21: pairing the − pod alone delivers all ten buttons, its twin's included — which §2.3.1 had established for the frames but not for the pairing. So take_plus_pod holds the + pod back while a known − pod may merely be asleep, and connect_controller with no pod named means the − pod rather than both. The wait is bounded by PLUS_GRACE, because a flat − pod should cost the rider a D-pad and not a controller, and Buttons is untouched: it is what makes the handover between the two configurations invisible. Not yet tested against real hardware — nothing was advertising here. The store, the retry budget and the pod-preference rules have unit tests, and a seeded devices.json was confirmed to load and seed the − pod at launch, but the connect path itself waits for a ride. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
3f23643e86 |
One pod is the whole controller
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> |
||
|
|
ff94b78375 |
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> |
||
|
|
a0190095fb |
Close the link before the budget runs out, not after
A pod that was connected, reporting battery every five seconds, and sending no button frames at all cost most of an evening. The cause was not in the decode: it was that the previous run never let go of the link. `Actor::teardown` unsubscribed every notifying characteristic before disconnecting, each under its own two-second timeout. A Click carries five of them, so the exit path could spend ten seconds on optional work against a supervisor budget of three. The supervisor gave up first, the process exited, and `disconnect` was never reached — leaving BlueZ holding the pod with no application running. The next connect inherited that half-dead session, and a half-dead session streams battery and no buttons, which reads exactly like broken hardware. Measured directly: both apps stopped, `bluetoothctl devices Connected` still listing the pod. It also explains the connect failures that came with it — `le-connection-abort-by-local`, then `service discovery timed out` — and why the fault came and went, since it depended on whether the last exit happened to time out. Nothing is lost by dropping the unsubscribes. The link going down clears the peripheral's CCCDs anyway, so they were only ever politeness toward a connection about to be destroyed. The heart rate actor had the same shape with one characteristic instead of five; the trainer's safety writes stay, because SAF-2 is not optional. All three now say what a timed-out disconnect costs. The old message named the symptom and not the consequence, and the consequence lands on the *next* run — which is precisely why this hid for so long. Also logs which pod reported which button. A pod reporting a button that belongs to the other one is invisible downstream, because the input carries the button and never its source. Fixes both platforms at once: this is `crates/ble`, and Android is the worse case — a leaked link there survives the app being swiped away. Verified with four connect/disconnect cycles against the `−` pod: every button registers on its documented bit (D-pad 0-3, paddle 8), and BlueZ holds nothing afterwards. That satisfies TASK-0(d) and retires RISK-9. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
be046f341c |
Make the BLE layer say what it actually received
Chasing a paddle that shifted on one platform and not the other cost several rebuilds, and most of that was spent unable to tell two very different situations apart: a pod that was sending nothing, and a pod whose frames we were receiving and quietly discarding. The logs looked identical because every decoder in this path fails by dropping. `click.rs` now logs every notification with its length and bytes before anything tries to interpret it, and the unhandled-frame line in `controller.rs` carries the payload rather than just the type byte — which is the least useful part of a frame you could not parse, since it is usually the framing that is wrong and not the content. The default log filter gains `bikecontrol_ble=debug`. It was `info`, which silenced the entire crate that owns every BLE conversation — subscribe failures included. Survivable on desktop where RUST_LOG can override it; not on Android, which has no environment to set and is exactly where the subscribe was failing. Adds `probe listen`, which decodes nothing on purpose: the GATT tree with descriptors (the CCCD value is where a subscribe goes wrong, and `inspect` stops short of it), then a subscribe to every notifying characteristic across all services, reporting each as ok or FAILED. Each notification prints characteristic, length and raw bytes, and the summary names the characteristics that stayed silent — the difference between a quiet device and listening in the wrong place. That last part is what settled this one: it recorded 304 button frames from a pod the app was reporting as dead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
dff3dc8367 | Fix flacky BLE connection to swift pannels | ||
|
|
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> |
||
|
|
57eb5e809b |
Virtual gearing, trainer-speed blend, and cadence decode
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> |