diff --git a/modes/fantasy.py b/modes/fantasy.py new file mode 100644 index 0000000..7f27134 --- /dev/null +++ b/modes/fantasy.py @@ -0,0 +1,283 @@ +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, GAME_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 = GAME_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 * GAME_WIDTH) + + return img, n + + +def _render_pil(img, cell, x): + # right-edge divider + for y in range(PANEL_HEIGHT): + img.putpixel((x + GAME_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 = GAME_WIDTH * len(cells) + + for i, cell in enumerate(cells): + bx = i * GAME_WIDTH - scroll_x + for wrap in [0, total_w]: + x = bx + wrap + if x + GAME_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 + GAME_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 = GAME_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/scoreboard.py b/scoreboard.py index 03901a6..0d0306f 100644 --- a/scoreboard.py +++ b/scoreboard.py @@ -6,7 +6,10 @@ 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 + +# modes import modes.score as score_mode +import modes.fantasy as fantasy_mode # --- Load environment vars --- load_dotenv() @@ -182,7 +185,11 @@ def run(): if supported_modes[current_mode] == "score": canvas_ref = score_mode.draw_frame(canvas) canvas = matrix.SwapOnVSync(canvas_ref) - ++times_ran + elif supported_modes[current_mode] == "fantasy": + canvas_ref = fantasy_mode.draw_frame(canvas) + canvas = matrix.SwapOnVSync(canvas_ref) + + ++times_ran if __name__ == "__main__": run() \ No newline at end of file