Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion integrations/calendar_countdown/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 '<name>' 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.
40 changes: 40 additions & 0 deletions integrations/calendar_countdown/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
98 changes: 98 additions & 0 deletions tests/test_calendar_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -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})
Loading