diff --git a/plugins.json b/plugins.json index 3953885c..b90d4b24 100644 --- a/plugins.json +++ b/plugins.json @@ -1,6 +1,6 @@ { "version": "1.0.0", - "last_updated": "2026-08-06", + "last_updated": "2026-08-07", "plugins": [ { "id": "cricket-scoreboard", @@ -215,7 +215,7 @@ "last_updated": "2026-08-05", "verified": true, "screenshot": "", - "latest_version": "1.7.3" + "latest_version": "1.8.0" }, { "id": "football-scoreboard", diff --git a/plugins/f1-scoreboard/README.md b/plugins/f1-scoreboard/README.md index bc7dd4d0..1b24ac59 100644 --- a/plugins/f1-scoreboard/README.md +++ b/plugins/f1-scoreboard/README.md @@ -100,6 +100,34 @@ Each mode section has an `enabled` toggle and mode-specific options: | `calendar` | `show_qualifying` | `true` | Include qualifying in calendar | | `calendar` | `show_sprint` | `true` | Include sprint weekends in calendar | +### Vegas marquee + +`vegas.sections` picks what F1 contributes to the Vegas continuous-scroll +marquee. It does not affect the display modes above, which still show +everything. + +| Key | Default | Description | +|---|---|---| +| `vegas.sections` | `["upcoming", "last_race"]` | Sections that join the marquee | + +The default is the next race (plus its circuit card) and the most recent race's +results — around 15 seconds. Sections are always shown in the canonical order +below, whatever order you list them in: + +`leaders` · `battles` · `spotlight` · `upcoming` · `last_race` · +`driver_standings` · `constructor_standings` · `recent_races` · `qualifying` · +`practice` · `sprint` · `calendar` + +`last_race` is the most recent race only; `recent_races` is all of the races +`recent_races.number_of_races` covers. Cards within a section still honour that +section's own settings, so `recent_races.show_gap_chart` and +`show_points_haul` apply to `last_race` too. + +Adding sections back makes F1 hold the marquee for longer — listing all twelve +measured 114 cards on a 512px panel, close to six minutes of unbroken F1 at +50px/s. Set an empty list to keep F1 out of the marquee entirely while leaving +its own display modes running. + ### Scroll & timing | Key | Default | Description | diff --git a/plugins/f1-scoreboard/config_schema.json b/plugins/f1-scoreboard/config_schema.json index bfac5ac1..0830dc3f 100644 --- a/plugins/f1-scoreboard/config_schema.json +++ b/plugins/f1-scoreboard/config_schema.json @@ -397,6 +397,26 @@ "x-propertyOrder": ["enabled", "show_practice", "show_qualifying", "show_sprint", "max_events"], "additionalProperties": false }, + "vegas": { + "type": "object", + "title": "Vegas Marquee", + "description": "What F1 contributes to the Vegas continuous-scroll marquee. Does not affect the plugin's own display modes, which still show everything.", + "properties": { + "sections": { + "type": "array", + "title": "Marquee Sections", + "x-widget": "checkbox-group", + "items": { + "type": "string", + "enum": ["leaders", "battles", "spotlight", "upcoming", "last_race", "driver_standings", "constructor_standings", "recent_races", "qualifying", "practice", "sprint", "calendar"] + }, + "default": ["upcoming", "last_race"], + "description": "Which sections join the marquee. The order you list them in does not matter — they are always shown in the fixed order leaders, battles, spotlight, upcoming, last_race, driver_standings, constructor_standings, recent_races, qualifying, practice, sprint, calendar. Default is the next race and the last race's results. Adding more makes F1 hold the marquee for longer — every section at once measured 114 cards, close to six minutes of unbroken F1 on a 512px panel. 'last_race' is the most recent race only; 'recent_races' is all of them. Leave empty to keep F1 out of the marquee entirely." + } + }, + "x-propertyOrder": ["sections"], + "additionalProperties": false + }, "dynamic_duration": { "type": "object", "title": "Dynamic Duration", @@ -739,7 +759,7 @@ "additionalProperties": false } }, - "x-propertyOrder": ["enabled", "display_duration", "update_interval", "favorite_team", "favorite_driver", "timezone", "driver_standings", "constructor_standings", "recent_races", "upcoming", "qualifying", "practice", "sprint", "calendar", "dynamic_duration", "scroll", "visual", "customization"], + "x-propertyOrder": ["enabled", "display_duration", "update_interval", "favorite_team", "favorite_driver", "timezone", "driver_standings", "constructor_standings", "recent_races", "upcoming", "qualifying", "practice", "sprint", "calendar", "vegas", "dynamic_duration", "scroll", "visual", "customization"], "additionalProperties": false, "required": ["enabled"] } diff --git a/plugins/f1-scoreboard/manager.py b/plugins/f1-scoreboard/manager.py index afd9b436..c0797ec1 100644 --- a/plugins/f1-scoreboard/manager.py +++ b/plugins/f1-scoreboard/manager.py @@ -101,6 +101,11 @@ def __init__(self, plugin_id, config, display_manager, self._calendar: List[Dict] = [] self._pole_positions: Dict[str, int] = {} + # Cards for the most recent race only. The marquee's "last_race" + # section shows just that race, while the recent_races scroll mode + # shows several, so the two need separate lists. + self._vegas_last_race_cards: List[Image.Image] = [] + # Live session state self._is_live: bool = False self._live_session: str = "" @@ -559,30 +564,23 @@ def _prepare_scroll_content(self): # Recent races (winners summary + podium cards + favorite highlight + points haul + gap chart) rr_cfg = self.config.get("recent_races", {}) - show_haul = rr_cfg.get("show_points_haul", True) - haul_top_n = rr_cfg.get("points_haul_drivers", 5) show_winners = rr_cfg.get("show_winners_summary", True) - show_gap_chart = rr_cfg.get("show_gap_chart", True) - gap_chart_n = rr_cfg.get("gap_chart_drivers", 5) + self._vegas_last_race_cards = [] if self._recent_races: cards = [] # Winners summary at the top (only if showing 2+ races) if show_winners and len(self._recent_races) > 1: cards.append(r.render_recent_winners_card(self._recent_races)) - for race in self._recent_races: - cards.append(r.render_race_result(race)) - # If favorite outside podium: results[3] is the appended favorite - results = race.get("results", []) - if self.favorite_driver and len(results) > 3: - fav = results[3] - if fav.get("code", "").upper() == self.favorite_driver: - cards.append(r.render_favorite_race_card(race, fav)) - # Gap chart bar visualization (skip if no result data available) - if show_gap_chart and race.get("all_results"): - cards.append(r.render_race_gap_chart(race, top_n=gap_chart_n)) - # Points haul bar chart (uses full unfiltered results) - if show_haul: - cards.append(r.render_race_points_haul(race, top_n=haul_top_n)) + for index, race in enumerate(self._recent_races): + race_cards = self._build_race_cards(race) + # _recent_races is most-recent-first, so index 0 is the race the + # marquee's "last_race" section shows. Captured here rather than + # re-rendered later, and rather than sliced back out of `cards` + # below — the per-race card count varies with config and with + # whether the favorite finished off the podium. + if index == 0: + self._vegas_last_race_cards = list(race_cards) + cards.extend(race_cards) self._scroll_manager.prepare_and_display( "recent_races", cards, separator) @@ -615,6 +613,44 @@ def _prepare_scroll_content(self): self._scroll_manager.prepare_and_display( "calendar", cards, separator) + def _build_race_cards(self, race: Dict) -> List[Image.Image]: + """ + Build the cards for a single race: the result, then whichever extras + are enabled. + + Shared by the recent_races scroll mode and the marquee's "last_race" + section so a race is presented the same way in both, and so the + recent_races toggles keep applying in the marquee. + + Args: + race: One entry from self._recent_races + + Returns: + Cards for that race, in display order + """ + r = self._scroll_renderer + rr_cfg = self.config.get("recent_races", {}) + cards = [r.render_race_result(race)] + + # If favorite outside podium: results[3] is the appended favorite + results = race.get("results", []) + if self.favorite_driver and len(results) > 3: + fav = results[3] + if fav.get("code", "").upper() == self.favorite_driver: + cards.append(r.render_favorite_race_card(race, fav)) + + # Gap chart bar visualization (skip if no result data available) + if rr_cfg.get("show_gap_chart", True) and race.get("all_results"): + cards.append(r.render_race_gap_chart( + race, top_n=rr_cfg.get("gap_chart_drivers", 5))) + + # Points haul bar chart (uses full unfiltered results) + if rr_cfg.get("show_points_haul", True): + cards.append(r.render_race_points_haul( + race, top_n=rr_cfg.get("points_haul_drivers", 5))) + + return cards + def _build_qualifying_cards(self) -> List[Image.Image]: """Build qualifying result cards grouped by Q session.""" if not self._qualifying: @@ -766,60 +802,121 @@ def _display_scroll_mode(self, display_mode: str, # ─── Vegas Mode ──────────────────────────────────────────────────── - def get_vegas_content(self) -> Optional[List[Image.Image]]: - """Return rendered cards for modes that have data.""" - images = [] + # Sections the marquee can show, in the order they are emitted. The keys + # are what a user puts in `vegas.sections`. + # + # Deliberately a small default. Contributing every prepared mode measured + # 114 cards / 14,592px on a 512px panel — near six minutes of uninterrupted + # F1 at 50px/s, because what the plugin's own rotation shows as eight + # separate screens the marquee splices into one unbroken block. + _VEGAS_SECTION_ORDER = ( + "leaders", + "battles", + "spotlight", + "upcoming", + "last_race", + "driver_standings", + "constructor_standings", + "recent_races", + "qualifying", + "practice", + "sprint", + "calendar", + ) + _VEGAS_DEFAULT_SECTIONS = ("upcoming", "last_race") + + # Sections that are just one or more prepared scroll modes, concatenated. + # "upcoming" and "last_race" are not here: the first renders fresh so its + # countdown is current, the second comes from its own card list. + _VEGAS_SECTION_MODES = { + "leaders": ("championship_leaders",), + "battles": ("championship_battle", "constructor_battle"), + "spotlight": ("driver_spotlight", "team_spotlight"), + "driver_standings": ("driver_standings",), + "constructor_standings": ("constructor_standings",), + "recent_races": ("recent_races",), + "qualifying": ("qualifying",), + "practice": ("practice",), + "sprint": ("sprint",), + "calendar": ("calendar",), + } - # Championship leaders overview card (very first) - if self._scroll_manager.is_mode_prepared("championship_leaders"): - images.extend( - self._scroll_manager.get_vegas_items_for_mode( - "championship_leaders")) - - # Championship battle card (P1 vs P2 title fight) - if self._scroll_manager.is_mode_prepared("championship_battle"): - images.extend( - self._scroll_manager.get_vegas_items_for_mode( - "championship_battle")) - - # Constructor championship battle card - if self._scroll_manager.is_mode_prepared("constructor_battle"): - images.extend( - self._scroll_manager.get_vegas_items_for_mode( - "constructor_battle")) - - # Spotlight cards go first (most important, followed driver/team) - for spotlight_key in ("driver_spotlight", "team_spotlight"): - if self._scroll_manager.is_mode_prepared(spotlight_key): - images.extend( - self._scroll_manager.get_vegas_items_for_mode( - spotlight_key)) - - # Upcoming race card (second — before standings) - if self._upcoming_race: - upcoming_card = self._scroll_renderer.render_upcoming_race( - self._enrich_upcoming_with_countdown(self._upcoming_race)) - images.append(upcoming_card) - # Circuit stats card (immediately after upcoming race card) + def _vegas_sections(self) -> List[str]: + """ + Which sections this plugin contributes to the marquee. + + Unknown names are dropped with a warning rather than failing the whole + list, so one typo costs the user that section and not the plugin. + """ + # The schema forbids a non-object here, but config.json is hand-edited + # often enough that a null or a stray list must not raise on the + # marquee's render path. + vegas_cfg = self.config.get("vegas") or {} + if not isinstance(vegas_cfg, dict): + self.logger.warning( + "vegas should be an object, got %r — using the default sections", + vegas_cfg) + return list(self._VEGAS_DEFAULT_SECTIONS) + + raw = vegas_cfg.get("sections", self._VEGAS_DEFAULT_SECTIONS) + if isinstance(raw, str): + raw = [raw] + if not isinstance(raw, (list, tuple)): + self.logger.warning( + "vegas.sections should be a list, got %r — using the default", + raw) + return list(self._VEGAS_DEFAULT_SECTIONS) + + wanted, unknown = [], [] + for name in raw: + key = str(name).strip().lower() + if key in self._VEGAS_SECTION_ORDER: + wanted.append(key) + elif key: + unknown.append(key) + + if unknown: + self.logger.warning( + "Ignoring unknown vegas.sections entries: %s (valid: %s)", + ", ".join(unknown), ", ".join(self._VEGAS_SECTION_ORDER)) + + # An empty list is a deliberate "keep F1 out of the marquee", so it is + # honoured; only a list with nothing usable in it falls back. + if not wanted and unknown: + return list(self._VEGAS_DEFAULT_SECTIONS) + return wanted + + def _vegas_section_images(self, section: str) -> List[Image.Image]: + """Rendered cards for one marquee section, empty when it has no data.""" + if section == "upcoming": + if not self._upcoming_race: + return [] + # Rendered per call, not taken from a prepared mode, so the + # countdown is current every time the marquee rebuilds the strip. + images = [self._scroll_renderer.render_upcoming_race( + self._enrich_upcoming_with_countdown(self._upcoming_race))] if self._scroll_renderer.show_circuit_info: - circuit_card = self._scroll_renderer.render_circuit_info_card( - self._upcoming_race) - images.append(circuit_card) - - # Standings and results - mode_data = { - "driver_standings": self._driver_standings, - "constructor_standings": self._constructor_standings, - "recent_races": self._recent_races, - "qualifying": self._qualifying, - "practice": self._practice_results, - "sprint": self._sprint, - "calendar": self._calendar, - } - for mode_key, data in mode_data.items(): - if data and self._scroll_manager.is_mode_prepared(mode_key): + images.append(self._scroll_renderer.render_circuit_info_card( + self._upcoming_race)) + return images + + if section == "last_race": + return list(self._vegas_last_race_cards) + + images = [] + for mode_key in self._VEGAS_SECTION_MODES.get(section, ()): + if self._scroll_manager.is_mode_prepared(mode_key): images.extend( self._scroll_manager.get_vegas_items_for_mode(mode_key)) + return images + + def get_vegas_content(self) -> Optional[List[Image.Image]]: + """Return rendered cards for the configured marquee sections.""" + wanted = set(self._vegas_sections()) + images = [] + for section in self._VEGAS_SECTION_ORDER: + if section in wanted: + images.extend(self._vegas_section_images(section)) return images if images else None diff --git a/plugins/f1-scoreboard/manifest.json b/plugins/f1-scoreboard/manifest.json index fd43c9d1..cf7a22db 100644 --- a/plugins/f1-scoreboard/manifest.json +++ b/plugins/f1-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "f1-scoreboard", "name": "F1 Scoreboard", - "version": "1.7.3", + "version": "1.8.0", "author": "ChuckBuilds", "class_name": "F1ScoreboardPlugin", "entry_point": "manager.py", @@ -29,6 +29,12 @@ "f1_calendar" ], "versions": [ + { + "version": "1.8.0", + "released": "2026-08-07", + "notes": "Stop F1 taking over the Vegas marquee. get_vegas_content() contributed every prepared mode at once, which measured 114 cards / 14,592px on a 512px panel -- close to six minutes of unbroken F1 at 50px/s, because what this plugin's own rotation shows as eight separate screens the marquee splices into one block. A new \"vegas\": {\"sections\": [...]} setting picks what joins the marquee, defaulting to the next race and the last race's results (around 15 seconds). Every section remains available: add them back to get the old behaviour, or set an empty list to keep F1 out of the marquee entirely. The plugin's own display modes are unchanged and still show everything.", + "ledmatrix_min_version": "2.0.0" + }, { "version": "1.7.3", "released": "2026-08-05", diff --git a/plugins/f1-scoreboard/test_vegas_sections.py b/plugins/f1-scoreboard/test_vegas_sections.py new file mode 100644 index 00000000..e7478f7e --- /dev/null +++ b/plugins/f1-scoreboard/test_vegas_sections.py @@ -0,0 +1,196 @@ +#!/usr/bin/env python3 +""" +Tests for which sections F1 contributes to the Vegas marquee. + +Regression under test: get_vegas_content() used to concatenate every prepared +mode. Measured on a 512px panel that was 114 cards / 14,592px -- close to six +minutes of unbroken F1 at 50px/s, because what the plugin's own rotation shows +as eight separate screens the marquee splices into one block. It now emits only +the sections named in ``vegas.sections``, defaulting to the next race and the +last race's results. + +The selection methods are exercised against a stand-in ``self`` rather than a +constructed plugin, so the test needs no display manager, no network and no +cache -- only that manager.py imports. + +Run: /bin/python plugins/f1-scoreboard/test_vegas_sections.py +""" + +import sys +from pathlib import Path + +plugin_dir = Path(__file__).parent +sys.path.insert(0, str(plugin_dir)) +# The core provides BasePlugin, which manager.py imports at module scope. +for candidate in (Path("/home/rackpi/projects/LEDMatrix"), + plugin_dir.parents[2] / "LEDMatrix"): + if (candidate / "src" / "plugin_system" / "base_plugin.py").exists(): + sys.path.insert(0, str(candidate)) + break + +from manager import F1ScoreboardPlugin # noqa: E402 + + +class _Logger: + def __init__(self): + self.warnings = [] + + def warning(self, msg, *args): + self.warnings.append(msg % args if args else msg) + + +class _ScrollManager: + """Stands in for ScrollDisplayManager, holding one marker per mode.""" + + def __init__(self, modes): + self._modes = modes + + def is_mode_prepared(self, mode_key): + return mode_key in self._modes + + def get_vegas_items_for_mode(self, mode_key): + return list(self._modes.get(mode_key, [])) + + +class _Renderer: + show_circuit_info = True + + def render_upcoming_race(self, race): + return "upcoming:" + race["name"] + + def render_circuit_info_card(self, race): + return "circuit:" + race["name"] + + +class _Plugin: + """A stand-in ``self`` carrying only what the selection methods touch.""" + + # The methods under test, bound to this stand-in. + _VEGAS_SECTION_ORDER = F1ScoreboardPlugin._VEGAS_SECTION_ORDER + _VEGAS_DEFAULT_SECTIONS = F1ScoreboardPlugin._VEGAS_DEFAULT_SECTIONS + _VEGAS_SECTION_MODES = F1ScoreboardPlugin._VEGAS_SECTION_MODES + _vegas_sections = F1ScoreboardPlugin._vegas_sections + _vegas_section_images = F1ScoreboardPlugin._vegas_section_images + get_vegas_content = F1ScoreboardPlugin.get_vegas_content + + def __init__(self, config=None, modes=None, upcoming=True, last_race=True): + self.config = config or {} + self.logger = _Logger() + self._scroll_manager = _ScrollManager(modes or {}) + self._scroll_renderer = _Renderer() + self._upcoming_race = {"name": "MONZA"} if upcoming else None + self._vegas_last_race_cards = ( + ["last:result", "last:gaps"] if last_race else []) + + def _enrich_upcoming_with_countdown(self, race): + return race + + +ALL_MODES = { + "championship_leaders": ["leaders"], + "championship_battle": ["battle:drv"], + "constructor_battle": ["battle:con"], + "driver_spotlight": ["spot:drv"], + "team_spotlight": ["spot:team"], + "driver_standings": ["drv:%d" % i for i in range(12)], + "constructor_standings": ["con:%d" % i for i in range(11)], + "recent_races": ["recent:%d" % i for i in range(7)], + "qualifying": ["quali:%d" % i for i in range(24)], + "practice": ["fp:%d" % i for i in range(33)], + "sprint": ["sprint:%d" % i for i in range(21)], + "calendar": ["cal:%d" % i for i in range(5)], +} + +failures = [] + + +def check(name, actual, expected): + if actual == expected: + print(" PASS %s" % name) + else: + print(" FAIL %s\n expected: %r\n actual: %r" + % (name, expected, actual)) + failures.append(name) + + +print("default is the next race and the last race's results") +p = _Plugin(modes=ALL_MODES) +check("default sections", p._vegas_sections(), ["upcoming", "last_race"]) +check("default content", p.get_vegas_content(), + ["upcoming:MONZA", "circuit:MONZA", "last:result", "last:gaps"]) +check("everything else is left out", 4, len(p.get_vegas_content())) + +print("\nthe old behaviour is still reachable by naming every section") +p = _Plugin(config={"vegas": {"sections": list( + F1ScoreboardPlugin._VEGAS_SECTION_ORDER)}}, modes=ALL_MODES) +everything = p.get_vegas_content() +check("all sections emit", len(everything), 2 + 2 + sum( + len(v) for v in ALL_MODES.values())) +check("leaders still lead", everything[0], "leaders") + +print("\nemission follows _VEGAS_SECTION_ORDER, not the user's ordering") +p = _Plugin(config={"vegas": {"sections": ["last_race", "upcoming"]}}) +check("reversed input, canonical output", p.get_vegas_content(), + ["upcoming:MONZA", "circuit:MONZA", "last:result", "last:gaps"]) + +print("\nsections with no data contribute nothing") +p = _Plugin(config={"vegas": {"sections": ["upcoming", "last_race"]}}, + upcoming=False, last_race=False) +check("no data at all -> None", p.get_vegas_content(), None) +p = _Plugin(config={"vegas": {"sections": ["qualifying"]}}, modes={}) +check("unprepared mode -> None", p.get_vegas_content(), None) + +print("\ncircuit card follows the renderer's own toggle") +p = _Plugin(config={"vegas": {"sections": ["upcoming"]}}) +p._scroll_renderer.show_circuit_info = False +check("circuit card suppressed", p.get_vegas_content(), ["upcoming:MONZA"]) + +print("\nlast_race is the most recent race, recent_races is all of them") +p = _Plugin(config={"vegas": {"sections": ["last_race"]}}, modes=ALL_MODES) +check("last_race", p.get_vegas_content(), ["last:result", "last:gaps"]) +p = _Plugin(config={"vegas": {"sections": ["recent_races"]}}, modes=ALL_MODES) +check("recent_races", len(p.get_vegas_content()), 7) + +print("\nan empty list keeps F1 out of the marquee") +p = _Plugin(config={"vegas": {"sections": []}}, modes=ALL_MODES) +check("empty stays empty", p._vegas_sections(), []) +check("empty -> None", p.get_vegas_content(), None) + +print("\nbad input degrades to something usable") +p = _Plugin(config={"vegas": {"sections": ["upcoming", "bogus"]}}) +check("unknown name dropped", p._vegas_sections(), ["upcoming"]) +check("and warned about", len(p.logger.warnings), 1) + +p = _Plugin(config={"vegas": {"sections": ["nope", "also-nope"]}}) +check("nothing usable -> default", p._vegas_sections(), + ["upcoming", "last_race"]) + +p = _Plugin(config={"vegas": {"sections": "upcoming"}}) +check("a bare string is taken as one entry", p._vegas_sections(), ["upcoming"]) + +p = _Plugin(config={"vegas": {"sections": 42}}) +check("a non-list -> default", p._vegas_sections(), ["upcoming", "last_race"]) +check("and warned about", len(p.logger.warnings), 1) + +p = _Plugin(config={"vegas": {"sections": [" UPCOMING "]}}) +check("case and whitespace tolerated", p._vegas_sections(), ["upcoming"]) + +p = _Plugin(config={}) +check("no vegas block -> default", p._vegas_sections(), + ["upcoming", "last_race"]) + +# The schema forbids these, but config.json is hand-edited often enough that +# the marquee's render path must not raise on them. +for bad in (None, [], ["upcoming"], "upcoming", 7): + p = _Plugin(config={"vegas": bad}) + check("vegas=%r -> default, no raise" % (bad,), p._vegas_sections(), + ["upcoming", "last_race"]) + +print("\nevery section in the order is resolvable") +p = _Plugin(modes=ALL_MODES) +unresolvable = [s for s in F1ScoreboardPlugin._VEGAS_SECTION_ORDER + if not p._vegas_section_images(s)] +check("no dead section keys", unresolvable, []) + +print("\n%s" % ("FAILED: %d" % len(failures) if failures else "All checks passed")) +sys.exit(1 if failures else 0)