Busy light
The busy light is a persistent indicator — a small solid dot in a corner of the panel — that turns on while you’re “busy” and off when you’re not. It is not a playlist widget: it does not occupy a section or take a turn in the rotation. It paints as an overlay on top of whatever is currently on screen, so it stays lit continuously across every section and every transition.
The MVP source is the simplest one that needs no accounts or API keys: a local file. While the file exists, the light is on; remove it and the light goes off. A background task re-checks the file every few seconds. (Real calendar / Slack / Teams sources are a planned addition behind the same overlay — see Extending it.)
How it works
Section titled “How it works”The light is a paint callback registered on the frame’s overlay hooks. Every time the engine pushes a frame to the panel — in the main scroll/hold loop, mid-transition, or inside an image/gif play() loop — that single swap point runs the overlay hooks on the real physical canvas just before the hardware swap. Because all three render paths route through that one swap, the dot composites over everything with no special per-path handling. Painting is a handful of SetPixel calls in physical pixels, so the per-frame cost is negligible.
The state is read on the render side and written by the background poll task; both run in the same event loop, so the on/off flag needs no locking.
Configuration
Section titled “Configuration”Add a top-level [busy_light] block (disabled by default):
Minimal
[busy_light]enabled = truefile_path = "~/.busy"corner = "top_right"color = [255, 0, 0]size = 4| Field | Type | Default | Notes |
|---|---|---|---|
enabled | bool | false | Master switch. When false, nothing is built and there is zero per-frame cost. |
file_path | string | "~/.busy" | The light is on while this file exists. ~ expands to the home directory. See Docker for the gotcha. |
poll_interval | float | 5.0 | Seconds between file checks. Lower = snappier reaction; the file stat is cheap. |
corner | string | "top_right" | One of top_left, top_right, bottom_left, bottom_right. |
color | [r, g, b] | [255,0,0] | Three ints, each 0–255. Validated at config load. |
size | int | 4 | Dot side length in physical pixels. See Sizing. |
Toggling it
Section titled “Toggling it”When running from a source checkout, the file path is literal:
touch ~/.busy # light ON (within poll_interval)rm ~/.busy # light OFFDocker
Section titled “Docker”This is the one sharp edge. Under Docker — the usual deploy — ~/.busy resolves to the container’s home directory, which your host shell can’t reach, so toggling ~/.busy on the host does nothing. Point file_path at the bind-mounted config dir instead. The compose file mounts ./config:/code/config:ro, so:
Docker
[busy_light]enabled = truefile_path = "/code/config/.busy" # in-container pathtouch config/.busy # on the host, from the repo dir → light ONrm config/.busy # → light OFFThe :ro mount only stops the container from writing; the host can freely create and delete config/.busy, and the container sees the change. The poller only reads the file (exists()), so read-only is fine.
Remote trigger (HTTP)
Section titled “Remote trigger (HTTP)”Instead of a local file, the light can be flipped by an HTTP call over your LAN — ideal for triggering it from another machine such as a work laptop. Set source = "http":
HTTP source
[busy_light]enabled = truesource = "http"http_host = "0.0.0.0"http_port = 8081ttl_seconds = 0 # 0 = explicit on/off; >0 = auto-clear after N secondsSet the shared secret in the LED_TICKER_BUSY_TOKEN environment variable (for example in your .env) — secrets belong in the environment, not in config.toml:
LED_TICKER_BUSY_TOKEN=change-meThe display reads the token env-first. A token = "…" field in [busy_light] still works as a fallback, but it logs a warning at startup telling you to move it to LED_TICKER_BUSY_TOKEN. With neither set, the listener is open to anyone on the LAN.
The listener exposes one route (change-me below stands in for your LED_TICKER_BUSY_TOKEN value):
# turn the light on / off (GET with a query param — easiest for macros)curl "http://PI-HOST:8081/busy?state=on&token=change-me"curl "http://PI-HOST:8081/busy?state=off&token=change-me"
# turn on, then auto-clear after 60 seconds (per-request TTL override)curl "http://PI-HOST:8081/busy?state=on&ttl=60&token=change-me"
# POST with a body works toocurl -X POST "http://PI-HOST:8081/busy" -H "X-Busy-Token: change-me" -d on
# read current statecurl "http://PI-HOST:8081/busy?token=change-me"PI-HOST is your Pi’s .local hostname (e.g. longboi.local) or a DHCP-reserved IP. The token may be passed as the token= query param or an X-Busy-Token: header; a wrong/missing token returns 401. A token in the query string can appear in logs — fine on a trusted LAN; use the header form if you’d rather not.
Exposing the port
Section titled “Exposing the port”-
Docker deploy: add a port mapping to that Pi’s compose service:
services:led-ticker:ports:- "8081:8081" -
Source-checkout / dev: the process binds the host port directly — no mapping needed; just make sure no host firewall blocks it.
TTL (auto-clear)
Section titled “TTL (auto-clear)”A busy light can auto-clear after a set time, so you don’t have to remember to send off. There are two ways to set it:
- Per request — add
&ttl=N(seconds) to astate=oncall:…/busy?state=on&ttl=60. The light turns on and clears itself afterNseconds unless refreshed by anotherstate=on. This overrides the config default for that one request;ttl=0means “stay on until an explicit off”. An invalidttl(non-numeric, negative, or non-finite likeinf/nan) returns400.ttlis always a query parameter — even on a POST it goes in the URL (POST …/busy?ttl=60with the state in the body), never in the request body. - Config default —
ttl_secondsin[busy_light]applies to everystate=onthat omits?ttl=.ttl_seconds = 0(the default) means on-until-explicit-off; set e.g.1800for a 30-minute safety net if the sender sleeps mid-meeting and never sendsoff.
TTL applies only to the HTTP source — the file source is polled directly and never arms a deadline.
Triggering from a Mac
Section titled “Triggering from a Mac”Both methods reduce to one HTTP call, so neither needs anything installed on a managed work laptop:
- Manual hotkey — Keyboard Maestro, Raycast, or a Shortcut with a global key running
curl "http://PI-HOST:8081/busy?state=on&token=…"(and anofftwin). - Focus automation (hands-off) — macOS Shortcuts → Automation → “When [Work] focus turns On” → action Get Contents of URL (GET)
http://PI-HOST:8081/busy?state=on&token=…; add a matching turns Off automation withstate=off. macOS itself is the detector — no background app of your own. - Auto-detect from camera use — for a fully hands-off “on while I’m in a meeting”, watch the camera and hit the endpoint. A ready-made Hammerspoon script ships in the repo at
deploy/busy-light-camera-watcher.lua: it flips the light on whenever any camera turns on (Zoom, Google Meet, FaceTime) and off when they all stop. SetPI_HOST/TOKENat the top and follow the install steps in the file header. Note: a call joined with video off never turns the camera on, so it won’t trigger — camera use is the signal.
Sizing per panel
Section titled “Sizing per panel”size is in physical pixels, so the right value depends on panel height:
| Panel | Height | Suggested size |
|---|---|---|
| Smallsign | 16 px | 3–4 |
| Bigsign / longboi | 64 px | 8–10 |
A size = 4 dot is a quarter of the smallsign’s height — clearly visible. The same 4 on a 64-tall panel is a barely-visible speck, so bump it to 8–10 there. The dot paints last each frame (over the content), so it’s never hidden by what’s underneath.
Extending it (real busy sources)
Section titled “Extending it (real busy sources)”The overlay mechanism is generic and the busy state is a plain flag, so a real source is a drop-in: a new poller whose update() sets the busy flag from an API (Google Calendar free/busy, a Slack/Teams presence endpoint) using the shared HTTP session and the standard background-poll loop, registering the same kind of corner-dot paint hook. Nothing in the frame or the overlay mechanism changes — only where the on/off signal comes from. This is planned, not yet shipped.