This commit is contained in:
2026-08-02 00:17:57 -04:00
parent f1975de000
commit 3ffe226b84
7 changed files with 182 additions and 664 deletions
-283
View File
@@ -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
+125 -160
View File
@@ -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
return matrix.canvas