Compare commits

..
5 Commits
Author SHA1 Message Date
ChuckandClaude Opus 5 c3d68f23f6 fix(web): drop the hasOwnProperty alias the descriptor read made redundant
own() now reads through Object.getOwnPropertyDescriptor, so the alias it used to call has no remaining reference.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 11:06:09 -04:00
ChuckandClaude Opus 5 ece2065e42 fix(web): clear the remaining static-analysis findings
Five, all on lines this branch touched.

The Python one is not a new defect: _set_missing_booleans_to_false's first
parameter was always named `config`, which shadows the `config` submodule
imported for its side effects at the bottom of this module. Editing the
signature simply put the existing warning on a changed line. The parameter is
the plugin's config dict, so `plugin_config` is what it should have been called
anyway; callers pass it positionally and are unaffected.

The JavaScript ones are the object-injection rule firing on reads keyed by
data. own() now goes through a property descriptor, so the one unavoidable
data-keyed read is no longer a computed member access; at() consumes its path
instead of indexing it; and the column set is a Map, which has no prototype to
pollute and needs no guarded reads at all.

Verified the widget still renders identically against football's real schema:
29 element rows, all four mode tabs, values populated, no console errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 10:56:30 -04:00
ChuckandClaude Opus 5 a016dd9446 docs(plugins): how to declare styleable elements; harden the widget's lookups
The plugin-author guide for the compact x-style-elements declaration -- what
each key does, how to read values back without breaking the "user-forced only
when it differs from the default" rule, and why a hand-written block needs no
changes to be adopted.

Also clears the static-analysis findings on style-editor.js. Every lookup in
that file is keyed by something out of a schema or a saved config, so a key of
__proto__ or constructor would walk the prototype chain and hand back a
function instead of a schema; reads now go through an own-property helper. The
panel registry became a list, and the flagged vars moved to their function
roots.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 10:39:51 -04:00
ChuckandClaude Opus 5 da3cef0bc0 feat(sports): resolve element colour by name, and honour visible/align/scale
Two of the three gaps this framework shipped with.

Colour by name. A draw resolved its colour by comparing the *identity* of the
font object it was handed, which cannot tell two elements apart when they share
a face -- so those draws went out white. Every bitmap font is in that case,
because a freetype.Face cannot be re-instantiated to un-share it, which is how
an element rendered in any of the 32 shipped BDF fonts silently lost a colour
its picker had offered all along. _draw_text_with_outline now takes
element="score_text" and reads the colour by name; the identity path remains
for un-annotated callers, but narrows before giving up -- one configured colour
among the sharers is the only thing the user can have meant.

Visible, align and scale. The resolver has understood these since the
framework landed and nothing consumed them: an element could be marked hidden
in the web UI and still render. Adds the stateless readers, the mixin
accessors, and a scale parameter on the one shared logo-sizing seam (keyed into
the cache, so two elements scaled differently cannot be served each other's
image). Naming an element in a draw also honours its visibility.

Untouched configs are unaffected: every new parameter defaults to today's
behaviour, and all ten affected plugins render pixel-identically to main across
every harness size.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 10:39:51 -04:00
ChuckandClaude Opus 5 1df51b172a fix(web): a partial save no longer switches off checkboxes it never showed
An HTML checkbox posts nothing when unchecked, so the save route walked the
schema and forced every boolean missing from the form to False. That is right
for the rendered form and wrong for every other caller: a script, the MQTT
bridge or a curl against the documented endpoint never rendered a checkbox, and
reading its silence as "all off" turns a one-field save into a mass disable.

Found on hardware. Posting four customization.* keys to a live device switched
off nfl.enabled, ncaa_fb.enabled and every display-mode toggle in one request.

