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>
343 lines
22 KiB
Markdown
343 lines
22 KiB
Markdown
# 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.
|