rework
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
__pycache__
|
__pycache__
|
||||||
.env
|
.env
|
||||||
logos
|
logos
|
||||||
|
old
|
||||||
@@ -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()
|
||||||
@@ -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
@@ -1,130 +1,76 @@
|
|||||||
import requests
|
import requests
|
||||||
import utils.logos as logos
|
|
||||||
from PIL import Image
|
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 time import time
|
||||||
|
from rgbmatrix import graphics
|
||||||
|
from vars import PANEL_HEIGHT, PANEL_WIDTH, GAME_WIDTH, DIVIDER_COLOR, Colors, font, font_small
|
||||||
|
|
||||||
# --- State ---
|
games = []
|
||||||
_games = []
|
last_fetch = 0
|
||||||
_last_fetch = 0
|
preferred_games = []
|
||||||
_preferred_games = []
|
preferred_teams = []
|
||||||
_preferred_teams = [
|
|
||||||
("BUF", "nfl"),
|
|
||||||
("BUF", "nhl"),
|
|
||||||
("TOR", "mlb"),
|
|
||||||
("LAL", "nba"),
|
|
||||||
("NYY", "mlb")
|
|
||||||
]
|
|
||||||
|
|
||||||
# Carousel scroll state
|
scroll_x = 0
|
||||||
_scroll_x = 0
|
scroll_speed = 1
|
||||||
_scroll_speed = 1 # pixels per frame
|
frames_per_tick = 2
|
||||||
_frames_per_tick = 2 # how many main loop ticks per scroll step (lower = faster)
|
tick = 0
|
||||||
_tick = 0
|
times_scrolled = 0
|
||||||
_virtual_canvas = None # PIL Image of the full wide render
|
virtual_canvas = None
|
||||||
_virtual_dirty = True # rebuild the virtual canvas on next frame
|
virtual_dirty = True
|
||||||
|
|
||||||
# --- Color helpers ---
|
def get_scores(sport, league):
|
||||||
def _rbg(color_tuple):
|
url = f"https://site.api.espn.com/apis/site/v2/sports/{sport}/{league}/scoreboard"
|
||||||
"""Convert an (R, G, B) tuple to a graphics.Color with G and B swapped
|
try:
|
||||||
to correct for RBG panel channel ordering on Waveshare P2.5 panels.
|
resp = requests.get(url, timeout=5)
|
||||||
Hardware channel order is (R, B, G) but the API expects (R, G, B),
|
resp.raise_for_status()
|
||||||
so we swap G and B before passing values in."""
|
result = []
|
||||||
r, g, b = color_tuple
|
for event in resp.json().get("events", []):
|
||||||
return graphics.Color(r, b, g)
|
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_all_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()
|
|
||||||
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():
|
|
||||||
print("fetching game scores from espn")
|
print("fetching game scores from espn")
|
||||||
result = []
|
result = []
|
||||||
result += _get_scores("hockey", "nhl")
|
result += get_scores("hockey", "nhl")
|
||||||
result += _get_scores("football", "nfl")
|
result += get_scores("football", "nfl")
|
||||||
result += _get_scores("basketball", "nba")
|
result += get_scores("basketball", "nba")
|
||||||
result += _get_scores("baseball", "mlb")
|
result += get_scores("baseball", "mlb")
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# --- Build ordered game list: preferred first, then rest ---
|
def ordered_games():
|
||||||
def _ordered_games():
|
preferred_ids = set(preferred_games)
|
||||||
preferred_ids = set(_preferred_games)
|
|
||||||
preferred = [g for g in _games if g["id"] in preferred_ids]
|
preferred = [g for g in _games if g["id"] in preferred_ids]
|
||||||
return preferred
|
return preferred
|
||||||
|
|
||||||
# --- Render a single game slot into a PIL image at a given x offset ---
|
def update_preferred():
|
||||||
def _render_game_to_pil(img, game, x_offset):
|
for gid in list(preferred_games):
|
||||||
league = game["league"]
|
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
|
# add new matching games
|
||||||
away_logo = logos.load_logo(league, game["away"])
|
for game in _games:
|
||||||
home_logo = logos.load_logo(league, game["home"])
|
if (game["away"], game["league"]) in preferred_teams or \
|
||||||
if away_logo:
|
(game["home"], game["league"]) in preferred_teams:
|
||||||
img.paste(away_logo.resize((14, 14)), (x_offset, 0))
|
preferred_games.append(game["id"])
|
||||||
if home_logo:
|
|
||||||
img.paste(home_logo.resize((14, 14)), (x_offset, 16))
|
|
||||||
|
|
||||||
# divider on right edge (except last slot handled by wrapping)
|
def draw_text_overlay(canvas, ordered, scroll_x):
|
||||||
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):
|
|
||||||
"""Draw all game text onto the rgbmatrix canvas accounting for scroll offset."""
|
"""Draw all game text onto the rgbmatrix canvas accounting for scroll offset."""
|
||||||
total_width = GAME_WIDTH * len(ordered)
|
total_width = GAME_WIDTH * len(ordered)
|
||||||
|
|
||||||
@@ -140,13 +86,13 @@ def _draw_text_overlay(canvas, ordered, scroll_x):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
graphics.DrawText(canvas, font_small, x + 18, 11,
|
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,
|
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,
|
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,
|
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)
|
# status line — only on preferred games (they get a wider single-game view)
|
||||||
# if game["id"] in set(_preferred_games):
|
# 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()
|
time = game_status_split[1].strip()
|
||||||
|
|
||||||
graphics.DrawText(canvas, font_small, x + 60, 10,
|
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,
|
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,
|
graphics.DrawText(canvas, font_small, x + 60, 30,
|
||||||
_rbg(Colors.YELLOW.value), game["venue"])
|
Colors.YELLOW.value, game["venue"])
|
||||||
else:
|
else:
|
||||||
graphics.DrawText(canvas, font_small, x + 65, 20,
|
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 ---
|
# divider on right edge (except last slot handled by wrapping)
|
||||||
def _update_preferred():
|
for row in range(PANEL_HEIGHT):
|
||||||
preferred_id_set = set(_preferred_games)
|
img.putpixel((x_offset + GAME_WIDTH - 1, row), DIVIDER_COLOR)
|
||||||
|
|
||||||
# remove finished or gone games
|
def build_canvas(matrix):
|
||||||
active_ids = {g["id"] for g in _games}
|
ordered = ordered_games()
|
||||||
for gid in list(_preferred_games):
|
if not ordered:
|
||||||
game = next((g for g in _games if g["id"] == gid), None)
|
return None
|
||||||
if game is None or "Final" in game["status"]:
|
|
||||||
_preferred_games.remove(gid)
|
|
||||||
|
|
||||||
# add new matching games
|
total_games = len(ordered)
|
||||||
for game in _games:
|
total_width = GAME_WIDTH * (total_games + 4)
|
||||||
if (game["away"], game["league"]) in _preferred_teams or \
|
img = Image.new("RGB", (total_width, PANEL_HEIGHT), (0,0,0))
|
||||||
(game["home"], game["league"]) in _preferred_teams:
|
|
||||||
_preferred_games.append(game["id"])
|
|
||||||
|
|
||||||
# --- Public draw_frame ---
|
for i, game in enumerate(ordered * 2):
|
||||||
def draw_frame(canvas):
|
if i >= total_games + 4:
|
||||||
global _games, _last_fetch, _virtual_canvas, _virtual_dirty, _scroll_x, _tick
|
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()
|
now = time()
|
||||||
|
|
||||||
# refresh scores every 30s
|
if now - last_fetch > 30 or not games:
|
||||||
if now - _last_fetch > 30 or not _games:
|
games = get_all_scores()
|
||||||
_games = _get_all_scores()
|
last_fetch = now
|
||||||
_last_fetch = now
|
update_preferred()
|
||||||
_update_preferred()
|
virtual_dirty = True
|
||||||
_virtual_dirty = True
|
|
||||||
|
|
||||||
if not _games:
|
if not games:
|
||||||
canvas.Clear()
|
matrix.canvas.Clear()
|
||||||
graphics.DrawText(canvas, font, 10, 22,
|
graphics.DrawText(matrix.canvas, font, 10, 22,
|
||||||
_rbg(Colors.RED.value), "No games today")
|
Colors.RED.value, "No games today")
|
||||||
return canvas
|
return matrix.canvas
|
||||||
|
|
||||||
# rebuild virtual canvas if data changed
|
# rebuild virtual canvas if data changed
|
||||||
if _virtual_dirty or _virtual_canvas is None:
|
if virtual_dirty or virtual_canvas is None:
|
||||||
result = _build_virtual_canvas()
|
result = build_canvas()
|
||||||
if result:
|
if result:
|
||||||
_virtual_canvas, _total_games = result
|
virtual_canvas, total_games = result
|
||||||
_virtual_dirty = False
|
virtual_dirty = False
|
||||||
_scroll_x = 0
|
scroll_x = 0
|
||||||
|
|
||||||
ordered = _ordered_games()
|
ordered = ordered_games()
|
||||||
total_scroll_width = GAME_WIDTH * len(ordered)
|
total_scroll_width = GAME_WIDTH * len(ordered)
|
||||||
|
|
||||||
canvas.Clear()
|
matrix.canvas.Clear()
|
||||||
|
|
||||||
# blit the PIL image slice (logos + dividers + backgrounds)
|
# blit the PIL image slice (logos + dividers + backgrounds)
|
||||||
if _virtual_canvas:
|
if virtual_canvas:
|
||||||
_blit_slice(canvas, _virtual_canvas, _scroll_x)
|
blit_slice(matrix.canvas, virtual_canvas, scroll_x)
|
||||||
|
|
||||||
# draw text on top via rgbmatrix (handles fonts correctly)
|
# 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
|
# advance scroll every N ticks
|
||||||
_tick += 1
|
_tick += 1
|
||||||
if _tick >= _frames_per_tick:
|
if _tick >= frames_per_tick:
|
||||||
_tick = 0
|
_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
|
||||||
+36
-182
@@ -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 rgbmatrix import RGBMatrix, RGBMatrixOptions
|
||||||
from dotenv import load_dotenv
|
from PIL import Image
|
||||||
from utils.vars import Colors, LOGO_DIR, ASSET_DIR
|
import os
|
||||||
|
import vars
|
||||||
|
|
||||||
# modes
|
class ScoreboardMatrix():
|
||||||
import modes.score as score_mode
|
logo_cache = {}
|
||||||
import modes.fantasy as fantasy_mode
|
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 ---
|
matrix = RGBMatrix(options=options)
|
||||||
load_dotenv()
|
canvas = matrix.CreateFrameCanvas()
|
||||||
|
|
||||||
# --- Matrix config ---
|
def load_logo_to_image(self, league, abbr, width, height, x_offset, y_offset):
|
||||||
options = RGBMatrixOptions()
|
key = f"{league}_{abbr}.png"
|
||||||
options.rows = 32
|
logo_path = os.path.join(vars.LOGOS_DIR, key)
|
||||||
options.cols = 64
|
if not os.path.exists(logo_path):
|
||||||
options.chain_length = 4
|
return None
|
||||||
options.parallel = 1
|
|
||||||
options.hardware_mapping = "regular"
|
|
||||||
options.gpio_slowdown = 5
|
|
||||||
options.disable_hardware_pulsing = True
|
|
||||||
options.brightness = 80
|
|
||||||
|
|
||||||
matrix = RGBMatrix(options=options)
|
if key in self.logo_cache:
|
||||||
canvas = matrix.CreateFrameCanvas()
|
loaded_image = self.logo_cache[key]
|
||||||
|
else:
|
||||||
|
loaded_image = Image.open(logo_path).convert("RGB")
|
||||||
|
self.logo_cache[key] = loaded_image
|
||||||
|
|
||||||
# --- Govee API ---
|
image = Image.new("RGB", (width, height), (0,0,0))
|
||||||
if os.environ.get('GOVEE_API_KEY'):
|
image.paste(loaded_image.resize((width, height), (x_offset, y_offset)))
|
||||||
govee_api = govee.GoveeApi(key=os.environ["GOVEE_API_KEY"])
|
|
||||||
|
|
||||||
# --- PyGame Audio ---
|
return image
|
||||||
# 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
|
|
||||||
|
|
||||||
# --- Goal celebrations ---
|
def iterate_slide_count(self):
|
||||||
def render_goal_frame(text, text_scale, bg_color, text_color):
|
self.slide_count += 1
|
||||||
big_h = max(8, int(32 * text_scale))
|
|
||||||
big_img = Image.new("RGB", (1024, 128), bg_color)
|
|
||||||
big_draw = ImageDraw.Draw(big_img)
|
|
||||||
|
|
||||||
try:
|
def next_slide(self, slide_name):
|
||||||
pil_font = ImageFont.truetype(
|
self.slide_count = 0
|
||||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", big_h
|
self.current_slide = slide_name
|
||||||
)
|
|
||||||
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()
|
|
||||||
@@ -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
|
|
||||||
Reference in New Issue
Block a user