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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>