Uptime Bar

Uptime Bar

by @maxswinkels

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.

zero-install monitorinfoutility

How to run

This app is a single Python file that talks directly to the BUSY Bar HTTP API: no libraries, no setup. Connect your bar via USB and run:

python app.py

Over USB the bar is always at 10.0.4.20. On Wi-Fi, pass the bar's IP with --host <address>.

No bar at hand? Run the BUSY Bar Emulator and point the app at it: python app.py --host 127.0.0.1:8080.

To keep it running without a terminal open, install it with busybar-manager, which handles the virtualenv, autostart and scheduling, and lets you switch between apps from a web dashboard.

Source code

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()
View source on GitHub →