Concept2 PM5 rowing app for the Arduino UNO Q. Connects to the PM5 over BLE, logs every stroke to SQLite, and delivers real-time analytics with no cloud dependency. The UI is a FastAPI web app — open it on iPad or iPhone via Safari over the UNO Q's own WiFi hotspot. The UNO Q simultaneously broadcasts live data as an FTMS Rower for ErgZone and Zwift.
| Live Display | Post-Session Summary |
|---|---|
![]() |
![]() |
| Workout Selector | Training Plan |
|---|---|
![]() |
![]() |
To add screenshots: open the app on iPad, take a screenshot, and drop it in
docs/screenshots/.
5 swipeable Row screens — Power, Endurance, Technique, Force Curve, and Intervals. Swipe left/right on the Row tab to switch. Tap any card to cycle through alternate metrics for that slot.
Live metrics — Pace, Watts, SPM (EMA-smoothed), Distance, Time, HR, Drive/Recovery timing, Peak/Avg force, Drag factor — all from the PM5 over BLE with no cables.
Force curve — Approximate Bezier drive curve drawn per stroke versus an ideal reference. Counts consecutive "perfect stroke" streaks (peak/avg force ratio, drive time, drive length vs expected).
HR zone strip — Live Z1–Z5 colour bar on the Endurance screen using the Tanaka max-HR formula.
Interval tracking — Work/rest phase countdown when a workout is loaded; shows current interval, remaining distance or time, and a progress bar.
Session recording — Every stroke logged to SQLite: drive/recovery timing, avg/peak force, work per stroke, HR. Sessions survive restarts and can be resumed.
Post-session summary — Pace graph, force curve trend, 500 m split table, Strive Score (HR-zone × time), streak count, personal records.
Personal records — Tracked per user for 2k/5k/10k pace, distance, time, avg/peak watts, avg SPM.
Workout library — 10 preset Concept2 pieces (2k Test, 5k Test, 6×500m, 4×1000m, etc.) plus an interval builder for custom sessions (distance, time, or calorie type; up to 50 reps).
Training plan — Assign workouts to days of the week.
Multi-erg selection — If two PM5s are in the same room a picker sheet appears; the browser remembers your choice.
Pace target — Set a /500m target; the pace card turns green/red live.
Audio cues — espeak announces distance, pace, and HR every 500 m or 60 s.
FTMS broadcast — Broadcasts as an FTMS Rower over BLE so ErgZone and Zwift on iPad/iPhone can connect directly.
TCX export — Every session exported to exports/ for Apple Health (via HealthFit), Garmin Connect, or Strava.
The PM5 pushes data on three notify-only characteristics (reads return zeros):
| Characteristic | UUID prefix | Rate | Fields |
|---|---|---|---|
| General Status | CE060031 |
~1 Hz | elapsed time, distance, workout state |
| Additional Status 1 | CE060032 |
~5 Hz | instantaneous speed, stroke state, drag factor |
| Stroke Data | CE060035 |
once per stroke | drive/recovery timing, forces, work, stroke count |
| Force Curve | CE06003D |
18 packets/stroke | one force sample per packet (Newtons) |
| Heart Rate | CE06003A |
on change | HR from paired ANT+ strap |
| Metric | Source | How |
|---|---|---|
| Pace | CE060035 | stroke_period / stroke_distance_m × 500 — 3-stroke rolling average |
| Watts | CE060035 | 2.80 / (pace_sec / 500)³ — Concept2 standard formula |
| SPM | CE060035 | 60 / stroke_period — 3-stroke rolling average; -- if last stroke > 10 s ago |
| Cal/hr | derived | 4 × current_watts + 300 |
| Calories | derived | (4 × avg_watts + 300) × elapsed / 3600 |
| Avg pace / watts | session | elapsed × 500 / distance and watts formula applied to avg pace |
| Drive:Rec ratio | CE060035 | drive_time / recovery_time |
| Peak/Avg force ratio | CE060035 | avg_force_n / peak_force_n |
Below is a representative snapshot of state after one complete stroke:
CE060031 — General Status (1 Hz)
elapsed_cs: 1250 → 12.50 s
distance_dm: 480 → 48.0 m
workout_state: 3 (in-use)
CE060032 — Additional Status 1 (5 Hz, three packets per stroke)
speed_mm_s: 4446 (drive surge: +600 over baseline)
3646 (decelerate: −200)
3446 (recovery: −400)
stroke_state: 1 / 2 / 3 (drive / decel / recovery)
drag_factor: 127
CE060035 — Stroke Data (once per stroke)
drive_length: 86 cm → state["drive_length"] = "0.86m"
drive_time: 60 ticks → state["drive_time"] = "0.60s"
recovery: 190 ticks → state["recovery"] = "1.90s"
stroke_distance: 960 cm² → 9.60 m
peak_force: 2800 /10 → 280.0 N
avg_force: 1950 /10 → 195.0 N
work/stroke: 3975 /10 → 397.5 J
stroke_count: 5
Derived from stroke data
stroke_period = 0.60 + 1.90 = 2.50 s
pace = 2.50 / 9.60 × 500 = 130.2 s → "2:10"
watts = 2.80 / (130.2 / 500)³ → 159 W
spm = round(60 / 2.50) → 24
dr:rec ratio = 0.60 / 1.90 → 0.32
CE06003D — Force Curve (18 samples per stroke, in Newtons)
index: 0 1 2 3 4 5 6 7 … 17
force: 0.0 52.5 168.0 252.0 280.0 241.5 189.0 140.0 … 0.0
↑ ramp up ↑ peak ↑ decay tail
Server-derived fields (api_state)
elapsed_str: "0:12"
distance_str: "48 m"
avg_pace: "2:10"
avg_watts: 159
cal_hr: 936 (4 × 159 + 300)
calories: 3 (936 × 12.5 / 3600)
What each Row screen shows at this moment
| Screen | Hero | Key cards |
|---|---|---|
| Power (0) | 159 W | 2:10 pace · 24 SPM · 48 m · 0:12 elapsed · 936 cal/hr |
| Endurance (1) | -- bpm | 2:10 · 159 W · 48 m |
| Technique (2) | — | 0.60 s drive · 1.90 s rec · ratio 0.32 · 0.86 m length |
| Force Curve (3) | canvas | peak 280 N · avg 195 N · P/A ratio 0.70 |
| Stroke Timing (4) | ratio bar | 0.60 s · 1.90 s · 0.32 |
- Python 3 —
bleak(BLE central),bless(FTMS peripheral),fastapi+uvicorn(web UI),SQLite(storage) - Target — Arduino UNO Q (Qualcomm QRB2210, ARM Cortex-A53, BT 5.1), Debian Linux, 4 GB model recommended
- Client — Any browser; optimised for iPad/iPhone Safari
Run the installer once after cloning. It handles everything — packages, WiFi AP, systemd service:
bash install.shThe script will:
- Install system packages (
python3-pip,espeak,hostapd,dnsmasq) - Install Python packages (
bleak,bless,fastapi,uvicorn) - Add your user to the
bluetoothgroup - Configure a WiFi access point — SSID: ErgRower / Password: rowrow12
- Set a static IP of
10.0.0.1on the WiFi interface - Create and enable a systemd
ergservice that starts the app on boot - Prompt to reboot
After reboot:
- Connect iPad/iPhone to the ErgRower WiFi network
- Open Safari and go to
http://10.0.0.1:8501
The app starts automatically on boot via systemd. No action needed after the initial setup and reboot.
# Check whether the service is running
sudo systemctl status erg
# View live logs
sudo journalctl -u erg -f
# Restart after pulling an update
sudo systemctl restart ergIf you haven't run install.sh or want to run it directly:
uvicorn server:app --host 0.0.0.0 --port 8501 --reloadThen open http://<UNO-Q-IP>:8501 in your browser. To find the IP, run hostname -I on the board.
If you prefer to configure the systemd service yourself instead of using install.sh:
# 1. Find where uvicorn is installed
which uvicorn
# 2. Create the service file
sudo tee /etc/systemd/system/erg.service > /dev/null <<EOF
[Unit]
Description=Erg-onomics rowing app
After=network.target bluetooth.target
[Service]
User=$USER
WorkingDirectory=$(pwd)
ExecStart=$(which uvicorn) server:app --host 0.0.0.0 --port 8501
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
EOF
# 3. Enable and start
sudo systemctl daemon-reload
sudo systemctl enable erg
sudo systemctl start erg
# 4. Verify
sudo systemctl status erg# Pull latest code
git pull
# Restart the service to pick up changes
sudo systemctl restart erg| WiFi network | ErgRower |
| Password | rowrow12 |
| URL | http://10.0.0.1:8501 |
Add to Home Screen in Safari for a full-screen experience (no browser chrome).
All data is stored in rowing.db (SQLite) in the working directory. The database is not checked in. Per-stroke data is in stroke_log; session summaries in sessions. Both are readable by any SQLite tool for custom analysis.
TCX files land in exports/ and can be imported directly into Garmin Connect or Apple Health via HealthFit.
pip install pytest
python -m pytest tests/ -v68 tests covering conversion math, workout CRUD, streak calculation, HR zone/Strive Score, PR detection, perfect-stroke streak, and interval state tracking.



