Slot Machine
Animated classic fruit-machine slot for BUSY Bar with automatic or START-button spins, cumulative score, win effects, jackpot animations, and sound.
How to run
This app depends on websockets. Grab the app folder
from GitHub, install once, then run:
pip install -r requirements.txt
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
"""BUSY Bar Slot Machine - animated classic fruit machine for the 72x16 display.
Features
--------
- Immediate spin on startup.
- Automatic spins every N seconds.
- Three independently decelerating reels with overshoot/bounce.
- Classic pixel symbols: CHERRY, LEMON, BELL, BAR, STAR, 7.
- Random weighted outcomes with wins, big wins and a rare jackpot.
- Win flashes, symbol pulses, marquee borders and score count-up.
- Optional synthesized "ding ding ding" audio generated locally as WAV.
- Manual START-button spin support via the BUSY Bar status WebSocket (no busylib).
- Test modes for deterministic win/jackpot sequences.
Examples
--------
python3 app.py
python3 app.py --host 127.0.0.1:8080
python3 app.py --spin-interval 15 --sound off
python3 app.py --test-win
python3 app.py --test-jackpot
"""
from __future__ import annotations
import argparse
import asyncio
import io
import queue
import json
import math
import random
import struct
import sys
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
import wave
import zlib
APP = "slot-machine"
W, H = 72, 16
DEFAULT_HOST = "10.0.4.20"
PRIORITY = 40
FPS = 15
FRAME_T = 1.0 / FPS
RING = 5
# ---------- palette ----------
BLACK = (0, 0, 0)
WHITE = (255, 255, 255)
GOLD = (255, 180, 0)
AMBER = (255, 120, 0)
RED = (255, 30, 30)
DARK_RED = (110, 0, 0)
GREEN = (30, 220, 80)
LIME = (170, 255, 40)
YELLOW = (255, 235, 40)
BLUE = (40, 130, 255)
CYAN = (30, 235, 255)
MAGENTA = (255, 50, 210)
PURPLE = (150, 40, 255)
SILVER = (210, 220, 230)
DARK = (16, 10, 4)
REEL_X = [2, 26, 50]
REEL_W = 20
REEL_Y = 1
REEL_H = 14
SYMBOL_W = 14
SYMBOL_H = 12
SPIN_CENTER_Y = 10 # +2 px only for reel/spin rendering
AWARD_CENTER_Y = 8 # original vertical position for win/jackpot symbol screens
# Symbol weight controls the natural resting probability when not forcing a win.
SYMBOL_WEIGHTS = {
"CHERRY": 28,
"LEMON": 24,
"BELL": 16,
"BAR": 13,
"STAR": 11,
"SEVEN": 8,
}
SYMBOLS = list(SYMBOL_WEIGHTS)
PAYOUTS = {
("CHERRY", "CHERRY"): (30, "WIN"),
("CHERRY", "CHERRY", "CHERRY"): (120, "BIG WIN"),
("LEMON", "LEMON", "LEMON"): (90, "WIN"),
("BELL", "BELL", "BELL"): (180, "BIG WIN"),
("BAR", "BAR", "BAR"): (300, "BIG WIN"),
("STAR", "STAR", "STAR"): (500, "SUPER WIN"),
("SEVEN", "SEVEN", "SEVEN"): (777, "JACKPOT"),
}
# ---------- tiny pixel font ----------
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"],
"B": ["110","101","110","101","110"],
"C": ["111","100","100","100","111"],
"D": ["110","101","101","101","110"],
"E": ["111","100","110","100","111"],
"G": ["111","100","101","101","111"],
"I": ["111","010","010","010","111"],
"J": ["001","001","001","101","111"],
"K": ["101","101","110","101","101"],
"L": ["100","100","100","100","111"],
"N": ["101","111","111","111","101"],
"O": ["111","101","101","101","111"],
"P": ["110","101","110","100","100"],
"R": ["110","101","110","101","101"],
"S": ["111","100","111","001","111"],
"T": ["111","010","010","010","010"],
"U": ["101","101","101","101","111"],
"W": ["101","101","111","111","101"],
"Y": ["101","101","010","010","010"],
" ": ["0","0","0","0","0"],
"+": ["0","010","111","010","0"],
}
def _blank(color=BLACK):
return [color] * (W * H)
def _px(buf, x, y, c):
if 0 <= x < W and 0 <= y < H:
buf[y * W + x] = c
def _rect(buf, x, y, w, h, c):
for yy in range(max(0, y), min(H, y + h)):
base = yy * W
for xx in range(max(0, x), min(W, x + w)):
buf[base + xx] = c
def _line_h(buf, x, y, w, c):
_rect(buf, x, y, w, 1, c)
def _line_v(buf, x, y, h, c):
_rect(buf, x, y, 1, h, c)
def draw_text(buf, text, x, y, color=WHITE, scale=1, center=False):
text = text.upper()
width = sum((len(FONT.get(ch, FONT[" "])[0]) + 1) * scale for ch in text)
if text:
width -= scale
if center:
x -= width // 2
pen = x
for ch in text:
glyph = FONT.get(ch, FONT[" "])
gw = len(glyph[0])
for gy, row in enumerate(glyph):
for gx, bit in enumerate(row):
if bit == "1":
_rect(buf, pen + gx * scale, y + gy * scale, scale, scale, color)
pen += (gw + 1) * scale
# ---------- symbols ----------
def symbol_cherry(buf, x, y):
_rect(buf, x+2, y+6, 4, 4, RED)
_rect(buf, x+8, y+7, 4, 4, RED)
_px(buf, x+2, y+9, DARK_RED); _px(buf, x+8, y+10, DARK_RED)
_line_v(buf, x+6, y+2, 5, GREEN)
_line_v(buf, x+9, y+2, 6, GREEN)
_line_h(buf, x+6, y+2, 4, GREEN)
_rect(buf, x+4, y+1, 3, 2, LIME)
def symbol_lemon(buf, x, y):
_rect(buf, x+3, y+3, 8, 6, YELLOW)
_rect(buf, x+2, y+5, 10, 3, YELLOW)
_px(buf, x+2, y+4, GOLD); _px(buf, x+11, y+8, GOLD)
_rect(buf, x+7, y+1, 3, 2, GREEN)
def symbol_bell(buf, x, y):
y -= 1 # optical alignment: bell sits 1 px higher
_rect(buf, x+4, y+2, 6, 1, GOLD)
_rect(buf, x+3, y+3, 8, 1, YELLOW)
_rect(buf, x+2, y+4, 10, 5, GOLD)
_rect(buf, x+1, y+8, 12, 2, YELLOW)
_rect(buf, x+5, y+10, 4, 1, AMBER)
_px(buf, x+6, y+11, WHITE); _px(buf, x+7, y+11, WHITE)
def symbol_bar(buf, x, y):
y -= 1 # optical alignment: BAR sits 1 px higher
_rect(buf, x+1, y+3, 12, 7, SILVER)
_rect(buf, x+2, y+4, 10, 5, BLACK)
draw_text(buf, "BAR", x+2, y+4, WHITE, 1)
def symbol_star(buf, x, y):
pts = [(6,1),(5,3),(2,3),(4,5),(3,8),(6,6),(9,8),(8,5),(10,3),(7,3)]
for px, py in pts:
_rect(buf, x+px, y+py, 2, 2, MAGENTA if py < 5 else PURPLE)
_rect(buf, x+5, y+4, 4, 3, WHITE)
def symbol_seven(buf, x, y):
_rect(buf, x+1, y+2, 12, 2, RED)
_rect(buf, x+9, y+4, 3, 2, RED)
_rect(buf, x+7, y+6, 3, 2, RED)
_rect(buf, x+5, y+8, 3, 3, RED)
_line_h(buf, x+2, y+1, 8, WHITE)
SYMBOL_DRAW = {
"CHERRY": symbol_cherry,
"LEMON": symbol_lemon,
"BELL": symbol_bell,
"BAR": symbol_bar,
"STAR": symbol_star,
"SEVEN": symbol_seven,
}
def draw_symbol(buf, name, cx, cy, pulse=0):
x = int(cx - SYMBOL_W/2)
y = int(cy - SYMBOL_H/2)
if pulse:
# bright backing shadow gives a convincing 1px pulse on this tiny matrix
_rect(buf, x-1, y-1, SYMBOL_W+2, SYMBOL_H+2, WHITE if pulse > 1 else GOLD)
_rect(buf, x, y, SYMBOL_W, SYMBOL_H, BLACK)
SYMBOL_DRAW[name](buf, x, y)
# ---------- frame encoding / HTTP ----------
def _png(pixels):
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):
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""))
class Busy:
def __init__(self, host):
self.base = "http://" + host.replace("http://", "").replace("https://", "").rstrip("/")
self.frame_no = 0
def _post(self, path, data, content_type):
req = urllib.request.Request(self.base + path, data=data, method="POST",
headers={"Content-Type": content_type})
with urllib.request.urlopen(req, timeout=6) as r:
return r.getcode()
def show(self, pixels):
fn = f"frame{self.frame_no % RING}.png"
self.frame_no += 1
try:
self._post(f"/api/assets/upload?application_name={APP}&file={fn}", _png(pixels), "application/octet-stream")
body = {"application_name": APP, "priority": PRIORITY,
"elements": [{"id": "frame", "type": "image", "path": fn, "x": 0, "y": 0}]}
return self._post("/api/display/draw", json.dumps(body).encode(), "application/json")
except urllib.error.HTTPError as e:
return e.code
def clear(self):
qs = urllib.parse.urlencode({"application_name": APP})
req = urllib.request.Request(self.base + "/api/display/draw?" + qs, method="DELETE")
try:
urllib.request.urlopen(req, timeout=5).close()
except Exception:
pass
def upload_audio(self, name, wav_bytes):
try:
self._post(f"/api/assets/upload?application_name={APP}&file={name}", wav_bytes, "application/octet-stream")
return True
except Exception as e:
print(f"audio upload failed: {e}")
return False
def play_audio(self, name):
body = json.dumps({"application_name": APP, "path": name}).encode()
try:
self._post("/api/audio/play", body, "application/json")
except Exception as e:
print(f"audio play failed: {e}")
# ---------- synthesized ding ----------
def make_ding_wav():
sr = 22050
notes = [(1046.5, 0.00), (1318.5, 0.18), (1568.0, 0.36)]
duration = 0.72
n = int(sr * duration)
samples = []
for i in range(n):
t = i / sr
v = 0.0
for freq, start in notes:
dt = t - start
if 0 <= dt < 0.32:
env = math.exp(-7.5 * dt)
# fundamental + a little metallic overtone
v += env * (math.sin(2*math.pi*freq*dt) + 0.33*math.sin(2*math.pi*freq*2.01*dt))
v = max(-1.0, min(1.0, v * 0.42))
samples.append(int(v * 32767))
out = io.BytesIO()
with wave.open(out, "wb") as wf:
wf.setnchannels(1); wf.setsampwidth(2); wf.setframerate(sr)
wf.writeframes(struct.pack("<%dh" % len(samples), *samples))
return out.getvalue()
# ---------- game logic ----------
def weighted_symbol():
return random.choices(SYMBOLS, weights=[SYMBOL_WEIGHTS[s] for s in SYMBOLS], k=1)[0]
def choose_result(force=None):
if force == "win":
return random.choice([
["CHERRY", "CHERRY", weighted_symbol()],
["LEMON", "LEMON", "LEMON"],
["BELL", "BELL", "BELL"],
["BAR", "BAR", "BAR"],
])
if force == "jackpot":
return ["SEVEN", "SEVEN", "SEVEN"]
# 2.4% explicit win pool, otherwise independently weighted symbols.
if random.random() < 0.024:
r = random.random()
if r < 0.45:
third = weighted_symbol()
return ["CHERRY", "CHERRY", third]
if r < 0.67:
return ["CHERRY"] * 3
if r < 0.80:
return ["LEMON"] * 3
if r < 0.90:
return ["BELL"] * 3
if r < 0.965:
return ["BAR"] * 3
if r < 0.995:
return ["STAR"] * 3
return ["SEVEN"] * 3
return [weighted_symbol(), weighted_symbol(), weighted_symbol()]
def payout(result):
tup = tuple(result)
if tup in PAYOUTS:
return PAYOUTS[tup]
if result[0] == "CHERRY" and result[1] == "CHERRY":
return PAYOUTS[("CHERRY", "CHERRY")]
return (0, None)
class Reel:
def __init__(self, x, start_symbol):
self.x = x
self.symbols = SYMBOLS[:]
random.shuffle(self.symbols)
self.pos = float(self.symbols.index(start_symbol))
self.vel = 0.0
self.target = start_symbol
self.stop_at = 0.0
self.stopped = True
self.bounce_t = 0.0
def start(self, target, stop_at):
self.target = target
self.stop_at = stop_at
self.vel = random.uniform(20.0, 25.0)
self.stopped = False
self.bounce_t = 0.0
def update(self, elapsed, dt):
if self.stopped:
if self.bounce_t > 0:
self.bounce_t = max(0, self.bounce_t - dt)
return
# hold high speed, then exponential deceleration approaching assigned stop.
remaining = self.stop_at - elapsed
if remaining > 0.45:
self.vel = min(27.0, self.vel + 22.0 * dt)
else:
self.vel = max(2.2, self.vel * (0.78 ** (dt * FPS)))
self.pos = (self.pos + self.vel * dt) % len(self.symbols)
if remaining <= 0:
target_i = self.symbols.index(self.target)
self.pos = float(target_i) + 0.12 # intentional overshoot
self.stopped = True
self.bounce_t = 0.18
def center_pos(self):
if self.stopped and self.bounce_t > 0:
# 0.12 -> slight downward overshoot then springs to zero.
k = self.bounce_t / 0.18
return (self.pos - 0.12) + 0.12 * k
return self.pos
def draw(self, buf, dim=False):
cx = self.x + REEL_W // 2
pos = self.center_pos()
base = math.floor(pos)
frac = pos - base
spacing = 14
for off in (-1, 0, 1):
idx = (base + off) % len(self.symbols)
cy = SPIN_CENTER_Y + (off - frac) * spacing
if -8 <= cy <= 24:
draw_symbol(buf, self.symbols[idx], cx, cy)
# reel mask/window frame
frame = (110, 70, 15) if dim else GOLD
_line_v(buf, self.x, 1, 14, frame)
_line_v(buf, self.x + REEL_W - 1, 1, 14, frame)
_line_h(buf, self.x, 1, REEL_W, frame)
_line_h(buf, self.x, 14, REEL_W, frame)
class SlotMachine:
def __init__(self, busy, sound=True):
self.busy = busy
self.sound = sound
self.audio_name = "slot-ding.wav"
self.audio_ready = False
self.credit = 0
self.current = ["CHERRY", "BAR", "SEVEN"]
self.reels = [Reel(REEL_X[i], self.current[i]) for i in range(3)]
if sound:
self.audio_ready = busy.upload_audio(self.audio_name, make_ding_wav())
def base_frame(self, marquee_phase=0, dim=False):
buf = _blank(BLACK)
# dark cabinet backdrop and two-line neon trim
_rect(buf, 0, 0, W, H, DARK)
for x in range(0, W, 4):
c = GOLD if ((x//4 + marquee_phase) % 2 == 0) else RED
_px(buf, x, 0, c); _px(buf, x+1, 15, c)
for reel in self.reels:
reel.draw(buf, dim=dim)
return buf
def idle_frame(self, phase):
buf = self.base_frame(phase)
# tiny center separators / indicator lamps
_px(buf, 23, SPIN_CENTER_Y, AMBER); _px(buf, 47, SPIN_CENTER_Y, AMBER)
return buf
def show_for(self, seconds, builder):
t0 = time.monotonic()
f = 0
while time.monotonic() - t0 < seconds:
self.busy.show(builder(f, time.monotonic()-t0))
f += 1
time.sleep(FRAME_T)
def spin(self, force=None):
result = choose_result(force)
now = time.monotonic()
stops = [1.15, 1.52, 1.95]
# Near-miss drama: if two 7s and not jackpot, make reel 3 land just after 7.
if force is None and result[:2] == ["SEVEN", "SEVEN"] and result[2] != "SEVEN":
stops[2] += 0.25
for i, reel in enumerate(self.reels):
reel.start(result[i], stops[i])
t0 = time.monotonic()
last = t0
frame = 0
while True:
t = time.monotonic()
elapsed = t - t0
dt = min(0.08, t - last)
last = t
for reel in self.reels:
reel.update(elapsed, dt)
buf = self.base_frame(frame // 2)
self.busy.show(buf)
frame += 1
if all(r.stopped and r.bounce_t <= 0 for r in self.reels) and elapsed > 2.05:
break
time.sleep(FRAME_T)
self.current = result
amount, label = payout(result)
if amount:
self.credit += amount
self.win_sequence(result, amount, label)
else:
# short settle dwell after a loss
self.show_for(0.9, lambda f, t: self.idle_frame(f//3))
return result, amount
def win_sequence(self, result, amount, label):
if self.sound and self.audio_ready:
self.busy.play_audio(self.audio_name)
# 1) impact flashes
self.show_for(0.45, lambda f, t: _blank(WHITE if f % 2 == 0 else GOLD))
# 2) symbol pulse + marquee, 1.2s
def pulse(f, t):
buf = _blank(DARK)
phase = f // 2
for x in range(W):
c = [RED, GOLD, WHITE, MAGENTA][(x//3 + phase) % 4]
_px(buf, x, 0, c); _px(buf, x, 15, c)
p = 2 if (f // 2) % 2 == 0 else 0
for i, sym in enumerate(result):
draw_symbol(buf, sym, REEL_X[i] + REEL_W//2, AWARD_CENTER_Y, pulse=p)
return buf
self.show_for(1.15 if label != "JACKPOT" else 1.65, pulse)
# 3) win banner, wipes left-right
banner_text = "JACKPOT" if label == "JACKPOT" else ("BIG WIN" if "BIG" in label else "WIN")
def banner(f, t):
buf = _blank(BLACK)
fill = min(W, int(t / 0.35 * W))
_rect(buf, 0, 0, fill, H, RED if label == "JACKPOT" else GOLD)
draw_text(buf, banner_text, W//2, 5, WHITE if fill > 30 else BLACK, 1, center=True)
return buf
self.show_for(0.75, banner)
# 4) count-up payout; jackpot gets longer show.
duration = 2.0 if label == "JACKPOT" else 1.35
def countup(f, t):
frac = min(1.0, t / duration)
shown = max(1, int(amount * (1 - (1-frac)**3)))
buf = _blank(BLACK)
if label == "JACKPOT":
# falling confetti pixels
rng = random.Random(f // 2)
for _ in range(18):
_px(buf, rng.randrange(W), rng.randrange(H), rng.choice([RED,GOLD,CYAN,MAGENTA,WHITE]))
draw_text(buf, "+" + str(shown), W//2, 5, GREEN if label != "JACKPOT" else YELLOW, 1, center=True)
return buf
self.show_for(duration, countup)
# 5) final credit splash, tiny but readable at 72x16
def credit(f, t):
buf = _blank(BLACK)
draw_text(buf, "WIN", 2, 1, GOLD)
draw_text(buf, str(amount), 2, 8, WHITE)
draw_text(buf, str(self.credit), 71, 8, GREEN, center=False)
# right-align manually using font width
# overwrite by redrawing at calculated x
return buf
# Better custom right-aligned version
def credit2(f, t):
buf = _blank(BLACK)
draw_text(buf, "WIN", 2, 1, GOLD)
draw_text(buf, str(amount), 2, 8, WHITE)
txt = str(self.credit)
tw = sum((len(FONT.get(ch, FONT[' '])[0]) + 1) for ch in txt) - 1
draw_text(buf, txt, 70 - tw, 8, GREEN)
return buf
self.show_for(1.0, credit2)
class StartButtonListener:
"""Direct BUSY Bar START-button listener over /api/status/ws.
This mirrors the media-player implementation: the status WebSocket carries
protobuf State messages. We decode only the fields needed for button input:
State.updates=2 -> StateUpdate.input=11 -> InputEvent.button_event=1.
START enum=2; PRESS=0 and RELEASE=1. Only PRESS queues a spin.
"""
def __init__(self, host, token=None):
self.host = host
self.token = token
self.events = queue.Queue()
self.stop_event = threading.Event()
self.thread = threading.Thread(target=self._thread_main, daemon=True)
self.available = True
self.error = None
def start(self):
try:
import websockets # noqa: F401
except ImportError as exc:
self.available = False
self.error = f"websockets not installed: {exc}"
print(f"START button unavailable ({self.error}); automatic spins still work")
return
self.thread.start()
def stop(self):
self.stop_event.set()
def spin_requested(self):
requested = False
try:
while True:
self.events.get_nowait()
requested = True
except queue.Empty:
return requested
def _thread_main(self):
try:
asyncio.run(self._listen_forever())
except Exception as exc:
self.available = False
self.error = str(exc)
print(f"START button listener stopped ({exc}); automatic spins still work")
async def _listen_forever(self):
import websockets
url = _ws_url(self.host, self.token)
backoff = 0.5
connected_once = False
while not self.stop_event.is_set():
try:
async with websockets.connect(
url,
max_size=4 * 1024 * 1024,
ping_interval=20,
ping_timeout=20,
close_timeout=3,
open_timeout=8,
) as ws:
await ws.send(json.dumps({"enable": True}))
if connected_once:
print("controls: BUSY Bar input reconnected")
else:
print("controls: START=spin (direct WebSocket)")
connected_once = True
self.available = True
self.error = None
backoff = 0.5
async for message in ws:
if self.stop_event.is_set():
return
if isinstance(message, str):
continue
try:
for event in _decode_state_inputs(bytes(message)):
be = event.get("button_event")
if be and be.get("button") == 2 and be.get("action") == 0:
self.events.put(True)
except Exception as exc:
print(f"controls: ignored malformed status frame: {exc}")
except asyncio.CancelledError:
raise
except Exception as exc:
if self.stop_event.is_set():
return
self.available = False
self.error = str(exc)
print(f"controls: input disconnected: {exc}; reconnecting in {backoff:g}s")
deadline = time.monotonic() + backoff
while not self.stop_event.is_set() and time.monotonic() < deadline:
await asyncio.sleep(min(0.25, max(0.0, deadline - time.monotonic())))
backoff = min(3.0, backoff * 2.0)
def _read_varint(buf, pos):
value = 0
shift = 0
while pos < len(buf):
b = buf[pos]
pos += 1
value |= (b & 0x7f) << shift
if not (b & 0x80):
return value, pos
shift += 7
if shift > 63:
raise ValueError("protobuf varint too long")
raise ValueError("truncated protobuf varint")
def _iter_proto_fields(buf):
pos = 0
while pos < len(buf):
key, pos = _read_varint(buf, pos)
field_no, wire = key >> 3, key & 7
if wire == 0:
value, pos = _read_varint(buf, pos)
yield field_no, wire, value
elif wire == 1:
if pos + 8 > len(buf):
return
yield field_no, wire, buf[pos:pos + 8]
pos += 8
elif wire == 2:
n, pos = _read_varint(buf, pos)
end = pos + n
if end > len(buf):
return
yield field_no, wire, buf[pos:end]
pos = end
elif wire == 5:
if pos + 4 > len(buf):
return
yield field_no, wire, buf[pos:pos + 4]
pos += 4
else:
raise ValueError(f"unsupported protobuf wire type {wire}")
def _decode_input_event(buf):
for field_no, wire, value in _iter_proto_fields(buf):
if field_no != 1 or wire != 2: # InputEvent.button_event
continue
button = 0
action = 0
for f, w, v in _iter_proto_fields(value):
if w == 0 and f == 1:
button = int(v)
elif w == 0 and f == 2:
action = int(v)
return {"button_event": {"button": button, "action": action}}
return None
def _decode_state_inputs(frame):
events = []
for field_no, wire, update in _iter_proto_fields(frame):
if field_no != 2 or wire != 2: # State.updates
continue
for uf, uw, uv in _iter_proto_fields(update):
if uf == 11 and uw == 2: # StateUpdate.input
event = _decode_input_event(uv)
if event:
events.append(event)
return events
def _ws_url(host, token=None):
raw = host.rstrip("/")
if "://" not in raw:
raw = "http://" + raw
parsed = urllib.parse.urlparse(raw)
scheme = "wss" if parsed.scheme == "https" else "ws"
path = parsed.path.rstrip("/") + "/api/status/ws"
query = parsed.query
if token:
token_q = urllib.parse.urlencode({"x-api-token": token})
query = token_q if not query else query + "&" + token_q
return urllib.parse.urlunparse((scheme, parsed.netloc, path, "", query, ""))
def parse_args():
p = argparse.ArgumentParser(description="Animated classic slot machine for BUSY Bar")
p.add_argument("--host", default=DEFAULT_HOST)
p.add_argument("--token", default=None, help="Wi-Fi access-key PIN, if required")
p.add_argument("--spin-interval", type=float, default=15.0,
help="seconds between automatic spins (default: 15)")
p.add_argument("--sound", choices=["on", "off"], default="on")
p.add_argument("--auto-spin", choices=["on", "off"], default="on")
p.add_argument("--test-win", action="store_true", help="force one winning spin and exit")
p.add_argument("--test-jackpot", action="store_true", help="force 777 jackpot and exit")
p.add_argument("--seed", type=int, default=None, help="random seed for repeatable testing")
return p.parse_args()
def main():
args = parse_args()
if args.seed is not None:
random.seed(args.seed)
args.spin_interval = max(3.0, args.spin_interval)
busy = Busy(args.host)
slot = SlotMachine(busy, sound=args.sound == "on")
controls = StartButtonListener(args.host, args.token)
controls.start()
print(f"slot-machine -> {busy.base} (Ctrl-C to stop)")
try:
# Always spin immediately on startup.
if args.test_jackpot:
slot.spin("jackpot")
return
if args.test_win:
slot.spin("win")
return
slot.spin()
last_spin = time.monotonic()
frame = 0
while True:
now = time.monotonic()
manual_spin = controls.spin_requested()
automatic_spin = args.auto_spin == "on" and now - last_spin >= args.spin_interval
if manual_spin or automatic_spin:
slot.spin()
last_spin = time.monotonic()
else:
busy.show(slot.idle_frame(frame // 4))
frame += 1
time.sleep(0.12)
except KeyboardInterrupt:
print("\nstopped.")
except urllib.error.URLError as e:
sys.exit(f"error: cannot reach {busy.base} - {e.reason}")
finally:
controls.stop()
busy.clear()
if __name__ == "__main__":
main()