Tends a walking treadmill. One Python daemon talks to the treadmill over Bluetooth: it detects sessions automatically, records speed, distance, real step counts, calories, heart rate, and HRV, drives the belt (speed, start/stop, interval programs), serves a local web dashboard with live charts and fitness trends, and — when you stop — builds a FIT file and uploads it to Strava and Garmin Connect. No custom hardware, no phone app, no cloud account of its own; your data lives in files on your machine.
Built for the LifeSpan TX6 Glow-Up, whose stock app deserved replacing.
- Treadmill: any pad whose Bluetooth module speaks the FitShow protocol —
the base advertises as
FS-…. Developed and tested on the LifeSpan TX6 Glow-Up (FitShow FS-BT-D2 module); other FitShow-equipped pads should work, possibly needing unit-calibration tweaks. The protocol details live inphase0/fitshow_probe.pyand the daemon source. - Heart rate: any BLE heart-rate device. A chest strap (e.g. Garmin HRM-Dual) provides beat-to-beat RR intervals for real HRV; a watch broadcasting wrist HR works too but carries no RR, so HRV stays blank.
- Host: a Mac or Linux box (Raspberry Pi is fine) with Bluetooth, near the treadmill, running Python 3.11+.
git clone https://github.com/sstjohn/milltender && cd milltender
python3 -m venv .venv && . .venv/bin/activate
pip install -r requirements.txtHeads-up: Strava requires a paid subscription to hold an API application (their developer-program change, effective 2026). If you subscribe:
-
Create an app at https://www.strava.com/settings/api — any name and category, website
http://localhost, Authorization Callback Domainlocalhost, any icon. -
Put the app's credentials in
.env(gitignored) next tomilltender.py:STRAVA_CLIENT_ID=12345 STRAVA_CLIENT_SECRET=… -
Run
python uploads.py strava-login. It prints an authorization URL; open it, click Authorize, and paste the resultinglocalhostURL back. The refresh token is saved locally and rotates automatically thereafter.
Garmin offers no hobbyist API, so milltender uses the community
python-garminconnect
library. It works, but it lives on the unofficial side of Garmin's fence:
expect it to need updates occasionally, and weigh that against Garmin's terms
before enabling it.
-
Add to
.env:GARMIN_EMAIL=you@example.com GARMIN_PASSWORD=… -
Run
python uploads.py garmin-loginonce. If your account has MFA you'll be prompted for the emailed code (running headless, drop the code into a.mfa_codefile instead). Tokens are cached in~/.garminconnectand refresh themselves for about a year.
Either platform is optional: with only one configured, the other simply
reports a failed upload and the FIT file stays in sessions/ for whenever.
python milltender.py # dashboard on http://localhost:8321 (and your LAN)Walk. That's the whole workflow: the daemon notices the belt start (even if it started before the daemon connected — totals are reconstructed from the base's counters), records everything, waits out a resume grace when you stop, captures a minute of recovery heart rate, uploads, and resets for next time.
To run permanently on a Mac, edit the paths in lol.ssj.milltender.plist,
copy it to ~/Library/LaunchAgents/, and
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/lol.ssj.milltender.plist.
On Linux, an equivalent systemd user service is a few lines.
The daemon usually talks to the belt over its own Bluetooth, but it doesn't have to be in range. Open the dashboard in a browser beside the treadmill and tap the treadmill pill in the header: that tab bridges the belt's Bluetooth to the daemon over a WebSocket, so the daemon can live on a machine that's nowhere near it. Tap the strap pill to bridge a heart-rate device the same way. The daemon keeps all of its logic either way — the browser is only the radio.
This uses Web Bluetooth, which browsers expose only in a secure context: reach
the dashboard over localhost (an SSH port-forward does the trick) or HTTPS,
in Chrome or Edge. Firefox and Safari don't implement it.
Five tabs: Live (speed/HR/HRV charts, stat tiles — time counts moving
minutes, so pauses don't inflate it — and belt controls with pause, stop, and a
speed stepper), Trends (your personal heart-rate-by-speed curve — all-time
vs. a selectable recent window — weekly volume, per-session recovery and
readiness numbers), History (every session, reviewable and replayable, with
FIT download and import of a .fit or sidecar recorded elsewhere), Programs
(interval workouts from four building blocks: timed holds, speed ramps,
heart-rate holds, and step/distance goals; any past session can be replayed as a
program), and Plan (a multi-week block built from those programs). A
countdown — three short beeps and a long one, plus a full-screen overlay — leads
every programmed speed change.
A plan saves you picking the right program every day: build weeks in the Plan
tab, and the Live tab opens with today's session and a button to start it. Which
week you're in comes from the calendar, so a session you skip is simply not done
— nothing carries over and nothing is owed. Completion is read back out of
sessions/, never recorded separately, so a walk counts because it happened.
The one number a plan tracks is minutes spent at running speed per week, against
what the weeks you built call for — it's the quantity that gets people hurt when
it climbs too fast, and it's measured from recorded speed rather than from what
you meant to do. Milltender will say so when an impact session falls inside
IMPACT_GAP_H of the last one; in the unpinned scheduling mode it offers a
walking session first instead. It never refuses to start anything.
Reviewing a session shows a few things the upload platforms don't, computed from the per-second data kept locally: heart-rate recovery after the belt stops (HRR30/HRR60 and the exponential decay constant τ), and aerobic decoupling — whether your heart worked harder at the same pace in the second half of the walk than the first, a durability signal that holds up on a real, variable walk because it matches on pace. Both stay quiet at easy intensities where the signal isn't there, rather than reporting noise.
| Variable | Default | Meaning |
|---|---|---|
TREADMILL_ADDRESS |
(a TX6) | BLE address/UUID of your treadmill |
TREADMILL_NAME_PREFIX |
FS- |
fallback discovery by name prefix |
HRM_ADDRESS |
auto |
pin a heart-rate device, or auto-discover |
WEB_PORT |
8321 |
dashboard port |
MAX_MPH |
6.0 |
hard ceiling on every speed the daemon commands |
GRACE_S |
180 |
resume window after the belt stops before uploading |
RECOVERY_S |
60 |
recovery-HR capture after a deliberate stop |
MIN_SESSION_S |
60 |
drop walks shorter than this many seconds; 0 keeps every one |
MIN_SESSION_STEPS |
50 |
...or with fewer steps than this |
IMPACT_MPH |
4.4 |
at or above this counts as running, not walking |
The default TREADMILL_ADDRESS won't match your unit; discovery falls back to
scanning for the FS- name prefix and finds it automatically — set the address
only to pin a specific device.
MAX_MPH binds manual controls, programs, ramps, and HR-holds alike. It
cannot restrain the handheld remote, which talks to the base on its own radio.
IMPACT_MPH is the line between walking and running for your legs, and the
default suits a ladder whose fast walks top out around 4 mph. Set it above your
briskest walk and below your easiest jog, or the plan's weekly running minutes
will count the wrong seconds.
This software starts and speeds up a motorized belt. Configure MAX_MPH to a
speed safe for everyone with access to the dashboard, treat program start/replay
with the respect you'd give the physical controls, and keep the remote nearby.
Each session becomes a FIT file plus a JSON sidecar in sessions/ — yours,
locally, forever, regardless of what the upload platforms do. The sidecars
power the trends, history, and plan views. Nothing leaves your machine except
the uploads you configured.
Your programs and your plan live beside them in programs.json and plan.json,
both untracked by git. The Plan tab writes plan.json for you, but it's plain
enough to edit by hand:
{
"name": "walk to jog",
"start": "2026-08-10",
"mode": "days",
"weeks": [
{"mon": "walk-jog 8x1", "wed": "tempo walk 3x6", "sat": "easy 45 base"}
]
}Values are program names, so editing a program updates every week that uses it,
and a weekday you leave out is a rest day. start snaps back to its Monday.
Under "mode": "open" a week is a plain list instead — ["walk-jog 8x1", "easy 30"] — for training on whatever days you get, in which case milltender
offers you the next session you haven't done rather than one tied to today.
pip install -r requirements-dev.txt
pytestThe suite covers the FitShow framing, the session state machine (including each of the base's empirically discovered quirks), FIT encoding via real encode-decode round-trips, the speed ceiling under adversarial conditions, program validation and replay, trends aggregation, and token rotation.
- "treadmill unreachable" while idle is normal — the base's Bluetooth sleeps ~10 minutes after use and wakes when you press start.
- Manufacturer app conflict: the base accepts one connection; close the vendor app if the daemon can't connect.
- Watch broadcast shows "connected" but no HR arrives: toggle the broadcast off and on — an unclean disconnect can wedge it.
- HRV chart empty: expected without a chest strap; wrist broadcasts carry no RR intervals.
- blak3r/treadspan — the MIT-licensed reverse-engineering groundwork on LifeSpan treadmills
- pcorliss/treadmill, daeken's notes — earlier LifeSpan BLE work
- cagnulein/qdomyos-zwift — the FitShow protocol reference


