Commit Graph
31 Commits
Author SHA1 Message Date
dtourolleandClaude Opus 5 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>
2026-08-21 18:27:13 +02:00
dtourolleandClaude Opus 5 db974dcac1 Pin the versionCode in the config, not in a file the build overwrites
🚴 Build and Test BikeControl / Workspace tests (push) Successful in 13m33s
🚴 Build and Test BikeControl / Android compile check (push) Successful in 3m29s
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>
2026-08-21 17:39:06 +02:00
dtourolleandClaude Opus 5 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>
2026-08-21 17:38:54 +02:00
dtourolleandClaude Opus 5 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>
2026-08-21 12:12:42 +02:00
dtourolleandClaude Opus 5 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>
2026-08-21 12:11:11 +02:00
dtourolleandClaude Opus 5 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>
2026-08-20 22:53:40 +02:00
dtourolleandClaude Opus 5 da8ad4c1b3 Unpack the Android tools where sdkmanager expects to find them
🚴 Build and Test BikeControl / Workspace tests (push) Failing after 4m32s
🚴 Build and Test BikeControl / Android compile check (push) Skipped
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>
2026-08-20 22:48:32 +02:00
dtourolleandClaude Opus 5 c7bb60c666 Stop shipping 32 GB to the daemon to build an image that reads none of it
🚴 Build and Test BikeControl / Workspace tests (push) Failing after 6m6s
🚴 Build and Test BikeControl / Android compile check (push) Skipped
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>
2026-08-20 22:28:06 +02:00
dtourolleandClaude Opus 5 3f23643e86 One pod is the whole controller
🚴 Build and Test BikeControl / Workspace tests (push) Failing after 2s
🚴 Build and Test BikeControl / Android compile check (push) Skipped
Measured on the hardware: connected on its own, the `−` pod delivers all
ten buttons. Its own paddle on bit 8 and its D-pad on bits 0-3, and the
`+` paddle on bit 12 with the face buttons on 4-7 relayed from its twin —
445 frames, one characteristic, one link, with the `+` pod never
connected at all.

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 22:21:40 +02:00
dtourolleandClaude Opus 5 ff94b78375 Never ride a link we did not open, and put a shifter on the other pod
🚴 Build and Test BikeControl / Workspace tests (push) Failing after 0s
🚴 Build and Test BikeControl / Android compile check (push) Skipped
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>
2026-08-20 21:05:40 +02:00
dtourolleandClaude Opus 5 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>
2026-08-20 20:25:25 +02:00
dtourolleandClaude Opus 5 a0190095fb Close the link before the budget runs out, not after
🚴 Build and Test BikeControl / Workspace tests (push) Failing after 0s
🚴 Build and Test BikeControl / Android compile check (push) Skipped
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>
2026-08-20 20:21:31 +02:00
dtourolleandClaude Fable 5 3274928a5f Stream heart rate from a strap or watch into the ride and the FIT file
🚴 Build and Test BikeControl / Workspace tests (push) Failing after 0s
🚴 Build and Test BikeControl / Android compile check (push) Skipped
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>
2026-08-20 19:27:04 +02:00
dtourolleandClaude Opus 5 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>
2026-08-20 19:16:26 +02:00
dtourolleandClaude Opus 5 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>
2026-08-20 19:15:53 +02:00
dtourolleandClaude Opus 5 cc9c1dbb39 Initialise jni-utils, or every connect panics
🚴 Build and Test BikeControl / Workspace tests (push) Failing after 0s
🚴 Build and Test BikeControl / Android compile check (push) Skipped
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>
2026-08-19 18:46:38 +02:00
dtourolleandClaude Opus 5 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>
2026-08-05 20:27:08 +02:00
dtourolleandClaude Opus 5 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>
2026-08-05 20:26:59 +02:00
dtourolleandClaude Opus 5 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>
2026-08-05 20:06:03 +02:00
dtourolleandClaude Opus 5 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>
2026-08-05 19:53:11 +02:00
dtourolleandClaude Opus 5 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>
2026-08-05 19:53:00 +02:00
dtourolleandClaude Opus 5 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>
2026-08-05 19:52:45 +02:00
dtourolleandClaude Opus 5 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>
2026-08-05 19:52:33 +02:00
dtourolle dff3dc8367 Fix flacky BLE connection to swift pannels 2026-08-05 18:25:16 +02:00
dtourolleandClaude Opus 5 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>
2026-08-05 18:21:08 +02:00
dtourolleandClaude Opus 5 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>
2026-08-05 15:46:07 +02:00
dtourolleandClaude Opus 5 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>
2026-08-05 15:33:28 +02:00
dtourolleandClaude Opus 5 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>
2026-08-05 13:50:01 +02:00
dtourolleandClaude Opus 5 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>
2026-08-05 13:34:27 +02:00
dtourolleandClaude Opus 5 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>
2026-08-05 13:13:30 +02:00
dtourolleandClaude Opus 5 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>
2026-08-05 13:11:32 +02:00