Countdown
Animated countdown to any moment, coarse when far off and mm:ss in the final hour, with amber/red thresholds and a red count-up once it passes.
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
"""Countdown: an animated countdown to any moment, with warning and danger
thresholds, that shows less detail the further away the deadline is.
python app.py # counts down to the next New Year
python app.py --host 127.0.0.1:8080 # emulator or a Wi-Fi bar
python app.py --label "LAUNCH" --datetime 2026-12-31T23:59
python app.py --in 90s # relative target, handy for testing
python app.py --warning 1w3d --danger 1d # amber under 10 days, red under 1 day
The display gets more precise as the deadline approaches: months and days far
out, "in X days" within a month, days and hours within a week, hours and
minutes within a day, and a big mm:ss for the final hour. Past the deadline the
background turns red and the clock counts up.
Press START on the bar to toggle between the countdown and the target date.
"""
import argparse
import calendar
import datetime
import json
import math
import os
import queue
import socket
import struct
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
from base64 import b64encode
APP = "countdown"
W, H = 72, 16
BAR_H = 2 # rows the progress bar takes along the bottom
# --- BUSY Bar HTTP API (stdlib only; docs: http://10.0.4.20/docs) ----------
def build_parser():
p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
p.add_argument("--host", default="10.0.4.20",
help="bar address; 10.0.4.20 is USB, 127.0.0.1:8080 the emulator")
p.add_argument("--label", default=None,
help="text above the countdown; with none set the countdown "
"takes the whole display")
p.add_argument("--datetime", dest="target", default=None,
help="target moment: 2026-12-31T23:59[:59], '2026-12-31 23:59', "
"2026-12-31 (midnight) or 23:59 (today, or tomorrow if past)")
p.add_argument("--in", dest="relative", default=None,
help="target as a duration from now, e.g. 90s, 2h30m, 1w3d")
p.add_argument("--warning", default="1d",
help="amber below this much time left (default 1d)")
p.add_argument("--danger", default="1h",
help="red below this much time left (default 1h)")
p.add_argument("--fps", type=float, default=12.0, help="frames per second (default 12)")
p.add_argument("--no-buttons", action="store_true",
help="skip the START-button listener")
p.add_argument("--show-target", action="store_true",
help="start on the target-date view instead of the countdown")
p.add_argument("--test", action="store_true", help="draw a single frame and exit")
return p
ARGS = build_parser().parse_args()
BASE = "http://" + ARGS.host.replace("http://", "").rstrip("/")
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():
req = urllib.request.Request(
BASE + "/api/display/draw?application_name=" + urllib.parse.quote(APP),
method="DELETE")
try:
with urllib.request.urlopen(req, timeout=5):
pass
except (urllib.error.URLError, OSError):
pass
def text(eid, 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": eid, "type": "text", "text": str(txt), "x": x, "y": y,
"font": font, "color": color, **kw}
def rect(eid, x, y, w, h, fill_colors, fill="solid", **kw):
# border_width defaults to 1 on the firmware, which paints a white outline
# around every rectangle; these are all fills, so it is always 0 here.
return {"id": eid, "type": "rectangle", "x": x, "y": y, "width": max(1, int(w)),
"height": max(1, int(h)), "fill": fill, "fill_colors": fill_colors,
"border_width": 0, **kw}
def hidden_rect(eid):
"""A 1x1 fully transparent rectangle.
The firmware keeps every id it has ever been sent until the app is cleared,
so an element cannot be un-drawn by leaving it out of the next frame. The
fixed id set below is therefore sent on every frame, with the ones a given
view does not use parked as transparent."""
return rect(eid, 0, 0, 1, 1, ["#00000000"])
# --- text metrics ----------------------------------------------------------
# Advance widths for printable ASCII, taken from the device font atlas, encoded
# as chr(32 + advance). Knowing how wide a string renders is what lets the app
# pick the largest font that still fits and centre things exactly.
_ADVANCES = {
"tiny": "#\"$&$%%\"##$$\"$\"%$$$$$$$$$$\"\"$$$$$$$$$$$$$$$%$&%%$%$$$%&&$&%#%#$$#$$$$$##$\"\"$\"&$$$$#$$$$&$$$$\"$%",
"small": "\"\"$&$%%\"##$$\"#\"#$#$$$$$$$$\"#$$$$%%%%%%%%%\"$%$&%%%%%%$%$&$$$###$$$$$$$$#$$\"#$\"&$$$$#$#$$&$$$$\"$%",
"condensed": "%#$&&('\"##$&#$$%%%%%%%%%%%\"#$&$&(&&&&&&&&$%&%(&&&&&&&&&(&&&#%#&%&%%%%%%%%$$%$&%%%%$%%%&&&%%$#$&",
"large": "&#$)(*)\"$$&&#$$&'%''''''''\"#$&$&*(''(''(($'''*(('(''(((*(('#&#&&&&&&&&%&&$$&$(&&&&%&%&&*&&&$\"$(",
"extra_large": "$#&*)+*#%%''#$#&(%((((((((##%'%',((((''((%'(')(((((('())(('$&$(%'((((''((%'(')(((((('())(('&#&(",
}
# A text element is anchored by its font box, not by its ink, and the box has
# blank rows on top. INK_TOP is how far below y the first lit row actually
# lands, so `y = wanted_row - INK_TOP[font]` puts ink exactly where intended.
# INK_CAPS is the lit height, used to check that a font fits the rows a view has
# left over.
# INK_CAPS is the lit height of capitals and digits, which is what a font has to
# fit in; the few lowercase descenders in these strings ("days", "1y") are
# allowed to reach a row or two further down.
INK_TOP = {"tiny": 1, "small": 2, "condensed": 2, "large": 2, "extra_large": 2}
INK_CAPS = {"tiny": 5, "small": 5, "condensed": 7, "large": 9, "extra_large": 10}
def text_width(txt, font):
table = _ADVANCES[font]
total = 0
for ch in txt:
i = ord(ch) - 32
total += ord(table[i]) - 32 if 0 <= i < len(table) else 4
return total
def fit_font(txt, fonts, max_width=W):
"""The first font in `fonts` that renders `txt` inside max_width."""
for font in fonts:
if text_width(txt, font) <= max_width:
return font
return fonts[-1]
VALUE_IDS = ("v0", "v1", "v2", "v3", "v4", "v5")
NUMBER_CHARS = "0123456789:+."
def split_runs(txt):
"""Alternating runs of [text, is_number] -- '5h 00m' -> 5 / h / 00 / m."""
runs = []
for ch in txt:
is_number = ch in NUMBER_CHARS
if runs and runs[-1][1] == is_number:
runs[-1][0] += ch
else:
runs.append([ch, is_number])
return runs
def value_line(txt, first_row, last_row, fonts, color):
"""The value, as one element per size it needs, padded to VALUE_IDS.
`extra_large` is a capitals-only face: handed "in 12 days" it prints
"IN 12 DAYS". So when it is the size in play, the digits go in it and the
words go in `large` beside them, bottom-aligned onto one baseline. Every
other size has real lowercase and stays a single element."""
runs = split_runs(txt)
if fonts[0] == "extra_large" and len(runs) > 1 and len(runs) <= len(VALUE_IDS):
width = sum(text_width(part, "extra_large" if num else "large")
for part, num in runs)
if width <= W:
baseline = first_row + INK_CAPS["extra_large"] - 1
parts, x = [], (W - width) // 2
for eid, (part, num) in zip(VALUE_IDS, runs):
font = "extra_large" if num else "large"
parts.append(text(eid, part, x=x, align="top_left", font=font,
y=baseline - INK_CAPS[font] + 1 - INK_TOP[font],
color=color))
x += text_width(part, font)
return parts
font = fit_font(txt, [f for f in fonts
if first_row + INK_CAPS[f] - 1 <= last_row] or list(fonts))
return [text(VALUE_IDS[0], txt, x=W // 2, y=first_row - INK_TOP[font],
font=font, color=color, align="top_mid")]
# --- colour ----------------------------------------------------------------
PALETTE = {
# state -> (accent rgb, dim rgb used for the label and the bar track)
"calm": ("#3FD8FF", "#0E4657"),
"warning": ("#FFB020", "#4A2E00"),
"danger": ("#FF3B30", "#4A0B08"),
"expired": ("#FFFFFF", "#FF2A1F"),
}
LED_ALERT = {"warning": "#FFB020FF", "danger": "#FF3B30FF", "expired": "#FF2A1FFF"}
def rgba(rgb, alpha):
"""'#RRGGBB' plus a 0..1 alpha -> the '#RRGGBBAA' the firmware wants."""
a = int(round(max(0.0, min(1.0, alpha)) * 255))
return "%s%02X" % (rgb, a)
def mix(rgb_a, rgb_b, t):
t = max(0.0, min(1.0, t))
out = "#"
for i in (1, 3, 5):
a, b = int(rgb_a[i:i + 2], 16), int(rgb_b[i:i + 2], 16)
out += "%02X" % int(round(a + (b - a) * t))
return out
# --- durations and dates ---------------------------------------------------
_UNIT_SECONDS = {"y": 365 * 86400, "mo": 30 * 86400, "w": 7 * 86400,
"d": 86400, "h": 3600, "m": 60, "s": 1}
def parse_duration(spec):
"""'1w3d' -> 864000. Accepts y/mo/w/d/h/m/s in any order, or bare seconds."""
raw = spec.strip().lower().replace(" ", "")
if not raw:
raise ValueError("empty duration")
try:
return float(raw) # a bare number means seconds
except ValueError:
pass
total, number, unit, i = 0.0, "", "", 0
while i < len(raw):
ch = raw[i]
if ch.isdigit() or ch == ".":
if unit: # a new number closes the previous pair
number, unit = "", ""
number += ch
i += 1
continue
# "mo" has to win over "m", so try the two-character unit first.
unit = raw[i:i + 2] if raw[i:i + 2] in _UNIT_SECONDS else ch
if unit not in _UNIT_SECONDS or not number:
raise ValueError("cannot read duration %r" % spec)
total += float(number) * _UNIT_SECONDS[unit]
i += len(unit)
number = ""
if number:
raise ValueError("duration %r ends without a unit" % spec)
return total
def next_new_year(now):
return now.replace(year=now.year + 1, month=1, day=1, hour=0, minute=0,
second=0, microsecond=0)
def parse_target(spec, now):
"""Read a target moment, in the machine's local timezone."""
raw = spec.strip().replace("T", " ")
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y-%m-%d",
"%d-%m-%Y %H:%M", "%d/%m/%Y %H:%M", "%H:%M:%S", "%H:%M"):
try:
parsed = datetime.datetime.strptime(raw, fmt)
except ValueError:
continue
if "%Y" not in fmt: # a bare clock time means today, else tomorrow
parsed = now.replace(hour=parsed.hour, minute=parsed.minute,
second=parsed.second, microsecond=0)
if parsed <= now:
parsed += datetime.timedelta(days=1)
return parsed
try: # anything else ISO-ish that the stdlib knows
parsed = datetime.datetime.fromisoformat(raw)
except ValueError:
raise SystemExit("countdown: cannot read --datetime %r" % spec)
if parsed.tzinfo is not None: # everything downstream is naive local time
parsed = parsed.astimezone().replace(tzinfo=None)
return parsed
def add_months(when, count):
month = when.month - 1 + count
year = when.year + month // 12
month = month % 12 + 1
day = min(when.day, calendar.monthrange(year, month)[1])
return when.replace(year=year, month=month, day=day)
def calendar_split(start, end):
"""Whole calendar months from start to end, plus the leftover timedelta."""
months = (end.year - start.year) * 12 + (end.month - start.month)
while months > 0 and add_months(start, months) > end:
months -= 1
while add_months(start, months + 1) <= end:
months += 1
return months, end - add_months(start, months)
# --- how much detail to show ----------------------------------------------
# The closer the deadline, the more precision is useful; a year out, seconds are
# noise. Each tier also names the unit the progress bar drains through, so the
# bar always measures something the text is not already showing.
def format_gap(now, later, overdue):
"""(text, tier, unit_seconds) for the gap between two moments."""
seconds = max(0.0, (later - now).total_seconds())
sign = "+" if overdue else ""
if seconds < 3600:
minutes, secs = divmod(int(seconds), 60)
return "%s%02d:%02d" % (sign, minutes, secs), "second", 60
if seconds < 86400:
hours = int(seconds // 3600)
minutes = int(seconds % 3600) // 60
return "%s%dh %02dm" % (sign, hours, minutes), "hour", 3600
if seconds < 7 * 86400:
days = int(seconds // 86400)
hours = int(seconds % 86400) // 3600
return "%s%dd %dh" % (sign, days, hours), "day", 86400
months, leftover = calendar_split(now, later)
if months < 1:
days = int(seconds // 86400)
return ("+%d days" % days) if overdue else ("in %d days" % days), "days", 86400
if months < 12:
return "%s%dmo %dd" % (sign, months, leftover.days), "months", 86400
years, rest = divmod(months, 12)
return "%s%dy %dmo" % (sign, years, rest), "years", 86400
def format_target(target):
"""('TUE 31 DEC 2026', '23:59') for the START-button peek view."""
return (target.strftime("%a %d %b %Y").upper(),
target.strftime("%H:%M"))
# --- animation -------------------------------------------------------------
def breathe(t, period, amount):
"""A slow sine between 1 - amount and 1."""
return 1.0 - amount * 0.5 * (1.0 - math.cos(2 * math.pi * t / period))
# Where the two thumps of one beat sit, and how wide they are, in seconds. Each
# beat is a quick lub-dub; how often one comes round is the period in MOOD, and
# the two are independent because these are in seconds rather than in fractions
# of the period.
THUMP_AT, ECHO_AT, THUMP_SPREAD = 0.05, 0.22, 0.045
def _bump(phase, period, centre, spread):
"""A gaussian on a circle, so a thump sitting near phase 0 keeps the half
of itself that falls off the end of the loop."""
distance = abs(phase - centre)
distance = min(distance, period - distance)
return math.exp(-(distance * distance) / (2 * spread * spread))
def heartbeat(t, period):
"""Two quick thumps per period, for the danger and expired states.
The thumps are placed in seconds rather than as a fraction of the period,
so shortening the period closes the gap between beats instead of squeezing
the beats themselves into a blur."""
phase = t % period
thump = _bump(phase, period, THUMP_AT, THUMP_SPREAD)
echo = 0.55 * _bump(phase, period, ECHO_AT, THUMP_SPREAD)
return min(1.0, thump + echo)
# How hard the value pulses in each state, and how fast. `period` is the whole
# beat: the calm and warning states breathe on a sine, danger and expired thump
# on the heartbeat above, and expired is the shortest gap of the four.
MOOD = {
"calm": {"period": 4.2, "amount": 0.18},
"warning": {"period": 2.2, "amount": 0.32},
"danger": {"period": 2.60, "amount": 0.55},
"expired": {"period": 1.90, "amount": 0.75},
}
def state_for(remaining, warning, danger):
if remaining <= 0:
return "expired"
if remaining <= danger:
return "danger"
if remaining <= warning:
return "warning"
return "calm"
def build_frame(now, target, warning, danger, t, show_target):
"""One frame, as a fixed set of elements always in the same order.
The firmware paints them in the order it first saw each id, so the
background has to lead and the text has to trail; sending the same ids in
the same order every frame keeps that stacking stable and keeps the stored
element set at its size instead of growing with the clock."""
remaining = (target - now).total_seconds()
state = state_for(remaining, warning, danger)
accent, dim = PALETTE[state]
mood = MOOD[state]
overdue = remaining <= 0
if state in ("danger", "expired"):
pulse = 0.55 + 0.45 * heartbeat(t, mood["period"])
else:
pulse = breathe(t, mood["period"], mood["amount"])
elements = []
# 1. background: only the overdue state paints one, throbbing red.
if overdue:
# Darkest at the top so the text stays readable, hottest along the
# bottom where it runs into the bar.
glow = 0.45 + 0.55 * heartbeat(t, mood["period"])
elements.append(rect("bg", 0, 0, W, H,
[rgba(mix("#280402", dim, glow * 0.25), 1.0),
rgba(mix("#6E0D08", dim, glow), 1.0)],
fill="gradient_v"))
else:
elements.append(hidden_rect("bg"))
# 2/3. the bottom two rows: a track, and a fill that drains through the
# current unit -- one minute in the final hour, one day out at month range.
if overdue:
text_line, tier, unit = format_gap(target, now, True)
else:
text_line, tier, unit = format_gap(now, target, False)
elements.append(rect("track", 0, H - BAR_H, W, BAR_H, [rgba(dim, 0.5)]))
if overdue:
elements.append(hidden_rect("fill"))
else:
# Brightest at the leading edge, so where the level currently sits reads
# as a crisp line rather than fading into the track.
drain = (remaining % unit) / unit
elements.append(rect("fill", 0, H - BAR_H, max(1, round(drain * W)), BAR_H,
[rgba(accent, 0.28), rgba(accent, 1.0)], fill="gradient_h"))
# 4/5. label and value.
if show_target:
head, clock = format_target(target)
label_text, value_text = head, clock
final_hour = False
else:
value_text = text_line
# The final hour drops the label and goes as big as the display allows:
# at that point the label is the least useful thing on the bar and the
# mm:ss is the most.
final_hour = tier == "second" and not overdue
label_text = "" if final_hour else LABEL
# With the label line free the value takes the whole display, so it starts
# from the biggest face and only steps down when a string is too wide.
value_fonts = (("condensed", "small", "tiny") if label_text
else ("extra_large", "large", "condensed", "small", "tiny"))
# Labels are upper-cased, so nothing on this line has a descender and the
# taller `small` face still lives inside the five rows above the value.
label_font = fit_font(label_text, ("small", "tiny"))
label_y = -INK_TOP[label_font] # lit rows start at row 0
if not label_text:
elements.append(text("label", " ", x=0, y=label_y, font=label_font,
color="#00000000"))
elif text_width(label_text, label_font) > W:
# Too long to fit: hand it to the firmware's scroller instead of cutting.
elements.append(text("label", label_text, x=0, y=label_y, font=label_font,
color=rgba(mix(dim, accent, 0.55), 0.75 + 0.25 * pulse),
align="top_left", width=W, scroll_rate=900,
scroll_start_delay=1500, scroll_repeat_delay=2200))
else:
elements.append(text("label", label_text, x=W // 2, y=label_y, font=label_font,
color=rgba(mix(dim, accent, 0.9 if overdue else 0.75),
0.7 + 0.3 * pulse),
align="top_mid"))
# One blank row under the label, so the two lines are not shoulder to
# shoulder; with no label the value just centres in what is left.
first_row = 6 if label_text else 2
parts = value_line(value_text, first_row, H - BAR_H - 1, value_fonts,
rgba(accent, 0.45 + 0.55 * pulse))
elements.extend(parts)
# Whatever the line did not need is still sent, blank, so a wide frame
# cannot leave a stray run of the previous one lit.
for eid in VALUE_IDS[len(parts):]:
elements.append(text(eid, " ", x=0, y=0, font="tiny", color="#00000000"))
return elements, state
# --- START button over the status WebSocket --------------------------------
# The bar streams input on /api/status/ws as protobuf. Decoding the two fields
# this app needs by hand keeps it stdlib-only:
# State.updates=2 -> StateUpdate.input=11 -> InputEvent.button_event=1
# Button START is enum 2; action PRESS is 0 and RELEASE is 1.
def _read_varint(buf, pos):
value, shift = 0, 0
while pos < len(buf):
byte = buf[pos]
pos += 1
value |= (byte & 0x7F) << shift
if not byte & 0x80:
return value, pos
shift += 7
if shift > 63:
raise ValueError("protobuf varint too long")
raise ValueError("truncated protobuf varint")
def _iter_fields(buf):
pos = 0
while pos < len(buf):
key, pos = _read_varint(buf, pos)
field, wire = key >> 3, key & 7
if wire == 0:
value, pos = _read_varint(buf, pos)
yield field, wire, value
elif wire == 2:
length, pos = _read_varint(buf, pos)
end = pos + length
if end > len(buf):
return
yield field, wire, buf[pos:end]
pos = end
elif wire in (1, 5):
width = 8 if wire == 1 else 4
if pos + width > len(buf):
return
yield field, wire, buf[pos:pos + width]
pos += width
else:
return
def start_pressed(frame):
"""True if this status frame carries a START press."""
for field, wire, update in _iter_fields(frame):
if field != 2 or wire != 2: # State.updates
continue
for ufield, uwire, payload in _iter_fields(update):
if ufield != 11 or uwire != 2: # StateUpdate.input
continue
for efield, ewire, event in _iter_fields(payload):
if efield != 1 or ewire != 2: # InputEvent.button_event
continue
button, action = 0, 0
for bfield, bwire, value in _iter_fields(event):
if bwire:
continue
if bfield == 1:
button = int(value)
elif bfield == 2:
action = int(value)
if button == 2 and action == 0:
return True
return False
class ButtonListener(threading.Thread):
"""A minimal RFC 6455 client for /api/status/ws, in a daemon thread.
Buttons are a bonus here, not the app: the recorder and the emulator do not
serve this endpoint at all, so every failure is swallowed after one line of
explanation and retried with a backoff. The countdown keeps drawing either
way."""
def __init__(self, host):
super().__init__(daemon=True)
self.host = host
self.events = queue.Queue()
self._stop = threading.Event()
self._complained = False
self._announced = False
def stop(self):
self._stop.set()
def presses(self):
count = 0
while True:
try:
self.events.get_nowait()
except queue.Empty:
return count
count += 1
def run(self):
backoff = 1.0
while not self._stop.is_set():
try:
self._session()
backoff = 1.0
except Exception as exc: # noqa: BLE001 - see docstring
if not self._complained:
print("countdown: no button stream (%s); the countdown runs anyway" % exc)
self._complained = True
backoff = min(30.0, backoff * 2)
deadline = time.monotonic() + backoff
while not self._stop.is_set() and time.monotonic() < deadline:
time.sleep(0.2)
def _session(self):
host, _, port = self.host.partition(":")
sock = socket.create_connection((host, int(port or 80)), timeout=8)
try:
sock.settimeout(60)
key = b64encode(os.urandom(16)).decode()
sock.sendall((
"GET /api/status/ws HTTP/1.1\r\n"
"Host: %s\r\n"
"Upgrade: websocket\r\n"
"Connection: Upgrade\r\n"
"Sec-WebSocket-Key: %s\r\n"
"Sec-WebSocket-Version: 13\r\n\r\n" % (self.host, key)).encode())
stream = sock.makefile("rb")
status = stream.readline()
if b"101" not in status:
raise OSError("handshake refused: %s" % status.decode(errors="replace").strip())
while stream.readline() not in (b"\r\n", b"\n", b""):
pass
self._send(sock, 0x1, json.dumps({"enable": True}).encode())
if not self._announced:
print("countdown: START button connected")
self._announced = True
payload = b""
while not self._stop.is_set():
fin, opcode, chunk = self._read_frame(stream)
if opcode == 0x8: # close
return
if opcode == 0x9: # ping
self._send(sock, 0xA, chunk)
continue
if opcode == 0xA:
continue
payload = chunk if opcode else payload + chunk
if not fin:
continue
if opcode == 0x2 and start_pressed(payload):
self.events.put(True)
payload = b""
finally:
sock.close()
@staticmethod
def _read_frame(stream):
head = stream.read(2)
if len(head) < 2:
raise OSError("stream closed")
fin, opcode = head[0] & 0x80, head[0] & 0x0F
masked, length = head[1] & 0x80, head[1] & 0x7F
if length == 126:
length = struct.unpack(">H", stream.read(2))[0]
elif length == 127:
length = struct.unpack(">Q", stream.read(8))[0]
mask = stream.read(4) if masked else b""
body = stream.read(length) if length else b""
if len(body) < length:
raise OSError("stream closed mid-frame")
if mask:
body = bytes(b ^ mask[i % 4] for i, b in enumerate(body))
return fin, opcode, body
@staticmethod
def _send(sock, opcode, data):
header = bytearray([0x80 | opcode])
mask = os.urandom(4)
size = len(data)
if size < 126:
header.append(0x80 | size)
elif size < 65536:
header.append(0x80 | 126)
header += struct.pack(">H", size)
else:
header.append(0x80 | 127)
header += struct.pack(">Q", size)
header += mask
header += bytes(byte ^ mask[i % 4] for i, byte in enumerate(data))
sock.sendall(bytes(header))
# --- app -------------------------------------------------------------------
NOW = datetime.datetime.now().replace(microsecond=0)
if ARGS.relative:
try:
TARGET = NOW + datetime.timedelta(seconds=parse_duration(ARGS.relative))
except ValueError as exc:
raise SystemExit("countdown: %s" % exc)
elif ARGS.target:
TARGET = parse_target(ARGS.target, NOW)
else:
TARGET = next_new_year(NOW)
# No --label means no label line at all, and the countdown gets the full height.
# Bitmap fonts are ASCII only, so anything outside that range becomes a dot
# rather than a 400 from the bar.
LABEL = "".join(ch if 0x20 <= ord(ch) <= 0x7E else "."
for ch in (ARGS.label or "").upper())
try:
WARNING = parse_duration(ARGS.warning)
DANGER = parse_duration(ARGS.danger)
except ValueError as exc:
raise SystemExit("countdown: %s" % exc)
if DANGER > WARNING: # a danger window wider than the warning one
WARNING, DANGER = DANGER, WARNING # would never let the amber state show
def main():
listener = None
if not (ARGS.no_buttons or ARGS.test):
listener = ButtonListener(ARGS.host.replace("http://", "").rstrip("/"))
listener.start()
show_target = ARGS.show_target
last_state = None
frame_time = 1.0 / max(1.0, ARGS.fps)
started = time.monotonic()
print("countdown: %s%s (%s)" % (LABEL + " -> " if LABEL else "",
TARGET.strftime("%Y-%m-%d %H:%M:%S"), BASE))
try:
while True:
loop_start = time.monotonic()
if listener and listener.presses() % 2:
show_target = not show_target
elements, state = build_frame(
datetime.datetime.now(), TARGET, WARNING, DANGER,
loop_start - started, show_target)
extra = {}
# Blink the status LED once on each step up in urgency, not every
# frame -- a 12 Hz strobe would be unbearable.
if state != last_state and state in LED_ALERT:
extra["led_notification_color"] = LED_ALERT[state]
try:
draw(elements, **extra)
last_state = state
except urllib.error.HTTPError as exc:
if exc.code != 409: # 409 = a higher-priority app owns the display
raise
except urllib.error.URLError as exc:
print("countdown: %s" % exc.reason)
time.sleep(1.0)
if ARGS.test:
return
elapsed = time.monotonic() - loop_start
if elapsed < frame_time:
time.sleep(frame_time - elapsed)
except KeyboardInterrupt:
print("\nstopped.")
finally:
if listener:
listener.stop()
if not ARGS.test:
clear()
if __name__ == "__main__":
main()