7497a5d602f816268a75018024fe89c6859072fc
34
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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>v0.1.0 |
||
|
|
33c92e38b7 |
Give the release build a signing key it can actually reach
The Android job has four secrets in it -- keystore, its password, the key alias and key password -- and the repo had none of them set. That fails in the worst available way: `echo "" | base64 -d` exits 0 and writes a zero-byte file, so the keystore step goes green and the failure surfaces minutes later inside gradle's signing task, at the tail of a ~1h20m run. Generated a 4096-bit RSA key (PKCS12, valid to 2054, alias `bikecontrol`) and uploaded all four to Gitea with `tea actions secrets create --stdin`. PKCS12 does not support a key password differing from the store password, so ANDROID_KEY_PASSWORD is deliberately the same value as ANDROID_KEYSTORE_PASSWORD rather than a second secret. The password is hex on purpose. CI writes keystore.properties through an unquoted heredoc, so the shell expands `$` and backticks, and .properties treats backslash as an escape -- hex is inert in both. Local side: android-keystore/ holds the key and its password, gitignored as a directory so the password file is covered as well as the *.jks glob. scripts/local-keystore.sh points a local build at it by writing gen/android/keystore.properties, the same file CI writes from secrets. `tauri android init` deletes that file, so the script is idempotent and meant to be re-run after any init. Verified: a local `cargo tauri android build --apk` now produces an APK that apksigner reports as CN=BikeControl, O=Tourolle, C=FR, where before it was silently debug-signed -- build.gradle.kts falls back to the debug signature when keystore.properties is absent rather than failing. The keystore is NOT recoverable if lost: Android will refuse any future update signed by a different key. It needs a backup somewhere off this machine. 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> |
||
|
|
a042814625 |
Bump nanoid past the zero-size loop advisory
`npm ci` in the UI started reporting one high-severity vulnerability: GHSA-2v37-7h3g-55p8, a custom generator called with size 0 spinning forever. Lockfile-only bump, 3.3.17 -> 3.3.18. Worth recording that this was never urgent. nanoid arrives as vite -> postcss, marked `"dev": true`, so it is build tooling and never reaches dist/. Postcss calls it with a fixed size for source-map ids, which is not the vulnerable path. npm audit scores the package in isolation, not the way this project calls it. `npm run build` still produces the same bundle. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
da56985499 |
Check every JNI symbol, not just the first one
🚴 Build and Test BikeControl / Workspace tests (push) Successful in 16m55s
Build & Release / Run tests (push) Successful in 6m35s
🚴 Build and Test BikeControl / Android compile check (push) Successful in 3m41s
Build & Release / Build Linux (deb + AppImage) (push) Successful in 15m18s
Build & Release / Build Arch package (push) Successful in 29m19s
Build & Release / Build Android APK (push) Failing after 29s
Build & Release / Create release (push) Skipped
The android-check job failed on a tree that is perfectly correct: android.rs exports three `Java_..._MainActivity_*` symbols, the check compared all three against a single expected name and called it a mismatch. Two bugs, both from assuming one native: - SYM collected every export while EXPECTED was built from one method, so the comparison was three lines against one. - The Kotlin side matched `external fun name()` on empty parens, which quietly skipped nativeSetActivity and nativeBluetoothStateChanged — parameters do not appear in the symbol name, so stop at the paren. Now both sides are collected into sets and diffed, so a native declared in Kotlin with no Rust half fails as loudly as the reverse. Verified against the real files: passes as-is, and fails with a readable diff for a Kotlin-only native and for a renamed Rust export. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
5a89433746 |
Cache the crates, not the 30 GB of build output
The same leak we just closed on JellyTau, fed harder from here. `target/` is 30 GB locally (25G debug, 3.1G android, 2.9G release) and was cached under two keys across five jobs, on the runner both repos share. - Cache registry/index, registry/cache and git/db only. registry/src is left out as well: 155 MB of .crate tarballs beats 1.1 GB extracted, and cargo re-extracts it for free. Verified that `cargo fetch` unpacks, so sync-android-sources.sh — which reads btleplug's Java backend out of registry/src before any build has run — still finds its sources. - Collapse cargo-host and cargo-android into one cargo-registry key. The split only existed to keep the two `target` dirs off each other; with target uncached, registry contents are target-independent. This also ends a silent miss: build-release's `test` and `build-linux` jobs shared cargo-host, so the test job claimed the key and build-linux's cache was never saved. - CARGO_INCREMENTAL=0. Never reused between runs, and the bulk of the 25 GB debug dir. - npm: cache ~/.npm instead of ui/node_modules. `npm ci` deletes node_modules before installing, so that entry was restored and thrown away unread. - Installer artifact retention 30d -> 7d; the tagged release carries them. Rust jobs now compile cold every run. sccache with a hard size cap is the way back if that starts to hurt. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
3143e7e51c |
Put listen before the tests, where clippy can live with it
🚴 Build and Test BikeControl / Workspace tests (push) Successful in 21m48s
Build & Release / Run tests (push) Successful in 14m3s
🚴 Build and Test BikeControl / Android compile check (push) Failing after 10s
Build & Release / Build Linux (deb + AppImage) (push) Successful in 17m22s
Build & Release / Build Arch package (push) Successful in 29m45s
Build & Release / Build Android APK (push) Failing after 22s
Build & Release / Create release (push) Skipped
`probe listen` was appended to the end of commands.rs, which put it after `mod tests` — `clippy::items_after_test_module`, denied by the `-D warnings` in the workspace clippy job. Pure code motion: the listen section moves up as a block, above the test module, and nothing else changes. This is the first commit whose clippy run can actually be observed. The job has been in .gitea/workflows since the images were pinned, but the image was never in the registry, so it has never run. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
da8ad4c1b3 |
Unpack the Android tools where sdkmanager expects to find them
The image had never been built successfully, which is why the registry
has never held it and why every CI run since the workflows landed died
at the pull.
The commandlinetools zip carries a single top-level `cmdline-tools/`.
Unzipping it straight into $ANDROID_HOME therefore lands `bin/` exactly
where `latest/` has to go, and the `mv cmdline-tools/cmdline-tools/*`
that followed matched nothing:
mv: cannot stat '/opt/android-sdk/cmdline-tools/cmdline-tools/*'
So unpack into /tmp and move that directory into place instead.
sdkmanager derives the SDK root from its own path and refuses to run
from anywhere but cmdline-tools/latest/, so the layout is not cosmetic —
and a `test -x` on it now fails the build here rather than three layers
later, where the error is a licence prompt that never returns.
Verified in the pushed image: node 20.20.2, npm 10.8.2, jq 1.7, JDK 17,
tauri-cli 2.11.4, NDK 27.0.11902837, and all three Android targets.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
c7bb60c666 |
Stop shipping 32 GB to the daemon to build an image that reads none of it
Neither Dockerfile has a COPY or an ADD. Both are environment images — CI checks the repo out inside the container, local use bind-mounts it — so every byte of the build context was uploaded and then ignored. Here that is 32 GB, 30 GB of it target/, before the first apt line runs. The registry has never held either image, so this had never actually been paid: the workflows were committed and the push never happened. 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> |
||
|
|
87e97e104f |
Write down what the pods actually did
Four findings from an evening on the hardware, three of which contradict what this document said. The undocumented `0000010x` characteristics are TI OAD firmware update: their `0x2901` descriptors read "Img Identify", "Img Block" and "OAD Extended Control". The table called them "undocumented, silent so far", which invites probing them to see what answers — and the answer is that `Img Block` takes firmware. They are silent because OAD says nothing until an update is running. Marked do-not-write. A pod offering a public key does not mean it wants encryption. The `−` pod sent a compressed P-256 point in the same session as 304 cleartext button frames. That was read twice as "it has switched to the encrypted path", and neither reading survived the capture it came from. The crypto section now says so, because the mistake is an easy one and it cost an evening. TASK-0(d) is satisfied and RISK-9 is retired. Every button on both pods registers on the bit §2.3.1 documents: D-pad 0-3 and `−` on bit 8 across four cycles, `A`/`B`/`Y`/`Z` on 4-7 and `+` on bit 12 over 83 presses. QZ's `wontfix` for the left `−` does not reproduce here, so OQ-10 needs no answer and gradient control stays on the left pod. The evening the left pod appeared dead was a SAF-9 violation, now §7.1. A half-closed link streams battery and no buttons, which is indistinguishable from broken hardware — and it was diagnosed as a dead pod, a wrong bit map, mis-filed pods and a lapsed unlock before anyone looked at what the previous run had failed to close. 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> |
||
|
|
13458ca180 |
Teach droidplug to indicate and to ask for a bigger MTU
Two Android-only BLE defects, both of which BlueZ hides and both of which made a working Click pod look like broken hardware. droidplug writes ENABLE_NOTIFICATION_VALUE to the CCCD whatever the characteristic supports. A characteristic that indicates but does not notify rejects that write, so the Click's Sync TX channel (00000004-19ca-…, read/indicate) failed to subscribe on every pod with "Unable to write descriptor". The patch picks the value from the characteristic's properties. droidplug also never calls requestMtu, so Android stayed at the 23-byte default and a notification carried 20 bytes. The pods send up to 106. Anything longer arrived truncated mid-field and failed to parse, which looks exactly like a pod that has gone quiet — the frames were being cut off, not withheld. Requesting 517 settles at 251 against this hardware, and a 105-byte frame now arrives whole. Both are applied by the sync script after it lifts the Java out of the crate, each guarded by a grep that fails the sync loudly if upstream moves the line rather than silently producing an unpatched build. Also ignore the .gradle cache that IDE Gradle daemons drop into src-tauri/android/app, which they mistake for a project root because of the build.gradle.kts template living there. Verified on the tablet: no subscribe failures, longest Click frame 105 bytes where the cap was 20, and both paddles shifting. 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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
8fcee06d84 |
Write down what the Android target actually costs
FR-9.17 to FR-9.19 state the screen-size contract: size is measured, the legibility floors are absolute, and touch is a first-class input. NFR-12 to NFR-14 state what the build now guarantees — pinned images, no source that exists only under gen/, and BLE Java locked to the crate. Three corrections to the spec, all of them places where it described a plan the code did not follow: The stack table still named tauri-plugin-blec. The code uses btleplug directly and has since the BLE crate was written; blec wraps it in its own device model, which crates/ble would then have to be written against twice. Recorded with the reason, not silently edited. RISK-1 (btleplug's Android backend is the least mature part of the stack) is partly retired rather than closed: the target builds, the Java is version-locked and the JNI init is symbol-checked in CI. A scan and a connect on real hardware are still unproven, so TASK-4 keeps that half and says exactly what is left — install on a phone, find the D100 and both pods, and survive a screen-off. NFR-11 is honest about being coarser on Android. FLAG_KEEP_SCREEN_ON is held for as long as the app is foregrounded, not scoped to the ride as the desktop inhibitor is. It needs no permission and cannot leak, but it is not what the requirement describes, so the requirement says so. 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> |
||
|
|
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> |
||
|
|
f2c4cb2120 |
Gearing as development, physics-derived load model, 105kg rider
Gears are metres per crank revolution rather than gradient offsets, and resistive_force_n exposes what the road is doing at a given speed so load can be computed directly instead of servoed. The load model is tested but not yet commanded: FTMS sim mode has the trainer compute rolling and aero itself, so sending a gradient that already contains them would double-count. Needs Crr/Cw zeroed and a ride to verify. 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> |
||
|
|
3e106de2c5 |
Add example profiles and noisy sample GPX
Four profiles exercising each waveform and channel; all verified to parse against the Profile schema. Sample GPX has realistic GPS elevation noise so the smoothing path (FR-5.2) is actually tested. 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> |