-
Notifications
You must be signed in to change notification settings - Fork 0
Add nyan_filler: on-device Nyan Cat dark-filler #17
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
2c80a93
7da9e5e
f041200
4944cf4
144620f
826f6f1
3ff65a3
3e36190
6d47cfd
f4a67c5
1d10a98
39a01bc
3867618
dd05ba4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| { | ||
| "fps": 12, | ||
| "color_mode": "rgb888", | ||
| "sections": [] | ||
| } |
Large diffs are not rendered by default.
Large diffs are not rendered by default.
| 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. |
| 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> |
| 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}] |
| 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 | ||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
On a fresh device where the asset is absent and the upload is currently throttled, rejected, or skipped because the local file 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()) | ||
Uh oh!
There was an error while loading. Please reload this page.