4269c5a44663a853e3644ab1db6f3cffd1738998
21
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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> |
||
|
|
5dff2500e2 |
Open picked files through the content resolver, not std::fs
Loading a GPX on Android failed for every file in the picker. The dialog
plugin fires ACTION_GET_CONTENT, which returns a `content://` URI, and
`load_profile_from_path` handed that straight to `std::fs::read_to_string`
— "no such file or directory" for a file the rider is looking at. Picking
from Nextcloud makes it plainer: a document provider backed by a server
may have no local file at all until the resolver opens the stream, so
there was never a path to find.
So the command now takes a `FilePath` and reads through tauri-plugin-fs,
which opens a path directly and a URI via the resolver. The plugin is
here for `FsExt` alone; nothing in ui/ calls its commands, so the
capabilities are unchanged.
Two things that were derived from the filename can no longer be:
- GPX is detected by content. A document id need not contain a name,
let alone an extension. No YAML profile begins with `<`.
- The route name falls back to the GPX's own <name>. Providers over
real storage encode the filename in the last segment, but an opaque
row id would have made a wretched route name.
save_fit had the same bug on the export side — PathBuf::from on a
save-dialog URI — and now writes down a resolver descriptor when handed
one.
Note for anyone rebuilding locally: gen/android/tauri.settings.gradle is
autogenerated and lists each plugin's Android project, so the new
plugin's Kotlin only reaches the APK after `cargo tauri android init` and
scripts/sync-android-sources.sh. CI already runs both.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
5f0fe7b403 |
Give the app a real icon, adaptive on Android
🚴 Build and Test BikeControl / Workspace tests (push) Successful in 10m18s
Build & Release / Run tests (push) Successful in 6m47s
🚴 Build and Test BikeControl / Android compile check (push) Successful in 3m36s
Build & Release / Build Linux (deb + AppImage) (push) Successful in 15m30s
Build & Release / Build Arch package (push) Successful in 29m17s
Build & Release / Build Android APK (push) Successful in 19m41s
Build & Release / Create release (push) Successful in 16s
Desktop icons regenerated from the new artwork with `cargo tauri icon`. It also emits Windows, macOS and iOS variants; those are left untracked, since bundle.icon lists only the four PNGs and this project ships deb, AppImage, Arch and APK. The Android set is the IconKitchen output rather than Tauri's, because Tauri's `icon` command produces only ic_launcher and a foreground layer. The full set adds the background and monochrome layers, which is what makes mipmap-anydpi-v26/ic_launcher.xml a real adaptive icon: the launcher masks it to whatever shape the device uses instead of pasting a circle into a square, and the monochrome layer means themed icons work on Android 13+. These live in src-tauri/android/src/main/res/, not gen/. gen/ is rewritten by `tauri android init`, so an icon dropped there is one git has never seen and the next init deletes -- the same trap the Kotlin sources are kept out of. sync-android-sources.sh already loops over res/*/ and needed no change to pick them up. Note that check-android-sources.sh only walks src/main/java, so res/ has no equivalent guard; the icons are tracked here by construction rather than by a check. AndroidManifest already pointed at @mipmap/ic_launcher and has no roundIcon, so nothing there had to change. Verified in the built release APK: aapt2 reports application-icon at every density resolving to the adaptive XML, with all three layers bound, and versionCode=1100. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
db974dcac1 |
Pin the versionCode in the config, not in a file the build overwrites
The Android job died on `tauri.properties not found`. It is not found because `cargo tauri android init` does not write it: in a clean clone init leaves gen/android/app/ holding build.gradle.kts, proguard-rules.pro and src/, nothing else. The generated app/.gitignore names the pattern — tauri.properties sits with tauri.build.gradle.kts and proguard-tauri.pro, the tauri.* files that `android build` stamps out on every run. So the step was wrong twice and the crash was the lucky half. Had the file existed, the build would have rewritten it from tauri.conf.json and discarded the sed — a green build shipping the default versionCode, which is the failure that reaches a rider's phone rather than the log. Tauri exposes the actual input, so use it: - tauri.conf.json gains bundle.android.versionCode, committed rather than conjured by CI, so the key is greppable and the sed has a fixed target. - ci-android-version-code.sh edits the config. Formula and the 1000 floor are unchanged; the header comment is rewritten, since its premise (init writes the file, the default collides) does not hold — Tauri's default is major*1000000 + minor*1000 + patch, monotonic, and it puts 0.1.0 at exactly 1000, which is where the floor comes from. Missing key or failed substitution now exits 1 instead of degrading to a silent no-op. - The step moves ahead of `android init`, next to ci-set-version.sh, since both edit the same config. Verified against a clean clone: 0.1.0 -> 1100, v0.2.3 -> 1203, v1.0.0 -> 11000, config still parses at each step. Then a real `cargo tauri android build`, which wrote versionCode=1100 into tauri.properties — the value reaches the APK. 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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
cc9c1dbb39 |
Initialise jni-utils, or every connect panics
Scanning worked on the tablet and connecting did not, and the reason was
one missing call.
btleplug's Android backend hands Rust its results as Java future objects
wrapped by jni-utils, and every one of those wrappers resolves its class
through a cache. Only `jni_utils::init` fills that cache. droidplug does
not call it — its own `init` registers droidplug's classes and assumes
the application has already done jni-utils' — and nothing else did
either, so the cache held droidplug's seven classes and none of
jni-utils' ten.
That split the BLE stack in half exactly where the symptom appeared.
Scan results arrive on a plain JNI callback and never touch a future, so
scanning was perfect. `connect` is the first path that awaits one, and
`JFuture::from_env` unwraps `get_class("…/future/Future")` — None —
straight into a panic on the runtime thread, taking the trainer
supervisor task with it. What reached the log was "trainer command
dropped — supervisor queue full or closed", which describes the corpse
rather than the cause; the panic itself only appeared under
RustStdoutStderr, and only because the Android target routes stdout to
logcat.
The GATT link was fine throughout, which is what made this confusing to
read: Android logged `onClientConnectionState … status=0 connected=true`
for the trainer a second *after* the task waiting for it had died.
Pinned to 0.1.1 deliberately. The cache is a static inside jni-utils, so
a second copy at a different version is a second, empty cache and the
panic comes back.
Verified on the tablet (Android 16, aarch64): both Click pods connect on
their own, and the trainer reaches state=Controlling with its FTMS
capabilities read back. Zero panics in the process log.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
a4670f4992 |
Attach the runtime threads to the JVM, or nothing scans
Every scan on the phone failed with "bluetooth error: JNI call failed", which is the entire symptom: jni's Display for JniCall drops the source that says what actually went wrong. It was ThreadDetached. droidplug reaches the JVM through JavaVM::get_env(), which does not attach — it fails outright on any thread the JVM has never seen. Every BLE call in this app is made from a Tauri task, and Tauri's default runtime spawns plain Rust worker threads, so on Android no BLE call could ever have worked. This was invisible until it ran on hardware: the desktop build shares the code and does not care. Attaching inside the tasks would not have fixed it. A Tokio task can move to another worker at any .await, so the thread that starts a scan is not necessarily the one that polls it next — the attachment has to belong to the threads, not the work. on_thread_start is the hook that gets that right, and it covers the blocking pool too. Permanent rather than scoped, because a scoped attachment detaches at the end of the guard, which for a worker thread means after the first task it runs. Ordering is load-bearing at both ends. The JVM is stashed in initBtleplug, which runs before the super chain that starts us, so it is there when the runtime is built; and the runtime is installed before tauri::Builder, because async_runtime::set only affects later spawns. The scan log now carries the Debug form as well as Display. The chain read `Bluetooth(Other(JniCall(ThreadDetached)))` all along and would have named this in the first minute rather than the last. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
aa99b83c40 |
Make the Android BLE backend fail loudly and recoverably
Three defects on the path between MainActivity and the scan loop, all of which presented as "no trainer found". initBtleplug ran after super.onCreate, which is a race rather than a clean ordering bug: the super chain dispatches Rust.create(), and tao's ndk_glue spawns a thread to run `run()` on. That thread builds the AppState and starts the scan loop concurrently. Reaching btleplug first hits droidplug's global_adapter(), which is an `expect` — the scan task panics and scanning is dead for the process, silently and only on some phones. Initialising before super.onCreate means the race cannot be lost. The same panic was reachable without any race, because init failure was logged and shrugged off while every later call still went through to `expect`. Failing soft is right; it just needed READY, so the call sites can produce an ordinary "no adapter" instead of taking the task down (NFR-4). MainActivity retries the init on resume, which is idempotent, so a rider who launched with Bluetooth off recovers by going to Settings. Neither of those covers a radio the rider switches off, which btleplug does not model at all: getDefaultAdapter() returns a disabled adapter whose scans just find nothing. MainActivity now watches ACTION_STATE_CHANGED — the quick-settings shade never fires onResume — and pushes the state to Rust, with requestBluetoothEnable coming back the other way so the connection screen can offer the system dialog rather than describing an empty room. Tri-state on purpose: unknown is not off, or a rider with a working radio gets told to switch it on at launch. Also: the adapter hint told Android riders to check BlueZ. Verified on debug and release APKs for aarch64. Release matters separately here — every one of these classes is reached only by name over JNI, so R8 would strip or rename the lot and the failure would appear only in a shipped build. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
06b4635470 |
Build every artifact in a pinned image, not on my laptop
Two builder images, because the jobs genuinely need two distributions: Ubuntu for the tests, the Linux bundle and the Android APK, and Arch for the .pkg.tar.zst, since makepkg is Arch-specific. Both are built from this repo (NFR-12) and pinned by tag in the workflows, so a Dockerfile change only reaches CI once it has been pushed. libdbus-1-dev is not incidental in the Ubuntu image: btleplug's Linux backend is bluez-async over the dbus crate, so without it the workspace does not build at all. The per-commit Android job is a cargo check, not an APK. The full signed build is ~15 minutes and runs only on tags; a one-minute check catches what actually breaks — the JNI shim, droidplug, and any desktop-only API that has crept into a shared crate. Two invariants a compiler cannot see are checked there too, because both fail silently: the app builds, installs, launches, and finds no trainer. The JNI symbol in android.rs is matched by the runtime by name, so renaming the Kotlin package compiles fine and simply never initialises btleplug; and gen/ must stay untracked or the sync script quietly becomes optional. The Android versionCode carries a 1000 floor. `tauri android init` writes 1000 for 0.1.0 today, so anyone holding a locally built APK already has that number installed, and a bare major/minor/patch code would be 100 — a downgrade, which Android refuses outright. The fmt check is advisory for now. The tree predates this workflow and `cargo fmt --all` currently rewrites ~2000 lines across 28 files; making it a gate here would mean landing a repo-wide reformat as a side effect of adding CI. Run fmt in its own commit, then drop the continue-on-error. The two clippy warnings that stood between the tree and a real `-D warnings` gate are fixed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
0679a1f524 |
Ride on Android: the same BLE stack, over JNI
G-4 said port to Android without rewriting the core, and nothing in crates/core, crates/ble or crates/fit needed touching (NFR-5) — the Android work is two files of glue and a Gradle project. btleplug's Android backend is a hybrid crate: the GATT work happens in Java and Rust drives it over JNI. `platform::init` has to run once with a JNIEnv, and it cannot come from Rust's own startup — JNI resolves classes with the calling thread's class loader, and a thread Rust spawned has only the bootstrap loader. So MainActivity.onCreate calls into src/android.rs, before super.onCreate: TauriActivity's super chain synchronously starts the thread that runs `run()`, which builds AppState and starts scanning while we are still in onCreate. Lose that race and droidplug's global_adapter() — an `expect` — panics inside the scan task, silently, for the life of the process. Failing soft here is not enough for the same reason, so init sets a READY flag and devices.rs asks before every call in. Bluetooth switched off at launch then reads as an ordinary "no adapter", which the connection screen already knows how to show, and onResume retries so switching it on and coming back works. The Java half is not a maven dependency. Upstream tells you to publish a 0.1.1-SNAPSHOT artifact to mavenLocal by hand, which no CI runner can reproduce and which drifts from the crate silently — the failure is a NoSuchMethodError at the first scan, not a build error. Instead sync-android-sources.sh lifts the classes out of the btleplug and jni-utils crate sources at exactly the versions in Cargo.lock, so a mismatch is impossible by construction. gen/ stays generated and untracked, so everything hand-written lives in src-tauri/android/ and is copied back after each `tauri android init`. check-android-sources.sh fails the build if a source exists only under gen/ or differs from its tracked copy: both are files git has never seen and the next init deletes, and the resulting APK builds, installs, and behaves as though they were never written. Permissions are split at API 31, because asking for one the platform does not know is a permanent denial. neverForLocation on BLUETOOTH_SCAN is a promise we can keep honestly: every scan filters by service UUID, so no location permission is needed on Android 12+. Also: tracing to logcat, since Android has no stdout and the default writer drops every line into a closed fd. 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> |
||
|
|
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> |
||
|
|
eb216dd9e6 |
Scaffold workspace, shared types and requirements spec
Cargo workspace with core/ble/fit/probe crates. crates/core/src/types.rs is the fixed contract between the BLE layer, ride engine and UI. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |