BikeLog service¶
Fetches the bike computer's sessions automatically as soon as it shows up in the WiFi, keeps them unchanged and serves GPX/CSV made from them.
A session is everything one boot of the bike computer writes: L_ (binary log), I_
(short statistics), D_ (debug log), S_/R_ (raw IMU data), T_ (time hints), N_
(Forumslader NMEA) -- all with the same stem (_143012), see
log format and CLI. In the service the files lie in the same structure as on
the SD card:
data/sessions/<device>/20260920/L_143012.bin (files directly in /BIKECOMP: folder "_")
data/index.sqlite3 index (sessions, files, hashes, key figures)
GPX, CSV etc. are generated from the raw data on demand -- a better export logic thus improves all old rides retroactively.
Fetching (pull)¶
The bike computer serves its SD card by HTTP anyway (/log/<day>/<file>). The service
fetches instead of the firmware uploading: no upload client on the device, no credentials
there, no retry logic in a task that competes with BLE and display for internal heap.
When
(puller.py):
- mDNS probes (main trigger): after every WiFi connect the firmware calls
MDNS.begin("TRGB-BC"), and the ESP32 probes its name while doing so (RFC 6762 8.1: queries forTRGB-BC.localwith an authority section). Only a device that is just (newly) joining the network does that -- i.e. after boot or WiFi reconnect, exactly when a session has been finished. A socket of its own listens passively for it; 10 s later the fetch runs (BOOT_SETTLE_S). Measured 2026-09-27: four probes within one second after the connect. - The ServiceBrowser alone is not enough: it only reports services it does not have in its cache yet, and after a quick restart the entry is still there (PTR TTL 75 min). That is how the first attempt on 2026-09-27 failed.
- Polling as a safety net: an mDNS address query every
BIKELOG_PULL_INTERVAL_S(120 s) -- if the bike computer is gone this costs nothing, if it (re)appears the fetch runs. Additionally every 30 min at the latest while it is online. - Follow-up: right after boot
LogSessionsonly moves the finished session out ofCUR/in the background. If there is more than the running session there at the time of the fetch (or the fetch fails), it is fetched again after 60 s, at most 10 times in a row. - By hand:
POST /api/v1/pullorbikelogservice pull.
What: every file of the list that is still missing or whose size has changed --
except CUR/ (running session) and deleted sessions. New sessions only come into being
on the bike computer at boot, a fetch without anything new is a single request. The list
comes from /logfiles.json if the firmware has the endpoint, otherwise from the HTML
page /logfiles/ (there without sizes: a known file is then not fetched again).
Measured 2026-09-27: first fetch 390 files / 23 MB in 3.5 min (≈110 kB/s, the ESP32 is the brake), follow-up fetch 2 s.
Deleting removes the files but leaves a tombstone in the index -- otherwise the
session would come back from the SD card on the next fetch. An explicit PUT brings it
back.
GPX export¶
For every session with a binary log the service automatically writes a file under
data/export/gpx/ (local start time + device; mtime = start of the ride) -- as the
hand-over point for Nextcloud sync, Komoot/Strava etc.
(exporter.py):
Tours/-- real rides, at leastMIN_EXPORT_DISTANCE_M(1 km) of real movement (GPS jumps around a standing position don't count, seebikelog.gpx.GpxStats.real_distance_m).Debug_Archive/-- everything else with a usable GPS fix: short test rides, a bike standing on the trainer with jittering phone GPS. Nothing is lost, it just does not end up in Nextcloud/Strava.
Sessions without any usable GPS fix get no file at all (gpx_status no-gps),
unreadable logs error: ….
It is rewritten (and moved between the two folders if necessary) when L_ or I_ of the
session arrives or changes, and for all sessions when EXPORT_VERSION is raised --
do that with every change to the content of the GPX, then old rides get the better
version too.
Before the export,
bikelog/sanitize.py
runs over the raw data (on by default, GpxOptions.sanitize or ?sanitize=false on the
download): at standstill (traffic light, break, before the first revolution) the GPS fix
wanders a few metres around the position -- sanitize recognises that by the wheel sensor
(speed < MOVING_KMH) and leaves only the one fix directly at the break from every
standstill, instead of a scribble on the map.
Content (details in
bikelog/gpx.py):
| Where | What |
|---|---|
<metadata> |
name, description with key figures (distance, moving time, average/maximum, altitude gain, heart rate, cadence, temperature, shocks, road quality, labels), link to the session (BIKELOG_PUBLIC_URL), bounds |
<metadata><extensions><bc:Ride> |
the same key figures machine-readable, distance per road class and per label, plus the device's I_ statistics unchanged (bc:deviceSummary) |
| track point, Garmin TPX v2 | atemp, hr, cad, speed, course -- read by Strava, Komoot, Garmin Connect |
track point, bc:TrackPoint |
trip distance, wheel speed, gradient (display/baro/IMU), barometric and GPS altitude, GPS accuracy and fix age, road class + OSM smoothness, roughness and RMS of the road-quality interval, manual label |
<wpt> |
shocks (sym "Danger Area", details in bc:Shock) and label changes (sym "Flag, Blue") |
bc = https://github.com/euphi/TRGB-BikeComputer/gpx/v1. Viewers skip unknown
extensions; the GPX validates against gpx.xsd, the TPX elements against
TrackPointExtensionv2.xsd. Lean, without bc::
GET /api/v1/sessions/{id}.gpx?rich=false or bikelog gpx --plain.
By hand: bikelogservice export (missing/outdated), export --all (all).
Komoot upload¶
Manual only -- no automatic trigger, unlike the Nextcloud sync below.
POST /api/v1/sessions/{id}/komoot uploads the ride to Komoot via
kompy (or, in the web interface, the "Komoot" button
next to every real ride). A reboot in the middle of a ride (short BLE dropout, break with
the bike computer switched off) produces several sessions for one tour -- sessions of the
same device with at most BIKELOG_KOMOOT_MERGE_GAP_S of pause between them are therefore
merged into one ride and uploaded as one GPX
(komoot.py);
all sessions involved show the same komoot_status afterwards. A second attempt without
?force=true is rejected with 409.
Needs pip install "bikelog[komoot]" (kompy + gpxpy, not part of [service]) and
BIKELOG_KOMOOT_EMAIL/BIKELOG_KOMOOT_PASSWORD in bikelog.env -- without both the
endpoint answers 409 ("not-configured"), everything else stays as it is. kompy does not
report a tour ID after the upload (limitation of the Komoot API), hence no direct link to
the new tour -- only the status (uploaded/error).
Unlike the normal export, the uploaded GPX has no <wpt> waypoints (shocks, label
changes): Komoot's import endpoint rejects every file with <wpt>
(400 query is required for type=tour_planned, verified against the real API on
2026-09-27 -- presumably their parser takes it for a planned route instead of a recording
for that reason alone). The rich bc: extensions per track point are not affected and
stay in.
Nextcloud sync¶
Unlike Komoot: runs automatically, no button, no endpoint. Every session with
gpx_status ok (i.e. in Tours/, see above) is uploaded by WebDAV into a configurable
directory on a Nextcloud instance
(nextcloud.py);
if a session is later no longer classified as "real" (export logic improved,
EXPORT_VERSION raised), its Nextcloud copy is deleted again instead of being left
orphaned. Debug_Archive sessions are never synchronised.
Needs pip install "bikelog[nextcloud]" (requests, not part of [service]) and in
bikelog.env:
BIKELOG_NEXTCLOUD_URL=https://cloud.example.com
BIKELOG_NEXTCLOUD_USER=bikelog
BIKELOG_NEXTCLOUD_PASSWORD=<app password>
BIKELOG_NEXTCLOUD_DIR=BikeLog # default, freely selectable
The password is an app password (Nextcloud: Settings -> Security -> "Devices & sessions" -> create a new app password), not the normal login password -- revocable on its own without touching the main access. Without URL/user/password the sync simply stays off.
Known gap: if a session is deleted (DELETE /api/v1/sessions/{id}), only the local copy
disappears -- the Nextcloud copy stays and has to be removed by hand.
Installation (home server: ~/bikelog)¶
Tools/BikeLogService/install.sh # or: install.sh /other/path
creates: ~/bikelog/venv (a venv of its own, the code is copied into it -- switching
branches in the repo does not touch the service), data/, bikelog.env (configuration,
never overwritten) and bikelog.service. Once as root:
sudo install -m 644 ~/bikelog/bikelog.service /etc/systemd/system/
sudo systemctl daemon-reload && sudo systemctl enable --now bikelog
journalctl -u bikelog -f
A system unit with User= instead of a user unit: needs no login session and no
enable-linger.
Update: after git pull run install.sh again, then sudo systemctl restart bikelog.
Interface: http://<server>:8080/ (list of rides), API documentation
http://<server>:8080/docs.
Development¶
cd Tools
python3 -m venv .venv
.venv/bin/pip install -r BikeLogService/requirements.txt
.venv/bin/python -m pytest
PYTHONPATH=.:BikeLogService .venv/bin/python -m bikelogservice serve --port 8081
Command line¶
bikelogservice serve [--host 0.0.0.0] [--port 8080]
bikelogservice pull [TRGB-BC.local | 192.168.0.171] [--device trgb] # fetch once
bikelogservice import /media/sd/BIKECOMP [--device trgb] # SD card in a card reader
bikelogservice export [--all] # write GPX files
pull and import work on the same data directory (BIKELOG_DATA_DIR) and may run in
parallel to the service.
Configuration¶
Environment variables (in the service: ~/bikelog/bikelog.env):
| Variable | Default | Meaning |
|---|---|---|
BIKELOG_DATA_DIR |
$STATE_DIRECTORY or ~/.local/state/bikelog |
sessions + SQLite index |
BIKELOG_PORT |
-- | only in the unit: port for serve |
BIKELOG_PULL |
0 |
fetching on/off |
BIKELOG_PULL_TARGETS |
trgb=TRGB-BC |
device=mdns-name, comma-separated |
BIKELOG_PULL_INTERVAL_S |
120 |
polling interval |
BIKELOG_PULL_MDNS |
1 |
listen for mDNS announcements |
BIKELOG_EXPORT_GPX |
1 |
GPX export on/off |
BIKELOG_EXPORT_DIR |
<data>/export/gpx |
target directory (must be writable in the unit: ReadWritePaths=) |
BIKELOG_PUBLIC_URL |
-- | e.g. http://server:8080, for the link in the GPX |
BIKELOG_REQUIRE_AUTH |
0 |
auth on/off |
BIKELOG_TOKENS |
-- | token:name, comma-separated |
BIKELOG_MAX_UPLOAD_BYTES |
64 MiB | upper limit per PUT |
BIKELOG_KOMOOT_EMAIL / BIKELOG_KOMOOT_PASSWORD |
-- | Komoot login; without both the upload is off |
BIKELOG_KOMOOT_ACTIVITY |
touringbicycle |
a SupportedActivities constant from kompy |
BIKELOG_KOMOOT_STATUS |
friends |
visibility of the uploaded tour (public/private/friends) |
BIKELOG_KOMOOT_MERGE_GAP_S |
1800 |
sessions of the same device with at most this much pause between them count as one interrupted ride |
BIKELOG_NEXTCLOUD_URL / _USER / _PASSWORD |
-- | Nextcloud login (app password); without all three the sync is off |
BIKELOG_NEXTCLOUD_DIR |
BikeLog |
target directory (WebDAV path, created if needed) |
Both variants of the bike computer (gravel and Forumslader) announce themselves as
TRGB-BC today -- the service cannot tell them apart and stores everything under one
device. Remedy as soon as both are in the same network: an mDNS name of its own per
variant or a device field in /logfiles.json.
API (v1)¶
| Method | Path | Purpose |
|---|---|---|
GET |
/ |
list of rides (HTML) |
GET |
/api/v1/health |
reachability + number of sessions |
GET |
/api/v1/sessions |
list (limit, offset), newest first |
GET |
/api/v1/sessions/{id} |
session with files, key figures, I_ statistics |
GET |
/api/v1/sessions/{id}.gpx |
GPX as in the export (max_fix_age_ms, segment_gap_s, ele, max_accuracy_m, shocks, labels, rich) |
GET |
/api/v1/sessions/{id}.csv |
CSV (with_gps) |
GET |
/api/v1/sessions/{id}/files/{name} |
one file unchanged |
DELETE |
/api/v1/sessions/{id} |
delete the files, keep the tombstone |
POST |
/api/v1/sessions/{id}/komoot |
upload as part of its tour (force), see above |
PUT |
/api/v1/devices/{device}/files/{day}/{name} |
deliver one file (body = file) |
GET/POST |
/api/v1/pull |
status of fetching / fetch now (device) |
PUT is idempotent: the same bytes once more give unchanged, other bytes under the
same name replaced (the bike computer rewrites I_ files when their format changes).
An L_ that cannot be read is kept anyway and explained in log_error -- it is the
device's data. CUR/ is rejected with 409.
curl -T L_143012.bin http://server:8080/api/v1/devices/trgb/files/20260920/L_143012.bin
Adding auth later¶
Auth is off but fully wired. Every route already depends on require_principal
(auth.py),
which currently returns an anonymous principal. Switching it on:
BIKELOG_REQUIRE_AUTH=1
BIKELOG_TOKENS=<token>:gravel
No route change, no migration; clients send Authorization: Bearer <token>. The HTML
page is then no longer readily reachable in the browser -- for a home server without
port forwarding auth therefore stays off. The test
test_every_route_requires_a_token_once_auth_is_on records that no route bypasses the
dependency.