Backlight policy for night riding (#45) #106

Merged
robert merged 1 commit from area/backlight-policy into main 2026-09-05 11:12:26 +02:00
Owner

Implements #45 (Backlight policy for night riding).

What this adds

watchapp/src/c/backlight.{c,h} — a new module, not main.c per the issue's
literal file list, because a persisted setting plus mode logic is exactly
the shape fields.c/page.c/state.c/carousel.c already have, and cramming it
into main.c would be inconsistent with the rest of this codebase's
one-concern-per-file pattern. main.c only gains two calls
(backlight_init()/backlight_deinit()), ride_link.c gains one
(backlight_tick() in its existing ~1 Hz timer).

Three modes, persisted as one packed byte under a new
PERSIST_KEY_BACKLIGHT_MODE (page.h's registry, keys 1-4 now):

  • BACKLIGHT_MODE_OFF (default) — no override, system default behaviour
  • BACKLIGHT_MODE_ALWAYS_ON — held on for the whole ride (the issue's
    "explicit opt-in mode")
  • BACKLIGHT_MODE_SCHEDULE — held on while riding, only during a configured
    dark-hours window (default 19:00-06:00)

Persistence uses page_store_load()'s pack-and-validate shape, not
PERSIST_KEY_ACTIVE_PAGE_INDEX's plain persist_read_int() — a corrupt
byte needs to fall back to a known-safe mode rather than be trusted as
whatever int happens to be stored. See backlight.h's own comment for the
full reasoning.

SDK facts checked, not assumed

Checked against the installed SDK
(~/.local/share/pebble-sdk/SDKs/4.33.1, both emery/include/pebble.h
and gabbro/include/pebble.h specifically, 2026-09-05):

  • light_enable(bool enable) and light_enable_interaction(void) are
    exactly the signatures expected — verified in the header, not recalled.
  • No live ambient-light-sensor API exists for apps on this SDK.
    AmbientLightLevel appears exactly once, as a 3-bit field inside
    HealthMinuteData (returned by health_service_get_minute_history()) —
    a historical, minute-granularity health record, not something an app can
    poll in real time. So "ambient-aware" activation is not honestly
    buildable here; BACKLIGHT_MODE_SCHEDULE is the schedule-based fallback
    the issue itself names for exactly this case.
  • Also corrected a factual error in docs/DESIGN.md section 7.4 (D48:
    reopened with a checked fact, not an argument) — it previously said
    emery/gabbro have "no backlight to wash out", which contradicts this
    issue's own premise and both platforms' installed headers exposing a real
    Light API with no platform restriction.

What's real vs. what's mechanism-only

  • Real, wired up, verified on the emery emulator: backlight_tick()
    runs off ride_link.c's existing ~1 Hz timer and calls light_enable()
    only when the desired state actually changes. Driving the emulator
    through a full ride lifecycle (with the shipped default temporarily
    forced to ALWAYS_ON for observation, then reverted before this commit —
    see the commit message) logged exactly two light_enable() calls across
    the whole run: true the moment the ride was RUNNING, false the moment
    it reached STOPPED, and nothing in between across a pause/resume —
    confirming the "only on change" property on real firmware, not just the
    host stub. A clean install with the shipped default (BACKLIGHT_MODE_OFF)
    logs zero light_enable() calls at all.
  • Mechanism-only, no caller yet: backlight_flash_for_turn_prompt()
    (the brief flash for a turn prompt, light_enable_interaction()) is
    built and host-tested, but nothing calls it — there is no turn-cue
    rendering anywhere in this watchapp yet (#33-#37 all still open). Matches
    the "build the mechanism, note the missing caller" pattern from tonight's
    #65/#19.
  • Also mechanism-only: nothing currently calls backlight_set_mode()
    to move a rider off the OFF default. There is no on-watch settings screen
    (carousel.c's button model is fully specified by DESIGN.md section 5, and
    D48 closes replanning it without a checked fact — inventing a new button
    gesture here would be exactly that) and no phone-side CONFIG_* wire
    message for it yet. backlight_set_mode()/backlight_get_mode() are
    fully built and tested for whichever future settings issue adds a real
    trigger.

Battery cost

Measured here: heap_bytes_free() before/after (RAM footprint moved from
13020 to 13252 bytes on emery/gabbro — two static bools and an enum, per
the memory_usage_report), and the emulator's logged light_enable() call
sequence (above). Not measured here, same as #12/#18/#69: real
multi-hour battery drain against NFR-B1's 25% budget — that needs Robert's
physical watch running BACKLIGHT_MODE_ALWAYS_ON or SCHEDULE for real hours,
which nothing in this codebase can trigger yet (see above).

Testing

watchapp/tests/test_backlight.c: 27 cases — the pure decision logic
(schedule window matching including the overnight wraparound, the
riding/mode/dark decision table, turn-prompt gating), pack/unpack
round-trip and out-of-range rejection, and (via two new stub additions to
tests/stubs/pebble.h/pebble_stub.c — a fake light_enable()/
light_enable_interaction() with call tracking, plus a no-op APP_LOG
macro) the persistence fallback behaviour and backlight_tick()/
backlight_deinit()'s actual call count and call value.

No cmake binary in this sandbox — hand-compiled with the exact flags in
tests/CMakeLists.txt. All five host suites pass clean (fields/page/state/
page_render_geometry/backlight, 119 assertions total, no regressions from
the page.h/stub changes).

pebble build clean on emery, gabbro and basalt.

Claude-Session: https://claude.ai/code/session_01DAoXbRmJUf2uxNYBfdAXPt

Implements #45 (Backlight policy for night riding). ## What this adds `watchapp/src/c/backlight.{c,h}` — a new module, not main.c per the issue's literal file list, because a persisted setting plus mode logic is exactly the shape fields.c/page.c/state.c/carousel.c already have, and cramming it into main.c would be inconsistent with the rest of this codebase's one-concern-per-file pattern. main.c only gains two calls (`backlight_init()`/`backlight_deinit()`), ride_link.c gains one (`backlight_tick()` in its existing ~1 Hz timer). **Three modes**, persisted as one packed byte under a new `PERSIST_KEY_BACKLIGHT_MODE` (page.h's registry, keys 1-4 now): - `BACKLIGHT_MODE_OFF` (default) — no override, system default behaviour - `BACKLIGHT_MODE_ALWAYS_ON` — held on for the whole ride (the issue's "explicit opt-in mode") - `BACKLIGHT_MODE_SCHEDULE` — held on while riding, only during a configured dark-hours window (default 19:00-06:00) Persistence uses page_store_load()'s pack-and-validate shape, not `PERSIST_KEY_ACTIVE_PAGE_INDEX`'s plain `persist_read_int()` — a corrupt byte needs to fall back to a known-safe mode rather than be trusted as whatever int happens to be stored. See backlight.h's own comment for the full reasoning. ## SDK facts checked, not assumed Checked against the installed SDK (`~/.local/share/pebble-sdk/SDKs/4.33.1`, **both** `emery/include/pebble.h` and `gabbro/include/pebble.h` specifically, 2026-09-05): - `light_enable(bool enable)` and `light_enable_interaction(void)` are exactly the signatures expected — verified in the header, not recalled. - **No live ambient-light-sensor API exists for apps on this SDK.** `AmbientLightLevel` appears exactly once, as a 3-bit field inside `HealthMinuteData` (returned by `health_service_get_minute_history()`) — a historical, minute-granularity health record, not something an app can poll in real time. So "ambient-aware" activation is not honestly buildable here; `BACKLIGHT_MODE_SCHEDULE` is the schedule-based fallback the issue itself names for exactly this case. - Also corrected a factual error in `docs/DESIGN.md` section 7.4 (D48: reopened with a checked fact, not an argument) — it previously said `emery`/`gabbro` have "no backlight to wash out", which contradicts this issue's own premise and both platforms' installed headers exposing a real `Light` API with no platform restriction. ## What's real vs. what's mechanism-only - **Real, wired up, verified on the emery emulator**: `backlight_tick()` runs off ride_link.c's existing ~1 Hz timer and calls `light_enable()` only when the desired state actually changes. Driving the emulator through a full ride lifecycle (with the shipped default temporarily forced to ALWAYS_ON for observation, then reverted before this commit — see the commit message) logged exactly two `light_enable()` calls across the whole run: `true` the moment the ride was RUNNING, `false` the moment it reached STOPPED, and *nothing* in between across a pause/resume — confirming the "only on change" property on real firmware, not just the host stub. A clean install with the shipped default (`BACKLIGHT_MODE_OFF`) logs zero `light_enable()` calls at all. - **Mechanism-only, no caller yet**: `backlight_flash_for_turn_prompt()` (the brief flash for a turn prompt, `light_enable_interaction()`) is built and host-tested, but nothing calls it — there is no turn-cue rendering anywhere in this watchapp yet (#33-#37 all still open). Matches the "build the mechanism, note the missing caller" pattern from tonight's #65/#19. - **Also mechanism-only**: nothing currently calls `backlight_set_mode()` to move a rider off the OFF default. There is no on-watch settings screen (carousel.c's button model is fully specified by DESIGN.md section 5, and D48 closes replanning it without a checked fact — inventing a new button gesture here would be exactly that) and no phone-side CONFIG_* wire message for it yet. `backlight_set_mode()`/`backlight_get_mode()` are fully built and tested for whichever future settings issue adds a real trigger. ## Battery cost Measured here: `heap_bytes_free()` before/after (RAM footprint moved from 13020 to 13252 bytes on emery/gabbro — two static bools and an enum, per the memory_usage_report), and the emulator's logged `light_enable()` call sequence (above). **Not measured here, same as #12/#18/#69**: real multi-hour battery drain against NFR-B1's 25% budget — that needs Robert's physical watch running BACKLIGHT_MODE_ALWAYS_ON or SCHEDULE for real hours, which nothing in this codebase can trigger yet (see above). ## Testing `watchapp/tests/test_backlight.c`: 27 cases — the pure decision logic (schedule window matching including the overnight wraparound, the riding/mode/dark decision table, turn-prompt gating), pack/unpack round-trip and out-of-range rejection, and (via two new stub additions to `tests/stubs/pebble.h`/`pebble_stub.c` — a fake `light_enable()`/ `light_enable_interaction()` with call tracking, plus a no-op `APP_LOG` macro) the persistence fallback behaviour and backlight_tick()/ backlight_deinit()'s actual call count and call value. No `cmake` binary in this sandbox — hand-compiled with the exact flags in `tests/CMakeLists.txt`. All five host suites pass clean (fields/page/state/ page_render_geometry/backlight, 119 assertions total, no regressions from the page.h/stub changes). `pebble build` clean on emery, gabbro and basalt. Claude-Session: https://claude.ai/code/session_01DAoXbRmJUf2uxNYBfdAXPt
Add backlight policy for night riding (issue #45)
Some checks failed
dev-artifact / build-pbw (push) Failing after 0s
dev-artifact / build-apk (push) Failing after 0s
dev-artifact / publish (push) Has been skipped
fast-lane / host-c-tests (push) Failing after 0s
fast-lane / jvm-tests (push) Failing after 0s
fast-lane / pebble-build (push) Failing after 0s
fast-lane / lint-and-secrets (push) Failing after 0s
fast-lane / meta-declares-required-jobs (push) Failing after 0s
fast-lane / host-c-tests (pull_request) Failing after 0s
fast-lane / jvm-tests (pull_request) Failing after 0s
fast-lane / pebble-build (pull_request) Failing after 0s
fast-lane / lint-and-secrets (pull_request) Failing after 0s
fast-lane / meta-declares-required-jobs (pull_request) Failing after 0s
9d3661031b
New module watchapp/src/c/backlight.{c,h}, following the codebase's
one-concern-per-file pattern (fields.c/page.c/state.c/carousel.c) rather
than cramming this into main.c as the issue text suggested — a persisted
setting plus mode logic is exactly the shape those files already have.

Three modes (BACKLIGHT_MODE_OFF / ALWAYS_ON / SCHEDULE), persisted as one
packed byte under a new PERSIST_KEY_BACKLIGHT_MODE (page.h's registry,
now at keys 1-4), using page_store_load()'s pack-and-validate shape
rather than PERSIST_KEY_ACTIVE_PAGE_INDEX's plain persist_read_int() --
see backlight.h's own comment for why a corrupt byte needs to fall back
to a known-safe mode rather than be trusted as whatever int is stored.

Checked against the installed SDK (~/.local/share/pebble-sdk/SDKs/4.33.1,
emery and gabbro headers): light_enable(bool)/light_enable_interaction()
are exactly as expected. AmbientLightLevel exists only inside
HealthMinuteData (health_service_get_minute_history(), a historical
per-minute record) -- there is no live ambient-light-sensor API on this
SDK, so BACKLIGHT_MODE_SCHEDULE is schedule-based (a configurable dark-
hours window, default 19:00-06:00) rather than ambient-aware, honestly,
per the issue's own fallback wording.

backlight_ride_state_counts_as_riding() (RUNNING or PAUSED) is wired into
ride_link.c's existing ~1 Hz tick for real -- backlight_tick() calls
light_enable() only on an actual change of desired state, verified both
on the host test suite (via a stub Light surface with call tracking,
mirroring the persist_* stub page.c/state.c already use) and against the
real SDK in the emery emulator: exactly two light_enable() calls logged
across a full ride lifecycle (true on RUNNING, false on STOPPED), none in
between across a pause/resume, and zero calls at all with the shipped
default mode (OFF). backlight_deinit() (main.c's prv_deinit()) returns
the backlight to automatic control on exit if this file had turned it
on -- the same NFR-B3 "must return to 0 on exit" reasoning
(REQUIREMENTS.md) already applied to the HRM sample period.

backlight_flash_for_turn_prompt() (light_enable_interaction(), the brief
flash for a turn prompt) is built and host-tested but has no real caller
yet -- there is no turn-cue rendering built in the watchapp at all
(issues #33-#37 still open), so there is no real trigger point. Matches
the "build the mechanism, note the missing caller" pattern.

No settings surface (on-watch menu or phone CONFIG push) exists yet to
move BACKLIGHT_MODE off its OFF default -- adding a new carousel button
gesture for this would reopen DESIGN.md section 5's button model, which
D48 closes without a checked fact. backlight_set_mode() is fully built
and tested; a future settings issue is expected to call it.

Also corrects a factual error in DESIGN.md 7.4 (D48: reopened only with a
checked fact) -- it previously claimed emery/gabbro have "no backlight to
wash out", contradicted by this issue's own premise and by both
platforms' installed SDK headers declaring a real Light API with no
platform restriction.

tests/test_backlight.c: 27 cases covering the pure decision logic
(schedule window matching including overnight wraparound, the
riding/mode/dark decision table, turn-prompt gating), pack/unpack, and
(via the stub) persistence fallback and the tick/deinit call-count and
call-value behaviour. Hand-compiled with the exact flags in
tests/CMakeLists.txt (no cmake binary in this sandbox) -- 119 assertions
clean across all five suites (fields/page/state/page_render_geometry/
backlight). pebble build clean on emery/gabbro/basalt, RAM footprint
unchanged from before this change on emery/gabbro (13020 -> 13252 bytes,
the two static bools/enum this file adds).

Battery cost: heap_bytes_free() delta and the emulator's light_enable()
call sequence are measured here; real multi-hour battery drain against
NFR-B1's 25% budget needs Robert's physical watch, same as #12/#18/#69.

Claude-Session: https://claude.ai/code/session_01DAoXbRmJUf2uxNYBfdAXPt
robert merged commit 77482a1856 into main 2026-09-05 11:12:26 +02:00
Sign in to join this conversation.
No description provided.