From 3ffe226b8442bd97cf625500116bd98bd8ddf0da Mon Sep 17 00:00:00 2001 From: Alex Frantz Date: Sun, 2 Aug 2026 00:17:57 -0400 Subject: [PATCH] rework --- .gitignore | 3 +- main.py | 16 +++ modes/fantasy.py | 283 -------------------------------------- modes/score.py | 285 +++++++++++++++++---------------------- scoreboard.py | 224 ++++++------------------------ utils/logos.py | 35 ----- utils/vars.py => vars.py | 0 7 files changed, 182 insertions(+), 664 deletions(-) create mode 100644 main.py delete mode 100644 modes/fantasy.py delete mode 100644 utils/logos.py rename utils/vars.py => vars.py (100%) diff --git a/.gitignore b/.gitignore index d889806..5062c27 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ __pycache__ .env -logos \ No newline at end of file +logos +old \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..150a762 --- /dev/null +++ b/main.py @@ -0,0 +1,16 @@ +from scoreboard import ScoreboardMatrix +from time import sleep + +# modes +from modes import score + +scoreboard_matrix = ScoreboardMatrix() + +def main(): + while True: + if scoreboard_matrix.current_slide == "score": + score.draw_frame(scoreboard_matrix) + + sleep(2) + +main() \ No newline at end of file diff --git a/modes/fantasy.py b/modes/fantasy.py deleted file mode 100644 index 29acd98..0000000 --- a/modes/fantasy.py +++ /dev/null @@ -1,283 +0,0 @@ -import requests -from PIL import Image -from rgbmatrix import graphics -import utils.logos as logos -from utils.vars import Colors, font, font_small, PANEL_WIDTH, PANEL_HEIGHT, FANTASY_WIDTH, DIVIDER_COLOR -from time import time - -FETCH_INTERVAL = 60 # seconds between API calls - -# Logo layout: 14x14 centered vertically in the 32px panel -LOGO_SIZE = 14 -LOGO_Y = 9 # (32 - 14) // 2 = 9 -TEXT_X = 17 # x offset for text after logo - -# Off-enum colors -_COLOR_GREEN = (0, 200, 80) - -# --- State --- -_data = None -_last_fetch = 0 -_cells = [] -_scroll_x = 0 -_scroll_speed = 1 -_frames_per_tick = 2 -_tick = 0 -_virtual_canvas = None -_virtual_dirty = True - - -# --------------------------------------------------------------------------- -# RBG channel correction for Waveshare P2.5 panels -# Hardware physical order is R-B-G, so swap G and B before passing to API. -# --------------------------------------------------------------------------- -def _rbg(rgb): - r, g, b = rgb - return graphics.Color(r, b, g) - - -# --------------------------------------------------------------------------- -# Data fetch -# --------------------------------------------------------------------------- -def _fetch(): - try: - resp = requests.get("https://api.alexav.gg/v4/sports/fantasy", timeout=5) - resp.raise_for_status() - return resp.json() - except Exception as e: - print(f"[fantasy] fetch error: {e}") - return None - - -# --------------------------------------------------------------------------- -# Formatting helpers -# --------------------------------------------------------------------------- -def _player_name(player): - """Return a short display name (≤9 chars). DEF units use team abbreviation.""" - if player.get("position") == "DEF": - return player.get("team", "DEF")[:9] - first = player.get("first_name", "") - last = player.get("last_name", "") - if first and last: - return f"{first[0]}.{last}"[:9] - return (last or first)[:9] - - -def _team_label(name, maxlen=12): - """Strip parenthetical suffixes and truncate team name.""" - paren = name.find(" (") - label = name[:paren] if paren > 0 else name - return label[:maxlen] - - -def _fmt_pts(pts): - """Format fantasy points to one decimal place.""" - return f"{float(pts):.1f}" - - -# --------------------------------------------------------------------------- -# Build the ordered cell list from API data -# Structure: [Team1 Header] [T1 Player ...] [Team2 Header] [T2 Player ...] -# --------------------------------------------------------------------------- -def _build_cells(data): - cells = [] - t1, t2 = data["team1"], data["team2"] - - for team, opp in [(t1, t2), (t2, t1)]: - meta = team["owner"].get("metadata", {}) - team_name = meta.get("team_name", team["owner"]["display_name"]) - winning = float(team["points"]) >= float(opp["points"]) - - # ── Team summary header cell ────────────────────────────────────── - cells.append({ - "type": "team_info", - "owner": team["owner"]["display_name"], - "team_name": team_name, - "points": team["points"], - "opp_points": opp["points"], - "winning": winning, - }) - - # ── One cell per starter ───────────────────────────────────────── - pp = team["players_points"] - for player in team["starters"]: - pid = player["player_id"] - injury = player.get("injury_status") - cells.append({ - "type": "player", - "nfl_team": player.get("team", ""), - "name": _player_name(player), - "position": player.get("position", ""), - "points": pp.get(pid, 0), - "injury": injury, - }) - - return cells - - -# --------------------------------------------------------------------------- -# PIL canvas: logos + dividers (blit-able background layer) -# --------------------------------------------------------------------------- -def _build_virtual_canvas(cells): - if not cells: - return None - n = len(cells) - total_w = FANTASY_WIDTH * (n + 4) # +4 tail so wrap never shows black - img = Image.new("RGB", (total_w, PANEL_HEIGHT), (0, 0, 0)) - - for i, cell in enumerate(cells * 2): - if i >= n + 4: - break - _render_pil(img, cell, i * FANTASY_WIDTH) - - return img, n - - -def _render_pil(img, cell, x): - # right-edge divider - for y in range(PANEL_HEIGHT): - img.putpixel((x + FANTASY_WIDTH - 1, y), DIVIDER_COLOR) - - # NFL team logo for player cells - if cell["type"] == "player" and cell["nfl_team"]: - logo = logos.load_logo("nfl", cell["nfl_team"]) - if logo: - img.paste(logo.resize((LOGO_SIZE, LOGO_SIZE)), (x + 1, LOGO_Y)) - - -# --------------------------------------------------------------------------- -# Blit a PANEL_WIDTH-wide slice from the PIL canvas to the matrix -# --------------------------------------------------------------------------- -def _blit_slice(canvas, img, offset): - w = img.width - for x in range(PANEL_WIDTH): - src_x = (offset + x) % w - for y in range(PANEL_HEIGHT): - r, g, b = img.getpixel((src_x, y)) - canvas.SetPixel(x, y, r, b, g) # RBG panels: swap G and B - - -# --------------------------------------------------------------------------- -# Text overlay (drawn on top of the blitted PIL layer) -# --------------------------------------------------------------------------- -def _draw_overlay(canvas, cells, scroll_x): - total_w = FANTASY_WIDTH * len(cells) - - for i, cell in enumerate(cells): - bx = i * FANTASY_WIDTH - scroll_x - for wrap in [0, total_w]: - x = bx + wrap - if x + FANTASY_WIDTH < 0 or x >= PANEL_WIDTH: - continue - if cell["type"] == "team_info": - _draw_team_info(canvas, cell, x) - else: - _draw_player(canvas, cell, x) - - -def _draw_team_info(canvas, cell, x): - """ - Layout (64 × 32 px cell): - y= 8 │ Owner display name (yellow) - y=17 │ Fantasy team name (white) - y=28 │ 144.0-134.3 score line (green if winning, red if losing) - """ - score_color = _COLOR_GREEN if cell["winning"] else Colors.RED.value - - graphics.DrawText(canvas, font_small, x + 2, 8, - _rbg(Colors.YELLOW.value), - cell["owner"][:12]) - - graphics.DrawText(canvas, font_small, x + 2, 17, - _rbg(Colors.WHITE.value), - _team_label(cell["team_name"])) - - score_line = f"{_fmt_pts(cell['points'])}-{_fmt_pts(cell['opp_points'])}" - graphics.DrawText(canvas, font_small, x + 2, 28, - _rbg(score_color), - score_line) - - -def _draw_player(canvas, cell, x): - """ - Layout (64 × 32 px cell): - Left 14 px │ NFL team logo (rendered in PIL layer) - x+17, y=13 │ Player name — white / yellow (Q) / red (Out/IR) - x+17, y=26 │ "POS pts" - top-right │ injury indicator letter (Q / D / X) - """ - injury = cell.get("injury") - - # Name color reflects injury severity - if injury in ("Out", "IR"): - name_color = Colors.RED.value - elif injury in ("Questionable", "Doubtful"): - name_color = Colors.YELLOW.value - else: - name_color = Colors.WHITE.value - - graphics.DrawText(canvas, font_small, x + TEXT_X, 13, - _rbg(name_color), cell["name"]) - - # Injury indicator letter in top-right corner of the cell - if injury: - ind_map = {"Questionable": "Q", "Doubtful": "D", "Out": "X", "IR": "IR"} - indicator = ind_map.get(injury, "?") - ind_color = Colors.YELLOW.value if injury in ("Questionable", "Doubtful") else Colors.RED.value - graphics.DrawText(canvas, font_small, x + FANTASY_WIDTH - 10, 7, - _rbg(ind_color), indicator) - - # Position + fantasy points - pts_line = f"{cell['position']} {_fmt_pts(cell['points'])}" - graphics.DrawText(canvas, font_small, x + TEXT_X, 26, - _rbg(Colors.WHITE.value), pts_line) - - -# --------------------------------------------------------------------------- -# Public entry point — call from the main render loop -# --------------------------------------------------------------------------- -def draw_frame(canvas): - global _data, _last_fetch, _cells - global _virtual_canvas, _virtual_dirty, _scroll_x, _tick - - now = time() - - # Refresh data on interval - if now - _last_fetch > FETCH_INTERVAL or _data is None: - fresh = _fetch() - if fresh: - _data = fresh - _cells = _build_cells(_data) - _virtual_dirty = True - _last_fetch = now - - if not _cells: - canvas.Clear() - graphics.DrawText(canvas, font, 8, 20, - _rbg(Colors.RED.value), "No fantasy") - return canvas - - # Rebuild PIL background layer when data changes - if _virtual_dirty or _virtual_canvas is None: - result = _build_virtual_canvas(_cells) - if result: - _virtual_canvas, _ = result - _virtual_dirty = False - _scroll_x = 0 - - total_scroll_w = FANTASY_WIDTH * len(_cells) - - canvas.Clear() - - if _virtual_canvas: - _blit_slice(canvas, _virtual_canvas, _scroll_x) - - _draw_overlay(canvas, _cells, _scroll_x) - - # Advance scroll position - _tick += 1 - if _tick >= _frames_per_tick: - _tick = 0 - _scroll_x = (_scroll_x + _scroll_speed) % total_scroll_w - - return canvas \ No newline at end of file diff --git a/modes/score.py b/modes/score.py index 00879c5..96e15a7 100644 --- a/modes/score.py +++ b/modes/score.py @@ -1,130 +1,76 @@ import requests -import utils.logos as logos from PIL import Image -from rgbmatrix import graphics -from utils.vars import Colors, font, font_small, PANEL_WIDTH, PANEL_HEIGHT, GAME_WIDTH, DIVIDER_COLOR from time import time +from rgbmatrix import graphics +from vars import PANEL_HEIGHT, PANEL_WIDTH, GAME_WIDTH, DIVIDER_COLOR, Colors, font, font_small -# --- State --- -_games = [] -_last_fetch = 0 -_preferred_games = [] -_preferred_teams = [ - ("BUF", "nfl"), - ("BUF", "nhl"), - ("TOR", "mlb"), - ("LAL", "nba"), - ("NYY", "mlb") -] +games = [] +last_fetch = 0 +preferred_games = [] +preferred_teams = [] -# Carousel scroll state -_scroll_x = 0 -_scroll_speed = 1 # pixels per frame -_frames_per_tick = 2 # how many main loop ticks per scroll step (lower = faster) -_tick = 0 -_virtual_canvas = None # PIL Image of the full wide render -_virtual_dirty = True # rebuild the virtual canvas on next frame +scroll_x = 0 +scroll_speed = 1 +frames_per_tick = 2 +tick = 0 +times_scrolled = 0 +virtual_canvas = None +virtual_dirty = True -# --- Color helpers --- -def _rbg(color_tuple): - """Convert an (R, G, B) tuple to a graphics.Color with G and B swapped - to correct for RBG panel channel ordering on Waveshare P2.5 panels. - Hardware channel order is (R, B, G) but the API expects (R, G, B), - so we swap G and B before passing values in.""" - r, g, b = color_tuple - return graphics.Color(r, b, g) +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() + result = [] + 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"] + result.append({ + "league": league, + "venue": comp["venue"]["fullName"], + "away": away["team"]["abbreviation"].upper(), + "away_score": away["score"], + "home": home["team"]["abbreviation"].upper(), + "home_score": home["score"], + "status": status, + "id": event["id"], + }) + return result + except Exception as e: + print(f"Fetch error ({league}): {e}") + return [] -# --- Fetch --- -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() - result = [] - 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"] - result.append({ - "league": league, - "venue": comp["venue"]["fullName"], - "away": away["team"]["abbreviation"].upper(), - "away_score": away["score"], - "home": home["team"]["abbreviation"].upper(), - "home_score": home["score"], - "status": status, - "id": event["id"], - }) - return result - except Exception as e: - print(f"Fetch error ({league}): {e}") - return [] - -def _get_all_scores(): +def get_all_scores(): print("fetching game scores from espn") result = [] - result += _get_scores("hockey", "nhl") - result += _get_scores("football", "nfl") - result += _get_scores("basketball", "nba") - result += _get_scores("baseball", "mlb") + result += get_scores("hockey", "nhl") + result += get_scores("football", "nfl") + result += get_scores("basketball", "nba") + result += get_scores("baseball", "mlb") return result -# --- Build ordered game list: preferred first, then rest --- -def _ordered_games(): - preferred_ids = set(_preferred_games) +def ordered_games(): + preferred_ids = set(preferred_games) preferred = [g for g in _games if g["id"] in preferred_ids] return preferred -# --- Render a single game slot into a PIL image at a given x offset --- -def _render_game_to_pil(img, game, x_offset): - league = game["league"] +def update_preferred(): + for gid in list(preferred_games): + game = next((g for g in _games if g["id"] == gid), None) + if game is None or "Final" in game["status"]: + preferred_games.remove(gid) - # logos - away_logo = logos.load_logo(league, game["away"]) - home_logo = logos.load_logo(league, game["home"]) - if away_logo: - img.paste(away_logo.resize((14, 14)), (x_offset, 0)) - if home_logo: - img.paste(home_logo.resize((14, 14)), (x_offset, 16)) + # add new matching games + for game in _games: + if (game["away"], game["league"]) in preferred_teams or \ + (game["home"], game["league"]) in preferred_teams: + preferred_games.append(game["id"]) - # divider on right edge (except last slot handled by wrapping) - for row in range(PANEL_HEIGHT): - img.putpixel((x_offset + GAME_WIDTH - 1, row), DIVIDER_COLOR) - -# --- Build the full virtual PIL canvas for all ordered games --- -def _build_virtual_canvas(): - ordered = _ordered_games() - if not ordered: - return None - - # wide enough for all games, plus one extra copy at the end for seamless wrap - total_games = len(ordered) - total_width = GAME_WIDTH * (total_games + 4) # +4 so wrap tail fills display - img = Image.new("RGB", (total_width, PANEL_HEIGHT), (0, 0, 0)) - - for i, game in enumerate(ordered * 2): # duplicate for seamless wrap - if i >= total_games + 4: - break - _render_game_to_pil(img, game, i * GAME_WIDTH) - - return img, total_games - -# --- Blit a 256-wide slice of the virtual canvas onto the rgbmatrix canvas --- -def _blit_slice(canvas, pil_img, x_offset): - total_width = pil_img.width - for x in range(PANEL_WIDTH): - src_x = (x_offset + x) % total_width - for y in range(PANEL_HEIGHT): - r, g, b = pil_img.getpixel((src_x, y)) - canvas.SetPixel(x, y, r, b, g) # RBG panels: swap G and B - -# --- Draw text onto the virtual canvas using PIL (since rgbmatrix fonts need a real canvas) --- -# We use rgbmatrix DrawText on the live canvas offset by -scroll_x for text only, -# and PIL for logos/backgrounds. See draw_frame() for how these combine. - -def _draw_text_overlay(canvas, ordered, scroll_x): +def draw_text_overlay(canvas, ordered, scroll_x): """Draw all game text onto the rgbmatrix canvas accounting for scroll offset.""" total_width = GAME_WIDTH * len(ordered) @@ -140,13 +86,13 @@ def _draw_text_overlay(canvas, ordered, scroll_x): continue graphics.DrawText(canvas, font_small, x + 18, 11, - _rbg(Colors.RED.value), game["away"]) + Colors.RED.value, game["away"]) graphics.DrawText(canvas, font_small, x + 18, 27, - _rbg(Colors.WHITE.value), game["home"]) + Colors.WHITE.value, game["home"]) graphics.DrawText(canvas, font, x + 40, 13, - _rbg(Colors.WHITE.value), str(game["away_score"])) + Colors.WHITE.value, str(game["away_score"])) graphics.DrawText(canvas, font, x + 40, 29, - _rbg(Colors.WHITE.value), str(game["home_score"])) + Colors.WHITE.value, str(game["home_score"])) # status line — only on preferred games (they get a wider single-game view) # if game["id"] in set(_preferred_games): @@ -161,77 +107,96 @@ def _draw_text_overlay(canvas, ordered, scroll_x): time = game_status_split[1].strip() graphics.DrawText(canvas, font_small, x + 60, 10, - _rbg(Colors.YELLOW.value), date) + Colors.YELLOW.value, date) graphics.DrawText(canvas, font_small, x + 60, 20, - _rbg(Colors.YELLOW.value), time) + Colors.YELLOW.value, time) graphics.DrawText(canvas, font_small, x + 60, 30, - _rbg(Colors.YELLOW.value), game["venue"]) + Colors.YELLOW.value, game["venue"]) else: graphics.DrawText(canvas, font_small, x + 65, 20, - _rbg(Colors.YELLOW.value), game["status"]) + Colors.YELLOW.value, game["status"]) +def render_game(matrix, img, game, x_offset): + league = game["league"] + away_logo = matrix.load_logo_to_image(league, game["away"]) + home_logo = matrix.load_logo_to_image(league, game["home"]) + if away_logo: + img.paste(away_logo.resize((14, 14)), (x_offset, 0)) + if home_logo: + img.paste(home_logo.resize((14, 14)), (x_offset, 16)) -# --- Preferred / stale game management --- -def _update_preferred(): - preferred_id_set = set(_preferred_games) + # divider on right edge (except last slot handled by wrapping) + for row in range(PANEL_HEIGHT): + img.putpixel((x_offset + GAME_WIDTH - 1, row), DIVIDER_COLOR) - # remove finished or gone games - active_ids = {g["id"] for g in _games} - for gid in list(_preferred_games): - game = next((g for g in _games if g["id"] == gid), None) - if game is None or "Final" in game["status"]: - _preferred_games.remove(gid) +def build_canvas(matrix): + ordered = ordered_games() + if not ordered: + return None - # add new matching games - for game in _games: - if (game["away"], game["league"]) in _preferred_teams or \ - (game["home"], game["league"]) in _preferred_teams: - _preferred_games.append(game["id"]) + total_games = len(ordered) + total_width = GAME_WIDTH * (total_games + 4) + img = Image.new("RGB", (total_width, PANEL_HEIGHT), (0,0,0)) -# --- Public draw_frame --- -def draw_frame(canvas): - global _games, _last_fetch, _virtual_canvas, _virtual_dirty, _scroll_x, _tick + for i, game in enumerate(ordered * 2): + if i >= total_games + 4: + break + render_game(matrix, img, game, i * GAME_WIDTH) + return img, total_games + +def blit_slice(canvas, pil_img, x_offset): + total_width = pil_img.width + for x in range(PANEL_WIDTH): + src_x = (x_offset + x) % total_width + for y in range(PANEL_HEIGHT): + r, g, b = pil_img.getpixel((src_x, y)) + canvas.SetPixel(x, y, r, b, g) + +def draw_frame(matrix): + global games, last_fetch, virtual_canvas, virtual_dirty, scroll_x, tick now = time() - # refresh scores every 30s - if now - _last_fetch > 30 or not _games: - _games = _get_all_scores() - _last_fetch = now - _update_preferred() - _virtual_dirty = True + if now - last_fetch > 30 or not games: + games = get_all_scores() + last_fetch = now + update_preferred() + virtual_dirty = True - if not _games: - canvas.Clear() - graphics.DrawText(canvas, font, 10, 22, - _rbg(Colors.RED.value), "No games today") - return canvas + if not games: + matrix.canvas.Clear() + graphics.DrawText(matrix.canvas, font, 10, 22, + Colors.RED.value, "No games today") + return matrix.canvas # rebuild virtual canvas if data changed - if _virtual_dirty or _virtual_canvas is None: - result = _build_virtual_canvas() + if virtual_dirty or virtual_canvas is None: + result = build_canvas() if result: - _virtual_canvas, _total_games = result - _virtual_dirty = False - _scroll_x = 0 + virtual_canvas, total_games = result + virtual_dirty = False + scroll_x = 0 - ordered = _ordered_games() + ordered = ordered_games() total_scroll_width = GAME_WIDTH * len(ordered) - canvas.Clear() + matrix.canvas.Clear() # blit the PIL image slice (logos + dividers + backgrounds) - if _virtual_canvas: - _blit_slice(canvas, _virtual_canvas, _scroll_x) + if virtual_canvas: + blit_slice(matrix.canvas, virtual_canvas, scroll_x) # draw text on top via rgbmatrix (handles fonts correctly) - _draw_text_overlay(canvas, ordered, _scroll_x) + draw_text_overlay(matrix.canvas, ordered, scroll_x) # advance scroll every N ticks _tick += 1 - if _tick >= _frames_per_tick: + if _tick >= frames_per_tick: _tick = 0 - _scroll_x = (_scroll_x + _scroll_speed) % total_scroll_width + next_x = scroll_x + scroll_speed + if next_x >= total_scroll_width: + matrix.next_slide("fantasy") + scroll_x = next_x % total_scroll_width - return canvas \ No newline at end of file + return matrix.canvas \ No newline at end of file diff --git a/scoreboard.py b/scoreboard.py index 991ea7b..40cd92c 100644 --- a/scoreboard.py +++ b/scoreboard.py @@ -1,193 +1,47 @@ -import os -import govee -import pygame -from time import sleep -from PIL import Image, ImageDraw, ImageFont from rgbmatrix import RGBMatrix, RGBMatrixOptions -from dotenv import load_dotenv -from utils.vars import Colors, LOGO_DIR, ASSET_DIR +from PIL import Image +import os +import vars -# modes -import modes.score as score_mode -import modes.fantasy as fantasy_mode +class ScoreboardMatrix(): + logo_cache = {} + current_slide = "score" + slide_count = 0 + 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 -# --- Load environment vars --- -load_dotenv() + matrix = RGBMatrix(options=options) + canvas = matrix.CreateFrameCanvas() -# --- 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 + def load_logo_to_image(self, league, abbr, width, height, x_offset, y_offset): + key = f"{league}_{abbr}.png" + logo_path = os.path.join(vars.LOGOS_DIR, key) + if not os.path.exists(logo_path): + return None + + if key in self.logo_cache: + loaded_image = self.logo_cache[key] + else: + loaded_image = Image.open(logo_path).convert("RGB") + self.logo_cache[key] = loaded_image + + image = Image.new("RGB", (width, height), (0,0,0)) + image.paste(loaded_image.resize((width, height), (x_offset, y_offset))) -matrix = RGBMatrix(options=options) -canvas = matrix.CreateFrameCanvas() + return image -# --- Govee API --- -if os.environ.get('GOVEE_API_KEY'): - govee_api = govee.GoveeApi(key=os.environ["GOVEE_API_KEY"]) + def iterate_slide_count(self): + self.slide_count += 1 -# --- 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 + def next_slide(self, slide_name): + self.slide_count = 0 + self.current_slide = slide_name -# --- 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 run(): - global canvas - times_ran = 0 - supported_modes = ["fantasy", "score"] - current_mode = 0 - - while True: - if times_ran >= 3: - times_ran = 0 - current_mode += 1 - if current_mode >= len(supported_modes): - current_mode = 0 - - if supported_modes[current_mode] == "score": - canvas_ref = score_mode.draw_frame(canvas) - canvas = matrix.SwapOnVSync(canvas_ref) - elif supported_modes[current_mode] == "fantasy": - canvas_ref = fantasy_mode.draw_frame(canvas) - canvas = matrix.SwapOnVSync(canvas_ref) - - times_ran += 1 - -if __name__ == "__main__": - run() \ No newline at end of file + \ No newline at end of file diff --git a/utils/logos.py b/utils/logos.py deleted file mode 100644 index fdf701c..0000000 --- a/utils/logos.py +++ /dev/null @@ -1,35 +0,0 @@ -import os -from utils.vars import LOGO_DIR -from PIL import Image - -logo_cache = {} - -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, b, r, g) # bgr panels \ No newline at end of file diff --git a/utils/vars.py b/vars.py similarity index 100% rename from utils/vars.py rename to vars.py