Ride state machine¶
State model for RimRidge: which state is behind the state icon (directly above
rr_line_rq) and the pause/start button (rr_btn_pause), and how that relates to the
statistics (tour/trip/start-ride, average speed without stops/breaks/cruise).
Diagram: ride-state-machine.svg.
Status: implemented (src/Stats/Statistics.{h,cpp}, src/Stats/Distance.{h,cpp}, UI
integration §6). Tested on the device with tap/long press via Tools/uishot.py and the
simulator build; a real ride is still pending
(cheat sheet). The icons for free ride and start are
placeholders.
1. Principle¶
Recording (binary log, L_*.bin) always runs, independent of the ride state -- this
state machine only influences which parts of the ride count into the "start/ride"
statistics and what icon and button show. GPX and the like are still produced afterwards
in the tooling (Tools/bikelog/).
2. Two axes, six leaf states¶
Statistics::EDrivingState stays a single flat enum (one leaf = one state-icon image,
one time_in[][] slot), but the transitions follow two independent axes:
- Ride mode (
rideMode, manual by a tap on the pause/start button):false= free ride/cruise,true= ride (with coast as a sub-state, cadence hysteresis as in the oldStatistics::cycle()logic: <40 rpm -> coasting, >50 rpm -> power). - Movement (automatic, from the speed): riding /
DS_STOP(v < 0.3 km/h) /DS_BREAK(stop > 2 min in one piece, as before). Applies in every ride mode -- a stop at a traffic light is the sameDS_STOPwhether one was in "ride" or "free ride/cruise"; when moving on (v > 5.5 km/h) it goes back into the mode that was active before the stop (the value ofrideModedecides, not the cadence).
enum EDrivingState {
DS_NO_CONN,
DS_BREAK,
DS_STOP,
DS_FREE_RIDE, // new: rideMode == false, moving
DS_DRIVE_COASTING, // rideMode == true, cadence low
DS_DRIVE_POWER, // rideMode == true, cadence normal
EDrivingStateMax
};
DS_FREE_RIDE covers both the initial state (before the first start tap) and "cruise"
-- free ride and cruise are exactly the same leaf state, icon and button symbol do
not differ. The only difference is invisible: whether a ride session is open (§4).
Persistence: times, cadence counters and distances for total, tour and trip are one blob
in NVS (src/Stats/StatsStore.h, times by state index). It is written every 5 min, on
the change to stop or no-connection and before powering off
(Statistics::persistNow()). A new state changes the layout and needs a new
StatsStore::VERSION.
3. Button and icon¶
| State | State icon | Pause/start button (rr_ic_pause) |
Tap (short) | Long press |
|---|---|---|---|---|
DS_FREE_RIDE |
free-ride icon (§6) | start arrow | -> ride (start if no session is open; otherwise resume from cruise) | stop if a session is open; otherwise as before toggle the standby timer (toggleStandbyMode()) |
DS_DRIVE_POWER / DS_DRIVE_COASTING |
"ride"/"coast" icon (power/coasting) |
pause symbol | -> cruise (DS_FREE_RIDE, session stays open) |
stop (close the session, back to real free ride) |
DS_STOP / DS_BREAK |
stop/break |
as the value of rideMode before the stop (start arrow or pause symbol) |
acts on the mode, not on the movement (see below) | as above |
A tap during DS_STOP/DS_BREAK toggles rideMode just like while riding -- so one
can tap pause at the traffic light already, it takes effect when moving on. The movement
axis (stop/break/riding) is unaffected.
The behaviour of the long press is deliberately left context-dependent (conflict with the existing function): with an open ride session it ends the ride, without an open session it still does what it did before (extend/disable the standby timer -- the only control for that on RimRidge). If that collides in everyday use (e.g. one wants to touch the standby timer during a running ride session), a second gesture would be needed -- deliberately not anticipated.
4. Ride session (rideSessionOpen)¶
A second flag, independent of rideMode/curDriveState:
- Becomes
trueon the first changefree ride -> ride(start tap without a session already open). At that moment:Statistics::reset(SUM_ESP_START)(zeroes times, cruise distance, maximum speed, cadence and remembers the distance as the base of the session). - Stays
trueas long as one switches back and forth between ride and cruise -- exactly the requirement "cruise does not end the ride". - Becomes
falseon the long-press stop. From then onSUM_ESP_STARTstays at the values of the ended ride (time does not continue, distance is frozen) until the next start tap opens a new session.
Distance's own SUM_ESP_START is and stays "since switching on" and is never reset --
binary log (dist_m), remaining navigation distance and gradient calculation need a
monotonic counter. The session is a window onto it (Statistics::sessionBase*/
sessionEnd*, Statistics::getDistance(SUM_ESP_START)).
time_in[DS_FREE_RIDE][SUM_ESP_START] only accumulates while rideSessionOpen == true
-- that is exactly the cruise time of the current ride (§5, AVG_NOCRUISE). Before the
first start (or after a stop) the start/ride clock does not run, although technically one
is already riding "free ride".
Tour, trip and total are unaffected by all this: Statistics::cycle() keeps writing for
them in every 500 ms tick, independent of ride mode and rideSessionOpen -- they "run
along anyway". "Counting the ride into trip/tour at the stop" is therefore not a separate
step but has been happening all the time.
5. Statistics: time, distance, averages¶
Basic rule: distance always counts, time only with a connected speed sensor. What
the wheel counter advances while the bike computer is off or the sensor is disconnected
ends up in the distance and additionally in lostDistanceFromNVS[] ("without sensor").
The net distance (getDistance(t, false)) is exactly the part for which there is
time -- it is used for all averages. DS_NO_CONN time is counted (diagnosis) but goes
into no time and no average.
enum EAvgType {
AVG_ALL, // riding + stops + breaks
AVG_DRIVE, // riding only (ride, coast and free ride/cruise)
AVG_NOBREAK, // riding + short stops, without breaks > 2 min
AVG_NOCRUISE, // riding in ride mode only: cruise time AND distance removed
EAvgTypeMax
};
- Moving time = power + coast + free ride. Coast/power is pure display (state icon) or for later evaluation, irrelevant for the statistics.
- Stops and breaks add time but (almost) no distance -- that is why
AVG_DRIVE/AVG_NOBREAK/AVG_ALLonly differ in the time, a distance per state is not needed. AVG_NOCRUISEneeds the cruise distance:Statistics::distFree[](only distance inDS_FREE_RIDE), persisted in NVS liketime_in[][](StatsStore::Summary::distFree). For tour/trip/total this means "only the ride parts of all rides"; whoever never presses start has no value there.- Stop -> break (> 2 min): exactly the time of this stop is moved (
stopEpisodeMs[]), not a blanketnow - timestamp_stop-- that used to rebook the time of older stops after a reconnect or reset. - A reconnect always goes to
DS_STOP(not into the state before the loss), so that no moving time runs until the first speed arrives. - Maximum speed is persisted per summary (
SPEED_MAX), reset with the reset, values120 km/h discarded; average cadence = crank revolutions / time with cadence > 0 while riding (
CAD_MREVS/CAD_MS).
Counter value of the sensor (Distance::updateRevs())¶
- Most CSC sensors keep counting cumulatively; cheap ones (CYCPLUS) start at 0 after every wake-up. Because the connect takes a while, the first value usually already holds a small number -- that was ridden and is counted.
- First value after switching on: counter >= stored -> the difference is "without sensor"; counter < stored (restart of the sensor) -> the whole current counter value is "without sensor".
- Counter smaller than the last value in operation: "without sensor" on a reconnect, normally ridden distance with a running connection; the base is set anew without losing what was there (the start distance used to drop to 0 and the new counter value was discarded).
- "No value yet" is a flag of its own (
revsInitialized,revsKnown[]viaisKey()), no longerlastRevs == 0-- 0 is a valid counter value.
6. UI integration¶
UIFacade::updateStateIcon()takes a third argument (rideMode) and mapsDS_FREE_RIDEto its own image (img_rr_icon_state_freeride, created as an EEZ resource -- a placeholder, to be checked by the user in the canvas).rr_btn_pausehas, in addition toLV_EVENT_LONG_PRESSED, a handler forLV_EVENT_SHORT_CLICKED(action_pause_click(); notCLICKED-- LVGL 8 also sends that after a long press, the stop would immediately have started a new session); the icon child (img_rr_icon_pausevs.img_rr_icon_start) changes inui_RimRidgeUpdateStateIcon()together with the state icon, since both depend on the samerideMode/curDriveState.UIDriveStateEventvaluesDSE_pauseButtonTap/DSE_pauseButtonStopinsrc/ui/ui.h/ui_events.cpp, analogous to the standby events.
7. Deliberately not implemented / known gaps¶
- Power (watts) in the coast criterion: there is no power meter in the project (no
cycling power service, see
BLEDevices.cpp) -- "cadence or power low" is therefore gated by cadence only. Without a power meter the coast/power split stays exactly the old cadence hysteresis (40/50 rpm). - Cadence "connected" flag: there is no separate "cadence sensor connected" signal
apart from the speed connection yet. Without a cadence sensor
cadencestays at its last value or at-1(invalid) --cycle()'s comparisoncadence < 40/> 50keeps treating-1as "low" (coasting), as in the old logic. A separate "no cadence sensor -> no coast split at all, always power" was not built; it can be added cleanly if needed. - Manually set total odometer (odometry page) counts as net distance without matching time -- the total average is too high as long as the difference is not entered as "lost".
- No host test for
Statistics/Distance: both are tightly coupled to Arduino/ESP32 (Ticker, Preferences,heap_caps_*, singletons), unlikeSessionStats/RoadQuality, which were deliberately factored out as pure algorithms. Verification was by the PlatformIO build (pio run) and code reading; for real run-time tests only a test on the device remains.
8. Binary log and GPX¶
Statistics::logRideState() writes a LogRec::RideState record (type 4,
src/LogRecords.h) on every change of curDriveState, rideMode or the ride session --
not only on state changes, because a tap at standstill or a long press in cruise only
change mode or session. The record carries state, ride mode, flags (RSF_SESSION_OPEN,
RSF_SESSION_START) and the distance since switching on; DS_FREE_RIDE with an open
session is cruise.
Tools/bikelog/gpx.py starts a new <trkseg> at every change and attaches the state as
<extensions><bc:RideState> to the segment (GpxOptions.ride_states, CLI
bikelog gpx --ride-states). Off by default: coast/power and every stop split the track
into many short segments, useful only for analysing time lost in traffic.