8 Commits
Author SHA1 Message Date
dtourolleandClaude Opus 5 41574f694e Record what the interface now promises
Six rows, each of which the last three commits either implement or were
written to stop being ambiguous about.

FR-7.4a says what "configurable" has to mean: reachable from the app,
validated, and durable. FR-7.4 has been a Must since the first draft and
was read as satisfied by a command that existed — which is how every
rider ended up on a 105 kg default.

FR-7.5a puts units on the display side of the line and keeps recordings
SI. FR-9.20 and FR-9.21 are the device screen: roles before radios, and
no MAC addresses. FR-9.22 and FR-9.23 are the ride screen: zones only
against a reference the rider gave, and gradient as a shape with the
next block announced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 20:15:18 +02:00
dtourolleandClaude Opus 5 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>
2026-08-21 19:36:34 +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 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 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 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 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 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