Ride state machine: idle/running/paused/stopped, with the dropout command queue (#11) #84
No reviewers
Labels
No labels
area:companion
area:docs
area:shared
area:tooling
area:watchapp
blocker
kind:chore
kind:feature
kind:spike
kind:test
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
robert/PedalPebble!84
Loading…
Reference in a new issue
No description provided.
Delete branch "area/ride-state-machine"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Implements issue #11 ("Ride state machine: idle / running / paused / stopped") and its
2026-09-02 update ("commands survive a dropout, and the watch never auto-pauses").
What's here
watchapp/src/c/state.h/state.c— the pure ride state machine, 100% host-testable (D34),no Pebble SDK calls at all:
ride_state_start_pause()(Select,short press) and
ride_state_stop_request()/stop_confirm()/stop_cancel()(long Selectwith confirmation, modelled as its own pending state rather than a UI dialog this file would
need the SDK for).
disconnected (reset to zero at the start of each outage) and is discarded the instant the
phone's real moving time arrives via
ride_state_apply_moving_time_from_phone()(D43 rule 3:"only one of them is ever recorded").
CMD_TIMEand held untilride_state_ack_commands_up_to()clears it. The ack removes aprefix of the queue (every entry with
cmd_time <= acked_cmd_time), not a single matchingentry — that's what "oldest-first" has to mean once the queue is deeper than one command, since
the phone acknowledges cumulatively.
ride_state_apply_incoming_ride_state()unconditionally refuses to apply aRIDE_STATEpushwhile anything is unacknowledged (D43 rule 2). That single guard is what makes "the queue drains
oldest-first before any
RIDE_STATEis applied" true, with no separate phased state machine:there is no window in which a push could jump an unacked command, because the only way this
function ever succeeds is for the queue to already be empty.
unacknowledged, while the local transition still happens. Needs 8 unacked Select presses inside
one dropout to trigger; logged at
APP_LOG_LEVEL_WARNINGbyride_link.crather than silently.watchapp/src/c/ride_link.h/ride_link.c— the SDK-facing shell: sends queued commands overAppMessage (
CMD/CMD_TIME), decodes inboundRIDE_STATE/STATE_ACK, subscribes toconnection_service, drives a 1 HzAppTimertick, and picks a vibration(
vibes_short_pulse/vibes_double_pulse/vibes_long_pulse) per transition. Resend policy isintentionally simple: retry the oldest unacked command once per tick while connected, rather than a
backoff state machine — cheap next to PROTOCOL.md's ~1.5 KB/s budget and easy to read.
watchapp/tests/test_state.c— 24 cases via the CMake/CTest harness PR #82 set up, includingthe D43 café scenario end to end (pause during a simulated dropout, reconnect, confirm still
paused, confirm an incoming
RIDE_STATE=runningis refused until the queued pause isacknowledged), the "second unacked command queues behind the first" case, and queue-full behaviour.
The Select-button overlap with #8
page_view.c's Select handler (issue #8's placeholder) still only cycles the three default pages —nothing in this PR rewires it. DESIGN.md section 5 already settles the eventual assignment (Select
starts/pauses, long Select stops with confirmation; Up/Down cycles pages), so this isn't really two
open options — it's that #8's placeholder is standing in for both Select's real job and Up/Down's,
because the real carousel (#60) doesn't exist yet. Moving page-cycling off Select and onto Up/Down,
and wiring Select to
ride_link_start_pause()/ride_link_stop_request()/stop_confirm()/stop_cancel(), is #60's job. Documented as an explicit open question in bothstate.handride_link.h's top comments. No button in this PR calls any ofride_link's rider-facingfunctions.
Verification
pebble buildforemery,gabbro,basalt: clean, 0 warnings (confirmed via-vagainst theactual
-Wall -Wextra -Werrorflags the SDK uses).fields/page/state, 68 cases total) passes via the real CMake/CTest harness.emeryemulator:pebble emu-bt-connectionto flipconnection_servicestate (logged both directions),
pebble send-app-messageto push fabricatedRIDE_STATEandSTATE_ACKtuples. Confirmed live, on real firmware: aRIDE_STATE=pausedpush is refused whilea
RIDE_CMD_STARTis unacked, and applies immediately once the matchingSTATE_ACKclears thequeue — the exact protocol-level behaviour
test_state.cproves at the host level. Theoutbound send path (
ride_link_start_pause()->AppMessageoutbox, loggedresult=0/APP_MSG_OK,confirmed by the outbox-sent handler, and the once-per-tick resend policy visibly retrying) was
also exercised, via a one-line call added to
prv_init()for this purpose only and revertedbefore this diff was finalized — there is no button to trigger it from yet (see above), so this
was the only way to prove that path runs on real firmware rather than just compiles.
memory_usage_report), identical across all threeplatforms (this is fixed static storage, not per-geometry).
ride_link_init()'s ownheap_bytes_free()delta is 0 — nothing in either new file calls anything that allocates.What's not host-tested, and why
ride_link.cis entirely SDK-bound and untested at the host level:AppMessageoutboxbegin/send/sent/failed,
connection_service_subscribe,vibes_*, andAppTimerdon't have (andweren't given) host stubs the way
page.c's narrowpersist_*surface does intests/stubs/pebble.h— building a faithful fakeAppMessagetransport to test resend/backofftiming against would be testing the fake, not the logic.
state.c's pure logic — the actualstate machine, the queue, and the moving/wall-clock accounting the SDK shell reacts to — is what
test_state.cproves exhaustively; the emulator runs above are what stand in for testingride_link.citself.Protocol fields interpreted rather than found explicitly specified
CMD's numeric values. PROTOCOL.md section 3 givesCMD's vocabulary as prose ("start /pause / resume / stop / lap / re-centre / zoom in / zoom out"), not a numbered table the way
RIDE_STATEgets one. Issue #7's codegen (shared/message_keys.json->proto.h) hasn't landed,so there's no other numbered source.
state.h'sRideCmdenum assigns 0-based values in theorder PROTOCOL.md's prose lists them — flagged in both the header comment and here. If #7 lands
with different numbers, this enum needs updating to match; a mismatch would silently send the
wrong byte with no compiler error, since both ends only ever see a bare
uint8.RIDE_STATE=4,STATE_ACK=6,CMD=71,CMD_TIME=74).ride_link.cuses these as local#defines copied from PROTOCOL.md's tables, sincepackage.json'smessageKeysis still just["dummy"](#7 not landed) and there's nogenerated header to include instead. Commented as "this file's only copy of that mapping" —
replace with a
proto.hinclude once #7 ships, don't keep both.Not scope creep, but adjacent
Extending
main.c's inbox handler to callride_link_handle_inbox()is the minimum needed forRIDE_STATE/STATE_ACKto actually reachstate.cin the running app — everything else on thephone -> watch wire is still undecoded, same as before this PR.