Real-time knee flexion tracking for post-surgical rehabilitation. An ESP32-S3 with dual MPU-6050 IMUs streams joint angle data over WiFi to a Python backend, which runs a biomechanical step detector, scores each walking session, and serves a mobile-first dashboard called Waddle.
ESP32-S3 (50 Hz) ──WiFi──► laptop:8000/ws
│
_handle_packet()
│
┌────────────┴────────────┐
│ │
StepDetector /feed WebSocket
(STANCE/SWING FSM) ──► browser dashboard
│
Auto-session logic:
idle → walking → start session
walking → stopped → save session + score
The ESP32 is the client — it dials out to your laptop IP on port 8000. Your laptop is the server. The browser dashboard connects to /feed for a live stream of flexion angle, step count, and walking status.
firmware/
src/
config.h — WiFi SSID/password, server IP, port
main.cpp — 50 Hz sample loop → angle → WebSocket send
imu.h / imu.cpp — MPU-6050 initialisation and raw read
angle.h / angle.cpp — complementary filter, flexion, calibration
wifi_ws.h / wifi_ws.cpp — WiFi connect, WebSocket client
backend/
main.py — FastAPI server: WebSocket receiver, REST API, static file host
step_detector.py — STANCE/SWING state machine step detector
scorer.py — 0–100 session scoring (ROM + consistency)
seed_harnold.py — Dev seed: 2 months of fake recovery data for "harnold"
requirements.txt
database.db — SQLite (auto-created on first run)
app-frontend/
index.html — Single-page app (login, walk test, recovery pages)
style.css — DM Sans + Playfair Display, green theme
app.js — WebSocket client, chart rendering, day aggregation
images/
duck.png
feet.png
PlatformIO (VS Code extension recommended). It downloads the toolchain and all libraries automatically.
Edit firmware/src/config.h:
#define WIFI_SSID "your-network-name"
#define WIFI_PASS "your-password"
#define WS_HOST "192.168.x.x" // your laptop's local IP
#define WS_PORT 8000Find your laptop IP on Windows:
ipconfig # look for "IPv4 Address" under the active adapterpio run -e freenove_esp32_s3_wroompio run -e freenove_esp32_s3_wroom -t uploadIf the board is stuck on waiting for download: hold BOOT, tap RST, release BOOT, then re-run the upload command. Press RST once flashing finishes.
pio device monitor # 115200 baudOutput with DEBUG_MODE defined:
IMU1: 12.4 IMU2: -3.1 FLEX: 15.5 dt: 20ms WS: connected
Change the COM port in platformio.ini if needed:
upload_port = COM7Python 3.11+
python -m venv .venvActivate:
| Shell | Command |
|---|---|
| PowerShell | .venv\Scripts\Activate.ps1 |
| cmd | .venv\Scripts\activate |
| Mac / Linux / Git Bash | source .venv/bin/activate |
pip install -r backend/requirements.txtDependencies: fastapi, uvicorn, sqlmodel, python-multipart, tzdata
uvicorn backend.main:app --host 0.0.0.0 --port 8000 --reloadPort conflict? If you get
WinError 10013, try--port 8080and updateWS_PORTinconfig.hto match.
Schema error on startup? Delete
backend/database.db— it is recreated automatically. This happens when upgrading from an older schema.
Open in your browser after starting the server:
http://localhost:8000/app
The frontend is a static SPA served by FastAPI from app-frontend/. No build step required.
Login — Enter your name (or a new name to create a new patient profile).
Walk Test — Main screen. Shows:
- Live knee flexion angle and step count (streamed via
/feedWebSocket) - Device connection status with 3-second silence watchdog
- Your Day card: aggregated daily score, total steps, ROM breakdown across all sessions today
- 15s Walk Test button — starts a timed demo session; results slide in inline
- Calibrate button — sends a zero-reference command to the ESP32 (hold leg straight for 2 s)
- Auto-sessions run silently in the background whenever walking is detected
Recovery — Bar chart history with four tabs:
- Daily — 24 hourly bars for today
- Weekly — 7 daily bars for this week
- Monthly — day-by-day bars for this month
- Overall — one bar per calendar month, all time
Bar height = total steps in that period. Bar colour = average session score (red → orange → yellow → green → A-grade green).
A switch user link appears below the banner on both Walk Test and Recovery pages. It pre-fills the current name so you can edit or replace it.
Sessions start and stop automatically — no button needed.
| Walk status | What happens |
|---|---|
idle → walking |
A new continuous session begins silently |
walking → stopped |
Session is scored and saved; "Your Day" card updates |
Walking is detected when ≥ 2 steps occur within a 4-second window. A session stops after 3 seconds with no new steps.
The 15s Walk Test button (demo mode) runs a fixed-duration session instead and displays results inline when it finishes.
backend/scorer.py produces a 0–100 score from per-step ROM values:
| Component | Weight | Measure |
|---|---|---|
| Max ROM score | 15% | How close the best single step was to 55° target |
| Avg ROM score | 60% | How close the mean step ROM was to 43° target |
| Consistency score | 25% | Step-to-step repeatability (penalises high std dev) |
Grades: A ≥ 85 · B ≥ 70 · C ≥ 62 · D ≥ 48 · F < 48
Scores above 78 receive a small boost so genuine A-grade walking scores near 95–100 rather than being capped at the weighted sum. Low-ROM gaits (post-surgical, limping) score in D–F as expected — the scale is designed to show meaningful progress even when absolute ROM is still limited.
backend/step_detector.py — online STANCE/SWING state machine running on each 50 Hz packet:
- Pre-filter: 5-sample moving average (~100 ms) to smooth noise before transitions
- STANCE → SWING: knee flexion rises above 12°
- SWING → STANCE (valid step): flex drops to 70% of the swing peak, step duration 300–2500 ms
- Step timing gate: rejects stumbles (< 300 ms) and pauses (> 2500 ms)
- Walking detection: ≥ 2 completed steps within a 4 s sliding window
- Stop detection: no step for > 3 s after walking
The 70% adaptive exit threshold means reduced-ROM patients (peak flex 20–30° post-op) are detected correctly without requiring a fixed low-angle return.
| Endpoint | Description |
|---|---|
WS /ws |
ESP32 connects here, pushes {"t":ms,"a1":°,"a2":°,"flex":°} at 50 Hz |
WS /feed |
Browser subscribes for forwarded IMU frames + status events |
/feed message types:
| Method | Endpoint | Description |
|---|---|---|
POST |
/set_patient?patient_id=x |
Set active patient for auto-sessions |
GET |
/current_patient |
Return the currently active patient ID |
| Method | Endpoint | Description |
|---|---|---|
POST |
/start_demo?patient_id=x&duration_s=15 |
Start a timed demo session |
GET |
/demo_status |
Poll demo progress / result |
POST |
/stop_demo |
Early-stop demo and save |
POST |
/start_continuous?patient_id=x |
Start open-ended session |
GET |
/continuous_status |
Live stats for the running session |
POST |
/stop_continuous |
Stop and save continuous session |
POST |
/calibrate |
Forward calibration command to ESP32 |
All history endpoints are bucketed and timezone-aware (America/Chicago). Each bucket includes total_steps, avg_score, session_count, and a sessions array.
| Method | Endpoint | Description |
|---|---|---|
GET |
/history/daily?patient_id=x&date=YYYY-MM-DD |
24 hourly buckets for one day |
GET |
/history/weekly?patient_id=x&week_start=YYYY-MM-DD |
7 daily buckets (Monday start) |
GET |
/history/monthly?patient_id=x&month=YYYY-MM |
Day-by-day buckets for one month |
GET |
/history/alltime?patient_id=x |
All sessions bucketed by calendar month |
| Method | Endpoint | Description |
|---|---|---|
GET |
/health |
{"status":"ok","esp32_connected":bool} |
GET |
/metrics?patient_id=x |
Aggregate lifetime stats |
GET |
/steps?session_id=n |
Per-step records for a session |
GET |
/score?session_id=n |
Score record for a session |
GET |
/app |
Serves the Waddle SPA (index.html) |
SQLite at backend/database.db, managed by SQLModel. Three tables:
SessionData — one row per session
id · patient_id · start_time (UTC naive) · duration_s · mode
mode is "continuous" (auto-session), "demo" (15 s test), or "manual" (legacy API).
StepEntry — one row per detected step
id · session_id · timestamp_ms · peak_flex · min_flex · rom · duration_ms
ScoreRecord — one row per session
id · session_id · overall · max_rom_score · avg_rom_score · consistency_score · grade · interpretation · max_rom · avg_rom · std_rom · step_count
To populate a demo patient with 61 days of realistic recovery data (grades F → D → C → B → A):
python backend/seed_harnold.pyThen log in as harnold on the frontend. The Recovery → Overall tab shows the full improvement arc.
Re-running the script prompts before overwriting existing data.
backend/esp32.py can feed synthetic IMU data to the server without physical hardware — useful for testing the step detector and scoring pipeline.
The dashboard works without a device connected — the device status dot shows amber ("disconnected") but all history and scoring features are fully usable.
{"t": 4521, "flex": 15.5} // IMU frame (50 Hz) {"steps": 12, "walk_status": "walking"} // step/walk status broadcast {"esp32_connected": true} // device connect/disconnect