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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added assets/nyan/frames/frame_0.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/nyan/frames/frame_1.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/nyan/frames/frame_10.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/nyan/frames/frame_11.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/nyan/frames/frame_12.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/nyan/frames/frame_13.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/nyan/frames/frame_14.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/nyan/frames/frame_15.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/nyan/frames/frame_16.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/nyan/frames/frame_17.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/nyan/frames/frame_18.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/nyan/frames/frame_19.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/nyan/frames/frame_2.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/nyan/frames/frame_20.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/nyan/frames/frame_21.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/nyan/frames/frame_22.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/nyan/frames/frame_23.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/nyan/frames/frame_3.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/nyan/frames/frame_4.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/nyan/frames/frame_5.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/nyan/frames/frame_6.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/nyan/frames/frame_7.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/nyan/frames/frame_8.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/nyan/frames/frame_9.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 5 additions & 0 deletions assets/nyan/meta.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"fps": 12,
"color_mode": "rgb888",
"sections": []
}
Binary file added assets/nyan/nyan_72x16.anim
Binary file not shown.
5 changes: 5 additions & 0 deletions config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
878 changes: 878 additions & 0 deletions docs/superpowers/plans/2026-08-06-nyan-filler.md

Large diffs are not rendered by default.

331 changes: 331 additions & 0 deletions docs/superpowers/specs/2026-08-06-nyan-filler-design.md

Large diffs are not rendered by default.

126 changes: 126 additions & 0 deletions integrations/nyan_filler/README.md
Original file line number Diff line number Diff line change
@@ -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.
Empty file.
30 changes: 30 additions & 0 deletions integrations/nyan_filler/com.busybar.nyan-filler.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key><string>com.busybar.nyan-filler</string>
<key>WorkingDirectory</key><string>__REPO__/integrations</string>
<key>ProgramArguments</key>
<array>
<string>__UV__</string>
<string>run</string>
<string>python</string>
<string>-m</string>
<string>nyan_filler.main</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>PYTHONPATH</key>
<string>__REPO__/src</string>
<key>PATH</key>
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
</dict>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
<key>ProcessType</key><string>Background</string>
<key>ThrottleInterval</key><integer>60</integer>
<key>StandardOutPath</key><string>__HOME__/Library/Logs/busybar/nyan.log</string>
<key>StandardErrorPath</key><string>__HOME__/Library/Logs/busybar/nyan.log</string>
</dict>
</plist>
45 changes: 45 additions & 0 deletions integrations/nyan_filler/logic.py
Original file line number Diff line number Diff line change
@@ -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}]
176 changes: 176 additions & 0 deletions integrations/nyan_filler/main.py
Original file line number Diff line number Diff line change
@@ -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
Comment thread
sumitake marked this conversation as resolved.
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)
Comment on lines +98 to +99

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid drawing until the asset is available

On a fresh device where the asset is absent and the upload is currently throttled, rejected, or skipped because the local file is missing, ensure_asset_uploaded() returns without setting asset_uploaded, but the draw is still submitted every poll. The device then rejects the missing-asset reference and BusyBarClient.draw() logs a warning each time—up to roughly 86,400 warnings per day at the default interval—so the new upload backoff does not prevent the persistent log/request flood. Skip the draw until the upload succeeds (and likewise while the local artifact is missing).

Useful? React with 👍 / 👎.

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())
Loading
Loading