34861e4e0443f10e0edff164933401d2a34759e3
14
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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> |
||
|
|
0c757a4a15 |
Colour effort by zone, and read distance in the rider's units
A watt is a fact; a zone is what it costs you. The biggest number on the ride screen was the same shade of white at 90 W and at 400 W, which is a thing no training app has done in fifteen years. Zones, with two rules: - **No reference, no zone.** An unset FTP draws the plain number. A zone measured against a guessed threshold would paint every ride with a confident lie. - **Colour never carries it alone.** "Z4" renders beside the swatch, so the meaning survives a colour-blind rider, a phone in direct sun and a black-and-white screenshot. Read off the rolling average, not the instantaneous watts: at 4 Hz the raw figure crosses two boundaries every pedal stroke, and a colour that strobes is worse than no colour. Not pedalling is not zone 1. Units are a display preference applied at the last step before the glass. Everything computed, stored and recorded stays SI, so a FIT file never depends on what the screen was set to. `format.ts` takes the unit system as an argument rather than reading a module-level setting — pure functions are what let every readout redraw the moment it changes. The `km` helper is gone rather than left beside `dist`, so there is no second way to format a distance that ignores the preference. Rust's block labels lose their baked-in kilometres. The block already carries start_x and end_x and the frontend renders that span in the rider's units; a kilometre in the text sat inside a sentence saying miles everywhere else. Also on the ride screen: - The gradient gets a wedge beside the number. A signed decimal has to be read; a slope is seen. Exaggerated and clamped, because a true-scale 6% is indistinguishable from 3% at 40 px wide. - What is coming, from the profile's own block list — "2.1 km at 12% in 460 m". The chart says where the rider is; what is about to happen is what decides whether to shift now. The data was already computed Rust-side and thrown away here. Close in, the small unit reads better than a fraction of the big one. - Mode and target merge into one chip. They are a single fact, and splitting them spent a chip of header width repeating the word "target". - The pod chip no longer reports a missing `+` pod while the `−` pod is connected. The `−` pod relays its twin, so that is the intended configuration — the ride screen was calling it a fault, contradicting the device screen two keystrokes away. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
cdff678167 |
Pair roles, not radios
The device screen answered the wrong question. A rider arriving at it has three: is the trainer on, will it take control, is the Click awake. A list sorted by signal strength answers none of them without being read, and it opened with a MAC address on every row — a number nobody types, acts on, or can tell from the one below it, and on Android a randomised one that changes anyway. So the setup comes first, as slots: Trainer, Shifter, Heart rate. Each says what is filling it, what is standing in the way, and the one action that would fix it. The list is still everything FR-9.1 asks for; it is just no longer the first thing to read. - FR-9.3 lives in the trainer tile: green only when connected *and* controlling, because a trainer that is attached and uncontrollable will not move the resistance. Its action then is Reconnect, since FTMS control is requested once at connect time — offering "acquire control" as a button the rider had failed to press would be a lie about what the protocol does. - Peripherals of no known role fold into a collapsed list. A scan in a flat picks up a dozen phones and a TV, and each was a full-height row between the rider and their trainer. The fold opens itself when no trainer has been identified at all, because a trainer that does not advertise FTMS until something connects classifies as unknown — that is the one case this must not swallow. - Kind is a glyph, identity is the name. Where two rows would otherwise be indistinguishable — a pair of pods, a room of "(no name)" — four characters of the address disambiguate them and nothing more. - Forget appears only on remembered devices. Forgetting a device that was never remembered is a no-op the rider had to read past on every row. - The Click panel is a repair manual, so it appears when there is something to repair. Its five-step drill used to be `open` by default in exactly the state riders hit most; it is now folded behind a summary, and its lede renders only while it is saying something the tile cannot. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
4269c5a446 |
Remember the rider, not only the hardware
`RiderConfig` lived in `RideInputs` and nowhere else, and nothing in the UI ever called `set_rider_config`. So every ride was ridden as the struct's own default — a 105 kg rider on an 8 kg bike — with no way to say otherwise short of editing the source. Mass is not a preference: it sets the speed a given power produces, the ETA that follows from it, the calorie estimate, and how a 6% ramp feels. FR-7.4 is a Must, and a command the UI never calls does not satisfy it. - settings.rs persists rider config, safety limits and display preferences to app_data_dir()/settings.json, written atomically and read back before the first tick, so no snapshot is ever computed against the default. Advisory like known.rs: an unreadable file costs the rider their setup, never their ride. - A stored file is refused *whole* if it fails the same checks the commands apply. It may predate a tightened bound or have been edited by hand, and a zero mass reaching the engine divides by itself on the next tick. - The commands validate with instructions rather than codes — "CdA must be between 0.1 and 1.5 m² — a road position is about 0.32" — because this is now a form a rider fills in, not a struct only I ever touched. - Preferences (FTP, maximum heart rate, units) are Tauri-side, not in `RiderConfig`. None of it reaches the physics, and crates/core is the frozen contract the engine and the FIT writer share. Zero is a real answer for both references and means "no zones", not "unset and guessed at". - SettingsScreen commits on field-exit and reseats every input from what Rust returned, so a rejected value can never sit on screen looking accepted. Weight, FTP and units are on top; the eight settings with a defensible default are folded away. - Reachable on `,` from any screen, returning to whichever screen opened it. Setup swallows the ride controls while it is up — a stray arrow key while reading the form must not trim the gradient of a ride happening behind it. 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> |
||
|
|
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> |
||
|
|
3274928a5f |
Stream heart rate from a strap or watch into the ride and the FIT file
A new HRM client in the BLE crate follows the crate's split: the 0x2A37 decoder is a pure function over bytes (u8/u16 formats, the three-state sensor-contact field, straps that append energy/RR data), and only the actor touches the radio. Anything exposing the standard Heart Rate Service works — a chest strap, or a Garmin watch with Broadcast Heart Rate on. A single-slot supervisor in the app owns the link, shaped like the trainer's and the controller's. It publishes bpm on a watch channel the session backend stamps onto each tick's telemetry — never over a heart rate FTMS itself reported, on the same authority rule as the Zwift cadence merge — and it clears the reading after eight silent seconds, so a strap taken off records nothing rather than a flatline of the last real value. From there the existing pipeline does the rest: ride screen tile, FIT records, avg/max in lap and session. The device list routes heart-rate rows to the supervisor, keeps the row alive while connected (a connected monitor stops advertising), and shows the live bpm as proof data is flowing — a connected-but-silent monitor otherwise looks exactly like a working one. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
dc5ba2cb07 |
Let the connection screen scroll, so the trainer can be reached
On the phone the D100 was found, listed, and completely unreachable: it sat below the fold and nothing scrolled. .screen declared grid-template-rows: auto auto 1fr but has four children — header, ClickPanel, the trainer gate, the list. The gate is conditional, so whenever it rendered it took the 1fr track and the list fell into an implicit auto row past the bottom of the screen. overflow-y was on the list, which was not the box overflowing, so there was nothing to scroll. On a desktop window everything fit and the bug never showed; on a 412px viewport the Click panel alone is taller than the screen. Flex has no fixed track count, so a conditional child cannot displace anything — the same reason .ride is a column and not a grid. On compact the whole screen scrolls as one document rather than pinning a header above a scrolling list. Giving the list its own scroll region there would leave it a few pixels tall: technically scrollable, still unusable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
7a4e2be65e |
Measure the screen, then decide what fits on it
The ride screen was built for a 1440x900 window and expressed its type scale in vw. On a phone that fails twice over: 7vw of a 412px viewport is 29px, well under what is readable from the bars, and five side-by-side readouts do not fit across 412px at any type size. Shrinking is not the answer to a small screen — showing less is (FR-9.17, FR-9.18). So screen size becomes a measured input. viewport.ts is a pure function from a measurement — width, height, DPR, whether the pointer is coarse — to a layout plan: type sizes in pixels, column counts, and which sections earn their space. viewport.svelte.ts measures and publishes it as CSS custom properties and data-* attributes; the stylesheets read those. The three max-width media queries are gone, so there is now exactly one definition of "narrow" in the codebase rather than four that can disagree about where a phone starts. The sizes are absolute rather than relative, and that is a physical argument, not a preference. A number has to subtend enough visual angle to read from the riding position. Desktop is ~96 CSS px per inch at about a metre; Android's CSS pixel is the dp, ~160 per inch, and a bar-mounted phone sits at roughly 0.6 m. (160/96) x (0.6/1.0) is almost exactly 1, so the same pixel size is about as readable in both places — which is why the floors are plain numbers with no per-platform correction, and why a small screen is a content problem. What gets dropped, and in what order: anything the rider cannot act on mid-ride goes before anything they can. Sparklines first — they are history, and a 60px chart is a smear. Then average / normalised / work / burned, which is what the summary screen is for. The detail row survives longer, because "climbing left" is the question a rider on a hill is actually asking, and elapsed time stays on a phone while covered and ascended go. The route profile is the screen's whole point (FR-9.7) and goes only in landscape on a phone, where keeping it would leave nothing for the numbers. Touch is treated as an input, not a narrower mouse (FR-9.19): 48px targets, hover styling suppressed so it does not stick after a tap, and keyboard hints hidden — with a word added to the help button, which carried only a key cap and would otherwise have become unpressable. Being a pure function is the point: "does this fit on a Pixel 7" is now answerable in CI on a machine with no phone attached. 15 tests, run by `npm --prefix ui test`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
3a2a787b7d |
Add Svelte GUI, FIT encoder and README
Standalone binary embeds the frontend, avoiding the dev-server dependency that made the window fail to load. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
7c17ca6158 |
Core ride logic, FTMS client, FIT encoder and probe CLI
Adds backing state for Resistance and Erg control modes, which had no value to hold and so could never satisfy FR-4.3/FR-4.6. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |