diff --git a/plugins.json b/plugins.json index d502e48..4cca2cd 100644 --- a/plugins.json +++ b/plugins.json @@ -1,6 +1,6 @@ { "version": "1.0.0", - "last_updated": "2026-02-17", + "last_updated": "2026-02-20", "plugins": [ { "id": "hello-world", @@ -221,10 +221,10 @@ "plugin_path": "plugins/football-scoreboard", "stars": 0, "downloads": 0, - "last_updated": "2026-02-15", + "last_updated": "2026-02-20", "verified": true, "screenshot": "", - "latest_version": "2.1.1" + "latest_version": "2.2.0" }, { "id": "ufc-scoreboard", @@ -270,10 +270,10 @@ "plugin_path": "plugins/basketball-scoreboard", "stars": 0, "downloads": 0, - "last_updated": "2026-02-15", + "last_updated": "2026-02-20", "verified": true, "screenshot": "", - "latest_version": "1.1.1" + "latest_version": "1.2.0" }, { "id": "baseball-scoreboard", @@ -296,10 +296,10 @@ "plugin_path": "plugins/baseball-scoreboard", "stars": 0, "downloads": 0, - "last_updated": "2026-02-17", + "last_updated": "2026-02-20", "verified": true, "screenshot": "", - "latest_version": "1.3.1" + "latest_version": "1.4.0" }, { "id": "soccer-scoreboard", @@ -624,6 +624,31 @@ "screenshot": "", "latest_version": "1.0.0" }, + { + "id": "f1-scoreboard", + "name": "F1 Scoreboard", + "description": "Comprehensive Formula 1 racing display with driver standings, constructor standings, race results, qualifying (Q1/Q2/Q3), free practice, sprint results, upcoming race countdown with circuit outline, and season calendar. Supports favorite team/driver filtering and team color accent bars.", + "author": "ChuckBuilds", + "category": "sports", + "tags": [ + "f1", + "formula1", + "racing", + "sports", + "standings", + "scoreboard", + "motorsport" + ], + "repo": "https://github.com/ChuckBuilds/ledmatrix-plugins", + "branch": "main", + "plugin_path": "plugins/f1-scoreboard", + "stars": 0, + "downloads": 0, + "last_updated": "2026-02-18", + "verified": true, + "screenshot": "", + "latest_version": "1.1.0" + }, { "id": "web-ui-info", "name": "Web UI Info", diff --git a/plugins/baseball-scoreboard/config_schema.json b/plugins/baseball-scoreboard/config_schema.json index 0f2576b..8f48d77 100644 --- a/plugins/baseball-scoreboard/config_schema.json +++ b/plugins/baseball-scoreboard/config_schema.json @@ -69,6 +69,62 @@ "type": "boolean", "default": true, "description": "Show upcoming MLB games" + }, + "live_display_mode": { + "type": "string", + "enum": ["switch", "scroll"], + "default": "switch", + "description": "Display mode for live games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" + }, + "recent_display_mode": { + "type": "string", + "enum": ["switch", "scroll"], + "default": "switch", + "description": "Display mode for recent games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" + }, + "upcoming_display_mode": { + "type": "string", + "enum": ["switch", "scroll"], + "default": "switch", + "description": "Display mode for upcoming games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" + } + } + }, + "scroll_settings": { + "type": "object", + "title": "Scroll Settings", + "description": "Settings for scroll display mode (when display mode is set to 'scroll')", + "properties": { + "scroll_speed": { + "type": "number", + "default": 50.0, + "minimum": 1.0, + "maximum": 200.0, + "description": "Scroll speed in pixels per second (default: 50). Higher values scroll faster." + }, + "scroll_delay": { + "type": "number", + "default": 0.01, + "minimum": 0.001, + "maximum": 0.1, + "description": "Delay between scroll frames in seconds (default: 0.01 = 100 FPS). Lower values = smoother scrolling." + }, + "gap_between_games": { + "type": "integer", + "default": 48, + "minimum": 8, + "maximum": 128, + "description": "Gap in pixels between game cards when scrolling" + }, + "show_league_separators": { + "type": "boolean", + "default": true, + "description": "Show league icons (MLB shield, NCAA logos) between different leagues" + }, + "dynamic_duration": { + "type": "boolean", + "default": true, + "description": "Automatically calculate display duration based on content width" } } }, @@ -344,6 +400,62 @@ "type": "boolean", "default": true, "description": "Show upcoming MiLB games" + }, + "live_display_mode": { + "type": "string", + "enum": ["switch", "scroll"], + "default": "switch", + "description": "Display mode for live games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" + }, + "recent_display_mode": { + "type": "string", + "enum": ["switch", "scroll"], + "default": "switch", + "description": "Display mode for recent games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" + }, + "upcoming_display_mode": { + "type": "string", + "enum": ["switch", "scroll"], + "default": "switch", + "description": "Display mode for upcoming games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" + } + } + }, + "scroll_settings": { + "type": "object", + "title": "Scroll Settings", + "description": "Settings for scroll display mode (when display mode is set to 'scroll')", + "properties": { + "scroll_speed": { + "type": "number", + "default": 50.0, + "minimum": 1.0, + "maximum": 200.0, + "description": "Scroll speed in pixels per second (default: 50). Higher values scroll faster." + }, + "scroll_delay": { + "type": "number", + "default": 0.01, + "minimum": 0.001, + "maximum": 0.1, + "description": "Delay between scroll frames in seconds (default: 0.01 = 100 FPS). Lower values = smoother scrolling." + }, + "gap_between_games": { + "type": "integer", + "default": 48, + "minimum": 8, + "maximum": 128, + "description": "Gap in pixels between game cards when scrolling" + }, + "show_league_separators": { + "type": "boolean", + "default": true, + "description": "Show league icons between different leagues" + }, + "dynamic_duration": { + "type": "boolean", + "default": true, + "description": "Automatically calculate display duration based on content width" } } }, @@ -619,6 +731,62 @@ "type": "boolean", "default": true, "description": "Show upcoming NCAA Baseball games" + }, + "live_display_mode": { + "type": "string", + "enum": ["switch", "scroll"], + "default": "switch", + "description": "Display mode for live games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" + }, + "recent_display_mode": { + "type": "string", + "enum": ["switch", "scroll"], + "default": "switch", + "description": "Display mode for recent games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" + }, + "upcoming_display_mode": { + "type": "string", + "enum": ["switch", "scroll"], + "default": "switch", + "description": "Display mode for upcoming games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" + } + } + }, + "scroll_settings": { + "type": "object", + "title": "Scroll Settings", + "description": "Settings for scroll display mode (when display mode is set to 'scroll')", + "properties": { + "scroll_speed": { + "type": "number", + "default": 50.0, + "minimum": 1.0, + "maximum": 200.0, + "description": "Scroll speed in pixels per second (default: 50). Higher values scroll faster." + }, + "scroll_delay": { + "type": "number", + "default": 0.01, + "minimum": 0.001, + "maximum": 0.1, + "description": "Delay between scroll frames in seconds (default: 0.01 = 100 FPS). Lower values = smoother scrolling." + }, + "gap_between_games": { + "type": "integer", + "default": 48, + "minimum": 8, + "maximum": 128, + "description": "Gap in pixels between game cards when scrolling" + }, + "show_league_separators": { + "type": "boolean", + "default": true, + "description": "Show league icons between different leagues" + }, + "dynamic_duration": { + "type": "boolean", + "default": true, + "description": "Automatically calculate display duration based on content width" } } }, diff --git a/plugins/baseball-scoreboard/manifest.json b/plugins/baseball-scoreboard/manifest.json index 808fa83..c043e32 100644 --- a/plugins/baseball-scoreboard/manifest.json +++ b/plugins/baseball-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "baseball-scoreboard", "name": "Baseball Scoreboard", - "version": "1.3.1", + "version": "1.4.0", "author": "ChuckBuilds", "description": "Live, recent, and upcoming baseball games across MLB, MiLB, and NCAA Baseball with real-time scores and schedules", "category": "sports", @@ -30,6 +30,11 @@ "branch": "main", "plugin_path": "plugins/baseball-scoreboard", "versions": [ + { + "released": "2026-02-20", + "version": "1.4.0", + "ledmatrix_min": "2.0.0" + }, { "released": "2026-02-17", "version": "1.3.1", @@ -71,7 +76,7 @@ "ledmatrix_min": "2.0.0" } ], - "last_updated": "2026-02-14", + "last_updated": "2026-02-20", "stars": 0, "downloads": 0, "verified": true, diff --git a/plugins/basketball-scoreboard/config_schema.json b/plugins/basketball-scoreboard/config_schema.json index c50f727..936811d 100644 --- a/plugins/basketball-scoreboard/config_schema.json +++ b/plugins/basketball-scoreboard/config_schema.json @@ -95,6 +95,62 @@ "type": "boolean", "default": true, "description": "Show upcoming NBA games" + }, + "live_display_mode": { + "type": "string", + "enum": ["switch", "scroll"], + "default": "switch", + "description": "Display mode for live games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" + }, + "recent_display_mode": { + "type": "string", + "enum": ["switch", "scroll"], + "default": "switch", + "description": "Display mode for recent games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" + }, + "upcoming_display_mode": { + "type": "string", + "enum": ["switch", "scroll"], + "default": "switch", + "description": "Display mode for upcoming games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" + } + } + }, + "scroll_settings": { + "type": "object", + "title": "Scroll Settings", + "description": "Settings for scroll display mode (when display mode is set to 'scroll')", + "properties": { + "scroll_speed": { + "type": "number", + "default": 50.0, + "minimum": 1.0, + "maximum": 200.0, + "description": "Scroll speed in pixels per second (default: 50). Higher values scroll faster." + }, + "scroll_delay": { + "type": "number", + "default": 0.01, + "minimum": 0.001, + "maximum": 0.1, + "description": "Delay between scroll frames in seconds (default: 0.01 = 100 FPS). Lower values = smoother scrolling." + }, + "gap_between_games": { + "type": "integer", + "default": 48, + "minimum": 8, + "maximum": 128, + "description": "Gap in pixels between game cards when scrolling" + }, + "show_league_separators": { + "type": "boolean", + "default": true, + "description": "Show league icons between different leagues" + }, + "dynamic_duration": { + "type": "boolean", + "default": true, + "description": "Automatically calculate display duration based on content width" } } }, @@ -351,6 +407,62 @@ "type": "boolean", "default": true, "description": "Show upcoming WNBA games" + }, + "live_display_mode": { + "type": "string", + "enum": ["switch", "scroll"], + "default": "switch", + "description": "Display mode for live games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" + }, + "recent_display_mode": { + "type": "string", + "enum": ["switch", "scroll"], + "default": "switch", + "description": "Display mode for recent games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" + }, + "upcoming_display_mode": { + "type": "string", + "enum": ["switch", "scroll"], + "default": "switch", + "description": "Display mode for upcoming games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" + } + } + }, + "scroll_settings": { + "type": "object", + "title": "Scroll Settings", + "description": "Settings for scroll display mode (when display mode is set to 'scroll')", + "properties": { + "scroll_speed": { + "type": "number", + "default": 50.0, + "minimum": 1.0, + "maximum": 200.0, + "description": "Scroll speed in pixels per second (default: 50). Higher values scroll faster." + }, + "scroll_delay": { + "type": "number", + "default": 0.01, + "minimum": 0.001, + "maximum": 0.1, + "description": "Delay between scroll frames in seconds (default: 0.01 = 100 FPS). Lower values = smoother scrolling." + }, + "gap_between_games": { + "type": "integer", + "default": 48, + "minimum": 8, + "maximum": 128, + "description": "Gap in pixels between game cards when scrolling" + }, + "show_league_separators": { + "type": "boolean", + "default": true, + "description": "Show league icons between different leagues" + }, + "dynamic_duration": { + "type": "boolean", + "default": true, + "description": "Automatically calculate display duration based on content width" } } }, @@ -607,6 +719,62 @@ "type": "boolean", "default": true, "description": "Show upcoming NCAA Men's Basketball games" + }, + "live_display_mode": { + "type": "string", + "enum": ["switch", "scroll"], + "default": "switch", + "description": "Display mode for live games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" + }, + "recent_display_mode": { + "type": "string", + "enum": ["switch", "scroll"], + "default": "switch", + "description": "Display mode for recent games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" + }, + "upcoming_display_mode": { + "type": "string", + "enum": ["switch", "scroll"], + "default": "switch", + "description": "Display mode for upcoming games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" + } + } + }, + "scroll_settings": { + "type": "object", + "title": "Scroll Settings", + "description": "Settings for scroll display mode (when display mode is set to 'scroll')", + "properties": { + "scroll_speed": { + "type": "number", + "default": 50.0, + "minimum": 1.0, + "maximum": 200.0, + "description": "Scroll speed in pixels per second (default: 50). Higher values scroll faster." + }, + "scroll_delay": { + "type": "number", + "default": 0.01, + "minimum": 0.001, + "maximum": 0.1, + "description": "Delay between scroll frames in seconds (default: 0.01 = 100 FPS). Lower values = smoother scrolling." + }, + "gap_between_games": { + "type": "integer", + "default": 48, + "minimum": 8, + "maximum": 128, + "description": "Gap in pixels between game cards when scrolling" + }, + "show_league_separators": { + "type": "boolean", + "default": true, + "description": "Show league icons between different leagues" + }, + "dynamic_duration": { + "type": "boolean", + "default": true, + "description": "Automatically calculate display duration based on content width" } } }, @@ -863,6 +1031,62 @@ "type": "boolean", "default": true, "description": "Show upcoming NCAA Women's Basketball games" + }, + "live_display_mode": { + "type": "string", + "enum": ["switch", "scroll"], + "default": "switch", + "description": "Display mode for live games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" + }, + "recent_display_mode": { + "type": "string", + "enum": ["switch", "scroll"], + "default": "switch", + "description": "Display mode for recent games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" + }, + "upcoming_display_mode": { + "type": "string", + "enum": ["switch", "scroll"], + "default": "switch", + "description": "Display mode for upcoming games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" + } + } + }, + "scroll_settings": { + "type": "object", + "title": "Scroll Settings", + "description": "Settings for scroll display mode (when display mode is set to 'scroll')", + "properties": { + "scroll_speed": { + "type": "number", + "default": 50.0, + "minimum": 1.0, + "maximum": 200.0, + "description": "Scroll speed in pixels per second (default: 50). Higher values scroll faster." + }, + "scroll_delay": { + "type": "number", + "default": 0.01, + "minimum": 0.001, + "maximum": 0.1, + "description": "Delay between scroll frames in seconds (default: 0.01 = 100 FPS). Lower values = smoother scrolling." + }, + "gap_between_games": { + "type": "integer", + "default": 48, + "minimum": 8, + "maximum": 128, + "description": "Gap in pixels between game cards when scrolling" + }, + "show_league_separators": { + "type": "boolean", + "default": true, + "description": "Show league icons between different leagues" + }, + "dynamic_duration": { + "type": "boolean", + "default": true, + "description": "Automatically calculate display duration based on content width" } } }, diff --git a/plugins/basketball-scoreboard/manifest.json b/plugins/basketball-scoreboard/manifest.json index 46ffb76..3982786 100644 --- a/plugins/basketball-scoreboard/manifest.json +++ b/plugins/basketball-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "basketball-scoreboard", "name": "Basketball Scoreboard", - "version": "1.1.1", + "version": "1.2.0", "description": "Live, recent, and upcoming basketball games across NBA, NCAA Men's, NCAA Women's, and WNBA with real-time scores and schedules", "author": "ChuckBuilds", "category": "sports", @@ -18,6 +18,11 @@ "branch": "main", "plugin_path": "plugins/basketball-scoreboard", "versions": [ + { + "version": "1.2.0", + "ledmatrix_min": "2.0.0", + "released": "2026-02-20" + }, { "version": "1.1.1", "ledmatrix_min": "2.0.0", @@ -36,7 +41,7 @@ ], "stars": 0, "downloads": 0, - "last_updated": "2026-02-15", + "last_updated": "2026-02-20", "verified": true, "screenshot": "", "display_modes": [ diff --git a/plugins/f1-scoreboard/assets/f1/circuits/austin.png b/plugins/f1-scoreboard/assets/f1/circuits/austin.png new file mode 100644 index 0000000..3eddf88 Binary files /dev/null and b/plugins/f1-scoreboard/assets/f1/circuits/austin.png differ diff --git a/plugins/f1-scoreboard/assets/f1/circuits/bahrain.png b/plugins/f1-scoreboard/assets/f1/circuits/bahrain.png new file mode 100644 index 0000000..e1fcdfd Binary files /dev/null and b/plugins/f1-scoreboard/assets/f1/circuits/bahrain.png differ diff --git a/plugins/f1-scoreboard/assets/f1/circuits/baku.png b/plugins/f1-scoreboard/assets/f1/circuits/baku.png new file mode 100644 index 0000000..f0668cf Binary files /dev/null and b/plugins/f1-scoreboard/assets/f1/circuits/baku.png differ diff --git a/plugins/f1-scoreboard/assets/f1/circuits/barcelona.png b/plugins/f1-scoreboard/assets/f1/circuits/barcelona.png new file mode 100644 index 0000000..b9594ed Binary files /dev/null and b/plugins/f1-scoreboard/assets/f1/circuits/barcelona.png differ diff --git a/plugins/f1-scoreboard/assets/f1/circuits/budapest.png b/plugins/f1-scoreboard/assets/f1/circuits/budapest.png new file mode 100644 index 0000000..0584588 Binary files /dev/null and b/plugins/f1-scoreboard/assets/f1/circuits/budapest.png differ diff --git a/plugins/f1-scoreboard/assets/f1/circuits/interlagos.png b/plugins/f1-scoreboard/assets/f1/circuits/interlagos.png new file mode 100644 index 0000000..ac44a72 Binary files /dev/null and b/plugins/f1-scoreboard/assets/f1/circuits/interlagos.png differ diff --git a/plugins/f1-scoreboard/assets/f1/circuits/jeddah.png b/plugins/f1-scoreboard/assets/f1/circuits/jeddah.png new file mode 100644 index 0000000..ec69822 Binary files /dev/null and b/plugins/f1-scoreboard/assets/f1/circuits/jeddah.png differ diff --git a/plugins/f1-scoreboard/assets/f1/circuits/las_vegas.png b/plugins/f1-scoreboard/assets/f1/circuits/las_vegas.png new file mode 100644 index 0000000..49f75a4 Binary files /dev/null and b/plugins/f1-scoreboard/assets/f1/circuits/las_vegas.png differ diff --git a/plugins/f1-scoreboard/assets/f1/circuits/losail.png b/plugins/f1-scoreboard/assets/f1/circuits/losail.png new file mode 100644 index 0000000..e1a7311 Binary files /dev/null and b/plugins/f1-scoreboard/assets/f1/circuits/losail.png differ diff --git a/plugins/f1-scoreboard/assets/f1/circuits/madrid.png b/plugins/f1-scoreboard/assets/f1/circuits/madrid.png new file mode 100644 index 0000000..1b64b5e Binary files /dev/null and b/plugins/f1-scoreboard/assets/f1/circuits/madrid.png differ diff --git a/plugins/f1-scoreboard/assets/f1/circuits/melbourne.png b/plugins/f1-scoreboard/assets/f1/circuits/melbourne.png new file mode 100644 index 0000000..1ce32bf Binary files /dev/null and b/plugins/f1-scoreboard/assets/f1/circuits/melbourne.png differ diff --git a/plugins/f1-scoreboard/assets/f1/circuits/mexico_city.png b/plugins/f1-scoreboard/assets/f1/circuits/mexico_city.png new file mode 100644 index 0000000..6de785f Binary files /dev/null and b/plugins/f1-scoreboard/assets/f1/circuits/mexico_city.png differ diff --git a/plugins/f1-scoreboard/assets/f1/circuits/miami.png b/plugins/f1-scoreboard/assets/f1/circuits/miami.png new file mode 100644 index 0000000..312df54 Binary files /dev/null and b/plugins/f1-scoreboard/assets/f1/circuits/miami.png differ diff --git a/plugins/f1-scoreboard/assets/f1/circuits/monaco.png b/plugins/f1-scoreboard/assets/f1/circuits/monaco.png new file mode 100644 index 0000000..1eff105 Binary files /dev/null and b/plugins/f1-scoreboard/assets/f1/circuits/monaco.png differ diff --git a/plugins/f1-scoreboard/assets/f1/circuits/montreal.png b/plugins/f1-scoreboard/assets/f1/circuits/montreal.png new file mode 100644 index 0000000..1353a77 Binary files /dev/null and b/plugins/f1-scoreboard/assets/f1/circuits/montreal.png differ diff --git a/plugins/f1-scoreboard/assets/f1/circuits/monza.png b/plugins/f1-scoreboard/assets/f1/circuits/monza.png new file mode 100644 index 0000000..eb2a722 Binary files /dev/null and b/plugins/f1-scoreboard/assets/f1/circuits/monza.png differ diff --git a/plugins/f1-scoreboard/assets/f1/circuits/shanghai.png b/plugins/f1-scoreboard/assets/f1/circuits/shanghai.png new file mode 100644 index 0000000..1cfdc1e Binary files /dev/null and b/plugins/f1-scoreboard/assets/f1/circuits/shanghai.png differ diff --git a/plugins/f1-scoreboard/assets/f1/circuits/silverstone.png b/plugins/f1-scoreboard/assets/f1/circuits/silverstone.png new file mode 100644 index 0000000..865e5fd Binary files /dev/null and b/plugins/f1-scoreboard/assets/f1/circuits/silverstone.png differ diff --git a/plugins/f1-scoreboard/assets/f1/circuits/singapore.png b/plugins/f1-scoreboard/assets/f1/circuits/singapore.png new file mode 100644 index 0000000..bf2e312 Binary files /dev/null and b/plugins/f1-scoreboard/assets/f1/circuits/singapore.png differ diff --git a/plugins/f1-scoreboard/assets/f1/circuits/spa.png b/plugins/f1-scoreboard/assets/f1/circuits/spa.png new file mode 100644 index 0000000..f27da6f Binary files /dev/null and b/plugins/f1-scoreboard/assets/f1/circuits/spa.png differ diff --git a/plugins/f1-scoreboard/assets/f1/circuits/spielberg.png b/plugins/f1-scoreboard/assets/f1/circuits/spielberg.png new file mode 100644 index 0000000..bb6992d Binary files /dev/null and b/plugins/f1-scoreboard/assets/f1/circuits/spielberg.png differ diff --git a/plugins/f1-scoreboard/assets/f1/circuits/suzuka.png b/plugins/f1-scoreboard/assets/f1/circuits/suzuka.png new file mode 100644 index 0000000..2f03bc5 Binary files /dev/null and b/plugins/f1-scoreboard/assets/f1/circuits/suzuka.png differ diff --git a/plugins/f1-scoreboard/assets/f1/circuits/yas_marina.png b/plugins/f1-scoreboard/assets/f1/circuits/yas_marina.png new file mode 100644 index 0000000..cd6c805 Binary files /dev/null and b/plugins/f1-scoreboard/assets/f1/circuits/yas_marina.png differ diff --git a/plugins/f1-scoreboard/assets/f1/circuits/zandvoort.png b/plugins/f1-scoreboard/assets/f1/circuits/zandvoort.png new file mode 100644 index 0000000..9483f63 Binary files /dev/null and b/plugins/f1-scoreboard/assets/f1/circuits/zandvoort.png differ diff --git a/plugins/f1-scoreboard/assets/f1/f1_logo.png b/plugins/f1-scoreboard/assets/f1/f1_logo.png new file mode 100644 index 0000000..1d6bb1f Binary files /dev/null and b/plugins/f1-scoreboard/assets/f1/f1_logo.png differ diff --git a/plugins/f1-scoreboard/assets/f1/teams/alpine.png b/plugins/f1-scoreboard/assets/f1/teams/alpine.png new file mode 100644 index 0000000..9561c15 Binary files /dev/null and b/plugins/f1-scoreboard/assets/f1/teams/alpine.png differ diff --git a/plugins/f1-scoreboard/assets/f1/teams/aston_martin.png b/plugins/f1-scoreboard/assets/f1/teams/aston_martin.png new file mode 100644 index 0000000..a918bb2 Binary files /dev/null and b/plugins/f1-scoreboard/assets/f1/teams/aston_martin.png differ diff --git a/plugins/f1-scoreboard/assets/f1/teams/ferrari.png b/plugins/f1-scoreboard/assets/f1/teams/ferrari.png new file mode 100644 index 0000000..9684583 Binary files /dev/null and b/plugins/f1-scoreboard/assets/f1/teams/ferrari.png differ diff --git a/plugins/f1-scoreboard/assets/f1/teams/haas.png b/plugins/f1-scoreboard/assets/f1/teams/haas.png new file mode 100644 index 0000000..05c7e6a Binary files /dev/null and b/plugins/f1-scoreboard/assets/f1/teams/haas.png differ diff --git a/plugins/f1-scoreboard/assets/f1/teams/mclaren.png b/plugins/f1-scoreboard/assets/f1/teams/mclaren.png new file mode 100644 index 0000000..091c6de Binary files /dev/null and b/plugins/f1-scoreboard/assets/f1/teams/mclaren.png differ diff --git a/plugins/f1-scoreboard/assets/f1/teams/mercedes.png b/plugins/f1-scoreboard/assets/f1/teams/mercedes.png new file mode 100644 index 0000000..13b9f31 Binary files /dev/null and b/plugins/f1-scoreboard/assets/f1/teams/mercedes.png differ diff --git a/plugins/f1-scoreboard/assets/f1/teams/rb.png b/plugins/f1-scoreboard/assets/f1/teams/rb.png new file mode 100644 index 0000000..8744b9f Binary files /dev/null and b/plugins/f1-scoreboard/assets/f1/teams/rb.png differ diff --git a/plugins/f1-scoreboard/assets/f1/teams/red_bull.png b/plugins/f1-scoreboard/assets/f1/teams/red_bull.png new file mode 100644 index 0000000..bf8cec8 Binary files /dev/null and b/plugins/f1-scoreboard/assets/f1/teams/red_bull.png differ diff --git a/plugins/f1-scoreboard/assets/f1/teams/sauber.png b/plugins/f1-scoreboard/assets/f1/teams/sauber.png new file mode 100644 index 0000000..3bd3fdd Binary files /dev/null and b/plugins/f1-scoreboard/assets/f1/teams/sauber.png differ diff --git a/plugins/f1-scoreboard/assets/f1/teams/williams.png b/plugins/f1-scoreboard/assets/f1/teams/williams.png new file mode 100644 index 0000000..95e6c12 Binary files /dev/null and b/plugins/f1-scoreboard/assets/f1/teams/williams.png differ diff --git a/plugins/f1-scoreboard/config_schema.json b/plugins/f1-scoreboard/config_schema.json new file mode 100644 index 0000000..4b9b2e9 --- /dev/null +++ b/plugins/f1-scoreboard/config_schema.json @@ -0,0 +1,424 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "F1 Scoreboard Plugin Configuration", + "description": "Configuration schema for the F1 Scoreboard plugin - displays Formula 1 standings, race results, qualifying, practice, sprint results, upcoming races, and race calendar", + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "title": "Enabled", + "default": true, + "description": "Enable or disable the F1 scoreboard plugin" + }, + "display_duration": { + "type": "integer", + "title": "Display Duration", + "default": 30, + "minimum": 5, + "maximum": 300, + "description": "Duration in seconds for the display controller to show each plugin mode before rotating" + }, + "update_interval": { + "type": "integer", + "title": "Update Interval", + "default": 3600, + "minimum": 60, + "maximum": 86400, + "description": "How often to fetch new data from APIs in seconds" + }, + "favorite_team": { + "type": "string", + "title": "Favorite Team", + "default": "", + "description": "Favorite constructor/team ID (e.g., mclaren, ferrari, red_bull, mercedes, williams, aston_martin, alpine, haas, sauber, rb). This team will always be shown and highlighted in standings." + }, + "favorite_driver": { + "type": "string", + "title": "Favorite Driver", + "default": "", + "description": "Favorite driver code (e.g., VER, NOR, PIA, LEC, SAI, HAM, RUS, ALO, STR). This driver will always be shown and highlighted in standings and results." + }, + "driver_standings": { + "type": "object", + "title": "Driver Standings", + "description": "Configuration for current driver championship standings", + "properties": { + "enabled": { + "type": "boolean", + "title": "Enabled", + "default": true, + "description": "Show driver standings display mode" + }, + "top_n": { + "type": "integer", + "title": "Top Drivers", + "default": 10, + "minimum": 3, + "maximum": 22, + "description": "Number of top drivers to show in standings" + }, + "always_show_favorite": { + "type": "boolean", + "title": "Always Show Favorite", + "default": true, + "description": "Always include favorite driver even if outside top N" + } + }, + "x-propertyOrder": ["enabled", "top_n", "always_show_favorite"], + "additionalProperties": false + }, + "constructor_standings": { + "type": "object", + "title": "Constructor Standings", + "description": "Configuration for current constructor championship standings", + "properties": { + "enabled": { + "type": "boolean", + "title": "Enabled", + "default": true, + "description": "Show constructor standings display mode" + }, + "top_n": { + "type": "integer", + "title": "Top Constructors", + "default": 10, + "minimum": 3, + "maximum": 12, + "description": "Number of top constructors to show" + }, + "always_show_favorite": { + "type": "boolean", + "title": "Always Show Favorite", + "default": true, + "description": "Always include favorite team even if outside top N" + } + }, + "x-propertyOrder": ["enabled", "top_n", "always_show_favorite"], + "additionalProperties": false + }, + "recent_races": { + "type": "object", + "title": "Recent Race Results", + "description": "Configuration for recent Grand Prix results", + "properties": { + "enabled": { + "type": "boolean", + "title": "Enabled", + "default": true, + "description": "Show recent race results display mode" + }, + "number_of_races": { + "type": "integer", + "title": "Number of Races", + "default": 3, + "minimum": 1, + "maximum": 10, + "description": "Number of recent races to show" + }, + "top_finishers": { + "type": "integer", + "title": "Top Finishers", + "default": 3, + "minimum": 1, + "maximum": 20, + "description": "Number of top finishers to display per race" + }, + "always_show_favorite": { + "type": "boolean", + "title": "Always Show Favorite", + "default": true, + "description": "Always include favorite driver in results even if outside top N finishers" + } + }, + "x-propertyOrder": ["enabled", "number_of_races", "top_finishers", "always_show_favorite"], + "additionalProperties": false + }, + "upcoming": { + "type": "object", + "title": "Upcoming Race", + "description": "Configuration for the next race weekend display", + "properties": { + "enabled": { + "type": "boolean", + "title": "Enabled", + "default": true, + "description": "Show upcoming race display mode" + }, + "show_session_times": { + "type": "boolean", + "title": "Show Session Times", + "default": true, + "description": "Show qualifying and sprint session times in addition to race time" + }, + "countdown_enabled": { + "type": "boolean", + "title": "Countdown Timer", + "default": true, + "description": "Show live countdown timer to next session" + } + }, + "x-propertyOrder": ["enabled", "show_session_times", "countdown_enabled"], + "additionalProperties": false + }, + "qualifying": { + "type": "object", + "title": "Qualifying Results", + "description": "Configuration for most recent qualifying session breakdown", + "properties": { + "enabled": { + "type": "boolean", + "title": "Enabled", + "default": true, + "description": "Show qualifying results display mode" + }, + "show_q1": { + "type": "boolean", + "title": "Show Q1", + "default": true, + "description": "Show Q1 results (all 20 drivers, bottom 5 eliminated)" + }, + "show_q2": { + "type": "boolean", + "title": "Show Q2", + "default": true, + "description": "Show Q2 results (top 15 drivers, bottom 5 eliminated)" + }, + "show_q3": { + "type": "boolean", + "title": "Show Q3", + "default": true, + "description": "Show Q3 results (top 10 drivers, determines pole position)" + }, + "show_gaps": { + "type": "boolean", + "title": "Show Gaps", + "default": true, + "description": "Show time differential to session leader" + } + }, + "x-propertyOrder": ["enabled", "show_q3", "show_q2", "show_q1", "show_gaps"], + "additionalProperties": false + }, + "practice": { + "type": "object", + "title": "Free Practice Results", + "description": "Configuration for free practice session standings", + "properties": { + "enabled": { + "type": "boolean", + "title": "Enabled", + "default": true, + "description": "Show free practice results display mode" + }, + "sessions_to_show": { + "type": "array", + "title": "Sessions to Show", + "x-widget": "checkbox-group", + "items": { + "type": "string", + "enum": ["FP1", "FP2", "FP3"] + }, + "default": ["FP1", "FP2", "FP3"], + "description": "Which free practice sessions to display" + }, + "top_n": { + "type": "integer", + "title": "Top Drivers", + "default": 10, + "minimum": 3, + "maximum": 22, + "description": "Number of top drivers to show per practice session" + } + }, + "x-propertyOrder": ["enabled", "sessions_to_show", "top_n"], + "additionalProperties": false + }, + "sprint": { + "type": "object", + "title": "Sprint Race Results", + "description": "Configuration for sprint race results (not all race weekends have sprints)", + "properties": { + "enabled": { + "type": "boolean", + "title": "Enabled", + "default": true, + "description": "Show sprint race results display mode" + }, + "top_finishers": { + "type": "integer", + "title": "Top Finishers", + "default": 10, + "minimum": 3, + "maximum": 22, + "description": "Number of top finishers to display" + } + }, + "x-propertyOrder": ["enabled", "top_finishers"], + "additionalProperties": false + }, + "calendar": { + "type": "object", + "title": "Race Calendar", + "description": "Configuration for upcoming race schedule display", + "properties": { + "enabled": { + "type": "boolean", + "title": "Enabled", + "default": true, + "description": "Show race calendar display mode" + }, + "show_practice": { + "type": "boolean", + "title": "Show Practice", + "default": false, + "description": "Include practice sessions in calendar" + }, + "show_qualifying": { + "type": "boolean", + "title": "Show Qualifying", + "default": true, + "description": "Include qualifying sessions in calendar" + }, + "show_sprint": { + "type": "boolean", + "title": "Show Sprint", + "default": true, + "description": "Include sprint sessions in calendar" + }, + "max_events": { + "type": "integer", + "title": "Max Events", + "default": 5, + "minimum": 1, + "maximum": 24, + "description": "Maximum number of upcoming race weekends to show" + } + }, + "x-propertyOrder": ["enabled", "show_practice", "show_qualifying", "show_sprint", "max_events"], + "additionalProperties": false + }, + "dynamic_duration": { + "type": "object", + "title": "Dynamic Duration", + "description": "Allow scrolling modes to run until their scroll cycle completes instead of using a fixed timer", + "properties": { + "enabled": { + "type": "boolean", + "title": "Enabled", + "default": true, + "description": "Enable dynamic duration for scrolling display modes" + }, + "max_duration_seconds": { + "type": "integer", + "title": "Max Duration", + "default": 120, + "minimum": 30, + "maximum": 300, + "description": "Maximum seconds before forcing rotation even if scroll is incomplete" + } + }, + "x-propertyOrder": ["enabled", "max_duration_seconds"], + "additionalProperties": false + }, + "customization": { + "type": "object", + "title": "Display Customization", + "description": "Customize fonts for different text elements", + "properties": { + "header_text": { + "type": "object", + "title": "Header Text", + "description": "Font for section headers (GP names, session labels)", + "properties": { + "font": { + "type": "string", + "title": "Font", + "enum": ["PressStart2P-Regular.ttf", "4x6-font.ttf", "5by7.regular.ttf"], + "default": "PressStart2P-Regular.ttf" + }, + "font_size": { + "type": "integer", + "title": "Font Size", + "minimum": 4, + "maximum": 16, + "default": 8 + } + }, + "x-propertyOrder": ["font", "font_size"], + "additionalProperties": false + }, + "position_text": { + "type": "object", + "title": "Position Numbers", + "description": "Font for standings position numbers and driver codes", + "properties": { + "font": { + "type": "string", + "title": "Font", + "enum": ["PressStart2P-Regular.ttf", "4x6-font.ttf", "5by7.regular.ttf"], + "default": "PressStart2P-Regular.ttf" + }, + "font_size": { + "type": "integer", + "title": "Font Size", + "minimum": 4, + "maximum": 16, + "default": 8 + } + }, + "x-propertyOrder": ["font", "font_size"], + "additionalProperties": false + }, + "detail_text": { + "type": "object", + "title": "Detail Text", + "description": "Font for points, times, gaps, and other details", + "properties": { + "font": { + "type": "string", + "title": "Font", + "enum": ["PressStart2P-Regular.ttf", "4x6-font.ttf", "5by7.regular.ttf"], + "default": "4x6-font.ttf" + }, + "font_size": { + "type": "integer", + "title": "Font Size", + "minimum": 4, + "maximum": 16, + "default": 6 + } + }, + "x-propertyOrder": ["font", "font_size"], + "additionalProperties": false + }, + "small_text": { + "type": "object", + "title": "Small Text", + "description": "Font for secondary information (circuit name, location)", + "properties": { + "font": { + "type": "string", + "title": "Font", + "enum": ["PressStart2P-Regular.ttf", "4x6-font.ttf", "5by7.regular.ttf"], + "default": "4x6-font.ttf" + }, + "font_size": { + "type": "integer", + "title": "Font Size", + "minimum": 4, + "maximum": 12, + "default": 6 + } + }, + "x-propertyOrder": ["font", "font_size"], + "additionalProperties": false + } + }, + "x-propertyOrder": ["header_text", "position_text", "detail_text", "small_text"], + "additionalProperties": false + } + }, + "x-propertyOrder": ["enabled", "display_duration", "update_interval", "favorite_team", "favorite_driver", "driver_standings", "constructor_standings", "recent_races", "upcoming", "qualifying", "practice", "sprint", "calendar", "dynamic_duration", "customization"], + "additionalProperties": false, + "required": ["enabled"] +} diff --git a/plugins/f1-scoreboard/f1_data.py b/plugins/f1-scoreboard/f1_data.py new file mode 100644 index 0000000..0092097 --- /dev/null +++ b/plugins/f1-scoreboard/f1_data.py @@ -0,0 +1,1074 @@ +""" +F1 Data Source Module + +Handles all API interactions for the F1 Scoreboard plugin. +Uses three data sources: +- ESPN F1 API: Schedule, calendar, circuit info, session types +- Jolpi API (Ergast replacement): Standings, race results, qualifying, sprints +- OpenF1 API: Free practice results, driver info, team colors +""" + +import logging +import time +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional, Tuple + +import requests +from requests.adapters import HTTPAdapter +from urllib3.util.retry import Retry + +from team_colors import normalize_constructor_id + +logger = logging.getLogger(__name__) + +# API Base URLs +ESPN_BASE = "https://site.api.espn.com/apis/site/v2/sports/racing/f1" +JOLPI_BASE = "https://api.jolpi.ca/ergast/f1" +OPENF1_BASE = "https://api.openf1.org/v1" + +class F1DataSource: + """Fetches and processes F1 data from ESPN, Jolpi, and OpenF1 APIs.""" + + def __init__(self, cache_manager=None, config: Dict[str, Any] = None): + """ + Initialize the data source. + + Args: + cache_manager: LEDMatrix cache manager for persistent caching + config: Plugin configuration dictionary + """ + self.cache_manager = cache_manager + self.config = config or {} + + # HTTP session with retry logic + self.session = requests.Session() + retry_strategy = Retry( + total=3, + backoff_factor=1, + status_forcelist=[429, 500, 502, 503, 504], + allowed_methods=["GET"], + ) + adapter = HTTPAdapter(max_retries=retry_strategy) + self.session.mount("https://", adapter) + self.session.mount("http://", adapter) + self.session.headers.update({ + "User-Agent": "LEDMatrix-F1/1.0", + "Accept": "application/json", + }) + + # Cache durations in seconds + self._cache_durations = { + "schedule": 6 * 3600, # 6 hours + "standings": 3600, # 1 hour + "race_results": 24 * 3600, # 24 hours + "qualifying": 24 * 3600, # 24 hours + "sprint": 24 * 3600, # 24 hours + "practice": 2 * 3600, # 2 hours + "drivers": 24 * 3600, # 24 hours + } + + # In-memory cache for when no cache_manager + self._mem_cache: Dict[str, Tuple[float, Any]] = {} + + # Memoize latest round to avoid redundant HTTP requests + self._latest_round_cache: Dict[int, Tuple[float, int]] = {} + + # ─── Cache Helpers ───────────────────────────────────────────────── + + def _get_cached(self, key: str, category: str = "schedule") -> Optional[Any]: + """Get cached data if still valid.""" + max_age = self._cache_durations.get(category, 3600) + + if self.cache_manager: + return self.cache_manager.get(key, max_age=max_age) + + # Fallback to in-memory cache + if key in self._mem_cache: + cached_time, data = self._mem_cache[key] + if time.time() - cached_time < max_age: + return data + return None + + def _set_cached(self, key: str, data: Any): + """Store data in cache.""" + if self.cache_manager: + self.cache_manager.set(key, data) + else: + self._mem_cache[key] = (time.time(), data) + + def _fetch_json(self, url: str, params: Dict = None, + timeout: int = 30) -> Optional[Dict]: + """Fetch JSON from a URL with error handling.""" + try: + response = self.session.get(url, params=params, timeout=timeout) + response.raise_for_status() + return response.json() + except requests.RequestException as e: + logger.error("API request failed for %s: %s", url, e) + return None + + def _fallback_previous_season(self, method_name: str, season: int, + default_return=None, **kwargs): + """Fall back to previous season when current has no data (pre-season). + + Performs a single bounded fallback to (current_year - 1) to avoid + recursive HTTP requests through multiple empty seasons. + """ + current_year = datetime.now(timezone.utc).year + if season >= current_year and season > 2000: + method = getattr(self, method_name, None) + if method is None or not callable(method): + logger.error("Invalid fallback method: %s", method_name) + return default_return + target = current_year - 1 + logger.info("No %s data for %d, falling back to %d", + method_name, season, target) + return method(season=target, **kwargs) + return default_return + + # ─── ESPN: Schedule & Calendar ───────────────────────────────────── + + def fetch_schedule(self, season: int = None) -> Optional[List[Dict]]: + """ + Fetch the full F1 season schedule from ESPN. + + Returns list of events with sessions, circuits, and status info. + """ + if season is None: + season = datetime.now(timezone.utc).year + + cache_key = f"f1_schedule_{season}" + cached = self._get_cached(cache_key, "schedule") + if cached is not None: + return cached + + data = self._fetch_json(f"{ESPN_BASE}/scoreboard", + params={"dates": str(season), "limit": "200"}) + if not data: + return None + + events = [] + for event in data.get("events", []): + parsed = self._parse_espn_event(event) + if parsed: + events.append(parsed) + + self._set_cached(cache_key, events) + return events + + def _parse_espn_event(self, event: Dict) -> Optional[Dict]: + """Parse an ESPN event into a clean data structure.""" + try: + circuit = event.get("circuit", {}) + address = circuit.get("address", {}) + + sessions = [] + for comp in event.get("competitions", []): + comp_type = comp.get("type", {}) + status = comp.get("status", {}) + status_type = status.get("type", {}) + + session = { + "id": comp.get("id"), + "type_id": comp_type.get("id", ""), + "type_abbr": comp_type.get("abbreviation", ""), + "date": comp.get("date", ""), + "status_state": status_type.get("state", "pre"), + "status_completed": status_type.get("completed", False), + "status_detail": status_type.get("detail", ""), + "status_short": status_type.get("shortDetail", ""), + "broadcast": comp.get("broadcast", ""), + } + + # Parse competitors for completed sessions + competitors = [] + for c in comp.get("competitors", []): + athlete = c.get("athlete", {}) + competitors.append({ + "id": c.get("id"), + "order": c.get("order", 0), + "winner": c.get("winner", False), + "name": athlete.get("displayName", ""), + "short_name": athlete.get("shortName", ""), + "flag_url": athlete.get("flag", {}).get("href", ""), + }) + + if competitors: + session["competitors"] = sorted(competitors, + key=lambda x: x["order"]) + + sessions.append(session) + + return { + "id": event.get("id"), + "name": event.get("name", ""), + "short_name": event.get("shortName", ""), + "date": event.get("date", ""), + "end_date": event.get("endDate", ""), + "circuit_name": circuit.get("fullName", ""), + "city": address.get("city", ""), + "country": address.get("country", ""), + "sessions": sessions, + } + except Exception as e: + logger.error("Error parsing ESPN event: %s", e) + return None + + def get_upcoming_race(self) -> Optional[Dict]: + """Get the next upcoming race event.""" + events = self.fetch_schedule() + if not events: + return None + + now = datetime.now(timezone.utc) + + for event in events: + # Find the Race session + race_session = None + for s in event.get("sessions", []): + if s.get("type_abbr") == "Race": + race_session = s + break + + if not race_session: + continue + + # Check if race hasn't happened yet + if race_session.get("status_state") == "pre": + # Calculate countdown to next session + next_session = self._get_next_session(event, now) + countdown_seconds = None + next_session_type = None + + if next_session and next_session.get("date"): + try: + session_dt = datetime.fromisoformat( + next_session["date"].replace("Z", "+00:00")) + countdown_seconds = max( + 0, (session_dt - now).total_seconds()) + next_session_type = next_session.get("type_abbr", "") + except (ValueError, TypeError): + pass + + return { + **event, + "countdown_seconds": countdown_seconds, + "next_session_type": next_session_type, + } + + return None + + def _get_next_session(self, event: Dict, + now: datetime) -> Optional[Dict]: + """Find the next upcoming session within an event.""" + for session in event.get("sessions", []): + if session.get("status_state") == "pre" and session.get("date"): + try: + session_dt = datetime.fromisoformat( + session["date"].replace("Z", "+00:00")) + if session_dt > now: + return session + except (ValueError, TypeError): + continue + return None + + def get_calendar(self, show_practice: bool = False, + show_qualifying: bool = True, + show_sprint: bool = True, + max_events: int = 5) -> List[Dict]: + """ + Get upcoming race calendar with filtered sessions. + + Returns list of session entries for future events. + """ + events = self.fetch_schedule() + if not events: + return [] + + now = datetime.now(timezone.utc) + calendar_entries = [] + events_added = 0 + + for event in events: + if events_added >= max_events: + break + + has_future_sessions = False + + for session in event.get("sessions", []): + # Filter by session type + abbr = session.get("type_abbr", "") + if abbr in ("FP1", "FP2", "FP3") and not show_practice: + continue + if abbr == "Qual" and not show_qualifying: + continue + if abbr in ("SS", "SR") and not show_sprint: + continue + + # Only future sessions + if session.get("status_state") != "pre": + continue + + try: + session_dt = datetime.fromisoformat( + session["date"].replace("Z", "+00:00")) + if session_dt <= now: + continue + except (ValueError, TypeError): + continue + + has_future_sessions = True + calendar_entries.append({ + "event_name": event.get("short_name", event.get("name", "")), + "circuit": event.get("circuit_name", ""), + "city": event.get("city", ""), + "country": event.get("country", ""), + "session_type": abbr, + "date": session.get("date", ""), + "status_detail": session.get("status_short", ""), + "broadcast": session.get("broadcast", ""), + }) + + if has_future_sessions: + events_added += 1 + + return calendar_entries + + # ─── Jolpi: Driver Standings ─────────────────────────────────────── + + def fetch_driver_standings(self, season: int = None) -> List[Dict]: + """ + Fetch current driver championship standings. + + Returns list of driver standing entries with position, points, wins. + Falls back to previous season if current season has no data yet. + """ + if season is None: + season = datetime.now(timezone.utc).year + + cache_key = f"f1_driver_standings_{season}" + cached = self._get_cached(cache_key, "standings") + if cached is not None: + return cached + + data = self._fetch_json( + f"{JOLPI_BASE}/{season}/driverStandings.json") + if not data: + # Try current season keyword + data = self._fetch_json( + f"{JOLPI_BASE}/current/driverStandings.json") + if not data: + return self._fallback_previous_season( + "fetch_driver_standings", season, default_return=[]) + + standings = [] + try: + standings_lists = (data.get("MRData", {}) + .get("StandingsTable", {}) + .get("StandingsLists", [])) + if not standings_lists: + return self._fallback_previous_season( + "fetch_driver_standings", season, default_return=[]) + + # Populate round cache so _get_latest_round skips HTTP request + try: + round_num = int(standings_lists[0].get("round", 0)) + if round_num > 0: + self._latest_round_cache[season] = ( + time.time(), round_num) + except (ValueError, TypeError): + pass + + for entry in standings_lists[0].get("DriverStandings", []): + driver = entry.get("Driver", {}) + constructors = entry.get("Constructors", []) + constructor = constructors[0] if constructors else {} + + standings.append({ + "position": int(entry.get("position", 0)), + "points": float(entry.get("points", 0)), + "wins": int(entry.get("wins", 0)), + "driver_id": driver.get("driverId", ""), + "code": driver.get("code", ""), + "first_name": driver.get("givenName", ""), + "last_name": driver.get("familyName", ""), + "number": driver.get("permanentNumber", ""), + "nationality": driver.get("nationality", ""), + "constructor_id": normalize_constructor_id( + constructor.get("constructorId", "")), + "constructor": constructor.get("name", ""), + }) + except (KeyError, IndexError, ValueError) as e: + logger.error("Error parsing driver standings: %s", e) + return [] + + self._set_cached(cache_key, standings) + return standings + + # ─── Jolpi: Constructor Standings ────────────────────────────────── + + def fetch_constructor_standings(self, season: int = None) -> List[Dict]: + """ + Fetch current constructor championship standings. + + Returns list of constructor standing entries. + Falls back to previous season if current season has no data yet. + """ + if season is None: + season = datetime.now(timezone.utc).year + + cache_key = f"f1_constructor_standings_{season}" + cached = self._get_cached(cache_key, "standings") + if cached is not None: + return cached + + data = self._fetch_json( + f"{JOLPI_BASE}/{season}/constructorStandings.json") + if not data: + data = self._fetch_json( + f"{JOLPI_BASE}/current/constructorStandings.json") + if not data: + return self._fallback_previous_season( + "fetch_constructor_standings", season, default_return=[]) + + standings = [] + try: + standings_lists = (data.get("MRData", {}) + .get("StandingsTable", {}) + .get("StandingsLists", [])) + if not standings_lists: + return self._fallback_previous_season( + "fetch_constructor_standings", season, default_return=[]) + + for entry in standings_lists[0].get("ConstructorStandings", []): + constructor = entry.get("Constructor", {}) + + standings.append({ + "position": int(entry.get("position", 0)), + "points": float(entry.get("points", 0)), + "wins": int(entry.get("wins", 0)), + "constructor_id": normalize_constructor_id( + constructor.get("constructorId", "")), + "constructor": constructor.get("name", ""), + "nationality": constructor.get("nationality", ""), + }) + except (KeyError, IndexError, ValueError) as e: + logger.error("Error parsing constructor standings: %s", e) + return [] + + self._set_cached(cache_key, standings) + return standings + + # ─── Jolpi: Race Results ─────────────────────────────────────────── + + def fetch_race_results(self, season: int, round_num: int) -> Optional[Dict]: + """ + Fetch results for a specific race. + + Returns race info with full results including timing data. + """ + cache_key = f"f1_race_results_{season}_{round_num}" + cached = self._get_cached(cache_key, "race_results") + if cached is not None: + return cached + + data = self._fetch_json( + f"{JOLPI_BASE}/{season}/{round_num}/results.json") + if not data: + return None + + try: + races = (data.get("MRData", {}) + .get("RaceTable", {}) + .get("Races", [])) + if not races: + return None + + race = races[0] + circuit = race.get("Circuit", {}) + location = circuit.get("Location", {}) + + results = [] + for r in race.get("Results", []): + driver = r.get("Driver", {}) + constructor = r.get("Constructor", {}) + time_data = r.get("Time", {}) + fastest = r.get("FastestLap", {}) + fastest_time = fastest.get("Time", {}) + + results.append({ + "position": int(r.get("position", 0)), + "points": float(r.get("points", 0)), + "code": driver.get("code", ""), + "first_name": driver.get("givenName", ""), + "last_name": driver.get("familyName", ""), + "driver_id": driver.get("driverId", ""), + "number": r.get("number", ""), + "constructor_id": normalize_constructor_id( + constructor.get("constructorId", "")), + "constructor": constructor.get("name", ""), + "grid": int(r.get("grid", 0)), + "laps": int(r.get("laps", 0)), + "status": r.get("status", ""), + "time": time_data.get("time", ""), + "time_millis": time_data.get("millis", ""), + "fastest_lap_rank": fastest.get("rank", ""), + "fastest_lap_time": fastest_time.get("time", ""), + "fastest_lap_number": fastest.get("lap", ""), + }) + + parsed = { + "season": race.get("season", str(season)), + "round": race.get("round", str(round_num)), + "race_name": race.get("raceName", ""), + "circuit_name": circuit.get("circuitName", ""), + "city": location.get("locality", ""), + "country": location.get("country", ""), + "date": race.get("date", ""), + "time": race.get("time", ""), + "results": results, + } + + self._set_cached(cache_key, parsed) + return parsed + + except (KeyError, IndexError, ValueError) as e: + logger.error("Error parsing race results for %s R%d: %s", + season, round_num, e) + return None + + def fetch_recent_races(self, season: int = None, + count: int = 3) -> List[Dict]: + """ + Fetch the last N completed race results. + + Returns list of race result dicts, most recent first. + """ + if season is None: + season = datetime.now(timezone.utc).year + + cache_key = f"f1_recent_races_{season}_{count}" + cached = self._get_cached(cache_key, "race_results") + if cached is not None: + return cached + + current_round = self._get_latest_round(season) + + if current_round == 0: + return self._fallback_previous_season( + "fetch_recent_races", season, default_return=[], count=count) + + races = [] + for round_num in range(current_round, max(0, current_round - count), -1): + result = self.fetch_race_results(season, round_num) + if result: + races.append(result) + + self._set_cached(cache_key, races) + return races + + # ─── Jolpi: Qualifying Results ───────────────────────────────────── + + def fetch_qualifying(self, season: int = None, + round_num: int = None) -> Optional[Dict]: + """ + Fetch qualifying results with Q1/Q2/Q3 times. + + If round_num is None, fetches the most recent qualifying. + Returns parsed qualifying data with gap calculations. + """ + if season is None: + season = datetime.now(timezone.utc).year + + # Determine latest round if not specified + if round_num is None: + round_num = self._get_latest_round(season) + if round_num == 0: + return self._fallback_previous_season( + "fetch_qualifying", season) + + cache_key = f"f1_qualifying_{season}_{round_num}" + cached = self._get_cached(cache_key, "qualifying") + if cached is not None: + return cached + + data = self._fetch_json( + f"{JOLPI_BASE}/{season}/{round_num}/qualifying.json") + if not data: + return None + + try: + races = (data.get("MRData", {}) + .get("RaceTable", {}) + .get("Races", [])) + if not races: + return None + + race = races[0] + results = [] + + for q in race.get("QualifyingResults", []): + driver = q.get("Driver", {}) + constructor = q.get("Constructor", {}) + + entry = { + "position": int(q.get("position", 0)), + "code": driver.get("code", ""), + "first_name": driver.get("givenName", ""), + "last_name": driver.get("familyName", ""), + "driver_id": driver.get("driverId", ""), + "number": q.get("number", ""), + "constructor_id": normalize_constructor_id( + constructor.get("constructorId", "")), + "constructor": constructor.get("name", ""), + "q1": q.get("Q1", ""), + "q2": q.get("Q2", ""), + "q3": q.get("Q3", ""), + } + + results.append(entry) + + # Calculate gaps for each qualifying session + for session_key in ("q1", "q2", "q3"): + leader_time = None + for entry in results: + time_str = entry.get(session_key, "") + if time_str: + seconds = self._parse_lap_time(time_str) + if seconds is not None: + if leader_time is None: + leader_time = seconds + gap = seconds - leader_time + entry[f"{session_key}_gap"] = ( + f"+{gap:.3f}" if gap > 0 else "") + else: + entry[f"{session_key}_gap"] = "" + else: + entry[f"{session_key}_gap"] = "" + + # Determine elimination status based on actual entry count + total = len(results) + q1_cutoff = total - 5 # Bottom 5 eliminated in Q1 + q2_cutoff = q1_cutoff - 5 # Next 5 eliminated in Q2 + for entry in results: + pos = entry["position"] + if pos > q1_cutoff: + entry["eliminated_in"] = "Q1" + elif pos > q2_cutoff: + entry["eliminated_in"] = "Q2" + else: + entry["eliminated_in"] = "" + + circuit = race.get("Circuit", {}) + location = circuit.get("Location", {}) + + parsed = { + "season": race.get("season", str(season)), + "round": race.get("round", str(round_num)), + "race_name": race.get("raceName", ""), + "circuit_name": circuit.get("circuitName", ""), + "city": location.get("locality", ""), + "country": location.get("country", ""), + "date": race.get("date", ""), + "results": results, + } + + self._set_cached(cache_key, parsed) + return parsed + + except (KeyError, IndexError, ValueError) as e: + logger.error("Error parsing qualifying for %s R%d: %s", + season, round_num, e) + return None + + # ─── Jolpi: Sprint Results ───────────────────────────────────────── + + def fetch_sprint_results(self, season: int = None, + round_num: int = None) -> Optional[Dict]: + """ + Fetch sprint race results. + + Not all rounds have sprints; returns None if no sprint data. + """ + if season is None: + season = datetime.now(timezone.utc).year + + if round_num is None: + round_num = self._get_latest_round(season) + if round_num == 0: + return self._fallback_previous_season( + "fetch_sprint_results", season) + + cache_key = f"f1_sprint_{season}_{round_num}" + cached = self._get_cached(cache_key, "sprint") + if cached is not None: + return cached + + # Try current round and work backwards to find most recent sprint + for r in range(round_num, max(0, round_num - 5), -1): + data = self._fetch_json( + f"{JOLPI_BASE}/{season}/{r}/sprint.json") + if not data: + continue + + try: + races = (data.get("MRData", {}) + .get("RaceTable", {}) + .get("Races", [])) + if not races: + continue + + race = races[0] + sprint_results = race.get("SprintResults", []) + if not sprint_results: + continue + + results = [] + for sr in sprint_results: + driver = sr.get("Driver", {}) + constructor = sr.get("Constructor", {}) + time_data = sr.get("Time", {}) + + results.append({ + "position": int(sr.get("position", 0)), + "points": float(sr.get("points", 0)), + "code": driver.get("code", ""), + "first_name": driver.get("givenName", ""), + "last_name": driver.get("familyName", ""), + "driver_id": driver.get("driverId", ""), + "number": sr.get("number", ""), + "constructor_id": normalize_constructor_id( + constructor.get("constructorId", "")), + "constructor": constructor.get("name", ""), + "grid": int(sr.get("grid", 0)), + "laps": int(sr.get("laps", 0)), + "status": sr.get("status", ""), + "time": time_data.get("time", ""), + }) + + circuit = race.get("Circuit", {}) + location = circuit.get("Location", {}) + + parsed = { + "season": race.get("season", str(season)), + "round": str(r), + "race_name": race.get("raceName", ""), + "circuit_name": circuit.get("circuitName", ""), + "city": location.get("locality", ""), + "country": location.get("country", ""), + "date": race.get("date", ""), + "results": results, + } + + self._set_cached(cache_key, parsed) + return parsed + + except (KeyError, IndexError, ValueError) as e: + logger.error("Error parsing sprint for %s R%d: %s", + season, r, e) + continue + + return None + + # ─── Jolpi: Pole Positions ───────────────────────────────────────── + + def calculate_pole_positions(self, season: int = None) -> Dict[str, int]: + """ + Count pole positions per driver for the season. + + Iterates qualifying results and counts position=1 for each driver. + + Returns: + Dict mapping driver code to pole count + """ + if season is None: + season = datetime.now(timezone.utc).year + + cache_key = f"f1_poles_{season}" + cached = self._get_cached(cache_key, "qualifying") + if cached is not None: + return cached + + current_round = self._get_latest_round(season) + poles: Dict[str, int] = {} + + for r in range(1, current_round + 1): + quali = self.fetch_qualifying(season, r) + if quali and quali.get("results"): + for entry in quali["results"]: + if entry.get("position") == 1: + code = entry.get("code", "") + if code: + poles[code] = poles.get(code, 0) + 1 + break + + self._set_cached(cache_key, poles) + return poles + + # ─── OpenF1: Free Practice Results ───────────────────────────────── + + def fetch_practice_results(self, session_name: str = "Practice 3", + year: int = None) -> Optional[Dict]: + """ + Fetch free practice session results from OpenF1. + + Gets best lap time per driver and final positions. + + Args: + session_name: "Practice 1", "Practice 2", or "Practice 3" + year: Season year + + Returns: + Dict with session info and driver results sorted by best lap + """ + if year is None: + year = datetime.now(timezone.utc).year + + cache_key = f"f1_practice_{session_name}_{year}" + cached = self._get_cached(cache_key, "practice") + if cached is not None: + return cached + + # Find the most recent session of this type + sessions_data = self._fetch_json( + f"{OPENF1_BASE}/sessions", + params={ + "year": year, + "session_name": session_name, + }) + + if not sessions_data or not isinstance(sessions_data, list): + return None + + # Get most recent completed session + latest_session = None + for s in reversed(sessions_data): + if s.get("date_end"): + latest_session = s + break + + if not latest_session: + return None + + session_key = latest_session.get("session_key") + if not session_key: + return None + + # Fetch all laps for this session + laps_data = self._fetch_json( + f"{OPENF1_BASE}/laps", + params={"session_key": session_key}) + + if not laps_data or not isinstance(laps_data, list): + return None + + # Find best lap per driver + best_laps: Dict[int, Dict] = {} + for lap in laps_data: + driver_num = lap.get("driver_number") + duration = lap.get("lap_duration") + if driver_num is None or duration is None: + continue + + try: + duration = float(duration) + except (ValueError, TypeError): + continue + + if duration <= 0: + continue + + if (driver_num not in best_laps or + duration < best_laps[driver_num]["duration"]): + best_laps[driver_num] = { + "driver_number": driver_num, + "duration": duration, + "lap_number": lap.get("lap_number", 0), + } + + if not best_laps: + return None + + # Fetch driver info to map numbers to names/teams + drivers_data = self._fetch_json( + f"{OPENF1_BASE}/drivers", + params={"session_key": session_key}) + + driver_info = {} + if drivers_data and isinstance(drivers_data, list): + for d in drivers_data: + num = d.get("driver_number") + if num is not None: + driver_info[num] = { + "name": d.get("full_name", ""), + "code": d.get("name_acronym", ""), + "team": d.get("team_name", ""), + "team_color": d.get("team_colour", ""), + "number": num, + } + + # Sort by best lap time + sorted_laps = sorted(best_laps.values(), key=lambda x: x["duration"]) + + # Build results + results = [] + leader_time = sorted_laps[0]["duration"] if sorted_laps else 0 + + for i, lap in enumerate(sorted_laps): + driver_num = lap["driver_number"] + info = driver_info.get(driver_num, {}) + gap = lap["duration"] - leader_time + + # Format duration as lap time string + minutes = int(lap["duration"]) // 60 + seconds = lap["duration"] - (minutes * 60) + time_str = f"{minutes}:{seconds:06.3f}" + + # Map team name to constructor ID + team_name = info.get("team", "") + constructor_id = normalize_constructor_id(team_name) + + results.append({ + "position": i + 1, + "code": info.get("code", f"#{driver_num}"), + "name": info.get("name", f"Driver #{driver_num}"), + "number": str(driver_num), + "constructor_id": constructor_id, + "constructor": team_name, + "best_lap": time_str, + "best_lap_seconds": lap["duration"], + "gap": f"+{gap:.3f}" if gap > 0 else "", + "gap_seconds": gap, + }) + + # Map session_name to short FP label + fp_map = { + "Practice 1": "FP1", + "Practice 2": "FP2", + "Practice 3": "FP3", + } + + parsed = { + "session_name": fp_map.get(session_name, session_name), + "circuit": latest_session.get("circuit_short_name", ""), + "country": latest_session.get("country_name", ""), + "date": latest_session.get("date_start", ""), + "results": results, + } + + self._set_cached(cache_key, parsed) + return parsed + + # ─── Helpers ─────────────────────────────────────────────────────── + + def _get_latest_round(self, season: int) -> int: + """Get the latest completed round number for a season (memoized).""" + # Return memoized value if fresh (within standings cache duration) + max_age = self._cache_durations.get("standings", 3600) + if season in self._latest_round_cache: + cached_time, round_num = self._latest_round_cache[season] + if time.time() - cached_time < max_age: + return round_num + + data = self._fetch_json( + f"{JOLPI_BASE}/{season}/driverStandings.json") + if not data: + data = self._fetch_json( + f"{JOLPI_BASE}/current/driverStandings.json") + if not data: + return 0 + + try: + standings_lists = (data.get("MRData", {}) + .get("StandingsTable", {}) + .get("StandingsLists", [])) + if standings_lists: + round_num = int(standings_lists[0].get("round", 0)) + self._latest_round_cache[season] = (time.time(), round_num) + return round_num + except (KeyError, IndexError, ValueError): + pass + return 0 + + @staticmethod + def _parse_lap_time(time_str: str) -> Optional[float]: + """ + Parse a lap time string like '1:15.096' into total seconds. + + Returns None if parsing fails. + """ + if not time_str: + return None + try: + if ":" in time_str: + parts = time_str.split(":") + minutes = int(parts[0]) + seconds = float(parts[1]) + return minutes * 60 + seconds + return float(time_str) + except (ValueError, IndexError): + return None + + # ─── Favorite Filtering ──────────────────────────────────────────── + + def apply_favorite_filter(self, entries: List[Dict], top_n: int, + favorite_driver: str = "", + favorite_team: str = "", + always_show_favorite: bool = True, + driver_key: str = "code", + team_key: str = "constructor_id" + ) -> List[Dict]: + """ + Apply favorite driver/team filtering to a list of entries. + + Shows top N entries, then appends favorite if not already shown. + + Args: + entries: List of standings/results entries + top_n: Number of top entries to show + favorite_driver: Favorite driver code (e.g., "NOR") + favorite_team: Favorite constructor ID (e.g., "mclaren") + always_show_favorite: Whether to append favorite if outside top N + driver_key: Key name for driver code in entry dict + team_key: Key name for constructor ID in entry dict + + Returns: + Filtered list of entries + """ + if not entries: + return [] + + # Take top N + shown = entries[:top_n] + shown_codes = {e.get(driver_key, "").upper() for e in shown} + shown_teams = {e.get(team_key, "").lower() for e in shown} + + if not always_show_favorite: + return shown + + # Add favorite driver if not already shown + if favorite_driver: + fav_upper = favorite_driver.upper() + if fav_upper not in shown_codes: + for entry in entries[top_n:]: + if entry.get(driver_key, "").upper() == fav_upper: + fav_entry = dict(entry) + fav_entry["is_favorite"] = True + shown.append(fav_entry) + shown_codes.add(fav_upper) + break + + # Add favorite team drivers if not already shown + if favorite_team: + fav_team = normalize_constructor_id(favorite_team) + if fav_team not in shown_teams: + for entry in entries[top_n:]: + if entry.get(team_key, "") == fav_team: + code = entry.get(driver_key, "").upper() + if code not in shown_codes: + fav_entry = dict(entry) + fav_entry["is_favorite"] = True + shown.append(fav_entry) + shown_codes.add(code) + + return shown diff --git a/plugins/f1-scoreboard/f1_renderer.py b/plugins/f1-scoreboard/f1_renderer.py new file mode 100644 index 0000000..fc02934 --- /dev/null +++ b/plugins/f1-scoreboard/f1_renderer.py @@ -0,0 +1,834 @@ +""" +F1 Renderer Module + +Renders all F1 display mode cards as PIL Images for the LED matrix. +All layouts are fully dynamic - dimensions are proportional to display size. +Supports 64x32, 128x32, 96x48, 192x48, and any other matrix configuration. +""" + +import logging +import math +import os +import time +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple, Union + +from PIL import Image, ImageDraw, ImageFont + +from logo_downloader import F1LogoLoader +from team_colors import (F1_RED, PODIUM_COLORS, get_team_color, + normalize_constructor_id) + +logger = logging.getLogger(__name__) + +# Accent bar width as fraction of display width +ACCENT_BAR_RATIO = 0.025 # ~3px on 128-wide display + + +class F1Renderer: + """Renders F1 display cards as PIL Images.""" + + def __init__(self, display_width: int, display_height: int, + config: Optional[Dict[str, Any]] = None, + logo_loader: Optional[F1LogoLoader] = None, + custom_logger: Optional[logging.Logger] = None): + self.display_width = display_width + self.display_height = display_height + self.config = config or {} + self.logger = custom_logger or logger + + # Logo loader + self.logo_loader = logo_loader or F1LogoLoader() + + # Calculate dynamic sizes + self.accent_bar_width = max(2, int(display_width * ACCENT_BAR_RATIO)) + self.logo_max_height = int(display_height * 0.8) + self.logo_max_width = int(display_height * 0.8) + + # Load fonts + self.fonts = self._load_fonts() + + def _load_fonts(self) -> Dict[str, Any]: + """Load fonts with config overrides and fallbacks.""" + fonts = {} + customization = self.config.get("customization", {}) + + # Scale font sizes based on display height + height_scale = self.display_height / 32.0 + + header_cfg = customization.get("header_text", {}) + position_cfg = customization.get("position_text", {}) + detail_cfg = customization.get("detail_text", {}) + small_cfg = customization.get("small_text", {}) + + fonts["header"] = self._load_font( + header_cfg.get("font", "PressStart2P-Regular.ttf"), + int(header_cfg.get("font_size", max(6, int(8 * height_scale))))) + fonts["position"] = self._load_font( + position_cfg.get("font", "PressStart2P-Regular.ttf"), + int(position_cfg.get("font_size", max(6, int(8 * height_scale))))) + fonts["detail"] = self._load_font( + detail_cfg.get("font", "4x6-font.ttf"), + int(detail_cfg.get("font_size", max(5, int(6 * height_scale))))) + fonts["small"] = self._load_font( + small_cfg.get("font", "4x6-font.ttf"), + int(small_cfg.get("font_size", max(5, int(6 * height_scale))))) + + return fonts + + def _load_font(self, font_name: str, + size: int) -> Union[ImageFont.FreeTypeFont, Any]: + """Load a font with multiple path fallbacks.""" + font_paths = [ + f"assets/fonts/{font_name}", + str(Path(__file__).parent.parent.parent / + "assets" / "fonts" / font_name), + ] + + for path in font_paths: + try: + return ImageFont.truetype(path, size) + except (OSError, IOError): + continue + + self.logger.warning("Could not load font %s size %d, using default", + font_name, size) + return ImageFont.load_default() + + # ─── Text Drawing Helpers ────────────────────────────────────────── + + def _draw_text_outlined(self, draw: ImageDraw.ImageDraw, xy: Tuple[int, int], + text: str, font, fill=(255, 255, 255), + outline=(0, 0, 0)): + """Draw text with a 1px outline for readability.""" + x, y = xy + for dx, dy in [(-1, 0), (1, 0), (0, -1), (0, 1)]: + draw.text((x + dx, y + dy), text, font=font, fill=outline) + draw.text((x, y), text, font=font, fill=fill) + + def _get_text_width(self, draw: ImageDraw.ImageDraw, text: str, + font) -> int: + """Get the width of rendered text.""" + bbox = draw.textbbox((0, 0), text, font=font) + return bbox[2] - bbox[0] + + def _get_text_height(self, draw: ImageDraw.ImageDraw, text: str, + font) -> int: + """Get the height of rendered text.""" + bbox = draw.textbbox((0, 0), text, font=font) + return bbox[3] - bbox[1] + + def _truncate_text(self, draw: ImageDraw.ImageDraw, text: str, + font, max_width: int) -> str: + """Truncate text to fit within max_width pixels.""" + if self._get_text_width(draw, text, font) <= max_width: + return text + while len(text) > 1: + text = text[:-1] + if self._get_text_width(draw, text + "..", font) <= max_width: + return text + ".." + return text + + # ─── Accent Bar Drawing ─────────────────────────────────────────── + + def _draw_accent_bar(self, draw: ImageDraw.ImageDraw, + constructor_id: str, x: int = 0, + is_favorite: bool = False): + """Draw a team color accent bar on the left edge.""" + color = get_team_color(constructor_id) + bar_width = self.accent_bar_width + if is_favorite: + bar_width = max(bar_width + 1, int(bar_width * 1.5)) + + draw.rectangle( + [x, 0, x + bar_width - 1, self.display_height - 1], + fill=color) + + # ─── Driver Standings Card ───────────────────────────────────────── + + def render_driver_standing(self, entry: Dict) -> Image.Image: + """ + Render a single driver standings card. + + Layout: [accent bar] [pos] [team logo] [code] [points] [W/P stats] + """ + img = Image.new("RGBA", + (self.display_width, self.display_height), + (0, 0, 0, 255)) + draw = ImageDraw.Draw(img) + + constructor_id = entry.get("constructor_id", "") + is_favorite = entry.get("is_favorite", False) + + # Accent bar + self._draw_accent_bar(draw, constructor_id, is_favorite=is_favorite) + + x_offset = self.accent_bar_width + 2 + + # Position number + pos_text = f"P{entry.get('position', '?')}" + self._draw_text_outlined(draw, (x_offset, 2), pos_text, + self.fonts["position"], + fill=(255, 255, 255)) + pos_width = self._get_text_width(draw, pos_text, self.fonts["position"]) + x_offset += pos_width + 3 + + # Team logo + logo = self.logo_loader.get_team_logo( + constructor_id, self.logo_max_height, self.logo_max_width) + if logo: + logo_y = (self.display_height - logo.height) // 2 + img.paste(logo, (x_offset, logo_y), logo) + x_offset += logo.width + 3 + + # Driver code (large) + code = entry.get("code", "???") + self._draw_text_outlined(draw, (x_offset, 2), code, + self.fonts["position"], + fill=(255, 255, 255)) + + # Full name (small, below code if space) + name_y = 2 + self._get_text_height(draw, code, self.fonts["position"]) + 2 + if name_y + 6 < self.display_height: + full_name = f"{entry.get('first_name', '')} {entry.get('last_name', '')}" + self._draw_text_outlined(draw, (x_offset, name_y), full_name, + self.fonts["small"], + fill=(180, 180, 180)) + + # Points (right-aligned) + points = entry.get("points", 0) + points_text = f"{int(points)}pts" + pts_width = self._get_text_width(draw, points_text, self.fonts["detail"]) + pts_x = self.display_width - pts_width - 2 + self._draw_text_outlined(draw, (pts_x, 2), points_text, + self.fonts["detail"], + fill=(255, 255, 0)) + + # Wins and poles (right-aligned, below points) + wins = entry.get("wins", 0) + poles = entry.get("poles", 0) + stats_text = f"{wins}W {poles}P" + stats_width = self._get_text_width(draw, stats_text, self.fonts["small"]) + stats_x = self.display_width - stats_width - 2 + stats_y = 2 + self._get_text_height(draw, points_text, + self.fonts["detail"]) + 2 + if stats_y + 6 < self.display_height: + self._draw_text_outlined(draw, (stats_x, stats_y), stats_text, + self.fonts["small"], + fill=(200, 200, 200)) + + return img + + # ─── Constructor Standings Card ──────────────────────────────────── + + def render_constructor_standing(self, entry: Dict) -> Image.Image: + """ + Render a single constructor standings card. + + Layout: [accent bar] [pos] [team logo] [team name] [points] [wins] + """ + img = Image.new("RGBA", + (self.display_width, self.display_height), + (0, 0, 0, 255)) + draw = ImageDraw.Draw(img) + + constructor_id = entry.get("constructor_id", "") + is_favorite = entry.get("is_favorite", False) + + # Accent bar + self._draw_accent_bar(draw, constructor_id, is_favorite=is_favorite) + + x_offset = self.accent_bar_width + 2 + + # Position + pos_text = f"P{entry.get('position', '?')}" + self._draw_text_outlined(draw, (x_offset, 2), pos_text, + self.fonts["position"], + fill=(255, 255, 255)) + pos_width = self._get_text_width(draw, pos_text, self.fonts["position"]) + x_offset += pos_width + 3 + + # Team logo + logo = self.logo_loader.get_team_logo( + constructor_id, self.logo_max_height, self.logo_max_width) + if logo: + logo_y = (self.display_height - logo.height) // 2 + img.paste(logo, (x_offset, logo_y), logo) + x_offset += logo.width + 3 + + # Team name + team_name = entry.get("constructor", "") + self._draw_text_outlined(draw, (x_offset, 2), team_name, + self.fonts["position"], + fill=get_team_color(constructor_id)) + + # Points (right-aligned) + points = entry.get("points", 0) + points_text = f"{int(points)}pts" + pts_width = self._get_text_width(draw, points_text, self.fonts["detail"]) + pts_x = self.display_width - pts_width - 2 + self._draw_text_outlined(draw, (pts_x, 2), points_text, + self.fonts["detail"], + fill=(255, 255, 0)) + + # Wins (right-aligned, below points) + wins = entry.get("wins", 0) + wins_text = f"{wins}W" + wins_width = self._get_text_width(draw, wins_text, self.fonts["small"]) + wins_x = self.display_width - wins_width - 2 + wins_y = 2 + self._get_text_height(draw, points_text, + self.fonts["detail"]) + 2 + if wins_y + 6 < self.display_height: + self._draw_text_outlined(draw, (wins_x, wins_y), wins_text, + self.fonts["small"], + fill=(200, 200, 200)) + + return img + + # ─── Recent Race Results Card ────────────────────────────────────── + + def render_race_result(self, race: Dict) -> Image.Image: + """ + Render a race result card with podium visualization. + + Layout: [GP name + winner time] [P1 P2 P3 with team colors + medals] + """ + img = Image.new("RGBA", + (self.display_width, self.display_height), + (0, 0, 0, 255)) + draw = ImageDraw.Draw(img) + + results = race.get("results", []) + race_name = race.get("race_name", "Grand Prix") + + # Shorten race name to fit + short_name = race_name.replace("Grand Prix", "GP") + + # Header: GP name + self._draw_text_outlined(draw, (2, 1), short_name, + self.fonts["detail"], + fill=F1_RED) + + # Winner time (right-aligned on header line) + if results: + winner_time = results[0].get("time", "") + if winner_time: + tw = self._get_text_width(draw, winner_time, self.fonts["small"]) + self._draw_text_outlined( + draw, (self.display_width - tw - 2, 1), + winner_time, self.fonts["small"], + fill=(200, 200, 200)) + + # Podium section - top 3 finishers + header_height = self._get_text_height(draw, short_name, + self.fonts["detail"]) + 4 + podium_y = header_height + + # Calculate space per podium position + top_n = min(len(results), 3) + if top_n == 0: + return img + + section_width = self.display_width // top_n + + for i in range(top_n): + r = results[i] + pos = r.get("position", i + 1) + code = r.get("code", "???") + constructor_id = r.get("constructor_id", "") + team_color = get_team_color(constructor_id) + medal_color = PODIUM_COLORS.get(pos, (200, 200, 200)) + + x_base = i * section_width + + # Position with medal color + pos_label = f"P{pos}" + self._draw_text_outlined(draw, (x_base + 2, podium_y), + pos_label, self.fonts["detail"], + fill=medal_color) + + # Driver code + code_y = podium_y + self._get_text_height( + draw, pos_label, self.fonts["detail"]) + 1 + self._draw_text_outlined(draw, (x_base + 2, code_y), + code, self.fonts["detail"], + fill=(255, 255, 255)) + + # Team color dot + dot_y = code_y + self._get_text_height( + draw, code, self.fonts["detail"]) + 1 + if dot_y + 3 < self.display_height: + draw.rectangle( + [x_base + 2, dot_y, + x_base + 2 + self.accent_bar_width * 3, dot_y + 2], + fill=team_color) + + # Mini team logo + mini_logo = self.logo_loader.get_team_logo( + constructor_id, + max_height=int(self.display_height * 0.3), + max_width=int(section_width * 0.4)) + if mini_logo: + logo_x = x_base + section_width - mini_logo.width - 1 + logo_y = podium_y + if logo_y + mini_logo.height < self.display_height: + img.paste(mini_logo, (logo_x, logo_y), mini_logo) + + return img + + # ─── Shared Driver Row Helper ───────────────────────────────────── + + def _render_driver_row(self, entry: Dict, time_key: str = "", + gap_key: str = "", + show_eliminated: bool = False) -> Image.Image: + """ + Render a common driver row card used by qualifying, practice, sprint. + + Layout: [accent bar] [pos] [code] [time] [gap] [team logo] + + Args: + entry: Driver entry dict + time_key: Key for the time field (e.g. "best_lap", "time") + gap_key: Key for the gap field (e.g. "gap") + show_eliminated: Whether to show "OUT" for eliminated entries + """ + img = Image.new("RGBA", + (self.display_width, self.display_height), + (0, 0, 0, 255)) + draw = ImageDraw.Draw(img) + + constructor_id = entry.get("constructor_id", "") + self._draw_accent_bar(draw, constructor_id) + + x_offset = self.accent_bar_width + 2 + + # Position + pos_text = f"P{entry.get('position', '?')}" + self._draw_text_outlined(draw, (x_offset, 2), pos_text, + self.fonts["position"], + fill=(255, 255, 255)) + pos_width = self._get_text_width(draw, pos_text, self.fonts["position"]) + x_offset += pos_width + 4 + + # Driver code + code = entry.get("code", "???") + self._draw_text_outlined(draw, (x_offset, 2), code, + self.fonts["position"], + fill=(255, 255, 255)) + code_width = self._get_text_width(draw, code, self.fonts["position"]) + x_offset += code_width + 4 + + # Time + time_str = entry.get(time_key, "") if time_key else "" + time_width = 0 + if time_str: + self._draw_text_outlined(draw, (x_offset, 2), time_str, + self.fonts["detail"], + fill=(200, 200, 200)) + time_width = self._get_text_width(draw, time_str, + self.fonts["detail"]) + x_offset += time_width + 4 + elif show_eliminated: + eliminated = entry.get("eliminated_in", "") + if eliminated: + self._draw_text_outlined(draw, (x_offset, 2), "OUT", + self.fonts["detail"], + fill=(255, 80, 80)) + + # Gap to leader + gap_str = entry.get(gap_key, "") if gap_key else "" + if gap_str: + gap_y = 2 + self._get_text_height(draw, "1:00", + self.fonts["detail"]) + 2 + if gap_y + 6 < self.display_height: + gap_x = (x_offset - time_width - 4 + if time_str else x_offset) + self._draw_text_outlined(draw, (gap_x, gap_y), + gap_str, self.fonts["small"], + fill=(255, 200, 0)) + + # Team logo (right-aligned) + logo = self.logo_loader.get_team_logo( + constructor_id, + max_height=int(self.display_height * 0.6), + max_width=int(self.display_height * 0.6)) + if logo: + logo_x = self.display_width - logo.width - 2 + logo_y = (self.display_height - logo.height) // 2 + img.paste(logo, (logo_x, logo_y), logo) + + return img + + # ─── Qualifying Results Card ─────────────────────────────────────── + + def render_qualifying_entry(self, entry: Dict, + session_label: str = "Q3") -> Image.Image: + """Render a single qualifying result entry.""" + session_key = session_label.lower() + return self._render_driver_row( + entry, + time_key=session_key, + gap_key=f"{session_key}_gap", + show_eliminated=True) + + def render_qualifying_header(self, + session_label: str = "Q3", + race_name: str = "") -> Image.Image: + """Render a qualifying session header card.""" + img = Image.new("RGBA", + (self.display_width, self.display_height), + (0, 0, 0, 255)) + draw = ImageDraw.Draw(img) + + # F1 logo + f1_logo = self.logo_loader.get_f1_logo( + max_height=int(self.display_height * 0.4), + max_width=int(self.display_width * 0.15)) + if f1_logo: + img.paste(f1_logo, (2, 2), f1_logo) + + # Header text + header_x = (f1_logo.width + 6) if f1_logo else 4 + header_text = f"QUALIFYING - {session_label}" + self._draw_text_outlined(draw, (header_x, 2), header_text, + self.fonts["header"], + fill=F1_RED) + + # Race name below + if race_name: + short_name = race_name.replace("Grand Prix", "GP") + name_y = 2 + self._get_text_height( + draw, header_text, self.fonts["header"]) + 2 + if name_y + 6 < self.display_height: + self._draw_text_outlined(draw, (4, name_y), short_name, + self.fonts["small"], + fill=(180, 180, 180)) + + return img + + # ─── Practice Results Card ───────────────────────────────────────── + + def render_practice_entry(self, entry: Dict) -> Image.Image: + """Render a practice session result entry.""" + return self._render_driver_row( + entry, time_key="best_lap", gap_key="gap") + + def render_practice_header(self, session_name: str = "FP3", + circuit: str = "") -> Image.Image: + """Render a practice session header card.""" + img = Image.new("RGBA", + (self.display_width, self.display_height), + (0, 0, 0, 255)) + draw = ImageDraw.Draw(img) + + f1_logo = self.logo_loader.get_f1_logo( + max_height=int(self.display_height * 0.4), + max_width=int(self.display_width * 0.15)) + if f1_logo: + img.paste(f1_logo, (2, 2), f1_logo) + + header_x = (f1_logo.width + 6) if f1_logo else 4 + header_text = f"FREE PRACTICE {session_name[-1]}" if len(session_name) == 3 else session_name + self._draw_text_outlined(draw, (header_x, 2), header_text, + self.fonts["header"], + fill=F1_RED) + + if circuit: + name_y = 2 + self._get_text_height( + draw, header_text, self.fonts["header"]) + 2 + if name_y + 6 < self.display_height: + self._draw_text_outlined(draw, (4, name_y), circuit, + self.fonts["small"], + fill=(180, 180, 180)) + + return img + + # ─── Sprint Results Card ─────────────────────────────────────────── + + def render_sprint_entry(self, entry: Dict) -> Image.Image: + """Render a sprint result entry.""" + return self._render_driver_row(entry, time_key="time") + + def render_sprint_header(self, race_name: str = "") -> Image.Image: + """Render a sprint race header card.""" + img = Image.new("RGBA", + (self.display_width, self.display_height), + (0, 0, 0, 255)) + draw = ImageDraw.Draw(img) + + f1_logo = self.logo_loader.get_f1_logo( + max_height=int(self.display_height * 0.4), + max_width=int(self.display_width * 0.15)) + if f1_logo: + img.paste(f1_logo, (2, 2), f1_logo) + + header_x = (f1_logo.width + 6) if f1_logo else 4 + self._draw_text_outlined(draw, (header_x, 2), "SPRINT", + self.fonts["header"], + fill=F1_RED) + + if race_name: + short_name = race_name.replace("Grand Prix", "GP") + name_y = 2 + self._get_text_height( + draw, "SPRINT", self.fonts["header"]) + 2 + if name_y + 6 < self.display_height: + self._draw_text_outlined(draw, (4, name_y), short_name, + self.fonts["small"], + fill=(180, 180, 180)) + + return img + + # ─── Upcoming Race Card ──────────────────────────────────────────── + + def render_upcoming_race(self, race: Dict) -> Image.Image: + """ + Render the upcoming race card with countdown and circuit outline. + + Layout: [F1 logo] [GP name] [circuit outline] + [circuit name] [circuit outline] + [city, country] [circuit outline] + [countdown timer] + """ + img = Image.new("RGBA", + (self.display_width, self.display_height), + (0, 0, 0, 255)) + draw = ImageDraw.Draw(img) + + # Load circuit image and calculate text area width + circuit_img = self.logo_loader.get_circuit_image( + circuit_name=race.get("circuit_name", ""), + city=race.get("city", ""), + max_height=self.display_height - 4, + max_width=int(self.display_width * 0.35)) + + if circuit_img: + # Place circuit image on the right side, vertically centered + circuit_x = self.display_width - circuit_img.width - 2 + circuit_y = (self.display_height - circuit_img.height) // 2 + img.paste(circuit_img, (circuit_x, circuit_y), circuit_img) + text_max_x = circuit_x - 2 + else: + text_max_x = self.display_width - 2 + + y_pos = 1 + + # F1 logo + GP name on top line + f1_logo = self.logo_loader.get_f1_logo( + max_height=int(self.display_height * 0.3), + max_width=int(self.display_width * 0.12)) + if f1_logo: + img.paste(f1_logo, (1, y_pos), f1_logo) + name_x = f1_logo.width + 3 + else: + name_x = 2 + + # GP name + race_name = race.get("short_name", race.get("name", "")) + short_name = race_name.replace("Grand Prix", "GP") + short_name = self._truncate_text( + draw, short_name, self.fonts["header"], text_max_x - name_x) + self._draw_text_outlined(draw, (name_x, y_pos), short_name, + self.fonts["header"], + fill=F1_RED) + + header_h = max( + f1_logo.height if f1_logo else 0, + self._get_text_height(draw, short_name, self.fonts["header"])) + y_pos += header_h + 2 + + # Circuit name + circuit = race.get("circuit_name", "") + if circuit and y_pos + 6 < self.display_height - 10: + circuit = self._truncate_text( + draw, circuit, self.fonts["small"], text_max_x - 2) + self._draw_text_outlined(draw, (2, y_pos), circuit, + self.fonts["small"], + fill=(180, 180, 180)) + y_pos += self._get_text_height(draw, circuit, + self.fonts["small"]) + 1 + + # City, Country + location_parts = [] + if race.get("city"): + location_parts.append(race["city"]) + if race.get("country"): + location_parts.append(race["country"]) + location = ", ".join(location_parts) + + if location and y_pos + 6 < self.display_height - 8: + location = self._truncate_text( + draw, location, self.fonts["small"], text_max_x - 2) + self._draw_text_outlined(draw, (2, y_pos), location, + self.fonts["small"], + fill=(150, 150, 150)) + y_pos += self._get_text_height(draw, location, + self.fonts["small"]) + 1 + + # Countdown timer (bottom) + countdown_seconds = race.get("countdown_seconds") + if countdown_seconds is not None and countdown_seconds >= 0: + countdown_y = self.display_height - self._get_text_height( + draw, "0D", self.fonts["detail"]) - 2 + + if countdown_seconds < 3600: + # Less than 1 hour - show session type + session_type = race.get("next_session_type", "RACE") + label_map = { + "FP1": "FP1 SOON", "FP2": "FP2 SOON", "FP3": "FP3 SOON", + "Qual": "QUALIFYING", "Race": "RACE DAY", + "SS": "SPRINT QUALI", "SR": "SPRINT RACE", + } + label = label_map.get(session_type, "RACE DAY") + label = self._truncate_text( + draw, label, self.fonts["detail"], text_max_x - 2) + + # Pulsing effect: vary brightness + pulse = int(180 + 75 * math.sin(time.time() * 3)) + pulse = max(150, min(255, pulse)) + self._draw_text_outlined(draw, (2, countdown_y), label, + self.fonts["detail"], + fill=(pulse, pulse, 0)) + else: + # Show countdown + days = int(countdown_seconds // 86400) + hours = int((countdown_seconds % 86400) // 3600) + minutes = int((countdown_seconds % 3600) // 60) + + if days > 0: + countdown_text = f"{days}D {hours}H {minutes}M" + else: + countdown_text = f"{hours}H {minutes}M" + + # Date prefix + race_date = race.get("date", "") + date_prefix = "" + if race_date: + try: + dt = datetime.fromisoformat( + race_date.replace("Z", "+00:00")) + date_prefix = dt.strftime("%b %d").upper() + " " + except (ValueError, TypeError): + pass + + full_text = date_prefix + countdown_text + full_text = self._truncate_text( + draw, full_text, self.fonts["detail"], text_max_x - 2) + self._draw_text_outlined(draw, (2, countdown_y), full_text, + self.fonts["detail"], + fill=(0, 255, 0)) + + return img + + # ─── Calendar Entry Card ────────────────────────────────────────── + + def render_calendar_entry(self, entry: Dict) -> Image.Image: + """ + Render a calendar session entry. + + Layout: [date] [day] [session type] [GP short name] + """ + img = Image.new("RGBA", + (self.display_width, self.display_height), + (0, 0, 0, 255)) + draw = ImageDraw.Draw(img) + + # Parse date + date_str = entry.get("date", "") + date_display = "" + day_display = "" + if date_str: + try: + dt = datetime.fromisoformat( + date_str.replace("Z", "+00:00")) + date_display = dt.strftime("%b %d").upper() + day_display = dt.strftime("%a").upper() + except (ValueError, TypeError): + pass + + x_offset = 2 + + # Date + if date_display: + self._draw_text_outlined(draw, (x_offset, 2), date_display, + self.fonts["position"], + fill=(255, 255, 255)) + date_width = self._get_text_width(draw, date_display, + self.fonts["position"]) + x_offset += date_width + 4 + + # Day of week + if day_display: + self._draw_text_outlined(draw, (x_offset, 2), day_display, + self.fonts["detail"], + fill=(150, 150, 150)) + day_width = self._get_text_width(draw, day_display, + self.fonts["detail"]) + x_offset += day_width + 4 + + # Session type with color coding + session_type = entry.get("session_type", "") + session_colors = { + "Race": (255, 0, 0), + "Qual": (255, 200, 0), + "FP1": (100, 200, 100), + "FP2": (100, 200, 100), + "FP3": (100, 200, 100), + "SS": (255, 150, 0), + "SR": (255, 100, 0), + } + session_color = session_colors.get(session_type, (200, 200, 200)) + + session_label = { + "FP1": "FP1", "FP2": "FP2", "FP3": "FP3", + "Qual": "QUALI", "Race": "RACE", + "SS": "S.QUALI", "SR": "SPRINT", + }.get(session_type, session_type) + + self._draw_text_outlined(draw, (x_offset, 2), session_label, + self.fonts["detail"], + fill=session_color) + session_width = self._get_text_width(draw, session_label, + self.fonts["detail"]) + x_offset += session_width + 4 + + # Event name + event_name = entry.get("event_name", "") + short_event = event_name.replace("Grand Prix", "GP") + max_name_width = self.display_width - x_offset - 2 + short_event = self._truncate_text( + draw, short_event, self.fonts["small"], max_name_width) + + self._draw_text_outlined(draw, (x_offset, 2), short_event, + self.fonts["small"], + fill=(180, 180, 180)) + + # Time on second line + time_str = entry.get("status_detail", "") + if time_str and self.display_height > 16: + time_y = 2 + self._get_text_height( + draw, date_display or "A", self.fonts["position"]) + 2 + if time_y + 6 < self.display_height: + self._draw_text_outlined(draw, (2, time_y), time_str, + self.fonts["small"], + fill=(120, 120, 120)) + + return img + + # ─── Section Separator ───────────────────────────────────────────── + + def render_f1_separator(self) -> Image.Image: + """Render an F1 logo separator card for vegas scroll.""" + img = Image.new("RGBA", + (self.display_height, self.display_height), + (0, 0, 0, 255)) + + logo = self.logo_loader.get_f1_logo( + max_height=int(self.display_height * 0.6), + max_width=int(self.display_height * 0.6)) + if logo: + x = (self.display_height - logo.width) // 2 + y = (self.display_height - logo.height) // 2 + img.paste(logo, (x, y), logo) + + return img diff --git a/plugins/f1-scoreboard/logo_downloader.py b/plugins/f1-scoreboard/logo_downloader.py new file mode 100644 index 0000000..23e85ba --- /dev/null +++ b/plugins/f1-scoreboard/logo_downloader.py @@ -0,0 +1,292 @@ +""" +Logo loader for F1 Scoreboard Plugin + +Handles loading, caching, and resizing of F1 team logos, the F1 brand logo, +and circuit layout images. All assets are bundled as static PNGs. +Falls back to generating text-based placeholder logos for any missing teams. +""" + +import logging +import os +from pathlib import Path +from typing import Dict, Optional, Tuple +from PIL import Image, ImageDraw, ImageFont + +from team_colors import get_team_color, normalize_constructor_id + +logger = logging.getLogger(__name__) + + + +# Map ESPN circuit names/cities to our bundled circuit image filenames +# Keys are lowercased substrings matched against circuit_name or city +CIRCUIT_FILENAME_MAP = { + "melbourne": "melbourne", + "albert park": "melbourne", + "shanghai": "shanghai", + "suzuka": "suzuka", + "bahrain": "bahrain", + "sakhir": "bahrain", + "jeddah": "jeddah", + "miami": "miami", + "hard rock": "miami", + "gilles villeneuve": "montreal", + "montreal": "montreal", + "monaco": "monaco", + "monte carlo": "monaco", + "catalunya": "barcelona", + "barcelona": "barcelona", + "red bull ring": "spielberg", + "spielberg": "spielberg", + "silverstone": "silverstone", + "spa": "spa", + "francorchamps": "spa", + "stavelot": "spa", + "hungaroring": "budapest", + "budapest": "budapest", + "zandvoort": "zandvoort", + "monza": "monza", + "madrid": "madrid", + "baku": "baku", + "marina bay": "singapore", + "singapore": "singapore", + "americas": "austin", + "austin": "austin", + "hermanos rodriguez": "mexico_city", + "mexico": "mexico_city", + "interlagos": "interlagos", + "carlos pace": "interlagos", + "sao paulo": "interlagos", + "las vegas": "las_vegas", + "losail": "losail", + "lusail": "losail", + "qatar": "losail", + "yas marina": "yas_marina", + "abu dhabi": "yas_marina", +} + + +class F1LogoLoader: + """Loads, caches, and resizes F1 team logos and circuit images.""" + + def __init__(self, plugin_dir: str = None): + """ + Initialize the logo loader. + + Args: + plugin_dir: Path to the plugin directory (contains assets/f1/) + """ + if plugin_dir is None: + plugin_dir = os.path.dirname(os.path.abspath(__file__)) + + self.plugin_dir = Path(plugin_dir) + self.teams_dir = self.plugin_dir / "assets" / "f1" / "teams" + self.circuits_dir = self.plugin_dir / "assets" / "f1" / "circuits" + self.f1_logo_path = self.plugin_dir / "assets" / "f1" / "f1_logo.png" + + # In-memory cache: key -> PIL Image (already resized) + self._cache: Dict[str, Image.Image] = {} + + def get_team_logo(self, constructor_id: str, max_height: int = 28, + max_width: int = 28) -> Image.Image: + """ + Get a team logo, resized to fit within max dimensions. + + Always returns an image — generates a text placeholder if no + logo file exists for the given constructor. + + Args: + constructor_id: Constructor identifier (any format) + max_height: Maximum height in pixels + max_width: Maximum width in pixels + + Returns: + PIL Image in RGBA mode + """ + normalized = normalize_constructor_id(constructor_id) + cache_key = f"team_{normalized}_{max_width}x{max_height}" + + if cache_key in self._cache: + return self._cache[cache_key] + + logo = self._load_logo(normalized, max_width, max_height) + self._cache[cache_key] = logo + return logo + + def get_f1_logo(self, max_height: int = 12, + max_width: int = 20) -> Optional[Image.Image]: + """ + Get the F1 brand logo. + + Args: + max_height: Maximum height in pixels + max_width: Maximum width in pixels + + Returns: + PIL Image in RGBA mode, or None if unavailable + """ + cache_key = f"f1_logo_{max_width}x{max_height}" + + if cache_key in self._cache: + return self._cache[cache_key] + + if self.f1_logo_path.exists(): + try: + img = Image.open(self.f1_logo_path).convert("RGBA") + img.thumbnail((max_width, max_height), Image.Resampling.LANCZOS) + self._cache[cache_key] = img + return img + except Exception as e: + logger.warning("Failed to load F1 logo: %s", e) + + # Create F1 text placeholder + placeholder = self._create_text_placeholder("F1", max_width, max_height, + color=(229, 0, 0)) + self._cache[cache_key] = placeholder + return placeholder + + def _load_logo(self, constructor_id: str, max_width: int, + max_height: int) -> Image.Image: + """Load a team logo from disk, with placeholder fallback. + + Always returns an image — generates a text placeholder if no + logo file exists on disk. + """ + logo_path = self.teams_dir / f"{constructor_id}.png" + + if logo_path.exists(): + try: + img = Image.open(logo_path).convert("RGBA") + img.thumbnail((max_width, max_height), Image.Resampling.LANCZOS) + return img + except Exception as e: + logger.warning("Failed to load logo for %s: %s", + constructor_id, e) + + # Try common filename variations + for variation in [constructor_id.replace("_", ""), + constructor_id.replace("_", "-")]: + alt_path = self.teams_dir / f"{variation}.png" + if alt_path.exists(): + try: + img = Image.open(alt_path).convert("RGBA") + img.thumbnail((max_width, max_height), + Image.Resampling.LANCZOS) + return img + except Exception as e: + logger.debug("Failed to load logo variant %s: %s", + alt_path, e) + + # Create placeholder with team color + color = get_team_color(constructor_id) + abbr = constructor_id[:3].upper() if constructor_id else "???" + return self._create_text_placeholder(abbr, max_width, max_height, + color=color) + + def _create_text_placeholder(self, text: str, width: int, height: int, + color: Tuple[int, int, int] = (200, 200, 200) + ) -> Image.Image: + """Create a simple text-based placeholder logo.""" + img = Image.new("RGBA", (width, height), (0, 0, 0, 0)) + draw = ImageDraw.Draw(img) + + try: + font = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6) + except Exception: + try: + font = ImageFont.truetype( + str(Path(__file__).parent.parent.parent / + "assets" / "fonts" / "4x6-font.ttf"), 6) + except Exception: + font = ImageFont.load_default() + + text = text[:3] + bbox = draw.textbbox((0, 0), text, font=font) + text_w = bbox[2] - bbox[0] + text_h = bbox[3] - bbox[1] + + x = (width - text_w) // 2 + y = (height - text_h) // 2 + + # Draw outline + for dx, dy in [(-1, 0), (1, 0), (0, -1), (0, 1)]: + draw.text((x + dx, y + dy), text, font=font, fill=(0, 0, 0)) + draw.text((x, y), text, font=font, fill=color) + + return img + + def get_circuit_image(self, circuit_name: str = "", city: str = "", + max_height: int = 28, + max_width: int = 40) -> Optional[Image.Image]: + """ + Get a circuit layout image by matching circuit name or city. + + Args: + circuit_name: Circuit name (e.g., "Silverstone Circuit") + city: City name (e.g., "Melbourne") + max_height: Maximum height in pixels + max_width: Maximum width in pixels + + Returns: + PIL Image in RGBA mode (white outline on transparent), or None + """ + filename = self._resolve_circuit_filename(circuit_name, city) + if not filename: + return None + + cache_key = f"circuit_{filename}_{max_width}x{max_height}" + if cache_key in self._cache: + return self._cache[cache_key] + + circuit_path = self.circuits_dir / f"{filename}.png" + if not circuit_path.exists(): + return None + + try: + img = Image.open(circuit_path).convert("RGBA") + img.thumbnail((max_width, max_height), Image.Resampling.LANCZOS) + self._cache[cache_key] = img + return img + except Exception as e: + logger.warning("Failed to load circuit image %s: %s", filename, e) + return None + + @staticmethod + def _resolve_circuit_filename(circuit_name: str, city: str) -> str: + """Resolve a circuit name/city to a filename key. + + Matches longest keys first to prevent short-key false positives + (e.g. 'spa' matching inside a longer unrelated string). + """ + combined = f"{circuit_name} {city}".lower() + # Sort by key length descending so longer, more specific keys match first + for key, filename in sorted(CIRCUIT_FILENAME_MAP.items(), + key=lambda kv: len(kv[0]), + reverse=True): + if key in combined: + return filename + return "" + + def clear_cache(self): + """Clear the in-memory logo cache.""" + self._cache.clear() + + def preload_all_teams(self, max_height: int = 28, max_width: int = 28): + """ + Preload all team logos into cache. + + Args: + max_height: Maximum height for cached logos + max_width: Maximum width for cached logos + """ + if not self.teams_dir.exists(): + logger.warning("Teams logo directory not found: %s", self.teams_dir) + return + + count = 0 + for logo_file in self.teams_dir.glob("*.png"): + constructor_id = logo_file.stem + self.get_team_logo(constructor_id, max_height, max_width) + count += 1 + + logger.info("Preloaded %d team logos", count) diff --git a/plugins/f1-scoreboard/manager.py b/plugins/f1-scoreboard/manager.py new file mode 100644 index 0000000..ffdac68 --- /dev/null +++ b/plugins/f1-scoreboard/manager.py @@ -0,0 +1,621 @@ +""" +F1 Scoreboard Plugin + +Main plugin class for the Formula 1 Scoreboard. +Displays driver standings, constructor standings, race results, qualifying, +practice, sprint results, upcoming races, and race calendar. +""" + +import logging +import time +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional + +from PIL import Image + +from src.plugin_system.base_plugin import BasePlugin, VegasDisplayMode + +from f1_data import F1DataSource +from f1_renderer import F1Renderer +from logo_downloader import F1LogoLoader +from scroll_display import ScrollDisplayManager +from team_colors import normalize_constructor_id + +logger = logging.getLogger(__name__) + + +class F1ScoreboardPlugin(BasePlugin): + """ + Formula 1 Scoreboard Plugin. + + Displays F1 standings, race results, qualifying breakdowns, practice + standings, sprint results, upcoming races, and race calendar. + Supports favorite driver/team highlighting and Vegas scroll mode. + """ + + def __init__(self, plugin_id, config, display_manager, + cache_manager, plugin_manager): + super().__init__(plugin_id, config, display_manager, + cache_manager, plugin_manager) + + # Display dimensions + if hasattr(display_manager, "matrix") and display_manager.matrix: + self.display_width = display_manager.matrix.width + self.display_height = display_manager.matrix.height + else: + self.display_width = getattr(display_manager, "width", 128) + self.display_height = getattr(display_manager, "height", 32) + + # Favorites + self.favorite_driver = config.get("favorite_driver", "").upper() + self.favorite_team = normalize_constructor_id( + config.get("favorite_team", "")) + + # Display duration + self.display_duration = config.get("display_duration", 30) + + # Initialize components + self.logo_loader = F1LogoLoader() + self.data_source = F1DataSource(cache_manager, config) + self.renderer = F1Renderer( + self.display_width, self.display_height, + config, self.logo_loader, self.logger) + self._scroll_manager = ScrollDisplayManager( + display_manager, config, self.logger) + + # Data state + self._driver_standings: List[Dict] = [] + self._constructor_standings: List[Dict] = [] + self._recent_races: List[Dict] = [] + self._upcoming_race: Optional[Dict] = None + self._qualifying: Optional[Dict] = None + self._practice_results: Dict[str, Dict] = {} # FP1/FP2/FP3 + self._sprint: Optional[Dict] = None + self._calendar: List[Dict] = [] + self._pole_positions: Dict[str, int] = {} + + # Timing + self._last_update = 0 + self._update_interval = config.get("update_interval", 3600) + + # Display state tracking (for dynamic duration) + self._current_display_mode: Optional[str] = None + + # Build enabled modes + self.modes = self._build_enabled_modes() + + # Preload logos + self.logo_loader.preload_all_teams( + self.renderer.logo_max_height, + self.renderer.logo_max_width) + + self.logger.info("F1 Scoreboard initialized with %d modes: %s", + len(self.modes), ", ".join(self.modes)) + + def _build_enabled_modes(self) -> List[str]: + """Build list of enabled display modes from config.""" + modes = [] + mode_configs = { + "f1_driver_standings": self.config.get( + "driver_standings", {}).get("enabled", True), + "f1_constructor_standings": self.config.get( + "constructor_standings", {}).get("enabled", True), + "f1_recent_races": self.config.get( + "recent_races", {}).get("enabled", True), + "f1_upcoming": self.config.get( + "upcoming", {}).get("enabled", True), + "f1_qualifying": self.config.get( + "qualifying", {}).get("enabled", True), + "f1_practice": self.config.get( + "practice", {}).get("enabled", True), + "f1_sprint": self.config.get( + "sprint", {}).get("enabled", True), + "f1_calendar": self.config.get( + "calendar", {}).get("enabled", True), + } + + for mode, enabled in mode_configs.items(): + if enabled: + modes.append(mode) + + return modes + + # ─── Update ──────────────────────────────────────────────────────── + + def update(self): + """Fetch and update all F1 data from APIs.""" + now = time.time() + if now - self._last_update < self._update_interval: + return + + self.logger.info("Updating F1 data...") + self._last_update = now + + for step in (self._update_standings, + self._update_recent_races, + self._update_upcoming, + self._update_qualifying, + self._update_practice, + self._update_sprint, + self._update_calendar, + self._prepare_scroll_content): + try: + step() + except Exception as e: + self.logger.error("Error in %s: %s", step.__name__, + e, exc_info=True) + + def _update_standings(self): + """Update driver and constructor standings.""" + # Driver standings + if "f1_driver_standings" in self.modes: + standings = self.data_source.fetch_driver_standings() + if standings: + # Calculate poles + self._pole_positions = ( + self.data_source.calculate_pole_positions()) + + # Shallow copy entries before adding poles to avoid + # mutating the cached standings dicts + standings = [dict(e) for e in standings] + for entry in standings: + code = entry.get("code", "") + entry["poles"] = self._pole_positions.get(code, 0) + + # Apply favorite filter + top_n = self.config.get( + "driver_standings", {}).get("top_n", 10) + always_show = self.config.get( + "driver_standings", {}).get("always_show_favorite", True) + + self._driver_standings = self.data_source.apply_favorite_filter( + standings, top_n, + favorite_driver=self.favorite_driver, + favorite_team=self.favorite_team, + always_show_favorite=always_show) + + # Constructor standings + if "f1_constructor_standings" in self.modes: + standings = self.data_source.fetch_constructor_standings() + if standings: + top_n = self.config.get( + "constructor_standings", {}).get("top_n", 10) + always_show = self.config.get( + "constructor_standings", {}).get( + "always_show_favorite", True) + + self._constructor_standings = ( + self.data_source.apply_favorite_filter( + standings, top_n, + favorite_team=self.favorite_team, + always_show_favorite=always_show, + driver_key="constructor_id", + team_key="constructor_id")) + + def _update_recent_races(self): + """Update recent race results.""" + if "f1_recent_races" not in self.modes: + return + + count = self.config.get("recent_races", {}).get("number_of_races", 3) + races = self.data_source.fetch_recent_races(count=count) + if races: + top_finishers = self.config.get( + "recent_races", {}).get("top_finishers", 3) + always_show = self.config.get( + "recent_races", {}).get("always_show_favorite", True) + + # Shallow copy race dicts before mutating results to avoid + # altering the cached objects from fetch_recent_races + filtered_races = [] + for race in races: + race_copy = dict(race) + results = race.get("results", []) + race_copy["results"] = self.data_source.apply_favorite_filter( + results, top_finishers, + favorite_driver=self.favorite_driver, + always_show_favorite=always_show) + filtered_races.append(race_copy) + + self._recent_races = filtered_races + + def _update_upcoming(self): + """Update upcoming race data.""" + if "f1_upcoming" not in self.modes: + return + + upcoming = self.data_source.get_upcoming_race() + if upcoming: + self._upcoming_race = upcoming + + def _update_qualifying(self): + """Update qualifying results.""" + if "f1_qualifying" not in self.modes: + return + + qualifying = self.data_source.fetch_qualifying() + if qualifying: + self._qualifying = qualifying + + def _update_practice(self): + """Update free practice results.""" + if "f1_practice" not in self.modes: + return + + sessions = self.config.get( + "practice", {}).get("sessions_to_show", ["FP1", "FP2", "FP3"]) + top_n = self.config.get("practice", {}).get("top_n", 10) + + session_name_map = { + "FP1": "Practice 1", + "FP2": "Practice 2", + "FP3": "Practice 3", + } + + for fp_key in sessions: + session_name = session_name_map.get(fp_key) + if not session_name: + continue + + result = self.data_source.fetch_practice_results(session_name) + if result: + # Shallow copy before slicing to avoid mutating cached dict + result_copy = dict(result) + if result_copy.get("results"): + result_copy["results"] = result_copy["results"][:top_n] + self._practice_results[fp_key] = result_copy + + def _update_sprint(self): + """Update sprint race results.""" + if "f1_sprint" not in self.modes: + return + + sprint = self.data_source.fetch_sprint_results() + if sprint: + # Shallow copy before slicing to avoid mutating cached dict + sprint_copy = dict(sprint) + top_n = self.config.get("sprint", {}).get("top_finishers", 10) + if sprint_copy.get("results"): + sprint_copy["results"] = sprint_copy["results"][:top_n] + self._sprint = sprint_copy + + def _update_calendar(self): + """Update race calendar.""" + if "f1_calendar" not in self.modes: + return + + cal_config = self.config.get("calendar", {}) + calendar = self.data_source.get_calendar( + show_practice=cal_config.get("show_practice", False), + show_qualifying=cal_config.get("show_qualifying", True), + show_sprint=cal_config.get("show_sprint", True), + max_events=cal_config.get("max_events", 5)) + if calendar: + self._calendar = calendar + + # ─── Scroll Content Preparation ──────────────────────────────────── + + def _prepare_scroll_content(self): + """Pre-render all scroll mode content.""" + separator = self.renderer.render_f1_separator() + + # Driver standings + if self._driver_standings: + cards = [self.renderer.render_driver_standing(e) + for e in self._driver_standings] + self._scroll_manager.prepare_and_display( + "driver_standings", cards, separator) + + # Constructor standings + if self._constructor_standings: + cards = [self.renderer.render_constructor_standing(e) + for e in self._constructor_standings] + self._scroll_manager.prepare_and_display( + "constructor_standings", cards, separator) + + # Recent races + if self._recent_races: + cards = [self.renderer.render_race_result(race) + for race in self._recent_races] + self._scroll_manager.prepare_and_display( + "recent_races", cards, separator) + + # Qualifying + if self._qualifying: + cards = self._build_qualifying_cards() + if cards: + self._scroll_manager.prepare_and_display( + "qualifying", cards, separator) + + # Practice + practice_cards = self._build_practice_cards() + if practice_cards: + self._scroll_manager.prepare_and_display( + "practice", practice_cards, separator) + + # Sprint + if self._sprint and self._sprint.get("results"): + cards = [self.renderer.render_sprint_header( + self._sprint.get("race_name", ""))] + for entry in self._sprint["results"]: + cards.append(self.renderer.render_sprint_entry(entry)) + self._scroll_manager.prepare_and_display( + "sprint", cards, separator) + + # Calendar + if self._calendar: + cards = [self.renderer.render_calendar_entry(e) + for e in self._calendar] + self._scroll_manager.prepare_and_display( + "calendar", cards, separator) + + def _build_qualifying_cards(self) -> List[Image.Image]: + """Build qualifying result cards grouped by Q session.""" + if not self._qualifying: + return [] + + cards = [] + quali_config = self.config.get("qualifying", {}) + results = self._qualifying.get("results", []) + race_name = self._qualifying.get("race_name", "") + + for session_key, show_key, label in [ + ("q3", "show_q3", "Q3"), + ("q2", "show_q2", "Q2"), + ("q1", "show_q1", "Q1"), + ]: + if not quali_config.get(show_key, True): + continue + + # Add session header + cards.append(self.renderer.render_qualifying_header( + label, race_name)) + + # Add entries for this session + for entry in results: + # Only show entries that have a time for this session + if entry.get(session_key): + cards.append(self.renderer.render_qualifying_entry( + entry, label)) + elif entry.get("eliminated_in") == label: + # Show eliminated driver + cards.append(self.renderer.render_qualifying_entry( + entry, label)) + + return cards + + def _build_practice_cards(self) -> List[Image.Image]: + """Build practice result cards for all configured sessions.""" + cards = [] + + for fp_key in ["FP3", "FP2", "FP1"]: # Most recent first + if fp_key not in self._practice_results: + continue + + fp_data = self._practice_results[fp_key] + cards.append(self.renderer.render_practice_header( + fp_key, fp_data.get("circuit", ""))) + + for entry in fp_data.get("results", []): + cards.append(self.renderer.render_practice_entry(entry)) + + return cards + + # ─── Display ─────────────────────────────────────────────────────── + + def display(self, force_clear=False, display_mode=None) -> bool: + """ + Display the current F1 mode. + + Args: + force_clear: Whether to clear display first + display_mode: Specific mode to display (from manifest display_modes) + + Returns: + True if content was displayed, False if mode has no data + """ + if not self.enabled: + return False + + if display_mode is None: + display_mode = self.modes[0] if self.modes else "f1_driver_standings" + + self._current_display_mode = display_mode + + if display_mode == "f1_upcoming": + return self._display_upcoming(force_clear) + elif display_mode in ("f1_driver_standings", + "f1_constructor_standings", + "f1_recent_races", + "f1_qualifying", + "f1_practice", + "f1_sprint", + "f1_calendar"): + return self._display_scroll_mode(display_mode, force_clear) + else: + self.logger.warning("Unknown display mode: %s", display_mode) + return False + + def _display_upcoming(self, force_clear: bool) -> bool: + """Display the upcoming race card (static).""" + if not self._upcoming_race: + return False + + if force_clear: + self.display_manager.image.paste( + Image.new("RGB", + (self.display_width, self.display_height), + (0, 0, 0)), + (0, 0)) + + # Work on a shallow copy to avoid mutating cached data + upcoming = dict(self._upcoming_race) + upcoming["countdown_seconds"] = None + + now = datetime.now(timezone.utc) + + for session in upcoming.get("sessions", []): + if session.get("status_state") == "pre" and session.get("date"): + try: + parsed_dt = datetime.fromisoformat( + session["date"].replace("Z", "+00:00")) + if parsed_dt > now: + upcoming["countdown_seconds"] = max( + 0, (parsed_dt - now).total_seconds()) + upcoming["next_session_type"] = session.get( + "type_abbr", "") + break + except (ValueError, TypeError): + continue + + card = self.renderer.render_upcoming_race(upcoming) + self.display_manager.image.paste(card, (0, 0)) + self.display_manager.update_display() + return True + + def _display_scroll_mode(self, display_mode: str, + force_clear: bool) -> bool: + """Display a scrolling mode.""" + mode_key = self._MODE_KEY_MAP.get(display_mode, display_mode) + + if not self._scroll_manager.is_mode_prepared(mode_key): + self._prepare_scroll_content() + + if not self._scroll_manager.is_mode_prepared(mode_key): + return False + + self._scroll_manager.display_frame(mode_key, force_clear) + return True + + # ─── Vegas Mode ──────────────────────────────────────────────────── + + def get_vegas_content(self) -> Optional[List[Image.Image]]: + """Return rendered cards for modes that have data.""" + images = [] + + # Only include modes that have actual data + 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.extend( + self._scroll_manager.get_vegas_items_for_mode(mode_key)) + + # Add upcoming race card if available + if self._upcoming_race: + upcoming_card = self.renderer.render_upcoming_race( + self._upcoming_race) + images.insert(0, upcoming_card) + + return images if images else None + + def get_vegas_content_type(self) -> str: + """Return multi for scrolling content.""" + return "multi" + + def get_vegas_display_mode(self) -> VegasDisplayMode: + """Return SCROLL for continuous scrolling.""" + return VegasDisplayMode.SCROLL + + # ─── Dynamic Duration ────────────────────────────────────────────── + + _SCROLL_MODES = frozenset({ + "f1_driver_standings", "f1_constructor_standings", + "f1_recent_races", "f1_qualifying", "f1_practice", + "f1_sprint", "f1_calendar", + }) + + _MODE_KEY_MAP = { + "f1_driver_standings": "driver_standings", + "f1_constructor_standings": "constructor_standings", + "f1_recent_races": "recent_races", + "f1_qualifying": "qualifying", + "f1_practice": "practice", + "f1_sprint": "sprint", + "f1_calendar": "calendar", + } + + def supports_dynamic_duration(self) -> bool: + """Enable dynamic duration for scrolling modes.""" + dd = self.config.get("dynamic_duration", {}) + if not isinstance(dd, dict) or not dd.get("enabled", True): + return False + return (self._current_display_mode is not None + and self._current_display_mode in self._SCROLL_MODES) + + def is_cycle_complete(self) -> bool: + """Scroll cycle complete when ScrollHelper reports done.""" + if not self._current_display_mode: + return True + mode_key = self._MODE_KEY_MAP.get(self._current_display_mode) + if not mode_key: + return True + return self._scroll_manager.is_scroll_complete(mode_key) + + def reset_cycle_state(self) -> None: + """Reset scroll position for the current mode.""" + super().reset_cycle_state() + if self._current_display_mode: + mode_key = self._MODE_KEY_MAP.get(self._current_display_mode) + if mode_key: + self._scroll_manager.reset_mode(mode_key) + + # ─── Lifecycle ───────────────────────────────────────────────────── + + def get_info(self) -> Dict[str, Any]: + """Return diagnostic info for the web UI.""" + info = super().get_info() + info.update({ + "name": "F1 Scoreboard", + "enabled_modes": self.modes, + "mode_count": len(self.modes), + "last_update": self._last_update, + "has_driver_standings": bool(self._driver_standings), + "has_constructor_standings": bool(self._constructor_standings), + "has_recent_races": bool(self._recent_races), + "has_upcoming_race": self._upcoming_race is not None, + "has_qualifying": self._qualifying is not None, + "has_practice": bool(self._practice_results), + "has_sprint": self._sprint is not None, + "has_calendar": bool(self._calendar), + "favorite_driver": self.favorite_driver, + "favorite_team": self.favorite_team, + }) + return info + + def on_config_change(self, new_config): + """Handle config changes.""" + super().on_config_change(new_config) + + self.favorite_driver = new_config.get("favorite_driver", "").upper() + self.favorite_team = normalize_constructor_id( + new_config.get("favorite_team", "")) + self._update_interval = new_config.get("update_interval", 3600) + self.display_duration = new_config.get("display_duration", 30) + self.modes = self._build_enabled_modes() + + # Force re-render with new settings + self.renderer = F1Renderer( + self.display_width, self.display_height, + new_config, self.logo_loader, self.logger) + self._scroll_manager = ScrollDisplayManager( + self.display_manager, new_config, self.logger) + + # Force data refresh + self._last_update = 0 + + def cleanup(self): + """Clean up resources.""" + try: + self.logo_loader.clear_cache() + self.logger.info("F1 Scoreboard cleanup completed") + except Exception: + self.logger.exception("Error during F1 Scoreboard cleanup") + super().cleanup() diff --git a/plugins/f1-scoreboard/manifest.json b/plugins/f1-scoreboard/manifest.json new file mode 100644 index 0000000..541ae01 --- /dev/null +++ b/plugins/f1-scoreboard/manifest.json @@ -0,0 +1,37 @@ +{ + "id": "f1-scoreboard", + "name": "F1 Scoreboard", + "version": "1.1.0", + "author": "ChuckBuilds", + "class_name": "F1ScoreboardPlugin", + "entry_point": "manager.py", + "repo": "https://github.com/ChuckBuilds/ledmatrix-plugins", + "branch": "main", + "plugin_path": "plugins/f1-scoreboard", + "description": "Formula 1 racing plugin showing driver/constructor standings, race results, qualifying breakdowns, practice standings, sprint results, upcoming races, and race calendar with team-colored displays and favorite driver/team support", + "category": "sports", + "tags": ["f1", "formula1", "racing", "motorsport", "sports", "scoreboard"], + "display_modes": [ + "f1_driver_standings", + "f1_constructor_standings", + "f1_recent_races", + "f1_upcoming", + "f1_qualifying", + "f1_practice", + "f1_sprint", + "f1_calendar" + ], + "versions": [ + { + "version": "1.1.0", + "ledmatrix_min": "2.0.0", + "released": "2026-02-17" + } + ], + "last_updated": "2026-02-18", + "stars": 0, + "downloads": 0, + "verified": true, + "screenshot": "", + "config_schema": "config_schema.json" +} diff --git a/plugins/f1-scoreboard/requirements.txt b/plugins/f1-scoreboard/requirements.txt new file mode 100644 index 0000000..208d37f --- /dev/null +++ b/plugins/f1-scoreboard/requirements.txt @@ -0,0 +1,2 @@ +requests>=2.32.0 +Pillow>=12.1.1 diff --git a/plugins/f1-scoreboard/scroll_display.py b/plugins/f1-scoreboard/scroll_display.py new file mode 100644 index 0000000..05ac208 --- /dev/null +++ b/plugins/f1-scoreboard/scroll_display.py @@ -0,0 +1,234 @@ +""" +Scroll Display Handler for F1 Scoreboard Plugin + +Implements horizontal scrolling of F1 standings, results, qualifying, +practice, sprint, and calendar cards using ScrollHelper. +""" + +import logging +from typing import Any, Dict, List, Optional + +from PIL import Image + +try: + from src.common.scroll_helper import ScrollHelper +except ImportError as _scroll_import_err: + ScrollHelper = None + logging.getLogger(__name__).warning( + "ScrollHelper not available, scrolling disabled: %s", + _scroll_import_err) + +logger = logging.getLogger(__name__) + + +class ScrollDisplay: + """ + Handles scroll display for a single F1 display mode. + + Pre-renders content cards, composes them into a scrolling image, + and manages scroll state. + """ + + def __init__(self, display_manager, config: Optional[Dict[str, Any]] = None, + custom_logger: Optional[logging.Logger] = None): + self.display_manager = display_manager + self.config = config or {} + self.logger = custom_logger or logger + + # Get display dimensions + if hasattr(display_manager, "matrix") and display_manager.matrix: + self.display_width = display_manager.matrix.width + self.display_height = display_manager.matrix.height + else: + self.display_width = getattr(display_manager, "width", 128) + self.display_height = getattr(display_manager, "height", 32) + + # Initialize ScrollHelper with config-driven parameters + self.scroll_helper = None + if ScrollHelper: + self.scroll_helper = ScrollHelper( + self.display_width, + self.display_height, + self.logger + ) + scroll_cfg = self.config.get("scroll", {}) + if not isinstance(scroll_cfg, dict): + scroll_cfg = {} + self.scroll_helper.set_frame_based_scrolling( + scroll_cfg.get("frame_based", True)) + self.scroll_helper.set_scroll_speed( + scroll_cfg.get("scroll_speed", 1)) + self.scroll_helper.set_scroll_delay( + scroll_cfg.get("scroll_delay", 0.03)) + self.scroll_helper.set_dynamic_duration_settings( + enabled=True, + min_duration=scroll_cfg.get("min_duration", 15), + max_duration=scroll_cfg.get("max_duration", 120), + buffer=self.display_width + ) + + # Content state + self._content_items: List[Image.Image] = [] + self._vegas_content_items: List[Image.Image] = [] + self._is_prepared = False + + def prepare_scroll_content(self, cards: List[Image.Image], + separator: Image.Image = None): + """ + Prepare scroll content from pre-rendered cards. + + Args: + cards: List of PIL Images to scroll through + separator: Optional separator image between cards + """ + if not cards: + self._content_items = [] + self._vegas_content_items = [] + self._is_prepared = False + return + + self._content_items = list(cards) + self._vegas_content_items = list(cards) + + if self.scroll_helper: + # Build content items list with separators + content_with_seps = [] + for i, card in enumerate(cards): + content_with_seps.append(card) + if separator and i < len(cards) - 1: + content_with_seps.append(separator) + + self.scroll_helper.create_scrolling_image( + content_with_seps, + item_gap=4, + element_gap=2 + ) + + self._is_prepared = True + + def display_scroll_frame(self, force_clear: bool = False) -> bool: + """ + Display the next scroll frame. + + Args: + force_clear: Whether to force clear the display first + + Returns: + True if scroll is complete (looped), False otherwise + """ + if not self.scroll_helper or not self._is_prepared: + # Static fallback: show first card when scrolling unavailable + if self._content_items: + first = self._content_items[0] + if isinstance(first, Image.Image): + self.display_manager.image.paste(first, (0, 0)) + self.display_manager.update_display() + return True + + if force_clear: + self.scroll_helper.reset() + + self.scroll_helper.update_scroll_position() + visible = self.scroll_helper.get_visible_portion() + + if visible: + if isinstance(visible, Image.Image): + self.display_manager.image.paste(visible, (0, 0)) + else: + # Numpy array + pil_image = Image.fromarray(visible) + self.display_manager.image.paste(pil_image, (0, 0)) + self.display_manager.update_display() + + return self.scroll_helper.is_scroll_complete() + + def reset(self): + """Reset scroll position to beginning.""" + if self.scroll_helper: + self.scroll_helper.reset() + + def is_prepared(self) -> bool: + """Check if content has been prepared for scrolling.""" + return self._is_prepared + + def get_content_count(self) -> int: + """Get the number of content items.""" + return len(self._content_items) + + def is_scroll_complete(self) -> bool: + """Check if the scroll cycle has completed.""" + if not self.scroll_helper or not self._is_prepared: + return True + return self.scroll_helper.is_scroll_complete() + + def get_vegas_items(self) -> List[Image.Image]: + """Get the vegas content items for this display.""" + return self._vegas_content_items + + +class ScrollDisplayManager: + """ + Manages multiple ScrollDisplay instances, one per display mode. + """ + + def __init__(self, display_manager, config: Optional[Dict[str, Any]] = None, + custom_logger: Optional[logging.Logger] = None): + self.display_manager = display_manager + self.config = config or {} + self.logger = custom_logger or logger + + self._scroll_displays: Dict[str, ScrollDisplay] = {} + + def get_or_create(self, mode_key: str) -> ScrollDisplay: + """Get or create a ScrollDisplay for a given mode.""" + if mode_key not in self._scroll_displays: + self._scroll_displays[mode_key] = ScrollDisplay( + self.display_manager, + self.config, + self.logger + ) + return self._scroll_displays[mode_key] + + def prepare_and_display(self, mode_key: str, cards: List[Image.Image], + separator: Image.Image = None): + """Prepare scroll content for a mode.""" + sd = self.get_or_create(mode_key) + sd.prepare_scroll_content(cards, separator) + + def display_frame(self, mode_key: str, + force_clear: bool = False) -> bool: + """Display a scroll frame for a mode. Returns True if complete.""" + if mode_key not in self._scroll_displays: + return True + return self._scroll_displays[mode_key].display_scroll_frame( + force_clear) + + def reset_mode(self, mode_key: str): + """Reset scroll position for a mode.""" + if mode_key in self._scroll_displays: + self._scroll_displays[mode_key].reset() + + def get_all_vegas_content_items(self) -> List[Image.Image]: + """Collect all vegas content items across all modes.""" + items = [] + for sd in self._scroll_displays.values(): + items.extend(sd.get_vegas_items()) + return items + + def is_mode_prepared(self, mode_key: str) -> bool: + """Check if a mode has prepared content.""" + if mode_key not in self._scroll_displays: + return False + return self._scroll_displays[mode_key].is_prepared() + + def is_scroll_complete(self, mode_key: str) -> bool: + """Check if a mode's scroll cycle has completed.""" + if mode_key not in self._scroll_displays: + return True + return self._scroll_displays[mode_key].is_scroll_complete() + + def get_vegas_items_for_mode(self, mode_key: str) -> List[Image.Image]: + """Get vegas content items for a specific mode.""" + if mode_key not in self._scroll_displays: + return [] + return self._scroll_displays[mode_key].get_vegas_items() diff --git a/plugins/f1-scoreboard/team_colors.py b/plugins/f1-scoreboard/team_colors.py new file mode 100644 index 0000000..5bd9214 --- /dev/null +++ b/plugins/f1-scoreboard/team_colors.py @@ -0,0 +1,122 @@ +""" +F1 Team Color Definitions + +Official team colors sourced from OpenF1 API and Formula 1 branding. +Used for team color accent bars and visual identification on LED matrix displays. +""" + +# Official F1 team colors as RGB tuples +# Source: OpenF1 API driver endpoint team_colour field +F1_TEAM_COLORS = { + "mclaren": (244, 118, 0), # #F47600 - Papaya Orange + "red_bull": (71, 129, 215), # #4781D7 - Blue + "mercedes": (0, 215, 182), # #00D7B6 - Teal + "ferrari": (237, 17, 49), # #ED1131 - Red + "williams": (24, 104, 219), # #1868DB - Blue + "aston_martin": (34, 153, 113), # #229971 - British Racing Green + "alpine": (0, 161, 232), # #00A1E8 - Blue + "haas": (156, 159, 162), # #9C9FA2 - Silver/Grey + "sauber": (245, 5, 55), # #F50537 - Red (Audi transition) + "rb": (102, 152, 255), # #6698FF - Blue + "cadillac": (144, 144, 144), # #909090 - Grey (2026) +} + +# Aliases for different naming conventions across APIs +# Jolpi uses constructorId like "mclaren", ESPN may use different names +_CONSTRUCTOR_ALIASES = { + # Alternate names from different sources + "racing_bulls": "rb", + "rb_f1_team": "rb", + "visa_cash_app_rb": "rb", + "kick_sauber": "sauber", + "stake_f1_team": "sauber", + "audi": "sauber", + "haas_f1_team": "haas", + "alphatauri": "rb", + "alfa": "sauber", + "alfa_romeo": "sauber", + "toro_rosso": "rb", + "force_india": "aston_martin", + "racing_point": "aston_martin", + "renault": "alpine", + "red bull racing": "red_bull", + "red bull": "red_bull", + "aston martin": "aston_martin", + "rb f1 team": "rb", + "haas f1 team": "haas", + "alpine f1 team": "alpine", +} + +# Podium accent colors (metallic) +PODIUM_COLORS = { + 1: (255, 215, 0), # #FFD700 - Gold + 2: (192, 192, 192), # #C0C0C0 - Silver + 3: (205, 127, 50), # #CD7F32 - Bronze +} + +# F1 brand red color +F1_RED = (229, 0, 0) # #E50000 + + +def normalize_constructor_id(constructor_id): + """ + Normalize a constructor ID/name to our standard key format. + + Handles variations from different APIs (Jolpi, ESPN, OpenF1). + + Args: + constructor_id: Raw constructor identifier from any API + + Returns: + Normalized constructor key matching F1_TEAM_COLORS keys + """ + if not constructor_id: + return "" + + # Lowercase and strip whitespace + key = constructor_id.lower().strip() + + # Check direct match first + if key in F1_TEAM_COLORS: + return key + + # Check aliases + if key in _CONSTRUCTOR_ALIASES: + return _CONSTRUCTOR_ALIASES[key] + + # Try replacing spaces/hyphens with underscores + key_underscore = key.replace(" ", "_").replace("-", "_") + if key_underscore in F1_TEAM_COLORS: + return key_underscore + if key_underscore in _CONSTRUCTOR_ALIASES: + return _CONSTRUCTOR_ALIASES[key_underscore] + + return key + + +def get_team_color(constructor_id): + """ + Get the RGB color tuple for a constructor/team. + + Args: + constructor_id: Constructor identifier (any format) + + Returns: + RGB tuple (r, g, b) or default white if not found + """ + normalized = normalize_constructor_id(constructor_id) + return F1_TEAM_COLORS.get(normalized, (200, 200, 200)) + + +def get_constructor_logo_filename(constructor_id): + """ + Get the expected logo filename for a constructor. + + Args: + constructor_id: Constructor identifier (any format) + + Returns: + Logo filename like 'mclaren.png' + """ + normalized = normalize_constructor_id(constructor_id) + return f"{normalized}.png" if normalized else "unknown.png" diff --git a/plugins/football-scoreboard/config_schema.json b/plugins/football-scoreboard/config_schema.json index 644adff..591909b 100644 --- a/plugins/football-scoreboard/config_schema.json +++ b/plugins/football-scoreboard/config_schema.json @@ -67,6 +67,62 @@ "type": "boolean", "default": true, "description": "Show upcoming NFL games" + }, + "live_display_mode": { + "type": "string", + "enum": ["switch", "scroll"], + "default": "switch", + "description": "Display mode for live games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" + }, + "recent_display_mode": { + "type": "string", + "enum": ["switch", "scroll"], + "default": "switch", + "description": "Display mode for recent games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" + }, + "upcoming_display_mode": { + "type": "string", + "enum": ["switch", "scroll"], + "default": "switch", + "description": "Display mode for upcoming games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" + } + } + }, + "scroll_settings": { + "type": "object", + "title": "Scroll Settings", + "description": "Settings for scroll display mode (when display mode is set to 'scroll')", + "properties": { + "scroll_speed": { + "type": "number", + "default": 50.0, + "minimum": 1.0, + "maximum": 200.0, + "description": "Scroll speed in pixels per second (default: 50). Higher values scroll faster." + }, + "scroll_delay": { + "type": "number", + "default": 0.01, + "minimum": 0.001, + "maximum": 0.1, + "description": "Delay between scroll frames in seconds (default: 0.01 = 100 FPS). Lower values = smoother scrolling." + }, + "gap_between_games": { + "type": "integer", + "default": 48, + "minimum": 8, + "maximum": 128, + "description": "Gap in pixels between game cards when scrolling" + }, + "show_league_separators": { + "type": "boolean", + "default": true, + "description": "Show league icons between different leagues" + }, + "dynamic_duration": { + "type": "boolean", + "default": true, + "description": "Automatically calculate display duration based on content width" } } }, @@ -296,6 +352,62 @@ "type": "boolean", "default": true, "description": "Show upcoming NCAA FB games" + }, + "live_display_mode": { + "type": "string", + "enum": ["switch", "scroll"], + "default": "switch", + "description": "Display mode for live games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" + }, + "recent_display_mode": { + "type": "string", + "enum": ["switch", "scroll"], + "default": "switch", + "description": "Display mode for recent games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" + }, + "upcoming_display_mode": { + "type": "string", + "enum": ["switch", "scroll"], + "default": "switch", + "description": "Display mode for upcoming games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" + } + } + }, + "scroll_settings": { + "type": "object", + "title": "Scroll Settings", + "description": "Settings for scroll display mode (when display mode is set to 'scroll')", + "properties": { + "scroll_speed": { + "type": "number", + "default": 50.0, + "minimum": 1.0, + "maximum": 200.0, + "description": "Scroll speed in pixels per second (default: 50). Higher values scroll faster." + }, + "scroll_delay": { + "type": "number", + "default": 0.01, + "minimum": 0.001, + "maximum": 0.1, + "description": "Delay between scroll frames in seconds (default: 0.01 = 100 FPS). Lower values = smoother scrolling." + }, + "gap_between_games": { + "type": "integer", + "default": 48, + "minimum": 8, + "maximum": 128, + "description": "Gap in pixels between game cards when scrolling" + }, + "show_league_separators": { + "type": "boolean", + "default": true, + "description": "Show league icons between different leagues" + }, + "dynamic_duration": { + "type": "boolean", + "default": true, + "description": "Automatically calculate display duration based on content width" } } }, @@ -487,9 +599,8 @@ } } } - } - }, - "customization": { + }, + "customization": { "type": "object", "title": "Display Customization", "description": "Customize fonts for different text elements on the scoreboard", @@ -884,6 +995,7 @@ }, "x-propertyOrder": ["score_text", "period_text", "team_name", "status_text", "detail_text", "rank_text", "layout"], "additionalProperties": false + } }, "additionalProperties": false, "required": ["enabled"] diff --git a/plugins/football-scoreboard/manifest.json b/plugins/football-scoreboard/manifest.json index 01b3cfb..be94ac8 100644 --- a/plugins/football-scoreboard/manifest.json +++ b/plugins/football-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "football-scoreboard", "name": "Football Scoreboard", - "version": "2.1.1", + "version": "2.2.0", "author": "ChuckBuilds", "class_name": "FootballScoreboardPlugin", "description": "Standalone plugin for live, recent, and upcoming football games across NFL and NCAA Football with real-time scores, down/distance, possession, and game status. Now with organized nested config!", @@ -24,6 +24,11 @@ "ncaa_fb_live" ], "versions": [ + { + "version": "2.2.0", + "ledmatrix_min": "2.0.0", + "released": "2026-02-20" + }, { "version": "2.1.1", "ledmatrix_min": "2.0.0", @@ -210,7 +215,7 @@ "ledmatrix_min": "2.0.0" } ], - "last_updated": "2026-02-15", + "last_updated": "2026-02-20", "stars": 0, "downloads": 0, "verified": true,