From 2c80a93e86a62b2491ecb064a8bb5ca5a552bc73 Mon Sep 17 00:00:00 2001 From: John Osumi <931193+sumitake@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:06:30 -0700 Subject: [PATCH 01/13] docs: nyan_filler design spec + implementation plan Co-Authored-By: Claude Opus 4.8 --- .../plans/2026-08-06-nyan-filler.md | 878 ++++++++++++++++++ .../specs/2026-08-06-nyan-filler-design.md | 331 +++++++ 2 files changed, 1209 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-06-nyan-filler.md create mode 100644 docs/superpowers/specs/2026-08-06-nyan-filler-design.md diff --git a/docs/superpowers/plans/2026-08-06-nyan-filler.md b/docs/superpowers/plans/2026-08-06-nyan-filler.md new file mode 100644 index 0000000..7b8e43c --- /dev/null +++ b/docs/superpowers/plans/2026-08-06-nyan-filler.md @@ -0,0 +1,878 @@ +# Nyan Cat Dark-Filler Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a `nyan_filler` integration that draws a native, on-device Nyan Cat animation at a low priority to fill the panel whenever it would otherwise be black (CI dwell gaps and idle), except during quiet hours. + +**Architecture:** A third self-contained integration (`integrations/nyan_filler/`, pure `logic.py` + thin `main.py`) mirroring the existing two. It draws an `AnimationElement` at a new `PRIORITY_FILLER = 5` — above the empty/stub screen (priority 0) but below built-in apps (10) and every other tier — so the firmware's existing priority arbitration makes it fill black gaps, never override a built-in app, and yield to calendar/CI/alerts/sessions. The animation is a native `.anim` (`bicycle0`) generated at build time by an in-repo encoder ported from the firmware web draw-tool's `seq2anim.ts`; the device self-loops it, so the host only re-asserts one small draw per poll. + +**Tech Stack:** Python ≥3.12, stdlib + `requests` (runtime); Pillow + the in-repo encoder (dev/build tools only); launchd; pytest. + +**Reference spec:** `docs/superpowers/specs/2026-08-06-nyan-filler-design.md` (all four on-device spikes resolved in §5b). + +## Global Constraints + +- **Public repo — sanitize everything.** Never print `config.toml` (it holds personal data + the cloud token). +- **Runtime deps = stdlib + `BusyBarClient` only.** Pillow and the `.anim` encoder are dev/build tools under `tools/`, never imported by `integrations/nyan_filler/` at runtime. +- **`PRIORITY_FILLER = 5`** — exact value; ordering `0 < 5 < PRIORITY_AMBIENT (20)` (verified on-device: black gaps rest at priority 0, built-in apps at 10). +- **Config defaults (exact):** `enabled = true`, `poll_seconds = 1`, `quiet_hours = "00:00-07:00"`. +- **App name = `"nyan_filler"`; asset name = `"nyan_72x16.anim"`; element id = `"nyan"`.** +- **`.anim` format (`bicycle0`):** 36-byte header, `default` section, raw (`encoding=0`) frames, pixels packed **BGR**. Color mode `rgb888` = 0. +- Tests live in `tests/test_*.py`; run with `uv run pytest` (pythonpath=src is configured in `pyproject.toml`). +- Follow existing patterns: caller-owned `state`/cache dicts mutated in place; `run_once(...) -> str` summary; exponential backoff on `UNREACHABLE`. + +## File Structure + +- `tools/anim_encoder.py` — pure `bicycle0` encoder (frames+meta → bytes). Dev tool. **(Task 1)** +- `tools/build_nyan_anim.py` — render Nyan frames + encode → `assets/nyan/nyan_72x16.anim`. Dev tool. **(Task 2)** +- `assets/nyan/nyan_72x16.anim` — committed generated asset. **(Task 2)** +- `assets/nyan/meta.json` — `{fps, color_mode, sections}`. **(Task 2)** +- `src/busybar/display.py` — add `PRIORITY_FILLER = 5`. **(Task 3)** +- `src/busybar/config.py` — add `[nyan_filler]` to `DEFAULTS`. **(Task 3)** +- `src/busybar/client.py` — add `upload_asset(...)`. **(Task 5)** +- `integrations/nyan_filler/__init__.py` — empty package marker. **(Task 4)** +- `integrations/nyan_filler/logic.py` — quiet-hours + element builder (pure). **(Task 4)** +- `integrations/nyan_filler/main.py` — poll loop. **(Task 5)** +- `integrations/nyan_filler/com.busybar.nyan-filler.plist` — launchd agent. **(Task 6)** +- `integrations/nyan_filler/README.md` — install + behavior docs. **(Task 6)** +- `config.example.toml` — add `[nyan_filler]` section. **(Task 6)** +- `pyproject.toml` — add `pillow` to the `dev` dependency group. **(Task 2)** +- `tests/test_anim_encoder.py`, `tests/test_nyan_asset.py`, `tests/test_nyan_logic.py`, `tests/test_nyan_main.py`; append to `tests/test_display.py`, `tests/test_config.py`. + +--- + +### Task 1: `bicycle0` animation encoder + +**Files:** +- Create: `tools/anim_encoder.py` +- Create: `tools/__init__.py` (empty, so tests can `from tools.anim_encoder import ...`) +- Test: `tests/test_anim_encoder.py` + +**Interfaces:** +- Produces: `encode_anim(frames_bgr: list[bytes], width: int, height: int, fps: int, color_mode: str = "rgb888", sections: list[tuple[int,int,str]] | None = None) -> bytes` — each `frames_bgr[i]` is exactly `width*height*3` BGR bytes; returns a complete `.anim` file. `parse_header(data: bytes) -> dict` — decodes the header into `{"magic","width","height","color_mode","fps","n_sections","n_encoded","n_display"}` (used by tests and Task 2). + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_anim_encoder.py +import struct +from tools.anim_encoder import encode_anim, parse_header + +RED_BGR = bytes([0, 0, 255]) # BGR packing of RGB red +BLUE_BGR = bytes([255, 0, 0]) # BGR packing of RGB blue + +def _solid(color: bytes, w=72, h=16) -> bytes: + return color * (w * h) + +def test_header_fields_and_counts(): + data = encode_anim([_solid(RED_BGR), _solid(BLUE_BGR)], 72, 16, fps=2) + h = parse_header(data) + assert h["magic"] == b"bicycle0" + assert (h["width"], h["height"]) == (72, 16) + assert h["color_mode"] == 0 # rgb888 + assert h["fps"] == 2 + assert h["n_display"] == 2 # two display frames + assert h["n_encoded"] == 2 # two distinct encoded frames + assert h["n_sections"] == 1 # the implicit "default" section + +def test_consecutive_identical_frames_dedup(): + data = encode_anim([_solid(RED_BGR)] * 3, 72, 16, fps=1) + h = parse_header(data) + assert h["n_display"] == 3 # three display frames... + assert h["n_encoded"] == 1 # ...collapsed to one encoded frame + +def test_first_frame_pixels_roundtrip(): + # The first raw frame's bytes must be the exact BGR payload we passed in. + frame = _solid(RED_BGR) + data = encode_anim([frame, _solid(BLUE_BGR)], 72, 16, fps=2) + # frames start after header(36) + sections chunk; the default section is + # 13 + len("default") + 1 = 21 bytes -> frames at offset 57. + off = 36 + 21 + encoding, duration, length = data[off], data[off+1], struct.unpack_from(" bytes: + if not frames_bgr: + raise ValueError("at least one frame required") + expected = width * height * (3 if color_mode == "rgb888" else 1) + for i, f in enumerate(frames_bgr): + if len(f) != expected: + raise ValueError(f"frame {i}: got {len(f)} bytes, expected {expected}") + + # Collapse consecutive identical frames into one encoded frame (duration++). + enc: list[list] = [] # [encoding, duration, data] + last = None + for f in frames_bgr: + if last is not None and f == last: + enc[-1][1] += 1 + continue + last = f + enc.append([0, 1, f]) # encoding=0 (raw), duration=1 + + frames_chunk_len = sum(4 + len(e[2]) for e in enc) + max_encoded_len = max(len(e[2]) for e in enc) + + n = len(frames_bgr) + all_sections: list[tuple[int, int, str]] = [(0, n - 1, "default")] + for s in (sections or []): + if s[2] == "default": + raise ValueError('section name "default" is reserved') + all_sections.append(s) + sections_chunk_len = sum(13 + len(name.encode()) + 1 for _, _, name in all_sections) + + # Map each display-frame index -> (byte offset of its encoded frame, remaining duration). + disp: list[tuple[int, int]] = [] + off = HEADER_LENGTH + sections_chunk_len + for _, dur, data in enc: + for d in range(dur, 0, -1): + disp.append((off, d)) + off += 4 + len(data) + + out = bytearray(b"bicycle0") + out += bytes([0, width, height, 0 if color_mode == "rgb888" else 1]) + out += bytes([fps]) + out += struct.pack(" dict: + if data[:8] != b"bicycle0": + raise ValueError("bad magic") + scl, fcl, n_sections, n_encoded, n_display = struct.unpack_from("=8.3", "pillow>=10.0"] +``` + +Run: `uv sync` (installs Pillow into the dev env). + +- [ ] **Step 2: Write the builder** + +The renderer geometry is copied from the reference community app (`maxswinkels/busybar-apps`, `apps/nyan-cat/app.py`) — the pop-tart cat, rainbow, and twinkling stars, drawn into a flat 72×16 RGB buffer. `random` is seeded for deterministic, committable frames. + +```python +# tools/build_nyan_anim.py +"""Render a fixed Nyan Cat loop and compile it to a bicycle0 .anim. + +Renderer geometry adapted from the community reference app +(maxswinkels/busybar-apps, apps/nyan-cat). Deterministic (seeded RNG) so the +committed asset is reproducible. Build-time only. + + uv run python tools/build_nyan_anim.py +""" +from __future__ import annotations + +import json +import random +from pathlib import Path + +from PIL import Image + +from tools.anim_encoder import encode_anim + +W, H = 72, 16 +FRAMES = 24 # ~2s loop at 12 fps +FPS = 12 + +CRUST=(0xFF,0xCC,0x99); FROSTING=(0xFF,0x99,0xFF); SPRINKLE=(0xDD,0x33,0x88) +GRAY=(0x99,0x99,0x99); BLACK=(0,0,0); CHEEK=(0xFF,0x99,0x99); STAR=(0xFF,0xFF,0xFF) +RAINBOW=[(0xFF,0,0),(0xFF,0x99,0),(0xFF,0xFF,0),(0x33,0xFF,0),(0,0x99,0xFF),(0x66,0x33,0xFF)] +CX,BY=44,3; HX,HY=CX+9,5; TRAIL_END=CX-5 + +def _blank(): return [(0,0,0)]*(W*H) +def _rect(buf,x,y,w,h,rgb): + x2,y2=min(W,x+w),min(H,y+h); x,y=max(0,x),max(0,y) + for yy in range(y,y2): + base=yy*W + for xx in range(x,x2): buf[base+xx]=rgb + +def _stars_state(): return [{"x":8,"y":3,"p":0},{"x":26,"y":13,"p":2},{"x":46,"y":1,"p":1},{"x":66,"y":11,"p":3}] +def _tick_stars(buf,stars,rng): + for s in stars: + s["x"]-=3; s["p"]=(s["p"]+1)%4 + if s["x"]<-2: s["x"]=W+rng.randint(0,10); s["y"]=rng.randint(1,H-2) + x,y,p=s["x"],s["y"],s["p"] + if p==0: _rect(buf,x,y,1,1,STAR) + elif p==1: _rect(buf,x-1,y,3,1,STAR); _rect(buf,x,y-1,1,3,STAR) + elif p==2: _rect(buf,x-2,y,5,1,STAR); _rect(buf,x,y-2,1,5,STAR) + else: + for dx,dy in ((-2,0),(2,0),(0,-2),(0,2)): _rect(buf,x+dx,y+dy,1,1,STAR) + +def _rainbow(buf,phase): + for band,color in enumerate(RAINBOW): + y=2+band*2; x=0 + while x= 1 + assert h["fps"] == json.loads(META.read_text())["fps"] +``` + +- [ ] **Step 5: Run tests** + +Run: `uv run pytest tests/test_nyan_asset.py -v` +Expected: PASS. + +- [ ] **Step 6: Commit** (commit the generated asset + frames) + +```bash +git add pyproject.toml tools/build_nyan_anim.py assets/nyan/ tests/test_nyan_asset.py +git commit -m "assets: generate committed Nyan .anim (24-frame loop, 12fps)" +``` + +--- + +### Task 3: Shared wiring — `PRIORITY_FILLER` + config defaults + +**Files:** +- Modify: `src/busybar/display.py` (add the constant near `PRIORITY_AMBIENT`) +- Modify: `src/busybar/config.py` (add `nyan_filler` to `DEFAULTS`) +- Test: append to `tests/test_display.py` and `tests/test_config.py` + +**Interfaces:** +- Produces: `busybar.display.PRIORITY_FILLER = 5`; `DEFAULTS["nyan_filler"] = {"enabled","poll_seconds","quiet_hours"}`. + +- [ ] **Step 1: Write failing tests** + +```python +# tests/test_display.py (append) +from busybar.display import PRIORITY_FILLER, PRIORITY_AMBIENT + +def test_filler_priority_below_builtin_and_ambient(): + assert PRIORITY_FILLER == 5 + assert 0 < PRIORITY_FILLER < 10 < PRIORITY_AMBIENT # 10 = built-in app tier +``` + +```python +# tests/test_config.py (append) +from busybar.config import load_config + +def test_nyan_filler_defaults(): + cfg = load_config(path=None)["nyan_filler"] + assert cfg == {"enabled": True, "poll_seconds": 1, "quiet_hours": "00:00-07:00"} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_display.py::test_filler_priority_below_builtin_and_ambient tests/test_config.py::test_nyan_filler_defaults -v` +Expected: FAIL (ImportError / KeyError). + +- [ ] **Step 3: Add the constant** in `src/busybar/display.py`, immediately below the `PRIORITY_AMBIENT`/`AMBIENT_REDRAW_SECONDS` block: + +```python +PRIORITY_FILLER = 5 +"""Decorative screen-filler (e.g. nyan_filler). Strictly below PRIORITY_AMBIENT +(20) AND below the firmware's built-in-app tier (10), but above the empty/stub +screen (0). Verified on-device (spec 2026-08-06 §5b, SPK-3): the panel's +black/resting state -- true idle AND the CI overlay's silence gap -- rests at +priority 0, so a priority-5 draw fills those gaps; a built-in app at priority 10 +outranks it, so the filler never overrides the clock/desktop. Every other tier +(ambient 20, overlay 21, raised 25, alert 60, urgent 65, session 90) preempts +it. Draw with loop=true and re-assert every poll: a same-element redraw +continues the on-device loop (SPK-2), so unconditional per-poll redraw does not +stutter. +""" +``` + +- [ ] **Step 4: Add the config defaults** in `src/busybar/config.py`, as a new top-level key in `DEFAULTS` (after `ci_status`): + +```python + "nyan_filler": { + "enabled": True, + "poll_seconds": 1, # reclaims a dark gap within ~1s; the draw is + # tiny and mostly-sleeping (see nyan_filler/README) + "quiet_hours": "00:00-07:00", # local time; "" disables quiet hours entirely + }, +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `uv run pytest tests/test_display.py tests/test_config.py -v` +Expected: PASS (all, including pre-existing). + +- [ ] **Step 6: Commit** + +```bash +git add src/busybar/display.py src/busybar/config.py tests/test_display.py tests/test_config.py +git commit -m "display+config: add PRIORITY_FILLER=5 tier and [nyan_filler] defaults" +``` + +--- + +### Task 4: `nyan_filler/logic.py` — quiet-hours + element builder (pure) + +**Files:** +- Create: `integrations/nyan_filler/__init__.py` (empty) +- Create: `integrations/nyan_filler/logic.py` +- Test: `tests/test_nyan_logic.py` + +**Interfaces:** +- Consumes: `busybar.display.PRIORITY_FILLER`. +- Produces: + - `FILLER_APP = "nyan_filler"`, `ASSET_NAME = "nyan_72x16.anim"`, `ELEMENT_ID = "nyan"` + - `parse_quiet_hours(s: str) -> tuple[int, int] | None` — `(start_min, end_min)` in minutes-since-midnight, or `None` when `s == ""`. Raises `ValueError` on malformed input. + - `in_quiet_hours(now: datetime, window: tuple[int, int] | None) -> bool` — `False` when `window is None` or `start == end`; supports midnight wrap; inclusive start, exclusive end. + - `build_filler_elements(asset: str, timeout_s: int) -> list[dict]` + +- [ ] **Step 1: Write the failing tests** + +```python +# tests/test_nyan_logic.py +from datetime import datetime +import pytest +from integrations.nyan_filler.logic import ( + parse_quiet_hours, in_quiet_hours, build_filler_elements, ELEMENT_ID) + +def _at(h, m=0): return datetime(2026, 8, 6, h, m) + +def test_parse_basic_and_empty(): + assert parse_quiet_hours("00:00-07:00") == (0, 420) + assert parse_quiet_hours("23:00-07:00") == (1380, 420) + assert parse_quiet_hours("") is None + +@pytest.mark.parametrize("bad", ["7-8", "25:00-01:00", "01:60-02:00", "0100-0200", "01:00_02:00"]) +def test_parse_rejects_malformed(bad): + with pytest.raises(ValueError): + parse_quiet_hours(bad) + +def test_same_day_window_inclusive_start_exclusive_end(): + w = parse_quiet_hours("00:00-07:00") + assert in_quiet_hours(_at(0, 0), w) is True # inclusive start + assert in_quiet_hours(_at(3), w) is True + assert in_quiet_hours(_at(6, 59), w) is True + assert in_quiet_hours(_at(7, 0), w) is False # exclusive end + assert in_quiet_hours(_at(12), w) is False + +def test_midnight_wrap_window(): + w = parse_quiet_hours("23:00-07:00") + assert in_quiet_hours(_at(23, 30), w) is True + assert in_quiet_hours(_at(2), w) is True + assert in_quiet_hours(_at(7, 0), w) is False + assert in_quiet_hours(_at(12), w) is False + +def test_none_and_equal_bounds_never_quiet(): + assert in_quiet_hours(_at(3), None) is False + assert in_quiet_hours(_at(3), (120, 120)) is False # start == end -> never + +def test_element_shape(): + els = build_filler_elements("nyan_72x16.anim", timeout_s=2) + assert els == [{"id": ELEMENT_ID, "type": "animation", "path": "nyan_72x16.anim", + "x": 0, "y": 0, "loop": True, "timeout": 2}] +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_nyan_logic.py -v` +Expected: FAIL (`ModuleNotFoundError`). + +- [ ] **Step 3: Implement `logic.py`** + +```python +# integrations/nyan_filler/logic.py +"""Pure helpers for the nyan_filler integration: quiet-hours parsing/gating and +the animation element payload. No I/O -- fully unit-tested.""" +from __future__ import annotations + +import re +from datetime import datetime + +FILLER_APP = "nyan_filler" +ASSET_NAME = "nyan_72x16.anim" +ELEMENT_ID = "nyan" + +_HHMM = re.compile(r"^([01]?\d|2[0-3]):([0-5]\d)-([01]?\d|2[0-3]):([0-5]\d)$") + + +def parse_quiet_hours(s: str) -> tuple[int, int] | None: + """'HH:MM-HH:MM' -> (start_min, end_min) minutes-since-midnight. '' -> None + (quiet hours disabled). Raises ValueError on any other malformed input.""" + if s == "": + return None + m = _HHMM.match(s.strip()) + if not m: + raise ValueError(f"invalid quiet_hours {s!r}; expected 'HH:MM-HH:MM' or ''") + sh, sm, eh, em = (int(g) for g in m.groups()) + return sh * 60 + sm, eh * 60 + em + + +def in_quiet_hours(now: datetime, window: tuple[int, int] | None) -> bool: + """True iff `now`'s local wall-clock falls in the window. Inclusive start, + exclusive end. Supports a window that wraps midnight (start > end). A window + with start == end is treated as 'never quiet'.""" + if window is None: + return False + start, end = window + if start == end: + return False + cur = now.hour * 60 + now.minute + if start < end: + return start <= cur < end + return cur >= start or cur < end # wraps midnight + + +def build_filler_elements(asset: str, timeout_s: int) -> list[dict]: + """The single looping animation element drawn at PRIORITY_FILLER.""" + return [{"id": ELEMENT_ID, "type": "animation", "path": asset, + "x": 0, "y": 0, "loop": True, "timeout": timeout_s}] +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/test_nyan_logic.py -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add integrations/nyan_filler/__init__.py integrations/nyan_filler/logic.py tests/test_nyan_logic.py +git commit -m "nyan_filler: pure quiet-hours logic and animation element builder" +``` + +--- + +### Task 5: `client.upload_asset` + `nyan_filler/main.py` poll loop + +**Files:** +- Modify: `src/busybar/client.py` (add `upload_asset`) +- Create: `integrations/nyan_filler/main.py` +- Test: `tests/test_nyan_main.py` (and one small `upload_asset` test in `tests/test_client.py`) + +**Interfaces:** +- Consumes: `BusyBarClient.draw/clear`, `busybar.config.load_config/device_kwargs`, `busybar.display.PRIORITY_FILLER`, `logic.*`. +- Produces: + - `BusyBarClient.upload_asset(application_name: str, filename: str, data: bytes) -> bool` — local-only POST of raw bytes; `True` on HTTP 200. + - `nyan_filler.main.run_once(client, cfg, now, state, dry_run=False) -> str` — one poll cycle. `state` is a caller-owned dict (`{"quiet_cleared": bool}`) mutated in place. + +- [ ] **Step 1: Write the failing tests** + +```python +# tests/test_nyan_main.py +from datetime import datetime +from busybar.client import DrawResult +from busybar.display import PRIORITY_FILLER +from integrations.nyan_filler.main import run_once +from integrations.nyan_filler.logic import FILLER_APP, ASSET_NAME + +class FakeClient: + def __init__(self, result=DrawResult.DRAWN): + self.result = result; self.draws = []; self.clears = 0 + def draw(self, app, elements, priority=50, led_notification_color=None): + self.draws.append((app, elements, priority)); return self.result + def clear(self, app): + self.clears += 1; return True + +BASE = {"nyan_filler": {"enabled": True, "poll_seconds": 1, "quiet_hours": "00:00-07:00"}} + +def test_draws_at_filler_priority_when_active(): + c = FakeClient(); st = {} + summary = run_once(c, BASE, datetime(2026, 8, 6, 12, 0), st) # noon: not quiet + assert len(c.draws) == 1 + app, elements, priority = c.draws[0] + assert app == FILLER_APP and priority == PRIORITY_FILLER + assert elements[0]["type"] == "animation" and elements[0]["path"] == ASSET_NAME + assert elements[0]["loop"] is True + assert "drawn" in summary + +def test_quiet_hours_clears_once_then_stays_silent(): + c = FakeClient(); st = {} + run_once(c, BASE, datetime(2026, 8, 6, 3, 0), st) # 3am: quiet + run_once(c, BASE, datetime(2026, 8, 6, 3, 1), st) # still quiet + assert c.clears == 1 # cleared once on entry, not every poll + assert c.draws == [] + +def test_leaving_quiet_hours_draws_again(): + c = FakeClient(); st = {} + run_once(c, BASE, datetime(2026, 8, 6, 3, 0), st) # quiet -> clears + run_once(c, BASE, datetime(2026, 8, 6, 8, 0), st) # active -> draws + assert c.clears == 1 and len(c.draws) == 1 + +def test_disabled_is_noop(): + c = FakeClient(); st = {} + cfg = {"nyan_filler": {**BASE["nyan_filler"], "enabled": False}} + summary = run_once(c, cfg, datetime(2026, 8, 6, 12, 0), st) + assert c.draws == [] and "disabled" in summary +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_nyan_main.py -v` +Expected: FAIL (`ModuleNotFoundError`). + +- [ ] **Step 3: Add `upload_asset` to `src/busybar/client.py`** (after `clear`): + +```python + def upload_asset(self, application_name: str, filename: str, data: bytes) -> bool: + """Upload a raw asset (e.g. a compiled .anim) to the device's app asset + store. Local-only: assets live on the physical device, so this never + uses the cloud transport. Returns True on HTTP 200.""" + resp = self._try_local( + "POST", + f"/api/assets/upload?application_name={application_name}&file={filename}", + data=data, headers={"Content-Type": "application/octet-stream"}) + if resp is None: + log.warning("asset upload unreachable: %s/%s", application_name, filename) + return False + if resp.status_code != 200: + log.warning("asset upload failed: HTTP %s %s", resp.status_code, resp.text[:200]) + return resp.status_code == 200 +``` + +- [ ] **Step 4: Implement `main.py`** + +```python +# integrations/nyan_filler/main.py +import sys +from pathlib import Path + +try: + import busybar # noqa: F401 +except ImportError: + sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "src")) + +import argparse +import logging +import time +from datetime import datetime + +from busybar.client import BusyBarClient, DrawResult +from busybar.config import device_kwargs, load_config +from busybar.display import PRIORITY_FILLER + +from .logic import (FILLER_APP, ASSET_NAME, build_filler_elements, + in_quiet_hours, parse_quiet_hours) + +APP = FILLER_APP +log = logging.getLogger(APP) + +ASSET_PATH = Path(__file__).resolve().parents[2] / "assets" / "nyan" / ASSET_NAME + + +def run_once(client, cfg: dict, now: datetime, state: dict, dry_run: bool = False) -> str: + """One poll cycle. `state` is a caller-owned dict mutated in place: + `quiet_cleared` records whether we've already released the panel for the + current quiet window (so we clear once on entry, not every poll).""" + c = cfg["nyan_filler"] + if not c["enabled"]: + return "disabled; no-op" + + window = parse_quiet_hours(c["quiet_hours"]) + if in_quiet_hours(now, window): + if not state.get("quiet_cleared"): + if not dry_run: + client.clear(APP) + state["quiet_cleared"] = True + return "quiet hours: released panel" + return "quiet hours: silent" + state["quiet_cleared"] = False + + timeout_s = max(2, int(c["poll_seconds"]) * 2) # self-clears if the poller dies + elements = build_filler_elements(ASSET_NAME, timeout_s) + if dry_run: + return f"DRY-RUN draw @ {PRIORITY_FILLER}: {elements!r}" + result = client.draw(APP, elements, priority=PRIORITY_FILLER) + if result == DrawResult.UNREACHABLE: + return "device unreachable" + return f"nyan @ {PRIORITY_FILLER} -> {result.value}" + + +def main() -> int: + parser = argparse.ArgumentParser(description="BUSY Bar Nyan dark-filler") + parser.add_argument("--once", action="store_true") + parser.add_argument("--dry-run", action="store_true") + args = parser.parse_args() + logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") + + cfg = load_config() + client = BusyBarClient(**device_kwargs(cfg)) + client.clear(APP) # drop any stale element from a previous process + + # Self-healing install: (re)upload the committed asset on startup so the + # device always has it. One ~83 KB POST per process start, never per poll. + if not args.dry_run: + if ASSET_PATH.exists(): + client.upload_asset(APP, ASSET_NAME, ASSET_PATH.read_bytes()) + else: + log.warning("asset %s missing; run `uv run python tools/build_nyan_anim.py`", ASSET_PATH) + + state: dict = {} + backoff = 5 + while True: + summary = run_once(client, cfg, datetime.now(), state, args.dry_run) + log.info(summary) + if args.once: + return 0 + if summary == "device unreachable": + time.sleep(backoff) + backoff = min(backoff * 2, 300) + else: + backoff = 5 + time.sleep(cfg["nyan_filler"]["poll_seconds"]) + + +if __name__ == "__main__": + raise SystemExit(main()) +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `uv run pytest tests/test_nyan_main.py tests/test_client.py -v` +Expected: PASS. + +- [ ] **Step 6: Full-suite check + dry-run smoke test** + +Run: `uv run pytest -q` (all pre-existing + new tests green) +Run: `uv run python -m nyan_filler.main --once --dry-run` (from `integrations/`; prints a DRY-RUN draw line, no device writes) + +- [ ] **Step 7: Commit** + +```bash +git add src/busybar/client.py integrations/nyan_filler/main.py tests/test_nyan_main.py tests/test_client.py +git commit -m "nyan_filler: poll loop with quiet-hours gate and startup asset upload" +``` + +--- + +### Task 6: Packaging — launchd plist, README, config example + +**Files:** +- Create: `integrations/nyan_filler/com.busybar.nyan-filler.plist` +- Create: `integrations/nyan_filler/README.md` +- Modify: `config.example.toml` (add `[nyan_filler]`) + +**Interfaces:** none (packaging/docs). + +- [ ] **Step 1: Create the plist** (mirror `ci_status`'s template, placeholders `__REPO__`/`__UV__`/`__HOME__` are filled by the same install step as the other agents): + +```xml + + + + + Labelcom.busybar.nyan-filler + WorkingDirectory__REPO__/integrations + ProgramArguments + + __UV__ + run + python + -m + nyan_filler.main + + EnvironmentVariables + + PYTHONPATH + __REPO__/src + PATH + /opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin + + RunAtLoad + KeepAlive + ProcessTypeBackground + ThrottleInterval60 + StandardOutPath__HOME__/Library/Logs/busybar/nyan.log + StandardErrorPath__HOME__/Library/Logs/busybar/nyan.log + + +``` + +- [ ] **Step 2: Add the config example** to `config.example.toml`: + +```toml +[nyan_filler] +enabled = true # set false to disable the animation without uninstalling the agent +poll_seconds = 1 # how quickly a dark gap is reclaimed (the draw is tiny; see README) +quiet_hours = "00:00-07:00" # local time; "" disables quiet hours entirely +``` + +- [ ] **Step 3: Write `integrations/nyan_filler/README.md`** covering: what it does (fills black gaps with an on-device Nyan animation at `PRIORITY_FILLER = 5`); that it **never overrides a built-in app** (priority 10) and yields to calendar/CI/alerts/sessions; the config keys; that the animation runs on-device (native `.anim`) so host cost is ~1 tiny draw/sec; how to regenerate the asset (`uv run python tools/build_nyan_anim.py`); and that the agent uploads the asset to the device on startup. Reference `docs/superpowers/specs/2026-08-06-nyan-filler-design.md` for the priority/spike rationale. + +- [ ] **Step 4: Verify the suite still passes** + +Run: `uv run pytest -q` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add integrations/nyan_filler/com.busybar.nyan-filler.plist integrations/nyan_filler/README.md config.example.toml +git commit -m "nyan_filler: launchd agent, README, and config example" +``` + +--- + +### Task 7: On-device verification (operator/primary pass — not a subagent task) + +Requires the live device at `10.0.4.20`. Run after Task 6. This is a manual/primary checklist, not a pytest gate. + +- [ ] **Asset renders:** `uv run python -m nyan_filler.main --once` uploads + draws once; capture `GET /api/screen?display=0` and confirm non-black Nyan pixels. +- [ ] **Fills gaps during a CI run:** with `ci_status` running and a real CI run active, watch the panel — confirm Nyan appears in the ~10 s gaps between CI frames (no black gaps), and the CI frames still preempt it. +- [ ] **Never overrides a built-in app:** put the device on a built-in app (e.g. the clock); confirm Nyan does **not** replace it (priority 5 < 10). +- [ ] **Quiet hours:** temporarily set `quiet_hours` to a window covering "now"; confirm the panel goes dark and stays dark; step past the end; confirm Nyan resumes. +- [ ] **Preemption:** confirm a calendar approach/imminent draw and a CI alert each cleanly take the screen and Nyan resumes after they clear. +- [ ] **Cost sanity:** confirm the process is idle/sleeping between polls (no measurable CPU). + +--- + +## Self-Review + +**1. Spec coverage:** +- §2 scope (new integration, PRIORITY_FILLER, launchd, config, quiet hours) → Tasks 3–6. ✓ +- §5b spikes → encoded into Task 1/2 (encoder+asset), Task 3 (priority), Task 5 (redraw-every-poll). ✓ +- §6 priority 5 + never-override-builtin → Task 3 + Task 7. ✓ +- §7 authoring pipeline (ported seq2anim encoder, raw frames, BGR, committed asset) → Tasks 1–2. ✓ +- §8 loop/redraw policy (unconditional per-poll redraw, timeout ≈ 2×poll, quiet-hours clear-on-entry) → Task 5. ✓ +- §9 config keys/defaults → Task 3 + Task 6. ✓ +- §10 quiet-hours semantics (wrap, empty, start==end, inclusive/exclusive, clear-on-entry) → Task 4 + Task 5. ✓ +- §11 error handling (backoff, 409 normal via DrawResult, asset-missing warning) → Task 5. ✓ +- §13 tests (quiet-hours boundaries, config parse) → Tasks 3–5. ✓ +- §14 on-device verification → Task 7. ✓ + +**2. Placeholder scan:** No TBD/TODO; every code step has runnable code; the on-device steps in Task 7 are explicitly a manual pass, not a pytest gate. ✓ + +**3. Type consistency:** `FILLER_APP`/`ASSET_NAME`/`ELEMENT_ID` defined in Task 4 and reused verbatim in Task 5. `encode_anim`/`parse_header` signatures identical across Tasks 1–2. `run_once(client, cfg, now, state, dry_run=False)` consistent between Task 5 impl and tests. `PRIORITY_FILLER` (Task 3) consumed in Task 5. ✓ diff --git a/docs/superpowers/specs/2026-08-06-nyan-filler-design.md b/docs/superpowers/specs/2026-08-06-nyan-filler-design.md new file mode 100644 index 0000000..7c71ace --- /dev/null +++ b/docs/superpowers/specs/2026-08-06-nyan-filler-design.md @@ -0,0 +1,331 @@ +# Nyan Cat Dark-Filler — Design Spec + +**Date:** 2026-08-06 +**Status:** approved design; all three on-device spikes resolved (see §5b) +**Depends on:** `busybar.display` priority ladder, `src/busybar/client.py`, the existing `ci_status` / `calendar_countdown` integrations (as peers, not as callers) + +--- + +## 1. Goal + +Keep the BUSY Bar's 72×16 panel visually alive instead of black during the dead +gaps that appear while `ci_status` rotates its overlay frames — by drawing a +looping **Nyan Cat** animation at a priority low enough that every real +integration preempts it, and only when the panel would otherwise be dark. + +The animation itself runs **on the device** (via the firmware's native +`AnimationElement`); the host's only job is a lightweight arbitration loop that +re-asserts the frame when the panel goes dark and stays quiet otherwise. + +## 2. Scope & non-goals + +**In scope** +- A new, self-contained integration `integrations/nyan_filler/` (pure `logic.py` + + thin `main.py` loop), mirroring the structure of the existing two. +- A new shared priority tier `PRIORITY_FILLER` in `busybar/display.py`. +- Its own launchd agent `com.busybar.nyan-filler`. +- A `[nyan_filler]` config section. +- A configurable **quiet-hours** window during which the filler stays dark. + +**Non-goals** +- **Not CI-gated.** The filler is "fill whenever the panel is dark," not "fill + only during CI runs." The user chose always-on-when-dark scope specifically + because it needs *zero* coordination with `ci_status` (matching this + codebase's "independent pollers, no cross-process coordination" principle), + and because a low-priority filler already yields to everything automatically. + During a CI run this produces exactly the requested CI-screen↔Nyan + alternation as a side effect. +- **No on-device logic.** The *arbitration* loop cannot run on the device — see + §4. Only the animation *playback* is on-device. +- **No new dependencies.** stdlib + the existing `BusyBarClient`. + +## 3. Background: what the "blank screen" actually is + +`ci_status` draws its overlay frames (running badge, GraphQL/REST quota) at +`PRIORITY_OVERLAY` (21) with a 10 s element `timeout`, then deliberately goes +**silent for ~10 s** so the ambient calendar can reclaim the screen in the gap. +But the firmware **evicts** occluded elements rather than restoring them, so a +gap is only filled if *some* app actively redraws into it. When there is no +imminent calendar event, nothing redraws, and the gap goes black. + +This is measured and documented in `integrations/calendar_countdown/README.md`: +the calendar recovers roughly 3 of 4 gaps *when an event is pending*, and +effectively 0 of 4 when none is. That recurring black gap — alternating ~10 s +frame / ~10 s black — is the "blank screen." The filler's job is to occupy it. + +## 4. Platform investigation: there is no on-device runtime for our logic + +This was investigated directly (device at `10.0.4.20`, its `/openapi.yaml`, the +community app repo, and the vendor's no-code widget article) because it +determines whether any of this can avoid the host. Findings: + +- **The local HTTP API is a remote-control surface.** Every endpoint was read: + there is no app-install, app-run, or task-schedule endpoint. `/api/display/draw` + is the built-in **"Canvas"** app's remote-draw channel. The `install_manifest_*` + codes in the spec are the **firmware-update** state machine, not app install. +- **The community "apps" are host-side.** `maxswinkels/busybar-apps` (source of + the Nyan reference) states its apps are *"host-side Python scripts… not + applications installed on the device itself."* Its `busybar-manager` companion + is a *local* manager that proxies to the device. +- **The no-code "widgets" feature is also host-driven.** The vendor article + ("Make BUSY Bar Widgets Without Coding") describes AI-generated **Python host + scripts** rendering over HTTP; widgets are static images/text, carry no + conditional/scheduling logic, and **do not persist when the host disconnects.** +- **What *is* on-device: playback.** `AnimationElement` with `loop: true` loops + autonomously on the device once drawn — this is the piece we exploit to keep + host cost near zero. + +**Conclusion:** animation playback is on-device; the arbitration loop +(draw-when-dark, quiet-hours) must run on a host. This is inherent to the +platform, not a limitation of the design. The eviction model reinforces it: +after a higher app clears, Nyan is evicted, not restored, so an external actor +must re-assert the draw — nothing on the device will. + +## 5. Architecture + +A third integration, structured exactly like the existing two: + +``` +integrations/nyan_filler/ + __init__.py + logic.py # pure: quiet-hours gate, redraw decision, asset paths + main.py # thin loop: poll, gate, draw/clear, backoff + com.busybar.nyan-filler.plist + README.md +assets/nyan/… # the baked animation asset(s), uploaded to the device once +src/busybar/display.py # + PRIORITY_FILLER +config.example.toml # + [nyan_filler] +tests/test_nyan_logic.py +``` + +No coordination with `ci_status` or `calendar_countdown`. The filler draws low; +the existing priority ladder does all arbitration. + +## 5b. Spike results (2026-08-06, live device, firmware 1.1.1) + +All verified against the bar at `10.0.4.20` via `/api/display/draw` and +framebuffer reads (`/api/screen?display=0`, base64 72×16×3 BGR). + +- **SPK-1 — native `.anim` works.** Uploaded a real 72×16 `.anim` to a test app + and drew it as an `AnimationElement` → HTTP 200, full panel rendered. The + `.anim` "bicycle0" container is compiled from a documented `.zip` + (`frame_N.png` + `meta.json`) via busylib-py's converter (§7). Native + on-device playback confirmed feasible; no per-frame host pushing. +- **SPK-2 — redraw continues (no restart).** With a 1 fps test animation, the + frame showing immediately before a same-element redraw was still showing + immediately after (loop position preserved, no jump to frame 0). → the filler + may redraw every poll unconditionally with no stutter (§8). +- **SPK-3 — priority floor is 0 at rest.** Polled draws across ~40 s during a + live CI run: when the panel was black (idle **and** CI silence gap), a `@1` + draw succeeded **16/16**; while a CI overlay frame was up (≥20) every `@1`/`@11` + draw was rejected `409`. → `PRIORITY_FILLER = 5` fills black gaps and is + rejected by any built-in app (10), so it never overrides one (§6). +- **SPK-4 — Python `bicycle0` encoder works.** A Python port of `seq2anim.ts` + (raw-frame path) parsed the reference `tracks.anim` header correctly + (72×16, rgb888, fps 1, 4 frames, 1 section) and produced a 2-frame test + `.anim` that uploaded, drew, and animated on-panel as RED/BLUE alternating — + correct BGR order and header. Custom-`.anim` generation is validated (§7). + +## 6. Priority & arbitration + +The device documents its own system-app priority tiers (from `/openapi.yaml`, +`priority` field, range 1–100): + +- **0** — stub/poweroff (reserved; draws must use ≥1) +- **10** — any standard built-in app (clock, etc.) +- **90** — active BUSY/CUSTOM work session + +And the empirically-established firmware fact (probed during v1.5, and the +reason the OpenAPI's own "equal priority overrides" text is wrong): **a draw +from a different `application_name` is accepted only at a *strictly greater* +priority than the app currently holding the screen; equal priority is rejected.** + +**SPK-3 resolved on-device (see §5b):** the panel's black/resting state — both +true idle and the CI silence gap — rests at **priority 0** (a `@1` draw won all +16/16 times the panel was black). So the filler does **not** need to sit above +the built-in tier to fill gaps. Chosen: + +```python +PRIORITY_FILLER = 5 # stub (0) < FILLER (5) < built-in apps (10) < AMBIENT (20) +``` + +This satisfies the operator's explicit preference — **never override a built-in +app.** A filler at 5 is rejected by anything at ≥ 5 from another app, so a +built-in app (10) keeps the screen; the filler only wins the genuinely-empty +(priority-0) panel. + +Resulting arbitration (all automatic via strictly-greater-priority): + +| App drawing | Priority | vs. filler (5) | +|---|---|---| +| stub / empty (idle **and** CI gap) | 0 | filler wins → **Nyan fills** | +| **nyan_filler** | **5** | — | +| built-in idle app (clock/desktop) | 10 | preempts Nyan → clock kept, **not overridden** | +| calendar event / CI overlay | 20 / 21 | preempts Nyan | +| calendar approach / raised | 25 | preempts Nyan | +| CI alert (fail/stuck) | 60 | preempts Nyan | +| calendar imminent (urgent) | 65 | preempts Nyan | +| BUSY/CUSTOM work session | 90 | preempts Nyan | + +## 7. Animation asset (native `.anim`, resolved) + +The animation lives on the device as a native `.anim` file and self-loops. Draw: + +```json +{"id": "nyan", "type": "animation", "path": "nyan_72x16.anim", "loop": true, + "x": 0, "y": 0, "timeout": } +``` + +**Authoring pipeline (build-time, no runtime dependency).** The device `.anim` +is the firmware's `bicycle0` container. busylib-py's converter does **not** +support animation (`video.py` raises `NotImplementedError`), so we generate the +`.anim` with a small in-repo encoder **ported from the firmware web draw-tool's +`seq2anim.ts`** (`busy-app/busybar-firmware` `assets/frontend/util/seq2anim.ts`), +validated end-to-end on-device (§5b, SPK-4): + +- **Frame generation** (`tools/gen_nyan_frames.py`, dev tool): render a fixed + ~24-frame Nyan loop as `frame_0.png … frame_n.png` (72×16 RGB), reusing the + reference renderer's geometry (`maxswinkels/busybar-apps` `apps/nyan-cat` + `_blank/_tick_stars/_rainbow/_cat`). Uses Pillow (a dev-only dependency). +- **Encoding** (`tools/anim_encoder.py`, dev tool): PNG frames + meta → `.anim`. + `bicycle0` header (36 bytes: magic, `flags`, `width`, `height`, `color_mode` + `0=rgb888`, `fps`, `max_encoded_len` u16, pad, `sections_len`/`frames_len`/ + `n_sections`/`n_encoded`/`n_display` u32×5), a `default` section, then frames. + Pixels are packed **BGR** (RGBA→BGR). Frames use **`encoding=0` (raw)** — the + format's uncompressed path — so no RLE is needed (~83 KB for 24 frames, far + under the 2 MB stock sizes); RLE is a possible future size optimization only. +- The resulting `assets/nyan/nyan_72x16.anim` (and its source frames) are + committed. The shipped integration uses stdlib + `BusyBarClient` only; + Pillow/the encoder are dev tools, not runtime deps. + +**Install step:** upload the `.anim` once per device via +`POST /api/assets/upload?application_name=nyan_filler&file=nyan_72x16.anim` +(documented in the integration README; the loop tolerates a missing asset — §11). + +The three spikes that gated this are all resolved on-device — see §5b. In +particular `AnimationElement` rendering, the `.anim` upload, redraw-continues, +and the priority floor were each verified against the live device. + +**Fallback (now unlikely, kept for completeness):** if a custom `.anim` proves +impractical to generate, pre-stage a fixed loop of PNG frames and play them with +draw-only `ImageElement` swaps at ~6–8 fps — still far cheaper than the +reference app's per-frame encode+upload. Not needed given SPK-1 succeeded. + +## 8. Filler loop & redraw policy + +`main.py` loop, one wake per `poll_seconds`: + +1. If in a **quiet-hours** window → ensure the panel is released (`client.clear("nyan_filler")` + once, on entry) and sleep. No HTTP draw. +2. Else → **reclaim if dark.** Attempt/maintain the Nyan draw at `PRIORITY_FILLER`. + - `DRAWN` → Nyan is up and self-looping on-device. + - `409` (something higher owns the screen) → expected and silent. + - device unreachable → back off (5 s → ×2 → cap 300 s), same pattern as `ci_status`. + +**Redraw-without-stutter — settled by SPK-2 (redraw continues):** the loop +redraws every tick **unconditionally**. When Nyan is already playing, the +same-element redraw continues the on-device loop seamlessly (no restart); when +preempted it 409s (harmless); when a black gap opens the redraw lands and Nyan +appears within one poll. No framebuffer-read gating is needed. Draw with +`loop: true` and `timeout ≈ 2 × poll_seconds` (so a dead poller's frame +self-clears quickly, per the ambient-tier convention) — the every-tick redraw +refreshes it long before it expires. + +The pure decision (the quiet-hours gate) lives in `logic.py` and is unit-tested; +the HTTP calls stay in `main.py`. + +## 9. Config — `[nyan_filler]` + +```toml +[nyan_filler] +enabled = true +poll_seconds = 1 # how quickly a dark gap is reclaimed (≤ this many seconds) +quiet_hours = "00:00-07:00" # local time; "" disables quiet hours entirely +# opacity = 100 # optional AnimationElement opacity (0–100) +``` + +Defaults chosen by the operator: `quiet_hours = "00:00-07:00"`, `poll_seconds = 1`. +`enabled = false` makes the agent a no-op (clears once and idles) without +uninstalling it. + +## 10. Quiet-hours semantics + +- Parsed as `"HH:MM-HH:MM"` in the host's **local** time. +- **Midnight-wrap supported:** `"23:00-07:00"` means 23:00→07:00 crossing + midnight; `"00:00-07:00"` is the simple same-day case. A window whose start == + end is treated as "never quiet" (not "always quiet"). +- Empty string `""` disables quiet hours (always eligible to fill). +- On **entering** a quiet window the loop issues one `clear` so the last Nyan + frame doesn't persist frozen; thereafter it stays silent until the window ends. +- Boundary rule: start is inclusive, end is exclusive (`start ≤ now < end`), so + a `07:00` end means the filler is eligible again exactly at 07:00. + +## 11. Error handling + +- **Device unreachable:** exponential backoff (5 s → 300 s cap), identical to + `ci_status`; the summary line ends `unreachable` so `main` distinguishes it. +- **409 low-priority:** normal operation (something real is on screen); logged at + debug, not warning. +- **Asset missing** (draw returns an asset error): log a single warning naming the + expected asset path and the upload command; keep looping (the panel just stays + whatever it was). The one-time asset upload is an install step, documented in + the integration README, not done on every boot. +- Never print `config.toml` contents (consistent with the repo-wide rule). + +## 12. Machine-cost analysis (why `poll_seconds = 1` is fine) + +The loop is a **mostly-sleeping process**. Per second it does exactly one thing: +one ~200-byte animation draw (which either lands as DRAWN or is rejected 409 — +both tiny), or — during quiet hours — a clock check with **no** HTTP call. No +framebuffer reads (SPK-2 made them unnecessary). Worst case ≈ 1 small HTTP +round-trip/second to a **local USB-Ethernet device**. + +For comparison, the reference Nyan app pushes ~25 requests/second (full-frame +PNG encode + upload each). This design is ~1–2 orders of magnitude lighter and +lighter than the two agents already running (`ci_status`, `calendar_countdown`). +`poll_seconds = 1` does not materially affect the machine; it is accepted as the +default. (It remains a knob: a larger value reduces calls further at the cost of +slower gap reclaim — the operator preferred fast reclaim.) + +## 13. Testing + +Pure-logic unit tests (`tests/test_nyan_logic.py`) — the part that carries risk: +- Quiet-hours gate: same-day window, midnight-wrap window, empty window, + start==end, and the inclusive-start/exclusive-end boundaries. +- Config parsing/validation for the `[nyan_filler]` section (bad `quiet_hours` + string → clear error, not a crash). + +Device-facing code (`main.py` HTTP calls, the asset upload) stays thin and is +exercised by the on-device verification pass, matching how the other two +integrations are tested. `PRIORITY_FILLER` selection is device-behavioral and +verified in SPK-3, not unit-tested. + +## 14. On-device verification plan + +1. **SPK-1/2/3** as above (format, redraw semantics, priority). +2. **Alternation:** during a real CI run, confirm the panel shows + badge → Nyan → quota → Nyan → … with no black gaps, ~10 s per frame. +3. **Quiet hours:** set a window covering "now," confirm the panel goes dark and + stays dark; step past the end, confirm Nyan resumes. +4. **Preemption:** confirm a calendar approach/imminent draw and a CI alert each + cleanly take the screen from Nyan, and Nyan resumes after they clear. +5. Frame-capture check that Nyan's ink is present and the asset renders at 0,0 + without clipping (reuse the existing capture harness). + +## 15. Open questions / risks + +- **All three spikes are resolved (§5b)** — native `.anim` renders, redraw + continues, priority floor is 0. No open feasibility risk remains. +- **Frame authoring quality** is the remaining craft item: baking a ~24-frame + Nyan loop that reads well and loops without an obvious seam (the reference + renderer uses random star positions, so a baked loop won't wrap perfectly — + acceptable for a decorative filler; tune frame count during implementation). +- **Quiet-hours uses host local time**, which follows the Mac's timezone; no DST + handling beyond what the OS clock provides (acceptable — it's decorative). + +## 16. Out of scope / future + +- Multiple/selectable animations, or sourcing other `busybar-apps` effects. +- Reacting to device state (brightness, session) beyond priority arbitration. +- Running the loop on a homelab host instead of the Mac (possible only once the + bar is LAN-reachable rather than USB-tethered; noted, not built). From 7da9e5eeaaf09cd108e15e2d7ffc4ff10499145b Mon Sep 17 00:00:00 2001 From: John Osumi <931193+sumitake@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:09:50 -0700 Subject: [PATCH 02/13] tools: add bicycle0 .anim encoder (ported from seq2anim.ts) --- tests/test_anim_encoder.py | 37 +++++++++++++++++ tools/__init__.py | 0 tools/anim_encoder.py | 83 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 120 insertions(+) create mode 100644 tests/test_anim_encoder.py create mode 100644 tools/__init__.py create mode 100644 tools/anim_encoder.py diff --git a/tests/test_anim_encoder.py b/tests/test_anim_encoder.py new file mode 100644 index 0000000..c7aeba9 --- /dev/null +++ b/tests/test_anim_encoder.py @@ -0,0 +1,37 @@ +import struct +from tools.anim_encoder import encode_anim, parse_header + +RED_BGR = bytes([0, 0, 255]) # BGR packing of RGB red +BLUE_BGR = bytes([255, 0, 0]) # BGR packing of RGB blue + +def _solid(color: bytes, w=72, h=16) -> bytes: + return color * (w * h) + +def test_header_fields_and_counts(): + data = encode_anim([_solid(RED_BGR), _solid(BLUE_BGR)], 72, 16, fps=2) + h = parse_header(data) + assert h["magic"] == b"bicycle0" + assert (h["width"], h["height"]) == (72, 16) + assert h["color_mode"] == 0 # rgb888 + assert h["fps"] == 2 + assert h["n_display"] == 2 # two display frames + assert h["n_encoded"] == 2 # two distinct encoded frames + assert h["n_sections"] == 1 # the implicit "default" section + +def test_consecutive_identical_frames_dedup(): + data = encode_anim([_solid(RED_BGR)] * 3, 72, 16, fps=1) + h = parse_header(data) + assert h["n_display"] == 3 # three display frames... + assert h["n_encoded"] == 1 # ...collapsed to one encoded frame + +def test_first_frame_pixels_roundtrip(): + # The first raw frame's bytes must be the exact BGR payload we passed in. + frame = _solid(RED_BGR) + data = encode_anim([frame, _solid(BLUE_BGR)], 72, 16, fps=2) + # frames start after header(36) + sections chunk; the default section is + # 13 + len("default") + 1 = 21 bytes -> frames at offset 57. + off = 36 + 21 + encoding, duration, length = data[off], data[off+1], struct.unpack_from(" bytes: + if not frames_bgr: + raise ValueError("at least one frame required") + expected = width * height * (3 if color_mode == "rgb888" else 1) + for i, f in enumerate(frames_bgr): + if len(f) != expected: + raise ValueError(f"frame {i}: got {len(f)} bytes, expected {expected}") + + # Collapse consecutive identical frames into one encoded frame (duration++). + enc: list[list] = [] # [encoding, duration, data] + last = None + for f in frames_bgr: + if last is not None and f == last: + enc[-1][1] += 1 + continue + last = f + enc.append([0, 1, f]) # encoding=0 (raw), duration=1 + + frames_chunk_len = sum(4 + len(e[2]) for e in enc) + max_encoded_len = max(len(e[2]) for e in enc) + + n = len(frames_bgr) + all_sections: list[tuple[int, int, str]] = [(0, n - 1, "default")] + for s in (sections or []): + if s[2] == "default": + raise ValueError('section name "default" is reserved') + all_sections.append(s) + sections_chunk_len = sum(13 + len(name.encode()) + 1 for _, _, name in all_sections) + + # Map each display-frame index -> (byte offset of its encoded frame, remaining duration). + disp: list[tuple[int, int]] = [] + off = HEADER_LENGTH + sections_chunk_len + for _, dur, data in enc: + for d in range(dur, 0, -1): + disp.append((off, d)) + off += 4 + len(data) + + out = bytearray(b"bicycle0") + out += bytes([0, width, height, 0 if color_mode == "rgb888" else 1]) + out += bytes([fps]) + out += struct.pack(" dict: + if data[:8] != b"bicycle0": + raise ValueError("bad magic") + scl, fcl, n_sections, n_encoded, n_display = struct.unpack_from(" Date: Thu, 6 Aug 2026 21:15:48 -0700 Subject: [PATCH 03/13] assets: generate committed Nyan .anim (24-frame loop, 12fps) --- assets/nyan/frames/frame_0.png | Bin 0 -> 381 bytes assets/nyan/frames/frame_1.png | Bin 0 -> 368 bytes assets/nyan/frames/frame_10.png | Bin 0 -> 344 bytes assets/nyan/frames/frame_11.png | Bin 0 -> 358 bytes assets/nyan/frames/frame_12.png | Bin 0 -> 375 bytes assets/nyan/frames/frame_13.png | Bin 0 -> 364 bytes assets/nyan/frames/frame_14.png | Bin 0 -> 374 bytes assets/nyan/frames/frame_15.png | Bin 0 -> 335 bytes assets/nyan/frames/frame_16.png | Bin 0 -> 342 bytes assets/nyan/frames/frame_17.png | Bin 0 -> 339 bytes assets/nyan/frames/frame_18.png | Bin 0 -> 368 bytes assets/nyan/frames/frame_19.png | Bin 0 -> 380 bytes assets/nyan/frames/frame_2.png | Bin 0 -> 374 bytes assets/nyan/frames/frame_20.png | Bin 0 -> 372 bytes assets/nyan/frames/frame_21.png | Bin 0 -> 329 bytes assets/nyan/frames/frame_22.png | Bin 0 -> 329 bytes assets/nyan/frames/frame_23.png | Bin 0 -> 329 bytes assets/nyan/frames/frame_3.png | Bin 0 -> 349 bytes assets/nyan/frames/frame_4.png | Bin 0 -> 350 bytes assets/nyan/frames/frame_5.png | Bin 0 -> 346 bytes assets/nyan/frames/frame_6.png | Bin 0 -> 383 bytes assets/nyan/frames/frame_7.png | Bin 0 -> 380 bytes assets/nyan/frames/frame_8.png | Bin 0 -> 372 bytes assets/nyan/frames/frame_9.png | Bin 0 -> 333 bytes assets/nyan/meta.json | 5 ++ assets/nyan/nyan_72x16.anim | Bin 0 -> 76177 bytes pyproject.toml | 2 +- tests/test_nyan_asset.py | 14 +++++ tools/build_nyan_anim.py | 95 ++++++++++++++++++++++++++++++++ 29 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 assets/nyan/frames/frame_0.png create mode 100644 assets/nyan/frames/frame_1.png create mode 100644 assets/nyan/frames/frame_10.png create mode 100644 assets/nyan/frames/frame_11.png create mode 100644 assets/nyan/frames/frame_12.png create mode 100644 assets/nyan/frames/frame_13.png create mode 100644 assets/nyan/frames/frame_14.png create mode 100644 assets/nyan/frames/frame_15.png create mode 100644 assets/nyan/frames/frame_16.png create mode 100644 assets/nyan/frames/frame_17.png create mode 100644 assets/nyan/frames/frame_18.png create mode 100644 assets/nyan/frames/frame_19.png create mode 100644 assets/nyan/frames/frame_2.png create mode 100644 assets/nyan/frames/frame_20.png create mode 100644 assets/nyan/frames/frame_21.png create mode 100644 assets/nyan/frames/frame_22.png create mode 100644 assets/nyan/frames/frame_23.png create mode 100644 assets/nyan/frames/frame_3.png create mode 100644 assets/nyan/frames/frame_4.png create mode 100644 assets/nyan/frames/frame_5.png create mode 100644 assets/nyan/frames/frame_6.png create mode 100644 assets/nyan/frames/frame_7.png create mode 100644 assets/nyan/frames/frame_8.png create mode 100644 assets/nyan/frames/frame_9.png create mode 100644 assets/nyan/meta.json create mode 100644 assets/nyan/nyan_72x16.anim create mode 100644 tests/test_nyan_asset.py create mode 100644 tools/build_nyan_anim.py diff --git a/assets/nyan/frames/frame_0.png b/assets/nyan/frames/frame_0.png new file mode 100644 index 0000000000000000000000000000000000000000..4878723a4d78201fca33da0797f38405cfb03327 GIT binary patch literal 381 zcmV-@0fPRCP)Gt;{$X)$$AjI8X<&dyq4*0 zBr#hC!)UwEM@;x7IfP&Z-(=K=fj?LhKi46G5&ncMVi)X{lu{In3?AQldn80Pl z@1j(7)k_+7;7di`)L7|Tb}Vc2bxxkHF(kl>zk}}C*e$An zn>#yDWD3l7Am>lX^mv%6;o3{pvoHXM94}|CZ bdOU(}WPO~ExDMkH00000NkvXXu0mjfz-F=u literal 0 HcmV?d00001 diff --git a/assets/nyan/frames/frame_1.png b/assets/nyan/frames/frame_1.png new file mode 100644 index 0000000000000000000000000000000000000000..607966f2a42bda41fdbffed08e7fd22fbccdfc5d GIT binary patch literal 368 zcmV-$0gwKPP)TG=4u{Sg@fX|BG#9Gx|Z|TRC z0%ZO_%nv5twybyU$0n!+UkdW3#@4=N$FeqG=H%rXLjtV$J80gG=kLP=cA&@x z;cbO541|3WlXL}`$sVCIbOdK6vO-sJMe{^Mh{KDN7FF;2703~MTpWmA95bVT1-uFC ze4tnV+u)WU{=n_z&Zu)=2I-I3>OD^(63^QJ%gDpFtC%m7SoK^{Z&a&i^fUm|SY zh;4m%Q`PoaU}k<|-iyK@{fW=;@y74oY@k4~Whnt5C5Bs0JK!R*-Q?!SN1WZ*>aoSS zzYfy3DWVgRJS#a~R++qlJjUva`n=s($n?rpE3p{rdvslF7-I|a(n?~j*=BC-G7h(0000a`HKxNn**T=+yUx<7dLlgBV~JIcIoJd>pEJ zQ*9V+1cQnF(}l^HAJB-@_sc8r1#qBMrTgEQTD2&Cx( z$24Kr1>FQ6T+pZAZHPWZFW&*@`xIHZb?lb1-THU<1rrvV7s!SPQ~&?~07*qoM6N<$ Ef@JQWkpKVy literal 0 HcmV?d00001 diff --git a/assets/nyan/frames/frame_12.png b/assets/nyan/frames/frame_12.png new file mode 100644 index 0000000000000000000000000000000000000000..bb6e93bfdc54863fa75839a4b1eef549f2ebb369 GIT binary patch literal 375 zcmV--0f_#IP)3n2u0b8uf0 z^O5i_U}OyvWWwiv6!xd6g@suW*)QbAb=4$kP*p`@H-ykktAWslznhdW;Y+1zabSIv z7PXNJ^R7)*9~R31cY`Nr#IJDk*Kc=0vz|OHg{0i8+Bb&xp9ka4fX}>#xTjO)SP~ia zV92`sq+^}bFyVJ`uuepBuO#cD8siZ-HiKVIWWZm3BH7}Lcf;yfosBO$_Jt-U@LBPj z$W`6^b!)g%fUNC@`N5=Z%SP9}Z5D6Amx6q#vD~-hSkk6tPM)qYB*2Q_LGy0R^*jT1 zpvXKh+ku=v#rLVmo_nC15Nub^F(kl>-;92XHGc^XT`i|N80y2@14T|6@jsx~a0kj1 Vo{=H>MH~PC002ovPDHLkV1mejsn!4h literal 0 HcmV?d00001 diff --git a/assets/nyan/frames/frame_13.png b/assets/nyan/frames/frame_13.png new file mode 100644 index 0000000000000000000000000000000000000000..46bac676fcf756deaec0e49a8d728d1bcf6e26a6 GIT binary patch literal 364 zcmV-y0h9iTP)U7SjeqELfgl>ptb zH+hIoRr9QLds&4?kk||+Ow>W|@25vx-*PsriIvrOII%Z0Fo9n$eU;qR)h@9iTLZeb zck6@6)gv3F{<87bL$AE~81RcIdXGCCwi0000< KMNUMnLSTY0T%->G literal 0 HcmV?d00001 diff --git a/assets/nyan/frames/frame_14.png b/assets/nyan/frames/frame_14.png new file mode 100644 index 0000000000000000000000000000000000000000..00ac6a56184ee2942601955d3dec0c4fc166cbe5 GIT binary patch literal 374 zcmV-+0g3*JP)jbGuR_g}uDs*;i;a(YHpj3UaSf_d)1Hzs5$I2YqoJC;NSJs5)1h#lLB zMi>4r3hP89H%hj{CWZ+VW-#B2FCulDL1`>x*b`~|L4^tYdhuf*%iA$m_0aqBz}v`4 zd_K&FA=^G=Be$E4(++&ktE%9e)BKF(zNIskwApPYFAIAw#GJhDpT0Z{k8NQJ%x3UM zm-y;}Fz@~9+irtyLJk@I06-`A$;1!~#LRIhk1>}YJVLw6i&u77CAy#gv z)xR5#r<_`;k1;ZHBVz`hYXkkd#yFz=r^%Gl`d~L=}wJKUddSS?HTQbo9H*mUCkzih_e>y?OOK2%VcMSOgR;r zLQ-Tb@7tWQIZn2m$?A>?_#iHA^iMc4jk~b)Cv?mMSN)(j(VZXk;j*hZqBMVVD2_002ovPDHLkV1g{0nC}1p literal 0 HcmV?d00001 diff --git a/assets/nyan/frames/frame_16.png b/assets/nyan/frames/frame_16.png new file mode 100644 index 0000000000000000000000000000000000000000..29b294f5b592683f04e98c978bf62b0a6a853eb5 GIT binary patch literal 342 zcmV-c0jd6pP)J>Cz^_egx7$S6RLXOFCZ_(5BmwCf8Gm53x0CL zS^Zzbe#xoFWbV$)y^R@ot{wDsu|A{y6e7uZ-!(noWoD|Vl!XBngy^Ez%2gEnim-Si z@%iD+tX;$aSI|MBlfL2>e!k=ztp*Ad4_QjEUlFn*ICaNXlZzjW;%S5(o51tAlm0bD z>r^%Gm2Piq+us@@ta2Ne9xvSNr5OLNbyru#6XUEJQx_gWg3OKj_0}C(wsD ot1sZ`k)l*?eqG$!cIo5z1r-d67zA2PQvd(}07*qoM6N<$f(}=h&;S4c literal 0 HcmV?d00001 diff --git a/assets/nyan/frames/frame_17.png b/assets/nyan/frames/frame_17.png new file mode 100644 index 0000000000000000000000000000000000000000..2a754621176e7842c306df5172eb60f26e88b0e9 GIT binary patch literal 339 zcmV-Z0j&OsP)f5Hi4N`e^_2%of(#BtVU zW^Xu#3V9>`3m*RUA)jd1$tfI?6k}v&j(3ENdB?FVa?*pLcp9N&19(3+;%}p}PE|{< zbop3$mDU|<2%#;%>JLq0MKdsvS=E+f!PjTB7jD8|MX4%_6e2Dx(x0{9@Xi?{yCY=E zsnHaYDr2Q@Ib%6awwy^+0Xin&hq$!iZ{Wx@?t-Pi!7&ef>VjT{?p)A^YbT%|ah6|! lhdD*5-Ev($+H&j9;TyPzj2>ixQUm}1002ovPDHLkV1mB8l2rfz literal 0 HcmV?d00001 diff --git a/assets/nyan/frames/frame_18.png b/assets/nyan/frames/frame_18.png new file mode 100644 index 0000000000000000000000000000000000000000..d327a588c9d8dbeb585ddd451763f4b20c18b6ae GIT binary patch literal 368 zcmV-$0gwKPP)x5>g%nE-z`_N|oLzKlnIDKF%1T7ycJqL>pk; zgpbI7A?#Pt3X8KxWDUgQ8Z}8?^xlg+rY#0SKSWU`d}&k#Mkx~MqK!Dtn>O#^DP#=8P{jQ}%+6>b$O0!QKCahH}>s&r5J2;rId@r)sQ;eUZWngHLY z@Dcehg#9W?VcvUY_Par3>qyokYSaAD7^C_%Y_Skp5JgSl*W{qe2}+U325lsMPuh%u zyO6QKna8m|;~!p?!>hf#%LgJ`vgDqU6v6f4)k=NH1U_?0QB{LHXYU-#B7+_b#np%% z8IfN+G-p-CX%+Z)%J#BHt@{i zcTuXk+Dpp5E;&8krO?xNj7;CMRr;5WvorY0gRWTmmL1F5OkI!1fBXUdblbGki9$a|4N@j!j3VE|8Xchzq9_->RH{-yDH7?Vje?Dfwiw|q z6m)RqIQD1!3U|G|hYz%>q?Arc%3#>c3g&|YpLt0!ms9On5jp9>5L}Jev7ORz;qT(X zIuTiWCEH;W+ylo(5HOGfKYg7ZIlt*_m>sLD@oC52(82&7FMbniRd>DRm#`He^ZQ|a zFvYfIeP~}cNj>? UTkT<3zW@LL07*qoM6N<$f~(l5b^rhX literal 0 HcmV?d00001 diff --git a/assets/nyan/frames/frame_20.png b/assets/nyan/frames/frame_20.png new file mode 100644 index 0000000000000000000000000000000000000000..657565bd1f7e87dc9ff0501947b26af1a3b0c962 GIT binary patch literal 372 zcmV-)0gL{LP)oRItwmeCM_lQ#lqMmVO6p}3ZUm0aAYdW`{_@pdDZh0#M8}TR*msPX%U&(_`zr@$vWalpECgjNlP1gii@tfJFT+5qaxAgT<2gC8_ZHXc$jrcFn_wWheF_M;M Slpp;70000uhDEquz2B#!so z%e zW{=HwvSm+JS4_Z#xU|u4aHJb|Vd+n(muhDEquz2B#!so z%e zW{=HwvSm+JS4_Z#xU|u4aHJb|Vd+n(muhDEquz2B#!so z%e zW{=HwvSm+JS4_Z#xU|u4aHJb|Vd+n(mm80#0{+9eXB{yszlXAIoeLi{;GkDe#zN!nq+-FGcBQ{7s+fe9fL;p;+` z7rcZUw2_4Vqs`1NIEDhbLi`0@?s=C7nswyJACeTo)!)NoRNirH5;^F>P+X1Bu@2nt z3-Py+S*NP0S3193MS~JTD9g|CL)Dnk>`IS$w_c;Ia1s70QdN^kA>za$9mlfQyiE3w zCR2`y#wN*nEcI>bv8kPGUX!OC6R;sp%J4UEq#Ad@(*D6Q=lRqDy$anqppVBMzxU8a vEcp=!H;%hc7sYhbadv6*sRF3~>v08d{2rANY32qh00000NkvXXu0mjfZ!VpK literal 0 HcmV?d00001 diff --git a/assets/nyan/frames/frame_4.png b/assets/nyan/frames/frame_4.png new file mode 100644 index 0000000000000000000000000000000000000000..9dde8f3a0059a55d8065f4154724c68067491dac GIT binary patch literal 350 zcmV-k0iphhP)v4oI41}c_DoG1alT<=#NC{F+xP`Rh4Dk&yhUIY1oWEZNFSZs*Jc#Zj#z>vu_gG@S zYa;~QFIHYqtG^rWQY&w8JHQzO=e3c3OPta565Qp1Ts$N98Cy**elUuw5oT-x=jTTH zbBffdYMv|I9v(8FfDmoR literal 0 HcmV?d00001 diff --git a/assets/nyan/frames/frame_5.png b/assets/nyan/frames/frame_5.png new file mode 100644 index 0000000000000000000000000000000000000000..fb36af4796d448eabd0fd65b650b2102809a4dbf GIT binary patch literal 346 zcmV-g0j2(lP)O;QP|AtgvPnHJKDb{sYYSYJP8$@(KXU&35KLXzze{yBoXuiCGN z(1f2{a901@&?UF}f@lXgqu{x8(%1Nm)(4A$$Hk!OXD?M1+1h%54k5beI~mJCF7aRlS0l{W1fI{G^xHJ4 z6Ol4kQV%okPTzMnAcqir^^Je18XK8D(__)rYjkAlqF<$qDhJ6SP>~|}vbk>F{4;$@qv-$>_?p>7JE$wEg_0*r^2I@hT5?m=Qe*gdg07*qoM6N<$f(pf$Pyhe` literal 0 HcmV?d00001 diff --git a/assets/nyan/frames/frame_6.png b/assets/nyan/frames/frame_6.png new file mode 100644 index 0000000000000000000000000000000000000000..16f2f445e81af1e2173cbbe8e37c4b320f205e68 GIT binary patch literal 383 zcmV-_0f7FAP)Uw2)S~vB_~5SjS1oO&;0t!@v@2SxJQGgPRB;(A$7f zEDYwm@DcexguN8CurNDBHcv9HyCzMO&N|Z$d!^f96FdUPX0TzR4t)RHJz{)IZ&)3xv+;;yAq3{KAAO-c6L@;@o5)pN>>OWY zn{v9o=lM~qS{tuxTQ*Ajvhmh~uN!hvW4Uk1v82sqNhl&oRpA>H_F81Zmj5f>P`iJ) zd9Z*YGtdkKeCRH|r6N0aLGP;ytr~+}5eewUZ$=+u&2NHY=(bBcH8vE7m()WitHX}C dm;V7hh98l>o%%9(hYA1y002ovPDHLkV1gaRt9t+d literal 0 HcmV?d00001 diff --git a/assets/nyan/frames/frame_7.png b/assets/nyan/frames/frame_7.png new file mode 100644 index 0000000000000000000000000000000000000000..cb566ebbce2f2ee4ea03a42b2618b8e39046fa55 GIT binary patch literal 380 zcmV-?0fYXDP)`)D@N;;Y=}-Hgh1EB%|z7& zwK2+w!Mq6{k^e*3pQ02NdXLCDkZY%?PST|JUgURJq9OD_6lKDfLe=0?0PDJKjNj|KCTi{p^0y;9_51-Q`<~N=VlVfEye(l%|Ep*_q;#V(*zE845vUr?5U%%o aeiARYMWKm(9XG200000oQ>GA zjnpvVZ(?Pgh%CL5yjumgz_A`I=*WOSysAfxZ>$ZIV`Vm8cI*pHbl|h%SFu#}(0gpS zT!2jN-TYwkb<0{;e{2S|;7dkc-La)_$+4u(=Q(-$jv)ai{1C#%|4Ows9 S=Vpxn0000qB+r!%LAfIkCWQ(S0FEmoo55lKQYGY_%&fP z5gQk@>VF%)2cx(ep<@$xKX=maQ&^{} zxmUV&_%TB3wXM9Q3^L7 f7mv1``g42%*IbDee_!g|{R{{HRPZ*OmZ{{44v-^u%L zy^4}ljzwiF`<4=G2=gqq-BZ*StmVPb!Ne@mZv5lOIQ7>e(42<-RI z^eGhNApsJm$El~!5k@Jg+0Gt?^7_xG|E27dMSSgZgppj{>61}QgT*-%zg|cJRnf$H z8OKNmJ%J237|yBwGhS9WWg&1K9rFW|-hBV`mOn`y^I%Cb65k+fXxsf|Wpccb6xuCK z_D*p!z7_rk#PKpZVkpNoU|BAVPP5;|)6yjG@1Nh&Cn@5mG+3NN@eYT(zp!dZM~^bk zeY_a{68|wTtQxY2_Q9V1{^_UuN$QvfOOlaz$4iD%GWv{sR!>ZV9*UFkt?(lNwH14` z#dGjc%ya5teA$7c@h;B+_=AsJlJeofbzXk1TN8c+4R$NmyW+m)q~p2Jz6&1sOl_VL8?{Nrh+>f2DYoa(M zOpn7=vd;t~#%rn{&M&6&!WIgtDU0~p=LjR#i!)v&@ovWr^)il;4tfF^a4?)x{b#(a zUdlq?Iy&YDCcR0u%7~^~l8nSR2pif~FDsMdg{07KX|i|9t3qqB<)D~*=nNm9Zg8<1% zd1ZL2f)idV_>Cjj;!#@ZM={T-hf!b$j>fw@2jCAra!Fod`1Bg=t5wmp@-xrN+HrQy zl6aQ9(9dvA^?Uqup}#bkn<}PBruusf+H=IlS6fD1>*H&mBaB!t&Ulr?yB)Xn^w#?t z$4Cb~febhp&Z+)0UPe$^2wX?U{J^9)nVg`!n1;DD*e9FWtqS2fyfm0iku#_!VcMg@ z?=a|kX!jHvcw0r=<-sBA#VKE=VHn>m9s#I*vM)zUPo%-5w^V*{T5k8SI!cDLcRh%| z<)jE0Z8;W)Xy20d31hyR220EQ=o2cW^zku>Fs;JS9mNYTcGxsO!!y-Cjm@5z;AcJk z(Dz-EB#+-uuhz*PR`~rM<5lACX&v+&(tqgsz8EFn@3Eeq@fwQHc$x0PUdnhWJACbP zgb~9t)!(Ct^)ik~B-(wNenjzCXf5{rSPvocvXUtm2}e>k?9=oksx29>3V(yJp=}z> z%H((GK{Cy@E5v`JIjH=*v{ta!l z29vFm;b*;jO+4X+-Td7eZ0iS@_L%7xT6iJ3>(Fj4TMQ+*&BRR_Y@x>RUmA?f0QcME zn;|8m&+R7kZ+2{Iq#8+_X|Om;;?v~amAMWq?BFAd88w!*_yFT#G5hTvzKB-d1z23u#KpJ&%|U`J(b$6{O>Oy-Sh6{bll z{AcyDHqkd4{kTulk0`GZO~tiAK5spQgX_?`4J0H-n;$cEJ4=L)$cAOcOBZzWs9MdwN>~?g9$YYqxhN9sL*W3yl|bz+bY_|LXD0W z!#~sS@GLYh4Yrmo&djTLE)8}|rSaCiC4O=Y>FMcNOX6wvx(^u6seTXtWKCRGOgsj6 zHT3WHT&+d4*?#)Pa(grGVYWRlMu}Eod`tWpFKjiCTHKP=)8_~y*3&cmCGmYz9_wWs z8L4w>4Chq;8854s;YWhYrMo^tk-$MU2Gfug+N_;_L;Gb~CEs5tFNVLuPk33G#L!D| zB`z!T-pV)o&fo0SXs!4B-%)I|ws&`b+07;Fwt^C^f|WvB@=~qBG)aXYc?llJ3#(?l zQUmiin|)70`7#Z|_-64AgA9cleWsM4g051Uobt->R0SuzR`BXq9u|W`v@PHk!?-gF zEXioR%Y%T?mM|Qm?J~T|utbAdnV)Iagp@u$9x0|t7}An>mb}oar;ZR%G_xRf92qUIR7_X9ew==b#-bh)S80nxVkO2q7In{s0 z%Lpn9f$Qj)ADHwe)hZ98!FoPzM0r(cEtb5Ue5*pZ4y9hNtijs!B=1y{Fzr#{7aY>1 z7#dvwlqhG?)}obqKFhVjkfBaD-MIZ^@*7PE z+Lq+Nu>2OoC|H0qM&sS#2pDY%!y(!(LpqGS(y&Kzeu)NqzUdG~`1q)WWSWHGEs1Bz z3;hh|RKLf6vesW3%qEO!lBs?>`)XVoO!qK*?Wd<7P1RZ~x!ZBGo?bge+l-Bi7(&4O zJsB7=Il&;6#JBTrXd5q+%GUlOooKf-**nE~X|Ux>r=jo7-26JNf>pB`*&*f;!<^Be znuKYO5aU`a;fT^**QVq44foz)D=!l^l*MMc6F*?n@^EZ}^SBbx2+r*)}4zX%TM<>HPVyGv5r8t>Z z8PQrxqo2he^TMhji)g2e)X6l)RDZ-HC8N*CXZ6G+=wbLV2JMn~OlY>k*CE>C!6Djq z>$|9ZI2yknImcpnruyel>9P|1tatzL8f?2|82+jLZeoxA+*kH# zUECMr1`Q^p^zp{KI4Tg)GcjCpJN{huVujSPxbF87R&XBLdrO5vDj;$rXNxK zmj+`q!2NA%w?Z_nVr8=HkX1_kElu`*X|POnq*CP6Df5dH?(Q$Fn$^onVjeNf84Zfz z(qL=`xIa!W6=3>zAFxQxj!Do{qNyYa=0rQqj{#;YULB$>9vq@=$G(tps=>DX`?;oM z>$bEtc^+z{*QW$W<7uLz4;Y@Q{vzyt!4x9rR!te;rc% zOle%-gRz;R|Bq%XlU;{cooHjdynSh~OM{_G$!0^Y8NI0V;)(0VmtTjemYD7I_rkh` z3A_9m@2k}llc0xDz!9&>;%WA} z4;ap=eh)ui4Rw{2m18yV81dEuUE70{LLQ2&%TElif^>GcUi_GBlXzjXNQ9AOmGd=HYXTobmzHtOwJjyBkDCRl! zFly|;(Ri2V0Q|v6F3C#_nFf0r^0;%O{m2BFCSjaP;#u-SKf^iI@A03k^<0DbL^fu< z`=!Cy%pCY_R#!uN4$~*tbBL{LeSGb6gb~vu3{^?I+j09IQMee#NC!QE3^*9hss1xw zRx)KFa2*}<1C!poG#H%$rZ&01P3=~Na2?vExCY3|yw|JQA6kP+4m;{`Ce;DG7`T+SXx7Uw0c>Y94{n=cF&|A zQC?g@efc2`g5za$#88fFfUL~BL}~U%)L_^NELCguQeId!t4^DVouGVV&S+3g!n8+) z-@$OauxiFDHC!GXvR<6>WnzZ$&EgdRR!w)_{57R?SS-m;d1ZL2f^lNiR_xIh&%sAA zw|UC^Nhil1qf5v|3vSq~xdvU({C33qhN-)Dl+mkX*p zGPLt=Xxsf|Wpccb6xuCK_D)c-o_^>zhms_ZZ$KO`qa%iLTmzQn!ss;nPHo9}mG~RB zO&q%G5UYlCbTZ5%hI-Ohij!%T5v{c}`dR!jFRU7}h<3_IolIj)^+!BXGWv{sR!>ZV z9)=%d&@PF`gk~#z9ilBB9HMQGdhY(RiAu=mUmls{bbJcS$eHOz^Yb z{nB7;X2jn=T+e}fv)H=0FUF<8WZtM&VVb1Ee^xJR6SY|EwNKNJD6a?BV9SC-w8~t6 zo7$}qmj+w)$vPXC23!8OZ2R-lU~C4szujK@I~8D}&B$XvII~JLl_be>FTON?8m#d3 zBecbXL$uR8Pwu=l*b^0)^!k+HXgp0+^Z~;&)nA1DC=J2Sl*XmO*vwoS?DO)ypKe<5 zm0g3|pCC}JQlYh2@*Y#v%$Ekk8S4J5=h9$n$Imq9E(TB-e2eeFv}z2`RKHw2*uuK| z%Wf`QH0*lA1;m`up!k{6=*zJmJ`#V-OX%1WSwuV1F;A)`W;^ZdW?UK!FJPON7cwpl z_JGAEJw0Vo5>K<&eZX)|^?UdyYhvMV_G+}&_3t9?|6YY{%xc6f`KINb$qA+mOX3fE zDWPNS6eD#`jp3Z?KjUR3GyF(UZcg+28`Z{Ct1xX@@%>mk`-ZmS`=U;ONq)+U;ji!$ zUREZ%6j$Q1GH(TvVYBc2zKHSNoA|!DABu0Zw)d~9ReIQr&VBx4URbs5LM3l54-Tm; zVZ27P*1|fAM*wP{?8_Oim;^oPd1pHjz{YDu9&Pa`r}QIr4!tsJEXioR%X0w!;3Jph zC5D#@lrU;$O=p zjP#+rOrNB@lpVhAB~LA;Nf`c;c-V1;TCA6GL?Y2n32jb>bE^N0mz7Mp2vj6xLosAp zHX`ngYLy4jVB2+^Qlq>ov=&R==60(>)0S2yG4xVg1D1+dy_&sgFjg-nHik5nBpHft zw6=G5f7#6??2wK|n@0?n2ZxN;R6ha`+wsDxA&Y3cJUC>%IOUaTswK&YW ze`me;kfxF(L-7uTTMzB#vc*v7+acRjoLz^E*HpiORKxMQWieECE)Nb_FHU(et#WCw z>@Qj2{G%-;z#-aLo!f0zN0wnU-sM5SXiFFl(RLYLW%!GKfBz~}pPwhwDvbGPJj+w% zXLzRitH2-RA^2HOU-KJ9k4Ls=`jmyRP+KxyCH|h)LC<0B)MAuqOQuyAuc7#i7q*7C zv{QYKFk*P7`g;_yUdB;3WA@so=|>d*Ol{Ufh`g*`)?^?xIokYvCKyfCS}b`x|Aw|{ zFe{Veg{07KX|i{6YSz;aX)Q^T$2TC3m(dYJIj#Y+Vs8bKVYBc2jV0q%;&0eC#ptd> zteRD)&BRVnIhx-Y6laCjS{nUKn;rAQsu{1;a9nW6G{#gv0#I8@MxQAq_|t)yO*{;L X8eA2hCRh0S5!&LxA=+u4=XU-dIydk! literal 0 HcmV?d00001 diff --git a/pyproject.toml b/pyproject.toml index a387575..a300969 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ dependencies = [ ] [dependency-groups] -dev = ["pytest>=8.3"] +dev = ["pytest>=8.3", "pillow>=10.0"] [build-system] requires = ["hatchling"] diff --git a/tests/test_nyan_asset.py b/tests/test_nyan_asset.py new file mode 100644 index 0000000..241f627 --- /dev/null +++ b/tests/test_nyan_asset.py @@ -0,0 +1,14 @@ +import json +from pathlib import Path +from tools.anim_encoder import parse_header + +ASSET = Path(__file__).resolve().parents[1] / "assets" / "nyan" / "nyan_72x16.anim" +META = Path(__file__).resolve().parents[1] / "assets" / "nyan" / "meta.json" + +def test_committed_asset_parses(): + h = parse_header(ASSET.read_bytes()) + assert h["magic"] == b"bicycle0" + assert (h["width"], h["height"]) == (72, 16) + assert h["color_mode"] == 0 + assert h["n_display"] >= 1 + assert h["fps"] == json.loads(META.read_text())["fps"] diff --git a/tools/build_nyan_anim.py b/tools/build_nyan_anim.py new file mode 100644 index 0000000..11c8377 --- /dev/null +++ b/tools/build_nyan_anim.py @@ -0,0 +1,95 @@ +"""Render a fixed Nyan Cat loop and compile it to a bicycle0 .anim. + +Renderer geometry adapted from the community reference app +(maxswinkels/busybar-apps, apps/nyan-cat). Deterministic (seeded RNG) so the +committed asset is reproducible. Build-time only. + + uv run python tools/build_nyan_anim.py +""" +from __future__ import annotations + +import json +import random +from pathlib import Path + +from PIL import Image + +from tools.anim_encoder import encode_anim + +W, H = 72, 16 +FRAMES = 24 # ~2s loop at 12 fps +FPS = 12 + +CRUST=(0xFF,0xCC,0x99); FROSTING=(0xFF,0x99,0xFF); SPRINKLE=(0xDD,0x33,0x88) +GRAY=(0x99,0x99,0x99); BLACK=(0,0,0); CHEEK=(0xFF,0x99,0x99); STAR=(0xFF,0xFF,0xFF) +RAINBOW=[(0xFF,0,0),(0xFF,0x99,0),(0xFF,0xFF,0),(0x33,0xFF,0),(0,0x99,0xFF),(0x66,0x33,0xFF)] +CX,BY=44,3; HX,HY=CX+9,5; TRAIL_END=CX-5 + +def _blank(): return [(0,0,0)]*(W*H) +def _rect(buf,x,y,w,h,rgb): + x2,y2=min(W,x+w),min(H,y+h); x,y=max(0,x),max(0,y) + for yy in range(y,y2): + base=yy*W + for xx in range(x,x2): buf[base+xx]=rgb + +def _stars_state(): return [{"x":8,"y":3,"p":0},{"x":26,"y":13,"p":2},{"x":46,"y":1,"p":1},{"x":66,"y":11,"p":3}] +def _tick_stars(buf,stars,rng): + for s in stars: + s["x"]-=3; s["p"]=(s["p"]+1)%4 + if s["x"]<-2: s["x"]=W+rng.randint(0,10); s["y"]=rng.randint(1,H-2) + x,y,p=s["x"],s["y"],s["p"] + if p==0: _rect(buf,x,y,1,1,STAR) + elif p==1: _rect(buf,x-1,y,3,1,STAR); _rect(buf,x,y-1,1,3,STAR) + elif p==2: _rect(buf,x-2,y,5,1,STAR); _rect(buf,x,y-2,1,5,STAR) + else: + for dx,dy in ((-2,0),(2,0),(0,-2),(0,2)): _rect(buf,x+dx,y+dy,1,1,STAR) + +def _rainbow(buf,phase): + for band,color in enumerate(RAINBOW): + y=2+band*2; x=0 + while x Date: Thu, 6 Aug 2026 21:20:55 -0700 Subject: [PATCH 04/13] display+config: add PRIORITY_FILLER=5 tier and [nyan_filler] defaults --- src/busybar/config.py | 6 ++++++ src/busybar/display.py | 13 +++++++++++++ tests/test_config.py | 5 +++++ tests/test_display.py | 6 ++++++ 4 files changed, 30 insertions(+) diff --git a/src/busybar/config.py b/src/busybar/config.py index f8fb690..4f66007 100644 --- a/src/busybar/config.py +++ b/src/busybar/config.py @@ -72,6 +72,12 @@ # ci_status/README.md's "Snoozing alerts" section. 0 disables. "snooze_minutes": 30, }, + "nyan_filler": { + "enabled": True, + "poll_seconds": 1, # reclaims a dark gap within ~1s; the draw is + # tiny and mostly-sleeping (see nyan_filler/README) + "quiet_hours": "00:00-07:00", # local time; "" disables quiet hours entirely + }, } diff --git a/src/busybar/display.py b/src/busybar/display.py index a061a38..cd0dbcf 100644 --- a/src/busybar/display.py +++ b/src/busybar/display.py @@ -53,6 +53,19 @@ """ AMBIENT_REDRAW_SECONDS = 10 +PRIORITY_FILLER = 5 +"""Decorative screen-filler (e.g. nyan_filler). Strictly below PRIORITY_AMBIENT +(20) AND below the firmware's built-in-app tier (10), but above the empty/stub +screen (0). Verified on-device (spec 2026-08-06 §5b, SPK-3): the panel's +black/resting state -- true idle AND the CI overlay's silence gap -- rests at +priority 0, so a priority-5 draw fills those gaps; a built-in app at priority 10 +outranks it, so the filler never overrides the clock/desktop. Every other tier +(ambient 20, overlay 21, raised 25, alert 60, urgent 65, session 90) preempts +it. Draw with loop=true and re-assert every poll: a same-element redraw +continues the on-device loop (SPK-2), so unconditional per-poll redraw does not +stutter. +""" + def ambient_timeout(poll_seconds: float) -> int: """Element timeout (seconds) for an ambient-tier draw at the given poll diff --git a/tests/test_config.py b/tests/test_config.py index 835b961..5470711 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -112,3 +112,8 @@ def test_device_kwargs_no_warnings_when_all_keys_known(caplog): caplog.set_level(logging.WARNING, logger="busybar.config") device_kwargs({"device": dict(DEFAULTS["device"])}) assert not [r for r in caplog.records if r.levelname == "WARNING"] + + +def test_nyan_filler_defaults(): + cfg = load_config(path=None)["nyan_filler"] + assert cfg == {"enabled": True, "poll_seconds": 1, "quiet_hours": "00:00-07:00"} diff --git a/tests/test_display.py b/tests/test_display.py index b752976..348b3be 100644 --- a/tests/test_display.py +++ b/tests/test_display.py @@ -74,3 +74,9 @@ def test_overlay_gap_elapsed_matches_dwell_threshold_semantics(): # boundary value itself is exact, not off-by-a-rounding-error. last_end = NOW - timedelta(seconds=OVERLAY_DWELL_SECONDS) assert overlay_gap_elapsed(last_end, NOW) == OVERLAY_DWELL_SECONDS + + +def test_filler_priority_below_builtin_and_ambient(): + from busybar.display import PRIORITY_FILLER + assert PRIORITY_FILLER == 5 + assert 0 < PRIORITY_FILLER < 10 < PRIORITY_AMBIENT # 10 = built-in app tier From 144620fdd6375153ecc4cae9cf52b2d13b356caa Mon Sep 17 00:00:00 2001 From: John Osumi <931193+sumitake@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:25:33 -0700 Subject: [PATCH 05/13] nyan_filler: pure quiet-hours logic and animation element builder --- integrations/nyan_filler/__init__.py | 0 integrations/nyan_filler/logic.py | 45 ++++++++++++++++++++++++++++ tests/test_nyan_logic.py | 40 +++++++++++++++++++++++++ 3 files changed, 85 insertions(+) create mode 100644 integrations/nyan_filler/__init__.py create mode 100644 integrations/nyan_filler/logic.py create mode 100644 tests/test_nyan_logic.py diff --git a/integrations/nyan_filler/__init__.py b/integrations/nyan_filler/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/integrations/nyan_filler/logic.py b/integrations/nyan_filler/logic.py new file mode 100644 index 0000000..80f78ce --- /dev/null +++ b/integrations/nyan_filler/logic.py @@ -0,0 +1,45 @@ +"""Pure helpers for the nyan_filler integration: quiet-hours parsing/gating and +the animation element payload. No I/O -- fully unit-tested.""" +from __future__ import annotations + +import re +from datetime import datetime + +FILLER_APP = "nyan_filler" +ASSET_NAME = "nyan_72x16.anim" +ELEMENT_ID = "nyan" + +_HHMM = re.compile(r"^([01]?\d|2[0-3]):([0-5]\d)-([01]?\d|2[0-3]):([0-5]\d)$") + + +def parse_quiet_hours(s: str) -> tuple[int, int] | None: + """'HH:MM-HH:MM' -> (start_min, end_min) minutes-since-midnight. '' -> None + (quiet hours disabled). Raises ValueError on any other malformed input.""" + if s == "": + return None + m = _HHMM.match(s.strip()) + if not m: + raise ValueError(f"invalid quiet_hours {s!r}; expected 'HH:MM-HH:MM' or ''") + sh, sm, eh, em = (int(g) for g in m.groups()) + return sh * 60 + sm, eh * 60 + em + + +def in_quiet_hours(now: datetime, window: tuple[int, int] | None) -> bool: + """True iff `now`'s local wall-clock falls in the window. Inclusive start, + exclusive end. Supports a window that wraps midnight (start > end). A window + with start == end is treated as 'never quiet'.""" + if window is None: + return False + start, end = window + if start == end: + return False + cur = now.hour * 60 + now.minute + if start < end: + return start <= cur < end + return cur >= start or cur < end # wraps midnight + + +def build_filler_elements(asset: str, timeout_s: int) -> list[dict]: + """The single looping animation element drawn at PRIORITY_FILLER.""" + return [{"id": ELEMENT_ID, "type": "animation", "path": asset, + "x": 0, "y": 0, "loop": True, "timeout": timeout_s}] diff --git a/tests/test_nyan_logic.py b/tests/test_nyan_logic.py new file mode 100644 index 0000000..8afcb08 --- /dev/null +++ b/tests/test_nyan_logic.py @@ -0,0 +1,40 @@ +from datetime import datetime +import pytest +from integrations.nyan_filler.logic import ( + parse_quiet_hours, in_quiet_hours, build_filler_elements, ELEMENT_ID) + +def _at(h, m=0): return datetime(2026, 8, 6, h, m) + +def test_parse_basic_and_empty(): + assert parse_quiet_hours("00:00-07:00") == (0, 420) + assert parse_quiet_hours("23:00-07:00") == (1380, 420) + assert parse_quiet_hours("") is None + +@pytest.mark.parametrize("bad", ["7-8", "25:00-01:00", "01:60-02:00", "0100-0200", "01:00_02:00"]) +def test_parse_rejects_malformed(bad): + with pytest.raises(ValueError): + parse_quiet_hours(bad) + +def test_same_day_window_inclusive_start_exclusive_end(): + w = parse_quiet_hours("00:00-07:00") + assert in_quiet_hours(_at(0, 0), w) is True # inclusive start + assert in_quiet_hours(_at(3), w) is True + assert in_quiet_hours(_at(6, 59), w) is True + assert in_quiet_hours(_at(7, 0), w) is False # exclusive end + assert in_quiet_hours(_at(12), w) is False + +def test_midnight_wrap_window(): + w = parse_quiet_hours("23:00-07:00") + assert in_quiet_hours(_at(23, 30), w) is True + assert in_quiet_hours(_at(2), w) is True + assert in_quiet_hours(_at(7, 0), w) is False + assert in_quiet_hours(_at(12), w) is False + +def test_none_and_equal_bounds_never_quiet(): + assert in_quiet_hours(_at(3), None) is False + assert in_quiet_hours(_at(3), (120, 120)) is False # start == end -> never + +def test_element_shape(): + els = build_filler_elements("nyan_72x16.anim", timeout_s=2) + assert els == [{"id": ELEMENT_ID, "type": "animation", "path": "nyan_72x16.anim", + "x": 0, "y": 0, "loop": True, "timeout": 2}] From 826f6f1d73bdd99130cda1fbea0d31d2e268810b Mon Sep 17 00:00:00 2001 From: John Osumi <931193+sumitake@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:32:16 -0700 Subject: [PATCH 06/13] nyan_filler: poll loop with quiet-hours gate and startup asset upload --- integrations/nyan_filler/main.py | 90 ++++++++++++++++++++++++++++++++ src/busybar/client.py | 15 ++++++ tests/test_client.py | 26 +++++++++ tests/test_nyan_main.py | 44 ++++++++++++++++ 4 files changed, 175 insertions(+) create mode 100644 integrations/nyan_filler/main.py create mode 100644 tests/test_nyan_main.py diff --git a/integrations/nyan_filler/main.py b/integrations/nyan_filler/main.py new file mode 100644 index 0000000..cf39070 --- /dev/null +++ b/integrations/nyan_filler/main.py @@ -0,0 +1,90 @@ +import sys +from pathlib import Path + +try: + import busybar # noqa: F401 +except ImportError: + sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "src")) + +import argparse +import logging +import time +from datetime import datetime + +from busybar.client import BusyBarClient, DrawResult +from busybar.config import device_kwargs, load_config +from busybar.display import PRIORITY_FILLER + +from .logic import (FILLER_APP, ASSET_NAME, build_filler_elements, + in_quiet_hours, parse_quiet_hours) + +APP = FILLER_APP +log = logging.getLogger(APP) + +ASSET_PATH = Path(__file__).resolve().parents[2] / "assets" / "nyan" / ASSET_NAME + + +def run_once(client, cfg: dict, now: datetime, state: dict, dry_run: bool = False) -> str: + """One poll cycle. `state` is a caller-owned dict mutated in place: + `quiet_cleared` records whether we've already released the panel for the + current quiet window (so we clear once on entry, not every poll).""" + c = cfg["nyan_filler"] + if not c["enabled"]: + return "disabled; no-op" + + window = parse_quiet_hours(c["quiet_hours"]) + if in_quiet_hours(now, window): + if not state.get("quiet_cleared"): + if not dry_run: + client.clear(APP) + state["quiet_cleared"] = True + return "quiet hours: released panel" + return "quiet hours: silent" + state["quiet_cleared"] = False + + timeout_s = max(2, int(c["poll_seconds"]) * 2) # self-clears if the poller dies + elements = build_filler_elements(ASSET_NAME, timeout_s) + if dry_run: + return f"DRY-RUN draw @ {PRIORITY_FILLER}: {elements!r}" + result = client.draw(APP, elements, priority=PRIORITY_FILLER) + if result == DrawResult.UNREACHABLE: + return "device unreachable" + return f"nyan @ {PRIORITY_FILLER} -> {result.value}" + + +def main() -> int: + parser = argparse.ArgumentParser(description="BUSY Bar Nyan dark-filler") + parser.add_argument("--once", action="store_true") + parser.add_argument("--dry-run", action="store_true") + args = parser.parse_args() + logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") + + cfg = load_config() + client = BusyBarClient(**device_kwargs(cfg)) + client.clear(APP) # drop any stale element from a previous process + + # Self-healing install: (re)upload the committed asset on startup so the + # device always has it. One ~83 KB POST per process start, never per poll. + if not args.dry_run: + if ASSET_PATH.exists(): + client.upload_asset(APP, ASSET_NAME, ASSET_PATH.read_bytes()) + else: + log.warning("asset %s missing; run `uv run python tools/build_nyan_anim.py`", ASSET_PATH) + + state: dict = {} + backoff = 5 + while True: + summary = run_once(client, cfg, datetime.now(), state, args.dry_run) + log.info(summary) + if args.once: + return 0 + if summary == "device unreachable": + time.sleep(backoff) + backoff = min(backoff * 2, 300) + else: + backoff = 5 + time.sleep(cfg["nyan_filler"]["poll_seconds"]) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/busybar/client.py b/src/busybar/client.py index 1eccede..df536c1 100644 --- a/src/busybar/client.py +++ b/src/busybar/client.py @@ -225,6 +225,21 @@ def clear(self, application_name: str) -> bool: params={"application_name": application_name}) return resp is not None and resp.status_code == 200 + def upload_asset(self, application_name: str, filename: str, data: bytes) -> bool: + """Upload a raw asset (e.g. a compiled .anim) to the device's app asset + store. Local-only: assets live on the physical device, so this never + uses the cloud transport. Returns True on HTTP 200.""" + resp = self._try_local( + "POST", + f"/api/assets/upload?application_name={application_name}&file={filename}", + data=data, headers={"Content-Type": "application/octet-stream"}) + if resp is None: + log.warning("asset upload unreachable: %s/%s", application_name, filename) + return False + if resp.status_code != 200: + log.warning("asset upload failed: HTTP %s %s", resp.status_code, resp.text[:200]) + return resp.status_code == 200 + def status(self) -> dict | None: resp = self._request("GET", "/api/status") return resp.json() if resp is not None and resp.status_code == 200 else None diff --git a/tests/test_client.py b/tests/test_client.py index 58d57aa..12c0dbe 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -312,6 +312,32 @@ def test_no_recovery_probe_before_window_when_never_degraded(mock_request): assert mock_request.call_args.args[1] == "http://192.0.2.1/api/display/draw" +# --- upload_asset (nyan_filler, local-only) --------------------------------- + +@patch("busybar.client.requests.request") +def test_upload_asset_success(mock_request): + mock_request.return_value = _response(200) + data = b"\x00\x01\x02binarydata" + assert BusyBarClient().upload_asset("nyan_filler", "nyan_72x16.anim", data) is True + method, url = mock_request.call_args.args + assert method == "POST" + assert "application_name=nyan_filler" in url and "file=nyan_72x16.anim" in url + assert mock_request.call_args.kwargs["headers"] == {"Content-Type": "application/octet-stream"} + assert mock_request.call_args.kwargs["data"] == data + + +@patch("busybar.client.requests.request") +def test_upload_asset_false_on_non_200(mock_request): + mock_request.return_value = _response(500) + assert BusyBarClient().upload_asset("nyan_filler", "nyan_72x16.anim", b"x") is False + + +@patch("busybar.client.requests.request") +def test_upload_asset_false_when_unreachable(mock_request): + mock_request.side_effect = requests.ConnectionError() + assert BusyBarClient().upload_asset("nyan_filler", "nyan_72x16.anim", b"x") is False + + # --- token never logged (v1.6 security requirement) ------------------------- @patch("busybar.client.time.monotonic") diff --git a/tests/test_nyan_main.py b/tests/test_nyan_main.py new file mode 100644 index 0000000..60ab5fd --- /dev/null +++ b/tests/test_nyan_main.py @@ -0,0 +1,44 @@ +from datetime import datetime +from busybar.client import DrawResult +from busybar.display import PRIORITY_FILLER +from integrations.nyan_filler.main import run_once +from integrations.nyan_filler.logic import FILLER_APP, ASSET_NAME + +class FakeClient: + def __init__(self, result=DrawResult.DRAWN): + self.result = result; self.draws = []; self.clears = 0 + def draw(self, app, elements, priority=50, led_notification_color=None): + self.draws.append((app, elements, priority)); return self.result + def clear(self, app): + self.clears += 1; return True + +BASE = {"nyan_filler": {"enabled": True, "poll_seconds": 1, "quiet_hours": "00:00-07:00"}} + +def test_draws_at_filler_priority_when_active(): + c = FakeClient(); st = {} + summary = run_once(c, BASE, datetime(2026, 8, 6, 12, 0), st) # noon: not quiet + assert len(c.draws) == 1 + app, elements, priority = c.draws[0] + assert app == FILLER_APP and priority == PRIORITY_FILLER + assert elements[0]["type"] == "animation" and elements[0]["path"] == ASSET_NAME + assert elements[0]["loop"] is True + assert "drawn" in summary + +def test_quiet_hours_clears_once_then_stays_silent(): + c = FakeClient(); st = {} + run_once(c, BASE, datetime(2026, 8, 6, 3, 0), st) # 3am: quiet + run_once(c, BASE, datetime(2026, 8, 6, 3, 1), st) # still quiet + assert c.clears == 1 # cleared once on entry, not every poll + assert c.draws == [] + +def test_leaving_quiet_hours_draws_again(): + c = FakeClient(); st = {} + run_once(c, BASE, datetime(2026, 8, 6, 3, 0), st) # quiet -> clears + run_once(c, BASE, datetime(2026, 8, 6, 8, 0), st) # active -> draws + assert c.clears == 1 and len(c.draws) == 1 + +def test_disabled_is_noop(): + c = FakeClient(); st = {} + cfg = {"nyan_filler": {**BASE["nyan_filler"], "enabled": False}} + summary = run_once(c, cfg, datetime(2026, 8, 6, 12, 0), st) + assert c.draws == [] and "disabled" in summary From 3ff65a3a84cdfaf1fdc29eaf287b2e4007be0c37 Mon Sep 17 00:00:00 2001 From: John Osumi <931193+sumitake@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:38:36 -0700 Subject: [PATCH 07/13] nyan_filler: gate startup clear() behind --dry-run Review found the unconditional client.clear(APP) at main() startup fired a real device write even under --dry-run, contradicting the plan's Step 6 "no device writes" acceptance criterion for dry-run mode. Move it inside the same guard that already gated the self-healing asset upload so both startup device writes are skipped together in dry-run. --- integrations/nyan_filler/main.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/integrations/nyan_filler/main.py b/integrations/nyan_filler/main.py index cf39070..34673b1 100644 --- a/integrations/nyan_filler/main.py +++ b/integrations/nyan_filler/main.py @@ -61,11 +61,13 @@ def main() -> int: cfg = load_config() client = BusyBarClient(**device_kwargs(cfg)) - client.clear(APP) # drop any stale element from a previous process - # Self-healing install: (re)upload the committed asset on startup so the - # device always has it. One ~83 KB POST per process start, never per poll. + # Startup clear + self-healing asset (re)upload -- both real device + # writes, so both are gated behind --dry-run (no device writes at all + # in dry-run mode). Upload: one ~83 KB POST per process start, never + # per poll. if not args.dry_run: + client.clear(APP) # drop any stale element from a previous process if ASSET_PATH.exists(): client.upload_asset(APP, ASSET_NAME, ASSET_PATH.read_bytes()) else: From 3e36190ebae450fbaa75ce61f32c63921fd99246 Mon Sep 17 00:00:00 2001 From: John Osumi <931193+sumitake@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:42:17 -0700 Subject: [PATCH 08/13] nyan_filler: launchd agent, README, and config example --- config.example.toml | 5 + integrations/nyan_filler/README.md | 116 ++++++++++++++++++ .../nyan_filler/com.busybar.nyan-filler.plist | 30 +++++ 3 files changed, 151 insertions(+) create mode 100644 integrations/nyan_filler/README.md create mode 100644 integrations/nyan_filler/com.busybar.nyan-filler.plist diff --git a/config.example.toml b/config.example.toml index 6bb5e19..2b301a1 100644 --- a/config.example.toml +++ b/config.example.toml @@ -73,3 +73,8 @@ repo_refresh_minutes = 60 # how often the repo list itself is re-enumerated ( # 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 + +[nyan_filler] +enabled = true # set false to disable the animation without uninstalling the agent +poll_seconds = 1 # how quickly a dark gap is reclaimed (the draw is tiny; see README) +quiet_hours = "00:00-07:00" # local time; "" disables quiet hours entirely diff --git a/integrations/nyan_filler/README.md b/integrations/nyan_filler/README.md new file mode 100644 index 0000000..5bf0865 --- /dev/null +++ b/integrations/nyan_filler/README.md @@ -0,0 +1,116 @@ +# Nyan Filler Integration + +## What It Does + +This integration fills unused screen time on the busybar device with a full-panel animated Nyan Cat, drawing at the **filler** tier (`busybar.display.PRIORITY_FILLER`, priority 5) — the lowest tier. The animation is a native on-device asset (`.anim` format) and runs entirely on the device, so host CPU overhead is minimal: roughly one tiny draw per second. + +**Priority ensures it never interrupts anything important.** Built-in apps draw at priority 10 (above filler); the CI status alert at priority 60, calendar events at priorities 20–65, and BUSY/CUSTOM sessions at priority 90 all preempt the Nyan animation immediately. The integration yields to every other display consumer — it exists *only* to fill gaps that would otherwise show black. Once any higher-priority display appears, the Nyan animation disappears, and when the screen goes dark again, it resumes. + +## Requirements + +- **Python 3.12+** and `uv` package manager +- **Device reachable** on your LAN (default `10.0.4.20` over USB-Ethernet; configurable for Wi-Fi) +- **Optional: macOS for autostart.** The LaunchAgent autostart packaging is macOS-specific; manual runs of the integration work on any OS with Python 3.12+ + +## Design: On-Device Animation + +The Nyan Cat animation is compiled into a native `.anim` asset and uploaded to the device on the integration's first run. Once stored, the device plays it entirely in firmware — no further communication with the host beyond the periodic poll interval. This design keeps host overhead minimal: the integration spends most cycles simply checking whether to show the filler or not (when higher-priority content is active), not streaming animation frames. + +The asset is regenerated by running: + +```bash +uv run python -m tools.build_nyan_anim +``` + +If you modify the source or rebuild the animation, the agent automatically re-uploads the asset to the device on its next startup. + +## Setup + +### 1. Configure + +Copy the example config to your repository root: + +```bash +cp config.example.toml config.toml +``` + +Edit `config.toml` and configure the `[nyan_filler]` section: + +```toml +[nyan_filler] +enabled = true # set false to disable the animation without uninstalling the agent +poll_seconds = 1 # how quickly a dark gap is reclaimed (the draw is tiny; see README) +quiet_hours = "00:00-07:00" # local time; "" disables quiet hours entirely +``` + +### 2. Test in Foreground + +From the repository root, run the integration once: + +```bash +cd integrations +uv run python -m nyan_filler.main --once --dry-run +``` + +Verify that the output shows the Nyan animation was uploaded (or is already on the device). The `--dry-run` flag prints the display payload without sending it. **This test run confirms the device is reachable and the animation asset is intact before automating.** + +### 3. Verify Config + +Once the foreground test completes, your `config.toml` is in place. The LaunchAgent installation step below will automate polling. + +## Config Reference + +| Key | Type | Default | Purpose | +|---|---|---|---| +| `enabled` | boolean | true | Enable or disable the Nyan animation. Set `false` to pause the filler without uninstalling the agent; restart the agent to re-enable. | +| `poll_seconds` | integer | 1 | Polling interval in seconds. Determines how quickly the agent reclaims a dark gap once higher-priority content disappears. Default 1 second matches the on-device animation frame rate. | +| `quiet_hours` | string | "00:00-07:00" | Local time window during which the filler is suppressed (typically for sleep hours). Format: `"HH:MM-HH:MM"`; use `""` to disable quiet hours entirely. | + +## Autostart + +### Install LaunchAgent + +From the repository root, run these commands to install the Nyan filler as a background service that starts at login: + +```bash +cd integrations/nyan_filler +mkdir -p ~/Library/Logs/busybar +sed -e "s|__REPO__|$(git rev-parse --show-toplevel)|" -e "s|__UV__|$(command -v uv)|" -e "s|__HOME__|$HOME|" \ + com.busybar.nyan-filler.plist > ~/Library/LaunchAgents/com.busybar.nyan-filler.plist +launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.busybar.nyan-filler.plist +``` + +The agent will start automatically at your next login and run continuously, polling the device at the interval specified in `config.toml`. The agent sets PYTHONPATH to the repo's src/ directory so the busybar package resolves even without a healthy editable install. + +### Uninstall LaunchAgent + +To stop the service and remove it from autostart: + +```bash +launchctl bootout gui/$(id -u) ~/Library/LaunchAgents/com.busybar.nyan-filler.plist +rm ~/Library/LaunchAgents/com.busybar.nyan-filler.plist +``` + +## Logs + +Stdout and stderr are redirected to `~/Library/Logs/busybar/nyan.log`. View recent activity with: + +```bash +tail -f ~/Library/Logs/busybar/nyan.log +``` + +## Display Priority and Behavior + +The Nyan filler draws at priority 5, the lowest tier on the device. The full priority ladder and how this integration fits into it are documented in `src/busybar/display.py` and the design spec (`docs/superpowers/specs/2026-08-06-nyan-filler-design.md`). + +**When does Nyan show?** +- The screen is dark (no built-in app, no calendar, no CI status, no BUSY session active). +- The current local time is outside any `quiet_hours` window. +- `enabled = true` in config. + +**When is Nyan hidden?** +- Any other integration (CI status, calendar, built-in apps) draws at priority 10 or higher — Nyan disappears immediately. +- A BUSY/CUSTOM session is active on the device (priority 90). +- The current time falls inside `quiet_hours`. + +**Restart edge case.** If the integration restarts and the animation asset is not yet on the device, it will be uploaded on the first draw attempt. This only happens once, on the first run or after a device reset — subsequent starts reuse the same asset. diff --git a/integrations/nyan_filler/com.busybar.nyan-filler.plist b/integrations/nyan_filler/com.busybar.nyan-filler.plist new file mode 100644 index 0000000..1d58584 --- /dev/null +++ b/integrations/nyan_filler/com.busybar.nyan-filler.plist @@ -0,0 +1,30 @@ + + + + + Labelcom.busybar.nyan-filler + WorkingDirectory__REPO__/integrations + ProgramArguments + + __UV__ + run + python + -m + nyan_filler.main + + EnvironmentVariables + + PYTHONPATH + __REPO__/src + PATH + /opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin + + RunAtLoad + KeepAlive + ProcessTypeBackground + ThrottleInterval60 + StandardOutPath__HOME__/Library/Logs/busybar/nyan.log + StandardErrorPath__HOME__/Library/Logs/busybar/nyan.log + + From 6d47cfd1ba66731e1e6ec639c06c6ac2c3b035a5 Mon Sep 17 00:00:00 2001 From: John Osumi <931193+sumitake@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:48:12 -0700 Subject: [PATCH 09/13] nyan_filler: fix three README accuracy issues Fix three factual inaccuracies identified in post-review: 1. --once --dry-run makes zero device calls; fix test section to recommend --once 2. Asset uploads on EVERY process start, not conditionally; fix startup section 3. poll_seconds controls host re-assertion, not animation frame rate; fix config table All fixes verified against main.py code. Tests still green (369 passed). --- integrations/nyan_filler/README.md | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/integrations/nyan_filler/README.md b/integrations/nyan_filler/README.md index 5bf0865..8038c6b 100644 --- a/integrations/nyan_filler/README.md +++ b/integrations/nyan_filler/README.md @@ -45,14 +45,22 @@ quiet_hours = "00:00-07:00" # local time; "" disables quiet hours entirely ### 2. Test in Foreground -From the repository root, run the integration once: +From the repository root, run the integration once to validate connectivity and upload the asset: ```bash cd integrations +uv run python -m nyan_filler.main --once +``` + +Verify that the output shows the draw was successful (`nyan @ 5 -> ACCEPTED` or similar). This confirms the device is reachable and the animation asset has been uploaded. + +To test configuration without device I/O (dry-run mode only): + +```bash uv run python -m nyan_filler.main --once --dry-run ``` -Verify that the output shows the Nyan animation was uploaded (or is already on the device). The `--dry-run` flag prints the display payload without sending it. **This test run confirms the device is reachable and the animation asset is intact before automating.** +The `--dry-run` flag prints what the integration *would* draw without making any device calls — this is useful for validating config without touching the device. ### 3. Verify Config @@ -63,7 +71,7 @@ Once the foreground test completes, your `config.toml` is in place. The LaunchAg | Key | Type | Default | Purpose | |---|---|---|---| | `enabled` | boolean | true | Enable or disable the Nyan animation. Set `false` to pause the filler without uninstalling the agent; restart the agent to re-enable. | -| `poll_seconds` | integer | 1 | Polling interval in seconds. Determines how quickly the agent reclaims a dark gap once higher-priority content disappears. Default 1 second matches the on-device animation frame rate. | +| `poll_seconds` | integer | 1 | Polling interval in seconds. Determines how quickly the agent wakes to re-assert the filler draw and reclaims a dark gap once higher-priority content disappears. | | `quiet_hours` | string | "00:00-07:00" | Local time window during which the filler is suppressed (typically for sleep hours). Format: `"HH:MM-HH:MM"`; use `""` to disable quiet hours entirely. | ## Autostart @@ -113,4 +121,4 @@ The Nyan filler draws at priority 5, the lowest tier on the device. The full pri - A BUSY/CUSTOM session is active on the device (priority 90). - The current time falls inside `quiet_hours`. -**Restart edge case.** If the integration restarts and the animation asset is not yet on the device, it will be uploaded on the first draw attempt. This only happens once, on the first run or after a device reset — subsequent starts reuse the same asset. +**Startup asset upload.** The integration automatically re-uploads the animation asset (~83 KB POST) on every non-dry-run process start (including KeepAlive-triggered relaunches), but never during polling. This design ensures the asset is always fresh on the device without needing a separate manual upload step. The upload happens before the main polling loop starts, so it's part of process initialization, not per-poll overhead. From f4a67c506f76a774e21c90d9e68b945d6b362504 Mon Sep 17 00:00:00 2001 From: John Osumi <931193+sumitake@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:53:24 -0700 Subject: [PATCH 10/13] nyan_filler: fix remaining README consistency issues Fix four more accuracy issues identified in second review round: 1. Design section: clarify upload happens on EVERY non-dry-run process start, not just first run 2. Design section: reword rebuild guidance to clarify re-uploads are unconditional 3. Test output example: use real DrawResult value 'drawn' instead of 'ACCEPTED' 4. Add quiet_hours caveat to test section (default window suppresses draw) All fixes verified against main.py and src/busybar/client.py. Tests still green (369 passed). --- integrations/nyan_filler/README.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/integrations/nyan_filler/README.md b/integrations/nyan_filler/README.md index 8038c6b..5c4aa71 100644 --- a/integrations/nyan_filler/README.md +++ b/integrations/nyan_filler/README.md @@ -14,7 +14,7 @@ This integration fills unused screen time on the busybar device with a full-pane ## Design: On-Device Animation -The Nyan Cat animation is compiled into a native `.anim` asset and uploaded to the device on the integration's first run. Once stored, the device plays it entirely in firmware — no further communication with the host beyond the periodic poll interval. This design keeps host overhead minimal: the integration spends most cycles simply checking whether to show the filler or not (when higher-priority content is active), not streaming animation frames. +The Nyan Cat animation is compiled into a native `.anim` asset (~83 KB) and automatically re-uploaded to the device on every non-dry-run process start (including KeepAlive-triggered relaunches). Once stored, the device plays it entirely in firmware. Between these re-upload cycles, the host's only device communication is the periodic re-assert draw (~1 tiny frame per poll). This design keeps host overhead minimal: the integration spends most cycles simply checking whether to show the filler or not (when higher-priority content is active), not streaming animation frames. The asset is regenerated by running: @@ -22,7 +22,7 @@ The asset is regenerated by running: uv run python -m tools.build_nyan_anim ``` -If you modify the source or rebuild the animation, the agent automatically re-uploads the asset to the device on its next startup. +If you modify the source or rebuild the animation, run this command to regenerate it. The agent will automatically upload the new asset on its next startup, along with every subsequent non-dry-run process start. ## Setup @@ -52,7 +52,9 @@ cd integrations uv run python -m nyan_filler.main --once ``` -Verify that the output shows the draw was successful (`nyan @ 5 -> ACCEPTED` or similar). This confirms the device is reachable and the animation asset has been uploaded. +Verify that the output shows the draw was successful (`nyan @ 5 -> drawn` or similar). This confirms the device is reachable and the animation asset has been uploaded. + +**Note:** If you run this test during the default `quiet_hours` window (`00:00-07:00`), the integration will suppress the draw and print `quiet hours: released panel` instead — the test will pass without drawing anything. To verify a draw, run the test outside quiet hours or temporarily set `quiet_hours = ""` in your config. To test configuration without device I/O (dry-run mode only): From 1d10a9845cbc126cdc463b5d8c75889d478c8349 Mon Sep 17 00:00:00 2001 From: John Osumi <931193+sumitake@users.noreply.github.com> Date: Thu, 6 Aug 2026 22:05:31 -0700 Subject: [PATCH 11/13] nyan_filler: final-review fixes (log gating, invocation, hermetic test, quiet_hours validation) Applies all four must-fix items from the final whole-branch review: gate per-poll INFO logging behind a change/heartbeat helper (mirrors calendar_countdown's should_log_info), fix the broken `python tools/build_nyan_anim.py` invocation to `-m tools.build_nyan_anim` in both the tool's docstring and main.py's runtime warning, make test_nyan_filler_defaults hermetic against a real repo-root config.toml, and fail fast with a clean error (not a launchd crash-loop) on a malformed quiet_hours at startup. Co-Authored-By: Claude Opus 4.8 --- integrations/nyan_filler/main.py | 41 ++++++++++++++++++++++++++++++-- tests/test_config.py | 8 +++++-- tests/test_nyan_main.py | 19 ++++++++++++++- tools/build_nyan_anim.py | 2 +- 4 files changed, 64 insertions(+), 6 deletions(-) diff --git a/integrations/nyan_filler/main.py b/integrations/nyan_filler/main.py index 34673b1..1b97d82 100644 --- a/integrations/nyan_filler/main.py +++ b/integrations/nyan_filler/main.py @@ -19,6 +19,7 @@ in_quiet_hours, parse_quiet_hours) APP = FILLER_APP +HEARTBEAT_SECONDS = 600 log = logging.getLogger(APP) ASSET_PATH = Path(__file__).resolve().parents[2] / "assets" / "nyan" / ASSET_NAME @@ -52,6 +53,21 @@ def run_once(client, cfg: dict, now: datetime, state: dict, dry_run: bool = Fals return f"nyan @ {PRIORITY_FILLER} -> {result.value}" +def should_log_info(summary: str, last_logged_summary: str | None, + seconds_since_heartbeat: float, + heartbeat_seconds: int = HEARTBEAT_SECONDS) -> bool: + """Log-noise control, mirroring calendar_countdown's should_log_info: at + nyan_filler's default poll_seconds=1, logging every summary at INFO + would produce ~86,400 near-identical lines/day to an un-rotated log for + no new information on most polls (the summary is almost always + identical poll to poll). INFO only when the summary actually changed + since the last INFO line, or a heartbeat interval has elapsed (so a + long unchanging run still leaves a periodic "yes, I'm alive" trail) -- + DEBUG otherwise. + """ + return summary != last_logged_summary or seconds_since_heartbeat >= heartbeat_seconds + + def main() -> int: parser = argparse.ArgumentParser(description="BUSY Bar Nyan dark-filler") parser.add_argument("--once", action="store_true") @@ -60,6 +76,19 @@ def main() -> int: logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") cfg = load_config() + + # Clean-startup validation (mirrors ci_status's config_requires_repos): + # a malformed quiet_hours would otherwise raise ValueError from inside + # run_once() on every poll, and main()'s while True loop has no guard -- + # under launchd KeepAlive that's a silent crash-loop instead of one + # clear, actionable failure at startup. Parsed once here and discarded; + # run_once's own parse_quiet_hours call is unaffected. + try: + parse_quiet_hours(cfg["nyan_filler"]["quiet_hours"]) + except ValueError as exc: + log.error("invalid [nyan_filler] quiet_hours config: %s", exc) + return 1 + client = BusyBarClient(**device_kwargs(cfg)) # Startup clear + self-healing asset (re)upload -- both real device @@ -71,13 +100,21 @@ def main() -> int: if ASSET_PATH.exists(): client.upload_asset(APP, ASSET_NAME, ASSET_PATH.read_bytes()) else: - log.warning("asset %s missing; run `uv run python tools/build_nyan_anim.py`", ASSET_PATH) + log.warning("asset %s missing; run `uv run python -m tools.build_nyan_anim`", ASSET_PATH) state: dict = {} backoff = 5 + last_logged_summary: str | None = None + last_heartbeat = time.monotonic() while True: summary = run_once(client, cfg, datetime.now(), state, args.dry_run) - log.info(summary) + now_monotonic = time.monotonic() + if args.once or should_log_info(summary, last_logged_summary, now_monotonic - last_heartbeat): + log.info(summary) + last_logged_summary = summary + last_heartbeat = now_monotonic + else: + log.debug(summary) if args.once: return 0 if summary == "device unreachable": diff --git a/tests/test_config.py b/tests/test_config.py index 5470711..f6a8e71 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -114,6 +114,10 @@ def test_device_kwargs_no_warnings_when_all_keys_known(caplog): assert not [r for r in caplog.records if r.levelname == "WARNING"] -def test_nyan_filler_defaults(): - cfg = load_config(path=None)["nyan_filler"] +def test_nyan_filler_defaults(tmp_path): + # Hermetic: load from an isolated tmp_path with no [nyan_filler] section + # (mirrors test_defaults_when_no_file's isolation) rather than path=None, + # which would read any real repo-root config.toml and break once the + # operator adds a [nyan_filler] section there. + cfg = load_config(tmp_path / "missing.toml")["nyan_filler"] assert cfg == {"enabled": True, "poll_seconds": 1, "quiet_hours": "00:00-07:00"} diff --git a/tests/test_nyan_main.py b/tests/test_nyan_main.py index 60ab5fd..a844cc8 100644 --- a/tests/test_nyan_main.py +++ b/tests/test_nyan_main.py @@ -1,7 +1,7 @@ from datetime import datetime from busybar.client import DrawResult from busybar.display import PRIORITY_FILLER -from integrations.nyan_filler.main import run_once +from integrations.nyan_filler.main import run_once, should_log_info from integrations.nyan_filler.logic import FILLER_APP, ASSET_NAME class FakeClient: @@ -42,3 +42,20 @@ def test_disabled_is_noop(): cfg = {"nyan_filler": {**BASE["nyan_filler"], "enabled": False}} summary = run_once(c, cfg, datetime(2026, 8, 6, 12, 0), st) assert c.draws == [] and "disabled" in summary + + +# --- log-noise control (I-1: default poll_seconds=1 would otherwise sixfold +# calendar_countdown's own worst case -- ~86,400 near-identical lines/day at +# INFO). should_log_info mirrors calendar_countdown.main.should_log_info. + +def test_should_log_info_true_when_summary_changes(): + assert should_log_info("nyan @ 5 -> drawn", "nyan @ 5 -> reused", seconds_since_heartbeat=0) is True + +def test_should_log_info_false_when_summary_unchanged_and_no_heartbeat_due(): + assert should_log_info("nyan @ 5 -> drawn", "nyan @ 5 -> drawn", seconds_since_heartbeat=1) is False + +def test_should_log_info_true_when_unchanged_past_heartbeat(): + assert should_log_info("nyan @ 5 -> drawn", "nyan @ 5 -> drawn", + seconds_since_heartbeat=600, heartbeat_seconds=600) is True + assert should_log_info("nyan @ 5 -> drawn", "nyan @ 5 -> drawn", + seconds_since_heartbeat=599, heartbeat_seconds=600) is False diff --git a/tools/build_nyan_anim.py b/tools/build_nyan_anim.py index 11c8377..909c10c 100644 --- a/tools/build_nyan_anim.py +++ b/tools/build_nyan_anim.py @@ -4,7 +4,7 @@ (maxswinkels/busybar-apps, apps/nyan-cat). Deterministic (seeded RNG) so the committed asset is reproducible. Build-time only. - uv run python tools/build_nyan_anim.py + uv run python -m tools.build_nyan_anim """ from __future__ import annotations From 39a01bc657ca66c6f1eb6c6bdebbdeaa9a3079a0 Mon Sep 17 00:00:00 2001 From: John Osumi <931193+sumitake@users.noreply.github.com> Date: Thu, 6 Aug 2026 22:54:43 -0700 Subject: [PATCH 12/13] =?UTF-8?q?nyan=5Ffiller:=20self-heal=20asset=20uplo?= =?UTF-8?q?ad=20=E2=80=94=20retry=20until=20it=20lands=20once?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The .anim upload was a one-shot at startup guarded by --dry-run; if the device was transiently unreachable at that instant, upload_asset returned False, nothing retried it, and every subsequent draw for the life of the process referenced an asset that was never uploaded (it worked only when the asset happened to already be on the device from a prior run). Move the upload into run_once behind an `asset_uploaded` latch: attempt it on each active poll until it succeeds once, then never again — no per-poll uploads in steady state. In the in-scope transient-unreachable case the retries are naturally spaced by main()'s exponential UNREACHABLE backoff. A locally missing build artifact is warned once and skipped (polling can't fix it). Extend FakeClient with upload_asset coverage and refresh the README section that previously said the upload never happens during polling. Co-Authored-By: Claude Opus 4.8 --- integrations/nyan_filler/README.md | 2 +- integrations/nyan_filler/main.py | 50 ++++++++++++++++---- tests/test_nyan_main.py | 73 +++++++++++++++++++++++++++++- 3 files changed, 113 insertions(+), 12 deletions(-) diff --git a/integrations/nyan_filler/README.md b/integrations/nyan_filler/README.md index 5c4aa71..1984141 100644 --- a/integrations/nyan_filler/README.md +++ b/integrations/nyan_filler/README.md @@ -123,4 +123,4 @@ The Nyan filler draws at priority 5, the lowest tier on the device. The full pri - A BUSY/CUSTOM session is active on the device (priority 90). - The current time falls inside `quiet_hours`. -**Startup asset upload.** The integration automatically re-uploads the animation asset (~83 KB POST) on every non-dry-run process start (including KeepAlive-triggered relaunches), but never during polling. This design ensures the asset is always fresh on the device without needing a separate manual upload step. The upload happens before the main polling loop starts, so it's part of process initialization, not per-poll overhead. +**Self-healing asset upload.** The integration automatically (re)uploads the animation asset (~83 KB POST) once per non-dry-run process start (including KeepAlive-triggered relaunches), so the asset is always fresh on the device without a separate manual upload step. The upload is attempted on the first active poll and, if the device is transiently unreachable at that moment, retried on subsequent polls until it lands once — then never re-uploaded again for the life of the process. So it is one upload per process in the normal case and never per-poll in steady state, but it does self-heal within a process rather than being abandoned if the very first attempt fails. diff --git a/integrations/nyan_filler/main.py b/integrations/nyan_filler/main.py index 1b97d82..f7df2a9 100644 --- a/integrations/nyan_filler/main.py +++ b/integrations/nyan_filler/main.py @@ -25,10 +25,43 @@ ASSET_PATH = Path(__file__).resolve().parents[2] / "assets" / "nyan" / ASSET_NAME +def ensure_asset_uploaded(client, state: dict) -> None: + """Idempotently make sure the .anim asset is on the device before we draw + an element that references it. `state` is the same caller-owned dict + run_once threads through the loop. + + The upload is conceptually a one-shot install step (~83 KB, not per-poll). + But a single startup attempt that happens to land while the device is + transiently unreachable used to be discarded silently (upload_asset returns + False and nothing retried it), leaving every subsequent draw for the life + of the process referencing an asset that was never uploaded. So we latch on + success instead: attempt the upload on each active poll until it lands once + (`state["asset_uploaded"]`), then never upload again -- no per-poll uploads + in steady state. In the in-scope transient-unreachable case these retries + are naturally spaced out by main()'s exponential UNREACHABLE backoff, and + upload_asset logs its own failure reason on each attempt. + + A locally missing build artifact is a different failure -- polling can't + fix it -- so it's warned once (naming the rebuild command) and skipped + without retry bookkeeping; if the file later appears it uploads on the next + poll.""" + if state.get("asset_uploaded"): + return + if not ASSET_PATH.exists(): + if not state.get("asset_missing_warned"): + log.warning("asset %s missing; run `uv run python -m tools.build_nyan_anim`", ASSET_PATH) + state["asset_missing_warned"] = True + return + if client.upload_asset(APP, ASSET_NAME, ASSET_PATH.read_bytes()): + state["asset_uploaded"] = True + + def run_once(client, cfg: dict, now: datetime, state: dict, dry_run: bool = False) -> str: """One poll cycle. `state` is a caller-owned dict mutated in place: `quiet_cleared` records whether we've already released the panel for the - current quiet window (so we clear once on entry, not every poll).""" + current quiet window (so we clear once on entry, not every poll); + `asset_uploaded` latches once the .anim asset has landed on the device + (see ensure_asset_uploaded).""" c = cfg["nyan_filler"] if not c["enabled"]: return "disabled; no-op" @@ -47,6 +80,7 @@ def run_once(client, cfg: dict, now: datetime, state: dict, dry_run: bool = Fals elements = build_filler_elements(ASSET_NAME, timeout_s) if dry_run: return f"DRY-RUN draw @ {PRIORITY_FILLER}: {elements!r}" + ensure_asset_uploaded(client, state) # retries until it lands once; then a no-op result = client.draw(APP, elements, priority=PRIORITY_FILLER) if result == DrawResult.UNREACHABLE: return "device unreachable" @@ -91,16 +125,14 @@ def main() -> int: client = BusyBarClient(**device_kwargs(cfg)) - # Startup clear + self-healing asset (re)upload -- both real device - # writes, so both are gated behind --dry-run (no device writes at all - # in dry-run mode). Upload: one ~83 KB POST per process start, never - # per poll. + # Startup clear -- a real device write, so gated behind --dry-run (no + # device writes at all in dry-run mode). The asset (re)upload is no longer + # done here: run_once owns it now (ensure_asset_uploaded), attempting the + # ~83 KB POST on each active poll only until it lands once, so a device + # that is transiently unreachable at process start self-heals within the + # same process instead of never uploading for its lifetime. if not args.dry_run: client.clear(APP) # drop any stale element from a previous process - if ASSET_PATH.exists(): - client.upload_asset(APP, ASSET_NAME, ASSET_PATH.read_bytes()) - else: - log.warning("asset %s missing; run `uv run python -m tools.build_nyan_anim`", ASSET_PATH) state: dict = {} backoff = 5 diff --git a/tests/test_nyan_main.py b/tests/test_nyan_main.py index a844cc8..d227c1a 100644 --- a/tests/test_nyan_main.py +++ b/tests/test_nyan_main.py @@ -5,14 +5,25 @@ from integrations.nyan_filler.logic import FILLER_APP, ASSET_NAME class FakeClient: - def __init__(self, result=DrawResult.DRAWN): - self.result = result; self.draws = []; self.clears = 0 + def __init__(self, result=DrawResult.DRAWN, upload=True): + self.result = result; self.draws = []; self.clears = 0; self.uploads = [] + # `upload` is either a constant bool (every upload_asset returns it) or + # a list of bools consumed one-per-call, to script "fail then succeed" + # (an exhausted list falls back to True). + self._upload = list(upload) if isinstance(upload, (list, tuple)) else upload def draw(self, app, elements, priority=50, led_notification_color=None): self.draws.append((app, elements, priority)); return self.result def clear(self, app): self.clears += 1; return True + def upload_asset(self, app, filename, data): + self.uploads.append((app, filename, len(data))) + if isinstance(self._upload, list): + return self._upload.pop(0) if self._upload else True + return self._upload BASE = {"nyan_filler": {"enabled": True, "poll_seconds": 1, "quiet_hours": "00:00-07:00"}} +ACTIVE = datetime(2026, 8, 6, 12, 0) # noon: not in the 00:00-07:00 quiet window +QUIET = datetime(2026, 8, 6, 3, 0) # 3am: quiet def test_draws_at_filler_priority_when_active(): c = FakeClient(); st = {} @@ -44,6 +55,64 @@ def test_disabled_is_noop(): assert c.draws == [] and "disabled" in summary +# --- self-healing asset upload (the fix): the .anim upload is a one-shot +# install step, but a startup attempt that lands while the device is +# transiently unreachable must not be abandoned for the life of the process -- +# otherwise every subsequent draw references an asset that was never uploaded. +# run_once latches on upload success: it retries the upload on each active poll +# until it lands once, then never uploads again (no per-poll uploads in steady +# state). + +def test_uploads_asset_once_before_first_active_draw(): + c = FakeClient(); st = {} + run_once(c, BASE, ACTIVE, st) + assert len(c.uploads) == 1 + app, filename, nbytes = c.uploads[0] + assert app == FILLER_APP and filename == ASSET_NAME and nbytes > 0 + assert len(c.draws) == 1 # upload happened, then the draw + +def test_no_reupload_in_steady_state(): + c = FakeClient(); st = {} + for _ in range(5): # five successful active polls, shared state + run_once(c, BASE, ACTIVE, st) + assert len(c.uploads) == 1 # uploaded once, never again + assert len(c.draws) == 5 + +def test_upload_retried_each_poll_until_it_succeeds(): + # Device unreachable for the first two upload attempts, then reachable. + c = FakeClient(upload=[False, False, True]); st = {} + run_once(c, BASE, ACTIVE, st) # attempt 1 -> False + run_once(c, BASE, ACTIVE, st) # attempt 2 -> False + run_once(c, BASE, ACTIVE, st) # attempt 3 -> True (latches) + run_once(c, BASE, ACTIVE, st) # no further attempt + assert len(c.uploads) == 3 # retried until success, then stopped + assert st.get("asset_uploaded") is True + +def test_dry_run_never_uploads(): + c = FakeClient(); st = {} + summary = run_once(c, BASE, ACTIVE, st, dry_run=True) + assert c.uploads == [] and c.draws == [] # no device writes at all + assert "DRY-RUN" in summary + +def test_quiet_hours_never_uploads(): + c = FakeClient(); st = {} + run_once(c, BASE, QUIET, st) # asset is only needed to draw; quiet -> no draw + assert c.uploads == [] and c.draws == [] + +def test_missing_local_asset_warns_once_and_skips_upload(monkeypatch, caplog): + import logging + from pathlib import Path + from integrations.nyan_filler import main as nyan_main + monkeypatch.setattr(nyan_main, "ASSET_PATH", Path("/does/not/exist/nyan.anim")) + c = FakeClient(); st = {} + with caplog.at_level(logging.WARNING): + run_once(c, BASE, ACTIVE, st) # local build artifact absent + run_once(c, BASE, ACTIVE, st) + assert c.uploads == [] # nothing to upload + assert sum("missing" in r.message for r in caplog.records) == 1 # warned once + assert len(c.draws) == 2 # loop keeps running regardless + + # --- log-noise control (I-1: default poll_seconds=1 would otherwise sixfold # calendar_countdown's own worst case -- ~86,400 near-identical lines/day at # INFO). should_log_info mirrors calendar_countdown.main.should_log_info. From dd05ba45a8f01bf201ffc3a276c958c7e82a2320 Mon Sep 17 00:00:00 2001 From: John Osumi <931193+sumitake@users.noreply.github.com> Date: Fri, 7 Aug 2026 12:48:42 -0700 Subject: [PATCH 13/13] =?UTF-8?q?nyan=5Ffiller:=20address=20Codex=20review?= =?UTF-8?q?=20=E2=80=94=20split=20long=20frame=20runs;=20throttle=20upload?= =?UTF-8?q?=20retries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - anim_encoder: cap the per-frame duration byte at 255, splitting runs of 256+ identical frames into chunks (was a ValueError on serialize). - ensure_asset_uploaded: exponential backoff (5s->x2->300s cap) between failed upload attempts, so a persistent non-200 (device reachable for draws but rejecting the upload) no longer re-POSTs ~76KB every poll; transient failures still retry until they latch. Updated the retry test to advance the clock past each backoff window. Co-Authored-By: Claude Opus 4.8 --- integrations/nyan_filler/main.py | 21 ++++++++++-- tests/test_anim_encoder.py | 10 ++++++ tests/test_nyan_main.py | 56 ++++++++++++++++++++++++++++---- tools/anim_encoder.py | 6 +++- 4 files changed, 83 insertions(+), 10 deletions(-) diff --git a/integrations/nyan_filler/main.py b/integrations/nyan_filler/main.py index f7df2a9..bb8490a 100644 --- a/integrations/nyan_filler/main.py +++ b/integrations/nyan_filler/main.py @@ -25,6 +25,10 @@ ASSET_PATH = Path(__file__).resolve().parents[2] / "assets" / "nyan" / ASSET_NAME +UPLOAD_BACKOFF_START = 5 # seconds; first delay after a failed asset upload +UPLOAD_BACKOFF_CAP = 300 # seconds; cap on the delay between persistent-failure retries + + def ensure_asset_uploaded(client, state: dict) -> None: """Idempotently make sure the .anim asset is on the device before we draw an element that references it. `state` is the same caller-owned dict @@ -37,9 +41,11 @@ def ensure_asset_uploaded(client, state: dict) -> None: of the process referencing an asset that was never uploaded. So we latch on success instead: attempt the upload on each active poll until it lands once (`state["asset_uploaded"]`), then never upload again -- no per-poll uploads - in steady state. In the in-scope transient-unreachable case these retries - are naturally spaced out by main()'s exponential UNREACHABLE backoff, and - upload_asset logs its own failure reason on each attempt. + in steady state. Each retry is throttled by its own exponential backoff + (`UPLOAD_BACKOFF_START` -> x2 -> `UPLOAD_BACKOFF_CAP`), so a *persistent* + upload failure -- a device that answers draws but keeps rejecting the + upload (4xx/5xx) -- can't re-POST the ~76 KB asset and log a warning on + every poll; a transient failure that later succeeds still latches and stops. A locally missing build artifact is a different failure -- polling can't fix it -- so it's warned once (naming the rebuild command) and skipped @@ -52,8 +58,17 @@ def ensure_asset_uploaded(client, state: dict) -> None: log.warning("asset %s missing; run `uv run python -m tools.build_nyan_anim`", ASSET_PATH) state["asset_missing_warned"] = True return + now_mono = time.monotonic() + if now_mono < state.get("upload_next_try", 0.0): + return # throttled after a recent failed upload attempt if client.upload_asset(APP, ASSET_NAME, ASSET_PATH.read_bytes()): state["asset_uploaded"] = True + state.pop("upload_backoff", None) + state.pop("upload_next_try", None) + else: + backoff = min(max(state.get("upload_backoff", 0) * 2, UPLOAD_BACKOFF_START), UPLOAD_BACKOFF_CAP) + state["upload_backoff"] = backoff + state["upload_next_try"] = now_mono + backoff def run_once(client, cfg: dict, now: datetime, state: dict, dry_run: bool = False) -> str: diff --git a/tests/test_anim_encoder.py b/tests/test_anim_encoder.py index c7aeba9..d7ff1e7 100644 --- a/tests/test_anim_encoder.py +++ b/tests/test_anim_encoder.py @@ -35,3 +35,13 @@ def test_first_frame_pixels_roundtrip(): payload = data[off+4:off+4+length] assert encoding == 0 and length == 72*16*3 assert payload == frame + + +def test_long_identical_run_splits_under_duration_byte(): + # A run of >255 identical frames must not overflow the 1-byte duration + # field (would raise ValueError in bytes([...])). It splits into chunks. + frame = _solid(RED_BGR) + data = encode_anim([frame] * 300, 72, 16, fps=1) # must not raise + h = parse_header(data) + assert h["n_display"] == 300 + assert h["n_encoded"] == 2 # 255 + 45 diff --git a/tests/test_nyan_main.py b/tests/test_nyan_main.py index d227c1a..50e4d5c 100644 --- a/tests/test_nyan_main.py +++ b/tests/test_nyan_main.py @@ -78,13 +78,22 @@ def test_no_reupload_in_steady_state(): assert len(c.uploads) == 1 # uploaded once, never again assert len(c.draws) == 5 -def test_upload_retried_each_poll_until_it_succeeds(): - # Device unreachable for the first two upload attempts, then reachable. +def test_upload_retried_until_it_succeeds_throttled(monkeypatch): + # Device rejects the first two upload attempts, then accepts. Retries are + # throttled by exponential backoff, so the clock is advanced past each + # backoff window between polls; the upload is still retried until it + # succeeds, then latched -- the same guarantee as before, just spaced out. + import integrations.nyan_filler.main as m + clock = {"t": 0.0} + monkeypatch.setattr(m.time, "monotonic", lambda: clock["t"]) c = FakeClient(upload=[False, False, True]); st = {} - run_once(c, BASE, ACTIVE, st) # attempt 1 -> False - run_once(c, BASE, ACTIVE, st) # attempt 2 -> False - run_once(c, BASE, ACTIVE, st) # attempt 3 -> True (latches) - run_once(c, BASE, ACTIVE, st) # no further attempt + run_once(c, BASE, ACTIVE, st) # t=0: attempt 1 -> False (backoff) + clock["t"] = 10.0 + run_once(c, BASE, ACTIVE, st) # t=10: attempt 2 -> False (backoff) + clock["t"] = 30.0 + run_once(c, BASE, ACTIVE, st) # t=30: attempt 3 -> True (latches) + clock["t"] = 60.0 + run_once(c, BASE, ACTIVE, st) # latched: no further attempt assert len(c.uploads) == 3 # retried until success, then stopped assert st.get("asset_uploaded") is True @@ -128,3 +137,38 @@ def test_should_log_info_true_when_unchanged_past_heartbeat(): seconds_since_heartbeat=600, heartbeat_seconds=600) is True assert should_log_info("nyan @ 5 -> drawn", "nyan @ 5 -> drawn", seconds_since_heartbeat=599, heartbeat_seconds=600) is False + + +def test_upload_retry_is_throttled_on_persistent_failure(monkeypatch): + import integrations.nyan_filler.main as m + clock = {"t": 0.0} + monkeypatch.setattr(m.time, "monotonic", lambda: clock["t"]) + + class UploadFailClient: + def __init__(self): self.uploads = 0 + def upload_asset(self, app, name, data): self.uploads += 1; return False + + c = UploadFailClient(); st = {} + m.ensure_asset_uploaded(c, st) # t=0: attempts, fails, backoff starts + assert c.uploads == 1 + m.ensure_asset_uploaded(c, st) # t=0 still within backoff: throttled + assert c.uploads == 1 + clock["t"] = 6.0 + m.ensure_asset_uploaded(c, st) # past the backoff: retries + assert c.uploads == 2 + + +def test_upload_success_latches_and_clears_backoff(monkeypatch): + import integrations.nyan_filler.main as m + monkeypatch.setattr(m.time, "monotonic", lambda: 0.0) + + class OkClient: + def __init__(self): self.uploads = 0 + def upload_asset(self, app, name, data): self.uploads += 1; return True + + c = OkClient(); st = {} + m.ensure_asset_uploaded(c, st) + assert st["asset_uploaded"] is True and c.uploads == 1 + assert "upload_next_try" not in st + m.ensure_asset_uploaded(c, st) # latched: no further uploads + assert c.uploads == 1 diff --git a/tools/anim_encoder.py b/tools/anim_encoder.py index 65140d9..8af9ddc 100644 --- a/tools/anim_encoder.py +++ b/tools/anim_encoder.py @@ -24,10 +24,14 @@ def encode_anim(frames_bgr: list[bytes], width: int, height: int, fps: int, raise ValueError(f"frame {i}: got {len(f)} bytes, expected {expected}") # Collapse consecutive identical frames into one encoded frame (duration++). + # `duration` (and a section's duration_override) is serialized as a single + # byte, so a run of >255 identical frames must be split into chunks of at + # most 255 -- otherwise `bytes([duration])` raises ValueError. Splitting is + # transparent: each chunk is another raw frame with the same pixel data. enc: list[list] = [] # [encoding, duration, data] last = None for f in frames_bgr: - if last is not None and f == last: + if last is not None and f == last and enc[-1][1] < 255: enc[-1][1] += 1 continue last = f