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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -1379,3 +1379,84 @@ 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.

### Correction (`dev/claude/chirp-snd-fix` round): the chirp was silent — `.wav` vs `.snd`, and a deferred, swallowed 200

**The "chirp's real playback timestamp" claim two paragraphs above was
wrong.** `play_audio` returning `True` at `2026-08-04T06:54:16.622887Z`
(quoted in this round's report) was real evidence a well-formed request
reached the device and got a `200` back -- it was NOT evidence of audible
playback, and in fact nothing was audible. This was caught by the
operator's own ear-testing after deploy, then root-caused via device
storage forensics, not by anything observable from this codebase or the
device's documented API alone.

**Root cause, confirmed both by the operator listening and by a live `GET
/api/storage/list` of `/ext/apps_assets/shared/sounds` against the real
device:**

1. **The firmware build pipeline converts `.wav` SOURCE files to `.snd`
at packaging time.** The runtime filenames under
`/ext/apps_assets/shared/sounds/` are `calendar_event_starts.snd`,
`calendar_reminder_ends.snd`, `volume_change.snd` -- never `.wav`.
Neither the firmware source tree (which stores `.wav` sources) nor the
OpenAPI spec (which never mentions the conversion at all) reveals
this; the ONLY way to find the real runtime filename is a live storage
listing against the actual device, not a source-tree grep or a spec
read. This is the same category of "the documented/source-visible
description doesn't match runtime reality" as Finding 1 and Finding 2
in the "Probe findings" section above -- those two live in
`busybar/display.py`'s module docstring since they're display-priority
facts; this one (and #2 below) are audio-specific, so their
code-adjacent canonical copy is `BusyBarClient.play_audio`'s own
docstring in `src/busybar/client.py` instead.
2. **`POST /api/audio/play` returns `200` BEFORE the actual file open.**
Playback is queued behind a short amp holdoff (confirmed ~100ms); an
open failure at holdoff-fire (e.g. because the filename doesn't exist
at runtime) is logged device-side only and otherwise swallowed --
nothing about the failure crosses back over the HTTP response. A
wrong `stock_path` is therefore indistinguishable from a correct one
at every software layer available to this codebase: the request is
well-formed, the response is `200`, `play_audio` returns `True`, and
the calling code (this project's own `should_chirp`/`commit_chirped`
logic) correctly commits the chirp as delivered -- with no sound at
all. This is a genuinely new class of firmware-doc gap for this
project: not "the doc's *description* of a rule is wrong" (Finding 1)
or "an *observable* transition is undocumented" (Finding 2), but "the
API's own success signal is emitted before the operation it describes
has actually been attempted," making software-only verification of
this endpoint structurally impossible.

**Operator ear-test matrix (the evidence that actually resolved this):**

| stock_path / asset | Audible? |
|---|---|
| `shared/calendar_event_starts.wav` (the original, wrong extension) | **No** |
| An uploaded `.wav` asset (via `path`, not `stock_path`) | Yes |
| `shared/calendar_event_starts.snd` (corrected) | Yes |

The middle row is worth noting on its own: an *uploaded* `.wav` asset
plays fine (the upload pipeline evidently doesn't need the same `.snd`
conversion, or handles it transparently) -- the `.wav`-is-silent problem
is specific to referencing a *stock* asset by its (wrong, pre-conversion)
filename, not a general "this firmware can't play `.wav` audio" claim.

**Fix:** `CHIRP_STOCK_PATH` corrected to
`"shared/calendar_event_starts.snd"`. `BusyBarClient.play_audio`'s
docstring now documents both the `.wav`→`.snd` runtime conversion and the
deferred/swallowed-`200` caveat explicitly, with the operator instruction
to verify any future stock filename against a live storage listing rather
than the source tree or the OpenAPI spec. The chirp call site
(`calendar_countdown/main.py`) now logs at `INFO` on *every* chirp
attempt, success or failure (`"chirp played (%s) -> %s"`) -- the original
bug was doubly silent (no sound AND no log line, since only failures were
previously logged, in `play_audio` itself), which is why it took storage
forensics rather than a log check to diagnose. A regression test pins
`CHIRP_STOCK_PATH.endswith(".snd")` directly so a future edit can't
silently reintroduce `.wav`.

No further on-device round was performed for this fix specifically: the
operator directly ear-verified the corrected `.snd` call as part of
reporting the original bug, which is stronger evidence than a fresh
scripted probe could add (a scripted probe can only confirm HTTP `200`
again, exactly the signal already shown not to prove audibility).
4 changes: 3 additions & 1 deletion integrations/calendar_countdown/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,9 @@ of these four keys.
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`) -- 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.
- **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.snd`) -- 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.

**A note on that `.snd` extension.** The firmware build pipeline converts `.wav` source files to `.snd` at packaging time, so the *runtime* filename under a stock sound directory is never `.wav`, even though the source asset is -- this is invisible from the OpenAPI spec or the firmware source tree; the only way to find it is a live storage listing against the actual device. An earlier version of this feature shipped with the wrong (`.wav`) extension: `POST /api/audio/play` returned `200` for it every time (the endpoint queues playback behind a short amp holdoff and returns success *before* the actual file open, so a missing/wrong file at that point is logged device-side only and never reaches the HTTP response) -- so the chirp was **silently non-functional** end to end until an operator noticed no sound was playing. Every chirp attempt is now logged at `INFO` (`chirp played (<path>) -> <True/False>`) specifically so a repeat of this -- a "successful" `play_audio` call that produces no actual sound -- at least leaves a trace to correlate against what you actually hear, since a `200`/`True` response alone does not prove audibility. If you ever change `CHIRP_STOCK_PATH` to a different stock sound, verify the exact filename against `GET /api/storage/list` on the real device first, not the firmware source tree.

**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.

Expand Down
31 changes: 25 additions & 6 deletions integrations/calendar_countdown/logic.py
Original file line number Diff line number Diff line change
Expand Up @@ -329,14 +329,33 @@ def resolve_led_value(led_should_be_on: bool, led_was_on: bool) -> str | None:
return None


CHIRP_STOCK_PATH = "shared/calendar_event_starts.wav"
CHIRP_STOCK_PATH = "shared/calendar_event_starts.snd"
# ^ 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
# generated/uploaded asset -- no asset generation, upload, or
# repo-committed binary is needed for the chirp; see the spec doc's
# v1.5.2 section for why the naming ("calendar event starts") is an
# exact semantic match for the T-0 chirp this feature fires.
#
# v1.5.2.1 CORRECTION: this was originally ".wav" -- the v1.5.2 on-device
# probe (POST /api/audio/play with that stock_path) returned 200, which
# was WRONGLY taken as confirmation the chirp was audible. It was not.
# Root cause, confirmed by operator ear-testing plus device storage
# forensics (a live GET of /api/storage/list against
# /ext/apps_assets/shared/sounds -- nothing in the source tree or the
# OpenAPI spec reveals this): the firmware build pipeline converts .wav
# SOURCE files to **.snd** at packaging time; the runtime filenames are
# .snd (e.g. calendar_event_starts.snd), never .wav. Compounding this,
# /api/audio/play returns 200 BEFORE the actual (deferred) file open --
# playback is queued behind a ~100ms amp holdoff, and an open failure at
# holdoff-fire is logged device-side only and otherwise swallowed. A
# `True` from play_audio therefore does NOT prove audible playback; a
# wrong filename (like the original ".wav" here) is indistinguishable
# from success at every software layer available to this codebase. The
# operator's ear-test matrix that confirmed this: stock ".wav" -> silent,
# an uploaded ".wav" asset -> audible, stock ".snd" -> audible. See
# BusyBarClient.play_audio's docstring for the same caveat stated at the
# API-client level, and the spec doc's firmware-facts section for the
# full writeup.


def check_threshold_ordering(cfg: dict) -> str | None:
Expand Down
23 changes: 18 additions & 5 deletions integrations/calendar_countdown/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,14 +118,27 @@ def run_once(client, fetch, cfg: dict, now: datetime, dry_run: bool,
# 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.
#
# Observability (v1.5.2.1): log EVERY attempt at INFO, success or
# failure -- not just failures (client.play_audio already logs those
# at WARNING). This is what the silent-.wav bug needed and didn't
# have: a 200/True response from play_audio does NOT prove audible
# playback (see play_audio's docstring for the full deferred-open/
# swallowed-failure explanation), so a log line is the only trace
# this process can leave for later correlation against an operator's
# own ear-test -- a "successful" chirp that nobody heard was
# previously doubly silent (no sound AND no log line), which is why
# the root cause took device storage forensics to find instead of a
# five-second log check.
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):
played = client.play_audio(APP, stock_path=CHIRP_STOCK_PATH)
log.info("chirp played (%s) -> %s", CHIRP_STOCK_PATH, played)
if played:
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.
# else: 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 --
Expand Down
30 changes: 29 additions & 1 deletion src/busybar/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ 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
sound, e.g. "shared/calendar_event_starts.snd" -- 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
Expand All @@ -57,6 +57,34 @@ def play_audio(self, application_name: str, stock_path: str | None = None,
own volume setting; playback always uses whatever volume is
currently configured on the device.

**Stock sound filenames are `.snd` at runtime, not `.wav`, even
though the source assets in the firmware repo are `.wav` files.**
The build pipeline converts `.wav` sources to `.snd` at packaging
time; the source tree and the OpenAPI spec never reveal this --
the only way to find the real runtime filename is a live `GET
/api/storage/list` of the target directory (e.g.
`/ext/apps_assets/shared/sounds`) against the actual device.
Always verify a stock filename against that listing before
shipping it in a `stock_path`, not against the source repo or the
API docs.

**A `True` return does NOT prove audible playback.** This
endpoint returns `200` BEFORE the actual file open -- playback is
queued behind a short amp holdoff (~100ms), and an open failure
at holdoff-fire (e.g. because the filename is wrong) is logged
device-side only and otherwise swallowed; nothing comes back over
this HTTP response either way. A wrong filename (confirmed with
the original, incorrect `.wav` stock_path used here before this
was diagnosed) is therefore indistinguishable from a correct one
at every layer this codebase can observe -- the request succeeds,
the response is `200`, and `play_audio` returns `True`, with no
actual sound. The only way to confirm real audibility is a human
listening on the actual hardware; log every attempt's outcome
(both `True` and `False`) at the call site so a silent-but-
"successful" chirp is at least visible in the log for later
correlation against an operator report, rather than doubly silent
(no sound AND no log line) the way the original bug was.

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
Expand Down
18 changes: 15 additions & 3 deletions tests/test_calendar_logic.py
Original file line number Diff line number Diff line change
Expand Up @@ -602,9 +602,21 @@ def test_chirp_state_prunes_old_entries():

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"
# needed -- see logic.py's module comment for why this is a
# firmware-shipped stock sound, not a repo-committed binary.
assert CHIRP_STOCK_PATH == "shared/calendar_event_starts.snd"

def test_chirp_stock_path_is_snd_not_wav_regression():
# Regression, v1.5.2.1: the runtime filename is .snd -- the firmware
# build pipeline converts .wav SOURCE files to .snd at packaging time,
# invisible from the source tree or the OpenAPI spec. The original
# ".wav" stock_path returned 200 from /api/audio/play (a silently
# accepted but wrong filename -- see play_audio's docstring for why a
# 200 doesn't prove audible playback) and produced no sound at all,
# confirmed by operator ear-testing. This test pins the extension
# directly so a future edit can't silently regress back to .wav.
assert CHIRP_STOCK_PATH.endswith(".snd")
assert not CHIRP_STOCK_PATH.endswith(".wav")


# --- next_sleep_seconds: T-0 chirp precision --------------------------------------
Expand Down
35 changes: 35 additions & 0 deletions tests/test_calendar_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,41 @@ def test_run_once_chirps_exactly_once_on_start_transition():
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_logs_info_on_successful_play(caplog):
# v1.5.2.1 observability fix: a "successful" (True-returning) chirp
# attempt must leave a log trace -- the silent-.wav bug was doubly
# silent (no sound AND no log line) precisely because success wasn't
# logged at all, only failures were (in client.play_audio itself).
import logging
client = Mock(); client.draw.return_value = DrawResult.DRAWN
client.play_audio.return_value = True
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)
with caplog.at_level(logging.INFO, logger="calendar_countdown"):
run_once(client, lambda hours: [started], CFG, later, dry_run=False, state=state)
matches = [rec.message for rec in caplog.records if "chirp played" in rec.message]
assert len(matches) == 1
assert CHIRP_STOCK_PATH in matches[0]
assert "True" in matches[0]

def test_run_once_chirp_logs_info_on_failed_play(caplog):
import logging
client = Mock(); client.draw.return_value = DrawResult.DRAWN
client.play_audio.return_value = 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)
with caplog.at_level(logging.INFO, logger="calendar_countdown"):
run_once(client, lambda hours: [started], CFG, later, dry_run=False, state=state)
matches = [rec.message for rec in caplog.records if "chirp played" in rec.message]
assert len(matches) == 1
assert "False" in matches[0]

def test_run_once_chirp_disabled_never_fires():
client = Mock(); client.draw.return_value = DrawResult.DRAWN
client.play_audio.return_value = True
Expand Down
Loading