Text Display

Text Display

by @LuisWollenschneider

Show some text. That's it!

zero-install infotext

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
"""Text Display: show any text in the biggest font, centered, scrolling if it is too long.

    python app.py --text "Hello"                       # BUSY Bar over USB (always 10.0.4.20)
    python app.py --text "Hello" --color "#FF0000"     # custom color (alpha defaults to FF)
    python app.py --text "Hello" --host 127.0.0.1:8080 # emulator or a Wi-Fi bar
"""
import argparse
import json
import re
import sys
import time
import urllib.error
import urllib.request

APP = "text-display"

# The matrix is 72x16. extra_large glyphs are 16px tall, so the text is
# vertically centered by anchoring it at y = 8 with a mid_* / center align.
WIDTH = 72
MID_Y = 8

DEFAULT_COLOR = "#519ABAFF"

# Scroll settings (see openapi.yaml, TextElement): scroll_rate is in pixels
# per MINUTE, the delays are milliseconds. 360 px/min is ~6 px/sec, a
# comfortable reading pace. Scrolling only happens when the text is wider
# than the element's `width`, so short text just sits still.
SCROLL_RATE = 1200
SCROLL_START_DELAY = 800
SCROLL_REPEAT_DELAY = 2000

# extra_large advance widths for printable ASCII (0x20..0x7E), one character
# per glyph as chr(48 + advance_px) — i.e. "4" is 4px. Taken from the firmware
# font atlas, so "does this fit in 72px?" is exact rather than guessed.
ADVANCES = "436:9;:3557734368588888888335757<88887788578798888887899887464858888877885787988888878998876368"
# (` has no glyph in the atlas and is listed with the common 8px advance.)


# ---------------------------------------------------------------------------
# BUSY Bar HTTP API — self-contained, stdlib only.
# Over USB the bar is always at 10.0.4.20; --host targets a Wi-Fi bar or the
# emulator. Full API docs are served by the device: http://10.0.4.20/docs
# ---------------------------------------------------------------------------

# argparse so busybar-manager can auto-discover options by parsing --help.
def _args():
    p = argparse.ArgumentParser(description="Display text on BUSY Bar in extra_large font")
    p.add_argument("--host", default="10.0.4.20")
    p.add_argument("--text", required=True, help="text to display (printable ASCII)")
    p.add_argument("--color", default=DEFAULT_COLOR,
                   help="text color as #RRGGBB or #RRGGBBAA (default %(default)s)")
    return p.parse_args()


def parse_color(value):
    """Accept #RRGGBB or #RRGGBBAA; the API always wants #RRGGBBAA, so a
    missing alpha channel becomes FF (fully opaque)."""
    if re.fullmatch(r"#[0-9a-fA-F]{6}", value):
        return (value + "FF").upper()
    if re.fullmatch(r"#[0-9a-fA-F]{8}", value):
        return value.upper()
    sys.exit(f"error: --color must be #RRGGBB or #RRGGBBAA, got {value!r}")


_ARGS = _args()
BASE = "http://" + _ARGS.host.replace("http://", "").rstrip("/")
COLOR = parse_color(_ARGS.color)


def draw(elements, **extra):
    body = {"application_name": APP, "elements": elements, **extra}
    req = urllib.request.Request(BASE + "/api/display/draw",
                                 data=json.dumps(body).encode(), method="POST",
                                 headers={"Content-Type": "application/json"})
    with urllib.request.urlopen(req, timeout=5):
        pass


def text(txt, x=0, y=0, font="normal", color="#FFFFFFFF", **kw):
    # Every element needs an id, and colors are #RRGGBBAA (API 25.0.0+).
    return {"id": "0", "type": "text", "text": str(txt), "x": x, "y": y, "font": font, "color": color, **kw}


# ---------------------------------------------------------------------------
# App
# ---------------------------------------------------------------------------

def text_width(txt):
    """Rendered width in pixels of txt in the extra_large font."""
    return sum(ord(ADVANCES[ord(c) - 32]) - 48 for c in txt)


def element_for(txt):
    """Centered when the text fits the 72px matrix, otherwise a full-width
    label that the device scrolls for us."""
    if text_width(txt) <= WIDTH:
        # align anchors the element box, so no exact text measuring is needed.
        return text(txt, x=WIDTH // 2, y=MID_Y, font="extra_large", color=COLOR, align="center")
    return text(txt, x=0, y=MID_Y, font="extra_large", color=COLOR, align="mid_left",
                width=WIDTH, scroll_rate=SCROLL_RATE,
                scroll_start_delay=SCROLL_START_DELAY,
                scroll_repeat_delay=SCROLL_REPEAT_DELAY)


def tick(element):
    """Returns True once the element is on screen. Redrawing restarts the
    scroll animation, so the loop below only calls this until it succeeds."""
    try:
        draw([element])
        return True
    except urllib.error.HTTPError as e:
        # A higher-priority app owns the screen: keep retrying.
        if e.code != 409:
            raise
        print("display busy (409), retrying...")
        return False


if __name__ == "__main__":
    if not re.fullmatch(r"[\x20-\x7E]+", _ARGS.text):
        sys.exit("error: --text must be non-empty printable ASCII (the fonts are bitmap ASCII)")

    element = element_for(_ARGS.text)
    scrolling = "scroll_rate" in element
    print(f"text-display → {BASE}  ({'scrolling' if scrolling else 'centered'}, Ctrl-C to stop)")
    try:
        # The element has no timeout, so it stays up; the loop is here to keep
        # the app alive and to retry while the display is owned by another app.
        while not tick(element):
            time.sleep(2.0)
        while True:
            time.sleep(1.0)
    except KeyboardInterrupt:
        print("\nstopped.")
    except urllib.error.HTTPError as e:
        sys.exit(f"error: HTTP {e.code} — {e.read().decode('utf-8', 'ignore')}")
    except urllib.error.URLError as e:
        sys.exit(f"error: cannot reach {BASE} — {e.reason}")
View source on GitHub →