Clock
Customizable Clock widget - customizable, big time, refreshed every second.
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
"""Clock widget: big time, refreshed every second.
python app.py # original/default view
python app.py --host 127.0.0.1:8080 # emulator or a Wi-Fi bar
python app.py --no-seconds # show HH:MM only
python app.py --color '#00FFAA' # custom time color
python app.py --date # add a second date line
python app.py --date --date-format written --language it
python app.py --icon clock # clock, hourglass, calendar
python app.py --date --icon calendar --color '#FFD54F'
Icons are true 16x16 RGBA PNG assets generated in memory and uploaded once at
startup. They occupy the rightmost 16 pixels, while text stays in its own area.
"""
import argparse
import json
import math
import re
import signal
import struct
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
import zlib
APP = "clock"
ICON_SIZE = 16
ICON_X = 56
ICON_Y = 0
TEXT_AREA_MID_X = 27
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def _color(value):
"""Accept RRGGBB or RRGGBBAA, with or without '#', and return #RRGGBBAA."""
value = value.strip().lstrip("#")
if not re.fullmatch(r"[0-9a-fA-F]{6}([0-9a-fA-F]{2})?", value):
raise argparse.ArgumentTypeError("color must be RRGGBB or RRGGBBAA")
if len(value) == 6:
value += "FF"
return "#" + value.upper()
def _args():
# argparse lets busybar-manager auto-discover options by parsing --help.
p = argparse.ArgumentParser(description="Clock widget for BUSY Bar")
p.add_argument("--host", default="10.0.4.20")
p.add_argument("--no-seconds", action="store_true",
help="show HH:MM only, hide the seconds")
p.add_argument("--color", type=_color, default="#FFFFFFFF",
help="time color as RRGGBB or RRGGBBAA (default: FFFFFFFF)")
p.add_argument("--date", action="store_true",
help="show the date on a second line")
p.add_argument("--date-format", choices=["numeric", "written"], default="numeric",
help="second-line date format (default: numeric)")
p.add_argument("--language", choices=["en", "it", "fr", "de", "es", "pt"], default="en",
help="language used by --date-format written (default: en)")
p.add_argument("--date-color", type=_color, default=None,
help="date color; defaults to --color")
p.add_argument("--icon", choices=["clock", "hourglass", "calendar"], default=None,
help="show a large 16x16 color icon on the right")
p.add_argument("--icon-color", type=_color, default=None,
help="override the icon's main accent color")
return p.parse_args()
_ARGS = _args()
BASE = "http://" + _ARGS.host.replace("http://", "").rstrip("/")
TIME_FMT = "%H:%M" if _ARGS.no_seconds else "%H:%M:%S"
DATE_COLOR = _ARGS.date_color or _ARGS.color
# ---------------------------------------------------------------------------
# Localized date names
# ---------------------------------------------------------------------------
WEEKDAYS = {
"en": ["MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN"],
"it": ["LUN", "MAR", "MER", "GIO", "VEN", "SAB", "DOM"],
"fr": ["LUN", "MAR", "MER", "JEU", "VEN", "SAM", "DIM"],
"de": ["MO", "DI", "MI", "DO", "FR", "SA", "SO"],
"es": ["LUN", "MAR", "MIE", "JUE", "VIE", "SAB", "DOM"],
"pt": ["SEG", "TER", "QUA", "QUI", "SEX", "SAB", "DOM"],
}
MONTHS = {
"en": ["JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"],
"it": ["GEN", "FEB", "MAR", "APR", "MAG", "GIU", "LUG", "AGO", "SET", "OTT", "NOV", "DIC"],
"fr": ["JAN", "FEV", "MAR", "AVR", "MAI", "JUN", "JUL", "AOU", "SEP", "OCT", "NOV", "DEC"],
"de": ["JAN", "FEB", "MAR", "APR", "MAI", "JUN", "JUL", "AUG", "SEP", "OKT", "NOV", "DEZ"],
"es": ["ENE", "FEB", "MAR", "ABR", "MAY", "JUN", "JUL", "AGO", "SEP", "OCT", "NOV", "DIC"],
"pt": ["JAN", "FEV", "MAR", "ABR", "MAI", "JUN", "JUL", "AGO", "SET", "OUT", "NOV", "DEZ"],
}
def format_date(now):
if _ARGS.date_format == "numeric":
return time.strftime("%d/%m/%Y", now)
weekday = WEEKDAYS[_ARGS.language][now.tm_wday]
month = MONTHS[_ARGS.language][now.tm_mon - 1]
return f"{weekday} {now.tm_mday:02d} {month}"
# ---------------------------------------------------------------------------
# BUSY Bar HTTP helpers
# ---------------------------------------------------------------------------
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 clear():
"""Remove every element owned by this app from the display."""
query = urllib.parse.urlencode({"application_name": APP})
req = urllib.request.Request(BASE + "/api/display/draw?" + query, method="DELETE")
with urllib.request.urlopen(req, timeout=5):
pass
def _safe_clear():
try:
clear()
except urllib.error.HTTPError as exc:
# 404/409/410 are harmless during shutdown or when another app owns the bar.
if exc.code not in (404, 409, 410):
print(f"clear failed: HTTP {exc.code}", file=sys.stderr)
except urllib.error.URLError as exc:
print(f"clear failed: {exc.reason}", file=sys.stderr)
def _upload_asset(filename, data):
query = urllib.parse.urlencode({"application_name": APP, "file": filename})
url = BASE + "/api/assets/upload?" + query
# A just-cleared asset can briefly still be locked by the display. Retry 508
# a few times rather than failing startup.
for attempt in range(3):
req = urllib.request.Request(
url,
data=data,
method="POST",
headers={"Content-Type": "application/octet-stream"},
)
try:
with urllib.request.urlopen(req, timeout=5):
return
except urllib.error.HTTPError as exc:
if exc.code == 508 and attempt < 2:
time.sleep(0.1)
continue
raise
def text(txt, x=0, y=0, font="normal", color="#FFFFFFFF", el_id="0", **kw):
return {
"id": el_id,
"type": "text",
"text": str(txt),
"x": x,
"y": y,
"font": font,
"color": color,
**kw,
}
def image(path, x, y, el_id="icon"):
return {"id": el_id, "type": "image", "path": path, "x": x, "y": y}
# ---------------------------------------------------------------------------
# 16x16 color icon generation (stdlib only)
# ---------------------------------------------------------------------------
def _rgba(hex_color):
value = hex_color.lstrip("#")
return tuple(int(value[i:i + 2], 16) for i in range(0, 8, 2))
def _blank_icon():
return [(0, 0, 0, 0)] * (ICON_SIZE * ICON_SIZE)
def _px(buf, x, y, color):
if 0 <= x < ICON_SIZE and 0 <= y < ICON_SIZE:
buf[y * ICON_SIZE + x] = color
def _rect(buf, x, y, width, height, color):
for yy in range(y, y + height):
for xx in range(x, x + width):
_px(buf, xx, yy, color)
def _line(buf, x0, y0, x1, y1, color):
"""Small integer Bresenham line for clock hands."""
dx = abs(x1 - x0)
sx = 1 if x0 < x1 else -1
dy = -abs(y1 - y0)
sy = 1 if y0 < y1 else -1
err = dx + dy
while True:
_px(buf, x0, y0, color)
if x0 == x1 and y0 == y1:
break
e2 = 2 * err
if e2 >= dy:
err += dy
x0 += sx
if e2 <= dx:
err += dx
y0 += sy
def _clock_icon(accent=None):
buf = _blank_icon()
ring = _rgba(accent or "#2F80EDFF")
face = _rgba("#D9F1FFFF")
shadow = _rgba("#123A5AFF")
tick = _rgba("#3F5D73FF")
hands = _rgba("#102A43FF")
second = _rgba("#FF4D5AFF")
center = _rgba("#FFD54FFF")
# Antialiased-looking circular face from two crisp pixel radii.
for y in range(ICON_SIZE):
for x in range(ICON_SIZE):
d = math.hypot(x - 7.5, y - 7.5)
if d <= 7.3:
_px(buf, x, y, shadow)
if d <= 6.5:
_px(buf, x, y, ring)
if d <= 5.5:
_px(buf, x, y, face)
for x, y in ((7, 3), (12, 7), (7, 12), (3, 7)):
_px(buf, x, y, tick)
_line(buf, 7, 7, 7, 4, hands)
_line(buf, 7, 7, 11, 8, hands)
_line(buf, 7, 7, 5, 11, second)
_rect(buf, 7, 7, 2, 2, center)
return buf
def _hourglass_icon(accent=None):
buf = _blank_icon()
frame_dark = _rgba("#6B451AFF")
frame = _rgba(accent or "#D99A2BFF")
frame_light = _rgba("#FFD36AFF")
glass_edge = _rgba("#4B9FC5FF")
glass = _rgba("#BFEFFFFF")
sand = _rgba("#FFB72BFF")
sand_light = _rgba("#FFD56AFF")
# Wooden/golden caps and narrow side supports.
_rect(buf, 2, 1, 12, 2, frame_dark)
_rect(buf, 3, 2, 10, 1, frame)
_rect(buf, 3, 13, 10, 1, frame)
_rect(buf, 2, 14, 12, 1, frame_dark)
_rect(buf, 3, 3, 1, 10, frame)
_rect(buf, 12, 3, 1, 10, frame)
_px(buf, 4, 2, frame_light)
_px(buf, 11, 13, frame_light)
# Glass narrows toward the neck.
for x in range(4, 12):
_px(buf, x, 4, glass_edge)
for x in range(5, 11):
_px(buf, x, 5, glass)
for x in range(6, 10):
_px(buf, x, 6, glass)
_rect(buf, 7, 7, 2, 2, glass)
for x in range(6, 10):
_px(buf, x, 9, glass)
for x in range(5, 11):
_px(buf, x, 10, glass)
for x in range(4, 12):
_px(buf, x, 11, glass_edge)
# Sand: a small upper pile, falling stream, and lower mound.
_rect(buf, 5, 5, 6, 1, sand_light)
_rect(buf, 6, 6, 4, 1, sand)
_rect(buf, 7, 7, 2, 3, sand)
_rect(buf, 6, 10, 4, 1, sand_light)
_rect(buf, 5, 11, 6, 1, sand)
return buf
def _calendar_icon(accent=None):
buf = _blank_icon()
outline = _rgba("#294A63FF")
header = _rgba(accent or "#E64B4BFF")
header_light = _rgba("#FF7777FF")
paper = _rgba("#F8FCFFFF")
paper_shadow = _rgba("#DCE8F0FF")
ring = _rgba("#6F8799FF")
ink = _rgba("#245D9CFF")
# Rounded page silhouette with binding rings.
_rect(buf, 2, 2, 12, 13, outline)
_rect(buf, 1, 4, 14, 9, outline)
_rect(buf, 2, 4, 12, 10, paper)
_rect(buf, 2, 4, 12, 3, header)
_rect(buf, 3, 4, 10, 1, header_light)
_rect(buf, 4, 0, 2, 4, ring)
_rect(buf, 10, 0, 2, 4, ring)
_px(buf, 4, 1, paper_shadow)
_px(buf, 10, 1, paper_shadow)
# Two rows of date cells; one highlighted day makes the icon read clearly
# as a calendar even at the native 16x16 resolution.
cell = _rgba("#91A9BAFF")
for x in (4, 7, 10):
_rect(buf, x, 8, 2, 2, cell)
_rect(buf, x, 11, 2, 2, cell)
_rect(buf, 7, 11, 2, 2, ink)
_rect(buf, 3, 13, 10, 1, paper_shadow)
return buf
ICON_BUILDERS = {
"clock": _clock_icon,
"hourglass": _hourglass_icon,
"calendar": _calendar_icon,
}
def _png_rgba(pixels):
"""Encode a 16x16 RGBA pixel list as PNG bytes, without Pillow."""
raw = bytearray()
for y in range(ICON_SIZE):
raw.append(0) # PNG filter: none
for x in range(ICON_SIZE):
raw += bytes(pixels[y * ICON_SIZE + x])
def chunk(tag, data):
payload = tag + data
return (struct.pack(">I", len(data)) + payload +
struct.pack(">I", zlib.crc32(payload) & 0xFFFFFFFF))
return (b"\x89PNG\r\n\x1a\n" +
chunk(b"IHDR", struct.pack(">IIBBBBB", ICON_SIZE, ICON_SIZE, 8, 6, 0, 0, 0)) +
chunk(b"IDAT", zlib.compress(bytes(raw), 9)) +
chunk(b"IEND", b""))
def prepare_icon():
if not _ARGS.icon:
return None
pixels = ICON_BUILDERS[_ARGS.icon](_ARGS.icon_color)
accent = (_ARGS.icon_color or "native").replace("#", "")
filename = f"icon_{_ARGS.icon}_{accent}.png"
_upload_asset(filename, _png_rgba(pixels))
return filename
# ---------------------------------------------------------------------------
# App layout
# ---------------------------------------------------------------------------
_ICON_PATH = None
def build_elements(now):
hhmm = time.strftime(TIME_FMT, now)
# Preserve the original output when no new feature is enabled.
if not _ARGS.date and not _ARGS.icon and _ARGS.color == "#FFFFFFFF":
return [text(hhmm, x=36, y=15, font="extra_large", align="bottom_mid")]
# Color-only customization keeps the original one-line layout and font.
if not _ARGS.date and not _ARGS.icon:
return [text(hhmm, x=36, y=15, font="extra_large",
color=_ARGS.color, align="bottom_mid")]
elements = []
if _ARGS.icon and _ICON_PATH:
elements.append(image(_ICON_PATH, ICON_X, ICON_Y))
if _ARGS.date:
# Two lines. With an icon, the text is centered inside x=0..54 and never
# gets shifted by the icon itself; without an icon it uses the full bar.
center_x = TEXT_AREA_MID_X if _ARGS.icon else 36
elements.append(text(hhmm, x=center_x, y=0, font="normal",
color=_ARGS.color, el_id="time", align="top_mid"))
elements.append(text(format_date(now), x=center_x, y=9, font="small",
color=DATE_COLOR, el_id="date", align="top_mid"))
return elements
# Icon-only view. HH:MM:SS uses normal so it fits cleanly in the 55-pixel
# text area. HH:MM has enough room for large.
font = "large" if _ARGS.no_seconds else "normal"
elements.append(text(hhmm, x=TEXT_AREA_MID_X, y=8, font=font,
color=_ARGS.color, el_id="time", align="center"))
return elements
def tick():
now = time.localtime()
try:
draw(build_elements(now))
except urllib.error.HTTPError as exc:
if exc.code != 409:
raise
print("display busy (409), retrying...")
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
_STOP = False
def _request_stop(_signum, _frame):
# busybar-manager may use SIGTERM when switching applications. Catch it so
# the finally block runs and the clock clears its elements cleanly.
global _STOP
_STOP = True
def main():
global _ICON_PATH
if hasattr(signal, "SIGTERM"):
signal.signal(signal.SIGTERM, _request_stop)
print(f"clock -> {BASE} (Ctrl-C to stop)")
try:
# Always start from a known clean state before uploading/drawing anything.
_safe_clear()
_ICON_PATH = prepare_icon()
while not _STOP:
tick()
time.sleep(1.0)
except KeyboardInterrupt:
print("\nstopped.")
except urllib.error.HTTPError as exc:
sys.exit(f"error: HTTP {exc.code} - {exc.read().decode('utf-8', 'ignore')}")
except urllib.error.URLError as exc:
sys.exit(f"error: cannot reach {BASE} - {exc.reason}")
finally:
# Also runs on Ctrl+C, normal exit, errors, and SIGTERM from an app switch.
_safe_clear()
if __name__ == "__main__":
main()