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['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, 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 # preferred games 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 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 or "Final" in shown_game[0]['status']: preferred_games.remove(preferred_game) # get 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') # 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(10) if __name__ == "__main__": run()