The form now reports the top-level sections it drew (__rendered_section), and
inside those an absent checkbox still means unchecked -- including a section
whose only fields are checkboxes that are all off, which no heuristic could
recover. A post with no marker only touches objects it actually posted a field
from. Meta fields are dropped before form keys are treated as config paths,
because unknown keys are otherwise written straight into config.json.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 10:39:51 -04:00
14 changed files with 1050 additions and 105 deletions
+14
View File
@@ -37,6 +37,20 @@ ships this):
says `score`, and `records`/`record` and `status_text`/`status` split seven says `score`, and `records`/`record` and `status_text`/`status` split seven
to two across the published schemas. A lookup tries the exact name first, so to two across the published schemas. A lookup tries the exact name first, so
this is inert for a config that already matches. this is inert for a config that already matches.
- `src.element_style.element_visible(config, element, default, mode)`,
`element_align(...)` and `element_scale(...)` — the stateless reads for the
three knobs the resolver already understood but no draw path consumed, so an
element could be marked hidden in the web UI and still render.
- `SportsCoreSharedMixin._draw_text_with_outline(..., element="score_text")` —
naming the element resolves its colour by name and honours its visibility
toggle. Without a name the colour is inferred from font-object identity,
which cannot separate two elements sharing a face; that is the case every
bitmap font is in, because a `freetype.Face` cannot be re-instantiated, and
it is how a BDF-rendered element silently lost a configured colour. Shared
faces now resolve when exactly one sharer has a colour set.
- `LogoHelper.load_logo(..., scale=)` — applies a user's image scale, and keys
the cache on the scaled box so two elements scaled differently cannot be
served each other's image.
- `src.element_style.native_bdf_size(font)` — the one pixel size a bitmap font - `src.element_style.native_bdf_size(font)` — the one pixel size a bitmap font
can render at, or None for a scalable one. The web UI needs this to know can render at, or None for a scalable one. The web UI needs this to know
whether a size control can take effect at all. whether a size control can take effect at all.
+135
View File
@@ -0,0 +1,135 @@
# Per-element styling for plugin authors
Users want to change the font, size and colour of individual things on screen,
nudge them a few pixels, hide the ones they do not care about, and scale a logo
down. This is the one system that does that, and a plugin joins it by
**declaring elements in its `config_schema.json`** — not by writing a style
resolver, a font cache or a web form.
The short version:
```jsonc
"customization": {
"type": "object",
"title": "Display Customization",
"x-style-elements": {
"score_text": {
"title": "Score",
"font": { "default": "PressStart2P-Regular.ttf" },
"size": { "default": 10, "min": 4, "max": 16 },
"color": { "default": [255, 255, 255] },
"offsets": true,
"visible": true,
"align": true
},
"home_logo": { "title": "Home logo", "offsets": true, "scale": true }
},
"x-style-modes": ["live", "upcoming", "recent"]
}
```
That is the whole declaration. The core expands it into a full JSON Schema, the
web UI renders a compact style editor with a row per element, and the values
land in `config.json` under the keys you named.
## What each key does
| Key | Effect |
|---|---|
| `font` | Font picker listing every shipped **and user-uploaded** font. |
| `size` | Number field. `min`/`max` also cap which fixed-size fonts are offered. |
| `color` | Colour swatch; stored as `[r, g, b]`. |
| `offsets` | X/Y nudge, stored under `customization.layout.<element>`. |
| `visible` | Show/hide toggle. |
| `align` | `left` / `center` / `right`. |
| `scale` | Size multiplier, for logos and images. Also under `layout`. |
`x-style-modes` is optional. Declare it and every element gains a per-mode
override tab — a scoreboard can then style its live, upcoming and recent cards
separately. **A mode field left blank means "inherit", not zero.**
## Reading the values
Every plugin inherits `BasePlugin.styles`, which finds your `config_schema.json`
on its own:
```python
style = self.styles.style(
"score_text",
classic_font="PressStart2P-Regular.ttf", # what you shipped
classic_size=10,
classic_color=(255, 255, 255),
)
if style.visible:
draw.text((x + style.offset[0], y + style.offset[1]),
text, font=style.font, fill=style.color)
```
For a specific mode, use `self.styles_for("recent")`, or set
`STYLE_MODE = "recent"` on the class and keep calling `self.styles`.
### The one rule that matters
**Pass your shipped values as the `classic_*` arguments.** The resolver returns
them verbatim unless the user actually changed something, which is what keeps an
untouched install rendering byte-identically. It can tell the difference because
a value only counts as user-forced when it *differs from the schema default* —
the save path writes the full default object into `config.json` on every save,
so "present in config" proves nothing.
Never compare against the default yourself; that rule lives in exactly one place.
### Stateless readers
For helpers handed a config dict rather than a plugin instance:
```python
from src.element_style import (element_color, element_visible,
element_align, element_scale, layout_offset)
colour = element_color(config, "score_text", (255, 255, 255), mode)
shown = element_visible(config, "records", True, mode)
dy = layout_offset(config, "score", "y_offset", 0, mode)
```
## Sports scoreboards
`SportsCoreSharedMixin` wires most of this up already. Two things to know:
* **Name your draws.** `_draw_text_with_outline(..., element="score_text")`
resolves the colour by name *and* honours the visibility toggle. Without it
the colour has to be guessed from the identity of the font object, which
cannot tell two elements apart when they share a face — the case every
bitmap font is in.
* **Modes are free.** Live/upcoming/recent are separate instances, so setting
`SKIN_MODE` on each is enough; no call site passes a mode.
## Adopting an existing hand-written block
If your schema already spells out `font` / `font_size` / `text_color` per
element longhand, **you do not need to change anything**. The core recognises
that shape and upgrades it in place: the style editor, the real font picker
(including uploaded fonts) and per-mode overrides all appear on a core update.
Add `x-style-modes` if you want the mode tabs.
## Fonts, and why size is sometimes locked
32 of the 35 shipped fonts are fixed-strike BDF bitmaps: they render at exactly
one pixel size and ignore `font_size`. The picker knows which, and the editor
locks the size field to the native size and labels it `fixed`. A font too tall
for the `max` you declared is not offered at all.
Uploaded fonts (Fonts tab) land in `assets/fonts/` and appear in the picker
automatically.
## Checklist
1. Declare `x-style-elements` (and `x-style-modes` if you have modes).
2. Read through `self.styles`, passing your shipped values as `classic_*`.
3. Honour `style.visible`, `style.offset` and `style.scale` where they apply.
4. Confirm an untouched config renders identically:
`python scripts/check_plugin.py --plugin <id>`.
5. Monorepo plugins: bump `manifest.json` and run `python update_registry.py`.
See also: [docs/PLUGIN_CONFIGURATION_GUIDE.md](PLUGIN_CONFIGURATION_GUIDE.md),
[docs/FONT_MANAGER.md](FONT_MANAGER.md).
+2
View File
@@ -45,6 +45,8 @@ Going deeper:
- [PLUGIN_CONFIG_QUICK_START.md](PLUGIN_CONFIG_QUICK_START.md) — minimal config you need - [PLUGIN_CONFIG_QUICK_START.md](PLUGIN_CONFIG_QUICK_START.md) — minimal config you need
- [PLUGIN_CONFIGURATION_GUIDE.md](PLUGIN_CONFIGURATION_GUIDE.md) — schema design - [PLUGIN_CONFIGURATION_GUIDE.md](PLUGIN_CONFIGURATION_GUIDE.md) — schema design
- [PLUGIN_ELEMENT_STYLING.md](PLUGIN_ELEMENT_STYLING.md) — let users restyle,
move, hide and scale individual elements (per display mode, if you have them)
- [PLUGIN_CONFIGURATION_TABS.md](PLUGIN_CONFIGURATION_TABS.md) — multi-tab UI configs - [PLUGIN_CONFIGURATION_TABS.md](PLUGIN_CONFIGURATION_TABS.md) — multi-tab UI configs
- [PLUGIN_CONFIG_ARCHITECTURE.md](PLUGIN_CONFIG_ARCHITECTURE.md) — how the config system works - [PLUGIN_CONFIG_ARCHITECTURE.md](PLUGIN_CONFIG_ARCHITECTURE.md) — how the config system works
- [PLUGIN_CONFIG_CORE_PROPERTIES.md](PLUGIN_CONFIG_CORE_PROPERTIES.md) — properties every plugin honors - [PLUGIN_CONFIG_CORE_PROPERTIES.md](PLUGIN_CONFIG_CORE_PROPERTIES.md) — properties every plugin honors
+67 -16
View File
@@ -35,6 +35,29 @@ from src.common.permission_utils import (
# trade for not re-warning about a file nobody is going to add. # trade for not re-warning about a file nobody is going to add.
MISSING_LOGO_RECHECK_SECONDS = 3600.0 MISSING_LOGO_RECHECK_SECONDS = 3600.0
#: Bounds on a user-supplied logo scale. Wide enough to be useful, closed
#: enough that a typo cannot ask for a 4000px image on a 64px panel.
MIN_LOGO_SCALE = 0.05
MAX_LOGO_SCALE = 8.0
def _usable_scale(scale) -> float:
"""A scale that can be applied, or 1.0.
Anything unusable -- None, a string, zero, a negative, NaN, infinity --
means "as shipped", because the alternative is a blank panel from a
mistyped number.
"""
try:
value = float(scale)
except (TypeError, ValueError):
return 1.0
if value != value or value in (float('inf'), float('-inf')):
return 1.0
if value < MIN_LOGO_SCALE or value > MAX_LOGO_SCALE:
return 1.0
return value
# Well above any real team logo; bounds what a remote URL can write to disk. # Well above any real team logo; bounds what a remote URL can write to disk.
@@ -88,18 +111,23 @@ class LogoHelper:
'Accept': 'image/*', 'Accept': 'image/*',
}) })
def load_logo(self, team_abbr: str, logo_path: Union[str, Path], def load_logo(self, team_abbr: str, logo_path: Union[str, Path],
max_width: Optional[int] = None, max_width: Optional[int] = None,
max_height: Optional[int] = None) -> Optional[Image.Image]: max_height: Optional[int] = None,
scale: float = 1.0) -> Optional[Image.Image]:
""" """
Load and resize a team logo. Load and resize a team logo.
Args: Args:
team_abbr: Team abbreviation for caching team_abbr: Team abbreviation for caching
logo_path: Path to the logo file logo_path: Path to the logo file
max_width: Maximum width (defaults to display_width * 1.5) max_width: Maximum width (defaults to display_width * 1.5)
max_height: Maximum height (defaults to display_height * 1.5) max_height: Maximum height (defaults to display_height * 1.5)
scale: User's size multiplier for this image, from
``customization.layout.<element>.scale``. 1.0 is untouched and
takes exactly the path it always did. Callers hold the config,
so they resolve the element name; this only applies the number.
Returns: Returns:
PIL Image object or None if loading fails PIL Image object or None if loading fails
@@ -115,6 +143,12 @@ class LogoHelper:
max_width = int(self.display_width * 1.5) max_width = int(self.display_width * 1.5)
if max_height is None: if max_height is None:
max_height = int(self.display_height * 1.5) max_height = int(self.display_height * 1.5)
scale = _usable_scale(scale)
if scale != 1.0:
max_width = max(1, int(round(max_width * scale)))
max_height = max(1, int(round(max_height * scale)))
# The key carries the scaled box, so two elements scaled differently
# cannot be served each other's image.
cache_key = f"{team_abbr}_{logo_path}_{max_width}x{max_height}" cache_key = f"{team_abbr}_{logo_path}_{max_width}x{max_height}"
if cache_key in self._logo_cache: if cache_key in self._logo_cache:
self.logger.debug(f"Using cached logo for {team_abbr}") self.logger.debug(f"Using cached logo for {team_abbr}")
@@ -146,7 +180,8 @@ class LogoHelper:
logo = logo.convert('RGBA') logo = logo.convert('RGBA')
# Resize if needed # Resize if needed
logo = self._resize_logo(logo, max_width, max_height) logo = self._resize_logo(logo, max_width, max_height,
allow_upscale=scale > 1.0)
# Cache the logo # Cache the logo
self._cache_logo(cache_key, logo) self._cache_logo(cache_key, logo)
@@ -158,10 +193,11 @@ class LogoHelper:
self.logger.error(f"Error loading logo for {team_abbr}: {e}") self.logger.error(f"Error loading logo for {team_abbr}: {e}")
return None return None
def load_logo_with_download(self, team_abbr: str, logo_path: Union[str, Path], def load_logo_with_download(self, team_abbr: str, logo_path: Union[str, Path],
logo_url: Optional[str] = None, logo_url: Optional[str] = None,
max_width: Optional[int] = None, max_width: Optional[int] = None,
max_height: Optional[int] = None) -> Optional[Image.Image]: max_height: Optional[int] = None,
scale: float = 1.0) -> Optional[Image.Image]:
""" """
Load logo with automatic download if missing. Load logo with automatic download if missing.
@@ -181,7 +217,8 @@ class LogoHelper:
# failed download does not count: it wears the real logo's filename, so # failed download does not count: it wears the real logo's filename, so
# trusting the file's existence is what left teams as grey boxes. # trusting the file's existence is what left teams as grey boxes.
if logo_path.exists() and not self._is_stale_placeholder(logo_path): if logo_path.exists() and not self._is_stale_placeholder(logo_path):
return self.load_logo(team_abbr, logo_path, max_width, max_height) return self.load_logo(team_abbr, logo_path, max_width, max_height,
scale)
# Download if URL provided and file doesn't exist # Download if URL provided and file doesn't exist
if logo_url: if logo_url:
@@ -193,7 +230,8 @@ class LogoHelper:
# from the cache before touching the disk -- so without this the # from the cache before touching the disk -- so without this the
# real logo would not appear until the process restarted. # real logo would not appear until the process restarted.
self._invalidate_cached_logo(team_abbr, logo_path) self._invalidate_cached_logo(team_abbr, logo_path)
return self.load_logo(team_abbr, logo_path, max_width, max_height) return self.load_logo(team_abbr, logo_path, max_width, max_height,
scale)
except Exception as e: except Exception as e:
self.logger.error(f"Failed to download logo for {team_abbr}: {e}") self.logger.error(f"Failed to download logo for {team_abbr}: {e}")
# The retry failed, so restart the back-off. The stale # The retry failed, so restart the back-off. The stale
@@ -328,18 +366,31 @@ class LogoHelper:
), ),
} }
def _resize_logo(self, logo: Image.Image, max_width: Optional[int] = None, def _resize_logo(self, logo: Image.Image, max_width: Optional[int] = None,
max_height: Optional[int] = None) -> Image.Image: max_height: Optional[int] = None,
"""Resize logo to fit display dimensions.""" allow_upscale: bool = False) -> Image.Image:
"""Resize logo to fit display dimensions.
``allow_upscale`` is only set when the user asked for a scale above 1:
the fit rule is "never larger than the box", and growing an image
nobody asked to grow would change every existing render.
"""
if max_width is None: if max_width is None:
max_width = int(self.display_width * 1.5) max_width = int(self.display_width * 1.5)
if max_height is None: if max_height is None:
max_height = int(self.display_height * 1.5) max_height = int(self.display_height * 1.5)
# Only resize if necessary # Only resize if necessary
if logo.width <= max_width and logo.height <= max_height: if logo.width <= max_width and logo.height <= max_height:
return logo if not allow_upscale or not logo.width or not logo.height:
return logo
ratio = min(max_width / logo.width, max_height / logo.height)
if ratio <= 1:
return logo
return logo.resize((max(1, int(logo.width * ratio)),
max(1, int(logo.height * ratio))),
Image.Resampling.LANCZOS)
# Maintain aspect ratio # Maintain aspect ratio
logo.thumbnail((max_width, max_height), Image.Resampling.LANCZOS) logo.thumbnail((max_width, max_height), Image.Resampling.LANCZOS)
return logo return logo
+41 -8
View File
@@ -113,26 +113,59 @@ def element_color(config: Optional[Dict[str, Any]], element: str,
return _shared(config, element, default, mode) return _shared(config, element, default, mode)
def font_color(config: Optional[Dict[str, Any]], fonts: Optional[Dict[str, Any]], def resolve_font_color(config: Optional[Dict[str, Any]],
font, default: Tuple[int, int, int] = (255, 255, 255)): fonts: Optional[Dict[str, Any]], font,
default: Tuple[int, int, int],
element_for_font: Dict[str, str],
mode: Optional[str] = None):
"""Colour for whichever element owns this face. """Colour for whichever element owns this face.
Matched on identity, and deliberately gives up when one object is Identity matching is a stand-in for the element name, used where the draw
shared: the last-resort font path can hand the same face to several site only ever received a font. Prefer ``element=`` on the draw call; this
keys, and there is no right answer for which element's colour that is. is the fallback for the sites that have not been annotated yet.
White is what those draws used before, so ambiguity costs nothing.
One object can legitimately belong to several elements -- a size resolver
can land two of them on the same face, and a BDF face cannot be un-shared
at all because ``freetype.Face`` objects cannot be rebuilt from a path.
Those draws used to go out white, which is how an element rendered in any
of the 32 shipped bitmap fonts could silently lose a colour the user had
set. So ambiguity is now narrowed before it is given up on: among the
elements sharing a face, a single configured colour is the only thing the
user can have meant, and several that agree mean the same thing. Only a
genuine disagreement falls back to *default*.
The element vocabulary is a parameter because the two callers disagree
about it -- the mixin's map says ``team_text`` where this module's says
``team_name`` -- and quietly re-pointing either at the other's names would
change which colour setting a live install honours.
""" """
try: try:
fonts = fonts or {} fonts = fonts or {}
matches = [element for key, element in ELEMENT_FOR_FONT.items() matches = [element for key, element in element_for_font.items()
if fonts.get(key) is font] if fonts.get(key) is font]
if len(matches) == 1: if len(matches) == 1:
return element_color(config, matches[0], default) return element_color(config, matches[0], default, mode)
if len(matches) > 1:
configured = []
for element in matches:
colour = element_color(config, element, None, mode)
if colour is not None and colour not in configured:
configured.append(colour)
if len(configured) == 1:
return configured[0]
except (AttributeError, TypeError): except (AttributeError, TypeError):
pass pass
return default return default
def font_color(config: Optional[Dict[str, Any]], fonts: Optional[Dict[str, Any]],
font, default: Tuple[int, int, int] = (255, 255, 255),
mode: Optional[str] = None):
"""Colour for whichever element owns this face, by this module's map."""
return resolve_font_color(config, fonts, font, default, ELEMENT_FOR_FONT,
mode)
def coerce_rgb(value, fallback): def coerce_rgb(value, fallback):
"""Turn a configured [R, G, B] list into a clamped (r, g, b) tuple.""" """Turn a configured [R, G, B] list into a clamped (r, g, b) tuple."""
# Checked before unpacking: a 3-character string ("123") would otherwise # Checked before unpacking: a 3-character string ("123") would otherwise
+74 -18
View File
@@ -191,7 +191,8 @@ class SportsCoreSharedMixin:
img = Image.new("RGB", (self.display_width, self.display_height), (0, 0, 0)) img = Image.new("RGB", (self.display_width, self.display_height), (0, 0, 0))
draw = ImageDraw.Draw(img) draw = ImageDraw.Draw(img)
status = game.get("status_text", "N/A") status = game.get("status_text", "N/A")
self._draw_text_with_outline(draw, status, (2, 2), self.fonts["status"]) self._draw_text_with_outline(draw, status, (2, 2), self.fonts["status"],
element="status_text")
self.display_manager.image.paste(img, (0, 0)) self.display_manager.image.paste(img, (0, 0))
# Don't call update_display here, let subclasses handle it after drawing # Don't call update_display here, let subclasses handle it after drawing
except Exception as e: except Exception as e:
@@ -507,8 +508,10 @@ class SportsCoreSharedMixin:
+ self._get_layout_offset('score', 'x_offset')) + self._get_layout_offset('score', 'x_offset'))
vs_y = (center_y - 3 vs_y = (center_y - 3
+ self._get_layout_offset('score', 'y_offset')) + self._get_layout_offset('score', 'y_offset'))
vs_x = self._aligned_x('score_text', vs_width, width, vs_x)
self._draw_text_with_outline( self._draw_text_with_outline(
draw, vs_text, (vs_x, vs_y), self.fonts["score"] draw, vs_text, (vs_x, vs_y), self.fonts["score"],
element="score_text"
) )
# "vs" and "none" both push the date and time out to the edges, time # "vs" and "none" both push the date and time out to the edges, time
@@ -783,6 +786,46 @@ class SportsCoreSharedMixin:
return _shared(self.config, element, default, return _shared(self.config, element, default,
getattr(self, "SKIN_MODE", None)) getattr(self, "SKIN_MODE", None))
def _element_visible(self, element: str, default: bool = True) -> bool:
"""Whether ``customization.<element>.visible`` allows this draw.
Mode-aware like the colour read, so a user can hide the records on the
recent card and keep them on the upcoming one.
"""
from src.element_style import element_visible
return element_visible(self.config, element, default,
getattr(self, "SKIN_MODE", None))
def _element_align(self, element: str, default: Optional[str] = None):
"""``customization.<element>.align``: 'left', 'center' or 'right'."""
from src.element_style import element_align
return element_align(self.config, element, default,
getattr(self, "SKIN_MODE", None))
def _element_scale(self, element: str, default: float = 1.0) -> float:
"""``customization.layout.<element>.scale`` -- logos, mostly."""
from src.element_style import element_scale
return element_scale(self.config, element, default,
getattr(self, "SKIN_MODE", None))
def _aligned_x(self, element: str, text_width: float, container_width: int,
centered_x: float) -> float:
"""Where a run of text starts, honouring ``align``.
Unset means "leave it exactly where it was", so this returns the
caller's own x rather than re-deriving a centre: these draws have
accumulated per-sport nudges and a centre computed here would not be
the same pixel.
"""
align = self._element_align(element)
if not align:
return centered_x
if align == 'left':
return 0
if align == 'right':
return max(0, container_width - text_width)
return centered_x
def _unshare_element_fonts(self, fonts): def _unshare_element_fonts(self, fonts):
"""Give each colourable element its own face object. """Give each colourable element its own face object.
@@ -824,25 +867,31 @@ class SportsCoreSharedMixin:
def _font_color(self, font, default: Tuple[int, int, int] = (255, 255, 255)): def _font_color(self, font, default: Tuple[int, int, int] = (255, 255, 255)):
"""Colour for whichever element owns this face. """Colour for whichever element owns this face.
Matched on identity, and deliberately gives up when one object is The fallback for draw sites that were only ever handed a font. Prefer
shared: the last-resort font path can hand the same face to several ``element=`` on :meth:`_draw_text_with_outline`, which needs none of
keys, and there is no right answer for which element's colour that is. this. Shared with the scroll card's copy so the narrowing rule that
White is what those draws used before, so ambiguity costs nothing. rescues bitmap-font colours lives in one place; the element vocabulary
stays this class's own, because its map says ``team_text`` where
sports_card's says ``team_name``.
""" """
try: from src.common.sports_card import resolve_font_color
fonts = getattr(self, "fonts", None) or {} return resolve_font_color(
matches = [element for key, element in self._ELEMENT_FOR_FONT.items() getattr(self, "config", None), getattr(self, "fonts", None), font,
if fonts.get(key) is font] default, self._ELEMENT_FOR_FONT, getattr(self, "SKIN_MODE", None))
if len(matches) == 1:
return self._element_color(matches[0], default)
except (AttributeError, TypeError):
pass
return default
def _draw_text_with_outline( def _draw_text_with_outline(
self, draw, text, position, font, fill=None, outline_color=(0, 0, 0) self, draw, text, position, font, fill=None, outline_color=(0, 0, 0),
element=None
): ):
"""Draw text with a black outline for better readability.""" """Draw text with a black outline for better readability.
Pass ``element`` (``"score_text"``, ``"status_text"``, ...) wherever the
caller knows what it is drawing: the colour is then read by name, which
is exact. Without it the colour has to be inferred from the identity of
the font object, which cannot tell two elements apart when they share a
face -- the case every bitmap font is in, because a ``freetype.Face``
cannot be re-instantiated.
"""
# Disable anti-aliasing: pixel/bitmap fonts (e.g. PressStart2P) get # Disable anti-aliasing: pixel/bitmap fonts (e.g. PressStart2P) get
# anti-aliased into dim partial-lit pixels on a 1:1 LED matrix, muddying # anti-aliased into dim partial-lit pixels on a 1:1 LED matrix, muddying
# glyphs. 1-bit mode keeps strokes crisp. # glyphs. 1-bit mode keeps strokes crisp.
@@ -852,7 +901,14 @@ class SportsCoreSharedMixin:
# and they only ever changed the font. An explicit fill still wins: # and they only ever changed the font. An explicit fill still wins:
# the odds colours and the favourite-result score tint mean something # the odds colours and the favourite-result score tint mean something
# the palette does not. # the palette does not.
if fill is None: if element is not None:
# Named, so both questions can be answered exactly: whether this
# element is meant to be on screen at all, and what colour it is.
if not self._element_visible(element):
return
if fill is None:
fill = self._element_color(element)
elif fill is None:
fill = self._font_color(font) fill = self._font_color(font)
draw.fontmode = "1" draw.fontmode = "1"
x, y = position x, y = position
+77
View File
@@ -1115,6 +1115,83 @@ def element_color(config: Any, element_key: str,
return default return default
def _element_field(config: Any, element_key: str, field: str,
mode: Optional[str] = None, in_layout: bool = False):
"""Raw ``customization[.layout].<element>.<field>``, mode first.
The shared body behind the stateless visible/align/scale readers. Returns
None for "not configured", which every caller turns into its own default --
None must not collapse into a value here, because for a mode it is the
inherit sentinel.
"""
block = config.get('customization') if isinstance(config, dict) else None
block = block if isinstance(block, dict) else {}
def _read(source):
if not isinstance(source, dict):
return None
holder = source.get('layout') if in_layout else source
return _lookup_element(holder, element_key).get(field)
if mode:
modes = block.get('modes')
mode_block = modes.get(mode) if isinstance(modes, dict) else None
override = _read(mode_block)
if override is not None:
return override
return _read(block)
def element_visible(config: Any, element_key: str, default: bool = True,
mode: Optional[str] = None) -> bool:
"""``customization.<element>.visible``, or *default*.
The stateless form of the flag :meth:`ElementStyleResolver.style` already
resolves, for the scoreboard draw paths that hold a config rather than a
resolver.
"""
try:
value = _element_field(config, element_key, 'visible', mode)
return default if value is None else _coerce_bool(value, default)
except Exception as e:
logger.warning("Error reading visibility for %s: %s", element_key, e)
return default
def element_align(config: Any, element_key: str,
default: Optional[str] = None,
mode: Optional[str] = None) -> Optional[str]:
"""``customization.<element>.align`` ('left'/'center'/'right'), or *default*."""
try:
value = _element_field(config, element_key, 'align', mode)
if value is None:
return default
# _coerce_align answers None for anything that is not an alignment;
# that is "no preference", which means the caller's default.
coerced = _coerce_align(value)
return default if coerced is None else coerced
except Exception as e:
logger.warning("Error reading alignment for %s: %s", element_key, e)
return default
def element_scale(config: Any, element_key: str, default: float = 1.0,
mode: Optional[str] = None) -> float:
"""``customization.layout.<element>.scale``, or *default*.
Scale sits in the layout block beside the offsets, because it positions and
sizes rather than styles -- a logo has no font or colour but is very much
something users want smaller.
"""
try:
value = _element_field(config, element_key, 'scale', mode,
in_layout=True)
return default if value is None else _coerce_scale(value, default)
except Exception as e:
logger.warning("Error reading scale for %s: %s", element_key, e)
return default
class ElementStyleResolver: class ElementStyleResolver:
"""Resolves per-element user styling against schema defaults. """Resolves per-element user styling against schema defaults.
+187
View File
@@ -0,0 +1,187 @@
"""Visibility, alignment and scale reach the draw.
The resolver has understood these three since the framework landed, but nothing
on the scoreboard path consumed them: an element could be marked hidden in the
web UI and still render. This covers the readers, the mixin accessors the draw
paths use, and the one shared logo-sizing seam.
The rule throughout is that an untouched config takes exactly the path it took
before -- so each test that asserts an effect has a sibling asserting the
default is inert.
"""
import logging
import sys
from pathlib import Path
import pytest
from PIL import Image
PROJECT_ROOT = Path(__file__).parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
from src.common.logo_helper import LogoHelper # noqa: E402
from src.common.sports_shared import SportsCoreSharedMixin # noqa: E402
from src.element_style import ( # noqa: E402
element_align, element_scale, element_visible)
HIDDEN = {"customization": {"records": {"visible": False}}}
ALIGNED = {"customization": {"score_text": {"align": "right"}}}
SCALED = {"customization": {"layout": {"home_logo": {"scale": 0.5}}}}
class TestTheReaders:
def test_an_element_is_visible_unless_it_says_otherwise(self):
assert element_visible({}, "records") is True
assert element_visible(HIDDEN, "records") is False
def test_a_mode_can_hide_what_the_base_shows(self):
cfg = {"customization": {"records": {"visible": True},
"modes": {"recent": {"records": {"visible": False}}}}}
assert element_visible(cfg, "records") is True
assert element_visible(cfg, "records", mode="recent") is False
def test_a_mode_that_says_nothing_inherits(self):
"""None is the inherit sentinel and must not read as False."""
cfg = {"customization": {"records": {"visible": False},
"modes": {"live": {"records": {"visible": None}}}}}
assert element_visible(cfg, "records", mode="live") is False
def test_alignment_is_unset_by_default(self):
assert element_align({}, "score_text") is None
assert element_align(ALIGNED, "score_text") == "right"
def test_scale_reads_the_layout_block(self):
assert element_scale({}, "home_logo") == 1.0
assert element_scale(SCALED, "home_logo") == 0.5
def test_scale_takes_a_per_mode_override(self):
cfg = {"customization": {"layout": {"home_logo": {"scale": 0.5}},
"modes": {"live": {"layout": {"home_logo": {"scale": 2.0}}}}}}
assert element_scale(cfg, "home_logo", mode="live") == 2.0
assert element_scale(cfg, "home_logo") == 0.5
@pytest.mark.parametrize("cfg", [
None, {}, "nonsense", {"customization": "nonsense"},
{"customization": {"records": "nonsense"}},
{"customization": {"records": {"visible": "maybe"}}},
])
def test_a_hostile_config_never_raises(self, cfg):
assert element_visible(cfg, "records") is True
assert element_align(cfg, "records") is None
assert element_scale(cfg, "records") == 1.0
class _Draw:
"""Records what would have been drawn."""
def __init__(self):
self.calls = []
self.fontmode = ""
def text(self, position, text, font=None, fill=None):
self.calls.append({"position": position, "text": text, "fill": fill})
class _Host(SportsCoreSharedMixin):
def __init__(self, config, mode=None):
self.config = config
self.fonts = {}
self.logger = logging.getLogger("test")
self.SKIN_MODE = mode
class TestTheDrawPath:
def test_a_hidden_element_is_not_drawn_at_all(self):
draw = _Draw()
_Host(HIDDEN)._draw_text_with_outline(
draw, "9-1", (0, 0), None, element="records")
assert draw.calls == [], (
"a hidden element must produce no draw, outline included")
def test_a_visible_element_still_draws(self):
draw = _Draw()
_Host({})._draw_text_with_outline(
draw, "9-1", (0, 0), None, element="records")
assert draw.calls, "the default must be inert"
def test_naming_the_element_resolves_its_colour(self):
draw = _Draw()
cfg = {"customization": {"score_text": {"text_color": [1, 2, 3]}}}
_Host(cfg)._draw_text_with_outline(
draw, "21", (0, 0), None, element="score_text")
# Last call is the text itself; the earlier eight are the outline.
assert draw.calls[-1]["fill"] == (1, 2, 3)
def test_an_explicit_fill_still_wins(self):
"""Odds colours and the favourite-result tint mean something the
palette does not."""
draw = _Draw()
cfg = {"customization": {"score_text": {"text_color": [1, 2, 3]}}}
_Host(cfg)._draw_text_with_outline(
draw, "21", (0, 0), None, fill=(9, 9, 9), element="score_text")
assert draw.calls[-1]["fill"] == (9, 9, 9)
def test_a_mode_hides_only_that_mode(self):
cfg = {"customization": {"modes": {"recent": {"records": {"visible": False}}}}}
recent, live = _Draw(), _Draw()
_Host(cfg, "recent")._draw_text_with_outline(
recent, "9-1", (0, 0), None, element="records")
_Host(cfg, "live")._draw_text_with_outline(
live, "9-1", (0, 0), None, element="records")
assert recent.calls == []
assert live.calls
class TestAlignment:
def test_unset_leaves_the_caller_where_it_was(self):
"""These draws carry per-sport nudges; a centre recomputed here would
not be the same pixel."""
assert _Host({})._aligned_x("score_text", 20, 64, 22) == 22
def test_right_pushes_to_the_edge(self):
assert _Host(ALIGNED)._aligned_x("score_text", 20, 64, 22) == 44
def test_left_goes_to_zero(self):
cfg = {"customization": {"score_text": {"align": "left"}}}
assert _Host(cfg)._aligned_x("score_text", 20, 64, 22) == 0
def test_text_wider_than_the_panel_is_not_pushed_off(self):
assert _Host(ALIGNED)._aligned_x("score_text", 100, 64, 0) == 0
class TestLogoScale:
@pytest.fixture
def logo(self, tmp_path):
path = tmp_path / "team.png"
Image.new("RGBA", (40, 40), (255, 0, 0, 255)).save(path)
return path
def test_an_unscaled_load_is_unchanged(self, logo):
helper = LogoHelper(display_width=64, display_height=32)
plain = helper.load_logo("AAA", logo, 32, 32)
assert plain.size == (32, 32)
def test_a_half_scale_logo_is_half_the_box(self, logo):
helper = LogoHelper(display_width=64, display_height=32)
small = helper.load_logo("AAA", logo, 32, 32, scale=0.5)
assert small.size == (16, 16)
def test_scaling_up_grows_the_logo(self, logo):
"""The fit rule never grows an image, so this only happens when the
user asks for it."""
helper = LogoHelper(display_width=64, display_height=32)
big = helper.load_logo("AAA", logo, 64, 64, scale=2.0)
assert big.size[0] > 40
def test_two_scales_do_not_share_a_cache_entry(self, logo):
helper = LogoHelper(display_width=64, display_height=32)
full = helper.load_logo("AAA", logo, 32, 32)
half = helper.load_logo("AAA", logo, 32, 32, scale=0.5)
assert full.size == (32, 32) and half.size == (16, 16)
@pytest.mark.parametrize("bad", [0, -1, None, "big", float("inf"),
float("nan")])
def test_an_unusable_scale_is_ignored(self, logo, bad):
helper = LogoHelper(display_width=64, display_height=32)
assert helper.load_logo("AAA", logo, 32, 32, scale=bad).size == (32, 32)
+29 -3
View File
@@ -65,11 +65,37 @@ class TestColour:
cfg = {"customization": {"score_text": {"text_color": [4, 5, 6]}}} cfg = {"customization": {"score_text": {"text_color": [4, 5, 6]}}}
assert C.font_color(cfg, {"score": a, "team": b}, a) == (4, 5, 6) assert C.font_color(cfg, {"score": a, "team": b}, a) == (4, 5, 6)
def test_a_shared_face_gives_up_rather_than_guessing(self): def test_a_shared_face_takes_the_one_colour_that_was_configured(self):
"""One object used for two elements has no single right colour.""" """A face shared by two elements used to go out white even when only
one of them had a colour set -- and a bitmap face is *always* shared,
because a freetype.Face cannot be re-instantiated to un-share it. That
is how an element in any of the 32 shipped BDF fonts silently lost the
colour its picker had offered all along. One configured colour among
the sharers is the only thing the user can have meant."""
shared = object() shared = object()
cfg = {"customization": {"score_text": {"text_color": [4, 5, 6]}}} cfg = {"customization": {"score_text": {"text_color": [4, 5, 6]}}}
assert C.font_color(cfg, {"score": shared, "team": shared}, shared) == (255, 255, 255) assert C.font_color(cfg, {"score": shared, "team": shared},
shared) == (4, 5, 6)
def test_a_shared_face_still_gives_up_when_the_colours_disagree(self):
"""Two different answers is the case with no right answer."""
shared = object()
cfg = {"customization": {"score_text": {"text_color": [4, 5, 6]},
"team_name": {"text_color": [7, 8, 9]}}}
assert C.font_color(cfg, {"score": shared, "team": shared},
shared) == (255, 255, 255)
def test_sharers_that_agree_resolve_to_that_colour(self):
shared = object()
cfg = {"customization": {"score_text": {"text_color": [4, 5, 6]},
"team_name": {"text_color": [4, 5, 6]}}}
assert C.font_color(cfg, {"score": shared, "team": shared},
shared) == (4, 5, 6)
def test_an_unconfigured_shared_face_keeps_the_default(self):
shared = object()
assert C.font_color({}, {"score": shared, "team": shared}, shared,
(9, 9, 9)) == (9, 9, 9)
class TestFavourites: class TestFavourites:
@@ -0,0 +1,249 @@
"""A save must only switch off checkboxes the caller actually saw.
An HTML checkbox posts nothing when unchecked, so the save route walks the
schema and forces every boolean missing from the form to ``False``. That is
right for the rendered form and wrong for everything else: a caller that posts
four fields -- a script, the MQTT bridge, a curl against the documented
endpoint -- never rendered a checkbox, and reading its silence as "all off"
turns a one-field save into a mass disable.
That is not hypothetical. Posting four ``customization.*`` keys to a live
device switched off ``nfl.enabled``, ``ncaa_fb.enabled`` and every display-mode
toggle in one request.
The fix keeps both halves working, so both are pinned here:
* the rendered form reports the sections it drew (``__rendered_section``), and
inside those, an absent checkbox still means unchecked -- including a section
whose only fields are checkboxes that are all off, which no heuristic could
recover;
* a post with no such marker only touches objects it actually posted a field
from.
"""
import json
import sys
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from flask import Flask
from werkzeug.datastructures import MultiDict
PROJECT_ROOT = Path(__file__).parent.parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
SCHEMA = {
"type": "object",
"properties": {
"enabled": {"type": "boolean", "default": True},
"nfl": {
"type": "object",
"properties": {
"enabled": {"type": "boolean", "default": True},
"live_priority": {"type": "boolean", "default": True},
"update_interval": {"type": "integer", "default": 3600},
# A section with nothing but checkboxes: when they are all
# unchecked it posts no keys at all.
"display_modes": {
"type": "object",
"properties": {
"show_live": {"type": "boolean", "default": True},
"show_recent": {"type": "boolean", "default": True},
},
},
},
},
"customization": {
"type": "object",
"properties": {
"score_text": {
"type": "object",
"properties": {
"visible": {"type": "boolean", "default": True},
"text_color": {
"type": "array",
"items": {"type": "integer"},
"minItems": 3, "maxItems": 3,
"default": [255, 255, 255],
},
},
},
},
},
},
}
STORED = {
"enabled": True,
"nfl": {
"enabled": True,
"live_priority": True,
"update_interval": 3600,
"display_modes": {"show_live": True, "show_recent": True},
},
"customization": {"score_text": {"visible": True,
"text_color": [255, 255, 255]}},
}
class _SaveOk:
status = type("S", (), {"value": "success"})()
message = None
@pytest.fixture
def post(tmp_path):
"""POST form data to the real save route; yields the stored config."""
from src.plugin_system.schema_manager import SchemaManager
from web_interface.blueprints import api_v3 as a
originals = {k: getattr(a.api_v3, k, None)
for k in ("config_manager", "schema_manager", "plugin_manager")}
pdir = tmp_path / "plugins" / "demo"
pdir.mkdir(parents=True)
(pdir / "config_schema.json").write_text(json.dumps(SCHEMA), encoding="utf-8")
store = {"demo": json.loads(json.dumps(STORED))}
cm = MagicMock()
cm.load_config.side_effect = lambda: json.loads(json.dumps(store))
cm.get_config_path.return_value = str(tmp_path / "config.json")
def _save(cfg, **_kw):
store.clear()
store.update(cfg)
return _SaveOk()
cm.save_config_atomic.side_effect = _save
a.api_v3.config_manager = cm
a.api_v3.schema_manager = SchemaManager(plugins_dir=tmp_path / "plugins",
project_root=tmp_path)
pm = MagicMock()
pm.plugins = {}
pm.get_plugin.return_value = None
a.api_v3.plugin_manager = pm
app = Flask(__name__)
app.config["TESTING"] = True
app.register_blueprint(a.api_v3, url_prefix="/api/v3")
client = app.test_client()
def _post(form):
# A list of pairs, not a dict: __rendered_section repeats, and that
# repetition is the whole point of the marker.
data = MultiDict(form) if isinstance(form, list) else form
resp = client.post("/api/v3/plugins/config?plugin_id=demo", data=data)
return resp, store.get("demo", {})
try:
yield _post
finally:
for k, v in originals.items():
setattr(a.api_v3, k, v)
# What the style editor posts when someone changes one colour and nothing else.
PARTIAL = [
("customization.score_text.text_color.0", "255"),
("customization.score_text.text_color.1", "0"),
("customization.score_text.text_color.2", "255"),
]
class TestAPartialPost:
def test_it_does_not_disable_another_section(self, post):
"""The live-device regression, in one assertion."""
_post = post
resp, cfg = _post(list(PARTIAL))
assert resp.status_code == 200, resp.get_json()
assert cfg["nfl"]["enabled"] is True
assert cfg["nfl"]["live_priority"] is True
assert cfg["nfl"]["display_modes"] == {"show_live": True,
"show_recent": True}
def test_it_still_applies_what_was_posted(self, post):
_post = post
_resp, cfg = _post(list(PARTIAL))
assert cfg["customization"]["score_text"]["text_color"] == [255, 0, 255]
def test_it_leaves_a_sibling_checkbox_it_never_mentioned(self, post):
"""`visible` lives beside the colour, but the post carried no field
from that object -- only from the colour array inside it."""
_post = post
_resp, cfg = _post(list(PARTIAL))
assert cfg["customization"]["score_text"]["visible"] is True
def test_it_does_clear_a_box_in_an_object_it_posted_to(self, post):
"""Evidence, not guesswork: a field from `display_modes` was posted,
so the checkbox missing from that same object really is unchecked."""
_post = post
_resp, cfg = _post([("nfl.display_modes.show_live", "true")])
assert cfg["nfl"]["display_modes"] == {"show_live": True,
"show_recent": False}
assert cfg["nfl"]["enabled"] is True
class TestTheRenderedForm:
"""With section markers the old behaviour must be exactly preserved."""
def test_an_unchecked_box_is_still_cleared(self, post):
_post = post
resp, cfg = _post([("__rendered_section", "nfl"),
("__rendered_section", "customization"),
("enabled", "true"),
("nfl.update_interval", "3600"),
("nfl.display_modes.show_live", "true")])
assert resp.status_code == 200, resp.get_json()
assert cfg["nfl"]["enabled"] is False
assert cfg["nfl"]["live_priority"] is False
assert cfg["nfl"]["display_modes"]["show_recent"] is False
def test_a_section_of_only_checkboxes_all_unchecked_is_cleared(self, post):
"""It posts no keys of its own, so only the marker can vouch for it."""
_post = post
_resp, cfg = _post([("__rendered_section", "nfl"),
("nfl.update_interval", "3600")])
assert cfg["nfl"]["display_modes"] == {"show_live": False,
"show_recent": False}
def test_a_section_the_form_did_not_draw_is_untouched(self, post):
_post = post
_resp, cfg = _post([("__rendered_section", "nfl"),
("nfl.update_interval", "3600")])
assert cfg["customization"]["score_text"]["visible"] is True
def test_the_marker_is_not_written_into_the_config(self, post):
"""It describes the submission; it is not a config path. Unknown form
keys are otherwise stored verbatim."""
_post = post
_resp, cfg = _post([("__rendered_section", "nfl"),
("nfl.update_interval", "3600")])
assert "__rendered_section" not in cfg
assert not [k for k in cfg if k.startswith("__")]
class TestTheFormEmitsTheMarker:
"""The fix only works if the rendered form actually reports its sections."""
def test_every_top_level_section_is_reported(self, tmp_path):
import re
from jinja2 import Environment, FileSystemLoader
templates = PROJECT_ROOT / "web_interface" / "templates"
env = Environment(loader=FileSystemLoader(str(templates)))
source = (templates / "v3" / "partials" / "plugin_config.html").read_text(
encoding="utf-8")
assert '__rendered_section' in source, (
"the form must tell the save route which sections it drew")
# It has to cover the advanced tier too, or collapsing a section into
# Advanced Settings would quietly stop its checkboxes clearing.
marker = re.search(r"\{% for key in ([^%]+) %\}\s*"
r"<input type=\"hidden\" name=\"__rendered_section\"",
source)
assert marker, "marker loop not found"
assert "tiers.basic" in marker.group(1)
assert "tiers.advanced" in marker.group(1)
+76 -19
View File
@@ -1031,14 +1031,59 @@ def _set_nested_value(config, key_path, value):
# value (e.g. the per-mode "inherit the base" override) and must overwrite # value (e.g. the per-mode "inherit the base" override) and must overwrite
# whatever was already stored. # whatever was already stored.
current[segments[-1]] = value current[segments[-1]] = value
def _set_missing_booleans_to_false(config, schema_props, form_keys, prefix='', config_node=None):
#: Hidden field the rendered plugin form repeats once per top-level section it
#: drew. Named with a leading underscore pair so the save path can drop it (and
#: anything else meta) before treating form keys as config paths.
_RENDERED_SECTION_FIELD = '__rendered_section'
def _boolean_is_in_scope(full_path, prefix, sections, submitted_parents):
"""Whether a missing checkbox at ``full_path`` may be forced to False.
"Missing" only means "unchecked" for a form that actually rendered the
control. A caller that posts a handful of fields -- a script, the MQTT
bridge, a curl against the documented endpoint -- never rendered anything,
and reading its silence as "every other checkbox is off" turns a one-field
save into a mass disable. That is not hypothetical: a partial post of four
``customization.*`` keys switched off ``nfl.enabled``, ``ncaa_fb.enabled``
and every display-mode toggle on a live device.
Two ways to be in scope:
* ``sections`` -- the rendered form lists the top-level sections it drew
(``__rendered_section``). Anything it drew is fair game, including a
section whose only fields are checkboxes that are all unchecked, which
is the case no heuristic can recover.
* ``submitted_parents`` -- no marker, so fall back to evidence: the
containing object must have had at least one field posted.
"""
if sections is not None:
return full_path.split('.', 1)[0] in sections
if submitted_parents is None:
return True
return prefix in submitted_parents
def _submitted_parents(form_keys):
"""The object paths a form actually posted a field from ('' = top level)."""
parents = set()
for key in form_keys:
parents.add(key.rsplit('.', 1)[0] if '.' in key else '')
return parents
def _set_missing_booleans_to_false(plugin_config, schema_props, form_keys, prefix='', config_node=None,
sections=None, submitted_parents=None):
"""Walk schema and set missing boolean form fields to False. """Walk schema and set missing boolean form fields to False.
HTML checkboxes don't submit values when unchecked. When saving plugin config, HTML checkboxes don't submit values when unchecked. When saving plugin plugin_config,
the backend starts from existing config (to support partial form updates), which the backend starts from existing plugin_config (to support partial form updates), which
means an unchecked checkbox's old ``True`` value persists. This function detects means an unchecked checkbox's old ``True`` value persists. This function detects
boolean schema properties not present in the form submission and explicitly sets boolean schema properties not present in the form submission and explicitly sets
them to ``False``. them to ``False`` -- but only where that silence is evidence, see
:func:`_boolean_is_in_scope`.
The top-level ``enabled`` field is excluded because it has its own preservation The top-level ``enabled`` field is excluded because it has its own preservation
logic in the save endpoint. logic in the save endpoint.
@@ -1047,15 +1092,21 @@ def _set_missing_booleans_to_false(config, schema_props, form_keys, prefix='', c
(e.g. ``feeds.custom_feeds.0.enabled``). (e.g. ``feeds.custom_feeds.0.enabled``).
Args: Args:
config: The root plugin config dict (used for pure-dict paths) plugin_config: The root plugin plugin_config dict (used for pure-dict paths)
schema_props: Schema ``properties`` dict at the current nesting level schema_props: Schema ``properties`` dict at the current nesting level
form_keys: Set of form field names that were submitted form_keys: Set of form field names that were submitted
prefix: Dot-notation prefix for the current nesting level prefix: Dot-notation prefix for the current nesting level
config_node: The current config subtree when inside an array item (avoids config_node: The current plugin_config subtree when inside an array item (avoids
using _set_nested_value which corrupts lists) using _set_nested_value which corrupts lists)
sections: Top-level sections the form reported rendering, or None when it
reported none (then submitted_parents decides)
submitted_parents: Object paths with at least one posted field; computed
on the first call when there are no section markers
""" """
# Determine which config node to operate on if sections is None and submitted_parents is None:
node = config_node if config_node is not None else config submitted_parents = _submitted_parents(form_keys)
# Determine which plugin_config node to operate on
node = config_node if config_node is not None else plugin_config
for prop_name, prop_schema in schema_props.items(): for prop_name, prop_schema in schema_props.items():
if not isinstance(prop_schema, dict): if not isinstance(prop_schema, dict):
@@ -1065,14 +1116,17 @@ def _set_missing_booleans_to_false(config, schema_props, form_keys, prefix='', c
prop_type = prop_schema.get('type') prop_type = prop_schema.get('type')
if prop_type == 'boolean' and full_path != 'enabled': if prop_type == 'boolean' and full_path != 'enabled':
# If this boolean wasn't submitted in the form, it's an unchecked checkbox # If this boolean wasn't submitted in the form, it's an unchecked
if full_path not in form_keys: # checkbox -- provided the form drew it at all.
if (full_path not in form_keys
and _boolean_is_in_scope(full_path, prefix, sections,
submitted_parents)):
if config_node is not None: if config_node is not None:
# Inside an array item — set directly on the item dict # Inside an array item — set directly on the item dict
node[prop_name] = False node[prop_name] = False
else: else:
# Pure dict path — use helper # Pure dict path — use helper
_set_nested_value(config, full_path, False) _set_nested_value(plugin_config, full_path, False)
elif prop_type == 'object' and 'properties' in prop_schema: elif prop_type == 'object' and 'properties' in prop_schema:
# Recurse into nested objects # Recurse into nested objects
@@ -1081,12 +1135,14 @@ def _set_missing_booleans_to_false(config, schema_props, form_keys, prefix='', c
if prop_name not in node or not isinstance(node[prop_name], dict): if prop_name not in node or not isinstance(node[prop_name], dict):
node[prop_name] = {} node[prop_name] = {}
_set_missing_booleans_to_false( _set_missing_booleans_to_false(
config, prop_schema['properties'], form_keys, full_path, plugin_config, prop_schema['properties'], form_keys, full_path,
config_node=node[prop_name] config_node=node[prop_name],
sections=sections, submitted_parents=submitted_parents
) )
else: else:
_set_missing_booleans_to_false( _set_missing_booleans_to_false(
config, prop_schema['properties'], form_keys, full_path plugin_config, prop_schema['properties'], form_keys, full_path,
sections=sections, submitted_parents=submitted_parents
) )
elif prop_type == 'array': elif prop_type == 'array':
@@ -1108,15 +1164,15 @@ def _set_missing_booleans_to_false(config, schema_props, form_keys, prefix='', c
if not indices: if not indices:
continue continue
# Navigate to the array in the config (create if missing) # Navigate to the array in the plugin_config (create if missing)
if config_node is not None: if config_node is not None:
if prop_name not in node or not isinstance(node[prop_name], list): if prop_name not in node or not isinstance(node[prop_name], list):
node[prop_name] = [] node[prop_name] = []
array_list = node[prop_name] array_list = node[prop_name]
else: else:
# Navigate from root config through dict keys to get the list # Navigate from root plugin_config through dict keys to get the list
parts = full_path.split('.') parts = full_path.split('.')
current = config current = plugin_config
for part in parts[:-1]: for part in parts[:-1]:
if part not in current or not isinstance(current[part], dict): if part not in current or not isinstance(current[part], dict):
current[part] = {} current[part] = {}
@@ -1135,8 +1191,9 @@ def _set_missing_booleans_to_false(config, schema_props, form_keys, prefix='', c
array_list[idx] = {} array_list[idx] = {}
item_prefix = f"{full_path}.{idx}" item_prefix = f"{full_path}.{idx}"
_set_missing_booleans_to_false( _set_missing_booleans_to_false(
config, items_schema['properties'], form_keys, item_prefix, plugin_config, items_schema['properties'], form_keys, item_prefix,
config_node=array_list[idx] config_node=array_list[idx],
sections=sections, submitted_parents=submitted_parents
) )
def _enhance_schema_with_core_properties(schema): def _enhance_schema_with_core_properties(schema):
""" """
+14 -2
View File
@@ -5,7 +5,7 @@ endpoint names are unchanged by living here.
""" """
from web_interface.blueprints.api_v3 import ( from web_interface.blueprints.api_v3 import (
ErrorCode, OperationType, PROJECT_ROOT, Path, Response, ErrorCode, OperationType, PROJECT_ROOT, Path, Response,
_CALENDAR_LIST_MAX_PAGES, _SKIP_FIELD, _coerce_to_bool, _CALENDAR_LIST_MAX_PAGES, _RENDERED_SECTION_FIELD, _SKIP_FIELD, _coerce_to_bool,
_do_transactional_uninstall, _enhance_schema_with_core_properties, _do_transactional_uninstall, _enhance_schema_with_core_properties,
_filter_config_by_schema, _get_plugin_version, _get_schema_property, _filter_config_by_schema, _get_plugin_version, _get_schema_property,
_installed_plugin_ids, _is_plugin_update_available, _installed_plugin_ids, _is_plugin_update_available,
@@ -1730,6 +1730,11 @@ def save_plugin_config():
# Convert form data to config dict # Convert form data to config dict
# Form fields can use dot notation for nested values (e.g., "transition.type") # Form fields can use dot notation for nested values (e.g., "transition.type")
form_data = request.form.to_dict() form_data = request.form.to_dict()
# Meta fields describe the submission, they are not config paths.
# Unknown keys are otherwise written straight into config.json by
# the non-indexed pass below.
form_data = {k: v for k, v in form_data.items()
if not k.startswith('__')}
# First pass: handle bracket notation array fields (e.g., "field_name[]" from checkbox-group) # First pass: handle bracket notation array fields (e.g., "field_name[]" from checkbox-group)
# These fields use getlist() to preserve all values, then replace in form_data # These fields use getlist() to preserve all values, then replace in form_data
@@ -2089,7 +2094,14 @@ def save_plugin_config():
# Walk the schema and set any boolean fields missing from form data to False. # Walk the schema and set any boolean fields missing from form data to False.
if schema and 'properties' in schema: if schema and 'properties' in schema:
form_keys = set(request.form.keys()) form_keys = set(request.form.keys())
_set_missing_booleans_to_false(plugin_config, schema['properties'], form_keys) # The rendered form reports which top-level sections it drew, so
# an unchecked box can be told apart from a field the caller
# never had in front of it. A caller that sends none gets the
# evidence-based fallback in _boolean_is_in_scope.
rendered_sections = set(request.form.getlist(_RENDERED_SECTION_FIELD))
_set_missing_booleans_to_false(
plugin_config, schema['properties'], form_keys,
sections=rendered_sections or None)
# Get schema manager instance (for JSON requests) # Get schema manager instance (for JSON requests)
schema_mgr = api_v3.schema_manager schema_mgr = api_v3.schema_manager
@@ -39,6 +39,30 @@
var FONT_CACHE = null; var FONT_CACHE = null;
var FONT_INFLIGHT = null; var FONT_INFLIGHT = null;
/**
* Read one own property, by a key that came from data.
*
* Every lookup in here is keyed by something out of a schema or a saved
* config -- an element name, a mode name, a field name. A key of
* `__proto__` or `constructor` would otherwise walk up the prototype
* chain and hand back a function instead of a schema, so reads go
* through here and misses come back undefined.
*/
function own(obj, key) {
if (!obj || typeof obj !== 'object') { return undefined; }
// Via the descriptor rather than obj[key]: this is the one read that
// cannot avoid a data-supplied key, and going through the descriptor
// means there is no computed member access here at all.
var descriptor = Object.getOwnPropertyDescriptor(obj, key);
return descriptor ? descriptor.value : undefined;
}
/** `own`, but always an object -- for `(x || {}).properties` chains. */
function ownObj(obj, key) {
var found = own(obj, key);
return (found && typeof found === 'object') ? found : {};
}
/** The font catalog, fetched once per page. */ /** The font catalog, fetched once per page. */
function loadFonts() { function loadFonts() {
if (FONT_CACHE) { return Promise.resolve(FONT_CACHE); } if (FONT_CACHE) { return Promise.resolve(FONT_CACHE); }
@@ -51,7 +75,7 @@
.then(function (payload) { .then(function (payload) {
var catalog = (payload && payload.data && payload.data.catalog) || {}; var catalog = (payload && payload.data && payload.data.catalog) || {};
FONT_CACHE = Object.keys(catalog).map(function (key) { FONT_CACHE = Object.keys(catalog).map(function (key) {
var entry = catalog[key]; var entry = ownObj(catalog, key);
return { return {
filename: entry.filename, filename: entry.filename,
label: entry.display_name || entry.filename, label: entry.display_name || entry.filename,
@@ -74,10 +98,11 @@
function el(tag, attrs, children) { function el(tag, attrs, children) {
var node = document.createElement(tag); var node = document.createElement(tag);
Object.keys(attrs || {}).forEach(function (k) { Object.keys(attrs || {}).forEach(function (k) {
if (k === 'class') { node.className = attrs[k]; } var v = own(attrs, k);
else if (k === 'text') { node.textContent = attrs[k]; } if (k === 'class') { node.className = v; }
else if (attrs[k] !== null && attrs[k] !== undefined) { else if (k === 'text') { node.textContent = v; }
node.setAttribute(k, attrs[k]); else if (v !== null && v !== undefined) {
node.setAttribute(k, v);
} }
}); });
(children || []).forEach(function (c) { node.appendChild(c); }); (children || []).forEach(function (c) { node.appendChild(c); });
@@ -87,9 +112,11 @@
/** Walk a nested value object by path segments. */ /** Walk a nested value object by path segments. */
function at(value, path) { function at(value, path) {
var cur = value; var cur = value;
for (var i = 0; i < path.length; i++) { // Consumed rather than indexed, so no step reads path[i].
var remaining = (path || []).slice();
while (remaining.length) {
if (cur === null || typeof cur !== 'object') { return undefined; } if (cur === null || typeof cur !== 'object') { return undefined; }
cur = cur[path[i]]; cur = own(cur, remaining.shift());
} }
return cur; return cur;
} }
@@ -121,12 +148,12 @@
var order = schema['x-propertyOrder'] || Object.keys(props); var order = schema['x-propertyOrder'] || Object.keys(props);
return order.filter(function (k) { return order.filter(function (k) {
return k !== 'layout' && k !== 'modes' return k !== 'layout' && k !== 'modes'
&& props[k] && props[k].properties; && own(props, k) && ownObj(props, k).properties;
}); });
} }
function titleOf(schema, key) { function titleOf(schema, key) {
var prop = (schema.properties || {})[key] || {}; var prop = ownObj(schema.properties || {}, key);
return prop.title || key.replace(/_/g, ' '); return prop.title || key.replace(/_/g, ' ');
} }
@@ -257,11 +284,16 @@
// drives them. // drives them.
var wrap = el('div', { class: 'flex items-center gap-1' }); var wrap = el('div', { class: 'flex items-center gap-1' });
var has = Array.isArray(current) && current.length >= 3; var has = Array.isArray(current) && current.length >= 3;
var channels = [0, 1, 2].map(function (i) { var startValues = has ? current.slice(0, 3) : [];
var input = el('input', { type: 'hidden', name: name + '.' + i }); var channels = startValues.concat([null, null, null])
if (has) { input.value = current[i]; } .slice(0, 3)
return input; .map(function (channelValue, i) {
}); var input = el('input', {
type: 'hidden', name: name + '.' + i
});
if (has) { input.value = channelValue; }
return input;
});
// A disabled input is not submitted at all, which is how an unset // A disabled input is not submitted at all, which is how an unset
// optional colour says nothing rather than posting three empty // optional colour says nothing rather than posting three empty
@@ -280,8 +312,8 @@
}); });
swatch.addEventListener('input', function () { swatch.addEventListener('input', function () {
var hex = swatch.value; var hex = swatch.value;
[1, 3, 5].forEach(function (start, i) { channels.forEach(function (channel, i) {
channels[i].value = parseInt(hex.substr(start, 2), 16); channel.value = parseInt(hex.substr(1 + i * 2, 2), 16);
}); });
setSubmitted(true); setSubmitted(true);
if (clearBtn) { clearBtn.classList.remove('hidden'); } if (clearBtn) { clearBtn.classList.remove('hidden'); }
@@ -334,25 +366,28 @@
*/ */
function columnsFor(schema) { function columnsFor(schema) {
var props = schema.properties || {}; var props = schema.properties || {};
var layoutProps = (props.layout || {}).properties || {}; var layoutProps = ownObj(props, 'layout').properties || {};
var seen = {}; // A Map, not an object: the keys are field names out of a schema, so
// a field literally named "constructor" is a column like any other
// and never touches a prototype.
var seen = new Map();
elementKeys(schema).forEach(function (key) { elementKeys(schema).forEach(function (key) {
Object.keys((props[key] || {}).properties || {}).forEach( Object.keys(ownObj(props, key).properties || {}).forEach(
function (f) { seen[f] = 'element'; }); function (f) { seen.set(f, 'element'); });
Object.keys((layoutProps[key] || {}).properties || {}).forEach( Object.keys(ownObj(layoutProps, key).properties || {}).forEach(
function (f) { seen[f] = 'layout'; }); function (f) { seen.set(f, 'layout'); });
}); });
var known = COLUMN_ORDER.filter(function (f) { return seen[f]; }); var known = COLUMN_ORDER.filter(function (f) { return seen.get(f); });
// Anything the schema declares that this file has never heard of // Anything the schema declares that this file has never heard of
// still gets a column, rather than silently vanishing. // still gets a column, rather than silently vanishing.
var extra = Object.keys(seen).filter(function (f) { var extra = Array.from(seen.keys()).filter(function (f) {
return COLUMN_ORDER.indexOf(f) === -1; return COLUMN_ORDER.indexOf(f) === -1;
}).sort(); }).sort();
return known.concat(extra).map(function (f) { return known.concat(extra).map(function (f) {
return { return {
key: f, key: f,
where: seen[f], where: seen.get(f),
label: COLUMN_LABELS[f] || f.replace(/_/g, ' ') label: own(COLUMN_LABELS, f) || f.replace(/_/g, ' ')
}; };
}); });
} }
@@ -362,10 +397,10 @@
var prop = opts.prop; var prop = opts.prop;
var declared = prop.type; var declared = prop.type;
var types = Array.isArray(declared) ? declared : [declared]; var types = Array.isArray(declared) ? declared : [declared];
var xOptions = prop['x-options'] || prop['x_options'] || {};
var cap = Number(xOptions.maxFixedSize) || opts.maxFixedSize || null;
if (opts.key === 'font' || prop['x-widget'] === 'font-selector') { if (opts.key === 'font' || prop['x-widget'] === 'font-selector') {
var xOptions = prop['x-options'] || prop['x_options'] || {};
var cap = Number(xOptions.maxFixedSize) || opts.maxFixedSize || null;
return fontControl(opts.name, opts.current, return fontControl(opts.name, opts.current,
usableFonts(opts.fonts, cap, opts.current), usableFonts(opts.fonts, cap, opts.current),
opts.optional, opts.onFontChange); opts.optional, opts.onFontChange);
@@ -390,9 +425,9 @@
var value = opts.value; var value = opts.value;
var optional = opts.optional; var optional = opts.optional;
var props = ((schema.properties || {})[key] || {}).properties || {}; var props = ownObj(schema.properties || {}, key).properties || {};
var layoutProps = ((schema.properties || {}).layout || {}).properties || {}; var layoutProps = ownObj(schema.properties || {}, 'layout').properties || {};
var axes = (layoutProps[key] || {}).properties || {}; var axes = ownObj(layoutProps, key).properties || {};
var row = el('div', { var row = el('div', {
class: 'style-editor-row grid items-center gap-2 py-1', class: 'style-editor-row grid items-center gap-2 py-1',
@@ -427,7 +462,8 @@
opts.columns.forEach(function (col) { opts.columns.forEach(function (col) {
var inLayout = col.where === 'layout'; var inLayout = col.where === 'layout';
var prop = inLayout ? axes[col.key] : props[col.key]; var prop = inLayout ? own(axes, col.key) : own(props, col.key);
var cell;
if (!prop) { if (!prop) {
// This element does not declare that field; keep the grid // This element does not declare that field; keep the grid
// aligned with an empty cell. // aligned with an empty cell.
@@ -452,7 +488,7 @@
if (col.key === 'font') { fontSelect = node; } if (col.key === 'font') { fontSelect = node; }
if (col.key === 'font_size') { if (col.key === 'font_size') {
sizeInput = node; sizeInput = node;
var cell = el('div', { class: 'flex items-center' }); cell = el('div', { class: 'flex items-center' });
cell.appendChild(node); cell.appendChild(node);
cell.appendChild(sizeNote); cell.appendChild(sizeNote);
node = cell; node = cell;
@@ -520,10 +556,12 @@
container.appendChild(root); container.appendChild(root);
loadFonts().then(function (fonts) { loadFonts().then(function (fonts) {
var modeProps = ((schema.properties || {}).modes || {}).properties || {}; var modeProps = ownObj(schema.properties || {}, 'modes').properties || {};
var modes = Object.keys(modeProps); var modes = Object.keys(modeProps);
var panels = {}; // A list, not a keyed object: the ids are mode names out of
// the schema, and nothing here needs a lookup by key.
var panels = [];
var tabs = null; var tabs = null;
if (modes.length) { if (modes.length) {
tabs = el('div', { class: 'style-editor-tabs flex gap-1 mb-2' }); tabs = el('div', { class: 'style-editor-tabs flex gap-1 mb-2' });
@@ -531,14 +569,14 @@
} }
function panel(id, node) { function panel(id, node) {
panels[id] = node; panels.push({ id: id, node: node });
node.classList.add('style-editor-panel'); node.classList.add('style-editor-panel');
root.appendChild(node); root.appendChild(node);
} }
function show(id) { function show(id) {
Object.keys(panels).forEach(function (k) { panels.forEach(function (p) {
panels[k].hidden = (k !== id); p.node.hidden = (p.id !== id);
}); });
if (!tabs) { return; } if (!tabs) { return; }
Array.prototype.forEach.call(tabs.children, function (b) { Array.prototype.forEach.call(tabs.children, function (b) {
@@ -565,7 +603,7 @@
tab('__base__', 'All modes'); tab('__base__', 'All modes');
modes.forEach(function (mode) { modes.forEach(function (mode) {
var modeSchema = modeProps[mode] || {}; var modeSchema = ownObj(modeProps, mode);
var modeValue = at(current, ['modes', mode]) || {}; var modeValue = at(current, ['modes', mode]) || {};
var node = el('div'); var node = el('div');
node.appendChild(el('p', { node.appendChild(el('p', {
@@ -1191,6 +1191,14 @@
{% endif %} {% endif %}
{% endif %} {% endif %}
{% endfor %} {% endfor %}
{# Tell the save path which sections this form actually
drew. An unchecked checkbox posts nothing, so without
this the server cannot tell "the user cleared it"
from "the caller never had that field" -- and a
partial post would read as every box being off. #}
{% for key in tiers.basic + tiers.advanced %}
<input type="hidden" name="__rendered_section" value="{{ key }}">
{% endfor %}
{% for key in tiers.basic %} {% for key in tiers.basic %}
{% set prop = schema.properties[key] %} {% set prop = schema.properties[key] %}
{% set value = config[key] if key in config else none %} {% set value = config[key] if key in config else none %}