From 3ca20334f8f491f22ce1998aa2970a62ae2faad0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 18:19:06 -0400 Subject: [PATCH] fix(schemas): make 26 declared settings reachable in the config form The web UI's config form iterates x-propertyOrder and nothing else: {% set property_order = schema['x-propertyOrder'] if 'x-propertyOrder' in schema else schema.properties.keys()|list %} {% for key in property_order %} {% if key in schema.properties %} A property the schema declares but that list omits is therefore never rendered. No field, no error, no hint the setting exists. The value still validates on save and the plugin still reads it, so the only way to set one was to hand-edit config.json on the device. Twenty-six settings across seven plugins were in that state: ledmatrix-flights flightaware_api_key + 7 more basketball scroll_card, background_service, both idle intervals afl / nrl / soccer scroll_card, both idle intervals masters-tournament four duration/sizing settings f1-scoreboard customization.auto_scale Two of those deserve calling out. ledmatrix-flights' flightaware_api_key is marked x-secret: true -- someone set up masking for a field that could not be typed into. And the idle-poll intervals are the ones whose plumbing was fixed in #295 so they would finally take effect; they still could not be set. These are omissions, not deliberate hiding. Twenty-two of the twenty-six already carry x-advanced: true, and nobody flags a field "advanced" meaning "invisible" -- x-advanced is the supported way to de-emphasise one, and it puts the field in a collapsed Advanced Settings section. There is no supported way to hide a property and no schema in the repo attempts it. What hid this for so long is an asymmetry between the two renderers: the client-side one in app-shell.js sorts unlisted properties into an unorderedEntries list and still shows them, while the server-rendered form drops them. The same schema looks fine in one and is unreachable in the other. Order-only change: verified per plugin that the schemas are byte-identical once x-propertyOrder is stripped, so nothing was added, renamed or retyped. scripts/test_property_order_coverage.py guards it, and does not merely assert the rule -- it lifts the ordering loop out of the shipped template, renders it with a two-property schema listing only one, and shows the other never reaches the form. Mutation-checked: dropping flightaware_api_key back out fails the check by name. All 52 test suites across the seven plugins exit 0. --- plugins.json | 14 +- plugins/afl-scoreboard/config_schema.json | 7 +- plugins/afl-scoreboard/manifest.json | 8 +- .../basketball-scoreboard/config_schema.json | 6 +- plugins/basketball-scoreboard/manifest.json | 8 +- plugins/f1-scoreboard/config_schema.json | 271 +++++++++++++++--- plugins/f1-scoreboard/manifest.json | 8 +- plugins/ledmatrix-flights/config_schema.json | 41 ++- plugins/ledmatrix-flights/manifest.json | 8 +- plugins/masters-tournament/config_schema.json | 22 +- plugins/masters-tournament/manifest.json | 8 +- plugins/nrl-scoreboard/config_schema.json | 7 +- plugins/nrl-scoreboard/manifest.json | 8 +- plugins/soccer-scoreboard/config_schema.json | 27 +- plugins/soccer-scoreboard/manifest.json | 12 +- scripts/test_property_order_coverage.py | 121 ++++++++ 16 files changed, 493 insertions(+), 83 deletions(-) create mode 100644 scripts/test_property_order_coverage.py diff --git a/plugins.json b/plugins.json index 5b8f3de6..df1ae59e 100644 --- a/plugins.json +++ b/plugins.json @@ -101,7 +101,7 @@ "last_updated": "2026-08-13", "verified": true, "screenshot": "", - "latest_version": "1.16.0" + "latest_version": "1.16.1" }, { "id": "calendar", @@ -215,7 +215,7 @@ "last_updated": "2026-08-05", "verified": true, "screenshot": "", - "latest_version": "1.8.3" + "latest_version": "1.8.4" }, { "id": "football-scoreboard", @@ -439,7 +439,7 @@ "last_updated": "2026-08-03", "verified": true, "screenshot": "", - "latest_version": "1.12.11" + "latest_version": "1.12.12" }, { "id": "march-madness", @@ -487,7 +487,7 @@ "last_updated": "2026-08-05", "verified": true, "screenshot": "", - "latest_version": "2.5.4" + "latest_version": "2.5.5" }, { "id": "mqtt-notifications", @@ -760,7 +760,7 @@ "last_updated": "2026-08-13", "verified": true, "screenshot": "", - "latest_version": "2.12.0" + "latest_version": "2.12.1" }, { "id": "static-image", @@ -1048,7 +1048,7 @@ "downloads": 0, "verified": true, "screenshot": "", - "latest_version": "1.9.0", + "latest_version": "1.9.1", "last_updated": "2026-08-13" }, { @@ -1095,7 +1095,7 @@ "last_updated": "2026-08-13", "verified": true, "screenshot": "", - "latest_version": "1.9.0" + "latest_version": "1.9.1" }, { "id": "jellyfin-now-playing", diff --git a/plugins/afl-scoreboard/config_schema.json b/plugins/afl-scoreboard/config_schema.json index a6beea3e..bd7cbb7f 100644 --- a/plugins/afl-scoreboard/config_schema.json +++ b/plugins/afl-scoreboard/config_schema.json @@ -566,7 +566,7 @@ "mode_durations": { "type": "object", "title": "Mode-Level Durations", - "description": "Control total duration for each mode type. If not set, uses dynamic calculation (total_games \u00d7 per_game_duration).", + "description": "Control total duration for each mode type. If not set, uses dynamic calculation (total_games × per_game_duration).", "properties": { "recent_mode_duration": { "type": [ @@ -1330,6 +1330,9 @@ "background_service", "customization", "schedule_lookback_days", - "schedule_lookahead_days" + "schedule_lookahead_days", + "scroll_card", + "no_data_interval_seconds", + "live_idle_max_interval_seconds" ] } diff --git a/plugins/afl-scoreboard/manifest.json b/plugins/afl-scoreboard/manifest.json index 13d1d7d4..a4669c76 100644 --- a/plugins/afl-scoreboard/manifest.json +++ b/plugins/afl-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "afl-scoreboard", "name": "AFL Scoreboard", - "version": "1.9.0", + "version": "1.9.1", "author": "ChuckBuilds", "description": "Live, recent, and upcoming AFL (Australian Football League) games with real-time scores and game status.", "category": "sports", @@ -18,6 +18,12 @@ "afl_upcoming" ], "versions": [ + { + "version": "1.9.1", + "released": "2026-08-19", + "ledmatrix_min_version": "2.0.0", + "notes": "Make scroll_card and the two idle-poll intervals reachable in the web UI. The config form renders only the properties named in x-propertyOrder, so a setting the schema declares but that list omits was never drawn -- no field, no error, no hint it existed, and the only way to set it was to hand-edit config.json on the device. The values already validated on save and the plugin already read them; only the form was missing them." + }, { "version": "1.9.0", "released": "2026-08-19", diff --git a/plugins/basketball-scoreboard/config_schema.json b/plugins/basketball-scoreboard/config_schema.json index 2e389166..ba4afe4f 100644 --- a/plugins/basketball-scoreboard/config_schema.json +++ b/plugins/basketball-scoreboard/config_schema.json @@ -2558,6 +2558,10 @@ "ncaaw", "customization", "schedule_lookback_days", - "schedule_lookahead_days" + "schedule_lookahead_days", + "scroll_card", + "background_service", + "no_data_interval_seconds", + "live_idle_max_interval_seconds" ] } diff --git a/plugins/basketball-scoreboard/manifest.json b/plugins/basketball-scoreboard/manifest.json index 5e094852..c64e9694 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.16.0", + "version": "1.16.1", "description": "Live, recent, and upcoming basketball games across NBA, NCAA Men's, NCAA Women's, and WNBA with real-time scores, schedules, and March Madness tournament support", "author": "ChuckBuilds", "category": "sports", @@ -18,6 +18,12 @@ "branch": "main", "plugin_path": "plugins/basketball-scoreboard", "versions": [ + { + "version": "1.16.1", + "released": "2026-08-19", + "ledmatrix_min_version": "2.0.0", + "notes": "Make scroll_card, background_service and the two idle-poll intervals reachable in the web UI. The config form renders only the properties named in x-propertyOrder, so a setting the schema declares but that list omits was never drawn -- no field, no error, no hint it existed, and the only way to set it was to hand-edit config.json on the device. The values already validated on save and the plugin already read them; only the form was missing them." + }, { "version": "1.16.0", "released": "2026-08-19", diff --git a/plugins/f1-scoreboard/config_schema.json b/plugins/f1-scoreboard/config_schema.json index 0830dc3f..3dd2bbc5 100644 --- a/plugins/f1-scoreboard/config_schema.json +++ b/plugins/f1-scoreboard/config_schema.json @@ -73,7 +73,11 @@ "description": "Always include favorite driver even if outside top N" } }, - "x-propertyOrder": ["enabled", "top_n", "always_show_favorite"], + "x-propertyOrder": [ + "enabled", + "top_n", + "always_show_favorite" + ], "additionalProperties": false }, "constructor_standings": { @@ -111,7 +115,12 @@ "description": "Show each constructor's two drivers and their individual point contributions (e.g. NOR 189 | PIA 145)" } }, - "x-propertyOrder": ["enabled", "top_n", "always_show_favorite", "show_driver_split"], + "x-propertyOrder": [ + "enabled", + "top_n", + "always_show_favorite", + "show_driver_split" + ], "additionalProperties": false }, "recent_races": { @@ -204,7 +213,19 @@ "description": "Number of drivers to show on the race gap bar chart (3-10)" } }, - "x-propertyOrder": ["enabled", "number_of_races", "top_finishers", "always_show_favorite", "show_position_delta", "show_dnf_status", "show_points_haul", "points_haul_drivers", "show_winners_summary", "show_gap_chart", "gap_chart_drivers"], + "x-propertyOrder": [ + "enabled", + "number_of_races", + "top_finishers", + "always_show_favorite", + "show_position_delta", + "show_dnf_status", + "show_points_haul", + "points_haul_drivers", + "show_winners_summary", + "show_gap_chart", + "gap_chart_drivers" + ], "additionalProperties": false }, "upcoming": { @@ -240,7 +261,12 @@ "description": "Show a circuit facts card after the upcoming race card: laps, distance, and lap record" } }, - "x-propertyOrder": ["enabled", "show_session_times", "countdown_enabled", "show_circuit_info"], + "x-propertyOrder": [ + "enabled", + "show_session_times", + "countdown_enabled", + "show_circuit_info" + ], "additionalProperties": false }, "qualifying": { @@ -290,7 +316,14 @@ "description": "Show a summary card at the start of qualifying with each team's intra-team battle (which driver outqualified their teammate and by how many positions)" } }, - "x-propertyOrder": ["enabled", "show_q3", "show_q2", "show_q1", "show_gaps", "show_team_duel"], + "x-propertyOrder": [ + "enabled", + "show_q3", + "show_q2", + "show_q1", + "show_gaps", + "show_team_duel" + ], "additionalProperties": false }, "practice": { @@ -310,9 +343,17 @@ "x-widget": "checkbox-group", "items": { "type": "string", - "enum": ["FP1", "FP2", "FP3"] + "enum": [ + "FP1", + "FP2", + "FP3" + ] }, - "default": ["FP1", "FP2", "FP3"], + "default": [ + "FP1", + "FP2", + "FP3" + ], "description": "Which free practice sessions to display" }, "top_n": { @@ -325,7 +366,11 @@ "description": "Number of top drivers to show per practice session" } }, - "x-propertyOrder": ["enabled", "sessions_to_show", "top_n"], + "x-propertyOrder": [ + "enabled", + "sessions_to_show", + "top_n" + ], "additionalProperties": false }, "sprint": { @@ -349,7 +394,10 @@ "description": "Number of top finishers to display" } }, - "x-propertyOrder": ["enabled", "top_finishers"], + "x-propertyOrder": [ + "enabled", + "top_finishers" + ], "additionalProperties": false }, "calendar": { @@ -394,7 +442,13 @@ "description": "Maximum number of upcoming race weekends to show" } }, - "x-propertyOrder": ["enabled", "show_practice", "show_qualifying", "show_sprint", "max_events"], + "x-propertyOrder": [ + "enabled", + "show_practice", + "show_qualifying", + "show_sprint", + "max_events" + ], "additionalProperties": false }, "vegas": { @@ -408,13 +462,31 @@ "x-widget": "checkbox-group", "items": { "type": "string", - "enum": ["leaders", "battles", "spotlight", "upcoming", "last_race", "driver_standings", "constructor_standings", "recent_races", "qualifying", "practice", "sprint", "calendar"] + "enum": [ + "leaders", + "battles", + "spotlight", + "upcoming", + "last_race", + "driver_standings", + "constructor_standings", + "recent_races", + "qualifying", + "practice", + "sprint", + "calendar" + ] }, - "default": ["upcoming", "last_race"], + "default": [ + "upcoming", + "last_race" + ], "description": "Which sections join the marquee. The order you list them in does not matter — they are always shown in the fixed order leaders, battles, spotlight, upcoming, last_race, driver_standings, constructor_standings, recent_races, qualifying, practice, sprint, calendar. Default is the next race and the last race's results. Adding more makes F1 hold the marquee for longer — every section at once measured 114 cards, close to six minutes of unbroken F1 on a 512px panel. 'last_race' is the most recent race only; 'recent_races' is all of them. Leave empty to keep F1 out of the marquee entirely." } }, - "x-propertyOrder": ["sections"], + "x-propertyOrder": [ + "sections" + ], "additionalProperties": false }, "dynamic_duration": { @@ -439,7 +511,10 @@ "description": "Maximum seconds before forcing rotation even if scroll is incomplete" } }, - "x-propertyOrder": ["enabled", "max_duration_seconds"], + "x-propertyOrder": [ + "enabled", + "max_duration_seconds" + ], "additionalProperties": false }, "scroll": { @@ -498,14 +573,25 @@ "x-advanced": true, "type": "array", "title": "Color (RGB)", - "items": { "type": "integer", "minimum": 0, "maximum": 255 }, + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, "minItems": 3, "maxItems": 3, - "default": [180, 0, 255], + "default": [ + 180, + 0, + 255 + ], "description": "RGB color of the fastest lap dot (default: purple [180, 0, 255])" } }, - "x-propertyOrder": ["enabled", "color"], + "x-propertyOrder": [ + "enabled", + "color" + ], "additionalProperties": false }, "gap_bar": { @@ -521,7 +607,9 @@ "description": "Show the points gap bar in driver and constructor standings" } }, - "x-propertyOrder": ["enabled"], + "x-propertyOrder": [ + "enabled" + ], "additionalProperties": false }, "standings_header": { @@ -544,7 +632,10 @@ "description": "Show current round number and season progress bar on the header card" } }, - "x-propertyOrder": ["enabled", "show_round"], + "x-propertyOrder": [ + "enabled", + "show_round" + ], "additionalProperties": false }, "circuit_map": { @@ -560,7 +651,9 @@ "description": "Show circuit layout diagram on the upcoming race card" } }, - "x-propertyOrder": ["enabled"], + "x-propertyOrder": [ + "enabled" + ], "additionalProperties": false }, "championship_leaders": { @@ -576,7 +669,9 @@ "description": "Show the championship leaders intro card in the scroll sequence" } }, - "x-propertyOrder": ["enabled"], + "x-propertyOrder": [ + "enabled" + ], "additionalProperties": false }, "championship_battle": { @@ -592,7 +687,9 @@ "description": "Show the driver championship battle card in the scroll sequence" } }, - "x-propertyOrder": ["enabled"], + "x-propertyOrder": [ + "enabled" + ], "additionalProperties": false }, "constructor_battle": { @@ -608,7 +705,9 @@ "description": "Show the constructor championship battle card in the scroll sequence" } }, - "x-propertyOrder": ["enabled"], + "x-propertyOrder": [ + "enabled" + ], "additionalProperties": false }, "driver_form": { @@ -624,7 +723,9 @@ "description": "Show the driver form guide card at the start of the driver standings section" } }, - "x-propertyOrder": ["enabled"], + "x-propertyOrder": [ + "enabled" + ], "additionalProperties": false }, "last_race_points": { @@ -640,11 +741,23 @@ "description": "Show last-race points (+N) in the stat zone of each driver and constructor standings card" } }, - "x-propertyOrder": ["enabled"], + "x-propertyOrder": [ + "enabled" + ], "additionalProperties": false } }, - "x-propertyOrder": ["fastest_lap_dot", "gap_bar", "standings_header", "circuit_map", "championship_leaders", "championship_battle", "constructor_battle", "driver_form", "last_race_points"], + "x-propertyOrder": [ + "fastest_lap_dot", + "gap_bar", + "standings_header", + "circuit_map", + "championship_leaders", + "championship_battle", + "constructor_battle", + "driver_form", + "last_race_points" + ], "additionalProperties": false }, "customization": { @@ -667,7 +780,18 @@ "x-advanced": true, "type": "string", "title": "Font", - "enum": ["", "4x6.bdf", "5x8.bdf", "6x10.bdf", "7x13.bdf", "9x15.bdf", "10x20.bdf", "PressStart2P-Regular.ttf", "4x6-font.ttf", "5by7.regular.ttf"], + "enum": [ + "", + "4x6.bdf", + "5x8.bdf", + "6x10.bdf", + "7x13.bdf", + "9x15.bdf", + "10x20.bdf", + "PressStart2P-Regular.ttf", + "4x6-font.ttf", + "5by7.regular.ttf" + ], "default": "" }, "font_size": { @@ -679,7 +803,10 @@ "default": 8 } }, - "x-propertyOrder": ["font", "font_size"], + "x-propertyOrder": [ + "font", + "font_size" + ], "additionalProperties": false }, "position_text": { @@ -691,7 +818,18 @@ "x-advanced": true, "type": "string", "title": "Font", - "enum": ["", "4x6.bdf", "5x8.bdf", "6x10.bdf", "7x13.bdf", "9x15.bdf", "10x20.bdf", "PressStart2P-Regular.ttf", "4x6-font.ttf", "5by7.regular.ttf"], + "enum": [ + "", + "4x6.bdf", + "5x8.bdf", + "6x10.bdf", + "7x13.bdf", + "9x15.bdf", + "10x20.bdf", + "PressStart2P-Regular.ttf", + "4x6-font.ttf", + "5by7.regular.ttf" + ], "default": "" }, "font_size": { @@ -703,7 +841,10 @@ "default": 8 } }, - "x-propertyOrder": ["font", "font_size"], + "x-propertyOrder": [ + "font", + "font_size" + ], "additionalProperties": false }, "detail_text": { @@ -715,7 +856,18 @@ "x-advanced": true, "type": "string", "title": "Font", - "enum": ["", "4x6.bdf", "5x8.bdf", "6x10.bdf", "7x13.bdf", "9x15.bdf", "10x20.bdf", "PressStart2P-Regular.ttf", "4x6-font.ttf", "5by7.regular.ttf"], + "enum": [ + "", + "4x6.bdf", + "5x8.bdf", + "6x10.bdf", + "7x13.bdf", + "9x15.bdf", + "10x20.bdf", + "PressStart2P-Regular.ttf", + "4x6-font.ttf", + "5by7.regular.ttf" + ], "default": "" }, "font_size": { @@ -727,7 +879,10 @@ "default": 6 } }, - "x-propertyOrder": ["font", "font_size"], + "x-propertyOrder": [ + "font", + "font_size" + ], "additionalProperties": false }, "small_text": { @@ -739,7 +894,18 @@ "x-advanced": true, "type": "string", "title": "Font", - "enum": ["", "4x6.bdf", "5x8.bdf", "6x10.bdf", "7x13.bdf", "9x15.bdf", "10x20.bdf", "PressStart2P-Regular.ttf", "4x6-font.ttf", "5by7.regular.ttf"], + "enum": [ + "", + "4x6.bdf", + "5x8.bdf", + "6x10.bdf", + "7x13.bdf", + "9x15.bdf", + "10x20.bdf", + "PressStart2P-Regular.ttf", + "4x6-font.ttf", + "5by7.regular.ttf" + ], "default": "" }, "font_size": { @@ -751,15 +917,46 @@ "default": 6 } }, - "x-propertyOrder": ["font", "font_size"], + "x-propertyOrder": [ + "font", + "font_size" + ], "additionalProperties": false } }, - "x-propertyOrder": ["header_text", "position_text", "detail_text", "small_text"], + "x-propertyOrder": [ + "header_text", + "position_text", + "detail_text", + "small_text", + "auto_scale" + ], "additionalProperties": false } }, - "x-propertyOrder": ["enabled", "display_duration", "update_interval", "favorite_team", "favorite_driver", "timezone", "driver_standings", "constructor_standings", "recent_races", "upcoming", "qualifying", "practice", "sprint", "calendar", "vegas", "dynamic_duration", "scroll", "visual", "customization"], + "x-propertyOrder": [ + "enabled", + "display_duration", + "update_interval", + "favorite_team", + "favorite_driver", + "timezone", + "driver_standings", + "constructor_standings", + "recent_races", + "upcoming", + "qualifying", + "practice", + "sprint", + "calendar", + "vegas", + "dynamic_duration", + "scroll", + "visual", + "customization" + ], "additionalProperties": false, - "required": ["enabled"] + "required": [ + "enabled" + ] } diff --git a/plugins/f1-scoreboard/manifest.json b/plugins/f1-scoreboard/manifest.json index 8992e247..57a9db58 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.8.3", + "version": "1.8.4", "author": "ChuckBuilds", "class_name": "F1ScoreboardPlugin", "entry_point": "manager.py", @@ -29,6 +29,12 @@ "f1_calendar" ], "versions": [ + { + "version": "1.8.4", + "released": "2026-08-19", + "ledmatrix_min_version": "2.0.0", + "notes": "Make customization.auto_scale reachable in the web UI. The config form renders only the properties named in x-propertyOrder, so a setting the schema declares but that list omits was never drawn -- no field, no error, no hint it existed, and the only way to set it was to hand-edit config.json on the device. The values already validated on save and the plugin already read them; only the form was missing them." + }, { "version": "1.8.3", "released": "2026-08-14", diff --git a/plugins/ledmatrix-flights/config_schema.json b/plugins/ledmatrix-flights/config_schema.json index 0b9b0a13..972209e3 100644 --- a/plugins/ledmatrix-flights/config_schema.json +++ b/plugins/ledmatrix-flights/config_schema.json @@ -388,7 +388,10 @@ "type": "string", "title": "Altimeter Unit", "description": "Altimeter setting display: 'inhg' shows US style (A30.01), 'hpa' shows international style (Q1016).", - "enum": ["inhg", "hpa"], + "enum": [ + "inhg", + "hpa" + ], "default": "inhg" }, "temp_unit": { @@ -396,7 +399,10 @@ "type": "string", "title": "Temperature Unit", "description": "Temperature/dewpoint unit on the decoded card.", - "enum": ["c", "f"], + "enum": [ + "c", + "f" + ], "default": "c" }, "wind_unit": { @@ -404,7 +410,12 @@ "type": "string", "title": "Wind Speed Unit", "description": "Wind speed unit. Aviation standard is knots (kt).", - "enum": ["kt", "mph", "kmh", "ms"], + "enum": [ + "kt", + "mph", + "kmh", + "ms" + ], "default": "kt" }, "visibility_unit": { @@ -412,7 +423,11 @@ "type": "string", "title": "Visibility Unit", "description": "Visibility unit: statute miles (US), metres or kilometres (international).", - "enum": ["sm", "m", "km"], + "enum": [ + "sm", + "m", + "km" + ], "default": "sm" } }, @@ -690,7 +705,7 @@ "x-advanced": true, "type": "boolean", "title": "Show Airline Logos", - "description": "Show airline logos in area mode (8\u00d78 pixel sprites next to callsigns). Flight detail layouts always show logos in the logo zone when available (52 airlines included as PNG assets).", + "description": "Show airline logos in area mode (8×8 pixel sprites next to callsigns). Flight detail layouts always show logos in the logo zone when available (52 airlines included as PNG assets).", "default": true }, "scroll_speed": { @@ -730,17 +745,17 @@ "flightaware": { "type": "object", "title": "FlightAware AeroAPI (Paid - Optional)", - "description": "OPTIONAL PAID SERVICE \u2014 skip this section entirely unless you have a FlightAware AeroAPI subscription. Enabling this will make API calls that are billed at $0.005 each. Free route data via FR24 enrichment (above) works well for most users without any cost.", + "description": "OPTIONAL PAID SERVICE — skip this section entirely unless you have a FlightAware AeroAPI subscription. Enabling this will make API calls that are billed at $0.005 each. Free route data via FR24 enrichment (above) works well for most users without any cost.", "properties": { "api_key": { "type": "string", - "description": "Your FlightAware AeroAPI key. Leave blank if you are not using FlightAware \u2014 the plugin works fully without this.", + "description": "Your FlightAware AeroAPI key. Leave blank if you are not using FlightAware — the plugin works fully without this.", "x-secret": true, "default": "" }, "enabled": { "type": "boolean", - "description": "Enable paid FlightAware API calls. Must be true AND api_key must be set for FlightAware to be used. Disabled by default \u2014 do not enable unless you have a paid subscription.", + "description": "Enable paid FlightAware API calls. Must be true AND api_key must be set for FlightAware to be used. Disabled by default — do not enable unless you have a paid subscription.", "default": false }, "max_api_calls_per_hour": { @@ -966,6 +981,14 @@ "offline_database_update_interval_days", "opensky_username", "opensky_password", - "flightaware" + "flightaware", + "flightaware_api_key", + "flight_plan_enabled", + "max_api_calls_per_hour", + "daily_api_budget", + "flight_plan_cache_ttl_hours", + "min_callsign_length", + "airline_callsign_prefixes", + "background_service" ] } diff --git a/plugins/ledmatrix-flights/manifest.json b/plugins/ledmatrix-flights/manifest.json index 10a81e0d..77e01fba 100644 --- a/plugins/ledmatrix-flights/manifest.json +++ b/plugins/ledmatrix-flights/manifest.json @@ -1,7 +1,7 @@ { "id": "ledmatrix-flights", "name": "Flight Tracker", - "version": "1.12.11", + "version": "1.12.12", "description": "Real-time aircraft tracking with ADS-B/FlightRadar24/OpenSky/adsb.fi/adsb.lol data, map backgrounds, area mode, flight tracking, anchor airport, flight records, and optional airport weather (METAR/TAF/PIREP/SIGMET via the free NOAA Aviation Weather Center API)", "author": "ChuckBuilds", "entry_point": "manager.py", @@ -37,6 +37,12 @@ "min_ledmatrix_version": "2.0.0", "max_ledmatrix_version": "3.0.0", "versions": [ + { + "version": "1.12.12", + "released": "2026-08-19", + "ledmatrix_min_version": "2.0.0", + "notes": "Make the FlightAware API key, the flight-plan settings and background_service reachable in the web UI. The config form renders only the properties named in x-propertyOrder, so a setting the schema declares but that list omits was never drawn -- no field, no error, no hint it existed, and the only way to set it was to hand-edit config.json on the device. The values already validated on save and the plugin already read them; only the form was missing them." + }, { "version": "1.12.11", "released": "2026-08-18", diff --git a/plugins/masters-tournament/config_schema.json b/plugins/masters-tournament/config_schema.json index 0c63ac6b..64343d02 100644 --- a/plugins/masters-tournament/config_schema.json +++ b/plugins/masters-tournament/config_schema.json @@ -178,7 +178,11 @@ "minimum": 1, "maximum": 18 }, - "default": [12, 13, 16], + "default": [ + 12, + 13, + 16 + ], "description": "Featured holes to highlight (Amen Corner, par 3s)" } } @@ -451,7 +455,11 @@ "color_scheme": { "x-advanced": true, "type": "string", - "enum": ["classic", "bright", "subdued"], + "enum": [ + "classic", + "bright", + "subdued" + ], "default": "classic", "description": "Masters color palette variant" } @@ -459,7 +467,9 @@ } }, "additionalProperties": false, - "required": ["enabled"], + "required": [ + "enabled" + ], "x-propertyOrder": [ "enabled", "display_duration", @@ -469,6 +479,10 @@ "favorite_players", "display_modes", "notifications", - "branding" + "branding", + "player_card_duration", + "hole_display_duration", + "page_display_duration", + "scroll_card_width" ] } diff --git a/plugins/masters-tournament/manifest.json b/plugins/masters-tournament/manifest.json index 60f85be8..1d655261 100644 --- a/plugins/masters-tournament/manifest.json +++ b/plugins/masters-tournament/manifest.json @@ -1,7 +1,7 @@ { "id": "masters-tournament", "name": "Masters Tournament", - "version": "2.5.4", + "version": "2.5.5", "description": "Broadcast-quality Masters Tournament display with real ESPN player headshots, accurate Augusta National hole layouts, fun facts, past champions, live leaderboards, and pixel-perfect LED matrix rendering", "author": "ChuckBuilds", "class_name": "MastersTournamentPlugin", @@ -43,6 +43,12 @@ "height": 64 }, "versions": [ + { + "version": "2.5.5", + "released": "2026-08-19", + "ledmatrix_min_version": "2.0.0", + "notes": "Make the player-card, hole, page and scroll-card sizing settings reachable in the web UI. The config form renders only the properties named in x-propertyOrder, so a setting the schema declares but that list omits was never drawn -- no field, no error, no hint it existed, and the only way to set it was to hand-edit config.json on the device. The values already validated on save and the plugin already read them; only the form was missing them." + }, { "version": "2.5.4", "released": "2026-08-05", diff --git a/plugins/nrl-scoreboard/config_schema.json b/plugins/nrl-scoreboard/config_schema.json index f67bcd81..9b82e2e7 100644 --- a/plugins/nrl-scoreboard/config_schema.json +++ b/plugins/nrl-scoreboard/config_schema.json @@ -562,7 +562,7 @@ "mode_durations": { "type": "object", "title": "Mode-Level Durations", - "description": "Control total duration for each mode type. If not set, uses dynamic calculation (total_games \u00d7 per_game_duration).", + "description": "Control total duration for each mode type. If not set, uses dynamic calculation (total_games × per_game_duration).", "properties": { "recent_mode_duration": { "type": [ @@ -1299,6 +1299,9 @@ "background_service", "customization", "schedule_lookback_days", - "schedule_lookahead_days" + "schedule_lookahead_days", + "scroll_card", + "no_data_interval_seconds", + "live_idle_max_interval_seconds" ] } diff --git a/plugins/nrl-scoreboard/manifest.json b/plugins/nrl-scoreboard/manifest.json index 566d751d..43f831ac 100644 --- a/plugins/nrl-scoreboard/manifest.json +++ b/plugins/nrl-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "nrl-scoreboard", "name": "NRL Scoreboard", - "version": "1.9.0", + "version": "1.9.1", "author": "ChuckBuilds", "description": "Live, recent, and upcoming NRL (National Rugby League) games with real-time scores and game status.", "category": "sports", @@ -18,6 +18,12 @@ "nrl_upcoming" ], "versions": [ + { + "version": "1.9.1", + "released": "2026-08-19", + "ledmatrix_min_version": "2.0.0", + "notes": "Make scroll_card and the two idle-poll intervals reachable in the web UI. The config form renders only the properties named in x-propertyOrder, so a setting the schema declares but that list omits was never drawn -- no field, no error, no hint it existed, and the only way to set it was to hand-edit config.json on the device. The values already validated on save and the plugin already read them; only the form was missing them." + }, { "version": "1.9.0", "released": "2026-08-19", diff --git a/plugins/soccer-scoreboard/config_schema.json b/plugins/soccer-scoreboard/config_schema.json index 907194ea..d810c744 100644 --- a/plugins/soccer-scoreboard/config_schema.json +++ b/plugins/soccer-scoreboard/config_schema.json @@ -641,7 +641,7 @@ "mode_durations": { "type": "object", "title": "Mode-Level Durations", - "description": "Control total duration for each mode type. If not set, uses dynamic calculation (total_games \u00d7 per_game_duration).", + "description": "Control total duration for each mode type. If not set, uses dynamic calculation (total_games × per_game_duration).", "properties": { "recent_mode_duration": { "type": [ @@ -1050,7 +1050,7 @@ "mode_durations": { "type": "object", "title": "Mode-Level Durations", - "description": "Control total duration for each mode type. If not set, uses dynamic calculation (total_games \u00d7 per_game_duration).", + "description": "Control total duration for each mode type. If not set, uses dynamic calculation (total_games × per_game_duration).", "properties": { "recent_mode_duration": { "type": [ @@ -1459,7 +1459,7 @@ "mode_durations": { "type": "object", "title": "Mode-Level Durations", - "description": "Control total duration for each mode type. If not set, uses dynamic calculation (total_games \u00d7 per_game_duration).", + "description": "Control total duration for each mode type. If not set, uses dynamic calculation (total_games × per_game_duration).", "properties": { "recent_mode_duration": { "type": [ @@ -1868,7 +1868,7 @@ "mode_durations": { "type": "object", "title": "Mode-Level Durations", - "description": "Control total duration for each mode type. If not set, uses dynamic calculation (total_games \u00d7 per_game_duration).", + "description": "Control total duration for each mode type. If not set, uses dynamic calculation (total_games × per_game_duration).", "properties": { "recent_mode_duration": { "type": [ @@ -2277,7 +2277,7 @@ "mode_durations": { "type": "object", "title": "Mode-Level Durations", - "description": "Control total duration for each mode type. If not set, uses dynamic calculation (total_games \u00d7 per_game_duration).", + "description": "Control total duration for each mode type. If not set, uses dynamic calculation (total_games × per_game_duration).", "properties": { "recent_mode_duration": { "type": [ @@ -2686,7 +2686,7 @@ "mode_durations": { "type": "object", "title": "Mode-Level Durations", - "description": "Control total duration for each mode type. If not set, uses dynamic calculation (total_games \u00d7 per_game_duration).", + "description": "Control total duration for each mode type. If not set, uses dynamic calculation (total_games × per_game_duration).", "properties": { "recent_mode_duration": { "type": [ @@ -3095,7 +3095,7 @@ "mode_durations": { "type": "object", "title": "Mode-Level Durations", - "description": "Control total duration for each mode type. If not set, uses dynamic calculation (total_games \u00d7 per_game_duration).", + "description": "Control total duration for each mode type. If not set, uses dynamic calculation (total_games × per_game_duration).", "properties": { "recent_mode_duration": { "type": [ @@ -3504,7 +3504,7 @@ "mode_durations": { "type": "object", "title": "Mode-Level Durations", - "description": "Control total duration for each mode type. If not set, uses dynamic calculation (total_games \u00d7 per_game_duration).", + "description": "Control total duration for each mode type. If not set, uses dynamic calculation (total_games × per_game_duration).", "properties": { "recent_mode_duration": { "type": [ @@ -3913,7 +3913,7 @@ "mode_durations": { "type": "object", "title": "Mode-Level Durations", - "description": "Control total duration for each mode type. If not set, uses dynamic calculation (total_games \u00d7 per_game_duration).", + "description": "Control total duration for each mode type. If not set, uses dynamic calculation (total_games × per_game_duration).", "properties": { "recent_mode_duration": { "type": [ @@ -4322,7 +4322,7 @@ "mode_durations": { "type": "object", "title": "Mode-Level Durations", - "description": "Control total duration for each mode type. If not set, uses dynamic calculation (total_games \u00d7 per_game_duration).", + "description": "Control total duration for each mode type. If not set, uses dynamic calculation (total_games × per_game_duration).", "properties": { "recent_mode_duration": { "type": [ @@ -4370,7 +4370,7 @@ "custom_leagues": { "type": "array", "title": "Add More Leagues", - "description": "Add any soccer league available on ESPN. Click 'Add Item', then fill in BOTH a name and a league code. Common codes: eng.2 (English Championship), eng.3 (League One), eng.fa (FA Cup), eng.league_cup (EFL Cup), mex.1 (Liga MX), arg.1 (Argentina), bra.1 (Brazil), ned.1 (Eredivisie), sco.1 (Scottish Premiership), tur.1 (Turkish S\u00fcper Lig), bel.1 (Belgian Pro League)", + "description": "Add any soccer league available on ESPN. Click 'Add Item', then fill in BOTH a name and a league code. Common codes: eng.2 (English Championship), eng.3 (League One), eng.fa (FA Cup), eng.league_cup (EFL Cup), mex.1 (Liga MX), arg.1 (Argentina), bra.1 (Brazil), ned.1 (Eredivisie), sco.1 (Scottish Premiership), tur.1 (Turkish Süper Lig), bel.1 (Belgian Pro League)", "x-widget": "array-table", "x-columns": [ "name", @@ -5404,6 +5404,9 @@ "leagues", "customization", "schedule_lookback_days", - "schedule_lookahead_days" + "schedule_lookahead_days", + "scroll_card", + "no_data_interval_seconds", + "live_idle_max_interval_seconds" ] } diff --git a/plugins/soccer-scoreboard/manifest.json b/plugins/soccer-scoreboard/manifest.json index 6e512f25..5c63b691 100644 --- a/plugins/soccer-scoreboard/manifest.json +++ b/plugins/soccer-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "soccer-scoreboard", "name": "Soccer Scoreboard", - "version": "2.12.0", + "version": "2.12.1", "author": "ChuckBuilds", "description": "Live, recent, and upcoming soccer games across multiple leagues including Premier League, La Liga, Bundesliga, Serie A, Ligue 1, MLS, Liga Portugal, Champions League, Europa League, and FIFA World Cup", "category": "sports", @@ -26,6 +26,12 @@ "soccer_upcoming" ], "versions": [ + { + "version": "2.12.1", + "released": "2026-08-19", + "ledmatrix_min_version": "2.0.0", + "notes": "Make scroll_card and the two idle-poll intervals reachable in the web UI. The config form renders only the properties named in x-propertyOrder, so a setting the schema declares but that list omits was never drawn -- no field, no error, no hint it existed, and the only way to set it was to hand-edit config.json on the device. The values already validated on save and the plugin already read them; only the form was missing them." + }, { "version": "2.12.0", "released": "2026-08-19", @@ -149,7 +155,7 @@ { "released": "2026-07-29", "version": "2.5.0", - "notes": "Corrected every team code in TEAMS.md against ESPN's live data \u2014 Manchester United is MAN (not MUN), Manchester City MNC (not MCI), Real Madrid RMA, and Ligue 1 had eight wrong codes. The plugin now also says why a league is empty: an unrecognised favorite team logs a warning naming the closest match, while a correct code in a league with no fixtures yet logs the date the season starts.", + "notes": "Corrected every team code in TEAMS.md against ESPN's live data — Manchester United is MAN (not MUN), Manchester City MNC (not MCI), Real Madrid RMA, and Ligue 1 had eight wrong codes. The plugin now also says why a league is empty: an unrecognised favorite team logs a warning naming the closest match, while a correct code in a league with no fixtures yet logs the date the season starts.", "ledmatrix_min": "2.0.0" }, { @@ -173,7 +179,7 @@ { "released": "2026-07-02", "version": "2.2.0", - "notes": "Add exclude_teams (hide specific teams from live rotation and recent/final scores \u2014 spoiler protection) and filtering.favorite_live_boost (tune how much more often your favorite's live game appears in rotation vs other live games) per league, including custom leagues.", + "notes": "Add exclude_teams (hide specific teams from live rotation and recent/final scores — spoiler protection) and filtering.favorite_live_boost (tune how much more often your favorite's live game appears in rotation vs other live games) per league, including custom leagues.", "ledmatrix_min": "2.0.0" }, { diff --git a/scripts/test_property_order_coverage.py b/scripts/test_property_order_coverage.py new file mode 100644 index 00000000..59626f00 --- /dev/null +++ b/scripts/test_property_order_coverage.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +"""A setting missing from x-propertyOrder cannot be configured at all. + +The web UI's config form iterates ``x-propertyOrder`` and nothing else: + + {% set property_order = schema['x-propertyOrder'] + if 'x-propertyOrder' in schema + else schema.properties.keys()|list %} + {% for key in property_order %} + {% if key in schema.properties %} + +A property declared in the schema but absent from that list is therefore +never rendered -- no field, no error, no hint that the setting exists. The +value still validates on save and the plugin still reads it, so the only way +to set one was to hand-edit config.json on the device. + +Twenty-six settings across seven plugins were in that state, including +ledmatrix-flights' ``flightaware_api_key`` -- marked ``x-secret: true`` for +masking, yet impossible to enter -- and the two idle-poll intervals whose +plumbing had just been fixed so they would finally take effect. Twenty-two of +the twenty-six carried ``x-advanced: true``: nobody flags a field "advanced" +meaning "invisible", so these were omissions, not deliberate hiding. There is +no supported way to hide a property, and no schema in the repo attempts one. + +Note the asymmetry that hid this: the client-side renderer in app-shell.js +appends unlisted properties instead of dropping them, so the same schema can +look fine there and be unreachable in the server-rendered form. + +Run: /bin/python scripts/test_property_order_coverage.py +""" + +import json +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +failures = [] + + +def check(label, ok): + print((" PASS " if ok else " FAIL ") + label) + if not ok: + failures.append(label) + + +def unlisted(node, path=()): + """Every (location, property) a config form would silently skip.""" + out = [] + if not isinstance(node, dict): + return out + props = node.get("properties") + if isinstance(props, dict): + order = node.get("x-propertyOrder") + if isinstance(order, list): + for key in props: + if key not in order: + out.append((".".join(path) or "(root)", key)) + for key, value in props.items(): + out += unlisted(value, path + (key,)) + items = node.get("items") + if isinstance(items, dict): + out += unlisted(items, path + ("[]",)) + return out + + +def main(): + print("every declared setting must be reachable in the config form") + offenders = [] + schemas = sorted((REPO / "plugins").glob("*/config_schema.json")) + for path in schemas: + plugin = path.parent.name + missing = unlisted(json.load(path.open(encoding="utf-8"))) + if missing: + offenders.append((plugin, missing)) + for plugin, missing in offenders: + for loc, key in missing: + print(f" {plugin}: {loc}.{key}") + check(f"{len(schemas)} schemas, none hiding a declared setting " + f"({sum(len(m) for _, m in offenders)} hidden)", not offenders) + + print("\nthe form really does drop what the order omits") + # Not a claim about the template -- render it and look. + core = None + for candidate in (Path("/home/rackpi/projects/LEDMatrix"), + REPO.parent / "LEDMatrix"): + if (candidate / "web_interface" / "templates" / "v3" / "partials" + / "plugin_config.html").exists(): + core = candidate + break + if core is None: + print(" SKIP no LEDMatrix core checkout found (set LEDMATRIX_CORE)") + else: + import re + from jinja2 import DictLoader, Environment + source = (core / "web_interface" / "templates" / "v3" / "partials" + / "plugin_config.html").read_text(encoding="utf-8") + match = re.search( + r"(\{%\s*set property_order = schema\['x-propertyOrder'\].*?" + r"\{%\s*endfor\s*%\})", source, re.S) + check("the ordering loop is still in the shipped template", bool(match)) + if match: + # The loop only sorts keys into tiers, so append an emitter to see + # which keys it actually considered. + block = match.group(1) + "|{{ tiers.basic }}{{ tiers.advanced }}|" + env = Environment(loader=DictLoader({'f': block}), autoescape=True) + schema = {'properties': {'shown': {'type': 'string'}, + 'hidden': {'type': 'string'}}, + 'x-propertyOrder': ['shown']} + tiers = env.get_template('f').render(schema=schema).split('|')[1] + check("a listed property reaches the form (%s)" % tiers.strip(), + 'shown' in tiers) + check("a property absent from the order is dropped entirely", + 'hidden' not in tiers) + + print("\n%s" % ("FAILED: %d" % len(failures) if failures + else "All checks passed")) + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main())