A self-contained training assistant running on an M5Stack Core S3. It guides you with sound, colour and light so you can train without looking at the screen. It needs no internet, no phone, no watch and no wristband.
One device, two kinds of training, same philosophy — alternating effort and rest:
- Interval training. Configurable work and rest cycles, a countdown timer and a stopwatch. It started as an assistant for finger-strength work on a climbing hangboard, 10 s hang / 20 s rest — but that is just one exercise among many it can drive.
- Reactive training. Two units light up as pads and you react: reaction speed, visual and auditory memory, and balance. Six games, synchronised over ESP-NOW.
Precise intervals, two synchronised units, signalling through sound, colour and light: all of it goes well beyond climbing. With paced stimuli you can train strength, balance, dexterity and memory, from children to older adults — general fitness, rehabilitation work, or exercise with a playful edge. The cycles adjust to the pace of each exercise and each person.
| Main menu | Reflex menu |
|---|---|
![]() |
![]() |
This is not a medical device. It is an interval-training and stimulus-response aid, not a prescriber of load, and it is not clinically validated. Warm up, progress gradually, and stop on sharp pain.
| Component | Role | Required |
|---|---|---|
| M5Stack Core S3 | Everything essential: touch screen, internal speaker, LTR-553 proximity sensor, BMI270 IMU, ESP32-S3 with ESP-NOW | Yes |
| M5GO Bottom3 | 10 addressable RGB LEDs + battery | No — recommended |
| GoPlus2 | Battery only | No |
A bare Core S3 is enough. The bases are additive: the LED bar is bonus feedback and the batteries just make it portable. The second unit in the reactive games runs the exact same firmware with no LED base at all — the LED protocol is write-only, so nothing ever waits for a strip that isn't there.
Sound comes from the Core S3's internal speaker (M5.Speaker), with no
accessories — that is what makes hands-free use work in the first place. If you want
something more discreet, the sound can be turned off or turned down.
- 10/20 — fixed cycle, zero configuration. 5 s prep, then 20 × (10 s work / 20 s rest).
- Custom — work, rest, reps per set, number of sets, rest between sets. Ships pre-filled as 7/3 repeaters. Saved to NVS.
- Timer — hours:minutes:seconds countdown with an end-of-time alert. Saved to NVS.
- Stopwatch — counts up, with a scrollable lap list.
Why 10/20? The fixed mode reproduces a published protocol rather than a number someone liked the sound of. In Gilmore et al., Sports Medicine – Open 2024, low-intensity hangs of 10 s on / 20 s off across a ~10-minute block — the "Abrahangs" routine, co-authored by Emil Abrahamsson — produced grip-strength gains equal to maximal hangs, and the two proved additive when combined. The ten minutes are not arbitrary either: collagen-synthesis signalling in loaded connective tissue peaks at around 10 minutes of loading and stays refractory for hours afterwards (Paxton et al., Tissue Eng Part A 2012; Baar, Sports Med 2017) — which is the argument for a short block repeated through the day rather than one long session.
Lilo supplies the cadence, not the load. That protocol asks for roughly 40% of maximum — "a light strain on the forearms" — and at least 6 hours between sessions. Both are yours to judge, and Custom will run any other work/rest ratio you want.
| Editor | Rest between sets | Finished |
|---|---|---|
![]() |
![]() |
![]() |
The rounds counter shows what is left in the current set, and the set indicator only
appears when there is more than one set. The final screen shows the work as you
configured it — 1 round x 2 sets.
All three timed modes share one idle screen: mode name in the bar, total duration above the play button. While counting, the bar swaps the name for whatever is actually changing — the workout's remaining total in the cycle modes, nothing at all in the timer.
| 10/20 | Custom | Timer | Stopwatch |
|---|---|---|---|
![]() |
![]() |
![]() |
![]() |
Both screens act as pads. The proximity sensor detects a hand approaching, so you can hit a pad without touching it.
| Game | Input | What it trains |
|---|---|---|
| Reaction | proximity or touch | reaction time, target discrimination (green = go, other colours = decoy) |
| Survival | proximity or touch | same, sudden death, pace accelerates |
| Sequence | touch only | visual and auditory memory — Simon, growing sequence |
| Twist | gyroscope | reaction with a wrist-rotation gesture |
| Balance | accelerometer | continuous control — hold the pad level; first to tip loses |
| Duel | proximity or touch | 1v1 head-to-head, best of N |
Every one runs the same shape — configure, get ready, react, see where you landed:
| Reaction | Survival | Sequence |
|---|---|---|
![]() |
![]() |
![]() |
| Twist | Balance | Duel |
|---|---|---|
![]() |
![]() |
![]() |
Each game has its own settings, saved to NVS. There are five in total, and each game uses the ones that make sense for it:
| Setting | What it controls | Range | Default |
|---|---|---|---|
| ROUNDS | how many presentations before the game ends | 3 to 20 | 5 |
| WINDOW | how long you have to react before it counts as a miss | 0.1 to 10 s (±0.1) | 2.5 s |
| INTERVAL | the wait between presentations | 0.1 to 10 s (±0.1) | 2.0 s |
| MODE | how the interval evolves through the game (see below) | FIXED/RAND/DECR | RAND |
| THRESHOLD | the tilt that counts as tipping over, in Balance | 3° to 30° | 7° |
| BEST OF | rounds needed to win a Duel — odd numbers only, first to the majority | 3 to 9 | 3 |
MODE is what changes the character of a game the most:
- FIXED — the wait is always the same. You can settle into the rhythm and anticipate.
- RAND — drawn between 0.1 s and the value you set, so there is nothing to anticipate. This is what measures reaction proper, rather than a sense of timing.
- DECR — starts at the value you set and shortens by 0.15 s per round, down to a floor of 0.1 s. The game tightens on its own for as long as you last.
| Game | Settings | Defaults |
|---|---|---|
| Reaction | rounds · window · mode · interval | 5 · 2.5 s · RAND · 2.0 s |
| Survival | window · mode · interval | 2.5 s · DECR · 2.0 s |
| Sequence | window · mode · interval | 3.0 s · FIXED · 0.6 s |
| Twist | rounds · window · mode · interval | 5 · 2.5 s · RAND · 2.0 s |
| Balance | threshold · mode | 7° · FIXED |
| Duel | best of · window · mode · interval | 3 · 2.5 s · RAND · 2.0 s |
Three games read the settings their own way. Survival has no rounds — it is sudden death, and lasts as long as you do. In Sequence, the window is the time allowed per touch and the interval is the tempo the sequence is played back at, not a wait. And Balance has no RAND: a tilt limit tightening at random would be a lottery rather than a game — under DECR the threshold closes by 1° every 5 seconds, down to 3°.
The defaults are not uniform by accident either. Survival ships on DECR because accelerating is what it is, and Sequence ships on FIXED because what you memorise is a steady tempo — randomising it would make memory compete with surprise.
You configure one game at a time, each with its own fields:
| Reaction | Sequence | Duel |
|---|---|---|
![]() |
![]() |
![]() |
Balance is the odd one out — instead of reacting to a flash, you hold the unit level and the screen tells you how close to tipping you are. Green is safe, amber is a warning, and the beeps get more insistent as you approach the threshold.
Flash the same firmware to both. On first entry into REFLEX each unit asks what it is:
- HOST — runs the game logic, timing and scoring. It is also a pad.
- PAD — lights up when told, detects hits and reports back.
The role is your choice and is remembered in NVS; tap the title bar to change it. The pairing itself is automatic: while unpaired, each unit broadcasts its role over ESP-NOW and adopts the first unit with the complementary role. No MAC addresses in the source, no per-unit builds.
If both units end up with the same role they will not pair, and both screens say so instead of waiting forever. A third unit in the room stays inert rather than interfering — this is a two-unit design.
| Choosing a role | Paired and waiting | Both set the same |
|---|---|---|
![]() |
![]() |
![]() |
These are the three channels from the introduction: the sound, the colour of the screen
and the light of the LEDs. The state machine emits events (EnteredWork,
FinalCountdown, EnteredDone, …) consumed in parallel by three independent layers, so
changing one never touches the logic of the modes.
Sound — high tone entering work, low tone entering rest, triple beep over the last 3 seconds, a short melody on completion.
Screen — phase name and background colour, big phase seconds, rounds remaining, total-workout progress bar. Rendered to a single PSRAM canvas and pushed in one go, so transitions are atomic and flicker-free.
The top bar follows one rule everywhere in the app: back arrow left, actions right, and in
the middle the name of what you picked while nothing is moving, live information while it
counts. So 10/20 on the idle screen becomes the workout's remaining total once you
start, and TIMER gives way to nothing at all. A name is something you chose seconds ago;
the time is the thing that keeps changing, and it earns the space.
LEDs — colour says what (green = working, blue = rest, orange = long rest, yellow = about to start, red = last 3 seconds of work), and the number lit says how much is left. The bar empties like an hourglass rather than filling. Two axes of information at a glance, which is what makes hands-busy use work.
In the last 3 seconds the colour changes and stays — it does not blink. With only 3 seconds to read, blinking is noise.
| Working | Resting | Three seconds left |
|---|---|---|
![]() |
![]() |
![]() |
Brightness · sound on/off + volume · LEDs on/off + intensity · screen sleep (on/off and inactivity timeout) · theme (vivid / digital / earthy) · language (PT/EN). All persisted in NVS. The screen sleeps on idle screens only — never mid-workout — and wakes on a hand waved past the proximity sensor.
The theme changes the whole screen — phase backgrounds and every accent in the app. The LEDs always keep the vivid scheme: desaturating them would flatten the phase distinction and the red warning, which is exactly the channel you rely on when you are not looking at the screen.
The reflex games keep their own fixed colours for the same reason, only stronger: there the colour is the rule (green = touch, anything else = do not), and telling the two apart is a reaction measured in milliseconds. Making that discrimination depend on an appearance setting would change the difficulty of the game. Only the chrome, the countdown and the result screens follow the theme.
The settings screen itself, in all three, and the menu cycling through them:
| Vivid | Digital | Earthy | Cycling |
|---|---|---|---|
![]() |
![]() |
![]() |
![]() |
And the screen where it matters most — the last 3 seconds of work, where the background is the warning:
| Vivid | Digital | Earthy |
|---|---|---|
![]() |
![]() |
![]() |
And every colour of the three palettes, side by side:
Built with PlatformIO (VS Code), Arduino framework, board m5stack-cores3.
Dependencies (platformio.ini) are installed on first build:
M5Unified, FastLED, Preferences.
Development was assisted by Claude Code, inside VS Code.
- Install VS Code and the PlatformIO IDE extension — that is what brings the
piocommand. - Clone the repository and open the folder in VS Code.
- Connect the Core S3 over USB-C.
pio run -t upload.
The first build downloads the ESP32 toolchain and the libraries and takes a few minutes;
later ones are quick. Nothing has to be installed by hand — platformio.ini
handles the libraries. The Core S3 uses the ESP32-S3's native USB, so it shows up as a
serial port with no extra driver; if it does not, pio device list shows what the system
can see.
| Action | Command |
|---|---|
| Build | pio run |
| Build and flash | pio run -t upload |
| Flash and open serial monitor | pio run -t upload -t monitor |
| List USB ports | pio device list |
| Pick the port explicitly | pio run -t upload --upload-port /dev/cu.usbmodem… |
These all act on m5stack-cores3, the production firmware, because
platformio.ini sets it as default_envs. Every other environment is a
developer build and needs an explicit -e — see below.
Flash both units with the same command — roles are chosen on the device, not at build time.
Notes for reproducing the build:
- The base LED strip is on GPIO5, 10 LEDs, GRB order. The base also needs 5 V on
the bus:
M5.Power.setExtOutput(true)(done inLedFeedback::begin()). - The proximity sensor (LTR-553ALS) sits at address 0x23 on
M5.In_I2C. If it does not answer, the firmware degrades gracefully — touch still wakes the screen. - Flashing replaces the factory UiFlow2 firmware. It is reversible: reinstall UiFlow2 with M5Burner.
Isolated bring-up environments used while developing, each behind a build flag. They
build the same tree with a different main, so they need an explicit -e:
| Environment | What it is for | Flash with |
|---|---|---|
m5stack-cores3 |
the real firmware (default) | pio run -t upload |
shot |
USB screen capture for the docs | pio run -e shot -t upload |
reflex-r1 |
ESP-NOW transport — PING/PONG + latency, both units | pio run -e reflex-r1 -t upload |
reflex-r2 |
hit detection over the proximity sensor, one unit | pio run -e reflex-r2 -t upload |
reflex-r3 |
bare reaction game, both units | pio run -e reflex-r3 -t upload |
reflex-r7-imu |
wrist-rotation tuning (BMI270), one unit | pio run -e reflex-r7-imu -t upload |
reflex-r8-balance |
tilt tuning for Balance (BMI270), one unit | pio run -e reflex-r8-balance -t upload |
Add -t monitor to any of them to open the serial monitor straight after flashing.
shot runs at 921600 baud, the rest at 115200.
The environments do not coexist on the device. Each flash replaces the previous one entirely, so the list below is not a chain of installs — it is the complete set of commands, one at a time, depending on what you want on the unit at that moment. To get back to the normal firmware, flash the first one:
pio run -e m5stack-cores3 -t upload # production firmware (same as `pio run -t upload`)
pio run -e shot -t upload # USB screen capture
pio run -e reflex-r1 -t upload # ESP-NOW: PING/PONG + latency (both units)
pio run -e reflex-r2 -t upload # hit detection over proximity (one unit)
pio run -e reflex-r3 -t upload # bare reaction game (both units)
pio run -e reflex-r7-imu -t upload # wrist rotation, BMI270 (one unit)
pio run -e reflex-r8-balance -t upload # Balance tilt, BMI270 (one unit)
Because default_envs is set, a plain pio run only compiles the production firmware.
To check that a change has not broken the bring-up builds, name them all:
pio run -e m5stack-cores3 -e shot -e reflex-r1 -e reflex-r2 -e reflex-r3 \
-e reflex-r7-imu -e reflex-r8-balance
Both run from the repo root and need pyserial + pillow. macOS system Python has
neither, so activate whatever environment you installed them into first — otherwise they
stop and tell you which interpreter is missing what.
Capturing screenshots with env:shot — see tools/screenshot.py:
pio run -e shot -t upload
pip install pyserial pillow
python3 tools/screenshot.py # Enter captures whatever is on screen
Assembling the README's animated GIFs from those captures — see tools/gifs.py. No ffmpeg involved; the frames are real screens put in a row, and the sequences live at the top of the file:
python3 tools/gifs.py # all of them
python3 tools/gifs.py 1020 timer # just these
src/
main.cpp setup/loop — non-blocking loop (millis() only, never delay())
Modes.* orchestration: screens (menu/workout/settings), touch, events
StateMachine.* cycle modes: PREP → WORK → REST → SET_REST → DONE + events
TimerMode.* countdown engine; emits the same events
StopwatchMode.* stopwatch engine: counts up + laps
AudioFeedback.* beeps per event (M5.Speaker, non-blocking)
ScreenUI.* all rendering (M5Unified) + settings screen
Textos.h PT/EN strings, chosen inline where they are drawn
Settings.* global settings (brightness, sound, LEDs, sleep, theme, language) + NVS
LedFeedback.* colour per phase + emptying bar (FastLED)
Proximity.* LTR-553 proximity sensor — wakes the screen, feeds hit detection
Config.* WorkoutConfig (modes 1/2) + Timer namespace (mode 3) + NVS
# REFLEX mode (two units)
ReflexMode.* game engine (host: logic + scoring; pad: reactive)
Link.* ESP-NOW transport, role-based automatic pairing
HitDetector.* hit detection (baseline + edge + debounce) over Proximity
MotionDetector.* wrist-rotation detection over the gyroscope (Twist)
BalanceDetector.* tilt detection over the accelerometer (Balance)
ReflexConfig.* per-game settings + unit role + NVS
Screenshot.* USB screen capture (env:shot only)
Never delay(). Arduino runs a single loop(), and it is that loop which reads the
touch screen, redraws the display, steps the LEDs and feeds the sound. delay() is not
waiting while doing something else — it is stopping. A rest written as delay(20000)
would leave 20 seconds with no touch, no countdown on screen and no LEDs going out. So
time here is read rather than waited on: the moment a phase started is stored, and
every pass through the loop asks how much of it has gone by.
Copyright (C) 2026 Paulo Semblano.
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License version 3 or (at your option) any later version. See LICENSE for the full text.
It is distributed in the hope that it will be useful, but without any warranty — without even the implied warranty of merchantability or fitness for a particular purpose.
In practice: use it, study it, modify it and build on it freely, including commercially. If you distribute a modified version, it has to stay open under the same licence, keep the copyright notices, and say what you changed.






























