Skip to content

Repository files navigation

MLB Scoreboard for LEDMatrix

⚠️ Panel size: 128x32 only (currently). Every layout in this plugin is hand-tuned pixel-exact for a 128x32 panel (two 64x32 panels chained) -- confirmed via direct testing that several layouts genuinely collide/overlap on other sizes (a 64x32 single panel, for example), not just look cramped. On any other panel size, the plugin shows a clear "128x32 required" message instead of a broken layout. Making this size-adaptive is a real, larger undertaking (going through every hardcoded pixel constant and converting it to a width/height-relative formula) that hasn't been done yet -- if that's something you want to help with, contributions welcome.

A custom MLB scoreboard for ChuckBuilds/LEDMatrix, built for a 128x32 panel: two team columns on the left (big logo, bold abbreviation + score on a darkened bar), and a diamond/inning/count/outs readout on the right. Cycles through every currently live MLB game by default.

Install

  1. Push this folder to your own GitHub repo (repo root = this folder).
  2. On your Pi, open the web UI: http://<pi-ip>:5000Plugin Manager.
  3. Use Install from GitHub URL, paste your repo URL.
  4. Restart the display service so the loader picks it up.
  5. Add the block from example_config.json in the web UI config editor, setting favorite_teams to your team(s).

Testing without hardware

Set "test_mode": true to render a fake in-progress game (ATH @ DET) instead of calling ESPN.

