Files
bike-app/docs/RESEARCH.md
T
BBergleandClaude Opus 5 7f33cb1593
CI / Repo hygiene (pull_request) Successful in 2s
CI / Web (lint, typecheck, build) (pull_request) Successful in 14s
CI / Migrations reversible (pull_request) Successful in 6s
CI / API (lint, types, tests) (pull_request) Successful in 53s
docs: correct the false Wi-Fi premise; add UI and live-tracking phases
The plan's headline section claimed the Rider 650 has on-device Wi-Fi and a
Data Sync menu that uploads to Bryton's cloud with no phone involved. It
does not. The unit has ANT+ and Bluetooth only; its sole sync route is BLE
to the Bryton Active app. Confirmed on the physical device, corroborated by
BikeRadar's hands-on. Likely origin: conflation with the Rider 750 / S800,
which do have Wi-Fi.

Impact is narrower than it first appears and no built code is invalidated:
everything downstream of Bryton's cloud never depended on how a ride got
into that cloud, so the poller, ingestion, schema, wear engine and all of
Phase 0 stand. What was invalidated is the product promise — Phase 1 was
called "Zero-touch ride history" and claimed to fix the original complaint
(having to remember to open the Active app). It does not; it is one-tap.
Renamed accordingly rather than leaving the doc overclaiming.

Corrections propagated everywhere the premise had spread: PLAN.md's opening
sections, Phase 1, top risks (the chain is now longer and has a human link
that fails silently — earns a "nothing ingested in N days" nudge), and the
verification checklist; DECISIONS.md D3's justification; README.md, which
was additionally stale on nearly every other point (claimed no code written,
Postgres, compose, four containers); and RESEARCH.md, where the claim
originated under a "verified" header it had not earned. RESEARCH.md is
annotated rather than rewritten — it is a record of what was found, and the
correction is part of that record. USB path facts are marked unverified too,
since they came from the same unverified batch.

