Files
scoreboard/scoreboard.py
T
2026-06-21 00:45:42 -04:00

442 lines
13 KiB
Python

import requests
import os
import govee
import math
import pygame
from enum import Enum
from time import sleep, time
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
from rgbmatrix import RGBMatrix, RGBMatrixOptions, graphics
from dotenv import load_dotenv
# --- Current file path for file resolution
SCRIPT_DIR = Path(__file__).parent.resolve()
# --- Load environment vars ---
load_dotenv()
# --- Default vars ---
ASSET_DIR = os.path.join(SCRIPT_DIR, "assets")
LOGO_DIR = os.path.join(ASSET_DIR, "logos")
# --- Matrix config ---
options = RGBMatrixOptions()
options.rows = 32
options.cols = 64
options.chain_length = 4
options.parallel = 1
options.hardware_mapping = "regular"
options.gpio_slowdown = 5
options.disable_hardware_pulsing = True
options.brightness = 80
matrix = RGBMatrix(options=options)
canvas = matrix.CreateFrameCanvas()
# --- Font initialization ---
font = graphics.Font()
font_small = graphics.Font()
font_big = graphics.Font()
font.LoadFont(os.path.join(ASSET_DIR, "fonts/7x13.bdf"))
font_small.LoadFont(os.path.join(ASSET_DIR, "fonts/5x7.bdf"))
font_big.LoadFont(os.path.join(ASSET_DIR, "fonts/9x18.bdf"))
# --- Logo cache ---
logo_cache = {}
# --- Pre-built colors ---
class Colors(Enum):
WHITE = (255, 255, 255)
YELLOW = (255, 200, 0)
RED = (255, 50, 50)
SABRES_BLUE = (0, 135, 48)
SABRES_GOLD = (252, 20, 210)
# --- Govee API ---
if os.environ.get('GOVEE_API_KEY'):
govee_api = govee.GoveeApi(key=os.environ["GOVEE_API_KEY"])
# --- PyGame Audio ---
# FIX: guard pygame init so a missing audio device on headless Pi doesn't segfault
try:
pygame.mixer.init()
audio_available = True
except pygame.error as e:
print(f"Audio init failed: {e}")
audio_available = False
# --- Goal celebrations ---
def render_goal_frame(text, text_scale, bg_color, text_color):
big_h = max(8, int(32 * text_scale))
big_img = Image.new("RGB", (1024, 128), bg_color)
big_draw = ImageDraw.Draw(big_img)
try:
pil_font = ImageFont.truetype(
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", big_h
)
except:
pil_font = ImageFont.load_default()
bbox = big_draw.textbbox((0, 0), text, font=pil_font)
tw = bbox[2] - bbox[0]
th = bbox[3] - bbox[1]
tx = (1024 - tw) // 2
ty = (128 - th) // 2 - bbox[1]
big_draw.text((tx, ty), text, font=pil_font, fill=text_color)
scaled = big_img.resize((256, 32), Image.LANCZOS)
logo_path = os.path.join(LOGO_DIR, "nhl_BUF.png")
if os.path.exists(logo_path):
try:
logo = Image.open(logo_path).convert("RGBA")
logo = logo.resize((28, 28), Image.LANCZOS)
r, g, b, a = logo.split()
logo_rbg = Image.merge("RGBA", (r, b, g, a))
pixels = logo_rbg.load()
for px in range(logo_rbg.width):
for py in range(logo_rbg.height):
rv, gv, bv, av = pixels[px, py]
if rv < 30 and gv < 30 and bv < 30:
pixels[px, py] = (rv, gv, bv, 0)
bg_left = Image.new("RGBA", (28, 28), bg_color + (255,))
bg_left.paste(logo_rbg, mask=logo_rbg.split()[3])
scaled.paste(bg_left.convert("RGB"), (2, 2))
bg_right = Image.new("RGBA", (28, 28), bg_color + (255,))
bg_right.paste(logo_rbg, mask=logo_rbg.split()[3])
scaled.paste(bg_right.convert("RGB"), (226, 2))
except Exception as e:
print(f"Logo paste error: {e}")
return scaled
def play_goal_celebration(text, color1, color2):
global canvas
# Phase 1: zoom in from tiny to full, alternating bg color
zoom_steps = [0.1, 0.2, 0.35, 0.5, 0.65, 0.8, 0.95, 1.1, 1.0]
for _ in range(5):
for i, scale in enumerate(zoom_steps):
bg = color1 if i % 2 == 0 else color2
fg = color2 if i % 2 == 0 else color1
frame = render_goal_frame(text, scale, bg, fg)
canvas.Clear()
draw_pil_image(canvas, frame)
canvas = matrix.SwapOnVSync(canvas)
sleep(0.05)
# Phase 2: rapid flashing at full size
for i in range(10):
bg = color1 if i % 2 == 0 else color2
fg = color2 if i % 2 == 0 else color1
frame = render_goal_frame(text, 1.0, bg, fg)
canvas.Clear()
draw_pil_image(canvas, frame)
canvas = matrix.SwapOnVSync(canvas)
sleep(0.12)
# Phase 3: zoom back out and fade to white flash
zoom_out = [1.0, 1.1, 1.2, 1.3, 1.4]
for i, scale in enumerate(zoom_out):
bg = color2 if i % 2 == 0 else color1
fg = color1 if i % 2 == 0 else color2
frame = render_goal_frame(text, scale, bg, fg)
canvas.Clear()
draw_pil_image(canvas, frame)
canvas = matrix.SwapOnVSync(canvas)
sleep(0.08)
# Phase 4: white flash to end
for _ in range(3):
canvas.Clear()
frame = render_goal_frame(
text, 1.0, Colors.SABRES_GOLD.value, Colors.SABRES_BLUE.value
)
draw_pil_image(canvas, frame)
canvas = matrix.SwapOnVSync(canvas)
sleep(0.1)
# FIX: don't reassign canvas on .Clear() — it returns None in some bindings
canvas.Clear()
canvas = matrix.SwapOnVSync(canvas)
# stop music if playing
if audio_available:
pygame.mixer.music.stop()
sleep(0.5)
def play_audio(filename):
if not audio_available:
return
pygame.mixer.music.load(os.path.join(ASSET_DIR, filename))
pygame.mixer.music.play()
# --- Utilities ---
def draw_pil_image(canvas, img):
# FIX: ensure RGB (no alpha channel) to avoid 4-tuple unpack crash
img = img.convert("RGB")
for x in range(img.width):
for y in range(img.height):
# FIX: bounds check so we never call SetPixel out of matrix range
if x >= 256 or y >= 32:
continue
r, g, b = img.getpixel((x, y))
canvas.SetPixel(x, y, b, g, r) # bgr panels
def load_logo(league, abbr):
key = f"{league}_{abbr}"
if key in logo_cache:
return logo_cache[key]
path = os.path.join(LOGO_DIR, f"{key}.png")
if not os.path.exists(path):
print(f"Logo not found: {path}")
logo_cache[key] = None
return None
try:
# FIX: convert to RGB here so draw_logo always gets 3-channel pixels
img = Image.open(path).convert("RGB")
logo_cache[key] = img
return img
except Exception as e:
print(f"Error loading logo {key}: {e}")
logo_cache[key] = None
return None
def draw_logo(canvas, img, x, y):
if img is None:
return
for px in range(img.width):
for py in range(img.height):
# FIX: unpack as RGB (load_logo guarantees RGB now)
r, g, b = img.getpixel((px, py))
canvas.SetPixel(x + px, y + py, r, b, g) # bgr panels
# --- Fetch scores ---
def get_scores(sport, league):
url = f"https://site.api.espn.com/apis/site/v2/sports/{sport}/{league}/scoreboard"
try:
resp = requests.get(url, timeout=5)
resp.raise_for_status()
games = []
for event in resp.json().get("events", []):
comp = event["competitions"][0]
teams = comp["competitors"]
home = next(t for t in teams if t["homeAway"] == "home")
away = next(t for t in teams if t["homeAway"] == "away")
status = event["status"]["type"]["shortDetail"]
games.append(
{
"league": league,
"away": away["team"]["abbreviation"].upper(),
"away_score": away["score"],
"home": home["team"]["abbreviation"].upper(),
"home_score": home["score"],
"status": status,
"id": event["id"],
}
)
return games
except Exception as e:
print(f"Fetch error ({league}): {e}")
return []
def get_all_scores():
print('fetching game scores from espn')
games = []
games += get_scores("hockey", "nhl")
games += get_scores("football", "nfl")
games += get_scores("basketball", "nba")
games += get_scores("baseball", "mlb")
return games
# --- Game drawing ---
def draw_all_games(canvas, games, start_index):
for i in range(4):
game_index = (start_index + i) % len(games)
game = games[game_index]
offset = i * 64
league = game["league"]
away_logo = load_logo(league, game["away"])
home_logo = load_logo(league, game["home"])
draw_logo(canvas, away_logo, offset + 0, 0)
draw_logo(canvas, home_logo, offset + 0, 16)
graphics.DrawText(
canvas,
font_small,
offset + 18,
11,
graphics.Color(*Colors.RED.value),
game["away"],
)
graphics.DrawText(
canvas,
font_small,
offset + 18,
27,
graphics.Color(*Colors.WHITE.value),
game["home"],
)
graphics.DrawText(
canvas,
font,
offset + 40,
13,
graphics.Color(*Colors.WHITE.value),
str(game["away_score"]),
)
graphics.DrawText(
canvas,
font,
offset + 40,
29,
graphics.Color(*Colors.WHITE.value),
str(game["home_score"]),
)
if i < 3:
for row in range(32):
canvas.SetPixel(offset + 63, row, 40, 40, 40)
def draw_single_game(canvas, game):
league = game["league"]
home_logo = load_logo(league, game["home"])
away_logo = load_logo(league, game["away"])
draw_logo(canvas, away_logo, 0, 0)
draw_logo(canvas, home_logo, 0, 16)
graphics.DrawText(
canvas,
font_small,
18,
11,
graphics.Color(*Colors.WHITE.value),
game["away"],
)
graphics.DrawText(
canvas,
font_small,
18,
27,
graphics.Color(*Colors.WHITE.value),
game["home"],
)
graphics.DrawText(
canvas,
font,
40,
13,
graphics.Color(*Colors.WHITE.value),
str(game["away_score"]),
)
graphics.DrawText(
canvas,
font,
40,
29,
graphics.Color(*Colors.WHITE.value),
str(game["home_score"]),
)
graphics.DrawText(
canvas,
font,
55,
22,
graphics.Color(*Colors.WHITE.value),
str(game["status"])
)
# --- Main loop ---
def run():
global canvas
games = []
last_fetch = 0
current_page = 0
page_display_time = 8
last_switch = time()
show_preferred = True
current_preferred_game = 0
preferred_games = []
preferred_teams = [
("BUF", "nfl"),
("BUF", "nhl"),
("TOR", "mlb"),
("LAL", "nba"),
]
while True:
now = time()
if now - last_fetch > 30 or len(games) <= 0:
games = get_all_scores()
last_fetch = now
if games:
canvas.Clear()
# clear finished preferred games
if len(preferred_games) > 0:
for preferred_game in preferred_games[:]:
shown_game = [g for g in games if preferred_game == g['id']]
if len(shown_game) <= 0 or "Final" in shown_game[0]['status']:
preferred_games.remove(preferred_game)
# collect new preferred games
for game in games:
if (game['away'], game['league']) in preferred_teams or (game['home'], game['league']) in preferred_teams:
if 'Final' not in game['status'] and game['id'] not in preferred_games:
preferred_games.append(game['id'])
print(preferred_games)
if now - last_switch > page_display_time:
last_switch = now
if show_preferred and len(preferred_games) > 0:
single_preferred_game = next(
(g for g in games if g['id'] == preferred_games[current_preferred_game]), None
)
if single_preferred_game:
print(f'Showing preferred game {single_preferred_game["home"]} vs {single_preferred_game["away"]}')
draw_single_game(canvas, single_preferred_game)
current_preferred_game += 1
if current_preferred_game >= len(preferred_games):
show_preferred = False
current_preferred_game = 0
current_page = 0
else:
print(f'Showing all games page {current_page} / {len(games)}')
draw_all_games(canvas, games, current_page)
current_page += 4
if current_page >= len(games):
current_page = 0
if len(preferred_games) > 0:
show_preferred = True
else:
canvas.Clear()
print('No games available')
graphics.DrawText(canvas, font, 10, 22, graphics.Color(*Colors.RED.value), "No games today")
canvas = matrix.SwapOnVSync(canvas)
sleep(10)
if __name__ == "__main__":
run()