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)
Clock steps inside a session (the phone's GPS time was wrong when the bike computer
started, 2026-10-04: a 2 h ride became 17 h with an hour of the evening before) are undone
when reading: the T_ file lists the steps, the jumps of the record times show where they
happened, the last clock counts
(bikelog.timefix).
The summary of such a session shows time=repaired.
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 credentials on the device, no retry logic in a
task that competes with BLE and display for internal heap. For a bike that is not parked in
reach of the home WiFi there is the push as well.
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 10 min at the latest while it is online (the bike computer finishes a session when the ride session is ended, see below). - 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 when the ride session is ended on purpose (long press), at boot, or when
rotate is typed on the serial console -- not when WiFi connects, which also happens on a phone
hotspot in the middle of a tour, 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.
Uploading from the bike computer (push)¶
The pull needs the bike computer in the home WiFi. A bike that is parked elsewhere (at work, in a cellar without reception) can upload instead: whenever it is online it sends every finished session file by WebDAV to a Nextcloud folder, and the service collects them from there. Push is an addition, not a replacement -- both can run for the same device:
- The files stay on the SD card unchanged, the pull still finds them.
- The service files a pushed file through the same
put_file()as a pulled one: the same bytes again areunchanged, nothing is stored twice. - A session deleted or archived in the service is not brought back by a push (the inbox drops
its files; a direct upload carries the header
X-BikeLog-Auto: 1, see the API).
Bike computer (LogPush.cpp,
page /push, linked from the start page and from /wifi):
| Target | URL | User / password |
|---|---|---|
| Upload link (simplest) | the share link of a folder shared as "File drop", https://<cloud>/s/<token> |
empty / the share password, if it has one |
| WebDAV folder with an account | https://<cloud>/remote.php/dav/files/<user>/BikeLog-Inbox/<network name> |
Nextcloud user / an app password (Settings -> Security -> "Devices & sessions") of its own, revocable alone |
An upload link needs no account on the bike computer. The firmware turns it into its WebDAV side
(/public.php/webdav, the token as user). A files drop takes nothing but PUT and keeps only the
file name (Nextcloud 31, FilesDropPlugin), so the files arrive flat as
<network name>~<day>~<file> (TRGB-FL~20261018~L_143012.bin, a file directly in /BIKECOMP:
TRGB-FL~~L0042.bin); for a "file request" Nextcloud puts them into a folder per nickname (the
firmware sends its network name). A name that is already there is not replaced but stored as
... (2).bin -- the service takes such copies oldest first. With an account, missing folders are
created (MKCOL) and the day folders of the card are kept. Test target checks URL and login (on
an upload link it writes <network name>~push-test.txt, which the service deletes), Upload now does
not wait for the next round, Mark all as uploaded records what is on the card without
uploading it -- for a card whose sessions the service already has by pull. The password is
only taken from the form, never shown again or logged.
When: 20 s after every WiFi connect, then every 10 min while online (a ride ended with a long
press shows up without a reconnect), 5 min after a failure. Only finished sessions, never
CUR/. What has been uploaded is listed in /bcpush.txt on the SD card (<path> <size>); a
file goes up again only if its size changes (new summary format). Delete the file to upload
everything again. HTTPS is checked against the common root CAs (Let's Encrypt and the large
CAs: ESP-IDF's own "common CAs" bundle, CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEFAULT_CMN, renewed
with every IDF update); a self-signed certificate does not work. Serial console:
push [status|now|test|mark|on|off].
The WiFi itself follows its own rules (WiFi): it must know the network, and it switches off 5 minutes after boot if none is in reach. The upload also runs over a phone hotspot in the middle of a ride; it only sends finished sessions, a few hundred KB per ride.
Service (inbox.py):
needs the Nextcloud login of the sync and the inbox folder:
BIKELOG_NEXTCLOUD_INBOX=BikeLog-Inbox
BIKELOG_NEXTCLOUD_INBOX is the folder behind the upload link (or above the device folders),
seen from the service's Nextcloud account. It looks into the inbox every BIKELOG_INBOX_INTERVAL_S (5 min), stores each file and then
deletes it there; an emptied day folder goes once nothing has been written to it for an hour.
The folder below the inbox is the network name of the bike computer; BIKELOG_PULL_TARGETS maps
it to the device name (pendler=TRGB-FL files BikeLog-Inbox/TRGB-FL/... under pendler, the
same sessions as the pull of TRGB-FL.local), a name without a target is used as it is. By hand:
POST /api/v1/inbox or bikelogservice inbox.
Without a Nextcloud the bike computer can also upload straight to the service, if it can reach it
(VPN, port forwarding): folder URL http://<server>:8080/api/v1/devices/<device>/files, user
empty, a BIKELOG_TOKENS token as the password if auth is on.
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, and every test session however long (gpx_statustest). Nothing is lost, it just does not end up in Nextcloud/Strava/Komoot.
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¶
Never automatic -- but the web page asks: every finished real ride that is neither
uploaded nor declined shows up in a box at the top, "New ride ready -- upload to Komoot?",
with the buttons Zu Komoot hochladen and Nicht hochladen. The question comes back on
every page view until one of the two is pressed (closing the page answers nothing);
declining is stored (komoot_prompt = ignored, per merged tour) and can be undone with
POST /api/v1/sessions/{id}/komoot/ignore?ask_again=true. A failed upload keeps asking.
Sessions that existed when this was introduced (schema 7) are not asked about; their
"Komoot" button in the table is still there.
While files are being fetched the page shows a progress box (file n of m, MB so far, then "processing": GPX export, Nextcloud) and reloads itself every 3 s until done; the journal logs every file, the number of sessions to fetch and each GPX export.
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. The rides list shows such a ride as one row with the
figures of the whole ride (distance, moving time, average) and its parts smaller below it
("Teil 1/2", no rule between them); the head row links to the report of the whole ride.
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.
Test sessions¶
Sessions with emulated data are recognised and kept apart from the rides
(bikelog/testride.py):
| Marker | Recognised by |
|---|---|
| Simuliert | LOG_SIMULATED in the log: the sensor simulator (sim on the serial console, simulator build) or a TrailBridge test ride (SIM_FLAGS, firmware since 2026-10-02) |
| GPS-Wiedergabe? | older TrailBridge test rides without the flag: GPS travels at least 500 m, the wheel sensor less than 15 % of that. A real ride with a dead wheel sensor looks the same, hence the question mark |
Test sessions are hidden in the ride list (and in GET /api/v1/sessions), the filter
says how many; "Testfahrten zeigen" shows them with their marker. They do not count in
training, recurring climbs or goals, go to Debug_Archive/ and are never synchronised or
offered for Komoot. The ride page says why a session counts as a test and has a button
to overrule the detection in either direction ("Doch eine echte Fahrt", "Als Testfahrt
markieren"; API: POST /api/v1/sessions/{id}/test?mark=test|real|auto).
Idle sessions and archive¶
Every boot of the bike computer is a session, also when it was only switched on at home.
A session in which the wheel stood and the position got nowhere (wheel < 50 m, all GPS
fixes within 300 m), or which has no binary log at all or only an empty one (nothing but
zero bytes), is idle
(bikelog/testride.py).
A log in an unknown format, on the other hand, stays in the list marked "Log unlesbar"
(the hover text names the version byte found) and is not tried again for the report.
Idle sessions never appear in the ride list (it links to them: "N Leerlauf-Sitzungen im
Archiv") or in GET /api/v1/sessions (idle=true includes them), get no GPX and count
nowhere.
BIKELOG_IDLE_ARCHIVE_DAYS (default 7) days after they were fetched, their files move
from <data>/sessions/ to <data>/archive/ (same tree) -- debug logs stay available
with a shell
(archive.py).
The index keeps a tombstone of them for as long as the files are still on the SD card,
otherwise the next pull would fetch them again; the pull drops it once they are gone
from the card's listing (this applies to deleted sessions as well).
The page Archiv (/archive) lists idle sessions not yet archived and archived ones
still on the card. "Auf dem BC löschen" (one session, or all) deletes their files on the
bike computer right away through the firmware's /del/ endpoint -- only while it is
reachable; nothing is queued. A session whose files are all gone from the card is moved
to the archive and dropped from the index. A file that is no longer on the card counts as
deleted: the firmware answers 404 for it, 403 only for the running session's files or
one being pushed (older firmware also answers 403 for a missing file -- then the
device's listing decides). Idle sessions deleted on the bike computer itself (e.g. its
Cleanup) are archived by the next pull without a delete request; rides that left the
card stay in the service. API: POST /api/v1/archive/device-delete
(session_id, all if omitted; 409 when the device is not reachable).
Bikes¶
The page Räder (/bikes) holds the bikes -- name, type, weight ready to ride, CdA,
Crr -- and which bike computer rides on which bike from which day on
(bikelog/bikes.py,
stored in <data>/bikes.json). A session gets the bike its device was assigned to on the
day of the ride; on the ride page one ride can be given another bike. The power estimate
of the report then uses rider weight (page Fahrer) + bike weight, and the bike's CdA
and Crr; rides without a bike keep the rider page's defaults. Changing a bike or an
assignment recomputes the reports concerned. The page shows the kilometres per bike.
Importing rides (GPX)¶
"GPX-Fahrten importieren" on the ride list takes rides recorded elsewhere (Garmin, Strava,
Komoot ...): GPX with times; heart rate, cadence and temperature from Garmin's
TrackPointExtension are taken over, the gradient is smoothed over ±50 m
(bikelog/gpximport.py).
An imported ride is a session of the device import (the log made from it next to the
original GPX) and counts like any other ride -- report, training, climbs -- but is neither
exported nor uploaded. Optionally with a bike and as an earlier edition of a goal: the
goal then lists "Deine bisherigen Teilnahmen" with time, speed, heart rate, power and TRIMP.
Importing the same file again replaces the session. API: PUT /api/v1/import/gpx
(bike_id, event_id, body = the file).
Planned and regular routes¶
The page Strecken (/routes) keeps routes to ride: planned (a tour coming up) and
regular (the commute, the evening loop). Upload one or several GPX files; each is
checked for what TrailBridge's navigation will make of it, read the way the app's
GpxParser reads it
(bikelog/routecheck.py):
| Check | What it says |
|---|---|
| geometry | track (trk) or only route points (rte: TrailBridge knows nothing between them, warning); points without a valid coordinate, fewer than two points, under 100 m, no XML/GPX, a DOCTYPE or over 20 MB = not usable |
| turn hints | from OsmAnd's route segments, from the route points (rtept with a turn), or -- none in the file, or only start/via/destination as bikerouter.de writes them -- derived from the track's angles by the app (no street names) |
| heights | without <ele> on (nearly) every point no elevation profile and no climbs on the bike computer (warning) |
| gaps | points more than 2 km apart (simplified track) or holes over 200 m between track segments (warning) |
| times | time stamps in the file: the remaining time comes from the planned riding time |
| waypoints | wpt within 100 m of the route appear in the route overview, the others are counted |
The list is short: the current (planned) routes on top, below them, collapsible, the
regular ones (open) and the archive (closed) -- per row name, tags, distance, elevation
gain and a small elevation profile (the same scale in every row up to 300 m of height
difference, so a flat route stays flat; hidden on a phone) and a button In TrailBridge
laden (load into TrailBridge; for the route offered it says whether it is on the phone yet);
the verdict only shows when something is wrong. Above it one line says which route TrailBridge
has and when the app last asked, unfolding to the setup. A
click on the route opens its page (/routes/{id}) with distance, elevation (with min/max),
the climbs with category (same criteria as the ride report), planned time and speed if the
file has times, the elevation profile (its height axis spans at least 200 m, as in every elevation profile of
the service) and the findings. There a route can be renamed, re-filed, tagged Pendel/Alltag
(commute) and Training, archived (which takes it back from TrailBridge), replaced by a new
version of its GPX, downloaded and deleted. A file TrailBridge cannot read at all is
refused and not kept. Stored in <data>/routes.json, the files unchanged in
<data>/routes/ (routes.py); when the check
itself changes, CHECK_VERSION is bumped and the service redoes the stored checks on start.
Waypoints (<wpt> in the GPX, e.g. named by BRouter-Web with a right click): the list shows
the named ones only as thin vertical strokes in the small elevation profile, at their distance
from the start. The route's page shows a map (the route as a line, no map tiles) and the
elevation profile with each waypoint marked and named, and below them a table with the name,
the distance from the start and to the destination. A wpt with a type (from, via, to)
sits on a route point; one without (a point of interest) may lie beside the route, so its
distance is that of its projection onto the track (a note says how far off it is; further than
1 km it is left out). BRouter-Web's machine names (from, via1, to) are start, destination
and unnamed via points: greyed in the table, no stroke in the list, no name in the charts. The
OsmAnd turn hints in <rte> are not waypoints. Names are escaped for HTML.
Back to the planner: the URL BRouter-Web or bikerouter.de writes into the GPX (trk/link with
lonlats= in the fragment, http(s) only) becomes a button In BRouter bearbeiten (edit in BRouter) on
the route's page. It adds ?bikelog=<id>&bikelogname=<name>: a BRouter-Web patched for it then offers to update this route in its export
dialog (PUT /api/v1/routes/{id}/gpx; name, tags and id stay, the old file is overwritten, and a route offered to TrailBridge is loaded again).
A bikerouter.de link just opens there (In bikerouter.de öffnen).
Routes for TrailBridge¶
"Für TrailBridge bereitstellen" marks one route (marking another one unmarks it). The app picks it up by itself. Contract between service and app:
- Setup in the app: only the service's address (
BIKELOG_PUBLIC_URL, e.g.https://bikelog.example.org). No password: like the pages, the feed is limited by the network -- the service is reachable from the home network and the VPN only (reverse proxy withallow/deny). ShouldBIKELOG_REQUIRE_AUTHbe switched on, the feed needs a token like every other path. - When the app asks: when it starts, then every 15 minutes -- but only while it neither navigates nor plays a route back (loading a route ends both).
GET /api/v1/trailbridge/route--204no route marked;200JSON{id, name, kind, revision, marked_at, sha256, size_bytes, distance_km, ascent_m, verdict, gpx_url}withETag: "<revision>"; withIf-None-Match: "<revision>"304.gpx_urlis a path relative to the service.- New = another revision. The revision changes with the file (new version uploaded) and with every marking -- marking the same route again sends it once more. The app keeps the last revision it loaded and does nothing while it is the same.
GET /api/v1/trailbridge/routes/{id}.gpx-- the file unchanged (X-Bikelog-Revision). The app loads it like a GPX opened by hand (it does not start the navigation).POST /api/v1/trailbridge/route/fetched?revision=<revision>-- after the app parsed it fine. The page then shows "Von TrailBridge geladen" with the time;409if the revision is no longer current (the next poll brings the new one).
The page also shows when the app asked last (since the service started).
Session report¶
/ride/{id} (click on a ride in the list) shows the report of a session: key figures,
elevation profile with the climbs, heart-rate zones, estimated power, road quality and
shocks, technical findings. GET /api/v1/sessions/{id}/report.md is the same as German
Markdown, …/report.json the figures behind it. All of it comes from
bikelog/report.py
(details: tools).
Reports are kept in the reports table of the index, under a key made of the report
version, the hash of the L_ file and the rider data. After a pull (and at start) the
service computes whatever is missing or stale, at the lowest CPU priority
(analysis.py).
A new report version or changed rider data therefore reach every past ride by themselves.
Wind. The power estimate and the aerobic decoupling include the wind of the ride
(details). The service asks Open-Meteo for the hourly wind of the
ride's day and 0.1 degree cell -- nothing else leaves the machine, the track never does -- and
keeps every answer under <data>/weather/ (2026-10-24_49.4_10.9.json), so recomputing a
report needs no network and gives the same result. The ride page shows mean wind, gusts, the
share of head- and tailwind and, next to the power, the figure without wind. Where the
decoupling would only reflect the wind (out against it, back with it), it says so instead of
showing a number. If Open-Meteo is unreachable or has no data yet, the report is computed
without wind (the page says why) and tried again an hour later; days that came from the
"historical forecast" API or are less than a week old are fetched again from the archive once
that is final (wind.retry_after in the report JSON). BIKELOG_WIND=0 switches the
lookup off; the factor from the 10 m wind to the wind at the rider (default 0.55) is
wind_factor on the page Fahrer. Shelter (forest, buildings) is not modelled.
Idle time. Time before the first and after the last real movement (device switched on in the garage, still running at home) is neither a stop nor part of the duration, the temperature or the GPS statistics; stops in the middle of the ride stay. Height changes while standing (a lift ride with the bike) are not counted as climbing.
Rider data for heart-rate zones, TRIMP and W/kg are entered on the page Fahrer
(/athlete) and end up in <data>/athlete.json (other path: BIKELOG_ATHLETE_FILE).
Every key is optional; without the file the report simply has no zones:
{"hr_max": 186, "hr_rest": 48, "mass_kg": 88, "rider_kg": 76, "cda": 0.38, "crr": 0.006}
The JSON is meant as the input for a text generator (template or local LLM): every number is computed there, a model only has to put it into words -- see the ride in words.
The ride in words (local LLM)¶
With BIKELOG_LLM_URL set (an Ollama server, e.g.
http://localhost:11434) the ride page gets a section In Worten: two or three
paragraphs on what kind of ride it was, how load and heart rate compare with the recent
rides, whether that fits the training phase of the next goal, and technical warnings.
Everything stays on the home server.
- The model only puts figures into words. The prompt
(
bikelog/narrate.py) gives it labelled lines with spelled-out units and their meaning ("Höhenmeter bergauf: 480 m", "Form (TSB): -12 (negativ = ermüdet)"), plus the comparison with the rides of the 42 days before, the load on the morning of the ride and the next goal with its phase -- no abbreviations, nothing to compute. (In the first test qwen3:8b read "480 hm" as hectometres and added them to the distance.) - Every number in the answer that is not in the prompt (allowing for rounding) is listed under the text: not proof of a mistake, a reason to look.
- Thinking is switched off (
"think": false): qwen3 otherwise reasons for several hundred tokens first -- minutes on a CPU. - Texts are written in the background after the reports, newest ride first, one at a time
(
llm.py), and kept in the tablenarratives. A ride without a text always gets one; if its facts change (new goal, another bike, new prompt) it is rewritten only for rides of the lastBIKELOG_LLM_REFRESH_DAYS. Neu schreiben on the page writes it again, before any other. Test sessions and rides under 1 km get none.
On the Orange Pi 5 (16 GB, CPU only) qwen3:8b writes about 2.7 tokens/s, a text takes
2--4 minutes. A 14B model needs about 9 GB while loading (Ollama repacks the weights for
the ARM CPU) -- with other services on the machine that ends in the OOM killer. Recommended
Ollama settings (sudo systemctl edit ollama):
[Service]
Nice=10
OOMScoreAdjust=500
Environment=OLLAMA_NUM_PARALLEL=1
Environment=OLLAMA_CONTEXT_LENGTH=4096
Environment=OLLAMA_FLASH_ATTENTION=1
Environment=OLLAMA_KV_CACHE_TYPE=q8_0
Training, goals, climbs¶
The web pages are in the bike computer's Rim & Ridge design (design system). Besides the ride list and the ride report there are:
| Page | Content |
|---|---|
Training (/training) |
fitness (CTL, 42-day mean of the TRIMP), fatigue (ATL, 7 days) and form (TSB) over time; kilometres and hours per heart-rate zone per week; weekly table. Needs hr_max, rides without heart rate count as 0 |
Ziele (/goals) |
target events with date and priority (A/B/C), countdown and training phase (base, build, peak, taper); upload the course as GPX (button in the goal, or right away in the form for a new one) for distance, elevation and climbs. Compared with the last 6 weeks: longest ride against the race distance, biggest weekly elevation against the race's; for every climb of the course an estimated time from the best VAM on comparable climbs of the last 90 days |
Anstiege (/climbs) |
climbs ridden more than once (foot and summit at most 150 m apart, needs GPS), every effort with time, gap to the best, VAM, heart rate, power |
Fahrer (/athlete) |
rider data, see above |
Code: bikelog/training.py
(pure, works on the report JSON only, tests tests/test_training.py), pages in
webui.py
and charts.py
(SVG on the server, no JS library), tests tests/test_pages.py. Target events live in
<data>/events.json, their GPX files in <data>/events/.
One ride, several sessions¶
When the bike computer reboots on the way, one ride becomes several sessions. Sessions of
the same device whose gap (end of one to start of the next) is at most
BIKELOG_KOMOOT_MERGE_GAP_S (30 min) are one ride -- the same grouping as for the Komoot
upload
(tours.py).
Training, goals and climbs count the ride, its report runs over the joined records (the
distance continues, the reboot is a stop). The ride list marks the parts ("Teil 1/2"),
the session page links to the report of the whole ride (/tour/{id}). This relies on the
times: after a reset without clock the firmware takes the GPS time from TrailBridge and
corrects the timestamps written before; a session that never got a time (no WLAN, no
TrailBridge) stays on its own. Test and idle sessions never join a ride.
The pages load the fonts from Google Fonts; without internet the browser falls back to system fonts.
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
bikelogservice inbox # collect the push inbox once
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) |
BIKELOG_NEXTCLOUD_INBOX |
-- | folder the bike computers push to (WebDAV path); empty = not looked at |
BIKELOG_INBOX_INTERVAL_S |
300 |
how often the inbox is looked at |
BIKELOG_ATHLETE_FILE |
<data>/athlete.json |
rider data for the session report |
BIKELOG_WIND |
1 |
wind from Open-Meteo in the ride report; 0 = reports without wind, no network access |
BIKELOG_IDLE_ARCHIVE_DAYS |
7 |
idle sessions to archive/ after this many days, 0 = never |
BIKELOG_LLM_URL |
-- | Ollama server for the ride in words; unset = off |
BIKELOG_LLM_MODEL |
qwen3:8b |
Ollama model |
BIKELOG_LLM_TIMEOUT_S |
1800 |
longest wait for one answer |
BIKELOG_LLM_REFRESH_DAYS |
14 |
texts whose facts changed are rewritten for rides of this many days only |
Several bike computers: each build variant has a network name of its own (mDNS host and
default hotspot SSID, BC_HOSTNAME in platformio.ini): the gravel build TRGB-BC, the
Forumslader build TRGB-FL; changeable on the bike computer's page /wifi
(WiFi). One pull target per bike computer, e.g.
BIKELOG_PULL_TARGETS=trgb=TRGB-BC,pendler=TRGB-FL; the part before = is the device name
the sessions are filed under and that bikes are assigned to (see bikes). Keep the
name of an existing target, the stored sessions are filed under it.
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, tests: include test sessions, idle: include idle sessions), 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 |
GET |
/api/v1/sessions/{id}/report.json |
key figures of the session, see session report |
GET |
/api/v1/sessions/{id}/report.md |
the same as a German Markdown report |
POST |
/api/v1/sessions/{id}/test |
overrule the test detection (mark=test|real|auto) |
POST |
/api/v1/archive/device-delete |
delete idle/archived sessions on the bike computer now (session_id) |
PUT |
/api/v1/import/gpx |
import a recorded ride (bike_id, event_id; body = GPX) |
GET |
/api/v1/training |
daily load (TRIMP, CTL, ATL, TSB), weeks, recurring climbs (days, weeks) |
GET |
/api/v1/events |
target events with readiness |
PUT |
/api/v1/events/{id}/gpx |
course GPX of a target event (body = file) |
GET |
/routes |
planned and regular routes (HTML) |
GET |
/routes/{id} |
one route with elevation profile, climbs and findings (HTML) |
GET/PUT |
/api/v1/routes |
routes with check and figures (tags, archived) / upload one (kind=planned|regular, tags=commute,training, name, filename; body = GPX) |
GET/DELETE |
/api/v1/routes/{id} |
one route / delete it |
GET |
/api/v1/routes/{id}.gpx |
its GPX unchanged |
PUT |
/api/v1/routes/{id}/gpx |
new version of the file (filename) |
POST |
/api/v1/routes/{id}/trailbridge |
offer it to TrailBridge (on=false: take it back) |
GET |
/api/v1/trailbridge/route |
the route marked for TrailBridge, see above |
GET |
/api/v1/trailbridge/routes/{id}.gpx |
its file, for the app |
POST |
/api/v1/trailbridge/route/fetched |
the app confirms a revision (revision) |
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 |
POST |
/api/v1/sessions/{id}/komoot/ignore |
answer the page's question with "no" (ask_again=true: ask again) |
PUT |
/api/v1/devices/{device}/files/{day}/{name} |
deliver one file (body = file) |
GET/POST |
/api/v1/pull |
status of fetching / fetch now (device) |
GET/POST |
/api/v1/inbox |
status of the push inbox / look into it now |
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. A deleted session comes back with a PUT --
except with the header X-BikeLog-Auto: 1 (the bike computer's automatic push): then the
answer is {"status": "deleted"} and nothing is stored, as with the pull.
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.