Matrix Digital Rain
Matrix-style digital rain animation for BUSY Bar, with configurable character size, speed, density, and color theme.
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
"""Matrix Digital Rain for BUSY Bar.
Classic green digital-rain animation for the 72x16 BUSY Bar display.
Each frame is rasterized locally and pushed as a single PNG image element,
which keeps animation smooth and avoids the display element-count limit.
python3 matrix_digital_rain.py
python3 matrix_digital_rain.py --host 127.0.0.1:8080
python3 matrix_digital_rain.py --fps 12 --speed 1.2 --density 0.75
python3 matrix_digital_rain.py --theme cyan
python3 matrix_digital_rain.py --test
No external Python dependencies are required.
"""
import argparse
import json
import random
import struct
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
import zlib
APP = "matrix-digital-rain"
W, H = 72, 16
# Ultra-compact 2x3 pixel glyphs. At 72x16 this gives the rain enough
# columns and vertical character cells to look dense rather than oversized.
FONT = {
"0": ["11", "11", "11"], "1": ["01", "01", "01"],
"2": ["11", "01", "10"], "3": ["11", "01", "11"],
"4": ["10", "11", "01"], "5": ["11", "10", "01"],
"6": ["10", "11", "11"], "7": ["11", "01", "01"],
"8": ["11", "11", "11"], "9": ["11", "11", "01"],
"A": ["11", "11", "10"], "C": ["11", "10", "11"],
"E": ["11", "11", "10"], "F": ["11", "11", "10"],
"H": ["10", "11", "10"], "J": ["01", "01", "11"],
"N": ["11", "11", "11"], "P": ["11", "11", "10"],
"S": ["11", "10", "01"], "T": ["11", "01", "01"],
"U": ["10", "10", "11"], "X": ["10", "01", "10"],
"Y": ["10", "11", "01"], "Z": ["11", "01", "10"],
"+": ["00", "11", "01"], "-": ["00", "11", "00"],
"#": ["11", "11", "11"], "*": ["10", "01", "10"],
}
SMALL_FONT = FONT
BIG_FONT = {
"0": ["111","101","101","101","111"], "1": ["010","110","010","010","111"],
"2": ["111","001","111","100","111"], "3": ["111","001","111","001","111"],
"4": ["101","101","111","001","001"], "5": ["111","100","111","001","111"],
"6": ["111","100","111","101","111"], "7": ["111","001","010","010","010"],
"8": ["111","101","111","101","111"], "9": ["111","101","111","001","111"],
"A": ["010","101","111","101","101"], "C": ["111","100","100","100","111"],
"E": ["111","100","110","100","111"], "F": ["111","100","110","100","100"],
"H": ["101","101","111","101","101"], "J": ["001","001","001","101","111"],
"N": ["101","111","111","111","101"], "P": ["110","101","110","100","100"],
"S": ["111","100","111","001","111"], "T": ["111","010","010","010","010"],
"U": ["101","101","101","101","111"], "X": ["101","101","010","101","101"],
"Y": ["101","101","010","010","010"], "Z": ["111","001","010","100","111"],
"+": ["000","010","111","010","000"], "-": ["000","000","111","000","000"],
"#": ["101","111","101","111","101"], "*": ["000","101","010","101","000"],
}
FONT = SMALL_FONT
CHARS = list(SMALL_FONT)
THEMES = {
"green": {
"head": (220, 255, 220),
"bright": (60, 255, 90),
"mid": (0, 170, 45),
"dim": (0, 70, 18),
},
"cyan": {
"head": (220, 255, 255),
"bright": (60, 255, 255),
"mid": (0, 170, 190),
"dim": (0, 60, 70),
},
"amber": {
"head": (255, 245, 210),
"bright": (255, 180, 35),
"mid": (180, 90, 0),
"dim": (70, 30, 0),
},
"violet": {
"head": (220, 205, 255),
"bright": (130, 70, 255),
"mid": (75, 30, 160),
"dim": (28, 8, 65),
},
}
def _base(host):
host = host.replace("http://", "").replace("https://", "").rstrip("/")
return "http://" + host
def _png(pixels):
"""Encode a flat 72x16 RGB buffer as a minimal RGBA PNG using stdlib only."""
raw = bytearray()
for y in range(H):
raw.append(0)
base = y * W
for x in range(W):
r, g, b = pixels[base + x]
raw += bytes((r, g, b, 255))
def chunk(tag, data):
blob = tag + data
return (struct.pack(">I", len(data)) + blob +
struct.pack(">I", zlib.crc32(blob) & 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""))
_RING = 4
_frame_no = 0
def _post(host, path, data, content_type):
req = urllib.request.Request(
_base(host) + path,
data=data,
method="POST",
headers={"Content-Type": content_type},
)
with urllib.request.urlopen(req, timeout=5) as resp:
return resp.getcode()
def show(host, pixels):
"""Upload and draw one full-screen frame."""
global _frame_no
filename = "frame%d.png" % (_frame_no % _RING)
_frame_no += 1
try:
_post(
host,
"/api/assets/upload?application_name=%s&file=%s" % (APP, filename),
_png(pixels),
"application/octet-stream",
)
body = {
"application_name": APP,
"elements": [
{"id": "frame", "type": "image", "path": filename, "x": 0, "y": 0}
],
}
return _post(host, "/api/display/draw", json.dumps(body).encode(), "application/json")
except urllib.error.HTTPError as exc:
if exc.code == 409:
return 409
raise
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=5):
pass
except urllib.error.URLError:
pass
def blank():
return [(0, 0, 0)] * (W * H)
def px(buf, x, y, color):
if 0 <= x < W and 0 <= y < H:
buf[y * W + x] = color
def mix(a, b, t):
return tuple(int(a[i] + (b[i] - a[i]) * t) for i in range(3))
def draw_glyph(buf, x, y, char, color, font):
glyph = font.get(char)
if not glyph:
return
for gy, row in enumerate(glyph):
for gx, bit in enumerate(row):
if bit == "1":
px(buf, x + gx, y + gy, color)
class Drop:
"""One falling rain column rendered in a 4x6 logical character cell."""
def __init__(self, col, density, speed, char_size):
self.col = col
self.density = density
self.base_speed = speed
self.font = SMALL_FONT if char_size == "small" else BIG_FONT
self.cell_w = 3 if char_size == "small" else 4
self.cell_h = 4 if char_size == "small" else 6
self.reset(initial=True)
def reset(self, initial=False):
self.length = random.randint(2, 6)
self.row = random.uniform(-8.0, 2.0 if initial else -1.0)
self.speed = random.uniform(0.55, 1.35) * self.base_speed
self.active = random.random() < self.density
self.chars = [random.choice(CHARS) for _ in range(self.length + 2)]
self.mutate_clock = random.randint(1, 4)
def update(self, dt):
if not self.active:
if random.random() < min(1.0, dt * 0.18 * self.density):
self.active = True
self.row = random.uniform(-4.0, -1.0)
return
self.row += self.speed * dt * 7.5
self.mutate_clock -= 1
if self.mutate_clock <= 0:
self.mutate_clock = random.randint(1, 4)
if self.chars:
self.chars[random.randrange(len(self.chars))] = random.choice(CHARS)
if self.row - self.length > 4.0:
self.reset()
def draw(self, buf, theme):
if not self.active:
return
x = self.col * self.cell_w
head_row = int(self.row)
for i in range(self.length):
logical_row = head_row - i
y = logical_row * self.cell_h - 1
if y >= H or y + 3 < 0:
continue
if i == 0:
color = theme["head"]
elif i == 1:
color = theme["bright"]
else:
t = (i - 2) / max(1, self.length - 2)
color = mix(theme["mid"], theme["dim"], min(1.0, t))
char = self.chars[i % len(self.chars)]
draw_glyph(buf, x, y, char, color, self.font)
class MatrixRain:
def __init__(self, density=0.75, speed=1.0, theme="green", char_size="small"):
self.theme = THEMES[theme]
cell_w = 3 if char_size == "small" else 4
self.cols = W // cell_w
self.drops = [Drop(c, density, speed, char_size) for c in range(self.cols)]
def update(self, dt):
for drop in self.drops:
drop.update(dt)
def frame(self):
buf = blank()
for drop in self.drops:
drop.draw(buf, self.theme)
return buf
def parse_args():
p = argparse.ArgumentParser(description="Matrix Digital Rain for BUSY Bar")
p.add_argument("--host", default="10.0.4.20", help="BUSY Bar host (default: 10.0.4.20)")
p.add_argument("--fps", type=float, default=12.0, help="frames per second (default: 12)")
p.add_argument("--speed", type=float, default=1.0, help="fall speed multiplier (default: 1.0)")
p.add_argument("--density", type=float, default=0.78, help="active column density 0..1 (default: 0.78)")
p.add_argument("--theme", choices=sorted(THEMES), default="green", help="color theme")
p.add_argument("--char-size", choices=["small", "big"], default="small",
help="character size: small (2x3) or big (3x5), default: small")
p.add_argument("--seed", type=int, default=None, help="random seed for reproducible animation")
p.add_argument("--test", action="store_true", help="draw one frame and exit")
return p.parse_args()
def main():
args = parse_args()
args.fps = max(1.0, min(30.0, args.fps))
args.speed = max(0.1, min(4.0, args.speed))
args.density = max(0.05, min(1.0, args.density))
if args.seed is not None:
random.seed(args.seed)
rain = MatrixRain(args.density, args.speed, args.theme, args.char_size)
if args.test:
for _ in range(8):
rain.update(0.10)
status = show(args.host, rain.frame())
print(f"test: drew one frame ({args.theme}) status {status}")
return
print(f"matrix-digital-rain -> {_base(args.host)} (Ctrl-C to stop)")
interval = 1.0 / args.fps
last = time.monotonic()
try:
while True:
start = time.monotonic()
now = start
dt = min(0.25, now - last)
last = now
rain.update(dt)
show(args.host, rain.frame())
elapsed = time.monotonic() - start
if elapsed < interval:
time.sleep(interval - elapsed)
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(args.host)} - {exc.reason}")
finally:
clear(args.host)
if __name__ == "__main__":
main()