Uptime Bar
Connects to your Uptime Kuma instance and draws every monitor as one cell in a wall of pixels: dim green while all is well, then a red takeover naming the site and its downtime when one fails.
By maxswinkels · ▲ 0 · Rate and Discuss
How It Works
Connects to your Uptime Kuma instance and draws every monitor as one cell in a wall of pixels: dim green while all is well, then a red takeover naming the site and its downtime when one fails.
Download and extract the complete app folder. Keep its files together; some apps include additional modules, assets, or configuration examples.
Review app.py and any configuration examples for this app’s options, then run it from the extracted folder:
python3 app.pyConnect BUSY Bar over USB, or use the connection options supported by this app. See the official HTTP API documentation for device setup.
Source Files
These are the mirrored source bytes. The complete package also includes every asset and supporting file.
app.py
#!/usr/bin/env python3
"""Uptime Bar: your Uptime Kuma monitors as a wall of pixels, silent until something goes down.
export KUMA_URL=https://uptime.example.com
export KUMA_API_KEY=<your-api-key> # Uptime Kuma: Settings -> API Keys
python3 app.py # BUSY Bar over USB (always 10.0.4.20)
python3 app.py --host 127.0.0.1:8080 # emulator or a Wi-Fi bar
python3 app.py --status-page mystatus # no API key: read a public status page
python3 app.py --demo # scripted cycle, no network
"""
import argparse
import base64
import json
import os
import re
import struct
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
import zlib
APP = "uptime-bar"
W, H = 72, 16
# Monitor states, matching Uptime Kuma's own numbering.
DOWN, UP, PENDING, MAINTENANCE = 0, 1, 2, 3
# Cell colors. UP is deliberately dim: a healthy wall should not demand attention.
CELL_COLORS = {
UP: (26, 160, 74),
DOWN: (239, 68, 68),
PENDING: (245, 158, 11),
MAINTENANCE: (59, 130, 246),
}
EMPTY = (0, 0, 0)
STATE_PATH = os.path.join(
os.environ.get("XDG_CACHE_HOME") or os.path.expanduser("~/.cache"),
"busybar-uptime-bar.json",
)
def parse_args():
p = argparse.ArgumentParser(description="Uptime Kuma monitor wall for BUSY Bar")
p.add_argument("--host", default="10.0.4.20")
p.add_argument("--kuma-url", default=os.environ.get("KUMA_URL", ""),
help="Uptime Kuma base URL (or set KUMA_URL)")
p.add_argument("--api-key", default=os.environ.get("KUMA_API_KEY", ""),
help="API key for /metrics (or set KUMA_API_KEY)")
p.add_argument("--status-page", default=os.environ.get("KUMA_STATUS_PAGE", ""),
help="read a public status page by slug instead of /metrics")
p.add_argument("--interval", type=int, default=30, help="seconds between polls")
p.add_argument("--recovery-hold", type=int, default=5,
help="seconds to show the recovery flash")
p.add_argument("--rotate", type=float, default=3.0,
help="seconds per site when several are down")
p.add_argument("--no-sound", action="store_true", help="stay silent on a new outage")
p.add_argument("--test", action="store_true", help="draw one frame and exit")
p.add_argument("--demo", action="store_true", help="scripted cycle on fake data")
p.add_argument("--tour", action="store_true",
help="walk every screen and grid size in turn, on fake data")
p.add_argument("--dump", action="store_true", help="print parsed monitors and exit")
return p.parse_args()
# ---------------------------------------------------------------------------
# BUSY Bar HTTP API (stdlib only; docs: http://10.0.4.20/docs)
# ---------------------------------------------------------------------------
def _base(host):
return "http://" + host.replace("http://", "").replace("https://", "").rstrip("/")
def _draw(host, elements, priority=30, led=None):
"""POST /api/display/draw. Returns the HTTP status; 409 means a busier app owns the screen."""
body = {"application_name": APP, "priority": priority, "elements": elements}
if led:
body["led_notification_color"] = led
req = urllib.request.Request(
_base(host) + "/api/display/draw",
data=json.dumps(body).encode(), method="POST",
headers={"Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(req, timeout=8) as r:
return r.getcode()
except urllib.error.HTTPError as e:
return e.code
def _clear(host):
qs = urllib.parse.urlencode({"application_name": APP})
req = urllib.request.Request(_base(host) + "/api/display/draw?" + qs, method="DELETE")
try:
with urllib.request.urlopen(req, timeout=8) as r:
return r.getcode()
except urllib.error.HTTPError as e:
return e.code
def _upload(host, filename, data):
qs = urllib.parse.urlencode({"application_name": APP, "file": filename})
req = urllib.request.Request(
_base(host) + "/api/assets/upload?" + qs, data=data, method="POST",
headers={"Content-Type": "application/octet-stream"},
)
try:
with urllib.request.urlopen(req, timeout=10) as r:
return r.getcode()
except urllib.error.HTTPError as e:
return e.code
def _play(host, stock_path):
body = {"application_name": APP, "stock_path": stock_path}
req = urllib.request.Request(
_base(host) + "/api/audio/play", data=json.dumps(body).encode(), method="POST",
headers={"Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(req, timeout=5) as r:
return r.getcode()
except urllib.error.HTTPError as e:
return e.code
def _png(pixels):
"""Encode a W*H list of (r, g, b) tuples as an RGBA PNG. No Pillow needed."""
raw = bytearray()
for y in range(H):
raw.append(0)
for x in range(W):
r, g, b = pixels[y * W + x]
raw += bytes((r, g, b, 255))
def chunk(tag, data):
c = tag + data
return struct.pack(">I", len(data)) + c + struct.pack(">I", zlib.crc32(c) & 0xFFFFFFFF)
return (b"\x89PNG\r\n\x1a\n"
+ chunk(b"IHDR", struct.pack(">IIBBBBB", W, H, 8, 6, 0, 0, 0))
+ chunk(b"IDAT", zlib.compress(bytes(raw), 6))
+ chunk(b"IEND", b""))
# ---------------------------------------------------------------------------
# Uptime Kuma
# ---------------------------------------------------------------------------
_METRIC_RE = re.compile(r'^monitor_status\{(?P<labels>.*)\}\s+(?P<value>-?\d+(?:\.\d+)?)\s*$')
_NAME_RE = re.compile(r'monitor_name="((?:[^"\\]|\\.)*)"')
def _parse_metrics(text):
"""Pull (name, status) out of Uptime Kuma's Prometheus /metrics output."""
monitors = []
for line in text.splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
m = _METRIC_RE.match(line)
if not m:
continue
name = _NAME_RE.search(m.group("labels"))
if not name:
continue
label = name.group(1).replace('\\"', '"').replace("\\\\", "\\").replace("\\n", " ")
try:
status = int(float(m.group("value")))
except ValueError:
continue
monitors.append((label, status))
return monitors
def _fetch_metrics(url, api_key):
"""GET {url}/metrics with Basic auth: empty username, API key as the password."""
req = urllib.request.Request(url.rstrip("/") + "/metrics",
headers={"User-Agent": "busybar-uptime-bar/1.0"})
if api_key:
token = base64.b64encode(("" + ":" + api_key).encode()).decode()
req.add_header("Authorization", "Basic " + token)
with urllib.request.urlopen(req, timeout=15) as r:
return _parse_metrics(r.read().decode("utf-8", "ignore"))
def _get_json(url):
req = urllib.request.Request(url, headers={"User-Agent": "busybar-uptime-bar/1.0"})
with urllib.request.urlopen(req, timeout=15) as r:
return json.loads(r.read().decode("utf-8", "ignore"))
def _fetch_status_page(url, slug):
"""Fallback for instances without an API key: a public status page needs no auth."""
base = url.rstrip("/")
page = _get_json(f"{base}/api/status-page/{urllib.parse.quote(slug)}")
names = {}
for group in page.get("publicGroupList") or []:
for mon in group.get("monitorList") or []:
names[str(mon.get("id"))] = mon.get("name") or str(mon.get("id"))
beats = _get_json(f"{base}/api/status-page/heartbeat/{urllib.parse.quote(slug)}")
monitors = []
for mid, series in (beats.get("heartbeatList") or {}).items():
if not series:
continue
monitors.append((names.get(str(mid), str(mid)), int(series[-1].get("status", UP))))
return monitors
def _demo_monitors(elapsed):
"""A scripted cycle so the app can be previewed without a Kuma instance."""
names = ["alpha.example.com", "api.example.com", "blog.example.net", "cdn.example.com",
"checkout.example.com", "docs.example.io", "mail.example.com", "shop.example.com",
"status.example.com", "store.example.net", "support.example.com", "www.example.com"]
names += [f"node-{i:02d}.example.com" for i in range(1, 41)]
names.sort()
states = {n: UP for n in names}
states[names[3]] = MAINTENANCE
phase = elapsed % 16.0
if 3.0 <= phase < 4.0:
states["checkout.example.com"] = PENDING
if 4.0 <= phase < 10.0:
states["checkout.example.com"] = DOWN
if 6.5 <= phase < 10.0:
states["mail.example.com"] = DOWN
return sorted(states.items())
TOUR_LONG = "very-long-hostname.example.com"
def _tour_names(count):
fixed = ["alpha.example.com", "api.example.com", "blog.example.net", "cdn.example.com",
"checkout.example.com", "docs.example.io", "mail.example.com", "shop.example.com"]
names = fixed[:count]
names += [f"node-{i:02d}.example.com" for i in range(1, count - len(names) + 1)]
return sorted(names)
def _tour_monitors(elapsed):
"""Walk every screen in turn: each grid density, then every alert state.
Only the monitor list is scripted; the outage timer, rotation and recovery
flash all come from the normal code path, so the tour exercises the real logic.
"""
t = elapsed % 34.0
if t < 3.0:
return [(n, UP) for n in _tour_names(8)] # sparse wall
if t < 6.0:
return [(n, UP) for n in _tour_names(26)] # two rows
if t >= 29.0:
return [(n, UP) for n in _tour_names(120)] # dense 1px layout
names = sorted(_tour_names(51) + [TOUR_LONG])
states = {n: UP for n in names}
states["cdn.example.com"] = MAINTENANCE # blue cell, from 6s on
if 9.0 <= t < 11.0:
states["checkout.example.com"] = PENDING # amber cell
if 11.0 <= t < 23.0:
states["checkout.example.com"] = DOWN # single-outage takeover
if 16.0 <= t < 23.0:
states["mail.example.com"] = DOWN # rotation between sites
states[TOUR_LONG] = DOWN # name long enough to scroll
return sorted(states.items())
# ---------------------------------------------------------------------------
# Rendering
# ---------------------------------------------------------------------------
def grid_layout(count):
"""Pick a cell size that fits `count` monitors on 72x16, densest-readable first."""
for cell_w, pitch_x, cell_h, pitch_y in ((2, 3, 3, 4), (1, 2, 3, 4), (1, 2, 1, 2)):
cols = (W + pitch_x - cell_w) // pitch_x
rows = (H + pitch_y - cell_h) // pitch_y
if cols * rows >= count:
return cell_w, pitch_x, cell_h, pitch_y, cols, rows
return 1, 2, 1, 2, (W + 1) // 2, (H + 1) // 2
def render_grid(monitors):
"""Monitors as a wall of cells, row-major in the caller's (alphabetical) order."""
px = [EMPTY] * (W * H)
cell_w, pitch_x, cell_h, pitch_y, cols, rows = grid_layout(len(monitors))
# Center on the rows actually in use, so a half-empty wall still sits level.
used_rows = max(1, min(rows, -(-len(monitors) // cols)))
used_cols = min(cols, len(monitors))
ox = max(0, (W - (used_cols * pitch_x - (pitch_x - cell_w))) // 2)
oy = max(0, (H - (used_rows * pitch_y - (pitch_y - cell_h))) // 2)
for i, (_, status) in enumerate(monitors[:cols * rows]):
col, row = i % cols, i // cols
color = CELL_COLORS.get(status, CELL_COLORS[PENDING])
for dy in range(cell_h):
y = oy + row * pitch_y + dy
if not (0 <= y < H):
continue
for dx in range(cell_w):
x = ox + col * pitch_x + dx
if 0 <= x < W:
px[y * W + x] = color
return px
def _text(eid, txt, y, font, color, scroll=False):
el = {"id": eid, "type": "text", "text": txt, "y": y, "font": font, "color": color}
if scroll:
el.update({"x": 0, "align": "top_left", "width": W,
"scroll_rate": 600, "scroll_start_delay": 800, "scroll_repeat_delay": 1200})
else:
el.update({"x": W // 2, "align": "top_mid"})
return el
# Elements are parked off-screen rather than dropped: the firmware keeps a
# persistent, id-keyed set per app and never releases what you stop sending.
def park(eid):
return {"id": eid, "type": "text", "text": " ", "x": -400, "y": 0,
"font": "tiny", "color": "#00000000"}
def fmt_duration(seconds):
seconds = max(0, int(seconds))
if seconds < 60:
return "%dS" % seconds
if seconds < 3600:
return "%dM" % (seconds // 60)
if seconds < 86400:
return "%dH%02d" % (seconds // 3600, (seconds % 3600) // 60)
return "%dD%02dH" % (seconds // 86400, (seconds % 86400) // 3600)
# Per-glyph advance widths for ASCII 32..126, taken from the device font atlas.
# A flat estimate is too coarse here: "mail.example.com" measures 60 px, but
# 16 chars x 5 px would call it 80 and scroll a name that fits comfortably.
_ADVANCE = {
"tiny": "32464552334424254444444444224444444444444445465545444566465353443444443342242644443444464444245",
"small": "22464552334423234344444444234444555555555245465555554546444333440444443442342644443434464444245",
}
def text_width(txt, font="small"):
table = _ADVANCE.get(font)
if not table:
return len(txt) * 6
total = 0
for ch in txt:
i = ord(ch) - 32
total += int(table[i]) if 0 <= i < len(table) else 6
return total
def fits(txt, font="small"):
return text_width(txt, font) <= W
# ---------------------------------------------------------------------------
# Outage bookkeeping
# ---------------------------------------------------------------------------
def load_state():
try:
with open(STATE_PATH, "r", encoding="utf-8") as fh:
data = json.load(fh)
return {k: float(v) for k, v in (data.get("down_since") or {}).items()}
except (OSError, ValueError, AttributeError):
return {}
def save_state(down_since):
try:
os.makedirs(os.path.dirname(STATE_PATH), exist_ok=True)
tmp = STATE_PATH + ".tmp"
with open(tmp, "w", encoding="utf-8") as fh:
json.dump({"down_since": down_since}, fh)
os.replace(tmp, STATE_PATH)
except OSError:
pass # a missing cache only costs us the outage timer across restarts
class Screen:
"""Owns the three element ids and only pushes what actually changed."""
def __init__(self, host):
self.host = host
self.frame_no = 0
self.last_png = None
self.last_push = 0.0
self.grid_file = None # last asset name we know the device accepted
self.priority = 0
self.sent = {}
def _send(self, elements, priority, led=None):
# The firmware refuses a lower-priority draw even from the current
# owner, so stepping down from an alert means releasing the screen first.
if priority < self.priority:
_clear(self.host)
self.sent.clear()
self.priority = priority
changed = [el for el in elements if self.sent.get(el["id"]) != el]
if not changed and not led:
return True
status = _draw(self.host, changed or elements, priority=priority, led=led)
if status == 409:
return False # someone louder owns the screen; try again next tick
if status >= 400:
print(f"draw failed: HTTP {status}", file=sys.stderr)
return False
for el in changed:
self.sent[el["id"]] = el
return True
def show_grid(self, monitors):
data = _png(render_grid(monitors))
now = time.monotonic()
# Re-upload only when the wall actually changed, but refresh once a
# minute anyway so the element can never quietly expire on the device.
if data != self.last_png or self.grid_file is None or now - self.last_push > 60:
fn = "grid%d.png" % (self.frame_no % 4)
self.frame_no += 1
if _upload(self.host, fn, data) >= 400:
return
self.last_png, self.last_push, self.grid_file = data, now, fn
bg = {"id": "bg", "type": "image", "path": self.grid_file, "x": 0, "y": 0}
self._send([bg, park("t1"), park("t2")], priority=30)
def show_message(self, name, label, label_color, priority, led=None):
elements = []
if "bg" in self.sent: # nothing to hide until a grid has actually been drawn
elements.append({"id": "bg", "type": "image", "path": self.grid_file,
"x": -400, "y": 0})
elements += [
_text("t1", name, 1, "small", "#FFFFFFFF", scroll=not fits(name, "small")),
_text("t2", label, 10, "tiny", label_color),
]
self._send(elements, priority=priority, led=led)
def main():
args = parse_args()
if args.dump:
monitors = _fetch_metrics(args.kuma_url, args.api_key) if not args.status_page \
else _fetch_status_page(args.kuma_url, args.status_page)
for name, status in sorted(monitors):
print(f"{status} {name}")
print(f"\n{len(monitors)} monitors")
return
# --test means "show me a frame", so fall back to demo data rather than
# refusing: it keeps the app smoke-testable without a Kuma instance.
if args.test and not args.demo and not args.tour and not args.kuma_url:
print("no KUMA_URL set, drawing demo data", file=sys.stderr)
args.demo = True
offline = args.demo or args.tour
configured = bool(offline or args.kuma_url)
if not configured:
print("no Kuma instance: set KUMA_URL (and KUMA_API_KEY), or pass --demo",
file=sys.stderr)
screen = Screen(args.host)
down_since = {} if offline else load_state()
monitors, last_poll = [], 0.0
recovered_until, recovered_name, recovery_led = 0.0, "", False
outage_seen = set()
started = time.monotonic()
print(f"{APP} -> {_base(args.host)} (Ctrl-C to stop)")
try:
while True:
now = time.monotonic()
if not configured:
# Say so on the bar itself, not just in a terminal nobody is watching.
screen.show_message("UPTIME BAR", "SET KUMA_URL", "#F59E0BFF", priority=30)
if args.test:
break
time.sleep(2.0)
continue
if args.tour:
monitors = _tour_monitors(now - started)
elif args.demo:
monitors = _demo_monitors(now - started)
elif now - last_poll >= args.interval or not last_poll:
try:
monitors = _fetch_status_page(args.kuma_url, args.status_page) \
if args.status_page else _fetch_metrics(args.kuma_url, args.api_key)
monitors.sort()
last_poll = now
except Exception as e:
print(f"fetch error (reusing previous data): {e}", file=sys.stderr)
last_poll = now # never hammer a failing instance
if not monitors:
time.sleep(1.0)
continue
down = [name for name, status in monitors if status == DOWN]
for name in down:
down_since.setdefault(name, time.time())
for name in [n for n in down_since if n not in down]:
del down_since[name]
recovered_until, recovered_name = now + args.recovery_hold, name
recovery_led = True
outage_seen.discard(name)
if not offline:
save_state(down_since)
if down:
new_outage = [n for n in down if n not in outage_seen]
if new_outage and not args.no_sound:
_play(args.host, "calendar_event_starts")
outage_seen.update(down)
idx = int(now / args.rotate) % len(down) if len(down) > 1 else 0
name = sorted(down)[idx]
elapsed = time.time() - down_since.get(name, time.time())
label = "DOWN " + fmt_duration(elapsed)
if len(down) > 1:
label += " %d/%d" % (idx + 1, len(down))
screen.show_message(name, label, "#EF4444FF", priority=60,
led="#EF4444FF" if new_outage else None)
elif now < recovered_until:
screen.show_message(recovered_name, "BACK UP", "#22C55EFF", priority=60,
led="#22C55EFF" if recovery_led else None)
recovery_led = False
else:
screen.show_grid(monitors)
if args.test:
break
time.sleep(1.0)
except KeyboardInterrupt:
print("\nstopped.")
finally:
_clear(args.host)
if __name__ == "__main__":
main()manifest.yaml
name: Uptime Bar
author: maxswinkels
description: "Connects to your Uptime Kuma instance and draws every monitor as one cell in a wall of pixels: dim green while all is well, then a red takeover naming the site and its downtime when one fails."
tags:
- monitor
- info
- utility
preview: ./preview.gifSource and Provenance
Source commit: 26b19ba9ce2ba192c45b4c0eadcfad53df42b153
Content SHA-256: 5733a86f14bfdaaa89abb7c5320d45d170b40d14e7a476e0ea1facef3a8d824b
Rate and Discuss
Comments and reactions live in this site’s GitHub repository. Sign in with GitHub to participate.





