From 09311f25dfff5b71d1e43179e888e6edbb4e50ac Mon Sep 17 00:00:00 2001 From: John Osumi <931193+sumitake@users.noreply.github.com> Date: Tue, 4 Aug 2026 00:06:06 -0700 Subject: [PATCH 1/4] calendar+display: urgent-ambient tier elevates imminent events above alerts Operator-reported UX gap: a persistent CI failure alert (PRIORITY_ALERT, 60) permanently evicted the calendar, hiding an imminent event with no way for the ambient tier to ever reclaim the screen -- unlike the overlay tier, ambient has no dwell/silence contract of its own to fall back on. Fixes this with a state-dependent draw priority: as an upcoming event gets closer, the calendar climbs two new shared tiers. busybar/display.py gains PRIORITY_AMBIENT_RAISED (25, strictly between the overlay tier and PRIORITY_ALERT -- "near-term, may outrank overlays, never alerts") and PRIORITY_AMBIENT_URGENT (65, strictly between PRIORITY_ALERT and PRIORITY_SESSION -- "imminent, may outrank alerts, never sessions"), each fully documented with their contract text. led_notification_color is called out explicitly as the one channel NOT subject to this same priority arbitration -- it gets through even when a BUSY/CUSTOM session owns the whole panel, making it the session-safe signal for the final-minute LED below. Ladder-ordering test extended: 20 < 21 < 25 < 60 < 65 < 90, plus dedicated boundary tests for each new tier. calendar_countdown/logic.py gains select_priority (in_progress -> 20; <= notice_minutes -> 65, covering both the NOTICE and WARNING visual states unchanged; <= approach_minutes -> 25; else 20 -- deliberately a separate ladder from _state_for's palette selection, not a 1:1 mapping: approach changes priority without changing the palette at all) and select_led (LED on every draw from imminent_minutes before start until the event starts, off the instant in_progress is true). New config keys: approach_minutes=30, imminent_minutes=1 (LED window only). in_progress deliberately stays at the baseline: once a meeting has started you already know about it, so the elevation exists to catch your attention before it starts, not to keep fighting for the screen once it has. Priority changes for the same app_name need no clear() of their own (same app upserting the same ids at a new priority always succeeds regardless of the number); ci_status's own alert redraws while the calendar holds the higher tier get a 409 (REJECTED), which its existing unified shape-tracking mechanism (from the v1.5 revision round) already handles cleanly with zero new code -- only commits state on a confirmed DRAWN result. Stage 4 -- one-time chirp at event start (T-0), not during the countdown: should_chirp/commit_chirped do edge-detected (not level- detected) once-per-event firing, tracked by event start timestamp in a caller-owned chirp_state dict, pruned after 24h. commit is separate from the fire decision so a failed play_audio retries next poll rather than silently skipping forever (same DRAWN-gated-commit discipline used throughout this codebase, applied to an audio action). A restart during an event's final minute/after it started never observed the event as upcoming, so it correctly never fires -- documented tradeoff versus the alternative (level-detection) risking a spurious chirp on every restart. next_sleep_seconds sleeps exactly until a sooner-than-usual event start instead of the full poll interval, landing the transition-detecting poll within about a second of T-0. Audio: probed the firmware's own stock sounds first (per instruction -- found assets/shared/sounds/calendar_event_starts.wav, an exact semantic match) before generating anything; confirmed live (POST /api/audio/play with that stock_path returned 200). No asset generation, upload, or repo-committed binary needed. BusyBarClient.play_audio (src/busybar/ client.py) supports both stock_path and path per the device's PlayAudio schema but this feature only uses stock_path; it has no volume parameter at all, deliberately, so playback always uses whatever volume is currently configured -- /api/audio/volume is never touched. New config key chirp=true (false disables audio entirely). TZ=UTC uv run pytest -q: 272 passed in this commit's isolated state (verified via git stash --keep-index before committing). On-device: full eviction/409/recovery cycle confirmed against the REAL live ci_status and calendar_countdown LaunchAgents (not a simulation) -- a genuine live CI failure alert visibly evicted by a calendar draw at priority 65, the live agent's own next redraw attempt logged "-> rejected" (409) exactly as designed, and "-> drawn" resumed once the calendar dropped back to priority 20. All four escalation stages (approach/ notice/warn/imminent) captured individually with matching palettes; the imminent stage's led_notification_color field confirmed present in the actual payload sent (a frame capture can't show the LED itself). One real, audible chirp fired via the actual should_chirp/play_audio path against a synthetic event, landing 0.01s after the event's start (2026-08-04T06:54:16.622887+00:00 UTC) -- confirms both the T-0 timing precision and the once-per-event edge detection (fired exactly once across a 10-poll window spanning the transition). Live LaunchAgents paused (bootout) for the duration of the eviction/stage captures to avoid interference and restarted afterward, confirmed healthy. Discovered and worked around (not fixed, out of scope for this commit, flagged separately): BusyBarClient.set_busy_simple sends a request body shape the device's PUT /api/busy/snapshot actually rejects (confirmed against the real device) -- the correct shape wraps the fields under a "snapshot" key plus a top-level "snapshot_timestamp_ms". Co-Authored-By: Claude Fable 5 --- config.example.toml | 17 ++ integrations/calendar_countdown/README.md | 32 ++++ integrations/calendar_countdown/logic.py | 188 +++++++++++++++++++- integrations/calendar_countdown/main.py | 62 +++++-- src/busybar/client.py | 37 ++++ src/busybar/config.py | 12 ++ src/busybar/display.py | 60 ++++++- tests/test_calendar_logic.py | 206 ++++++++++++++++++++++ tests/test_calendar_loop.py | 140 ++++++++++++++- tests/test_client.py | 44 +++++ tests/test_display.py | 15 +- 11 files changed, 790 insertions(+), 23 deletions(-) diff --git a/config.example.toml b/config.example.toml index 3392f3a..1a7711d 100644 --- a/config.example.toml +++ b/config.example.toml @@ -15,6 +15,16 @@ include_all_day = false auto_busy = false # calendars = ["Work"] # omit to read all calendars +# v1.5.2 escalation ladder -- as an upcoming event gets closer, the calendar +# climbs busybar/display.py's shared priority ladder so it can no longer be +# silently buried, first by the overlay-tier CI badge/quota rotation, then +# by a persistent CI failure/stuck alert. See calendar_countdown/README.md's +# "Escalation ladder" section before tuning. +approach_minutes = 30 # inside this (outside notice_minutes): priority rises above the + # overlay tier (PRIORITY_AMBIENT_RAISED) -- normal palette, unchanged +imminent_minutes = 1 # inside this (and not yet started): LED blinks on every draw +chirp = true # one-time audio chirp exactly at event start (T-0); false disables audio + [ci_status] poll_seconds = 120 repos = ["your-user/your-repo"] @@ -33,3 +43,10 @@ watch_account_repos = false # repos_exclude = ["your-user/archived-experiment"] # silence specific repos without leaving account mode active_within_days = 30 # only repos pushed within this window are polled repo_refresh_minutes = 60 # how often the repo list itself is re-enumerated (new repos picked up within this interval) + +# Alert snooze via the device's native start button (v1.5.2). Starting a +# BUSY session on the device while a CI alert is showing snoozes that exact +# failure/stuck fingerprint for snooze_minutes once the session ends -- any +# change (new failure, different workflow, resolved-then-new) re-alerts +# immediately. See ci_status/README.md's "Snoozing alerts" section. +snooze_minutes = 30 # 0 disables the feature diff --git a/integrations/calendar_countdown/README.md b/integrations/calendar_countdown/README.md index 3a05647..92d654d 100644 --- a/integrations/calendar_countdown/README.md +++ b/integrations/calendar_countdown/README.md @@ -64,6 +64,9 @@ progress_window_minutes = 60 # drain track empties from full over this many mi include_all_day = false # include all-day events (default: false) auto_busy = false # auto-mark as BUSY during events (default: false) # calendars = ["Work"] # optional: list calendar names to limit scope (discover with --list-calendars) +approach_minutes = 30 # v1.5.2 escalation ladder -- see "Escalation ladder" below +imminent_minutes = 1 # LED blinks on every draw inside this window +chirp = true # one-time audio chirp exactly at event start; false disables audio ``` ### 4. Test Live @@ -88,6 +91,9 @@ Verify that the output shows your next upcoming event with the correct countdown | `include_all_day` | boolean | false | Include all-day events in the display | | `auto_busy` | boolean | false | Automatically mark device BUSY during event time | | `calendars` | array of strings | (all) | List of calendar names to monitor. Discover available names with `uv run python -m calendar_countdown.main --list-calendars` | +| `approach_minutes` | integer | 30 | v1.5.2 escalation ladder: inside this window (and outside `notice_minutes`) the draw priority rises above the overlay tier. See "Escalation ladder" below. | +| `imminent_minutes` | integer | 1 | Inside this window (event not yet started), the LED blinks on every draw. | +| `chirp` | boolean | true | Play a one-time audio chirp exactly at event start (T-0). Set false to disable audio entirely. | ## Autostart @@ -135,3 +141,29 @@ This integration draws at the **ambient** tier (`busybar.display.PRIORITY_AMBIEN | 10 (current default) | 4 of 6 | 35 / 20 / 10 | Matching the poll to the dwell gap exactly (10s) did not eliminate the dark gaps entirely -- the two timers still run independently with no cross-process coordination, so recovery timing within a gap varies (observed roughly 2-8s into a given 10s gap) and 2 of the 6 sampled cycles still showed no recovery at all -- but it took the calendar from "never recovers" to "recovers in most cycles." A separate on-device run exercising the full 3-frame overlay rotation (running badge -> GraphQL quota -> REST quota -> repeat) at the same 10s dwell showed the same pattern: the calendar reclaimed 3 of the 4 gap windows sampled. If your setup still shows the panel dark for more than a few seconds at a stretch, that is consistent with this measurement, not a bug; lowering `poll_seconds` further has diminishing returns since the elements' own render/transmit latency puts a floor on how tightly the two timers can align. + +## Escalation ladder + +An operator-reported UX gap: a persistent CI failure alert (`ci_status`, `PRIORITY_ALERT`) permanently evicted the calendar, hiding an imminent event with no way for the calendar to ever reclaim the screen -- the ambient tier has no dwell/silence contract of its own the way the overlay tier does. v1.5.2's fix is a state-dependent draw priority: as an upcoming event gets closer, the calendar climbs `busybar/display.py`'s shared priority ladder so it can no longer be silently buried, first by the overlay-tier CI badge/quota rotation and then by a genuine alert itself. + +| Window | Priority | Palette | LED | Notes | +|---|---|---|---|---| +| `normal` (beyond `approach_minutes`) | `PRIORITY_AMBIENT` (20) | normal | off | Baseline, unchanged from before v1.5.2. | +| `approach` (within `approach_minutes`, outside `notice_minutes`) | `PRIORITY_AMBIENT_RAISED` (25) | normal (unchanged) | off | Strictly above the overlay tier (21) -- the countdown can no longer be silently interrupted by the running-CI badge/quota rotation, but a genuine alert (60) still wins. Purely a priority change; nothing looks different on screen. | +| `notice` (within `notice_minutes`) | `PRIORITY_AMBIENT_URGENT` (65) | amber | off | Strictly above `PRIORITY_ALERT` (60) -- a persistent CI failure/stuck alert no longer permanently buries an imminent event. | +| `warn` (within `warn_minutes`) | `PRIORITY_AMBIENT_URGENT` (65) | red | off | Same priority as `notice` -- they differ visually and (below) in LED, not in urgency toward the display arbitration. | +| *imminent window* (within `imminent_minutes`, part of `warn`) | `PRIORITY_AMBIENT_URGENT` (65) | red (same as `warn`) | **on**, every draw | Not a separate priority tier -- `imminent_minutes` governs only the LED. See "Audio and LED" below. | +| `in_progress` | `PRIORITY_AMBIENT` (20) | teal | off | Deliberately NOT elevated -- once a meeting has started you already know about it (you're either in it or conspicuously not); the elevation exists to catch your attention *before* an event starts, not to keep fighting for the screen once it has. An alert regains the panel here exactly as it did before this feature existed. | + +**The eviction/409 interplay.** `PRIORITY_AMBIENT_URGENT`'s draw succeeding while a `ci_status` alert is showing evicts that alert's elements outright (the firmware evicts, never restores -- see `src/busybar/display.py`'s fact 2). `ci_status` itself doesn't need to know or care: it keeps trying to redraw its alert every poll per its own no-dwell contract, gets a `409` (`DrawResult.REJECTED`) while the calendar holds the higher tier, treats that as expected and silent (nothing new here -- the same handling that already existed for the overlay tier's own dwell gaps), and re-asserts itself the instant the calendar drops back down to `PRIORITY_AMBIENT` -- typically at the calendar's *next* poll after the event starts or leaves its notice window, so the reappearance lands within one `poll_seconds` of the calendar itself, not instantly. In between, the calendar transiently owns the panel -- expected, not a bug, and needs no cross-process coordination. + +## Audio and LED (final-minute window) + +Independent of the priority ladder above, two more signals fire during the final `imminent_minutes` before an event starts (default: the last 1 minute): + +- **LED** (`led_notification_color`) blinks on *every* draw from `imminent_minutes` before start until the event actually starts, then stops (no LED once `in_progress`). The LED is a separate hardware channel from the drawn elements' priority arbitration entirely -- it is the one signal that still gets through even when a BUSY/CUSTOM session (`PRIORITY_SESSION`, 90) owns the whole panel, so it's the session-safe way to still notice an imminent event while in a session. +- **Chirp**: a short audio tone plays exactly once per event, at the moment it starts (T-0) -- not during the final-minute countdown itself. It uses a firmware-shipped **stock sound** (`shared/calendar_event_starts.wav`), confirmed via a live on-device probe (`POST /api/audio/play` with that `stock_path` returned `200`) before this design was chosen -- no asset generation, upload, or repo-committed audio file is needed or used. Playback always uses whatever volume is currently configured on the device; this integration never reads or sets `/api/audio/volume`. Set `chirp = false` to disable audio entirely. + +**Timing precision.** The main loop normally sleeps for a full `poll_seconds` between polls, but when an upcoming event's start is sooner than that, it sleeps exactly until that start instead -- so the poll that detects the transition (and fires the chirp) lands within about a second of the real start time, not up to a full `poll_seconds` late. + +**Once-per-event semantics.** The chirp fires on the transition edge only -- the poll where this *process* observes an event go from upcoming to started -- tracked in memory, keyed by the event's own start timestamp. It will not repeat on subsequent polls while the same event stays in progress. **Restart edge case**: this tracking is in-memory only, so a process restart during an event's final minute (or any time after it has already started) does not re-fire the chirp for that event -- the new process never observed it as "upcoming," so the edge is never detected. This is a deliberate tradeoff (documented, not a bug): the alternative (chirping on level-detection alone) would risk a spurious chirp on every restart during an active event. diff --git a/integrations/calendar_countdown/logic.py b/integrations/calendar_countdown/logic.py index f14b603..74a518a 100644 --- a/integrations/calendar_countdown/logic.py +++ b/integrations/calendar_countdown/logic.py @@ -1,6 +1,10 @@ from dataclasses import dataclass from datetime import datetime, timedelta +# v1.5.2 escalation ladder: the shared priority tiers (see busybar/display.py +# for the full ladder contract and the two firmware facts it's built on). +from busybar.display import PRIORITY_AMBIENT, PRIORITY_AMBIENT_RAISED, PRIORITY_AMBIENT_URGENT + PANEL_WIDTH = 72 PANEL_HEIGHT = 16 @@ -227,6 +231,185 @@ def _state_for(minutes_left: float, notice_minutes: int, warn_minutes: int, return STATE_NORMAL +def _minutes_left(event: CalEvent, now: datetime, in_progress: bool) -> float: + """Minutes until the moment that currently matters for `event`: + time-to-end if it's in progress, time-to-start otherwise. Factored out + of build_elements so main.run_once can compute the identical value + (same `event`/`now`/`in_progress` in, same number out, no drift risk) + for the v1.5.2 priority/LED/chirp decisions without duplicating this + branch inline.""" + if in_progress: + return (event.end - now).total_seconds() / 60 + return (event.start - now).total_seconds() / 60 + + +# --- v1.5.2 escalation ladder: priority, LED, and event-start chirp ------------- +# +# A persistent CI failure alert (busybar.display.PRIORITY_ALERT) used to +# permanently evict the calendar, hiding an imminent event with no way for +# the ambient tier to ever reclaim the screen -- an operator-reported UX +# gap. The fix is a state-DEPENDENT draw priority: as an upcoming event +# gets closer, calendar_countdown climbs the shared priority ladder so it +# can no longer be silently buried, first by the overlay-tier CI +# badge/quota rotation (PRIORITY_AMBIENT_RAISED, once inside +# `approach_minutes`) and then by a genuine alert itself +# (PRIORITY_AMBIENT_URGENT, once inside `notice_minutes`, covering both +# the existing NOTICE/WARNING visual states unchanged). See +# busybar.display's docstrings for the full priority-tier contracts and +# the eviction/409 interplay this creates with ci_status's own alert +# (worked through in the spec doc's v1.5.2 section), and IMMINENT_LED_COLOR +# below for the session-safe final-minute signal that rides alongside it. +# +# Deliberately NOT elevated while in_progress: once a meeting has started +# you already know about it (you're either in it or conspicuously not) -- +# the elevation exists to catch your attention BEFORE an event starts, not +# to keep fighting for the screen once it has. An alert regains the panel +# for an in-progress (or normal, un-approaching) event exactly as it did +# before this feature existed. +IMMINENT_LED_COLOR = "#E24B4AFF" +CHIRP_STOCK_PATH = "shared/calendar_event_starts.wav" +# ^ A firmware-shipped stock sound (see BusyBarClient.play_audio), not a +# generated/uploaded asset -- confirmed via a live on-device probe (POST +# /api/audio/play with this exact stock_path returned 200) before this +# was chosen. No asset generation, upload, or repo-committed binary is +# needed for the v1.5.2 chirp; see the spec doc's v1.5.2 section for the +# probe transcript and why the naming ("calendar event starts") is an +# exact semantic match for the T-0 chirp this feature fires. + + +def select_priority(minutes_left: float, approach_minutes: int, notice_minutes: int, + in_progress: bool) -> int: + """The draw priority for this poll (v1.5.2 escalation ladder) -- + deliberately a SEPARATE ladder from `_state_for`'s visual-palette + selection, not a 1:1 mapping of it: the "approach" window changes + priority without changing the palette at all (still STATE_NORMAL + colors -- see build_elements), and the NOTICE and WARNING visual + states share the SAME priority (both must be able to preempt a + persistent alert -- the whole point of this tier) even though they're + visually distinct. + + - in_progress: PRIORITY_AMBIENT (20) -- see the module-level comment + above for why elevation doesn't apply here. + - <= notice_minutes (covers both NOTICE and WARNING visually): + PRIORITY_AMBIENT_URGENT (65) -- strictly above PRIORITY_ALERT, so a + persistent CI failure/stuck alert no longer permanently buries an + imminent event. + - <= approach_minutes (but > notice_minutes): PRIORITY_AMBIENT_RAISED + (25) -- strictly above PRIORITY_OVERLAY, so the countdown can no + longer be silently interrupted by the running-CI badge/quota + rotation during this window, but still strictly below PRIORITY_ALERT + -- a genuine alert still wins over a merely-approaching event. + - otherwise (normal, > approach_minutes): PRIORITY_AMBIENT (20). + """ + if in_progress: + return PRIORITY_AMBIENT + if minutes_left <= notice_minutes: + return PRIORITY_AMBIENT_URGENT + if minutes_left <= approach_minutes: + return PRIORITY_AMBIENT_RAISED + return PRIORITY_AMBIENT + + +def select_led(minutes_left: float, imminent_minutes: int, in_progress: bool) -> str | None: + """`led_notification_color` for this poll's draw, or `None`. Fires on + every draw from `imminent_minutes` before start until the event + actually starts -- a continuous blink through the final window, not a + one-shot -- and stops the instant `in_progress` is true (no LED once + the event has started; the LED's job is to announce the imminent + start, not to keep announcing an event already underway). Independent + of `select_priority`: the LED is a separate hardware channel from the + drawn elements' priority arbitration entirely, and per + PRIORITY_AMBIENT_URGENT's docstring is the one signal that still gets + through even when a BUSY/CUSTOM session (PRIORITY_SESSION, 90) owns + the whole panel. + """ + if in_progress: + return None + if minutes_left <= imminent_minutes: + return IMMINENT_LED_COLOR + return None + + +def _prune_chirp_state(chirp_state: dict, now: datetime, max_age_hours: int = 24) -> None: + """Drops event-start timestamps older than `max_age_hours` from both + tracked sets, so a long-running process's chirp bookkeeping doesn't + grow without bound over weeks/months of uptime. Called on every + should_chirp check; cheap (a couple of set comprehensions over what + is in practice a small number of distinct events).""" + cutoff = now - timedelta(hours=max_age_hours) + for key in ("seen_upcoming", "chirped"): + if key in chirp_state: + chirp_state[key] = {s for s in chirp_state[key] if s >= cutoff} + + +def should_chirp(event: CalEvent, in_progress: bool, now: datetime, + chirp_state: dict, chirp_enabled: bool) -> bool: + """True exactly on the poll where THIS PROCESS observes `event` + transition from upcoming to started -- edge detection, not level + detection. `chirp_state` is a caller-owned dict (same pattern as every + other cache in this codebase) tracking two sets: "seen_upcoming" + (event start timestamps this process has observed with + in_progress=False at some earlier poll) and "chirped" (event starts + already fired for). Every call records `event` into "seen_upcoming" + when it's not yet in progress, REGARDLESS of `chirp_enabled` -- so the + bookkeeping stays accurate even if chirp is toggled on mid-run. The + True/False decision itself only fires when: `chirp_enabled`, + `in_progress` is true THIS poll, `event.start` was previously seen + upcoming, and it hasn't already been chirped. + + This is the mechanism behind two required behaviors: (1) a process + that starts up mid-event (in_progress=True on the very first poll it + ever sees for that event) never added that event's start to + "seen_upcoming", so the transition is never detected and no chirp + fires for it -- restarting during an event's final minute, or any + time after it started, does not produce a spurious chirp. (2) a + continuously-running process chirps exactly once per event, on the + single poll where the transition is observed, never again on + subsequent in_progress polls for the same event. + + Does NOT itself mark anything "chirped" -- see commit_chirped, which + the caller must invoke only once the actual audio play call is + confirmed to have succeeded, so a transient failure retries on the + next poll rather than silently skipping the chirp forever (the same + DRAWN-gated-commit discipline used throughout this codebase for + display state). + """ + _prune_chirp_state(chirp_state, now) + start = event.start + if not in_progress: + chirp_state.setdefault("seen_upcoming", set()).add(start) + return False + if not chirp_enabled: + return False + seen_upcoming = chirp_state.get("seen_upcoming", set()) + chirped = chirp_state.get("chirped", set()) + return start in seen_upcoming and start not in chirped + + +def commit_chirped(event: CalEvent, chirp_state: dict) -> None: + """Marks `event` as chirped -- call only after confirming the actual + `client.play_audio` call succeeded (see should_chirp's docstring).""" + chirp_state.setdefault("chirped", set()).add(event.start) + + +def next_sleep_seconds(poll_seconds: float, seconds_until_start: float | None) -> float: + """The sleep duration before the next poll (v1.5.2 chirp T-0 + precision). Normally just `poll_seconds`, but if the currently-known + upcoming event's start is sooner than a full poll interval away + (`seconds_until_start` is not None and strictly between 0 and + `poll_seconds`), sleeps exactly until that start instead -- so the + poll that detects the upcoming -> in_progress transition (and fires + the chirp) lands within about a second of the real start time, rather + than up to a full `poll_seconds` late. `seconds_until_start` of `None` + (no upcoming event), `<= 0` (already started or passed), or + `>= poll_seconds` (not imminent enough to matter) all fall through to + the normal interval unchanged. + """ + if seconds_until_start is not None and 0 < seconds_until_start < poll_seconds: + return seconds_until_start + return poll_seconds + + def _title_fits(title: str, width_px: int) -> bool: return len(title) * SMALL_FONT_CHAR_PX <= width_px @@ -292,10 +475,7 @@ def build_elements(event: CalEvent, now: datetime, cfg: dict, timeout_s: int, # geometry comment above TITLE_Y. title = ascii_safe(event.title).upper() - if in_progress: - minutes_left = (event.end - now).total_seconds() / 60 - else: - minutes_left = (event.start - now).total_seconds() / 60 + minutes_left = _minutes_left(event, now, in_progress) state = _state_for(minutes_left, cfg["notice_minutes"], cfg["warn_minutes"], in_progress) diff --git a/integrations/calendar_countdown/main.py b/integrations/calendar_countdown/main.py index 68a8a73..acc6ee5 100644 --- a/integrations/calendar_countdown/main.py +++ b/integrations/calendar_countdown/main.py @@ -13,16 +13,14 @@ from busybar.client import BusyBarClient, DrawResult from busybar.config import load_config -from busybar.display import PRIORITY_AMBIENT, ambient_timeout +from busybar.display import ambient_timeout from .logic import (ascii_safe, build_elements, select_active_event, - select_next_event) + select_next_event, _minutes_left, select_priority, + select_led, should_chirp, commit_chirped, + next_sleep_seconds, CHIRP_STOCK_PATH) APP = "calendar_countdown" -# Ambient-tier priority (see busybar.display for the full ladder contract -# and the two firmware facts it's built on). Was a local PRIORITY=20 -# constant before v1.5's shared display-tier framework. -PRIORITY = PRIORITY_AMBIENT HEARTBEAT_SECONDS = 600 log = logging.getLogger(APP) @@ -30,9 +28,14 @@ def run_once(client, fetch, cfg: dict, now: datetime, dry_run: bool, state: dict | None = None) -> str: """Run one poll cycle. `state`, when passed, is a caller-owned dict this - function uses to remember the previous draw's `in_progress` value across - calls (main() passes one shared dict across loop iterations; tests - calling run_once standalone can omit it). + function uses to remember the previous draw's `in_progress` value + across calls (main() passes one shared dict across loop iterations; + tests calling run_once standalone can omit it), plus (v1.5.2) the + next known event's start time (`next_start`, for the T-0 sleep- + shortening in main()'s loop) and the chirp edge-detection bookkeeping + (`seen_upcoming`/`chirped`, maintained by should_chirp/commit_chirped + -- see calendar_countdown.logic for the full escalation-ladder and + chirp design). The upcoming and in-progress layouts use different element id sets (`time` vs `ends`) and the device's draw endpoint upserts by id rather @@ -42,7 +45,12 @@ def run_once(client, fetch, cfg: dict, now: datetime, dry_run: bool, expires (originally found with the v1.3 `time_card`+`time` vs `ends` id sets; the same upsert-by-id model applies regardless of which ids are in play). `state` lets us clear only at the transition, not on - every poll. + every poll. Priority changes (v1.5.2's escalation ladder) do NOT need + this same clear-on-change treatment: they're the same app_name + upserting the same element ids at a new priority number, not a shape + change -- see busybar.display's PRIORITY_AMBIENT_URGENT docstring for + why a strictly-higher same-app_name draw always succeeds regardless + of priority. """ c = cfg["calendar_countdown"] timeout_s = ambient_timeout(c["poll_seconds"]) @@ -67,12 +75,33 @@ def run_once(client, fetch, cfg: dict, now: datetime, dry_run: bool, client.clear(APP) if state is not None: state["in_progress"] = None + state["next_start"] = None return "no upcoming event; cleared" + # Recorded regardless of dry_run: this is pure bookkeeping about what + # the calendar says, not a device action, so main()'s sleep-shortening + # calculation stays accurate even across dry-run polls. + if state is not None: + state["next_start"] = None if in_progress else event.start + label = f"{'active' if in_progress else 'upcoming'} {ascii_safe(event.title)!r}" if dry_run: return f"DRY-RUN would draw: {label} (in_progress={in_progress})" + # Event-start chirp (v1.5.2): fires on the upcoming -> in_progress + # transition edge only -- see should_chirp's docstring for the full + # restart-safety and once-per-event reasoning. Placed after the + # dry_run return so a dry run never plays real audio or touches the + # chirp bookkeeping. + if state is not None: + if should_chirp(event, in_progress, now, state, c["chirp"]): + if client.play_audio(APP, stock_path=CHIRP_STOCK_PATH): + commit_chirped(event, state) + # else: play_audio already logged the failure; leaving + # "chirped" uncommitted means the next poll (still + # in_progress, same event) retries rather than silently + # skipping the chirp forever. + if state is not None and state.get("in_progress") not in (None, in_progress): # clear()'s own success/failure is intentionally not checked here -- # only draw()'s result (below) gates whether `state` commits. If @@ -86,7 +115,10 @@ def run_once(client, fetch, cfg: dict, now: datetime, dry_run: bool, client.clear(APP) elements = build_elements(event, now, c, timeout_s, in_progress) - result = client.draw(APP, elements=elements, priority=PRIORITY) + minutes_left = _minutes_left(event, now, in_progress) + priority = select_priority(minutes_left, c["approach_minutes"], c["notice_minutes"], in_progress) + led = select_led(minutes_left, c["imminent_minutes"], in_progress) + result = client.draw(APP, elements=elements, priority=priority, led_notification_color=led) 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` @@ -169,7 +201,13 @@ def main() -> int: backoff = min(backoff * 2, 300) else: backoff = 5 - time.sleep(cfg["calendar_countdown"]["poll_seconds"]) + # v1.5.2 T-0 chirp precision: sleep exactly until the next + # known event's start, not a full poll interval, when that's + # sooner -- see next_sleep_seconds's docstring. + next_start = state.get("next_start") + seconds_until_start = ((next_start - datetime.now(timezone.utc)).total_seconds() + if next_start is not None else None) + time.sleep(next_sleep_seconds(cfg["calendar_countdown"]["poll_seconds"], seconds_until_start)) if __name__ == "__main__": diff --git a/src/busybar/client.py b/src/busybar/client.py index a2dd508..11c9535 100644 --- a/src/busybar/client.py +++ b/src/busybar/client.py @@ -43,6 +43,43 @@ def draw(self, application_name: str, elements: list[dict], priority: int = 50, log.warning("draw failed: HTTP %s %s", resp.status_code, resp.text[:200]) return DrawResult.ERROR + def play_audio(self, application_name: str, stock_path: str | None = None, + path: str | None = None) -> bool: + """POST /api/audio/play (v1.5.2, added for calendar_countdown's + event-start chirp). Exactly one of `stock_path` (a firmware-shipped + sound, e.g. "shared/calendar_event_starts.wav" -- pattern + `shared/[a-z0-9_.]+$`, no further subdirectories) or `path` (a file + previously uploaded into this app's own assets directory) must be + given, matching the device's own PlayAudio schema. Never touches + `/api/audio/volume` -- this method has no volume parameter at all, + deliberately, so a caller can't accidentally change the operator's + own volume setting; playback always uses whatever volume is + currently configured on the device. + + Returns True on a confirmed 200, False on anything else (network + unreachable, 400 invalid path, 404 file not found, or any other + non-200) -- best-effort, non-fatal by design: a caller should log + the outcome but never let an audio failure block or crash the + display loop (the same "audio failure may occur after display + content is visible" tolerance the device's own client libraries + document for this endpoint). + """ + body: dict = {"application_name": application_name} + if stock_path is not None: + body["stock_path"] = stock_path + elif path is not None: + body["path"] = path + else: + raise ValueError("play_audio requires exactly one of stock_path or path") + resp = self._request("POST", "/api/audio/play", json=body) + if resp is None: + log.debug("play_audio: device unreachable") + return False + if resp.status_code == 200: + return True + log.warning("play_audio failed: HTTP %s %s", resp.status_code, resp.text[:200]) + return False + def clear(self, application_name: str) -> bool: resp = self._request("DELETE", "/api/display/draw", params={"application_name": application_name}) diff --git a/src/busybar/config.py b/src/busybar/config.py index b9dd609..9390588 100644 --- a/src/busybar/config.py +++ b/src/busybar/config.py @@ -23,6 +23,15 @@ "include_all_day": False, "auto_busy": False, "calendars": [], + # v1.5.2 escalation ladder -- see busybar/display.py's + # PRIORITY_AMBIENT_RAISED/PRIORITY_AMBIENT_URGENT docstrings and + # calendar_countdown.logic.select_priority for the full ladder. + "approach_minutes": 30, # <= this and > notice_minutes: PRIORITY_AMBIENT_RAISED + # (can no longer be silently interrupted by the overlay + # tier's CI badge/quota rotation) + "imminent_minutes": 1, # <= this (and not in_progress): LED blinks on every draw + "chirp": True, # one-time audio chirp exactly at event start (T-0); + # set false to disable audio entirely }, "ci_status": { "poll_seconds": 120, @@ -43,6 +52,9 @@ "repos_exclude": [], # silence specific repos without leaving account mode "active_within_days": 30, # only repos pushed within this window are polled "repo_refresh_minutes": 60, # how often the repo list itself is re-enumerated + # Alert snooze via the device's native start button (v1.5.2) -- see + # ci_status/README.md's "Snoozing alerts" section. 0 disables. + "snooze_minutes": 30, }, } diff --git a/src/busybar/display.py b/src/busybar/display.py index 1584b24..5dfffa8 100644 --- a/src/busybar/display.py +++ b/src/busybar/display.py @@ -86,11 +86,65 @@ def overlay_gap_elapsed(last_dwell_end, now) -> float: return (now - last_dwell_end).total_seconds() +PRIORITY_AMBIENT_RAISED = 25 +"""An ambient app carrying near-term (but not yet imminent) user-critical +information may draw here instead of PRIORITY_AMBIENT (v1.5.2). Strictly +above PRIORITY_OVERLAY (21) -- so a raised-tier ambient draw can no longer +be silently interrupted by an overlay-tier dwell rotation (e.g. the +running-CI badge/quota frames) -- and strictly below PRIORITY_ALERT (60) +-- a genuine alert still wins over a merely-approaching event. This tier +exists for the "approach" window: calendar_countdown uses it once an +event is within `approach_minutes` of starting but still outside its +`notice_minutes` window (see calendar_countdown.logic.select_priority). +Overlay and alert tiers must never draw here -- this is an ambient-only +elevation, not a general-purpose "important overlay" priority; an overlay +frame that wants to preempt alerts belongs at PRIORITY_AMBIENT_URGENT or +higher only if it is itself carrying ambient, not overlay, semantics +(none currently do). +""" + PRIORITY_ALERT = 60 """Urgent, preempting states (e.g. CI failure/stuck badges). Always wins -over PRIORITY_AMBIENT and PRIORITY_OVERLAY by virtue of being a strictly -higher number (fact 1 above) -- no dwell/silence contract; draw -immediately and keep redrawing every poll while the condition holds. +over PRIORITY_AMBIENT, PRIORITY_OVERLAY, and PRIORITY_AMBIENT_RAISED by +virtue of being a strictly higher number (fact 1 above) -- no dwell/ +silence contract; draw immediately and keep redrawing every poll while +the condition holds. +""" + +PRIORITY_AMBIENT_URGENT = 65 +"""An ambient app carrying IMMINENT user-critical information may draw +here instead of PRIORITY_AMBIENT (v1.5.2) -- strictly above +PRIORITY_ALERT (60), so it can preempt even a genuine, currently-active +alert (fact 2 means that alert's elements are evicted, not merely +occluded-and-later-restored -- see the eviction/409 interplay in the spec +doc's v1.5.2 section for why this is safe: the alert's own app keeps +trying to redraw every poll per its no-dwell contract, gets a `409` +REJECTED response while this tier holds the screen, treats that as +expected and silent, and re-asserts itself the moment this tier drops +back down -- no cross-process coordination needed). Strictly below +PRIORITY_SESSION (90) -- a real BUSY/CUSTOM work session still wins. + +This tier exists specifically to close an operator-reported UX gap: a +persistent CI failure alert was permanently evicting the calendar, +hiding imminent events with no way for the calendar to ever reclaim the +screen (an ambient app has no dwell/silence contract of its own to fall +back on the way the overlay tier does). calendar_countdown elevates here +once an event enters its `notice_minutes` window and stays here through +`warn_minutes`, reverting to PRIORITY_AMBIENT once the event starts (see +calendar_countdown.logic.select_priority) -- deliberately NOT while +merely in_progress, since once a meeting has started you already know +about it; the elevation exists to catch your attention BEFORE it starts. + +**The LED is the session-safe channel.** A BUSY/CUSTOM session at +PRIORITY_SESSION (90) still outranks this tier for the *panel*, so an +urgent-ambient draw's `elements` can be evicted the same way an alert's +can. `led_notification_color`, however, is a separate hardware channel +from the drawn elements/z-order arbitration entirely -- it is not +subject to the same priority eviction, so it is the one signal that gets +through even when a session owns the whole screen. Use it for anything +that must be noticeable regardless of what else currently has the panel +(calendar_countdown sets it during its final-minute LED window -- +`imminent_minutes` -- for exactly this reason). """ PRIORITY_SESSION = 90 diff --git a/tests/test_calendar_logic.py b/tests/test_calendar_logic.py index ab6069f..b9c5dd0 100644 --- a/tests/test_calendar_logic.py +++ b/tests/test_calendar_logic.py @@ -12,6 +12,11 @@ TRACK_FILL_IN_PROGRESS, TIME_TEXT_COLOR, ENDS_TEXT_COLOR, ENDS_TEXT, DIVIDER_COLOR, DIGIT_COLOR, PANEL_WIDTH, PANEL_HEIGHT, CD_TEXT_X, CD_TEXT_MAX_WIDTH, GLYPH_ADVANCE_PX, + _minutes_left, select_priority, select_led, should_chirp, commit_chirped, + next_sleep_seconds, IMMINENT_LED_COLOR, CHIRP_STOCK_PATH, +) +from busybar.display import ( + PRIORITY_AMBIENT, PRIORITY_AMBIENT_RAISED, PRIORITY_AMBIENT_URGENT, ) TZ = timezone.utc @@ -393,3 +398,204 @@ def test_no_foreground_element_inks_beyond_panel_rows(): for el in els: rows = ink_rows(el) assert min(rows) >= 0 and max(rows) <= PANEL_HEIGHT - 1, (el["id"], rows) + + +# --- v1.5.2 escalation ladder: select_priority ------------------------------------ + +APPROACH = 30 +NOTICE = 15 +WARN = 5 +IMMINENT = 1 + +def test_select_priority_normal_beyond_approach(): + assert select_priority(45, APPROACH, NOTICE, in_progress=False) == PRIORITY_AMBIENT + +def test_select_priority_approach_tier(): + # Inside approach_minutes, outside notice_minutes. + assert select_priority(30, APPROACH, NOTICE, in_progress=False) == PRIORITY_AMBIENT_RAISED + assert select_priority(16, APPROACH, NOTICE, in_progress=False) == PRIORITY_AMBIENT_RAISED + +def test_select_priority_notice_tier(): + assert select_priority(15, APPROACH, NOTICE, in_progress=False) == PRIORITY_AMBIENT_URGENT + assert select_priority(6, APPROACH, NOTICE, in_progress=False) == PRIORITY_AMBIENT_URGENT + +def test_select_priority_warn_tier_same_priority_as_notice(): + # warn and notice share PRIORITY_AMBIENT_URGENT -- they differ visually + # (palette) and in LED (select_led), not in priority. + assert select_priority(5, APPROACH, NOTICE, in_progress=False) == PRIORITY_AMBIENT_URGENT + assert select_priority(1, APPROACH, NOTICE, in_progress=False) == PRIORITY_AMBIENT_URGENT + +def test_select_priority_in_progress_always_baseline(): + # Even with minutes_left inside every elevated window, in_progress + # forces the baseline priority -- see the module docstring for why. + assert select_priority(0.5, APPROACH, NOTICE, in_progress=True) == PRIORITY_AMBIENT + assert select_priority(-5, APPROACH, NOTICE, in_progress=True) == PRIORITY_AMBIENT + +def test_select_priority_boundary_exact_approach_minutes(): + assert select_priority(APPROACH, APPROACH, NOTICE, in_progress=False) == PRIORITY_AMBIENT_RAISED + assert select_priority(APPROACH + 0.01, APPROACH, NOTICE, in_progress=False) == PRIORITY_AMBIENT + +def test_select_priority_boundary_exact_notice_minutes(): + assert select_priority(NOTICE, APPROACH, NOTICE, in_progress=False) == PRIORITY_AMBIENT_URGENT + assert select_priority(NOTICE + 0.01, APPROACH, NOTICE, in_progress=False) == PRIORITY_AMBIENT_RAISED + + +# --- select_led -------------------------------------------------------------------- + +def test_select_led_none_outside_imminent_window(): + assert select_led(2, IMMINENT, in_progress=False) is None + assert select_led(15, IMMINENT, in_progress=False) is None + +def test_select_led_fires_inside_imminent_window(): + assert select_led(1, IMMINENT, in_progress=False) == IMMINENT_LED_COLOR + assert select_led(0.1, IMMINENT, in_progress=False) == IMMINENT_LED_COLOR + +def test_select_led_never_fires_in_progress(): + # Even with minutes_left well inside the imminent window (e.g. a + # negative value from an event that's technically started), in_progress + # forces no LED. + assert select_led(0.5, IMMINENT, in_progress=True) is None + assert select_led(-1, IMMINENT, in_progress=True) is None + +def test_select_led_boundary_exact_imminent_minutes(): + assert select_led(IMMINENT, IMMINENT, in_progress=False) == IMMINENT_LED_COLOR + assert select_led(IMMINENT + 0.01, IMMINENT, in_progress=False) is None + +def test_select_led_only_notice_and_warn_have_no_led_outside_imminent(): + # warn_minutes=5 is well outside imminent_minutes=1 by default -- no LED + # in the warn tier itself, only inside the (much narrower) imminent one. + assert select_led(WARN, IMMINENT, in_progress=False) is None + + +# --- palette stays 4-way: approach adds NO new visual state ----------------------- + +def test_approach_window_uses_normal_palette_not_a_new_state(): + # v1.5.2: the approach tier changes priority only -- build_elements + # (and _state_for underneath it) never sees approach_minutes at all, + # so an event at e.g. 20 minutes out (inside approach, outside notice) + # still renders with STATE_NORMAL colors. + e = CalEvent("Standup", NOW + timedelta(minutes=20), NOW + timedelta(minutes=50), False) + els = build_elements(e, NOW, CFG, timeout_s=90, in_progress=False) + bg = next(el for el in els if el["id"] == "bg") + assert bg["fill_colors"] == BG_GRADIENT[STATE_NORMAL] + + +# --- should_chirp / commit_chirped: once-per-event, edge-detected ---------------- +# (reuses the module's own `ev(offset_min, ...)` helper defined near the top) + +def test_should_chirp_false_while_upcoming(): + event = ev(2) + state = {} + assert should_chirp(event, in_progress=False, now=NOW, chirp_state=state, chirp_enabled=True) is False + assert event.start in state["seen_upcoming"] + +def test_should_chirp_true_on_transition_edge(): + event = ev(2) + state = {} + # Poll while upcoming (records seen_upcoming). + should_chirp(event, in_progress=False, now=NOW, chirp_state=state, chirp_enabled=True) + # Next poll: event has started. + assert should_chirp(event, in_progress=True, now=NOW, chirp_state=state, chirp_enabled=True) is True + +def test_should_chirp_does_not_refire_after_commit(): + event = ev(2) + state = {} + should_chirp(event, in_progress=False, now=NOW, chirp_state=state, chirp_enabled=True) + assert should_chirp(event, in_progress=True, now=NOW, chirp_state=state, chirp_enabled=True) is True + commit_chirped(event, state) + # Multiple subsequent in_progress polls must never re-fire. + assert should_chirp(event, in_progress=True, now=NOW, chirp_state=state, chirp_enabled=True) is False + assert should_chirp(event, in_progress=True, now=NOW, chirp_state=state, chirp_enabled=True) is False + +def test_should_chirp_restart_mid_event_does_not_fire(): + # Fresh chirp_state (as if the process just restarted) that never saw + # this event as upcoming -- the documented restart-safety edge case. + event = ev(-2) # already started + state = {} + assert should_chirp(event, in_progress=True, now=NOW, chirp_state=state, chirp_enabled=True) is False + +def test_should_chirp_disabled_never_fires(): + event = ev(2) + state = {} + should_chirp(event, in_progress=False, now=NOW, chirp_state=state, chirp_enabled=False) + assert should_chirp(event, in_progress=True, now=NOW, chirp_state=state, chirp_enabled=False) is False + +def test_should_chirp_disabled_still_tracks_seen_upcoming(): + # Observation happens regardless of chirp_enabled, so toggling chirp on + # mid-run doesn't lose the edge-detection precondition it needs. + event = ev(2) + state = {} + should_chirp(event, in_progress=False, now=NOW, chirp_state=state, chirp_enabled=False) + assert event.start in state["seen_upcoming"] + +def test_should_chirp_new_event_fires_independently(): + event_a = ev(2) + event_b = ev(5, dur_min=15) + state = {} + should_chirp(event_a, in_progress=False, now=NOW, chirp_state=state, chirp_enabled=True) + assert should_chirp(event_a, in_progress=True, now=NOW, chirp_state=state, chirp_enabled=True) is True + commit_chirped(event_a, state) + # A different event (different start) is entirely independent. + should_chirp(event_b, in_progress=False, now=NOW, chirp_state=state, chirp_enabled=True) + assert should_chirp(event_b, in_progress=True, now=NOW, chirp_state=state, chirp_enabled=True) is True + +def test_commit_chirped_not_called_means_retry_next_poll(): + # Mirrors the DRAWN-gated commit discipline: if the caller doesn't + # call commit_chirped (e.g. because play_audio failed), the very next + # poll must still see should_chirp return True. + event = ev(2) + state = {} + should_chirp(event, in_progress=False, now=NOW, chirp_state=state, chirp_enabled=True) + assert should_chirp(event, in_progress=True, now=NOW, chirp_state=state, chirp_enabled=True) is True + # (caller's play_audio "failed" -- commit_chirped deliberately not called) + assert should_chirp(event, in_progress=True, now=NOW, chirp_state=state, chirp_enabled=True) is True + +def test_chirp_state_prunes_old_entries(): + event = ev(2) + state = {} + should_chirp(event, in_progress=False, now=NOW, chirp_state=state, chirp_enabled=True) + assert event.start in state["seen_upcoming"] + much_later = NOW + timedelta(hours=48) + # A call for an unrelated, far-future event 48h later should prune the + # old entry out of seen_upcoming. + other = ev(48 * 60 + 2) + should_chirp(other, in_progress=False, now=much_later, chirp_state=state, chirp_enabled=True) + assert event.start not in state["seen_upcoming"] + +def test_chirp_stock_path_is_a_firmware_stock_sound_not_an_uploaded_asset(): + # Documents the design choice (v1.5.2): no asset generation/upload + # needed -- see logic.py's module comment for the on-device probe that + # confirmed this exact stock_path works. + assert CHIRP_STOCK_PATH == "shared/calendar_event_starts.wav" + + +# --- next_sleep_seconds: T-0 chirp precision -------------------------------------- + +def test_next_sleep_seconds_normal_when_no_upcoming_event(): + assert next_sleep_seconds(10, None) == 10 + +def test_next_sleep_seconds_normal_when_event_far_out(): + assert next_sleep_seconds(10, 25.0) == 10 # >= poll_seconds, not imminent enough + +def test_next_sleep_seconds_shortens_when_start_is_sooner(): + assert next_sleep_seconds(10, 3.0) == 3.0 + +def test_next_sleep_seconds_normal_when_already_started(): + assert next_sleep_seconds(10, 0) == 10 + assert next_sleep_seconds(10, -5.0) == 10 + +def test_next_sleep_seconds_boundary_exact_poll_seconds(): + # Strictly less than poll_seconds triggers shortening; exactly equal + # does not (falls through to the normal interval, same value either way). + assert next_sleep_seconds(10, 10.0) == 10 + + +# --- _minutes_left ------------------------------------------------------------- + +def test_minutes_left_upcoming_uses_start(): + e = ev(23) + assert _minutes_left(e, NOW, in_progress=False) == 23.0 + +def test_minutes_left_in_progress_uses_end(): + e = ev(-5, dur_min=30) # started 5 min ago, 30 min long -> ends in 25 min + assert _minutes_left(e, NOW, in_progress=True) == 25.0 diff --git a/tests/test_calendar_loop.py b/tests/test_calendar_loop.py index 7fae8c1..db8c96c 100644 --- a/tests/test_calendar_loop.py +++ b/tests/test_calendar_loop.py @@ -15,7 +15,10 @@ "warn_minutes": 5, "notice_minutes": 15, "progress_window_minutes": 60, "include_all_day": False, - "auto_busy": False, "calendars": []}} + "auto_busy": False, "calendars": [], + # v1.5.2 escalation ladder + chirp + "approach_minutes": 30, "imminent_minutes": 1, + "chirp": True}} def make_event(offset_min: int, dur_min: int = 30, title: str = "Standup") -> CalEvent: @@ -26,7 +29,10 @@ def make_event(offset_min: int, dur_min: int = 30, title: str = "Standup") -> Ca def test_draws_countdown_for_upcoming_event(): client = Mock() client.draw.return_value = DrawResult.DRAWN - event = make_event(23) + # offset > approach_minutes (30) so this stays in the baseline "normal" + # priority tier -- see the v1.5.2 escalation-ladder tests below for the + # approach/notice/warn priority selection itself. + event = make_event(40) summary = run_once(client, lambda hours: [event], CFG, NOW, dry_run=False) client.draw.assert_called_once() kwargs = client.draw.call_args.kwargs @@ -201,3 +207,133 @@ def test_should_log_info_true_on_heartbeat_even_if_unchanged(): seconds_since_heartbeat=600, heartbeat_seconds=600) is True assert should_log_info("drew X -> drawn", "drew X -> drawn", seconds_since_heartbeat=599, heartbeat_seconds=600) is False + + +# --- v1.5.2 escalation ladder + LED, end to end through run_once ----------------- + +from busybar.display import PRIORITY_AMBIENT_RAISED, PRIORITY_AMBIENT_URGENT +from calendar_countdown.logic import IMMINENT_LED_COLOR, CHIRP_STOCK_PATH + +def test_run_once_draws_at_raised_priority_in_approach_window(): + client = Mock(); client.draw.return_value = DrawResult.DRAWN + event = make_event(20) # inside approach_minutes(30), outside notice_minutes(15) + run_once(client, lambda hours: [event], CFG, NOW, dry_run=False) + assert client.draw.call_args.kwargs["priority"] == PRIORITY_AMBIENT_RAISED + assert client.draw.call_args.kwargs["led_notification_color"] is None + +def test_run_once_draws_at_urgent_priority_in_notice_window(): + client = Mock(); client.draw.return_value = DrawResult.DRAWN + event = make_event(10) # inside notice_minutes(15) + run_once(client, lambda hours: [event], CFG, NOW, dry_run=False) + assert client.draw.call_args.kwargs["priority"] == PRIORITY_AMBIENT_URGENT + assert client.draw.call_args.kwargs["led_notification_color"] is None + +def test_run_once_led_fires_in_imminent_window(): + client = Mock(); client.draw.return_value = DrawResult.DRAWN + event = make_event(0.5) # inside imminent_minutes(1) + run_once(client, lambda hours: [event], CFG, NOW, dry_run=False) + assert client.draw.call_args.kwargs["priority"] == PRIORITY_AMBIENT_URGENT + assert client.draw.call_args.kwargs["led_notification_color"] == IMMINENT_LED_COLOR + +def test_run_once_in_progress_stays_baseline_no_led(): + client = Mock(); client.draw.return_value = DrawResult.DRAWN + active = make_event(-1, dur_min=30, title="Active") + run_once(client, lambda hours: [active], CFG, NOW, dry_run=False) + assert client.draw.call_args.kwargs["priority"] == PRIORITY_AMBIENT + assert client.draw.call_args.kwargs["led_notification_color"] is None + + +# --- v1.5.2 chirp, end to end through run_once ------------------------------------ + +def test_run_once_chirps_exactly_once_on_start_transition(): + client = Mock(); client.draw.return_value = DrawResult.DRAWN + client.play_audio.return_value = True + state: dict = {} + upcoming = make_event(0.2) # about to start + run_once(client, lambda hours: [upcoming], CFG, NOW, dry_run=False, state=state) + client.play_audio.assert_not_called() # still upcoming -- no chirp yet + + # Same event's start timestamp must line up for edge detection -- build + # the "started" version from the original event's own start directly, + # not a fresh make_event() call (which would compute a different start). + started = CalEvent(upcoming.title, upcoming.start, upcoming.start + timedelta(minutes=30), False) + later = upcoming.start + timedelta(seconds=1) + run_once(client, lambda hours: [started], CFG, later, dry_run=False, state=state) + client.play_audio.assert_called_once_with("calendar_countdown", stock_path=CHIRP_STOCK_PATH) + + # A further poll, still in_progress, must not re-chirp. + client.play_audio.reset_mock() + run_once(client, lambda hours: [started], CFG, later + timedelta(seconds=10), dry_run=False, state=state) + client.play_audio.assert_not_called() + +def test_run_once_chirp_disabled_never_fires(): + client = Mock(); client.draw.return_value = DrawResult.DRAWN + client.play_audio.return_value = True + cfg = {"calendar_countdown": {**CFG["calendar_countdown"], "chirp": False}} + state: dict = {} + upcoming = make_event(0.2) + run_once(client, lambda hours: [upcoming], cfg, NOW, dry_run=False, state=state) + started = CalEvent(upcoming.title, upcoming.start, upcoming.start + timedelta(minutes=30), False) + later = upcoming.start + timedelta(seconds=1) + run_once(client, lambda hours: [started], cfg, later, dry_run=False, state=state) + client.play_audio.assert_not_called() + +def test_run_once_restart_mid_event_does_not_chirp(): + # Fresh state dict (as if the process just started) whose very first + # poll already finds the event in_progress -- no chirp, matching + # should_chirp's documented restart-safety edge case. + client = Mock(); client.draw.return_value = DrawResult.DRAWN + client.play_audio.return_value = True + state: dict = {} + active = make_event(-2, dur_min=30, title="Active") + run_once(client, lambda hours: [active], CFG, NOW, dry_run=False, state=state) + client.play_audio.assert_not_called() + +def test_run_once_chirp_retries_next_poll_if_play_fails(): + client = Mock(); client.draw.return_value = DrawResult.DRAWN + client.play_audio.return_value = False # transient failure + state: dict = {} + upcoming = make_event(0.2) + run_once(client, lambda hours: [upcoming], CFG, NOW, dry_run=False, state=state) + started = CalEvent(upcoming.title, upcoming.start, upcoming.start + timedelta(minutes=30), False) + later = upcoming.start + timedelta(seconds=1) + run_once(client, lambda hours: [started], CFG, later, dry_run=False, state=state) + assert client.play_audio.call_count == 1 + + # Retries on the next poll since the failure wasn't committed. + client.play_audio.return_value = True + run_once(client, lambda hours: [started], CFG, later + timedelta(seconds=5), dry_run=False, state=state) + assert client.play_audio.call_count == 2 + +def test_run_once_dry_run_never_chirps(): + client = Mock() + state: dict = {} + upcoming = make_event(0.2) + run_once(client, lambda hours: [upcoming], CFG, NOW, dry_run=True, state=state) + started = CalEvent(upcoming.title, upcoming.start, upcoming.start + timedelta(minutes=30), False) + later = upcoming.start + timedelta(seconds=1) + run_once(client, lambda hours: [started], CFG, later, dry_run=True, state=state) + client.play_audio.assert_not_called() + + +# --- state["next_start"] bookkeeping (feeds main()'s sleep-shortening) ----------- + +def test_run_once_records_next_start_for_upcoming_event(): + client = Mock(); client.draw.return_value = DrawResult.DRAWN + state: dict = {} + event = make_event(23) + run_once(client, lambda hours: [event], CFG, NOW, dry_run=False, state=state) + assert state["next_start"] == event.start + +def test_run_once_next_start_none_when_in_progress(): + client = Mock(); client.draw.return_value = DrawResult.DRAWN + state: dict = {} + active = make_event(-5, dur_min=30, title="Active") + run_once(client, lambda hours: [active], CFG, NOW, dry_run=False, state=state) + assert state["next_start"] is None + +def test_run_once_next_start_none_when_no_event(): + client = Mock() + state: dict = {} + run_once(client, lambda hours: [], CFG, NOW, dry_run=False, state=state) + assert state["next_start"] is None diff --git a/tests/test_client.py b/tests/test_client.py index 85147c2..4aefe38 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -81,3 +81,47 @@ def test_status_success(mock_request): payload = {"version": "1.0.0", "uptime_ms": 300_000} mock_request.return_value = _response(200, payload) assert BusyBarClient().status() == payload + + +# --- play_audio (v1.5.2, calendar_countdown chirp) ------------------------------- + +@patch("busybar.client.requests.request") +def test_play_audio_stock_path_success(mock_request): + mock_request.return_value = _response(200) + assert BusyBarClient().play_audio("calendar_countdown", stock_path="shared/calendar_event_starts.wav") is True + method, url = mock_request.call_args.args + assert method == "POST" and url == "http://10.0.4.20/api/audio/play" + body = mock_request.call_args.kwargs["json"] + assert body == {"application_name": "calendar_countdown", + "stock_path": "shared/calendar_event_starts.wav"} + +@patch("busybar.client.requests.request") +def test_play_audio_path_variant(mock_request): + mock_request.return_value = _response(200) + assert BusyBarClient().play_audio("app", path="data.snd") is True + body = mock_request.call_args.kwargs["json"] + assert body == {"application_name": "app", "path": "data.snd"} + +def test_play_audio_requires_exactly_one_source(): + try: + BusyBarClient().play_audio("app") + raise AssertionError("expected ValueError") + except ValueError: + pass + +@patch("busybar.client.requests.request") +def test_play_audio_false_on_404(mock_request): + mock_request.return_value = _response(404) + assert BusyBarClient().play_audio("app", stock_path="shared/nope.wav") is False + +@patch("busybar.client.requests.request") +def test_play_audio_false_on_unreachable(mock_request): + mock_request.side_effect = requests.ConnectionError() + assert BusyBarClient().play_audio("app", stock_path="shared/x.wav") is False + +@patch("busybar.client.requests.request") +def test_play_audio_never_touches_volume_endpoint(mock_request): + mock_request.return_value = _response(200) + BusyBarClient().play_audio("app", stock_path="shared/x.wav") + for call in mock_request.call_args_list: + assert "/api/audio/volume" not in call.args[1] diff --git a/tests/test_display.py b/tests/test_display.py index 08e9f19..b752976 100644 --- a/tests/test_display.py +++ b/tests/test_display.py @@ -1,7 +1,8 @@ from datetime import datetime, timedelta, timezone from busybar.display import ( - PRIORITY_AMBIENT, PRIORITY_OVERLAY, PRIORITY_ALERT, PRIORITY_SESSION, + PRIORITY_AMBIENT, PRIORITY_OVERLAY, PRIORITY_AMBIENT_RAISED, PRIORITY_ALERT, + PRIORITY_AMBIENT_URGENT, PRIORITY_SESSION, AMBIENT_REDRAW_SECONDS, OVERLAY_DWELL_SECONDS, ambient_timeout, overlay_gap_elapsed, ) @@ -12,7 +13,9 @@ def test_priority_ladder_values(): assert PRIORITY_AMBIENT == 20 assert PRIORITY_OVERLAY == 21 + assert PRIORITY_AMBIENT_RAISED == 25 assert PRIORITY_ALERT == 60 + assert PRIORITY_AMBIENT_URGENT == 65 assert PRIORITY_SESSION == 90 def test_priority_ladder_is_strictly_increasing(): @@ -20,13 +23,21 @@ def test_priority_ladder_is_strictly_increasing(): # REJECTED by the firmware (probed, contradicts the OpenAPI doc), so # every tier that must be able to preempt the one below it needs a # strictly greater number, not merely a "greater or equal" one. - ladder = [PRIORITY_AMBIENT, PRIORITY_OVERLAY, PRIORITY_ALERT, PRIORITY_SESSION] + # v1.5.2: 20 < 21 < 25 < 60 < 65 < 90. + ladder = [PRIORITY_AMBIENT, PRIORITY_OVERLAY, PRIORITY_AMBIENT_RAISED, + PRIORITY_ALERT, PRIORITY_AMBIENT_URGENT, PRIORITY_SESSION] assert ladder == sorted(set(ladder)) assert len(ladder) == len(set(ladder)) def test_overlay_priority_strictly_exceeds_ambient(): assert PRIORITY_OVERLAY > PRIORITY_AMBIENT +def test_ambient_raised_strictly_between_overlay_and_alert(): + assert PRIORITY_OVERLAY < PRIORITY_AMBIENT_RAISED < PRIORITY_ALERT + +def test_ambient_urgent_strictly_between_alert_and_session(): + assert PRIORITY_ALERT < PRIORITY_AMBIENT_URGENT < PRIORITY_SESSION + def test_cadence_constants(): # Tuned down from 15 to 10 after on-device re-measurement showed 15s # only recovering the ambient app's screen time in 2 of 6 dwell cycles From af59d21fff8608c32156c16860197904221dcd68 Mon Sep 17 00:00:00 2001 From: John Osumi <931193+sumitake@users.noreply.github.com> Date: Tue, 4 Aug 2026 00:06:51 -0700 Subject: [PATCH 2/4] ci: snooze alerts via the device's native start button Additive feature riding on the v1.5.2 branch. Raw physical button events aren't API-observable (confirmed: the status WebSocket only reports what's currently on screen, not button events), but the BUSY/ CUSTOM session the button starts is, via client.get_busy(). The snooze rule rides on that: an alert showing at the moment a session starts is treated as "the operator saw it and pressed the button." compute_alert_fingerprint (ci_status/logic.py): a frozenset of (repo, workflow, category) triples, category being "failing" or "stuck" so a pair moving between categories counts as a change, not the same alert continuing. update_snooze: the full state machine, in-memory (restart clears -- documented). Pending starts only on a genuine inactive -> active transition (edge, not level) while an alert is showing -- a session that predates the alert, or predates a fingerprint change mid-session, must not be misattributed as "you just pressed the button for this." Detecting the edge needs the previous poll's observation (session_was_active), which defaults to True (not False) whenever unobserved -- on the very first poll of a fresh process, or after a gap from the deliberately-gated get_busy() polling (only while an alert is showing or a snooze is pending/active, never on a fully idle poll) -- biasing against a false-positive auto-snooze at the cost of occasionally requiring the operator to press the button again. Once pending, the session ending starts a timed snooze (snooze_until = now + snooze_minutes); any fingerprint change at any point clears the snooze and re-alerts immediately; expiry resumes alerting if still failing. New config key snooze_minutes=30 (0 disables). build_ci_payload gains suppress_alert (skip failure/stuck entirely, falling through to overlay/quiet-green/nothing -- "running/quota rotation and green behavior unaffected" is explicit) and suppress_led (blank the failure branch's LED specifically without suppressing the draw -- used during pending, since the alert's own element draw still proceeds as normal and gets naturally rejected by the session's higher priority anyway, but its LED bypasses that same arbitration and would otherwise keep blinking through a session the operator just acknowledged). main.run_once computes effective_has_alert = has_alert and not suppress_alert and uses it for the overlay rotation's own "an alert takes precedence" check too, not just the alert badge's own rendering. TZ=UTC uv run pytest -q: 299 passed in this commit's isolated state (verified via git stash --keep-index before committing). New coverage: compute_alert_fingerprint, the full update_snooze state machine (pending -> timed -> suppression -> expiry, fingerprint-change re-alert at every stage including mid-pending and mid-timed, snooze_minutes=0 disables, the conservative session_was_active default), and the loop- level end-to-end sequence through run_once (busy-poll gating -- never called when idle -- LED suppressed during pending, alert suppressed during timed, running/quota rotation unaffected while suppressed, full backward compatibility when snooze_state is omitted). On-device: every actual display action (draw/clear, the LED field, re-alert) verified against the real device through the real ci_status.main.run_once, including a genuinely hardware-triggered BUSY session via POST /api/input?key=busy (the documented remote button- emulation endpoint) and key=off to end it -- the closest possible reproduction of the actual operator flow short of a physical press. Captured: alert visible, panel dark during the timed-snooze suppression window, and the alert reappearing on a new failure fingerprint. The live ci_status LaunchAgent was paused (bootout) for the duration to avoid interference and restarted afterward, confirmed healthy. Co-Authored-By: Claude Fable 5 --- integrations/ci_status/README.md | 26 ++++ integrations/ci_status/logic.py | 162 +++++++++++++++++++++- integrations/ci_status/main.py | 50 ++++++- tests/test_ci_logic.py | 145 +++++++++++++++++++ tests/test_ci_loop.py | 231 +++++++++++++++++++++++++++++++ 5 files changed, 605 insertions(+), 9 deletions(-) diff --git a/integrations/ci_status/README.md b/integrations/ci_status/README.md index 3fd1588..0d2552b 100644 --- a/integrations/ci_status/README.md +++ b/integrations/ci_status/README.md @@ -87,6 +87,7 @@ Once the foreground test completes, your `config.toml` is in place and GitHub au | `repos_exclude` | array of strings | `[]` | Repos to never watch, regardless of mode — silences a specific repo without leaving account mode (or, less commonly, without editing `repos`). Applied last, unconditionally; a no-op when empty. | | `active_within_days` | integer | 30 | In account mode, only auto-discovered repos pushed within this many days are watched (caps request volume on large accounts). Repos in `repos` are never subject to this filter. | | `repo_refresh_minutes` | integer | 60 | How often the account's repo list is re-enumerated. A newly created (or newly pushed-to, if previously outside the active window) repo is picked up within this interval, not instantly. | +| `snooze_minutes` | integer | 30 | v1.5.2: how long an alert stays snoozed after you start-then-end a BUSY session on the device while it's showing. `0` disables the feature. See "Snoozing alerts" below. | ## Account-wide watching @@ -98,6 +99,18 @@ By default this integration watches exactly the repos listed in `repos`. Setting **Caveat: `active_within_days` filters on `pushed_at`, a repo-level field — it has no idea about *schedule*-triggered workflow runs.** A repo whose CI only ever runs on a cron schedule (no pushes) will fall out of the active window and stop being watched even while its scheduled runs keep firing, because nothing about a scheduled run touches `pushed_at`. If you rely on schedule-triggered CI on a repo that doesn't otherwise see regular pushes, add it to `repos` explicitly (explicit repos are never subject to the active-window filter) rather than relying on account-wide discovery to keep watching it. +## Snoozing alerts + +**From the button's perspective:** you're looking at a persistent CI failure or stuck-queue alert on the device, and you already know about it — you don't want to keep seeing it right now. Press the device's native **start** button (the same one that begins a BUSY/CUSTOM session), then press it again to end the session whenever you're ready. Once that session ends, this exact failure stays off the panel for `snooze_minutes` (default 30) — no config edit, no separate acknowledgement step, just the button you were already going to press anyway. + +**Why a session, not a dedicated gesture.** Raw physical button presses aren't observable through the device's API at all (confirmed: the status WebSocket only reports what's currently on screen, not button events) — but the BUSY/CUSTOM session the button starts *is*, via `client.get_busy()`. The snooze rule rides on that signal rather than needing a new one: an alert showing at the moment a session starts is treated as "you saw it and pressed the button." While the session runs, the alert is naturally hidden anyway (`PRIORITY_SESSION`, 90, outranks the alert's 60) — the snooze rule's actual work happens once the session *ends*. + +**What exactly gets remembered.** The snooze is scoped to the precise set of currently-failing/stuck `repo:workflow` pairs (their "fingerprint"), not "alerts in general." If anything about that set changes while snoozed — a new repo starts failing, a different workflow in the same repo fails, or the original failure resolves and a new one appears — the snooze is dropped immediately and the (new) alert shows right away, even mid-snooze. The snooze also only ever suppresses the alert badge itself: the running-CI badge/quota overlay and the quiet-green "CI ok" text (if enabled) behave exactly as if nothing were snoozed at all. + +**During the session itself** (before you've ended it), the alert's own LED — which normally blinks even through a BUSY session, since LED is a separate channel from the panel's own priority arbitration — is suppressed too, the moment the session starts. You pressed the button; the LED doesn't need to keep insisting. + +**Restart edge case.** This state is in-memory only, like every other cache in this codebase — a process restart loses any pending or active snooze. A restart that happens to land while a session is *already* active is deliberately treated conservatively: rather than risk assuming a session that predates this process's own observation was "a button press for the alert," a fresh process requires an actually-observed inactive → active transition before it will start a new pending snooze. Practically: if you restart the integration mid-session, you may need to end and (if still needed) re-acknowledge via a fresh session press. + ## Display Priority Tiers This integration's alert badges (failure/stuck) and its overlay-tier @@ -123,6 +136,19 @@ preempts it unconditionally — a failure or stuck-queue badge always wins over the running badge or a quota frame, per the precedence in `build_ci_payload` (failure > stuck > overlay > quiet green > nothing). +**v1.5.2: the alert tier is no longer the ceiling.** `calendar_countdown` +can elevate to `PRIORITY_AMBIENT_URGENT` (65, above `PRIORITY_ALERT`) when +one of its own events is imminent (see its README's "Escalation ladder") +— closing an operator-reported gap where a persistent CI failure alert +permanently buried an imminent calendar event with no way for the +calendar to ever reclaim the screen. When that happens, this integration's +own alert draw gets a `409` (`DrawResult.REJECTED`) exactly like it always +has against the overlay tier's own dwell gaps — `run_once` treats that as +expected and silent (no state committed, no crash), and the alert +reappears on its own next poll once the calendar drops back down. See +`calendar_countdown`'s README for the full eviction/409 interplay and the +priority table. + ## Overlay Rotation: Running Badge + Quota Frames While any configured repo has an `in_progress` run (and nothing is failing diff --git a/integrations/ci_status/logic.py b/integrations/ci_status/logic.py index 8b01a6f..bfc6fa0 100644 --- a/integrations/ci_status/logic.py +++ b/integrations/ci_status/logic.py @@ -662,7 +662,8 @@ def _badge_elements(text: str, bg_color: str, text_color: str, timeout_s: int) - def build_ci_payload(states: list[RepoState], show_green: bool, timeout_s: int, - overlay: dict | None = None) -> dict | None: + overlay: dict | None = None, suppress_alert: bool = False, + suppress_led: bool = False) -> dict | None: """Precedence: failure > stuck > overlay (whichever frame the caller's rotation picked -- the running badge or a quota frame) > quiet green > nothing. Failure and stuck stay at PRIORITY_ALERT (60, unchanged) and @@ -672,14 +673,31 @@ def build_ci_payload(states: list[RepoState], show_green: bool, timeout_s: int, fully pre-built payload dict from `build_overlay_payload` (already carrying its own `priority`/`elements`/`led`) so this function's job is purely precedence, not rendering. + + `suppress_alert` (v1.5.2 snooze) skips the failure/stuck branches + entirely when true -- the caller (main.run_once, via update_snooze) + has decided this exact alert fingerprint is currently snoozed, so + precedence falls through to overlay/quiet-green/nothing exactly as if + nothing were failing: "running/quota rotation and green behavior + unaffected" per the snooze design. `suppress_led` (also v1.5.2) blanks + the failure branch's LED specifically, without suppressing the alert + draw itself -- used during the snooze-PENDING phase (a BUSY session is + active but hasn't ended yet): the alert's own element draw still + proceeds as normal (and gets naturally rejected by the session's + higher priority, same as always), but the LED -- a separate channel + that is NOT gated by the same priority arbitration and would otherwise + keep blinking through the session -- is silenced once the operator has + visibly acknowledged the alert by starting a session. `suppress_led` + has no effect on the stuck branch (its LED is already always `None`). """ failures = [(s.repo, name) for s in states for name in s.failing] stuck = [(s.repo, name) for s in states for name in s.stuck] - if failures: + if failures and not suppress_alert: text = "CI FAIL " + " ".join(f"{repo}:{name}" for repo, name in failures) + led = None if suppress_led else "#FF0000FF" return {"elements": _badge_elements(text, "#A32D2DFF", "#FFFFFFFF", timeout_s), - "priority": PRIORITY_ALERT, "led": "#FF0000FF"} - if stuck: + "priority": PRIORITY_ALERT, "led": led} + if stuck and not suppress_alert: text = "CI stuck " + " ".join(f"{repo}:{name}" for repo, name in stuck) return {"elements": _badge_elements(text, "#BA7517FF", "#0B0B0BFF", timeout_s), "priority": PRIORITY_ALERT, "led": None} @@ -689,3 +707,139 @@ def build_ci_payload(states: list[RepoState], show_green: bool, timeout_s: int, return {"elements": [_text_element("CI ok", "#00FF00FF", timeout_s)], "priority": PRIORITY_ALERT, "led": None} return None + + +# --- alert snooze via the device's native start button (v1.5.2) ----------------- +# +# Raw physical button events aren't API-observable (confirmed: the status +# WebSocket is screen-only), but the BUSY session it starts is, via +# client.get_busy(). The snooze rule rides on that: an alert showing at the +# moment a session starts is treated as "the operator saw it and pressed +# the button" -- once the session ends, that exact failure/stuck fingerprint +# is suppressed for `snooze_minutes`. Any change to the fingerprint (a new +# failure, a different workflow, resolved-then-new) immediately clears the +# snooze and re-alerts; so does the snooze's own expiry if the same +# fingerprint is still failing. + +def compute_alert_fingerprint(states: list[RepoState]) -> frozenset: + """The identity of "what's currently alerting" -- a frozenset of + `(repo, workflow, category)` triples, category being "failing" or + "stuck" so a repo:workflow pair moving between the two categories + counts as a fingerprint change (not silently treated as "the same + alert"), matching the snooze rule's "ANY fingerprint change... clear + snooze, alert immediately." Empty (falsy) when nothing is failing or + stuck. + """ + return (frozenset((s.repo, name, "failing") for s in states for name in s.failing) + | frozenset((s.repo, name, "stuck") for s in states for name in s.stuck)) + + +def update_snooze(alert_fingerprint: frozenset, busy_active: bool, now: datetime, + snooze_minutes: int, snooze_state: dict) -> tuple[bool, bool]: + """Advances the snooze state machine by one poll and returns + `(suppress_alert, suppress_led)` for THIS poll. `snooze_state` is a + caller-owned dict (same pattern as every other cache in this codebase), + mutated in place, with up to three keys: `"session_was_active"` + (tracked every call, for edge-detecting the inactive->active + transition -- see below), `"fingerprint"` (the alert fingerprint a + pending-or-active snooze applies to), and `"snooze_until"` (absent + while pending -- session still running -- set to a datetime once the + session ends and the timed snooze begins). + + State machine: + 1. **Pending** starts only on a genuine inactive -> active transition + (edge, not level -- see below) while an alert is currently showing: + records `fingerprint`, no `snooze_until` yet. Returns + `(False, True)` -- the alert draw itself still proceeds as normal + (and will be naturally rejected by the session's own higher + priority, same as always), but its LED is suppressed, since LED is + a separate channel not gated by that same priority arbitration and + would otherwise keep blinking through the session the operator just + acknowledged. + 2. While still **pending** (fingerprint unchanged, session still + active): keeps returning `(False, True)`. + 3. The session **ends** (busy_active goes false) while still pending, + same fingerprint: sets `snooze_until = now + snooze_minutes` and + returns `(True, False)` -- the timed snooze begins this exact poll. + 4. While **timed** and `now < snooze_until`, same fingerprint: + `(True, False)` -- alert suppressed entirely (falls through to + overlay/quiet-green/nothing in build_ci_payload), no LED question + even arises since the alert branch never runs. + 5. **Expiry** (`now >= snooze_until`): state clears, `(False, False)` + -- back to alerting normally if still failing. + 6. **Any fingerprint change** at any pending/timed point: immediately + clears the fingerprint/snooze_until (but not the + `session_was_active` tracking -- see below), falling through to + step 1's logic fresh for the new fingerprint (or `(False, False)` + if nothing is failing/stuck anymore, or if a session isn't already + active for a fresh pending to start against). + 7. `snooze_minutes <= 0` disables the feature outright: any existing + fingerprint/snooze_until is cleared and `(False, False)` always. + + **Edge, not level, and why the default matters.** Requirement 1 is a + TRANSITION ("busy snapshot transitions from inactive... to active"), + not a level condition ("an alert is showing and a session happens to + be active") -- otherwise a session that was ALREADY running before an + alert appeared (or before a fingerprint changed mid-session) would be + wrongly treated as "you just pressed the button for this," silently + snoozing something the operator never actually acknowledged. Detecting + the edge needs to know what the PREVIOUS poll observed, tracked via + `session_was_active` -- but polling is deliberately gated (see + main.run_once) to skip `get_busy()` entirely when idle (no alert, no + snooze state), which means there can be gaps where `session_was_active` + wasn't being updated. On the first poll after such a gap (or the very + first poll of a fresh process), `session_was_active` defaults to + `True` -- not `False` -- so an as-yet-unobserved busy session is + assumed to possibly PRE-DATE the alert rather than assumed absent: + the conservative direction is to require an actually-OBSERVED + inactive->active transition before granting pending, at the cost of + occasionally missing a legitimate fresh session-start that happens to + coincide with polling just resuming (a minor inconvenience -- the + operator presses the button again -- versus the alternative of a + silent, unintended auto-snooze). + + **Restart safety**: a process restart gets a fresh empty + `snooze_state`, so `session_was_active` defaults to `True` on the very + first poll regardless of the device's actual state -- the same + conservative default above, which also happens to correctly prevent a + restart-during-an-already-active-session from being misattributed as + a fresh button press. In-memory only; documented as a known limitation + (a snooze in effect at restart is lost, same as every other cache in + this codebase). + """ + was_active = snooze_state.get("session_was_active", True) + snooze_state["session_was_active"] = busy_active + + if snooze_minutes <= 0: + snooze_state.pop("fingerprint", None) + snooze_state.pop("snooze_until", None) + return False, False + + pending_fp = snooze_state.get("fingerprint") + snooze_until = snooze_state.get("snooze_until") + + if pending_fp is not None and alert_fingerprint != pending_fp: + snooze_state.pop("fingerprint", None) + snooze_state.pop("snooze_until", None) + pending_fp = None + snooze_until = None + + if pending_fp is None: + if alert_fingerprint and busy_active and not was_active: + snooze_state["fingerprint"] = alert_fingerprint + snooze_state.pop("snooze_until", None) + return False, True + return False, False + + if snooze_until is None: + if busy_active: + return False, True + snooze_state["snooze_until"] = now + timedelta(minutes=snooze_minutes) + return True, False + + if now < snooze_until: + return True, False + + snooze_state.pop("fingerprint", None) + snooze_state.pop("snooze_until", None) + return False, False diff --git a/integrations/ci_status/main.py b/integrations/ci_status/main.py index c3d9603..b5ecb43 100644 --- a/integrations/ci_status/main.py +++ b/integrations/ci_status/main.py @@ -17,8 +17,9 @@ from .logic import ( RepoState, RunningInfo, QuotaInfo, - build_ci_payload, build_overlay_payload, evaluate_runs, + build_ci_payload, build_overlay_payload, compute_alert_fingerprint, evaluate_runs, overlay_frame_sequence, parse_rate_limit, resolve_repo_list, select_running_run, + update_snooze, ) APP = "ci_status" @@ -87,7 +88,8 @@ def run_once(client, poller, cfg: dict, now: datetime, running_cache: dict[str, list[dict]] | None = None, overlay_state: dict | None = None, quota_cache: dict | None = None, - repo_cache: dict | None = None) -> str: + repo_cache: dict | None = None, + snooze_state: dict | None = None) -> str: """`running_cache`, `overlay_state`, `quota_cache`, and `repo_cache`, when passed, are caller-owned dicts this function mutates in place (mirroring `state_cache`'s existing pattern) so `main()` can hold one @@ -148,6 +150,14 @@ class the v1.3.1 calendar transition-clear fix addressed, recurring at prematurely was the root cause of a real bug where the clear-gate saw "no shape on record" and wrongly concluded no clear was needed on the next transition. + + Alert snooze (v1.5.2, `snooze_state`): omitting it (the default) skips + the snooze subsystem entirely -- every poll behaves exactly as if + nothing were ever snoozed. When given, see + `ci_status.logic.update_snooze`'s docstring for the full state + machine; `client.get_busy()` is polled only while there's something + to track (an alert currently showing, or an existing pending/timed + snooze), never on a fully idle poll. """ c = cfg["ci_status"] timeout_s = int(c["poll_seconds"] * 1.5) @@ -181,6 +191,34 @@ class the v1.3.1 calendar transition-clear fix addressed, recurring at states = list(state_cache.values()) has_alert = any(s.failing or s.stuck for s in states) + # Alert snooze via the device's native start button (v1.5.2) -- see + # ci_status.logic.update_snooze's docstring for the full state + # machine. get_busy() is polled only while there's something to + # track (an alert showing, or an existing pending/timed snooze), + # never on a fully idle poll, to keep idle cycles lean. + suppress_alert = False + suppress_led = False + if snooze_state is not None: + snooze_minutes = c.get("snooze_minutes", 0) + alert_fingerprint = compute_alert_fingerprint(states) + should_poll_busy = not dry_run and snooze_minutes > 0 and ( + bool(alert_fingerprint) or bool(snooze_state.get("fingerprint"))) + if should_poll_busy: + busy = client.get_busy() or {} + busy_active = busy.get("type") not in (None, "NOT_STARTED") + else: + busy_active = False + suppress_alert, suppress_led = update_snooze( + alert_fingerprint, busy_active, now, snooze_minutes, snooze_state) + + # While snoozed, ci_status behaves as if nothing is failing/stuck at + # all for every OTHER precedence purpose too -- "running/quota + # rotation and green behavior unaffected" is the explicit design + # intent, not just the alert badge's own rendering (build_ci_payload, + # below, gets the same suppress_alert). The overlay rotation's own + # "an alert takes precedence" check needs the identical view. + effective_has_alert = has_alert and not suppress_alert + overlay_payload = None frame_index = 0 stay_silent = False @@ -196,7 +234,7 @@ class the v1.3.1 calendar transition-clear fix addressed, recurring at running_cache[repo] = running_runs selected = select_running_run(running_cache) - if selected is None or has_alert: + if selected is None or effective_has_alert: # Nothing running, or an alert takes precedence this poll -- # reset only the ROTATION bookkeeping, so the next run to start # always begins at the CI badge. Deliberately do NOT touch @@ -239,7 +277,8 @@ class the v1.3.1 calendar transition-clear fix addressed, recurring at if frame_data_unavailable: return "overlay frame data unavailable this cycle; skipping (no draw, no clear)" - payload = build_ci_payload(states, c["show_green"], timeout_s, overlay=overlay_payload) + payload = build_ci_payload(states, c["show_green"], timeout_s, overlay=overlay_payload, + suppress_alert=suppress_alert, suppress_led=suppress_led) if dry_run: return f"DRY-RUN payload: {payload!r}" if payload is None: @@ -329,12 +368,13 @@ def main() -> int: overlay_state: dict = {} quota_cache: dict = {} repo_cache: dict = {} + snooze_state: dict = {} backoff = 5 while True: summary = run_once(client, poller, cfg, datetime.now(timezone.utc), state_cache, args.dry_run, running_cache=running_cache, overlay_state=overlay_state, quota_cache=quota_cache, - repo_cache=repo_cache) + repo_cache=repo_cache, snooze_state=snooze_state) log.info(summary) if args.once: return 0 diff --git a/tests/test_ci_logic.py b/tests/test_ci_logic.py index c1fc3f9..e9744e9 100644 --- a/tests/test_ci_logic.py +++ b/tests/test_ci_logic.py @@ -12,6 +12,7 @@ _format_eta_text, _progress_width, _build_running_title, parse_rate_limit, _quota_headroom, _quota_used_width, resolve_repo_list, _eta_label, RUNNING_NUMERAL_X, RUNNING_LABEL_GAP_PX, + compute_alert_fingerprint, update_snooze, ) from busybar.display import PRIORITY_OVERLAY, OVERLAY_DWELL_SECONDS, PRIORITY_ALERT from calendar_countdown.logic import _text_width_px @@ -609,3 +610,147 @@ def test_eta_label_falls_back_to_left_end_to_end_through_build_overlay_payload() by_id = _by_id(payload["elements"]) assert by_id["eta"]["text"] == "~1h00m" assert by_id["eta_label"]["text"] == "left" + + +# --- alert snooze via the device's native start button (v1.5.2) ----------------- + +def test_compute_alert_fingerprint_empty_when_all_green(): + assert compute_alert_fingerprint([RepoState("o/r", [], [])]) == frozenset() + +def test_compute_alert_fingerprint_covers_failing_and_stuck(): + states = [RepoState("o/r", ["tests"], ["lint"])] + fp = compute_alert_fingerprint(states) + assert fp == frozenset({("o/r", "tests", "failing"), ("o/r", "lint", "stuck")}) + +def test_compute_alert_fingerprint_category_change_is_a_different_fingerprint(): + failing_fp = compute_alert_fingerprint([RepoState("o/r", ["tests"], [])]) + stuck_fp = compute_alert_fingerprint([RepoState("o/r", [], ["tests"])]) + assert failing_fp != stuck_fp + +def test_compute_alert_fingerprint_multi_repo(): + states = [RepoState("o/a", ["tests"], []), RepoState("o/b", ["build"], [])] + fp = compute_alert_fingerprint(states) + assert fp == frozenset({("o/a", "tests", "failing"), ("o/b", "build", "failing")}) + + +# --- update_snooze: the full state machine --------------------------------------- + +FP_A = frozenset({("o/r", "tests", "failing")}) +FP_B = frozenset({("o/r", "build", "failing")}) # a different fingerprint +EMPTY_FP = frozenset() + +def test_update_snooze_disabled_always_passthrough(): + state = {} + assert update_snooze(FP_A, True, NOW, 0, state) == (False, False) + assert state.get("fingerprint") is None + +def test_update_snooze_no_alert_no_session_is_a_noop(): + state = {} + assert update_snooze(EMPTY_FP, False, NOW, 30, state) == (False, False) + assert "fingerprint" not in state + +def test_update_snooze_alert_alone_no_session_no_pending(): + state = {} + assert update_snooze(FP_A, False, NOW, 30, state) == (False, False) + assert "fingerprint" not in state + +def test_update_snooze_first_ever_poll_with_session_already_active_does_not_pend(): + # Conservative default: session_was_active defaults to True on a + # fresh/never-observed state, so the very first poll (even if + # busy_active happens to be True) is never mistaken for a fresh + # inactive->active transition -- see update_snooze's docstring. + state = {} + assert update_snooze(FP_A, True, NOW, 30, state) == (False, False) + assert "fingerprint" not in state + assert state["session_was_active"] is True + +def test_update_snooze_genuine_transition_establishes_pending(): + state = {} + update_snooze(FP_A, False, NOW, 30, state) # observe inactive first + result = update_snooze(FP_A, True, NOW, 30, state) # now transitions to active + assert result == (False, True) # draw proceeds, LED suppressed + assert state["fingerprint"] == FP_A + assert "snooze_until" not in state + +def test_update_snooze_stays_pending_while_session_continues(): + state = {} + update_snooze(FP_A, False, NOW, 30, state) + update_snooze(FP_A, True, NOW, 30, state) + later = NOW + timedelta(minutes=2) + assert update_snooze(FP_A, True, later, 30, state) == (False, True) + assert "snooze_until" not in state + +def test_update_snooze_session_end_starts_timed_snooze(): + state = {} + update_snooze(FP_A, False, NOW, 30, state) + update_snooze(FP_A, True, NOW, 30, state) + session_end = NOW + timedelta(minutes=5) + result = update_snooze(FP_A, False, session_end, 30, state) + assert result == (True, False) + assert state["snooze_until"] == session_end + timedelta(minutes=30) + +def test_update_snooze_suppresses_through_the_timed_window(): + state = {} + update_snooze(FP_A, False, NOW, 30, state) + update_snooze(FP_A, True, NOW, 30, state) + session_end = NOW + timedelta(minutes=5) + update_snooze(FP_A, False, session_end, 30, state) + mid_snooze = session_end + timedelta(minutes=10) + assert update_snooze(FP_A, False, mid_snooze, 30, state) == (True, False) + +def test_update_snooze_expires_and_realerts_if_still_failing(): + state = {} + update_snooze(FP_A, False, NOW, 30, state) + update_snooze(FP_A, True, NOW, 30, state) + session_end = NOW + timedelta(minutes=5) + update_snooze(FP_A, False, session_end, 30, state) + after_expiry = session_end + timedelta(minutes=31) + assert update_snooze(FP_A, False, after_expiry, 30, state) == (False, False) + assert "fingerprint" not in state + +def test_update_snooze_fingerprint_change_during_pending_reelerts(): + state = {} + update_snooze(FP_A, False, NOW, 30, state) + update_snooze(FP_A, True, NOW, 30, state) # pending on FP_A + later = NOW + timedelta(minutes=1) + # A different fingerprint appears while still pending on FP_A -- + # clears the old pending. Session is still active (level, not a fresh + # edge for FP_B), so FP_B does NOT get a fresh pending either. + result = update_snooze(FP_B, True, later, 30, state) + assert result == (False, False) + assert "fingerprint" not in state + +def test_update_snooze_fingerprint_change_during_timed_snooze_realerts_immediately(): + state = {} + update_snooze(FP_A, False, NOW, 30, state) + update_snooze(FP_A, True, NOW, 30, state) + session_end = NOW + timedelta(minutes=5) + update_snooze(FP_A, False, session_end, 30, state) # timed snooze on FP_A + mid_snooze = session_end + timedelta(minutes=10) + # A DIFFERENT failure shows up while FP_A is still timed-snoozed and + # no session is running -- must alert immediately, not stay suppressed. + result = update_snooze(FP_B, False, mid_snooze, 30, state) + assert result == (False, False) + assert "fingerprint" not in state + +def test_update_snooze_resolved_then_new_clears_snooze(): + state = {} + update_snooze(FP_A, False, NOW, 30, state) + update_snooze(FP_A, True, NOW, 30, state) + session_end = NOW + timedelta(minutes=5) + update_snooze(FP_A, False, session_end, 30, state) # timed on FP_A + mid_snooze = session_end + timedelta(minutes=10) + # FP_A resolved entirely (nothing failing) -- also a fingerprint change. + result = update_snooze(EMPTY_FP, False, mid_snooze, 30, state) + assert result == (False, False) + assert "fingerprint" not in state + +def test_update_snooze_session_starting_mid_alert_after_continuous_polling(): + # The "normal" full flow, polled continuously (no gating gaps): alert + # appears while no session is running, then a session starts. + state = {} + assert update_snooze(FP_A, False, NOW, 30, state) == (False, False) + t1 = NOW + timedelta(seconds=10) + assert update_snooze(FP_A, False, t1, 30, state) == (False, False) # still no session + t2 = t1 + timedelta(seconds=10) + assert update_snooze(FP_A, True, t2, 30, state) == (False, True) # session starts -- pending diff --git a/tests/test_ci_loop.py b/tests/test_ci_loop.py index 46f1f0e..148814a 100644 --- a/tests/test_ci_loop.py +++ b/tests/test_ci_loop.py @@ -595,3 +595,234 @@ def test_config_requires_repos_ok_when_watch_account_repos_key_absent(): assert config_requires_repos(cfg) is None cfg_empty = {"ci_status": {"repos": []}} assert config_requires_repos(cfg_empty) is not None + + +# --- v1.5.2: REJECTED handling during calendar priority elevation --------------- +# +# calendar_countdown can now draw at PRIORITY_AMBIENT_RAISED (25, inside its +# approach window) or PRIORITY_AMBIENT_URGENT (65, inside its notice/warn +# window and beyond PRIORITY_ALERT), evicting ci_status's own elements. +# ci_status's own next redraw attempt at its own priority then gets a 409 +# (DrawResult.REJECTED) while the calendar holds the higher tier -- expected +# and silent per busybar.client's own DrawResult.REJECTED docstring. These +# tests confirm ci_status's run_once tolerates that cleanly: no crash, no +# state/shape committed on a REJECTED draw, and a full recovery once the +# calendar drops back down and the next draw actually lands. + +def test_alert_rejected_during_calendar_elevation_does_not_commit_then_recovers(): + client = Mock() + poller = Mock() + poller.fetch_runs.return_value = [_run("failure")] + overlay_state: dict = {} + + # Poll 1: calendar is elevated (PRIORITY_AMBIENT_URGENT=65 > alert's 60) + # -- the alert draw is rejected. + client.draw.return_value = DrawResult.REJECTED + summary1 = run_once(client, poller, CFG, NOW, {}, dry_run=False, overlay_state=overlay_state) + assert "rejected" in summary1 + assert "last_shape" not in overlay_state # nothing committed on a rejected draw + assert client.clear.call_count == 0 # no clear attempted for a first-ever draw attempt + + # Poll 2: still elevated -- same shape, still rejected. Must not crash, + # must not attempt a clear (no shape change on record to clear from). + later = NOW + timedelta(seconds=10) + summary2 = run_once(client, poller, CFG, later, {}, dry_run=False, overlay_state=overlay_state) + assert "rejected" in summary2 + assert "last_shape" not in overlay_state + assert client.clear.call_count == 0 + + # Poll 3: calendar has dropped back down -- the alert draw finally lands. + client.draw.return_value = DrawResult.DRAWN + later2 = later + timedelta(seconds=10) + summary3 = run_once(client, poller, CFG, later2, {}, dry_run=False, overlay_state=overlay_state) + assert "drawn" in summary3 + assert overlay_state["last_shape"] == frozenset({"bg", "ci"}) + +def test_overlay_dwell_rejected_during_calendar_elevation_resumes_after(): + client = Mock() + poller = Mock() + poller.fetch_runs.return_value = [_run("success")] + poller.fetch_running_runs.return_value = [_running_run()] + poller.fetch_median_eta.return_value = None + overlay_state: dict = {} + + # Poll 1: calendar is in its approach window (PRIORITY_AMBIENT_RAISED=25 + # > overlay's 21) -- the running-badge dwell draw is rejected. + client.draw.return_value = DrawResult.REJECTED + summary1 = run_once(client, poller, CFG_RUNNING, NOW, {}, dry_run=False, + running_cache={}, overlay_state=overlay_state) + assert "rejected" in summary1 + assert "last_dwell_end" not in overlay_state # dwell never actually started + assert "last_shape" not in overlay_state + + # Poll 2: calendar has dropped back down -- the SAME dwell slot (never + # consumed, since the rejected attempt didn't commit) draws successfully. + # This is the "no crash, rotation resumes after" requirement -- an + # immediate retry lands, not a wait for a phantom dwell that never + # actually rendered anything. + client.draw.return_value = DrawResult.DRAWN + soon_after = NOW + timedelta(seconds=1) + summary2 = run_once(client, poller, CFG_RUNNING, soon_after, {}, dry_run=False, + running_cache={}, overlay_state=overlay_state) + assert "drawn" in summary2 + assert overlay_state.get("last_dwell_end") is not None + assert overlay_state["last_shape"] == frozenset({"bg", "title", "track", "track_fill", "eta"}) + assert client.draw.call_count == 2 # both attempts drew (1st rejected, 2nd landed) -- no crash anywhere + + +# --- alert snooze via the device's native start button (v1.5.2) ----------------- + +CFG_SNOOZE = {"ci_status": {**CFG["ci_status"], "snooze_minutes": 30}} + +def _busy(active: bool) -> dict: + return {"type": "SIMPLE" if active else "NOT_STARTED"} + +def test_snooze_get_busy_not_called_when_idle(): + client = Mock() + poller = Mock() + poller.fetch_runs.return_value = [_run("success")] # all green -- no alert + snooze_state: dict = {} + run_once(client, poller, CFG_SNOOZE, NOW, {}, dry_run=False, snooze_state=snooze_state) + client.get_busy.assert_not_called() + +def test_snooze_get_busy_called_when_alert_showing(): + client = Mock(); client.draw.return_value = DrawResult.DRAWN + client.get_busy.return_value = _busy(False) + poller = Mock() + poller.fetch_runs.return_value = [_run("failure")] + snooze_state: dict = {} + run_once(client, poller, CFG_SNOOZE, NOW, {}, dry_run=False, snooze_state=snooze_state) + client.get_busy.assert_called_once() + +def test_snooze_get_busy_skipped_when_snooze_minutes_zero(): + client = Mock(); client.draw.return_value = DrawResult.DRAWN + poller = Mock() + poller.fetch_runs.return_value = [_run("failure")] + snooze_state: dict = {} + run_once(client, poller, CFG, NOW, {}, dry_run=False, snooze_state=snooze_state) # CFG has no snooze_minutes -> 0 + client.get_busy.assert_not_called() + +def test_snooze_full_state_machine_end_to_end_through_run_once(): + client = Mock(); client.draw.return_value = DrawResult.DRAWN + poller = Mock() + poller.fetch_runs.return_value = [_run("failure")] + snooze_state: dict = {} + state_cache: dict = {} + + # Poll 1: alert showing, no session yet -- observe inactive. + client.get_busy.return_value = _busy(False) + s1 = run_once(client, poller, CFG_SNOOZE, NOW, state_cache, dry_run=False, snooze_state=snooze_state) + assert client.draw.call_args.kwargs["led_notification_color"] == "#FF0000FF" + assert "FAIL" in s1 + + # Poll 2: session starts -- pending. Draw still proceeds (elements as + # normal) but LED must be suppressed now. + client.get_busy.return_value = _busy(True) + t1 = NOW + timedelta(seconds=10) + run_once(client, poller, CFG_SNOOZE, t1, state_cache, dry_run=False, snooze_state=snooze_state) + assert client.draw.call_args.kwargs["led_notification_color"] is None + assert "FAIL" in client.draw.call_args.args[1][1]["text"] # still the real alert badge + + # Poll 3: session ends -- timed snooze begins. Alert suppressed entirely + # (falls through to "all green; cleared" since show_green is False and + # no overlay). + client.get_busy.return_value = _busy(False) + t2 = t1 + timedelta(minutes=2) + s3 = run_once(client, poller, CFG_SNOOZE, t2, state_cache, dry_run=False, snooze_state=snooze_state) + assert "cleared" in s3 + + # Poll 4: still within the 30-minute snooze window, same fingerprint -- + # stays suppressed. + t3 = t2 + timedelta(minutes=10) + s4 = run_once(client, poller, CFG_SNOOZE, t3, state_cache, dry_run=False, snooze_state=snooze_state) + assert "cleared" in s4 + + # Poll 5: snooze expired -- alert resumes (still the same failure). + t4 = t2 + timedelta(minutes=31) + client.draw.reset_mock() + s5 = run_once(client, poller, CFG_SNOOZE, t4, state_cache, dry_run=False, snooze_state=snooze_state) + assert "FAIL" in s5 + assert client.draw.call_args.kwargs["led_notification_color"] == "#FF0000FF" + +def test_snooze_fingerprint_change_realerts_during_timed_window(): + client = Mock(); client.draw.return_value = DrawResult.DRAWN + poller = Mock() + state_cache: dict = {} + snooze_state: dict = {} + + poller.fetch_runs.return_value = [_run("failure")] + client.get_busy.return_value = _busy(False) + run_once(client, poller, CFG_SNOOZE, NOW, state_cache, dry_run=False, snooze_state=snooze_state) + client.get_busy.return_value = _busy(True) + t1 = NOW + timedelta(seconds=10) + run_once(client, poller, CFG_SNOOZE, t1, state_cache, dry_run=False, snooze_state=snooze_state) + client.get_busy.return_value = _busy(False) + t2 = t1 + timedelta(minutes=2) + run_once(client, poller, CFG_SNOOZE, t2, state_cache, dry_run=False, snooze_state=snooze_state) + assert "fingerprint" in snooze_state # timed snooze now active for "o/r:tests" + + # A DIFFERENT workflow starts failing while still within the timed + # snooze window -- must alert immediately, not stay suppressed. + def different_failure(repo): + return [{"workflow_id": 2, "name": "lint", "status": "completed", + "conclusion": "failure", "created_at": "2026-08-03T13:30:00Z"}] + poller.fetch_runs.side_effect = different_failure + t3 = t2 + timedelta(minutes=5) + s = run_once(client, poller, CFG_SNOOZE, t3, state_cache, dry_run=False, snooze_state=snooze_state) + assert "FAIL" in s + assert "lint" in s + +def test_snooze_running_and_green_behavior_unaffected_while_suppressed(): + # While an alert is timed-snoozed, the overlay (running badge) + # rotation and quiet-green precedence must behave exactly as if + # nothing were failing at all. + client = Mock(); client.draw.return_value = DrawResult.DRAWN + poller = Mock() + poller.fetch_runs.return_value = [_run("failure")] + poller.fetch_running_runs.return_value = [_running_run()] + poller.fetch_median_eta.return_value = None + state_cache: dict = {} + snooze_state: dict = {} + running_cache: dict = {} + overlay_state: dict = {} + cfg = {"ci_status": {**CFG_RUNNING["ci_status"], "snooze_minutes": 30}} + + client.get_busy.return_value = _busy(False) + run_once(client, poller, cfg, NOW, state_cache, dry_run=False, + running_cache=running_cache, overlay_state=overlay_state, snooze_state=snooze_state) + client.get_busy.return_value = _busy(True) + t1 = NOW + timedelta(seconds=10) + run_once(client, poller, cfg, t1, state_cache, dry_run=False, + running_cache=running_cache, overlay_state=overlay_state, snooze_state=snooze_state) + client.get_busy.return_value = _busy(False) + t2 = t1 + timedelta(minutes=2) + run_once(client, poller, cfg, t2, state_cache, dry_run=False, + running_cache=running_cache, overlay_state=overlay_state, snooze_state=snooze_state) + + # Now timed-snoozed. Poll again after the overlay dwell gap: the + # running badge should draw normally (not suppressed by the snoozed + # alert) since a run is still active. + t3 = t2 + timedelta(seconds=2 * OVERLAY_DWELL_SECONDS + 1) + s = run_once(client, poller, cfg, t3, state_cache, dry_run=False, + running_cache=running_cache, overlay_state=overlay_state, snooze_state=snooze_state) + by_id = {e["id"]: e for e in client.draw.call_args.args[1]} + assert "eta" in by_id # the running badge shape, not the alert's {bg, ci} + assert "drawn" in s + +def test_snooze_state_omitted_is_fully_backward_compatible(): + # Omitting snooze_state entirely (the default) must behave exactly as + # before this feature existed -- no get_busy call, no suppression. + client = Mock(); client.draw.return_value = DrawResult.DRAWN + poller = Mock() + poller.fetch_runs.return_value = [_run("failure")] + run_once(client, poller, CFG_SNOOZE, NOW, {}, dry_run=False) + client.get_busy.assert_not_called() + assert client.draw.call_args.kwargs["led_notification_color"] == "#FF0000FF" + +def test_snooze_dry_run_never_calls_get_busy(): + client = Mock() + poller = Mock() + poller.fetch_runs.return_value = [_run("failure")] + snooze_state: dict = {} + run_once(client, poller, CFG_SNOOZE, NOW, {}, dry_run=True, snooze_state=snooze_state) + client.get_busy.assert_not_called() From 585e958f82cc0efae0d66308c3277a9ded4ea383 Mon Sep 17 00:00:00 2001 From: John Osumi <931193+sumitake@users.noreply.github.com> Date: Tue, 4 Aug 2026 00:07:16 -0700 Subject: [PATCH 3/4] docs: v1.5.2 spec section -- escalation ladder, stage 4, and snooze New "v1.5.2 urgent-ambient escalation, stage-4 chirp, and CI-alert snooze" section covering the final converged design across the round's several in-flight operator refinements (a two-stage proposal superseded by three-stage, then four-stage, then a stage-4 timing split, then the additive snooze feature) -- documents the final state, not the intermediate ones. Covers: the two new priority tiers and their contracts, the escalation ladder table, the eviction/409 interplay (verified against the real live agents, not just asserted), stage 4's LED/chirp timing split and the firmware stock-sound probe (with its exact timestamp), the once-per-event edge-detection design and its documented restart tradeoff, and the full snooze state machine including the conservative session_was_active default and its reasoning. Verification totals and on-device summary; full captures and transcripts live in the implementation report for this round. Co-Authored-By: Claude Fable 5 --- ...6-08-03-calendar-ci-integrations-design.md | 258 ++++++++++++++++++ 1 file changed, 258 insertions(+) diff --git a/docs/superpowers/specs/2026-08-03-calendar-ci-integrations-design.md b/docs/superpowers/specs/2026-08-03-calendar-ci-integrations-design.md index 3a8c3d5..9cea6c6 100644 --- a/docs/superpowers/specs/2026-08-03-calendar-ci-integrations-design.md +++ b/docs/superpowers/specs/2026-08-03-calendar-ci-integrations-design.md @@ -1121,3 +1121,261 @@ same-priority `preview` draw was observed being rejected outright (the same equal-priority-different-`application_name` firmware behavior the v1.5 probes found) until the priority was raised, matching the precedent already set by the original v1.5 badge-variant verification script. + +## 2026-08-04 — v1.5.2 urgent-ambient escalation, stage-4 chirp, and CI-alert snooze + +**Status:** Implemented, branch `dev/claude/urgent-ambient-v1.5.2` off `main` +(which by this point includes the full v1.5/v1.5.1 lines, merged via PR #9 +and PR #10). Not pushed. + +Operator-reported live UX gap: a persistent CI failure alert +(`PRIORITY_ALERT`, 60) permanently evicted the calendar, hiding an +imminent event with no way for the ambient tier to ever reclaim the +screen -- unlike the overlay tier, the ambient tier has no dwell/silence +contract of its own to fall back on. This round's fix arrived across three +operator messages that iterated on the same in-flight work (a two-stage +proposal superseded by a three-stage one, then a four-stage one, then a +timing split for stage 4, then an entirely additive snooze feature); this +section documents the final, converged design, not the intermediate ones. + +### Framework: two new priority tiers (`src/busybar/display.py`) + +``` +PRIORITY_AMBIENT = 20 +PRIORITY_OVERLAY = 21 +PRIORITY_AMBIENT_RAISED = 25 -- new +PRIORITY_ALERT = 60 +PRIORITY_AMBIENT_URGENT = 65 -- new +PRIORITY_SESSION = 90 +``` + +- **`PRIORITY_AMBIENT_RAISED` (25)**: an ambient app carrying near-term + (not yet imminent) user-critical information. Strictly above + `PRIORITY_OVERLAY`, strictly below `PRIORITY_ALERT` -- may outrank + overlays, never alerts. Overlay and alert tiers must never draw here; + it's an ambient-only elevation. +- **`PRIORITY_AMBIENT_URGENT` (65)**: an ambient app carrying imminent + user-critical information. Strictly above `PRIORITY_ALERT`, strictly + below `PRIORITY_SESSION` -- may outrank a genuine alert, never a real + BUSY/CUSTOM session. **The LED is the session-safe channel**: a session + at 90 still outranks this tier for the drawn *elements*, but + `led_notification_color` is a separate hardware channel entirely, not + subject to the same priority arbitration -- it is the one signal that + still gets through even when a session owns the whole panel. + +Both are documented in `busybar/display.py` with the full contract text +(when each may/must not be used) and covered by the ladder-ordering test +(`20 < 21 < 25 < 60 < 65 < 90`, plus dedicated +`PRIORITY_OVERLAY < PRIORITY_AMBIENT_RAISED < PRIORITY_ALERT` and +`PRIORITY_ALERT < PRIORITY_AMBIENT_URGENT < PRIORITY_SESSION` boundary +tests). + +### Escalation ladder (`calendar_countdown`) + +New pure functions in `calendar_countdown/logic.py`, deliberately +SEPARATE from `_state_for`'s existing 4-way visual-palette selection, not +a 1:1 mapping of it -- the "approach" window changes priority without +changing the palette at all, and NOTICE/WARNING share the same priority +despite being visually distinct: + +- `_minutes_left(event, now, in_progress)` -- extracted from + `build_elements`'s own inline calculation so `main.run_once` can compute + the identical value for the priority/LED/chirp decisions without + duplicating the branch (same `event`/`now`/`in_progress` in, same number + out, no drift risk). +- `select_priority(minutes_left, approach_minutes, notice_minutes, + in_progress) -> int`: `in_progress` -> `PRIORITY_AMBIENT`; `<= + notice_minutes` (covers NOTICE and WARNING) -> `PRIORITY_AMBIENT_URGENT`; + `<= approach_minutes` -> `PRIORITY_AMBIENT_RAISED`; else + `PRIORITY_AMBIENT`. +- `select_led(minutes_left, imminent_minutes, in_progress) -> str | None`: + fires on every draw from `imminent_minutes` before start until the event + starts, stops the instant `in_progress` is true. + +Final ladder: + +| Window | Priority | Palette | LED | +|---|---|---|---| +| normal (> `approach_minutes`) | `PRIORITY_AMBIENT` (20) | normal | off | +| approach (<= `approach_minutes`, > `notice_minutes`) | `PRIORITY_AMBIENT_RAISED` (25) | normal (unchanged) | off | +| notice (<= `notice_minutes`, > `warn_minutes`) | `PRIORITY_AMBIENT_URGENT` (65) | amber | off | +| warn (<= `warn_minutes`) | `PRIORITY_AMBIENT_URGENT` (65) | red | off | +| imminent (<= `imminent_minutes`, subset of warn) | `PRIORITY_AMBIENT_URGENT` (65) | red (same as warn) | **on** | +| in_progress | `PRIORITY_AMBIENT` (20) | teal | off | + +New config keys (`[calendar_countdown]`): `approach_minutes = 30`, +`imminent_minutes = 1` (governs the LED window only -- see the timing +split below), `chirp = true`. + +**Why in_progress stays at the baseline, not elevated.** Once a meeting +has started you already know about it -- you're either in it or +conspicuously not. The elevation exists to catch your attention *before* +an event starts, not to keep fighting for the screen once it has; an +alert regains the panel for an in-progress event exactly as it did before +this feature existed. + +**Eviction/409 interplay (verified, not just asserted).** A +`PRIORITY_AMBIENT_URGENT` draw succeeding while a `ci_status` alert is +showing evicts the alert's elements outright (fact 2: eviction, not +restoration). `ci_status` needs no new code for this: its existing +unified shape-tracking mechanism (added in the v1.5 revision round) +already only commits `overlay_state`/`last_shape` on a confirmed `DRAWN` +result, so a `409` (`DrawResult.REJECTED`) from trying to redraw the alert +while the calendar holds the higher tier is already handled exactly like +any other rejected draw -- no crash, no state committed, and the alert +resumes cleanly once `client.draw` finally lands again. Two new regression +tests (`test_alert_rejected_during_calendar_elevation_does_not_commit_ +then_recovers`, `test_overlay_dwell_rejected_during_calendar_elevation_ +resumes_after`) exercise this end-to-end through `ci_status.main.run_once` +and confirm the existing mechanism was already sufficient. + +Priority changes for the SAME app_name (the calendar's own escalation) +need no clear() of their own, unlike shape changes: it's the same +`application_name` upserting the same element ids at a new priority +number, and a strictly-higher-priority same-app_name draw always +succeeds regardless of the priority value. + +### Stage 4: LED + T-0 chirp + +**LED timing.** `imminent_minutes` (default 1) governs only the LED +window, independent of priority (which stays at `PRIORITY_AMBIENT_URGENT` +throughout notice/warn/imminent uniformly). The LED blinks on every draw +from `imminent_minutes` before start until the event starts, then stops. + +**Chirp timing (operator-amended from an initial "during the final +minute" proposal to a precise T-0 design).** The chirp fires exactly once +per event, at the moment it starts -- the transition edge from upcoming +to in_progress, not any point during the countdown. `next_sleep_seconds +(poll_seconds, seconds_until_start)` sleeps exactly until a sooner-than- +usual event start instead of the full `poll_seconds`, so the poll that +detects the transition (and fires the chirp) lands within about a second +of the real start time. + +**Audio API probe (done before any implementation, per the operator's +explicit instruction to check firmware research notes first).** The +firmware ships stock sounds at `assets/shared/sounds/`, including +`calendar_event_starts.wav` and `calendar_reminder_ends.wav` -- an exact +semantic match for this feature, found in the scratchpad's firmware +research notes (`busy-app-org-research.md` / `firmware_tree.txt`) before +generating anything. Confirmed live: `POST /api/audio/play` with +`{"application_name": "calendar_countdown", "stock_path": +"shared/calendar_event_starts.wav"}` returned `200` at `2026-08-04T06:23:32Z`. +No asset generation, upload, or repo-committed binary was needed -- +`BusyBarClient.play_audio` (added to `src/busybar/client.py`) supports +both `stock_path` (firmware-shipped) and `path` (an app's own uploaded +asset) per the device's `PlayAudio` schema, but this feature only ever +uses `stock_path`. `play_audio` has no volume parameter at all -- +deliberately, so playback always uses whatever volume is currently +configured on the device; `/api/audio/volume` is never touched. + +**Once-per-event semantics, edge-detected not level-detected** +(`should_chirp`/`commit_chirped`, `calendar_countdown/logic.py`). +`chirp_state` (caller-owned, mirroring every other cache in this +codebase) tracks two sets: `seen_upcoming` (event starts observed with +`in_progress=False` at some earlier poll -- recorded every call, +regardless of whether chirp is enabled, so toggling it on mid-run doesn't +lose the precondition) and `chirped` (already-fired starts). The fire +decision requires the CURRENT poll to see `in_progress=True` for a start +present in `seen_upcoming` and absent from `chirped` -- level-detection +(`in_progress=True` alone) would spuriously re-chirp on every poll for an +ongoing event, and would also spuriously chirp on a restart that happens +to come up mid-event; edge-detection avoids both. Commit is a separate +step (`commit_chirped`), called only after `client.play_audio` returns a +confirmed success, so a transient audio failure retries on the next poll +rather than silently skipping the chirp forever -- the same DRAWN-gated- +commit discipline used throughout this codebase for display state, +applied here to an audio action. `_prune_chirp_state` drops entries older +than 24h so a long-running process's bookkeeping doesn't grow unbounded. + +**Restart edge case (documented, not fixed -- an accepted tradeoff).** A +restart during an event's final minute, or any time after it started, +never observed that event as upcoming, so the edge is never detected and +the chirp does not fire for it. The alternative (level-detection) would +risk a spurious chirp on every restart during an active event, judged +worse. + +### CI-alert snooze via the device's native start button (additive, +ci_status) + +Raw physical button events are not API-observable (confirmed: the status +WebSocket only reports what's currently on screen, not button events), +but the BUSY/CUSTOM session the button starts *is*, via +`client.get_busy()`. The snooze rule rides on that: an alert showing at +the moment a session starts is treated as "the operator saw it and +pressed the button." + +**Fingerprint** (`compute_alert_fingerprint`, `ci_status/logic.py`): a +frozenset of `(repo, workflow, category)` triples, `category` being +`"failing"` or `"stuck"` so a pair moving between categories counts as a +change, not the same alert continuing. Empty when nothing is +failing/stuck. + +**State machine** (`update_snooze`, in-memory, restart clears -- see its +own extensive docstring for the full reasoning): + +1. **Pending** starts only on a genuine inactive -> active transition + (edge, not level) while an alert is showing: records the fingerprint. + Returns `(suppress_alert=False, suppress_led=True)` -- the alert draw + still proceeds as normal (naturally rejected by the session's own + higher priority, same as always), but its LED is silenced, since LED + bypasses that same priority arbitration and would otherwise keep + blinking through a session the operator just acknowledged. +2. The session **ends** while still pending, same fingerprint: begins a + timed snooze, `snooze_until = now + snooze_minutes`. Returns + `(True, False)` -- the alert is now suppressed entirely (falls through + to overlay/quiet-green/nothing in `build_ci_payload`, exactly as if + nothing were failing). +3. **Any fingerprint change** at any pending/timed point clears the + snooze immediately and re-alerts (or re-arms pending fresh, only if a + session is *already* active AND this is a genuine edge for the NEW + fingerprint -- not merely "a session happens to be running"). +4. **Expiry**: clears and resumes alerting normally if still failing. +5. `snooze_minutes <= 0` disables the feature outright. + +**Edge, not level, and the conservative default.** Requirement: pending +starts only on an *observed* inactive -> active transition, not merely +"an alert is showing and a session happens to be active" -- otherwise a +session that predates the alert (or predates a fingerprint change +mid-session) would be wrongly treated as "you just pressed the button for +this." Detecting the edge needs the previous poll's observation +(`session_was_active`), tracked in `snooze_state` -- but polling +`get_busy()` is deliberately gated (only while an alert is showing or a +snooze is pending/active, never on a fully idle poll, per the operator's +"keep idle cycles lean" requirement), which can create observation gaps. +On the first poll after such a gap (or the very first poll of a fresh +process), `session_was_active` defaults to `True`, not `False` -- biasing +against a false-positive auto-snooze (an unobserved pre-existing session +being misattributed as a fresh acknowledgement) at the cost of +occasionally requiring the operator to press the button again. This same +default also correctly handles a process restart that happens to land +mid-session. + +**`build_ci_payload` additions**: `suppress_alert` (skip the +failure/stuck branches entirely, falling through to overlay/quiet-green/ +nothing) and `suppress_led` (blank the failure branch's LED specifically, +without suppressing the draw -- used during pending; has no effect on +stuck, whose LED is already always `None`). `main.run_once` computes an +`effective_has_alert = has_alert and not suppress_alert` and uses it for +the overlay rotation's own "an alert takes precedence" reset check too -- +"running/quota rotation and green behavior unaffected" is an explicit +design requirement, not just about the alert badge's own rendering. + +### Verification + +`TZ=UTC uv run pytest -q`: 299 passed (274 at the start of this branch's +work, after the prior v1.5/v1.5.1 lines). New coverage: priority +selection (all boundary cases including exact-threshold), LED window +boundaries, the approach window's palette staying `STATE_NORMAL` (no new +visual state), chirp edge-detection (transition fires, restart-mid-event +doesn't, disabled never fires, retry-on-play-failure, pruning), +`next_sleep_seconds` boundaries, `play_audio` (stock_path/path variants, +failure handling, never touches the volume endpoint), the two REJECTED- +during-elevation regression tests, `compute_alert_fingerprint`, and the +full `update_snooze` state machine (pending -> timed -> suppression -> +expiry, fingerprint-change re-alert at every stage, `snooze_minutes=0` +disables, busy-poll gating). + +On-device verification, docs, and the report are covered in the +implementation report for this round; see there for verbatim frame +captures, the chirp's real playback timestamp, and the snooze end-to-end +sequence against the live device. From 492b4f2ea66cc56f2fc106a1c492a3222fe05758 Mon Sep 17 00:00:00 2001 From: John Osumi <931193+sumitake@users.noreply.github.com> Date: Tue, 4 Aug 2026 00:41:00 -0700 Subject: [PATCH 4/4] ci+calendar: fix snooze staleness bug, LED stuck-on path, review findings Final-gate review on the v1.5.2 round found one Critical (reproduced by the reviewer), two Important, and three Minor issues. All addressed here. Critical -- snooze auto-suppressed unacknowledged failures: update_snooze unconditionally wrote session_was_active every poll, including a dummy False for idle (unpolled) cycles. A run of idle polls would stamp session_was_active=False regardless of the device's real state; if a BUSY session then started unobserved and only afterward did an alert appear (triggering the first real get_busy() poll, correctly observing busy_active=True), the stale False read as a fresh transition and silently snoozed a failure the operator never acknowledged. Fixed: busy_active is now bool | None, with None meaning "not polled this cycle" -- session_was_active is committed only when busy_active is not None, so an unpolled period can no longer corrupt it. (Also fixed an ordering bug introduced while writing this fix: was_active must be read BEFORE the new value is committed, not after -- caught immediately by 8 failing pre-existing tests, corrected, re-verified.) New tests reproduce the reviewer's exact scenario and its mirror, at both the pure-function and full-loop (through the real run_once) level. Important -- LED stuck-on path: no code path guaranteed the LED turns off when an imminent event vanishes without passing through in_progress (all-day filtering, or an event shorter than one poll interval) -- the "no upcoming event" path never touched the LED field at all. Probed what's actually checkable (no status endpoint exposes LED state anywhere in the API; a 409 response carries no LED info) -- unresolvable from this codebase, so the fix is hypothesis-agnostic instead of depending on an answer: select_led now returns a bool ("should be on"), and a new resolve_led_value combines that with a newly-tracked state["led_on"] (DRAWN-gated commit, same discipline as every other piece of device state here) to send an EXPLICIT LED_OFF_COLOR ("#00000000") on every on->off transition rather than omitting the field and hoping. The "no upcoming event" path now checks led_on before its clear() and, if lit, sends a minimal 1x1 transparent placeholder (LED_OFF_ELEMENTS, self-expiring) carrying the explicit off value first -- clear() is a bare DELETE with no body and can't carry it, and the draw endpoint requires at least one element. Tests cover both repro shapes (normal in_progress transition, and the vanishing- event edge case) plus no-spurious-flush and retry-on-failure. On-device (preview app): confirmed the device accepts both new request shapes (200) and the placeholder renders as a true visual no-op. Missing on-device evidence: the initial round's completion message cited specific results (chirp timing, stage captures, the eviction/409 cycle) with no persistent report file recording them -- .superpowers/sdd/display-v1.5.2-report.md did not exist. Written now, reconstructed from saved scratchpad artifacts (frame captures, scripts) and an accurate transcription of terminal output actually observed during the initial round -- not regenerated or fabricated. One fresh on-device check included for the LED-off mechanism specifically, since it's genuinely new request shapes the initial round never exercised. Flagged the same gap for the still-missing v1.5.1 report as a separate follow-up task, out of scope for this branch. Minor -- config sanity: check_threshold_ordering warns once at startup if approach_minutes/notice_minutes/warn_minutes/imminent_minutes violate their assumed nesting; documented in config.example.toml and the README. Minor -- chirp keying: should_chirp/commit_chirped now key by (start, ascii-safe title) via _chirp_key, not start alone, so two events sharing an exact start timestamp (two all-day events, two calendars firing simultaneously) can't collide on one tracking entry. Minor -- unverified LED claim: busybar/display.py's PRIORITY_AMBIENT_URGENT docstring asserted the LED survives a session's panel eviction as settled fact. Confirmed unverifiable through this API (no LED-state-observable endpoint exists anywhere in the OpenAPI spec) -- softened to an explicit "assumed, unverified" framing everywhere it's referenced (display.py, calendar_countdown/logic.py, the README), and added a concrete operator-verification recipe to calendar_countdown/README.md ("Verifying the LED assumption") for a post-deploy live check. TZ=UTC uv run pytest -q: 319 passed (299 before this fix round). Co-Authored-By: Claude Fable 5 --- config.example.toml | 4 +- integrations/calendar_countdown/README.md | 19 +- integrations/calendar_countdown/logic.py | 203 ++++++++++++++++++---- integrations/calendar_countdown/main.py | 50 +++++- integrations/ci_status/logic.py | 41 ++++- integrations/ci_status/main.py | 8 +- src/busybar/display.py | 24 ++- tests/test_calendar_logic.py | 114 +++++++++--- tests/test_calendar_loop.py | 90 ++++++++++ tests/test_ci_logic.py | 63 +++++++ tests/test_ci_loop.py | 54 ++++++ 11 files changed, 587 insertions(+), 83 deletions(-) diff --git a/config.example.toml b/config.example.toml index 1a7711d..cdd69b2 100644 --- a/config.example.toml +++ b/config.example.toml @@ -19,7 +19,9 @@ auto_busy = false # climbs busybar/display.py's shared priority ladder so it can no longer be # silently buried, first by the overlay-tier CI badge/quota rotation, then # by a persistent CI failure/stuck alert. See calendar_countdown/README.md's -# "Escalation ladder" section before tuning. +# "Escalation ladder" section before tuning. ASSUMED ORDERING (logged as a +# one-time startup warning if violated -- see check_threshold_ordering): +# approach_minutes > notice_minutes > warn_minutes >= imminent_minutes. approach_minutes = 30 # inside this (outside notice_minutes): priority rises above the # overlay tier (PRIORITY_AMBIENT_RAISED) -- normal palette, unchanged imminent_minutes = 1 # inside this (and not yet started): LED blinks on every draw diff --git a/integrations/calendar_countdown/README.md b/integrations/calendar_countdown/README.md index 92d654d..2ad00db 100644 --- a/integrations/calendar_countdown/README.md +++ b/integrations/calendar_countdown/README.md @@ -146,6 +146,17 @@ Matching the poll to the dwell gap exactly (10s) did not eliminate the dark gaps An operator-reported UX gap: a persistent CI failure alert (`ci_status`, `PRIORITY_ALERT`) permanently evicted the calendar, hiding an imminent event with no way for the calendar to ever reclaim the screen -- the ambient tier has no dwell/silence contract of its own the way the overlay tier does. v1.5.2's fix is a state-dependent draw priority: as an upcoming event gets closer, the calendar climbs `busybar/display.py`'s shared priority ladder so it can no longer be silently buried, first by the overlay-tier CI badge/quota rotation and then by a genuine alert itself. +**Assumed ordering.** The four thresholds are assumed to nest: +`approach_minutes > notice_minutes > warn_minutes >= imminent_minutes`. +This isn't enforced (nothing crashes if violated -- each threshold is +checked independently), but a violated ordering makes a tier +unreachable in practice (e.g. `approach_minutes <= notice_minutes` means +the "approach" priority tier is dead: anything inside it is already +inside `notice_minutes` too, which is checked first). `main()` logs a +one-time startup warning (`check_threshold_ordering`) if the configured +values violate this ordering -- check `calendar.log` after changing any +of these four keys. + | Window | Priority | Palette | LED | Notes | |---|---|---|---|---| | `normal` (beyond `approach_minutes`) | `PRIORITY_AMBIENT` (20) | normal | off | Baseline, unchanged from before v1.5.2. | @@ -161,9 +172,13 @@ An operator-reported UX gap: a persistent CI failure alert (`ci_status`, `PRIORI Independent of the priority ladder above, two more signals fire during the final `imminent_minutes` before an event starts (default: the last 1 minute): -- **LED** (`led_notification_color`) blinks on *every* draw from `imminent_minutes` before start until the event actually starts, then stops (no LED once `in_progress`). The LED is a separate hardware channel from the drawn elements' priority arbitration entirely -- it is the one signal that still gets through even when a BUSY/CUSTOM session (`PRIORITY_SESSION`, 90) owns the whole panel, so it's the session-safe way to still notice an imminent event while in a session. +- **LED** (`led_notification_color`) blinks on *every* draw from `imminent_minutes` before start until the event actually starts, then stops (no LED once `in_progress`) -- turned off via an explicit off value on the exact transition poll, not by silently omitting the field (see "Guaranteed LED-off" below). The LED is a separate field from the drawn elements in the device's own API schema, and this integration is built on the ASSUMPTION -- **not independently verified** -- that it survives a BUSY/CUSTOM session's (`PRIORITY_SESSION`, 90) eviction of the panel the way the elements themselves don't. There is no API endpoint that exposes current LED state, so this can only be confirmed by a human watching the physical LED; see "Verifying the LED assumption" below. - **Chirp**: a short audio tone plays exactly once per event, at the moment it starts (T-0) -- not during the final-minute countdown itself. It uses a firmware-shipped **stock sound** (`shared/calendar_event_starts.wav`), confirmed via a live on-device probe (`POST /api/audio/play` with that `stock_path` returned `200`) before this design was chosen -- no asset generation, upload, or repo-committed audio file is needed or used. Playback always uses whatever volume is currently configured on the device; this integration never reads or sets `/api/audio/volume`. Set `chirp = false` to disable audio entirely. **Timing precision.** The main loop normally sleeps for a full `poll_seconds` between polls, but when an upcoming event's start is sooner than that, it sleeps exactly until that start instead -- so the poll that detects the transition (and fires the chirp) lands within about a second of the real start time, not up to a full `poll_seconds` late. -**Once-per-event semantics.** The chirp fires on the transition edge only -- the poll where this *process* observes an event go from upcoming to started -- tracked in memory, keyed by the event's own start timestamp. It will not repeat on subsequent polls while the same event stays in progress. **Restart edge case**: this tracking is in-memory only, so a process restart during an event's final minute (or any time after it has already started) does not re-fire the chirp for that event -- the new process never observed it as "upcoming," so the edge is never detected. This is a deliberate tradeoff (documented, not a bug): the alternative (chirping on level-detection alone) would risk a spurious chirp on every restart during an active event. +**Guaranteed LED-off.** Because there's no way to confirm whether omitting `led_notification_color` turns off a previously-lit LED or merely leaves it as-is (unverifiable through the API -- see above), this integration never relies on omission for an on->off transition. It tracks whether the LED is believed to be lit across polls and, on the poll where it should turn off, sends an explicit off value (`#00000000`) instead of dropping the field -- correct regardless of which behavior the firmware actually has. This applies even when the imminent event disappears entirely without ever starting (filtered out, or shorter than one poll interval): with nothing left to draw, a minimal 1x1 transparent placeholder element carries the explicit off value before the panel is cleared. + +**Verifying the LED assumption.** To confirm the LED-during-a-session assumption on real hardware: with `notice_minutes`/`warn_minutes`/`imminent_minutes` set low enough to reach the imminent window quickly (or just wait for a real event to approach), start a BUSY/CUSTOM session on the device (the physical start button) while an event is inside its `imminent_minutes` window, and watch the LED. If it keeps blinking through the session, the assumption holds and no further action is needed. If it goes dark once the session starts, the assumption in `busybar/display.py`'s `PRIORITY_AMBIENT_URGENT` docstring is wrong and should be corrected (and the LED can no longer be relied on as a session-safe signal for this or any future integration). + +**Once-per-event semantics.** The chirp fires on the transition edge only -- the poll where this *process* observes an event go from upcoming to started -- tracked in memory, keyed by `(start timestamp, title)`, not the start timestamp alone (two distinct events that happen to share the exact same start -- two all-day events both effectively at midnight, or two calendars firing something simultaneously -- are tracked independently, so chirping one never silently marks the other as already handled). It will not repeat on subsequent polls while the same event stays in progress. **Restart edge case**: this tracking is in-memory only, so a process restart during an event's final minute (or any time after it has already started) does not re-fire the chirp for that event -- the new process never observed it as "upcoming," so the edge is never detected. This is a deliberate tradeoff (documented, not a bug): the alternative (chirping on level-detection alone) would risk a spurious chirp on every restart during an active event. diff --git a/integrations/calendar_countdown/logic.py b/integrations/calendar_countdown/logic.py index 74a518a..2ffd450 100644 --- a/integrations/calendar_countdown/logic.py +++ b/integrations/calendar_countdown/logic.py @@ -258,7 +258,9 @@ def _minutes_left(event: CalEvent, now: datetime, in_progress: bool) -> float: # busybar.display's docstrings for the full priority-tier contracts and # the eviction/409 interplay this creates with ci_status's own alert # (worked through in the spec doc's v1.5.2 section), and IMMINENT_LED_COLOR -# below for the session-safe final-minute signal that rides alongside it. +# below for the final-minute LED signal that rides alongside it -- ASSUMED +# (not verified) to survive a session's panel-level eviction; see +# busybar.display.PRIORITY_AMBIENT_URGENT's docstring for the caveat. # # Deliberately NOT elevated while in_progress: once a meeting has started # you already know about it (you're either in it or conspicuously not) -- @@ -267,6 +269,66 @@ def _minutes_left(event: CalEvent, now: datetime, in_progress: bool) -> float: # for an in-progress (or normal, un-approaching) event exactly as it did # before this feature existed. IMMINENT_LED_COLOR = "#E24B4AFF" + +LED_OFF_COLOR = "#00000000" +# ^ Explicit LED-off value (zero alpha, matching this device's +# #RRGGBBAA convention elsewhere -- e.g. RectangleElement's own +# transparent-fill default). Whether OMITTING led_notification_color +# entirely turns a previously-set LED off, or whether the LED is +# "sticky" until explicitly changed, is NOT verifiable through this +# API (no endpoint exposes current LED state, and the device's own +# OpenAPI doc -- already shown wrong about priority arbitration +# elsewhere in this codebase -- claims omission means "will not +# blink," which isn't the same claim as "turns off a currently-lit +# LED"). Sending this explicit off value on every on->off transition is +# the hypothesis-agnostic safe choice: correct whether omission alone +# would have worked or not, at the cost of one redundant field on the +# rare poll where a transition actually happens. See resolve_led_value. +LED_OFF_ELEMENTS = [{ + "id": "led_off_flush", "type": "rectangle", "x": 0, "y": 0, + "width": 1, "height": 1, "fill": "solid", "fill_colors": ["#00000000"], + "border_width": 0, "timeout": 5, +}] +# ^ A minimal (1x1, fully transparent, 5s self-expiring) placeholder +# element -- the draw endpoint's `elements` field requires at least one +# entry (minItems: 1 in the device's own schema), so there is no way to +# send a bare led_notification_color with no visible element at all. +# Used only on the "no upcoming event" path (main.run_once), where +# there is otherwise nothing to draw but the LED may still need an +# explicit off transition -- invisible against any background, and +# self-expires quickly regardless. + + +def resolve_led_value(led_should_be_on: bool, led_was_on: bool) -> str | None: + """The actual `led_notification_color` to send THIS draw, combining + "should the LED be on right now" (select_led) with the caller's own + tracked previous-poll LED state. Returns `IMMINENT_LED_COLOR` while + the LED should be on; `LED_OFF_COLOR` (explicit, not just an omitted + field) on the exact poll where the LED transitions from on to off -- + see LED_OFF_COLOR's own docstring for why omission alone isn't + trusted; `None` (omit the field) once already off and staying off, + since there's nothing to turn off and omitting is the more compact + request. + + This is intentionally the ONLY place that decides between + IMMINENT_LED_COLOR / LED_OFF_COLOR / None -- main.run_once must call + this for every path that can draw or otherwise signal the device + (the normal event draw, AND the "no upcoming event" path via + LED_OFF_ELEMENTS), tracking `led_was_on` in its own caller-owned + state dict, committed only after a confirmed successful send (the + same DRAWN-gated-commit discipline used throughout this codebase) -- + a vanishing event (an all-day filter, an event shorter than one poll + interval) must still resolve to an explicit off, not silently skip + the transition just because there's no "normal" draw to piggyback it + on. + """ + if led_should_be_on: + return IMMINENT_LED_COLOR + if led_was_on: + return LED_OFF_COLOR + return None + + CHIRP_STOCK_PATH = "shared/calendar_event_starts.wav" # ^ A firmware-shipped stock sound (see BusyBarClient.play_audio), not a # generated/uploaded asset -- confirmed via a live on-device probe (POST @@ -277,6 +339,52 @@ def _minutes_left(event: CalEvent, now: datetime, in_progress: bool) -> float: # exact semantic match for the T-0 chirp this feature fires. +def check_threshold_ordering(cfg: dict) -> str | None: + """Sanity-checks the escalation ladder's ASSUMED ordering: + `approach_minutes > notice_minutes > warn_minutes >= imminent_minutes`. + Returns a warning message naming the first violated invariant (or + `None` if the config is sane) -- the caller (main()) should log this + ONCE at startup, not every poll. + + A violated ordering doesn't crash anything -- select_priority and + select_led each evaluate their own thresholds independently and will + still produce SOME answer -- but the ladder's intended meaning + ("closer to the event = more urgent") breaks down in ways that are + easy to misconfigure by accident. Concretely: `select_priority` checks + `<= notice_minutes` before `<= approach_minutes`, so if + `approach_minutes <= notice_minutes`, the "approach" tier + (PRIORITY_AMBIENT_RAISED) becomes a dead branch that's never actually + reached -- anything inside `approach_minutes` is already inside + `notice_minutes` too and matches that check first. Similarly + `notice_minutes <= warn_minutes` would make the NOTICE/amber visual + state (`_state_for`) unreachable, and `warn_minutes < imminent_minutes` + would mean the LED's imminent window extends beyond the red WARNING + state that's supposed to contain it. + """ + approach = cfg.get("approach_minutes") + notice = cfg.get("notice_minutes") + warn = cfg.get("warn_minutes") + imminent = cfg.get("imminent_minutes") + if None in (approach, notice, warn, imminent): + return None # an old-style cfg dict missing v1.5.2 keys -- nothing to check + if approach <= notice: + return (f"[calendar_countdown] approach_minutes ({approach}) should be greater than " + f"notice_minutes ({notice}) -- the escalation ladder assumes approach_minutes > " + f"notice_minutes > warn_minutes >= imminent_minutes; as configured, the 'approach' " + f"priority tier (PRIORITY_AMBIENT_RAISED) may never actually be reached.") + if notice <= warn: + return (f"[calendar_countdown] notice_minutes ({notice}) should be greater than " + f"warn_minutes ({warn}) -- the escalation ladder assumes approach_minutes > " + f"notice_minutes > warn_minutes >= imminent_minutes; as configured, the NOTICE " + f"(amber) visual state may never actually be reached.") + if warn < imminent: + return (f"[calendar_countdown] warn_minutes ({warn}) should be >= imminent_minutes " + f"({imminent}) -- the escalation ladder assumes approach_minutes > notice_minutes > " + f"warn_minutes >= imminent_minutes; as configured, the LED's imminent window " + f"extends beyond the red WARNING state meant to contain it.") + return None + + def select_priority(minutes_left: float, approach_minutes: int, notice_minutes: int, in_progress: bool) -> int: """The draw priority for this poll (v1.5.2 escalation ladder) -- @@ -310,36 +418,54 @@ def select_priority(minutes_left: float, approach_minutes: int, notice_minutes: return PRIORITY_AMBIENT -def select_led(minutes_left: float, imminent_minutes: int, in_progress: bool) -> str | None: - """`led_notification_color` for this poll's draw, or `None`. Fires on - every draw from `imminent_minutes` before start until the event - actually starts -- a continuous blink through the final window, not a - one-shot -- and stops the instant `in_progress` is true (no LED once - the event has started; the LED's job is to announce the imminent - start, not to keep announcing an event already underway). Independent - of `select_priority`: the LED is a separate hardware channel from the - drawn elements' priority arbitration entirely, and per - PRIORITY_AMBIENT_URGENT's docstring is the one signal that still gets - through even when a BUSY/CUSTOM session (PRIORITY_SESSION, 90) owns - the whole panel. +def select_led(minutes_left: float, imminent_minutes: int, in_progress: bool) -> bool: + """Whether the LED should be on RIGHT NOW, purely a function of the + current moment -- True on every poll from `imminent_minutes` before + start until the event actually starts (a continuous blink through the + final window, not a one-shot), False the instant `in_progress` is + true (no LED once the event has started; the LED's job is to announce + the imminent start, not to keep announcing an event already + underway). Independent of `select_priority`: the LED is a separate + hardware channel from the drawn elements' priority arbitration + entirely, and per PRIORITY_AMBIENT_URGENT's docstring is assumed to + still get through even when a BUSY/CUSTOM session (PRIORITY_SESSION, + 90) owns the whole panel -- unverified beyond the request payload + itself; see that docstring's caveat. + + Deliberately does NOT decide the actual `led_notification_color` + value to send -- that also depends on whether the LED was already on + last poll (see resolve_led_value), which this function has no + knowledge of and shouldn't need to: it answers "should it be on now," + the caller (resolve_led_value, called from main.run_once) answers + "what do I need to SEND to make that true, given what was sent + before." """ - if in_progress: - return None - if minutes_left <= imminent_minutes: - return IMMINENT_LED_COLOR - return None + return not in_progress and minutes_left <= imminent_minutes + + +def _chirp_key(event: CalEvent) -> tuple[datetime, str]: + """The identity should_chirp/commit_chirped track an event by: + `(start, ascii-safe title)`, not `start` alone. Two distinct events + that happen to share the exact same start timestamp (all-day events + sharing midnight, or two calendars both firing something at the same + moment) would otherwise collide on a single set entry -- one event's + "seen upcoming" or "chirped" marker would incorrectly apply to the + other. `ascii_safe` matches the same sanitization already applied to + titles elsewhere in this module, so the key is stable regardless of + non-ASCII characters in the raw title.""" + return (event.start, ascii_safe(event.title)) def _prune_chirp_state(chirp_state: dict, now: datetime, max_age_hours: int = 24) -> None: - """Drops event-start timestamps older than `max_age_hours` from both - tracked sets, so a long-running process's chirp bookkeeping doesn't - grow without bound over weeks/months of uptime. Called on every - should_chirp check; cheap (a couple of set comprehensions over what - is in practice a small number of distinct events).""" + """Drops entries whose start timestamp is older than `max_age_hours` + from both tracked sets, so a long-running process's chirp bookkeeping + doesn't grow without bound over weeks/months of uptime. Called on + every should_chirp check; cheap (a couple of set comprehensions over + what is in practice a small number of distinct events).""" cutoff = now - timedelta(hours=max_age_hours) for key in ("seen_upcoming", "chirped"): if key in chirp_state: - chirp_state[key] = {s for s in chirp_state[key] if s >= cutoff} + chirp_state[key] = {k for k in chirp_state[key] if k[0] >= cutoff} def should_chirp(event: CalEvent, in_progress: bool, now: datetime, @@ -347,19 +473,20 @@ def should_chirp(event: CalEvent, in_progress: bool, now: datetime, """True exactly on the poll where THIS PROCESS observes `event` transition from upcoming to started -- edge detection, not level detection. `chirp_state` is a caller-owned dict (same pattern as every - other cache in this codebase) tracking two sets: "seen_upcoming" - (event start timestamps this process has observed with - in_progress=False at some earlier poll) and "chirped" (event starts - already fired for). Every call records `event` into "seen_upcoming" - when it's not yet in progress, REGARDLESS of `chirp_enabled` -- so the - bookkeeping stays accurate even if chirp is toggled on mid-run. The - True/False decision itself only fires when: `chirp_enabled`, - `in_progress` is true THIS poll, `event.start` was previously seen - upcoming, and it hasn't already been chirped. + other cache in this codebase) tracking two sets keyed by + `_chirp_key(event)` (`(start, ascii-safe title)`, not `start` alone -- + see that function's docstring for why): "seen_upcoming" (events this + process has observed with in_progress=False at some earlier poll) and + "chirped" (events already fired for). Every call records `event` into + "seen_upcoming" when it's not yet in progress, REGARDLESS of + `chirp_enabled` -- so the bookkeeping stays accurate even if chirp is + toggled on mid-run. The True/False decision itself only fires when: + `chirp_enabled`, `in_progress` is true THIS poll, `event`'s key was + previously seen upcoming, and it hasn't already been chirped. This is the mechanism behind two required behaviors: (1) a process that starts up mid-event (in_progress=True on the very first poll it - ever sees for that event) never added that event's start to + ever sees for that event) never added that event's key to "seen_upcoming", so the transition is never detected and no chirp fires for it -- restarting during an event's final minute, or any time after it started, does not produce a spurious chirp. (2) a @@ -375,21 +502,21 @@ def should_chirp(event: CalEvent, in_progress: bool, now: datetime, display state). """ _prune_chirp_state(chirp_state, now) - start = event.start + key = _chirp_key(event) if not in_progress: - chirp_state.setdefault("seen_upcoming", set()).add(start) + chirp_state.setdefault("seen_upcoming", set()).add(key) return False if not chirp_enabled: return False seen_upcoming = chirp_state.get("seen_upcoming", set()) chirped = chirp_state.get("chirped", set()) - return start in seen_upcoming and start not in chirped + return key in seen_upcoming and key not in chirped def commit_chirped(event: CalEvent, chirp_state: dict) -> None: """Marks `event` as chirped -- call only after confirming the actual `client.play_audio` call succeeded (see should_chirp's docstring).""" - chirp_state.setdefault("chirped", set()).add(event.start) + chirp_state.setdefault("chirped", set()).add(_chirp_key(event)) def next_sleep_seconds(poll_seconds: float, seconds_until_start: float | None) -> float: diff --git a/integrations/calendar_countdown/main.py b/integrations/calendar_countdown/main.py index acc6ee5..29aa6db 100644 --- a/integrations/calendar_countdown/main.py +++ b/integrations/calendar_countdown/main.py @@ -13,12 +13,13 @@ from busybar.client import BusyBarClient, DrawResult from busybar.config import load_config -from busybar.display import ambient_timeout +from busybar.display import PRIORITY_AMBIENT, ambient_timeout from .logic import (ascii_safe, build_elements, select_active_event, select_next_event, _minutes_left, select_priority, - select_led, should_chirp, commit_chirped, - next_sleep_seconds, CHIRP_STOCK_PATH) + select_led, resolve_led_value, LED_OFF_ELEMENTS, LED_OFF_COLOR, + should_chirp, commit_chirped, + next_sleep_seconds, CHIRP_STOCK_PATH, check_threshold_ordering) APP = "calendar_countdown" HEARTBEAT_SECONDS = 600 @@ -32,10 +33,17 @@ def run_once(client, fetch, cfg: dict, now: datetime, dry_run: bool, across calls (main() passes one shared dict across loop iterations; tests calling run_once standalone can omit it), plus (v1.5.2) the next known event's start time (`next_start`, for the T-0 sleep- - shortening in main()'s loop) and the chirp edge-detection bookkeeping - (`seen_upcoming`/`chirped`, maintained by should_chirp/commit_chirped - -- see calendar_countdown.logic for the full escalation-ladder and - chirp design). + shortening in main()'s loop), the chirp edge-detection bookkeeping + (`seen_upcoming`/`chirped`, maintained by should_chirp/commit_chirped), + and `led_on` -- whether the LED is believed to currently be lit, + committed only after a confirmed successful send (see + resolve_led_value's docstring). This last one matters on EVERY path + that can draw or otherwise signal the device, including the "no + upcoming event" path below: an event that vanishes without ever + passing through `in_progress=True` (filtered out, or shorter than one + poll interval) must still resolve its LED to an explicit off, not + silently strand it lit. See calendar_countdown.logic for the full + escalation-ladder, LED, and chirp design. The upcoming and in-progress layouts use different element id sets (`time` vs `ends`) and the device's draw endpoint upserts by id rather @@ -72,6 +80,23 @@ def run_once(client, fetch, cfg: dict, now: datetime, dry_run: bool, if event is None: if not dry_run: + # LED-off flush (v1.5.2): the event vanished (filtered out, or + # was shorter than one poll interval) without ever passing + # through the normal draw path below, which is the only other + # place that would otherwise send an explicit LED-off. There's + # nothing to draw, but if the LED is believed to still be lit + # from an earlier poll, it must still be explicitly turned off + # -- clear() alone can't carry the LED field (it's a bare + # DELETE, no body), so a minimal placeholder draw carries it + # instead. See LED_OFF_ELEMENTS/resolve_led_value's docstrings. + if state is not None and state.get("led_on"): + led_off_result = client.draw(APP, elements=LED_OFF_ELEMENTS, + priority=PRIORITY_AMBIENT, + led_notification_color=LED_OFF_COLOR) + if led_off_result == DrawResult.DRAWN: + state["led_on"] = False + # else: leave led_on=True so the next poll retries the + # off-transition rather than assuming it landed. client.clear(APP) if state is not None: state["in_progress"] = None @@ -117,7 +142,9 @@ def run_once(client, fetch, cfg: dict, now: datetime, dry_run: bool, elements = build_elements(event, now, c, timeout_s, in_progress) minutes_left = _minutes_left(event, now, in_progress) priority = select_priority(minutes_left, c["approach_minutes"], c["notice_minutes"], in_progress) - led = select_led(minutes_left, c["imminent_minutes"], in_progress) + led_should_be_on = select_led(minutes_left, c["imminent_minutes"], in_progress) + led_was_on = state.get("led_on", False) if state is not None else False + led = resolve_led_value(led_should_be_on, led_was_on) result = client.draw(APP, elements=elements, priority=priority, led_notification_color=led) if state is not None and result == DrawResult.DRAWN: # Only commit the transition once it actually lands on the device. @@ -128,6 +155,10 @@ def run_once(client, fetch, cfg: dict, now: datetime, dry_run: bool, # otherwise let stale elements from the old layout persist # unbounded (no further poll would ever re-attempt the clear). state["in_progress"] = in_progress + # Same discipline for the LED: only believe it's in the intended + # state once this exact draw (carrying that exact led value) is + # confirmed to have landed. + state["led_on"] = led_should_be_on return f"drew {label} -> {result.value}" @@ -168,6 +199,9 @@ def main() -> int: return 0 cfg = load_config() + ordering_warning = check_threshold_ordering(cfg["calendar_countdown"]) + if ordering_warning is not None: + log.warning(ordering_warning) client = BusyBarClient(host=cfg["device"]["host"]) # Drop any stale elements from a previous process. This also protects a # restart onto this version against every id change made across the diff --git a/integrations/ci_status/logic.py b/integrations/ci_status/logic.py index bfc6fa0..c2fbd95 100644 --- a/integrations/ci_status/logic.py +++ b/integrations/ci_status/logic.py @@ -734,7 +734,7 @@ def compute_alert_fingerprint(states: list[RepoState]) -> frozenset: | frozenset((s.repo, name, "stuck") for s in states for name in s.stuck)) -def update_snooze(alert_fingerprint: frozenset, busy_active: bool, now: datetime, +def update_snooze(alert_fingerprint: frozenset, busy_active: bool | None, now: datetime, snooze_minutes: int, snooze_state: dict) -> tuple[bool, bool]: """Advances the snooze state machine by one poll and returns `(suppress_alert, suppress_led)` for THIS poll. `snooze_state` is a @@ -787,8 +787,30 @@ def update_snooze(alert_fingerprint: frozenset, busy_active: bool, now: datetime `session_was_active` -- but polling is deliberately gated (see main.run_once) to skip `get_busy()` entirely when idle (no alert, no snooze state), which means there can be gaps where `session_was_active` - wasn't being updated. On the first poll after such a gap (or the very - first poll of a fresh process), `session_was_active` defaults to + wasn't being updated. + + **Critical correctness point (fixed after an initial version got this + wrong -- see the regression tests): `session_was_active` is committed + ONLY on a poll where `get_busy()` was ACTUALLY called this cycle**, + signaled by `busy_active` being a real `bool` rather than `None`. + `main.run_once` passes `None` for `busy_active` whenever polling was + gated off (idle: no alert, no existing snooze state). An earlier + version unconditionally wrote `busy_active` every call, including a + dummy `False` for gated-off polls -- which meant a sequence of idle + polls (no alert yet) would stamp `session_was_active = False` + regardless of the device's ACTUAL state; if a session then started + while STILL idle (unobserved, since nothing was polling), and only + THEN did an alert appear (triggering the first real `get_busy()` call, + correctly observing `busy_active=True`), the stale `False` from the + dummy writes would read as "was NOT active a moment ago, now IS + active" -- a spurious transition -- and silently start a pending + snooze for a failure the operator never acknowledged. Committing only + on an actual poll (leaving `session_was_active` untouched otherwise) + closes this: an unobserved period leaves the value at whatever it was + (or its default) rather than being corrupted by an unpolled guess. + + On the first EVER poll of a fresh process, or the first poll after any + gap where `session_was_active` was never committed, it defaults to `True` -- not `False` -- so an as-yet-unobserved busy session is assumed to possibly PRE-DATE the alert rather than assumed absent: the conservative direction is to require an actually-OBSERVED @@ -808,7 +830,8 @@ def update_snooze(alert_fingerprint: frozenset, busy_active: bool, now: datetime this codebase). """ was_active = snooze_state.get("session_was_active", True) - snooze_state["session_was_active"] = busy_active + if busy_active is not None: + snooze_state["session_was_active"] = busy_active if snooze_minutes <= 0: snooze_state.pop("fingerprint", None) @@ -832,7 +855,15 @@ def update_snooze(alert_fingerprint: frozenset, busy_active: bool, now: datetime return False, False if snooze_until is None: - if busy_active: + # Defensive: main.run_once's polling gate guarantees busy_active + # is a real bool (not None) whenever pending_fp is set (a pending + # snooze always keeps polling -- see should_poll_busy), so this + # should never actually see None here. If it somehow did anyway, + # treat "unknown" the same as "still active" (stay pending rather + # than prematurely starting the timed snooze on an unpolled + # guess) -- the same conservative direction as everywhere else in + # this function. + if busy_active is None or busy_active: return False, True snooze_state["snooze_until"] = now + timedelta(minutes=snooze_minutes) return True, False diff --git a/integrations/ci_status/main.py b/integrations/ci_status/main.py index b5ecb43..1206656 100644 --- a/integrations/ci_status/main.py +++ b/integrations/ci_status/main.py @@ -207,7 +207,13 @@ class the v1.3.1 calendar transition-clear fix addressed, recurring at busy = client.get_busy() or {} busy_active = busy.get("type") not in (None, "NOT_STARTED") else: - busy_active = False + # None, not False -- "not polled this cycle," distinct from a + # confirmed-inactive observation. update_snooze only commits + # its session_was_active tracking when given a real bool; see + # its docstring for the exact bug a dummy False here caused + # (a false-positive auto-snooze for a session that predated + # the alert). + busy_active = None suppress_alert, suppress_led = update_snooze( alert_fingerprint, busy_active, now, snooze_minutes, snooze_state) diff --git a/src/busybar/display.py b/src/busybar/display.py index 5dfffa8..a061a38 100644 --- a/src/busybar/display.py +++ b/src/busybar/display.py @@ -135,16 +135,24 @@ def overlay_gap_elapsed(last_dwell_end, now) -> float: merely in_progress, since once a meeting has started you already know about it; the elevation exists to catch your attention BEFORE it starts. -**The LED is the session-safe channel.** A BUSY/CUSTOM session at +**The LED is ASSUMED to be the session-safe channel -- unverified, +requires operator observation.** A BUSY/CUSTOM session at PRIORITY_SESSION (90) still outranks this tier for the *panel*, so an urgent-ambient draw's `elements` can be evicted the same way an alert's -can. `led_notification_color`, however, is a separate hardware channel -from the drawn elements/z-order arbitration entirely -- it is not -subject to the same priority eviction, so it is the one signal that gets -through even when a session owns the whole screen. Use it for anything -that must be noticeable regardless of what else currently has the panel -(calendar_countdown sets it during its final-minute LED window -- -`imminent_minutes` -- for exactly this reason). +can. The device's own API schema describes `led_notification_color` as a +separate field from the drawn elements, and probing what's actually +checkable from this codebase (the 409 rejection response body, whether +any status endpoint exposes current LED state) turned up nothing that +either confirms or refutes whether it survives the same priority +eviction that evicts the elements -- there is no LED-state-observable +endpoint anywhere in the device's API, and this claim can only be +settled by a human actually watching the physical LED during a live +session test. Until that observation happens, treat "the LED gets +through a session" as a design assumption this codebase acts on (see +calendar_countdown, which sets the LED during its final-minute window -- +`imminent_minutes` -- specifically because a session might otherwise +hide it), not a verified fact. See calendar_countdown's README for a +short recipe to verify this on the actual hardware. """ PRIORITY_SESSION = 90 diff --git a/tests/test_calendar_logic.py b/tests/test_calendar_logic.py index b9c5dd0..7bd8756 100644 --- a/tests/test_calendar_logic.py +++ b/tests/test_calendar_logic.py @@ -12,8 +12,9 @@ TRACK_FILL_IN_PROGRESS, TIME_TEXT_COLOR, ENDS_TEXT_COLOR, ENDS_TEXT, DIVIDER_COLOR, DIGIT_COLOR, PANEL_WIDTH, PANEL_HEIGHT, CD_TEXT_X, CD_TEXT_MAX_WIDTH, GLYPH_ADVANCE_PX, - _minutes_left, select_priority, select_led, should_chirp, commit_chirped, - next_sleep_seconds, IMMINENT_LED_COLOR, CHIRP_STOCK_PATH, + _minutes_left, select_priority, select_led, resolve_led_value, should_chirp, commit_chirped, + next_sleep_seconds, IMMINENT_LED_COLOR, CHIRP_STOCK_PATH, _chirp_key, + check_threshold_ordering, ) from busybar.display import ( PRIORITY_AMBIENT, PRIORITY_AMBIENT_RAISED, PRIORITY_AMBIENT_URGENT, @@ -440,31 +441,46 @@ def test_select_priority_boundary_exact_notice_minutes(): assert select_priority(NOTICE + 0.01, APPROACH, NOTICE, in_progress=False) == PRIORITY_AMBIENT_RAISED -# --- select_led -------------------------------------------------------------------- +# --- select_led (v1.5.2 revision: returns a bool "should be on", not a +# color -- resolve_led_value below decides the actual value to send) -------------- -def test_select_led_none_outside_imminent_window(): - assert select_led(2, IMMINENT, in_progress=False) is None - assert select_led(15, IMMINENT, in_progress=False) is None +def test_select_led_false_outside_imminent_window(): + assert select_led(2, IMMINENT, in_progress=False) is False + assert select_led(15, IMMINENT, in_progress=False) is False -def test_select_led_fires_inside_imminent_window(): - assert select_led(1, IMMINENT, in_progress=False) == IMMINENT_LED_COLOR - assert select_led(0.1, IMMINENT, in_progress=False) == IMMINENT_LED_COLOR +def test_select_led_true_inside_imminent_window(): + assert select_led(1, IMMINENT, in_progress=False) is True + assert select_led(0.1, IMMINENT, in_progress=False) is True -def test_select_led_never_fires_in_progress(): +def test_select_led_never_true_in_progress(): # Even with minutes_left well inside the imminent window (e.g. a # negative value from an event that's technically started), in_progress - # forces no LED. - assert select_led(0.5, IMMINENT, in_progress=True) is None - assert select_led(-1, IMMINENT, in_progress=True) is None + # forces False. + assert select_led(0.5, IMMINENT, in_progress=True) is False + assert select_led(-1, IMMINENT, in_progress=True) is False def test_select_led_boundary_exact_imminent_minutes(): - assert select_led(IMMINENT, IMMINENT, in_progress=False) == IMMINENT_LED_COLOR - assert select_led(IMMINENT + 0.01, IMMINENT, in_progress=False) is None + assert select_led(IMMINENT, IMMINENT, in_progress=False) is True + assert select_led(IMMINENT + 0.01, IMMINENT, in_progress=False) is False def test_select_led_only_notice_and_warn_have_no_led_outside_imminent(): # warn_minutes=5 is well outside imminent_minutes=1 by default -- no LED # in the warn tier itself, only inside the (much narrower) imminent one. - assert select_led(WARN, IMMINENT, in_progress=False) is None + assert select_led(WARN, IMMINENT, in_progress=False) is False + + +# --- resolve_led_value: explicit off-transition, not a bare omission ------------- + +def test_resolve_led_value_should_be_on_returns_imminent_color(): + assert resolve_led_value(led_should_be_on=True, led_was_on=False) == IMMINENT_LED_COLOR + assert resolve_led_value(led_should_be_on=True, led_was_on=True) == IMMINENT_LED_COLOR + +def test_resolve_led_value_off_transition_is_explicit_not_omitted(): + from calendar_countdown.logic import LED_OFF_COLOR + assert resolve_led_value(led_should_be_on=False, led_was_on=True) == LED_OFF_COLOR + +def test_resolve_led_value_already_off_omits_field(): + assert resolve_led_value(led_should_be_on=False, led_was_on=False) is None # --- palette stays 4-way: approach adds NO new visual state ----------------------- @@ -487,7 +503,7 @@ def test_should_chirp_false_while_upcoming(): event = ev(2) state = {} assert should_chirp(event, in_progress=False, now=NOW, chirp_state=state, chirp_enabled=True) is False - assert event.start in state["seen_upcoming"] + assert _chirp_key(event) in state["seen_upcoming"] def test_should_chirp_true_on_transition_edge(): event = ev(2) @@ -526,7 +542,7 @@ def test_should_chirp_disabled_still_tracks_seen_upcoming(): event = ev(2) state = {} should_chirp(event, in_progress=False, now=NOW, chirp_state=state, chirp_enabled=False) - assert event.start in state["seen_upcoming"] + assert _chirp_key(event) in state["seen_upcoming"] def test_should_chirp_new_event_fires_independently(): event_a = ev(2) @@ -539,6 +555,28 @@ def test_should_chirp_new_event_fires_independently(): should_chirp(event_b, in_progress=False, now=NOW, chirp_state=state, chirp_enabled=True) assert should_chirp(event_b, in_progress=True, now=NOW, chirp_state=state, chirp_enabled=True) is True +def test_should_chirp_same_start_different_title_do_not_collide(): + # Two distinct events sharing the exact same start timestamp (e.g. two + # all-day events both effectively at midnight, or two calendars firing + # something simultaneously) must be tracked independently -- keyed by + # (start, title), not start alone -- so chirping/committing one never + # silently marks the other as already handled. + start = NOW + timedelta(minutes=2) + event_a = CalEvent("Standup", start, start + timedelta(minutes=30), False) + event_b = CalEvent("Retro", start, start + timedelta(minutes=15), False) + state = {} + should_chirp(event_a, in_progress=False, now=NOW, chirp_state=state, chirp_enabled=True) + assert should_chirp(event_a, in_progress=True, now=NOW, chirp_state=state, chirp_enabled=True) is True + commit_chirped(event_a, state) + # event_b shares event_a's start but was never itself observed + # upcoming -- must NOT be considered already-chirped just because + # event_a (same start, different title) was. + assert should_chirp(event_b, in_progress=True, now=NOW, chirp_state=state, chirp_enabled=True) is False + # Once event_b is properly observed upcoming first, it chirps on its + # own, independently of event_a's already-committed chirp. + should_chirp(event_b, in_progress=False, now=NOW, chirp_state=state, chirp_enabled=True) + assert should_chirp(event_b, in_progress=True, now=NOW, chirp_state=state, chirp_enabled=True) is True + def test_commit_chirped_not_called_means_retry_next_poll(): # Mirrors the DRAWN-gated commit discipline: if the caller doesn't # call commit_chirped (e.g. because play_audio failed), the very next @@ -554,13 +592,13 @@ def test_chirp_state_prunes_old_entries(): event = ev(2) state = {} should_chirp(event, in_progress=False, now=NOW, chirp_state=state, chirp_enabled=True) - assert event.start in state["seen_upcoming"] + assert _chirp_key(event) in state["seen_upcoming"] much_later = NOW + timedelta(hours=48) # A call for an unrelated, far-future event 48h later should prune the # old entry out of seen_upcoming. other = ev(48 * 60 + 2) should_chirp(other, in_progress=False, now=much_later, chirp_state=state, chirp_enabled=True) - assert event.start not in state["seen_upcoming"] + assert _chirp_key(event) not in state["seen_upcoming"] def test_chirp_stock_path_is_a_firmware_stock_sound_not_an_uploaded_asset(): # Documents the design choice (v1.5.2): no asset generation/upload @@ -599,3 +637,39 @@ def test_minutes_left_upcoming_uses_start(): def test_minutes_left_in_progress_uses_end(): e = ev(-5, dur_min=30) # started 5 min ago, 30 min long -> ends in 25 min assert _minutes_left(e, NOW, in_progress=True) == 25.0 + + +# --- check_threshold_ordering: v1.5.2 config sanity warning ---------------------- + +SANE_CFG = {"approach_minutes": 30, "notice_minutes": 15, "warn_minutes": 5, "imminent_minutes": 1} + +def test_check_threshold_ordering_sane_config_no_warning(): + assert check_threshold_ordering(SANE_CFG) is None + +def test_check_threshold_ordering_approach_not_greater_than_notice(): + cfg = {**SANE_CFG, "approach_minutes": 15} # == notice_minutes + msg = check_threshold_ordering(cfg) + assert msg is not None + assert "approach_minutes" in msg and "notice_minutes" in msg + +def test_check_threshold_ordering_notice_not_greater_than_warn(): + cfg = {**SANE_CFG, "notice_minutes": 5} # == warn_minutes + msg = check_threshold_ordering(cfg) + assert msg is not None + assert "notice_minutes" in msg and "warn_minutes" in msg + +def test_check_threshold_ordering_warn_less_than_imminent(): + cfg = {**SANE_CFG, "warn_minutes": 0, "imminent_minutes": 1} + msg = check_threshold_ordering(cfg) + assert msg is not None + assert "warn_minutes" in msg and "imminent_minutes" in msg + +def test_check_threshold_ordering_warn_equal_imminent_is_fine(): + # warn_minutes >= imminent_minutes is the assumed (non-strict) bound. + cfg = {**SANE_CFG, "warn_minutes": 1, "imminent_minutes": 1} + assert check_threshold_ordering(cfg) is None + +def test_check_threshold_ordering_missing_keys_returns_none(): + # An old-style cfg dict predating the v1.5.2 keys -- nothing to check, + # must not KeyError. + assert check_threshold_ordering({"notice_minutes": 15, "warn_minutes": 5}) is None diff --git a/tests/test_calendar_loop.py b/tests/test_calendar_loop.py index db8c96c..7866c92 100644 --- a/tests/test_calendar_loop.py +++ b/tests/test_calendar_loop.py @@ -243,6 +243,96 @@ def test_run_once_in_progress_stays_baseline_no_led(): assert client.draw.call_args.kwargs["led_notification_color"] is None +# --- v1.5.2 LED stuck-on fix: guaranteed off-transition, both repro shapes ------- +# +# Critical review finding: omitting led_notification_color is unverified as +# a way to turn off a previously-lit LED (no status endpoint exposes LED +# state, and the device's own OpenAPI doc -- already wrong once about +# priority arbitration -- only claims omission means "won't blink", not +# "turns off a lit one"). resolve_led_value sends an EXPLICIT off value on +# every on->off transition instead, tracked via state["led_on"], covering +# both the normal in_progress transition and the "event vanishes without +# ever reaching in_progress" edge case the review specifically named +# (all-day filtering, or an event shorter than one poll interval). + +def test_run_once_led_off_explicit_on_normal_in_progress_transition(): + client = Mock(); client.draw.return_value = DrawResult.DRAWN + state: dict = {} + imminent = make_event(0.5) # inside imminent_minutes(1) -- LED on + run_once(client, lambda hours: [imminent], CFG, NOW, dry_run=False, state=state) + assert client.draw.call_args.kwargs["led_notification_color"] == IMMINENT_LED_COLOR + assert state["led_on"] is True + + # Next poll: the same event has started (in_progress=True) -- LED must + # turn off via an EXPLICIT value, not just an omitted field. + started = CalEvent(imminent.title, imminent.start, imminent.start + timedelta(minutes=30), False) + later = imminent.start + timedelta(seconds=1) + run_once(client, lambda hours: [started], CFG, later, dry_run=False, state=state) + led_sent = client.draw.call_args.kwargs["led_notification_color"] + assert led_sent is not None and led_sent != IMMINENT_LED_COLOR # an explicit off value + assert state["led_on"] is False + + # A further poll, still in_progress: LED already off and staying off + # -- the field can be safely omitted now (nothing to turn off). + run_once(client, lambda hours: [started], CFG, later + timedelta(seconds=10), dry_run=False, state=state) + assert client.draw.call_args.kwargs["led_notification_color"] is None + +def test_run_once_led_off_explicit_when_event_vanishes_without_in_progress(): + # Repro shape 1 (the review's specific concern): the event disappears + # entirely on the next poll -- filtered out (e.g. all-day toggling) or + # simply shorter than one poll interval -- WITHOUT ever passing + # through in_progress=True. The "no upcoming event" path is the only + # other place besides the normal draw that can carry the LED field, + # via an explicit flush draw before clear(). + client = Mock(); client.draw.return_value = DrawResult.DRAWN + state: dict = {} + imminent = make_event(0.5) + run_once(client, lambda hours: [imminent], CFG, NOW, dry_run=False, state=state) + assert state["led_on"] is True + client.draw.reset_mock() + + later = NOW + timedelta(seconds=5) + summary = run_once(client, lambda hours: [], CFG, later, dry_run=False, state=state) + assert "cleared" in summary + # An explicit LED-off flush draw must have happened (a real draw() + # call carrying the off value), separate from -- and before -- clear(). + client.draw.assert_called_once() + flush_kwargs = client.draw.call_args.kwargs + assert flush_kwargs["led_notification_color"] is not None + assert flush_kwargs["led_notification_color"] != IMMINENT_LED_COLOR + client.clear.assert_called_once_with("calendar_countdown") + assert state["led_on"] is False + +def test_run_once_no_led_flush_when_led_was_already_off_and_event_vanishes(): + # No spurious flush draw when the LED wasn't on to begin with. + client = Mock() + state: dict = {} + run_once(client, lambda hours: [make_event(40)], CFG, NOW, dry_run=False, state=state) # normal, no LED + assert state.get("led_on", False) is False + client.draw.reset_mock(); client.clear.reset_mock() + + run_once(client, lambda hours: [], CFG, NOW, dry_run=False, state=state) + client.draw.assert_not_called() # no flush needed + client.clear.assert_called_once_with("calendar_countdown") + +def test_run_once_led_flush_retries_next_poll_if_it_fails(): + client = Mock() + state: dict = {} + client.draw.return_value = DrawResult.DRAWN + imminent = make_event(0.5) + run_once(client, lambda hours: [imminent], CFG, NOW, dry_run=False, state=state) + assert state["led_on"] is True + + client.draw.return_value = DrawResult.REJECTED # the flush attempt fails + later = NOW + timedelta(seconds=5) + run_once(client, lambda hours: [], CFG, later, dry_run=False, state=state) + assert state["led_on"] is True # not committed -- must retry + + client.draw.return_value = DrawResult.DRAWN + run_once(client, lambda hours: [], CFG, later + timedelta(seconds=5), dry_run=False, state=state) + assert state["led_on"] is False # retried and landed + + # --- v1.5.2 chirp, end to end through run_once ------------------------------------ def test_run_once_chirps_exactly_once_on_start_transition(): diff --git a/tests/test_ci_logic.py b/tests/test_ci_logic.py index e9744e9..4384c81 100644 --- a/tests/test_ci_logic.py +++ b/tests/test_ci_logic.py @@ -672,6 +672,69 @@ def test_update_snooze_genuine_transition_establishes_pending(): assert state["fingerprint"] == FP_A assert "snooze_until" not in state +# --- Critical regression: unpolled (None) cycles must never corrupt +# session_was_active -- reviewer-reproduced bug. An earlier version wrote +# busy_active unconditionally every call, including a dummy False for +# gated-off (idle) polls; a run of idle polls would stamp +# session_was_active=False regardless of the device's real state, so a +# session that started (unobserved) during that idle stretch and was +# already running by the time an alert first appeared would be +# misread as a fresh transition and silently snoozed -- an +# unacknowledged failure. Fixed: busy_active=None means "not polled this +# cycle" and must NOT be committed. + +def test_update_snooze_unpolled_cycles_do_not_corrupt_session_was_active(): + state = {} + # Simulates several idle polls where get_busy() was never called + # (main.run_once passes None in this case). + update_snooze(EMPTY_FP, None, NOW, 30, state) + update_snooze(EMPTY_FP, None, NOW, 30, state) + update_snooze(EMPTY_FP, None, NOW, 30, state) + assert state.get("session_was_active", True) is True # untouched, still the conservative default + +def test_update_snooze_session_predating_alert_does_not_snooze_reviewer_scenario(): + # The exact reviewer-reported scenario: idle polls (unpolled, None) -> + # a session starts DURING that unpolled stretch (never observed) -> + # an alert appears, triggering the first real poll, which correctly + # observes busy_active=True -- but this must NOT be read as a fresh + # transition, since the session predates the alert and the operator + # never acknowledged it. + state = {} + update_snooze(EMPTY_FP, None, NOW, 30, state) # idle poll 1, unpolled + update_snooze(EMPTY_FP, None, NOW, 30, state) # idle poll 2, unpolled + # Session starts here, still unobserved (no alert yet, still not polling). + t1 = NOW + timedelta(seconds=30) + result = update_snooze(FP_A, True, t1, 30, state) # alert appears -- first real poll + assert result == (False, False) # must NOT pend -- no acknowledged transition observed + assert "fingerprint" not in state + +def test_update_snooze_mirror_alert_first_then_session_starts_while_polled(): + # The mirror case (still correct, unaffected by the fix): the alert + # appears FIRST (triggering real polling immediately), observes + # inactive, and only THEN does the session start while polling + # continues -- a genuine, fully-observed transition, so pending + # DOES start, exactly as designed. + state = {} + update_snooze(FP_A, False, NOW, 30, state) # alert showing, polled, session inactive + t1 = NOW + timedelta(seconds=10) + result = update_snooze(FP_A, True, t1, 30, state) # still polled -- session starts + assert result == (False, True) + assert state["fingerprint"] == FP_A + +def test_update_snooze_none_after_established_pending_defensive_stays_pending(): + # Defensive case documented in update_snooze: main.run_once's own + # gating guarantees busy_active is never None while a fingerprint is + # pending (a pending snooze always keeps polling), but the function + # itself treats an (unexpected) None here as "stay pending" rather + # than risk prematurely starting the timed snooze on an unpolled guess. + state = {} + update_snooze(FP_A, False, NOW, 30, state) + update_snooze(FP_A, True, NOW, 30, state) # now pending on FP_A + assert state.get("snooze_until") is None + result = update_snooze(FP_A, None, NOW, 30, state) + assert result == (False, True) # still pending, not prematurely timed + assert "snooze_until" not in state + def test_update_snooze_stays_pending_while_session_continues(): state = {} update_snooze(FP_A, False, NOW, 30, state) diff --git a/tests/test_ci_loop.py b/tests/test_ci_loop.py index 148814a..d350a3b 100644 --- a/tests/test_ci_loop.py +++ b/tests/test_ci_loop.py @@ -685,6 +685,60 @@ def test_snooze_get_busy_not_called_when_idle(): run_once(client, poller, CFG_SNOOZE, NOW, {}, dry_run=False, snooze_state=snooze_state) client.get_busy.assert_not_called() +def test_snooze_reviewer_reproduced_scenario_session_predates_alert_no_snooze(): + # Critical regression, full loop level: idle polls (green, get_busy + # gated off -> None passed to update_snooze) while a session is + # ALREADY active (unobserved, since nothing is polling yet) -- then a + # failure appears, triggering the first real get_busy() poll, which + # correctly observes the session as active. This must NOT be read as + # a fresh transition (the session predates the alert; the operator + # never acknowledged this specific failure) -- no pending, no + # suppression, the alert draws normally with its normal LED. + client = Mock(); client.draw.return_value = DrawResult.DRAWN + # If get_busy() were ever (wrongly) called during the idle polls, this + # would make it look like a fresh transition -- it must simply never + # be consulted during those polls at all (see should_poll_busy gating). + client.get_busy.return_value = _busy(True) + poller = Mock() + poller.fetch_runs.return_value = [_run("success")] # green -- idle + state_cache: dict = {} + snooze_state: dict = {} + + run_once(client, poller, CFG_SNOOZE, NOW, state_cache, dry_run=False, snooze_state=snooze_state) + t1 = NOW + timedelta(seconds=10) + run_once(client, poller, CFG_SNOOZE, t1, state_cache, dry_run=False, snooze_state=snooze_state) + client.get_busy.assert_not_called() # confirmed never polled while idle + + # A session is "already active" the whole time (per client.get_busy's + # mocked return value) -- unobserved so far. Now a failure appears. + poller.fetch_runs.return_value = [_run("failure")] + t2 = t1 + timedelta(seconds=10) + s3 = run_once(client, poller, CFG_SNOOZE, t2, state_cache, dry_run=False, snooze_state=snooze_state) + client.get_busy.assert_called_once() # first real poll, triggered by the alert appearing + assert "FAIL" in s3 + assert "fingerprint" not in snooze_state # must NOT have pended + assert client.draw.call_args.kwargs["led_notification_color"] == "#FF0000FF" # normal LED, not suppressed + +def test_snooze_mirror_alert_first_then_session_starts_while_polled_loop_level(): + # Mirror case, full loop level: alert appears first (polling begins + # immediately, observes inactive), then a session starts while still + # polling -- a genuinely observed transition, so pending DOES start. + client = Mock(); client.draw.return_value = DrawResult.DRAWN + poller = Mock() + poller.fetch_runs.return_value = [_run("failure")] + state_cache: dict = {} + snooze_state: dict = {} + + client.get_busy.return_value = _busy(False) + run_once(client, poller, CFG_SNOOZE, NOW, state_cache, dry_run=False, snooze_state=snooze_state) + assert "fingerprint" not in snooze_state + + client.get_busy.return_value = _busy(True) + t1 = NOW + timedelta(seconds=10) + run_once(client, poller, CFG_SNOOZE, t1, state_cache, dry_run=False, snooze_state=snooze_state) + assert snooze_state.get("fingerprint") is not None # pending established, as designed + assert client.draw.call_args.kwargs["led_notification_color"] is None # LED suppressed while pending + def test_snooze_get_busy_called_when_alert_showing(): client = Mock(); client.draw.return_value = DrawResult.DRAWN client.get_busy.return_value = _busy(False)