Ride state machine: idle/running/paused/stopped, with the dropout command queue (#11) #84

Merged
robert merged 1 commit from area/ride-state-machine into main 2026-09-04 09:31:40 +02:00
Owner

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:

  • idle -> running -> paused -> running -> stopped, driven by ride_state_start_pause() (Select,
    short press) and ride_state_stop_request() / stop_confirm() / stop_cancel() (long Select
    with confirmation, modelled as its own pending state rather than a UI dialog this file would
    need the SDK for).
  • Moving time accumulates only while running. A separate wall-clock counter takes over while
    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").
  • A fixed-capacity (8 entries, no malloc) outbound command queue. Every command is stamped with
    CMD_TIME and held until ride_state_ack_commands_up_to() clears it. The ack removes a
    prefix of the queue (every entry with cmd_time <= acked_cmd_time), not a single matching
    entry — 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 a RIDE_STATE push
    while anything is unacknowledged (D43 rule 2). That single guard is what makes "the queue drains
    oldest-first before any RIDE_STATE is 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.
  • Queue-full behaviour: a new command is dropped (not the oldest one) if all 8 slots are already
    unacknowledged, while the local transition still happens. Needs 8 unacked Select presses inside
    one dropout to trigger; logged at APP_LOG_LEVEL_WARNING by ride_link.c rather than silently.

watchapp/src/c/ride_link.h / ride_link.c — the SDK-facing shell: sends queued commands over
AppMessage (CMD/CMD_TIME), decodes inbound RIDE_STATE/STATE_ACK, subscribes to
connection_service, drives a 1 Hz AppTimer tick, and picks a vibration
(vibes_short_pulse/vibes_double_pulse/vibes_long_pulse) per transition. Resend policy is
intentionally 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, including
the D43 café scenario end to end (pause during a simulated dropout, reconnect, confirm still
paused, confirm an incoming RIDE_STATE=running is refused until the queued pause is
acknowledged), 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 both state.h and
ride_link.h's top comments. No button in this PR calls any of ride_link's rider-facing
functions.

Verification

  • pebble build for emery, gabbro, basalt: clean, 0 warnings (confirmed via -v against the
    actual -Wall -Wextra -Werror flags the SDK uses).
  • Host test suite (fields/page/state, 68 cases total) passes via the real CMake/CTest harness.
  • Exercised live in the emery emulator: pebble emu-bt-connection to flip connection_service
    state (logged both directions), pebble send-app-message to push fabricated RIDE_STATE and
    STATE_ACK tuples. Confirmed live, on real firmware: a RIDE_STATE=paused push is refused while
    a RIDE_CMD_START is unacked, and applies immediately once the matching STATE_ACK clears the
    queue — the exact protocol-level behaviour test_state.c proves at the host level. The
    outbound send path (ride_link_start_pause() -> AppMessage outbox, logged result=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 reverted
    before 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.
  • Static RAM footprint: +1684 bytes (waf's memory_usage_report), identical across all three
    platforms (this is fixed static storage, not per-geometry). ride_link_init()'s own
    heap_bytes_free() delta is 0 — nothing in either new file calls anything that allocates.

What's not host-tested, and why

ride_link.c is entirely SDK-bound and untested at the host level: AppMessage outbox
begin/send/sent/failed, connection_service_subscribe, vibes_*, and AppTimer don't have (and
weren't given) host stubs the way page.c's narrow persist_* surface does in
tests/stubs/pebble.h — building a faithful fake AppMessage transport to test resend/backoff
timing against would be testing the fake, not the logic. state.c's pure logic — the actual
state machine, the queue, and the moving/wall-clock accounting the SDK shell reacts to — is what
test_state.c proves exhaustively; the emulator runs above are what stand in for testing
ride_link.c itself.

Protocol fields interpreted rather than found explicitly specified

  • CMD's numeric values. PROTOCOL.md section 3 gives CMD's vocabulary as prose ("start /
    pause / resume / stop / lap / re-centre / zoom in / zoom out"), not a numbered table the way
    RIDE_STATE gets one. Issue #7's codegen (shared/message_keys.json -> proto.h) hasn't landed,
    so there's no other numbered source. state.h's RideCmd enum assigns 0-based values in the
    order 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.
  • The wire key ids themselves (RIDE_STATE=4, STATE_ACK=6, CMD=71, CMD_TIME=74).
    ride_link.c uses these as local #defines copied from PROTOCOL.md's tables, since
    package.json's messageKeys is still just ["dummy"] (#7 not landed) and there's no
    generated header to include instead. Commented as "this file's only copy of that mapping" —
    replace with a proto.h include once #7 ships, don't keep both.

Not scope creep, but adjacent

Extending main.c's inbox handler to call ride_link_handle_inbox() is the minimum needed for
RIDE_STATE/STATE_ACK to actually reach state.c in the running app — everything else on the
phone -> watch wire is still undecoded, same as before this PR.

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: - idle -> running -> paused -> running -> stopped, driven by `ride_state_start_pause()` (Select, short press) and `ride_state_stop_request()` / `stop_confirm()` / `stop_cancel()` (long Select with confirmation, modelled as its own pending state rather than a UI dialog this file would need the SDK for). - Moving time accumulates only while running. A separate wall-clock counter takes over while 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"). - A fixed-capacity (8 entries, no malloc) outbound command queue. Every command is stamped with `CMD_TIME` and held until `ride_state_ack_commands_up_to()` clears it. The ack removes a *prefix* of the queue (every entry with `cmd_time <= acked_cmd_time`), not a single matching entry — 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 a `RIDE_STATE` push while anything is unacknowledged (D43 rule 2). That single guard is what makes "the queue drains oldest-first before any `RIDE_STATE` is 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. - Queue-full behaviour: a new command is dropped (not the oldest one) if all 8 slots are already unacknowledged, while the local transition still happens. Needs 8 unacked Select presses inside one dropout to trigger; logged at `APP_LOG_LEVEL_WARNING` by `ride_link.c` rather than silently. **`watchapp/src/c/ride_link.h` / `ride_link.c`** — the SDK-facing shell: sends queued commands over AppMessage (`CMD`/`CMD_TIME`), decodes inbound `RIDE_STATE`/`STATE_ACK`, subscribes to `connection_service`, drives a 1 Hz `AppTimer` tick, and picks a vibration (`vibes_short_pulse`/`vibes_double_pulse`/`vibes_long_pulse`) per transition. Resend policy is intentionally 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, including the D43 café scenario end to end (pause during a simulated dropout, reconnect, confirm still paused, confirm an incoming `RIDE_STATE=running` is refused until the queued pause is acknowledged), 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 both `state.h` and `ride_link.h`'s top comments. No button in this PR calls any of `ride_link`'s rider-facing functions. ## Verification - `pebble build` for `emery`, `gabbro`, `basalt`: clean, 0 warnings (confirmed via `-v` against the actual `-Wall -Wextra -Werror` flags the SDK uses). - Host test suite (`fields`/`page`/`state`, 68 cases total) passes via the real CMake/CTest harness. - Exercised live in the `emery` emulator: `pebble emu-bt-connection` to flip `connection_service` state (logged both directions), `pebble send-app-message` to push fabricated `RIDE_STATE` and `STATE_ACK` tuples. Confirmed live, on real firmware: a `RIDE_STATE=paused` push is refused while a `RIDE_CMD_START` is unacked, and applies immediately once the matching `STATE_ACK` clears the queue — the exact protocol-level behaviour `test_state.c` proves at the host level. The outbound send path (`ride_link_start_pause()` -> `AppMessage` outbox, logged `result=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 reverted before 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. - Static RAM footprint: +1684 bytes (waf's `memory_usage_report`), identical across all three platforms (this is fixed static storage, not per-geometry). `ride_link_init()`'s own `heap_bytes_free()` delta is 0 — nothing in either new file calls anything that allocates. ## What's not host-tested, and why `ride_link.c` is entirely SDK-bound and untested at the host level: `AppMessage` outbox begin/send/sent/failed, `connection_service_subscribe`, `vibes_*`, and `AppTimer` don't have (and weren't given) host stubs the way `page.c`'s narrow `persist_*` surface does in `tests/stubs/pebble.h` — building a faithful fake `AppMessage` transport to test resend/backoff timing against would be testing the fake, not the logic. `state.c`'s pure logic — the actual state machine, the queue, and the moving/wall-clock accounting the SDK shell reacts to — is what `test_state.c` proves exhaustively; the emulator runs above are what stand in for testing `ride_link.c` itself. ## Protocol fields interpreted rather than found explicitly specified - **`CMD`'s numeric values.** PROTOCOL.md section 3 gives `CMD`'s vocabulary as prose ("start / pause / resume / stop / lap / re-centre / zoom in / zoom out"), not a numbered table the way `RIDE_STATE` gets one. Issue #7's codegen (`shared/message_keys.json` -> `proto.h`) hasn't landed, so there's no other numbered source. `state.h`'s `RideCmd` enum assigns 0-based values in the order 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`. - **The wire key ids themselves** (`RIDE_STATE`=4, `STATE_ACK`=6, `CMD`=71, `CMD_TIME`=74). `ride_link.c` uses these as local `#define`s copied from PROTOCOL.md's tables, since `package.json`'s `messageKeys` is still just `["dummy"]` (#7 not landed) and there's no generated header to include instead. Commented as "this file's only copy of that mapping" — replace with a `proto.h` include once #7 ships, don't keep both. ## Not scope creep, but adjacent Extending `main.c`'s inbox handler to call `ride_link_handle_inbox()` is the minimum needed for `RIDE_STATE`/`STATE_ACK` to actually reach `state.c` in the running app — everything else on the phone -> watch wire is still undecoded, same as before this PR.
Ride state machine: idle/running/paused/stopped, with the dropout command queue (#11)
Some checks failed
dev-artifact / build-pbw (push) Has been cancelled
dev-artifact / build-apk (push) Has been cancelled
fast-lane / host-c-tests (push) Has been cancelled
fast-lane / jvm-tests (push) Has been cancelled
fast-lane / pebble-build (push) Has been cancelled
fast-lane / lint-and-secrets (push) Has been cancelled
fast-lane / meta-declares-required-jobs (push) Has been cancelled
fast-lane / host-c-tests (pull_request) Has been cancelled
fast-lane / jvm-tests (pull_request) Has been cancelled
fast-lane / pebble-build (pull_request) Has been cancelled
fast-lane / lint-and-secrets (pull_request) Has been cancelled
fast-lane / meta-declares-required-jobs (pull_request) Has been cancelled
dev-artifact / publish (push) Has been cancelled
b31da8593d
Implements issue #11 and its 2026-09-02 update ("commands survive a
dropout, and the watch never auto-pauses").

watchapp/src/c/state.h / state.c
  Pure ride state machine, 100% host-testable (D34), no Pebble SDK calls:
  - idle -> running -> paused -> running -> stopped, driven by
    ride_state_start_pause() (Select) and ride_state_stop_request()/
    stop_confirm()/stop_cancel() (long Select with confirmation).
  - Moving time accumulates only while running; a separate wall-clock
    counter takes over while disconnected (reset to zero at the start of
    each outage) and is discarded the moment the phone's real moving time
    arrives via ride_state_apply_moving_time_from_phone() (D43 rule 3).
  - A fixed-capacity (8, no malloc) outbound command queue. Every command
    is stamped with CMD_TIME and held until ride_state_ack_commands_up_to()
    clears it; the ack removes a *prefix* of the queue (every entry with
    cmd_time <= the acked value), not a single matching entry, which is
    what "oldest-first" means once the queue is deeper than one command.
  - ride_state_apply_incoming_ride_state() unconditionally refuses to apply
    a RIDE_STATE push while anything is unacknowledged (D43 rule 2). This
    single guard is what makes "the queue drains oldest-first before any
    RIDE_STATE is applied" true without a separate phased state machine:
    there is no window in which a push can jump an unacked command.

watchapp/src/c/ride_link.h / ride_link.c
  SDK-facing shell (not host-tested — see PR description for why): sends
  queued commands over AppMessage (CMD/CMD_TIME), decodes inbound
  RIDE_STATE/STATE_ACK, subscribes to connection_service, drives a 1 Hz
  AppTimer tick, and picks a vibration per transition. No button calls any
  of ride_link's rider-facing functions yet — see below.

watchapp/tests/test_state.c
  24 cases, including the D43 café scenario end to end: pause during a
  simulated dropout, reconnect, confirm the ride is still paused, and that
  an incoming RIDE_STATE=running is refused until the queued pause is
  acknowledged.

The Select-button overlap with #8's placeholder page-cycling
  page_view.c's Select handler (issue #8) still only cycles the three
  default pages; nothing here rewires it. DESIGN.md section 5 already
  settles the eventual assignment (Select starts/pauses, long Select
  stops; Up/Down cycles pages) — #60 is where page-cycling moves off
  Select and onto Up/Down, and where ride_link_start_pause()/
  stop_request()/stop_confirm()/stop_cancel() actually get wired to a
  button. Documented as an explicit open question in both state.h and
  ride_link.h rather than resolved here.

Verified: pebble build clean (0 warnings) for emery/gabbro/basalt; host
test suite (fields/page/state) passes via the real CMake/CTest harness;
exercised live in the emery emulator via emu-bt-connection and
send-app-message — confirmed a RIDE_STATE push is refused while a command
is unacked and applies once STATE_ACK clears the queue. New static
footprint measured at +1684 bytes RAM (waf's report) across all three
platforms; ride_link_init() itself costs 0 heap bytes (no malloc anywhere
in either file).

Claude-Session: https://claude.ai/code/session_01DAoXbRmJUf2uxNYBfdAXPt
robert merged commit 4577e2418e into main 2026-09-04 09:31:40 +02:00
Sign in to join this conversation.
No description provided.