diff --git a/assets/nyan/frames/frame_0.png b/assets/nyan/frames/frame_0.png new file mode 100644 index 0000000..4878723 Binary files /dev/null and b/assets/nyan/frames/frame_0.png differ diff --git a/assets/nyan/frames/frame_1.png b/assets/nyan/frames/frame_1.png new file mode 100644 index 0000000..607966f Binary files /dev/null and b/assets/nyan/frames/frame_1.png differ diff --git a/assets/nyan/frames/frame_10.png b/assets/nyan/frames/frame_10.png new file mode 100644 index 0000000..d64f6f3 Binary files /dev/null and b/assets/nyan/frames/frame_10.png differ diff --git a/assets/nyan/frames/frame_11.png b/assets/nyan/frames/frame_11.png new file mode 100644 index 0000000..dd9631f Binary files /dev/null and b/assets/nyan/frames/frame_11.png differ diff --git a/assets/nyan/frames/frame_12.png b/assets/nyan/frames/frame_12.png new file mode 100644 index 0000000..bb6e93b Binary files /dev/null and b/assets/nyan/frames/frame_12.png differ diff --git a/assets/nyan/frames/frame_13.png b/assets/nyan/frames/frame_13.png new file mode 100644 index 0000000..46bac67 Binary files /dev/null and b/assets/nyan/frames/frame_13.png differ diff --git a/assets/nyan/frames/frame_14.png b/assets/nyan/frames/frame_14.png new file mode 100644 index 0000000..00ac6a5 Binary files /dev/null and b/assets/nyan/frames/frame_14.png differ diff --git a/assets/nyan/frames/frame_15.png b/assets/nyan/frames/frame_15.png new file mode 100644 index 0000000..4f90279 Binary files /dev/null and b/assets/nyan/frames/frame_15.png differ diff --git a/assets/nyan/frames/frame_16.png b/assets/nyan/frames/frame_16.png new file mode 100644 index 0000000..29b294f Binary files /dev/null and b/assets/nyan/frames/frame_16.png differ diff --git a/assets/nyan/frames/frame_17.png b/assets/nyan/frames/frame_17.png new file mode 100644 index 0000000..2a75462 Binary files /dev/null and b/assets/nyan/frames/frame_17.png differ diff --git a/assets/nyan/frames/frame_18.png b/assets/nyan/frames/frame_18.png new file mode 100644 index 0000000..d327a58 Binary files /dev/null and b/assets/nyan/frames/frame_18.png differ diff --git a/assets/nyan/frames/frame_19.png b/assets/nyan/frames/frame_19.png new file mode 100644 index 0000000..bbdf379 Binary files /dev/null and b/assets/nyan/frames/frame_19.png differ diff --git a/assets/nyan/frames/frame_2.png b/assets/nyan/frames/frame_2.png new file mode 100644 index 0000000..7311572 Binary files /dev/null and b/assets/nyan/frames/frame_2.png differ diff --git a/assets/nyan/frames/frame_20.png b/assets/nyan/frames/frame_20.png new file mode 100644 index 0000000..657565b Binary files /dev/null and b/assets/nyan/frames/frame_20.png differ diff --git a/assets/nyan/frames/frame_21.png b/assets/nyan/frames/frame_21.png new file mode 100644 index 0000000..a7f5cf7 Binary files /dev/null and b/assets/nyan/frames/frame_21.png differ diff --git a/assets/nyan/frames/frame_22.png b/assets/nyan/frames/frame_22.png new file mode 100644 index 0000000..a7f5cf7 Binary files /dev/null and b/assets/nyan/frames/frame_22.png differ diff --git a/assets/nyan/frames/frame_23.png b/assets/nyan/frames/frame_23.png new file mode 100644 index 0000000..a7f5cf7 Binary files /dev/null and b/assets/nyan/frames/frame_23.png differ diff --git a/assets/nyan/frames/frame_3.png b/assets/nyan/frames/frame_3.png new file mode 100644 index 0000000..7135822 Binary files /dev/null and b/assets/nyan/frames/frame_3.png differ diff --git a/assets/nyan/frames/frame_4.png b/assets/nyan/frames/frame_4.png new file mode 100644 index 0000000..9dde8f3 Binary files /dev/null and b/assets/nyan/frames/frame_4.png differ diff --git a/assets/nyan/frames/frame_5.png b/assets/nyan/frames/frame_5.png new file mode 100644 index 0000000..fb36af4 Binary files /dev/null and b/assets/nyan/frames/frame_5.png differ diff --git a/assets/nyan/frames/frame_6.png b/assets/nyan/frames/frame_6.png new file mode 100644 index 0000000..16f2f44 Binary files /dev/null and b/assets/nyan/frames/frame_6.png differ diff --git a/assets/nyan/frames/frame_7.png b/assets/nyan/frames/frame_7.png new file mode 100644 index 0000000..cb566eb Binary files /dev/null and b/assets/nyan/frames/frame_7.png differ diff --git a/assets/nyan/frames/frame_8.png b/assets/nyan/frames/frame_8.png new file mode 100644 index 0000000..5b51755 Binary files /dev/null and b/assets/nyan/frames/frame_8.png differ diff --git a/assets/nyan/frames/frame_9.png b/assets/nyan/frames/frame_9.png new file mode 100644 index 0000000..ad7cba8 Binary files /dev/null and b/assets/nyan/frames/frame_9.png differ diff --git a/assets/nyan/meta.json b/assets/nyan/meta.json new file mode 100644 index 0000000..b5189a5 --- /dev/null +++ b/assets/nyan/meta.json @@ -0,0 +1,5 @@ +{ + "fps": 12, + "color_mode": "rgb888", + "sections": [] +} diff --git a/assets/nyan/nyan_72x16.anim b/assets/nyan/nyan_72x16.anim new file mode 100644 index 0000000..84c6a8f Binary files /dev/null and b/assets/nyan/nyan_72x16.anim differ diff --git a/config.example.toml b/config.example.toml index 389bc2b..1ee7c62 100644 --- a/config.example.toml +++ b/config.example.toml @@ -77,3 +77,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/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). diff --git a/integrations/nyan_filler/README.md b/integrations/nyan_filler/README.md new file mode 100644 index 0000000..1984141 --- /dev/null +++ b/integrations/nyan_filler/README.md @@ -0,0 +1,126 @@ +# 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 (~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: + +```bash +uv run python -m tools.build_nyan_anim +``` + +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 + +### 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 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 -> 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): + +```bash +uv run python -m nyan_filler.main --once --dry-run +``` + +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 + +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 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 + +### 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`. + +**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/__init__.py b/integrations/nyan_filler/__init__.py new file mode 100644 index 0000000..e69de29 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 + + 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/integrations/nyan_filler/main.py b/integrations/nyan_filler/main.py new file mode 100644 index 0000000..bb8490a --- /dev/null +++ b/integrations/nyan_filler/main.py @@ -0,0 +1,176 @@ +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 +HEARTBEAT_SECONDS = 600 +log = logging.getLogger(APP) + +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 + 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. 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 + 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 + 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: + """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); + `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" + + 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}" + 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" + 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") + 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() + + # 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 -- 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 + + 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) + 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": + 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/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/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/src/busybar/config.py b/src/busybar/config.py index e139cce..f132bf2 100644 --- a/src/busybar/config.py +++ b/src/busybar/config.py @@ -79,6 +79,12 @@ "snooze_minutes": 30, "running_spinner": True, # animated 8x8 spinner on the running badge }, + "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_anim_encoder.py b/tests/test_anim_encoder.py new file mode 100644 index 0000000..d7ff1e7 --- /dev/null +++ b/tests/test_anim_encoder.py @@ -0,0 +1,47 @@ +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("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_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_config.py b/tests/test_config.py index 1b319a2..a2116d8 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -113,6 +113,16 @@ def test_device_kwargs_no_warnings_when_all_keys_known(caplog): device_kwargs({"device": dict(DEFAULTS["device"])}) assert not [r for r in caplog.records if r.levelname == "WARNING"] + +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"} + + def test_animation_accent_defaults(tmp_path): cfg = load_config(tmp_path / "missing.toml") cal = cfg["calendar_countdown"] 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 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/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}] diff --git a/tests/test_nyan_main.py b/tests/test_nyan_main.py new file mode 100644 index 0000000..50e4d5c --- /dev/null +++ b/tests/test_nyan_main.py @@ -0,0 +1,174 @@ +from datetime import datetime +from busybar.client import DrawResult +from busybar.display import PRIORITY_FILLER +from integrations.nyan_filler.main import run_once, should_log_info +from integrations.nyan_filler.logic import FILLER_APP, ASSET_NAME + +class FakeClient: + 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 = {} + 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 + + +# --- 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_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) # 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 + +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. + +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 + + +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/__init__.py b/tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tools/anim_encoder.py b/tools/anim_encoder.py new file mode 100644 index 0000000..8af9ddc --- /dev/null +++ b/tools/anim_encoder.py @@ -0,0 +1,87 @@ +"""Encoder for the BUSY Bar 'bicycle0' animation container. + +Ported from the firmware web draw-tool's assets/frontend/util/seq2anim.ts +(busy-app/busybar-firmware). Uses only the raw-frame path (encoding=0): the +format also defines an RLE path, but raw frames are valid and far simpler, and +a 24-frame 72x16 animation is ~83 KB raw (stock anims run to ~2 MB). This is a +build-time tool; nothing in integrations/ imports it at runtime. +""" +from __future__ import annotations + +import struct + +HEADER_LENGTH = 36 + + +def 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: + 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++). + # `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 and enc[-1][1] < 255: + 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("