Turn a manual (non-motorized) treadmill into an Xbox controller, so you can walk and run inside PC games. A magnet on the belt roller passes a hall sensor wired to an ESP32; the ESP32 counts pulses and a Python script translates pulses-per-second into a virtual Xbox 360 left-stick deflection (plus optional sprint button).
Tested target: Skyrim. Should work with any game that takes a standard Xbox controller.
This project started from these two earlier mouse-sensor-based VR treadmill projects, and inherits the same general idea (read motion of the belt, drive a virtual joystick):
- https://github.com/ZeGollyGosh/VR-Treadmill
treadmill.py(original mouse-based approach) - https://github.com/Mark-Renzi/VR-Treadmill
Maratron's main divergence is dropping the optical mouse pointed at the belt in favor of a hall-effect reed sensor + ESP32, which is much more robust (no drift, no need to wake up the mouse, no mouse.position = (700, 500) trick to recenter, no Windows mouse acceleration to fight).
- Sensor: hall-effect / reed switch sensing magnets glued to the front roller of the treadmill (11 magnets, one revolution = 12.9 cm of belt travel).
- MCU: ESP32, Arduino framework. Single sketch in
arduino/treadmill_to_py/. - Transport: USB serial @ 115200 baud, request/response (no streaming).
- Host: Python 3 on Windows.
pyserial: talks to the ESP32vgamepad: exposes a virtual Xbox 360 controller via ViGEmBuspydantic: typed profile config
flowchart LR
subgraph Treadmill["Treadmill (physical)"]
Roller["Front roller<br/>11 magnets"]
Hall["Hall / reed sensor"]
Roller -- "magnet passes" --> Hall
end
subgraph ESP["ESP32 (firmware)"]
ISR["hallISR()<br/>2ms debounce"]
Counter["pulseCount<br/>volatile uint32_t"]
SerialFW["Serial @ 115200<br/>R / C protocol"]
Hall -- "FALLING edge<br/>on GPIO 4" --> ISR
ISR --> Counter
Counter --> SerialFW
end
subgraph PC["Windows host"]
Py["python/src/treadmill.py<br/>(pyserial + pydantic)"]
VG["vgamepad<br/>(ViGEmBus driver)"]
Game["Game<br/>(Skyrim, ...)"]
SerialFW <-- "USB serial" --> Py
Py -- "left stick + sprint" --> VG
VG -- "virtual Xbox 360 pad" --> Game
end
- Hall sensor signal -> ESP32 GPIO
4(HALL_PINin the sketch).INPUT_PULLUPis used; the sensor pulls the line to GND on each magnet pass, which fires aFALLINGinterrupt. - 11 magnets are mounted around the front roller. With a roller circumference of ~12.9 cm, each pulse =
12.9 / 11 ≈ 1.17 cmof belt travel. AdjustAMOUNT_OF_MAGNETSandONE_REVOLUTION_CMinpython/src/treadmill.pyto match your physical setup.
File: arduino/treadmill_to_py/treadmill_to_py.ino
pulseCountis avolatile uint32_tincremented insidehallISR().- The ISR has a 2 ms software debounce (
interruptTime - lastInterruptTime > 2). Cheap reeds can ring; without this you'd get phantom pulses on every magnet pass. - The main loop does no work until the host asks for data. There is no autonomous "tick"; the ESP32 just keeps counting.
- Serial protocol:
- Host sends
R(single byte, "Request") -> ESP32 repliespulseCount,millis()\n(CSV, two unsigned ints). - Host sends
C("Clear") -> ESP32 resetspulseCountto 0 undernoInterrupts()and repliesACK:RESET.
- Host sends
- The pulse count is absolute and monotonic (until reset). The host computes deltas; this means a dropped serial frame doesn't lose steps.
sequenceDiagram
participant Magnet as Magnet on roller
participant ISR as ESP32 hallISR
participant MCU as ESP32 main loop
participant Host as Python host
Note over ISR: pulseCount = 0
loop every belt rotation
Magnet->>ISR: FALLING edge on GPIO 4
ISR->>ISR: debounce 2ms<br/>then increment pulseCount
end
Note over Host: every 100 ms
Host->>MCU: byte R - request
MCU->>Host: CSV reply pulseCount,arduino_ms
Host->>Host: delta = pulseCount - last<br/>dt = arduino_ms - last_ms
opt manual reset
Host->>MCU: byte C - clear
MCU->>ISR: noInterrupts, pulseCount = 0
MCU->>Host: reset acknowledged
end
File: python/src/treadmill.py
Per poll (~100 ms):
- Write
R, read one line, parsepulse_count, arduino_millis. new_pulses = pulse_count - last_pulse_countandinterval_s = (arduino_millis - last_arduino_ms) / 1000. Using the Arduino's own clock for the delta avoids any jitter from Python sleep timing.pulses_per_sec = new_pulses / interval_s.- Normalize:
speed = pulses_per_sec / max_pulses_per_second-> ~0..1 for walking, >1 when running. - Apply
gain, clamp to[0, 1]. - EMA smoothing:
filtered = filtered * (1 - smoothing) + speed * smoothing. - Snap to 0 when stopped, apply deadzone.
- Sensitivity curve: two-segment piecewise, quadratic below
walk_thresholdfor fine low-speed control,x^0.6above for a softer ramp into running. (This is the part we want to replace with a user-editable curve, see roadmap.) - Scale to Xbox stick range (
-32768..32767) and push togamepad.left_joystick(x=0, y=joy_y). - Sprint: when
joy_y > run_threshold * 32767, pressrun_button(HOLDkeeps it pressed;CLICK_RELEASEtaps it for 100 ms;NONEdisables it). For Skyrim, full-stick deflection feels like a walk in-game, so the default profile usesNONEand you tap sprint manually.
flowchart TD
Poll["Poll Arduino<br/>write 'R', read line"]
Parse["Parse 'pulseCount,millis()'"]
Delta["new_pulses = pulses - last_pulses<br/>dt = arduino_ms - last_ms"]
PPS["pulses_per_sec = new_pulses / dt"]
Norm["speed = pps / max_pulses_per_second<br/>(0..1 walking, >1 running)"]
Gain["speed *= gain<br/>clamp [0, 1]"]
EMA["filtered = filtered*(1-s) + speed*s<br/>(EMA smoothing)"]
Dead{"filtered < deadzone<br/>or stopped?"}
Zero["filtered = 0"]
Curve{"filtered < walk_threshold?"}
Low["curved = (f/wt)² · wt<br/>(fine control)"]
High["curved = wt + ((f-wt)/(1-wt))^0.6 · (1-wt)<br/>(softer ramp)"]
Joy["joy_y = int(curved · 32767)"]
Stick["gamepad.left_joystick(0, joy_y)"]
Sprint{"joy_y ><br/>run_threshold · 32767?"}
Press["press / hold / tap<br/>run_button"]
Release["release run_button"]
Update["gamepad.update()"]
Poll --> Parse --> Delta --> PPS --> Norm --> Gain --> EMA --> Dead
Dead -- yes --> Zero --> Curve
Dead -- no --> Curve
Curve -- yes --> Low --> Joy
Curve -- no --> High --> Joy
Joy --> Stick --> Sprint
Sprint -- yes --> Press --> Update
Sprint -- no --> Release --> Update
Update --> Poll
On Ctrl+C, total pulses are converted to centimeters via DISTANCE_PER_PULSE_CM and appended to distance_log.json.
| Goal | Parameter | Direction |
|---|---|---|
| Walk MORE to reach 100% | max_pulses_per_second |
increase |
| Walk LESS to reach 100% | max_pulses_per_second |
decrease |
| Feels sluggish | smoothing |
increase |
| Joystick too twitchy | smoothing |
decrease |
| Ignore tiny accidental movements | deadzone |
increase |
| Sprint triggers too early | run_threshold |
increase |
The active profile is defined at the bottom of python/src/treadmill.py (currently skyrim). Edit and re-run.
Prereqs:
- Python 3.10+ on Windows
- ViGEmBus driver installed (required by
vgamepad) - Arduino IDE (or PlatformIO) with ESP32 board support
- A USB cable and a known COM port (default expected:
COM7, changeSERIAL_PORTinpython/src/treadmill.py)
Install Python deps:
python -m venv .venv
.\.venv\Scripts\activate
pip install -r requirements.txtFlash the ESP32:
- Open
arduino/treadmill_to_py/treadmill_to_py.inoin Arduino IDE. - Select your ESP32 board + port, upload.
- Open Serial Monitor at 115200 once to confirm you see
ESP32 Treadmill Sensor Ready.... Close the monitor (it holds the port) before running the Python script.
.\.venv\Scripts\python.exe python\src\treadmill.pyYou should see Connected. Waiting for data.... Step on the belt: Total / New / Uptime / Time since last should print each time pulses arrive. The virtual Xbox controller is now driving the left stick.
Ctrl+C to stop. A line is appended to distance_log.json with total cm/m/km for the session.
Layout:
arduino/treadmill_to_py/ ESP32 firmware (single .ino)
python/src/
treadmill.py main entry point
max_pps_view.py calibration: walk to find your real max pulses/sec
debug.py minimal serial probe (sanity check the link)
distance_log.json per-session distance log (gitignored)
curve.json placeholder for future curve tuner output
.legacy/ earlier mouse-sensor approach + curve editor (reference only)
Calibration workflow when first setting up:
- Pulse-per-cm: power off, mark the belt, move it ~30 cm by hand, count pulses in
debug.py. Plug values intoAMOUNT_OF_MAGNETSandONE_REVOLUTION_CM. - Max pulses/sec: run
max_pps_view.py, walk at the pace that should be "100% walk" for several seconds, note the peak. Setmax_pulses_per_secondin your profile to that number. - Smoothing/deadzone/threshold: launch a game, iterate on the profile until walking feels right and the sprint trigger lines up with where you'd naturally start jogging.
When tweaking the sketch, remember to close the Arduino Serial Monitor before running the Python script, only one process can hold the COM port at a time.
Most of these were sketched in the original plan and have reference implementations sitting in .legacy/ from the earlier mouse-sensor era. Items are roughly ordered by what unblocks what.
-
Curve tuner (PyQt6). Replace the hardcoded two-segment piecewise curve with a draggable spline. Reference implementation:
.legacy/mouse_sensor/src/Screens/CurveEditorWindow.py(drag/add/delete control points, draggable trigger-zone slider, save/load to JSONcurve.jsonis the expected on-disk format). Integration is the part that was half-done in the deletedpython/src/ui.py: after computingfiltered_speed, feed it tocurve_data.interpolate_y_from_points(int(filtered_speed * 32767)), then convert the returned graph-Y back to a joystick value via(margin + graph_height - y) / graph_height * 32767. Replaces thewalk_threshold/ curve power constants. -
Distance log viewer.
distance_log.jsonalready accumulates per-session totals. A small tab showing daily/weekly km, a graph, and per-profile attribution would close the loop. Models already drafted:UsageMetricsin.legacy/mouse_sensor/src/classes/profile.py. -
Profiles. One curve + sprint config per game, auto-switched by foreground window name. Reference models in
.legacy/mouse_sensor/src/classes/profile.py:HardwareConfigbelt size, magnets/revolution (per treadmill, not per game)UserConfigstep size, max walking speed (per user)GameProfilewindow name, run trigger threshold, curve points (per game)UserProfilebundles the above + metricsAppStatetop-level container with active user/profile
-
Boot wizard. First-launch flow: pick/create user, calibrate hall sensor sensitivity, calibrate stride length by walking N known steps and entering N, create a default profile.
-
Auto profile switching. Watch foreground window title; switch the active
GameProfilewhen it matches. Original notes also suggested toggling Windows mouse acceleration on start/stop keep that idea in mind if any games need it. -
Sprint mode polish. Today's "click_release" mode taps the button once on threshold crossing. Some games want repeated taps while held above threshold make this configurable.
-
Hot-reload of profile. Editing
python/src/treadmill.pyrequires a restart; the eventual UI should swap profiles live without reconnecting the gamepad.
vgamepadrequires the ViGEmBus driver. IfVX360Gamepad()raises, install/repair the driver first.- The default
SERIAL_PORT = "COM7"is host-specific check Device Manager. - The 2 ms ISR debounce in the sketch is tuned for ~11 magnets at walking speed. If you have many more magnets or run very fast, you may start dropping pulses; raise it cautiously.
distance_log.jsonis appended every session and is gitignored.- The 100 ms poll interval is a compromise: shorter = lower input lag but more serial chatter; longer = smoother averaging but laggy direction changes.