What changed in this round

  • Anti-aliased triangle & diamond: the inning indicator and bases are now drawn at 4x resolution and downsampled with LANCZOS resampling before compositing onto the panel image. This produces real partial-brightness edge pixels instead of a binary on/off fill, which is what was causing the jagged diagonal edges before. RGB LED panels can display those intermediate brightness levels, so this is a genuine visual improvement on real hardware, not just a software nicety. Verified by checking the rendered output actually contains a gradient of pixel values along the edges, not just pure black/white.
  • Much bigger logos: logos now fill nearly the entire 32x32 team column (up to 30px) instead of sharing space with a separate text row. A darkened bar (half-brightness version of the team color) sits across the bottom of the column so the abbreviation/score text stays readable on top of the logo instead of competing with it.
  • Selectable bundled fonts: three real pixel fonts pulled directly from ChuckBuilds/LEDMatrix's assets/fonts/ folder are now bundled with this plugin (in ./fonts/), so they're guaranteed available regardless of your install's layout. Pick one via font_choice:
    • "5by7" (default) -- best balance of legibility and compactness, comfortably fits "ABBR SCORE" including double-digit scores
    • "4x6" -- similarly compact, slightly different look
    • "press_start_2p" -- the classic arcade font, but it's quite wide per character; measured widths show it only fits this column's 32px width at a very small 4-5px tall size, so it's available but not the best fit for this particular row
    • "system" -- falls back to a generic bold monospace font (and still tries auto-discovering a font from your main LEDMatrix install's assets/fonts/ folder first, preferring anything with "press", "pixel", "matrix", etc. in the filename)
  • Text size fits dynamically regardless of font choice: the abbreviation+score line shrinks its font size (down to a floor) until it actually fits the column width, since different fonts have very different glyph widths.

Choosing a font

Set font_choice in config to "5by7" (default), "4x6", "press_start_2p", or "system". Measured glyph widths for "ATH 3" / "DET 12" at the sizes this plugin will actually use:

Font Height at max fitting size Fits "DET 12" in 28px?
5by7 6-7px Yes, comfortably
4x6 6-8px Yes, comfortably
press_start_2p 4-5px Only just -- quite cramped
system (DejaVu Sans Mono Bold) 5-7px Yes

If you try press_start_2p and it looks too small to read, that's expected given its per-character width -- switch back to 5by7 or 4x6 for this particular layout.

Licensing note: these font files were pulled from ChuckBuilds/LEDMatrix's assets/fonts/ folder and are bundled directly in this plugin's fonts/ subfolder for portability. Press Start 2P is SIL Open Font License licensed, so redistribution is fine. I don't know the license terms for 5by7 and 4x6 specifically -- worth a quick check if you plan to share this plugin publicly, though for personal use on your own display it's not a concern.

Layout notes / where to tweak things

All rendering lives in manager.py::display():

  • Team columns (_draw_team_column): logo sized to nearly fill the column, darkened team-color bar at the bottom holding bold "ABBR SCORE" text, dynamically sized to fit.
  • Anti-aliasing (_draw_smooth_polygon): generic helper used by both the diamond and the inning triangle. Adjust supersample (default 4) if you want even smoother edges at the cost of a bit more render time per frame.
  • Right half layout (updated):
    • upper-left: inning indicator
    • upper-right: outs indicator (colors via out_fill_color/out_empty_color)
    • middle: diamond of bases (colors via base_fill_color/base_empty_color). Its size is now derived from the actual vertical gap between the top row and bottom row (_draw_diamond's h parameter is a real space budget, not a fixed scale factor) -- so if you adjust anything else in this layout, the diamond automatically resizes to fit without needing to hand-tune overlap margins again.
    • bottom-left: ball-strike count
    • bottom-right: current batter (_draw_batter), first initial + last name, next to the count. Shrinks to fit whatever width remains.
  • Rotation (_maybe_rotate, _current_game): advances through self.live_games every game_rotation_seconds.

About the batter name specifically

ESPN's scoreboard endpoint isn't officially documented, so I don't have a confirmed field name for the current batter -- I couldn't verify this against live data from this sandbox (no network access here). _parse_game's extract_batter_name() tries several plausible paths (situation.batter.athlete.displayName, situation.atBat.athlete.displayName, etc.) and just omits the batter line if none of them match, rather than crashing or showing a placeholder.

If the batter name doesn't show up during a real live game: check your plugin logs during that game. If you're comfortable poking at it, the quickest fix is to temporarily add self.logger.info(f"situation keys: {situation.keys()}") right after situation = comp.get("situation", {}) in _parse_game, restart, and send me what it logs during a live at-bat -- I'll wire up the correct path immediately.

Config options

See config_schema.json for the full list.

Key Default Notes
favorite_teams ["PHI"] Fallback game + rotation filter if restricted
show_favorite_teams_only false STRICT: never show other teams' games at all, live or otherwise
game_rotation_seconds 8 How long each game shows before switching
update_interval_seconds 300 Poll rate when nothing is live
live_update_interval_seconds 15 Poll rate while games are live
away_color / home_color white / white Fallback colors if use_team_colors can't find one
use_team_colors true Pull real team colors from ESPN
show_logos true Show team logos
logo_dir assets/sports/mlb_logos Local logo folder, checked before ESPN
base_fill_color / base_empty_color white / grey Diamond colors
out_fill_color / out_empty_color orange / grey Outs indicator colors
show_batter_name true Show current batter next to the count
show_last_play true Flash the last play for significant moments
last_play_display_seconds 5 How long the last-play flash stays up
last_play_filter significant significant or all
show_past_games false Include recent final favorite-team games in rotation
show_upcoming_games false Include upcoming favorite-team games in rotation
max_past_games / max_upcoming_games 3 / 3 Caps on how many of each to include
past_upcoming_all_teams false Expand past/upcoming to all teams when no favorite is live (non-strict mode only)
test_mode false Render a fake game for layout testing

If text still looks wrong / blocky / generic on real hardware

This is very likely a font loading failure, not a design problem. PIL's built-in fallback font (ImageFont.load_default()) ignores whatever size you ask for and renders a fixed, crude bitmap font -- which would explain blocky/unreadable text AND an oversized ball-strike count at the same time, since neither can actually shrink to the size the code is requesting.

Check your plugin logs right after this loads. You should see:

INFO: font_choice '5by7' -> bundled file found OK at .../fonts/5by7_regular.ttf

If instead you see:

ERROR: font_choice '5by7' -> bundled file NOT FOUND at ...

that confirms the fonts/ folder didn't make it into your installed copy of the plugin correctly. Most likely cause: if you used the GitHub web upload method rather than git, double check the three .ttf files actually show up in your repo's fonts/ folder (binary files sometimes don't survive a drag-and-drop upload cleanly). Re-upload them if they're missing or 0 bytes, then reinstall/update the plugin.

I verified this diagnostic actually fires correctly by testing with the fonts/ folder deliberately removed -- it logs a clear, loud error rather than failing silently into the generic fallback.

Other fixes in this round

  • Faux-bold no longer smears text: it previously offset the text both horizontally AND vertically to thicken strokes. At these very small glyph heights (5-7px), the vertical offset was blurring letters into the row above/below, which likely also contributed to readability complaints even with the correct font loading fine. Now horizontal-only.
  • Ball-strike count uses its own smaller font (font_count, size 6) instead of reusing the same size as the inning number -- it was rendering noticeably larger than intended.
  • Inning triangle is no longer anti-aliased: at only 6px, the supersample+downsample smoothing was producing a soft/blobby shape instead of a clean triangle -- ironically, anti-aliasing hurt more than it helped at that size. It's drawn with a hard edge now (the diamond, being bigger, still benefits from and keeps anti-aliasing).
  • Inning indicator now has a proper top margin (2px) instead of sitting almost flush against the panel's physical top edge.

Fixes: text halo and diamond outline thickness

  • The "halo/extra stroke" around text was the faux-bold trick: text was being drawn twice, offset by 1px, to fake a bolder weight. At these tiny glyph sizes that reads as a double-struck ghost image rather than actual boldness. Removed entirely -- text is now drawn once, plainly, using the font's own weight.
  • The diamond's unoccupied-base outline is now an exact 1px line: it was being run through the same anti-aliasing helper as the inning triangle (supersample 4x + LANCZOS downsample), which blurred a requested 1px outline into something visually thicker. Switched to PIL's plain polygon(..., outline=..., width=1) -- verified by checking the actual rendered pixel values contain no in-between (anti-aliased) shades, only pure background/outline/fill colors.
  • The diamond ended up marginally bigger as a side effect of these layout numbers working out that way -- not a deliberate bump, just where the math landed.

Fixed: real regression -- DUE UP stopped triggering during genuine data gaps

Confirmed via a real screenshot: a mid-inning gap showed a floating "P:13" with no batter name and no "DUE UP" fallback -- looked broken. Root cause was a real regression from the pitcher-toggle changes: the DUE-UP decision (has_pitcher) got accidentally coupled to the new show_pitch_count toggle's output, so pitch count alone (which can genuinely persist in ESPN's data even when the pitcher's NAME and the batter are both missing) was enough to satisfy "has a pitcher" and block DUE UP from showing -- even though there was no actually useful pitcher info to show instead.

Fixed by separating two different questions that had gotten conflated: whether a pitcher genuinely EXISTS in the data (for deciding DUE UP) is now based strictly on the raw pitcher name field, never the display toggles or pitch count. Whether that pitcher's info gets DISPLAYED is still controlled by show_pitcher_name/ show_pitch_count as intended, just no longer entangled with the DUE-UP decision. Reproduced the exact reported scenario (pitch count present, pitcher name and batter both missing) and confirmed DUE UP now triggers correctly; re-verified both of the toggle-combination tests from the original feature still pass too.

Config review: fixed outdated docs, added 4 missing toggles, reordered everything

Did a full audit of all 32 settings for clarity/accuracy/gaps, per request. Findings and fixes:

Outdated descriptions (were just wrong, not style):

  • past_upcoming_all_teams still described the OLD "only checks if a favorite is live" behavior -- doesn't mention the liveness-aware fix from a couple rounds back (now applies whenever ANYTHING is live, favorite or not). Updated to reflect actual current behavior.
  • game_rotation_seconds said "each live game" -- rotation includes past/upcoming too now. Updated.

Naming collision clarified: display_duration (whole-plugin rotation in the broader LEDMatrix system) vs game_rotation_seconds (game-to-game rotation within this plugin) -- similar enough names that they're easy to confuse. Both descriptions now explicitly cross-reference and distinguish each other.

Non-obvious interactions now documented: use_team_colors silently overriding away_color/home_color (now stated on both sides); last_play_favorites_only vs show_favorite_teams_only being two different "favorites only" concepts controlling different things (now cross-referenced).

Four new toggles, closing real gaps that had no setting at all:

  • show_pitcher_name / show_pitch_count -- independent toggles, mirroring the existing show_batter_name. Tested all four combinations (both on, name-only, count-only, both off) -- each renders correctly, including the previously-impossible "count without a name" case, which required handling in _draw_pitch_info itself, not just gating at the call site.
  • show_delayed_overlay -- the DELAYED feature had zero way to turn it off. Verified disabling it correctly suppresses the red text.
  • show_home_run_animation -- no way to keep regular last-play flashes while disabling just the home run animation specifically. When off, home runs fall back to the plain last-play overlay and correctly use last_play_display_seconds instead of home_run_display_seconds for timing (verified the duration selection itself respects the toggle, not just the animation call).

Reordered all 36 settings into logical groups: core -> timing -> favorite-team scope -> past games -> upcoming games -> logos/colors -> live-game info toggles -> last-play/flash system -> testing. Rebuilt example_config.json to match the new order and include the new settings.

Verified end-to-end, not just JSON-valid: cross-referenced every schema key against actual cfg.get() calls in the code -- confirmed zero orphaned settings (schema entries the code never reads) and zero undocumented settings (code reading something with no schema entry), aside from the expected enabled field which is read by the broader plugin framework rather than this plugin's own code. Ran the full smoke test using the actual rebuilt example_config.json file directly (not just hand-picked test values) to confirm it genuinely works end to end.

Fixed: home team's logo box was visibly smaller than away's

Confirmed via direct pixel measurement: away's box measured a full 41px of visible colored area, home's only 39px. Root cause was an asymmetry in the grid strokes added a couple rounds back -- the away stroke was positioned just outside away's own box (never touching its 0-40 range), but the home stroke was placed AT the start of home's box, eating into its first 2 columns. Fixed by moving the home stroke to mirror the away one -- just outside home's box instead of overlapping it. Verified both boxes now measure exactly 41px.

Fixed: upcoming games disappearing near midnight

Real log confirmed the exact symptom: "10 past, 0 upcoming" at 2:21am, despite games actually being scheduled that day. Root cause: the upcoming-games lookahead only ever looked forward starting from tomorrow, on the assumption that the main scoreboard fetch (no date parameter) always covers today -- true most of the time, but near midnight ESPN's own "today" can still reflect the previous calendar day for a while, leaving a real gap where NEITHER fetch covers what the local clock considers "today."

Fixed by extending the lookahead to explicitly include today (offset=0) as a redundant check -- harmless the rest of the day, since if the main fetch already found today's games correctly this just re-finds the same ones (deduped by event_id when merged). Tested the exact scenario from the log: a main fetch returning only finished games (simulating ESPN's lagging "today"), with a real upcoming game for the local date that only the explicit same-day query finds -- confirmed it's now correctly picked up.

Fixed: UPCOMING/date/time text was measurably off-center

Confirmed real via direct pixel measurement (not just visual impression): all three lines leaned consistently ~1-1.5px left of where they should be. Root cause was the exact same one already found and fixed for the box-score digits -- _measure()'s reported width includes trailing advance space, not just actual ink, so centering against it systematically shifts text left. Applied the same ink-extent-based centering fix used there.

Also caught and corrected an error in my own verification: my first comparison used 64.0 as the "true center" of the middle section, which is wrong -- a 46px-wide section starting at x=41 spans pixels 41 to 86 inclusive, so the actual true center is 63.5. Recomputed against the correct value: the title now lands exactly on 63.5 (a full 1px improvement from 62.5 before the fix), the date line matches exactly, and the time line is 0.5px off -- an unavoidable integer-rounding artifact from odd leftover space, not a bug.

Fixed: bar text touching the white strokes, no padding

Measured directly and found two real zero-gap cases: the horizontal stroke (top of the bar) had text starting immediately in the very next row with 0px clearance, and a long record could reach (home's case: actually overlap) the vertical stroke down the middle of the bar.

Fixed both: shifted the bar text down 1px to clear the horizontal stroke, and added a hard clamp on the record's position so it can never get closer than 1px to the middle stroke, regardless of how long the record text is -- this is a mathematical guarantee by construction (a min()/max() against the stroke's fixed position), not just something that happened to test fine. Verified with the original data (11px/1px+ gaps, comfortably clear) and stress-tested with unrealistically long records (102-60/100-62) to confirm the clamp actually engages under real pressure, not just passing by coincidence in the common case.

One thing worth flagging: my own first verification pass on this produced a false "still overlapping" result, because the vertical middle stroke itself spans the full bar height, and a naive scan for "white pixels in the home text region" was picking up the stroke's own pixels and mistaking them for text. Corrected the verification to exclude the stroke's own columns before re-checking -- worth keeping in mind for anyone touching this code again.

Three fixes: logo bleed into the record bar, centering, and grid strokes

Logo bleed, confirmed and fixed: found the real cause -- logo sizing included a "+4 bleed" allowance meant for spilling harmlessly off the panel's outer edge (fine in the live/final layouts), but for upcoming games that same +4px instead spilled DOWN into the record bar below, since the vertical centering clamps to 0 rather than splitting overflow evenly top/bottom -- an oversized logo dumps its whole overflow downward rather than peeking out symmetrically. Removed the bleed allowance specifically for upcoming-game logos (kept for live/final, where it's harmless). Verified the new size (23px) exactly matches the available top-tier height, and confirmed via row-by-row pixel density that no logo-shaped blob appears below the boundary -- only the intentional stroke line and the bar's own thin text.

Horizontal centering: measured directly and found only a ~1.5px offset (likely just integer rounding), not the significant shift shown in the screenshot -- suspect that screenshot was from a slightly earlier version, given how many layout iterations happened in quick succession this session. Worth a fresh look once updated.

New: 2px white grid strokes dividing the four sections, per sketch -- right edge of the away block / left edge of the home block (top-tier verticals), a full-width horizontal across the top of the record bar, and a vertical down the middle of the bar where the two halves meet. Drawn last so they sit visibly on top of logos and bar text rather than being potentially obscured underneath.

Fixed: date/time block had zero clearance above the bottom bar

Measured directly rather than assuming: the last line of text ("7:10 PM") was running with exactly 0px of clearance before the bottom bar's boundary -- not just tight, genuinely touching. Root cause was simple: the original spacing (4px after the title, 2px between date/time lines) was sized for the FULL panel height from the earlier layout, and never got adjusted when the middle block's available height shrank to just the area above the new bottom bar.

Tightened both gaps (4px->3px, 2px->1px) to reclaim exactly the 2px needed. Verified directly: the last line's lowest text pixel now sits 2px clear of the boundary, confirmed by pixel measurement rather than just visual inspection.

Adjusted: abbreviation centered under its logo, not the whole half

Follow-up clarification: abbreviation should align directly under its team's logo (like a traditional scoreboard), with the record placed adjacent to it -- not the combined "ABBR RECORD" text centered as one block within the whole half of the bar. Away reads "DET 54-39" with DET anchored left (under the logo) and the record trailing right; home mirrors it, "52-43 PHI" with PHI anchored right (under its logo) and the record leading in from the left.

Verified with direct pixel measurement: the abbreviation's measured ink center (19px) closely matches the logo's actual center (20.5px, from the same side_w/2 calculation the logo positioning already uses) -- off by only the expected integer-rounding amount. Re-confirmed zero color bleed across the center boundary in either direction.

Redesigned upcoming-game layout: two-tier, full-width bottom bar

Clarified from a follow-up: abbreviation and record should sit side by side on one line (not stacked), and the team-colored bars should meet in the middle with no gap, while logos stay where they were.

Restructured into two tiers instead of three side-by-side columns:

  • Bottom tier: a single-line, full-width color bar split exactly at center -- left half is the away team's color with "ABBR RECORD" side by side, right half is home's, meeting with zero gap.
  • Top tier: logos near the edges (same horizontal position as before) with "UPCOMING" + date/time centered between them, confined to just the area above the bar rather than the full height.

This also incidentally solves the original width problem for free: each half of a full-width bar is far more generous (64px) than the narrow side columns from the previous version, so "DET 54-39" fits comfortably on one line without needing to shrink the font or stack anything.

Updated logo sizing to match -- logos now only have the area above the bar to fit in, not the full panel height, so the size cap changed accordingly. Verified with direct pixel checks: the full bottom bar row has zero black gap anywhere, the away/home colors transition cleanly at the exact center pixel, and there's zero actual color bleed across that boundary in either direction (checked specifically for away-color pixels in the home half and vice versa, not just "any non-background pixel," since text color naturally isn't background color).

