Planning output only; no application code yet.
Key findings driving the design:
- The Bryton Rider 650 has on-device Wi-Fi (Main Menu -> Data Sync) and
uploads to Bryton's cloud with no phone and no Bryton Active app. Paired
with the reverse-engineered Bryton cloud API — which returns the original
unmodified FIT bytes — this makes ride sync fully hands-off, and higher
fidelity than the current Strava route (Strava's API cannot return the
original file, only smoothed streams).
- Build fresh rather than forking Endurain or FitTrackee; borrow Endurain's
gear/component structure and strava-gear's retroactive time-ranged wear
computation.
- PWA rather than a native iOS app: iOS 16.4+ gives home-screen PWAs real
push notifications, which was the only thing that used to force native.
Docs:
docs/PLAN.md stack, schema, ingestion, auth, notifications, roadmap,
CI/CD, risks, verification
docs/RESEARCH.md Bryton cloud protocol, FIT library comparison,
maintenance intervals, geo services, Gitea gotchas
docs/DECISIONS.md decisions taken, alternatives rejected, rationale
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
22 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 (verified 2026-09-20)
The Rider 650 is modern generation. Two independent extraction paths, both good:
- USB mass storage. Mounts as a plain FAT volume labelled
Bryton; activities are native Garmin-format.fitfiles inBryton/Activities/. No driver, no udev rule needed beyond convenience — it is plainusb-storage. Filter onID_FS_LABEL=Bryton. Caveat: sources disagree across models about whether the folder isActivities/,Actives/, or the volume root. Discover it at runtime with a recursive glob; don't hardcode. - On-device Wi-Fi.
Main Menu -> Data Synclets the head unit join a Wi-Fi hotspot directly and upload tracks to Bryton's cloud with no phone and no Bryton Active app. This is the key finding — it removes the phone from the pipeline entirely. Still to verify: whether it uploads automatically on joining Wi-Fi, or only when you manually trigger Data Sync from the menu. Two-minute test; do it first.
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}])
Listing — subscribe("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_tcxexist 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
TrackPointExtensionnamespace 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
secrets.GITEA_TOKENCANNOT 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 hitunauthorized: reqPackageAccess. Use a PAT (Settings -> Applications -> Manage Access Tokens) scopedpackage:write(+read:packagefor pulls), stored as a repo secret.jobs.<id>.environmentis silently ignored — no environment protection rules, no approvals. Don't design a deploy gate around it.- Marketplace actions need github.com reachability.
[actions] DEFAULT_ACTIONS_URL = githubis the default, so unqualifieduses: actions/checkout@v4resolves to github.com. SetDEFAULT_ACTIONS_URL = selfand 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 atgitea.com/actions/*but "it's impossible to mirror all of them." - Scheduled workflows have shipped flaky. Cron triggers firing at wrong times or not at all
(issues #26571, #34138). Always pair
schedule:withworkflow_dispatch:. runs-ononly supports a plain label or[a, b], not GitHub's runner-group matrix syntax. Most step-level expression functions beyondalways()are unimplemented. Problem matchers and error annotations are no-ops.- Org/user-level secrets have had visibility bugs (issue #30361). Prefer repo-level secrets.
docker.sockin 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@v4is 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
- Runner does
docker compose pull && up -don the host — simplest, no SSH key, no extra service, fastest. Chosen. Cost: runner needs docker socket access (= root). - SSH deploy from a separate runner — decouples build from deploy, but same blast radius relocated.
- 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.
- Komodo / Dockge / Portainer webhooks — nice UI, but another daemon with socket access.
- 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
- Does Rider 650 Data Sync upload automatically on joining Wi-Fi, or only on manual trigger? Determines how completely the phone is removed from the loop. Two-minute test.
- Exact on-device
.fitpath — documented asBryton/Activities/for the 650, but sources disagree across models. Onels -Rsettles it. - Bryton's FIT encoder quirks — untested against
fitdecode. Get one real 650 file and run it through before finalising the schema. Check:file_id.typepresent? manufacturer ID sane? lap boundaries sensible?moving_timepopulated? - 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).
- Bryton cloud rate limits — undocumented. Start at 15-20 min polling and watch for 429s.