Add planning docs for self-hosted cycling app
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>
This commit is contained in:
+39
@@ -0,0 +1,39 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.pytest_cache/
|
||||||
|
*.egg-info/
|
||||||
|
|
||||||
|
# Node
|
||||||
|
node_modules/
|
||||||
|
.svelte-kit/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
.vite/
|
||||||
|
|
||||||
|
# Environment & secrets
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
|
||||||
|
# Data — never commit ride files, blobs, or dumps
|
||||||
|
blobstore/
|
||||||
|
import_inbox/
|
||||||
|
*.fit
|
||||||
|
*.gpx
|
||||||
|
*.tcx
|
||||||
|
*.sql
|
||||||
|
*.dump
|
||||||
|
!apps/api/tests/fixtures/fit/*.fit
|
||||||
|
|
||||||
|
# OS / editor
|
||||||
|
.DS_Store
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# bike-app
|
||||||
|
|
||||||
|
A self-hosted cycling app: syncs rides from a **Bryton Rider 650**, tracks mileage like Strava, and
|
||||||
|
adds a **spare-parts inventory** and a **maintenance record** with mileage-milestone reminders.
|
||||||
|
|
||||||
|
**Status: planning complete, no code written yet.**
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
Today the Rider 650 syncs over Bluetooth to the Bryton Active phone app, which forwards to Strava —
|
||||||
|
but Active has no background sync, so you have to remember to open the app. Research found a better
|
||||||
|
path that removes the phone entirely:
|
||||||
|
|
||||||
|
```
|
||||||
|
ride ends -> Rider 650 joins home Wi-Fi (Main Menu -> Data Sync)
|
||||||
|
-> uploads to Bryton cloud
|
||||||
|
-> this app's poller fetches the ORIGINAL FIT file
|
||||||
|
-> rides, wear tracking, and push reminders
|
||||||
|
```
|
||||||
|
|
||||||
|
That's also *higher fidelity* than the current route — Strava's API can only ever return smoothed
|
||||||
|
streams, never the original file.
|
||||||
|
|
||||||
|
## Docs
|
||||||
|
|
||||||
|
| File | What's in it |
|
||||||
|
|---|---|
|
||||||
|
| [`docs/PLAN.md`](docs/PLAN.md) | The full implementation plan: stack, schema, ingestion pipeline, auth, notifications, phased roadmap, CI/CD, risks, verification |
|
||||||
|
| [`docs/RESEARCH.md`](docs/RESEARCH.md) | Raw findings: the Bryton cloud protocol (endpoints, headers, auth), FIT library comparisons, maintenance interval tables, self-hostable geo services, Gitea Actions gotchas |
|
||||||
|
| [`docs/DECISIONS.md`](docs/DECISIONS.md) | Every decision taken, what was rejected, and why |
|
||||||
|
|
||||||
|
## Planned stack
|
||||||
|
|
||||||
|
Python 3.12 / FastAPI / SQLAlchemy async / PostgreSQL 16 + PostGIS, `procrastinate` for jobs,
|
||||||
|
SvelteKit static SPA as an installable PWA, MapLibre GL, all behind Caddy in Docker Compose.
|
||||||
|
Source control and CI in self-hosted Gitea with an act_runner on the same box.
|
||||||
|
|
||||||
|
Four containers in v1, under 2GB RAM.
|
||||||
|
|
||||||
|
## Next steps
|
||||||
|
|
||||||
|
1. **Verify on the Rider 650:** does `Main Menu -> Data Sync` upload *automatically* on joining
|
||||||
|
Wi-Fi, or only on manual trigger? This determines how completely the phone leaves the loop.
|
||||||
|
2. **Plug the 650 in over USB** and `ls -R` the mounted volume to confirm the real `.fit` path
|
||||||
|
(documented as `Bryton/Activities/`, but worth confirming).
|
||||||
|
3. **Grab a real `.fit` file** from it and run it through `fitdecode` — Bryton's encoder is not
|
||||||
|
Garmin's, and the schema should be checked against reality before it's written.
|
||||||
|
4. Then Phase 0: scaffolding and CI (see the roadmap in `docs/PLAN.md`).
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
# Decisions
|
||||||
|
|
||||||
|
Settled during the planning session on 2026-09-20. Each entry records what was chosen, what was
|
||||||
|
rejected, and why — so we don't relitigate them.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### D1 — Build fresh, don't fork
|
||||||
|
**Chosen:** greenfield, borrowing data models only.
|
||||||
|
**Rejected:** forking Endurain (AGPL, FastAPI+Vue, already has gear/component tracking, but in a
|
||||||
|
feature freeze with a thin maps story) or FitTrackee (AGPL, mature heatmaps, but its "equipment" is
|
||||||
|
one flat tag per workout with no wear intervals — the entire parts/maintenance system would be
|
||||||
|
bolted on anyway).
|
||||||
|
**Why:** nothing existing covers rides + real parts inventory + maintenance + the self-host
|
||||||
|
features. Take Endurain's gear/component table structure and strava-gear's retroactive wear
|
||||||
|
computation as references; own every line; avoid AGPL entanglement.
|
||||||
|
|
||||||
|
### D2 — PWA, not a native iOS app
|
||||||
|
**Chosen:** SvelteKit static SPA, installed to the iPhone home screen.
|
||||||
|
**Rejected:** a native SwiftUI app.
|
||||||
|
**Why:** a home-screen PWA gets the icon, standalone display, offline caching, and — since iOS 16.4
|
||||||
|
— real push notifications, which was the only thing that used to force native. Native would cost
|
||||||
|
$99/yr for an Apple Developer account, TestFlight/sideloading to reach family phones, and a second
|
||||||
|
codebase forever. The two real PWA gaps on iOS (Web Bluetooth, Background Sync) are irrelevant here:
|
||||||
|
Bryton BLE is a dead end regardless, and the *server* does all syncing.
|
||||||
|
**Kept as insurance:** the backend stays strictly API-first with a CI-enforced OpenAPI contract, so
|
||||||
|
if Apple ever makes the PWA route untenable, a native client is a code-generation exercise.
|
||||||
|
|
||||||
|
### D3 — Bryton cloud poller is the primary ingestion path
|
||||||
|
**Chosen:** server-side poller against the reverse-engineered Bryton Active API, every 15-20 min.
|
||||||
|
**Rejected:** Strava as a source (no `export_original` — decoded smoothed streams only; plus the
|
||||||
|
June 2026 tier restructure caps new apps at 10 users and requires a paid dev subscription);
|
||||||
|
BLE/ANT-FS direct (nobody has reverse-engineered Bryton's BLE — weeks of work, breaks on firmware
|
||||||
|
updates); depending on the Bryton Active phone app (the original complaint).
|
||||||
|
**Why:** the Rider 650 has on-device Wi-Fi (`Main Menu -> Data Sync`) and uploads to Bryton's cloud
|
||||||
|
with no phone involved, and the cloud API returns the **original unmodified FIT bytes**. That's both
|
||||||
|
zero-touch *and* higher fidelity than the current Strava route.
|
||||||
|
**Fallbacks, both built:** USB watch folder (also the historical-backfill mechanism, so it stays
|
||||||
|
exercised rather than bit-rotting) and manual upload.
|
||||||
|
|
||||||
|
### D4 — Python / FastAPI / Postgres+PostGIS
|
||||||
|
**Chosen:** Python 3.12, FastAPI, Pydantic v2, SQLAlchemy 2.0 async, Alembic, PostgreSQL 16 + PostGIS 3.4.
|
||||||
|
**Rejected:** TypeScript full-stack, Go.
|
||||||
|
**Why:** `fitdecode` is Python-only, and the Bryton poller reference implementation is Python
|
||||||
|
(~200 lines of Meteor DDP to vendor rather than reimplement — DDP over SockJS is fiddlier than it
|
||||||
|
looks in JS). FastAPI emits OpenAPI 3.1 for free. PostGIS is needed for heatmaps, bbox queries, and
|
||||||
|
self-segment matching. Go has the best raw performance and a fine FIT library but the weakest
|
||||||
|
data-analysis ecosystem for the later analytics work.
|
||||||
|
|
||||||
|
### D5 — procrastinate for background jobs
|
||||||
|
**Chosen:** `procrastinate` (Postgres-backed queue).
|
||||||
|
**Rejected:** Celery (needs Redis/RabbitMQ, heavyweight, weak async, second-class Postgres broker),
|
||||||
|
arq (Redis-only — a whole container for ~50 tasks/day), APScheduler (a scheduler, not a durable
|
||||||
|
queue: no retries, no dead-lettering, no multi-worker coordination).
|
||||||
|
**Why:** the decisive property is **transactional enqueue** — the `raw_files` INSERT and the parse-job
|
||||||
|
enqueue commit atomically on one connection, so there are no orphaned blobs and no jobs pointing at
|
||||||
|
rolled-back rows. That's impossible with a Redis broker without inventing an outbox. It also has
|
||||||
|
built-in cron, which removes the scheduler container, and keeps job state inside the same `pg_dump`.
|
||||||
|
|
||||||
|
### D6 — Opaque bearer tokens, no JWT
|
||||||
|
**Chosen:** Argon2id passwords + opaque tokens in a `sessions` table, HttpOnly cookie for the PWA.
|
||||||
|
**Rejected:** JWT.
|
||||||
|
**Why:** at 5-15 users, verification is one indexed PK lookup (~0.1ms), and you get instant
|
||||||
|
revocation, a real device list, and no key-rotation or clock-skew bug class. JWT's only advantage is
|
||||||
|
stateless horizontal scale, which will never arrive — choosing it would be a permanent complexity
|
||||||
|
tax against a benefit that never materialises.
|
||||||
|
|
||||||
|
### D7 — Raw bytes are the only truth
|
||||||
|
**Chosen:** every ingested file is written to a content-addressed blob store *before* parsing, and is
|
||||||
|
never mutated or deleted. All tables are rebuildable projections.
|
||||||
|
**Why:** this converts "a parser bug wrote wrong elevation to 4,000 rides" and "reprocess a decade of
|
||||||
|
history against a better DEM" from incidents/migrations into routine batch jobs (`parser_version`
|
||||||
|
bump + requeue). It is the single most load-bearing rule in the design, and it's also the primary
|
||||||
|
data-loss control.
|
||||||
|
|
||||||
|
### D8 — No odometer column anywhere; wear is derived
|
||||||
|
**Chosen:** `component_installs` as a time-ranged association (strava-gear's model, made relational
|
||||||
|
with a GIST `EXCLUDE` constraint), with wear computed by replaying the activity stream.
|
||||||
|
**Rejected:** a stored running odometer per component.
|
||||||
|
**Why:** correcting "I actually swapped that chain a week earlier" becomes one `UPDATE` and every
|
||||||
|
downstream number self-corrects. Parts moving between bikes is two rows. A stored counter can do
|
||||||
|
neither without a reconciliation nightmare — which is exactly where FitTrackee's flat equipment tag
|
||||||
|
falls over in year two.
|
||||||
|
|
||||||
|
### D9 — Streams as columnar int32 arrays
|
||||||
|
**Chosen:** one row per channel per activity, `values_i32[]` with a scale factor.
|
||||||
|
**Rejected:** a normalized per-sample table (~5x larger with index, and every real query wants the
|
||||||
|
whole stream anyway), JSONB (untyped, 3-5x larger, slow to deserialise), TimescaleDB (solves
|
||||||
|
cross-entity firehose scans; we do per-entity blob reads — and it would mean abandoning the
|
||||||
|
`postgis/postgis` base image and taking on extension-version coupling at every Postgres upgrade).
|
||||||
|
**Bonus:** FIT stores position as int32 semicircles, so lat/lon are lossless and free in this encoding.
|
||||||
|
Aggregates are precomputed at ingest into `activity_stats`, never scanned from streams.
|
||||||
|
|
||||||
|
### D10 — Imperial display units
|
||||||
|
**Chosen:** `users.unit_system` defaults to imperial. Storage stays SI integers throughout
|
||||||
|
(metres, seconds, mm/s, minor currency units); units are strictly a presentation concern.
|
||||||
|
|
||||||
|
### D11 — Notification dedupe by cycle sequence
|
||||||
|
**Chosen:** `notification_log` with `UNIQUE (user_id, dedupe_key)` where the key is
|
||||||
|
`service_due:<rule_id>:<component_id>:<cycle_seq>:<threshold>` and `cycle_seq` counts service
|
||||||
|
events logged against that (component, rule).
|
||||||
|
**Why:** a naive nightly evaluator nags you about the same chain every night until you fix it, and
|
||||||
|
you learn to ignore it. This fires exactly once at 80%, once at 100%, then goes quiet; logging the
|
||||||
|
service increments the cycle and re-arms the next 200 miles. It's how recurrence works without a
|
||||||
|
cron-style recurrence engine.
|
||||||
|
|
||||||
|
### D12 — ntfy first, Web Push second
|
||||||
|
**Chosen:** `apprise` -> ntfy as the primary notification channel; Web Push (VAPID/`pywebpush`) as
|
||||||
|
the nicer layer on top; every notification is also an in-app inbox row.
|
||||||
|
**Why:** `apprise` is already in the stack for poller alerts and works on iOS with no PWA-install
|
||||||
|
requirement, so notifications can ship early. Apple's `web.push.apple.com` does speak standard RFC
|
||||||
|
8291 (no Apple Developer account needed), but only for home-screen-installed PWAs, and iOS silently
|
||||||
|
drops subscriptions after OS updates. Push must never be the only path to the information.
|
||||||
|
|
||||||
|
### D13 — Monorepo
|
||||||
|
**Chosen:** one repo for API + web + deploy + workflows.
|
||||||
|
**Why:** one maintainer, and API and client change together constantly. D2 removed the only real
|
||||||
|
argument for splitting (a native app would have needed macOS runners that a Linux act_runner can't
|
||||||
|
provide) — now every artefact builds on the same runner.
|
||||||
|
|
||||||
|
### D14 — Research agent models
|
||||||
|
**Chosen:** Opus for the Bryton protocol research and the architecture design (ambiguous,
|
||||||
|
reverse-engineering, synthesis-heavy); Sonnet for the two breadth surveys (existing self-hosted
|
||||||
|
apps, Gitea CI patterns) where material is well-documented and the work is volume.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deliberately deferred
|
||||||
|
|
||||||
|
- **Routing** (Valhalla/Photon/Overpass) — Phase 5, optional. Several GB of RAM for something
|
||||||
|
Komoot already does well.
|
||||||
|
- **Local LLM ride summaries** (Ollama) — Phase 4, behind a compose profile.
|
||||||
|
- **Friends/family cross-visibility** — Phase 4, as an *additive* widened RLS policy, never as
|
||||||
|
removal of the default scope.
|
||||||
|
- **Legacy Bryton format support** — out of scope entirely. The Rider 650 writes `.fit`.
|
||||||
|
- **Reverse-engineering Bryton's BLE** — explicitly rejected. See RESEARCH.md §1.
|
||||||
+674
@@ -0,0 +1,674 @@
|
|||||||
|
# Self-Hosted Cycling App — Implementation Plan
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
You want a self-hosted Strava replacement that syncs rides from your **Bryton Rider 650**, tracks
|
||||||
|
your cumulative mileage, and adds two things Strava does badly or not at all: a **spare-parts
|
||||||
|
inventory** and a **maintenance record**. It runs on your own Linux server, with source control in
|
||||||
|
self-hosted Gitea and an act_runner on the same box for automated builds.
|
||||||
|
|
||||||
|
The specific pain driving this: today your Rider 650 syncs over Bluetooth to the Bryton Active
|
||||||
|
phone app, which forwards to Strava — but Active has no background sync, so **you have to remember
|
||||||
|
to open the app**. Research turned up a better path that removes the phone from the loop entirely
|
||||||
|
(see "The sync breakthrough" below).
|
||||||
|
|
||||||
|
You also want **mileage-milestone notifications** — "every 200 miles, clean and lube the drivetrain" —
|
||||||
|
which makes the maintenance side push-based rather than something you have to remember to go look at.
|
||||||
|
|
||||||
|
Directory is empty; this is greenfield. Decisions already made:
|
||||||
|
- **Build fresh** (borrow data models from Endurain and strava-gear, don't fork either)
|
||||||
|
- **Multi-user** — you plus friends/family, invite-only
|
||||||
|
- **PWA, not a native iOS app** — installed to the iPhone home screen
|
||||||
|
- **Imperial units by default** — you think in miles; storage stays SI, display is miles
|
||||||
|
- **Phased roadmap** with the self-hosting-only features staged in deliberately
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The sync breakthrough
|
||||||
|
|
||||||
|
Two facts verified during research change the design:
|
||||||
|
|
||||||
|
1. **Your Rider 650 has on-device Wi-Fi.** Its main menu has a `Data Sync` entry where the head unit
|
||||||
|
itself joins a Wi-Fi hotspot and uploads tracks to Bryton's cloud — **no phone, no Active app**.
|
||||||
|
2. **Bryton's cloud API is fully reverse-engineered** and returns the **original, unmodified FIT
|
||||||
|
bytes**. Working MIT reference implementation: `github.com/jorge-huxley/intervalssync`
|
||||||
|
(Python, updated 2026-09-17).
|
||||||
|
|
||||||
|
So the pipeline becomes fully hands-off:
|
||||||
|
|
||||||
|
```
|
||||||
|
ride ends → Rider 650 joins home Wi-Fi → uploads to Bryton cloud
|
||||||
|
→ your server's poller fetches the original FIT → app
|
||||||
|
```
|
||||||
|
|
||||||
|
**First action before writing any code:** on the Rider 650, go to `Main Menu → Data Sync`, join your
|
||||||
|
home Wi-Fi, and confirm a test ride uploads without the phone. If it only syncs on manual menu
|
||||||
|
trigger rather than automatically, the fallback is still good (USB, below) — but verify this, because
|
||||||
|
it determines whether Phase 2 fully solves your complaint.
|
||||||
|
|
||||||
|
**Why not Strava as the source:** Strava's API has no `export_original` endpoint — you get decoded,
|
||||||
|
smoothed streams, never the original file. Its June 2026 tier restructure also caps new apps at 10
|
||||||
|
users and requires the *developer* to hold a paid Strava subscription. Dead end; skip it.
|
||||||
|
|
||||||
|
**Why not Bluetooth direct:** Bryton's BLE sync protocol is not reverse-engineered by anyone —
|
||||||
|
no Gadgetbridge support, no ANT-FS, no published UUIDs. No third-party app, native or otherwise,
|
||||||
|
can pull rides off the head unit. Explicitly out of scope.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
| Layer | Choice | Why |
|
||||||
|
|---|---|---|
|
||||||
|
| API | Python 3.12, FastAPI, Pydantic v2, SQLAlchemy 2.0 async, Alembic | `fitdecode` is Python-only, so Python is forced; FastAPI emits OpenAPI 3.1 free |
|
||||||
|
| DB | PostgreSQL 16 + PostGIS 3.4 (`postgis/postgis:16-3.4`) | Needed for heatmaps, bbox queries, self-segment matching |
|
||||||
|
| Jobs | **procrastinate** (Postgres-backed queue) | Transactional enqueue; no Redis; built-in cron and retries |
|
||||||
|
| Frontend | SvelteKit `adapter-static` **SPA + PWA** — no Node process in prod | Static files served by Caddy; enforces API-first by construction |
|
||||||
|
| Maps | MapLibre GL JS from day one | Renders raster tiles now, self-hosted PMTiles vector later — a config change, not a rewrite |
|
||||||
|
| FIT parsing | `fitdecode` | Thread-safe, preserves header+CRC, correct developer-field handling; `python-fitparse`'s own maintainers point here |
|
||||||
|
| Auth | Argon2id + **opaque bearer tokens, no JWT** | Instant revocation, device list, no key rotation. Statelessness buys nothing at 15 users |
|
||||||
|
| Notifications | `apprise` library (ntfy default) | A library, not another container |
|
||||||
|
|
||||||
|
**Rejected:** Redis (nothing needs it), TimescaleDB (see streams decision), SSR, Celery, native iOS.
|
||||||
|
|
||||||
|
### The PWA decision
|
||||||
|
|
||||||
|
A home-screen-installed PWA gets you the icon, full-screen chrome-free display, offline caching, and
|
||||||
|
— since iOS 16.4 — **real push notifications**, which is the only thing that used to force native.
|
||||||
|
Going native would cost $99/yr for an Apple Developer account, TestFlight or sideloading to get it
|
||||||
|
onto family phones, and a second codebase forever. The two real PWA gaps on iOS (Web Bluetooth,
|
||||||
|
Background Sync) don't matter here: Bryton BLE is a dead end anyway, and the *server* does all syncing.
|
||||||
|
|
||||||
|
Design consequences — the iOS-specific traps, all of which have cheap answers if you know them upfront:
|
||||||
|
|
||||||
|
| Limitation | Design response |
|
||||||
|
|---|---|
|
||||||
|
| **Push requires "Add to Home Screen"** | Silently fails from a plain Safari tab. Treat install as a mandatory onboarding flow with a persistent card (iOS has no `beforeinstallprompt`, so there's no programmatic install). Only offer "Enable reminders" once `display-mode: standalone` is true. |
|
||||||
|
| **A denied notification permission is sticky** | The user must delete and reinstall the PWA to be asked again. So: request only from a direct user gesture, only in standalone, only after an explanatory screen. Never burn the prompt. |
|
||||||
|
| **An installed PWA has its own storage partition, separate from Safari** | The user *will* be logged in in Safari and logged out in the installed app and think it's broken. Document it in onboarding; 90-day cookie; "keep me signed in" on by default. |
|
||||||
|
| No Web NFC | Works anyway with **no app**: an NTAG sticker encoding `https://host/b/<tag_uid>` — iOS background tag reading fires from the lock screen and opens the URL, and the PWA's scope claims it so it opens *in* the app. Server-side that's one route. |
|
||||||
|
| No `BarcodeDetector` | `getUserMedia` → **`zxing-wasm`**, lazy-loaded so the ~300KB WASM isn't in the shell bundle. |
|
||||||
|
| No Background Sync | Irrelevant — all syncing is server-side. Offline writes go to a small IndexedDB outbox flushed on `online`/`visibilitychange`, keyed by the **client-generated UUIDv7 that is already the row's PK**, so replay is idempotent with no server-side idempotency table. |
|
||||||
|
| Storage eviction | Bounded caches with explicit `ExpirationPlugin` limits (unbounded caches are what trigger eviction of the *whole origin*). Never treat client storage as durable; mark unsaved outbox items visibly rather than optimistically pretending they saved. |
|
||||||
|
| Aggressive service-worker termination | The SW does two things only: caching and displaying pushes. Nothing load-bearing. |
|
||||||
|
|
||||||
|
Service worker via `@vite-pwa/sveltekit` in `injectManifest` mode. Caching: precache the hashed shell;
|
||||||
|
cache-first on `index.html` for navigations (so it opens instantly and offline); **NetworkFirst with a
|
||||||
|
3s timeout on `/garage` and `/maintenance/due`** so the garage with no signal still shows "chain: 4,100
|
||||||
|
/ 4,800 mi" — the highest-value offline surface in the app; CacheFirst on tiles; **network-only on all
|
||||||
|
mutations**, never silently cached.
|
||||||
|
|
||||||
|
Keep the backend API-first anyway (versioned `/api/v1`, bearer tokens, committed OpenAPI schema).
|
||||||
|
It costs almost nothing, and if Apple ever makes the PWA route untenable, a native client becomes a
|
||||||
|
code-generation exercise against a stable contract rather than a rewrite.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Service topology
|
||||||
|
|
||||||
|
**v1 — 4 containers, under 2GB RAM total:**
|
||||||
|
- `caddy` — serves the static SPA, proxies `/api/*`. Same-origin, so no CORS. Your existing reverse proxy terminates TLS in front.
|
||||||
|
- `api` — uvicorn/FastAPI. Runs Alembic on entrypoint.
|
||||||
|
- `worker` — same image, `procrastinate worker`. Owns periodic tasks too, so no separate scheduler.
|
||||||
|
- `db` — postgis/postgis:16-3.4.
|
||||||
|
|
||||||
|
Volumes: `pgdata`, `blobstore` (content-addressed raw FIT + attachments), `import_inbox` (USB watch folder bind-mount).
|
||||||
|
|
||||||
|
**Phase 2 adds zero containers** (poller is a periodic task; Open-Meteo is a public API).
|
||||||
|
**Phase 3 adds two:** `tileserver` (tileserver-gl-light + regional PMTiles, ~200MB–1GB) and `topodata` (Open Topo Data + region-clipped SRTM).
|
||||||
|
**Phase 4+ behind opt-in compose profiles:** `ollama`, `grafana`, and optionally `valhalla`/`photon`/`overpass`.
|
||||||
|
|
||||||
|
**Explicitly NOT on day one:** Valhalla, Photon, Overpass, Nominatim (>1TB/128GB RAM — never), Ollama, Redis, MinIO, Grafana, a separate scheduler.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schema
|
||||||
|
|
||||||
|
Conventions: UUIDv7 PKs (time-ordered, URL-safe, client-generatable). All `timestamptz` UTC. **All
|
||||||
|
physical quantities as SI integers** — distance in metres, time in seconds, speed in mm/s, altitude
|
||||||
|
in cm, money in minor units. Units are a presentation concern.
|
||||||
|
|
||||||
|
### Two architectural rules that everything else depends on
|
||||||
|
|
||||||
|
**Rule 1 — raw bytes are the only truth.** Every ingested file is written to a content-addressed blob
|
||||||
|
store *before* parsing and is never mutated or deleted (`ON DELETE RESTRICT`). Every table below is a
|
||||||
|
**rebuildable projection**: delete the projection, re-parse, and you must get the same result. This is
|
||||||
|
what turns "parser bug corrupted 4,000 elevations" and "retroactively reprocess all history against a
|
||||||
|
better DEM" from disasters/migrations into routine batch jobs. It is the single most important rule here.
|
||||||
|
|
||||||
|
**Rule 2 — there is no odometer column anywhere.** Component wear is *derived* by replaying the
|
||||||
|
activity stream against time-ranged install records (the strava-gear insight, made relational).
|
||||||
|
Correcting "I actually swapped that chain a week earlier" is one `UPDATE`, and every downstream number
|
||||||
|
self-corrects. A stored counter cannot do that, nor handle parts moving between bikes, without a
|
||||||
|
reconciliation nightmare.
|
||||||
|
|
||||||
|
### Tables
|
||||||
|
|
||||||
|
**Identity:** `users` (carries `timezone` and `unit_system`, **defaulting to imperial** for you — it
|
||||||
|
drives display only, never storage), `invites` (only `sha256(code)` stored), `sessions` (opaque token
|
||||||
|
hashes), `api_tokens` (scoped, for Home Assistant/Grafana).
|
||||||
|
|
||||||
|
**Raw storage:** `raw_files` — `content_sha256`, `storage_path` (`blobstore/ab/cd/<sha>.fit`),
|
||||||
|
`source` (`upload|usb|bryton_cloud|gpx_import`), `source_ref`, `parse_state`
|
||||||
|
(`pending|parsed|failed|quarantined|not_an_activity`), `parser_version`, `fit_type`.
|
||||||
|
`UNIQUE (user_id, content_sha256)`.
|
||||||
|
|
||||||
|
**Activities:** `activities` (+ `activity_laps`, `activity_stats`). Notable columns:
|
||||||
|
- `ascent_device_m` **and** `ascent_dem_m` kept separately with an `elevation_source` flag, so Phase 3
|
||||||
|
DEM reprocessing never destroys the barometric original.
|
||||||
|
- `track geometry(LineStringZM, 4326)` full-res, plus `track_simplified` (~10m) for list maps, plus a
|
||||||
|
generated `bbox`. GIST index on the simplified track.
|
||||||
|
- `wet_fraction` (denormalised from weather, so the wear query needs no join), `is_indoor`,
|
||||||
|
`counts_for_wear` (user override).
|
||||||
|
- `fit_time_created` + `device_serial` → partial unique index. This is the natural dedupe key.
|
||||||
|
|
||||||
|
**Streams — columnar arrays, one row per channel** (`activity_streams`: `channel`, `n`, `scale`,
|
||||||
|
`values_i32[]`). Decision and justification:
|
||||||
|
- **Size.** A 3h ride at 1Hz × ~9 channels: normalized per-sample rows ≈ 1.2MB + 0.3MB index;
|
||||||
|
scaled-int arrays ≈ 150–250KB after TOAST/LZ4, zero index cost. ~5× multiplier, compounding forever
|
||||||
|
under a full-retention requirement.
|
||||||
|
- **Read pattern.** Every real query is "give me the whole stream to draw a chart" — one TOAST fetch,
|
||||||
|
and it maps 1:1 onto the JSON the client wants with no row-to-column pivot.
|
||||||
|
- **Lat/lon are free.** FIT stores position as int32 semicircles; `values_i32` holds them losslessly.
|
||||||
|
- **Not JSONB** (untyped, 3–5× larger, slow to deserialise). **Not TimescaleDB** — it solves
|
||||||
|
cross-entity scans over a firehose; we do per-entity blob reads. Adopting it means abandoning the
|
||||||
|
`postgis/postgis` base image and taking on extension-version coupling at every Postgres upgrade.
|
||||||
|
- Aggregates (HR zone totals, power curve) are **precomputed at ingest** into `activity_stats`, never
|
||||||
|
scanned from streams.
|
||||||
|
|
||||||
|
**Bikes and parts** — the part of the schema most people get wrong:
|
||||||
|
- `bikes` — includes `initial_distance_m` (km ridden before this system existed) and `nfc_tag_uid`.
|
||||||
|
- `component_models` — shared catalogue (kind, manufacturer, model, `spec jsonb`, `gtin` barcode).
|
||||||
|
- `components` — a **tracked individual physical object** with identity and history. Has
|
||||||
|
`purchase_cost_minor`, `initial_distance_m`, `inventory_item_id` provenance.
|
||||||
|
- `component_installs` — **time-ranged association**, mounted to a bike XOR a parent component
|
||||||
|
(cassette → wheelset → bike). A GIST `EXCLUDE` constraint enforces that a component can only be in
|
||||||
|
one place at a time. Moving a wheelset between bikes is two rows.
|
||||||
|
- `inventory_items` — **fungible shelf stock** with a `quantity`, `min_quantity` low-stock threshold,
|
||||||
|
`location`, `gtin`. Plus `inventory_transactions`, an append-only ledger whose running sum *is* the
|
||||||
|
quantity.
|
||||||
|
|
||||||
|
**Why inventory and components are separate tables:** a spare chain on the shelf has no identity worth
|
||||||
|
tracking — you own "3 × Shimano CN-M8100", not three named chains. A *fitted* chain has identity,
|
||||||
|
install history, accrued km, and a cost-per-km. "Install from inventory" is the state transition:
|
||||||
|
decrement quantity, create a `components` row carrying the cost and a provenance link, create an
|
||||||
|
install row. Modelling stock as components-without-installs forces fake identities onto consumables
|
||||||
|
(sealant, cables, bar tape) and makes "how many chains do I have left?" a COUNT over a table that also
|
||||||
|
contains every chain you retired since 2019.
|
||||||
|
|
||||||
|
**Maintenance:** `service_rules` (scoped to a component XOR bike XOR kind), `service_events`,
|
||||||
|
`service_event_components`, `attachments` (receipts, photos, manuals — polymorphic on entity_type/id).
|
||||||
|
|
||||||
|
**Notifications:** `push_subscriptions`, `notification_log`, `notification_prefs`,
|
||||||
|
`odometer_milestones` — defined in the milestones section below.
|
||||||
|
|
||||||
|
**Integrations:** `integration_credentials` (AES-GCM encrypted, key from the compose `.env`),
|
||||||
|
`integration_health` (consecutive failures + error taxonomy, drives alerting).
|
||||||
|
|
||||||
|
**Weather:** `weather_observations` keyed on a **0.05° grid cell + UTC hour**, so nearby rides reuse
|
||||||
|
cached data, plus a per-activity `activity_weather` rollup.
|
||||||
|
|
||||||
|
### The wear engine
|
||||||
|
|
||||||
|
`service_rules` carries `metric` (`distance | ride_time | calendar`), `threshold`, `basis`
|
||||||
|
(`since_install | since_last_service`), `wet_multiplier`, and `include_indoor`. Three SQL layers:
|
||||||
|
|
||||||
|
1. `v_component_activity` — joins installs to activities on the time range.
|
||||||
|
2. `component_usage(component, since, wet_mult, include_indoor)` — sums
|
||||||
|
`distance_m * (1 + (wet_mult - 1) * wet_fraction)`, so a fully-wet ride on rim pads
|
||||||
|
(`wet_multiplier = 4.0`) counts 4×, a dry ride 1×, a half-wet ride 2.5×. Raw distance is retained
|
||||||
|
separately so the UI can show *"480 km ridden / 1,150 km effective wear"*.
|
||||||
|
3. `v_component_due` — percentage used, remaining, and a **projected due date** from your trailing
|
||||||
|
90-day rate.
|
||||||
|
|
||||||
|
One view answers every maintenance question in the product:
|
||||||
|
- chain @ 4,800km — `distance`, `since_install`, wet 2.0
|
||||||
|
- chain wax @ 350km — `distance`, `since_last_service`, wet 3.0
|
||||||
|
- fork lowers @ 50 ride **hours** — `ride_time`, `since_last_service`, `include_indoor=false`
|
||||||
|
- sealant @ 90 **days** — `calendar`, `since_last_service`
|
||||||
|
- BB @ 6 months **OR** 4,800km — two rules on one component; whichever hits 100% first wins the badge
|
||||||
|
|
||||||
|
`component_usage_cache` is refreshed by a debounced job after ingest, install edits, and service events.
|
||||||
|
The view stays authoritative; the dashboard reads the cache.
|
||||||
|
|
||||||
|
**Cost-per-km falls straight out** of `purchase_cost_minor / raw_distance_m` — a genuinely
|
||||||
|
differentiating number no cloud service gives you, for zero incremental work once these tables exist.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mileage milestones and notifications
|
||||||
|
|
||||||
|
This is the feature that makes the maintenance side push-based instead of something you have to
|
||||||
|
remember to go and check. Two distinct kinds of milestone, sharing one delivery system.
|
||||||
|
|
||||||
|
### Kind 1 — recurring maintenance intervals
|
||||||
|
|
||||||
|
"Every 200 miles, clean and lube the drivetrain" is already expressible: a `service_rule` with
|
||||||
|
`metric='distance'`, `threshold=321869` (200 mi in metres), `basis='since_last_service'`. The
|
||||||
|
`since_last_service` basis is what makes it **recurring** — log the service, the basis moves forward,
|
||||||
|
and the counter re-arms automatically. No separate "repeating rule" concept is needed.
|
||||||
|
|
||||||
|
**Seed catalogue, shipped as defaults** so the app is useful the moment you add a bike, with every
|
||||||
|
rule editable and dismissible. Intervals below are the consensus from mainstream cycling maintenance
|
||||||
|
guides; stored in metres/seconds/days, displayed in miles.
|
||||||
|
|
||||||
|
*Recurring tasks (`basis='since_last_service'`):*
|
||||||
|
|
||||||
|
| Task | Interval | Metric | Wet × | Notes |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| **Clean & lube drivetrain** | **200 mi** | distance | 2.5 | your example; the headline default |
|
||||||
|
| Wipe & re-lube chain (dry lube) | 150 mi | distance | 3.0 | dusty conditions shorten this |
|
||||||
|
| Wipe & re-lube chain (wet lube) | 250 mi | distance | 3.0 | |
|
||||||
|
| Re-wax chain (if waxing) | 200 mi | distance | 4.0 | |
|
||||||
|
| Measure chain wear with a gauge | 500 mi | distance | 1.0 | replace at 0.5% for 11/12-speed |
|
||||||
|
| Inspect brake pads | 500 mi | distance | 2.0 | discs: replace under 1.5mm |
|
||||||
|
| Check BB & headset for play | 500 mi | distance | 1.0 | |
|
||||||
|
| Check tyre pressure | 7 days | calendar | — | |
|
||||||
|
| Inspect / top up tubeless sealant | 90 days | calendar | 1.0 | it evaporates |
|
||||||
|
| Inspect cables & housing | 1,000 mi | distance | 1.5 | |
|
||||||
|
| Bolt torque check | 90 days | calendar | — | |
|
||||||
|
| Service hub/BB/headset bearings | 2,000 mi **or** 180 days | two rules | 2.0 | whichever first |
|
||||||
|
| Replace cables & housing | 2,500 mi | distance | 1.5 | |
|
||||||
|
| Full annual service | 365 days | calendar | — | |
|
||||||
|
| Fork lowers service | 50 ride hours | ride_time | 1.0 | MTB; `include_indoor=false` |
|
||||||
|
| Fork/shock full service | 150 ride hours | ride_time | 1.0 | |
|
||||||
|
|
||||||
|
*Replacement rules (`basis='since_install'`, `is_replacement=true`, retires the component):*
|
||||||
|
|
||||||
|
| Part | Interval | Wet × |
|
||||||
|
|---|---|---|
|
||||||
|
| Chain | 2,000 mi | 2.0 |
|
||||||
|
| Cassette | 6,000 mi | 2.0 |
|
||||||
|
| Chainrings | 15,000 mi | 2.0 |
|
||||||
|
| Rear tyre | 2,500 mi | 1.5 |
|
||||||
|
| Front tyre | 5,000 mi | 1.5 |
|
||||||
|
| Disc brake pads | 1,200 mi | 4.0 |
|
||||||
|
| Rim brake pads | 1,500 mi | 4.0 |
|
||||||
|
| Bar tape | 365 days | — |
|
||||||
|
|
||||||
|
Note the rear tyre wearing 2–3× faster than the front is why `tyre_front` and `tyre_rear` are separate
|
||||||
|
`kind` values rather than one "tyre" kind with a shared interval.
|
||||||
|
|
||||||
|
### Kind 2 — odometer achievement milestones
|
||||||
|
|
||||||
|
The other reading of "mileage milestones": *"the Ribble just passed 5,000 miles"*, *"you've done 1,000
|
||||||
|
miles this year."* Cheap to add and satisfying, which is the whole point of a mileage tracker.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE odometer_milestones (
|
||||||
|
id uuid PRIMARY KEY, user_id uuid NOT NULL,
|
||||||
|
scope text NOT NULL, -- 'user' | 'bike' | 'component'
|
||||||
|
scope_id uuid,
|
||||||
|
period text NOT NULL, -- 'lifetime' | 'year' | 'month'
|
||||||
|
period_key text, -- '2026' for yearly
|
||||||
|
threshold_m bigint NOT NULL,
|
||||||
|
reached_at timestamptz NOT NULL,
|
||||||
|
activity_id uuid REFERENCES activities(id), -- the ride that crossed it
|
||||||
|
UNIQUE (user_id, scope, scope_id, period, period_key, threshold_m)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Ladders (all configurable): bikes every 500 mi lifetime; you every 1,000 mi lifetime; round numbers
|
||||||
|
100/250/500/1,000/2,500/5,000/10,000; annual goal progress at 25/50/75/100%. The `UNIQUE` constraint
|
||||||
|
means a milestone fires exactly once, ever — and the `activity_id` link lets the notification say
|
||||||
|
*"your 5,000th mile on the Ribble was on this morning's ride."*
|
||||||
|
|
||||||
|
### Delivery
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE push_subscriptions (
|
||||||
|
id uuid PRIMARY KEY, user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
endpoint text NOT NULL UNIQUE, -- e.g. https://web.push.apple.com/...
|
||||||
|
p256dh text NOT NULL, auth text NOT NULL,
|
||||||
|
user_agent text, is_standalone boolean,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
last_success_at timestamptz, failure_count int NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE notification_log ( -- idempotency AND the in-app inbox
|
||||||
|
id uuid PRIMARY KEY, user_id uuid NOT NULL,
|
||||||
|
kind text NOT NULL, -- 'service_due'|'milestone'|'low_stock'|'sync_failed'|'weekly_summary'
|
||||||
|
dedupe_key text NOT NULL,
|
||||||
|
title text, body text, url text,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
pushed_at timestamptz, read_at timestamptz,
|
||||||
|
UNIQUE (user_id, dedupe_key)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE notification_prefs (
|
||||||
|
user_id uuid PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
service_warn boolean DEFAULT true, -- fire at warn_at_pct (80%)
|
||||||
|
service_due boolean DEFAULT true, -- fire at 100%
|
||||||
|
milestones boolean DEFAULT true,
|
||||||
|
low_stock boolean DEFAULT true,
|
||||||
|
sync_health boolean DEFAULT true,
|
||||||
|
digest_mode text DEFAULT 'immediate', -- 'immediate' | 'daily' | 'weekly'
|
||||||
|
digest_hour smallint DEFAULT 18,
|
||||||
|
quiet_hours_start time, quiet_hours_end time -- interpreted in users.timezone
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**The dedupe key is what stops this becoming spam**, and it's the one part that's easy to get wrong.
|
||||||
|
Key format: `service_due:<rule_id>:<component_id>:<cycle_seq>:<threshold>`, where `cycle_seq` is the
|
||||||
|
count of service events logged against that (component, rule) so far. Consequences:
|
||||||
|
- Within one cycle, each threshold fires **exactly once** — one nudge at 80%, one at 100%, then silence.
|
||||||
|
It does not re-notify nightly about the same chain.
|
||||||
|
- Logging the service increments `cycle_seq`, so the *next* 200-mile crossing is a new key and fires
|
||||||
|
again. That's how "every 200 miles" repeats forever without a cron-style recurrence engine.
|
||||||
|
- Milestones use `milestone:<scope>:<scope_id>:<period_key>:<threshold_m>` and are naturally once-ever.
|
||||||
|
|
||||||
|
**Evaluation job** `evaluate_notifications` runs (a) after every ingest, so a milestone or a newly-due
|
||||||
|
service arrives within minutes of the ride landing, and (b) nightly, to catch calendar-based rules that
|
||||||
|
no ride triggers. It reads `component_usage_cache` against `v_component_due`, inserts `notification_log`
|
||||||
|
rows, and enqueues sends on the `notify` queue. Quiet hours defer rather than drop.
|
||||||
|
|
||||||
|
**Channels, in order of reliability:**
|
||||||
|
1. **ntfy via `apprise`** — the primary. `apprise` is already in the stack for poller alerts, works on
|
||||||
|
iOS through the ntfy app with no PWA-install requirement, and is the most robust option. Roughly an
|
||||||
|
hour of work, which is why notifications can ship in Phase 1 rather than waiting for the PWA plumbing.
|
||||||
|
2. **Web Push (VAPID)** — the nicer experience. Apple's `web.push.apple.com` endpoint speaks standard
|
||||||
|
RFC 8291, so `pywebpush` reaches an iPhone with **no Apple Developer account and no APNs
|
||||||
|
certificate** — the only requirement is that the PWA is installed to the home screen. `410 Gone`/`404`
|
||||||
|
prunes the subscription row; `429` backs off. `navigator.setAppBadge(n)` puts a count of outstanding
|
||||||
|
due items on the home-screen icon for free.
|
||||||
|
3. **Email** via apprise, for weekly digests.
|
||||||
|
|
||||||
|
**Push is never the only path.** Every notification is a `notification_log` row rendered as an in-app
|
||||||
|
inbox, so a dead push channel degrades the experience without breaking the feature. That matters
|
||||||
|
because iOS silently drops push subscriptions after OS updates and long idle periods.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ingestion pipeline
|
||||||
|
|
||||||
|
**One canonical path.** Every source funnels through
|
||||||
|
`ingest_bytes(user_id, data, source, source_ref) -> RawFile` before any parsing. One parser, one
|
||||||
|
dedupe implementation, one set of side effects.
|
||||||
|
|
||||||
|
```
|
||||||
|
manual upload ─┐
|
||||||
|
USB watcher ─┼→ ingest_bytes() → raw_files row + blob (same txn) → enqueue parse_raw_file
|
||||||
|
Bryton poller ─┘ │
|
||||||
|
▼
|
||||||
|
fitdecode → discriminate → rebuild projection in ONE txn
|
||||||
|
→ enqueue compute_stats, enrich_weather, recompute_wear
|
||||||
|
```
|
||||||
|
|
||||||
|
Idempotent by construction: `INSERT ... ON CONFLICT (user_id, content_sha256) DO NOTHING RETURNING id`.
|
||||||
|
Nothing returned ⇒ already have it ⇒ enqueue nothing. Uploading the same file 100 times costs 100
|
||||||
|
hashes and zero rows.
|
||||||
|
|
||||||
|
**Dedupe, three layers in order:**
|
||||||
|
1. **Content hash** — catches re-uploads and USB rescans. **Filename is never consulted** (Bryton reuses names).
|
||||||
|
2. **FIT natural key** (`device_serial` + `file_id.time_created`) — catches the same ride arriving via
|
||||||
|
USB *and* the cloud where the bytes differ. This is what makes dual-source operation safe.
|
||||||
|
3. **Temporal overlap heuristic** — same user, start within 90s, duration within 5%, distance within 2%
|
||||||
|
⇒ flag `duplicate_of_id`, keep both, offer a UI action. **Never auto-delete, only auto-hide.**
|
||||||
|
|
||||||
|
**Activity vs. course discrimination — a real bug waiting to happen.** Bryton writes routes as `.fit`
|
||||||
|
too. Check `file_id.type` (4 = activity, 6 = course, 32 = monitoring). Bryton's encoder is not
|
||||||
|
Garmin's, so if the type is absent or nonstandard, fall back to message-shape inspection: ≥1 `session`
|
||||||
|
**and** ≥1 timestamped `record` ⇒ activity; `course`/`course_point` messages or positions without
|
||||||
|
timestamps ⇒ course. Anything unclassifiable ⇒ `quarantined`, blob retained, one alert, surfaced in an
|
||||||
|
admin list. **Never silently drop.**
|
||||||
|
|
||||||
|
Other Bryton hardening: map nonstandard manufacturer/product IDs via serial prefix and store the raw
|
||||||
|
values; store laps verbatim but **never derive session totals by summing laps** (use the `session`
|
||||||
|
message); compute `moving_time` from records with speed > 0.5 m/s if absent.
|
||||||
|
|
||||||
|
**Sources** implement a common `ActivitySource` protocol:
|
||||||
|
- **Upload** — `POST /api/v1/uploads`, multipart, 50MB cap, accepts `.fit`, `.fit.gz`, `.gpx`, `.tcx`.
|
||||||
|
- **USB watcher** — 60s periodic scan of `/import/inbox/**/*.fit`. A host udev rule on volume label
|
||||||
|
`Bryton` mounts the device **read-only** and rsyncs into the inbox. **Discover the subfolder at
|
||||||
|
runtime by recursive glob** — sources disagree on whether it's `Activities/`, `Actives/`, or root, so
|
||||||
|
don't hardcode it (your 650 is documented as `Bryton/Activities/`, but verify).
|
||||||
|
- **Bryton cloud (Phase 2)** — Meteor DDP over SockJS to `m3.brytonactive.com`: `login` with the
|
||||||
|
SHA-256 digest → `subscribe("activityList")` → read `userActivities` (**filter `_deleted`
|
||||||
|
tombstones**) → diff against `raw_files.source_ref` → `GET /api/activity?id=<id>` with `X-User-Id`,
|
||||||
|
`X-Auth-Token`, `x-api-key`, `User-Agent: okhttp/4.12.0` → raw original FIT bytes. Poll every 20 min,
|
||||||
|
jittered. **Vendor** the `intervalssync` protocol logic with the upstream commit SHA in a header
|
||||||
|
comment rather than taking a runtime dependency on a reverse-engineering project.
|
||||||
|
|
||||||
|
**Credential warning:** the Bryton SHA-256 digest **is** the credential — it replays as a password.
|
||||||
|
Store it AES-GCM encrypted with a key from the compose `.env` (not in the DB), never return it from any
|
||||||
|
endpoint (the Pydantic response model simply doesn't contain the field), and redact it in logs. Say so
|
||||||
|
plainly in the setup UI.
|
||||||
|
|
||||||
|
**Job runner — procrastinate**, chosen over Celery (needs Redis, poor async, Postgres broker is
|
||||||
|
second-class), arq (Redis-only — a whole container for ~50 tasks/day), and APScheduler (a scheduler,
|
||||||
|
not a durable queue — no retries, no dead-lettering). The decisive property is **transactional
|
||||||
|
enqueue**: the `raw_files` INSERT and the parse-job enqueue commit atomically on one connection. No
|
||||||
|
orphaned blobs, no jobs referencing rolled-back rows. Impossible with a Redis broker without inventing
|
||||||
|
an outbox. Queues: `ingest` (2), `enrich` (4), `maintenance` (1).
|
||||||
|
|
||||||
|
**Poller failure alerting** — `integration_health` tracks `consecutive_failures` and an error taxonomy
|
||||||
|
(`auth|protocol|network|ratelimit`). Via apprise/ntfy, max once per 24h: 3 consecutive failures; no
|
||||||
|
success in 48h while enabled; **`auth` or `protocol` errors alert immediately on the first failure** —
|
||||||
|
`protocol` is the API-changed-under-us signal. A **nightly canary** fetches the activity list only and
|
||||||
|
asserts it parses, so breakage surfaces on rest days rather than three weeks later. Persistent UI
|
||||||
|
banner while unhealthy; `/api/v1/health/integrations` feeds Home Assistant in Phase 4.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Auth
|
||||||
|
|
||||||
|
**Opaque bearer tokens against a `sessions` table. No JWT.** At your scale, verification is one indexed
|
||||||
|
PK lookup (~0.1ms), and you get instant revocation, an "active devices" list, and no key-rotation or
|
||||||
|
clock-skew bugs. JWT's only advantage is stateless horizontal scale, which will never arrive here.
|
||||||
|
|
||||||
|
One token, two transports, one verification path: web gets `Set-Cookie: HttpOnly; Secure; SameSite=Lax`,
|
||||||
|
any future non-browser client gets `Authorization: Bearer`. A single FastAPI dependency reads bearer
|
||||||
|
first, then cookie, then sets `app.user_id` for RLS. **The web client gets no capability another client
|
||||||
|
lacks** — the cookie is transport convenience only.
|
||||||
|
|
||||||
|
- **CSRF:** cookie-authenticated mutations require `Origin` to match the configured public URL; bearer
|
||||||
|
requests skip it (attackers can't set that header). SameSite=Lax as defence in depth.
|
||||||
|
- **Isolation, enforced twice:** a repository layer where every query starts from a `scoped(User)` base,
|
||||||
|
**and Postgres RLS enabled from day one** with `SET LOCAL app.user_id` per request transaction.
|
||||||
|
Migrations run as a `BYPASSRLS` owner; the app connects as a non-owner. RLS is cheap now and means
|
||||||
|
re-auditing every query later. Friends/family visibility in Phase 2 is an *additive* widened policy,
|
||||||
|
never removal of the default scope.
|
||||||
|
- **Invites:** open signup does not exist as a setting. Admin generates a code; only `sha256(code)` is
|
||||||
|
stored; registration validates and increments `used_count` in the same transaction with
|
||||||
|
`SELECT ... FOR UPDATE` so a shared link can't be used twice concurrently. First user is bootstrapped
|
||||||
|
by CLI (`docker compose run api velodrome create-admin`), not a web setup wizard a scanner could race.
|
||||||
|
- Argon2id `t=3, m=64MiB, p=4`; login rate-limited per-IP and per-account in Postgres.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Unique self-hosting features (staged by value/effort)
|
||||||
|
|
||||||
|
These are the payoff for self-hosting — things Strava structurally cannot do.
|
||||||
|
|
||||||
|
**Free, because they're schema properties:**
|
||||||
|
- **No privacy zones, ever.** No third party holds your data, so show real door-to-door routes.
|
||||||
|
- **Unlimited full-resolution retention**, forever, of the original files.
|
||||||
|
- **Cost-per-km on every component**, and per-kind averages ("my chains cost £0.019/km").
|
||||||
|
- **Receipts and photos attached** to parts, service events, and bikes.
|
||||||
|
- **Wet-weighted wear** — rim pads genuinely wear ~4× faster in the rain, and you have the weather data.
|
||||||
|
|
||||||
|
**Cheap and high value:**
|
||||||
|
- **Mileage-milestone and maintenance pushes** straight to your phone — the reason the garage data is
|
||||||
|
worth keeping. Strava's gear tracking can't do interval reminders at all.
|
||||||
|
- **Grafana pointed straight at Postgres** — roughly an afternoon, the cheapest analytics in the plan.
|
||||||
|
- **Barcode-scan parts** into inventory at purchase/install time (`zxing-wasm`).
|
||||||
|
- **Low-stock alerts** — "you're down to your last chain and the fitted one is at 87%".
|
||||||
|
- **Bulk-import your entire GPX archive** regardless of file count — no API quotas.
|
||||||
|
|
||||||
|
**Phase 3–4, genuinely differentiated:**
|
||||||
|
- **Retroactive elevation reprocessing** of every historical ride against a better DEM — the direct
|
||||||
|
payoff for the immutable-bytes rule.
|
||||||
|
- **Personal segment matching** against your own ride archive — your own PRs, no third-party segment
|
||||||
|
database, nothing made public.
|
||||||
|
- **Overnight batch compute** on idle server time: heatmap tiles, segment PRs, power curves.
|
||||||
|
- **Home Assistant entities** — "km until chain due", "days since last ride", "spare chains in stock".
|
||||||
|
- **NFC tag per bike** — tap the frame, its maintenance page opens (iOS Shortcuts, no app needed).
|
||||||
|
- **Local LLM ride summaries** via Ollama, behind a compose profile. Nothing leaves the house.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
Estimates assume one developer working evenings and weekends.
|
||||||
|
|
||||||
|
**Phase 0 — Scaffolding (2 weeks).** Monorepo, `uv`/`ruff`/`mypy --strict`, FastAPI skeleton with
|
||||||
|
`/healthz` and OpenAPI, Alembic baseline (users/invites/sessions **with RLS policies from the first
|
||||||
|
migration**), SvelteKit static SPA shell with login, manifest + service worker + precache passing
|
||||||
|
Lighthouse installability, VAPID keypair, Caddy, compose, Gitea Actions green, image in the registry,
|
||||||
|
deployed.
|
||||||
|
*Done when:* you log in at the real URL, add it to your iPhone home screen, it launches standalone —
|
||||||
|
and a push to `main` rebuilds and redeploys it.
|
||||||
|
|
||||||
|
**Phase 1 — Zero-touch ride history (6–8 weeks).** Ingestion core (all three dedupe layers, course
|
||||||
|
discrimination, quarantine); **the Bryton cloud poller as the primary path**, polling every 15 min with
|
||||||
|
the full `integration_health` alerting stack; USB watcher for historical backfill and as the break-glass
|
||||||
|
path; manual upload; activity list/detail with MapLibre and stream charts; totals and trends by
|
||||||
|
week/month/year and per bike, in miles; bikes CRUD; **odometer milestone notifications** (they only need
|
||||||
|
activities, so they ship here); invites; nightly `pg_dump -Fc` + restic.
|
||||||
|
*Done when:* you finish a ride, tap Data Sync on the 650, put the bike away, and it's on your phone
|
||||||
|
within 15 minutes with **zero further interaction** — and you stop opening Strava to look at your own
|
||||||
|
data. **This phase fixes your original complaint; protect it from scope creep.**
|
||||||
|
|
||||||
|
**Phase 2 — The garage (5–7 weeks).** Components and time-ranged installs; inventory with the stock
|
||||||
|
ledger and install-from-stock; service events with photo/receipt attachments; **the seeded service-rule
|
||||||
|
catalogue** across all three metrics with due/warn badges and projected-due dates;
|
||||||
|
`component_usage_cache` and its recompute job; **maintenance notifications end-to-end** (ntfy first,
|
||||||
|
Web Push second, in-app inbox always) with the cycle-seq dedupe; low-stock alerts; Open-Meteo weather
|
||||||
|
enrichment; wet-ride weighting switched on.
|
||||||
|
*Done when:* you get a push saying *"Drivetrain clean & lube due on the Ribble — 205 miles since last
|
||||||
|
time. You have 2 chains on shelf B,"* and can log the job from the garage floor.
|
||||||
|
|
||||||
|
**Phase 3 — Own the map + cheap differentiators (4–6 weeks).** Regional PMTiles + tileserver-gl, Open
|
||||||
|
Topo Data with retroactive re-elevation of the whole archive, overnight heatmap generation, personal
|
||||||
|
segment matching, cost-per-km dashboards, barcode scanning, NFC deep-link routes, nested installs
|
||||||
|
(wheelsets).
|
||||||
|
*Done when:* no third-party network request is needed in normal use.
|
||||||
|
|
||||||
|
**Phase 4 — Home and household (3–5 weeks).** Offline write outbox, friends/family visibility and a
|
||||||
|
household feed, Home Assistant entities via scoped API tokens, Grafana on the `obs` profile, Ollama ride
|
||||||
|
summaries on the `ai` profile, weekly summary push.
|
||||||
|
*Done when:* a display in the hallway shows the next thing that needs doing to a bike.
|
||||||
|
|
||||||
|
**Phase 5 — Routing (optional).** Valhalla, Photon, Overpass surface tags, route planning with `.fit`
|
||||||
|
course export back to the Rider 650. Several GB of RAM for something Komoot already does well — build
|
||||||
|
it only if Phase 4 leaves appetite.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Repo layout and Gitea CI/CD
|
||||||
|
|
||||||
|
**Monorepo** — one maintainer, and API and client change together constantly.
|
||||||
|
|
||||||
|
```
|
||||||
|
apps/api/ pyproject.toml, velodrome/{api,models,ingest,sources,jobs,wear}/, alembic/, tests/
|
||||||
|
apps/web/ SvelteKit PWA, src/lib/api/ (generated types)
|
||||||
|
packages/openapi/ openapi.json ← COMMITTED; the contract artefact
|
||||||
|
deploy/ docker-compose.yml, .prod.yml, Caddyfile, .env.example,
|
||||||
|
systemd/velodrome-backup.{service,timer}, scripts/restore-drill.sh
|
||||||
|
.gitea/workflows/ ci.yml release.yml deploy.yml renovate.yml nightly.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
**`ci.yml`** — push/PR with `paths:` filters so a web change doesn't run pytest.
|
||||||
|
`actions/cache@v4` works (act_runner has a built-in cache server). API job runs lint/mypy/pytest against
|
||||||
|
a **`postgis/postgis:16-3.4` service container** (service containers work in Docker mode) with real
|
||||||
|
Alembic migrations and **a corpus of ~20 real Rider 650 `.fit` files as golden parser fixtures**. An
|
||||||
|
`openapi-drift` job regenerates the schema and `git diff --exit-code`s it. A `migrations` job asserts
|
||||||
|
`upgrade → downgrade -1 → upgrade` succeeds and `alembic check` finds no model drift.
|
||||||
|
|
||||||
|
**`release.yml`** — on `v*` tags, buildx multi-stage, push to the Gitea registry.
|
||||||
|
**Authenticate with a PAT secret (`package:write` scope) — `secrets.GITEA_TOKEN` *cannot* push to the
|
||||||
|
Gitea container registry.** This is a documented Gitea limitation and will waste an hour if forgotten.
|
||||||
|
|
||||||
|
**`deploy.yml`** — `workflow_dispatch` + on release. Runs on a `[self-hosted, host]`-labelled runner in
|
||||||
|
host mode so it can reach the Docker socket: `compose pull` → `run --rm api alembic upgrade head` →
|
||||||
|
`compose up -d` → `curl -f /healthz`. Migrations run as an explicit step **before** `up -d`, never from
|
||||||
|
the container entrypoint, so failures fail the deploy visibly. Note **`jobs.*.environment` is silently
|
||||||
|
ignored by Gitea** — there are no environment protection rules, so the gate is manual dispatch plus a
|
||||||
|
namespaced `PROD_*` secret.
|
||||||
|
|
||||||
|
**`renovate.yml`** — self-hosted Renovate (Dependabot is GitHub-only), weekly cron **plus
|
||||||
|
`workflow_dispatch`**, because Gitea's cron scheduler has shipped flaky releases and you need a manual
|
||||||
|
trigger. Exclude `fitdecode` and anything Bryton-adjacent from auto-merge.
|
||||||
|
|
||||||
|
**`nightly.yml`** — Bryton canary, projection-integrity check, `restic check --read-data-subset=5%`,
|
||||||
|
heatmap/segment recompute.
|
||||||
|
|
||||||
|
**Security note:** the runner holds the Docker socket, which is root-equivalent on the host. Acceptable
|
||||||
|
for a private single-maintainer instance — but never make this repo public or add untrusted
|
||||||
|
collaborators without disabling Actions on fork PRs.
|
||||||
|
|
||||||
|
**Backups are a systemd timer on the host, not a Gitea Action** — `pg_dump -Fc` + restic to B2/S3 with
|
||||||
|
**append-only repo credentials** (so a compromised app host can't delete history), plus a restic
|
||||||
|
snapshot of `blobstore/`. Backups must not depend on CI, because CI is the thing most likely to be
|
||||||
|
broken when you need a restore. Quarterly `restore-drill.sh` restores into a throwaway stack and
|
||||||
|
asserts activity counts match.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Top risks
|
||||||
|
|
||||||
|
**1. The Bryton private API breaks silently.** Hardcoded API key, undocumented protocol, zero
|
||||||
|
stability guarantee — and the failure mode is *silence*. Mitigations: the poller is one
|
||||||
|
`ActivitySource` among several, and the USB path ships first in Phase 1 so the system is never
|
||||||
|
*dependent* on it; nightly canary; immediate alerting on `auth`/`protocol` errors; vendored protocol
|
||||||
|
pinned to an upstream SHA so fixes are a diff, not a re-derivation; dual-source dedupe on the FIT
|
||||||
|
natural key means you can fall back to USB mid-week and lose nothing and duplicate nothing.
|
||||||
|
|
||||||
|
**2. Losing or corrupting years of ride history.** The realistic threats are mundane — a parser bug
|
||||||
|
writes wrong elevation to 4,000 rides, a migration drops a column, a disk dies. The raw-bytes-are-truth
|
||||||
|
rule is the control: every derived table rebuilds from the blob store, so a parser bug is a
|
||||||
|
`parser_version` bump and a requeue, not data loss. Plus offsite append-only backups independent of CI,
|
||||||
|
migration up/down/up testing in CI, golden FIT fixtures, and quarterly restore drills — an untested
|
||||||
|
backup is a hypothesis.
|
||||||
|
|
||||||
|
**3. Never shipping.** This scope is multiple person-years if attacked at once, and the failure mode is
|
||||||
|
a half-built system where you're *still* syncing manually. Mitigations: every phase has a *behavioural*
|
||||||
|
done criterion, not a feature list; v1 is capped at four containers; the heavy geo/AI services are
|
||||||
|
behind opt-in profiles in Phases 3–5; the highest-value differentiators (cost-per-km, wet-weighted
|
||||||
|
wear) are schema properties that cost nothing; and **Phase 2 is scheduled early and protected**,
|
||||||
|
because if the project stalls right after it, it has still succeeded.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
**Before coding:** on the Rider 650, `Main Menu → Data Sync` → join home Wi-Fi → ride → confirm the
|
||||||
|
activity reaches Bryton's cloud with the phone switched off. Then plug it in over USB and `ls -R` the
|
||||||
|
mounted volume to confirm the actual `.fit` path.
|
||||||
|
|
||||||
|
**Phase 0:** `curl https://host/healthz` returns 200; push to `main` produces a new registry image and
|
||||||
|
a redeployed container; `docker compose logs` shows migrations applied.
|
||||||
|
|
||||||
|
**Phase 1 — ingestion:**
|
||||||
|
- Upload a real Rider 650 `.fit` → activity appears with correct distance, elevation, and map track.
|
||||||
|
- Upload the **same file twice** → exactly one activity, one `raw_files` row.
|
||||||
|
- Upload a Bryton **route/course** `.fit` → does *not* become an activity; classified correctly.
|
||||||
|
- Run the poller against your real Bryton account → new rides ingest with byte-identical content to the
|
||||||
|
USB copy (compare `content_sha256`).
|
||||||
|
- Import the same ride via **both** USB and cloud → one activity; dedupe layer 2 catches it.
|
||||||
|
- Break the credential deliberately → an `auth` alert fires immediately and the UI banner appears.
|
||||||
|
- Confirm the stored Bryton credential appears in **no** API response and **no** log line.
|
||||||
|
- `pytest` green against the golden-fixture corpus in CI.
|
||||||
|
- Log in as a second invited user → sees none of your data. Verify RLS directly: `SET app.user_id` to
|
||||||
|
user B, `SELECT * FROM activities`, expect zero of user A's rows.
|
||||||
|
- Cross 1,000 miles on a bike → exactly one milestone notification, naming the ride that crossed it.
|
||||||
|
|
||||||
|
**Phase 2 — garage and notifications:**
|
||||||
|
- Create a bike, install a chain, import 3 rides → chain shows summed distance. Edit the install date
|
||||||
|
backwards → the number self-corrects with no manual recomputation.
|
||||||
|
- 90-day sealant rule → due badge at the right date. 50-ride-hour fork rule → tracks hours not miles,
|
||||||
|
and ignores indoor rides.
|
||||||
|
- Install a part from inventory → quantity decrements, a `components` row appears carrying the cost,
|
||||||
|
ledger balances.
|
||||||
|
- **The recurrence test, which is the one that matters:** set the 200-mile drivetrain rule, ride past
|
||||||
|
160 mi → one "80%" notification. Ride past 200 mi → one "due" notification. **Run the evaluator ten
|
||||||
|
more times → no further notifications.** Log the service → ride another 200 mi → it fires again.
|
||||||
|
- Import a wet ride (`wet_fraction` near 1.0) on rim pads → effective wear advances ~4× raw distance,
|
||||||
|
and the UI shows both numbers.
|
||||||
|
- Install the PWA on a second family member's iPhone, enable reminders, trigger a due rule → push
|
||||||
|
arrives on the lock screen and the app badge shows the count.
|
||||||
|
- Disable push and re-run → the notification still appears in the in-app inbox and via ntfy.
|
||||||
|
|
||||||
|
**End-to-end, the real test:** finish a ride, put the bike away, don't touch your phone. Within 15
|
||||||
|
minutes the ride is in the app with weather, every fitted component's wear has moved, and if the
|
||||||
|
drivetrain crossed 200 miles your phone has already told you.
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
# 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:
|
||||||
|
|
||||||
|
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.
|
||||||
|
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**. 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.**
|
||||||
|
|
||||||
|
```python
|
||||||
|
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_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](https://open-meteo.com/en/docs/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](https://open-meteo.com/en/docs/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
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
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?**
|
||||||
|
Determines how completely the phone is removed from the loop. Two-minute test.
|
||||||
|
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.
|
||||||
Reference in New Issue
Block a user