From 07e7e87f2f404ece153830c2018160dee8222f36 Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Wed, 2 Sep 2026 22:28:53 -0400 Subject: [PATCH] fix(on-air): find the real LEDMatrix root when loading the sign font The sign looks for a TTF under the LEDMatrix assets folder, resolving the root as two levels above the plugin file. That is right when the plugin sits at /plugin-repos/, and wrong whenever the plugins directory is configured somewhere else -- the search then finds nothing and the sign drops to a built-in font a fraction of the intended size, with no error. It now asks the imported core where it lives and keeps the old guess and the working directory as fallbacks. That is also what made this plugin impossible to document honestly: rendered from this repo, the sign came out in tiny default lettering rather than the 80%-of-panel-height face a real install shows. README: real rendered screenshots throughout, the config.json key for all 16 settings, and three corrections. - "The label (max 16 chars) replaces ON AIR ... on panels 128px wide or larger ON AIR appears as the header with your label as a subtitle below it" -- there is no length bound and no header/subtitle layout in the code. The label is drawn on its own, centred, scaled to fit. - Five automation examples sent {"color": [255, 140, 0]} where they meant the background. In a payload, "color" is the text colour and "bg" is the background, so those examples produce orange lettering on the default red rather than an amber sign. The payload reference already said this; the examples contradicted it. Tooling: shots can now declare "attrs", runtime state applied to the plugin instance after the core's loader builds it. An event-driven plugin like this one holds its interesting state in memory, put there by an MQTT message, so no configuration reaches it and a documentation render would only ever show the idle black frame. Co-Authored-By: Claude Opus 5 --- README.md | 2 +- docs/assets/on-air/color-vs-bg.png | Bin 0 -> 8968 bytes docs/assets/on-air/hero.png | Bin 0 -> 1248 bytes docs/assets/on-air/on-off.png | Bin 0 -> 5605 bytes docs/assets/on-air/panel-sizes.png | Bin 0 -> 12913 bytes docs/assets/on-air/presets.png | Bin 0 -> 11955 bytes docs/assets/on-air/shots.json | 78 ++++++++++++++ plugins.json | 4 +- plugins/on-air/README.md | 105 ++++++++++++++----- plugins/on-air/manager.py | 51 ++++++--- plugins/on-air/manifest.json | 10 +- scripts/docs_render_support/sitecustomize.py | 62 +++++++++++ scripts/render_docs_assets.py | 6 +- 13 files changed, 269 insertions(+), 49 deletions(-) create mode 100644 docs/assets/on-air/color-vs-bg.png create mode 100644 docs/assets/on-air/hero.png create mode 100644 docs/assets/on-air/on-off.png create mode 100644 docs/assets/on-air/panel-sizes.png create mode 100644 docs/assets/on-air/presets.png create mode 100644 docs/assets/on-air/shots.json diff --git a/README.md b/README.md index 9fae8532..ad071555 100644 --- a/README.md +++ b/README.md @@ -143,7 +143,7 @@ curl -X POST http://your-pi-ip:5000/api/v3/plugins/install \ | Plugin | Description | Preview | |--------|-------------|---------| | [MQTT Notifications](./plugins/mqtt-notifications/) | HomeAssistant notifications via MQTT | | -| [On Air Light](./plugins/on-air/) | Broadcast ON AIR tally light, triggered remotely via MQTT / Home Assistant | | +| [On Air Light](./plugins/on-air/) | Broadcast ON AIR tally light, triggered remotely via MQTT / Home Assistant | on-air on an LED panel | ### Custom (3) diff --git a/docs/assets/on-air/color-vs-bg.png b/docs/assets/on-air/color-vs-bg.png new file mode 100644 index 0000000000000000000000000000000000000000..7677eb793c64788138174d8579349ab9bb325f59 GIT binary patch literal 8968 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 d{kjDUprwRM-g;L9e>c>6>0C)V=xh{{p*EU3dTh literal 0 HcmV?d00001 diff --git a/docs/assets/on-air/hero.png b/docs/assets/on-air/hero.png new file mode 100644 index 0000000000000000000000000000000000000000..7221a309a9e0041da8f8630efbe8b0a7d71f85d8 GIT binary patch literal 1248 zcmeAS@N?(olHy`uVBq!ia0y~yU|9muFQs9{8R zA9g=ue}GHdk`u zxw|j2|1nKap8I*KF~7FERkFtO%*mf`zxLal<~EOYTF2-ar^rf=>_}4R&<}G?zSM<&1ovj2YQlLSHJp`f2`^cYbOw}f5 zZ_mH^?z?{Us}%2)HTgzMa*qj?N%uZ~`sf$4no_OQ_4dOZ{ulGA-@UG#Xg432Dvq2_ z*k10nGxSx_xuZKCzX1lZz&{o?wB2?#D~f)kJc61L4V>lg&+ZLmU^pbMKvK>Gng~lh ss3u}YEN1E;Mh&LvnC2jmdKI;Vst0OSK(>;M1& literal 0 HcmV?d00001 diff --git a/docs/assets/on-air/on-off.png b/docs/assets/on-air/on-off.png new file mode 100644 index 0000000000000000000000000000000000000000..b4f1cd83ea0e474bc9e4fa1f3f5463756c9e4254 GIT binary patch literal 5605 zcmb_gX;@R|w%&+}Xa!LtMXOM$A}B^cpnyQBAd`ScOu~#D<{6|AAV6qE#WD#3kx7&p zLO^5)Gbm6566P?Ilra$IDS?E*-LdxGavz^_dQQ9le97L)+Ur~Ide{2i_4JOu=F!8a z4+8*jR9j2^E&v=<2LP^Z;_Mtw9!JBqE{rOMEMEd%*s094kov$>?)4*kFX@Ig3b$F=q+6c7(Hh|Kb2=`I^9S_|v0f@2_y~9kM#bS$=yIe9Vpu{PM}h zr$>8i>^1+%#vb#ZZ2Wuk4vu#jAK;IVp79y9#T-4WcwI)O_{|%S+3rmA_JlCScOrsz zV6l86lJ~?#^+wUC8xL3ucm&=uaDyaI_O(_|_8ouv=+UEg9-vcNwl*CVu`!f`)`Iu}&fZEvnTWjT|ahDsb= zhs$Kw0YEP&>oLEu%8WB_hXrZv6eoCjF}Zo@FXJ&7OEi09Yinz2YN`zbt((nCl8}%% zKVCUkfUO8nQ+pSAR*AVjYX~di0!r5Jx(H&$0l?=nL2*1(luvoRGxUWLa&elNgdJ98 z&ZHwLf%515H)wHjaj;Q~d-vAsx%t%_VF+{C`}IINKTzUPWdiQujj0t}Utiz5CT_WJ zsMHy2agSsVGr_wBQUV!)+bhjDQ~XX7G^i^tuHqzD@F`aWHaFCF4u@a;cv*na4S~`8 zj&QO>AUe^6Y7+`NaoblwKwMnh*47q3>SL;*(NzBoJGn<|2!v_SP!t#=Om&Y3L z?i7o;zK^fGkpL0U#axxVbSX5n$hz5Qw)=w9jcaJoYfbf_pq+S0(-=gug4Z;2W3H#W zyIplR;N#m9%|2p>8o*&Gk!IeNPH_01<>q-)pOmH1UQ?*|11 zvFFHs)D*GQfCqTD(d2``tEQn8r^CRj13I&VGR>Db96ZLe?fysp{b>$^9@xlJ9yo)! z>|;MRCaRz8(e8)xt}D$WyX)v+(N%ONm})_7X8~X!CC?gxjb>sDbaj&gW~acvnd<8o zvi*%{xlACK`z9@@xBrBJVnu%b{HTWe(jXBIhi^~!Gs;f_!SVAiUNB4i)AM5`IX9e zh@9(CE#9}Q4IP*}ErHo-g9I|yBSJmxlQ-Iw?0YLI;o*_?2AdXiD+N=05Pdt@Ue0ag zStqHIj#`t%lTi#pT3Xs9-$+0k&Ol|QE?qNfE_QHsB1$E3ZNBHig<)qmuMbi;4Va4` zKto{G2Kv)aG&MD0%;*o%)v0w3UBd#=@$o(l20IgrmTZK!_EYRpKr7x%_Zw)>#Iu`F zA>{%?yX+{H?d1vu1F>F6soQJws)x-fo^VP7D>Eabdh>lUV-16VqNk1Rkqdq7F3oe~ z3NL$D8w2{{+vOZJ0V!HZ*RH~XJqXBL81#m`-Mmo6+Rje9k~^uAo#bAzv8B)vsTg?M z#AL|Q!oM~n!)AMsTH=~SXF9J@d;`_24;MS}nx9piFQ2?CaXz!4A=g{e8y2`igi7?8 zOq2Ypd13$j-qFOwgr;*acp&P1KA7G+V`Dr+cz5&`1Th8eFfFztf;%qrC$&B?X1FwbgjY!od+d zkxFixC!$nM;BdLIWUuB(rL{qOJ9K*ayypkQ;?x297FE#fTgOITu^MT{l&vo1Ou)Jn z3^EYRr5UvOjwj{{@ak$ysrst4EH+xox+8gQ-tefK)p$mWsmiGITrH}*N5+5s+YJA) zi6^Pgp7*d`^PmwaHX4ods;U-umNgt5XMfr%gqN-_3@?$Z&@edM#DJegpJNOg?|N;H zQG`^-P^kfL7k{J>TM6?4gHwnrrc4V@PfzwEV?ADTTxl%x9Yi&M!z1r-b;wzbdwrH0 zB+Kaw!tM{^UYLP(u}CMyq!|@&K%fbM%EKFY3;Q5}W6nYF2+;b-8E5V6>gr7M${!*^Axm|X zV*5Tdp3<<;A2pAfytOHs`vS<(eIOZ~ZztQ0{;q+f)$!6;=&Vxh`g<>s1!v^latdyN zMOSq^L#|+lX#+b*KoO3nq1{67m2*|}nJ?KW?l>=j zJq>OItY@>bn5A{nzLgD=3$wFA0!3C0OvDa-wNV=rL?6tC)?Uk=mW~*4$6PgcXAhR- ztn~1kmbtije2hk!Ybv7V(p3qSnO7yDv9$ECP*I+9vcAjDz<_zr_pYS#tA>X{>#kVV zIN9#*aKj7Cx@wxg7S6YrQsf3`F(9{DcCrudJS1t^QQJ-B+zWEF0*-?$wYD^UW@=;RM~@C9q$nZ!jZ&nQ&?`-4iAQ2%<85qy*xo&S zQKp!=G2Rpwf5muv^tnOBa$1=ED01oQ0zRkQfh{%{v|DBDEqSLSXm07dDjyy}2t-32 zWGCL0m1ei#YouQYQX*isiCX4D9>F4)z=Kp?Uf|%{3(ecPEcxvhB=-^o9cptqd>*3Z>XUJu)!$V5& zrJ%Xl`kbCy$eGU9hS`K7>kgw;a@7hkn^5KsC6`+Ts!zRA(?sCW9EPFRzr4OVlc`Jg zisk~mbFuMSZ?L2fiL%ZnrB2>Mv)16zs9)wXYbHG^*NIZ%*-+eZ!@Db^1!N|rSb2o> zKHmgqu6HSurkGsm)B7Q>um`5)wWt^la5+zrGM*iie@Lb_*&@ z5Uh7Z5IPJ-b*s_ZK1J4Q{Y4QLUJ!un8ctEPy`@g6eUgfPr{OxB6nPoox@OOr_C{J_elbyTuK(E9;T`+%*fNXX^Y)YP@x)^t-`0bjmF&FYhW zv*A1#cFIrJ678x+gu(NDcXQMp7P3|%z(lpEy-|o7;f$3vKKV$mNJtpL487dyB~daFf;SQEp9-h zc4x~k^CWPr*6t8Ty7)?CrI^A}A3$v;+1U#5L)j?hi38usLMW^KUo820y*vQ`C6`o{ zt$NRm3{@j6u4MqOYawO6wJ z=_BK&4(FgZpKEx+)JQ6WEHr{gn73_xwI2}SoPxn?k9gcG{66R8XLV&*%+PJ7R9d+&)OG8*kPLmy zGc)R|gwB}g-tLkvEtLZAo^1IwnSl}JZQWuvIf;rH3q^>j8Ys+g!gpyEoW-f%Oz2+Lbfrhbtmts3OCLYaUz? z(KnD**3&0k!Y31!PJ7`U31peX>#Sjwm&9w1*ALS>3BjnPt3xF+GhUrk5df5(f19YL zi{?HKcGv5R6+c?`jp;yb&Fgfy=+9vwMhr5n|AnNzLCR1_UFx0v zUM1+t?gx0^X6uhWK^X`ubc_EQT=DYAbccb1_-!Z0@pvz<6f+=r+2-TCVh?b8F+FA{ zlaXL5AL##3vgOuE!83}rwxHi%?lXvZi^1@Z%4HdSrd;vTFC;gIvHNgFgzq&k|wJ{N`U21CM$w z*Nf~;n_t2YVzh-7q+eeU&Ang@!o*rG?>^JjkQH%D&8?tK(BWdt(l0{U$#40W()5N` zUoxw;YE=aZsT4xT{T>xVko|+YY)3-HfN-JI@@s6_G&n zSR$5|xNCC1+R}Pi^=6BL0{L;%t!JL6Sm@%$8>v7t%D%$UR4(c3-(&u_45N-`tj{O( zOvbToQG06sGc{7=J?_@sB#Y&Tmh zTsjmSjmd$170miqD|h|r1S!t^6bLE&V}JcUr~WfX|5CXD1Xj?$hL0yk_nEbD9Maa% LS16Z&6Oj@EM8yIY5RoDfuu!Ci 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 literal 0 HcmV?d00001 diff --git a/docs/assets/on-air/presets.png b/docs/assets/on-air/presets.png new file mode 100644 index 0000000000000000000000000000000000000000..559ac06f4dacf3f42d236d2d317b3506a279e469 GIT binary patch literal 11955 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 literal 0 HcmV?d00001 diff --git a/docs/assets/on-air/shots.json b/docs/assets/on-air/shots.json new file mode 100644 index 00000000..a5b6fc55 --- /dev/null +++ b/docs/assets/on-air/shots.json @@ -0,0 +1,78 @@ +{ + "plugin": "on-air", + "defaults": { + "width": 128, + "height": 32, + "scale": 6, + "config": { + "enabled": true, + "mqtt_host": "127.0.0.1", + "mqtt_port": 1 + }, + "attrs": { + "on_air": true + } + }, + "shots": [ + { "name": "hero" }, + { "name": "off", "attrs": { "on_air": false }, "standalone": false }, + { "name": "on", "standalone": false }, + + { "name": "size-64x32", "width": 64, "standalone": false }, + { "name": "size-128x32", "width": 128, "standalone": false }, + { "name": "size-128x64", "width": 128, "height": 64, "standalone": false }, + { "name": "size-256x32", "width": 256, "standalone": false }, + + { "name": "preset-onair", "standalone": false, + "attrs": { "on_air": true, "label": "ON AIR", "active_bg_color": [200, 10, 10] } }, + { "name": "preset-meeting", "standalone": false, + "attrs": { "on_air": true, "label": "IN MEETING", "active_bg_color": [255, 140, 0] } }, + { "name": "preset-live", "standalone": false, + "attrs": { "on_air": true, "label": "LIVE", "active_bg_color": [255, 0, 80] } }, + { "name": "preset-dnd", "standalone": false, + "attrs": { "on_air": true, "label": "DO NOT DISTURB", "active_bg_color": [180, 0, 180] } }, + + { "name": "color-only", "standalone": false, + "attrs": { "on_air": true, "label": "IN MEETING", "active_text_color": [255, 140, 0] } }, + { "name": "bg-only", "standalone": false, + "attrs": { "on_air": true, "label": "IN MEETING", "active_bg_color": [255, 140, 0] } } + ], + "composites": [ + { + "name": "panel-sizes", + "columns": 2, + "cells": [ + {"shot": "size-64x32", "label": "64 x 32"}, + {"shot": "size-128x32", "label": "128 x 32"}, + {"shot": "size-128x64", "label": "128 x 64"}, + {"shot": "size-256x32", "label": "256 x 32"} + ] + }, + { + "name": "presets", + "columns": 2, + "cells": [ + {"shot": "preset-onair", "label": "broadcast red", "sublabel": "bg [200, 10, 10]"}, + {"shot": "preset-meeting", "label": "amber", "sublabel": "bg [255, 140, 0]"}, + {"shot": "preset-live", "label": "hot pink", "sublabel": "bg [255, 0, 80]"}, + {"shot": "preset-dnd", "label": "purple", "sublabel": "bg [180, 0, 180]"} + ] + }, + { + "name": "color-vs-bg", + "columns": 1, + "cells": [ + {"shot": "color-only", "label": "\"color\": [255, 140, 0]", "sublabel": "sets the text only"}, + {"shot": "bg-only", "label": "\"bg\": [255, 140, 0]", "sublabel": "sets the sign"} + ] + }, + { + "name": "on-off", + "columns": 2, + "cells": [ + {"shot": "on", "label": "ON", "sublabel": "holds the display"}, + {"shot": "off", "label": "OFF", "sublabel": "a black frame, cycled past"} + ] + } + ] +} diff --git a/plugins.json b/plugins.json index f900d57c..7505947e 100644 --- a/plugins.json +++ b/plugins.json @@ -578,10 +578,10 @@ "plugin_path": "plugins/on-air", "stars": 0, "downloads": 0, - "last_updated": "2026-07-31", + "last_updated": "2026-09-02", "verified": true, "screenshot": "", - "latest_version": "1.2.5", + "latest_version": "1.2.6", "icon": "fa-circle-dot" }, { diff --git a/plugins/on-air/README.md b/plugins/on-air/README.md index 2e9d4726..ccb64d68 100644 --- a/plugins/on-air/README.md +++ b/plugins/on-air/README.md @@ -2,6 +2,12 @@ A retro broadcast tally light for your LED matrix. Publish a single MQTT message to take over the display with a bold "ON AIR" sign — it holds until you send the off command, so it works as a persistent do-not-disturb signal during calls, recordings, or livestreams. Text, text color, and background color are all customizable. +![The ON AIR sign on a 128x32 panel: white PressStart2P lettering filling a +broadcast-red field](../../docs/assets/on-air/hero.png) + +*Every image in this README is real plugin output, rendered at the true panel +size and scaled up so the pixels stay pixels.* + --- ## Table of Contents @@ -49,23 +55,58 @@ mosquitto_pub -h -t ledmatrix/on-air/set -m OFF ## Plugin Configuration -| Field | Default | Description | -|---|---|---| -| **Sign Text** | `ON AIR` | Text shown when activated (max 32 chars). Overridable per-message via JSON. | -| **Text Color** | `[255, 255, 255]` | RGB color of the sign text. | -| **Background Color** | `[200, 10, 10]` | RGB background color — broadcast red by default. | -| **Font** | *(blank)* | Path to a TTF font relative to the LEDMatrix root (e.g. `assets/fonts/PressStart2P-Regular.ttf`). Blank uses the default font, auto-sized to 80% of display height. | -| **Font Size (px)** | `0` | Font height in pixels when a custom Font is set. `0` auto-sizes to 80% of display height. | -| **MQTT Broker Host** | `localhost` | IP or hostname of your MQTT broker. | -| **MQTT Port** | `1883` | Broker port (use 8883 for TLS). | -| **MQTT Username** | *(blank)* | Leave blank if no auth required. | -| **MQTT Password** | *(blank)* | Leave blank if no auth required. | -| **Command Topic** | `ledmatrix/on-air/set` | Topic the plugin **subscribes** to (publish `ON`/`OFF` or JSON). | -| **State Topic** | `ledmatrix/on-air/state` | Topic the plugin **publishes** to after each state change. | -| **Enable Home Assistant Auto-Discovery** | `true` | Announce to HA via MQTT discovery so it auto-creates the device (switch, label sensor, connectivity) — no `configuration.yaml` needed. | -| **HA Discovery Prefix** | `homeassistant` | MQTT topic prefix your HA MQTT integration listens on. | -| **Device Name in Home Assistant** | `LED Matrix — On Air` | How the device appears under Settings → Devices & Services → MQTT. | -| **Display Duration (seconds)** | `5` | How long the sign stays on screen each rotation cycle while active (1–60). | +Settings are edited in the plugin's tab in the LEDMatrix web UI. **Field** is the +label shown there; **Key** is the same setting as it appears in +`config/config.json` under `on-air`, which is what you need when editing the file +directly. The full schema is [`config_schema.json`](config_schema.json). + +| Field | Key | Default | Description | +|---|---|---|---| +| *(header toggle)* | `enabled` | `true` | Master switch, shown as the toggle in the tab header rather than a field in the form. | +| **Sign Text** | `default_label` | `ON AIR` | Text shown when activated (up to 32 characters). Overridable per-message via JSON. | +| **Text Color** | `text_color` | `[255, 255, 255]` | RGB color of the sign text. | +| **Background Color** | `background_color` | `[200, 10, 10]` | RGB background color — broadcast red by default. | +| **Font** | `font_path` | *(blank)* | Path to a TTF font relative to the LEDMatrix root (e.g. `assets/fonts/PressStart2P-Regular.ttf`). Blank auto-selects one from the LEDMatrix assets folder, sized to 80% of display height. | +| **Font Size (px)** | `font_size` | `0` | Font height in pixels when a custom Font is set. `0` auto-sizes to 80% of display height. | +| **MQTT Broker Host** | `mqtt_host` | `localhost` | IP or hostname of your MQTT broker. | +| **MQTT Port** | `mqtt_port` | `1883` | Broker port (use 8883 for TLS). | +| **MQTT Username** | `mqtt_username` | *(blank)* | Leave blank if no auth required. | +| **MQTT Password** | `mqtt_password` | *(blank)* | Leave blank if no auth required. Marked secret, so the web UI masks it. | +| **Command Topic** | `command_topic` | `ledmatrix/on-air/set` | Topic the plugin **subscribes** to (publish `ON`/`OFF` or JSON). | +| **State Topic** | `state_topic` | `ledmatrix/on-air/state` | Topic the plugin **publishes** to after each state change. | +| **Enable Home Assistant Auto-Discovery** | `ha_discovery` | `true` | Announce to HA via MQTT discovery so it auto-creates the device (switch, label sensor, connectivity) — no `configuration.yaml` needed. | +| **HA Discovery Prefix** | `discovery_prefix` | `homeassistant` | MQTT topic prefix your HA MQTT integration listens on. | +| **Device Name in Home Assistant** | `device_name` | `LED Matrix — On Air` | How the device appears under Settings → Devices & Services → MQTT. | +| **Display Duration (seconds)** | `display_duration` | `5` | How long the sign stays on screen each rotation cycle while active (1–60). | + +```json +{ + "on-air": { + "enabled": true, + "default_label": "ON AIR", + "background_color": [200, 10, 10], + "mqtt_host": "homeassistant.local" + } +} +``` + +### What the two states look like + +![The sign on and off](../../docs/assets/on-air/on-off.png) + +**ON** pins the display: the sign holds the panel and your other plugins do not +get a turn until an off command arrives. **OFF** renders a plain black frame +rather than dropping out of the rotation — the display duration cycles past it +almost instantly, and it avoids the display controller briefly falling back to +its "Initializing" screen while the stop request is processed. + +### Sizing + +The sign auto-sizes: the font is loaded at 80% of the panel height, then scaled +down if the text would be wider than 95% of the panel. A longer label therefore +comes out smaller rather than clipped. + +![The same sign on four panel sizes](../../docs/assets/on-air/panel-sizes.png) --- @@ -95,7 +136,7 @@ Case-insensitive. Any of these work. {"state": "on", "label": "LIVE"} ``` -The label (max 16 chars) replaces "ON AIR" on the display. On panels 128 px wide or larger, "ON AIR" appears as the header with your label as a subtitle below it. +The label replaces the sign text entirely — "ON AIR" is not kept as a header, and the label is not truncated. It is drawn on its own, centred, at whatever size fits the panel, so a long label simply comes out smaller. It lasts until the next command; an `ON` with no label restores the configured **Sign Text**. ### JSON — state + label + color @@ -114,6 +155,16 @@ The label (max 16 chars) replaces "ON AIR" on the display. On panels 128 px wide | Livestreaming | `[255, 0, 80]` — hot pink | | Do Not Disturb | `[180, 0, 180]` — purple | +![The four background presets](../../docs/assets/on-air/presets.png) + +> **`bg` sets the sign; `color` sets only the lettering.** They are easy to mix +> up, and the result is very different: +> +> ![The same label sent with color versus bg](../../docs/assets/on-air/color-vs-bg.png) +> +> If you want an amber sign, send `bg`. Sending `color` leaves the background at +> its configured red and just tints the text. + --- ## Home Assistant Setup @@ -333,13 +384,13 @@ action: payload: > {% set mode = trigger.to_state.state %} {% if mode == 'Recording' %} - {"state": "on", "label": "RECORDING", "color": [255, 20, 20]} + {"state": "on", "label": "RECORDING", "bg": [255, 20, 20]} {% elif mode == 'Meeting' %} - {"state": "on", "label": "IN MEETING", "color": [255, 140, 0]} + {"state": "on", "label": "IN MEETING", "bg": [255, 140, 0]} {% elif mode == 'Livestream' %} - {"state": "on", "label": "LIVE", "color": [255, 0, 80]} + {"state": "on", "label": "LIVE", "bg": [255, 0, 80]} {% elif mode == 'Focus' %} - {"state": "on", "label": "DO NOT DISTURB", "color": [180, 0, 180]} + {"state": "on", "label": "DO NOT DISTURB", "bg": [180, 0, 180]} {% else %} OFF {% endif %} @@ -379,13 +430,13 @@ action: {% if s == 'Off' %} OFF {% elif s == 'On Air' %} - {"state": "on", "label": "ON AIR", "color": [255, 20, 20]} + {"state": "on", "label": "ON AIR", "bg": [255, 20, 20]} {% elif s == 'Recording' %} - {"state": "on", "label": "RECORDING", "color": [255, 20, 20]} + {"state": "on", "label": "RECORDING", "bg": [255, 20, 20]} {% elif s == 'In Meeting' %} - {"state": "on", "label": "IN MEETING", "color": [255, 140, 0]} + {"state": "on", "label": "IN MEETING", "bg": [255, 140, 0]} {% elif s == 'Live' %} - {"state": "on", "label": "LIVE", "color": [255, 0, 80]} + {"state": "on", "label": "LIVE", "bg": [255, 0, 80]} {% endif %} ``` @@ -411,7 +462,7 @@ mosquitto_pub -h -t ledmatrix/on-air/set \ # Turn on with label + amber color mosquitto_pub -h -t ledmatrix/on-air/set \ - -m '{"state": "on", "label": "IN MEETING", "color": [255, 140, 0]}' + -m '{"state": "on", "label": "IN MEETING", "bg": [255, 140, 0]}' # Watch state feedback in another terminal mosquitto_sub -h -t ledmatrix/on-air/state diff --git a/plugins/on-air/manager.py b/plugins/on-air/manager.py index c40b1ad9..09228e74 100644 --- a/plugins/on-air/manager.py +++ b/plugins/on-air/manager.py @@ -25,7 +25,7 @@ import time import uuid from pathlib import Path -from typing import Any, Dict, Optional, Tuple +from typing import Any, Dict, List, Optional, Tuple from PIL import Image, ImageDraw, ImageFont @@ -100,6 +100,29 @@ def __init__(self, plugin_id: str, config: Dict[str, Any], # ── Font helpers ──────────────────────────────────────────────────────────── + @staticmethod + def _project_roots() -> List[Path]: + """Directories that may hold the LEDMatrix ``assets/fonts`` folder. + + The plugin normally sits at ``/plugin-repos/``, so two + levels up is the root -- but the plugins directory is configurable, and + when it lives outside the core tree that guess finds nothing and the + sign silently drops to a built-in font a fraction of the intended size. + Ask the imported core where it is first, and keep the old guess and the + working directory as fallbacks. + """ + roots: List[Path] = [] + try: + import src # the LEDMatrix core package + if getattr(src, '__file__', None): + roots.append(Path(src.__file__).resolve().parent.parent) + except Exception: # nosec B110 - absence is expected off a real install + pass + roots.append(Path(__file__).resolve().parent.parent.parent) + roots.append(Path.cwd()) + seen = set() + return [r for r in roots if not (str(r) in seen or seen.add(str(r)))] + def _load_configured_font(self) -> Optional[ImageFont.FreeTypeFont]: """Load font from font_path at font_size; returns None to use built-ins.""" path = (self.font_path or '').strip() @@ -109,10 +132,7 @@ def _load_configured_font(self) -> Optional[ImageFont.FreeTypeFont]: # Resolve path: absolute → cwd-relative → project-root-relative candidates = [path] if not os.path.isabs(path): - candidates.append(os.path.join(os.getcwd(), path)) - plugin_dir = Path(__file__).parent - project_root = plugin_dir.parent.parent - candidates.append(str(project_root / path)) + candidates.extend(str(root / path) for root in self._project_roots()) for candidate in candidates: if os.path.exists(candidate): try: @@ -126,21 +146,20 @@ def _load_configured_font(self) -> Optional[ImageFont.FreeTypeFont]: def _find_system_ttf(self) -> Optional[str]: """Search the LEDMatrix assets folder for a usable TTF font file.""" - plugin_dir = Path(__file__).parent - project_root = plugin_dir.parent.parent preferred = [ 'assets/fonts/PressStart2P-Regular.ttf', 'assets/fonts/4x6-font.ttf', ] - for rel in preferred: - p = project_root / rel - if p.exists(): - return str(p) - # Fall back to any .ttf found in assets/fonts/ - fonts_dir = project_root / 'assets' / 'fonts' - if fonts_dir.is_dir(): - for p in sorted(fonts_dir.glob('*.ttf')): - return str(p) + for project_root in self._project_roots(): + for rel in preferred: + p = project_root / rel + if p.exists(): + return str(p) + # Fall back to any .ttf found in assets/fonts/ + fonts_dir = project_root / 'assets' / 'fonts' + if fonts_dir.is_dir(): + for p in sorted(fonts_dir.glob('*.ttf')): + return str(p) return None def _auto_font(self, dh: int): diff --git a/plugins/on-air/manifest.json b/plugins/on-air/manifest.json index cdfa8109..f28a8536 100644 --- a/plugins/on-air/manifest.json +++ b/plugins/on-air/manifest.json @@ -1,7 +1,7 @@ { "id": "on-air", "name": "On Air Light", - "version": "1.2.5", + "version": "1.2.6", "author": "ChuckBuilds", "description": "Retro broadcast ON AIR tally light. Activate remotely via MQTT or Home Assistant to signal you're on a call, recording, or live \u2014 stays on until you turn it off.", "entry_point": "manager.py", @@ -22,6 +22,12 @@ ">=2.0.0" ], "versions": [ + { + "version": "1.2.6", + "released": "2026-09-02", + "notes": "Font resolution and documentation. The sign looked for the LEDMatrix assets folder two levels above the plugin, which is only correct when the plugins directory sits inside the core tree; anywhere else it found nothing and silently fell back to a built-in font a fraction of the intended size. It now asks the imported core where it lives, keeping the old guess and the working directory as fallbacks. The README gains real rendered screenshots and the config.json key for every setting, corrects two claims about the label that the code does not implement, and fixes five automation examples that set 'color' where they meant 'bg'.", + "ledmatrix_min_version": "2.0.0" + }, { "version": "1.2.5", "released": "2026-08-15", @@ -75,7 +81,7 @@ "license": "GPL-3.0", "homepage": "https://github.com/ChuckBuilds/ledmatrix-plugins/tree/main/plugins/on-air", "config_schema": "config_schema.json", - "last_updated": "2026-07-31", + "last_updated": "2026-09-02", "stars": 0, "downloads": 0, "verified": true, diff --git a/scripts/docs_render_support/sitecustomize.py b/scripts/docs_render_support/sitecustomize.py index 20844d5e..90fe76c1 100644 --- a/scripts/docs_render_support/sitecustomize.py +++ b/scripts/docs_render_support/sitecustomize.py @@ -52,3 +52,65 @@ def today(cls): _docs_http_replay.install() except Exception: # never let doc tooling break the render it is measuring pass + + +# Some plugins hold their interesting state in memory, put there by an event +# the renderer cannot produce -- an MQTT message, a webhook, a button press. +# No configuration reaches that state, so a documentation render would only +# ever show the idle frame. LEDMATRIX_DOCS_ATTRS names attributes to set on the +# plugin instance once the core's loader has built it, which is the same thing +# the event would have done, without the plugin knowing it is being rendered. +_ATTRS = os.environ.get("LEDMATRIX_DOCS_ATTRS") +if _ATTRS: + import json as _json + import sys as _sys + + _WANTED = _json.loads(_ATTRS) + _TARGET = "src.plugin_system.plugin_loader" + + def _apply(instance): + for name, value in _WANTED.items(): + if isinstance(value, list) and len(value) == 3 and all( + isinstance(v, int) for v in value): + value = tuple(value) # colours are tuples everywhere in the core + setattr(instance, name, value) + return instance + + class _PatchingLoader: + """Wraps the real loader so the module is patched right after it runs.""" + + def __init__(self, inner): + self._inner = inner + + def create_module(self, spec): + return self._inner.create_module(spec) + + def exec_module(self, module): + self._inner.exec_module(module) + real = module.PluginLoader.load_plugin + + def load_plugin(self, *args, **kwargs): + instance, mod = real(self, *args, **kwargs) + return _apply(instance), mod + + module.PluginLoader.load_plugin = load_plugin + + def __getattr__(self, name): + return getattr(self._inner, name) + + class _AttrFinder: + """Hands back the real spec with its loader wrapped, once.""" + + def find_spec(self, name, path=None, target=None): + if name != _TARGET: + return None + index = _sys.meta_path.index(self) + for finder in _sys.meta_path[index + 1:]: + find = getattr(finder, "find_spec", None) + spec = find(name, path, target) if find else None + if spec is not None and spec.loader is not None: + spec.loader = _PatchingLoader(spec.loader) + return spec + return None + + _sys.meta_path.insert(0, _AttrFinder()) diff --git a/scripts/render_docs_assets.py b/scripts/render_docs_assets.py index 42a1d93d..36c7bd4b 100644 --- a/scripts/render_docs_assets.py +++ b/scripts/render_docs_assets.py @@ -263,12 +263,16 @@ def render_shot( # clock, a countdown, or a "starts in 2h" line differs on every run. freeze_time = shot.get("freeze_time", defaults.get("freeze_time")) http_replay = shot.get("http_replay", defaults.get("http_replay")) - if freeze_time or http_replay: + # Runtime state that an event would normally have set; see the shim. + attrs = shot.get("attrs", defaults.get("attrs")) + if freeze_time or http_replay or attrs: support_dir = str(Path(__file__).resolve().parent / "docs_render_support") existing = env.get("PYTHONPATH") env["PYTHONPATH"] = f"{support_dir}{os.pathsep}{existing}" if existing else support_dir if freeze_time: env["LEDMATRIX_DOCS_FREEZE_TIME"] = str(freeze_time) + if attrs: + env["LEDMATRIX_DOCS_ATTRS"] = json.dumps(attrs) if http_replay: replay_path = (shot_list_dir / http_replay).resolve() if not replay_path.is_file():