⚠️ 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.
- Push this folder to your own GitHub repo (repo root = this folder).
- On your Pi, open the web UI:
http://<pi-ip>:5000→ Plugin Manager. - Use Install from GitHub URL, paste your repo URL.
- Restart the display service so the loader picks it up.
- Add the block from
example_config.jsonin the web UI config editor, settingfavorite_teamsto your team(s).
Set "test_mode": true to render a fake in-progress game (ATH @ DET)
instead of calling ESPN.
- 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 viafont_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'sassets/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.
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.
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. Adjustsupersample(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'shparameter 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 throughself.live_gameseverygame_rotation_seconds.
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.
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 |
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.
- 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.
- 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.
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.
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_teamsstill 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_secondssaid "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 existingshow_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_infoitself, 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 uselast_play_display_secondsinstead ofhome_run_display_secondsfor 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.
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.
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.
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.
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.
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.
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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
"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.
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.
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.
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.
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.
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.
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.
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.
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).
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.
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: 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
DWIDTHvalue (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.
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:
- 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.
- No favorite live,
show_favorite_teams_onlyoff (default): all live games leaguewide + past/upcoming games (scope controlled by the newpast_upcoming_all_teams, favorites-only by default). - No favorite live,
show_favorite_teams_onlyon (strict mode): only favorites' past/upcoming games -- no other team's live game ever appears, full stop. - 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.
away_color/home_colorfallback defaults changed to white ([255,255,255]) per request.font_choiceremoved entirely as a user setting --tom_thumbis 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.
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):
- Ball arc -- a small ball traces a parabolic path across the panel with a short fading trail, representing the moment of contact.
- Strobing flash -- background alternates black/batting-team-color a few times per second, bold "HOME RUN!" text staying steady on top.
- 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.typeis checked against a denylist (NON_SIGNIFICANT_PLAY_TYPES) of routine pitch-level updates -- confirmed from real data that ESPN sends a newlastPlayon 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 toNON_SIGNIFICANT_PLAY_TYPESinmanager.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_playword-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=1against 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.
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.
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.
- 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
+ 4in_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.
ESPN's public scoreboard endpoint, no API key required:
https://site.api.espn.com/apis/site/v2/sports/baseball/mlb/scoreboard