Android refuses an APK whose versionCode is not greater than the
installed one, and 0.2.5 is on the tablet. 1000 + major*10000 +
minor*100 + patch puts 0.2.6 at 1206.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Connecting both pods is the configuration in which the `−` pod stops
reporting its own paddle (§2.3.1). `Cmd::Seen` has always known that and
declined; an explicit `Cmd::Connect` walked straight past it, and
housekeeping then closed the redundant link a second or so later — which
looks like the app handling the case and is not the same thing. On the
tablet, 2026-08-27 15:55:39, the pair was joined for 1.4 s and the `−`
pod did not report another press for the rest of the session, across two
fresh links and an app reinstall.
So the rule now lives on both paths. `auto` is still armed by the refused
request, so the fallback stands: the moment the `−` pod goes away, the
`+` pod is taken on its next advertisement.
And the UI stops offering the trap. The `+` pod's "Connect anyway" sat
next to a working controller, which put the one action that breaks
shifting a single tap from a rider hunting for a way to fix shifting. It
now reads "Held in reserve", which is what that pod is.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A link can wedge *after* working. On the tablet, 2026-08-27: the − pod
connected at 15:46:29, carried sixty presses, and at 15:47:19 stopped
sending button frames altogether while still streaming battery every five
seconds. The link was up, the pod was answering, and not one press
arrived for the rest of the ride.
The §7.1 detector could not see it. Its test was
`buttons_this_link == 0` — a link that had ever carried a button was
exempt, on the reasoning that a healthy pod proves itself once and should
never be disturbed again. Exempt for life turned out to mean dead for the
ride.
So the clock runs from the last press rather than from the connect, and
falls back to the connect for a link that never carried one. The cost of
being wrong is unchanged and still real — a rider who genuinely has not
shifted for NO_INPUT_AFTER loses shifting for the few seconds a reconnect
takes — which is why the window stays longer than any climb's worth of
steady pedalling.
Automatic recovery is deliberately slow, because the supervisor cannot
tell a wedged pod from a rider who is not shifting. The rider can, so the
shifter tile's action while connected is now Reconnect: drop the link and
take it again, immediately, instead of waiting out the window.
Not the both-pods failure, which was the first suspicion and would have
been the better story — connecting both is what stops the − pod reporting
its own paddle. The log rules it out: one `pod connected`, for the − pod,
and no redundant link ever closed. Every `plus` in it is the − pod
relaying its twin's paddle over the mesh, which is the pair working as
designed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two failures on the tablet, one of them mine from an hour ago.
The real one first. A trainer that drops mid-ride could not get back:
its supervisor reconnects on its own (FR-1.10) and those attempts never
pass through `DeviceRegistry::connect`, so nothing suspended the device
list for them. On Android a GATT link that is discovering services while
a scan is running is killed by the platform, and the log has it exactly —
a reconnect at 13:07:18 dying with `Disconnected while discovering
services`, inside a list-scan session opened at 13:07:02.
My first fix was to suspend the list whenever the trainer was
mid-connect. That was drawn around the wrong thing. `Connecting` covers
the 15 s *search*, `Reconnecting` covers the backoff between attempts,
and against an asleep trainer those alternate for the whole of
RECONNECT_ATTEMPTS — so the list scan went off the air for minutes and
every other device starved with it. A pod that dropped could never be
seen again, which is what "the pods disconnect after 40 seconds" was.
The window that matters is narrower than either: connect, then discover
services. `scan::gatt_setup` marks it — an RAII guard taken by the
trainer, pod and heart rate paths the moment their search returns a
peripheral — and the device list yields only for that. Measured on the
tablet: 1.2 s of yielding for a heart rate connect, then straight back to
one session per 21 s.
Also: the "+ pod seen; the − pod speaks for the pair" line is logged once
per run of refusals rather than once per sighting. The device list
republishes several times a second and every pass re-reported a visible
pod — 274 identical lines in five minutes, burying the connect failures
the log was being read for.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Android refuses an APK whose versionCode is not greater than the
installed one, so a fix cannot reach a tablet that already carries
0.2.0 without this. 1000 + major*10000 + minor*100 + patch puts 0.2.1
at 1201.
Built and installed on the tablet with the release key, over the top of
0.2.0 rather than through an uninstall, so the rider's settings.json and
devices.json survive the update.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The tag stays the single source of truth — ci-set-version.sh rewrites
this key on a tag build and ci-android-version-code.sh derives the
versionCode from it. Committing the same values keeps a local or
sideloaded build from reporting 0.1.0, and keeps the checked-in number
greppable and honest between releases.
1000 + major*10000 + minor*100 + patch puts 0.2.0 at 1200, which is what
CI will compute for v0.2.0. The workspace Cargo.toml is deliberately
left alone: editing it invalidates Cargo.lock and breaks every --locked
build in the same run, to change a number that appears nowhere but the
binary's own metadata.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
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>