Repository navigation
Firmware Timing
Snapshot of
docs/firmware/timing.mdat commit707b4a0, preserved before the docs were compacted. History only: the current specs are in the repository.
This document lists every place where the firmware deals with time: the main loop, constants, the latency between pods, and how reaction times are measured.
Since firmware 2.0 the pods talk over ESP-NOW (see link-protocol.md). Values marked measured come from the BLE + ESP-NOW hardware test of ADR 0005 (3–4 pods on USB, 30 min runs, 2026-10-29) and the firmware 2.12 soak (central + 1 peripheral, phone connected, 30 min). Everything else is an estimate from the design; §6 lists what is still to measure.
Time bases:
-
esp_timer_get_time(): µs since boot, per pod. Used for the link, clock sync, button edges and reaction times. -
millis(): the same clock in ms. Used by blocks for delays and timeouts. -
Network time = the central's
esp_timer. Peripherals convert it with theirClockSyncoffset.
Arduino loopTask (core 0) WiFi task (higher priority)
──────────────────────────────────────── ──────────────────────────────
loop() → RoleManager::run() ESP-NOW receive callback
Central: button.update() → copy frame + rx timestamp into rxQueue
network.service() ESP-NOW send callback
└ link.service(): tx results, → tx status into txStatusQueue
retries, next frame per peer
└ rx frames → claims, PONG, events
BlockManager::run()
Peripheral: button.update(), link.service(),
rx frames → state machine
- No blocking calls in the game path. Delays, waiting for presses and waiting for radio frames all return to the loop. The only blocking spots are the 2 s boot animation and a 0.5 s white flash when pairing is cleared.
-
Effects (firmware ≥ 2.8) are computed on each pod from the synchronised clock, so pods
with the same start time change their LEDs in the same loop pass (± clock sync error).
Without a running effect
LEDManager::service()returns at once. While a smooth effect runs on an armed pod, a press can be seen up to oneshow()(~0.6 ms) later; the reaction time is still measured from the first LED update of the activation. -
LEDs are only rewritten on changes. Every driver write goes through
LEDManager::present(), which callsshow()only when the frame differs from the last one sent (#21). The peripheral no longer refreshes its LEDs every pass, so its loop runs in the µs range and polls the button and the radio queue very often. -
Idle wait (firmware ≥ 2.14, #21): in the idle states the loop waits up to 1 ms for a radio frame
(
EspNowLink::idleWait, a peek on the receive queue, so a frame wakes it at once). Otherwise it spins. The rule:- Idle (the loop may wait): peripheral UNCLAIMED and READY; central MENU, CONFIRM and ERROR. Nothing is due in these states: no activation is armed and no game timing runs.
- Never idle (the loop runs without a wait): peripheral SCHEDULED, ACTIVE, HOLDING, DONE; central DISCOVERY, RUN, PAUSED. A frame that arms an activation moves the state out of idle in the same pass, so the first pass of an armed activation already runs without the wait.
- Effects and their timers (esp_timer) do not depend on the wait, because they are timed from the clock, not from the loop pass.
-
Busy loop: outside the idle states
loop()still never sleeps. WiFi power save is off, so the radio is always receiving. This trades battery for latency. A ~1 h runtime target is acceptable. Light sleep is not used on claimed pods (see architecture.md §8). -
Logging (#21):
sdkconfig.NewBoardsetsCONFIG_LOG_MAXIMUM_LEVEL=3(INFO), soESP_LOGDandESP_LOGVare removed at compile time. No print runs per loop pass: the ESP_LOG calls are per event (frames lost, claims, state changes), andRESULT/SUMMARYlines are written once per measurement.
Measured on the central in RUN (ADR 0005): longest loop gap 7.6 ms without BLE; with the phone connected 15–17 ms, gaps > 10 ms about 4 per minute (the BLE controller and host share the one core). The table below is estimated.
| Situation | Dominant cost | Approx. pass time |
|---|---|---|
| Peripheral, idle / waiting for press | button read + empty queues | a few µs |
| Peripheral, pass with an LED change | one NeoPixel::show() (18 LEDs ≈ 0.54 ms + latch) |
~0.6–1 ms |
| Any pod, effect animating, frame unchanged | compute one frame (18 LEDs, a few colour scalings) | a few µs more |
| Any pod, effect animating, new frame | one show(), at most every 16 ms (smooth effects: dim, colorShift, pulse) |
~0.6–1 ms |
| Central, RUN, waiting block | button, network.service(), one execute()
|
µs range |
| Central, RUN, block chain | instant blocks in one pass (max 32), one show() per LED block |
~0.6–1 ms per LED block |
| Idle state (peripheral UNCLAIMED / READY, central MENU / CONFIRM / ERROR) | wait for a frame, at most 1 ms | 0–1 ms, a frame wakes it at once |
Any Serial.print / ESP_LOGx
|
USB-CDC output (Arduino log level INFO, ESP-IDF INFO) | variable |
| Where | Constant | Value | Effect |
|---|---|---|---|
| PeripheralRole | HELLO interval | 250 ms | discovery speed (unclaimed pods only) |
| PING interval | 200 ms | heartbeat + clock sync sample rate | |
| central timeout | 2 s without PONG | back to UNCLAIMED | |
| pairing reset | hold 3 s while unclaimed | set id cleared | |
| PodNetwork | pod timeout | 1.5 s without frames | game PAUSED |
| claim timeout | 0.5 s | try the next HELLO | |
| EspNowLink | tx timeout | 100 ms without send callback | counted as failed attempt |
| retries | ARM/RESET/EFFECT 5, PRESSED 8, CLAIM 3, PING/PONG 0 | ||
| queue | 8 frames per peer | oldest non-sent frame dropped when full | |
| ClockSync | window | 8 samples (1.6 s), min-delay sample used | |
remoteMultiPress |
syncStartMs |
20 ms (per block) | lead time for synchronous start |
| Where | Constant | Value | Effect |
|---|---|---|---|
main.cpp |
boot battery gauge | 2000 ms, loop every 10 ms (static ring) | role decision at the end |
main.cpp |
idle wait | 1 ms (EspNowLink::idleWait) |
only in the idle states, §1 |
AutoPowerOff |
idle time before power-off | 10 min (AUTO_POWER_OFF_MINUTES) |
timer restarts on every button edge (architecture.md §8.2) |
AutoPowerOff |
goodbye | 1200 ms (white drain, then dark) | then deep sleep |
LEDManager |
brightness cap |
LED_BRIGHTNESS_CAP 255 (full) |
applied to all output |
Battery.cpp |
battery sample | 16 × analogReadMilliVolts, refresh every 3000 ms |
|
ErrorAnimation.h |
blink | 1000 ms on / 1000 ms off | |
LEDManager |
MIN_FRAME_US |
16 ms | minimum time between two effect frames |
| default period | 1000 ms |
blink, pulse, spin with periodMs 0 |
|
CentralRole::runModelSelector |
ramp step | counter +1 every ~10 ms while held | |
| short press | release after ≤ 1.0 s | next program | |
| dead zone | release after 1.0 … 1.8 s | nothing | |
| long press | held > ~1.8 s | program selected | |
Input/Button |
lockout | 20 ms after each accepted edge | debounce. The first edge is accepted immediately |
button block |
shortMs |
default 1000 ms | short/long on release |
| File | Constant | Value |
|---|---|---|
solo.json |
random wait / result display | 800 … 5000 ms / 2500 ms |
lib/loop.json |
blue pause after MAX_LOOPS rounds |
3000 ms |
lib/star.json, lib/random.json
|
EnterProgram |
0 ms (no artificial gap) |
lib/multi.json |
pause between rounds / red flash | 3000 ms / 1000 ms |
ARM arrives (or scheduled start time reached)
→ setLEDColors(colorActive) LED data written (~0.6 ms)
→ t0 = esp_timer_get_time() reference point: LEDs just updated
... user reacts ...
button.update() sees LOW (first edge, then 20 ms lockout)
→ reaction = pressTimeUs - t0 sent in PRESSED (µs)
- Reference point is right after the LED data was sent, so it is within ~0.3 ms of the visible LED change (WS2812 latch). Earlier firmware started the timer one loop pass (~1 ms) before the LEDs changed.
- Resolution: the button is polled every pass. The peripheral loop is now in the µs range, so press detection adds well under 1 ms.
- Accuracy overall: about ±0.5 ms (estimate). The radio does not affect it, because the time is measured locally.
The button block with a pressed port continues on the press edge. With "record" it
measures press time − block entry time. The block is entered in the same pass, right
after the LED block that lit the central, so the accuracy is similar (≤ ~1 ms).
remoteMultiPress measures the central's own reaction from the moment it lit its LEDs at
the synchronised start time.
The charge and release blocks measure on the pod that owns the button, from the button edge
timestamps (the same Button code as above), not from when a frame arrives:
-
Hold time (
heldMsin RELEASED) = release edge − press edge. Accuracy as a press: well under 1 ms plus the 20 ms debounce lockout after an edge (a release within 20 ms of the press is not seen). -
Start reaction (
release,signal) = release edge − the moment the pod updated its LEDs to the go colour (HoldSession::doSwitch, reference point as in §3.1). A release before the switch time is a false start. - The central sees PRESSED / RELEASED a few ms later (§4). The
triggermode leaves the block at the central's receive time of RELEASED, so atimerStarton the next block starts a few ms after the physical release, as for remote presses. - Timeouts (
maxHoldMs, signaltimeoutMs,noHold) run on the central's clock and include that latency; the charge of an accumulating block at its time limit is taken on the pod.
ESP-NOW frames are sent immediately; there is no connection interval to wait for.
Measured (ADR 0005): 0 of 60 000 frames lost or retried in three 30 min runs, with and without a
phone. Ack round trip (send → ack received on the central, ackUsAvg / ackUsMax, worst pod)
1.6–1.8 ms average, 10.7 ms maximum. This round trip covers T2 + the peripheral's receive + the ack
back, so the estimates for T2 / R2 hold.
| Step | What happens | Estimate |
|---|---|---|
| T1 | Central loop decides (block chain in the same pass) | < 0.1 ms + LED shows of instant blocks |
| T2 |
esp_now_send → air → peripheral receive callback (≤ 64 byte frame at 1 Mbit/s) |
~0.5–2 ms |
| T3 | Peripheral loop picks the frame up, updates LEDs | ≤ ~1 ms |
| R1 | Press edge → PRESSED sent | < 0.1 ms |
| R2 | Air → central receive callback | ~0.5–2 ms |
| R3 | Central loop: event → block → next ARM | < 0.1 ms + LED shows |
| retry | lost frame, resent after the failed send callback | + ~1–3 ms each |
Remote press → next remote pod lit (random mode): R1 + R2 + R3 + T2 + T3 ≈ 2–5 ms.
With BLE (firmware 1.x) this was estimated at ~60–200 ms.
Remote press → central lit (star mode): R1 + R2 + R3 ≈ 1–3 ms.
Start skew in RUN modes: all pods light at the same network time, syncStartMs
(20 ms) after the block started. Measured (ADR 0005): clock sync offset jitter about 0.9 ms
(worst pod 1.1 ms), with and without the phone; no late ARM in 1 564 starts, every ARM arrived at least
9.7 ms before its start time; the latest peripheral LED start was 2.7 ms (no phone) and 3.7 ms
(phone) after the scheduled time. If an ARM needs so many retries that it arrives after the start time,
that pod lights late but measures correctly from its own LED onset. The total spread including the
central is not measured yet (§4.1, §6).
The central lights its ring from an esp_timer at the start time (LEDManager::playEffect
arms a one-shot timer for a future start; the timer runs in the esp_timer task). Before, the
central drew its first frame in the next loop pass, so with BLE on (loop gaps up to 17 ms,
ADR 0005 Findings) the start was up to one
loop gap late. The loop pass still draws the frames after the first one, at most every 16 ms.
The output is guarded by a mutex, because the loop and the timer both write the LEDs.
Blocks that light the central at a synchronous start (remoteMultiPress, toggle, effect,
delay countdown) arm it on enter, so the timer is set before the start time.
Measured values of the BLE + ESP-NOW test (ADR 0005, 3 pods, interval 100 ms): loop gaps > 10 ms about 4 per minute with the phone connected, longest gap 17 ms; central start lateness before this change was the loop gap (up to 17 ms), so the estimated spread was 9.3 ms (no phone) and 18.8 ms (phone). With the timer start the central's term should be gone; this is still to confirm on a logic analyser (§6).
| Phase | Estimate |
|---|---|
| Program selected → radio up | ESP-NOW start (BLE stays on from firmware 2.12), ~0.2–0.5 s |
| Per peripheral | waits for its next HELLO (≤ 250 ms) + CLAIM/ACK (a few ms). Claims are sequential |
| 7 peripherals | ≈ 1–2 s until green |
| Clock synchronised | first PING right after the claim; several samples before a game starts |
Before, BLE needed 2 s scan windows plus about 0.5–2 s per connection.
Done: frame loss, ack round trip, clock sync jitter, ARM margins and loop gaps (ADR 0005); BLE through a 30 min game with heap and interval (firmware 2.12 soak: minimum free heap 80 KB, interval 100 ms, no disconnect). Still open: the total start spread on a logic analyser (step 2, #24) and the reaction accuracy (step 3). Procedures: testing.md §8.
-
Link latency: log
rxTimeUsof PRESSED events against the central's ARM send time over a few hundred activations (CSV on serial). Or toggle a spare GPIO on ARM send (central) and on LED update (peripheral) and measure with a logic analyser. - Start skew: RUN5, connect the WS2812 data lines of two pods to a logic analyser, compare the start of the LED frames.
-
Reaction accuracy: LED data line vs. button line on one pod, compare with the
RESULTvalue. -
Loss: count
frame type … lostwarnings over a 30 min session, or readstatus.pods[].link(txLost,txRetries,ackUsAvg,ackUsMax).
The measured values replace the estimates in this document.
-
2.4 GHz congestion (gyms with many WiFi networks) adds retries and jitter. The
channel is fixed (
ESPNOW_CHANNEL). Choosing a less used channel can help. - Serial logging in hot paths can add delays when the console buffer fills. Use a lower log level for timing measurements.
- No pre-arming yet: pod → pod always goes through the central (2 hops). A direct pod-to-pod trigger would save one hop (~1–2 ms). See improvement-plan.md.
For users and coaches
Repository and workflow
Firmware
- Firmware Overview
- Firmware Architecture
- Firmware Link Protocol
- Firmware Config API
- Firmware Programs
- Firmware Testing
- Firmware Timing
- Firmware Host Syntax Check
Config App
Community platform (on hold)
Decisions (ADRs)
- Decisions
- ADR 0001 Repo Layout and Workflow
- ADR 0002 Versions and Firmware Delivery
- ADR 0003 Community Platform
- ADR 0004 Platform Architecture
- ADR 0005 Live Results over BLE
- ADR 0006 No OTA
- ADR 0007 Platform on Hold
Archive