The Agent That Replaced Kinomap: a Cross-Trainer App in One Afternoon
toc
Note from Sander: this started as “the Kinomap app keeps losing the tablet” and ended as a complete replacement, built together with Claude in one long afternoon and evening. I asked it to write the story down. The “we” is the two of us; I was mostly on the cross-trainer and in Garmin Connect, it was mostly in the editor.
The cross-trainer in the house is a Focus Fitness Stride. It has a Bluetooth console, and the way you are supposed to use that console is through Kinomap: an app that shows you a video of a road somewhere and sets the resistance to match the hill. That is a fine idea. It is less fine when the app spends the first ten minutes of every workout not connecting to the tablet that lives on the machine, or connecting and then dropping out, and the tablet is the whole reason the Bluetooth was interesting in the first place.
At some point this afternoon Sander stopped trying to make Kinomap behave and asked a different question: the console speaks a public Bluetooth standard, we have a Kubernetes cluster in the cupboard and a Garmin account each, so why are we fiddling with someone else’s app at all? Five and a half hours and forty-odd commits later the answer is: we are not.
What was built
Two halves, one repository folder, one afternoon.
crosstrainer/
android/ Kotlin/Compose app: BLE to the trainer and a heart-rate sensor,
live dashboard, recording, programs, history, Garmin push
backend/ FastAPI on Talos, own CloudNativePG Postgres, device tokens,
FIT export, Garmin Connect upload per account, k8s manifests
scripts/ garmin_login.py (one-time MFA login), fake_ftms.py (a fake trainer)
docs/ ftms-notes.md (what the machine really sends), ideas.mdThe app connects to the trainer over Bluetooth Low Energy using the Fitness Machine Service (FTMS), the Bluetooth SIG profile that treadmills, bikes and cross-trainers are supposed to implement. It shows every value the machine sends, records one sample per second in a foreground service, keeps the session on the device, uploads it to the backend when it can, and shows history, per-session charts and weekly progress. The backend stores the samples, computes the summary, builds a FIT file per session (the format Garmin devices write themselves, sport fitness_equipment, sub-sport elliptical, with the heart-rate, cadence, power and distance time series) and uploads it to Garmin Connect with the token of whoever did the workout. Sabine gets her workouts on her account with full graphs, Sander on his. No Kinomap, no Focus app, no CSV export, no “sync with Strava and hope”.
Everything deploys the way the rest of the lab does: push to main, a Forgejo workflow builds the image and commits the tag bump into the manifests, Argo CD syncs the lab-crosstrainer application. The database is a CloudNativePG cluster with a nightly backup to the object store, and the app applies its own schema files at start-up, so a new table is a new .sql file and a push. Nothing was applied by hand all day, which is the only way a day like this stays sane.
15:50 — the scaffold, before touching the machine
The first commit already had both halves: 21 unit tests on the Android side against synthetic FTMS packets built from the spec, 31 pytest tests on the backend, a docker-compose stack exercised end to end in WSL, a FIT round-trip decoded with Garmin’s own SDK. Nothing had been near the real trainer yet, and the README said so in bold.
Two things from that first hour are worth keeping. The FIT encoder uses the fit-tool library, which has a quirk worth a line in anyone’s notes: date_time fields take milliseconds since the Unix epoch and convert themselves to FIT’s 1989 epoch, but local_date_time (the activity’s local timestamp) does not, so that one is written as raw FIT seconds by hand. Get it wrong and Garmin shows your 18:00 workout at 16:00. And garminconnect, the Python library that talks to Garmin Connect, opens token files with O_NOFOLLOW, which silently fails on Kubernetes because secret mounts are symlinks, so the token blob is read by us and passed to login() inline, where the library treats anything over 512 characters as a token instead of a path. Both are the kind of thing you only learn once.
The first deploy failed on something more mundane: the Postgres password contains an @, and interpolating it into a postgresql:// URL broke the connection string. psycopg’s make_conninfo builds it from parts; one commit, done.
16:24 — the machine does not send what the spec says
Then the app met the trainer, and the trainer turned out to be a bike.
The console is a Changyow SM8387, advertised as FOCUSSTRIDE0033. Its GATT table has the FTMS service, as promised. What it does not have is the Cross Trainer Data characteristic (0x2ACE) that a cross-trainer is supposed to notify on. It sends Indoor Bike Data (0x2AD2), 23 bytes at 1 Hz, as if it were a spin bike. The app’s parser was written for 0x2ACE and would have sat there forever waiting for a characteristic that never appears.
The fix was to stop assuming: the app now subscribes to whatever FTMS data characteristic the machine offers (anything from 0x2ACD to 0x2AD2) and parses both layouts into the same record. The raw dump screen, built for exactly this moment, gave us the ground truth:
74 1B | C0 08 | 78 00 | DC 00 00 | 02 00 | 53 00 | 07 00 00 00 00 | 00 | 43 00 | 00 00
flags | 22.40 | 120=60| 220 m | res 2 | 83 W | 7 kcal, 0/h, 0/min | hr 0 | 67 s | rem 0
| km/h | rpm | | | | |(no hands)| |Every field had a small surprise in it, and they are all in docs/ftms-notes.md now. Cadence comes as flywheel rpm in 0.5 units, which on this machine is close enough to strides per minute. Resistance arrives as a raw level (2 means level 2) even though the status characteristic reports the same thing ×10 and the control point wants ×10 to set it, so “level 5” is 50 going out and 5 coming in. Energy per hour and per minute are always zero. Heart rate is the hand grips and reads zero the moment you let go, which the app now shows as a cleared value instead of a stale one. And the distance and calorie counters run since the console was switched on, not since you pressed Start, so a session’s distance is the last value minus the first, in the app and in the backend summary and in the FIT file, so the three always agree.
By 16:39 the app did what Kinomap does on connect: Request Control, then Start, after which the console streams live values and shows its little Bluetooth icon. It also blanks its own display in that mode, exactly like Kinomap, which is why there is a setting to only listen. Resistance control from the app, which the first README had marked “experimental, verify on the machine”, was verified on the machine, levels 1 to 16.
17:13 — whose Garmin is it
The first real upload went to the wrong account. Not wrong in any interesting way: the backend had one Garmin account configured, Sabine’s, and Sander’s test session landed on it. The fix is a rule that has held since: a session is pushed to the account named after its owner, /secrets/garmin/<user>.json, and the single configured account is only a fallback. The stray activity is still on her Garmin; deleting it is on her list, not ours.
Authentication, by the way, is deliberately boring. There are no passwords. Each device gets a token from a CLI in the cluster, the app stores it encrypted with an Android Keystore key, the server stores only its SHA-256, and revoking a device is one command. The app records without a token at all; the upload simply waits until one is set.
17:35 — the tablet gets its own dashboard
The device by the trainer is a Galaxy Tab S11 Ultra in landscape, and a phone layout stretched across it looked silly. At 840 dp and up the Train screen becomes a two-column dashboard: time and heart rate huge on the left with the other numbers under them, and on the right four live charts, heart rate, power, cadence and speed, over a rolling fifteen minutes, filling the whole height. The bottom tab bar becomes a navigation rail. It is the screen you can read from the pedals, which is the whole point of a tablet on a cross-trainer.
18:50 — two people, one tablet
Then the obvious problem: the tablet is shared, and a device token belongs to one user. Sabine’s workout must not land in Sander’s history, or on his Garmin.
The answer is profiles, and it took one SQL file and an HTTP header. A token can be shared (python -m app.tokens share 2 sabine), which fills a profiles column. The app then sends X-Profile: sabine on every request and the server treats that as the acting user: it owns the uploaded session, it is whose lists and progress come back, and it is whose Garmin account the push goes to. A profile the token does not list is a 403. Phone tokens are single-user and never see any of this.
On the tablet the active profile is a Netflix-style avatar at the top of the rail, tap for the menu; on a phone it is a Training as Sander | Sabine card at the top of the Train screen. The profile is captured when you press Start and stored in the session, so switching the avatar after a workout does not move it. Deployed and tried on the tablet by 19:35.
19:58 — workout programs, from photos of the console
This is where the afternoon stopped being a replacement and started being better than the thing it replaced.
The console has twelve built-in programs, P01 to P12, each a little bar chart of resistance over time. Sander sent photos of all of them from the console’s display. I cropped and enlarged the bar area of each photo, counted bars and rows, and came out with twelve profiles of twenty segments each (P11 arrived after dinner, in a separate photo):
"P03 Ramps" to intArrayOf(1, 3, 5, 7, 9, 1, 3, 5, 7, 9, 1, 3, 5, 7, 9, 1, 3, 5, 7, 9),
"P07 Intervals" to intArrayOf(1, 1, 5, 5, 1, 1, 9, 9, 1, 1, 5, 5, 1, 1, 9, 9, 1, 1, 5, 5),
"P12 Sprints" to intArrayOf(1, 1, 8, 8, 8, 1, 1, 8, 8, 8, 1, 1, 8, 8, 8, 1, 1, 8, 8, 8),Those are templates in the app, built in. Equal neighbouring segments merge into one step, ninety seconds a segment gives the console’s default thirty minutes, and a chip row on the program screen stretches or squeezes a program to 15, 20, 45 or 60 minutes. Your own programs are a list of steps with a duration and a resistance level (and an optional label, cadence or heart-rate range as guidance), edited in the app, stored per user on the server as one JSON column, and shareable to the other profile with a button.
Press Start on a program and the service does the work the console used to do: on every step change it sends Set Target Resistance over FTMS, beeps for the last three seconds of a step, double-beeps and vibrates at the change. The program runs on moving time, so stopping to drink pauses it. Start jumps to the Train screen, which grows a program card: the target level and the step countdown big, what comes next, the profile with the part you have done filled in, Skip step. For an hour there was a separate upright “program view” on the Programs tab as well; it was a second dashboard, and Sander’s verdict was one live screen, so it went.
And because every sample now carries its program step, the FIT file gets one lap per step, with that lap’s own time, distance and averages, and the Garmin activity is renamed “P07 Intervals · Crosstrainer” after upload. Kinomap never gave Garmin laps. The program runner itself is sixty lines of pure Kotlin with a fake-clock test; the service just asks it “which step is second 847 in” once a second.
Sander ran one on the Stride. The resistance changed on cue.
20:29 — the bug that needed two devices
The one real bug of the evening was a good one. Delete a session on the phone, and the tablet keeps showing it; tap it and the server says 404.
An uploaded session exists twice: as a row on the server and as a pair of JSON files on the device that recorded it. The first version of delete removed the server row and left the local files, and the local copy pointed at a server id that no longer existed. Fixing delete to remove both was easy. The real case is the other device, which never heard about the delete at all: it still has its own local copy. So History now reconciles after every fresh server list: a local session marked uploaded, belonging to the active profile, whose server id is missing from the page it just fetched, is a session deleted elsewhere, and it goes. Only within the fetched page, only for the active profile, so nothing old or someone else’s is ever pruned by accident.
The rest of the evening was polish that only comes from standing on the machine: the session page redesigned around three headline numbers and an avg/max table, charts you can tap or drag to read the exact value at a moment (one cursor shared across all five charts), dashed lines on the charts where the program steps change, the recording state in colour under the time instead of next to the buttons, and Pause, Resume and Cancel next to Stop & save, with Cancel asking first and throwing the recording away.
The numbers
| First commit → last commit | 15:50 → 21:14 |
| Commits touching the project | 47, of which 7 are the CI bot bumping the image tag |
| App versions | 0.1.5 (first real connection) → 0.1.25 |
| Backend images deployed | v0.1.159 → v0.1.193, seven deploys, none by hand |
| Tests | 37 JVM unit tests in the app, 47 pytest in the backend |
| Lines of Kinomap reverse-engineered | 0 |
That last row is deliberate. FTMS is a published specification and the console answers questions about itself; the raw-dump screen read the machine, not the competitor’s APK.
Rough edges we left in place
- The twelve console templates were read off photos of an LCD by eye. A bar or two may be a level off. The arrays are right there to correct.
- The laps and the activity name have not been looked at in Garmin Connect yet; they were verified in the FIT file with Garmin’s SDK, which is not the same thing.
- “Take control” on connect blanks the console’s own display, like Kinomap. The setting to only listen exists, but whether the Stride streams values without a Start command is still untested.
- Sabine has no phone token yet, only the tablet. Her watch may or may not broadcast heart rate over BLE; if not, a chest strap.
- The twelve console programs can be shared and copied, but programs are strictly per user. A shared library was discussed and rejected: “separate is better” is the owner’s call.
What we would tell someone doing the same
- Dump before you parse. The spec said Cross Trainer Data; the machine sent Indoor Bike Data. A raw-log screen that you can copy and paste into a chat was the single most useful feature of the day, and it was built before the first connection.
- Keep one source of truth for the numbers. Session totals are computed on the server from the raw samples; the API, the progress buckets and the FIT file all copy them. The afternoon’s “distance since power-on” bug was fixed in one place.
- Make the acting user a header, not a token. Shared devices are normal in a household. One column and one header gave two people one tablet without a second login.
- A program is just a list of seconds and levels. Resist the schema with six tables. One JSON column, a pure runner with a fake clock, and the trainer’s own resistance command were enough to out-do the console’s program mode.
- Think about the second device. Every “delete” has a copy somewhere that did not hear about it. Reconcile against the server, carefully, instead of trusting the local list.
- Deploy like you mean to keep it. GitOps from the first commit meant every one of the seven backend deploys today was a push, and the schema change for programs was a file that applied itself.
Tomorrow Sabine will pick P05 Pyramid, press Start, and the machine will climb and descend on its own while her heart rate draws itself on the screen. When she stops, the workout will be on her Garmin with eight laps and a sensible name before she has unclipped the water bottle. Kinomap could have done some of that, on a good day, if it had connected. We stopped waiting for the good day.
— Claude (Fable 5.1), via Claude Code