D20 records the process lesson: the plan contained the right check ("first
action before writing any code"), it was never run, and nothing downstream
required it to have been. Device capabilities get confirmed on the device
before being written as fact.

Also adds the two phases requested before this came up, both grounded in
feasibility research rather than assumption:
- Phase 1A, an open-ended UI pass done together, including the verbose field
  surface driven off activity_field_inventory.
- Phase 1B, live tracking. Constrained hard by reality: iOS suspends
  backgrounded PWAs and implements no Web Bluetooth, and Bryton's own Live
  Track needs the phone relaying over BLE, so the tracking client cannot be
  our PWA. Shape that works is OwnTracks POSTing to our API for position,
  with the server deriving distance/pace/elevation; HR and power need BLE and
  are explicitly a second-class opt-in, not a blocker. Two rules written in:
  live positions must never become activities (invariant #6), and "no privacy
  zones, ever" does not extend to a public live link.

Inserted as 1A/1B rather than renumbering Phases 2-5, whose numbers are
referenced from DECISIONS.md, deploy/README.md and code comments.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R2ZKeWkZV7ehf7fivrAkkG
2026-09-21 23:20:37 -04:00

23 KiB

Research Findings

Compiled 2026-09-20 by four research agents (Opus for the Bryton protocol work and the architecture design; Sonnet for the two breadth surveys). Confidence is flagged where it matters. Re-verify anything marked unverified before building on it.


1. Getting data off the Bryton Rider 650

Device facts (dated 2026-09-20 — item 2 was WRONG, see correction)

⚠️ Correction, 2026-09-22. Item 2 below is false and was never verified on the device. The Rider 650 has no Wi-Fi — ANT+ and Bluetooth only — and no Data Sync menu entry. Its only sync route is BLE to the Bryton Active app. Confirmed on the physical unit, corroborated by BikeRadar's hands-on ("ANT+ and Bluetooth connectivity", syncing via "Bryton's Active App"). The likely origin is conflation with the Rider 750 / S800, which do have Wi-Fi. The header on this section originally read "verified 2026-09-20" — it was not verified; that word is the reason the claim propagated into docs/PLAN.md as a premise and survived an entire phase of work. See docs/DECISIONS.md D20. Item 1 (USB) is still believed correct but is also unverified against the device — treat it as an open question, not a finding, until someone plugs the unit in.

The Rider 650 is modern generation. Extraction paths:

  1. USB mass storage. Mounts as a plain FAT volume labelled Bryton; activities are native Garmin-format .fit files in Bryton/Activities/. No driver, no udev rule needed beyond convenience — it is plain usb-storage. Filter on ID_FS_LABEL=Bryton. Caveat: sources disagree across models about whether the folder is Activities/, Actives/, or the volume root. Discover it at runtime with a recursive glob; don't hardcode. Status: unverified on the device.
  2. On-device Wi-Fi. Main Menu -> Data Sync lets the head unit join a Wi-Fi hotspot directly and upload tracks to Bryton's cloud with no phone and no Bryton Active app. FALSE — the device has no Wi-Fi and no such menu. See the correction above. The real path is head unit -> BLE -> Bryton Active app -> Bryton cloud, so the phone cannot be removed from the pipeline by any means available to this project.

Bryton Active cloud API (reverse-engineered, working)

There is no official/public Bryton API — no developer portal, no OAuth. But the private API is fully reverse-engineered and current.

Reference implementation: github.com/jorge-huxley/intervalssync (MIT, Python, 21 stars, last push 2026-09-17). Also on PyPI as intervalssync with a headless CLI that emits JSON and proper exit codes. The protocol was read directly from src/intervalssync/bryton/{ddp,api}.py:

Transport — Meteor DDP over SockJS/WebSocket. Bryton's backend is a Meteor app.

Hosts:      m3.brytonactive.com      (app/API host, DEFAULT_HOST)
            active.brytonsport.com   (web host)
Handshake:  GET https://m3.brytonactive.com/sockjs/info
            wss://m3.brytonactive.com/sockjs/<3-digit>/<8-char>/websocket
            -> {"msg":"connect","version":"1","support":["1","pre2","pre1"]}

Auth — standard Meteor login method, SHA-256 password digest. No OAuth, no client secret.

digest = hashlib.sha256(password.encode()).hexdigest()
call("login", [{"user": {"email": email},
                "password": {"digest": digest, "algorithm": "sha-256"}}])
# -> {"id": <userId>, "token": <resume token>, ...}
# session resume: call("login", [{"resume": auth_token}])

Listingsubscribe("activityList", []) populates the userActivities collection. Documents carry _id, name/title, local_start_time (epoch seconds), and a _deleted flag / literal name == "_deleted" tombstone which you must filter out.

Download — plain REST, returns raw original FIT bytes:

GET https://m3.brytonactive.com/api/activity?id=<activity_id>
Headers:
  X-User-Id:     <userId>
  X-Auth-Token:  <token>
  x-api-key:     UHIJdntFZFkZaVJsInRHfRcYES08Fwp0Bwkf     # hardcoded app key
  User-Agent:    okhttp/4.12.0

The reference client validates the response by checking for the .FIT magic at bytes 8-13, confirming you receive a genuine, unmodified FIT file — not a re-encode.

Risk assessment. Reliability: medium. Private API, hardcoded app key, okhttp UA spoof. A Meteor method rename, key rotation, or UA check breaks it silently. But Meteor DDP collection names are load-bearing for Bryton's own app, so they change slowly, and a small project has kept this working. Rate limits are undocumented — poll conservatively (15-20 min).

Security note that matters: the SHA-256 digest is the credential — it replays as a password. Leaking your DB leaks Bryton account access. Encrypt at rest, never return it from an API, redact in logs.

Third-party corroboration: Terra (tryterra.co/integrations/brytonsport) sells a Bryton integration described as a "web API integration" with "no provider credentials required", polling every 5-10 min — i.e. the same unofficial route. Terra lists support for Rider S800, S500, 750, 650, 550, 460, 320.

Why NOT Strava as the data source

  • Strava API v3 has no way to retrieve the original file. export_original/export_tcx exist only on the website, not the API. You get /activities/{id} + /streams — decoded arrays with Strava's own smoothing applied, no developer fields, no device/session metadata, no laps-as-recorded fidelity.
  • June 2026 developer-program restructure: new apps default to single-athlete; Standard Tier caps at 10 users and requires the developer to hold an active Strava subscription; Extended Access needs an application. Strava also explicitly blocked apps reaching the API via middleware/aggregator layers.
  • Queued breaking changes: 2026-09-01 retired Club Activities/Admins/Members and gated Segments Explore; June 2027 forces header-only tokens and moves api.strava.com -> www.api-v3.strava.com. (single secondary source — verify at developers.strava.com)

Verdict: use Strava only as a redundancy signal ("did a ride happen?"), never as data of record.

Why BLE / ANT+ direct is out of scope

  • ANT-FS: not supported by Bryton. Bryton units are ANT+ sensor consumers and BLE peripherals to the Active app. No evidence of an ANT-FS server on any Bryton model. (negative claim — exhaustive search found no counterexample)
  • Tigge/openant (v1.3.2) implements ANT-FS but its working-device list is Garmin-only.
  • Gadgetbridge has zero Bryton support — grepped the supported-devices page, no matches, no device-request issue, no protocol wiki page.
  • Bryton's proprietary BLE sync is not publicly reverse-engineered. No service/characteristic UUIDs, no protocol notes anywhere. The only artefact in existence is a single commit "Attempt to copnnect via BT" (sic) from 2026-05-02 in a 3-commit fork.
  • Reverse-engineering it would mean btsnoop_hci captures off an Android phone running Active, and you would be the first person publicly to do it. Weeks, not hours, and it breaks on firmware updates. Skip.

Legacy Bryton tooling (NOT needed for the 650 — recorded for completeness)

bryton-gps-linux (github.com/Pitmairen/bryton-gps-linux) is effectively abandoned: last real commit 2016-06-04, Python 2.7, GPL-3.0, 9 unanswered open issues. Supports Rider 20/20+/21/30/35/40/50 via raw SCSI reads (py_sg) for the proprietary pre-FIT binary format. Emits GPX/TCX only, never FIT. Of its 13 forks, 12 are dead mirrors; penseleit/bryton-gps-linux branch python3 has 3 commits from 2026-05-02. Irrelevant to the Rider 650 — that writes .fit directly.

FIT parsing libraries (verified against GitHub/npm/PyPI APIs 2026-09-20)

Language Recommendation Rationale
Python fitdecode (polyvertex/fitdecode, last push 2025-08-06, 218*) Clean-room rewrite of fitparse. Thread-safe (matters for concurrent upload parsing), preserves FIT header + CRC footer instead of discarding them, optional CRC checking (match/compute/ignore) for speed, fixed developer-field decoding. python-fitparse's own maintainers point users here; its 821 stars are legacy inertia.
JS/TS @garmin/fitsdk 21.214.0 (official, ~monthly releases) — but license is Garmin's own, not OSI. OSS alternative: fit-file-parser 5.2.1 (MIT-ish, pins to the Garmin profile) n/a — we chose Python
Go muktihari/fit (not tormoder/fit, which declared itself unmaintained Sept 2024 and points at muktihari) Preserves message ordering and surfaces unknown messages instead of dropping them — matters for a non-Garmin encoder
Rust crate fitparser (repo stadelmanma/fitparse-rs) Note the naming trap: repo is fitparse-rs, crate is fitparser, and a separate stale fitparse crate exists

Cross-cutting warning: Bryton's FIT encoder is not Garmin's. Expect nonstandard manufacturer/product IDs, missing or minimal device_info, unusual lap/session boundaries, and possibly zero developer fields. Test against a real Rider 650 file before designing the schema.

Format fidelity: FIT >> TCX > GPX

  • FIT -> TCX loses: advanced dynamics, developer fields, most session/device metadata. Retains GPS, time, distance, per-lap structure, HR, cadence, power.
  • FIT -> GPX loses far more: lap markers, session totals, device settings, developer fields, R-R intervals, L/R power balance, and power itself (no standard extension). Only HR, cadence and temperature survive, via the non-standard TrackPointExtension namespace that many apps ignore.

Design rule: model the DB on FIT's structure (session -> laps -> records + events), treat TCX/GPX as degraded inputs that null columns. Never convert FIT->GPX for storage. Keep original bytes forever.


2. Prior art

Self-hosted fitness trackers surveyed

Project Stack License Parts/maintenance? Status
Endurain FastAPI + Vue 3 + Postgres AGPL-3.0 Yes — gear tracking and gear-component tracking (chain replacement etc.) 2.2k*, active but in a feature freeze to harden the core
FitTrackee Flask + Vue 3 + Postgres/PostGIS AGPL Weak — one flat "equipment" tag per workout, accrues totals, no service intervals v1.3.5, 146 releases, active (primary dev on Codeberg)
Dawarich Rails + PostGIS + Sidekiq AGPL-3.0 No (it's a Google Timeline replacement) Very active, "breaking changes expected"
Wanderer SvelteKit + PocketBase AGPLv3 No 3.9k*, active, hiking/trail-catalogue oriented
Traccar Java, 200+ device protocols Apache 2.0 No Mature but it's a live fleet tracker — wrong tool
GoldenCheetah C++/Qt desktop GPLv2 No v3.8 Sep 2025. Best-in-class CP/W'bal/TSB modelling reference
strava-gear Python CLI + SQLite MIT Yes, dedicated — YAML rule-based wear, parts move between bikes, hashtag temp swaps 36*, small but the best data-model reference
LubeLogger .NET + LiteDB/Postgres MIT Vehicle-oriented (cars) 2.8k*, very active. Good UX reference for service records + receipts
Wrench Turn Go + Node GPL-3.0 Generic multi-equipment Alpha, too immature to fork
Intervals.icu SaaS, closed Some gear tracking Feature reference only: 140+ metrics, multi-model power curve

Decision taken: build fresh. No project covers rides + real parts inventory + maintenance + the self-host features. Borrow Endurain's gear/component table structure and strava-gear's retroactive time-ranged wear computation, but own the code and avoid AGPL entanglement.

Component wear intervals (the seed catalogue source data)

Component Interval Trigger metric
Chain 2,000-3,000 mi (3,200-4,800 km); replace at 0.75% stretch, 0.5% for 11/12-speed distance + gauge
Cassette 1,500-5,000 mi (2,400-8,000 km); survives 2-3 chains if chain replaced on time distance
Chainrings up to 25,000 km road; most replace every 2-3 yrs distance/time
BB / headset / hub bearings every 6 months or ~3,000 mi (4,800 km) time or distance
Brake pads (rim) highly variable — a single wet century ~= 500 dry miles of wear distance, wet-weighted
Brake pads (disc) ~1,500-3,000 km; replace under 1.5mm material distance
Tyres rear wears 2-3x faster than front; 2,500-5,000+ km distance
Cables/housing inspect 1,000 mi, replace 2,500 mi or annually distance/time
Tubeless sealant every 2-3 months (evaporates) calendar
Suspension lower service ~50 hours ride hours
Suspension full service ~125-200 hours ride hours
Chain wax every 300-400 km distance
Clean & lube drivetrain every 100-200 mi (dry lube 100-200, wet lube 200-300) distance
Chain wear check every 500 mi / monthly distance

The wet-ride multiplier in the schema exists because of the rim-pad line above: it is a real, large effect, and weather data makes it computable.

Sources: road.cc, BikeRadar, The Service Course, Canadian Cycling Magazine, TBS Bike Parts, ProBikeGarage, Bike New York, bikegremlin.com, Roadman Cycling.

Canonical parts/maintenance data model (synthesized)

ProBikeGarage (commercial) is the fullest feature reference: register components -> assign to bike(s) -> auto-accrue distance from linked activities -> interval alerts -> log service events. Supports grouping sub-components (wheel + its bearings) and swapping gear per ride type (race wheels, trainer tyre).

strava-gear's model is the cleanest open-source one and the one we adopt: components have a since: timestamped install/removal rule, and wear is computed retroactively from the activity stream rather than stored as a running odometer — which elegantly handles retroactive corrections.


3. Self-hostable enrichment services

Rule: scope everything to your riding region, never the planet.

Service Purpose Footprint Home-server viable?
Open Topo Data Elevation (SRTM/Copernicus DEM) ~20GB+ disk global, low RAM (memory-mapped) Yes — the practical choice, fine on a Pi-class box
open-elevation Elevation, older alternative whole dataset must fit in RAM unless chunked Marginal; Open Topo Data is better maintained
PMTiles / Protomaps Vector tiles, single file + HTTP range requests planet ~120GB, but regional extract = a few hundred MB to a few GB Yes — best fit. No tile server needed, any static host with range requests
TileServer-GL Serves vector/raster tiles + MapLibre style low; disk for the tile source Yes
OpenFreeMap self-host Pre-built OSM vector tiles similar to PMTiles Yes
Photon (komoot) Geocoding, Elasticsearch-based planet ~95GB disk / 64GB RAM recommended; country extract runs in 8-16GB Regional yes, planet no
Nominatim Geocoding >1TB fast disk, ~128GB RAM for planet NO — never at home. Region extract only
Pelias Geocoding similar to Photon, more ops complexity Overkill
Valhalla Routing ~100GB disk planet but only 4-8GB RAM at serve time (tiles load on demand) Best routing fit
GraphHopper Routing, Java 8-16GB heap to build planet; 6-10GB serve Good middle ground
OSRM Routing ~55GB RAM / 50GB disk planet Regional only
Overpass API POI / road-surface tags dev/small extract 1-2GB RAM; personal instance 4-8GB, 20-30GB storage. OVERPASS_META=no saves ~30% disk Yes, easily

Weather — do NOT self-host. Open-Meteo Historical Weather API is ERA5 reanalysis, hourly back to January 1940, globally complete, 9-25km resolution, free JSON over HTTP, no API key, non-commercial use. Its Air Quality API (CAMS-sourced) gives PM2.5, PM10, NO2, O3, SO2, CO, dust, UV, pollen, EU+US AQI, with historical coverage — also free and keyless. The server is open source (AGPLv3) and data is mirrored on AWS Open Data if you ever need offline operation, but for one cyclist's enrichment volume the public API is plenty. Cache per ride.


4. Gitea Actions + act_runner (2026)

Gitea Actions left experimental status in ~1.21 (enabled by default from 1.21.0+). Current stable line is 1.26 (adds concurrency:, reusable workflows from private repos, configurable Actions-token permissions, per-runner pause/disable, non-zipped artifacts, re-run-failed-jobs-only). 1.24 added the artifact-download API, runner-registration API, and workflow_dispatch via API.

Gotchas that will cost you time if you don't know them

  1. secrets.GITEA_TOKEN CANNOT push to the Gitea container registry. Gitea's own comparison doc lists this under unsupported: "Package repository authorization: GITEA_TOKEN cannot publish to package repositories." Users hit unauthorized: reqPackageAccess. Use a PAT (Settings -> Applications -> Manage Access Tokens) scoped package:write (+ read:package for pulls), stored as a repo secret.
  2. jobs.<id>.environment is silently ignored — no environment protection rules, no approvals. Don't design a deploy gate around it.
  3. Marketplace actions need github.com reachability. [actions] DEFAULT_ACTIONS_URL = github is the default, so unqualified uses: actions/checkout@v4 resolves to github.com. Set DEFAULT_ACTIONS_URL = self and mirror actions into your own instance if you lock down runner egress. You can always bypass per-step with a fully-qualified URL (uses: https://github.com/docker/build-push-action@v6). Gitea mirrors popular actions at gitea.com/actions/* but "it's impossible to mirror all of them."
  4. Scheduled workflows have shipped flaky. Cron triggers firing at wrong times or not at all (issues #26571, #34138). Always pair schedule: with workflow_dispatch:.
  5. runs-on only supports a plain label or [a, b], not GitHub's runner-group matrix syntax. Most step-level expression functions beyond always() are unimplemented. Problem matchers and error annotations are no-ops.
  6. Org/user-level secrets have had visibility bugs (issue #30361). Prefer repo-level secrets.
  7. docker.sock in the runner = root on the host (gitea/runner issue #167). Acceptable for a private single-maintainer instance; never with untrusted collaborators or fork PRs.

What works well

  • actions/cache@v4 is built in — act_runner ships a cache server (v2 backend, Feb 2025). No extra service. Works for npm/pnpm/Go/pip/uv caches.
  • Service containers work in Docker mode — standard GitHub services: syntax, since it's a plain Docker feature. Postgres/PostGIS test containers are fine. Only rough edge: the UI doesn't render a dedicated log section for them.
  • Multi-arch buildx works (setup-qemu-action + setup-buildx-action + build-push-action). QEMU arm64 emulation is slow. For DinD runners you must install binfmt inside the DinD container: docker run --privileged --rm tonistiigi/binfmt --install all.
  • Registry cleanup rules exist per package owner: Settings -> Packages -> Cleanup Rules, with "keep last N versions" plus a regex, previewable before running.

Runner setup

act_runner:
  image: gitea/act_runner:latest
  environment:
    GITEA_INSTANCE_URL: "https://git.example.com"
    GITEA_RUNNER_REGISTRATION_TOKEN: "${RUNNER_TOKEN}"
    GITEA_RUNNER_LABELS: "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest"
  volumes:
    - ./data:/data
    - /var/run/docker.sock:/var/run/docker.sock   # DooD — root-equivalent, see gotcha 7

Label format <name>[:<schema>[:<args>]], schema is docker or host. Token from Instance/Org/Repo -> Actions -> Runners -> "Create new runner", or gitea actions generate-runner-token. Label precedence: --labels/env > runner.labels in config > labels baked into .runner.

Deploy patterns, ranked for one home server

  1. Runner does docker compose pull && up -d on the host — simplest, no SSH key, no extra service, fastest. Chosen. Cost: runner needs docker socket access (= root).
  2. SSH deploy from a separate runner — decouples build from deploy, but same blast radius relocated.
  3. Watchtower / diun polling the registry — zero CI deploy logic, but Watchtower can't validate an image is good before swapping it in. Use only for third-party containers you don't build.
  4. Komodo / Dockge / Portainer webhooks — nice UI, but another daemon with socket access.
  5. Pull-based GitOps agent — best security posture (host never listens), but DIY; there's no Flux/Argo equivalent for plain compose.

Renovate (Dependabot is GitHub-only)

Runs as a scheduled Gitea Action using ghcr.io/renovatebot/renovate, with RENOVATE_PLATFORM: gitea, RENOVATE_ENDPOINT: https://git.example.com/api/v1/, and a PAT with repo write (the automatic token is workflow-run-scoped and can't open PRs). Update rules go in renovate.json in the target repo. Gitea publishes a shared config at gitea.com/gitea/renovate-config.

Backups

pg_dump -Fc (custom format, parallel restore) piped into restic -> B2/S3. restic over borg here because of native object-storage backends, content-defined chunking that dedupes well against a slowly-growing FIT archive, and a single static binary. Litestream does not apply — it's SQLite WAL streaming only. Orchestrate with a systemd timer, not a Gitea Action (Persistent=true catches missed runs; and backups must not depend on CI being healthy — CI is the most likely thing to be broken when you need a restore). Use append-only repo credentials so a compromised app host can't delete history. 3-2-1. Test restores — an untested backup is a hypothesis.


5. Open questions to resolve before/while building

  1. Does Rider 650 Data Sync upload automatically on joining Wi-Fi, or only on manual trigger? RESOLVED 2026-09-22 — the question was malformed. There is no Wi-Fi and no Data Sync on this device; the phone cannot be removed from the loop at all. See the correction at the top of this file and docs/DECISIONS.md D20. This was the single most consequential open question in this list, it was marked "two-minute test," and it went unanswered while an entire phase was planned and built on the assumed answer.
  2. Exact on-device .fit path — documented as Bryton/Activities/ for the 650, but sources disagree across models. One ls -R settles it.
  3. Bryton's FIT encoder quirks — untested against fitdecode. Get one real 650 file and run it through before finalising the schema. Check: file_id.type present? manufacturer ID sane? lap boundaries sensible? moving_time populated?
  4. Strava's June-2027 base-URL change — single secondary source; verify at developers.strava.com if you ever care (we don't, since Strava isn't a source).
  5. Bryton cloud rate limits — undocumented. Start at 15-20 min polling and watch for 429s.