Compare commits

..
Author SHA1 Message Date
ChuckBuildsandClaude Opus 5 9e1c339273 docs(sports): record B6 as done, not as waiting
This PR was opened to say B6 was deliberately held. It has since run, so the
document said the opposite of the truth -- which is the exact failure it was
written to fix: "a plan that misreports which phase it is in is worse than no
plan".

Merges current main first (53 commits), which brings in #508's corrections to
this same file, then replaces the hold with what happened.

**Why the hold lifted is worth recording, because the stated gate was never
met.** It asked for evidence of 3.2.0 uptake, and that evidence could not
arrive: the core updates by `git pull --rebase`, so release-asset counts cannot
measure it, and no store-side telemetry exists. What changed is that the risk
the gate protected against was closed directly -- the store now refuses a
plugin whose floor exceeds the running core on all three routes in:
install_plugin (#431/#433), update_plugin's git branch (#508), and
install_from_url (#510). A pre-3.2.0 user cannot receive a sunset plugin at
all, so they keep the version they run. Refusal replaced the bundled copy,
which is what the copy stood in for.

Records what shipped (eight plugins, ~5,800 lines, plugins #346/#349/#350/#351)
and the two findings worth carrying to the next module: baseball's fallback was
the only one holding orchestration logic the core lacked, and two tests had
been leaning on the guard -- soccer's stubbed `src` in a way that shadowed the
core, so it had been exercising the frozen copy rather than the shipping class
since B5.

The remaining-work list is replaced too. Its first item was "nothing on the
critical path, B6 is waiting on calendar time", which is no longer true. What
remains: hardware soaks (with a note to check the rig's display_mode first, or
a board in switch mode tells you nothing about the scroll code), cutting 3.3.0
-- not required by B6, whose floors are 3.2.0, but calendar 1.2.3 floors at
3.3.0 and is un-installable until it exists -- and reconsidering the modules
held back during the sunset.

Keeps the pre-B6 sections as history. The reasoning still applies to the next
module; it is just no longer in force for this one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9
2026-09-01 22:08:59 -04:00
ChuckBuildsandClaude Opus 5 c2ace90353 docs(sports): re-check the hold, and close two items that are already done
The remaining-work list had two entries that finished without the doc noticing,
which is the failure mode this file exists to prevent.

- The stale plugin-test tranche is gone. run_plugin_tests.py --all now reports
  174 passed, 2 skipped, 0 failed across the whole fleet. Recorded how to
  re-check it too: these are standalone scripts, not a pytest suite, and one
  calls sys.exit(1) at import, so pointing pytest at a plugin directory
  collapses into an INTERNALERROR that looks nothing like the real state.
- CLAUDE.md already says eight panel sizes.

That leaves the hardware soaks as the only open item needing work rather than
calendar time.

B6's prerequisite is now built -- core test/test_sports_sunset_matrix.py
(#505) -- so the phase table and the regression-test section say so, and the
two modelling traps it had to work through are recorded for whoever touches it
next: the copy-removed shape must be an unguarded import or the failure names
scroll_display_legacy instead of the core module, and only the leaf module may
be hidden because a pre-3.2.0 core still ships src/common/.

The hold itself is re-checked and unchanged: v3.2.0 is still latest,
__version__ is still 3.2.0, no 3.3.0, 23 days rather than the few months the
gate asks for. Also worth stating plainly -- the core updates by git pull, not
by downloading a release, so release-asset counts would not measure uptake even
if we had them. Whatever unblocks this has to come from the store side.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW
2026-08-26 10:33:31 -04:00
ChuckBuildsandClaude Opus 5 cc6a75712c docs(sports): scope the B5 safety claim to the fallback
The heading read "B5 — adoption is safe by construction", which this same
document disproves two sections later: four of the eight adopted plugins
shipped with scroll mode broken on a 3.2.0 core and were repaired in
plugins #251.

The body was already careful -- it says fallback compatibility is what is
guaranteed, and that correctness on a core which *does* ship the module
needs object-level and scroll-mode validation. The heading was not, and a
heading is what a reader scanning the plan actually takes away.

Retitled to name both halves, with a sentence up front saying why the
unqualified claim is false and pointing at the retrospective that shows
it. The phase intro said "one of them is safe by construction and the
other is not"; that now says what it actually means -- one cannot break a
user on an old core, the other can.

The second review point, MD018 on the ATX heading at line 409, does not
reproduce: that line now begins "(#431, #433)" rather than "#433)", so
there is no bare-hash heading. `grep -cE '^#+[^ #]'` returns 0 for the
whole file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW
2026-08-21 16:20:12 -04:00
ChuckBuildsandClaude Opus 5 ed2b81a4fb docs(sports): stop a wrapped PR reference reading as a heading
A line wrapped onto "#433), the newest manifest entry ...", which
markdownlint reads as a malformed ATX heading (MD018). Reflowed so the
line starts with "(#431, #433)" instead.

Not the suggested fix: adding a space after the hash would have turned
the PR reference into "# 433". The B5 safety claim raised alongside this
was already corrected in ac44b5a.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5
2026-08-11 12:37:18 -04:00
coderabbitai[bot]andCodeRabbit ac44b5a6f5 fix: apply CodeRabbit auto-fixes
Fixed 1 file(s) based on 2 unresolved review comments.

Co-authored-by: CodeRabbit <noreply@coderabbit.ai>
2026-08-05 20:59:54 +00:00
ChuckBuildsandClaude Opus 5 df8f69af30 docs(sports): record where B6 stands, and why it is waiting
The phase table had B4 as "next" and B5 as "after B4" while both had shipped,
and described B6 as blocked on B4's gate — which is now merged and released. A
plan that misreports which phase it is in is worse than no plan: the next
person reads it and repeats finished work.

Corrected, and three things that were only ever decided in conversation are now
written down:

  * **B6 is deliberately held.** 3.2.0 published 2026-08-03; 3.1.0 ran nine
    months before it. B6's premise is that cores without the module are gone,
    and there is no release-asset count or install telemetry to show that.
    Running it now strands users on their current plugin versions. The gate
    that makes it safe is already built and tested — it is the calendar that is
    missing, and no amount of further code changes that.
  * **Stop adopting further shared modules** (data_sources, game_renderer,
    base_odds_manager) until B6 closes. Each adoption adds a copy to keep in
    step against a payoff contingent on B6.
  * **A B5 retrospective**, because "the adoption went fine" is not what
    happened: four of eight shipped with scroll mode broken on a 3.2.0 core.
    The bundled fallback did not protect against it — the break was on the
    modern path — which is an argument for the sunset, not against it. Records
    the ledger too: net negative on disk until B6 runs.

Also replaces the "what's next" list, whose first five items were all done,
with what actually remains.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5
2026-08-05 14:14:00 -04:00
34 changed files with 20 additions and 10263 deletions
-75
View File
@@ -17,81 +17,6 @@ release that ships it.
accepts both, but the store flags the old spelling as deprecated accepts both, but the store flags the old spelling as deprecated
(`store_manager.py`) and only the new one is in `schema/manifest_schema.json`. (`store_manager.py`) and only the new one is in `schema/manifest_schema.json`.
## 3.3.0
**The release the sports scoreboards floor on to delete their bundled copies.**
3.2.0 shipped the unified sports library and made `ledmatrix_min_version`
enforceable; this ships the last three shared modules and completes the store
gate, so a scoreboard can now floor here and carry no fallback at all.
New modules a plugin may import via `src.*` and floor on 3.3.0 for:
- `src/common/sports_card.py` — settings, colour, font and date helpers for a
scoreboard's `game_renderer.py`. Free functions taking `config`/`fonts`
explicitly, so nothing about the caller's class is assumed.
- `src/common/sports_game_renderer.py` — `SportsGameRendererMixin`: scroll/Vegas
card geometry (centre gap, logo slot and cache key, layout offsets, the
upcoming-card date and time layout). No `__init__` and no state, so adoption
is one line on the class statement.
- `src/common/sports_shared.py` — `SportsCoreSharedMixin`,
`SportsLiveSharedMixin`, `SportsRecentSharedMixin`: the `sports.py` bodies
byte-identical in all eight lineage-sharing scoreboards.
All three sit under `src/common/` rather than `src/base_classes/sports/`,
deliberately: importing that package pulls `core.py` → `DisplayManager` →
`rgbmatrix`, and these are pure logic. Plugins importing them must not acquire a
hardware dependency.
Three notes for anyone adopting `sports_shared`:
- `SportsRecentSharedMixin` defines `__init__`. Its bare `super()` binds to the
mixin, so it reaches the host only when the mixin is listed **first** in the
bases. Reversing that order silently skips the host constructor.
- Methods that resolve the plugin's `config_schema.json` use `_plugin_dir()`,
which walks the MRO rather than reading `__file__` — `__file__` is now
`src/common/`. It walks because `SportsCore` is an ABC: a subclass built with
`type(name, bases, ns)` reports `__module__` as `"abc"`.
- `_get_timezone`, `_extract_game_details` and `_fetch_data` are byte-identical
across the eight but stay in the plugins. The first binds a per-plugin
timezone module whose contents differ; the other two are the abstract stubs
that define the sport.
**The store's compatibility gate is now on every registry-managed route.** 3.2.0
gated `install_plugin`. This release gates the git-pull update path and
`install_from_url`, so a plugin whose floor the core cannot meet is refused
after download with no partial directory left behind.
### Fixed
- **ESPN 403s.** `site.api` began rejecting the User-Agent strings this repo
sent on 2026-08-04; every shared-data-source scoreboard returned
`403 Forbidden`. Requests now send an identifying token with a project URL —
browser-style strings and bare custom tokens are both refused.
- **Low-memory boards becoming unreachable under load** while still answering
pings and serving the web UI. Fetched payloads are released after delivery
rather than pinned on the completed request for up to an hour, malloc arenas
are capped, and log volume and SD writes are reduced. Available memory is now
reported in Tools diagnostics.
- **A failed logo download pinning a team to a grey box**: the placeholder was
written under the real logo's filename, so later attempts found a file and
reported success without retrying.
- **A plugin enabled but never loaded is retried** rather than staying absent
with `error = null`.
- `ttl` now controls cache expiry; abandoned cache writes no longer leave temp
files; one cache-cleanup thread per directory rather than per manager.
- Odds are fetched for the games displayed, not the whole schedule window, and a
stalled ESPN no longer stalls the whole plugin update.
- Array-item secrets are no longer wiped or logged.
- A restored backup matches the device it was taken from.
- The web UI reports the real error instead of "unknown", rejects non-finite
JSON numbers, and stops checkbox groups posting back hidden options.
### Added
- `display.hardware.orientation` for panels mounted upside down.
- Vegas keeps live content in the ticker rather than being preempted by it.
- Schemas can label enum dropdown options.
## 3.2.0 ## 3.2.0
**The first release shipping the unified sports library.** This is the version **The first release shipping the unified sports library.** This is the version
+1 -23
View File
@@ -52,10 +52,6 @@ def main() -> int:
parser.add_argument('--height', type=int, default=32, help='Display height (default: 32)') parser.add_argument('--height', type=int, default=32, help='Display height (default: 32)')
parser.add_argument('--skip-update', action='store_true', parser.add_argument('--skip-update', action='store_true',
help='Skip calling update() (render display only)') help='Skip calling update() (render display only)')
parser.add_argument('--display-mode', default=None,
help='Display mode to render, for plugins that declare '
'more than one in their manifest (e.g. nrl_live). '
'Omitted, the plugin picks its own default.')
args = parser.parse_args() args = parser.parse_args()
@@ -145,26 +141,8 @@ def main() -> int:
except Exception as e: except Exception as e:
logger.warning("update() raised: %s — continuing to display()", e) logger.warning("update() raised: %s — continuing to display()", e)
# A plugin that declares several display modes usually renders nothing
# useful without being told which one to draw: the scoreboards keep their
# state on per-mode sub-managers and their no-argument path returns False.
# Only pass the argument when asked for, so the many plugins whose display()
# takes no display_mode keep working untouched.
try: try:
if args.display_mode: plugin_instance.display(force_clear=True)
try:
plugin_instance.display(display_mode=args.display_mode,
force_clear=True)
except TypeError as error:
if ("unexpected keyword argument" not in str(error)
or "display_mode" not in str(error)):
raise
logger.warning(
"%s.display() does not accept display_mode; rendering its "
"default screen instead", args.plugin)
plugin_instance.display(force_clear=True)
else:
plugin_instance.display(force_clear=True)
logger.debug("display() completed") logger.debug("display() completed")
except Exception as e: except Exception as e:
logger.error("Error in display(): %s", e) logger.error("Error in display(): %s", e)
+1 -1
View File
@@ -4,5 +4,5 @@ LEDMatrix Display System
Core source package for the LED Matrix Display project. Core source package for the LED Matrix Display project.
""" """
__version__ = "3.3.0" __version__ = "3.2.0"
+2 -52
View File
@@ -143,10 +143,8 @@ class LogoHelper:
""" """
logo_path = Path(logo_path) logo_path = Path(logo_path)
# Try to load existing logo first. A placeholder written by a previous # Try to load existing logo first
# failed download does not count: it wears the real logo's filename, so if logo_path.exists():
# trusting the file's existence is what left teams as grey boxes.
if logo_path.exists() and not self._is_stale_placeholder(logo_path):
return self.load_logo(team_abbr, logo_path, max_width, max_height) return self.load_logo(team_abbr, logo_path, max_width, max_height)
# Download if URL provided and file doesn't exist # Download if URL provided and file doesn't exist
@@ -154,61 +152,13 @@ class LogoHelper:
try: try:
self.logger.info(f"Downloading logo for {team_abbr} from {logo_url}") self.logger.info(f"Downloading logo for {team_abbr} from {logo_url}")
self._download_logo(logo_url, logo_path) self._download_logo(logo_url, logo_path)
# The file on disk just changed. Any cached image for it is the
# placeholder we came here to replace, and load_logo() answers
# from the cache before touching the disk -- so without this the
# real logo would not appear until the process restarted.
self._invalidate_cached_logo(team_abbr, logo_path)
return self.load_logo(team_abbr, logo_path, max_width, max_height) return self.load_logo(team_abbr, logo_path, max_width, max_height)
except Exception as e: except Exception as e:
self.logger.error(f"Failed to download logo for {team_abbr}: {e}") self.logger.error(f"Failed to download logo for {team_abbr}: {e}")
# The retry failed, so restart the back-off. The stale
# placeholder is still on disk with its old timestamp, and
# leaving it there means the next call retries immediately --
# a download attempt per call, which is what the back-off
# exists to prevent.
self._refresh_stale_placeholder(logo_path)
# Create placeholder if all else fails # Create placeholder if all else fails
return self._create_placeholder_logo(team_abbr, max_width, max_height) return self._create_placeholder_logo(team_abbr, max_width, max_height)
def _invalidate_cached_logo(self, team_abbr: str, logo_path: Path) -> None:
"""Drop every cached size of one logo after its file changed on disk."""
prefix = f"{team_abbr}_{logo_path}_"
for key in [k for k in self._logo_cache if k.startswith(prefix)]:
self._logo_cache.pop(key, None)
if key in self._cache_order:
self._cache_order.remove(key)
@staticmethod
def _refresh_stale_placeholder(logo_path: Path) -> None:
"""Restart the retry back-off after a failed download attempt."""
try:
from src.logo_downloader import refresh_placeholder_timestamp
except ImportError:
return
refresh_placeholder_timestamp(logo_path)
@staticmethod
def _is_stale_placeholder(logo_path: Path) -> bool:
"""True if the file is a placeholder old enough to be worth retrying.
Imported lazily so this module keeps working against a core build whose
logo_downloader predates placeholder marking.
"""
try:
from src.logo_downloader import (
PLACEHOLDER_RETRY_SECONDS,
is_placeholder_logo,
placeholder_age_seconds,
)
except ImportError:
return False
if not is_placeholder_logo(logo_path):
return False
age = placeholder_age_seconds(logo_path)
return age is None or age >= PLACEHOLDER_RETRY_SECONDS
def get_logo_variations(self, team_abbr: str) -> List[str]: def get_logo_variations(self, team_abbr: str) -> List[str]:
""" """
Get possible filename variations for a team abbreviation. Get possible filename variations for a team abbreviation.
-470
View File
@@ -1,470 +0,0 @@
"""Card-drawing helpers shared by every sports scoreboard plugin.
The eight scoreboards each carried byte-identical copies of the functions
below: the colour pickers, the settings lookup, the date and time formatting,
the favourite-team rules and the font-size grid snapping. One fix had to be
made eight times, and a new scoreboard started by copying them a ninth.
Everything here is a **free function taking explicit arguments**, not a base
class. Adoption is therefore per-function and reversible: a plugin keeps its
method and delegates the body, so the call sites and the override points are
untouched. That is also why `config`, `logger` and `fonts` are parameters
rather than attributes -- the helper never reaches back into the caller.
The bodies are the plugins' own code, moved rather than rewritten. The one
deliberate difference is `crisp_size`, which takes the seven-plugin guard
(`not desired`) instead of football's: they agree on every real input, and
the extra guard only stops a None size raising TypeError.
"""
import logging
from datetime import datetime, timezone
from typing import Any, Dict, Optional, Tuple
from zoneinfo import ZoneInfo
logger = logging.getLogger(__name__)
__all__ = [
"ELEMENT_FOR_FONT", "FAVORITE_RESULT_COLOR_DEFAULTS", "FONT_NAME_ALIASES",
"FONT_PIXEL_GRID", "MONTH_ABBR", "WEEKDAY_ABBR",
"scroll_card_option", "element_color", "font_color", "coerce_rgb",
"score_color_for", "recent_score_color", "favorite_teams_for",
"side_is_favorite", "side_score", "favorite_result",
"card_tzinfo", "weekday_for", "format_game_date", "format_game_time",
"vs_text", "upcoming_center_mode", "crisp_size", "schema_font_size",
"resolve_font_size", "unshare_element_fonts",
]
#: Which customization element owns each font key, for colour resolution.
ELEMENT_FOR_FONT: Dict[str, str] = {
"score": "score_text",
"time": "period_text",
"team": "team_name",
"status": "status_text",
"detail": "detail_text",
"rank": "rank_text",
}
#: Fallback colours when favourite_result_colors is on but a slot is unset.
FAVORITE_RESULT_COLOR_DEFAULTS: Dict[str, Tuple[int, int, int]] = {
"win": (0, 255, 0),
"loss": (255, 0, 0),
"tie": (255, 200, 0),
}
#: Family aliases the web UI may write, mapped to the shipped filename.
FONT_NAME_ALIASES: Dict[str, str] = {
"press_start": "PressStart2P-Regular.ttf",
"four_by_six": "4x6-font.ttf",
}
#: Pixel grid each face renders crisply on. Off-grid sizes anti-alias, which
#: on an LED matrix is a dim lamp rather than a soft edge.
FONT_PIXEL_GRID: Dict[str, int] = {
"PressStart2P-Regular.ttf": 8,
"4x6-font.ttf": 7,
}
MONTH_ABBR = ("Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
WEEKDAY_ABBR = ("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")
# ---------------------------------------------------------------------------
# Settings lookup
# ---------------------------------------------------------------------------
def scroll_card_option(config: Optional[Dict[str, Any]], key: str,
default: Any = None) -> Any:
"""Read one key from the scroll_card config block."""
block = (config or {}).get("scroll_card")
if isinstance(block, dict) and block.get(key) is not None:
return block.get(key)
return default
def vs_text(config: Optional[Dict[str, Any]]) -> str:
"""Separator drawn between the teams -- "VS", "@", "at", anything."""
return str(scroll_card_option(config, "vs_text", "VS"))
def upcoming_center_mode(config: Optional[Dict[str, Any]]) -> str:
"""Middle of an upcoming card: 'vs', 'date_time' or 'none'."""
mode = str(scroll_card_option(config, "upcoming_center", "vs") or "vs").lower()
return mode if mode in ("vs", "date_time", "none") else "vs"
# ---------------------------------------------------------------------------
# Colour
# ---------------------------------------------------------------------------
def element_color(config: Optional[Dict[str, Any]], element: str,
default: Tuple[int, int, int] = (255, 255, 255)):
"""Per-element text colour from customization.<element>.text_color."""
try:
cfg = (config or {}).get("customization", {}).get(element, {})
value = cfg.get("text_color")
if isinstance(value, (list, tuple)) and len(value) == 3:
return tuple(max(0, min(255, int(c))) for c in value)
if isinstance(value, str) and value.startswith("#") and len(value) == 7:
return tuple(int(value[i:i + 2], 16) for i in (1, 3, 5))
except (TypeError, ValueError):
pass
return default
def font_color(config: Optional[Dict[str, Any]], fonts: Optional[Dict[str, Any]],
font, default: Tuple[int, int, int] = (255, 255, 255)):
"""Colour for whichever element owns this face.
Matched on identity, and deliberately gives up when one object is
shared: the last-resort font path can hand the same face to several
keys, and there is no right answer for which element's colour that is.
White is what those draws used before, so ambiguity costs nothing.
"""
try:
fonts = fonts or {}
matches = [element for key, element in ELEMENT_FOR_FONT.items()
if fonts.get(key) is font]
if len(matches) == 1:
return element_color(config, matches[0], default)
except (AttributeError, TypeError):
pass
return default
def coerce_rgb(value, fallback):
"""Turn a configured [R, G, B] list into a clamped (r, g, b) tuple."""
# Checked before unpacking: a 3-character string ("123") would otherwise
# iterate into three digits and yield a colour rather than the fallback.
if not isinstance(value, (list, tuple)) or len(value) != 3:
return fallback
try:
r, g, b = (max(0, min(255, int(channel))) for channel in value)
except (TypeError, ValueError):
return fallback
return (r, g, b)
# ---------------------------------------------------------------------------
# Favourite teams
# ---------------------------------------------------------------------------
def favorite_teams_for(config: Dict[str, Any], game: Dict[str, Any]) -> list:
"""Favorite teams that apply to this game.
Both sources are used. Games carry the league manager's *resolved*
favorites, which is the only place dynamic groups such as AP_TOP_25
appear expanded; the config is read as well so an edit takes effect on
already-fetched games, and so hand-built game dicts (tests, other
callers) still work.
"""
favorites = list(game.get("favorite_teams") or [])
league_config = config.get(str(game.get("league", "") or ""))
if isinstance(league_config, dict):
favorites += list(league_config.get("favorite_teams") or [])
else:
favorites += list(config.get("favorite_teams") or [])
return favorites
def side_is_favorite(game: Dict[str, Any], side: str, favorites: set) -> bool:
"""Is the home/away side of this game a favorite team?
Reads both the flat (``home_abbr``) and nested (``home_team.abbrev``)
payload shapes, and matches on the ESPN id too, because a couple of
leagues (NRL) key favorites by id where abbreviations collide.
"""
candidates = [game.get(f"{side}_abbr"), game.get(f"{side}_id")]
team = game.get(f"{side}_team")
if isinstance(team, dict):
candidates += [team.get("abbrev"), team.get("abbreviation"), team.get("id")]
for value in candidates:
if value is not None and str(value).strip().upper() in favorites:
return True
return False
def side_score(game: Dict[str, Any], side: str) -> Optional[int]:
"""Numeric score for one side, from either payload shape."""
raw = None
team = game.get(f"{side}_team")
if isinstance(team, dict) and team.get("score") is not None:
raw = team.get("score")
if raw is None:
raw = game.get(f"{side}_score")
try:
return int(float(str(raw).strip()))
except (TypeError, ValueError):
return None
def favorite_result(config: Dict[str, Any], game: Dict[str, Any]) -> Optional[str]:
"""Say how the favorite team did in a finished game.
Returns 'win', 'loss' or 'tie', or None when there is no single team
to root for: no favorites configured, neither side is a favorite, or
*both* are -- a favorite-vs-favorite game has no losing side worth
flagging in red. Also None when the scores are not usable numbers.
"""
favorites = {
str(team).strip().upper()
for team in favorite_teams_for(config, game)
if str(team).strip()
}
if not favorites:
return None
home_fav = side_is_favorite(game, "home", favorites)
away_fav = side_is_favorite(game, "away", favorites)
if home_fav == away_fav:
return None
home_score = side_score(game, "home")
away_score = side_score(game, "away")
if home_score is None or away_score is None:
return None
if home_score == away_score:
return "tie"
favorite_score, other_score = (
(home_score, away_score) if home_fav else (away_score, home_score)
)
return "win" if favorite_score > other_score else "loss"
def recent_score_color(config: Dict[str, Any], logger, game: Dict[str, Any], default):
"""Fill color for a finished game's score, per favorite_result_colors."""
try:
settings = (config.get("customization") or {}).get(
"favorite_result_colors"
) or {}
if not settings.get("enabled", False):
return default
result = favorite_result(config, game)
if result is None:
return default
return coerce_rgb(
settings.get(f"{result}_color"),
FAVORITE_RESULT_COLOR_DEFAULTS[result],
)
except Exception:
logger.debug("Could not resolve favorite result color", exc_info=True)
return default
def score_color_for(config: Dict[str, Any], logger, game: Dict[str, Any],
game_type: str, default=None):
"""Fill color for a game card's score. Only finished games are tinted.
The default is the configured score colour rather than a flat white,
so customization.score_text.text_color shows on games the favourite
tint does not apply to. The tint still wins where it applies.
"""
if default is None:
default = element_color(config, 'score_text')
if game_type != "recent":
return default
return recent_score_color(config, logger, game, default)
# ---------------------------------------------------------------------------
# Date and time
# ---------------------------------------------------------------------------
def card_tzinfo(config: Optional[Dict[str, Any]], logger):
"""Timezone for weekday/24h conversions; falls back to UTC."""
configured = (config or {}).get("timezone")
if configured:
try:
return ZoneInfo(configured)
except (KeyError, ValueError, TypeError, OSError) as exc:
# KeyError covers ZoneInfoNotFoundError. A bad zone name in
# config should fall back to UTC, not blank the card.
logger.debug("Unusable timezone %r: %s", configured, exc)
return timezone.utc
def weekday_for(config: Optional[Dict[str, Any]], logger,
game: Optional[Dict]) -> str:
"""Weekday abbreviation from the game's start time, or ''."""
if not game:
return ""
raw = game.get("start_time_utc") or game.get("start_time")
if not raw:
return ""
try:
start = raw if isinstance(raw, datetime) else datetime.fromisoformat(
str(raw).replace("Z", "+00:00"))
return WEEKDAY_ABBR[start.astimezone(card_tzinfo(config, logger)).weekday()]
except (ValueError, TypeError):
return ""
def format_game_date(config: Optional[Dict[str, Any]], logger, date_text: str,
game: Optional[Dict] = None) -> str:
"""Format an upcoming card's date per scroll_card.date_format."""
raw = str(date_text or "").strip()
if not raw:
return ""
fmt = str(scroll_card_option(config, "date_format", "abbrev") or "abbrev")
if fmt == "numeric":
return raw
parts = raw.replace("-", "/").split("/")
if not (len(parts) >= 2 and parts[0].strip().isdigit() and parts[1].strip().isdigit()):
return raw
month, day = int(parts[0]), int(parts[1])
if not 1 <= month <= 12:
return raw
name = MONTH_ABBR[month - 1]
if fmt == "numeric_day_first":
return f"{day}/{month}"
if fmt == "day_first":
return f"{day} {name}"
if fmt == "weekday":
weekday = weekday_for(config, logger, game)
return f"{weekday} {name} {day}" if weekday else f"{name} {day}"
return f"{name} {day}"
def format_game_time(config: Optional[Dict[str, Any]], time_text: str) -> str:
"""Return the time as-is (12h) or converted to 24h."""
raw = str(time_text or "").strip()
if not raw or str(scroll_card_option(config, "time_format", "12h")) != "24h":
return raw
cleaned = raw.upper().replace(" ", "")
meridiem = "AM" if cleaned.endswith("AM") else "PM" if cleaned.endswith("PM") else ""
if not meridiem:
return raw
try:
hh, _, mm = cleaned[:-2].partition(":")
hour, minute = int(hh), int(mm or 0)
except ValueError:
return raw
if not (0 <= hour <= 12 and 0 <= minute <= 59):
return raw
hour = hour % 12 + (12 if meridiem == "PM" else 0)
return f"{hour:02d}:{minute:02d}"
# ---------------------------------------------------------------------------
# Font sizing
# ---------------------------------------------------------------------------
#: Per-schema caches, keyed by the schema's absolute path. Keyed rather than
#: global because each plugin declares its own defaults; keyed rather than
#: per-class because the helper has no class to hang it on.
_SCHEMA_FONT_SIZE_CACHE: Dict[str, Dict[str, int]] = {}
def crisp_size(font_file, desired, aliases=None, grid_table=None):
"""Snap *desired* to the nearest size *font_file* renders crisply at.
A face with no known grid is returned unchanged, so a user-supplied
font is never second-guessed.
``aliases`` and ``grid_table`` default to the shared tables; a plugin
that ships an extra face can pass its own without forking this.
"""
aliases = FONT_NAME_ALIASES if aliases is None else aliases
grid_table = FONT_PIXEL_GRID if grid_table is None else grid_table
font_file = aliases.get(font_file, font_file)
grid = grid_table.get(font_file)
if not grid or not desired or desired <= 0:
return desired
return max(grid, int(round(float(desired) / grid)) * grid)
def schema_font_size(schema_path: str, element_key) -> Optional[int]:
"""The font_size this plugin's config_schema.json declares, or None.
Cached per schema path. The plugins cached this on their own class; the
path is the same distinction expressed without one, so two plugins never
share an entry.
"""
if not element_key:
return None
cache = _SCHEMA_FONT_SIZE_CACHE.get(schema_path)
if cache is None:
cache = {}
try:
import json
with open(schema_path) as fh:
schema = json.load(fh)
props = (schema.get('properties', {})
.get('customization', {})
.get('properties', {}))
for key, spec in props.items():
size = spec.get('properties', {}).get('font_size', {}).get('default')
if size is not None:
cache[key] = int(size)
except Exception as exc:
# See sports_shared._schema_font_size: an unreadable schema
# silently disables the pixel-grid snap for every element.
# Built once per schema path, so this cannot repeat per frame.
logger.warning(
"could not read %s (%s: %s); font sizes will skip their "
"pixel grid snap and may render a pixel narrow",
schema_path, type(exc).__name__, exc)
cache = {}
_SCHEMA_FONT_SIZE_CACHE[schema_path] = cache
return cache.get(element_key)
def resolve_font_size(schema_path: str, element_config, element_key,
default_size, font_name, aliases=None, grid_table=None):
"""Size to render at: the user's choice, or a grid-snapped default.
A configured size counts as a real choice only when it differs from
the schema default. The web UI writes the whole schema default block
on every save, so "font_size == schema default" carries no intent and
would otherwise pin every install to an anti-aliased size forever.
"""
configured = (element_config or {}).get('font_size')
if configured is not None:
try:
configured = int(configured)
if configured != schema_font_size(schema_path, element_key):
return configured
except (TypeError, ValueError):
pass
return crisp_size(font_name, default_size, aliases, grid_table)
def unshare_element_fonts(logger, fonts):
"""Give each colourable element its own face object.
The colour a draw gets is resolved from the face it was handed, and
several of these loaders legitimately hand one object to more than one
element -- a size resolver that lands two elements on the same face, a
fallback that fills every key from one default, football's narrowing
step that deliberately shrinks the clock along with the score. Sharing
the object makes the element ambiguous and the colour unresolvable.
Re-instantiating from the same path and size gives a distinct object
with identical metrics, so nothing about the rendering changes; only
the ability to tell two elements apart does. Faces that cannot be
rebuilt (a BDF loaded through freetype.Face, anything without a usable
path) are left shared, and their draws stay white as before.
"""
try:
from PIL import ImageFont as _IF
except ImportError: # pragma: no cover
return fonts
seen = {}
for key in ELEMENT_FOR_FONT:
font = fonts.get(key)
if font is None:
continue
if id(font) not in seen:
seen[id(font)] = key
continue
path, size = getattr(font, "path", None), getattr(font, "size", None)
if not path or not size:
continue
try:
fonts[key] = _IF.truetype(path, size)
except (OSError, ValueError, TypeError):
logger.debug(
"Could not un-share the %s face; it keeps the default colour", key)
return fonts
-261
View File
@@ -1,261 +0,0 @@
"""The scroll/Vegas card geometry the sports scoreboards all share.
Eight scoreboards -- afl, baseball, basketball, football, hockey, lacrosse,
nrl and soccer -- each carried their own ``game_renderer.py``. After the card
helpers moved to ``src/common/sports_card.py`` the settings lookups were
shared, but the *geometry* was not: nine methods that decide how wide the
centre gap is, how much room a logo gets, and where an upcoming card's date
and time land were still eight separate copies. Five were byte-identical
across all eight plugins; the other four were identical in seven, with a
different single outlier each time.
That last detail is why this is a mixin rather than free functions. There is
no per-sport branching to write -- baseball needs its own
``_draw_upcoming_game_status`` and ``_logo_slot_width``, hockey its own
``_upcoming_date_and_time``, football its own ``_score_reserve_width``, and
every one of those is an ordinary override. The seven that agree inherit and
say nothing.
It is deliberately *only* a mixin: no ``__init__``, no state of its own. The
plugins' constructors differ in six ways and none of that difference is worth
unifying, so adoption is one line on the class statement plus deleting the
methods that now come from here.
What a host class must provide
------------------------------
Attributes: ``display_width``, ``display_height``, ``config``, ``fonts``,
``logger``, ``_team_rankings_cache``.
Methods: ``_draw_text_with_outline(draw, text, position, font, fill=None,
outline_color=(0, 0, 0))`` -- the one hook whose body genuinely varies -- plus
the ``sports_card`` delegations ``_scroll_card_option``,
``_upcoming_center_mode``, ``_vs_text``, ``_element_color``,
``_format_game_date`` and ``_format_game_time``.
"""
import math
from typing import ClassVar, Dict, Tuple
from PIL import Image, ImageDraw
class SportsGameRendererMixin:
"""Shared card geometry for the sports scoreboards. See module docstring."""
#: Centre strip as a fraction of card width, before clamping.
CENTER_GAP_RATIO: ClassVar[float] = 0.28
#: Clamp floor for the derived centre gap.
CENTER_GAP_MIN_PX: ClassVar[int] = 22
#: Clamp ceiling for the derived centre gap.
CENTER_GAP_MAX_PX: ClassVar[int] = 40
#: Breathing room kept between the score and each logo.
_SCORE_LOGO_GUTTER_PX: ClassVar[int] = 4
#: Widest score the centre strip must fit. Leagues that can reach three
#: digits a side override this with "000-000".
_SCORE_PROBE: ClassVar[str] = "00-00"
# ---- geometry ------------------------------------------------------
# Non-finite settings are rejected before any int()/round(): "inf" reaches
# these from config as a float or a string, passes an `isinstance` plus
# `>= 0` check unharmed, and then raises OverflowError out of int() --
# which the old `except (TypeError, ValueError)` did not catch, so it
# aborted the whole card render. Present in all eight plugins before this
# moved to the core; fixing it here fixes it in all eight.
def _score_reserve_width(self) -> int:
"""Centre strip the score actually needs, measured rather than assumed.
The gap was derived from the card width alone (width x
CENTER_GAP_RATIO, clamped to CENTER_GAP_MAX_PX) while the score's size
comes from config and the element-style resolver. Nothing compared the
two, so any score wider than the clamp was drawn over the logos.
Measuring it keeps the strip wide enough for whatever font is in play.
"""
try:
probe = ImageDraw.Draw(Image.new("RGB", (4, 4)))
width = probe.textlength(self._SCORE_PROBE, font=self.fonts['score'])
return int(width) + 2 * self._SCORE_LOGO_GUTTER_PX
except Exception:
self.logger.debug("Score reserve measurement failed", exc_info=True)
return 0
def _center_gap_width(self) -> int:
"""Width of the middle strip kept clear of logos.
``scroll_card.center_gap`` pins it outright; otherwise it scales with
the card width between the configurable min and max. 0 restores
edge-to-edge logos.
"""
configured = self._scroll_card_option("center_gap")
if (isinstance(configured, (int, float))
and math.isfinite(configured) and configured >= 0):
return int(configured)
ratio = self._scroll_card_option("center_gap_ratio", self.CENTER_GAP_RATIO)
low = self._scroll_card_option("center_gap_min", self.CENTER_GAP_MIN_PX)
high = self._scroll_card_option("center_gap_max", self.CENTER_GAP_MAX_PX)
try:
ratio, low, high = float(ratio), float(low), float(high)
if not (math.isfinite(ratio) and math.isfinite(low)
and math.isfinite(high)):
return self.CENTER_GAP_MIN_PX
scaled = round(self.display_width * ratio)
derived = int(max(int(low), min(int(high), scaled)))
# A strip narrower than the score is the bug, not a style choice.
# An explicit ``center_gap`` is still honoured above, including 0.
return max(derived, self._score_reserve_width())
except (TypeError, ValueError, OverflowError):
return self.CENTER_GAP_MIN_PX
def _logo_slot_width(self) -> int:
"""Per-side logo slot, leaving the center gap clear.
No longer capped at display_height: the card is sized as two
full-height logos plus the measured gap, so what is left after the gap
is exactly the logo's share. The cap was what froze the logos at 46px
on the old flat 128px card.
"""
available = (self.display_width - self._center_gap_width()) // 2
return max(8, available)
def _logo_cache_key(self, name: str) -> str:
"""Cache key scoped to the logo slot.
One cache dict is shared by renderers built for different card widths,
so a logo sized for a wide slot must not be handed to a narrow one.
"""
return f"{name}@{self._logo_slot_width()}x{self.display_height}"
def _layout_offset(self, element: str, axis: str, default: int = 0) -> int:
"""X/Y nudge for one element, from customization.layout.
Same block the full-screen scorebug reads (sports.py
_get_layout_offset), so a nudge configured in the web UI now moves
the element on the scroll/Vegas card too -- previously the schema
advertised these offsets but this renderer ignored them.
"""
try:
layout = (self.config or {}).get("customization", {}).get("layout", {})
value = (layout.get(element) or {}).get(axis, default)
if isinstance(value, bool):
return default
if isinstance(value, (int, float)):
return int(value) if math.isfinite(value) else default
if isinstance(value, str):
parsed = float(value)
return int(parsed) if math.isfinite(parsed) else default
except (TypeError, ValueError, OverflowError):
pass
return default
# ---- upcoming cards ------------------------------------------------
def _upcoming_date_and_time(self, game: Dict) -> Tuple[str, str]:
"""(date, time) for an upcoming card, from the extractor's flat keys."""
return (
str(game.get("game_date", "") or ""),
str(game.get("game_time", "") or ""),
)
def _draw_upcoming_center(self, draw: "ImageDraw.ImageDraw", game: Dict) -> None:
"""Draw the middle of an upcoming card.
Never a score: an upcoming game has not started, so the extractor's
0-0 is noise. Either the VS text (default), the date and time stacked,
or nothing at all.
"""
mode = self._upcoming_center_mode()
if mode == "none":
return
if mode == "vs":
vs_text = self._vs_text()
if not vs_text:
return
vs_width = draw.textlength(vs_text, font=self.fonts['score'])
vs_x = (self.display_width - vs_width) // 2 + self._layout_offset('score', 'x_offset')
vs_y = (self.display_height // 2) - 3 + self._layout_offset('score', 'y_offset')
self._draw_text_with_outline(
draw, vs_text, (vs_x, vs_y), self.fonts['score'],
fill=self._element_color('score_text')
)
return
date_text, time_text = self._upcoming_date_and_time(game)
lines = []
if self._scroll_card_option("show_date", True):
lines.append(self._format_game_date(date_text, game))
if self._scroll_card_option("show_time", True):
lines.append(self._format_game_time(time_text))
lines = [t for t in lines if t]
if not lines:
return
font = self.fonts.get('detail') or self.fonts['time']
line_h = 7
top = (self.display_height // 2) - (len(lines) * line_h) // 2
top += self._layout_offset('score', 'y_offset')
for i, line in enumerate(lines):
width = draw.textlength(line, font=font)
x = (self.display_width - width) // 2 + self._layout_offset('score', 'x_offset')
self._draw_text_with_outline(
draw, line, (x, top + i * line_h), font,
fill=self._element_color('detail_text')
)
def _draw_upcoming_game_status(self, draw: "ImageDraw.ImageDraw", game: Dict) -> None:
"""Draw the date and time around an upcoming card.
Time top and date bottom by default; scroll_card.swap_date_time puts
the date on top instead. Skipped when the pair is stacked in the
middle, which would otherwise print them twice.
"""
if self._upcoming_center_mode() == "date_time":
return
date_raw, time_raw = self._upcoming_date_and_time(game)
date_text = (self._format_game_date(date_raw, game)
if self._scroll_card_option("show_date", True) else "")
time_text = (self._format_game_time(time_raw)
if self._scroll_card_option("show_time", True) else "")
if self._scroll_card_option("swap_date_time", False):
top_text, top_el, bottom_text, bottom_el = (
date_text, 'date', time_text, 'time')
top_font = self.fonts.get('detail') or self.fonts['time']
bottom_font = self.fonts['time']
top_color, bottom_color = 'detail_text', 'period_text'
else:
top_text, top_el, bottom_text, bottom_el = (
time_text, 'time', date_text, 'date')
top_font = self.fonts['time']
bottom_font = self.fonts.get('detail') or self.fonts['time']
top_color, bottom_color = 'period_text', 'detail_text'
if top_text:
top_width = draw.textlength(top_text, font=top_font)
top_x = (self.display_width - top_width) // 2 + self._layout_offset(top_el, 'x_offset')
top_y = 1 + self._layout_offset(top_el, 'y_offset')
self._draw_text_with_outline(
draw, top_text, (top_x, top_y), top_font,
fill=self._element_color(top_color)
)
if bottom_text:
bottom_width = draw.textlength(bottom_text, font=bottom_font)
bottom_x = ((self.display_width - bottom_width) // 2
+ self._layout_offset(bottom_el, 'x_offset'))
# Measured, not a fixed -7: the detail font is 6px in most plugins
# but 10px in soccer and nrl, where "Sep 19" ran past the card.
ink_bottom = draw.textbbox((0, 0), bottom_text, font=bottom_font)[3]
bottom_y = (max(0, self.display_height - ink_bottom - 1)
+ self._layout_offset(bottom_el, 'y_offset'))
self._draw_text_with_outline(
draw, bottom_text, (bottom_x, bottom_y), bottom_font,
fill=self._element_color(bottom_color)
)
# ---- rankings ------------------------------------------------------
def set_rankings_cache(self, rankings: Dict[str, int]) -> None:
"""Set the team rankings cache for display."""
self._team_rankings_cache = rankings
File diff suppressed because it is too large Load Diff
-6
View File
@@ -364,7 +364,6 @@ class DisplayManager:
# Create image with the (logical) display dimensions # Create image with the (logical) display dimensions
self.image = Image.new('RGB', (self.matrix.width, self.matrix.height)) self.image = Image.new('RGB', (self.matrix.width, self.matrix.height))
self.draw = ImageDraw.Draw(self.image) self.draw = ImageDraw.Draw(self.image)
self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs.
logger.info(f"Image canvas created with dimensions: {self.matrix.width}x{self.matrix.height}") logger.info(f"Image canvas created with dimensions: {self.matrix.width}x{self.matrix.height}")
# Initialize font with Press Start 2P # Initialize font with Press Start 2P
@@ -404,7 +403,6 @@ class DisplayManager:
self.image = Image.new('RGB', (fallback_width, fallback_height)) self.image = Image.new('RGB', (fallback_width, fallback_height))
self.draw = ImageDraw.Draw(self.image) self.draw = ImageDraw.Draw(self.image)
self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs.
# Simple fallback visualization so web UI shows a realistic canvas # Simple fallback visualization so web UI shows a realistic canvas
try: try:
self.draw.rectangle([0, 0, fallback_width - 1, fallback_height - 1], outline=(255, 0, 0)) self.draw.rectangle([0, 0, fallback_width - 1, fallback_height - 1], outline=(255, 0, 0))
@@ -707,7 +705,6 @@ class DisplayManager:
# self.image, so swapping the buffer below is enough on its own. # self.image, so swapping the buffer below is enough on its own.
self.image = Image.new('RGB', (target_w, target_h)) self.image = Image.new('RGB', (target_w, target_h))
self.draw = ImageDraw.Draw(self.image) self.draw = ImageDraw.Draw(self.image)
self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs.
yield yield
finally: finally:
self.matrix = real_matrix self.matrix = real_matrix
@@ -817,7 +814,6 @@ class DisplayManager:
self.image = Image.new('RGB', (width, height)) self.image = Image.new('RGB', (width, height))
self.draw = ImageDraw.Draw(self.image) self.draw = ImageDraw.Draw(self.image)
self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs.
logger.debug("Cleared display in fallback mode") logger.debug("Cleared display in fallback mode")
return return
@@ -829,7 +825,6 @@ class DisplayManager:
# Create a new black image # Create a new black image
self.image = Image.new('RGB', (self.matrix.width, self.matrix.height)) self.image = Image.new('RGB', (self.matrix.width, self.matrix.height))
self.draw = ImageDraw.Draw(self.image) self.draw = ImageDraw.Draw(self.image)
self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs.
if not self._capture_mode_active: if not self._capture_mode_active:
# Clear both canvases and the underlying matrix to ensure no artifacts. # Clear both canvases and the underlying matrix to ensure no artifacts.
@@ -1263,7 +1258,6 @@ class DisplayManager:
try: try:
self.image = Image.new('RGB', (self.width, self.height)) self.image = Image.new('RGB', (self.width, self.height))
self.draw = ImageDraw.Draw(self.image) self.draw = ImageDraw.Draw(self.image)
self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs.
except (OSError, RuntimeError, ValueError, MemoryError): except (OSError, RuntimeError, ValueError, MemoryError):
logger.debug("Canvas reset during cleanup failed", exc_info=True) logger.debug("Canvas reset during cleanup failed", exc_info=True)
# Reset the singleton state when cleaning up # Reset the singleton state when cleaning up
+6 -114
View File
@@ -14,7 +14,6 @@ import json
from typing import Dict, List, Optional, Tuple from typing import Dict, List, Optional, Tuple
from pathlib import Path from pathlib import Path
from PIL import Image, ImageDraw, ImageFont from PIL import Image, ImageDraw, ImageFont
from PIL.PngImagePlugin import PngInfo
from requests.adapters import HTTPAdapter from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry from urllib3.util.retry import Retry
from src.common.permission_utils import ( from src.common.permission_utils import (
@@ -26,96 +25,6 @@ from src.common.permission_utils import (
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
#: PNG text key stamped into a generated placeholder so a later run can tell it
#: apart from a real logo that happens to be small.
PLACEHOLDER_MARKER = "ledmatrix_placeholder"
#: Geometry of a generated placeholder, used to recognise ones written before
#: the marker existed. Those are already on users' disks and would otherwise
#: never be retried.
PLACEHOLDER_SIZE = (64, 64)
PLACEHOLDER_BG = (100, 100, 100, 255)
#: How long a placeholder is trusted before the real logo is attempted again.
#: A placeholder means the download failed, and download failures are usually
#: transient (no network at boot, ESPN blipping). Retrying every frame would
#: hammer the API from a Pi that is also driving a panel; never retrying leaves
#: the team a grey box forever, which is the bug this exists to avoid.
PLACEHOLDER_RETRY_SECONDS = 6 * 60 * 60
def is_placeholder_logo(filepath: Path) -> bool:
"""True if the file at ``filepath`` is a generated placeholder, not a logo.
Checks the marker first, then falls back to matching the placeholder's
exact geometry and background colour so files written before the marker was
introduced are still recognised.
"""
try:
with Image.open(filepath) as img:
if img.info.get(PLACEHOLDER_MARKER):
return True
if img.size != PLACEHOLDER_SIZE:
return False
return img.convert("RGBA").getpixel((0, 0)) == PLACEHOLDER_BG
except Exception:
# Unreadable file: not provably a placeholder, and the caller's own
# error handling is better placed to deal with it.
return False
def should_attempt_download(filepath: Path, force_download: bool = False) -> bool:
"""Whether a real logo is worth (re)fetching for ``filepath``.
True when nothing is there, when the caller forced it, or when what is
there is a placeholder old enough to retry. A *fresh* placeholder says a
download just failed, so retrying it immediately would hammer the API for
a result that is very unlikely to have changed.
"""
if force_download or not filepath.exists():
return True
if not is_placeholder_logo(filepath):
return False
age = placeholder_age_seconds(filepath)
return age is None or age >= PLACEHOLDER_RETRY_SECONDS
def refresh_placeholder_timestamp(filepath: Path) -> bool:
"""Restamp a placeholder so a failed retry restarts the back-off clock.
Without this a stale placeholder stays stale: every later call sees an
expired timestamp, retries, fails, and leaves the timestamp untouched --
which is a download attempt per call, the opposite of what the back-off is
for.
"""
try:
if not is_placeholder_logo(filepath):
return False
metadata = PngInfo()
metadata.add_text(PLACEHOLDER_MARKER, str(time.time()))
with Image.open(filepath) as img:
img.copy().save(filepath, "PNG", pnginfo=metadata)
return True
except Exception:
logger.debug("Could not refresh placeholder timestamp for %s", filepath,
exc_info=True)
return False
def placeholder_age_seconds(filepath: Path) -> Optional[float]:
"""Seconds since a placeholder was written, or None if unknown."""
try:
with Image.open(filepath) as img:
stamped = img.info.get(PLACEHOLDER_MARKER)
if stamped and stamped != "1":
return max(0.0, time.time() - float(stamped))
except Exception:
pass
try:
return max(0.0, time.time() - filepath.stat().st_mtime)
except OSError:
return None
class LogoDownloader: class LogoDownloader:
"""Centralized logo downloader for team logos from ESPN API.""" """Centralized logo downloader for team logos from ESPN API."""
@@ -590,10 +499,8 @@ class LogoDownloader:
filename = f"{self.normalize_abbreviation(abbreviation)}.png" filename = f"{self.normalize_abbreviation(abbreviation)}.png"
filepath = Path(logo_dir) / filename filepath = Path(logo_dir) / filename
# A placeholder does not count as existing -- it is a previous # Skip if already exists and not forcing download
# failure, and a bulk pass is exactly where it should get another if filepath.exists() and not force_download:
# chance, subject to the same back-off as everywhere else.
if not should_attempt_download(filepath, force_download):
logger.debug(f"Skipping {display_name}: {filename} already exists") logger.debug(f"Skipping {display_name}: {filename} already exists")
continue continue
@@ -652,9 +559,8 @@ class LogoDownloader:
filename = f"{self.normalize_abbreviation(abbreviation)}.png" filename = f"{self.normalize_abbreviation(abbreviation)}.png"
filepath = Path(logo_dir) / filename filepath = Path(logo_dir) / filename
# Same eligibility rule as every other download site: a stale # Skip if already exists and not forcing download
# placeholder is a failed download, not a logo. if filepath.exists() and not force_download:
if not should_attempt_download(filepath, force_download):
logger.debug(f"Skipping {display_name} ({category}, {conference}): {filename} already exists") logger.debug(f"Skipping {display_name} ({category}, {conference}): {filename} already exists")
continue continue
@@ -768,12 +674,7 @@ class LogoDownloader:
# Fallback without font # Fallback without font
draw.text((16, 24), text, fill=(255, 255, 255, 255)) draw.text((16, 24), text, fill=(255, 255, 255, 255))
# Stamp it so a later run can tell this apart from a real logo and logo.save(filepath)
# retry the download, instead of treating the file's existence as
# proof the logo was fetched.
metadata = PngInfo()
metadata.add_text(PLACEHOLDER_MARKER, str(time.time()))
logo.save(filepath, "PNG", pnginfo=metadata)
# Set proper file permissions after saving # Set proper file permissions after saving
ensure_file_permissions(filepath, get_assets_file_mode()) ensure_file_permissions(filepath, get_assets_file_mode())
@@ -869,18 +770,9 @@ def download_missing_logo(league: str, team_id: str, team_abbreviation: str, log
# Use the exact filepath that was passed in (respects config settings) # Use the exact filepath that was passed in (respects config settings)
filepath = logo_path filepath = logo_path
if filepath.exists() and not should_attempt_download(filepath): if filepath.exists():
# Either a real logo, or a placeholder too fresh to be worth retrying.
logger.debug(f"Logo already exists for {team_abbreviation} ({league})") logger.debug(f"Logo already exists for {team_abbreviation} ({league})")
return True return True
if filepath.exists():
# A placeholder is a *failed* download wearing the real logo's
# filename. Treating it as "already exists" is what pinned a team to a
# grey box permanently after one transient failure.
logger.info(
"Logo for %s (%s) is a placeholder from a failed download; "
"retrying the real logo", team_abbreviation, league,
)
# Try to download the real logo first # Try to download the real logo first
logger.info(f"Attempting to download logo for {team_abbreviation} from {league}") logger.info(f"Attempting to download logo for {team_abbreviation} from {league}")
-40
View File
@@ -28,7 +28,6 @@ fixes their version string. See `docs/SPORTS_UNIFICATION.md`, phase B4.
from __future__ import annotations from __future__ import annotations
import re import re
from pathlib import Path
from typing import Any, Dict, Optional, Tuple from typing import Any, Dict, Optional, Tuple
# Below this, the core's self-reported version is not evidence of anything. # Below this, the core's self-reported version is not evidence of anything.
@@ -36,45 +35,6 @@ from typing import Any, Dict, Optional, Tuple
TRUSTWORTHY_FLOOR: Tuple[int, int, int] = (2, 0, 0) TRUSTWORTHY_FLOOR: Tuple[int, int, int] = (2, 0, 0)
# ``src/__init__.py`` relative to this file: src/plugin_system/ -> src/
_VERSION_FILE = Path(__file__).resolve().parent.parent / "__init__.py"
_VERSION_RE = re.compile(r'^__version__\s*=\s*["\']([^"\']+)["\']', re.M)
def current_core_version() -> str:
"""The core version as it is on disk right now, not as it was at import.
``from src import __version__`` binds whatever the process loaded at start.
The web UI runs as its own long-lived service (``ledmatrix-web.service``),
and updating the core replaces files on disk without restarting it -- the
update route says so explicitly and asks the user to restart. Its prompt
names the *display* service, so a user who follows it leaves the web
process holding the old number.
The plugin store's gate lives in that web process. Stale by one release is
exactly the case that matters: every plugin flooring on the release you
just installed gets refused, with a message blaming a core version that is
already correct on disk. 3.3.0 is the first release where that hits a whole
plugin family at once -- all eight sports scoreboards floor there.
Reading the file costs one stat and a small read per call, and only on the
install/update path. Any failure falls back to the imported value, so this
can only ever be as wrong as before, never worse.
"""
try:
text = _VERSION_FILE.read_text(encoding="utf-8")
match = _VERSION_RE.search(text)
if match:
return match.group(1)
except (OSError, UnicodeDecodeError):
pass
try:
from src import __version__ as imported
return imported
except Exception: # noqa: BLE001 - never let this raise
return "0.0.0"
def parse_semver(value: Any) -> Optional[Tuple[int, int, int]]: def parse_semver(value: Any) -> Optional[Tuple[int, int, int]]:
"""Parse ``X.Y.Z`` (extra parts and suffixes ignored) into a comparable """Parse ``X.Y.Z`` (extra parts and suffixes ignored) into a comparable
3-tuple, or ``None`` when unparseable. A leading ``v`` is tolerated.""" 3-tuple, or ``None`` when unparseable. A leading ``v`` is tolerated."""
+1 -1
View File
@@ -772,8 +772,8 @@ class PluginLoader:
newer than the running core. Advisory only — never raises — so a newer than the running core. Advisory only — never raises — so a
plugin that guards optional features with try/except keeps working. plugin that guards optional features with try/except keeps working.
""" """
from src import __version__ as core_version
from src.plugin_system import compatibility from src.plugin_system import compatibility
core_version = compatibility.current_core_version()
compatible, _reason = compatibility.check(manifest, core_version) compatible, _reason = compatibility.check(manifest, core_version)
if compatible: if compatible:
+2 -25
View File
@@ -1467,11 +1467,8 @@ class PluginStoreManager:
# already had. Allowing it costs them a plugin that raises # already had. Allowing it costs them a plugin that raises
# ModuleNotFoundError at load and is reported only as one line # ModuleNotFoundError at load and is reported only as one line
# in the journal. See docs/SPORTS_UNIFICATION.md (phase B4/B6). # in the journal. See docs/SPORTS_UNIFICATION.md (phase B4/B6).
from src import __version__ as core_version
from src.plugin_system import compatibility from src.plugin_system import compatibility
# On disk, not as imported: this process may predate the core
# update that made the plugin compatible. See
# compatibility.current_core_version.
core_version = compatibility.current_core_version()
compatible, reason = compatibility.check(manifest, core_version) compatible, reason = compatibility.check(manifest, core_version)
if not compatible: if not compatible:
@@ -1605,26 +1602,6 @@ class PluginStoreManager:
'error': f'Manifest missing required fields: {", ".join(missing_fields)}' 'error': f'Manifest missing required fields: {", ".join(missing_fields)}'
} }
# Refuse a plugin that needs a newer core than this one, exactly as
# _install_plugin_impl does after its download. Sideloading is an
# explicit act rather than an automatic store update, but the floor
# is not advice about intent -- it is a statement that the plugin
# cannot run here, and letting it through produces the same silent
# PluginState.ERROR at load. This was the last of the three routes
# in that skipped the check.
#
# Before the move, so the `finally` below removes the temp tree and
# nothing half-installed is left behind.
from src.plugin_system import compatibility
core_version = compatibility.current_core_version()
compatible, reason = compatibility.check(manifest, core_version)
if not compatible:
self.logger.error(
"Refusing to install %s from %s: %s",
plugin_id, repo_url, reason)
return {'success': False, 'error': reason}
# Validate version fields consistency (warnings only, not required) # Validate version fields consistency (warnings only, not required)
validation_errors = self._validate_manifest_version_fields(manifest) validation_errors = self._validate_manifest_version_fields(manifest)
if validation_errors: if validation_errors:
@@ -2647,8 +2624,8 @@ class PluginStoreManager:
manifest_path, plugin_id, e) manifest_path, plugin_id, e)
return True return True
from src import __version__ as core_version
from src.plugin_system import compatibility from src.plugin_system import compatibility
core_version = compatibility.current_core_version()
compatible, reason = compatibility.check(manifest, core_version) compatible, reason = compatibility.check(manifest, core_version)
if compatible: if compatible:
@@ -70,7 +70,6 @@ class VisualTestDisplayManager:
# Canvas # Canvas
self.image = Image.new('RGB', (width, height), (0, 0, 0)) self.image = Image.new('RGB', (width, height), (0, 0, 0))
self.draw = ImageDraw.Draw(self.image) self.draw = ImageDraw.Draw(self.image)
self.draw.fontmode = "1" # Match production: 1-bit text, so goldens show what the panel shows.
# Matrix proxy (plugins access display_manager.matrix.width/height) # Matrix proxy (plugins access display_manager.matrix.width/height)
self.matrix = _MatrixProxy(width, height) self.matrix = _MatrixProxy(width, height)
@@ -185,7 +184,6 @@ class VisualTestDisplayManager:
self.clear_called = True self.clear_called = True
self.image = Image.new('RGB', (self._width, self._height), (0, 0, 0)) self.image = Image.new('RGB', (self._width, self._height), (0, 0, 0))
self.draw = ImageDraw.Draw(self.image) self.draw = ImageDraw.Draw(self.image)
self.draw.fontmode = "1" # Match production: 1-bit text, so goldens show what the panel shows.
def update_display(self): def update_display(self):
"""No-op for hardware; marks that display was updated.""" """No-op for hardware; marks that display was updated."""
@@ -213,7 +211,6 @@ class VisualTestDisplayManager:
self.matrix = _MatrixProxy(target_w, target_h) self.matrix = _MatrixProxy(target_w, target_h)
self.image = Image.new('RGB', (target_w, target_h), (0, 0, 0)) self.image = Image.new('RGB', (target_w, target_h), (0, 0, 0))
self.draw = ImageDraw.Draw(self.image) self.draw = ImageDraw.Draw(self.image)
self.draw.fontmode = "1" # Match production: 1-bit text, so goldens show what the panel shows.
yield yield
finally: finally:
self._width, self._height = prev_w, prev_h self._width, self._height = prev_w, prev_h
@@ -572,7 +569,6 @@ class VisualTestDisplayManager:
self.draw_calls = [] self.draw_calls = []
self.image = Image.new('RGB', (self._width, self._height), (0, 0, 0)) self.image = Image.new('RGB', (self._width, self._height), (0, 0, 0))
self.draw = ImageDraw.Draw(self.image) self.draw = ImageDraw.Draw(self.image)
self.draw.fontmode = "1" # Match production: 1-bit text, so goldens show what the panel shows.
self._scrolling_state = { self._scrolling_state = {
'is_scrolling': False, 'is_scrolling': False,
'last_scroll_activity': 0, 'last_scroll_activity': 0,
@@ -586,4 +582,3 @@ class VisualTestDisplayManager:
"""Clean up resources.""" """Clean up resources."""
self.image = Image.new('RGB', (self._width, self._height), (0, 0, 0)) self.image = Image.new('RGB', (self._width, self._height), (0, 0, 0))
self.draw = ImageDraw.Draw(self.image) self.draw = ImageDraw.Draw(self.image)
self.draw.fontmode = "1" # Match production: 1-bit text, so goldens show what the panel shows.
-52
View File
@@ -1,52 +0,0 @@
"""web_interface/app.py resolves plugins_dir from config at import time.
`project_root` used to be assigned only inside the `else` branch (the
relative-path case). SchemaManager and composer_bp are wired up with
`project_root` further down the same module, so a device configured with an
*absolute* `plugin_system.plugins_directory` (a supported value -- see
CLAUDE.md) hit `UnboundLocalError` importing web_interface.app at all, taking
the whole web UI down.
app.py isn't imported directly by the test suite -- it has real side effects
(managers, background state) at import time -- so this extracts the exact
resolution block by its stable comment markers and executes it in isolation,
the same way the rest of the suite validates generated/templated code rather
than re-implementing it.
"""
from pathlib import Path
APP_PY = Path(__file__).resolve().parent.parent / "web_interface/app.py"
START_MARKER = "# Resolve plugin directory - handle both absolute and relative paths\n"
END_MARKER = "\nplugin_manager = PluginManager("
def _resolution_block() -> str:
src = APP_PY.read_text()
start = src.index(START_MARKER)
end = src.index(END_MARKER, start)
return src[start:end]
def test_resolution_block_still_matches_expected_markers():
"""If app.py is restructured enough that these markers move, the exec
below would silently test nothing -- fail loudly instead."""
block = _resolution_block()
assert "plugins_dir" in block and "os.path.isabs" in block
def test_absolute_plugins_directory_does_not_raise_unboundlocalerror():
import os
ns = {"os": os, "Path": Path, "__file__": str(APP_PY),
"plugins_dir_name": "/opt/ledmatrix-plugins"}
exec(compile(_resolution_block(), str(APP_PY), "exec"), ns) # noqa: S102
assert ns["project_root"] == APP_PY.parent.parent
assert ns["plugins_dir"] == Path("/opt/ledmatrix-plugins")
def test_relative_plugins_directory_still_resolves_under_project_root():
import os
ns = {"os": os, "Path": Path, "__file__": str(APP_PY),
"plugins_dir_name": "plugin-repos"}
exec(compile(_resolution_block(), str(APP_PY), "exec"), ns) # noqa: S102
assert ns["plugins_dir"] == ns["project_root"] / "plugin-repos"
@@ -1,64 +0,0 @@
"""composer_bp must actually be registered on the Flask app.
web_interface/blueprints/composer.py defines a full blueprint -- the
/composer/ page, /composer/api/generate, /composer/api/install, etc. -- but
nothing in web_interface/app.py imported or registered it. pages_v3 and
api_v3 both go through the same "import, wire managers, register_blueprint"
sequence; composer_bp had none of the three. The routes existed and worked
in isolation (every other composer test builds its own minimal Flask app
and registers composer_bp directly), but in the real running app every
composer URL -- including the page itself -- was a 404, and the frontend's
hardcoded '/composer/api/...' fetches (composer-app.js) had nothing to
reach.
app.py has import-time side effects (it loads config, builds managers, and
in some configurations starts background threads), so this scans the source
text rather than importing the module -- the same approach already used for
test_composer_js_contracts.py's structural checks.
"""
import re
from pathlib import Path
APP_PY = Path(__file__).resolve().parent.parent / "web_interface" / "app.py"
def _source() -> str:
return APP_PY.read_text()
def test_composer_blueprint_is_imported():
src = _source()
assert re.search(
r"from web_interface\.blueprints\.composer import composer_bp", src
), "app.py never imports composer_bp"
def test_composer_blueprint_is_registered_under_composer_prefix():
src = _source()
match = re.search(
r"app\.register_blueprint\(\s*composer_bp\s*,\s*url_prefix\s*=\s*"
r"['\"](?P<prefix>[^'\"]*)['\"]",
src,
)
assert match, "app.py never calls app.register_blueprint(composer_bp, ...)"
# composer.html and composer-app.js hardcode '/composer/api/...' and
# '/composer/api/fonts/...' -- any other prefix serves a page whose own
# fetches and @font-face all 404.
assert match.group("prefix") == "/composer"
def test_composer_blueprint_managers_are_wired_before_registration():
"""Registered with no managers set means every route 503s or crashes.
composer.py initialises config_manager/plugin_manager/plugins_dir/
project_root to None at import time specifically so app.py can set them
-- the same pattern api_v3 and pages_v3 use.
"""
src = _source()
register_match = re.search(r"app\.register_blueprint\(\s*composer_bp", src)
assert register_match, "app.py never registers composer_bp"
before_registration = src[: register_match.start()]
for attr in ("config_manager", "plugin_manager", "plugins_dir", "project_root"):
assert re.search(rf"composer_bp\.{attr}\s*=", before_registration), (
f"composer_bp.{attr} is never set before registration"
)
-323
View File
@@ -1,323 +0,0 @@
"""The composer generates Python that the plugin loader imports and executes.
/api/install writes the generated manager.py into plugins_dir and the loader
imports it, so anything the payload can splice into that source runs on the
device. The ast.parse check in _generate_plugin_files rejects only *invalid*
syntax -- an injected `import os` is perfectly valid and passed it.
Two ways in, both confirmed against the code before it was fixed:
metadata.name = a name containing a triple-quote, a newline, then
`import os; PWNED = os.getuid()`, then another triple-quote
-> closes the module docstring; the rest became module-level statements
(spelled out rather than shown literally -- writing the payload into
this docstring closes *this* file's docstring, which is the bug)
element x = '0 or __import__("os").system("id")'
-> f-string interpolated it verbatim: x=0 or __import__("os").system("id")
"""
import ast
import re
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from web_interface.blueprints import composer as C # noqa: E402
BASE_META = {"id": "test-plugin", "name": "Clock", "author": "a",
"version": "1.0.0", "description": "d"}
#: Values that terminate a Python expression and start a new statement.
EXPR_PAYLOADS = [
'0 or __import__("os").system("id")',
'0);import os;os.system("id");(',
'__import__("subprocess").run(["id"])',
"0 if False else exec('x=1')",
"1e999", "nan", "0x41", "0__0",
]
#: Values that close a string literal in the generated source.
LITERAL_PAYLOADS = [
'Clock"""\nimport os; PWNED = os.getuid()\n"""',
"Clock'''\nimport os\n'''",
'Clock" + __import__("os").system("id") + "',
"Clock\\", "Clock\nimport os",
]
def _payload(**over):
# dataModel.configVars is the key _generate_plugin_files reads; "config_vars"
# was never looked at, so anything passed through it tested nothing.
p = {"metadata": dict(BASE_META), "elements": [],
"dataModel": {"configVars": over.pop("config_vars", [])}}
p["metadata"].update(over.pop("metadata", {}))
p.update(over)
return p
def _generated(payload):
return C._generate_plugin_files(payload)["manager.py"]
def _module_level_code(src):
"""Statements at module level that are not the docstring/imports/classes."""
tree = ast.parse(src)
out = []
for node in tree.body:
if isinstance(node, (ast.ClassDef, ast.FunctionDef, ast.ImportFrom, ast.Import)):
continue
if isinstance(node, ast.Expr) and isinstance(node.value, ast.Constant):
continue # the docstring
out.append(ast.unparse(node))
return out
@pytest.mark.parametrize("payload", LITERAL_PAYLOADS)
def test_a_name_that_breaks_out_of_a_literal_is_refused(payload):
with pytest.raises(C.ComposerInputError):
_generated(_payload(metadata={"name": payload}))
#: Types with a drawing branch in manager.py.j2. An injection test using any
#: other type proves nothing: _preprocess_elements drops it, so its values
#: never reach the generated source and every assertion passes trivially.
#: This test previously used "line", which has never had a branch.
RENDERED_GEOMETRY_CASES = [
("rectangle", {"x": 0, "y": 0, "width": 10, "height": 8}),
("arc", {"x": 0, "y": 0, "width": 24, "height": 24}),
("ellipse", {"x": 0, "y": 0, "width": 24, "height": 12}),
("rounded_rectangle", {"x": 0, "y": 0, "width": 24, "height": 10}),
("gauge", {"x": 0, "y": 0, "width": 32, "height": 32}),
]
@pytest.mark.parametrize("etype,base", RENDERED_GEOMETRY_CASES)
@pytest.mark.parametrize("evil", EXPR_PAYLOADS)
@pytest.mark.parametrize("field", ["x", "y", "width", "height"])
def test_a_non_numeric_geometry_value_cannot_reach_the_source(etype, base, evil, field):
"""width/height were interpolated raw into the generated source.
p['x2_expr'] = f"({x_expr}) + {w}" with w straight off the payload, so a
rectangle with width='0 or __import__("os").system("id")' produced
[0, 0, (0) + 0 or __import__("os").system("id"), (0) + 8],
in a manager.py that /api/install writes to disk and the loader imports.
"""
el = {"type": etype, "id": "e1", **base}
el[field] = evil
src = _generated(_payload(elements=[el]))
assert "__import__" not in src, f"{etype}.{field}={evil!r} reached the generated source"
assert "os.system" not in src
assert not _module_level_code(src), \
f"{etype}.{field}={evil!r} produced module-level statements: {_module_level_code(src)}"
def test_every_injection_case_uses_a_type_that_actually_renders():
"""Guards against the whole suite quietly going vacuous again.
An element type with no template branch is dropped before generation, so
an injection test written against one asserts nothing and still passes.
"""
used = {etype for etype, _ in RENDERED_GEOMETRY_CASES}
missing = used - set(C._RENDERABLE_ELEMENT_TYPES)
assert not missing, f"injection tests use non-rendering types: {sorted(missing)}"
@pytest.mark.parametrize("evil", EXPR_PAYLOADS)
@pytest.mark.parametrize("channel", ["r", "g", "b"])
def test_a_non_numeric_colour_channel_cannot_reach_the_source(evil, channel):
el = {"type": "text", "id": "t1", "x": 0, "y": 0, "text": "hi",
"font": "press_start", "r": 255, "g": 255, "b": 255}
el[channel] = evil
src = _generated(_payload(elements=[el]))
assert "__import__" not in src and "os.system" not in src
assert not _module_level_code(src)
def test_colour_channels_are_clamped_to_a_byte():
el = {"type": "text", "id": "t1", "x": 0, "y": 0, "text": "hi",
"font": "press_start", "r": 99999, "g": -5, "b": 128}
src = _generated(_payload(elements=[el]))
assert "(255, 0, 128)" in src, "channels were not clamped to 0-255"
#: fillR/fillG/fillB (and outR/G/B, bgR/G/B, trackR/G/B) go through
#: _as_fill_filter -> _as_rgb_filter, a separate path from _rgb_expr above.
#: It used raw int() until it was found to raise ValueError on a non-numeric
#: channel instead of clamping like every other coerced value in this module.
@pytest.mark.parametrize("evil", EXPR_PAYLOADS)
@pytest.mark.parametrize("channel", ["fillR", "fillG", "fillB"])
def test_a_non_numeric_fill_channel_cannot_reach_the_source(evil, channel):
el = {"type": "rectangle", "id": "r1", "x": 0, "y": 0, "width": 10, "height": 8,
"fillR": 0, "fillG": 0, "fillB": 128}
el[channel] = evil
src = _generated(_payload(elements=[el]))
assert "__import__" not in src and "os.system" not in src
assert not _module_level_code(src)
def test_fill_channels_are_clamped_to_a_byte():
el = {"type": "rectangle", "id": "r1", "x": 0, "y": 0, "width": 10, "height": 8,
"fillR": 99999, "fillG": -5, "fillB": 128}
src = _generated(_payload(elements=[el]))
assert "(255, 0, 128)" in src, "fill channels were not clamped to 0-255"
def test_the_generated_module_still_has_no_top_level_statements():
"""The clean case: a normal payload produces only imports and a class."""
el = {"type": "text", "id": "t1", "x": 4, "y": 4, "text": "hi",
"font": "press_start", "r": 1, "g": 2, "b": 3}
src = _generated(_payload(elements=[el]))
assert not _module_level_code(src)
assert "(1, 2, 3)" in src
# --- config variable keys ---------------------------------------------------
def _with_key(key):
return {"metadata": dict(BASE_META), "elements": [],
"dataModel": {"configVars": [{"key": key, "type": "string",
"default": "x", "label": "L"}]}}
@pytest.mark.parametrize("key", ["class", "def", "import", "None", "True",
"lambda", "pass", "match", "case"])
def test_a_keyword_config_key_is_named_in_the_error(key):
"""ast.parse already rejected these, but as an unhelpful line number.
"Generated code has a syntax error: invalid syntax (line 17)" tells the
user nothing about which field to fix.
"""
with pytest.raises(C.ComposerInputError) as exc:
_generated(_with_key(key))
assert key in str(exc.value) and "keyword" in str(exc.value).lower()
@pytest.mark.parametrize("key", ["config", "logger", "display_manager",
"cache_manager", "plugin_id", "enabled",
"self", "update", "display"])
def test_a_reserved_attribute_config_key_is_refused(key):
"""These generate *valid* Python that silently clobbers plugin state.
The worst is `config`: the assignment lands right after super().__init__(),
so `self.config = config.get("config", "x")` replaces the plugin's config
dict with a string and every later self.config.get(...) fails at runtime.
"""
with pytest.raises(C.ComposerInputError) as exc:
_generated(_with_key(key))
assert key in str(exc.value) and "reserved" in str(exc.value).lower()
@pytest.mark.parametrize("key", ["brightness", "my_var", "_private", "x1",
"update_interval_seconds"])
def test_ordinary_config_keys_are_still_accepted(key):
src = _generated(_with_key(key))
assert f"self.{key} = config.get(" in src
def test_the_generated_config_assignment_does_not_precede_super_init():
"""Guards the reasoning behind the reserved list, not just the list."""
src = _generated(_with_key("brightness"))
body = src.splitlines()
super_at = next(i for i, line in enumerate(body) if "super().__init__(" in line)
assign_at = next(i for i, line in enumerate(body)
if "self.brightness = config.get(" in line)
assert assign_at > super_at, (
"config vars are assigned before super().__init__(); the reserved-name "
"list assumes they land after it")
# --- optional keys ----------------------------------------------------------
@pytest.mark.parametrize("el_type,missing", [
("text", "text"), ("text", "text2"), ("clock", "format"),
])
def test_an_element_missing_an_optional_key_does_not_500(el_type, missing):
"""`p` is a copy of the raw element, so an absent key stays absent.
The defaults were applied to locals only, so manager.py.j2 rendered
`{{ el.text | tojson }}` over a jinja2.Undefined and tojson raised
TypeError -- which no handler catches, making a missing key a 500 rather
than a validation error or a sensible default.
"""
el = {"type": el_type, "id": "e1", "x": 0, "y": 0, "font": "press_start"}
src = _generated(_payload(elements=[el]))
ast.parse(src) # must still be valid Python
assert "Undefined" not in src
def test_a_clock_without_a_format_uses_the_documented_default():
el = {"type": "clock", "id": "c1", "x": 0, "y": 0, "font": "press_start"}
src = _generated(_payload(elements=[el]))
assert '"%H:%M"' in src, "the %H:%M default did not reach the generated source"
#: (element type, channel key, base element) for colour channels that were
#: interpolated raw rather than through _rgb_expr/_safe_int. Prefixed channels
#: (emptyR/G/B, labelR/G/B) were the ones the original r/g/b test never reached.
RAW_COLOUR_CASES = [
("progress_bar", "r", {"x": 0, "y": 0}),
("progress_bar", "g", {"x": 0, "y": 0}),
("pips", "b", {"x": 0, "y": 0}),
("pips", "emptyR", {"x": 0, "y": 0}),
("pips", "emptyG", {"x": 0, "y": 0}),
("sparkline", "r", {"x": 0, "y": 0}),
("gauge", "labelR", {"x": 0, "y": 0, "width": 32, "height": 32}),
("gauge", "labelB", {"x": 0, "y": 0, "width": 32, "height": 32}),
]
@pytest.mark.parametrize("etype,channel,base", RAW_COLOUR_CASES)
@pytest.mark.parametrize("evil", EXPR_PAYLOADS)
def test_a_prefixed_colour_channel_cannot_reach_the_source(etype, channel, base, evil):
"""Five tuples were built with f"({el.get('r', 100)}, ...)" -- no coercion.
The pre-existing colour test only covered r/g/b on a text element, so the
prefixed channels and the four other types were never exercised.
"""
el = {"type": etype, "id": "e1", **base}
el[channel] = evil
src = _generated(_payload(elements=[el]))
assert "__import__" not in src, f"{etype}.{channel}={evil!r} reached the source"
assert "os.system" not in src
assert not _module_level_code(src)
@pytest.mark.parametrize("value", [float("inf"), float("-inf"), float("nan")])
@pytest.mark.parametrize("field", ["x", "y", "width", "height"])
def test_a_non_finite_dimension_does_not_escape_as_an_unhandled_error(field, value):
"""json.loads accepts Infinity/NaN and Flask passes them through, so a
payload can hand _safe_int a non-finite float. int(inf) raises
OverflowError -- neither ValueError nor ComposerInputError -- so it escaped
both handlers and surfaced as a 500 with a traceback instead of a 422."""
el = {"type": "rectangle", "id": "r1", "x": 0, "y": 0, "width": 10, "height": 8}
el[field] = value
src = _generated(_payload(elements=[el])) # must not raise
# A non-finite value must be replaced by the default, not spelled into the
# source. Word-boundary match: "info" in self.logger.info contains "inf".
assert not re.search(r"\b(inf|nan|Infinity|NaN)\b", src), \
f"{field}={value!r} leaked a non-finite literal into the source"
assert not _module_level_code(src)
@pytest.mark.parametrize("bad_id", [
'x = __import__("os").system("id") #',
"x\nimport os\n_y",
"x[0]",
"",
"a" * 200,
])
def test_a_marquee_id_cannot_become_code(bad_id):
"""data_key is spliced UNQUOTED into variable names
(_{{ data_key }}_text = ...), so a non-identifier id landed in the source
as code. ast.parse caught it, but the caller then got an opaque
"Generated code has a syntax error" rather than being told the id is bad."""
el = {"type": "marquee", "id": bad_id, "x": 0, "y": 0, "text": "hi"}
src = _generated(_payload(elements=[el])) # must not raise
assert "__import__(" not in src
assert "os.system(" not in src
assert not _module_level_code(src)
-124
View File
@@ -1,124 +0,0 @@
"""An element the template cannot draw must not produce an empty `if` block.
manager.py.j2 wraps each element in `if width >= N:` (breakpoint) and/or
`if int(time.time() * 2) % 2:` (blink), and the body comes from the per-type
branches. A type with no branch contributed nothing, so the wrapper opened a
block with no statements in it. ast.parse in _generate_plugin_files then
failed and the caller was told only:
Generated code has a syntax error: expected an indented block after
'if' statement on line 49
which names a line of generated source the user never sees. Confirmed against
the code before the fix with a `group` element carrying minWidth.
Two defences, both covered here: _preprocess_elements drops types the template
has no branch for, and the template emits a `pass` fallback so a type added to
the canvas before its branch exists degrades to a no-op instead of a broken
plugin.
"""
import ast
import re
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from web_interface.blueprints import composer as C # noqa: E402
TEMPLATE = (Path(__file__).resolve().parent.parent
/ "web_interface/templates/v3/composer/manager.py.j2")
BASE_META = {"id": "test-plugin", "name": "Clock", "author": "a",
"version": "1.0.0", "description": "d"}
def generate(element):
return C._generate_plugin_files({
"metadata": BASE_META,
"elements": [element],
"dataModel": {"configVars": []},
})
@pytest.mark.parametrize("wrapper", [
{"minWidth": 64}, # breakpoint block
{"blink": True}, # blink block
{"minWidth": 64, "blink": True}, # both, nested
])
@pytest.mark.parametrize("etype", ["group", "widget_9000", "section"])
def test_undrawable_element_does_not_break_generation(etype, wrapper):
element = {"type": etype, "x": 0, "y": 0, "color": "#ffffff", **wrapper}
files = generate(element) # must not raise ComposerInputError
assert "manager.py" in files
def test_drawable_element_still_renders_inside_a_breakpoint():
files = generate({"type": "text", "text": "hi", "x": 0, "y": 0,
"minWidth": 64, "color": "#ffffff"})
src = files["manager.py"]
assert "if width >= 64:" in src
assert "draw_text" in src
def test_renderable_types_match_the_template_branches():
"""The constant and the template must agree.
A type listed in the constant with no branch emits an empty block (the bug
above); a type with a branch but missing from the constant is silently
dropped from every generated plugin. Neither is visible without this check.
"""
branches = set(re.findall(r"el\.type == '([a-z_]+)'", TEMPLATE.read_text()))
assert branches == set(C._RENDERABLE_ELEMENT_TYPES)
def test_template_closes_the_branch_chain_with_a_fallback():
"""Belt and braces: even if the constant drifts, no empty block escapes."""
text = TEMPLATE.read_text()
assert "{% else %}" in text
assert "pass # element type" in text
@pytest.mark.parametrize("wrapper", [
{"minWidth": 64},
{"blink": True},
{"minWidth": 64, "blink": True},
])
def test_dynamic_text_with_non_config_binding_does_not_break_generation(wrapper):
"""dynamic_text only renders a body for binding.source == 'config'.
_preprocess_elements accepts any string as binding.source (it just
defaults a missing one to 'config'), so a 'live' or 'sensor' source --
anything a client sends that isn't literally 'config' -- hit the same
empty-if-block bug as an undrawable element type, just one level deeper:
the branch is taken, but its own inner `if` produced nothing.
"""
element = {"type": "dynamic_text", "x": 0, "y": 0, "color": "#ffffff",
"binding": {"source": "live", "key": "temperature"}, **wrapper}
files = generate(element) # must not raise ComposerInputError
assert "manager.py" in files
assert "non-config dynamic_text binding draws nothing" in files["manager.py"]
def test_binding_source_cannot_break_out_of_the_comment_it_lands_in():
"""binding.source used to be interpolated straight into a Python comment
(`pass # dynamic_text binding_source "{{ el.binding_source }}" draws
nothing`) with no escaping. A source string carrying a newline closed the
comment, and text on the following line(s), indented to match, became a
real statement in the generated plugin -- CWE-94, and live once
composer_bp was registered (confirmed: this exact payload produces a
manager.py containing a clean, ast.parse-valid `import os` against the
pre-fix template). The comment is now a fixed literal that never
interpolates the value at all.
"""
payload = "foo\n import os\n os.system('id') # "
element = {"type": "dynamic_text", "x": 0, "y": 0, "color": "#ffffff",
"binding": {"source": payload, "key": "temperature"}}
files = generate(element)
src = files["manager.py"]
assert "import os" not in src
assert "os.system" not in src
assert "non-config dynamic_text binding draws nothing" in src
ast.parse(src) # belt and braces: generate() already enforces this
-263
View File
@@ -1,263 +0,0 @@
"""Structural checks on the composer's JavaScript.
There is no JS test runner in this repo, but three defects here are structural
enough to assert on the parse tree, and each was a real bug:
- Stroke widths inside _drawElement were left in canvas pixels while the
geometry around them scaled by SCALE, so at SCALE>1 every outline rendered
thinner than one LED pixel and the preview stopped matching the panel.
- The `line` branch drew raw el.x0/el.y0/el.x1/el.y1, ignoring the anchor that
every other element type honours, so setting xAnchor moved everything except
lines -- and getBoundingBox had the same omission, leaving the hit box behind.
- Four methods mutated exactly what _snapshot() serialises (metadata,
currentPreset) without calling it. _snapshot is the only caller of
_debouncedAutosave, so those changes were lost on reload and could not be
undone.
"""
import re
from pathlib import Path
import pytest
tree_sitter = pytest.importorskip("tree_sitter")
tree_sitter_javascript = pytest.importorskip("tree_sitter_javascript")
JS_DIR = Path(__file__).resolve().parent.parent / "web_interface/static/v3/js/composer"
CANVAS = JS_DIR / "composer-canvas.js"
APP = JS_DIR / "composer-app.js"
def _function_source(path: Path, name: str) -> str:
"""Return the source of a top-level function declaration by name."""
src = path.read_bytes()
lang = tree_sitter.Language(tree_sitter_javascript.language())
tree = tree_sitter.Parser(lang).parse(src)
found = []
def walk(node):
if node.type == "function_declaration":
ident = node.child_by_field_name("name")
if ident is not None and src[ident.start_byte:ident.end_byte].decode() == name:
found.append(src[node.start_byte:node.end_byte].decode())
for c in node.children:
walk(c)
walk(tree.root_node)
assert found, f"{name} not found in {path.name}"
return found[0]
def _method_source(path: Path, name: str) -> str:
"""Return the source of a top-level object method by name."""
src = path.read_bytes()
lang = tree_sitter.Language(tree_sitter_javascript.language())
tree = tree_sitter.Parser(lang).parse(src)
found = []
def walk(node):
if node.type == "method_definition":
ident = node.child_by_field_name("name")
if ident is not None and src[ident.start_byte:ident.end_byte].decode() == name:
found.append(src[node.start_byte:node.end_byte].decode())
for c in node.children:
walk(c)
walk(tree.root_node)
assert found, f"{name} not found in {path.name}"
return found[0]
def test_both_files_parse():
lang = tree_sitter.Language(tree_sitter_javascript.language())
parser = tree_sitter.Parser(lang)
for path in (CANVAS, APP):
tree = parser.parse(path.read_bytes())
errors = []
def walk(node):
if node.type == "ERROR" or node.is_missing:
errors.append(node.start_point[0] + 1)
for c in node.children:
walk(c)
walk(tree.root_node)
assert not errors, f"{path.name} has parse errors at lines {errors}"
def test_element_strokes_scale_with_scale():
"""No bare `ctx.lineWidth = 1` inside _drawElement.
Selection handles and the grid are drawn in canvas pixels deliberately and
live in other functions, so this is scoped to the element drawing routine.
"""
body = _function_source(CANVAS, "_drawElement")
offenders = re.findall(r"ctx\.lineWidth\s*=\s*1\s*;", body)
assert not offenders, f"{len(offenders)} unscaled stroke width(s) in _drawElement"
def test_line_branch_applies_the_anchor_offset():
"""Scoped to _drawElement.
getBoundingBox has its own `case 'line': {` and appears first in the file,
so searching the whole text found *that* branch -- this assertion passed
with the draw branch's anchor offset removed. Verified: stripping it and
re-running gave 11/11 green.
"""
body = _function_source(CANVAS, "_drawElement")
line_branch = body[body.index("case 'line': {"):]
line_branch = line_branch[:line_branch.index("case 'divider'")]
assert "ax - el.x0" in line_branch and "ay - el.y0" in line_branch, \
"line drawing ignores xAnchor/yAnchor"
assert "moveTo(el.x0 * s" not in line_branch, \
"line still drawn from unanchored endpoints"
def test_line_bounding_box_applies_the_anchor_offset():
"""The companion to the above: scoped to getBoundingBox specifically, so
the two tests cannot both be satisfied by the same branch."""
body = _function_source(CANVAS, "getBoundingBox")
box = body[body.index("case 'line'"):]
box = box[:box.index("case 'divider'")]
assert "ax - el.x0" in box, "line bounding box ignores the anchor"
@pytest.mark.parametrize("method", [
"onBgColorChange", # mutates metadata.bgColor
"setCustomSize", # mutates currentPreset / MATRIX_W / MATRIX_H
"changePreset", # mutates currentPreset / MATRIX_W / MATRIX_H
"applyPresetLabel", # same, for sizes not in DISPLAY_PRESETS
"onColorChange", # the one that was already fixed — keeps it fixed
])
def test_state_mutations_take_a_snapshot(method):
body = _method_source(APP, method)
assert "_snapshot()" in body, \
f"{method} changes snapshotted state without calling _snapshot()"
assert "isDirty = true" in body, f"{method} does not mark the design dirty"
@pytest.mark.parametrize("method", ["changePreset", "applyPresetLabel"])
def test_restore_path_stays_snapshot_free(method):
"""_applyState and loadTemplate call these with {silent: true} while
restoring; snapshotting there would push restore steps onto the undo stack
and re-autosave the state just loaded."""
body = _method_source(APP, method)
assert "opts.silent" in body, f"{method} lost its silent guard"
snap = body.index("_snapshot()")
guard = body.index("!opts.silent")
assert guard < snap, f"{method} snapshots outside the !opts.silent guard"
TEMPLATE_HTML = (Path(__file__).resolve().parent.parent
/ "web_interface/templates/v3/composer.html")
#: The six toolbar buttons and the wrapper each must call.
ALIGN_BUTTONS = ["alignLeft", "alignHCenter", "alignRight",
"alignTop", "alignVCenter", "alignBottom"]
def test_alignment_buttons_use_the_anchor_clearing_path():
"""Two alignment implementations existed and the toolbar used the wrong one.
The legacy alignElement(dir) set el.x/el.y but left xAnchor/yAnchor in
place. resolveAnchor turns anchor='right' into `dim - val`, so "align left"
(el.x = 0) resolved to x = MATRIX_W -- the element jumped to the far right
edge instead. _alignElement clears the anchor first, so the stored value is
absolute, and it also updates el.x0/el.y0 so lines actually move.
"""
html = TEMPLATE_HTML.read_text()
for wrapper in ALIGN_BUTTONS:
assert f"{wrapper}()" in html, f"toolbar does not call {wrapper}()"
assert not re.search(r"[^_]alignElement\(", html), \
"toolbar still calls the legacy alignElement()"
def test_the_legacy_alignelement_is_gone():
"""Leaving it in place invites the toolbar drifting back to it."""
src = APP.read_text()
assert not re.search(r"^\s{4}alignElement\(dir\)", src, re.M), \
"legacy alignElement(dir) still defined"
def test_align_clears_the_anchor_and_moves_line_endpoints():
body = _method_source(APP, "_alignElement")
assert "xAnchor = null" in body and "yAnchor = null" in body, \
"_alignElement no longer clears the anchor, so aligning an anchored " \
"element resolves to the wrong edge"
assert "el.x0" in body and "el.y0" in body, \
"_alignElement no longer moves line endpoints"
def test_align_translates_both_line_endpoints_not_just_the_start():
"""Setting only x0 (or y0) left x1/y1 behind, so aligning a line changed
its shape instead of moving it -- e.g. a line from x0=20 to x1=50 aligned
right became x0=98, x1=50, stretching rather than translating it. Both
endpoints must move by the same delta."""
body = _method_source(APP, "_alignElement")
x_branch = body[body.index("if (axis === 'x')"):body.index("} else {")]
y_branch = body[body.index("} else {"):]
assert "el.x1" in x_branch, \
"_alignElement moves x0 but not x1 -- a line's shape changes, not its position"
assert "el.y1" in y_branch, \
"_alignElement moves y0 but not y1 -- a line's shape changes, not its position"
def test_divider_stroke_is_centered_in_led_pixels():
"""A 0.5 canvas-pixel offset (correct only at SCALE=1) was applied after
scaling instead of before it, so at SCALE>1 the stroke bled into the
preceding LED row/column instead of straddling its own."""
body = _function_source(CANVAS, "_drawElement")
divider_branch = body[body.index("case 'divider'"):]
divider_branch = divider_branch[:divider_branch.index("case 'pips'")]
assert "ay * s + 0.5" not in divider_branch, \
"divider still offsets by 0.5 canvas pixels after scaling"
assert "ax * s + 0.5" not in divider_branch, \
"divider still offsets by 0.5 canvas pixels after scaling"
assert "(ay + 0.5) * s" in divider_branch and "(ax + 0.5) * s" in divider_branch, \
"divider stroke is not centered within its LED pixel"
def test_gauge_radii_are_clamped_to_zero():
"""An imported design can carry a small gauge with a wide lineWidth --
width=1, height=1, lineWidth=3 sends a negative radius into
ctx.ellipse(), which throws IndexSizeError and aborts render() for every
element still to be drawn, not just the gauge."""
body = _function_source(CANVAS, "_drawElement")
gauge_branch = body[body.index("case 'gauge'"):]
assert re.search(r"Math\.max\(0,\s*rx\s*-\s*lwPx\s*/\s*2\)", gauge_branch), \
"gauge x-radius is not clamped to zero"
assert re.search(r"Math\.max\(0,\s*ry\s*-\s*lwPx\s*/\s*2\)", gauge_branch), \
"gauge y-radius is not clamped to zero"
assert "rx - lwPx / 2" not in re.sub(r"Math\.max\(0,\s*rx\s*-\s*lwPx\s*/\s*2\)", "", gauge_branch), \
"an unclamped gauge radius is still passed to ctx.ellipse()"
#: ELEMENT_DEFAULTS in composer-canvas.js gives exactly these types a
#: `binding` object; verified against that file rather than assumed.
BOUND_TYPES = ["dynamic_text", "progress_bar", "countdown", "pips", "sparkline", "gauge"]
def test_bound_types_constant_lists_every_bound_element_type():
"""ELEMENT_DEFAULTS in composer-canvas.js gives dynamic_text, progress_bar,
countdown, pips, sparkline and gauge a `binding` object. If a type is added
to (or removed from) that list without updating APP's BOUND_TYPES, the
checks below drift out of sync silently -- this pins the two together."""
src = APP.read_text()
match = re.search(r"const BOUND_TYPES = \[([^\]]*)\];", src)
assert match, "BOUND_TYPES constant not found in composer-app.js"
declared = {t.strip().strip("'\"") for t in match.group(1).split(",") if t.strip()}
assert declared == set(BOUND_TYPES), \
f"BOUND_TYPES {declared} does not match the bound element types {set(BOUND_TYPES)}"
@pytest.mark.parametrize("method", ["_isBound", "removeConfigVar", "_validateBeforeExport"])
def test_binding_checks_cover_every_bound_element_type(method):
"""Only dynamic_text and progress_bar were checked, so a countdown, pips,
sparkline or gauge element with an empty binding passed export validation
silently, and deleting a config var still used by one of them gave no
warning -- the plugin would read a now-missing key at runtime with no
indication why."""
body = _method_source(APP, method)
assert "BOUND_TYPES.includes(e.type)" in body, \
f"{method} does not check every bound element type via BOUND_TYPES"
assert "e.type === 'dynamic_text'" not in body, \
f"{method} still hardcodes only dynamic_text instead of BOUND_TYPES"
-225
View File
@@ -1,225 +0,0 @@
"""A composer plugin id must never resolve outside the plugins directory.
CodeQL reported sixteen high-severity py/path-injection alerts against
web_interface/blueprints/composer.py: a request-supplied plugin_id reaching
Path(plugins_dir) / plugin_id, which is then created, written to, deleted
(shutil.rmtree) and read back.
The id was already validated by an anchored regex, so every traversal payload
was in fact rejected. What was missing was the guarantee living *with* the path
building rather than in a regex several hundred lines away -- loosen that regex
later and the traversal opens silently, with nothing at the filesystem boundary
to catch it. _plugin_dir() closes that, and is the form static analysis can see.
"""
import sys
from pathlib import Path
from unittest.mock import MagicMock
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from web_interface.blueprints import composer as C # noqa: E402
#: Anything that has ever been used to climb out of a directory.
TRAVERSAL = [
"../../etc/passwd", "..", ".", "a/../../etc", "good/../../..",
"/etc/passwd", "//etc/passwd", "a\\..\\..", "a%2f..%2f..",
"....//....//etc", "a/./../../etc", "~", "~root",
"plugin/../../../../../../etc/shadow",
]
#: Rejected for shape, not traversal -- but rejected all the same.
MALFORMED = ["", "A-upper", "1-leading-digit", "-leading-dash", "has_underscore",
"has space", "has.dot", "a" * 64, "plugin\n", "plugin\n../../etc",
"\n", "plug\x00in"]
@pytest.fixture
def plugins_dir(tmp_path, monkeypatch):
base = tmp_path / "plugin-repos"
base.mkdir()
monkeypatch.setattr(C.composer_bp, "plugins_dir", str(base), raising=False)
return base
@pytest.mark.parametrize("payload", TRAVERSAL)
def test_traversal_payloads_are_refused(plugins_dir, payload):
assert C._plugin_dir(payload) is None
@pytest.mark.parametrize("payload", MALFORMED)
def test_malformed_ids_are_refused(plugins_dir, payload):
assert C._plugin_dir(payload) is None
@pytest.mark.parametrize("payload", ["a", "my-plugin", "x9", "a" * 63])
def test_valid_ids_resolve_inside_the_base(plugins_dir, payload):
resolved = C._plugin_dir(payload)
assert resolved is not None, f"{payload!r} was rejected but is valid"
assert resolved.parent == plugins_dir.resolve(), (
f"{payload!r} resolved to {resolved}, outside {plugins_dir}")
def test_no_payload_can_escape_even_if_the_regex_is_loosened(plugins_dir, monkeypatch):
"""The containment check must stand on its own.
This is the whole point of resolving at the filesystem boundary: if the id
pattern is ever relaxed, traversal must still be impossible. Replace the
regex with one that permits slashes and dots, then re-run the payloads.
"""
import re
monkeypatch.setattr(C, "_PLUGIN_ID_RE", re.compile(r"\A[\w./\\~-]+\Z"))
import os
escaped = []
base = os.path.realpath(str(plugins_dir))
for payload in TRAVERSAL:
resolved = C._plugin_dir(payload)
if resolved is None:
continue
real = os.path.realpath(str(resolved))
if real != base and os.path.commonpath([base, real]) != base:
escaped.append((payload, real))
assert not escaped, f"these escaped the base with a loosened regex: {escaped}"
def test_a_sibling_directory_with_a_shared_prefix_is_not_inside(tmp_path, monkeypatch):
"""commonpath, not startswith.
"/x/plugins-evil" starts with "/x/plugins" but is a different directory, so
a prefix test would accept it.
"""
base = tmp_path / "plugins"
base.mkdir()
(tmp_path / "plugins-evil").mkdir()
monkeypatch.setattr(C.composer_bp, "plugins_dir", str(base), raising=False)
import re
# Neutralise the two layers in front so this exercises the containment
# check itself; otherwise secure_filename rejects the payload first and a
# startswith regression would go unnoticed here.
monkeypatch.setattr(C, "_PLUGIN_ID_RE", re.compile(r"\A[\w./\\~-]+\Z"))
monkeypatch.setattr(C, "secure_filename", lambda v: v)
assert C._plugin_dir("../plugins-evil") is None
def test_containment_still_holds_if_the_sanitiser_is_defeated(plugins_dir, monkeypatch):
"""Each layer is tested on its own, not just the stack.
secure_filename's equality guard rejects every traversal payload before the
containment check sees it, so removing containment does not fail the other
tests -- which would make it look load-bearing when it is not. Neutralise
the regex *and* the sanitiser, and the realpath/commonpath check must still
refuse everything on its own.
"""
import re
monkeypatch.setattr(C, "_PLUGIN_ID_RE", re.compile(r"\A[\w./\\~-]+\Z"))
monkeypatch.setattr(C, "secure_filename", lambda v: v)
import os
base = os.path.realpath(str(plugins_dir))
escaped = []
for payload in TRAVERSAL:
resolved = C._plugin_dir(payload)
if resolved is None:
continue
real = os.path.realpath(str(resolved))
# Inside the base is fine -- "...." and "~" are ordinary directory
# names on Linux, so they are not escapes. What must never happen is
# landing outside the base, or on the base itself: install() rmtrees
# its target, so the plugins root resolving to a "plugin" would wipe
# every installed plugin.
if real == base or os.path.commonpath([base, real]) != base:
escaped.append((payload, real))
assert not escaped, f"containment alone let these through: {escaped}"
def test_secure_filename_never_rewrites_an_accepted_id(plugins_dir):
"""The sanitiser must be a no-op on everything the regex accepts.
If secure_filename ever altered an accepted id, _plugin_dir would resolve
to a *different* plugin's directory than the caller asked for -- a silent
redirect, which is worse than a refusal. The guard turns that into a
refusal; this proves the guard never has to fire in practice.
"""
import random
from werkzeug.utils import secure_filename
random.seed(1)
alphabet = "abcdefghijklmnopqrstuvwxyz0123456789-"
altered = []
for _ in range(2000):
n = random.randint(1, 63)
cand = random.choice("abcdefghijklmnopqrstuvwxyz") + "".join(
random.choice(alphabet) for _ in range(n - 1))
if C._PLUGIN_ID_RE.match(cand) and secure_filename(cand) != cand:
altered.append((cand, secure_filename(cand)))
assert not altered, f"secure_filename rewrote accepted ids: {altered[:5]}"
def test_a_trailing_newline_is_not_a_valid_id():
r"""Python's `$` also matches before a trailing newline, so the original
`^...$` accepted "myplugin\n" and would have created a directory whose
name ends in one. \Z does not."""
assert C._PLUGIN_ID_RE.match("myplugin") is not None
assert C._PLUGIN_ID_RE.match("myplugin\n") is None
# --- font serving -----------------------------------------------------------
FONT_TRAVERSAL = [
"../../../etc/passwd", "../config/config.json", "..%2f..%2fetc%2fpasswd",
"PressStart2P-Regular.ttf/../../../etc/passwd", "/etc/passwd", "",
"PressStart2P-Regular.TTF", # case differs -> not the allowlisted name
"PressStart2P-Regular.ttf ", # trailing space
]
def test_serve_font_refuses_a_file_that_exists_but_is_not_allowlisted(monkeypatch, tmp_path):
"""The allowlist must be what refuses it, not a missing file.
Asserting 404 on traversal payloads proves nothing here: Flask's router
will not match a path segment containing '/', and everything else 404s
simply because no such file exists. Put a real, readable file next to the
fonts and confirm it is still refused -- that is the allowlist working.
"""
fonts = tmp_path / "assets" / "fonts"
fonts.mkdir(parents=True)
(fonts / "id_rsa.ttf").write_bytes(b"PRIVATE KEY")
monkeypatch.setattr(C.composer_bp, "project_root", str(tmp_path), raising=False)
app = __import__("flask").Flask(__name__)
app.register_blueprint(C.composer_bp)
with app.test_client() as client:
resp = client.get("/api/fonts/id_rsa.ttf")
assert resp.status_code == 404, (
"a readable non-allowlisted file was served; the allowlist is not gating")
assert b"PRIVATE KEY" not in resp.data
@pytest.mark.parametrize("payload", FONT_TRAVERSAL)
def test_serve_font_refuses_anything_not_allowlisted(payload, monkeypatch, tmp_path):
"""The name reaching the filesystem must come from the allowlist constant.
_ALLOWED_FONTS gates this endpoint, so nothing here was ever exploitable.
Building the path from the matched constant rather than the request value
is what makes that provable -- and it is why CodeQL reported two
high-severity py/path-injection alerts on an endpoint that was already
safe.
"""
monkeypatch.setattr(C.composer_bp, "project_root", str(tmp_path), raising=False)
app = C.composer_bp.name and __import__("flask").Flask(__name__)
app.register_blueprint(C.composer_bp)
with app.test_client() as client:
resp = client.get(f"/api/fonts/{payload}")
assert resp.status_code in (404, 405, 308), (
f"{payload!r} was not refused (status {resp.status_code})")
def test_serve_font_still_serves_each_allowlisted_font(monkeypatch, tmp_path):
fonts = tmp_path / "assets" / "fonts"
fonts.mkdir(parents=True)
monkeypatch.setattr(C.composer_bp, "project_root", str(tmp_path), raising=False)
app = __import__("flask").Flask(__name__)
app.register_blueprint(C.composer_bp)
for name in C._ALLOWED_FONTS:
(fonts / name).write_bytes(b"\x00\x01ttf")
with app.test_client() as client:
resp = client.get(f"/api/fonts/{name}")
assert resp.status_code == 200, f"{name} should be served, got {resp.status_code}"
-122
View File
@@ -1,122 +0,0 @@
"""The gate must read the core version from disk, not from its own import.
`from src import __version__` binds whatever the process loaded at start. The
web UI is a long-lived service of its own (ledmatrix-web.service), and updating
the core replaces files on disk without restarting it -- the update route says
so and asks the user to restart, but its prompt named only the *display*
service, so a user who followed it left the web process holding the old number.
The plugin store's gate lives in that web process, so being stale by exactly
one release is the case that bites: every plugin flooring on the release you
just installed is refused, with a message blaming a core version that is
already correct on disk.
Observed on hardware: after updating a rig to 3.3.0 and restarting only the
display service, all eight sports scoreboards were refused with
"supports LEDMatrix >=3.3.0, but this system is running 3.2.0" while
src/__init__.py on that machine read 3.3.0.
"""
import importlib
import sys
import pytest
from src.plugin_system import compatibility
class TestCurrentCoreVersion:
def test_it_reads_the_file_rather_than_the_imported_value(self, monkeypatch, tmp_path):
# Simulate a process whose import predates the update: the module
# object says 3.2.0 while the file on disk says 3.3.0.
import src
monkeypatch.setattr(src, "__version__", "3.2.0")
fake = tmp_path / "__init__.py"
fake.write_text('__version__ = "3.3.0"\n', encoding="utf-8")
monkeypatch.setattr(compatibility, "_VERSION_FILE", fake)
assert compatibility.current_core_version() == "3.3.0"
def test_it_matches_the_real_file_by_default(self):
import src
assert compatibility.current_core_version() == src.__version__
@pytest.mark.parametrize("body", [
"__version__ = '3.4.1'\n",
'__version__="3.4.1"\n',
'"""doc"""\n\n__version__ = "3.4.1" # trailing comment\n',
])
def test_it_tolerates_the_ways_that_line_gets_written(self, monkeypatch, tmp_path, body):
fake = tmp_path / "__init__.py"
fake.write_text(body, encoding="utf-8")
monkeypatch.setattr(compatibility, "_VERSION_FILE", fake)
assert compatibility.current_core_version() == "3.4.1"
def test_a_missing_file_falls_back_to_the_import(self, monkeypatch, tmp_path):
# Never worse than before: an unreadable file returns what the old
# code would have returned.
import src
monkeypatch.setattr(src, "__version__", "3.2.0")
monkeypatch.setattr(compatibility, "_VERSION_FILE", tmp_path / "gone.py")
assert compatibility.current_core_version() == "3.2.0"
def test_a_file_without_the_line_falls_back(self, monkeypatch, tmp_path):
import src
monkeypatch.setattr(src, "__version__", "3.2.0")
fake = tmp_path / "__init__.py"
fake.write_text("# no version here\n", encoding="utf-8")
monkeypatch.setattr(compatibility, "_VERSION_FILE", fake)
assert compatibility.current_core_version() == "3.2.0"
def test_it_never_raises(self, monkeypatch, tmp_path):
# This runs on the install path; an exception here would surface as a
# failed update rather than a version mismatch.
bad = tmp_path / "__init__.py"
bad.write_bytes(b"\xff\xfe\x00 not utf-8 \xff")
monkeypatch.setattr(compatibility, "_VERSION_FILE", bad)
assert isinstance(compatibility.current_core_version(), str)
class TestTheBugItFixes:
def test_a_stale_import_no_longer_refuses_a_compatible_plugin(self, monkeypatch, tmp_path):
"""The exact hardware failure, as a test."""
import src
monkeypatch.setattr(src, "__version__", "3.2.0") # what the process holds
fake = tmp_path / "__init__.py"
fake.write_text('__version__ = "3.3.0"\n', encoding="utf-8") # what is on disk
monkeypatch.setattr(compatibility, "_VERSION_FILE", fake)
manifest = {"name": "Hockey Scoreboard", "min_ledmatrix_version": "3.3.0"}
stale_ok, _ = compatibility.check(manifest, src.__version__)
assert stale_ok is False, "precondition: the stale value is what refused it"
fresh_ok, reason = compatibility.check(
manifest, compatibility.current_core_version())
assert fresh_ok is True, f"the disk version must allow it, got: {reason}"
def test_it_still_refuses_when_the_core_really_is_too_old(self, monkeypatch, tmp_path):
# The gate must not become permissive: a genuinely old core still says no.
fake = tmp_path / "__init__.py"
fake.write_text('__version__ = "3.2.0"\n', encoding="utf-8")
monkeypatch.setattr(compatibility, "_VERSION_FILE", fake)
ok, reason = compatibility.check(
{"name": "Hockey", "min_ledmatrix_version": "3.3.0"},
compatibility.current_core_version())
assert ok is False
assert "3.2.0" in (reason or "")
class TestCallSites:
@pytest.mark.parametrize("module", [
"src.plugin_system.store_manager",
"src.plugin_system.plugin_loader",
])
def test_no_gate_binds_the_version_at_import(self, module):
"""Catch a future call site reintroducing the stale read."""
import inspect
mod = importlib.import_module(module)
source = inspect.getsource(mod)
assert "from src import __version__ as core_version" not in source, (
f"{module} binds __version__ at import; use "
f"compatibility.current_core_version() so a long-lived process "
f"sees a core update.")
+1 -225
View File
@@ -8,27 +8,11 @@ ensure_logo_directory, and the download_missing_logo function path
""" """
import os import os
import time
import pytest import pytest
from pathlib import Path from pathlib import Path
from unittest.mock import patch, Mock, MagicMock from unittest.mock import patch, Mock, MagicMock
from PIL import Image from src.logo_downloader import LogoDownloader
from PIL.PngImagePlugin import PngInfo
from src.logo_downloader import (
PLACEHOLDER_BG,
PLACEHOLDER_MARKER,
PLACEHOLDER_RETRY_SECONDS,
PLACEHOLDER_SIZE,
LogoDownloader,
download_missing_logo,
is_placeholder_logo,
placeholder_age_seconds,
refresh_placeholder_timestamp,
should_attempt_download,
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -143,211 +127,3 @@ class TestEnsureLogoDirectory:
with patch("builtins.open", side_effect=mock_open): with patch("builtins.open", side_effect=mock_open):
result = downloader.ensure_logo_directory(test_dir) result = downloader.ensure_logo_directory(test_dir)
assert result is False assert result is False
# ---------------------------------------------------------------------------
# Placeholder detection and retry
#
# A failed download used to be cached as a placeholder wearing the real logo's
# filename, and download_missing_logo returned early on "the file exists". One
# transient failure therefore pinned a team to a grey box permanently.
# ---------------------------------------------------------------------------
class TestPlaceholderLogos:
def _placeholder(self, tmp_path, abbrev="COLL"):
downloader = LogoDownloader()
assert downloader.create_placeholder_logo(abbrev, str(tmp_path)) is True
return tmp_path / f"{abbrev}.png"
def test_generated_placeholder_is_recognised(self, tmp_path):
assert is_placeholder_logo(self._placeholder(tmp_path)) is True
def test_real_logo_is_not_a_placeholder(self, tmp_path):
real = tmp_path / "REAL.png"
Image.new("RGBA", (500, 500), (12, 34, 56, 255)).save(real)
assert is_placeholder_logo(real) is False
def test_legacy_unmarked_placeholder_is_recognised(self, tmp_path):
"""Placeholders written before the marker existed must still be caught.
They are already sitting on users' disks; if they were not recognised
those teams would stay grey boxes forever even after this fix.
"""
legacy = tmp_path / "LEGACY.png"
Image.new("RGBA", PLACEHOLDER_SIZE, PLACEHOLDER_BG).save(legacy)
assert is_placeholder_logo(legacy) is True
def test_same_size_but_different_colour_is_not_a_placeholder(self, tmp_path):
real = tmp_path / "SMALL.png"
Image.new("RGBA", PLACEHOLDER_SIZE, (10, 200, 10, 255)).save(real)
assert is_placeholder_logo(real) is False
def test_missing_file_is_not_a_placeholder(self, tmp_path):
assert is_placeholder_logo(tmp_path / "nope.png") is False
def test_existing_real_logo_short_circuits_without_downloading(self, tmp_path):
real = tmp_path / "REAL.png"
Image.new("RGBA", (500, 500), (1, 2, 3, 255)).save(real)
with patch.object(LogoDownloader, "download_logo") as download:
assert download_missing_logo(
"afl", "1", "REAL", real, logo_url="http://example/x.png") is True
download.assert_not_called()
def _age_placeholder(self, path, seconds):
"""Rewrite a placeholder's marker so it reads as `seconds` old."""
metadata = PngInfo()
metadata.add_text(PLACEHOLDER_MARKER, str(time.time() - seconds))
with Image.open(path) as img:
img.copy().save(path, "PNG", pnginfo=metadata)
def test_stale_placeholder_triggers_a_retry(self, tmp_path):
path = self._placeholder(tmp_path)
self._age_placeholder(path, PLACEHOLDER_RETRY_SECONDS + 60)
assert placeholder_age_seconds(path) > PLACEHOLDER_RETRY_SECONDS
with patch.object(LogoDownloader, "download_logo", return_value=True) as download:
assert download_missing_logo(
"afl", "1", "COLL", path,
logo_url="http://example/coll.png") is True
download.assert_called_once()
def test_placeholder_age_survives_an_mtime_touch(self, tmp_path):
"""The age comes from the stamp, not the filesystem.
Anything that rewrites file times -- a backup restore, an rsync, a
permissions fix script -- would otherwise reset the retry clock.
"""
path = self._placeholder(tmp_path)
self._age_placeholder(path, PLACEHOLDER_RETRY_SECONDS + 60)
now = time.time()
os.utime(path, (now, now))
assert placeholder_age_seconds(path) > PLACEHOLDER_RETRY_SECONDS
def test_fresh_placeholder_does_not_retry(self, tmp_path):
"""Rate limiting: a placeholder written seconds ago must not re-download.
Without this the fix would trade a permanent grey box for an ESPN
request on every frame.
"""
path = self._placeholder(tmp_path)
with patch.object(LogoDownloader, "download_logo") as download:
assert download_missing_logo(
"afl", "1", "COLL", path,
logo_url="http://example/coll.png") is True
download.assert_not_called()
class TestDownloadEligibility:
"""One rule, shared by every download site.
The two bulk loops and the single-logo path each had their own idea of what
counted as "already have it", which is how one of them ended up retrying
fresh placeholders and the other skipping stale ones forever.
"""
def _placeholder(self, tmp_path, abbrev="COLL"):
assert LogoDownloader().create_placeholder_logo(abbrev, str(tmp_path))
return tmp_path / f"{abbrev}.png"
def _age(self, path, seconds):
metadata = PngInfo()
metadata.add_text(PLACEHOLDER_MARKER, str(time.time() - seconds))
with Image.open(path) as img:
img.copy().save(path, "PNG", pnginfo=metadata)
def test_missing_file_is_eligible(self, tmp_path):
assert should_attempt_download(tmp_path / "nope.png") is True
def test_real_logo_is_not_eligible(self, tmp_path):
real = tmp_path / "REAL.png"
Image.new("RGBA", (500, 500), (1, 2, 3, 255)).save(real)
assert should_attempt_download(real) is False
def test_force_download_beats_a_real_logo(self, tmp_path):
real = tmp_path / "REAL.png"
Image.new("RGBA", (500, 500), (1, 2, 3, 255)).save(real)
assert should_attempt_download(real, force_download=True) is True
def test_fresh_placeholder_is_not_eligible(self, tmp_path):
assert should_attempt_download(self._placeholder(tmp_path)) is False
def test_stale_placeholder_is_eligible(self, tmp_path):
path = self._placeholder(tmp_path)
self._age(path, PLACEHOLDER_RETRY_SECONDS + 60)
assert should_attempt_download(path) is True
def test_league_bulk_loop_skips_a_fresh_placeholder(self, tmp_path):
"""A bulk pass honours the same back-off as everything else."""
self._placeholder(tmp_path, "AAA")
downloader = LogoDownloader()
teams = [{"abbreviation": "AAA", "display_name": "A", "logo_url": "http://x/a.png"}]
with patch.object(LogoDownloader, "get_logo_directory", return_value=str(tmp_path)):
with patch.object(LogoDownloader, "fetch_teams_data", return_value={"sports": [{}]}):
with patch.object(LogoDownloader, "extract_teams_from_data", return_value=teams):
with patch.object(LogoDownloader, "download_logo") as download:
downloader.download_missing_logos_for_league("nfl")
download.assert_not_called()
def test_league_bulk_loop_retries_a_stale_placeholder(self, tmp_path):
path = self._placeholder(tmp_path, "AAA")
self._age(path, PLACEHOLDER_RETRY_SECONDS + 60)
downloader = LogoDownloader()
teams = [{"abbreviation": "AAA", "display_name": "A", "logo_url": "http://x/a.png"}]
with patch.object(LogoDownloader, "get_logo_directory", return_value=str(tmp_path)):
with patch.object(LogoDownloader, "fetch_teams_data", return_value={"sports": [{}]}):
with patch.object(LogoDownloader, "extract_teams_from_data", return_value=teams):
with patch.object(LogoDownloader, "download_logo", return_value=True) as download:
downloader.download_missing_logos_for_league("nfl")
download.assert_called_once()
def test_ncaa_bulk_loop_retries_a_stale_placeholder(self, tmp_path):
"""This loop skipped placeholders forever; it now shares the rule."""
path = self._placeholder(tmp_path, "AAA")
self._age(path, PLACEHOLDER_RETRY_SECONDS + 60)
downloader = LogoDownloader()
teams = [{"abbreviation": "AAA", "display_name": "A",
"logo_url": "http://x/a.png", "category": "FBS",
"conference": "SEC"}]
with patch.object(LogoDownloader, "get_logo_directory", return_value=str(tmp_path)):
with patch.object(LogoDownloader, "fetch_teams_data", return_value={"sports": [{}]}):
with patch.object(LogoDownloader, "extract_teams_from_data", return_value=teams):
with patch.object(LogoDownloader, "download_logo", return_value=True) as download:
downloader.download_all_ncaa_football_logos()
download.assert_called_once()
def test_ncaa_bulk_loop_skips_a_fresh_placeholder(self, tmp_path):
self._placeholder(tmp_path, "AAA")
downloader = LogoDownloader()
teams = [{"abbreviation": "AAA", "display_name": "A",
"logo_url": "http://x/a.png", "category": "FBS",
"conference": "SEC"}]
with patch.object(LogoDownloader, "get_logo_directory", return_value=str(tmp_path)):
with patch.object(LogoDownloader, "fetch_teams_data", return_value={"sports": [{}]}):
with patch.object(LogoDownloader, "extract_teams_from_data", return_value=teams):
with patch.object(LogoDownloader, "download_logo") as download:
downloader.download_all_ncaa_football_logos()
download.assert_not_called()
class TestRefreshPlaceholderTimestamp:
def test_restarts_the_back_off(self, tmp_path):
assert LogoDownloader().create_placeholder_logo("COLL", str(tmp_path))
path = tmp_path / "COLL.png"
metadata = PngInfo()
metadata.add_text(PLACEHOLDER_MARKER, str(time.time() - (PLACEHOLDER_RETRY_SECONDS + 60)))
with Image.open(path) as img:
img.copy().save(path, "PNG", pnginfo=metadata)
assert should_attempt_download(path) is True
assert refresh_placeholder_timestamp(path) is True
assert should_attempt_download(path) is False
def test_refuses_to_touch_a_real_logo(self, tmp_path):
real = tmp_path / "REAL.png"
Image.new("RGBA", (500, 500), (1, 2, 3, 255)).save(real)
before = real.read_bytes()
assert refresh_placeholder_timestamp(real) is False
assert real.read_bytes() == before
def test_missing_file_is_not_an_error(self, tmp_path):
assert refresh_placeholder_timestamp(tmp_path / "nope.png") is False
-73
View File
@@ -421,76 +421,3 @@ class TestSessionConfiguration:
def test_user_agent_and_accept_headers(self, helper): def test_user_agent_and_accept_headers(self, helper):
assert helper.session.headers["User-Agent"] == "LEDMatrix-Common/1.0" assert helper.session.headers["User-Agent"] == "LEDMatrix-Common/1.0"
assert helper.session.headers["Accept"] == "image/*" assert helper.session.headers["Accept"] == "image/*"
class TestStalePlaceholderHandling:
"""load_logo_with_download must not be fooled by a cached placeholder.
A placeholder wears the real logo's filename, so both the file cache and
the in-memory cache can hold one and look like a hit.
"""
def _placeholder(self, tmp_path, abbrev="COLL"):
from src.logo_downloader import LogoDownloader
assert LogoDownloader().create_placeholder_logo(abbrev, str(tmp_path))
return tmp_path / f"{abbrev}.png"
def _make_stale(self, path):
import time
from PIL.PngImagePlugin import PngInfo
from src.logo_downloader import PLACEHOLDER_MARKER, PLACEHOLDER_RETRY_SECONDS
metadata = PngInfo()
metadata.add_text(PLACEHOLDER_MARKER, str(time.time() - (PLACEHOLDER_RETRY_SECONDS + 60)))
with Image.open(path) as img:
img.copy().save(path, "PNG", pnginfo=metadata)
def test_fresh_placeholder_is_served_without_a_download(self, helper, tmp_path):
path = self._placeholder(tmp_path)
with patch.object(LogoHelper, "_download_logo") as download:
assert helper.load_logo_with_download("COLL", path, "http://x/c.png") is not None
download.assert_not_called()
def test_stale_placeholder_triggers_a_download(self, helper, tmp_path):
path = self._placeholder(tmp_path)
self._make_stale(path)
with patch.object(LogoHelper, "_download_logo") as download:
helper.load_logo_with_download("COLL", path, "http://x/c.png")
download.assert_called_once()
def test_replacement_logo_is_not_masked_by_the_cached_placeholder(self, helper, tmp_path):
"""The bug this guards: load_logo answers from cache before the disk.
Without invalidation the freshly downloaded logo would not appear until
the process restarted.
"""
path = self._placeholder(tmp_path)
first = helper.load_logo_with_download("COLL", path, "http://x/c.png")
assert first is not None
self._make_stale(path)
def fake_download(_self, _url, file_path):
Image.new("RGB", (500, 500), (7, 8, 9)).save(file_path, format="PNG")
with patch.object(LogoHelper, "_download_logo", fake_download):
second = helper.load_logo_with_download("COLL", path, "http://x/c.png")
assert second is not None
from src.logo_downloader import is_placeholder_logo
assert is_placeholder_logo(path) is False
assert second.getpixel((0, 0))[:3] == (7, 8, 9)
def test_failed_retry_restarts_the_back_off(self, helper, tmp_path):
"""Otherwise a stale placeholder means a download attempt per call."""
from src.logo_downloader import should_attempt_download
path = self._placeholder(tmp_path)
self._make_stale(path)
assert should_attempt_download(path) is True
def boom(_self, _url, _file_path):
raise OSError("network down")
with patch.object(LogoHelper, "_download_logo", boom):
helper.load_logo_with_download("COLL", path, "http://x/c.png")
assert should_attempt_download(path) is False
+2 -70
View File
@@ -155,9 +155,8 @@ class TestInstallGate:
rediscovering it. `update_plugin` also has a git branch that pulls in rediscovering it. `update_plugin` also has a git branch that pulls in
place and never re-downloads; that one is gated separately by place and never re-downloads; that one is gated separately by
`_gate_pulled_commit` and pinned in `TestGitPullGate` below. A third `_gate_pulled_commit` and pinned in `TestGitPullGate` below. A third
route, `install_from_url` (sideloading from a URL), is gated in that route, `install_from_url`, is still ungated — sideloading from a URL
function and pinned in `TestSideloadGate`. All three refuse on the same checks required fields but not the floor.
rule.
""" """
def _install_with_manifest(self, store, manifest, core_version, monkeypatch): def _install_with_manifest(self, store, manifest, core_version, monkeypatch):
@@ -218,73 +217,6 @@ class TestInstallGate:
assert (path / "manifest.json").exists() assert (path / "manifest.json").exists()
class TestSideloadGate:
"""`install_from_url` never looked at the core version.
Sideloading is an explicit act rather than an automatic store update, so
the argument for gating it is different: not "the user did not choose
this", but that the floor states the plugin *cannot run here*. Letting it
through produces the same silent PluginState.ERROR at load that the store
gate exists to prevent, and the user who typed the URL is no better placed
to diagnose it than one who pressed Update.
"""
def _sideload(self, store, manifest, core_version, monkeypatch):
mgr, plugins_dir = store
plugin_id = manifest["id"]
def fake_clone(repo_url, target_path, branches=None):
target_path.mkdir(parents=True, exist_ok=True)
(target_path / "manifest.json").write_text(
json.dumps(manifest), encoding="utf-8")
(target_path / "manager.py").write_text(
"class P: pass\n", encoding="utf-8")
return "main"
monkeypatch.setattr(mgr, "_install_via_git", fake_clone)
monkeypatch.setattr(mgr, "_install_dependencies", lambda *a, **k: True)
import src
monkeypatch.setattr(src, "__version__", core_version)
return mgr.install_from_url("https://example.invalid/plugin"), \
plugins_dir / plugin_id
def test_refuses_a_plugin_that_needs_a_newer_core(self, store, monkeypatch):
manifest = {
"id": "sideload-newer", "name": "Sideload", "class_name": "P",
"display_modes": ["a"], "min_ledmatrix_version": "9.9.9",
}
result, path = self._sideload(store, manifest, "3.2.0", monkeypatch)
assert result["success"] is False
assert "9.9.9" in result["error"], result["error"]
assert not path.exists(), (
"a refused sideload must not leave the plugin installed")
def test_allows_a_compatible_plugin(self, store, monkeypatch):
"""The guard against over-refusing: a gate that blocks everything
passes the test above and breaks sideloading entirely."""
manifest = {
"id": "sideload-fine", "name": "Sideload", "class_name": "P",
"display_modes": ["a"], "min_ledmatrix_version": "3.0.0",
}
result, path = self._sideload(store, manifest, "3.2.0", monkeypatch)
assert result["success"] is True, result.get("error")
assert (path / "manifest.json").exists()
def test_untrustworthy_core_does_not_block_a_2_0_0_floor(
self, store, monkeypatch):
"""Same rule as the other two routes: a v3.1.0 release reports 1.0.0,
and nearly every published manifest floors at 2.0.0."""
manifest = {
"id": "sideload-floored", "name": "Sideload", "class_name": "P",
"display_modes": ["a"], "versions": [{"ledmatrix_min": "2.0.0"}],
}
result, _ = self._sideload(store, manifest, "1.0.0", monkeypatch)
assert result["success"] is True, result.get("error")
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# The git-pull update path — the one route that does not re-download # The git-pull update path — the one route that does not re-download
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
-195
View File
@@ -1,195 +0,0 @@
"""The card helpers the eight scoreboards now share.
These bodies lived in eight byte-identical copies. Moving them here means one
fix reaches every scoreboard — and that a mistake does too, which is what this
file guards. Each case below is one the plugins' own code already handled; the
point is that it keeps handling it.
The functions take ``config``/``logger``/``fonts`` as arguments rather than
reading them off an instance, so a plugin keeps its method and delegates the
body. That is what let all eight adopt this with byte-identical renders.
"""
import logging
import json
import os
import pytest
from src.common import sports_card as C
@pytest.fixture
def log():
return logging.getLogger("test_sports_card")
class TestSettingsLookup:
def test_reads_the_scroll_card_block(self):
cfg = {"scroll_card": {"vs_text": "@"}}
assert C.scroll_card_option(cfg, "vs_text", "VS") == "@"
@pytest.mark.parametrize("cfg", [None, {}, {"scroll_card": None},
{"scroll_card": {"vs_text": None}}])
def test_missing_or_null_falls_back(self, cfg):
"""A null in config means "unset", not "empty string"."""
assert C.scroll_card_option(cfg, "vs_text", "VS") == "VS"
def test_upcoming_center_rejects_unknown_modes(self):
for bad in ("sideways", "", None, 7):
assert C.upcoming_center_mode({"scroll_card": {"upcoming_center": bad}}) == "vs"
assert C.upcoming_center_mode({"scroll_card": {"upcoming_center": "DATE_TIME"}}) == "date_time"
class TestColour:
def test_rgb_list_and_hex_both_work(self):
assert C.element_color({"customization": {"score_text": {"text_color": [1, 2, 3]}}},
"score_text") == (1, 2, 3)
assert C.element_color({"customization": {"score_text": {"text_color": "#ff8000"}}},
"score_text") == (255, 128, 0)
@pytest.mark.parametrize("value", ["nope", "#fff", [1, 2], None, ["a", "b", "c"]])
def test_unusable_colour_falls_back(self, value):
cfg = {"customization": {"score_text": {"text_color": value}}}
assert C.element_color(cfg, "score_text", (9, 9, 9)) == (9, 9, 9)
def test_coerce_rgb_clamps_rather_than_rejecting(self):
assert C.coerce_rgb([300, -5, 20], (0, 0, 0)) == (255, 0, 20)
def test_coerce_rgb_refuses_a_three_character_string(self):
""""123" would otherwise iterate into three digits and yield a colour."""
assert C.coerce_rgb("123", (7, 7, 7)) == (7, 7, 7)
def test_font_colour_is_resolved_by_identity(self):
a, b = object(), object()
cfg = {"customization": {"score_text": {"text_color": [4, 5, 6]}}}
assert C.font_color(cfg, {"score": a, "team": b}, a) == (4, 5, 6)
def test_a_shared_face_gives_up_rather_than_guessing(self):
"""One object used for two elements has no single right colour."""
shared = object()
cfg = {"customization": {"score_text": {"text_color": [4, 5, 6]}}}
assert C.font_color(cfg, {"score": shared, "team": shared}, shared) == (255, 255, 255)
class TestFavourites:
GAME = {"home_abbr": "TB", "away_abbr": "NO", "home_score": "21", "away_score": "17"}
def test_win_loss_and_tie(self):
cfg = {"favorite_teams": ["TB"]}
assert C.favorite_result(cfg, self.GAME) == "win"
assert C.favorite_result({"favorite_teams": ["NO"]}, self.GAME) == "loss"
tied = dict(self.GAME, home_score="3", away_score="3")
assert C.favorite_result(cfg, tied) == "tie"
def test_no_verdict_without_exactly_one_favourite_side(self):
assert C.favorite_result({}, self.GAME) is None
assert C.favorite_result({"favorite_teams": ["TB", "NO"]}, self.GAME) is None
assert C.favorite_result({"favorite_teams": ["SEA"]}, self.GAME) is None
def test_unusable_scores_give_no_verdict(self):
bad = dict(self.GAME, home_score="x")
assert C.favorite_result({"favorite_teams": ["TB"]}, bad) is None
def test_nested_payload_shape_is_read_too(self):
game = {"home_team": {"abbrev": "TB", "score": 9},
"away_team": {"abbrev": "NO", "score": 2}}
assert C.side_score(game, "home") == 9
assert C.side_is_favorite(game, "home", {"TB"}) is True
def test_matches_on_id_where_abbreviations_collide(self):
"""NRL keys favourites by ESPN id; abbreviations are not unique there."""
game = {"home_abbr": "SYD", "home_id": "4321"}
assert C.side_is_favorite(game, "home", {"4321"}) is True
def test_game_and_config_favourites_are_both_used(self):
"""Games carry resolved dynamic groups; config catches later edits."""
game = dict(self.GAME, favorite_teams=["NO"], league="nfl")
assert set(C.favorite_teams_for({"nfl": {"favorite_teams": ["TB"]}}, game)) == {"NO", "TB"}
class TestDateAndTime:
def test_date_formats(self, log):
for fmt, want in [("abbrev", "Sep 5"), ("numeric", "9/5"),
("day_first", "5 Sep"), ("numeric_day_first", "5/9")]:
cfg = {"scroll_card": {"date_format": fmt}}
assert C.format_game_date(cfg, log, "9/5") == want
@pytest.mark.parametrize("raw", ["", "garbage", "13/40", "no/slash/here"])
def test_unparseable_dates_pass_through(self, log, raw):
assert C.format_game_date({}, log, raw) == raw.strip()
def test_24h_conversion(self):
cfg = {"scroll_card": {"time_format": "24h"}}
assert C.format_game_time(cfg, "7:30 PM") == "19:30"
assert C.format_game_time(cfg, "12:00 AM") == "00:00"
assert C.format_game_time(cfg, "12:15 PM") == "12:15"
def test_12h_is_left_alone_and_junk_survives(self):
assert C.format_game_time({}, "7:30 PM") == "7:30 PM"
assert C.format_game_time({"scroll_card": {"time_format": "24h"}}, "soon") == "soon"
def test_a_bad_timezone_falls_back_to_utc(self, log):
"""A typo in config should not blank the card."""
from datetime import timezone
assert C.card_tzinfo({"timezone": "Not/AZone"}, log) is timezone.utc
class TestFontSizing:
def test_snaps_to_the_faces_pixel_grid(self):
assert C.crisp_size("4x6-font.ttf", 6) == 7 # 7px grid
assert C.crisp_size("PressStart2P-Regular.ttf", 10) == 8
assert C.crisp_size("PressStart2P-Regular.ttf", 13) == 16
def test_an_unknown_face_is_never_second_guessed(self):
assert C.crisp_size("SomeUserFont.ttf", 11) == 11
def test_aliases_resolve_before_the_grid_lookup(self):
assert C.crisp_size("four_by_six", 6) == C.crisp_size("4x6-font.ttf", 6)
@pytest.mark.parametrize("desired", [0, -3, None])
def test_unusable_sizes_pass_through_without_raising(self, desired):
"""None reached this in the field; football's variant raised TypeError."""
assert C.crisp_size("4x6-font.ttf", desired) == desired
def test_schema_cache_is_keyed_per_schema_not_globally(self, tmp_path):
"""Two plugins declaring different defaults must not share an answer."""
a, b = tmp_path / "a.json", tmp_path / "b.json"
for path, size in ((a, 11), (b, 22)):
path.write_text(json.dumps({"properties": {"customization": {"properties": {
"score_text": {"properties": {"font_size": {"default": size}}}}}}}))
assert C.schema_font_size(str(a), "score_text") == 11
assert C.schema_font_size(str(b), "score_text") == 22
def test_a_missing_schema_is_not_an_error(self, tmp_path):
assert C.schema_font_size(str(tmp_path / "nope.json"), "score_text") is None
def test_a_configured_size_matching_the_schema_default_is_not_a_choice(self, tmp_path):
"""The web UI writes the whole default block on every save, so
font_size == schema default carries no intent and must not pin the
install to an off-grid size forever."""
schema = tmp_path / "s.json"
schema.write_text(json.dumps({"properties": {"customization": {"properties": {
"score_text": {"properties": {"font_size": {"default": 10}}}}}}}))
got = C.resolve_font_size(str(schema), {"font_size": 10}, "score_text", 10,
"PressStart2P-Regular.ttf")
assert got == 8, "a default-valued size should snap to the grid"
def test_a_real_choice_wins(self, tmp_path):
schema = tmp_path / "s.json"
schema.write_text(json.dumps({"properties": {"customization": {"properties": {
"score_text": {"properties": {"font_size": {"default": 10}}}}}}}))
got = C.resolve_font_size(str(schema), {"font_size": 13}, "score_text", 10,
"PressStart2P-Regular.ttf")
assert got == 13, "an explicit size the user chose is not second-guessed"
class TestTables:
def test_every_font_key_maps_to_an_element(self):
assert set(C.ELEMENT_FOR_FONT) == {"score", "time", "team", "status", "detail", "rank"}
def test_result_colours_cover_every_verdict(self):
assert set(C.FAVORITE_RESULT_COLOR_DEFAULTS) == {"win", "loss", "tie"}
def test_month_and_weekday_tables_are_complete(self):
assert len(C.MONTH_ABBR) == 12 and len(C.WEEKDAY_ABBR) == 7
-258
View File
@@ -1,258 +0,0 @@
"""The shared card geometry, exercised against a host that supplies only what
the mixin's contract names.
The point of these is the contract, not the arithmetic. The mixin reaches for
``display_width``, ``fonts``, ``config`` and six ``sports_card`` delegations
through ``self``, and the eight plugins are what actually provide them. A
stub host that provides exactly the documented surface and nothing else is
what catches the mixin quietly growing a dependency the plugins do not have.
"""
import pytest
from PIL import Image, ImageDraw, ImageFont
from src.common.sports_game_renderer import SportsGameRendererMixin
class Host(SportsGameRendererMixin):
"""The documented contract, and not one attribute more."""
def __init__(self, width=128, height=32, config=None, scroll=None):
self.display_width = width
self.display_height = height
self.config = config or {}
self.logger = _Logger()
self._team_rankings_cache = {}
self._scroll = scroll or {}
font = ImageFont.load_default()
self.fonts = {'score': font, 'time': font, 'detail': font}
self.drawn = []
# -- the six sports_card delegations the mixin calls --
def _scroll_card_option(self, key, default=None):
return self._scroll.get(key, default)
def _upcoming_center_mode(self):
return self._scroll.get('upcoming_center', 'vs')
def _vs_text(self):
return self._scroll.get('vs_text', 'VS')
def _element_color(self, element):
return (255, 255, 255)
def _format_game_date(self, raw, game):
return raw
def _format_game_time(self, raw):
return raw
# -- the one hook whose body genuinely varies per plugin --
def _draw_text_with_outline(self, draw, text, position, font,
fill=None, outline_color=(0, 0, 0)):
self.drawn.append((text, position))
class _Logger:
def debug(self, *a, **k):
pass
def _draw():
return ImageDraw.Draw(Image.new("RGB", (256, 64)))
class TestCenterGap:
def test_explicit_center_gap_wins_outright(self):
assert Host(scroll={'center_gap': 31})._center_gap_width() == 31
def test_explicit_zero_restores_edge_to_edge_logos(self):
# 0 is a real setting, not a falsy miss -- the guard is `>= 0`.
assert Host(scroll={'center_gap': 0})._center_gap_width() == 0
def test_otherwise_it_scales_with_card_width_within_the_clamp(self):
h = Host(width=512)
# 512 * 0.28 = 143, clamped to the 40px ceiling.
assert h._center_gap_width() >= h.CENTER_GAP_MIN_PX
def test_the_gap_never_ends_up_narrower_than_the_score(self):
# This is the bug the measurement exists to prevent: a derived gap
# smaller than the rendered score drew the score over the logos.
h = Host(width=64)
assert h._center_gap_width() >= h._score_reserve_width()
def test_a_junk_ratio_falls_back_to_the_floor(self):
h = Host(scroll={'center_gap_ratio': 'wide'})
assert h._center_gap_width() == h.CENTER_GAP_MIN_PX
class TestNonFiniteSettings:
"""inf reaches int() and raises OverflowError, which the old
`except (TypeError, ValueError)` did not catch -- so one bad config value
aborted the entire card render rather than falling back."""
@pytest.mark.parametrize("bad", [float("inf"), float("-inf")])
def test_a_non_finite_center_gap_falls_back(self, bad):
h = Host(scroll={'center_gap': bad})
assert h._center_gap_width() >= h.CENTER_GAP_MIN_PX
@pytest.mark.parametrize("bad", [float("inf"), float("-inf"), float("nan")])
def test_a_non_finite_ratio_falls_back_to_the_floor(self, bad):
h = Host(scroll={'center_gap_ratio': bad})
assert h._center_gap_width() == h.CENTER_GAP_MIN_PX
@pytest.mark.parametrize("bad", [float("inf"), float("-inf")])
def test_non_finite_clamp_bounds_fall_back(self, bad):
h = Host(scroll={'center_gap_min': bad, 'center_gap_max': bad})
assert h._center_gap_width() == h.CENTER_GAP_MIN_PX
@pytest.mark.parametrize("bad", [float("inf"), float("-inf"), "inf", "-inf", "nan"])
def test_a_non_finite_layout_offset_gives_the_default(self, bad):
cfg = {'customization': {'layout': {'score': {'x_offset': bad}}}}
assert Host(config=cfg)._layout_offset('score', 'x_offset', 7) == 7
def test_a_finite_value_is_still_honoured(self):
# The guard must not swallow ordinary settings.
assert Host(scroll={'center_gap': 31})._center_gap_width() == 31
cfg = {'customization': {'layout': {'score': {'x_offset': -3}}}}
assert Host(config=cfg)._layout_offset('score', 'x_offset', 7) == -3
class TestScoreReserve:
def test_it_measures_the_probe_plus_both_gutters(self):
h = Host()
assert h._score_reserve_width() > 2 * h._SCORE_LOGO_GUTTER_PX
def test_a_wider_probe_reserves_more(self):
class Wide(Host):
_SCORE_PROBE = "000-000"
assert Wide()._score_reserve_width() > Host()._score_reserve_width()
def test_an_unmeasurable_font_reserves_nothing_rather_than_raising(self):
h = Host()
h.fonts = {'score': object()}
assert h._score_reserve_width() == 0
class TestLogoSlot:
def test_the_slot_is_what_is_left_after_the_gap(self):
h = Host(width=128, scroll={'center_gap': 40})
assert h._logo_slot_width() == 44
def test_it_is_not_capped_at_the_card_height(self):
# The height cap is what froze logos at 46px on a 128px card.
h = Host(width=512, height=32, scroll={'center_gap': 40})
assert h._logo_slot_width() > h.display_height
def test_a_gap_wider_than_the_card_still_leaves_a_usable_slot(self):
assert Host(width=64, scroll={'center_gap': 200})._logo_slot_width() == 8
def test_the_cache_key_is_scoped_to_the_slot_not_just_the_name(self):
# One cache dict is shared by renderers of different card widths.
narrow = Host(width=64, scroll={'center_gap': 20})._logo_cache_key("NYY")
wide = Host(width=256, scroll={'center_gap': 20})._logo_cache_key("NYY")
assert narrow != wide
class TestLayoutOffset:
def _host(self, value):
return Host(config={'customization': {'layout': {'score': {'x_offset': value}}}})
def test_it_reads_the_same_block_as_the_full_screen_scorebug(self):
assert self._host(5)._layout_offset('score', 'x_offset') == 5
def test_a_string_offset_from_the_web_ui_is_coerced(self):
assert self._host("-3")._layout_offset('score', 'x_offset') == -3
def test_a_bool_is_not_silently_an_offset_of_one(self):
assert self._host(True)._layout_offset('score', 'x_offset', 9) == 9
@pytest.mark.parametrize("cfg", [{}, {'customization': {}},
{'customization': {'layout': {}}}])
def test_a_missing_block_gives_the_default(self, cfg):
assert Host(config=cfg)._layout_offset('score', 'x_offset', 7) == 7
def test_an_unparseable_offset_gives_the_default(self):
assert self._host("left")._layout_offset('score', 'x_offset', 4) == 4
class TestUpcomingCenter:
def test_none_draws_nothing(self):
h = Host(scroll={'upcoming_center': 'none'})
h._draw_upcoming_center(_draw(), {})
assert h.drawn == []
def test_vs_is_the_default_and_never_a_score(self):
# An upcoming game has not started; the extractor's 0-0 is noise.
h = Host()
h._draw_upcoming_center(_draw(), {'home_score': 0, 'away_score': 0})
assert [t for t, _ in h.drawn] == ['VS']
def test_an_empty_vs_string_draws_nothing(self):
h = Host(scroll={'vs_text': ''})
h._draw_upcoming_center(_draw(), {})
assert h.drawn == []
def test_date_time_stacks_both_lines(self):
h = Host(scroll={'upcoming_center': 'date_time'})
h._draw_upcoming_center(_draw(), {'game_date': 'Sep 19', 'game_time': '7:00 PM'})
assert [t for t, _ in h.drawn] == ['Sep 19', '7:00 PM']
def test_hiding_both_lines_draws_nothing(self):
h = Host(scroll={'upcoming_center': 'date_time',
'show_date': False, 'show_time': False})
h._draw_upcoming_center(_draw(), {'game_date': 'Sep 19', 'game_time': '7:00 PM'})
assert h.drawn == []
class TestUpcomingStatus:
def test_time_on_top_and_date_below_by_default(self):
h = Host()
h._draw_upcoming_game_status(_draw(), {'game_date': 'Sep 19', 'game_time': '7:00 PM'})
assert [t for t, _ in h.drawn] == ['7:00 PM', 'Sep 19']
def test_swap_date_time_reverses_them(self):
h = Host(scroll={'swap_date_time': True})
h._draw_upcoming_game_status(_draw(), {'game_date': 'Sep 19', 'game_time': '7:00 PM'})
assert [t for t, _ in h.drawn] == ['Sep 19', '7:00 PM']
def test_it_stays_out_of_the_way_when_the_centre_already_has_them(self):
# Otherwise the date and time print twice on the same card.
h = Host(scroll={'upcoming_center': 'date_time'})
h._draw_upcoming_game_status(_draw(), {'game_date': 'Sep 19', 'game_time': '7:00 PM'})
assert h.drawn == []
def test_the_bottom_line_is_measured_not_a_fixed_offset(self):
# A fixed -7 ran "Sep 19" past the card wherever the detail font is
# 10px rather than 6px.
h = Host(height=64)
h._draw_upcoming_game_status(_draw(), {'game_date': 'Sep 19', 'game_time': '7:00 PM'})
bottom_y = h.drawn[1][1][1]
assert 0 <= bottom_y < h.display_height
class TestRankings:
def test_set_rankings_cache_replaces_the_cache(self):
h = Host()
h.set_rankings_cache({'UGA': 1})
assert h._team_rankings_cache == {'UGA': 1}
class TestContract:
def test_the_mixin_carries_no_state_of_its_own(self):
# Adoption must be one line on the class statement; a mixin with an
# __init__ would force eight constructors to cooperate.
assert '__init__' not in SportsGameRendererMixin.__dict__
def test_a_host_providing_the_documented_surface_needs_nothing_more(self):
# Host defines exactly what the module docstring names. If the mixin
# grows a new self.* dependency, this is what fails.
h = Host()
h._center_gap_width()
h._logo_slot_width()
h._logo_cache_key("X")
h._layout_offset('score', 'x_offset')
h._upcoming_date_and_time({})
h._draw_upcoming_center(_draw(), {})
h._draw_upcoming_game_status(_draw(), {})
h.set_rankings_cache({})
-376
View File
@@ -1,376 +0,0 @@
"""The shared sports.py mixins: their host contract, and _plugin_dir.
Two things are worth testing here and the rest is not. The 45 method bodies
moved verbatim from the plugins, so they are covered by the plugins' own tests
and by 176 byte-identical safety-harness renders. What is genuinely new is:
1. The contract. Every ``self.<CONSTANT>`` a mixin reads must be defined on the
mixin, or a host that does not happen to declare it raises AttributeError at
runtime. Two were missed on the first pass (_QUALITY_CHOICES and
_RANKING_COVERAGE_SECONDS); the eight plugins all declare them, so nothing
failed -- it would only have bitten a ninth. The test derives the list rather
than restating it, so the next omission fails here instead of in the field.
2. ``_plugin_dir``. This is the only line of genuinely new logic in the move. In
sports.py these methods found config_schema.json with ``__file__``; here that
is src/common/, so the plugin directory has to be recovered from the
instance -- and getting it wrong is silent, costing grid-snapped font sizes
(measured at 81% anti-aliased edges) rather than raising.
"""
import ast
import os
import sys
import types
from abc import ABC
import pytest
from src.common import sports_shared
from src.common.sports_shared import (
SportsCoreSharedMixin, SportsLiveSharedMixin, SportsRecentSharedMixin)
MIXINS = (SportsCoreSharedMixin, SportsLiveSharedMixin, SportsRecentSharedMixin)
def _constants_read_by_mixins():
"""Every ALL-CAPS ``self.X`` the mixin bodies read, found by parsing them."""
tree = ast.parse(open(sports_shared.__file__).read())
names = set()
for node in ast.walk(tree):
if (isinstance(node, ast.Attribute)
and isinstance(node.value, ast.Name)
and node.value.id == "self"
and node.attr.upper() == node.attr):
names.add(node.attr)
return names
class TestHostContract:
def test_every_constant_read_is_also_defined(self):
# Otherwise a host that does not declare it raises AttributeError the
# first time the code path runs -- which for these is mid-render.
missing = sorted(
name for name in _constants_read_by_mixins()
if not any(hasattr(m, name) for m in MIXINS))
assert missing == [], (
f"read but never defined on a mixin: {missing}. Give each a default "
f"on SportsCoreSharedMixin and document it in the module docstring.")
@pytest.mark.parametrize("name,expected", [
("_QUALITY_CHOICES", frozenset({"any", "ranked"})),
("_RANKING_COVERAGE_SECONDS", 3600),
("_SCORE_PROBE_TEXT", "00-00"),
("_FONT_DESIGN_HEIGHT", 32),
])
def test_defaults_match_what_the_plugins_ship(self, name, expected):
# The eight plugins declare their own copies, which shadow these. The
# values must still agree, or a ninth plugin inheriting the default
# behaves differently from the eight.
assert getattr(SportsCoreSharedMixin, name) == expected
def test_only_the_recent_mixin_carries_a_constructor(self):
# SportsCore and SportsLive keep their own __init__ -- those differ per
# plugin. SportsRecent.__init__ was one of the 48 byte-identical bodies,
# so it moved with the rest; that is deliberate, not an oversight.
assert "__init__" not in SportsCoreSharedMixin.__dict__
assert "__init__" not in SportsLiveSharedMixin.__dict__
assert "__init__" in SportsRecentSharedMixin.__dict__
def test_the_recent_constructor_still_chains_to_the_host(self):
"""Its zero-arg super() binds to where it is DEFINED, not where it is used.
Moving a body containing bare ``super()`` is the one move that can
change meaning: the compiler closes over __class__ = the defining class,
so after the move that is SportsRecentSharedMixin rather than the
plugin's SportsRecent. It still works only because the mixin is listed
first, leaving the host class next in the MRO -- adopt it in the other
order and the chain silently skips the host's __init__.
"""
calls = []
class Host:
def __init__(self, config, display_manager, cache_manager, logger, sport_key):
calls.append(sport_key)
self.mode_config = {}
class Recent(SportsRecentSharedMixin, Host):
pass
inst = Recent({}, None, None, None, "nhl")
assert calls == ["nhl"], "the host constructor must still run"
assert inst.current_game_index == 0
assert inst.update_interval == 3600
assert inst._zero_clock_timestamps == {}
def test_adopting_the_recent_mixin_second_would_skip_the_host(self):
# The failure mode the ordering above prevents, pinned so nobody
# "tidies" the base list.
calls = []
class Host:
def __init__(self, *a):
calls.append(a)
self.mode_config = {}
class Wrong(Host, SportsRecentSharedMixin):
pass
Wrong({}, None, None, None, "nhl")
# Host.__init__ wins and the mixin's setup never runs at all.
assert not hasattr(Wrong({}, None, None, None, "nhl"), "current_game_index")
class _Host(SportsCoreSharedMixin):
pass
def _write_plugin(tmp_path, name="fakeplug", schema=True):
"""A throwaway package on sys.path, with or without a config_schema.json."""
d = tmp_path / name
d.mkdir()
(d / "__init__.py").write_text("")
(d / "mod.py").write_text("class Leaf:\n pass\n")
if schema:
(d / "config_schema.json").write_text(
'{"properties": {"customization": {"properties": '
'{"score": {"properties": {"font_size": {"default": 16}}}}}}}')
return d
class TestPluginDir:
def test_it_finds_the_directory_holding_config_schema_json(self, tmp_path, monkeypatch):
d = _write_plugin(tmp_path)
monkeypatch.syspath_prepend(str(tmp_path))
mod = __import__("fakeplug.mod", fromlist=["Leaf"])
host = type("H", (mod.Leaf, SportsCoreSharedMixin), {})()
assert host._plugin_dir() == str(d)
def test_a_class_built_by_type_still_resolves(self, tmp_path, monkeypatch):
# SportsCore is an ABC, so type(name, bases, ns) reports __module__ as
# "abc" rather than the plugin -- which is exactly what the plugins'
# own tests build. Walking the MRO is what steps past it.
d = _write_plugin(tmp_path, "abcplug")
monkeypatch.syspath_prepend(str(tmp_path))
mod = __import__("abcplug.mod", fromlist=["Leaf"])
class Base(SportsCoreSharedMixin, mod.Leaf, ABC):
pass
synthetic = type("Probe", (Base,), {})
assert synthetic.__module__ == "abc", "precondition: the trap this guards"
assert synthetic.__new__(synthetic)._plugin_dir() == str(d)
def test_it_returns_none_when_no_schema_is_anywhere_on_the_mro(self, tmp_path, monkeypatch):
d = _write_plugin(tmp_path, "noschema", schema=False)
monkeypatch.syspath_prepend(str(tmp_path))
mod = __import__("noschema.mod", fromlist=["Leaf"])
host = type("H", (mod.Leaf, SportsCoreSharedMixin), {})()
# None rather than a wrong guess: _schema_font_size then caches empty
# and every element keeps its own default.
assert host._plugin_dir() is None
def test_it_never_returns_the_core_module_directory(self):
# The bug this replaced: __file__ pointed at src/common/, so the schema
# was never found and font sizes silently stopped snapping to the grid.
host = _Host()
core_common = os.path.dirname(os.path.abspath(sports_shared.__file__))
assert host._plugin_dir() != core_common
def test_a_module_with_no_file_is_skipped_not_crashed_on(self, monkeypatch):
# Namespace packages and some frozen/dynamic modules have no __file__.
ghost = types.ModuleType("ghost_no_file")
if hasattr(ghost, "__file__"):
del ghost.__file__
monkeypatch.setitem(sys.modules, "ghost_no_file", ghost)
cls = type("H", (SportsCoreSharedMixin,), {"__module__": "ghost_no_file"})
assert cls.__new__(cls)._plugin_dir() is None
class TestSchemaFontSize:
def test_it_reads_the_plugin_schema_not_the_cores(self, tmp_path, monkeypatch):
d = _write_plugin(tmp_path, "sizeplug")
monkeypatch.syspath_prepend(str(tmp_path))
mod = __import__("sizeplug.mod", fromlist=["Leaf"])
host = type("H", (mod.Leaf, SportsCoreSharedMixin), {})()
assert host._schema_font_size("score") == 16
def test_an_unknown_element_is_none(self, tmp_path, monkeypatch):
_write_plugin(tmp_path, "unkplug")
monkeypatch.syspath_prepend(str(tmp_path))
mod = __import__("unkplug.mod", fromlist=["Leaf"])
host = type("H", (mod.Leaf, SportsCoreSharedMixin), {})()
assert host._schema_font_size("nonesuch") is None
def test_an_empty_key_is_none_without_touching_the_disk(self):
assert _Host()._schema_font_size("") is None
def test_a_missing_schema_degrades_to_none_rather_than_raising(self, tmp_path, monkeypatch):
_write_plugin(tmp_path, "bareplug", schema=False)
monkeypatch.syspath_prepend(str(tmp_path))
mod = __import__("bareplug.mod", fromlist=["Leaf"])
host = type("H", (mod.Leaf, SportsCoreSharedMixin), {})()
assert host._schema_font_size("score") is None
class _LiveHost(SportsLiveSharedMixin):
"""The documented contract for the live mixin, and nothing else."""
def __init__(self, no_data_interval=300, stale_game_timeout=600, over=()):
self.no_data_interval = no_data_interval
self.stale_game_timeout = stale_game_timeout
self.game_update_timestamps = {}
self._over = set(over)
class _L:
def __getattr__(self, _n):
return lambda *a, **k: None
self.logger = _L()
def _is_game_really_over(self, game):
return game.get("id") in self._over
class TestLiveMixin:
"""These three moved to the core, so they are tested here.
They were already covered by hockey's and lacrosse's own tests, but those
two plugins disable live mode in their safety-harness fixtures, so the 176
renders never exercise this path. Testing the mixin directly means the
coverage no longer depends on which plugin happens to have a unit test.
"""
def test_a_stale_game_is_dropped_and_forgotten(self):
h = _LiveHost(stale_game_timeout=600)
import time as _t
h.game_update_timestamps["g1"] = {"last_seen": _t.time() - 5000}
games = [{"id": "g1", "home_abbr": "H", "away_abbr": "A"}]
h._detect_stale_games(games)
assert games == []
assert "g1" not in h.game_update_timestamps, "its timestamp must go too"
def test_a_fresh_game_survives(self):
h = _LiveHost(stale_game_timeout=600)
import time as _t
h.game_update_timestamps["g1"] = {"last_seen": _t.time() - 5}
games = [{"id": "g1"}]
h._detect_stale_games(games)
assert len(games) == 1
def test_a_game_never_seen_is_not_treated_as_stale(self):
# last_seen 0 means "no reading", not "seen at the epoch".
h = _LiveHost()
games = [{"id": "g1"}]
h._detect_stale_games(games)
assert len(games) == 1
def test_a_game_with_no_id_is_left_alone(self):
h = _LiveHost()
games = [{"home_abbr": "H"}]
h._detect_stale_games(games)
assert len(games) == 1
def test_a_finished_game_is_dropped_even_when_fresh(self):
h = _LiveHost(over=("g2",))
games = [{"id": "g1"}, {"id": "g2"}]
h._detect_stale_games(games)
assert [g["id"] for g in games] == ["g1"]
def test_removing_several_does_not_skip_any(self):
# It iterates a copy for exactly this reason; mutating the live list
# while looping would step over the element after each removal.
h = _LiveHost(over=("g1", "g2", "g3"))
games = [{"id": "g1"}, {"id": "g2"}, {"id": "g3"}]
h._detect_stale_games(games)
assert games == []
def test_the_idle_interval_escalates_with_the_empty_streak(self):
h = _LiveHost(no_data_interval=60)
h.live_idle_max_interval = 100000
base = h._idle_live_interval()
h._empty_live_streak = 6
short = h._idle_live_interval()
h._empty_live_streak = 24
long = h._idle_live_interval()
assert base < short < long
def test_the_ceiling_bounds_even_the_unescalated_interval(self):
# base > ceiling is a reachable config: the two settings are
# independent integers with no cross-validation. Returning base
# unclamped made the wait SHRINK as the streak grew.
h = _LiveHost(no_data_interval=3600)
h.live_idle_max_interval = 900
h._empty_live_streak = 0
assert h._idle_live_interval() == 900
h._empty_live_streak = 24
assert h._idle_live_interval() == 900
def test_finding_a_live_game_resets_the_streak(self):
h = _LiveHost()
h._note_live_fetch(False)
h._note_live_fetch(False)
assert h._empty_live_streak == 2
h._note_live_fetch(True)
assert h._empty_live_streak == 0
def test_the_streak_starts_from_absent_state(self):
# The host is not required to pre-declare _empty_live_streak.
h = _LiveHost()
assert not hasattr(h, "_empty_live_streak")
h._note_live_fetch(False)
assert h._empty_live_streak == 1
class TestPluginDirIsToldNotDeduced:
"""The regression that shipped: _plugin_dir returned None in production.
The first version walked the MRO for a class whose module sits beside a
config_schema.json. That passes when a test imports the plugin directly --
which is how it was verified -- and returns None under the real plugin
loader, which imports modules by a path that leaves no such entry on the
MRO.
Silent, and expensive: no schema means _schema_font_size returns None for
every element, so a configured size equal to the schema default stops
looking like a default, is treated as a deliberate choice, and skips the
grid snap. 4x6-font.ttf then renders at 6 rather than 7 -- 3px-wide glyphs
instead of 4px. On a 256x64 panel that made the odds, the records and the
date row illegible. A user counted the pixels; no gate here caught it.
"""
def test_a_declared_directory_is_used(self, tmp_path):
d = _write_plugin(tmp_path, "declared")
host = type("H", (SportsCoreSharedMixin,), {"_PLUGIN_DIR": str(d)})()
assert host._plugin_dir() == str(d)
def test_it_works_when_no_module_on_the_mro_helps(self, tmp_path, monkeypatch):
"""The production case: nothing on the MRO sits beside a schema."""
d = _write_plugin(tmp_path, "loaderstyle")
# A class whose module is not importable by name, as the loader produces.
cls = type("Loaded", (SportsCoreSharedMixin,), {"_PLUGIN_DIR": str(d)})
cls.__module__ = "a.module.name.that.is.not.in.sys.modules"
assert cls.__new__(cls)._plugin_dir() == str(d), (
"the declared directory must win when the MRO walk cannot help")
def test_without_it_the_mro_walk_would_have_failed(self):
# Pin the precondition, so this test still means something if the
# fallback is ever changed.
cls = type("Orphan", (SportsCoreSharedMixin,), {})
cls.__module__ = "not.a.real.module"
assert cls.__new__(cls)._plugin_dir() is None
def test_a_declared_directory_without_a_schema_is_not_trusted(self, tmp_path):
# A stale or wrong path must not shadow the fallback.
empty = tmp_path / "noschema"
empty.mkdir()
d = _write_plugin(tmp_path, "realone")
monkey = type("H", (SportsCoreSharedMixin,), {"_PLUGIN_DIR": str(empty)})
assert monkey.__new__(monkey)._plugin_dir() != str(empty)
def test_the_font_size_consequence(self, tmp_path):
"""End to end: a declared directory restores the schema lookup."""
d = _write_plugin(tmp_path, "sizeconseq")
host = type("H", (SportsCoreSharedMixin,), {"_PLUGIN_DIR": str(d)})()
assert host._schema_font_size("score") == 16, (
"without the schema this is None, which is what made a configured "
"size look user-chosen and skipped the grid snap")
+1 -8
View File
@@ -83,11 +83,11 @@ plugin_system_config = config.get('plugin_system', {})
plugins_dir_name = plugin_system_config.get('plugins_directory', 'plugin-repos') plugins_dir_name = plugin_system_config.get('plugins_directory', 'plugin-repos')
# Resolve plugin directory - handle both absolute and relative paths # Resolve plugin directory - handle both absolute and relative paths
project_root = Path(__file__).parent.parent
if os.path.isabs(plugins_dir_name): if os.path.isabs(plugins_dir_name):
plugins_dir = Path(plugins_dir_name) plugins_dir = Path(plugins_dir_name)
else: else:
# If relative, resolve relative to the project root (LEDMatrix directory) # If relative, resolve relative to the project root (LEDMatrix directory)
project_root = Path(__file__).parent.parent
plugins_dir = project_root / plugins_dir_name plugins_dir = project_root / plugins_dir_name
plugin_manager = PluginManager( plugin_manager = PluginManager(
@@ -157,7 +157,6 @@ _health_monitor_initialized = False
# Register blueprints # Register blueprints
from web_interface.blueprints.pages_v3 import pages_v3 from web_interface.blueprints.pages_v3 import pages_v3
from web_interface.blueprints.api_v3 import api_v3 from web_interface.blueprints.api_v3 import api_v3
from web_interface.blueprints.composer import composer_bp
# Initialize managers in blueprints # Initialize managers in blueprints
pages_v3.config_manager = config_manager pages_v3.config_manager = config_manager
@@ -174,11 +173,6 @@ api_v3.operation_queue = operation_queue
api_v3.plugin_state_manager = plugin_state_manager api_v3.plugin_state_manager = plugin_state_manager
api_v3.operation_history = operation_history api_v3.operation_history = operation_history
api_v3.health_monitor = health_monitor api_v3.health_monitor = health_monitor
composer_bp.config_manager = config_manager
composer_bp.plugin_manager = plugin_manager
composer_bp.plugins_dir = plugins_dir
composer_bp.project_root = project_root
# Initialize cache manager for API endpoints # Initialize cache manager for API endpoints
from src.cache_manager import CacheManager from src.cache_manager import CacheManager
api_v3.cache_manager = CacheManager() api_v3.cache_manager = CacheManager()
@@ -206,7 +200,6 @@ except Exception as _hm_err: # pragma: no cover - defensive startup guard
app.register_blueprint(pages_v3, url_prefix='') app.register_blueprint(pages_v3, url_prefix='')
app.register_blueprint(pages_v3, url_prefix='/v3', name='pages_v3_legacy') app.register_blueprint(pages_v3, url_prefix='/v3', name='pages_v3_legacy')
app.register_blueprint(api_v3, url_prefix='/api/v3') app.register_blueprint(api_v3, url_prefix='/api/v3')
app.register_blueprint(composer_bp, url_prefix='/composer')
# Route to serve plugin asset files (registered on main app, not blueprint, for /assets/... path) # Route to serve plugin asset files (registered on main app, not blueprint, for /assets/... path)
@app.route('/assets/plugins/<plugin_id>/uploads/<path:filename>', methods=['GET']) @app.route('/assets/plugins/<plugin_id>/uploads/<path:filename>', methods=['GET'])
File diff suppressed because it is too large Load Diff
-3
View File
@@ -426,9 +426,6 @@ a, button, input, select, textarea {
.md\:hidden { display: none; } .md\:hidden { display: none; }
.md\:block { display: block; } .md\:block { display: block; }
.md\:w-auto { width: auto; } .md\:w-auto { width: auto; }
/* composer.html labels its toolbar buttons `hidden md:inline`, so without
this the label is hidden at every width and the buttons stay icon-only. */
.md\:inline { display: inline; }
} }
@media (min-width: 1024px) { @media (min-width: 1024px) {
File diff suppressed because it is too large Load Diff
@@ -1,817 +0,0 @@
/**
* ComposerCanvas — stateless LED matrix canvas renderer.
*
* Coordinate system: LED pixels (integers). All drawing multiplies by SCALE.
* PIL draw.text(x,y) is top-left; canvas fillText(x,y) is baseline.
* → Canvas text cy = (actualY + fontSizePx) * SCALE
*
* Anchors: element x/y are offsets from their anchor point:
* xAnchor=null/'left' → x is fixed offset from left
* xAnchor='center' → x is offset from width/2
* xAnchor='right' → x is offset inward from right edge
* yAnchor follows the same pattern with 'top'/'middle'/'bottom'
*
* Breakpoints: elements with minWidth > currentMatrixW are rendered at 25% opacity.
*
* Resize handles: drawn on selected rectangles; 8 handles (corners + edge mids).
*/
/* eslint-disable security/detect-object-injection --
Every hit in this file is either an array index (this.elements[idx],
rawVals[i]) or a lookup on a module-private map keyed by an internal
element type (_HANDLE_CURSORS, ELEMENT_DEFAULTS, previewValues). None of
them takes an attacker-supplied property name, so none can reach a
prototype. Disabled per file rather than eight times inline. */
window.ComposerCanvas = (() => {
'use strict';
let _canvas = null;
let _ctx = null;
let _showGrid = true;
//: Element types the canvas draws resize handles for. Exported because the
//: editor has to gate its resize and hover behaviour on exactly this list --
//: the two had drifted, so handles appeared on five shapes that could not
//: actually be resized.
const RESIZABLE_TYPES = ['rectangle', 'rounded_rectangle', 'ellipse', 'arc',
'gauge', 'sparkline'];
const DISPLAY_PRESETS = [
{ label: '64×32', w: 64, h: 32 },
{ label: '128×32', w: 128, h: 32 },
{ label: '128×64', w: 128, h: 64 },
{ label: '256×32', w: 256, h: 32 },
{ label: '256×64', w: 256, h: 64 },
];
const FONT_MAP = {
press_start: { family: "'PressStart2P', monospace", sizePx: 8, charW: 8 },
four_by_six: { family: 'monospace', sizePx: 6, charW: 4 },
five_by_seven: { family: 'monospace', sizePx: 7, charW: 5 },
};
const ELEMENT_DEFAULTS = {
text: {
text: 'Hello', font: 'press_start',
r: 255, g: 255, b: 255,
text2: '', lineSpacing: 2, textAlign: 'left',
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
},
dynamic_text: {
binding: { source: 'config', key: '', format: null },
font: 'press_start', textAlign: 'left',
r: 255, g: 200, b: 100,
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
},
clock: {
format: '%H:%M', font: 'press_start',
r: 100, g: 255, b: 100,
format2: '', lineSpacing: 2, textAlign: 'left',
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
},
rectangle: {
width: 20, height: 8,
fillR: 0, fillG: 0, fillB: 128, hasFill: true,
outR: 255, outG: 255, outB: 255, hasOutline: true,
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
},
ellipse: {
width: 24, height: 12,
fillR: 0, fillG: 100, fillB: 200, hasFill: true,
outR: 100, outG: 180, outB: 255, hasOutline: true,
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
},
arc: {
width: 24, height: 24,
startAngle: 0, endAngle: 270, lineWidth: 2,
r: 255, g: 200, b: 0,
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
},
pixel: {
r: 255, g: 255, b: 255,
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
},
rounded_rectangle: {
width: 24, height: 10, borderRadius: 3,
fillR: 0, fillG: 80, fillB: 180, hasFill: true,
outR: 120, outG: 180, outB: 255, hasOutline: true,
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
},
line: {
x0: 0, y0: 16, x1: 63, y1: 16,
r: 180, g: 180, b: 180, lineWidth: 1,
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
},
divider: {
orientation: 'horizontal', y: 16, x: 64,
r: 100, g: 100, b: 100,
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
},
progress_bar: {
barWidth: 60, barHeight: 6,
binding: { source: 'config', key: '', format: null },
r: 80, g: 200, b: 80,
bgR: 30, bgG: 30, bgB: 30, hasBg: true,
outR: 100, outG: 100, outB: 100, hasOutline: true,
previewPct: 65,
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
},
countdown: {
binding: { source: 'config', key: '', format: null },
countdownFormat: 'dh',
font: 'four_by_six', textAlign: 'left',
r: 255, g: 180, b: 0,
previewText: '42d 3h',
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
},
marquee: {
text: 'Scrolling text', font: 'press_start',
r: 255, g: 255, b: 255,
scrollSpeed: 1, gap: 16, direction: 'left',
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
},
section: {
label: 'Section',
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
},
pips: {
count: 5, filled: 3, pipSize: 4, pipSpacing: 2,
r: 255, g: 200, b: 0,
emptyR: 50, emptyG: 50, emptyB: 50, showEmpty: true,
binding: { source: 'config', key: '', format: null },
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
},
sparkline: {
width: 40, height: 12,
barCount: 8, barSpacing: 1,
r: 80, g: 200, b: 120,
bgR: 30, bgG: 30, bgB: 30, hasBg: false,
binding: { source: 'config', key: '', format: null },
previewData: '0.3,0.6,0.4,0.8,0.5,0.9,0.7,0.85',
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
},
gauge: {
width: 32, height: 32,
startAngle: 135, endAngle: 45, lineWidth: 3,
binding: { source: 'config', key: '', format: null },
r: 80, g: 220, b: 80,
trackR: 40, trackG: 40, trackB: 40, hasTrack: true,
showLabel: true, font: 'four_by_six', labelR: 200, labelG: 200, labelB: 200,
previewPct: 65,
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
},
};
// ── Anchor resolution ────────────────────────────────────────────────
function resolveAnchor(val, anchor, dim) {
if (!anchor || anchor === 'left' || anchor === 'top') return val;
if (anchor === 'center' || anchor === 'middle') return Math.floor(dim / 2) + val;
if (anchor === 'right' || anchor === 'bottom') return dim - val;
return val;
}
function computeActualPos(el, matrixW, matrixH) {
const ax = resolveAnchor(el.x ?? el.x0 ?? 0, el.xAnchor, matrixW);
const ay = resolveAnchor(el.y ?? el.y0 ?? 0, el.yAnchor, matrixH);
return { x: ax, y: ay };
}
// ── Bounding box (LED pixel space) ──────────────────────────────────
function getBoundingBox(el, matrixW, matrixH) {
const { x: ax, y: ay } = computeActualPos(el, matrixW, matrixH);
const finfo = FONT_MAP[el.font] || FONT_MAP.press_start;
switch (el.type) {
case 'text': {
const t1 = el.text || '', t2 = el.text2 || '';
const w = Math.max(t1.length, t2.length) * finfo.charW;
const h = t2 ? finfo.sizePx * 2 + (el.lineSpacing ?? 2) : finfo.sizePx;
const bx = el.textAlign === 'center' ? ax - w / 2 : el.textAlign === 'right' ? ax - w : ax;
return { x: bx, y: ay, w, h };
}
case 'dynamic_text': {
const key = el.binding?.key || '?';
const w = (`{${key}}`).length * finfo.charW;
const bx = el.textAlign === 'center' ? ax - w / 2 : el.textAlign === 'right' ? ax - w : ax;
return { x: bx, y: ay, w, h: finfo.sizePx };
}
case 'clock': {
const t1 = el.format || '%H:%M', t2 = el.format2 || '';
const w = Math.max(t1.length, t2.length) * finfo.charW;
const h = t2 ? finfo.sizePx * 2 + (el.lineSpacing ?? 2) : finfo.sizePx;
const bx = el.textAlign === 'center' ? ax - w / 2 : el.textAlign === 'right' ? ax - w : ax;
return { x: bx, y: ay, w, h };
}
case 'countdown': {
const pt = el.previewText || '--d --h';
const w = pt.length * finfo.charW;
const bx = el.textAlign === 'center' ? ax - w / 2 : el.textAlign === 'right' ? ax - w : ax;
return { x: bx, y: ay, w, h: finfo.sizePx };
}
case 'rectangle':
case 'rounded_rectangle':
case 'ellipse':
case 'arc':
return { x: ax, y: ay, w: el.width, h: el.height };
case 'pixel':
return { x: ax, y: ay, w: 1, h: 1 };
case 'line': {
// Same anchor offset the draw path applies, or the hit box sits where
// the line used to be.
const dx = ax - el.x0, dy = ay - el.y0;
return {
x: Math.min(el.x0, el.x1) + dx, y: Math.min(el.y0, el.y1) + dy,
w: Math.max(1, Math.abs(el.x1 - el.x0)),
h: Math.max(1, Math.abs(el.y1 - el.y0)),
};
}
case 'divider':
return el.orientation === 'horizontal'
? { x: 0, y: ay, w: matrixW, h: 1 }
: { x: ax, y: 0, w: 1, h: matrixH };
case 'progress_bar':
return { x: ax, y: ay, w: el.barWidth ?? 60, h: el.barHeight ?? 6 };
case 'marquee': {
const mfinfo = FONT_MAP[el.font] || FONT_MAP.press_start;
return { x: 0, y: ay, w: matrixW, h: mfinfo.sizePx };
}
case 'gauge':
return { x: ax, y: ay, w: el.width ?? 32, h: el.height ?? 32 };
case 'sparkline':
return { x: ax, y: ay, w: el.width ?? 40, h: el.height ?? 12 };
case 'pips': {
const pc = el.count ?? 5, ps = el.pipSize ?? 4, pg = el.pipSpacing ?? 2;
return { x: ax, y: ay, w: pc * ps + (pc - 1) * pg, h: ps };
}
case 'section': {
// Was 0x0, so the element was unselectable except through the 3px
// hit-test padding and drew nothing at all -- a user adding one from
// the palette saw an empty canvas.
// Same font resolution as the draw case below, or the box will not
// match the glyphs: getBoundingBox's shared `finfo` falls back to
// press_start, and a section has no font of its own.
const sinfo = FONT_MAP[el.font] || FONT_MAP.four_by_six;
const label = el.label || 'Section';
return { x: ax, y: ay, w: label.length * sinfo.charW, h: sinfo.sizePx };
}
default:
return { x: ax, y: ay, w: 4, h: 4 };
}
}
// ── Resize handle support ─────────────────────────────────────────────
// Returns 8 handle points for a rectangle in LED pixel space
function _getRectHandles(el, matrixW, matrixH) {
const { x: ax, y: ay } = computeActualPos(el, matrixW, matrixH);
const w = el.width, h = el.height;
const cx = ax + w / 2, cy = ay + h / 2;
return {
nw: { x: ax, y: ay },
n: { x: cx, y: ay },
ne: { x: ax + w, y: ay },
w: { x: ax, y: cy },
e: { x: ax + w, y: cy },
sw: { x: ax, y: ay + h },
s: { x: cx, y: ay + h },
se: { x: ax + w, y: ay + h },
};
}
// Returns the handle direction under LED-space point (lx, ly), or null
function getResizeHandle(el, lx, ly, matrixW, matrixH) {
if (!RESIZABLE_TYPES.includes(el.type)) return null;
const handles = _getRectHandles(el, matrixW, matrixH);
const PAD = 4;
for (const [dir, pt] of Object.entries(handles)) {
if (Math.abs(lx - pt.x) <= PAD && Math.abs(ly - pt.y) <= PAD) return dir;
}
return null;
}
const _HANDLE_CURSORS = {
nw: 'nw-resize', n: 'n-resize', ne: 'ne-resize',
w: 'w-resize', e: 'e-resize',
sw: 'sw-resize', s: 's-resize', se: 'se-resize',
};
function getCursorForHandle(handle) {
return _HANDLE_CURSORS[handle] || 'crosshair';
}
// ── Hit test ─────────────────────────────────────────────────────────
function hitTest(el, lx, ly, matrixW, matrixH) {
const PAD = 3;
const bb = getBoundingBox(el, matrixW, matrixH);
return (
lx >= bb.x - PAD && lx <= bb.x + bb.w + PAD &&
ly >= bb.y - PAD && ly <= bb.y + bb.h + PAD
);
}
// ── Draw a single element ─────────────────────────────────────────────
function _drawElement(ctx, el, SCALE, matrixW, matrixH, opts = {}) {
const s = SCALE;
const { x: ax, y: ay } = computeActualPos(el, matrixW, matrixH);
const belowBreakpoint = el.minWidth > 0 && matrixW < el.minWidth;
const hidden = el.visible === false;
ctx.save();
if (hidden) ctx.globalAlpha = 0.12;
else if (belowBreakpoint) ctx.globalAlpha = 0.25;
// Blink animation: when blinkOff, fully hide blinking elements
if (el.blink) {
if (opts.blinkOff) { ctx.restore(); return; }
ctx.globalAlpha *= 0.55;
}
// Helper: compute draw X for text alignment
// biome-ignore lint/correctness/useQwikValidLexicalScope: not Qwik -- this is an Alpine.js component, and the rule is about Qwik's $() serialization boundary, which does not exist here.
const _textX = (text, finfo) => {
const tw = text.length * finfo.charW * s;
if (el.textAlign === 'center') return ax * s - tw / 2;
if (el.textAlign === 'right') return ax * s - tw;
return ax * s;
};
try {
switch (el.type) {
case 'section': {
// A design-time label: it marks a region for the author and is not
// emitted into the generated plugin. There was no case here at all,
// so adding "Section Label" from the palette drew nothing and left
// the user with an apparently broken control.
const sfinfo = FONT_MAP[el.font] || FONT_MAP.four_by_six;
ctx.font = `${sfinfo.sizePx * s}px ${sfinfo.family}`;
ctx.fillStyle = `rgba(${el.r ?? 120},${el.g ?? 120},${el.b ?? 120},0.85)`;
ctx.textBaseline = 'top';
ctx.fillText(el.label || 'Section', ax * s, ay * s);
break;
}
case 'text':
case 'dynamic_text':
case 'clock': {
const finfo = FONT_MAP[el.font] || FONT_MAP.press_start;
const key = el.binding?.key || '?';
const pv = opts.previewValues?.[key];
// Substitute {variable} tokens in text using previewValues
// biome-ignore lint/correctness/useQwikValidLexicalScope: not Qwik -- this is an Alpine.js component, and the rule is about Qwik's $() serialization boundary, which does not exist here.
const _subVars = str => (str || '').replace(/\{(\w+)\}/g, (_, k) => {
const v = opts.previewValues?.[k];
return v !== undefined && v !== '' ? String(v) : `{${k}}`;
});
const displayText =
el.type === 'text' ? _subVars(el.text || '')
: el.type === 'clock' ? (el.format || '%H:%M')
: (pv !== undefined && pv !== '' ? String(pv) : `{${key}}`);
ctx.font = `${finfo.sizePx * s}px ${finfo.family}`;
ctx.fillStyle = `rgb(${el.r},${el.g},${el.b})`;
ctx.fillText(displayText, _textX(displayText, finfo), (ay + finfo.sizePx) * s);
// Second line (text and clock)
if (el.type === 'text' && el.text2) {
const t2 = _subVars(el.text2);
const y2 = ay + finfo.sizePx + (el.lineSpacing ?? 2);
ctx.fillText(t2, _textX(t2, finfo), (y2 + finfo.sizePx) * s);
}
if (el.type === 'clock' && el.format2) {
const y2 = ay + finfo.sizePx + (el.lineSpacing ?? 2);
ctx.fillText(el.format2, _textX(el.format2, finfo), (y2 + finfo.sizePx) * s);
}
break;
}
case 'countdown': {
const finfo = FONT_MAP[el.font] || FONT_MAP.press_start;
const t = el.previewText || '--d --h';
ctx.font = `${finfo.sizePx * s}px ${finfo.family}`;
ctx.fillStyle = `rgb(${el.r},${el.g},${el.b})`;
ctx.fillText(t, _textX(t, finfo), (ay + finfo.sizePx) * s);
break;
}
case 'rectangle': {
const rx = ax * s, ry = ay * s;
const rw = el.width * s, rh = el.height * s;
if (el.hasFill) {
ctx.fillStyle = `rgb(${el.fillR},${el.fillG},${el.fillB})`;
ctx.fillRect(rx, ry, rw, rh);
}
if (el.hasOutline) {
ctx.strokeStyle = `rgb(${el.outR},${el.outG},${el.outB})`;
// 1 LED pixel, not 1 canvas pixel: at SCALE>1 an unscaled stroke
// renders thinner than the geometry it outlines.
ctx.lineWidth = s;
ctx.strokeRect(rx, ry, rw, rh);
}
break;
}
case 'ellipse': {
const cx = (ax + el.width / 2) * s;
const cy = (ay + el.height / 2) * s;
const lwPx = s; // 1 LED pixel
// Inset by half the stroke, which straddles the path, so the outline
// stays within the element's bounds.
const rx = Math.max(0, (el.width / 2) * s - (el.hasOutline ? lwPx / 2 : 0));
const ry = Math.max(0, (el.height / 2) * s - (el.hasOutline ? lwPx / 2 : 0));
ctx.beginPath();
ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2);
if (el.hasFill) {
ctx.fillStyle = `rgb(${el.fillR},${el.fillG},${el.fillB})`;
ctx.fill();
}
if (el.hasOutline) {
ctx.strokeStyle = `rgb(${el.outR},${el.outG},${el.outB})`;
ctx.lineWidth = lwPx;
ctx.stroke();
}
break;
}
case 'arc': {
const cx = (ax + el.width / 2) * s;
const cy = (ay + el.height / 2) * s;
const lwPx = Math.max(1, el.lineWidth || 2) * s;
const rx = Math.max(0, (el.width / 2) * s - lwPx / 2);
const ry = Math.max(0, (el.height / 2) * s - lwPx / 2);
// PIL: 0°=right, clockwise. Canvas: same with anticlockwise=false
const startRad = (el.startAngle ?? 0) * Math.PI / 180;
const endRad = (el.endAngle ?? 270) * Math.PI / 180;
ctx.beginPath();
ctx.ellipse(cx, cy, rx, ry, 0, startRad, endRad, false);
ctx.strokeStyle = `rgb(${el.r},${el.g},${el.b})`;
ctx.lineWidth = lwPx;
ctx.stroke();
break;
}
case 'pixel': {
ctx.fillStyle = `rgb(${el.r},${el.g},${el.b})`;
ctx.fillRect(ax * s, ay * s, s, s);
break;
}
case 'rounded_rectangle': {
const rx = ax * s, ry = ay * s;
const rw = el.width * s, rh = el.height * s;
const rad = Math.min((el.borderRadius ?? 3) * s, rw / 2, rh / 2);
ctx.beginPath();
ctx.roundRect(rx, ry, rw, rh, rad);
if (el.hasFill) {
ctx.fillStyle = `rgb(${el.fillR},${el.fillG},${el.fillB})`;
ctx.fill();
}
if (el.hasOutline) {
ctx.strokeStyle = `rgb(${el.outR},${el.outG},${el.outB})`;
ctx.lineWidth = s;
ctx.stroke();
}
break;
}
case 'line': {
// ax/ay resolve from el.x0 for a line, so this is the anchor offset.
// Without it a line stayed put while every other type moved.
const dx = ax - el.x0, dy = ay - el.y0;
ctx.strokeStyle = `rgb(${el.r},${el.g},${el.b})`;
ctx.lineWidth = Math.max(1, el.lineWidth || 1) * s;
ctx.beginPath();
ctx.moveTo((el.x0 + dx) * s, (el.y0 + dy) * s);
ctx.lineTo((el.x1 + dx) * s, (el.y1 + dy) * s);
ctx.stroke();
break;
}
case 'divider': {
const isH = (el.orientation || 'horizontal') === 'horizontal';
ctx.strokeStyle = `rgb(${el.r},${el.g},${el.b})`;
ctx.lineWidth = s;
ctx.beginPath();
if (isH) {
ctx.moveTo(0, (ay + 0.5) * s);
ctx.lineTo(_canvas.width, (ay + 0.5) * s);
} else {
ctx.moveTo((ax + 0.5) * s, 0);
ctx.lineTo((ax + 0.5) * s, _canvas.height);
}
ctx.stroke();
break;
}
case 'pips': {
const pipCount = Math.max(1, el.count ?? 5);
const pvPips = opts.previewValues?.[el.binding?.key];
const filledN = pvPips !== undefined
? Math.max(0, Math.min(pipCount, Math.round(parseFloat(pvPips) || 0)))
: Math.max(0, Math.min(pipCount, el.filled ?? 3));
const ps = Math.max(1, el.pipSize ?? 4);
const pg = Math.max(0, el.pipSpacing ?? 2);
for (let i = 0; i < pipCount; i++) {
const isFilled = i < filledN;
if (!isFilled && !el.showEmpty) continue;
ctx.fillStyle = isFilled
? `rgb(${el.r},${el.g},${el.b})`
: `rgb(${el.emptyR ?? 50},${el.emptyG ?? 50},${el.emptyB ?? 50})`;
ctx.fillRect((ax + i * (ps + pg)) * s, ay * s, ps * s, ps * s);
}
break;
}
case 'sparkline': {
const slW = el.width ?? 40, slH = el.height ?? 12;
const count = Math.max(1, el.barCount ?? 8);
const spacing = el.barSpacing ?? 1;
const barW = Math.max(1, Math.floor((slW - spacing * (count - 1)) / count));
const rawVals = (el.previewData || '').split(',')
.map(v => parseFloat(v.trim())).filter(n => !isNaN(n));
while (rawVals.length < count) rawVals.push(0);
const maxV = Math.max(...rawVals.slice(0, count), 0.001);
const rx = ax * s, ry = ay * s;
if (el.hasBg) {
ctx.fillStyle = `rgb(${el.bgR ?? 30},${el.bgG ?? 30},${el.bgB ?? 30})`;
ctx.fillRect(rx, ry, slW * s, slH * s);
}
ctx.fillStyle = `rgb(${el.r},${el.g},${el.b})`;
for (let i = 0; i < count; i++) {
const norm = Math.max(0, Math.min(1, rawVals[i] / maxV));
const barH = Math.max(1, Math.round(slH * norm));
const bx = rx + (barW + spacing) * i * s;
const by = ry + (slH - barH) * s;
ctx.fillRect(bx, by, barW * s, barH * s);
}
break;
}
case 'gauge': {
const gw = (el.width ?? 32), gh = (el.height ?? 32);
const cx = (ax + gw / 2) * s, cy = (ay + gh / 2) * s;
const rx = (gw / 2) * s, ry = (gh / 2) * s;
const lw = Math.max(1, (el.lineWidth ?? 3));
// rx/ry are canvas pixels ((gw/2)*s) but lw is LED pixels, so
// insetting by lw/2 under-corrected by the scale factor while the
// stroke was drawn at lw*s -- the arc spilled outside the element's
// reported bounding box at any SCALE > 1, and the preview stopped
// matching the generated PIL output.
const lwPx = lw * s;
const startDeg = el.startAngle ?? 135;
const endDeg = el.endAngle ?? 45;
// Arc sweep: from startDeg clockwise to endDeg (PIL convention)
const totalSweep = ((endDeg - startDeg) + 360) % 360 || 360;
const pvGauge = opts.previewValues?.[el.binding?.key];
const pct = pvGauge !== undefined
? Math.max(0, Math.min(100, parseFloat(pvGauge) || 0)) / 100
: Math.max(0, Math.min(100, el.previewPct ?? 65)) / 100;
const fillSweep = totalSweep * pct;
// A small imported gauge with a wide lineWidth would otherwise send
// a negative radius into ctx.ellipse(), which throws IndexSizeError
// and aborts the whole render() call.
const arx = Math.max(0, rx - lwPx / 2), ary = Math.max(0, ry - lwPx / 2);
// biome-ignore lint/correctness/useQwikValidLexicalScope: not Qwik -- this is an Alpine.js component, and the rule is about Qwik's $() serialization boundary, which does not exist here.
const toRad = deg => (deg - 90) * Math.PI / 180; // canvas 0=top, PIL 0=right → offset -90
// Track arc
if (el.hasTrack !== false) {
ctx.beginPath();
ctx.ellipse(cx, cy, arx, ary, 0, toRad(startDeg), toRad(startDeg + totalSweep), false);
ctx.strokeStyle = `rgb(${el.trackR ?? 40},${el.trackG ?? 40},${el.trackB ?? 40})`;
ctx.lineWidth = lwPx;
ctx.stroke();
}
// Fill arc
if (pct > 0) {
ctx.beginPath();
ctx.ellipse(cx, cy, arx, ary, 0, toRad(startDeg), toRad(startDeg + fillSweep), false);
ctx.strokeStyle = `rgb(${el.r},${el.g},${el.b})`;
ctx.lineWidth = lwPx;
ctx.stroke();
}
// Centre label
if (el.showLabel) {
const gfinfo = FONT_MAP[el.font || 'four_by_six'] || FONT_MAP.four_by_six;
const labelText = Math.round(pct * 100) + '%';
ctx.font = `${gfinfo.sizePx * s}px ${gfinfo.family}`;
ctx.fillStyle = `rgb(${el.labelR ?? 200},${el.labelG ?? 200},${el.labelB ?? 200})`;
const ltw = ctx.measureText(labelText).width;
ctx.fillText(labelText, cx - ltw / 2, cy + (gfinfo.sizePx * s) / 2);
}
break;
}
case 'marquee': {
const finfo = FONT_MAP[el.font] || FONT_MAP.press_start;
const text = el.text || 'Scrolling text';
const tw = text.length * finfo.charW * s;
const gap = (el.gap ?? 16) * s;
const totalW = tw + gap;
const tick = opts.animTick ?? 0;
const speed = (el.scrollSpeed ?? 1) * 2;
const scrolled = (tick * speed) % totalW;
// left: text enters from right; right: text enters from left
const startX = el.direction === 'right'
? scrolled - tw
: matrixW * s - scrolled;
ctx.font = `${finfo.sizePx * s}px ${finfo.family}`;
ctx.fillStyle = `rgb(${el.r},${el.g},${el.b})`;
// Clip to canvas width so text doesn't bleed outside
ctx.save();
ctx.beginPath();
ctx.rect(0, ay * s - 1, matrixW * s, (finfo.sizePx + 2) * s);
ctx.clip();
for (let i = -1; i <= 2; i++) {
ctx.fillText(text, startX + i * totalW, (ay + finfo.sizePx) * s);
}
ctx.restore();
break;
}
case 'progress_bar': {
const bw = el.barWidth ?? 60, bh = el.barHeight ?? 6;
const pvPb = opts.previewValues?.[el.binding?.key];
const pct = pvPb !== undefined
? Math.max(0, Math.min(100, parseFloat(pvPb) || 0)) / 100
: Math.max(0, Math.min(100, el.previewPct ?? 65)) / 100;
const rx = ax * s, ry = ay * s;
if (el.hasBg) {
ctx.fillStyle = `rgb(${el.bgR ?? 30},${el.bgG ?? 30},${el.bgB ?? 30})`;
ctx.fillRect(rx, ry, bw * s, bh * s);
}
const fillW = Math.max(0, Math.round(bw * pct));
if (fillW > 0) {
ctx.fillStyle = `rgb(${el.r},${el.g},${el.b})`;
ctx.fillRect(rx, ry, fillW * s, bh * s);
}
if (el.hasOutline) {
ctx.strokeStyle = `rgb(${el.outR ?? 100},${el.outG ?? 100},${el.outB ?? 100})`;
ctx.lineWidth = s;
ctx.strokeRect(rx, ry, bw * s, bh * s);
}
break;
}
}
if (belowBreakpoint) {
ctx.globalAlpha = 0.6;
const bb = getBoundingBox(el, matrixW, matrixH);
ctx.font = `${Math.max(8, s * 2)}px monospace`;
ctx.fillStyle = '#facc15';
ctx.fillText(`≥${el.minWidth}px`, bb.x * s, (bb.y + 4) * s);
}
} finally {
ctx.restore();
}
}
// ── Selection indicator ──────────────────────────────────────────────
function _drawSelection(ctx, el, SCALE, matrixW, matrixH) {
const bb = getBoundingBox(el, matrixW, matrixH);
const PAD = 2, s = SCALE;
const rx = bb.x * s - PAD, ry = bb.y * s - PAD;
const rw = bb.w * s + PAD * 2, rh = bb.h * s + PAD * 2;
ctx.save();
ctx.strokeStyle = '#3b82f6';
ctx.lineWidth = 1;
ctx.setLineDash([3, 2]);
ctx.strokeRect(rx, ry, rw, rh);
ctx.setLineDash([]);
if (el.xAnchor || el.yAnchor) {
ctx.font = `${Math.max(7, s)}px sans-serif`;
ctx.fillStyle = '#a78bfa';
const anchorText = [
el.xAnchor ? `x:${el.xAnchor[0]}` : '',
el.yAnchor ? `y:${el.yAnchor[0]}` : '',
].filter(Boolean).join(' ');
if (anchorText) ctx.fillText(anchorText, rx + 1, ry - 2);
}
// Resize handles: on rect, rounded rect, ellipse
if (RESIZABLE_TYPES.includes(el.type)) {
const handles = _getRectHandles(el, matrixW, matrixH);
const HS = 5;
ctx.fillStyle = 'white';
ctx.strokeStyle = '#2563eb';
ctx.lineWidth = 1;
for (const pt of Object.values(handles)) {
const hx = pt.x * s - HS / 2;
const hy = pt.y * s - HS / 2;
ctx.fillRect(hx, hy, HS, HS);
ctx.strokeRect(hx, hy, HS, HS);
}
} else {
// Corner dots for non-rectangle elements
ctx.fillStyle = '#3b82f6';
const HS = 4;
for (const [hx, hy] of [
[rx - HS / 2, ry - HS / 2], [rx + rw - HS / 2, ry - HS / 2],
[rx - HS / 2, ry + rh - HS / 2], [rx + rw - HS / 2, ry + rh - HS / 2],
]) ctx.fillRect(hx, hy, HS, HS);
}
ctx.restore();
}
// ── Dimension tooltip while dragging ─────────────────────────────────
function drawDragTooltip(ctx, el, SCALE, matrixW, matrixH) {
const bb = getBoundingBox(el, matrixW, matrixH);
const label = el.type === 'rectangle'
? `${el.width}×${el.height}`
: `${bb.x},${bb.y}`;
const s = SCALE;
ctx.save();
ctx.font = `${Math.max(9, s * 1.5)}px monospace`;
const tw = ctx.measureText(label).width;
const tx = bb.x * s, ty = (bb.y - 2) * s;
ctx.fillStyle = 'rgba(0,0,0,0.7)';
ctx.fillRect(tx - 2, ty - 10, tw + 4, 12);
ctx.fillStyle = 'white';
ctx.fillText(label, tx, ty);
ctx.restore();
}
// ── Public API ───────────────────────────────────────────────────────
function init(canvasEl) {
_canvas = canvasEl;
_ctx = canvasEl.getContext('2d');
}
function setGrid(show) { _showGrid = show; }
function updateCanvasSize(matrixW, matrixH, SCALE) {
if (!_canvas) return;
_canvas.width = matrixW * SCALE;
_canvas.height = matrixH * SCALE;
}
function render(elements, selectedId, matrixW, matrixH, SCALE, opts = {}) {
if (!_ctx) return;
const cW = matrixW * SCALE, cH = matrixH * SCALE;
const bg = opts.bgColor;
_ctx.fillStyle = bg ? `rgb(${bg.r},${bg.g},${bg.b})` : '#000';
_ctx.fillRect(0, 0, cW, cH);
if (_showGrid) {
_ctx.strokeStyle = 'rgba(255,255,255,0.07)';
_ctx.lineWidth = 0.5;
for (let x = SCALE; x < cW; x += SCALE) {
_ctx.beginPath(); _ctx.moveTo(x, 0); _ctx.lineTo(x, cH); _ctx.stroke();
}
for (let y = SCALE; y < cH; y += SCALE) {
_ctx.beginPath(); _ctx.moveTo(0, y); _ctx.lineTo(cW, y); _ctx.stroke();
}
}
for (const el of elements) _drawElement(_ctx, el, SCALE, matrixW, matrixH, opts);
if (opts.showRuler) {
_ctx.save();
_ctx.fillStyle = 'rgba(255,255,255,0.08)';
_ctx.fillRect(0, 0, cW, SCALE); // top strip
_ctx.fillRect(0, 0, SCALE, cH); // left strip
_ctx.strokeStyle = 'rgba(255,255,255,0.5)';
_ctx.fillStyle = 'rgba(255,255,255,0.6)';
_ctx.font = `${Math.max(5, SCALE - 1)}px monospace`;
const step = SCALE >= 4 ? 8 : 16;
for (let px = 0; px <= matrixW; px += step) {
const cx = px * SCALE;
const major = px % 32 === 0;
_ctx.lineWidth = 0.5;
_ctx.beginPath(); _ctx.moveTo(cx, 0); _ctx.lineTo(cx, major ? SCALE : SCALE * 0.5); _ctx.stroke();
if (major && px > 0 && px < matrixW - 4) _ctx.fillText(String(px), cx + 1, SCALE - 1);
}
for (let py = 0; py <= matrixH; py += step) {
const cy = py * SCALE;
const major = py % 32 === 0;
_ctx.beginPath(); _ctx.moveTo(0, cy); _ctx.lineTo(major ? SCALE : SCALE * 0.5, cy); _ctx.stroke();
if (major && py > 0 && py < matrixH - 4) _ctx.fillText(String(py), 1, cy + SCALE - 1);
}
_ctx.restore();
}
if (opts.showGuides) {
_ctx.save();
_ctx.strokeStyle = 'rgba(255,60,60,0.45)';
_ctx.lineWidth = 1;
_ctx.setLineDash([4, 3]);
const mx = Math.floor(cW / 2) + 0.5;
const my = Math.floor(cH / 2) + 0.5;
_ctx.beginPath(); _ctx.moveTo(mx, 0); _ctx.lineTo(mx, cH); _ctx.stroke();
_ctx.beginPath(); _ctx.moveTo(0, my); _ctx.lineTo(cW, my); _ctx.stroke();
_ctx.setLineDash([]);
_ctx.restore();
}
const sel = selectedId != null ? elements.find(e => e.id === selectedId) : null;
if (sel) {
_drawSelection(_ctx, sel, SCALE, matrixW, matrixH);
if (opts.showTooltip) drawDragTooltip(_ctx, sel, SCALE, matrixW, matrixH);
}
}
return {
init, render, setGrid, updateCanvasSize,
hitTest, getBoundingBox, computeActualPos, resolveAnchor,
getResizeHandle, getCursorForHandle,
ELEMENT_DEFAULTS, FONT_MAP, DISPLAY_PRESETS, RESIZABLE_TYPES,
};
})();
+1 -8
View File
@@ -1164,16 +1164,9 @@
// The pull replaced files on disk; the running services still // The pull replaced files on disk; the running services still
// hold the code they loaded at boot. Ask for the restart that // hold the code they loaded at boot. Ask for the restart that
// makes the update actually take effect. // makes the update actually take effect.
//
// Both services, not just the display. The plugin store's
// compatibility gate runs in the web process, so a web service
// still holding the previous version refuses every plugin that
// floors on the release just installed -- blaming a core
// version that is already correct on disk.
if (data.restart_required && typeof window.showRestartPending === 'function') { if (data.restart_required && typeof window.showRestartPending === 'function') {
window.showRestartPending( window.showRestartPending(
'Update installed \u2014 restart the display and web ' + 'Update installed \u2014 restart the display to run the new code');
'services to run the new code');
} }
} }
if (typeof showNotification === 'function') { if (typeof showNotification === 'function') {
File diff suppressed because it is too large Load Diff
@@ -1,302 +0,0 @@
"""
{{ plugin_name }} — LEDMatrix Plugin
Generated by LEDMatrix Plugin Composer on {{ generated_date }}
Extension points:
update() → add HTTP/MQTT data-fetching logic here
_get_display_values() → map fetched data to display strings
display() → add new elements or adapt layout per display size
"""
from src.plugin_system.base_plugin import BasePlugin
{% if has_clock or has_countdown %}
from datetime import datetime
{% endif %}
{% if has_blink %}
import time
{% endif %}
{% if has_text_template %}
from collections import defaultdict
{% endif %}
class {{ class_name }}(BasePlugin):
def __init__(self, plugin_id, config, display_manager, cache_manager, plugin_manager):
super().__init__(plugin_id, config, display_manager, cache_manager, plugin_manager)
{% for var in config_vars %}
self.{{ var.key }} = config.get({{ var.key | tojson }}, {{ var.default | tojson }})
{% endfor %}
# Live data cache — populated by update(); always {} in static layouts
self._data = {}
def update(self):
"""Fetch and refresh display data.
For dynamic plugins: fetch from APIs/MQTT here and store in self._data.
_get_display_values() will read self._data to produce display strings.
"""
# --- Data sources (add fetch logic here for dynamic plugins) ---
pass
def _get_display_values(self):
"""Map config variables and live data to display-ready strings.
This is the single extension point for v2 data sources:
add self._data lookups here once update() populates them.
"""
return {
{% for var in config_vars %}
{{ var.key | tojson }}: str(self.{{ var.key }}),
{% endfor %}
}
def display(self, force_clear=False):
try:
{% if has_text_template %}
values = defaultdict(str, self._get_display_values())
{% else %}
values = self._get_display_values()
{% endif %}
if force_clear:
self.display_manager.clear()
width = self.display_manager.width
height = self.display_manager.height
{% if bg_color %}
self.display_manager.draw.rectangle([0, 0, width, height], fill={{ bg_color }})
{% endif %}
# ── Elements (rendered bottom to top) ──────────────────────────
{% for el in elements %}
{% set p = " " if el.min_width > 0 else " " %}
{% set pi = (p + " ") if el.blink else p %}
{% if el.min_width > 0 %}
if width >= {{ el.min_width }}: # breakpoint: {{ el.min_width }}px+ displays only
{% endif %}
{% if el.blink %}
{{ p }}if int(time.time() * 2) % 2:
{% endif %}
{% if el.type == 'text' %}
{{ pi }}self.display_manager.draw_text(
{% if el.text_is_template %}
{{ pi }} {{ el.text | tojson }}.format_map(values),
{% else %}
{{ pi }} {{ el.text | tojson }},
{% endif %}
{{ pi }} x={{ el.x_expr }}, y={{ el.y_expr }},
{{ pi }} color={{ el.rgb_tuple }},
{{ pi }} font=self.display_manager.{{ el.font_attr }},
{{ pi }})
{% if el.text2 %}
{{ pi }}self.display_manager.draw_text(
{% if el.text_is_template %}
{{ pi }} {{ el.text2 | tojson }}.format_map(values),
{% else %}
{{ pi }} {{ el.text2 | tojson }},
{% endif %}
{{ pi }} x={{ el.x2_expr }}, y={{ el.y2_expr }},
{{ pi }} color={{ el.rgb_tuple }},
{{ pi }} font=self.display_manager.{{ el.font_attr }},
{{ pi }})
{% endif %}
{% elif el.type == 'dynamic_text' %}
{% if el.binding_source == 'config' %}
{{ pi }}self.display_manager.draw_text(
{{ pi }} values.get({{ el.binding_key | tojson }}, ''),
{{ pi }} x={{ el.x_expr }}, y={{ el.y_expr }},
{{ pi }} color={{ el.rgb_tuple }},
{{ pi }} font=self.display_manager.{{ el.font_attr }},
{{ pi }})
{% else %}
{{ pi }}pass # non-config dynamic_text binding draws nothing
{% endif %}
{% elif el.type == 'clock' %}
{{ pi }}self.display_manager.draw_text(
{{ pi }} datetime.now().strftime({{ el.format | tojson }}),
{{ pi }} x={{ el.x_expr }}, y={{ el.y_expr }},
{{ pi }} color={{ el.rgb_tuple }},
{{ pi }} font=self.display_manager.{{ el.font_attr }},
{{ pi }})
{% if el.format2 %}
{{ pi }}self.display_manager.draw_text(
{{ pi }} datetime.now().strftime({{ el.format2 | tojson }}),
{{ pi }} x={{ el.x2_expr }}, y={{ el.y2_expr }},
{{ pi }} color={{ el.rgb_tuple }},
{{ pi }} font=self.display_manager.{{ el.font_attr }},
{{ pi }})
{% endif %}
{% elif el.type == 'countdown' %}
{{ pi }}_cd_target = float(values.get({{ el.binding_key | tojson }}, 0) or 0)
{{ pi }}_cd_secs = max(0.0, _cd_target - datetime.now().timestamp())
{% if el.countdown_format == 'dhms' %}
{{ pi }}_cd_d, _cd_rem = divmod(int(_cd_secs), 86400)
{{ pi }}_cd_h, _cd_rem = divmod(_cd_rem, 3600)
{{ pi }}_cd_m, _cd_s = divmod(_cd_rem, 60)
{{ pi }}_cd_str = f'{_cd_d}d {_cd_h:02d}:{_cd_m:02d}:{_cd_s:02d}'
{% elif el.countdown_format == 'hms' %}
{{ pi }}_cd_h, _cd_rem = divmod(int(_cd_secs), 3600)
{{ pi }}_cd_m, _cd_s = divmod(_cd_rem, 60)
{{ pi }}_cd_str = f'{_cd_h}h {_cd_m:02d}:{_cd_s:02d}'
{% elif el.countdown_format == 'dhm' %}
{{ pi }}_cd_d, _cd_rem = divmod(int(_cd_secs), 86400)
{{ pi }}_cd_h, _cd_m = divmod(_cd_rem // 60, 60)
{{ pi }}_cd_str = f'{_cd_d}d {_cd_h:02d}h {_cd_m:02d}m'
{% else %}
{{ pi }}_cd_d, _cd_rem = divmod(int(_cd_secs), 86400)
{{ pi }}_cd_h = _cd_rem // 3600
{{ pi }}_cd_str = f'{_cd_d}d {_cd_h}h'
{% endif %}
{{ pi }}self.display_manager.draw_text(
{{ pi }} _cd_str,
{{ pi }} x={{ el.x_expr }}, y={{ el.y_expr }},
{{ pi }} color={{ el.rgb_tuple }},
{{ pi }} font=self.display_manager.{{ el.font_attr }},
{{ pi }})
{% elif el.type == 'rectangle' %}
{{ pi }}self.display_manager.draw.rectangle(
{{ pi }} [{{ el.x_expr }}, {{ el.y_expr }}, {{ el.x2_expr }}, {{ el.y2_expr }}],
{{ pi }} fill={{ el.fill_tuple }},
{{ pi }} outline={{ el.outline_tuple }},
{{ pi }})
{% elif el.type == 'arc' %}
{{ pi }}self.display_manager.draw.arc(
{{ pi }} [{{ el.x_expr }}, {{ el.y_expr }}, {{ el.x2_expr }}, {{ el.y2_expr }}],
{{ pi }} start={{ el.start_angle }}, end={{ el.end_angle }},
{{ pi }} fill={{ el.rgb_tuple }},
{{ pi }} width={{ el.line_width }},
{{ pi }})
{% elif el.type == 'ellipse' %}
{{ pi }}self.display_manager.draw.ellipse(
{{ pi }} [{{ el.x_expr }}, {{ el.y_expr }}, {{ el.x2_expr }}, {{ el.y2_expr }}],
{{ pi }} fill={{ el.fill_tuple }},
{{ pi }} outline={{ el.outline_tuple }},
{{ pi }})
{% elif el.type == 'pixel' %}
{{ pi }}self.display_manager.draw.point(
{{ pi }} [{{ el.x_expr }}, {{ el.y_expr }}],
{{ pi }} fill={{ el.rgb_tuple }},
{{ pi }})
{% elif el.type == 'rounded_rectangle' %}
{{ pi }}self.display_manager.draw.rounded_rectangle(
{{ pi }} [{{ el.x_expr }}, {{ el.y_expr }}, {{ el.x2_expr }}, {{ el.y2_expr }}],
{{ pi }} radius={{ el.border_radius }},
{{ pi }} fill={{ el.fill_tuple }},
{{ pi }} outline={{ el.outline_tuple }},
{{ pi }})
{% elif el.type in ('line', 'divider') %}
{{ pi }}self.display_manager.draw.line(
{{ pi }} [{{ el.x0_expr }}, {{ el.y0_expr }}, {{ el.x1_expr }}, {{ el.y1_expr }}],
{{ pi }} fill={{ el.rgb_tuple }},
{{ pi }} width={{ el.line_width }},
{{ pi }})
{% elif el.type == 'pips' %}
{{ pi }}_pip_filled = max(0, min({{ el.pip_count }}, int(float(values.get({{ el.binding_key | tojson }}, 0) or 0))))
{{ pi }}for _pip_i in range({{ el.pip_count }}):
{{ pi }} _pip_x = ({{ el.x_expr }}) + _pip_i * ({{ el.pip_size }} + {{ el.pip_spacing }})
{{ pi }} _pip_color = {{ el.fill_tuple }} if _pip_i < _pip_filled else {{ el.empty_tuple }}
{% if not el.show_empty %}
{{ pi }} if _pip_i >= _pip_filled:
{{ pi }} continue
{% endif %}
{{ pi }} self.display_manager.draw.rectangle(
{{ pi }} [_pip_x, {{ el.y_expr }}, _pip_x + {{ el.pip_size }} - 1, ({{ el.y_expr }}) + {{ el.pip_size }} - 1],
{{ pi }} fill=_pip_color,
{{ pi }} )
{% elif el.type == 'sparkline' %}
{{ pi }}_sl_raw = str(values.get({{ el.binding_key | tojson }}, '') or '')
{{ pi }}_sl_vals = [float(v.strip()) for v in _sl_raw.split(',') if v.strip()][:{{ el.bar_count }}]
{{ pi }}_sl_vals += [0.0] * max(0, {{ el.bar_count }} - len(_sl_vals))
{{ pi }}_sl_max = max(_sl_vals) if any(_sl_vals) else 1.0
{{ pi }}_sl_bw = max(1, ({{ el.bar_width_px }} - {{ el.bar_spacing }} * ({{ el.bar_count }} - 1)) // {{ el.bar_count }})
{% if el.bg_tuple != 'None' %}
{{ pi }}self.display_manager.draw.rectangle(
{{ pi }} [{{ el.x_expr }}, {{ el.y_expr }}, ({{ el.x_expr }}) + {{ el.bar_width_px }}, ({{ el.y_expr }}) + {{ el.bar_height_px }}],
{{ pi }} fill={{ el.bg_tuple }},
{{ pi }})
{% endif %}
{{ pi }}for _sl_i, _sl_v in enumerate(_sl_vals):
{{ pi }} _sl_norm = max(0.0, min(1.0, _sl_v / (_sl_max or 1)))
{{ pi }} _sl_bh = max(1, round({{ el.bar_height_px }} * _sl_norm))
{{ pi }} _sl_bx = ({{ el.x_expr }}) + (_sl_bw + {{ el.bar_spacing }}) * _sl_i
{{ pi }} _sl_by = ({{ el.y_expr }}) + {{ el.bar_height_px }} - _sl_bh
{{ pi }} self.display_manager.draw.rectangle(
{{ pi }} [_sl_bx, _sl_by, _sl_bx + _sl_bw - 1, _sl_by + _sl_bh - 1],
{{ pi }} fill={{ el.fill_tuple }},
{{ pi }} )
{% elif el.type == 'gauge' %}
{{ pi }}_gv = max(0.0, min(100.0, float(values.get({{ el.binding_key | tojson }}, 0) or 0)))
{{ pi }}_g_total = (({{ el.end_angle }} - {{ el.start_angle }}) % 360) or 360
{{ pi }}_g_sweep = _g_total * _gv / 100.0
{% if el.track_tuple != 'None' %}
{{ pi }}self.display_manager.draw.arc(
{{ pi }} [{{ el.x_expr }}, {{ el.y_expr }}, {{ el.x2_expr }}, {{ el.y2_expr }}],
{{ pi }} start={{ el.start_angle }}, end={{ el.start_angle }} + _g_total,
{{ pi }} fill={{ el.track_tuple }},
{{ pi }} width={{ el.line_width }},
{{ pi }})
{% endif %}
{{ pi }}if _g_sweep > 0:
{{ pi }} self.display_manager.draw.arc(
{{ pi }} [{{ el.x_expr }}, {{ el.y_expr }}, {{ el.x2_expr }}, {{ el.y2_expr }}],
{{ pi }} start={{ el.start_angle }}, end={{ el.start_angle }} + _g_sweep,
{{ pi }} fill={{ el.rgb_tuple }},
{{ pi }} width={{ el.line_width }},
{{ pi }} )
{% if el.show_label %}
{{ pi }}_g_cx = ({{ el.x_expr }}) + ({{ el.x2_expr }} - ({{ el.x_expr }})) // 2
{{ pi }}_g_cy = ({{ el.y_expr }}) + ({{ el.y2_expr }} - ({{ el.y_expr }})) // 2
{{ pi }}self.display_manager.draw_text(
{{ pi }} f'{int(_gv)}%',
{{ pi }} x=_g_cx, y=_g_cy,
{{ pi }} color={{ el.label_tuple }},
{{ pi }} font=self.display_manager.{{ el.font_attr }},
{{ pi }})
{% endif %}
{% elif el.type == 'marquee' %}
{{ pi }}_{{ el.data_key }}_text = {{ el.text | tojson }}
{{ pi }}_{{ el.data_key }}_tw = len(_{{ el.data_key }}_text) * {{ el.char_w }}
{{ pi }}_{{ el.data_key }}_x = int(self._data.get({{ el.data_key | tojson }}, width))
{% if el.direction == 'right' %}
{{ pi }}_{{ el.data_key }}_x += {{ el.scroll_speed }}
{{ pi }}if _{{ el.data_key }}_x > width:
{{ pi }} _{{ el.data_key }}_x = -(_{{ el.data_key }}_tw + {{ el.gap }})
{% else %}
{{ pi }}_{{ el.data_key }}_x -= {{ el.scroll_speed }}
{{ pi }}if _{{ el.data_key }}_x < -(_{{ el.data_key }}_tw + {{ el.gap }}):
{{ pi }} _{{ el.data_key }}_x = width
{% endif %}
{{ pi }}self._data[{{ el.data_key | tojson }}] = _{{ el.data_key }}_x
{{ pi }}self.display_manager.draw_text(
{{ pi }} _{{ el.data_key }}_text,
{{ pi }} x=_{{ el.data_key }}_x, y={{ el.y_expr }},
{{ pi }} color={{ el.rgb_tuple }},
{{ pi }} font=self.display_manager.{{ el.font_attr }},
{{ pi }})
{% elif el.type == 'progress_bar' %}
{{ pi }}_pb_x = {{ el.x_expr }}
{{ pi }}_pb_y = {{ el.y_expr }}
{{ pi }}_pb_pct = max(0.0, min(100.0, float(values.get({{ el.binding_key | tojson }}, 0) or 0))) / 100.0
{{ pi }}_pb_fill_w = int({{ el.bar_width }} * _pb_pct)
{{ pi }}self.display_manager.draw.rectangle(
{{ pi }} [_pb_x, _pb_y, _pb_x + {{ el.bar_width }}, _pb_y + {{ el.bar_height }}],
{{ pi }} fill={{ el.bg_tuple }},
{{ pi }} outline={{ el.outline_tuple }},
{{ pi }})
{{ pi }}if _pb_fill_w > 0:
{{ pi }} self.display_manager.draw.rectangle(
{{ pi }} [_pb_x, _pb_y, _pb_x + _pb_fill_w, _pb_y + {{ el.bar_height }}],
{{ pi }} fill={{ el.fill_tuple }},
{{ pi }} )
{% else %}
{{ pi }}pass # element type "{{ el.type }}" draws nothing
{% endif %}
{% endfor %}
# ── End elements ───────────────────────────────────────────────
self.display_manager.update_display()
except Exception as e:
self.logger.error('Display error: %s', e, exc_info=True)