Adjusted upcoming-game proportions per sketch: wider team blocks, narrower center

Follow-up to the layout above: widened the team-colored sections and narrowed the center strip, per a sketch showing bigger logos with the two colored blocks extending further toward the middle. Sized the center to the actual minimum needed rather than guessing -- measured the longest realistic date strings (~40px for something like "Sat Jul 11" or "Wed Sep 30") and set the center to 46px (small margin above that minimum), giving the team sections 41px each, up from the previous even 32px split.

Updated logo sizing to match -- confirmed it was still using the old formula and would have kept logos too small for the new wider sections otherwise. Verified with direct pixel checks: no text overflow from the center into either team section, clean (gap-free) boundaries at every row checked, and the full smoke test still passes.

New: team records on the upcoming-game screen, with a layout redesign

Added win-loss records to the upcoming-game view, per explicit design direction: rather than cramming records into the existing narrow team columns (which measurably didn't fit even at the smallest font -- tested "DET 54-39" at 36px needed vs. only 21px available), restructured the whole layout: teams now flank the left/right edges (logo + abbreviation + record each), opening up a wider center section for "UPCOMING" + date/time instead of confining it to a black half.

Data confidence: records is confirmed as a real field on each competitor (via community API documentation covering overall/home/away splits), but the exact sub-field names inside each entry aren't confirmed. Extraction tries a few plausible combinations and returns None rather than guessing wrong if nothing matches -- a missing record just quietly omits that line rather than showing a placeholder.

Verified: side sections render with zero gap against the middle section at every row tested, no text overflows from either side into the middle, the missing-record case falls back gracefully to a single abbreviation line without crashing, and the full smoke test still passes for all game types.

New: "DELAYED" overlay for rain delays / game stoppages

When a live game is detected as delayed, the whole right half now shows "DELAYED" in red instead of the normal pitch count/batter/ diamond info, which would otherwise just sit frozen/stale during a stoppage.

Data confidence, same honest breakdown as other ESPN-dependent features in this plugin: I don't have a real captured example of what ESPN's status object looks like during an actual delay -- haven't seen one in any of the real data collected throughout this project so far. Implemented defensively: checks several plausible status fields (description, detail, shortDetail, name) for the substring "delay" (case-insensitive), rather than betting on one exact string, so it's robust to whichever field ESPN actually uses. Tested against 6 plausible representations (various delay-indicating strings across different field names, plus normal in-progress and empty-status negative cases) -- all correctly detected or correctly not flagged.

If this doesn't trigger during a real rain delay, the fix is straightforward: whatever the real status text turns out to be almost certainly still contains "delay" somewhere, but if it doesn't, report back the actual status text and the keyword/field list can be adjusted directly.

New: past_upcoming_all_teams only applies when NOTHING is live anywhere

Clarified a real gap: previously, whenever past_upcoming_all_teams was on, it applied unconditionally -- including when some OTHER (non-favorite) team was live. That meant an unrelated team's past/ upcoming game could show up right alongside live games happening right now, which is a cluttered experience regardless of what that setting is for.

Split the cascade's non-strict branch into two: (1) some other team is live but not a favorite -- past/upcoming now restricted to favorites ONLY here, regardless of past_upcoming_all_teams; (2) literally nothing is live anywhere -- NOW past_upcoming_all_teams applies as designed (all teams if on, favorites only if off).

Verified with four scenarios: other team live + setting on -> only favorites' past/upcoming show alongside the live game, unrelated teams' final/upcoming excluded; nothing live anywhere + setting on -> ALL teams' final games correctly show; favorite live + other teams also live + setting on -> only the favorite's live game shows, nothing else at all; and the original favorite-live + favorites' own past/ upcoming case (from the previous round) still works unchanged.

Fixed: row 2 (away row) numbers sat 1px too high

Root cause confirmed numerically: 32 doesn't divide evenly by 3, so the three box-score rows come out to heights 11/10/11 -- header and home get 11px (leftover space after fitting the 5px-tall glyph is an even 6px, splitting perfectly 3px/3px), but the away row gets only 10px (leftover is an odd 5px, which floor-division centering can only split asymmetrically -- 2px top, 3px bottom), landing every digit 1px too high specifically in that row.

Fixed by rounding UP instead of flooring when computing the vertical offset -- confirmed this exact change leaves the already-correct header/home rows completely unaffected (their leftover space is even, so rounding up or down gives the identical result), while shifting the away row's text down exactly 1px. Verified via direct pixel measurement: away row now shows 3px top / 2px bottom padding (down from 2px/3px), matching header and home's 3px/3px as closely as an odd-height row possibly can.

New: favorite team's past/upcoming games no longer suppressed while a favorite is live

Previously, whenever any favorite team had a live game, that branch of the cascade showed ONLY the live game(s) -- completely suppressing past/upcoming games even for the SAME favorite team(s), regardless of show_past_games/show_upcoming_games. Now the live favorite game(s) still get priority (listed first in rotation), but the favorite's own past/upcoming games are included too if those toggles are on.

Scoped strictly to favorite teams regardless of past_upcoming_all_teams -- mixing in some unrelated team's past/upcoming game here would defeat the whole point of favorite-team prioritization. Verified with three scenarios: a favorite live + a different favorite's final game + a different favorite's upcoming game all correctly appear together, while non-favorite live/final games stay excluded in the same test; and separately, confirmed a non-favorite game stays excluded even with past_upcoming_all_teams: true enabled, while a favorite is live.

Resolved: R/H now absorb leftover space instead of leaving it unused

Final piece of a long debugging chain: viewed the raw diagnostic image directly (bypassing the web UI preview entirely, via a quick HTTP server on the Pi) and confirmed the E column genuinely IS narrow, matching spec exactly -- but there was real, visible unused green space after it, since the fixed-width grid (68px) didn't fully consume the available space (76px). Correct geometry, but wasted space that looked wrong regardless.

Fixed by making R/H's width dynamic: they now absorb ALL leftover space exactly (computed as (right_w - innings - E) / 2), rather than sitting at a fixed 9px and leaving the remainder as dead space. They never shrink below the minimum needed to comfortably fit double digits (still 9px as a floor), but grow to fill available space when there's room -- 13px in the standard 9-inning case, exactly consuming the full right half with zero leftover. This is a natural fit with the original spec, which already allowed R/H to be "slightly wider" than the innings/E.

Verified: innings and E still measure exactly 1px padding on both sides (unaffected by this change), R/H now show more generous padding since they're wider (5px each side, up from 1px), the grid extends all the way to the true panel edge with nothing left over, and extra- innings games still render without overflowing.

Root-cause summary for this whole thread: the "no left padding" perception was ultimately never a bug in the plugin's rendering math -- confirmed conclusively via a debug log reporting the exact internal state, matched character-for-character against independent sandbox computation, and finally against the raw rendered image itself, bypassing the web UI's preview rendering entirely. The one real, concrete bug in this whole investigation was the leftover unused space issue just described, now fixed.

Confirmed via direct debug logging: padding math is correct; reverted a regression

Added a debug log line directly inside _render_final_game reporting the exact internal col_bounds/grid_x0 the running code actually computes -- no external reconstruction, straight from the source. Ran it against the real deployed system: the reported values were character-for-character identical to this sandbox's own computation. That's conclusive -- the column-width and padding math is provably correct on the actual deployed code, not a version mismatch, not a stale file.

The centering change from the previous round was the actual regression. It fixed the original "E column looks too wide" issue, but introduced a new visible gap between the separator and the start of the grid that wasn't there before, which made the overall layout look worse. Reverted to flush-left alignment (grid_x0 = right_x0, no centering offset) while keeping the right-edge border, which is what actually fixed the E-column issue and doesn't depend on centering to work. Verified directly: left edge is now flush against the separator with no gap, the right-edge border still exists marking the table's true end, and padding still measures exactly 1px on every column.

Left the debug log line in place for now in case further diagnosis is needed -- worth removing once everything is confirmed resolved, since it fires on every final-game render.

Fixed: unbordered leftover space made E column look oversized

Confirmed real, separate from the padding question: the fixed- width grid (68px) is narrower than the available space (~76px), and it was left-aligned with ALL the leftover ~8px dumped after the E column, with no border marking where the table actually ends. That undivided trailing green space visually read as if it were part of the E column -- exactly "too much padding to the right of E."

Fixed by centering the grid within the available space (leftover splits evenly as ~4px margin on both sides instead of one large gap on one side) and drawing borders on the outer left/right edges too, not just internal dividers -- so the table's true boundary is visually unambiguous regardless of how much margin remains around it. Verified symmetric margins and confirmed both edges now show a real border pixel, with genuinely plain green (clearly outside the table) beyond each edge. Re-verified all padding still measures exactly 1px on every column after this change.

The "no left padding" issue is still open. I have not been able to reproduce it despite extensive testing -- multiple direct code re-reads, and reproductions using your exact digit sequence, all show correct 1px padding on every column. Both manager.py and the font file are now confirmed current on your end, and you've ruled out LED dot-mode as the cause, so the remaining explanation is either something in your specific runtime that I can't access from here, or a discrepancy between what I assume the code does and what it actually computes on your system. Updated diagnose_boxscore.py to independently scan the rendered image for where grid lines actually land (not just trust my own reconstruction of the math) and compare that against the expected column boundaries -- if those two don't match, that pinpoints the discrepancy directly instead of more guessing.

Box score: exact 1px padding per spec, investigated missing data

Replaced proportional/weighted column widths with exact fixed widths, computed directly from measured ink rather than distributing available space: inning and E columns are 3px ink + 1px padding each side = 5px exactly; R/H are 7px ink (measured for a real two-digit combo like "10") + 1px padding each side = 9px. Verified with direct pixel measurement: every column -- inning, R, H, and E alike -- now shows exactly 1px of padding on both sides, not the inconsistent 1-3px that fell out of the previous weighted/proportional approach. The grid is narrower than the full available width as a result (68px total); the remaining space stays plain green background, confirmed via pixel sampling that it's genuinely green, not black.

Investigated the missing 9th-inning box (ATL@STL): the most likely explanation is a real baseball/box-score convention, not a bug -- traditional box scores leave a half-inning's box blank (not "0") when a team didn't bat there, e.g. the home team winning without needing the bottom of the 9th. Added diagnostic logging that fires whenever the two teams' linescore array lengths genuinely differ, logging both raw arrays so this can be confirmed against real data rather than assumed. Verified the logging fires correctly against a reconstructed walk-off scenario.

Fixed for real this time: box score cross-row misalignment + logo bleed

Still looked scattered after the previous ink-based centering fix, so dug deeper: measured every digit's actual ink width individually and found "1" is genuinely only 2px wide in this font (BBX 2 5) while every other digit is 3px (BBX 3 5) -- confirmed straight from the font file, not a rendering bug. Each digit was correctly centered within its own cell, but a "1" in one row and a "0" in the same column a row below wouldn't align with each other, since one is narrower than the other -- exactly what reads as "off center in many spots" when scanning down a column rather than looking at any single digit in isolation.

Fixed by widening "1" to 3px (added a small base/foot, a common pixel-font convention), matching every other digit's width. Checked for collisions against every other letter/digit first -- none. Verified concretely: measured the horizontal center of every inning column in BOTH the away and home rows for the exact scenario from the screenshot -- all nine columns now align perfectly between rows, where "1"-containing columns previously did not.

Also fixed the logo bleed: confirmed the actual cause -- logo size was computed from the standard 50/50 column split (_resolve_logos), but the box score's _render_final_game uses a narrower 40/60 split for the team columns specifically. Logos were being sized for the wider column and then squeezed into the narrower one, overflowing into the box score. Fixed by making logo sizing game-type-aware (checks game["game_type"] and computes the correct column width for whichever layout is actually being used). Verified the computed sizes now genuinely differ (36px for live's wider column vs. 29px for final's narrower one) and confirmed no logo-colored pixels appear past the team-column boundary in a fresh render.

Root cause confirmed by directly comparing measured vs. actually rendered ink pixels: the centering math used the font's measured width, which includes the glyph's trailing advance spacing (DWIDTH), not just its actual ink (BBX) -- e.g. "0" measures 4px wide but its real ink is only 3px. Centering against the inflated width systematically shifted every digit left, and by an amount that varied between -1px and -2px depending on each cell's specific width (since the phantom extra pixel interacts differently with integer rounding at different widths) -- which is exactly what "scattered" looks like: not a uniform shift, an inconsistent one.

Fixed by centering against the actual rendered ink extent instead (reusing _ink_extent, built earlier for a different fix) rather than the advance-inclusive measurement. Verified by measuring every cell's ink-center vs. its true geometric center before and after: offsets went from wildly inconsistent (-1.0 to -2.0px, different per cell) to tight and uniform (-1.0px, occasionally -0.5px). The remaining sub-pixel residual is a genuine floor -- individual digits have different actual ink widths (e.g. "1" is narrower than "0"), and perfectly centering both parities of ink-width/cell-width combinations simultaneously isn't possible with integer pixels -- but a consistent ~1px lean reads as intentional rather than scattered.

New: restrict the last-play flash to favorite teams independent of rotation scope

Diagnosed the "jumping around a lot" report before changing anything: with show_favorite_teams_only off and 5+ simultaneous live games (as confirmed), normal rotation itself is calm (a predictable game_rotation_seconds-paced cycle), but the last-play flash can interrupt it for ANY of those games' significant plays -- and the aggregate rate of hits/walks/strikeouts/runs across 5+ games simultaneously is high enough that the flash fires very often, compounding with rotation to feel like constant jumping even though each individual mechanism is working as designed.

Added last_play_favorites_only (default off, opt-in) as a setting independent of overall rotation scope: normal rotation still cycles through every live game exactly as before, but the flash-interrupt only fires for your own favorite team's plays. Tested directly: confirmed a non-favorite game's significant play no longer queues a flash when enabled, a favorite team's still does, and the default (off) behavior is completely unchanged for anyone who wants the original all-games-can-flash behavior.

Pitch count: fixed a real cross-endpoint consistency bug

"Accurate for some games, not others" pointed at something game-specific rather than a logic bug, and there was a real one: the pitcher's name on screen comes from the scoreboard endpoint, but pitch-count enrichment separately re-derived the pitcher's id from a different endpoint (the summary call), fetched at a slightly different moment. If a pitching change happened in between those two fetches -- which occurs multiple times per game -- the count could end up computed for a different pitcher than the one whose name was actually displayed. That would show an accurate-looking name next to a wrong count, and only for games where the timing happened to line up badly.

Fixed by capturing the pitcher's id alongside their name at extraction time (same fetch, same moment), and having pitch-count enrichment prefer that id over re-deriving one from the separately-timed summary endpoint. Tested the exact failure scenario: a game dict carrying one pitcher's id (matching the displayed name) while the summary endpoint's own situation snapshot already shows a different pitcher (simulating a change that happened between fetches) -- confirmed the count now correctly reflects the displayed pitcher, not the stale/different one from the second endpoint.

New: box score polish -- thinner grid lines, narrower E column

Two visual fixes:

  • Grid lines were genuinely 2px thick, not 1px -- confirmed by direct pixel sampling: each cell independently drew its own full border, so two adjacent cells' borders sat immediately next to (not on top of) each other at every internal boundary. Rewrote to draw each internal divider ONCE as a unified single-pixel line spanning the whole table, instead of per-cell. Freed-up space goes toward slightly more room for the cells themselves, per your call to sacrifice stroke width for cell space if needed.
  • E (errors) no longer gets the wide double-digit treatment -- per the (very reasonable) observation that a double-digit error total is essentially never going to happen in real MLB. Only R and H keep the wider columns now; E is back to inning-column width, and that reclaimed space goes to R/H and the inning columns instead.

Verified with direct pixel sampling: both row and column dividers now show exactly one grid-line-colored pixel where there were two before; zero unexpected/black pixels anywhere in the box score area; and the double-digit R column still fully contains "10" without overflowing under the new width allocation.

Pitch count: fixed a likely undercounting bug, made counting robust to unknown types

Still not accurate after the first rebuild, and re-examining what I'd actually confirmed revealed a real gap: I'd only confirmed type.type == "play-result" on the FINAL pitch of an at-bat (a strikeout call) -- never on an intermediate ball or strike that doesn't end the at-bat. If those use a different type value (quite plausible for ESPN's play-by-play), filtering strictly to "play-result" would only count one pitch per at-bat faced, not the real per-pitch total -- a count far too low, and consistent with "still not accurate."

