Skip to content
led-ticker

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.)

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.

Add a top-level [busy_light] block (disabled by default):

Minimal

[busy_light]
enabled = true
file_path = "~/.busy"
corner = "top_right"
color = [255, 0, 0]
size = 4
FieldTypeDefaultNotes
enabledboolfalseMaster switch. When false, nothing is built and there is zero per-frame cost.
file_pathstring"~/.busy"The light is on while this file exists. ~ expands to the home directory. See Docker for the gotcha.
poll_intervalfloat5.0Seconds between file checks. Lower = snappier reaction; the file stat is cheap.
cornerstring"top_right"One of top_left, top_right, bottom_left, bottom_right.
color[r, g, b][255,0,0]Three ints, each 0255. Validated at config load.
sizeint4Dot side length in physical pixels. See Sizing.

When running from a source checkout, the file path is literal:

Terminal window
touch ~/.busy # light ON (within poll_interval)
rm ~/.busy # light OFF

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 = true
file_path = "/code/config/.busy" # in-container path
Terminal window
touch config/.busy # on the host, from the repo dir → light ON
rm config/.busy # → light OFF

The :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.

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 = true
source = "http"
http_host = "0.0.0.0"
http_port = 8081
ttl_seconds = 0 # 0 = explicit on/off; >0 = auto-clear after N seconds

Set 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:

Terminal window
LED_TICKER_BUSY_TOKEN=change-me

The 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):

Terminal window
# 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 too
curl -X POST "http://PI-HOST:8081/busy" -H "X-Busy-Token: change-me" -d on
# read current state
curl "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.

  • 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.

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 a state=on call: …/busy?state=on&ttl=60. The light turns on and clears itself after N seconds unless refreshed by another state=on. This overrides the config default for that one request; ttl=0 means “stay on until an explicit off”. An invalid ttl (non-numeric, negative, or non-finite like inf/nan) returns 400. ttl is always a query parameter — even on a POST it goes in the URL (POST …/busy?ttl=60 with the state in the body), never in the request body.
  • Config defaultttl_seconds in [busy_light] applies to every state=on that omits ?ttl=. ttl_seconds = 0 (the default) means on-until-explicit-off; set e.g. 1800 for a 30-minute safety net if the sender sleeps mid-meeting and never sends off.

TTL applies only to the HTTP source — the file source is polled directly and never arms a deadline.

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 an off twin).
  • 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 with state=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. Set PI_HOST/TOKEN at 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.

size is in physical pixels, so the right value depends on panel height:

PanelHeightSuggested size
Smallsign16 px34
Bigsign / longboi64 px810

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 810 there. The dot paints last each frame (over the content), so it’s never hidden by what’s underneath.

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.