From d8bbe302af664c4ae83045be617b1c9f0df9a7a9 Mon Sep 17 00:00:00 2001 From: Chuck Date: Tue, 17 Feb 2026 20:41:04 -0500 Subject: [PATCH 1/9] feat: add Formula 1 Scoreboard plugin Comprehensive F1 racing plugin with 8 display modes: - Driver standings, constructor standings - Recent race results with podium visualization - Upcoming race with countdown timer and circuit outline - Qualifying results (Q1/Q2/Q3 with times and gaps) - Free practice standings, sprint race results - Season calendar with session schedule Features: - Three API sources: ESPN (schedule), Jolpi (standings/results), OpenF1 (practice) - Favorite team and driver filtering - Team color accent bars and podium gold/silver/bronze - Bundled team logos (10) and circuit layout images (24) - Pre-season fallback to previous season data - Fully dynamic layout for any matrix size (64x32 to 192x48+) - Vegas scroll integration Co-Authored-By: Claude Opus 4.6 --- .../assets/f1/circuits/austin.png | Bin 0 -> 390 bytes .../assets/f1/circuits/bahrain.png | Bin 0 -> 385 bytes .../f1-scoreboard/assets/f1/circuits/baku.png | Bin 0 -> 332 bytes .../assets/f1/circuits/barcelona.png | Bin 0 -> 246 bytes .../assets/f1/circuits/budapest.png | Bin 0 -> 473 bytes .../assets/f1/circuits/interlagos.png | Bin 0 -> 959 bytes .../assets/f1/circuits/jeddah.png | Bin 0 -> 245 bytes .../assets/f1/circuits/las_vegas.png | Bin 0 -> 297 bytes .../assets/f1/circuits/losail.png | Bin 0 -> 797 bytes .../assets/f1/circuits/madrid.png | Bin 0 -> 296 bytes .../assets/f1/circuits/melbourne.png | Bin 0 -> 309 bytes .../assets/f1/circuits/mexico_city.png | Bin 0 -> 489 bytes .../assets/f1/circuits/miami.png | Bin 0 -> 214 bytes .../assets/f1/circuits/monaco.png | Bin 0 -> 349 bytes .../assets/f1/circuits/montreal.png | Bin 0 -> 237 bytes .../assets/f1/circuits/monza.png | Bin 0 -> 550 bytes .../assets/f1/circuits/shanghai.png | Bin 0 -> 331 bytes .../assets/f1/circuits/silverstone.png | Bin 0 -> 350 bytes .../assets/f1/circuits/singapore.png | Bin 0 -> 345 bytes .../f1-scoreboard/assets/f1/circuits/spa.png | Bin 0 -> 339 bytes .../assets/f1/circuits/spielberg.png | Bin 0 -> 276 bytes .../assets/f1/circuits/suzuka.png | Bin 0 -> 520 bytes .../assets/f1/circuits/yas_marina.png | Bin 0 -> 658 bytes .../assets/f1/circuits/zandvoort.png | Bin 0 -> 600 bytes plugins/f1-scoreboard/assets/f1/f1_logo.png | Bin 0 -> 443 bytes .../f1-scoreboard/assets/f1/teams/alpine.png | Bin 0 -> 628 bytes .../assets/f1/teams/aston_martin.png | Bin 0 -> 747 bytes .../f1-scoreboard/assets/f1/teams/ferrari.png | Bin 0 -> 1871 bytes .../f1-scoreboard/assets/f1/teams/haas.png | Bin 0 -> 1080 bytes .../f1-scoreboard/assets/f1/teams/mclaren.png | Bin 0 -> 586 bytes .../assets/f1/teams/mercedes.png | Bin 0 -> 1664 bytes plugins/f1-scoreboard/assets/f1/teams/rb.png | Bin 0 -> 1519 bytes .../assets/f1/teams/red_bull.png | Bin 0 -> 1042 bytes .../f1-scoreboard/assets/f1/teams/sauber.png | Bin 0 -> 1102 bytes .../assets/f1/teams/williams.png | Bin 0 -> 873 bytes plugins/f1-scoreboard/config_schema.json | 337 ++++++ plugins/f1-scoreboard/f1_data.py | 1075 +++++++++++++++++ plugins/f1-scoreboard/f1_renderer.py | 897 ++++++++++++++ plugins/f1-scoreboard/logo_downloader.py | 277 +++++ plugins/f1-scoreboard/manager.py | 521 ++++++++ plugins/f1-scoreboard/manifest.json | 34 + plugins/f1-scoreboard/requirements.txt | 2 + plugins/f1-scoreboard/scroll_display.py | 200 +++ plugins/f1-scoreboard/team_colors.py | 134 ++ 44 files changed, 3477 insertions(+) create mode 100644 plugins/f1-scoreboard/assets/f1/circuits/austin.png create mode 100644 plugins/f1-scoreboard/assets/f1/circuits/bahrain.png create mode 100644 plugins/f1-scoreboard/assets/f1/circuits/baku.png create mode 100644 plugins/f1-scoreboard/assets/f1/circuits/barcelona.png create mode 100644 plugins/f1-scoreboard/assets/f1/circuits/budapest.png create mode 100644 plugins/f1-scoreboard/assets/f1/circuits/interlagos.png create mode 100644 plugins/f1-scoreboard/assets/f1/circuits/jeddah.png create mode 100644 plugins/f1-scoreboard/assets/f1/circuits/las_vegas.png create mode 100644 plugins/f1-scoreboard/assets/f1/circuits/losail.png create mode 100644 plugins/f1-scoreboard/assets/f1/circuits/madrid.png create mode 100644 plugins/f1-scoreboard/assets/f1/circuits/melbourne.png create mode 100644 plugins/f1-scoreboard/assets/f1/circuits/mexico_city.png create mode 100644 plugins/f1-scoreboard/assets/f1/circuits/miami.png create mode 100644 plugins/f1-scoreboard/assets/f1/circuits/monaco.png create mode 100644 plugins/f1-scoreboard/assets/f1/circuits/montreal.png create mode 100644 plugins/f1-scoreboard/assets/f1/circuits/monza.png create mode 100644 plugins/f1-scoreboard/assets/f1/circuits/shanghai.png create mode 100644 plugins/f1-scoreboard/assets/f1/circuits/silverstone.png create mode 100644 plugins/f1-scoreboard/assets/f1/circuits/singapore.png create mode 100644 plugins/f1-scoreboard/assets/f1/circuits/spa.png create mode 100644 plugins/f1-scoreboard/assets/f1/circuits/spielberg.png create mode 100644 plugins/f1-scoreboard/assets/f1/circuits/suzuka.png create mode 100644 plugins/f1-scoreboard/assets/f1/circuits/yas_marina.png create mode 100644 plugins/f1-scoreboard/assets/f1/circuits/zandvoort.png create mode 100644 plugins/f1-scoreboard/assets/f1/f1_logo.png create mode 100644 plugins/f1-scoreboard/assets/f1/teams/alpine.png create mode 100644 plugins/f1-scoreboard/assets/f1/teams/aston_martin.png create mode 100644 plugins/f1-scoreboard/assets/f1/teams/ferrari.png create mode 100644 plugins/f1-scoreboard/assets/f1/teams/haas.png create mode 100644 plugins/f1-scoreboard/assets/f1/teams/mclaren.png create mode 100644 plugins/f1-scoreboard/assets/f1/teams/mercedes.png create mode 100644 plugins/f1-scoreboard/assets/f1/teams/rb.png create mode 100644 plugins/f1-scoreboard/assets/f1/teams/red_bull.png create mode 100644 plugins/f1-scoreboard/assets/f1/teams/sauber.png create mode 100644 plugins/f1-scoreboard/assets/f1/teams/williams.png create mode 100644 plugins/f1-scoreboard/config_schema.json create mode 100644 plugins/f1-scoreboard/f1_data.py create mode 100644 plugins/f1-scoreboard/f1_renderer.py create mode 100644 plugins/f1-scoreboard/logo_downloader.py create mode 100644 plugins/f1-scoreboard/manager.py create mode 100644 plugins/f1-scoreboard/manifest.json create mode 100644 plugins/f1-scoreboard/requirements.txt create mode 100644 plugins/f1-scoreboard/scroll_display.py create mode 100644 plugins/f1-scoreboard/team_colors.py 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 0000000000000000000000000000000000000000..3eddf88c299fd7e0d7785cc4ffecbca32e9936ac GIT binary patch literal 390 zcmV;10eSw3P)t>V&TsXv z3qk5`74W$~^zC863k6zRU9AFsl#-ux(bN;*DWxV#f^UGki0J0DWndb30UjgbI>k5- zZx?85YO73r-z`!Pfzg6r9}#h%ve3R!c~8lpo%|(I_bNA4m$GlGM_=igJB-O}iJX-| zIfDTEJ@caGHr$q}Gs6O!>S9rMR9AE3r4;U;H(8wr#(+jdT>N9rKVZit^L};FGDT+z zxofHX5ul+SsGUK{5pkvNq!@gkfqDu|W$pMKXaH}(b3|O`i_})xUi9OT)n@i!qDS8X kCV+9^6`0Ig^!Xr%FF+{Mdmw3?6#xJL07*qoM6N<$f}vKaV*mgE literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..e1fcdfd9dd8572a7f7790785d9db9577b8ba894c GIT binary patch literal 385 zcmV-{0e=38P)udhJx5H!D$4RhP;~|~eiLeW;k3RPcn(({B$ODbtjK{f zjjRRK3XLj|N)$oalaCApB2_u=X8193y9AYw28d|fS(>Zm%wj0=D`qa9sGY5@OexZ< z%KJ6w9_1AY26T(r@QiE}uR#uavZPt-R1bo;@MJeBB&#oz7+<~+Pn5hO8863Ih7{i* zvyIGH{jAA-)X`UTMpkA$v2u7d`Vz%N#LL~&4XTo2=PeK8Pv$bolf%t?R{m~7@^2bH f;iy{bO+CQ}_=p2z*9l`I00000NkvXXu0mjf07a=C literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..f0668cf003bb69b2c9e3e035740a44e9713265be GIT binary patch literal 332 zcmV-S0ki&zP)^hfqB)`nE$=qZ&Aty1b`he zX>hqvC7RpDl__NdKT!q%+PqRJXVTPxs`G0dXU8%-DxtEAWWA|bB~K4_rGPK|XlzJ! zCl!8j6Tz)IhV=Ah<==50I@Q!2u$6Gg z)Wb}7eqx6VKBTL6S_l=H2lCf#P|-h)C#Qyy-w-NF%Pd5hsk6*j`^@e~@4PQn)ypo< eR9ao+A|hXeAz#1Z-dPL)0000^2t#qz`(N3!8LG(Qmv*`tmK2=lkTfs}lGL0A0CY7^!WjVI+yPr_BS_V#jAZL+ z50rQU7l~B{CuY(BMfv31Z|I~}LiWyiubAA#A+$KwK2@(QSf zn*djRy7r-9yE|ceW){8frbd1GJ#?_{YzCb_o)sKOps5wd6!ZxBWj4Uk1KJ#LLQ{`fyZ$8tAUNr9O|Eu?KwZ z+EJQ~N#G-JERFFhb@REJ@_s P00000NkvXXu0mjfx(Cns literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..ac44a7270eabc3b6006a923641c977a1275ea2da GIT binary patch literal 959 zcmV;w13>(VP)8(0G>Pk}uQ2p`w9dI=;2bd~pQRq8>3xRRqu%wOuD?s2U;3i-3S19d0n7tl1?~cF zlT;aB=IprbUmED2vi)T8|3Xdw58EC}3iDqZdToDgyRAguVf)buf;^jiuc`wuY#y*Z zoc$}OG;j=f4LBg_%>oBqz&Vn-D+cV1YQ72>mbAWtO~0gv0H!31j{`@5aY6#X;L! zN(>g;9%$h34cqrlBEU^WizD#{)3uJc=67s=7%id9=?dFxCIMWl?VUx6l?{yc`jkxA zHj3VDO@IRtYmD5en^k5c>4xa+GbRck=`q`DY%c&l1EvB^l3uBZ@pIrl;DbEC(c~1% zfIdm*CHhMwtxWcO2}}zHb4M;n)~shn1z&7?hNRLPh^jprbAK(+OhF%9FR9b^R$#8A z>tcO#FqEHxVc>jV3|JPesx9rAwzmZ_*eU77Drt)tCxAoIj_w5BpJKZw8!Z9uBVZ%} zw+C1P{0f{4Tnn5fX*qBJ*pPv<-D!JAWMXEWSud#-_#(LFBH%LMxT12mNoujZANUQJ z3;Y0l415nf1MCAni7B!$Ir)~1yvF}~z}>)C!6CPg_z z*gqQlt*wl&*j11p8&liC0P002ovPDHLkV1m_Lvx)!! literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..ec69822234c835a5c5e4fdd073aaa1b72b79b204 GIT binary patch literal 245 zcmVbz#$HL zHoBXGUXE#noFtxS=_NoB5t7W(5Lu~r>B9{&1ISc)%$eu|Iozk%R>x^m00000NkvXXu0mjfMyy{! literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..49f75a4ef5f1a063afdcd865aef0e9c0db4ee6aa GIT binary patch literal 297 zcmV+^0oMMBP) z84rS5|22P5iHjlsH3IbpWCiyisfe>nto_@ph5)mrVZ5NX0(wvy{ERx)6PNw;Ff&`9 zFrbch2AFpi&LHA6Q?ET(16c(}9-togoqL!{iA&=nFZ*hpWTq!;ePsoAEAfnvfYBDY zN}L6ygUkqc>n1^T=q9kv#GQ8c#BbEnhfgx|BO0_ vsug7Ikq>8NMQ;vQ3We+m<}1VL%C5&BzB8;EJHRlo00000NkvXXu0mjfHwb@( literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..e1a73111538f58028b1d2dd71ca14fe08d6443b1 GIT binary patch literal 797 zcmV+&1LFLNP)0CDfZGQ?FP#F6p9qX3V-Dv)xOG0zNyOI>?Y51;{DlGKv{d=@Qxb510U7r&)K-mF3BYhZgp>O#r+coe`R zz~eUlcH7=l5%X#lsX+X#nB`On`=4#!w*5iLe!uOx;#7_8Ym14bGr$tC-u8(k&@^yO(rg8x2}$GaNL@~y zXJlDElQa#y4UC5%N5W0KCn=lTB0^)p^@@04`(h2uL`Jux(s`@Rceb6|Yb5KLqNJ3i zft*PCDcBsyU6>M?2ePT;|1QDyLtq;CCnU^8qCyHh1r{Z>{9@Y!z}DRPX!9c9wxoe# zFQ*K*W|O3E%AAHT*$b=*rRs;egc3ial4d$F7%s^}RTrX@7)QG?`YT!2>I^!Jv5Z*T z&NDP*zea`&ot&EvyMgtD?K!V-?p0`MPj2iOKIWHWl^4T?T$DU6Na_>BS*5hfZR0~>(NzzyJh+RoVS bPg|U4kZ$3I)N?rP00000NkvXXu0mjf8G3^& literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..1b64b5e194c6f5ec1ead1bed14e856c90799fe1d GIT binary patch literal 296 zcmV+@0oVSCP)T0{tv!uFLL;s)2}+E}hp4BH z*)y6mEwECik!YONLhNto>{KASnT_$vIV-UiX)7^4!ye2Ci(uXI#9238o=99pSkww0 zpEbhfh9#(oli+f*$%jJ{O(j!c6t7&P&$r-9k9E2Sci{v$?dS%?B^x~e0000S`w(^X+9$AOQ>TUqiB zah4+!fb^EIV+25}i+9nYOQa_ZBHlImm&eM=+7jKp6&K> zP0hqs(+YQA6|=6L&ckRA@A}}rad~G~^pFlLekuO$Ka~o;L%SE&Ivyr+00000NkvXX Hu0mjf#CeS) literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..6de785f2e3f7318142963b112d7bc13d0657eb54 GIT binary patch literal 489 zcmVX1^@s6L)dI(00057NklO+`V@iZt|N3tH}=sY_5d zgNsEG)F7eg$H8;!^>$u)%X?p6sPA^p^W%JYIOjPJ{MWxI^{V>A{d%v?sW;TYvRFa_ zt_GX|8o(@22TlOzfSyzg@DW%77J=6xgvT8LRKEjVA%qmfZ&I%Sy+9vu3OJT<`wA>a z_@xlS{l5ZK8$fqaxB~E(B78q^66i{}Z33TwwGhJDgOc4+Z3kEpsUzxb^@X~gx#!b) z0jdwn0?c8!9yy-08ZMVvy@R9fidt%D)tqPFpw)!s5Sc~Od zD4i_T2XmaRW&X!i^+B9mIt@1*&zvb5a7X=MS{v#s^{#rc4RDh&@BCpC4aZmiT>X** z|5<&i-c*MQAm{VZg5ISTN) z9dcSsLEc!}(70QVVXe{F%h0Hx#k2;{xvLGU>pj_tNiC5kyk@1&V|GcYFfxtIHK;^A z;z;AjM^sJh{GFVfCU(J3O>_ElSAiMP$Z)m`TC!fG{^}jPnNjrR4{nFMd@`V??2?(G Q*8l(j07*qoM6N<$f-A9F=>Px# literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..1eff105cdaf7f4fffae1481ada71e33492c4cc11 GIT binary patch literal 349 zcmV-j0iyniP)N4B`be3J7p zh8q@$Ht4h{X&d*olYk}RTjc!~KM=6PR8?)je`5SFW&?PZxZRh{QHb%5Xa-Q(84k5^ zI{PA8$x@Ok`6_1;5UmxYQx$?J5PDvk$12HYt(1L-ej%+weqoYOC8US3z{qcBh~le+ zqIro(GyHm2t`wUO0o7saSORN(lpH<#DqkL|UPa5Y_N8B6bZa|3SwBDeG4 zF6mTthPrIqE#ZoNNHHncb@3~aLgbzzR??L_2?=W7(}W~b^gG%H<;DXnu`53lXA)X* v%ER2%%F2Hwyb0J1x=B?mRCFW@caVDnyI300000NkvXXu0mjfoR^ta literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..1353a77c409ed895970139735535287a10792dbe GIT binary patch literal 237 zcmVL+j8GCPP?N!siZ)cf$(U{+9cXn5Pb3@QXOeNqQe6%mqfM4AL6SoXI8ap@~wi7jum nb@pHVv6SIGbzdQ6@gF2lpQYojAm3cu00000NkvXXu0mjfO5tBu literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..eb2a722491cdc644df94a3dff034c1344793c934 GIT binary patch literal 550 zcmV+>0@?kEP)WWmZ` zASE@ku@j=9!Tng=^EPkq-22`;=H5HLoA){A`EkC_InQ~{8ziV_ft|n>U}HoaO~bxgh5=3@T@^>QX>nGz9CfVXY<_W_%_#;(rgrh0ynLOt1T5cPVei8WZ`(yGGT zrxp&UBjN$|EXPuNS`B;#<{Q?|@-SD)k0N3^>+Vg^SFoQ*$9({(s*lx)4ifxQ+(CfN zB@Vlb6A^JG-RNJyKH!^r510qu13z=MUdz{g8rOh~$68=EBA%u3nneuMp|V|6kCY^^ zY^WD=@@S=K5E1u*9VIcgb&XveH5TgqMnemAxa>x&@6@SIGgHUY_qckDvhJ=90K0&# zz?+DeQu~2Dz+PZK@Cx_=%m813b?J4PO*OaySf9E>c3KsnADB)xS4%(1zk%1l$B3A0 oH=}Bn$r5*BpoM-#-u15FFE7&fP2fl#FaQ7m07*qoM6N<$f>vn+GXMYp literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..1cfdc1ee0cd322474a03d12a3bc2d3e09e81060a GIT binary patch literal 331 zcmV-R0kr;!P)2^7NMI}HYXltD2dO9#Q3%~u+|+Gl|*IL+>* zDW8C`N;`ovC9n1?K+M19>t;rHtjF_ChKAsKW;Zolw2aZd(|S}J9p>qCYfk2jH+wR1 z=tM_H)3gm9`-reOE!(IOplIJ%;@*AIsdgw3W+iJWs7s_3gJwoaM$%5ad#(a(*+F$5 zNh#TY|l1n#m(eo_Fy+3WXqFcL6OF(j d<(gpK--)g1ZM3Wphm^hx@>n)jp zA|lof5zVUTRa}sPmn0)=3A3%W_HJS(pst473bI<-?%N5Z?Yyd3omB>+tOGBxCW@X{ z;bUeTkIU3Z_NXHd5_dJ;AF!DJf4oC~2e{ZIkKCC1u5& zC1a53@IlHNblSyMf+5yA5?F#=V(Is-`~6~p{Qm8;r#U0u?h7EFhh!r5S;B0ys6bZ? zY6?{>v)c(s>b2jH!mYGAjJG4mM0dG+5IRwQhFG>q9MJ@`_@U<$aP!O~$!d<(N=Ua&p5?LLD?jO!kAX1DBeuw2WIHE089smFU07*qoM6N<$f`pfva{vGU literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..bf2e312289f645a7019420e3f151e55f860f0107 GIT binary patch literal 345 zcmV-f0jBh)7VJHdj~pqa1$z`k?xU=L0Q(WYke zR%Ib+lCX^O0PNTm(BA4d9lDC!wvoUQoCjjRtKUx^WIpiVdvh7=%IY3~qm!ALnX<}F zCAXr;AnMA=S2pDO9kluS|VDY@l^+ELi|%JPSsFR0L)B%zX>WlQN**PaZmQ07%?qo;#vuRm}|BKy`smsLD@?Qg1_FgW`yEYznw%Ff>7rwQGNPkPf^ l2bHa(;q(C6uR=49c>_Dut9?I+tpxx8002ovPDHLkV1gaFmW%)Z literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..bb6992d34fc65f145d61ebfdfde6990945054cef GIT binary patch literal 276 zcmV+v0qg#WP)~Yp~~LgLH|TU7snEonu+r1y*ErPAm)bB<#Uq-7nP$7T{Y&-b3ZWQ~5X?A*CCchAn;yR-10D|H7{PXXJ2{Xhfw0$AGL zfp3dLo2G66-j-`8Fq34sQ8!=#I+A4HYJ#ZO>qGh#bXa{|u}`Y!R~V~b?XPVG)x*Fc z;Ej3)=mVyKr|Ni;)0MF|0Fn$8`xejxJON((GlHL|Ce7r`srJ$G8t8IKE>ST-lzD_agE|SgdVkQ}C(|@m1z;)dcsAC=6 zKS~bOrmg{jhC0>8_X&8nN~UF63E&f8p;bNso042n2Ww!1l>jEW56lA3)QQD@^%^i! z%suLfx*)rjdb`rTs6JO`i#^eCa+m75ip$nk3t=Ph3Yb?PB{^CrVAoRT%KfbRpviZ+ z?&o#&H=_;!2Y`8CF3Fv`fts}pP#p)}10R4FNgh^w)4&d3G|9-{2K!CrjI7$n)FE|J zU1;9UwNj(N+$^hB)z`%WNb=7<8B1HNUI2CjpMj6S%RilyYoVX7f#nnH;p)l&0000< KMNUMnLSTX^(DESw literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..cd6c805ca92ef0d74c0cc194790459e2716df5be GIT binary patch literal 658 zcmV;D0&V??P)6o;Q9vSCKDvQ(x>iekfptdy|O_-IU#g|8aTLZp0UQiI7LQG-#YFp4OW4Mn~d zM2S&I)9j?iV5H`;a9{qt-uE^0n%CLf-?{gk``mN?x6>c~lc`(O3#w5;$TnamFcO#z zR0CUp>S2SbO~4pn0Wb>q49rN&_ab6$E_TE}KhlDg3e*5Sz}1MD32X&k0CUtO zx!gfls@4P3fziM$;3IGg7!whD)#X4$#Cf$NRsAg@YSoJoQCkqI_5+t9qAvMsfyuyk z;C)22{pDb5fIeU$@F^l13dQ|Vsk#Rk54;Cnr|EqWF>MgH(FODZHzQ(4vA7X=Ne6*N zz_@gco09*b1W5IwdORX(fPP?Qsl(+zq^bma49o*MfC<30VjDz60AC_vWAf)l#H2w% z)ko>24FL0j+Y!+o5lexM>gf{uRF#4hwbU(o)JFAAy0+Vyb=CDna<7yHP02?y`b~lL zmqqgSs}EJnfu79h`qawuW8UTYyVUDRuNo?_S>2rUku>gB8}j0o=PFiP)CWVZF0(oc;nX-Xtqe~gxnb9q3m)e>a|F&FKesfr?J_TN>2h{e6XasHm6Z3Mv zsw>I`=YZ$c2DMv##_wJ{>^3r^2U0gW3`|LLcPjz?W$NWL-d6`I$;wtbHjQUX_1fXE z>_h71XTv@LgnI6OK)cno>gtGS0(#YDl>)=hZ&|0ds(orBzrU~QhDw5;sO{=@^-!AI sSxKO3bJCqTT~SLvRXN?JeyJ4f2lpJRNtyqi{{R3007*qoM6N<$g84i=O8@`> literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..9483f6321ed3df032935dab3bf28217fd9ce0746 GIT binary patch literal 600 zcmV-e0;m0nP)^~`U}({wLd53zB;X*Z(1x}dUbdrFQ?iYYVqx)kBWL$y^~wyZ|<1- zv<9!pZKjD&b$ClUB_DuCc_$>b_PxMn;7ddtN&eHonBXlQ~xrS5j&i8bU5K&^=ejZmL|F~d{x^~lte^K zrpX7>)+_-7>igUaP(9aVY_cecR&0eP)RRC@6A>=2ItidBA_jqv>dlP!O{+m7uWesS mEjNH~KtJ%Vki!4ldbMA03N&wB&+2Ia0000qQ4 z4KLOIAKf$Wm)L`qGa8vTu-?#Mh;HbGGrlrTtNY!Sv$Ex^pZT=~`Rvsk@82(rKk@Xz zn>UA6Ru$~6-4V*~chxy>u~BKp|8Hz_-E60^KjZu`U8JusaNiD}QlHn$8@@6M&1PDx zaKL+~zt)sTosx2_v;X;;DhH0?u3WKMspUXO@geCyN&b=f6 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..9561c15600d9c5215325458e322830b47c9b7b22 GIT binary patch literal 628 zcmV-)0*n2LP)Px!|NsC0|Nnr{^E$Wfrr!7O`TcOp@{rc`V8rpi=lM{+ z@6YY~U;XFP0000AbW%=J0RR90Kp_8=)ewd3N`3$U0o_SNK~!ko?V8!PgCGn=1)Qo3 z4*&nJ*Va~1La3I!+?Det#q9Q&5L~4w7L`pava4;XV%4J&L~0(d_^lvQ`y@1iP)$)( zf>b5#4|x8BmKM+!f>;b-00S7n00uDNe*v_rS1B5Rtv?zl0J7iW*O~w@sy)6Ucm}Ta zG-L;u$&Vj6^fO>qhxjUYn!02B(4pS|#?~k4tN=ed-!eG?S_Sc^oJ+-Sj~e0*-3EB} z^m2+jbQjG+L zI3%Cyp~18BJ%&0I0jycrW5wHTb5Vz2fQ#G_+LLq$ z0&H0*)Al4C0swR5j$`qPFC7X2dlqV>c*VC4odJFe89Gl>WW3upT!+z4!vLU#+|y)0 z0MJ72$-x%cOyRv8_5s=K=_UA($6WTU-j%-wqd@rxIn zkgBo-z$@O!I*?e0aKO3qLd`SnP#h3+UTp6ruLFY4d()u^!0)_seAk3$00NNv*YC|2 zyVIH9^clba1~7mD3}68LU+(0G21;5$MSpOE{^$w$;h9Ah&GDb|8T$_|^FZW>=e<<` O0000EalYaqsP|(|IzA0;~b6PVF-e_K*Aje{JNA$eSlsHdYIE?#^84 z`kj&SXxp`@?DgBlYpUh8S683@+t&Tb?T-8oo&M_Vw{6_T*>C&2KUckFXo#+k;of5s zGlyB`0Ry|m0Y<)p1`zYV{DJe%_P4*)+%mIci1fEU{OQ}AkLZ}JTb@PdS6$)g$<58l zGfHnMMT!3Usr72+;hv1{tc4*RqKQ`&pIFU3yC8C9631#r<*;D2nGP>S6pf^J{#rjR zQ9aj2hYSD_Sh0I*(S)` z?CaV^X}cU}>{^;#I?L(C=91S*_k#|I7@a@!b9e7Dk<*%`f2%6CrzVdrbEsP_ZaRx zz{Muk6=>tQ=7OQfjEN^>|8EjI8F6Nv1AptLMZ!lIg}?MgaXsaG@xWvHujNVUd?Muz zkqdcxI6IgF1HulpG3EV_^F97yM*jcf7rt|CX6XDr{Z#Ug@KdI1Uj#=m7CG!mTEYE* zNn>)^xqnALJ7lI@5YbDxAQbt;VWUPN->Q7eg0$x{L9?=%igk56pV;mH>LUC+g_R-p zq*^K8U$iK!=iU?Z`~UH;)7j$Iv%b3?e`m+PS0|rNPmjv}ew%f1#m>FkuHE8)=W)+& UlkDvRV3K3-boFyt=akR{0NL+fkpKVy literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..968458311cddf092172988aa806679e03e1cb333 GIT binary patch literal 1871 zcmV-V2e9~wP)qBdH>GIK2N`z4m^K?5oSmCd7aPsZ%#h8isoB4QfqP_RV}}tMY7`r) ztE%&onVt?ArHySe>U*pWS7UfrJV0vMg%UR|C`Rr!d&=YKoBDgShH+8iK; zxgPcvA9kkt$LsH(gTdhE^Xre7oWhN}EL>I>m?}Wwl5_di_aBeHfBrMr5Q$M-c6Ox) zP=;NlT9JRApC4bo7sd#e=>cv>A5~AMPj`3s_w)I+kokOme|PsZol4B>fz(aa;+K2<}8I( zd|g15`+GJ3F380XlPHPcegTG`tIz(80qymIJzGGdkgFbsRt89_qOcplz(LP+Y+!L4 zRy=FK9bA;wuUwe<$pXm4#@2us*8`}Sr|ybSR56XPr3bL;0T6*D2k;;Vj?o6d1EDVf zlw9=Muz;@xUrbe(LEGLN0D9|qa{aMb2O&Vn*~Nm*mucuGSwziZA%h_x*r^DzqYK!u z9*Z#}+=B%8OTR!6Qk4+xZ2)lTQZ@vDX1j2hXA5d$X@FviE?}hsE<(ypFB4ssOG&v3 zY^MjC0(@JY_z0VZ4YCxp)(?o|6ed8zcToxOH36OyVPx~M6QqnE1Syq%;h&Onm1qJc zv@{^5;FIt)D$4=lB~V&GCwhQcq^zs}ATN#;^{%fj;skg-IUX|81H2*mJ_6=D>cM~2 z5HFcBLnDAa8RX z!*N$awY$tPUcLob(gHBnK4oqeF2VbHRt3lfW!~dog2_S#xiZEzfTdGc{i;dnfC4n=(n^@oniz1!GB07uXlVg`Xw<8y^AQ2eUKLBtz{n}fST=6dZK?B-2}o4z zo`I2~azDmavv$s`ZXS^&%GLqnBcMtd9z8SzjNBR@nao=!J44ypVA!-FHQB6g4H#gP zpI#x3MUb;OU?^Fg`b`02HOvNyvzm8h#d!(#>G*`kjazD#VuC>dOK||r)Ju3>3c$xH zfc@q@yT!&#H4Nnw0Yuk;ubP6^Mg2MIUDtrG0E@cC=2*=6aIhM0)zkDx2eR#N z*Eb!9Wo2&KCd|~r#J$K)%}yHdiJJ1SKzF=l=XWAwi7CX{HgCKS#TIFMsl!7}v%^(1 z*}AnHuYJu++#bYY{CPQ%@Zo!f2XVN4?|Q+YJRge&4qO&%j91hq`g?@`-W*>$$>6D( z*NA2miBeZkF=Ki4luUN&zFWB#6bY5G9_2@%pjgdzyjy#J?Bc8+CxW@IAFv25B}D+n zJKi5Gm~gsiZw2duQl#m8PVs_uvGWDROsIk$rRC8upiy3}Ks4FI70aZmbJNq*uXTsx z*AO7`_jE~AP0-S>c?(rV(9^Gb&9y`yH_1RxzxGAl1Z-{r4x4oM3h2&&a;`EwFx&f8 z<6%5VSx002ov JPDHLkV1mG*g4zH8 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..05c7e6ac91187f2819a7f5119c777f9176cae836 GIT binary patch literal 1080 zcmV-81jqY{P)bW59X;>&4y&>Lj3qR z<&;xS`48pmazo`#LAT4-hWhVoc~Gw3QT?CgVfl<9T+0{8HDdqrB@*_-p{`(TbX8OHJf?E0E69D>X)LgfNjeo_+4A(Qb0mU#f3^`CDZ**`^_y=%-u z@&HWsp$qd1eMhsOG@cvM0E~f}g?*I1(=S31lMDdOP`vc7*;?nB@+U2&1Kig_6ws#j z@a8Qf0qED7g#(PmwK>YMSfq>;0JHCVF!|}Np#~<_Q~(Z%w?u9cc(AA)$D{zXArF|; zFzS%NX61Bta{_?WYGjC_)WcJT6*LXt9`!!FALCtr-`YS03xLCe#=0Z|;FXW1(7Dla zDLUI0$FnKl+JGWp31U&A`VD|ui{?Cqmk))5b2ayJutcR)GzPMcgbGcPbJO|cL`4dK z(ZN6x(ws+l*^^Pn=}B#iaGDe4ah@$GYWu({89+IUZegclcIQykuNapyT>W(d|*Qg^WSu>6%w7jX9JDc z+f(WhYO)D#h#jG9^tUAU5ICRjs_>*yEWv_D)+MS$keh9U*e4YA~b1YX3NAvqb#xM;;kS4O?Zp5ai&@j^k(> zFdiQYN)d!;7<$3f@sS|-YCAv~${B0MKO883GE4b-^r79ON zc=-Q9=l}2T|Ns8}|D~n>H^l$nQSg6;!7B68O+XX4N`m}?8GyR(JN%y_dS17paUBB# z<4#W($B>MBZ)e?})~vwe+FH?C@bQ2BwyfJ5FNZ1$d_Gp0cixOs@x`LoX1u}n_tvG( z+P(Z)rpP5>L;b}wrp-0zZJM=>!7Y$?6XP32olOnjD5oXB^dATv{MUt17Ev$(R+-AYBv$>0BH zaUWZHTYn>qQ`U?8t#{9O&W>O3tDZrI<^74niUNQA8N_zH?_V4o{X+J**ok%WY6mX< zkz`-LUM92n`^AfK@9miwy1&cUm8ShHVEi#rZi2!Bt6zuC{8In#G=(1Y`fBF1v z>rzIk1z^;`_9FGK_)l- literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..13b9f31d96089c24c76b17a92f71cdca19ef1d2b GIT binary patch literal 1664 zcmV-`27md9P)nDj*g9Ta&apw zDV&^})z#F1fPY9xM#gv6{Qv*}3UpFVQvm<}|DQl0)sPT<*JiN*00rPlL_t(&-nE+9 zcH=4#MLDOsI|=h3^#7lFACRoU2oE>rB`dbXt_i3rtdvsf@AnseeSH1-r`q`W#)roD zk5WqgeBoo`>qjZ|jSr4*rT*cg~O7Z4b08^RTMB+nCDWw`0oRsSSFTjLi0Ki6S^*;41mivoBT-U5g>>-4#<{*2rmjDs~Q`@`8=m&Z9sidYv z)bcz)0w6P~?dc0EAg8l2H5o4ns_R~0%4EHZzW|VTF=Hy}%mqR9=MS0e_^kkB?0imY z=@V4fK7rH|E&!>wpJ-}9?GWw(zRlk?Xau;A)16qFTHt2$5D!QOdJ-8*=`kHj@fJ;2Lvr_6$yWe5mB?8Z;~)&lIr=@v_q(QX8= z0_lfIbAbTt#%UXs+Sn8S1OP4_0Kfs@Kmel|;j*~|v=wl@yDxzG@&U^%_Nd_=AQsTq z^4$P=@C4mS1$8t~Ag|93vjBK~Mha<pB>M7sbYMqY?!Ka1L(l0fDtlXBAn7?=^3`QT=?SHM$`Qk>Z=fO+&(rY$=; z)qa~!L)oCQ0ES{z)@e7-0Wc3OWabq>GqjOL*7pFhFc02zH^4NKgA}(-q;?9}v2jbf z0m9H#40SWb9#Fga({MY2z6S(>=D@Ie0wf7qHZMAk!brBM)RyZH z04Ajt*weJSsiw;{6T5)IYrs}t$W|bSSuR&(58w!Q0k~P*#GDeVtk{e7W^ErJuJ7cV zF-j)WQg7^*-krV{$oUSSDXABx#+6lZl5~#TDwEKw{wBe$lG|zEV`np+mMPZy*e!q4 z`td!%0=fU4jFX!(5Kth zhZF-$t|R@$7EriKnp`U%kcSW(BM&oYT^`@%!v&uS!r zH30~R%ugK-3J0f+iBAiEQlm;MlEMYRWLjHGent458dXyVk8rFa6;3K7H(R_p!dwOV zx(I~HERwZ(7hnPqsjtge1ZMLffgCyl5Dp7*I69xJ3BW`?Y)HY()Nc?Z0Fpb^o9cJpMC<7#`&(Re%G5=F zPuwf9J>xacIRs~)9?XdRw1j|UL1I-wWM@tKT3(2tdnA}#Nm zUucd4s7<0r2nlw{imQ3?f}&(H3J{$0*T%6+VwPXMpjgt~mXef|?yt;@^}+0}C|(D& zlvGy%U~G(~*r}z8llMNft2FCK=Jzg%0GOyry0D0ECTp!-FaRXZkI!!PXa{KNMLb_M zim&>qyyo#$v|X86rz}?h0000< KMNUMnLSTaI87#j5 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..8744b9fc33df2187e483c581ba4328b0c3c145b9 GIT binary patch literal 1519 zcmV_A%8)7kd5zwgxLNnQW|019+cPE!E?|NlV$|CANguDwHt000G2NklOv0A_wW9;A85B>T2`TAygeSgtID#tlDz^r9^_*H{3QUyZ2pOKe-w$}kfQ)R7B>;H52N0EPk;xb%hk&c)S`Y1$0h@%gV?chGj{vCv4$|3_~*;mJaT}hRj^&F6u*qv09G8q7X2i!Br+QSRHLa#>B=0s1sk-~9X9!#R>bOm5*#Yru$Z9Hd$A%KXSnU(k}%bS_4o>=5uIm#b^2te2(2Be7Fs5tW%RtaRlmSgxv-()wFBy%f&l@I`ry!Rrp|a0LuZ0ncU+4;P%`2R0^+O zdi5}l;+1+oYSHfvzgE*p7VW!|j#?x@8viNDH{K1UMjmKW3LgSWT^ej~P*P(48Y`Q4kQuV>XJBsK$?R`wu(<~^IYn!E_0IC4p3`#5CCT4 z0JH*kjct8)N+60}u{DNcj?Sn#PR8G28r74O%FY0w^O4>5B1{OA2Y;a`@mQeU{1h($pA?zrLQiu~%`iHPys;xNwG zKX~0c1x3op_qPAD_sM?^c>I<7;~x$DdlvBh{D&LQe|qxx$1{wv?`J;$`NtSz{{dhn V*hYp1iGlzC002ovPDHLkV1h?iexD}$J{LRLc|?3)irwsz2+y6g-1I2AkF0XP5$-~b$e18@KizybL00ji!~%V;gX?E$E0x!9GRn~vZp{QxL-?hvFI^WV&?_{HWC^zitnQi1s z4LmeifI{Fje-&nH03ie_>wjSBw~p2?(RT!+E(@R{j`vTadnGU$ElU{L2w|q%r!2a7 z3GjTAxiLuGi&|5z(PDPxKZV042AE~;DdcpM??F*D4(`>X!NKesAVZkSqvi||Bi-zj zdg$baOdB8*Ti#l+1dqe`VxIl05gPC^3UoU)gL`PIyiAE}(=XIm0Z-LI6AVB1VJ^+{ zs7Eun^E3IiE&r1@SUFwp=w>S`?P0)iK+vh^JRif%v zb(1(ONpaMiH%cJz#HMM65!T~W2oTFiQjt{bvKX!{t)!IP*|}7-&#pzX6R~?y1YQ71 zLhb+pj=I$>jI+&w@MI`8*wmI7u8-+D0hZY$%i6|J9B~K_wyW0w*$}~yz{E$Ljr(ov zHw&?ipbRi=OsR;AZ5|BRa!^V$HN>olgL!p}>tp)B5D3=PD*%TOeSzim?XEMP+Qx#S z8(U_3YjkV}zI&6D(yLFo2FzE0Cdv+4{-S~2*dgtr+nfH4cR{@flQxmzv04H8rZMaN zRsQp_rCuE_c++(BZx3E2efQB(@)!5j+s~$Ho=k_|E3d16f7S5^06YKykO7NNxCQ_K019+cPE!CN|NoDl zK-CbCQwQ!M000A-Nklb(_Y}odn%z8b^ z=rc%w5GDk5K3-^XT}}rw>UyM=bi5AsFSMF2o^YfUbtL3KtLjWnbpbbWsSCIeT3M+8 z)oEcF1C(@DXu}HvRM!lar(&X0MF6IkFelON1t>jleQZa|cK}w+4+y;(&?cB4Td@hi zEhMYw`2Z~;LsDk~R0El6vH;vcwj56f@UL<8@nfRLUt^!sFYcqSc`D$K{Y*fdKo;!h z0-^-6U_ToWBZPn#|Hc4!uQ>oXwF-ER&lup%_8TCK)^~u0eE`tQ0510eBwqyduonP! z3DCkKAmZN;0PO;REdpjW1xPgu&`<&}^)rbNv0Z>k6Ue*=HUlP2AoCtb0(^P}%TFB8 z!3MxRx;B_7K$HXYF@S<{fEPEDg&x`tfGz?MxqzsDgGrUl$Iu$!zLblF01hPqpaOs? z3+MuXE(_2BKx6}cdd=<}0)T~*VftKlNFmZmQ5_0pVoVl?9jppvnUHmL+olJU&pXfXI?bn>4`UCNT7I zR@#yQh@~w`76il+uea^fE`W&UreR+3^JAi?oyYWxg&vxu?^zWhNJ_h=09^{u&Hz-Q zCQK?8i<`iWn%x8zY645&h~u=bb&qpMF9ZWCBrxHa5Tf^$Wt8=Q?Scn_Qi6?ah4k=D z0*vx`f8UHDJ@iA{gp*94Q4B$w45Y+o($q>nP&&#Cbxu*7j~!#O^EZ>sf2A1m=4YkY z5ETXi)_=!2D1%0Zi~oV~KZ5jf&Rcs8T*_NVl~LST%8hY&kG*Cfqaa1q zBnuWCpBtYStsCl5LQrEkwP@BDyjPqm7D*=3ih@JT8G#S0DD%D1QimVU>4pWWr5%6G z%ae+NF9atsCR*$fjsR+;!?vK`(a)CSh55j0ULr58tobAi*%=ppbhJKtu>b5hiu8Dq zxJub)T%;Cep9I_JMmij0evnClHz57^V&kQk!r^~*$^Jhoj>l7gmGZv&zYZvTH}&cE z?S>;3Kb5h>$KQ5HG=^hJ@2DLIb$qQAE9O&<8apv>-I_v&r6P7%+7vo0{pu^Dg>^&d zGG>KNGM)q2eqdew$o*Cqa3bfrfCH)9gxhz#dP1(%+Hkvmy`OV9)p-5qcJ{V^18XJx ULH#;=cK`qY07*qoM6N<$f{5YllK=n! literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..95e6c12714eece2ff49e935e5cc8264ff42bbec5 GIT binary patch literal 873 zcmV-v1D5=WP)`HzB00PxXL_t(&-tC&vwxb{nMK!eO1rhB3 zf9zaar<#xiYKOJvK^_aTIUxsx+I7F>HjMp0FXM3UUgc-#87vKV1fD7;mz}Q2Xq~^g|FV(@Z2gm^aSZ$r$$u#p-57a@( z05hsLq5x3>3W1IJ9N-4C90g#jqAByKHgzVT z0L==x4>U+>f*=BB#R@o0NRo+DOqXlf3Pb=7plE^&AjgOZ&?=p_;Ye19lT{=r5uyOe z3f@{-f^32uAXx!&z%C&v`IYU-ycL4AvV^Gmvr|3qWdS=rXc5W)tQA-R@4mDtW?ox3 zD*z9Gq6s(vV+BFLhEMt$-03U(M1L`cMo^Cs1Mu_#q{%rtUZYJh2bk#tWXx28Q%u0W zn;Jzxpn`CWOx4Tp0}Dtr#8g7iBE$i9D*(;Hka^J^U#Ot+^?gwV;Jiu4v`CczrwSl> z77vnL?EwUKmoWnHa!s=0ZwI+FFp?H)kzUB7X%!f63^Y)nUem$OP)D-DHsX@-cbxE0j~i< zv4wPtSH9L<3KO_VNT=^j9oZ=b3GV#G>{J-uQv!T(^Xt_N+$8wxa{K~ZfD7>d1DgNk z-u$D1TU)@r{f8UvKRs#w@yu_zxB2|%w)Xu8@NHFL&#`c|00000NkvXXu0mjfu-A~| literal 0 HcmV?d00001 diff --git a/plugins/f1-scoreboard/config_schema.json b/plugins/f1-scoreboard/config_schema.json new file mode 100644 index 0000000..fe426ac --- /dev/null +++ b/plugins/f1-scoreboard/config_schema.json @@ -0,0 +1,337 @@ +{ + "$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", + "default": true, + "description": "Enable or disable the F1 scoreboard plugin" + }, + "display_duration": { + "type": "number", + "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", + "default": 3600, + "minimum": 60, + "maximum": 86400, + "description": "How often to fetch new data from APIs in seconds" + }, + "favorite_team": { + "type": "string", + "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", + "default": "", + "description": "Favorite driver code (e.g., 'NOR', 'VER', 'HAM', 'LEC', 'PIA', 'RUS'). 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", + "default": true, + "description": "Show driver standings display mode" + }, + "top_n": { + "type": "integer", + "default": 10, + "minimum": 3, + "maximum": 22, + "description": "Number of top drivers to show in standings" + }, + "always_show_favorite": { + "type": "boolean", + "default": true, + "description": "Always include favorite driver even if outside top N" + } + } + }, + "constructor_standings": { + "type": "object", + "title": "Constructor Standings", + "description": "Configuration for current constructor championship standings", + "properties": { + "enabled": { + "type": "boolean", + "default": true, + "description": "Show constructor standings display mode" + }, + "top_n": { + "type": "integer", + "default": 10, + "minimum": 3, + "maximum": 12, + "description": "Number of top constructors to show" + }, + "always_show_favorite": { + "type": "boolean", + "default": true, + "description": "Always include favorite team even if outside top N" + } + } + }, + "recent_races": { + "type": "object", + "title": "Recent Race Results", + "description": "Configuration for recent Grand Prix results", + "properties": { + "enabled": { + "type": "boolean", + "default": true, + "description": "Show recent race results display mode" + }, + "number_of_races": { + "type": "integer", + "default": 3, + "minimum": 1, + "maximum": 10, + "description": "Number of recent races to show" + }, + "top_finishers": { + "type": "integer", + "default": 3, + "minimum": 1, + "maximum": 20, + "description": "Number of top finishers to display per race" + }, + "always_show_favorite": { + "type": "boolean", + "default": true, + "description": "Always include favorite driver in results even if outside top N finishers" + } + } + }, + "upcoming": { + "type": "object", + "title": "Upcoming Race", + "description": "Configuration for the next race weekend display", + "properties": { + "enabled": { + "type": "boolean", + "default": true, + "description": "Show upcoming race display mode" + }, + "show_session_times": { + "type": "boolean", + "default": true, + "description": "Show qualifying and sprint session times in addition to race time" + }, + "countdown_enabled": { + "type": "boolean", + "default": true, + "description": "Show live countdown timer to next session" + } + } + }, + "qualifying": { + "type": "object", + "title": "Qualifying Results", + "description": "Configuration for most recent qualifying session breakdown", + "properties": { + "enabled": { + "type": "boolean", + "default": true, + "description": "Show qualifying results display mode" + }, + "show_q1": { + "type": "boolean", + "default": true, + "description": "Show Q1 results (all 20 drivers, bottom 5 eliminated)" + }, + "show_q2": { + "type": "boolean", + "default": true, + "description": "Show Q2 results (top 15 drivers, bottom 5 eliminated)" + }, + "show_q3": { + "type": "boolean", + "default": true, + "description": "Show Q3 results (top 10 drivers, determines pole position)" + }, + "show_gaps": { + "type": "boolean", + "default": true, + "description": "Show time differential to session leader" + } + } + }, + "practice": { + "type": "object", + "title": "Free Practice Results", + "description": "Configuration for free practice session standings", + "properties": { + "enabled": { + "type": "boolean", + "default": true, + "description": "Show free practice results display mode" + }, + "sessions_to_show": { + "type": "array", + "items": { + "type": "string", + "enum": ["FP1", "FP2", "FP3"] + }, + "default": ["FP1", "FP2", "FP3"], + "description": "Which free practice sessions to display" + }, + "top_n": { + "type": "integer", + "default": 10, + "minimum": 3, + "maximum": 22, + "description": "Number of top drivers to show per practice session" + } + } + }, + "sprint": { + "type": "object", + "title": "Sprint Race Results", + "description": "Configuration for sprint race results (not all race weekends have sprints)", + "properties": { + "enabled": { + "type": "boolean", + "default": true, + "description": "Show sprint race results display mode" + }, + "top_finishers": { + "type": "integer", + "default": 10, + "minimum": 3, + "maximum": 22, + "description": "Number of top finishers to display" + } + } + }, + "calendar": { + "type": "object", + "title": "Race Calendar", + "description": "Configuration for upcoming race schedule display", + "properties": { + "enabled": { + "type": "boolean", + "default": true, + "description": "Show race calendar display mode" + }, + "show_practice": { + "type": "boolean", + "default": false, + "description": "Include practice sessions in calendar" + }, + "show_qualifying": { + "type": "boolean", + "default": true, + "description": "Include qualifying sessions in calendar" + }, + "show_sprint": { + "type": "boolean", + "default": true, + "description": "Include sprint sessions in calendar" + }, + "max_events": { + "type": "integer", + "default": 5, + "minimum": 1, + "maximum": 24, + "description": "Maximum number of upcoming race weekends to show" + } + } + }, + "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", + "enum": ["PressStart2P-Regular.ttf", "4x6-font.ttf", "5by7.regular.ttf"], + "default": "PressStart2P-Regular.ttf" + }, + "font_size": { + "type": "integer", + "minimum": 4, + "maximum": 16, + "default": 8 + } + }, + "additionalProperties": false + }, + "position_text": { + "type": "object", + "title": "Position Numbers", + "description": "Font for standings position numbers and driver codes", + "properties": { + "font": { + "type": "string", + "enum": ["PressStart2P-Regular.ttf", "4x6-font.ttf", "5by7.regular.ttf"], + "default": "PressStart2P-Regular.ttf" + }, + "font_size": { + "type": "integer", + "minimum": 4, + "maximum": 16, + "default": 8 + } + }, + "additionalProperties": false + }, + "detail_text": { + "type": "object", + "title": "Detail Text", + "description": "Font for points, times, gaps, and other details", + "properties": { + "font": { + "type": "string", + "enum": ["PressStart2P-Regular.ttf", "4x6-font.ttf", "5by7.regular.ttf"], + "default": "4x6-font.ttf" + }, + "font_size": { + "type": "integer", + "minimum": 4, + "maximum": 16, + "default": 6 + } + }, + "additionalProperties": false + }, + "small_text": { + "type": "object", + "title": "Small Text", + "description": "Font for secondary information (circuit name, location)", + "properties": { + "font": { + "type": "string", + "enum": ["PressStart2P-Regular.ttf", "4x6-font.ttf", "5by7.regular.ttf"], + "default": "4x6-font.ttf" + }, + "font_size": { + "type": "integer", + "minimum": 4, + "maximum": 12, + "default": 6 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + }, + "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..b864973 --- /dev/null +++ b/plugins/f1-scoreboard/f1_data.py @@ -0,0 +1,1075 @@ +""" +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" + +# ESPN competition type IDs +ESPN_SESSION_TYPES = { + "1": "Practice", + "2": "Qualifying", + "3": "Race", + "5": "Sprint Shootout", + "6": "Sprint Race", +} + +# ESPN session type abbreviations +ESPN_SESSION_ABBRS = { + "FP1": "Practice", + "FP2": "Practice", + "FP3": "Practice", + "Qual": "Qualifying", + "Race": "Race", + "SS": "Sprint Shootout", + "SR": "Sprint Race", +} + + +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]] = {} + + # ─── 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, + **kwargs): + """Fall back to previous season when current has no data (pre-season).""" + current_year = datetime.now(timezone.utc).year + if season >= current_year and season > 2000: + logger.info("No %s data for %d, falling back to %d", + method_name, season, season - 1) + method = getattr(self, method_name) + return method(season=season - 1, **kwargs) + return [] if "standings" in method_name or "races" in method_name else None + + # ─── 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) + + standings = [] + try: + standings_lists = (data.get("MRData", {}) + .get("StandingsTable", {}) + .get("StandingsLists", [])) + if not standings_lists: + return self._fallback_previous_season( + "fetch_driver_standings", season) + + 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) + + 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) + + standings = [] + try: + standings_lists = (data.get("MRData", {}) + .get("StandingsTable", {}) + .get("StandingsLists", [])) + if not standings_lists: + return self._fallback_previous_season( + "fetch_constructor_standings", season) + + 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) + + 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 + + # First get the standings to find out what round we're at + standings_data = self._fetch_json( + f"{JOLPI_BASE}/{season}/driverStandings.json") + if not standings_data: + standings_data = self._fetch_json( + f"{JOLPI_BASE}/current/driverStandings.json") + + current_round = 0 + if standings_data: + try: + standings_lists = (standings_data.get("MRData", {}) + .get("StandingsTable", {}) + .get("StandingsLists", [])) + if standings_lists: + current_round = int(standings_lists[0].get("round", 0)) + except (KeyError, IndexError, ValueError): + pass + + if current_round == 0: + return self._fallback_previous_season( + "fetch_recent_races", season, 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 + for entry in results: + pos = entry["position"] + if pos > 15: + entry["eliminated_in"] = "Q1" + elif pos > 10: + 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.lower().replace(" ", "_")) + + 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.""" + 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: + return int(standings_lists[0].get("round", 0)) + 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: + entry["is_favorite"] = True + shown.append(entry) + 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: + if entry.get(driver_key, "").upper() not in shown_codes: + entry["is_favorite"] = True + shown.append(entry) + shown_codes.add( + entry.get(driver_key, "").upper()) + + return shown diff --git a/plugins/f1-scoreboard/f1_renderer.py b/plugins/f1-scoreboard/f1_renderer.py new file mode 100644 index 0000000..eb0b0f4 --- /dev/null +++ b/plugins/f1-scoreboard/f1_renderer.py @@ -0,0 +1,897 @@ +""" +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: Dict[str, Any] = None, + logo_loader: F1LogoLoader = None, + custom_logger: 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), + f"/var/home/chuck/Github/LEDMatrix/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] + + # ─── 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 + + # ─── Qualifying Results Card ─────────────────────────────────────── + + def render_qualifying_entry(self, entry: Dict, + session_label: str = "Q3") -> Image.Image: + """ + Render a single qualifying result entry. + + Layout: [accent bar] [pos] [code] [time] [gap] [team logo] + """ + 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 + + # Get the appropriate time for this Q session + session_key = session_label.lower() + time_str = entry.get(session_key, "") + gap_str = entry.get(f"{session_key}_gap", "") + + # Time + 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 + else: + # No time = eliminated or no run + 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 + if gap_str: + gap_y = 2 + self._get_text_height(draw, "1:00", + self.fonts["detail"]) + 2 + if gap_y + 6 < self.display_height: + self._draw_text_outlined(draw, (x_offset - time_width - 4 + if time_str else x_offset, + 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 + + 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. + + Layout: [accent bar] [pos] [code] [best lap] [gap] [team logo] + """ + 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 + + # Best lap time + best_lap = entry.get("best_lap", "") + if best_lap: + self._draw_text_outlined(draw, (x_offset, 2), best_lap, + self.fonts["detail"], + fill=(200, 200, 200)) + + # Gap + gap = entry.get("gap", "") + if gap: + gap_y = 2 + self._get_text_height( + draw, best_lap or "1:00", self.fonts["detail"]) + 2 + if gap_y + 6 < self.display_height: + self._draw_text_outlined(draw, (x_offset, gap_y), gap, + self.fonts["small"], + fill=(255, 200, 0)) + + # Team logo (right) + 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 + + 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. Same layout as qualifying entry.""" + 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 + + 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 + + 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/gap + time_str = entry.get("time", "") + if time_str: + self._draw_text_outlined(draw, (x_offset, 2), time_str, + self.fonts["detail"], + fill=(200, 200, 200)) + + # Team logo (right) + 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 + + 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") + 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: + 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: + 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") + + # 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 + 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") + # Truncate if too long + max_name_width = self.display_width - x_offset - 2 + while (self._get_text_width(draw, short_event, self.fonts["small"]) + > max_name_width and len(short_event) > 3): + short_event = short_event[:-1] + + 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..3ecf66f --- /dev/null +++ b/plugins/f1-scoreboard/logo_downloader.py @@ -0,0 +1,277 @@ +""" +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", + "madring": "madrid", + "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) -> Optional[Image.Image]: + """ + Get a team logo, resized to fit within max dimensions. + + 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, or None if unavailable + """ + 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) + if logo is not None: + 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) -> Optional[Image.Image]: + """Load a team logo from disk, with placeholder fallback.""" + 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: + pass + + # 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.""" + combined = f"{circuit_name} {city}".lower() + for key, filename in CIRCUIT_FILENAME_MAP.items(): + 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 + + for logo_file in self.teams_dir.glob("*.png"): + constructor_id = logo_file.stem + self.get_team_logo(constructor_id, max_height, max_width) + + logger.info("Preloaded %d team logos", len(self._cache)) diff --git a/plugins/f1-scoreboard/manager.py b/plugins/f1-scoreboard/manager.py new file mode 100644 index 0000000..f368d9e --- /dev/null +++ b/plugins/f1-scoreboard/manager.py @@ -0,0 +1,521 @@ +""" +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 + self.display_width = display_manager.matrix.width + self.display_height = display_manager.matrix.height + + # 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) + + # 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 + + try: + 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() + except Exception as e: + self.logger.error("Error updating F1 data: %s", 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()) + + # Add pole count to each driver + 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) + + for race in races: + results = race.get("results", []) + race["results"] = self.data_source.apply_favorite_filter( + results, top_finishers, + favorite_driver=self.favorite_driver, + always_show_favorite=always_show) + + self._recent_races = 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: + # Limit to top N + if result.get("results"): + result["results"] = result["results"][:top_n] + self._practice_results[fp_key] = result + + 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: + top_n = self.config.get("sprint", {}).get("top_finishers", 10) + if sprint.get("results"): + sprint["results"] = sprint["results"][:top_n] + self._sprint = sprint + + 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): + """ + Display the current F1 mode. + + Args: + force_clear: Whether to clear display first + display_mode: Specific mode to display (from manifest display_modes) + """ + if display_mode is None: + display_mode = self.modes[0] if self.modes else "f1_driver_standings" + + if display_mode == "f1_upcoming": + 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"): + self._display_scroll_mode(display_mode, force_clear) + else: + self.logger.warning("Unknown display mode: %s", display_mode) + + def _display_upcoming(self, force_clear: bool): + """Display the upcoming race card (static).""" + if not self._upcoming_race: + return + + # Re-calculate countdown for live updates + self._upcoming_race["countdown_seconds"] = None + now = datetime.now(timezone.utc) + next_session = None + + for session in self._upcoming_race.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: + next_session = session + break + except (ValueError, TypeError): + continue + + if next_session: + try: + session_dt = datetime.fromisoformat( + next_session["date"].replace("Z", "+00:00")) + self._upcoming_race["countdown_seconds"] = max( + 0, (session_dt - now).total_seconds()) + self._upcoming_race["next_session_type"] = next_session.get( + "type_abbr", "") + except (ValueError, TypeError): + pass + + card = self.renderer.render_upcoming_race(self._upcoming_race) + self.display_manager.image.paste(card, (0, 0)) + self.display_manager.update_display() + + def _display_scroll_mode(self, display_mode: str, + force_clear: bool): + """Display a scrolling mode.""" + 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", + } + + mode_key = mode_key_map.get(display_mode, display_mode) + + if not self._scroll_manager.is_mode_prepared(mode_key): + self._prepare_scroll_content() + + self._scroll_manager.display_frame(mode_key, force_clear) + + # ─── Vegas Mode ──────────────────────────────────────────────────── + + def get_vegas_content(self) -> Optional[List[Image.Image]]: + """Return all rendered cards for Vegas scroll mode.""" + images = [] + + # Ensure content is prepared + if not self._scroll_manager.get_all_vegas_content_items(): + self.update() + self._prepare_scroll_content() + + images = self._scroll_manager.get_all_vegas_content_items() + + # 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 + + # ─── Lifecycle ───────────────────────────────────────────────────── + + 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) + + # Force data refresh + self._last_update = 0 + + def cleanup(self): + """Clean up resources.""" + self.logger.info("F1 Scoreboard cleanup") + self.logo_loader.clear_cache() + super().cleanup() diff --git a/plugins/f1-scoreboard/manifest.json b/plugins/f1-scoreboard/manifest.json new file mode 100644 index 0000000..49a6081 --- /dev/null +++ b/plugins/f1-scoreboard/manifest.json @@ -0,0 +1,34 @@ +{ + "id": "f1-scoreboard", + "name": "F1 Scoreboard", + "version": "1.0.0", + "author": "ChuckBuilds", + "class_name": "F1ScoreboardPlugin", + "entry_point": "manager.py", + "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.0.0", + "ledmatrix_min": "2.0.0", + "released": "2026-02-17" + } + ], + "last_updated": "2026-02-17", + "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..1fc98c7 --- /dev/null +++ b/plugins/f1-scoreboard/requirements.txt @@ -0,0 +1,2 @@ +requests>=2.25.0 +Pillow>=9.0.0 diff --git a/plugins/f1-scoreboard/scroll_display.py b/plugins/f1-scoreboard/scroll_display.py new file mode 100644 index 0000000..37199b3 --- /dev/null +++ b/plugins/f1-scoreboard/scroll_display.py @@ -0,0 +1,200 @@ +""" +Scroll Display Handler for F1 Scoreboard Plugin + +Implements horizontal scrolling of F1 standings, results, qualifying, +practice, sprint, and calendar cards using ScrollHelper. +""" + +import logging +import time +from typing import Any, Dict, List, Optional + +from PIL import Image + +try: + from src.common.scroll_helper import ScrollHelper +except ImportError: + ScrollHelper = None + +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: Dict[str, Any] = None, + custom_logger: 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 + self.scroll_helper = None + if ScrollHelper: + self.scroll_helper = ScrollHelper( + self.display_width, + self.display_height, + self.logger + ) + self.scroll_helper.set_frame_based_scrolling(True) + self.scroll_helper.set_scroll_speed(1) + self.scroll_helper.set_scroll_delay(0.03) + self.scroll_helper.set_dynamic_duration_settings( + enabled=True, + min_duration=15, + 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 + self._prepare_time = 0 + + 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 + self._prepare_time = time.time() + + 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: + 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) + + +class ScrollDisplayManager: + """ + Manages multiple ScrollDisplay instances, one per display mode. + """ + + def __init__(self, display_manager, config: Dict[str, Any] = None, + custom_logger: 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._vegas_content_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() diff --git a/plugins/f1-scoreboard/team_colors.py b/plugins/f1-scoreboard/team_colors.py new file mode 100644 index 0000000..1974a06 --- /dev/null +++ b/plugins/f1-scoreboard/team_colors.py @@ -0,0 +1,134 @@ +""" +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 = { + # Jolpi/Ergast constructor IDs + "red_bull": "red_bull", + "mclaren": "mclaren", + "mercedes": "mercedes", + "ferrari": "ferrari", + "williams": "williams", + "aston_martin": "aston_martin", + "alpine": "alpine", + "haas": "haas", + "sauber": "sauber", + "rb": "rb", + "cadillac": "cadillac", + # 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" From 25dae33338d86b1dd2f32ba0938a198d035dd53c Mon Sep 17 00:00:00 2001 From: Chuck Date: Tue, 17 Feb 2026 20:41:41 -0500 Subject: [PATCH 2/9] chore: add f1-scoreboard to plugin registry Co-Authored-By: Claude Opus 4.6 --- plugins.json | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/plugins.json b/plugins.json index d502e48..6a8d142 100644 --- a/plugins.json +++ b/plugins.json @@ -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-17", + "verified": true, + "screenshot": "", + "latest_version": "1.0.0" + }, { "id": "web-ui-info", "name": "Web UI Info", From 4ee3b708efc01cba99be678741f93851d2483a89 Mon Sep 17 00:00:00 2001 From: Chuck Date: Wed, 18 Feb 2026 09:03:24 -0500 Subject: [PATCH 3/9] fix(f1-scoreboard): address code review findings Inline fixes: - Fix cached dict mutation in apply_favorite_filter (shallow copy entries) - Return [] from except block in standings fetch to prevent caching errors - Remove hardcoded dev path from font loading - Add _truncate_text helper and enforce text_max_x in render_upcoming_race - Remove dead 'madring' key from CIRCUIT_FILENAME_MAP - Pass force_clear through in _display_upcoming - Bump package pins (requests>=2.32.0, Pillow>=10.4.0) Nitpick fixes: - Add additionalProperties: false to all 8 mode objects in config schema - Use explicit default_return param in _fallback_previous_season - Extract _get_latest_round helper, remove duplicated standings fetch - Fix Optional type hints in f1_renderer, scroll_display - Add debug logging for swallowed exceptions in logo_downloader - Remove blocking update()/prepare from get_vegas_content - Remove unused _prepare_time field and time import from scroll_display - Remove redundant self-referencing aliases from team_colors Co-Authored-By: Claude Opus 4.6 --- plugins/f1-scoreboard/config_schema.json | 24 +++++++---- plugins/f1-scoreboard/f1_data.py | 51 ++++++++++-------------- plugins/f1-scoreboard/f1_renderer.py | 28 +++++++++++-- plugins/f1-scoreboard/logo_downloader.py | 6 +-- plugins/f1-scoreboard/manager.py | 15 +++---- plugins/f1-scoreboard/requirements.txt | 4 +- plugins/f1-scoreboard/scroll_display.py | 11 ++--- plugins/f1-scoreboard/team_colors.py | 12 ------ 8 files changed, 77 insertions(+), 74 deletions(-) diff --git a/plugins/f1-scoreboard/config_schema.json b/plugins/f1-scoreboard/config_schema.json index fe426ac..f8c9a59 100644 --- a/plugins/f1-scoreboard/config_schema.json +++ b/plugins/f1-scoreboard/config_schema.json @@ -55,7 +55,8 @@ "default": true, "description": "Always include favorite driver even if outside top N" } - } + }, + "additionalProperties": false }, "constructor_standings": { "type": "object", @@ -79,7 +80,8 @@ "default": true, "description": "Always include favorite team even if outside top N" } - } + }, + "additionalProperties": false }, "recent_races": { "type": "object", @@ -110,7 +112,8 @@ "default": true, "description": "Always include favorite driver in results even if outside top N finishers" } - } + }, + "additionalProperties": false }, "upcoming": { "type": "object", @@ -132,7 +135,8 @@ "default": true, "description": "Show live countdown timer to next session" } - } + }, + "additionalProperties": false }, "qualifying": { "type": "object", @@ -164,7 +168,8 @@ "default": true, "description": "Show time differential to session leader" } - } + }, + "additionalProperties": false }, "practice": { "type": "object", @@ -192,7 +197,8 @@ "maximum": 22, "description": "Number of top drivers to show per practice session" } - } + }, + "additionalProperties": false }, "sprint": { "type": "object", @@ -211,7 +217,8 @@ "maximum": 22, "description": "Number of top finishers to display" } - } + }, + "additionalProperties": false }, "calendar": { "type": "object", @@ -245,7 +252,8 @@ "maximum": 24, "description": "Maximum number of upcoming race weekends to show" } - } + }, + "additionalProperties": false }, "customization": { "type": "object", diff --git a/plugins/f1-scoreboard/f1_data.py b/plugins/f1-scoreboard/f1_data.py index b864973..4b71458 100644 --- a/plugins/f1-scoreboard/f1_data.py +++ b/plugins/f1-scoreboard/f1_data.py @@ -126,7 +126,7 @@ def _fetch_json(self, url: str, params: Dict = None, return None def _fallback_previous_season(self, method_name: str, season: int, - **kwargs): + default_return=None, **kwargs): """Fall back to previous season when current has no data (pre-season).""" current_year = datetime.now(timezone.utc).year if season >= current_year and season > 2000: @@ -134,7 +134,7 @@ def _fallback_previous_season(self, method_name: str, season: int, method_name, season, season - 1) method = getattr(self, method_name) return method(season=season - 1, **kwargs) - return [] if "standings" in method_name or "races" in method_name else None + return default_return # ─── ESPN: Schedule & Calendar ───────────────────────────────────── @@ -369,7 +369,7 @@ def fetch_driver_standings(self, season: int = None) -> List[Dict]: f"{JOLPI_BASE}/current/driverStandings.json") if not data: return self._fallback_previous_season( - "fetch_driver_standings", season) + "fetch_driver_standings", season, default_return=[]) standings = [] try: @@ -378,7 +378,7 @@ def fetch_driver_standings(self, season: int = None) -> List[Dict]: .get("StandingsLists", [])) if not standings_lists: return self._fallback_previous_season( - "fetch_driver_standings", season) + "fetch_driver_standings", season, default_return=[]) for entry in standings_lists[0].get("DriverStandings", []): driver = entry.get("Driver", {}) @@ -401,6 +401,7 @@ def fetch_driver_standings(self, season: int = None) -> List[Dict]: }) except (KeyError, IndexError, ValueError) as e: logger.error("Error parsing driver standings: %s", e) + return [] self._set_cached(cache_key, standings) return standings @@ -429,7 +430,7 @@ def fetch_constructor_standings(self, season: int = None) -> List[Dict]: f"{JOLPI_BASE}/current/constructorStandings.json") if not data: return self._fallback_previous_season( - "fetch_constructor_standings", season) + "fetch_constructor_standings", season, default_return=[]) standings = [] try: @@ -438,7 +439,7 @@ def fetch_constructor_standings(self, season: int = None) -> List[Dict]: .get("StandingsLists", [])) if not standings_lists: return self._fallback_previous_season( - "fetch_constructor_standings", season) + "fetch_constructor_standings", season, default_return=[]) for entry in standings_lists[0].get("ConstructorStandings", []): constructor = entry.get("Constructor", {}) @@ -454,6 +455,7 @@ def fetch_constructor_standings(self, season: int = None) -> List[Dict]: }) except (KeyError, IndexError, ValueError) as e: logger.error("Error parsing constructor standings: %s", e) + return [] self._set_cached(cache_key, standings) return standings @@ -551,27 +553,11 @@ def fetch_recent_races(self, season: int = None, if cached is not None: return cached - # First get the standings to find out what round we're at - standings_data = self._fetch_json( - f"{JOLPI_BASE}/{season}/driverStandings.json") - if not standings_data: - standings_data = self._fetch_json( - f"{JOLPI_BASE}/current/driverStandings.json") - - current_round = 0 - if standings_data: - try: - standings_lists = (standings_data.get("MRData", {}) - .get("StandingsTable", {}) - .get("StandingsLists", [])) - if standings_lists: - current_round = int(standings_lists[0].get("round", 0)) - except (KeyError, IndexError, ValueError): - pass + current_round = self._get_latest_round(season) if current_round == 0: return self._fallback_previous_season( - "fetch_recent_races", season, count=count) + "fetch_recent_races", season, default_return=[], count=count) races = [] for round_num in range(current_round, max(0, current_round - count), -1): @@ -1056,8 +1042,10 @@ def apply_favorite_filter(self, entries: List[Dict], top_n: int, if fav_upper not in shown_codes: for entry in entries[top_n:]: if entry.get(driver_key, "").upper() == fav_upper: - entry["is_favorite"] = True - shown.append(entry) + 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 @@ -1066,10 +1054,11 @@ def apply_favorite_filter(self, entries: List[Dict], top_n: int, if fav_team not in shown_teams: for entry in entries[top_n:]: if entry.get(team_key, "") == fav_team: - if entry.get(driver_key, "").upper() not in shown_codes: - entry["is_favorite"] = True - shown.append(entry) - shown_codes.add( - entry.get(driver_key, "").upper()) + 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 index eb0b0f4..fa1f329 100644 --- a/plugins/f1-scoreboard/f1_renderer.py +++ b/plugins/f1-scoreboard/f1_renderer.py @@ -30,9 +30,9 @@ class F1Renderer: """Renders F1 display cards as PIL Images.""" def __init__(self, display_width: int, display_height: int, - config: Dict[str, Any] = None, - logo_loader: F1LogoLoader = None, - custom_logger: logging.Logger = None): + 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 {} @@ -84,7 +84,6 @@ def _load_font(self, font_name: str, f"assets/fonts/{font_name}", str(Path(__file__).parent.parent.parent / "assets" / "fonts" / font_name), - f"/var/home/chuck/Github/LEDMatrix/assets/fonts/{font_name}", ] for path in font_paths: @@ -120,6 +119,17 @@ def _get_text_height(self, draw: ImageDraw.ImageDraw, text: str, 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, @@ -697,6 +707,8 @@ def render_upcoming_race(self, race: Dict) -> Image.Image: # 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) @@ -709,6 +721,8 @@ def render_upcoming_race(self, race: Dict) -> Image.Image: # 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)) @@ -724,6 +738,8 @@ def render_upcoming_race(self, race: Dict) -> Image.Image: 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)) @@ -745,6 +761,8 @@ def render_upcoming_race(self, race: Dict) -> Image.Image: "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)) @@ -775,6 +793,8 @@ def render_upcoming_race(self, race: Dict) -> Image.Image: 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)) diff --git a/plugins/f1-scoreboard/logo_downloader.py b/plugins/f1-scoreboard/logo_downloader.py index 3ecf66f..20051d3 100644 --- a/plugins/f1-scoreboard/logo_downloader.py +++ b/plugins/f1-scoreboard/logo_downloader.py @@ -46,7 +46,6 @@ "budapest": "budapest", "zandvoort": "zandvoort", "monza": "monza", - "madring": "madrid", "madrid": "madrid", "baku": "baku", "marina bay": "singapore", @@ -168,8 +167,9 @@ def _load_logo(self, constructor_id: str, max_width: int, img.thumbnail((max_width, max_height), Image.Resampling.LANCZOS) return img - except Exception: - pass + 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) diff --git a/plugins/f1-scoreboard/manager.py b/plugins/f1-scoreboard/manager.py index f368d9e..2101ce8 100644 --- a/plugins/f1-scoreboard/manager.py +++ b/plugins/f1-scoreboard/manager.py @@ -413,6 +413,14 @@ def _display_upcoming(self, force_clear: bool): if not self._upcoming_race: return + if force_clear: + self.display_manager.image.paste( + Image.new("RGB", + (self.display_manager.matrix.width, + self.display_manager.matrix.height), + (0, 0, 0)), + (0, 0)) + # Re-calculate countdown for live updates self._upcoming_race["countdown_seconds"] = None now = datetime.now(timezone.utc) @@ -468,13 +476,6 @@ def _display_scroll_mode(self, display_mode: str, def get_vegas_content(self) -> Optional[List[Image.Image]]: """Return all rendered cards for Vegas scroll mode.""" - images = [] - - # Ensure content is prepared - if not self._scroll_manager.get_all_vegas_content_items(): - self.update() - self._prepare_scroll_content() - images = self._scroll_manager.get_all_vegas_content_items() # Add upcoming race card if available diff --git a/plugins/f1-scoreboard/requirements.txt b/plugins/f1-scoreboard/requirements.txt index 1fc98c7..1773040 100644 --- a/plugins/f1-scoreboard/requirements.txt +++ b/plugins/f1-scoreboard/requirements.txt @@ -1,2 +1,2 @@ -requests>=2.25.0 -Pillow>=9.0.0 +requests>=2.32.0 +Pillow>=10.4.0 diff --git a/plugins/f1-scoreboard/scroll_display.py b/plugins/f1-scoreboard/scroll_display.py index 37199b3..c6290f9 100644 --- a/plugins/f1-scoreboard/scroll_display.py +++ b/plugins/f1-scoreboard/scroll_display.py @@ -6,7 +6,6 @@ """ import logging -import time from typing import Any, Dict, List, Optional from PIL import Image @@ -27,8 +26,8 @@ class ScrollDisplay: and manages scroll state. """ - def __init__(self, display_manager, config: Dict[str, Any] = None, - custom_logger: logging.Logger = None): + 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 @@ -63,7 +62,6 @@ def __init__(self, display_manager, config: Dict[str, Any] = None, self._content_items: List[Image.Image] = [] self._vegas_content_items: List[Image.Image] = [] self._is_prepared = False - self._prepare_time = 0 def prepare_scroll_content(self, cards: List[Image.Image], separator: Image.Image = None): @@ -98,7 +96,6 @@ def prepare_scroll_content(self, cards: List[Image.Image], ) self._is_prepared = True - self._prepare_time = time.time() def display_scroll_frame(self, force_clear: bool = False) -> bool: """ @@ -149,8 +146,8 @@ class ScrollDisplayManager: Manages multiple ScrollDisplay instances, one per display mode. """ - def __init__(self, display_manager, config: Dict[str, Any] = None, - custom_logger: logging.Logger = None): + 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 diff --git a/plugins/f1-scoreboard/team_colors.py b/plugins/f1-scoreboard/team_colors.py index 1974a06..5bd9214 100644 --- a/plugins/f1-scoreboard/team_colors.py +++ b/plugins/f1-scoreboard/team_colors.py @@ -24,18 +24,6 @@ # Aliases for different naming conventions across APIs # Jolpi uses constructorId like "mclaren", ESPN may use different names _CONSTRUCTOR_ALIASES = { - # Jolpi/Ergast constructor IDs - "red_bull": "red_bull", - "mclaren": "mclaren", - "mercedes": "mercedes", - "ferrari": "ferrari", - "williams": "williams", - "aston_martin": "aston_martin", - "alpine": "alpine", - "haas": "haas", - "sauber": "sauber", - "rb": "rb", - "cadillac": "cadillac", # Alternate names from different sources "racing_bulls": "rb", "rb_f1_team": "rb", From fbbe5c90b7ea2659df76dfaeb8edf7797c6fd690 Mon Sep 17 00:00:00 2001 From: Chuck Date: Wed, 18 Feb 2026 09:37:38 -0500 Subject: [PATCH 4/9] fix(f1-scoreboard): address round 2 code review findings Inline fixes: - Compute qualifying elimination cutoffs from result count instead of hardcoding 15/10 thresholds for a 20-car grid - Shallow copy driver standings entries before adding poles key - Shallow copy recent race dicts before mutating results - Shallow copy practice/sprint results before slicing - Bump Pillow to >=12.1.1 for CVE-2026-25990 Nitpick fixes: - Guard getattr in _fallback_previous_season against misspelled methods - Memoize _get_latest_round to avoid redundant HTTP requests across calculate_pole_positions, fetch_recent_races, fetch_qualifying, etc. - Initialize time_width=0 before conditional block in qualifying render - Extract _render_driver_row helper from render_qualifying_entry, render_practice_entry, and render_sprint_entry (-130 lines) - Fix get_team_logo return type to Image.Image (always returns image) - Sort CIRCUIT_FILENAME_MAP keys longest-first in _resolve_circuit_filename - Add get_vegas_items() public accessor on ScrollDisplay - Log warning on ScrollHelper import failure Co-Authored-By: Claude Opus 4.6 --- plugins/f1-scoreboard/f1_data.py | 30 ++++- plugins/f1-scoreboard/f1_renderer.py | 151 ++++++----------------- plugins/f1-scoreboard/logo_downloader.py | 21 +++- plugins/f1-scoreboard/manager.py | 30 +++-- plugins/f1-scoreboard/requirements.txt | 2 +- plugins/f1-scoreboard/scroll_display.py | 11 +- 6 files changed, 104 insertions(+), 141 deletions(-) diff --git a/plugins/f1-scoreboard/f1_data.py b/plugins/f1-scoreboard/f1_data.py index 4b71458..f58c0a3 100644 --- a/plugins/f1-scoreboard/f1_data.py +++ b/plugins/f1-scoreboard/f1_data.py @@ -91,6 +91,9 @@ def __init__(self, cache_manager=None, config: Dict[str, Any] = None): # 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]: @@ -130,9 +133,12 @@ def _fallback_previous_season(self, method_name: str, season: int, """Fall back to previous season when current has no data (pre-season).""" 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 logger.info("No %s data for %d, falling back to %d", method_name, season, season - 1) - method = getattr(self, method_name) return method(season=season - 1, **kwargs) return default_return @@ -647,12 +653,15 @@ def fetch_qualifying(self, season: int = None, else: entry[f"{session_key}_gap"] = "" - # Determine elimination status + # 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 > 15: + if pos > q1_cutoff: entry["eliminated_in"] = "Q1" - elif pos > 10: + elif pos > q2_cutoff: entry["eliminated_in"] = "Q2" else: entry["eliminated_in"] = "" @@ -961,7 +970,14 @@ def fetch_practice_results(self, session_name: str = "Practice 3", # ─── Helpers ─────────────────────────────────────────────────────── def _get_latest_round(self, season: int) -> int: - """Get the latest completed round number for a season.""" + """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: @@ -975,7 +991,9 @@ def _get_latest_round(self, season: int) -> int: .get("StandingsTable", {}) .get("StandingsLists", [])) if standings_lists: - return int(standings_lists[0].get("round", 0)) + 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 diff --git a/plugins/f1-scoreboard/f1_renderer.py b/plugins/f1-scoreboard/f1_renderer.py index fa1f329..8d64128 100644 --- a/plugins/f1-scoreboard/f1_renderer.py +++ b/plugins/f1-scoreboard/f1_renderer.py @@ -377,14 +377,21 @@ def render_race_result(self, race: Dict) -> Image.Image: return img - # ─── Qualifying Results Card ─────────────────────────────────────── + # ─── Shared Driver Row Helper ───────────────────────────────────── - def render_qualifying_entry(self, entry: Dict, - session_label: str = "Q3") -> Image.Image: + def _render_driver_row(self, entry: Dict, time_key: str = "", + gap_key: str = "", + show_eliminated: bool = False) -> Image.Image: """ - Render a single qualifying result entry. + 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), @@ -412,12 +419,9 @@ def render_qualifying_entry(self, entry: Dict, code_width = self._get_text_width(draw, code, self.fonts["position"]) x_offset += code_width + 4 - # Get the appropriate time for this Q session - session_key = session_label.lower() - time_str = entry.get(session_key, "") - gap_str = entry.get(f"{session_key}_gap", "") - # 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"], @@ -425,8 +429,7 @@ def render_qualifying_entry(self, entry: Dict, time_width = self._get_text_width(draw, time_str, self.fonts["detail"]) x_offset += time_width + 4 - else: - # No time = eliminated or no run + elif show_eliminated: eliminated = entry.get("eliminated_in", "") if eliminated: self._draw_text_outlined(draw, (x_offset, 2), "OUT", @@ -434,13 +437,14 @@ def render_qualifying_entry(self, entry: Dict, 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: - self._draw_text_outlined(draw, (x_offset - time_width - 4 - if time_str else x_offset, - gap_y), + 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)) @@ -456,6 +460,18 @@ def render_qualifying_entry(self, entry: Dict, 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: @@ -494,65 +510,9 @@ def render_qualifying_header(self, # ─── Practice Results Card ───────────────────────────────────────── def render_practice_entry(self, entry: Dict) -> Image.Image: - """ - Render a practice session result entry. - - Layout: [accent bar] [pos] [code] [best lap] [gap] [team logo] - """ - 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 - - # Best lap time - best_lap = entry.get("best_lap", "") - if best_lap: - self._draw_text_outlined(draw, (x_offset, 2), best_lap, - self.fonts["detail"], - fill=(200, 200, 200)) - - # Gap - gap = entry.get("gap", "") - if gap: - gap_y = 2 + self._get_text_height( - draw, best_lap or "1:00", self.fonts["detail"]) + 2 - if gap_y + 6 < self.display_height: - self._draw_text_outlined(draw, (x_offset, gap_y), gap, - self.fonts["small"], - fill=(255, 200, 0)) - - # Team logo (right) - 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 + """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: @@ -587,49 +547,8 @@ def render_practice_header(self, session_name: str = "FP3", # ─── Sprint Results Card ─────────────────────────────────────────── def render_sprint_entry(self, entry: Dict) -> Image.Image: - """Render a sprint result entry. Same layout as qualifying entry.""" - 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 - - 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 - - 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/gap - time_str = entry.get("time", "") - if time_str: - self._draw_text_outlined(draw, (x_offset, 2), time_str, - self.fonts["detail"], - fill=(200, 200, 200)) - - # Team logo (right) - 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 + """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.""" diff --git a/plugins/f1-scoreboard/logo_downloader.py b/plugins/f1-scoreboard/logo_downloader.py index 20051d3..4c5ca1c 100644 --- a/plugins/f1-scoreboard/logo_downloader.py +++ b/plugins/f1-scoreboard/logo_downloader.py @@ -88,17 +88,20 @@ def __init__(self, plugin_dir: str = None): self._cache: Dict[str, Image.Image] = {} def get_team_logo(self, constructor_id: str, max_height: int = 28, - max_width: int = 28) -> Optional[Image.Image]: + 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, or None if unavailable + PIL Image in RGBA mode """ normalized = normalize_constructor_id(constructor_id) cache_key = f"team_{normalized}_{max_width}x{max_height}" @@ -107,8 +110,7 @@ def get_team_logo(self, constructor_id: str, max_height: int = 28, return self._cache[cache_key] logo = self._load_logo(normalized, max_width, max_height) - if logo is not None: - self._cache[cache_key] = logo + self._cache[cache_key] = logo return logo def get_f1_logo(self, max_height: int = 12, @@ -247,9 +249,16 @@ def get_circuit_image(self, circuit_name: str = "", city: str = "", @staticmethod def _resolve_circuit_filename(circuit_name: str, city: str) -> str: - """Resolve a circuit name/city to a filename key.""" + """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() - for key, filename in CIRCUIT_FILENAME_MAP.items(): + # 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 "" diff --git a/plugins/f1-scoreboard/manager.py b/plugins/f1-scoreboard/manager.py index 2101ce8..b5a9106 100644 --- a/plugins/f1-scoreboard/manager.py +++ b/plugins/f1-scoreboard/manager.py @@ -146,7 +146,9 @@ def _update_standings(self): self._pole_positions = ( self.data_source.calculate_pole_positions()) - # Add pole count to each driver + # 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) @@ -194,14 +196,19 @@ def _update_recent_races(self): 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["results"] = self.data_source.apply_favorite_filter( + 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 = races + self._recent_races = filtered_races def _update_upcoming(self): """Update upcoming race data.""" @@ -243,10 +250,11 @@ def _update_practice(self): result = self.data_source.fetch_practice_results(session_name) if result: - # Limit to top N - if result.get("results"): - result["results"] = result["results"][:top_n] - self._practice_results[fp_key] = 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.""" @@ -255,10 +263,12 @@ def _update_sprint(self): 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.get("results"): - sprint["results"] = sprint["results"][:top_n] - self._sprint = sprint + if sprint_copy.get("results"): + sprint_copy["results"] = sprint_copy["results"][:top_n] + self._sprint = sprint_copy def _update_calendar(self): """Update race calendar.""" diff --git a/plugins/f1-scoreboard/requirements.txt b/plugins/f1-scoreboard/requirements.txt index 1773040..208d37f 100644 --- a/plugins/f1-scoreboard/requirements.txt +++ b/plugins/f1-scoreboard/requirements.txt @@ -1,2 +1,2 @@ requests>=2.32.0 -Pillow>=10.4.0 +Pillow>=12.1.1 diff --git a/plugins/f1-scoreboard/scroll_display.py b/plugins/f1-scoreboard/scroll_display.py index c6290f9..7f0b65c 100644 --- a/plugins/f1-scoreboard/scroll_display.py +++ b/plugins/f1-scoreboard/scroll_display.py @@ -12,8 +12,11 @@ try: from src.common.scroll_helper import ScrollHelper -except ImportError: +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__) @@ -140,6 +143,10 @@ def get_content_count(self) -> int: """Get the number of content items.""" return len(self._content_items) + def get_vegas_items(self) -> List[Image.Image]: + """Get the vegas content items for this display.""" + return self._vegas_content_items + class ScrollDisplayManager: """ @@ -187,7 +194,7 @@ 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._vegas_content_items) + items.extend(sd.get_vegas_items()) return items def is_mode_prepared(self, mode_key: str) -> bool: From 62eb9114598bb0fa8ee16c7ac49d99dfaea78b29 Mon Sep 17 00:00:00 2001 From: Chuck Date: Wed, 18 Feb 2026 09:50:46 -0500 Subject: [PATCH 5/9] fix(f1-scoreboard): address round 3 code review findings Inline fix: - Remove dead ESPN_SESSION_TYPES and ESPN_SESSION_ABBRS constants Nitpick fixes: - Remove redundant .lower().replace() before normalize_constructor_id - Populate _latest_round_cache from fetch_driver_standings to avoid duplicate HTTP requests from _get_latest_round - Single bounded fallback in _fallback_previous_season (always targets current_year - 1 instead of recursing through each year) - Use _truncate_text in calendar entry rendering for consistent ellipsis - Fix _load_logo return type to Image.Image (always returns via fallback) - Fix preload_all_teams log to count files iterated, not total cache size - Isolate each update step in own try/except so one failure doesn't abort remaining data fetches - Static fallback in display_scroll_frame: show first card when ScrollHelper is unavailable Co-Authored-By: Claude Opus 4.6 --- plugins/f1-scoreboard/f1_data.py | 44 ++++++++++-------------- plugins/f1-scoreboard/f1_renderer.py | 6 ++-- plugins/f1-scoreboard/logo_downloader.py | 12 +++++-- plugins/f1-scoreboard/manager.py | 24 +++++++------ plugins/f1-scoreboard/scroll_display.py | 6 ++++ 5 files changed, 48 insertions(+), 44 deletions(-) diff --git a/plugins/f1-scoreboard/f1_data.py b/plugins/f1-scoreboard/f1_data.py index f58c0a3..0092097 100644 --- a/plugins/f1-scoreboard/f1_data.py +++ b/plugins/f1-scoreboard/f1_data.py @@ -26,27 +26,6 @@ JOLPI_BASE = "https://api.jolpi.ca/ergast/f1" OPENF1_BASE = "https://api.openf1.org/v1" -# ESPN competition type IDs -ESPN_SESSION_TYPES = { - "1": "Practice", - "2": "Qualifying", - "3": "Race", - "5": "Sprint Shootout", - "6": "Sprint Race", -} - -# ESPN session type abbreviations -ESPN_SESSION_ABBRS = { - "FP1": "Practice", - "FP2": "Practice", - "FP3": "Practice", - "Qual": "Qualifying", - "Race": "Race", - "SS": "Sprint Shootout", - "SR": "Sprint Race", -} - - class F1DataSource: """Fetches and processes F1 data from ESPN, Jolpi, and OpenF1 APIs.""" @@ -130,16 +109,21 @@ def _fetch_json(self, url: str, params: Dict = 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).""" + """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, season - 1) - return method(season=season - 1, **kwargs) + method_name, season, target) + return method(season=target, **kwargs) return default_return # ─── ESPN: Schedule & Calendar ───────────────────────────────────── @@ -386,6 +370,15 @@ def fetch_driver_standings(self, season: int = None) -> List[Dict]: 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", []) @@ -933,8 +926,7 @@ def fetch_practice_results(self, session_name: str = "Practice 3", # Map team name to constructor ID team_name = info.get("team", "") - constructor_id = normalize_constructor_id( - team_name.lower().replace(" ", "_")) + constructor_id = normalize_constructor_id(team_name) results.append({ "position": i + 1, diff --git a/plugins/f1-scoreboard/f1_renderer.py b/plugins/f1-scoreboard/f1_renderer.py index 8d64128..fc02934 100644 --- a/plugins/f1-scoreboard/f1_renderer.py +++ b/plugins/f1-scoreboard/f1_renderer.py @@ -795,11 +795,9 @@ def render_calendar_entry(self, entry: Dict) -> Image.Image: # Event name event_name = entry.get("event_name", "") short_event = event_name.replace("Grand Prix", "GP") - # Truncate if too long max_name_width = self.display_width - x_offset - 2 - while (self._get_text_width(draw, short_event, self.fonts["small"]) - > max_name_width and len(short_event) > 3): - short_event = short_event[:-1] + 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"], diff --git a/plugins/f1-scoreboard/logo_downloader.py b/plugins/f1-scoreboard/logo_downloader.py index 4c5ca1c..23e85ba 100644 --- a/plugins/f1-scoreboard/logo_downloader.py +++ b/plugins/f1-scoreboard/logo_downloader.py @@ -146,8 +146,12 @@ def get_f1_logo(self, max_height: int = 12, return placeholder def _load_logo(self, constructor_id: str, max_width: int, - max_height: int) -> Optional[Image.Image]: - """Load a team logo from disk, with placeholder fallback.""" + 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(): @@ -279,8 +283,10 @@ def preload_all_teams(self, max_height: int = 28, max_width: int = 28): 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", len(self._cache)) + logger.info("Preloaded %d team logos", count) diff --git a/plugins/f1-scoreboard/manager.py b/plugins/f1-scoreboard/manager.py index b5a9106..16aff38 100644 --- a/plugins/f1-scoreboard/manager.py +++ b/plugins/f1-scoreboard/manager.py @@ -124,17 +124,19 @@ def update(self): self.logger.info("Updating F1 data...") self._last_update = now - try: - 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() - except Exception as e: - self.logger.error("Error updating F1 data: %s", e, exc_info=True) + 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.""" diff --git a/plugins/f1-scoreboard/scroll_display.py b/plugins/f1-scoreboard/scroll_display.py index 7f0b65c..2b4d364 100644 --- a/plugins/f1-scoreboard/scroll_display.py +++ b/plugins/f1-scoreboard/scroll_display.py @@ -111,6 +111,12 @@ def display_scroll_frame(self, force_clear: bool = False) -> bool: 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: From 982a0566c46f4d277a039520cf8f428dadf03cbd Mon Sep 17 00:00:00 2001 From: Chuck Date: Wed, 18 Feb 2026 10:38:51 -0500 Subject: [PATCH 6/9] feat(f1-scoreboard): add display controller parity and web UI polish Bring the F1 plugin up to parity with mature sports plugins: - display() returns bool so empty modes are skipped in rotation - Dynamic duration support (scroll modes run until cycle completes) - on_config_change rebuilds ScrollDisplayManager - get_info() provides diagnostic data for web UI - Vegas content filtered to only modes with data - Config schema: x-propertyOrder, x-widget checkbox-group, enum dropdowns for favorites, title on all fields, dynamic_duration block Co-Authored-By: Claude Opus 4.6 --- plugins.json | 6 +- plugins/f1-scoreboard/config_schema.json | 85 +++++++++++++- plugins/f1-scoreboard/manager.py | 134 +++++++++++++++++++---- plugins/f1-scoreboard/manifest.json | 4 +- plugins/f1-scoreboard/scroll_display.py | 9 ++ 5 files changed, 210 insertions(+), 28 deletions(-) diff --git a/plugins.json b/plugins.json index 6a8d142..f8458fc 100644 --- a/plugins.json +++ b/plugins.json @@ -1,6 +1,6 @@ { "version": "1.0.0", - "last_updated": "2026-02-17", + "last_updated": "2026-02-18", "plugins": [ { "id": "hello-world", @@ -644,10 +644,10 @@ "plugin_path": "plugins/f1-scoreboard", "stars": 0, "downloads": 0, - "last_updated": "2026-02-17", + "last_updated": "2026-02-18", "verified": true, "screenshot": "", - "latest_version": "1.0.0" + "latest_version": "1.1.0" }, { "id": "web-ui-info", diff --git a/plugins/f1-scoreboard/config_schema.json b/plugins/f1-scoreboard/config_schema.json index f8c9a59..64b72ba 100644 --- a/plugins/f1-scoreboard/config_schema.json +++ b/plugins/f1-scoreboard/config_schema.json @@ -6,11 +6,13 @@ "properties": { "enabled": { "type": "boolean", + "title": "Enabled", "default": true, "description": "Enable or disable the F1 scoreboard plugin" }, "display_duration": { "type": "number", + "title": "Display Duration", "default": 30, "minimum": 5, "maximum": 300, @@ -18,6 +20,7 @@ }, "update_interval": { "type": "integer", + "title": "Update Interval", "default": 3600, "minimum": 60, "maximum": 86400, @@ -25,13 +28,17 @@ }, "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." + "enum": ["", "mclaren", "ferrari", "red_bull", "mercedes", "williams", "aston_martin", "alpine", "haas", "sauber", "rb"], + "description": "Favorite constructor/team. This team will always be shown and highlighted in standings." }, "favorite_driver": { "type": "string", + "title": "Favorite Driver", "default": "", - "description": "Favorite driver code (e.g., 'NOR', 'VER', 'HAM', 'LEC', 'PIA', 'RUS'). This driver will always be shown and highlighted in standings and results." + "enum": ["", "VER", "NOR", "PIA", "LEC", "SAI", "HAM", "RUS", "ALO", "STR", "GAS", "DOO", "TSU", "LAW", "ALB", "SAR", "HUL", "BEA", "BOT", "ZHO", "OCO", "BOR"], + "description": "Favorite driver code. This driver will always be shown and highlighted in standings and results." }, "driver_standings": { "type": "object", @@ -40,11 +47,13 @@ "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, @@ -52,10 +61,12 @@ }, "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": { @@ -65,11 +76,13 @@ "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, @@ -77,10 +90,12 @@ }, "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": { @@ -90,11 +105,13 @@ "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, @@ -102,6 +119,7 @@ }, "top_finishers": { "type": "integer", + "title": "Top Finishers", "default": 3, "minimum": 1, "maximum": 20, @@ -109,10 +127,12 @@ }, "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": { @@ -122,20 +142,24 @@ "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": { @@ -145,30 +169,36 @@ "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": { @@ -178,11 +208,14 @@ "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"] @@ -192,12 +225,14 @@ }, "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": { @@ -207,17 +242,20 @@ "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": { @@ -227,32 +265,61 @@ "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": { @@ -267,16 +334,19 @@ "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": { @@ -286,16 +356,19 @@ "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": { @@ -305,16 +378,19 @@ "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": { @@ -324,22 +400,27 @@ "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/manager.py b/plugins/f1-scoreboard/manager.py index 16aff38..4e4e3d7 100644 --- a/plugins/f1-scoreboard/manager.py +++ b/plugins/f1-scoreboard/manager.py @@ -74,6 +74,9 @@ def __init__(self, plugin_id, config, display_manager, 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() @@ -396,19 +399,27 @@ def _build_practice_cards(self) -> List[Image.Image]: # ─── Display ─────────────────────────────────────────────────────── - def display(self, force_clear=False, display_mode=None): + 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": - self._display_upcoming(force_clear) + return self._display_upcoming(force_clear) elif display_mode in ("f1_driver_standings", "f1_constructor_standings", "f1_recent_races", @@ -416,14 +427,15 @@ def display(self, force_clear=False, display_mode=None): "f1_practice", "f1_sprint", "f1_calendar"): - self._display_scroll_mode(display_mode, force_clear) + 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): + def _display_upcoming(self, force_clear: bool) -> bool: """Display the upcoming race card (static).""" if not self._upcoming_race: - return + return False if force_clear: self.display_manager.image.paste( @@ -463,32 +475,43 @@ def _display_upcoming(self, force_clear: bool): card = self.renderer.render_upcoming_race(self._upcoming_race) 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): + force_clear: bool) -> bool: """Display a scrolling mode.""" - 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", - } - - mode_key = mode_key_map.get(display_mode, display_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 all rendered cards for Vegas scroll mode.""" - images = self._scroll_manager.get_all_vegas_content_items() + """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): + sd = self._scroll_manager._scroll_displays.get(mode_key) + if sd: + images.extend(sd.get_vegas_items()) # Add upcoming race card if available if self._upcoming_race: @@ -506,8 +529,72 @@ 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) @@ -523,12 +610,17 @@ def on_config_change(self, new_config): 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.""" - self.logger.info("F1 Scoreboard cleanup") - self.logo_loader.clear_cache() + try: + self.logo_loader.clear_cache() + self.logger.info("F1 Scoreboard cleanup completed") + except Exception as e: + self.logger.error("Error during cleanup: %s", e) super().cleanup() diff --git a/plugins/f1-scoreboard/manifest.json b/plugins/f1-scoreboard/manifest.json index 49a6081..326d66d 100644 --- a/plugins/f1-scoreboard/manifest.json +++ b/plugins/f1-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "f1-scoreboard", "name": "F1 Scoreboard", - "version": "1.0.0", + "version": "1.1.0", "author": "ChuckBuilds", "class_name": "F1ScoreboardPlugin", "entry_point": "manager.py", @@ -20,7 +20,7 @@ ], "versions": [ { - "version": "1.0.0", + "version": "1.1.0", "ledmatrix_min": "2.0.0", "released": "2026-02-17" } diff --git a/plugins/f1-scoreboard/scroll_display.py b/plugins/f1-scoreboard/scroll_display.py index 2b4d364..c4448f3 100644 --- a/plugins/f1-scoreboard/scroll_display.py +++ b/plugins/f1-scoreboard/scroll_display.py @@ -208,3 +208,12 @@ def is_mode_prepared(self, mode_key: str) -> bool: 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 + sd = self._scroll_displays[mode_key] + if not sd.scroll_helper or not sd.is_prepared(): + return True + return sd.scroll_helper.is_scroll_complete() From 2eb19505ca8ae9aa4089cf057523cc8eeb61f53a Mon Sep 17 00:00:00 2001 From: Chuck Date: Wed, 18 Feb 2026 10:52:06 -0500 Subject: [PATCH 7/9] fix(f1-scoreboard): address round 4 code review findings - Guard display_manager.matrix access with hasattr check - Shallow-copy upcoming race data to avoid mutating cache - Reuse parsed datetime instead of parsing twice - Config-driven ScrollHelper params with sensible defaults - Add ScrollDisplay.is_scroll_complete() delegation method - Add ScrollDisplayManager.get_vegas_items_for_mode() public accessor - Use self.logger.exception() in cleanup for traceback capture - Fix display_duration type from number to integer - Remove hardcoded enum arrays for favorite_team/driver (stale risk) Co-Authored-By: Claude Opus 4.6 --- plugins/f1-scoreboard/config_schema.json | 8 ++-- plugins/f1-scoreboard/manager.py | 53 +++++++++++------------- plugins/f1-scoreboard/scroll_display.py | 35 +++++++++++----- 3 files changed, 53 insertions(+), 43 deletions(-) diff --git a/plugins/f1-scoreboard/config_schema.json b/plugins/f1-scoreboard/config_schema.json index 64b72ba..4b9b2e9 100644 --- a/plugins/f1-scoreboard/config_schema.json +++ b/plugins/f1-scoreboard/config_schema.json @@ -11,7 +11,7 @@ "description": "Enable or disable the F1 scoreboard plugin" }, "display_duration": { - "type": "number", + "type": "integer", "title": "Display Duration", "default": 30, "minimum": 5, @@ -30,15 +30,13 @@ "type": "string", "title": "Favorite Team", "default": "", - "enum": ["", "mclaren", "ferrari", "red_bull", "mercedes", "williams", "aston_martin", "alpine", "haas", "sauber", "rb"], - "description": "Favorite constructor/team. This team will always be shown and highlighted in standings." + "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": "", - "enum": ["", "VER", "NOR", "PIA", "LEC", "SAI", "HAM", "RUS", "ALO", "STR", "GAS", "DOO", "TSU", "LAW", "ALB", "SAR", "HUL", "BEA", "BOT", "ZHO", "OCO", "BOR"], - "description": "Favorite driver code. This driver will always be shown and highlighted in standings and results." + "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", diff --git a/plugins/f1-scoreboard/manager.py b/plugins/f1-scoreboard/manager.py index 4e4e3d7..c172d51 100644 --- a/plugins/f1-scoreboard/manager.py +++ b/plugins/f1-scoreboard/manager.py @@ -39,8 +39,12 @@ def __init__(self, plugin_id, config, display_manager, cache_manager, plugin_manager) # Display dimensions - self.display_width = display_manager.matrix.width - self.display_height = display_manager.matrix.height + 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() @@ -440,39 +444,33 @@ def _display_upcoming(self, force_clear: bool) -> bool: if force_clear: self.display_manager.image.paste( Image.new("RGB", - (self.display_manager.matrix.width, - self.display_manager.matrix.height), + (self.display_width, self.display_height), (0, 0, 0)), (0, 0)) - # Re-calculate countdown for live updates - self._upcoming_race["countdown_seconds"] = None + # Work on a shallow copy to avoid mutating cached data + upcoming = dict(self._upcoming_race) + upcoming["countdown_seconds"] = None + now = datetime.now(timezone.utc) - next_session = None + next_session_dt = None - for session in self._upcoming_race.get("sessions", []): + for session in upcoming.get("sessions", []): if session.get("status_state") == "pre" and session.get("date"): try: - session_dt = datetime.fromisoformat( + parsed_dt = datetime.fromisoformat( session["date"].replace("Z", "+00:00")) - if session_dt > now: - next_session = session + if parsed_dt > now: + next_session_dt = parsed_dt + upcoming["countdown_seconds"] = max( + 0, (parsed_dt - now).total_seconds()) + upcoming["next_session_type"] = session.get( + "type_abbr", "") break except (ValueError, TypeError): continue - if next_session: - try: - session_dt = datetime.fromisoformat( - next_session["date"].replace("Z", "+00:00")) - self._upcoming_race["countdown_seconds"] = max( - 0, (session_dt - now).total_seconds()) - self._upcoming_race["next_session_type"] = next_session.get( - "type_abbr", "") - except (ValueError, TypeError): - pass - - card = self.renderer.render_upcoming_race(self._upcoming_race) + card = self.renderer.render_upcoming_race(upcoming) self.display_manager.image.paste(card, (0, 0)) self.display_manager.update_display() return True @@ -509,9 +507,8 @@ def get_vegas_content(self) -> Optional[List[Image.Image]]: } for mode_key, data in mode_data.items(): if data and self._scroll_manager.is_mode_prepared(mode_key): - sd = self._scroll_manager._scroll_displays.get(mode_key) - if sd: - images.extend(sd.get_vegas_items()) + images.extend( + self._scroll_manager.get_vegas_items_for_mode(mode_key)) # Add upcoming race card if available if self._upcoming_race: @@ -621,6 +618,6 @@ def cleanup(self): try: self.logo_loader.clear_cache() self.logger.info("F1 Scoreboard cleanup completed") - except Exception as e: - self.logger.error("Error during cleanup: %s", e) + except Exception: + self.logger.exception("Error during F1 Scoreboard cleanup") super().cleanup() diff --git a/plugins/f1-scoreboard/scroll_display.py b/plugins/f1-scoreboard/scroll_display.py index c4448f3..05ac208 100644 --- a/plugins/f1-scoreboard/scroll_display.py +++ b/plugins/f1-scoreboard/scroll_display.py @@ -43,7 +43,7 @@ def __init__(self, display_manager, config: Optional[Dict[str, Any]] = None, self.display_width = getattr(display_manager, "width", 128) self.display_height = getattr(display_manager, "height", 32) - # Initialize ScrollHelper + # Initialize ScrollHelper with config-driven parameters self.scroll_helper = None if ScrollHelper: self.scroll_helper = ScrollHelper( @@ -51,13 +51,19 @@ def __init__(self, display_manager, config: Optional[Dict[str, Any]] = None, self.display_height, self.logger ) - self.scroll_helper.set_frame_based_scrolling(True) - self.scroll_helper.set_scroll_speed(1) - self.scroll_helper.set_scroll_delay(0.03) + 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=15, - max_duration=120, + min_duration=scroll_cfg.get("min_duration", 15), + max_duration=scroll_cfg.get("max_duration", 120), buffer=self.display_width ) @@ -149,6 +155,12 @@ 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 @@ -213,7 +225,10 @@ 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 - sd = self._scroll_displays[mode_key] - if not sd.scroll_helper or not sd.is_prepared(): - return True - return sd.scroll_helper.is_scroll_complete() + 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() From 2142a8532a78785d6e0b53d66f5204661e352228 Mon Sep 17 00:00:00 2001 From: Chuck Date: Fri, 20 Feb 2026 19:42:51 -0500 Subject: [PATCH 8/9] feat(scoreboards): add scroll mode config to baseball, football, basketball MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `live_display_mode`, `recent_display_mode`, and `upcoming_display_mode` fields (enum: switch|scroll, default: switch) to each league's display_modes block in config_schema.json, plus a `scroll_settings` block with scroll_speed, scroll_delay, gap_between_games, show_league_separators, and dynamic_duration. All three plugins already had full scroll mode code infrastructure in manager.py; this change exposes those settings in the web UI so users can enable scroll mode on large displays where switch mode leaves excessive blank space. Hockey and soccer scoreboards were already complete (unchanged). Bumped versions: baseball 1.3.1→1.3.2, football 2.1.1→2.1.2, basketball 1.1.1→1.1.2 Co-Authored-By: Claude Sonnet 4.6 --- plugins.json | 14 +- .../baseball-scoreboard/config_schema.json | 168 +++++++++++++ plugins/baseball-scoreboard/manifest.json | 9 +- .../basketball-scoreboard/config_schema.json | 224 ++++++++++++++++++ plugins/basketball-scoreboard/manifest.json | 9 +- .../football-scoreboard/config_schema.json | 112 +++++++++ plugins/football-scoreboard/manifest.json | 9 +- 7 files changed, 532 insertions(+), 13 deletions(-) diff --git a/plugins.json b/plugins.json index f8458fc..b4a5de6 100644 --- a/plugins.json +++ b/plugins.json @@ -1,6 +1,6 @@ { "version": "1.0.0", - "last_updated": "2026-02-18", + "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.1.2" }, { "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.1.2" }, { "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.3.2" }, { "id": "soccer-scoreboard", 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..f8f62ba 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.3.2", "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.3.2", + "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..c003040 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.1.2", "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.1.2", + "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/football-scoreboard/config_schema.json b/plugins/football-scoreboard/config_schema.json index 644adff..11d1719 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" } } }, diff --git a/plugins/football-scoreboard/manifest.json b/plugins/football-scoreboard/manifest.json index 01b3cfb..a461a51 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.1.2", "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.1.2", + "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, From 261daa801781095b20577d64a35607a4061f53d4 Mon Sep 17 00:00:00 2001 From: Chuck Date: Fri, 20 Feb 2026 20:31:15 -0500 Subject: [PATCH 9/9] fix(scoreboards): address PR review - minor version bumps, schema fix, f1 cleanup - Bump baseball/football/basketball to minor versions (1.4.0, 2.2.0, 1.2.0) since adding new config fields is a backward-compatible feature addition - Fix football config_schema.json: move 'customization' block from top-level into 'properties' so it is recognized under additionalProperties:false - Remove unused next_session_dt variable in f1-scoreboard manager.py - Fix f1-scoreboard manifest: correct last_updated date (2026-02-17 -> 2026-02-18) and add missing repo/branch/plugin_path fields for registry consistency Co-Authored-By: Claude Sonnet 4.6 --- plugins.json | 6 +++--- plugins/baseball-scoreboard/manifest.json | 4 ++-- plugins/basketball-scoreboard/manifest.json | 4 ++-- plugins/f1-scoreboard/manager.py | 2 -- plugins/f1-scoreboard/manifest.json | 5 ++++- plugins/football-scoreboard/config_schema.json | 6 +++--- plugins/football-scoreboard/manifest.json | 4 ++-- 7 files changed, 16 insertions(+), 15 deletions(-) diff --git a/plugins.json b/plugins.json index b4a5de6..4cca2cd 100644 --- a/plugins.json +++ b/plugins.json @@ -224,7 +224,7 @@ "last_updated": "2026-02-20", "verified": true, "screenshot": "", - "latest_version": "2.1.2" + "latest_version": "2.2.0" }, { "id": "ufc-scoreboard", @@ -273,7 +273,7 @@ "last_updated": "2026-02-20", "verified": true, "screenshot": "", - "latest_version": "1.1.2" + "latest_version": "1.2.0" }, { "id": "baseball-scoreboard", @@ -299,7 +299,7 @@ "last_updated": "2026-02-20", "verified": true, "screenshot": "", - "latest_version": "1.3.2" + "latest_version": "1.4.0" }, { "id": "soccer-scoreboard", diff --git a/plugins/baseball-scoreboard/manifest.json b/plugins/baseball-scoreboard/manifest.json index f8f62ba..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.2", + "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", @@ -32,7 +32,7 @@ "versions": [ { "released": "2026-02-20", - "version": "1.3.2", + "version": "1.4.0", "ledmatrix_min": "2.0.0" }, { diff --git a/plugins/basketball-scoreboard/manifest.json b/plugins/basketball-scoreboard/manifest.json index c003040..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.2", + "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", @@ -19,7 +19,7 @@ "plugin_path": "plugins/basketball-scoreboard", "versions": [ { - "version": "1.1.2", + "version": "1.2.0", "ledmatrix_min": "2.0.0", "released": "2026-02-20" }, diff --git a/plugins/f1-scoreboard/manager.py b/plugins/f1-scoreboard/manager.py index c172d51..ffdac68 100644 --- a/plugins/f1-scoreboard/manager.py +++ b/plugins/f1-scoreboard/manager.py @@ -453,7 +453,6 @@ def _display_upcoming(self, force_clear: bool) -> bool: upcoming["countdown_seconds"] = None now = datetime.now(timezone.utc) - next_session_dt = None for session in upcoming.get("sessions", []): if session.get("status_state") == "pre" and session.get("date"): @@ -461,7 +460,6 @@ def _display_upcoming(self, force_clear: bool) -> bool: parsed_dt = datetime.fromisoformat( session["date"].replace("Z", "+00:00")) if parsed_dt > now: - next_session_dt = parsed_dt upcoming["countdown_seconds"] = max( 0, (parsed_dt - now).total_seconds()) upcoming["next_session_type"] = session.get( diff --git a/plugins/f1-scoreboard/manifest.json b/plugins/f1-scoreboard/manifest.json index 326d66d..541ae01 100644 --- a/plugins/f1-scoreboard/manifest.json +++ b/plugins/f1-scoreboard/manifest.json @@ -5,6 +5,9 @@ "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"], @@ -25,7 +28,7 @@ "released": "2026-02-17" } ], - "last_updated": "2026-02-17", + "last_updated": "2026-02-18", "stars": 0, "downloads": 0, "verified": true, diff --git a/plugins/football-scoreboard/config_schema.json b/plugins/football-scoreboard/config_schema.json index 11d1719..591909b 100644 --- a/plugins/football-scoreboard/config_schema.json +++ b/plugins/football-scoreboard/config_schema.json @@ -599,9 +599,8 @@ } } } - } - }, - "customization": { + }, + "customization": { "type": "object", "title": "Display Customization", "description": "Customize fonts for different text elements on the scoreboard", @@ -996,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 a461a51..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.2", + "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!", @@ -25,7 +25,7 @@ ], "versions": [ { - "version": "2.1.2", + "version": "2.2.0", "ledmatrix_min": "2.0.0", "released": "2026-02-20" },