Files
scoreboard/scoreboard.py
T
2026-05-05 16:39:13 -04:00

430 lines
12 KiB
Python

import requests
import os
import govee
import pygame
from enum import Enum
from time import sleep, time
from PIL import Image, ImageDraw, ImageFont
from rgbmatrix import RGBMatrix, RGBMatrixOptions, graphics
from dotenv import load_dotenv
# --- Load environment vars ---
load_dotenv()
# --- Default vars ---
ASSET_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"))
# try to load a big font for GOAL!, fall back to regular if not available
try:
font_big.LoadFont(os.path.join(ASSET_DIR, "fonts/9x18.bdf"))
except:
font_big = font
# --- 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['GOVEE_API_KEY']:
govee_api = govee.GoveeApi(key=os.environ["GOVEE_API_KEY"])
# --- PyGame Audio ---
pygame.mixer.init()
# --- 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)
# rpi specific, fall back to default font if not existing
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)
# paste sabres logo on left and right
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)
# swap G and B channels for RBG order
r, g, b, a = logo.split()
logo_rbg = Image.merge("RGBA", (r, b, g, a))
# make near-black pixels transparent
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)
# paste with transparency onto bg-colored canvas
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)
# clear board
canvas = canvas.Clear()
canvas = matrix.SwapOnVSync(canvas)
# stop music if playing
pygame.mixer.music.stop()
# Hold for a moment then return to scoreboard
sleep(0.5)
def play_audio(filename):
pygame.mixer.music.load(os.path.join(ASSET_DIR, filename))
pygame.mixer.music.play()
# --- Utilities ---
def draw_pil_image(canvas, img):
for x in range(img.width):
for y in range(img.height):
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:
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):
r, g, b = img.getpixel((px, py))
canvas.SetPixel(x + px, y + py, r, g, b) # 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
# --- Draw all games across all panels ---
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()
# preferred games
preferred_game_on = False
current_preferred_game = 0
preferred_games = []
preferred_teams = [
"BUF",
"TOR"
]
while True:
now = time()
if now - last_fetch > 30 or len(games) <= 0:
games = get_all_scores()
last_fetch = now
if games:
# clear bad preferred games out
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:
preferred_games.remove(preferred_game)
# get preferred games
for game in games:
if game['away'] in preferred_teams or game['home'] in preferred_teams:
preferred_game_on = True
preferred_games.append(game['id'])
# either show preferred games or all games
# depending on availability
if not preferred_game_on or len(preferred_games) <= 0:
if now - last_switch > page_display_time:
current_page = (current_page + 4) % max(len(games), 1)
last_switch = now
draw_all_games(canvas, games)
else:
if len(preferred_games) > 1 and now - last_switch > page_display_time:
if current_preferred_game + 1 > len(preferred_games):
current_preferred_game = 0
else:
current_preferred_game = (current_preferred_game + 1) % max(len(games), 1)
last_switch = now
draw_single_game(canvas, preferred_games[current_preferred_game])
else:
# no games available, just draw placeholder
graphics.DrawText(canvas, font, 10, 22, graphics.Color(*Colors.RED.value), "No games today")
canvas = matrix.SwapOnVSync(canvas)
sleep(0.03)
if __name__ == "__main__":
run()