From f0af8b1d946aeb09b8b025a9e85658d08234675d Mon Sep 17 00:00:00 2001 From: John Osumi <931193+sumitake@users.noreply.github.com> Date: Fri, 7 Aug 2026 00:53:47 -0700 Subject: [PATCH] calendar: fall back to in-progress layout when start-takeover animation is undrawable The v1.6 start-takeover draws a full-panel stock AnimationElement (shared/.anim) for the first start_window_seconds after an event begins. If start_animation names an animation absent from the device (an operator typo -- the default meeting_72x16 is valid), the live device rejects the draw with DrawResult.ERROR every poll. Since the takeover is the ONLY thing on screen, state never commits and run_once re-clears + re-fails each poll, leaving the panel DARK for the whole window instead of showing anything. run_once now falls back to the normal in-progress ("ENDS") layout for that poll when a just_started takeover draw returns ERROR, so a mistyped start_animation degrades to a live countdown rather than a blank screen, and logs the misconfiguration. Only ERROR triggers the fallback: REJECTED (a higher-priority app owns the screen -- the lower-priority in-progress draw would be rejected too) and UNREACHABLE (device down -- handled by the loop's backoff) do not. Per-poll, not latched, so it self-heals the moment the config is fixed or the animation appears, no restart needed. Adds four FakeClient tests (ERROR falls back; REJECTED/UNREACHABLE do not; fallback is per-poll not latched) and corrects the README, which had described the mistyped-name case as leaving the panel dark. Co-Authored-By: Claude Opus 4.8 --- integrations/calendar_countdown/README.md | 2 +- integrations/calendar_countdown/main.py | 40 +++++++++ tests/test_calendar_loop.py | 98 +++++++++++++++++++++++ 3 files changed, 139 insertions(+), 1 deletion(-) diff --git a/integrations/calendar_countdown/README.md b/integrations/calendar_countdown/README.md index f64a22c..754ca88 100644 --- a/integrations/calendar_countdown/README.md +++ b/integrations/calendar_countdown/README.md @@ -215,6 +215,6 @@ When an event begins and `start_animation` is configured (default `"meeting_72x1 ### Stock Animation Paths -All animations are device stock assets referenced by `stock_path` (e.g., `"shared/calendar_event_16x16.anim"`, `"shared/calendar_reminder_16x16.anim"`, `"shared/meeting_72x16.anim"`). No custom animation assets are uploaded or bundled with this integration; the device firmware ships all referenced stock animations at `/ext/apps_assets/shared/animations/`. The `start_animation` value must be a valid device stock animation name โ€” examples include `meeting_72x16`, `booked_72x16`, `wave_invitation_72x16`. Setting `start_animation = ""` disables the takeover. An invalid or misspelled animation name will not render, leaving the panel dark for the takeover window. +All animations are device stock assets referenced by `stock_path` (e.g., `"shared/calendar_event_16x16.anim"`, `"shared/calendar_reminder_16x16.anim"`, `"shared/meeting_72x16.anim"`). No custom animation assets are uploaded or bundled with this integration; the device firmware ships all referenced stock animations at `/ext/apps_assets/shared/animations/`. The `start_animation` value must be a valid device stock animation name โ€” examples include `meeting_72x16`, `booked_72x16`, `wave_invitation_72x16`. Setting `start_animation = ""` disables the takeover. Use a valid name: an invalid or misspelled animation name can't be drawn, and the integration falls back to the normal in-progress ("ENDS") layout for the takeover window rather than leaving the panel dark โ€” so a typo degrades to a plain live countdown, not a blank screen, and is logged (`start-takeover animation '' not drawable; falling back ...`). See the design spec (`docs/superpowers/specs/2026-08-06-animation-accents-design.md`, ยง 3) for the full list of verified stock animations and their dimensions. diff --git a/integrations/calendar_countdown/main.py b/integrations/calendar_countdown/main.py index e82ab8c..b95a457 100644 --- a/integrations/calendar_countdown/main.py +++ b/integrations/calendar_countdown/main.py @@ -186,6 +186,46 @@ def run_once(client, fetch, cfg: dict, now: datetime, dry_run: bool, client.clear(APP) result = client.draw(APP, elements=elements, priority=priority, led_notification_color=led) + + # v1.6.1 start-takeover graceful degradation: the just_started takeover + # is a FULL-PANEL swap -- its only two elements are `bg` + the stock + # animation named by start_animation (see build_elements). If that name + # doesn't match a stock animation on the device (an operator typo; the + # default meeting_72x16 is valid), the live device rejects the draw with + # DrawResult.ERROR every poll, `state` never commits, and run_once + # re-clears + re-fails each poll -- leaving the panel DARK for the whole + # start_window_seconds (~60s) instead of showing anything. Nothing else + # is on screen to mask it, because the takeover IS the whole screen. + # + # Fall back to the normal in-progress ("ENDS") layout for THIS poll so a + # mistyped start_animation degrades to a live countdown rather than a + # blank panel. Only ERROR triggers this, deliberately: + # - REJECTED means a strictly-higher-priority app already owns the + # screen; the in-progress layout draws at a LOWER priority + # (PRIORITY_AMBIENT vs the takeover's PRIORITY_AMBIENT_URGENT) and + # would be rejected too -- a pointless second draw. + # - UNREACHABLE means the device is down; the loop's backoff (main()) + # handles that, and a retry would just be unreachable again. + # Per-poll, not latched: if the operator fixes the config value or the + # stock animation later appears, the very next poll's takeover draw + # succeeds with no restart -- the same retry-not-assume discipline the + # led_on/chirp commits follow (commit only on confirmed success). No + # extra clear() is needed before the fallback draw: the failed takeover + # draw landed nothing, and the shape-tracker clear above already fired + # for any real id-set change (last_shape is never the takeover shape, + # since a takeover ERROR never commits it), so the device is already + # blank whenever it mattered -- see this function's docstring on the + # upsert-by-id model and why only draw() gates the state commit. + if just_started and result == DrawResult.ERROR: + log.warning("start-takeover animation %r not drawable; falling back to " + "in-progress layout for this poll", c["start_animation"]) + elements = build_elements(event, now, c, timeout_s, in_progress, just_started=False) + priority = select_priority(minutes_left, c["approach_minutes"], c["notice_minutes"], + in_progress, just_started=False) + new_shape = frozenset(e["id"] for e in elements) + result = client.draw(APP, elements=elements, priority=priority, led_notification_color=led) + label = f"{label} [start-anim fallback]" + if state is not None and result == DrawResult.DRAWN: # Only commit the transition once it actually lands on the device. # If draw() failed (UNREACHABLE/REJECTED/ERROR), leave `state` diff --git a/tests/test_calendar_loop.py b/tests/test_calendar_loop.py index 552d702..60b5c0c 100644 --- a/tests/test_calendar_loop.py +++ b/tests/test_calendar_loop.py @@ -528,3 +528,101 @@ def test_shape_change_triggers_clear(): ev2 = CalEvent("Standup", TAKEOVER_NOW - timedelta(seconds=10), TAKEOVER_NOW + timedelta(minutes=29), False) run_once(c, _fetch(ev2), _cfg(), TAKEOVER_NOW, dry_run=False, state=st) # takeover assert c.clears >= 1 # id-set changed -> cleared before the takeover draw + + +# --- v1.6.1 start-takeover graceful degradation on a bad start_animation --------- +# +# A mistyped start_animation names a stock animation the device doesn't have, +# so the live device rejects the full-panel takeover draw with +# DrawResult.ERROR every poll. Without the fallback in main.run_once, `state` +# never commits and the panel stays DARK for the whole start_window_seconds +# (the takeover is the ONLY thing on screen). The fallback redraws the normal +# in-progress "ENDS" layout for that poll instead. These tests drive a +# FakeClient that returns a chosen DrawResult for the takeover draw (the one +# carrying START_ANIM_ID) and DRAWN for everything else. + +IN_PROGRESS_SHAPE = frozenset({"bg", "title", "track", "track_fill", "ends", "divider", "cd_text"}) + + +class ResultFakeClient: + def __init__(self, takeover_result): + self.takeover_result = takeover_result + self.draws = []; self.clears = 0 + def draw(self, app, elements, priority=50, led_notification_color=None): + self.draws.append((elements, priority)) + if any(e["id"] == START_ANIM_ID for e in elements): + return self.takeover_result + return DrawResult.DRAWN + def clear(self, app): self.clears += 1; return True + def get_busy(self): return {} + def play_audio(self, *a, **k): return True + def set_busy_simple(self, *a, **k): return True + + +def _just_started_event(): + # In-progress and within start_window_seconds (60) -> just_started True. + return CalEvent("Standup", TAKEOVER_NOW - timedelta(seconds=15), + TAKEOVER_NOW + timedelta(minutes=29), False) + + +def test_start_takeover_error_falls_back_to_in_progress(): + c = ResultFakeClient(DrawResult.ERROR); st = {} + summary = run_once(c, _fetch(_just_started_event()), _cfg(), TAKEOVER_NOW, + dry_run=False, state=st) + # Exactly two draws: the failed takeover, then the in-progress fallback. + assert len(c.draws) == 2 + takeover_elements, takeover_priority = c.draws[0] + assert takeover_priority == PRIORITY_AMBIENT_URGENT + assert any(e["id"] == START_ANIM_ID for e in takeover_elements) + fallback_elements, fallback_priority = c.draws[1] + assert fallback_priority == PRIORITY_AMBIENT # in-progress baseline, not urgent + fb_ids = frozenset(e["id"] for e in fallback_elements) + assert START_ANIM_ID not in fb_ids + assert fb_ids == IN_PROGRESS_SHAPE + # Fallback landed (DRAWN) -> state commits the in-progress shape, so the + # window-exit poll sees no shape change and doesn't re-clear. + assert st["last_shape"] == IN_PROGRESS_SHAPE + assert "fallback" in summary and summary.endswith("drawn") + + +def test_start_takeover_rejected_does_not_fall_back(): + # REJECTED (a strictly-higher-priority app owns the screen) must NOT + # trigger the fallback: the in-progress layout draws at a LOWER priority + # and would be rejected too -- a pointless second draw. Only ERROR falls + # back. + c = ResultFakeClient(DrawResult.REJECTED); st = {} + summary = run_once(c, _fetch(_just_started_event()), _cfg(), TAKEOVER_NOW, + dry_run=False, state=st) + assert len(c.draws) == 1 # takeover only, no fallback draw + assert any(e["id"] == START_ANIM_ID for e in c.draws[0][0]) + assert st.get("last_shape") is None # not DRAWN -> not committed, retries next poll + assert summary.endswith("rejected") + + +def test_start_takeover_unreachable_does_not_fall_back(): + # UNREACHABLE (device down) is handled by main()'s backoff loop, not by a + # second (equally-unreachable) draw. + c = ResultFakeClient(DrawResult.UNREACHABLE); st = {} + summary = run_once(c, _fetch(_just_started_event()), _cfg(), TAKEOVER_NOW, + dry_run=False, state=st) + assert len(c.draws) == 1 + assert st.get("last_shape") is None + assert summary.endswith("unreachable") + + +def test_start_takeover_fallback_is_per_poll_not_latched(): + # Not latched: once the operator fixes the config value (or the stock + # animation later appears), the very next poll draws the takeover again + # with no restart -- matching the module's retry-not-assume discipline. + c = ResultFakeClient(DrawResult.ERROR); st = {} + run_once(c, _fetch(_just_started_event()), _cfg(), TAKEOVER_NOW, + dry_run=False, state=st) # poll 1: falls back + assert st["last_shape"] == IN_PROGRESS_SHAPE + + c.takeover_result = DrawResult.DRAWN # animation now drawable + c.draws.clear() + later = TAKEOVER_NOW + timedelta(seconds=10) # still inside the 60s window + run_once(c, _fetch(_just_started_event()), _cfg(), later, dry_run=False, state=st) + assert len(c.draws) == 1 # takeover, drawn straight away + assert any(e["id"] == START_ANIM_ID for e in c.draws[0][0]) + assert st["last_shape"] == frozenset({"bg", START_ANIM_ID})