Fixed by removing the dependency on any type value at all. Every pitch (confirmed from real data, both terminal and intermediate) carries an atBatId plus a sequential atBatPitchNumber. Counting DISTINCT (atBatId, atBatPitchNumber) pairs attributed to the pitcher naturally collapses duplicate bookkeeping entries (like the confirmed "End Batter/Pitcher" repeat of the final pitch) to a single count, without needing to know or guess which type values represent "real" pitches versus duplicates.

Tested against the exact ambiguity that caused the bug: a 3-pitch at-bat using three different type values for each pitch (ball, strike-looking, play-result) plus the confirmed duplicate marker -- correctly counts 3, not 1 (the undercounting bug) and not 4 (double- counting the duplicate). Also verified across a full multi-at-bat game (4+3+5 pitches across three at-bats) sums correctly to 12.

Fixed: double-digit numbers overflowing box score cells

Confirmed by direct measurement: "10" needs 8px of ink width, but every column (innings AND R/H/E) shared equal width -- only 6-7px total including borders. Any double-digit value was guaranteed to overflow into the neighboring cell, which is exactly what showed up in the photo (a double-digit run total).

Fixed by reallocating column width, not just shrinking text: R/H/E (game totals) realistically reach double digits far more often than any single inning does -- a team scoring 10+ runs in ONE inning is exceptionally rare in real MLB -- so R/H/E columns now get roughly 1.8x the width of an inning column, at the expense of inning columns being slightly narrower (still comfortably enough for the single digit they'll almost always need). Same weighted-cumulative-boundary approach as the earlier border fix, just with per-column weights instead of uniform ones.

Verified with actual pixel containment checks, not just visual inspection: rendered a game with a double-digit run total (matching the photo), double-digit hits for both teams, and confirmed each of R/H/E stays fully within its own column boundary with no bleed into the next. Also stress-tested the worst realistic combination -- extra innings AND double-digit hits/errors on both teams simultaneously -- and confirmed it still renders without crashing or overflowing.

New: dedicated (longer) duration for the home run animation

The 3-phase animation needs more time than a plain text flash to actually play out -- at the default last_play_display_seconds (5s), the fireworks/scrolling-text phase barely gets 2-3 seconds. Added home_run_display_seconds (default 10) as a separate setting: when the flash queue promotes a play, it now checks whether that specific play is a home run (via the same _is_home_run_play detection used for the animation itself) and uses the dedicated longer duration if so, while every other significant play still uses the normal last_play_display_seconds -- regardless of what either is configured to, one never affects the other.

Verified end-to-end: a queued home run correctly gets the full home_run_display_seconds duration, a queued regular play (e.g. a double) still gets the normal shorter duration in the same test run. At the new default (10s), phase math works out to ~7.3s for the settled fireworks+text phase, comfortably enough to read a full scrolling play description.

Pitch count: rebuilt on confirmed real data (no longer a guess)

Real diagnostic data (2026-07-11, across 7 live games) confirmed the entire previous approach was searching the wrong place: zero matches anywhere in boxscore for a pitch-count field across every game tested. The real signal is in the plays array -- each individual pitch is its own entry with type.type == "play-result", and the pitcher who threw it is identified via participants: [{"athlete": {"id": ...}, "type": "pitcher"}, ...]. There's no simple pre-computed "total pitches" field; the real cumulative count comes from counting these entries across the whole game (_count_pitches_for_pitcher).

A real double-counting trap the data revealed: the entry immediately after each at-bat's final pitch ("End Batter/Pitcher", type.type == "end-batterpitcher") repeats that same pitch's atBatPitchNumber -- confirmed via the real dump that this is a duplicate bookkeeping marker, not a new pitch. Filtering strictly to "play-result" avoids counting it twice.

Also handles a real edge case the diagnostic surfaced: situation.pitcher came back empty during a brief state transition between innings. Added a fallback that derives the current pitcher from the most recent play-result entry's participants instead, which is far more consistently populated.

Tested against the exact real structure from the diagnostic dump: correctly counts 3 (not 4) pitches for an at-bat ending in a strikeout without double-counting the duplicate marker; correctly isolates a different pitcher's count to zero/none; correctly sums across multiple at-bats and a mid-game pitching change (12 for a starter across three at-bats, 2 for the reliever who took over); and correctly falls back to deriving the pitcher's identity from play-by-play when the situation snapshot is empty.

The old boxscore-search logic is kept as a secondary fallback in case some other context does expose a direct field, but it's no longer the primary strategy since it's confirmed absent in the common case.

Three fixes: home run detection, box score left gap, pitch count accuracy

1. Home run animation never triggering -- root cause confirmed: detection relied solely on a guessed ESPN type code (HOME_RUN_PLAY_TYPES), which I'd explicitly flagged as unconfirmed when I built it. Real-world testing showed it's simply wrong -- real home runs never matched, always falling back to the plain text overlay. Fixed with a second, more reliable signal: _is_home_run_play now ALSO checks the play's actual narrative text for "home run" / "homers" / "homered" -- ESPN's human-readable phrasing for a home run call is predictable ("Judge homers to right field") regardless of whatever internal type code they use. Tested against 7 scenarios including the exact failure mode reported (unrecognized type code, real home-run text) -- all pass. Verified through the full display() pipeline at three different elapsed times, confirming all three animation phases (ball arc, strobe, fireworks) actually render distinct pixels, not just that the detection function returns the right boolean.

2. Box score had a 1px black gap on the left edge -- the separator bar is 1px wide at x=left_w, but the box score's green fill started at left_w+2, leaving x=left_w+1 completely unfilled (stayed whatever the original black background was). Invisible on the live/ upcoming layouts since their black half has no distinct fill color to seam against, but very visible here. Fixed by starting the green fill at left_w+1, immediately adjacent to the separator. Verified with direct pixel sampling across the transition -- no black pixel remains anywhere in it.

3. Pitch count inaccurate in most games -- found two real weaknesses in the matching logic and tightened both, though I want to be upfront that this reduces rather than eliminates the risk of inaccuracy, since I still don't have confirmed real data for ESPN's exact structure:

  • The specific-path matcher accepted a bare single-letter "P" label as meaning "Pitches" -- but box scores commonly use "P" for Position, not pitch count. Removed it, keeping only the much less ambiguous "PC", "PITCHES", "PITCHESTHROWN".
  • The generic fallback's ID-matching recursed through the entire response with no depth limit, meaning the pitcher's ID coincidentally appearing anywhere else in a large response (rosters, standings, play-by-play) could match an unrelated field. Added a depth limit (3 levels) so it only trusts nearby matches.
  • Tested both fixes against the exact false-positive scenario (a "P" = Position column) and a deliberately distant/unrelated match -- both now correctly rejected, while legitimate close matches still work.
  • Added an INFO-level log line whenever a count IS found, so it's easy to spot-check against a real broadcast without needing debug logging enabled.
  • Since games are live right now, running dump_summary.py (shared earlier) during an actual game would give real ground-truth data to fix this with certainty instead of further tightening guesses -- happy to take another pass once there's real data to work from.

Fixed: dark unfilled border around the box score

Root cause wasn't the grid math -- it was how right_w itself was computed. The formula width - right_x0 - 1 (used consistently across all three game-type layouts) deliberately leaves the very last pixel column unaccounted for; on the live/upcoming layouts that 1px never gets touched by anything so it just stays background-colored and unnoticed, but on the box score it meant that column was never filled with the green background at all -- still plain black, which read as a dark border along the right edge.

Also switched the grid from a fixed cell width (right_w // total_cols, floor division) to exact cumulative column/row boundaries, so the grid spans the entire available space with no leftover remainder pixels either -- distributing any rounding slack across cells instead of dumping it all into one unused strip. As a side effect, cells came out very slightly larger on average (6-7px instead of a flat 6px).

Verified concretely, not just visually: sampled the actual pixel color at the true right edge (x=127) before and after the fix -- was unfilled black, now correctly grid-green -- and scanned the entire box score area for any pixel that wasn't one of the four expected colors (background, grid line, text, winner-highlight). Found 32 stray pixels before the fix (exactly one column's worth, 32 rows tall -- matching the missing-column theory precisely), zero after.

New: traditional green box score for final games

Kept the left-half team columns (logo/abbreviation/score, same as the live layout, winning team's bar still highlighted yellow) and confined the box score to the black half only, per follow-up request -- header row (inning numbers + R/H/E labels), then one row per team showing runs scored each inning plus final Runs/Hits/Errors, Fenway-green style. No team-abbreviation column in the grid itself, since the team names are already visible on the left; the box score's top row is away, bottom is home, matching the left half's top/bottom team columns.

Squeezed the team columns to make room, per your explicit go-ahead: went from a 50/50 split to 40/60 (left/right). Verified this was actually necessary, not just assumed: at the original 50/50 split, box score cells came out to 5px wide, and cropping/inspecting an actual rendered digit at that size showed it bleeding right up against the cell border with no clearance. After the squeeze, cells are 6px, and the same check confirmed a full pixel of clearance from the border on both sides -- a real, measured improvement, not just "looks a little better."

Data confidence, same honest breakdown as everything else in this plugin: per-inning linescores are a documented ESPN field (confirmed via independent community API documentation, not just assumed), so those should be reliable. Hits and errors are NOT confirmed the same way -- there's a generic statistics array documented but not confirmed specifically for baseball H/E, so those get backfilled from ESPN's detailed summary endpoint (_enrich_boxscore_stats) if missing from the lightweight scoreboard response, and simply show blank (not a misleading 0) if even that doesn't find them. This only ever fetches once per completed game (tracked via _enriched_boxscore_event_ids) since a finished game's stats can't change -- verified this caching actually prevents a second fetch on a repeat call, not just that the fetch itself works.

Handles edge cases gracefully: extra-innings games shown are capped based on how many actually fit at a legible minimum cell width (re-verified a 15-inning game still renders without crashing or overflowing after the layout change), and missing hits/errors show blank cells rather than crashing (re-verified after the squeeze too).

Fixed: past games stopped showing up

Same root cause, opposite direction: this is the mirror image of the upcoming-games bug -- the main scoreboard call still only returns TODAY's games, and past_games was built exclusively from that single-day fetch's "post"-state entries. So a favorite team's most recent completed game only ever showed up if it happened to be today. On an off-day, or before today's game finishes, that most recent finished game was often yesterday or earlier -- which simply never appeared, exactly matching "past games stopped working."

Fixed the same way: added _fetch_past_games_lookback(), which explicitly queries the previous past_games_lookback_days days (default 3) via the dates=YYYYMMDD parameter, merges those with whatever same-day past games the main fetch found, dedupes by event ID, and sorts most-recent-first (reverse chronological -- unlike upcoming games, which sort soonest-first). Same slower refresh cadence as the upcoming lookahead (past_games_refresh_seconds, default 1800s), since completed games obviously don't change.

Verified end-to-end through the real code path: simulated a scenario where today has no game at all for the favorite team (an off-day) but a lookback day has their actual last completed game, and confirmed it now correctly appears in past_games and rotation. Also confirmed the slower refresh timer holds on repeated immediate calls, same as the upcoming-games fix.

Fixed: upcoming games never actually showing up

Root cause: ESPN's scoreboard endpoint returns only TODAY's games when called with no date parameter -- which is what this plugin was doing. A favorite team's actual next game is almost always tomorrow or later, not today, so upcoming_games was nearly always empty; only a same-day doubleheader's second game would ever show up. Meanwhile past_games worked fine, since a team that already played today shows up as "post" state in that same fetch -- which is exactly why it looked like only final scores were ever cycling.

Fixed: added _fetch_future_upcoming_games(), which explicitly queries ESPN's scoreboard for each of the next upcoming_games_lookahead_days days (default 5) using the dates=YYYYMMDD parameter, merges those results with whatever same-day upcoming games the main fetch found, dedupes by event ID, and sorts by actual start time. This runs on its own slower timer (upcoming_games_refresh_seconds, default 1800s/30min) rather than the normal live-polling cadence, since schedules don't change from one 15-second poll to the next -- no reason to re-issue several extra requests that often.

Verified end-to-end through the real code path, not just by re-reading the logic: simulated a scenario where today's fetch only contains a completed game and a future day's fetch (queried via the dates parameter) contains the actual next scheduled game, and confirmed it now correctly appears in rotation alongside the final score. Also confirmed the slower refresh timer actually holds -- ran two immediate back-to-back refreshes and verified the lookahead only fired once, not twice.

Separator bar: now 1px gray, and fixed the "N looks like M" issue

  • Separator bar: changed from 2px white to 1px, color (166, 166, 166), on all three game types. Verified by sampling the exact pixel column -- confirmed it's a single column wide and precisely that gray, with neighboring columns unaffected.
  • The "N" glyph really did look like "M" -- confirmed this by decoding the actual bitmap data straight from the font file rather than guessing: at only 3 pixels wide, tom_thumb's "N" originally used three solid middle rows, differing from "M" by exactly one row -- genuinely ambiguous, not a rendering bug. Iterated through several 3px-wide designs (zigzag diagonal, dot at various rows) before settling on a real diagonal instead: N is now 4px wide with an actual 2-step staircase stroke, up from the 3px every other letter uses. Confirmed via code inspection that this font's renderer already advances the cursor using each glyph's own DWIDTH value (not a fixed column width), so N's advance was bumped to 5 (instead of the usual 4) to preserve the 1px gap before the next letter -- verified this holds by directly measuring N's rendered width (5px, as intended) and by stress-testing real team abbreviations containing N ("MIN", "CIN") to confirm they still fit the column width comfortably even with N's extra pixel. The trade-off: N now takes up marginally more horizontal space than other letters, so a word containing N isn't quite as tightly uniform-width as one without -- judged an acceptable cost for a diagonal that actually reads as a diagonal. Verified pixel-for-pixel through the actual BDF parser at every design iteration, not just that the source file's bytes look right.

Favorite-team priority cascade

Reworked how favorite_teams interacts with rotation, since the old show_favorite_teams_only boolean was too blunt -- it either always restricted to favorites or never did, regardless of whether a favorite was actually playing. Now:

  1. A favorite team live? Rotation shows ONLY that game (or games, if multiple favorites are live simultaneously) -- past/upcoming and every other team's live game are fully suppressed while this is true.
  2. No favorite live, show_favorite_teams_only off (default): all live games leaguewide + past/upcoming games (scope controlled by the new past_upcoming_all_teams, favorites-only by default).
  3. No favorite live, show_favorite_teams_only on (strict mode): only favorites' past/upcoming games -- no other team's live game ever appears, full stop.
  4. Falls out naturally: if the live portion is empty in either branch, rotation is just past/upcoming; if everything is empty/disabled, falls back to the single best-guess favorite game (the original pre-past/upcoming-feature behavior).

show_favorite_teams_only is kept specifically as that strict override -- "never show any other team's games, period" -- rather than being replaced by the cascade, per your call. The new past_upcoming_all_teams setting only matters in non-strict mode.

Performance note: the extra per-game pitch-count API call and last-play-flash checking now only run on whichever games actually end up in rotation, not every live game leaguewide -- "show all live games" mode could otherwise mean a dozen extra summary-endpoint requests per poll for games that never even get displayed. Verified directly: set up a favorite-team game alongside three other leaguewide live games and confirmed enrichment was only attempted for the favorite's game.

Verified all four cascade branches end-to-end (not just by re-reading the logic) using synthetic scoreboard data run through the real _process_scoreboard/_maybe_refresh code path: favorite-live suppresses everything else; non-strict fallback includes other live games plus favorites-only past/upcoming; strict mode excludes other live games entirely; and past_upcoming_all_teams correctly expands scope in non-strict mode while strict mode correctly overrides it back to favorites-only.

Config cleanup

  • away_color/home_color fallback defaults changed to white ([255,255,255]) per request.
  • font_choice removed entirely as a user setting -- tom_thumb is now hardcoded as the only font. The internal fallback chain (other bundled TTFs, system fonts, PIL's default) still exists for robustness if BDF somehow fails to load, it's just no longer something you pick.

New: past games and upcoming games

Two new game types can now appear in rotation alongside live games:

  • Past (final) games (show_past_games): recently completed favorite-team games. The WINNING team's bottom bar is highlighted yellow (255, 200, 0) instead of its normal team color, and the black half shows "FINAL" centered -- no diamond/inning/etc, since there's no live situation to show.
  • Upcoming games (show_upcoming_games): scheduled favorite-team games, always shown in start-time order (sorted by ESPN's raw ISO8601 date string, which sorts correctly across date/month boundaries as a plain string comparison -- not by the formatted local date, which wouldn't). Team columns show only the abbreviation (no score number, since the game hasn't happened yet). The black half shows "UPCOMING" with the game's date and local start time below it.

Both are filtered to your favorite teams only, regardless of the show_favorite_teams_only setting (which governs live-game rotation scope) -- showing every past/upcoming MLB game league-wide would be dozens of entries per day, so this only makes sense scoped to teams you actually follow. Capped at max_past_games/max_upcoming_games (default 3 each) to keep rotation length reasonable.

How rotation works now: live games always take priority and appear first; past/upcoming games (if enabled) are appended after. If none of those have anything (no live games, and past/upcoming are off or empty), it falls back to the single best-guess favorite-team game -- the same fallback behavior from before this feature existed.

Local time assumption: start times convert from ESPN's UTC timestamp to your system's local timezone via Python's astimezone() -- this assumes your Pi's system clock/timezone is configured correctly, which is the normal case for a home device.

Verified with actual rendering and pixel checks, not just code review: confirmed the winning team's bar is measurably yellow while the losing team's stays its normal color; confirmed the upcoming-game team text is measurably narrower than the same team with a score shown (proving the score is genuinely omitted, not just visually similar); confirmed past-game filtering excludes non-favorite-team games and upcoming-game sorting/capping both work correctly against synthetic multi-game scoreboard data; and confirmed existing live-game rendering is completely unaffected by all this restructuring.

The bottom row of phase 3 (play description under "HOME RUN!") used the same wrap/shrink/truncate logic as the plain last-play overlay -- fine there, but this row only has room for a single line, so a long description would get aggressively shrunk or truncated with an ellipsis. Replaced with _draw_scrolling_text: short text just displays centered and static, but text wider than the box scrolls continuously (looping) using the same time-based approach as the rest of the animation, so nothing is ever cut off -- long descriptions just take a few seconds longer to fully read.

Caught a real bug while building this: the scroll position is naturally a float (from the time-based math), but BDFFont.draw() needs integer pixel coordinates for putpixel() -- confirmed by actually running it and hitting a TypeError, not just by inspection. Fixed by rounding to the nearest int before rendering.

Also renders onto a scratch canvas sized exactly to the text's box, then pastes that onto the real image -- this guarantees the scrolling text can never bleed outside its intended area (e.g. into the team panels) even at large negative offsets, since PIL's draw.text() only clips to the full target image's bounds, not to an arbitrary sub-region.

Verified directly: confirmed scroll position measurably changes across time samples while staying within the box's pixel bounds throughout, and confirmed short text (that fits without scrolling) renders identically across different time values rather than jittering unnecessarily.

When the last-play flash triggers for a home run specifically, it now plays a three-phase animated sequence instead of the plain text overlay, sequenced within the total last_play_display_seconds window (phase lengths scale proportionally, so this still looks reasonable whether that's set short or long):

  1. Ball arc -- a small ball traces a parabolic path across the panel with a short fading trail, representing the moment of contact.
  2. Strobing flash -- background alternates black/batting-team-color a few times per second, bold "HOME RUN!" text staying steady on top.
  3. Firework bursts + play text -- settles into a black background with a couple of recurring particle bursts, "HOME RUN!" steady beneath them, and the actual play description word-wrapped at the bottom.

All three phases are pure functions of elapsed time (time.time()), not persistent per-frame state, so they animate smoothly regardless of however often display() happens to get called.

Detection caveat, same pattern as pitch count/last-play: I do NOT have a confirmed real sample of ESPN's home-run type code (unlike NON_SIGNIFICANT_PLAY_TYPES, where "ball" and "start-batterpitcher" are both confirmed). HOME_RUN_PLAY_TYPES is a best-effort guess ("home-run", "homerun", "home_run", "hr", "home run"). If a real home run doesn't trigger the animation (falls back to the plain text overlay instead), check the logs for the "Last-play flash QUEUED for ... (type=...)" line during that play and add the actual type string to HOME_RUN_PLAY_TYPES in manager.py.

Verified each phase renders distinctly and correctly via direct pixel checks (not just visual inspection): phase 1's ball position measurably moves rightward over time, phase 2's background measurably toggles between team color and black, phase 3 shows both firework-colored particles and the "HOME RUN!" text simultaneously. Also confirmed a non-home-run significant play (e.g. a double) still correctly falls back to the plain text overlay rather than triggering the animation.

Root cause confirmed (not a data/filter problem): the original design set a per-game wall-clock expiry (flash_until = now + 5s) the moment a significant play was DETECTED, completely independent of whether that game happened to be on screen. Normal rotation runs on its own separate timer with zero awareness of pending flashes. With 2+ live games rotating, a flash could easily expire before rotation ever got around to actually showing that game -- so whether you saw it came down to unlucky timing, not whether the play was detected correctly. Even when the currently-displayed game got the play, rotation could still switch away mid-flash and cut it short.

Fixed: replaced the per-game timer with a queue (_pending_flash_event_ids) and a single "active" slot (_active_flash), serviced by _service_flash_queue() -- called every frame, BEFORE rotation. When a significant play is queued, it force-jumps current_index to that specific game (interrupting whatever rotation was about to show) and pauses normal rotation until the flash's duration naturally expires, at which point rotation gets a fresh window and resumes normally. If two games get significant plays close together, both queue up and get shown one after another rather than one clobbering the other.

Verified with real scenarios, not just code review:

  • A home run on a game that's NOT currently displayed correctly forces an immediate jump to that game (confirmed the display doesn't just keep showing whatever was already active and silently drop the play)
  • Rotation stays locked on the flashing game for its full duration even with a very short rotation interval that would otherwise want to switch away
  • Rotation resumes normally immediately after the flash expires
  • Two simultaneous significant plays on different games both get shown in sequence, neither one lost

When something significant happens (hit, walk, strikeout, out, run scored), the black half temporarily shows the play description instead of the normal inning/diamond/count layout, then reverts automatically. Team panels on the left stay untouched throughout.

  • Data source: situation.lastPlay -- confirmed present in the same real live-game JSON already captured for this plugin (unlike pitch count, this field IS in the lightweight scoreboard endpoint, so no extra API call was needed).
  • Filtering: situation.lastPlay.type.type is checked against a denylist (NON_SIGNIFICANT_PLAY_TYPES) of routine pitch-level updates -- confirmed from real data that ESPN sends a new lastPlay on every single pitch, not just outcomes (one sample was literally "Pitch 2 : Ball 2"). Only two real type codes have been confirmed so far ("ball" and "start-batterpitcher") -- I only have two samples to go on, so this is a denylist rather than an allowlist on purpose: an unknown type defaults to showing rather than defaulting to hidden, so a real highlight is less likely to get silently filtered out by an unrecognized type code. If routine updates still slip through, add the offending type string to NON_SIGNIFICANT_PLAY_TYPES in manager.py.
  • Doesn't flash on first sighting: a game's very first poll never triggers a flash (nothing to compare against yet) -- otherwise every game would flash immediately on plugin startup for whatever play happened to already be current.
  • Per-game tracking: each game's last-seen play ID and flash deadline are tracked separately (keyed by ESPN's event ID), so switching between games in rotation can't show a stale or mismatched flash from a different game.
  • Text wrapping: play descriptions are full sentences, so _draw_last_play word-wraps across multiple lines, picking the largest font size that fits both the width and the available height, falling back to the smallest size with a truncated+ellipsis line if even that doesn't fully fit.

Config: show_last_play (on/off), last_play_display_seconds (default 5), last_play_filter ("significant" or "all" -- "all" flashes for every single play update including routine pitches, per your choice of a conservative denylist-based filter for the default).

Tested the trigger logic directly (first-sighting suppression, routine plays correctly NOT triggering, significant plays correctly triggering, and the flash correctly expiring after its configured duration) and confirmed the overlay actually renders wrapped text while leaving the left-side team panels untouched.

This was a real, systemic bug, not occasional -- confirmed by testing several realistic pitcher name/pitch-count combinations against the actual layout width: "P:112 R. Zeferjahn" and "P:134 K. McGonigle" both needed truncation even at the smallest font size, while shorter ones like "P:47 T. Skubal" didn't. The previous code only applied tightening in an if text_to_draw == text: branch -- i.e., only when NO truncation happened. Any name long enough to need truncation (which turns out to be common, not rare) fell back to plain, untightened rendering. That's backwards: those are exactly the names that benefit from tightening the most, and the whole point of tightening was to fit more of them in.

Rebuilt both _draw_pitch_info and _draw_batter around a single draw/measure function each (_draw_pitch_line/_measure_pitch_line and _draw_name_tightened/_measure_name_tightened), so measurement and final rendering can never drift apart again -- the measurement functions literally call the drawing functions against a scratch canvas rather than reimplementing the position math separately, which is what let this bug happen in the first place.

Also changed what gets truncated: instead of trimming characters off the end of the whole combined string (which could theoretically eat into the "P:47" prefix), truncation now only ever shortens the pitcher/batter NAME, leaving the pitch count intact.

Verified by re-testing the exact names that triggered the bug -- confirmed tightened gaps (1px) now appear at both junctions even in cases that need truncation, not just the cases that fit without it.

Batter name now starts at the same left x-position as the inning indicator and pitch count/pitcher name above it, for a consistent left edge down the whole column. Ball-strike count moved to the bottom-right corner, right-aligned. Verified numerically that batter's available width is correctly capped before the count's position (no overlap -- confirmed a clean 2px gap between them) and confirmed via rendered pixel colors that each element lands in the expected corner.

Found the real cause: the "extra space" wasn't spacing added between characters, it was blank design space baked into narrow glyphs (colons, periods) that the font author left for normal-width spacing against a space character. Measured actual rendered ink pixels to confirm: a plain "P:" had a genuine 2px blank gap between P's ink and the colon's ink, and "T. Skubal" had a full 6px blank gap between the period and "S".

Added _ink_extent() (measures real rendered ink columns, not the font's nominal advance width) and _draw_tight_join() (positions two pieces of text so there's exactly N real background pixels between their ink, regardless of which font is active). Used this to tighten:

  • The gap between "P" and ":" in the pitch-count row (now 1px)
  • The gap between the first-initial+period and the last name, for both batter and pitcher names (also now 1px)

Caught a real off-by-one bug while building this: my first version's gap math was consistently 1px tighter than requested (a nominal "ink_gap=1" was actually rendering as 0px, touching). Fixed and re-verified by measuring the actual rendered gap directly -- confirmed exactly 1px now, not just "looks about right."

This only changes the final rendering step, not the width-fitting/ truncation logic (which still measures the untruncated natural string width, so it's a strictly safe direction of error -- actual rendered width is now slightly less than what was measured, never more). If truncation still kicks in for a very long name, it falls back to a plain (untightened) render for that specific case, since re-deriving tightened segment boundaries out of a string that's been cut mid-word isn't worth the complexity.

  • Outs circles: increased from 3px to 4px. Re-verified the gap is still exactly 1px (not touching) by directly measuring rendered pixels, same approach as the last fix -- tested size=4, gap=1 against a few alternatives before picking this one specifically because it measured a clean 1px gap.
  • "(TEAM) DUE UP": when ESPN's data has a gap between at-bats (no current batter AND no current pitcher listed -- this happens sometimes between plays), the top row now shows which team is up next instead of sitting blank. Team is derived from inning_half (away team if top of the inning, home team if bottom). Same fit-to-width and truncation safety as the other text elements. Verified it actually renders (checked real pixel output, not just that the code runs) and confirmed the diamond/inning/outs geometry is unaffected since the row still reserves the same fixed height either way.

Since ESPN's lightweight scoreboard endpoint doesn't include pitch count, this now makes a second request per live game per poll cycle to ESPN's more detailed summary endpoint:

https://site.api.espn.com/apis/site/v2/sports/baseball/mlb/summary?event=<id>

That endpoint isn't officially documented either, so _find_pitch_count() tries a specific plausible shape first (boxscore stat tables with labels/athletes/stats arrays, a common ESPN pattern), then falls back to a generic recursive scan of the whole response for any pitch-count-looking key whose subtree also contains the current pitcher's ID (to avoid grabbing some other player's count). Tested against synthetic versions of both shapes plus a false-positive check (confirms it picks the right player when multiple pitch counts are present) -- all pass. I could not test this against real ESPN summary data from this environment (no network access), so real-world accuracy is unverified until you try it live.

If it still doesn't show up: run dump_summary.py during a live game (same idea as the earlier dump_situation.py/batter-name diagnostic). It fetches the real summary JSON for whatever's live right now and prints every field whose name contains "pitch" or "count" anywhere in the response, plus the current pitcher's ID for cross-referencing. If nothing relevant turns up in that list, ESPN's summary endpoint may need a different query parameter or this data might live under a completely different structure than what I could reasonably guess -- paste me the output and I'll fix the path exactly rather than guessing again.

Performance note: this doubles the number of ESPN requests while games are live (one scoreboard + one summary per live game, each poll interval). For a personal display checking every 15s with a handful of live games, that's not meaningfully more load, but worth knowing it's there if you ever have many favorite teams' games live simultaneously.

Diamond size inconsistency (bases looking a different size for some games as the display cycled): confirmed root cause was that the diamond's vertical space depended on _draw_pitch_info's actual returned height, which varies per game -- 0px for a game with no pitcher data, ~6px for one with a pitcher name shown. That directly changed diamond_available_h (and therefore the diamond's computed size) game to game. Fixed by reserving a FIXED height for the pitch row regardless of its actual content -- verified numerically that the diamond's geometry (half, center_y) is now bit-for-bit identical whether or not a game has pitcher data.

Outs circles touching with no visible gap: found a real off-by-one bug -- a PIL ellipse box built from radius=2 (nominally a "4px" circle) actually renders 5 pixels wide, confirmed by direct pixel measurement. My spacing math assumed 4px circles, so the real 1px gap I intended became 0px in practice. Rewrote _draw_outs to build the box from the true desired pixel size directly rather than doubling a radius, reduced the size slightly (3px) as requested, and verified by scanning actual rendered pixels: exactly 1px of background between each circle now, confirmed programmatically, not just visually.

Pitch count: confirmed not available from this ESPN endpoint

As flagged before touching this, the pitch count isn't showing because it's very likely not present in ESPN's lightweight scoreboard endpoint at all (confirmed from real captured JSON -- situation.pitcher only has player info and a text summary, no numeric pitch count field). The pitcher's name still shows since that data does exist. If you want a real live pitch count, it would need an extra API call per live game (ESPN's more detailed boxscore/summary endpoint) rather than a one-line fix -- let me know if that's worth the added complexity/request volume and I'll build it.

  • Color swap: the darker shade now sits behind each team's logo (better contrast for light/white logo elements), and the full bright team color moved to the text bar behind the abbreviation/score.
  • Outs: now vertically stacked circles (filled = recorded, 1px outline = not) instead of a horizontal row of squares, positioned to the right of the diamond.
  • Inning indicator: moved down to sit vertically centered on the left side of the diamond, instead of pinned to the top corner.
  • New top row: pitch count + pitcher name, in the space freed up by moving inning/outs down. Format is "P:<count> <Pitcher Name>" when a pitch count is available, or just the pitcher's name if not.

Important caveat on pitch count: the real live-game JSON already captured for this plugin (during earlier batter-name debugging) shows situation.pitcher only contains playerId/period/athlete/ projections/summary -- no explicit numeric pitch-count field. extract_pitch_count() tries a few plausible alternate paths in case it's available under a different key or in other game states, but based on the data already seen, it will likely just show the pitcher's name without a count rather than populating P:47. If you want a guaranteed accurate live pitch count, that would need an extra per-game API call to ESPN's more detailed boxscore/summary endpoint -- let me know if you want that added (it's a bigger change: one more HTTP request per live game per poll, not a quick fix).

Diamond size: reworked to reserve exact horizontal space for inning (left) and outs (right) based on their actual measured widths (not a guessed proportion), then size the diamond to fill exactly what's left -- verified numerically that nothing overlaps. This happened to leave slightly more room than before, so the diamond is a bit bigger than in the previous layout.

The exception-handling fix above didn't resolve it, which ruled out that hypothesis and confirmed the other one: the core LEDMatrix scheduler most likely only calls this plugin's update() when its rotation slot is actively on screen, not continuously in the background. This plugin's own live_update_interval_seconds logic is useless if update() itself isn't being invoked often enough by something external.

Fix: this plugin now manages its own refresh cycle, independent of however often (or rarely) the core calls update(). A background daemon thread (started in __init__) checks every 5 seconds whether it's time to poll ESPN again, using the exact same interval logic and exception-safety as before. If the core DOES call update() regularly, that still works too -- both paths share the same _maybe_refresh() method, gated by the same last_fetch_time check, so there's no double-fetching risk either way.

Added a threading.Lock around all the shared game-state reads/writes (live_games, fallback_game, current_index, last_fetch_time) since now two different threads (the core's calling thread and this plugin's own background thread) can touch that state concurrently.

Verified concretely: ran the plugin with zero external calls to update()/display() at all -- just started it and slept -- and confirmed via logging that it fetched fresh data on its own schedule (3 fetches over 17 seconds at a short test interval, each ~5s apart, matching the background thread's check cadence). This proves data now refreshes regardless of the core's calling pattern.

Also added a cleanup() method that stops the background thread cleanly if the core supports calling it on plugin disable/unload (a no-op if it doesn't -- the thread is a daemon thread either way, so it won't prevent the process from exiting).

Since you confirmed the score was frozen too (not just balls/ strikes/batter), that ruled out a narrow bug in one specific field and pointed at something systemic: an unhandled exception silently killing all future updates after the first successful one.

Both update() and display() had this same structural gap: only the network request itself was wrapped in try/except. Anything that threw after that -- parsing an unusual game state (extra innings, a pitching change, a field that's temporarily null mid-play, etc.) -- would propagate straight out uncaught. If the core scheduler's plugin loop doesn't itself guard against a plugin's update()/display() throwing, one bad response at any point during a 3+ hour game could permanently stop this plugin from ever refreshing again -- exactly matching "shows latest info after restart, then nothing" (the restart re-runs __init__, which works fine off a fresh state; it's some later poll during the live game that likely hit the unhandled exception).

Fixed: both update()'s parsing/processing and display()'s rendering are now wrapped in their own broad exception handlers. update() keeps the last-known-good game data and logs a full traceback instead of losing everything; display() falls back to a simple error frame instead of crashing. I verified this concretely (not just by inspection): simulated a parsing exception mid-update and confirmed update() survives it, preserves the previous data, logs the full traceback, and display() continues working normally afterward.

If this was in fact the bug, the traceback that gets logged next time it happens will tell us exactly which game state triggered it -- please share it if you see the new ERROR: Fetched scoreboard successfully but failed to parse/process it: ... line in your logs, and I can add explicit handling for whatever specific case it turns out to be.

Diagnosing: live data seems to stop updating after restart

Two different things could cause this, and they need different fixes, so update() now logs enough to tell them apart:

Hypothesis A: the core LEDMatrix scheduler isn't calling update() often enough. This plugin's own polling logic (live_update_interval_seconds) only works if the core actually calls update() at least that often. If the scheduler only calls it when this plugin's rotation slot comes up on screen (not continuously in the background while other plugins are showing), the data would only ever refresh once per rotation cycle -- which could be much slower than 15s if there are several plugins in rotation. This is outside this plugin's control if it's what's happening.

Hypothesis B: update() is being called on schedule, but the fetch itself is silently failing every time (network hiccup, ESPN rate limiting, etc.) after the first successful one at startup.

What the new logging shows:

DEBUG: update() called but skipping fetch -- only 3.2s since last fetch (interval is 15s)...
INFO: Fetched scoreboard OK: 1 live game(s). First: ATH@DET 3-2, count 2-1, outs 1, batter=K. McGonigle

or, if the fetch is failing:

ERROR: Failed to fetch MLB scoreboard: <error details>

What to check: during a live game, watch the logs for a minute or two (past the first update). If you see repeated Fetched scoreboard OK lines with the count/batter actually changing between them, the fetch logic is fine and it's Hypothesis A (a core-scheduler question, not something in this plugin). If you see ERROR: Failed to fetch... repeating, that's Hypothesis B and tells us exactly what's failing. If you see neither -- no log lines from this plugin at all after the first one -- that's the strongest sign of Hypothesis A: update() simply isn't being invoked again.

One more useful data point: does the score (not just balls/strikes/ batter) also stay frozen after the first update, or does it change correctly while only balls/strikes/batter/count seem stuck? If score updates fine but those three don't, that points to something more specific worth digging into rather than a general polling problem.

Confirmed against real live-game data you pulled (ATH@DET and SEA@MIA, 2026-07-09) that ESPN's actual field structure is situation.batter.athlete.{displayName, fullName, shortName} -- extraction itself was working correctly and pulling the right name.

The actual bug was downstream: _draw_batter had a safety check that silently skipped drawing entirely if the formatted name didn't fit even at the smallest allowed font size. Tested with real data: "Kevin McGonigle" -> "K. McGonigle" measures 48px wide at the floor size, but only ~44px of space is available next to the count. That's not a rare edge case -- most real player names are long enough to hit this, which is why it looked like the feature was completely broken rather than just failing for a couple of long names.

Fixed: instead of skipping, it now truncates character-by-character (adding a trailing ".") until something fits, so you always see as much of the name as there's room for rather than nothing at all. Verified against "K. McGonigle", "V. Guerrero Jr.", and "R. Acuna Jr." -- all now render.

Also improved: now prefers ESPN's own pre-formatted shortName field (e.g. "K. McGonigle") instead of reformatting displayName ourselves -- more reliable for suffixes and multi-word names than a naive "first letter + rest" split, and it's real data ESPN already provides.

This was a real bug I introduced with the BDF support, not a tuning issue. _fit_font_for_width/_fit_font_for_pair were checking if self.font_choice in BDF_FONT_CHOICES to decide whether to skip the shrink-to-fit loop (since BDF is a fixed size and doesn't need shrinking). But that check trusted the config string, not whether a BDF font actually loaded. If tom-thumb.bdf failed to parse for any reason, _load_font() silently falls back to a TTF font -- and the shrink-loop-skip logic would still fire (because font_choice was still "tom_thumb"), returning that TTF font at a fixed start_size (10) with zero shrinking ever applied. That's exactly the "team abbreviations far too big" symptom -- confirmed by deliberately breaking BDF loading in a test and reproducing oversized, unshrunk text, then re-running the same test after the fix and confirming text stays compact (verified numerically: text height dropped from overflowing most of the panel to a normal ~7px band).

Fixed: the skip-shrinking decision now checks isinstance(loaded_font, BDFFont) -- the actual resolved object -- rather than trusting the config string. If BDF fails and falls back to a TTF, that TTF now correctly goes through the normal shrink loop.

Also hardened: _load_font() now tries every other bundled TTF this plugin ships with (5by7, 4x6, press_start_2p) before falling back to OS-level system fonts, since a minimal Raspberry Pi OS install may not have DejaVu/Liberation fonts at all. And startup now logs the actual contents of the plugin's fonts/ directory plus the concrete resolved font type, so you can confirm from logs alone -- no SSH needed -- whether BDF loaded correctly:

INFO: Plugin fonts/ directory (.../fonts) actually contains: ['PressStart2P-Regular.ttf', '5by7_regular.ttf', '4x6-font.ttf', 'tom-thumb.bdf']
INFO: font_choice 'tom_thumb' -> bundled file found OK at .../fonts/tom-thumb.bdf
INFO: font_choice 'tom_thumb' resolved to: BDFFont (correct)

If that last line instead says "resolved to a TrueType font" with a WARNING, or "resolved to <class ...ImageFont.ImageFont>" (PIL's crude default), that tells us definitively what's failing on your install -- please share that log output if things still look off.

Inning-number centering: I re-verified the centering math (from the previous fix) against both a working BDF font AND a simulated TTF fallback, and it measures an exact 0px difference between the triangle's center and the number's center in both cases. My read is that the "not centered" appearance in your screenshot was a symptom of the oversized-font bug above (a much bigger number just looks disproportionate next to a tiny triangle, even if technically centered) rather than a separate centering bug. Should resolve once you update.

The stray "0" artifact I can't yet explain. It's very likely connected to the same font-loading cascade (an oversized or mismeasured glyph from some other element bleeding into that area), but I can't pin down the exact mechanism without seeing your actual logs. Please check them after this update -- if it persists, the specific log lines above (especially the "resolved to" line) will help me find it fast rather than guessing again.

Logo size and inning-number centering (latest tweaks)

  • Logos are bigger and now allowed to bleed off the panel/column edges intentionally (removed the clamp that was snapping them to the top-left corner once they exceeded the column size). One trade-off worth knowing: since the two team columns sit side by side, a big enough logo can bleed a few px into the neighboring team's column too -- in practice this is rarely very visible since most team logos taper to transparent near their outer edge, but if it looks off with a particular team's logo shape, the fix is tuning the + 4 in _resolve_logos() back down.
  • Inning number centering was actually off by 1px, not just a matter of preference -- I measured the triangle's real rendered pixel extent for both orientations (top/bottom of inning) and found its vertical center was consistently 1px lower than where the number was landing. Fixed and re-verified: both orientations now measure an exact matching center (not just "close").

font_choice: "tom_thumb" is now the default. It uses Tom Thumb, a well-known 3x5 pixel BDF font (MIT licensed) purpose-built for tiny LED displays -- the same one referenced in hzeller/rpi-rgb-led-matrix's own font collection. Bundled at fonts/tom-thumb.bdf.

Why this should be the most robust option: Pillow's ImageFont.truetype() can't load .bdf at all, and BDF glyphs are exact per-pixel bitmaps rather than vector outlines FreeType has to rasterize. So this plugin includes its own minimal BDF parser/renderer (BDFFont class in manager.py) that reads the glyph bitmaps directly from the file and writes each "on" pixel straight to the image with putpixel() -- there's no rasterization step at all, so there's nothing that can introduce anti-aliasing, halos, or softness. I verified this by checking the actual rendered pixel values contain only the exact background/text colors with zero in-between shades, across every text element (team names, inning number, count, batter name).

One real limitation: BDF is a fixed pixel size (Tom Thumb's glyphs are only 3-4px wide), so it doesn't go through the shrink-to-fit sizing logic the TTF options use -- there's only one size. In practice this is fine since it's already extremely compact (comfortably fits "ABBR SCORE" including double-digit scores without ever needing to shrink), but if you ever see it overflow a row, that's the reason why, and the fix would be different from the TTF options (abbreviating text rather than picking a smaller size).

Other TTF options (5by7, 4x6, press_start_2p) are still available via font_choice if you'd rather compare looks side by side.

Data source

ESPN's public scoreboard endpoint, no API key required:

https://site.api.espn.com/apis/site/v2/sports/baseball/mlb/scoreboard

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages