mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-18 04:49:13 +00:00
Compare commits
34
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7603728e5a | ||
|
|
6c23994b2f | ||
|
|
f12d11334a | ||
|
|
be7a7b7baf | ||
|
|
406e68fba9 | ||
|
|
ea54e56bed | ||
|
|
696acdbc7b | ||
|
|
91d15a8943 | ||
|
|
6bea1a7c21 | ||
|
|
0730d95200 | ||
|
|
32d637a446 | ||
|
|
bc2dbf3824 | ||
|
|
cb0545ecb3 | ||
|
|
300cdaa250 | ||
|
|
10da2f97fd | ||
|
|
92f9d06af9 | ||
|
|
6e361e05cc | ||
|
|
a686932c7e | ||
|
|
2f42d179f6 | ||
|
|
37fc1b56b5 | ||
|
|
acc55ef119 | ||
|
|
1a0864e5d4 | ||
|
|
732c7d1a30 | ||
|
|
d42593e7ce | ||
|
|
f0bef7784c | ||
|
|
986f74e38b | ||
|
|
e450a6dfb6 | ||
|
|
5133643600 | ||
|
|
5929190e36 | ||
|
|
79ba93f5a6 | ||
|
|
e499efb1f0 | ||
|
|
cd7e16e58e | ||
|
|
47e3021fc3 | ||
|
|
e319540c6e |
@@ -17,6 +17,81 @@ release that ships it.
|
||||
accepts both, but the store flags the old spelling as deprecated
|
||||
(`store_manager.py`) and only the new one is in `schema/manifest_schema.json`.
|
||||
|
||||
## 3.3.0
|
||||
|
||||
**The release the sports scoreboards floor on to delete their bundled copies.**
|
||||
3.2.0 shipped the unified sports library and made `ledmatrix_min_version`
|
||||
enforceable; this ships the last three shared modules and completes the store
|
||||
gate, so a scoreboard can now floor here and carry no fallback at all.
|
||||
|
||||
New modules a plugin may import via `src.*` and floor on 3.3.0 for:
|
||||
|
||||
- `src/common/sports_card.py` — settings, colour, font and date helpers for a
|
||||
scoreboard's `game_renderer.py`. Free functions taking `config`/`fonts`
|
||||
explicitly, so nothing about the caller's class is assumed.
|
||||
- `src/common/sports_game_renderer.py` — `SportsGameRendererMixin`: scroll/Vegas
|
||||
card geometry (centre gap, logo slot and cache key, layout offsets, the
|
||||
upcoming-card date and time layout). No `__init__` and no state, so adoption
|
||||
is one line on the class statement.
|
||||
- `src/common/sports_shared.py` — `SportsCoreSharedMixin`,
|
||||
`SportsLiveSharedMixin`, `SportsRecentSharedMixin`: the `sports.py` bodies
|
||||
byte-identical in all eight lineage-sharing scoreboards.
|
||||
|
||||
All three sit under `src/common/` rather than `src/base_classes/sports/`,
|
||||
deliberately: importing that package pulls `core.py` → `DisplayManager` →
|
||||
`rgbmatrix`, and these are pure logic. Plugins importing them must not acquire a
|
||||
hardware dependency.
|
||||
|
||||
Three notes for anyone adopting `sports_shared`:
|
||||
|
||||
- `SportsRecentSharedMixin` defines `__init__`. Its bare `super()` binds to the
|
||||
mixin, so it reaches the host only when the mixin is listed **first** in the
|
||||
bases. Reversing that order silently skips the host constructor.
|
||||
- Methods that resolve the plugin's `config_schema.json` use `_plugin_dir()`,
|
||||
which walks the MRO rather than reading `__file__` — `__file__` is now
|
||||
`src/common/`. It walks because `SportsCore` is an ABC: a subclass built with
|
||||
`type(name, bases, ns)` reports `__module__` as `"abc"`.
|
||||
- `_get_timezone`, `_extract_game_details` and `_fetch_data` are byte-identical
|
||||
across the eight but stay in the plugins. The first binds a per-plugin
|
||||
timezone module whose contents differ; the other two are the abstract stubs
|
||||
that define the sport.
|
||||
|
||||
**The store's compatibility gate is now on every registry-managed route.** 3.2.0
|
||||
gated `install_plugin`. This release gates the git-pull update path and
|
||||
`install_from_url`, so a plugin whose floor the core cannot meet is refused
|
||||
after download with no partial directory left behind.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **ESPN 403s.** `site.api` began rejecting the User-Agent strings this repo
|
||||
sent on 2026-08-04; every shared-data-source scoreboard returned
|
||||
`403 Forbidden`. Requests now send an identifying token with a project URL —
|
||||
browser-style strings and bare custom tokens are both refused.
|
||||
- **Low-memory boards becoming unreachable under load** while still answering
|
||||
pings and serving the web UI. Fetched payloads are released after delivery
|
||||
rather than pinned on the completed request for up to an hour, malloc arenas
|
||||
are capped, and log volume and SD writes are reduced. Available memory is now
|
||||
reported in Tools diagnostics.
|
||||
- **A failed logo download pinning a team to a grey box**: the placeholder was
|
||||
written under the real logo's filename, so later attempts found a file and
|
||||
reported success without retrying.
|
||||
- **A plugin enabled but never loaded is retried** rather than staying absent
|
||||
with `error = null`.
|
||||
- `ttl` now controls cache expiry; abandoned cache writes no longer leave temp
|
||||
files; one cache-cleanup thread per directory rather than per manager.
|
||||
- Odds are fetched for the games displayed, not the whole schedule window, and a
|
||||
stalled ESPN no longer stalls the whole plugin update.
|
||||
- Array-item secrets are no longer wiped or logged.
|
||||
- A restored backup matches the device it was taken from.
|
||||
- The web UI reports the real error instead of "unknown", rejects non-finite
|
||||
JSON numbers, and stops checkbox groups posting back hidden options.
|
||||
|
||||
### Added
|
||||
|
||||
- `display.hardware.orientation` for panels mounted upside down.
|
||||
- Vegas keeps live content in the ticker rather than being preempted by it.
|
||||
- Schemas can label enum dropdown options.
|
||||
|
||||
## 3.2.0
|
||||
|
||||
**The first release shipping the unified sports library.** This is the version
|
||||
|
||||
+154
-36
@@ -204,7 +204,7 @@ legacy compatibility rather than the mechanism.
|
||||
B0–B3 are merged and shipping in core 3.2.0. Everything that remains is
|
||||
**rollout**, and it splits into three phases with very different risk profiles.
|
||||
The original plan folded the last two together; they are separated here because
|
||||
one of them is safe by construction and the other is not.
|
||||
one of them cannot break a user on an old core and the other can.
|
||||
|
||||
| Phase | Scope | Status | Gate |
|
||||
|---|---|---|---|
|
||||
@@ -212,9 +212,9 @@ one of them is safe by construction and the other is not.
|
||||
| **B1** | Promote the nine universal methods; convert `sports.py` → package | ✅ | Characterization suite green; no behavior change intended |
|
||||
| **B2** | `CelebrationMixin` + rotation strategies as opt-in capabilities | ✅ | Non-adopters have zero new code in their MRO; strategies checked against verbatim plugin transcriptions |
|
||||
| **B3** | Upstream the scroll **orchestration** layer as `src/common/sports_scroll.py`, reading `global_config['target_fps']` natively | ✅ | Content building stays per-sport |
|
||||
| **B4** | Ship 3.2.0 *and* make version reporting trustworthy | ⏳ **next** | Tag, release, and `src.__version__` agree; compatibility gate merged |
|
||||
| **B5** | Adoption — guarded core imports: three pilots, then the remaining six. **Bundled copies stay.** | after B4 | Per plugin: harness + goldens byte-identical, then a device soak |
|
||||
| **B6** | Sunset — delete the bundled copies | **blocked** | B4's gate shipped *and* in users' hands (see below) |
|
||||
| **B4** | Ship 3.2.0 *and* make version reporting trustworthy | ✅ | Released 2026-08-03; tag, release and `src.__version__` agree; compatibility gate merged (#428, #431, #433) |
|
||||
| **B5** | Adoption — guarded core imports, all eight. **Bundled copies stay.** | ✅ | All eight adopted; harness byte-identical; see the B5 retrospective below — four shipped broken and were repaired in plugins #251 |
|
||||
| **B6** | Sunset — delete the bundled copies | ✅ | Ran 2026-09-01, all eight. Floors at 3.2.0; the store refuses on all three routes in (#431/#433, #508, #510). See "B6 — what actually happened" |
|
||||
|
||||
### B4 — what "ship 3.2.0" actually requires
|
||||
|
||||
@@ -276,13 +276,21 @@ default is the more restrictive.
|
||||
`compatible_versions`. No manifest still carries it, so there is nothing to
|
||||
migrate there.)
|
||||
|
||||
### B5 — adoption is safe by construction
|
||||
### B5 — the *fallback* is safe by construction; the modern path is not
|
||||
|
||||
The heading matters, because the unqualified version of this claim is false and
|
||||
this document proves it two sections down: four of the eight adopted plugins
|
||||
shipped with scroll mode broken on a 3.2.0 core. What is safe by construction is
|
||||
narrower than "adoption".
|
||||
|
||||
A plugin adopting core imports keeps its bundled copy and reaches it through the
|
||||
guarded import (see the Upgradability table above). On a core that ships the
|
||||
module the plugin uses core code; on one that doesn't it falls back and behaves
|
||||
exactly as it does today. There is no version of this step that breaks a user,
|
||||
which is why it does not wait for B6's gate.
|
||||
guarded import (see the Upgradability table above). On a core that doesn't ship
|
||||
the module the plugin falls back and behaves exactly as it does today. That
|
||||
fallback compatibility — and only that — is safe by construction. On a core that
|
||||
*does* ship the module, correctness is not automatic — object-level and scroll-mode validation
|
||||
(building both classes and comparing, per the retrospective below) is required
|
||||
to prove full behavior. There is no version of this step that breaks a user *on
|
||||
an old core*, which is why it does not wait for B6's gate.
|
||||
|
||||
The hockey scroll-display pilot is **already validated**: adopted against a core
|
||||
carrying 3.2.0, `scroll_display.py` went from 691 to 289 lines and all 16 harness
|
||||
@@ -311,7 +319,8 @@ been in users' hands long enough that the population running a core without it
|
||||
is small.** The bundled copies cost disk space; deleting them early costs
|
||||
scoreboards, silently. That trade is not close.
|
||||
|
||||
Before the first sunset, add a **compatibility regression test**. It has to
|
||||
Before the first sunset, add a **compatibility regression test**. **Built:**
|
||||
core `test/test_sports_sunset_matrix.py` (#505). It has to
|
||||
cover four cases, not one — B5's safety claim and B6's failure mode are
|
||||
different propositions and only the second is obvious:
|
||||
|
||||
@@ -335,35 +344,144 @@ gate rather than trusting the failure to be noticed.
|
||||
The same suite should exercise the install/update gate, since it is the other
|
||||
half of the guarantee.
|
||||
|
||||
### B6 — what actually happened
|
||||
|
||||
**Ran 2026-09-01, across all eight scoreboards.** Held from 2026-08-05 to
|
||||
2026-09-01 on the argument below, which is kept because the reasoning applies to
|
||||
the next module, not because it is still in force.
|
||||
|
||||
**The hold, and why it lifted.** The stated gate was evidence of 3.2.0 uptake —
|
||||
"a few months of it being the default download, or store-side install data".
|
||||
That evidence never arrived and could not: the core updates by
|
||||
`git pull --rebase`, so release-asset counts cannot measure uptake, and no
|
||||
store-side telemetry exists. What changed instead is that the *risk* the gate
|
||||
protected against was closed directly. The store now refuses a plugin whose
|
||||
floor exceeds the running core on **all three** routes in:
|
||||
|
||||
| route | gated by |
|
||||
|---|---|
|
||||
| `install_plugin` — every path that re-downloads, `_reinstall_with_rollback` included | #431, #433 |
|
||||
| `update_plugin`'s git branch — pulls in place, re-downloads nothing | #508 |
|
||||
| `install_from_url` — sideloading | #510 |
|
||||
|
||||
With all three closed a pre-3.2.0 user cannot receive a sunset plugin at all;
|
||||
they keep the version they already run. The population the hold existed to
|
||||
protect is protected by refusal rather than by a bundled copy — which is what
|
||||
the copy was standing in for.
|
||||
|
||||
**What shipped.** Eight plugins, ~5,800 lines of frozen fallback deleted. Each:
|
||||
copy removed, guarded import collapsed to a plain one, floor raised to 3.2.0,
|
||||
`test_core_fallback.py` rewritten as `test_core_scroll.py` asserting the sunset
|
||||
rather than the fallback. `scripts/check_scroll_adoption.py` gained
|
||||
`sunset_violations` and a `SUNSET_PLUGINS` set naming all eight, so a
|
||||
resurrected copy or a returned guard fails CI.
|
||||
|
||||
Delivered as plugins #346 (hockey, later folded into #351), #349 (football),
|
||||
#350 (baseball), #351 (the remaining six).
|
||||
|
||||
**Two things found by doing it, both worth carrying forward:**
|
||||
|
||||
- **Only one fallback held orchestration logic the core lacked.** baseball's
|
||||
`_configure_scroll_helper` reinterpreted `scroll_speed` as pixels-per-*frame*
|
||||
when `speed × delay` fell outside the 0.1–5.0 window — measured, 10–20×
|
||||
faster than configured for a speed between 1.0 and 5.0. Standardised onto the
|
||||
core's behaviour (honour the documented px/sec, clamp) rather than preserved.
|
||||
Every other difference across the eight was a docstring, an unreachable
|
||||
`scroll_helper is None` guard, or an equivalent diagnostic.
|
||||
- **Two tests had been leaning on the guard without anyone knowing.**
|
||||
`soccer/test_live_screens.py` stubbed `src` in a way that shadowed the core,
|
||||
so its guarded import fell back and the test had been exercising the *frozen
|
||||
copy* rather than the shipping class since B5. Before sunsetting anything else
|
||||
that carries a guarded core import, grep for tests that stub `src`.
|
||||
|
||||
**The floor-raising traps still apply** to any future sunset: four plugins
|
||||
declare their floor top-level, where editing `versions[0]` is a silent no-op,
|
||||
and the floor has three live spellings (`min_ledmatrix_version`,
|
||||
`requires.min_ledmatrix_version`, `versions[].ledmatrix_min_version`, plus
|
||||
deprecated `ledmatrix_min`). See
|
||||
`src/plugin_system/compatibility.py:declared_min_version` for the resolution
|
||||
order any floor-raising tool must reproduce — and note the name is **inverted**
|
||||
between the top level and `versions[]`.
|
||||
|
||||
**Still not adopted, deliberately:** `data_sources.py`, `game_renderer.py` and
|
||||
`base_odds_manager.py`. The standing decision held them until B6 closed; it now
|
||||
has, so they can be reconsidered — with B5's lesson applied, which is to build
|
||||
the object and diff rendered output rather than trust a static check.
|
||||
|
||||
### B5 retrospective — what the adoption actually cost
|
||||
|
||||
Recorded because it is the evidence behind the two decisions above, and because
|
||||
"the adoption went fine" is not what happened.
|
||||
|
||||
**Four of the eight shipped with scroll mode broken** on a 3.2.0 core, and were
|
||||
repaired in plugins-repo #251. The restructure lifted the content methods
|
||||
verbatim but left the state they read off `self` behind: separator-icon
|
||||
constants (hockey, basketball, lacrosse) and the game-renderer cache (afl).
|
||||
hockey/basketball/lacrosse could not construct the scroll display at all; afl
|
||||
raised inside `prepare_scroll_content`, which the core base *catches*, so its
|
||||
only symptom was scroll mode silently drawing nothing.
|
||||
|
||||
Three things are worth carrying forward:
|
||||
|
||||
- **The bundled fallback did not protect anyone from this.** The break was on
|
||||
the modern path, which the fallback never touches. Carrying the second copy
|
||||
bought nothing against the actual defect while creating the divergence that
|
||||
produced it. That is an argument *for* B6, not against it.
|
||||
- **Every gate was green.** The safety harness renders the scoreboard screens,
|
||||
not scroll mode; `test_core_fallback.py` checked that methods existed and that
|
||||
their *globals* resolved, and `self.NHL_SEPARATOR_ICON` is an attribute read,
|
||||
invisible to an AST scan for `Name` loads. The fix was to stop reasoning about
|
||||
source and **build the object**: construct both classes on both paths, compare
|
||||
the separator icons they end up with, and assert the adopted class ends up
|
||||
with every instance attribute the bundled one sets.
|
||||
- **Test what the change touches, not what is convenient to render.** Scroll
|
||||
mode had no coverage because the harness could not reach it. A comparison
|
||||
harness that renders the same games through both paths and diffs the pixels
|
||||
needs no per-sport knowledge of the right answer, only that adopting core code
|
||||
did not change it.
|
||||
|
||||
**The ledger.** Before adoption, eight duplicated copies totalled 5,685 lines.
|
||||
After adoption plus the frozen legacy copies it was 10,610; removing the dead
|
||||
inline duplication (plugins #252) brought it to roughly 8,620. B6 would take it
|
||||
to about 3,300 including the shared core module — some 2,400 fewer than before
|
||||
this project started. **Until B6 runs, the adoption is net negative on disk**,
|
||||
and its one delivered user-visible gain is that adopted plugins honour the
|
||||
global `target_fps` instead of hardcoding ~100 FPS.
|
||||
|
||||
### Decision: stop adopting further modules until B6 closes
|
||||
|
||||
`data_sources.py` (9 copies), `game_renderer.py` (8) and `base_odds_manager.py`
|
||||
are the obvious next candidates. **Do not adopt them yet.** Each adoption adds
|
||||
carrying cost — a second copy to keep in step — against a payoff that is
|
||||
contingent on B6, and B6 is gated on an installed base we cannot currently
|
||||
measure. Consolidate what is already committed; revisit when B6 does.
|
||||
|
||||
## What's next
|
||||
|
||||
In order. Each step is independently useful and independently revertible.
|
||||
Steps 1–5 of the original plan are **done**: 3.2.0 is tagged and published with
|
||||
a version number CI now asserts (#428), the compatibility gate is in
|
||||
`install_plugin` and reads `compatible_versions` as well as the floor
|
||||
(#431, #433), the newest manifest entry is required to use `ledmatrix_min_version`
|
||||
(plugins #244), and all eight plugins have adopted the scroll orchestration
|
||||
(plugins #245–#249, repaired in #251, tidied in #252).
|
||||
|
||||
1. **Tag and publish v3.2.0.** The code is already on `main` (`21825cbf`).
|
||||
Nothing else blocks this, and it is what makes `ledmatrix_min_version:
|
||||
"3.2.0"` refer to something real.
|
||||
2. **Make the version number honest.** Have the release process assert that the
|
||||
tag, the GitHub release, and `src.__version__` agree — a check in CI is
|
||||
cheaper than the confusion of the last two releases. Then revisit the
|
||||
`< 2.0.0` skip in `_warn_if_incompatible`, which currently silences the
|
||||
warning for the users who most need it.
|
||||
3. **Add the compatibility gate** to `StoreManager.install_plugin` and
|
||||
`.update_plugin`: refuse a plugin whose declared floor exceeds
|
||||
`src.__version__`, and surface the reason in the store UI rather than only
|
||||
the log. This is the single change that turns the floor from documentation
|
||||
into a guarantee, and B6 depends on it.
|
||||
4. **Migrate the manifests** to `ledmatrix_min_version`, and reconcile them with
|
||||
`compatible_versions` (see above — that field is the required, canonical one,
|
||||
and the gate does not read it yet). Currently 28 plugins spell the floor both
|
||||
ways across their `versions[]` entries, 12 use only the old spelling, and 2
|
||||
only the new. Scope the sweep to the nine sports plugins if a 42-plugin
|
||||
version-bump wave isn't worth it — but the `compatible_versions` half has to
|
||||
cover every manifest the gate can refuse, or define explicit legacy handling,
|
||||
before the gate is allowed to block anything.
|
||||
5. **Run B5 adoption** — hockey, soccer, football, then the remaining six.
|
||||
Bundled copies stay. Byte-identical harness output per plugin, then a soak.
|
||||
6. **Only then plan B6**, with the compatibility regression test described above
|
||||
in CI first.
|
||||
What actually remains, smallest first:
|
||||
|
||||
1. **Soak the adoptions on hardware.** football and hockey have been run on a
|
||||
live rig through real games; baseball was watched through one earlier. The
|
||||
rest are proven by harness, unit tests and pixel comparison. Out-of-season
|
||||
sports cannot be soaked until their season starts. When you do, **check the
|
||||
rig's `*_display_mode` first** — a board in `switch` mode will happily load a
|
||||
sunset plugin and tell you nothing about the scroll code the sunset changed.
|
||||
2. **Cut 3.3.0.** Not required by B6 — its floors are 3.2.0, which is released —
|
||||
but `calendar` 1.2.3 floors at 3.3.0 for the device-authorization endpoints
|
||||
that landed after 3.2.0, so it is un-installable until the release exists.
|
||||
3. **Reconsider the held modules** (`data_sources.py`, `game_renderer.py`,
|
||||
`base_odds_manager.py`) now that the sunset has closed. `game_renderer.py` is
|
||||
the largest single duplication left: ~11,500 lines across eight plugins, with
|
||||
~36,500 more in the eight `sports.py`. Note that core already ships
|
||||
`src/base_classes/sports/` (~143KB, promoted in B1/B2) that **no plugin
|
||||
imports** — check whether it has drifted before treating it as the target.
|
||||
|
||||
## How to keep this project healthy
|
||||
|
||||
|
||||
@@ -52,6 +52,10 @@ def main() -> int:
|
||||
parser.add_argument('--height', type=int, default=32, help='Display height (default: 32)')
|
||||
parser.add_argument('--skip-update', action='store_true',
|
||||
help='Skip calling update() (render display only)')
|
||||
parser.add_argument('--display-mode', default=None,
|
||||
help='Display mode to render, for plugins that declare '
|
||||
'more than one in their manifest (e.g. nrl_live). '
|
||||
'Omitted, the plugin picks its own default.')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
@@ -141,8 +145,26 @@ def main() -> int:
|
||||
except Exception as e:
|
||||
logger.warning("update() raised: %s — continuing to display()", e)
|
||||
|
||||
# A plugin that declares several display modes usually renders nothing
|
||||
# useful without being told which one to draw: the scoreboards keep their
|
||||
# state on per-mode sub-managers and their no-argument path returns False.
|
||||
# Only pass the argument when asked for, so the many plugins whose display()
|
||||
# takes no display_mode keep working untouched.
|
||||
try:
|
||||
plugin_instance.display(force_clear=True)
|
||||
if args.display_mode:
|
||||
try:
|
||||
plugin_instance.display(display_mode=args.display_mode,
|
||||
force_clear=True)
|
||||
except TypeError as error:
|
||||
if ("unexpected keyword argument" not in str(error)
|
||||
or "display_mode" not in str(error)):
|
||||
raise
|
||||
logger.warning(
|
||||
"%s.display() does not accept display_mode; rendering its "
|
||||
"default screen instead", args.plugin)
|
||||
plugin_instance.display(force_clear=True)
|
||||
else:
|
||||
plugin_instance.display(force_clear=True)
|
||||
logger.debug("display() completed")
|
||||
except Exception as e:
|
||||
logger.error("Error in display(): %s", e)
|
||||
|
||||
+1
-1
@@ -4,5 +4,5 @@ LEDMatrix Display System
|
||||
Core source package for the LED Matrix Display project.
|
||||
"""
|
||||
|
||||
__version__ = "3.2.0"
|
||||
__version__ = "3.3.0"
|
||||
|
||||
|
||||
@@ -143,8 +143,10 @@ class LogoHelper:
|
||||
"""
|
||||
logo_path = Path(logo_path)
|
||||
|
||||
# Try to load existing logo first
|
||||
if logo_path.exists():
|
||||
# Try to load existing logo first. A placeholder written by a previous
|
||||
# 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.
|
||||
if logo_path.exists() and not self._is_stale_placeholder(logo_path):
|
||||
return self.load_logo(team_abbr, logo_path, max_width, max_height)
|
||||
|
||||
# Download if URL provided and file doesn't exist
|
||||
@@ -152,13 +154,61 @@ class LogoHelper:
|
||||
try:
|
||||
self.logger.info(f"Downloading logo for {team_abbr} from {logo_url}")
|
||||
self._download_logo(logo_url, logo_path)
|
||||
# The file on disk just changed. Any cached image for it is the
|
||||
# placeholder we came here to replace, and load_logo() answers
|
||||
# from the cache before touching the disk -- so without this the
|
||||
# real logo would not appear until the process restarted.
|
||||
self._invalidate_cached_logo(team_abbr, logo_path)
|
||||
return self.load_logo(team_abbr, logo_path, max_width, max_height)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to download logo for {team_abbr}: {e}")
|
||||
# The retry failed, so restart the back-off. The stale
|
||||
# placeholder is still on disk with its old timestamp, and
|
||||
# leaving it there means the next call retries immediately --
|
||||
# a download attempt per call, which is what the back-off
|
||||
# exists to prevent.
|
||||
self._refresh_stale_placeholder(logo_path)
|
||||
|
||||
# Create placeholder if all else fails
|
||||
return self._create_placeholder_logo(team_abbr, max_width, max_height)
|
||||
|
||||
def _invalidate_cached_logo(self, team_abbr: str, logo_path: Path) -> None:
|
||||
"""Drop every cached size of one logo after its file changed on disk."""
|
||||
prefix = f"{team_abbr}_{logo_path}_"
|
||||
for key in [k for k in self._logo_cache if k.startswith(prefix)]:
|
||||
self._logo_cache.pop(key, None)
|
||||
if key in self._cache_order:
|
||||
self._cache_order.remove(key)
|
||||
|
||||
@staticmethod
|
||||
def _refresh_stale_placeholder(logo_path: Path) -> None:
|
||||
"""Restart the retry back-off after a failed download attempt."""
|
||||
try:
|
||||
from src.logo_downloader import refresh_placeholder_timestamp
|
||||
except ImportError:
|
||||
return
|
||||
refresh_placeholder_timestamp(logo_path)
|
||||
|
||||
@staticmethod
|
||||
def _is_stale_placeholder(logo_path: Path) -> bool:
|
||||
"""True if the file is a placeholder old enough to be worth retrying.
|
||||
|
||||
Imported lazily so this module keeps working against a core build whose
|
||||
logo_downloader predates placeholder marking.
|
||||
"""
|
||||
try:
|
||||
from src.logo_downloader import (
|
||||
PLACEHOLDER_RETRY_SECONDS,
|
||||
is_placeholder_logo,
|
||||
placeholder_age_seconds,
|
||||
)
|
||||
except ImportError:
|
||||
return False
|
||||
if not is_placeholder_logo(logo_path):
|
||||
return False
|
||||
age = placeholder_age_seconds(logo_path)
|
||||
return age is None or age >= PLACEHOLDER_RETRY_SECONDS
|
||||
|
||||
def get_logo_variations(self, team_abbr: str) -> List[str]:
|
||||
"""
|
||||
Get possible filename variations for a team abbreviation.
|
||||
|
||||
@@ -0,0 +1,470 @@
|
||||
"""Card-drawing helpers shared by every sports scoreboard plugin.
|
||||
|
||||
The eight scoreboards each carried byte-identical copies of the functions
|
||||
below: the colour pickers, the settings lookup, the date and time formatting,
|
||||
the favourite-team rules and the font-size grid snapping. One fix had to be
|
||||
made eight times, and a new scoreboard started by copying them a ninth.
|
||||
|
||||
Everything here is a **free function taking explicit arguments**, not a base
|
||||
class. Adoption is therefore per-function and reversible: a plugin keeps its
|
||||
method and delegates the body, so the call sites and the override points are
|
||||
untouched. That is also why `config`, `logger` and `fonts` are parameters
|
||||
rather than attributes -- the helper never reaches back into the caller.
|
||||
|
||||
The bodies are the plugins' own code, moved rather than rewritten. The one
|
||||
deliberate difference is `crisp_size`, which takes the seven-plugin guard
|
||||
(`not desired`) instead of football's: they agree on every real input, and
|
||||
the extra guard only stops a None size raising TypeError.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
__all__ = [
|
||||
"ELEMENT_FOR_FONT", "FAVORITE_RESULT_COLOR_DEFAULTS", "FONT_NAME_ALIASES",
|
||||
"FONT_PIXEL_GRID", "MONTH_ABBR", "WEEKDAY_ABBR",
|
||||
"scroll_card_option", "element_color", "font_color", "coerce_rgb",
|
||||
"score_color_for", "recent_score_color", "favorite_teams_for",
|
||||
"side_is_favorite", "side_score", "favorite_result",
|
||||
"card_tzinfo", "weekday_for", "format_game_date", "format_game_time",
|
||||
"vs_text", "upcoming_center_mode", "crisp_size", "schema_font_size",
|
||||
"resolve_font_size", "unshare_element_fonts",
|
||||
]
|
||||
|
||||
#: Which customization element owns each font key, for colour resolution.
|
||||
ELEMENT_FOR_FONT: Dict[str, str] = {
|
||||
"score": "score_text",
|
||||
"time": "period_text",
|
||||
"team": "team_name",
|
||||
"status": "status_text",
|
||||
"detail": "detail_text",
|
||||
"rank": "rank_text",
|
||||
}
|
||||
|
||||
#: Fallback colours when favourite_result_colors is on but a slot is unset.
|
||||
FAVORITE_RESULT_COLOR_DEFAULTS: Dict[str, Tuple[int, int, int]] = {
|
||||
"win": (0, 255, 0),
|
||||
"loss": (255, 0, 0),
|
||||
"tie": (255, 200, 0),
|
||||
}
|
||||
|
||||
#: Family aliases the web UI may write, mapped to the shipped filename.
|
||||
FONT_NAME_ALIASES: Dict[str, str] = {
|
||||
"press_start": "PressStart2P-Regular.ttf",
|
||||
"four_by_six": "4x6-font.ttf",
|
||||
}
|
||||
|
||||
#: Pixel grid each face renders crisply on. Off-grid sizes anti-alias, which
|
||||
#: on an LED matrix is a dim lamp rather than a soft edge.
|
||||
FONT_PIXEL_GRID: Dict[str, int] = {
|
||||
"PressStart2P-Regular.ttf": 8,
|
||||
"4x6-font.ttf": 7,
|
||||
}
|
||||
|
||||
MONTH_ABBR = ("Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
||||
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
|
||||
WEEKDAY_ABBR = ("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Settings lookup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def scroll_card_option(config: Optional[Dict[str, Any]], key: str,
|
||||
default: Any = None) -> Any:
|
||||
"""Read one key from the scroll_card config block."""
|
||||
block = (config or {}).get("scroll_card")
|
||||
if isinstance(block, dict) and block.get(key) is not None:
|
||||
return block.get(key)
|
||||
return default
|
||||
|
||||
|
||||
def vs_text(config: Optional[Dict[str, Any]]) -> str:
|
||||
"""Separator drawn between the teams -- "VS", "@", "at", anything."""
|
||||
return str(scroll_card_option(config, "vs_text", "VS"))
|
||||
|
||||
|
||||
def upcoming_center_mode(config: Optional[Dict[str, Any]]) -> str:
|
||||
"""Middle of an upcoming card: 'vs', 'date_time' or 'none'."""
|
||||
mode = str(scroll_card_option(config, "upcoming_center", "vs") or "vs").lower()
|
||||
return mode if mode in ("vs", "date_time", "none") else "vs"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Colour
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def element_color(config: Optional[Dict[str, Any]], element: str,
|
||||
default: Tuple[int, int, int] = (255, 255, 255)):
|
||||
"""Per-element text colour from customization.<element>.text_color."""
|
||||
try:
|
||||
cfg = (config or {}).get("customization", {}).get(element, {})
|
||||
value = cfg.get("text_color")
|
||||
if isinstance(value, (list, tuple)) and len(value) == 3:
|
||||
return tuple(max(0, min(255, int(c))) for c in value)
|
||||
if isinstance(value, str) and value.startswith("#") and len(value) == 7:
|
||||
return tuple(int(value[i:i + 2], 16) for i in (1, 3, 5))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return default
|
||||
|
||||
|
||||
def font_color(config: Optional[Dict[str, Any]], fonts: Optional[Dict[str, Any]],
|
||||
font, default: Tuple[int, int, int] = (255, 255, 255)):
|
||||
"""Colour for whichever element owns this face.
|
||||
|
||||
Matched on identity, and deliberately gives up when one object is
|
||||
shared: the last-resort font path can hand the same face to several
|
||||
keys, and there is no right answer for which element's colour that is.
|
||||
White is what those draws used before, so ambiguity costs nothing.
|
||||
"""
|
||||
try:
|
||||
fonts = fonts or {}
|
||||
matches = [element for key, element in ELEMENT_FOR_FONT.items()
|
||||
if fonts.get(key) is font]
|
||||
if len(matches) == 1:
|
||||
return element_color(config, matches[0], default)
|
||||
except (AttributeError, TypeError):
|
||||
pass
|
||||
return default
|
||||
|
||||
|
||||
def coerce_rgb(value, fallback):
|
||||
"""Turn a configured [R, G, B] list into a clamped (r, g, b) tuple."""
|
||||
# Checked before unpacking: a 3-character string ("123") would otherwise
|
||||
# iterate into three digits and yield a colour rather than the fallback.
|
||||
if not isinstance(value, (list, tuple)) or len(value) != 3:
|
||||
return fallback
|
||||
try:
|
||||
r, g, b = (max(0, min(255, int(channel))) for channel in value)
|
||||
except (TypeError, ValueError):
|
||||
return fallback
|
||||
return (r, g, b)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Favourite teams
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def favorite_teams_for(config: Dict[str, Any], game: Dict[str, Any]) -> list:
|
||||
"""Favorite teams that apply to this game.
|
||||
|
||||
Both sources are used. Games carry the league manager's *resolved*
|
||||
favorites, which is the only place dynamic groups such as AP_TOP_25
|
||||
appear expanded; the config is read as well so an edit takes effect on
|
||||
already-fetched games, and so hand-built game dicts (tests, other
|
||||
callers) still work.
|
||||
"""
|
||||
favorites = list(game.get("favorite_teams") or [])
|
||||
league_config = config.get(str(game.get("league", "") or ""))
|
||||
if isinstance(league_config, dict):
|
||||
favorites += list(league_config.get("favorite_teams") or [])
|
||||
else:
|
||||
favorites += list(config.get("favorite_teams") or [])
|
||||
return favorites
|
||||
|
||||
|
||||
def side_is_favorite(game: Dict[str, Any], side: str, favorites: set) -> bool:
|
||||
"""Is the home/away side of this game a favorite team?
|
||||
|
||||
Reads both the flat (``home_abbr``) and nested (``home_team.abbrev``)
|
||||
payload shapes, and matches on the ESPN id too, because a couple of
|
||||
leagues (NRL) key favorites by id where abbreviations collide.
|
||||
"""
|
||||
candidates = [game.get(f"{side}_abbr"), game.get(f"{side}_id")]
|
||||
team = game.get(f"{side}_team")
|
||||
if isinstance(team, dict):
|
||||
candidates += [team.get("abbrev"), team.get("abbreviation"), team.get("id")]
|
||||
for value in candidates:
|
||||
if value is not None and str(value).strip().upper() in favorites:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def side_score(game: Dict[str, Any], side: str) -> Optional[int]:
|
||||
"""Numeric score for one side, from either payload shape."""
|
||||
raw = None
|
||||
team = game.get(f"{side}_team")
|
||||
if isinstance(team, dict) and team.get("score") is not None:
|
||||
raw = team.get("score")
|
||||
if raw is None:
|
||||
raw = game.get(f"{side}_score")
|
||||
try:
|
||||
return int(float(str(raw).strip()))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def favorite_result(config: Dict[str, Any], game: Dict[str, Any]) -> Optional[str]:
|
||||
"""Say how the favorite team did in a finished game.
|
||||
|
||||
Returns 'win', 'loss' or 'tie', or None when there is no single team
|
||||
to root for: no favorites configured, neither side is a favorite, or
|
||||
*both* are -- a favorite-vs-favorite game has no losing side worth
|
||||
flagging in red. Also None when the scores are not usable numbers.
|
||||
"""
|
||||
favorites = {
|
||||
str(team).strip().upper()
|
||||
for team in favorite_teams_for(config, game)
|
||||
if str(team).strip()
|
||||
}
|
||||
if not favorites:
|
||||
return None
|
||||
|
||||
home_fav = side_is_favorite(game, "home", favorites)
|
||||
away_fav = side_is_favorite(game, "away", favorites)
|
||||
if home_fav == away_fav:
|
||||
return None
|
||||
|
||||
home_score = side_score(game, "home")
|
||||
away_score = side_score(game, "away")
|
||||
if home_score is None or away_score is None:
|
||||
return None
|
||||
|
||||
if home_score == away_score:
|
||||
return "tie"
|
||||
favorite_score, other_score = (
|
||||
(home_score, away_score) if home_fav else (away_score, home_score)
|
||||
)
|
||||
return "win" if favorite_score > other_score else "loss"
|
||||
|
||||
|
||||
def recent_score_color(config: Dict[str, Any], logger, game: Dict[str, Any], default):
|
||||
"""Fill color for a finished game's score, per favorite_result_colors."""
|
||||
try:
|
||||
settings = (config.get("customization") or {}).get(
|
||||
"favorite_result_colors"
|
||||
) or {}
|
||||
if not settings.get("enabled", False):
|
||||
return default
|
||||
result = favorite_result(config, game)
|
||||
if result is None:
|
||||
return default
|
||||
return coerce_rgb(
|
||||
settings.get(f"{result}_color"),
|
||||
FAVORITE_RESULT_COLOR_DEFAULTS[result],
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Could not resolve favorite result color", exc_info=True)
|
||||
return default
|
||||
|
||||
|
||||
def score_color_for(config: Dict[str, Any], logger, game: Dict[str, Any],
|
||||
game_type: str, default=None):
|
||||
"""Fill color for a game card's score. Only finished games are tinted.
|
||||
|
||||
The default is the configured score colour rather than a flat white,
|
||||
so customization.score_text.text_color shows on games the favourite
|
||||
tint does not apply to. The tint still wins where it applies.
|
||||
"""
|
||||
if default is None:
|
||||
default = element_color(config, 'score_text')
|
||||
if game_type != "recent":
|
||||
return default
|
||||
return recent_score_color(config, logger, game, default)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Date and time
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def card_tzinfo(config: Optional[Dict[str, Any]], logger):
|
||||
"""Timezone for weekday/24h conversions; falls back to UTC."""
|
||||
configured = (config or {}).get("timezone")
|
||||
if configured:
|
||||
try:
|
||||
return ZoneInfo(configured)
|
||||
except (KeyError, ValueError, TypeError, OSError) as exc:
|
||||
# KeyError covers ZoneInfoNotFoundError. A bad zone name in
|
||||
# config should fall back to UTC, not blank the card.
|
||||
logger.debug("Unusable timezone %r: %s", configured, exc)
|
||||
return timezone.utc
|
||||
|
||||
|
||||
def weekday_for(config: Optional[Dict[str, Any]], logger,
|
||||
game: Optional[Dict]) -> str:
|
||||
"""Weekday abbreviation from the game's start time, or ''."""
|
||||
if not game:
|
||||
return ""
|
||||
raw = game.get("start_time_utc") or game.get("start_time")
|
||||
if not raw:
|
||||
return ""
|
||||
try:
|
||||
start = raw if isinstance(raw, datetime) else datetime.fromisoformat(
|
||||
str(raw).replace("Z", "+00:00"))
|
||||
return WEEKDAY_ABBR[start.astimezone(card_tzinfo(config, logger)).weekday()]
|
||||
except (ValueError, TypeError):
|
||||
return ""
|
||||
|
||||
|
||||
def format_game_date(config: Optional[Dict[str, Any]], logger, date_text: str,
|
||||
game: Optional[Dict] = None) -> str:
|
||||
"""Format an upcoming card's date per scroll_card.date_format."""
|
||||
raw = str(date_text or "").strip()
|
||||
if not raw:
|
||||
return ""
|
||||
fmt = str(scroll_card_option(config, "date_format", "abbrev") or "abbrev")
|
||||
if fmt == "numeric":
|
||||
return raw
|
||||
parts = raw.replace("-", "/").split("/")
|
||||
if not (len(parts) >= 2 and parts[0].strip().isdigit() and parts[1].strip().isdigit()):
|
||||
return raw
|
||||
month, day = int(parts[0]), int(parts[1])
|
||||
if not 1 <= month <= 12:
|
||||
return raw
|
||||
name = MONTH_ABBR[month - 1]
|
||||
if fmt == "numeric_day_first":
|
||||
return f"{day}/{month}"
|
||||
if fmt == "day_first":
|
||||
return f"{day} {name}"
|
||||
if fmt == "weekday":
|
||||
weekday = weekday_for(config, logger, game)
|
||||
return f"{weekday} {name} {day}" if weekday else f"{name} {day}"
|
||||
return f"{name} {day}"
|
||||
|
||||
|
||||
def format_game_time(config: Optional[Dict[str, Any]], time_text: str) -> str:
|
||||
"""Return the time as-is (12h) or converted to 24h."""
|
||||
raw = str(time_text or "").strip()
|
||||
if not raw or str(scroll_card_option(config, "time_format", "12h")) != "24h":
|
||||
return raw
|
||||
cleaned = raw.upper().replace(" ", "")
|
||||
meridiem = "AM" if cleaned.endswith("AM") else "PM" if cleaned.endswith("PM") else ""
|
||||
if not meridiem:
|
||||
return raw
|
||||
try:
|
||||
hh, _, mm = cleaned[:-2].partition(":")
|
||||
hour, minute = int(hh), int(mm or 0)
|
||||
except ValueError:
|
||||
return raw
|
||||
if not (0 <= hour <= 12 and 0 <= minute <= 59):
|
||||
return raw
|
||||
hour = hour % 12 + (12 if meridiem == "PM" else 0)
|
||||
return f"{hour:02d}:{minute:02d}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Font sizing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
#: Per-schema caches, keyed by the schema's absolute path. Keyed rather than
|
||||
#: global because each plugin declares its own defaults; keyed rather than
|
||||
#: per-class because the helper has no class to hang it on.
|
||||
_SCHEMA_FONT_SIZE_CACHE: Dict[str, Dict[str, int]] = {}
|
||||
|
||||
|
||||
def crisp_size(font_file, desired, aliases=None, grid_table=None):
|
||||
"""Snap *desired* to the nearest size *font_file* renders crisply at.
|
||||
|
||||
A face with no known grid is returned unchanged, so a user-supplied
|
||||
font is never second-guessed.
|
||||
|
||||
``aliases`` and ``grid_table`` default to the shared tables; a plugin
|
||||
that ships an extra face can pass its own without forking this.
|
||||
"""
|
||||
aliases = FONT_NAME_ALIASES if aliases is None else aliases
|
||||
grid_table = FONT_PIXEL_GRID if grid_table is None else grid_table
|
||||
font_file = aliases.get(font_file, font_file)
|
||||
grid = grid_table.get(font_file)
|
||||
if not grid or not desired or desired <= 0:
|
||||
return desired
|
||||
return max(grid, int(round(float(desired) / grid)) * grid)
|
||||
|
||||
|
||||
def schema_font_size(schema_path: str, element_key) -> Optional[int]:
|
||||
"""The font_size this plugin's config_schema.json declares, or None.
|
||||
|
||||
Cached per schema path. The plugins cached this on their own class; the
|
||||
path is the same distinction expressed without one, so two plugins never
|
||||
share an entry.
|
||||
"""
|
||||
if not element_key:
|
||||
return None
|
||||
cache = _SCHEMA_FONT_SIZE_CACHE.get(schema_path)
|
||||
if cache is None:
|
||||
cache = {}
|
||||
try:
|
||||
import json
|
||||
with open(schema_path) as fh:
|
||||
schema = json.load(fh)
|
||||
props = (schema.get('properties', {})
|
||||
.get('customization', {})
|
||||
.get('properties', {}))
|
||||
for key, spec in props.items():
|
||||
size = spec.get('properties', {}).get('font_size', {}).get('default')
|
||||
if size is not None:
|
||||
cache[key] = int(size)
|
||||
except Exception as exc:
|
||||
# See sports_shared._schema_font_size: an unreadable schema
|
||||
# silently disables the pixel-grid snap for every element.
|
||||
# Built once per schema path, so this cannot repeat per frame.
|
||||
logger.warning(
|
||||
"could not read %s (%s: %s); font sizes will skip their "
|
||||
"pixel grid snap and may render a pixel narrow",
|
||||
schema_path, type(exc).__name__, exc)
|
||||
cache = {}
|
||||
_SCHEMA_FONT_SIZE_CACHE[schema_path] = cache
|
||||
return cache.get(element_key)
|
||||
|
||||
|
||||
def resolve_font_size(schema_path: str, element_config, element_key,
|
||||
default_size, font_name, aliases=None, grid_table=None):
|
||||
"""Size to render at: the user's choice, or a grid-snapped default.
|
||||
|
||||
A configured size counts as a real choice only when it differs from
|
||||
the schema default. The web UI writes the whole schema default block
|
||||
on every save, so "font_size == schema default" carries no intent and
|
||||
would otherwise pin every install to an anti-aliased size forever.
|
||||
"""
|
||||
configured = (element_config or {}).get('font_size')
|
||||
if configured is not None:
|
||||
try:
|
||||
configured = int(configured)
|
||||
if configured != schema_font_size(schema_path, element_key):
|
||||
return configured
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return crisp_size(font_name, default_size, aliases, grid_table)
|
||||
|
||||
|
||||
def unshare_element_fonts(logger, fonts):
|
||||
"""Give each colourable element its own face object.
|
||||
|
||||
The colour a draw gets is resolved from the face it was handed, and
|
||||
several of these loaders legitimately hand one object to more than one
|
||||
element -- a size resolver that lands two elements on the same face, a
|
||||
fallback that fills every key from one default, football's narrowing
|
||||
step that deliberately shrinks the clock along with the score. Sharing
|
||||
the object makes the element ambiguous and the colour unresolvable.
|
||||
|
||||
Re-instantiating from the same path and size gives a distinct object
|
||||
with identical metrics, so nothing about the rendering changes; only
|
||||
the ability to tell two elements apart does. Faces that cannot be
|
||||
rebuilt (a BDF loaded through freetype.Face, anything without a usable
|
||||
path) are left shared, and their draws stay white as before.
|
||||
"""
|
||||
try:
|
||||
from PIL import ImageFont as _IF
|
||||
except ImportError: # pragma: no cover
|
||||
return fonts
|
||||
seen = {}
|
||||
for key in ELEMENT_FOR_FONT:
|
||||
font = fonts.get(key)
|
||||
if font is None:
|
||||
continue
|
||||
if id(font) not in seen:
|
||||
seen[id(font)] = key
|
||||
continue
|
||||
path, size = getattr(font, "path", None), getattr(font, "size", None)
|
||||
if not path or not size:
|
||||
continue
|
||||
try:
|
||||
fonts[key] = _IF.truetype(path, size)
|
||||
except (OSError, ValueError, TypeError):
|
||||
logger.debug(
|
||||
"Could not un-share the %s face; it keeps the default colour", key)
|
||||
return fonts
|
||||
@@ -0,0 +1,261 @@
|
||||
"""The scroll/Vegas card geometry the sports scoreboards all share.
|
||||
|
||||
Eight scoreboards -- afl, baseball, basketball, football, hockey, lacrosse,
|
||||
nrl and soccer -- each carried their own ``game_renderer.py``. After the card
|
||||
helpers moved to ``src/common/sports_card.py`` the settings lookups were
|
||||
shared, but the *geometry* was not: nine methods that decide how wide the
|
||||
centre gap is, how much room a logo gets, and where an upcoming card's date
|
||||
and time land were still eight separate copies. Five were byte-identical
|
||||
across all eight plugins; the other four were identical in seven, with a
|
||||
different single outlier each time.
|
||||
|
||||
That last detail is why this is a mixin rather than free functions. There is
|
||||
no per-sport branching to write -- baseball needs its own
|
||||
``_draw_upcoming_game_status`` and ``_logo_slot_width``, hockey its own
|
||||
``_upcoming_date_and_time``, football its own ``_score_reserve_width``, and
|
||||
every one of those is an ordinary override. The seven that agree inherit and
|
||||
say nothing.
|
||||
|
||||
It is deliberately *only* a mixin: no ``__init__``, no state of its own. The
|
||||
plugins' constructors differ in six ways and none of that difference is worth
|
||||
unifying, so adoption is one line on the class statement plus deleting the
|
||||
methods that now come from here.
|
||||
|
||||
What a host class must provide
|
||||
------------------------------
|
||||
Attributes: ``display_width``, ``display_height``, ``config``, ``fonts``,
|
||||
``logger``, ``_team_rankings_cache``.
|
||||
|
||||
Methods: ``_draw_text_with_outline(draw, text, position, font, fill=None,
|
||||
outline_color=(0, 0, 0))`` -- the one hook whose body genuinely varies -- plus
|
||||
the ``sports_card`` delegations ``_scroll_card_option``,
|
||||
``_upcoming_center_mode``, ``_vs_text``, ``_element_color``,
|
||||
``_format_game_date`` and ``_format_game_time``.
|
||||
"""
|
||||
|
||||
import math
|
||||
from typing import ClassVar, Dict, Tuple
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
|
||||
class SportsGameRendererMixin:
|
||||
"""Shared card geometry for the sports scoreboards. See module docstring."""
|
||||
|
||||
#: Centre strip as a fraction of card width, before clamping.
|
||||
CENTER_GAP_RATIO: ClassVar[float] = 0.28
|
||||
#: Clamp floor for the derived centre gap.
|
||||
CENTER_GAP_MIN_PX: ClassVar[int] = 22
|
||||
#: Clamp ceiling for the derived centre gap.
|
||||
CENTER_GAP_MAX_PX: ClassVar[int] = 40
|
||||
#: Breathing room kept between the score and each logo.
|
||||
_SCORE_LOGO_GUTTER_PX: ClassVar[int] = 4
|
||||
#: Widest score the centre strip must fit. Leagues that can reach three
|
||||
#: digits a side override this with "000-000".
|
||||
_SCORE_PROBE: ClassVar[str] = "00-00"
|
||||
|
||||
# ---- geometry ------------------------------------------------------
|
||||
|
||||
# Non-finite settings are rejected before any int()/round(): "inf" reaches
|
||||
# these from config as a float or a string, passes an `isinstance` plus
|
||||
# `>= 0` check unharmed, and then raises OverflowError out of int() --
|
||||
# which the old `except (TypeError, ValueError)` did not catch, so it
|
||||
# aborted the whole card render. Present in all eight plugins before this
|
||||
# moved to the core; fixing it here fixes it in all eight.
|
||||
|
||||
def _score_reserve_width(self) -> int:
|
||||
"""Centre strip the score actually needs, measured rather than assumed.
|
||||
|
||||
The gap was derived from the card width alone (width x
|
||||
CENTER_GAP_RATIO, clamped to CENTER_GAP_MAX_PX) while the score's size
|
||||
comes from config and the element-style resolver. Nothing compared the
|
||||
two, so any score wider than the clamp was drawn over the logos.
|
||||
Measuring it keeps the strip wide enough for whatever font is in play.
|
||||
"""
|
||||
try:
|
||||
probe = ImageDraw.Draw(Image.new("RGB", (4, 4)))
|
||||
width = probe.textlength(self._SCORE_PROBE, font=self.fonts['score'])
|
||||
return int(width) + 2 * self._SCORE_LOGO_GUTTER_PX
|
||||
except Exception:
|
||||
self.logger.debug("Score reserve measurement failed", exc_info=True)
|
||||
return 0
|
||||
|
||||
def _center_gap_width(self) -> int:
|
||||
"""Width of the middle strip kept clear of logos.
|
||||
|
||||
``scroll_card.center_gap`` pins it outright; otherwise it scales with
|
||||
the card width between the configurable min and max. 0 restores
|
||||
edge-to-edge logos.
|
||||
"""
|
||||
configured = self._scroll_card_option("center_gap")
|
||||
if (isinstance(configured, (int, float))
|
||||
and math.isfinite(configured) and configured >= 0):
|
||||
return int(configured)
|
||||
ratio = self._scroll_card_option("center_gap_ratio", self.CENTER_GAP_RATIO)
|
||||
low = self._scroll_card_option("center_gap_min", self.CENTER_GAP_MIN_PX)
|
||||
high = self._scroll_card_option("center_gap_max", self.CENTER_GAP_MAX_PX)
|
||||
try:
|
||||
ratio, low, high = float(ratio), float(low), float(high)
|
||||
if not (math.isfinite(ratio) and math.isfinite(low)
|
||||
and math.isfinite(high)):
|
||||
return self.CENTER_GAP_MIN_PX
|
||||
scaled = round(self.display_width * ratio)
|
||||
derived = int(max(int(low), min(int(high), scaled)))
|
||||
# A strip narrower than the score is the bug, not a style choice.
|
||||
# An explicit ``center_gap`` is still honoured above, including 0.
|
||||
return max(derived, self._score_reserve_width())
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return self.CENTER_GAP_MIN_PX
|
||||
|
||||
def _logo_slot_width(self) -> int:
|
||||
"""Per-side logo slot, leaving the center gap clear.
|
||||
|
||||
No longer capped at display_height: the card is sized as two
|
||||
full-height logos plus the measured gap, so what is left after the gap
|
||||
is exactly the logo's share. The cap was what froze the logos at 46px
|
||||
on the old flat 128px card.
|
||||
"""
|
||||
available = (self.display_width - self._center_gap_width()) // 2
|
||||
return max(8, available)
|
||||
|
||||
def _logo_cache_key(self, name: str) -> str:
|
||||
"""Cache key scoped to the logo slot.
|
||||
|
||||
One cache dict is shared by renderers built for different card widths,
|
||||
so a logo sized for a wide slot must not be handed to a narrow one.
|
||||
"""
|
||||
return f"{name}@{self._logo_slot_width()}x{self.display_height}"
|
||||
|
||||
def _layout_offset(self, element: str, axis: str, default: int = 0) -> int:
|
||||
"""X/Y nudge for one element, from customization.layout.
|
||||
|
||||
Same block the full-screen scorebug reads (sports.py
|
||||
_get_layout_offset), so a nudge configured in the web UI now moves
|
||||
the element on the scroll/Vegas card too -- previously the schema
|
||||
advertised these offsets but this renderer ignored them.
|
||||
"""
|
||||
try:
|
||||
layout = (self.config or {}).get("customization", {}).get("layout", {})
|
||||
value = (layout.get(element) or {}).get(axis, default)
|
||||
if isinstance(value, bool):
|
||||
return default
|
||||
if isinstance(value, (int, float)):
|
||||
return int(value) if math.isfinite(value) else default
|
||||
if isinstance(value, str):
|
||||
parsed = float(value)
|
||||
return int(parsed) if math.isfinite(parsed) else default
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
pass
|
||||
return default
|
||||
|
||||
# ---- upcoming cards ------------------------------------------------
|
||||
|
||||
def _upcoming_date_and_time(self, game: Dict) -> Tuple[str, str]:
|
||||
"""(date, time) for an upcoming card, from the extractor's flat keys."""
|
||||
return (
|
||||
str(game.get("game_date", "") or ""),
|
||||
str(game.get("game_time", "") or ""),
|
||||
)
|
||||
|
||||
def _draw_upcoming_center(self, draw: "ImageDraw.ImageDraw", game: Dict) -> None:
|
||||
"""Draw the middle of an upcoming card.
|
||||
|
||||
Never a score: an upcoming game has not started, so the extractor's
|
||||
0-0 is noise. Either the VS text (default), the date and time stacked,
|
||||
or nothing at all.
|
||||
"""
|
||||
mode = self._upcoming_center_mode()
|
||||
if mode == "none":
|
||||
return
|
||||
|
||||
if mode == "vs":
|
||||
vs_text = self._vs_text()
|
||||
if not vs_text:
|
||||
return
|
||||
vs_width = draw.textlength(vs_text, font=self.fonts['score'])
|
||||
vs_x = (self.display_width - vs_width) // 2 + self._layout_offset('score', 'x_offset')
|
||||
vs_y = (self.display_height // 2) - 3 + self._layout_offset('score', 'y_offset')
|
||||
self._draw_text_with_outline(
|
||||
draw, vs_text, (vs_x, vs_y), self.fonts['score'],
|
||||
fill=self._element_color('score_text')
|
||||
)
|
||||
return
|
||||
|
||||
date_text, time_text = self._upcoming_date_and_time(game)
|
||||
lines = []
|
||||
if self._scroll_card_option("show_date", True):
|
||||
lines.append(self._format_game_date(date_text, game))
|
||||
if self._scroll_card_option("show_time", True):
|
||||
lines.append(self._format_game_time(time_text))
|
||||
lines = [t for t in lines if t]
|
||||
if not lines:
|
||||
return
|
||||
font = self.fonts.get('detail') or self.fonts['time']
|
||||
line_h = 7
|
||||
top = (self.display_height // 2) - (len(lines) * line_h) // 2
|
||||
top += self._layout_offset('score', 'y_offset')
|
||||
for i, line in enumerate(lines):
|
||||
width = draw.textlength(line, font=font)
|
||||
x = (self.display_width - width) // 2 + self._layout_offset('score', 'x_offset')
|
||||
self._draw_text_with_outline(
|
||||
draw, line, (x, top + i * line_h), font,
|
||||
fill=self._element_color('detail_text')
|
||||
)
|
||||
|
||||
def _draw_upcoming_game_status(self, draw: "ImageDraw.ImageDraw", game: Dict) -> None:
|
||||
"""Draw the date and time around an upcoming card.
|
||||
|
||||
Time top and date bottom by default; scroll_card.swap_date_time puts
|
||||
the date on top instead. Skipped when the pair is stacked in the
|
||||
middle, which would otherwise print them twice.
|
||||
"""
|
||||
if self._upcoming_center_mode() == "date_time":
|
||||
return
|
||||
|
||||
date_raw, time_raw = self._upcoming_date_and_time(game)
|
||||
date_text = (self._format_game_date(date_raw, game)
|
||||
if self._scroll_card_option("show_date", True) else "")
|
||||
time_text = (self._format_game_time(time_raw)
|
||||
if self._scroll_card_option("show_time", True) else "")
|
||||
|
||||
if self._scroll_card_option("swap_date_time", False):
|
||||
top_text, top_el, bottom_text, bottom_el = (
|
||||
date_text, 'date', time_text, 'time')
|
||||
top_font = self.fonts.get('detail') or self.fonts['time']
|
||||
bottom_font = self.fonts['time']
|
||||
top_color, bottom_color = 'detail_text', 'period_text'
|
||||
else:
|
||||
top_text, top_el, bottom_text, bottom_el = (
|
||||
time_text, 'time', date_text, 'date')
|
||||
top_font = self.fonts['time']
|
||||
bottom_font = self.fonts.get('detail') or self.fonts['time']
|
||||
top_color, bottom_color = 'period_text', 'detail_text'
|
||||
|
||||
if top_text:
|
||||
top_width = draw.textlength(top_text, font=top_font)
|
||||
top_x = (self.display_width - top_width) // 2 + self._layout_offset(top_el, 'x_offset')
|
||||
top_y = 1 + self._layout_offset(top_el, 'y_offset')
|
||||
self._draw_text_with_outline(
|
||||
draw, top_text, (top_x, top_y), top_font,
|
||||
fill=self._element_color(top_color)
|
||||
)
|
||||
|
||||
if bottom_text:
|
||||
bottom_width = draw.textlength(bottom_text, font=bottom_font)
|
||||
bottom_x = ((self.display_width - bottom_width) // 2
|
||||
+ self._layout_offset(bottom_el, 'x_offset'))
|
||||
# Measured, not a fixed -7: the detail font is 6px in most plugins
|
||||
# but 10px in soccer and nrl, where "Sep 19" ran past the card.
|
||||
ink_bottom = draw.textbbox((0, 0), bottom_text, font=bottom_font)[3]
|
||||
bottom_y = (max(0, self.display_height - ink_bottom - 1)
|
||||
+ self._layout_offset(bottom_el, 'y_offset'))
|
||||
self._draw_text_with_outline(
|
||||
draw, bottom_text, (bottom_x, bottom_y), bottom_font,
|
||||
fill=self._element_color(bottom_color)
|
||||
)
|
||||
|
||||
# ---- rankings ------------------------------------------------------
|
||||
|
||||
def set_rankings_cache(self, rankings: Dict[str, int]) -> None:
|
||||
"""Set the team rankings cache for display."""
|
||||
self._team_rankings_cache = rankings
|
||||
File diff suppressed because it is too large
Load Diff
@@ -364,6 +364,7 @@ class DisplayManager:
|
||||
# Create image with the (logical) display dimensions
|
||||
self.image = Image.new('RGB', (self.matrix.width, self.matrix.height))
|
||||
self.draw = ImageDraw.Draw(self.image)
|
||||
self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs.
|
||||
logger.info(f"Image canvas created with dimensions: {self.matrix.width}x{self.matrix.height}")
|
||||
|
||||
# Initialize font with Press Start 2P
|
||||
@@ -403,6 +404,7 @@ class DisplayManager:
|
||||
|
||||
self.image = Image.new('RGB', (fallback_width, fallback_height))
|
||||
self.draw = ImageDraw.Draw(self.image)
|
||||
self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs.
|
||||
# Simple fallback visualization so web UI shows a realistic canvas
|
||||
try:
|
||||
self.draw.rectangle([0, 0, fallback_width - 1, fallback_height - 1], outline=(255, 0, 0))
|
||||
@@ -705,6 +707,7 @@ class DisplayManager:
|
||||
# self.image, so swapping the buffer below is enough on its own.
|
||||
self.image = Image.new('RGB', (target_w, target_h))
|
||||
self.draw = ImageDraw.Draw(self.image)
|
||||
self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs.
|
||||
yield
|
||||
finally:
|
||||
self.matrix = real_matrix
|
||||
@@ -814,6 +817,7 @@ class DisplayManager:
|
||||
|
||||
self.image = Image.new('RGB', (width, height))
|
||||
self.draw = ImageDraw.Draw(self.image)
|
||||
self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs.
|
||||
logger.debug("Cleared display in fallback mode")
|
||||
return
|
||||
|
||||
@@ -825,6 +829,7 @@ class DisplayManager:
|
||||
# Create a new black image
|
||||
self.image = Image.new('RGB', (self.matrix.width, self.matrix.height))
|
||||
self.draw = ImageDraw.Draw(self.image)
|
||||
self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs.
|
||||
|
||||
if not self._capture_mode_active:
|
||||
# Clear both canvases and the underlying matrix to ensure no artifacts.
|
||||
@@ -1258,6 +1263,7 @@ class DisplayManager:
|
||||
try:
|
||||
self.image = Image.new('RGB', (self.width, self.height))
|
||||
self.draw = ImageDraw.Draw(self.image)
|
||||
self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs.
|
||||
except (OSError, RuntimeError, ValueError, MemoryError):
|
||||
logger.debug("Canvas reset during cleanup failed", exc_info=True)
|
||||
# Reset the singleton state when cleaning up
|
||||
|
||||
+116
-8
@@ -14,6 +14,7 @@ import json
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from pathlib import Path
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
from PIL.PngImagePlugin import PngInfo
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.util.retry import Retry
|
||||
from src.common.permission_utils import (
|
||||
@@ -25,6 +26,96 @@ from src.common.permission_utils import (
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: PNG text key stamped into a generated placeholder so a later run can tell it
|
||||
#: apart from a real logo that happens to be small.
|
||||
PLACEHOLDER_MARKER = "ledmatrix_placeholder"
|
||||
|
||||
#: Geometry of a generated placeholder, used to recognise ones written before
|
||||
#: the marker existed. Those are already on users' disks and would otherwise
|
||||
#: never be retried.
|
||||
PLACEHOLDER_SIZE = (64, 64)
|
||||
PLACEHOLDER_BG = (100, 100, 100, 255)
|
||||
|
||||
#: How long a placeholder is trusted before the real logo is attempted again.
|
||||
#: A placeholder means the download failed, and download failures are usually
|
||||
#: transient (no network at boot, ESPN blipping). Retrying every frame would
|
||||
#: hammer the API from a Pi that is also driving a panel; never retrying leaves
|
||||
#: the team a grey box forever, which is the bug this exists to avoid.
|
||||
PLACEHOLDER_RETRY_SECONDS = 6 * 60 * 60
|
||||
|
||||
|
||||
def is_placeholder_logo(filepath: Path) -> bool:
|
||||
"""True if the file at ``filepath`` is a generated placeholder, not a logo.
|
||||
|
||||
Checks the marker first, then falls back to matching the placeholder's
|
||||
exact geometry and background colour so files written before the marker was
|
||||
introduced are still recognised.
|
||||
"""
|
||||
try:
|
||||
with Image.open(filepath) as img:
|
||||
if img.info.get(PLACEHOLDER_MARKER):
|
||||
return True
|
||||
if img.size != PLACEHOLDER_SIZE:
|
||||
return False
|
||||
return img.convert("RGBA").getpixel((0, 0)) == PLACEHOLDER_BG
|
||||
except Exception:
|
||||
# Unreadable file: not provably a placeholder, and the caller's own
|
||||
# error handling is better placed to deal with it.
|
||||
return False
|
||||
|
||||
|
||||
def should_attempt_download(filepath: Path, force_download: bool = False) -> bool:
|
||||
"""Whether a real logo is worth (re)fetching for ``filepath``.
|
||||
|
||||
True when nothing is there, when the caller forced it, or when what is
|
||||
there is a placeholder old enough to retry. A *fresh* placeholder says a
|
||||
download just failed, so retrying it immediately would hammer the API for
|
||||
a result that is very unlikely to have changed.
|
||||
"""
|
||||
if force_download or not filepath.exists():
|
||||
return True
|
||||
if not is_placeholder_logo(filepath):
|
||||
return False
|
||||
age = placeholder_age_seconds(filepath)
|
||||
return age is None or age >= PLACEHOLDER_RETRY_SECONDS
|
||||
|
||||
|
||||
def refresh_placeholder_timestamp(filepath: Path) -> bool:
|
||||
"""Restamp a placeholder so a failed retry restarts the back-off clock.
|
||||
|
||||
Without this a stale placeholder stays stale: every later call sees an
|
||||
expired timestamp, retries, fails, and leaves the timestamp untouched --
|
||||
which is a download attempt per call, the opposite of what the back-off is
|
||||
for.
|
||||
"""
|
||||
try:
|
||||
if not is_placeholder_logo(filepath):
|
||||
return False
|
||||
metadata = PngInfo()
|
||||
metadata.add_text(PLACEHOLDER_MARKER, str(time.time()))
|
||||
with Image.open(filepath) as img:
|
||||
img.copy().save(filepath, "PNG", pnginfo=metadata)
|
||||
return True
|
||||
except Exception:
|
||||
logger.debug("Could not refresh placeholder timestamp for %s", filepath,
|
||||
exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
def placeholder_age_seconds(filepath: Path) -> Optional[float]:
|
||||
"""Seconds since a placeholder was written, or None if unknown."""
|
||||
try:
|
||||
with Image.open(filepath) as img:
|
||||
stamped = img.info.get(PLACEHOLDER_MARKER)
|
||||
if stamped and stamped != "1":
|
||||
return max(0.0, time.time() - float(stamped))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
return max(0.0, time.time() - filepath.stat().st_mtime)
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
class LogoDownloader:
|
||||
"""Centralized logo downloader for team logos from ESPN API."""
|
||||
|
||||
@@ -499,8 +590,10 @@ class LogoDownloader:
|
||||
filename = f"{self.normalize_abbreviation(abbreviation)}.png"
|
||||
filepath = Path(logo_dir) / filename
|
||||
|
||||
# Skip if already exists and not forcing download
|
||||
if filepath.exists() and not force_download:
|
||||
# A placeholder does not count as existing -- it is a previous
|
||||
# failure, and a bulk pass is exactly where it should get another
|
||||
# chance, subject to the same back-off as everywhere else.
|
||||
if not should_attempt_download(filepath, force_download):
|
||||
logger.debug(f"Skipping {display_name}: {filename} already exists")
|
||||
continue
|
||||
|
||||
@@ -559,8 +652,9 @@ class LogoDownloader:
|
||||
filename = f"{self.normalize_abbreviation(abbreviation)}.png"
|
||||
filepath = Path(logo_dir) / filename
|
||||
|
||||
# Skip if already exists and not forcing download
|
||||
if filepath.exists() and not force_download:
|
||||
# Same eligibility rule as every other download site: a stale
|
||||
# placeholder is a failed download, not a logo.
|
||||
if not should_attempt_download(filepath, force_download):
|
||||
logger.debug(f"Skipping {display_name} ({category}, {conference}): {filename} already exists")
|
||||
continue
|
||||
|
||||
@@ -674,11 +768,16 @@ class LogoDownloader:
|
||||
# Fallback without font
|
||||
draw.text((16, 24), text, fill=(255, 255, 255, 255))
|
||||
|
||||
logo.save(filepath)
|
||||
|
||||
# Stamp it so a later run can tell this apart from a real logo and
|
||||
# retry the download, instead of treating the file's existence as
|
||||
# proof the logo was fetched.
|
||||
metadata = PngInfo()
|
||||
metadata.add_text(PLACEHOLDER_MARKER, str(time.time()))
|
||||
logo.save(filepath, "PNG", pnginfo=metadata)
|
||||
|
||||
# Set proper file permissions after saving
|
||||
ensure_file_permissions(filepath, get_assets_file_mode())
|
||||
|
||||
|
||||
logger.info(f"Created placeholder logo for {team_abbreviation} at {filepath}")
|
||||
return True
|
||||
|
||||
@@ -770,9 +869,18 @@ def download_missing_logo(league: str, team_id: str, team_abbreviation: str, log
|
||||
# Use the exact filepath that was passed in (respects config settings)
|
||||
filepath = logo_path
|
||||
|
||||
if filepath.exists():
|
||||
if filepath.exists() and not should_attempt_download(filepath):
|
||||
# Either a real logo, or a placeholder too fresh to be worth retrying.
|
||||
logger.debug(f"Logo already exists for {team_abbreviation} ({league})")
|
||||
return True
|
||||
if filepath.exists():
|
||||
# A placeholder is a *failed* download wearing the real logo's
|
||||
# filename. Treating it as "already exists" is what pinned a team to a
|
||||
# grey box permanently after one transient failure.
|
||||
logger.info(
|
||||
"Logo for %s (%s) is a placeholder from a failed download; "
|
||||
"retrying the real logo", team_abbreviation, league,
|
||||
)
|
||||
|
||||
# Try to download the real logo first
|
||||
logger.info(f"Attempting to download logo for {team_abbreviation} from {league}")
|
||||
|
||||
@@ -28,6 +28,7 @@ fixes their version string. See `docs/SPORTS_UNIFICATION.md`, phase B4.
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
# Below this, the core's self-reported version is not evidence of anything.
|
||||
@@ -35,6 +36,45 @@ from typing import Any, Dict, Optional, Tuple
|
||||
TRUSTWORTHY_FLOOR: Tuple[int, int, int] = (2, 0, 0)
|
||||
|
||||
|
||||
# ``src/__init__.py`` relative to this file: src/plugin_system/ -> src/
|
||||
_VERSION_FILE = Path(__file__).resolve().parent.parent / "__init__.py"
|
||||
_VERSION_RE = re.compile(r'^__version__\s*=\s*["\']([^"\']+)["\']', re.M)
|
||||
|
||||
|
||||
def current_core_version() -> str:
|
||||
"""The core version as it is on disk right now, not as it was at import.
|
||||
|
||||
``from src import __version__`` binds whatever the process loaded at start.
|
||||
The web UI runs as its own long-lived service (``ledmatrix-web.service``),
|
||||
and updating the core replaces files on disk without restarting it -- the
|
||||
update route says so explicitly and asks the user to restart. Its prompt
|
||||
names the *display* service, so a user who follows it leaves the web
|
||||
process holding the old number.
|
||||
|
||||
The plugin store's gate lives in that web process. Stale by one release is
|
||||
exactly the case that matters: every plugin flooring on the release you
|
||||
just installed gets refused, with a message blaming a core version that is
|
||||
already correct on disk. 3.3.0 is the first release where that hits a whole
|
||||
plugin family at once -- all eight sports scoreboards floor there.
|
||||
|
||||
Reading the file costs one stat and a small read per call, and only on the
|
||||
install/update path. Any failure falls back to the imported value, so this
|
||||
can only ever be as wrong as before, never worse.
|
||||
"""
|
||||
try:
|
||||
text = _VERSION_FILE.read_text(encoding="utf-8")
|
||||
match = _VERSION_RE.search(text)
|
||||
if match:
|
||||
return match.group(1)
|
||||
except (OSError, UnicodeDecodeError):
|
||||
pass
|
||||
try:
|
||||
from src import __version__ as imported
|
||||
return imported
|
||||
except Exception: # noqa: BLE001 - never let this raise
|
||||
return "0.0.0"
|
||||
|
||||
|
||||
def parse_semver(value: Any) -> Optional[Tuple[int, int, int]]:
|
||||
"""Parse ``X.Y.Z`` (extra parts and suffixes ignored) into a comparable
|
||||
3-tuple, or ``None`` when unparseable. A leading ``v`` is tolerated."""
|
||||
|
||||
@@ -772,8 +772,8 @@ class PluginLoader:
|
||||
newer than the running core. Advisory only — never raises — so a
|
||||
plugin that guards optional features with try/except keeps working.
|
||||
"""
|
||||
from src import __version__ as core_version
|
||||
from src.plugin_system import compatibility
|
||||
core_version = compatibility.current_core_version()
|
||||
|
||||
compatible, _reason = compatibility.check(manifest, core_version)
|
||||
if compatible:
|
||||
|
||||
@@ -1467,8 +1467,11 @@ class PluginStoreManager:
|
||||
# already had. Allowing it costs them a plugin that raises
|
||||
# ModuleNotFoundError at load and is reported only as one line
|
||||
# in the journal. See docs/SPORTS_UNIFICATION.md (phase B4/B6).
|
||||
from src import __version__ as core_version
|
||||
from src.plugin_system import compatibility
|
||||
# On disk, not as imported: this process may predate the core
|
||||
# update that made the plugin compatible. See
|
||||
# compatibility.current_core_version.
|
||||
core_version = compatibility.current_core_version()
|
||||
|
||||
compatible, reason = compatibility.check(manifest, core_version)
|
||||
if not compatible:
|
||||
@@ -1602,6 +1605,26 @@ class PluginStoreManager:
|
||||
'error': f'Manifest missing required fields: {", ".join(missing_fields)}'
|
||||
}
|
||||
|
||||
# Refuse a plugin that needs a newer core than this one, exactly as
|
||||
# _install_plugin_impl does after its download. Sideloading is an
|
||||
# explicit act rather than an automatic store update, but the floor
|
||||
# is not advice about intent -- it is a statement that the plugin
|
||||
# cannot run here, and letting it through produces the same silent
|
||||
# PluginState.ERROR at load. This was the last of the three routes
|
||||
# in that skipped the check.
|
||||
#
|
||||
# Before the move, so the `finally` below removes the temp tree and
|
||||
# nothing half-installed is left behind.
|
||||
from src.plugin_system import compatibility
|
||||
core_version = compatibility.current_core_version()
|
||||
|
||||
compatible, reason = compatibility.check(manifest, core_version)
|
||||
if not compatible:
|
||||
self.logger.error(
|
||||
"Refusing to install %s from %s: %s",
|
||||
plugin_id, repo_url, reason)
|
||||
return {'success': False, 'error': reason}
|
||||
|
||||
# Validate version fields consistency (warnings only, not required)
|
||||
validation_errors = self._validate_manifest_version_fields(manifest)
|
||||
if validation_errors:
|
||||
@@ -2624,8 +2647,8 @@ class PluginStoreManager:
|
||||
manifest_path, plugin_id, e)
|
||||
return True
|
||||
|
||||
from src import __version__ as core_version
|
||||
from src.plugin_system import compatibility
|
||||
core_version = compatibility.current_core_version()
|
||||
|
||||
compatible, reason = compatibility.check(manifest, core_version)
|
||||
if compatible:
|
||||
|
||||
@@ -70,6 +70,7 @@ class VisualTestDisplayManager:
|
||||
# Canvas
|
||||
self.image = Image.new('RGB', (width, height), (0, 0, 0))
|
||||
self.draw = ImageDraw.Draw(self.image)
|
||||
self.draw.fontmode = "1" # Match production: 1-bit text, so goldens show what the panel shows.
|
||||
|
||||
# Matrix proxy (plugins access display_manager.matrix.width/height)
|
||||
self.matrix = _MatrixProxy(width, height)
|
||||
@@ -184,6 +185,7 @@ class VisualTestDisplayManager:
|
||||
self.clear_called = True
|
||||
self.image = Image.new('RGB', (self._width, self._height), (0, 0, 0))
|
||||
self.draw = ImageDraw.Draw(self.image)
|
||||
self.draw.fontmode = "1" # Match production: 1-bit text, so goldens show what the panel shows.
|
||||
|
||||
def update_display(self):
|
||||
"""No-op for hardware; marks that display was updated."""
|
||||
@@ -211,6 +213,7 @@ class VisualTestDisplayManager:
|
||||
self.matrix = _MatrixProxy(target_w, target_h)
|
||||
self.image = Image.new('RGB', (target_w, target_h), (0, 0, 0))
|
||||
self.draw = ImageDraw.Draw(self.image)
|
||||
self.draw.fontmode = "1" # Match production: 1-bit text, so goldens show what the panel shows.
|
||||
yield
|
||||
finally:
|
||||
self._width, self._height = prev_w, prev_h
|
||||
@@ -569,6 +572,7 @@ class VisualTestDisplayManager:
|
||||
self.draw_calls = []
|
||||
self.image = Image.new('RGB', (self._width, self._height), (0, 0, 0))
|
||||
self.draw = ImageDraw.Draw(self.image)
|
||||
self.draw.fontmode = "1" # Match production: 1-bit text, so goldens show what the panel shows.
|
||||
self._scrolling_state = {
|
||||
'is_scrolling': False,
|
||||
'last_scroll_activity': 0,
|
||||
@@ -582,3 +586,4 @@ class VisualTestDisplayManager:
|
||||
"""Clean up resources."""
|
||||
self.image = Image.new('RGB', (self._width, self._height), (0, 0, 0))
|
||||
self.draw = ImageDraw.Draw(self.image)
|
||||
self.draw.fontmode = "1" # Match production: 1-bit text, so goldens show what the panel shows.
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"""web_interface/app.py resolves plugins_dir from config at import time.
|
||||
|
||||
`project_root` used to be assigned only inside the `else` branch (the
|
||||
relative-path case). SchemaManager and composer_bp are wired up with
|
||||
`project_root` further down the same module, so a device configured with an
|
||||
*absolute* `plugin_system.plugins_directory` (a supported value -- see
|
||||
CLAUDE.md) hit `UnboundLocalError` importing web_interface.app at all, taking
|
||||
the whole web UI down.
|
||||
|
||||
app.py isn't imported directly by the test suite -- it has real side effects
|
||||
(managers, background state) at import time -- so this extracts the exact
|
||||
resolution block by its stable comment markers and executes it in isolation,
|
||||
the same way the rest of the suite validates generated/templated code rather
|
||||
than re-implementing it.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
APP_PY = Path(__file__).resolve().parent.parent / "web_interface/app.py"
|
||||
|
||||
START_MARKER = "# Resolve plugin directory - handle both absolute and relative paths\n"
|
||||
END_MARKER = "\nplugin_manager = PluginManager("
|
||||
|
||||
|
||||
def _resolution_block() -> str:
|
||||
src = APP_PY.read_text()
|
||||
start = src.index(START_MARKER)
|
||||
end = src.index(END_MARKER, start)
|
||||
return src[start:end]
|
||||
|
||||
|
||||
def test_resolution_block_still_matches_expected_markers():
|
||||
"""If app.py is restructured enough that these markers move, the exec
|
||||
below would silently test nothing -- fail loudly instead."""
|
||||
block = _resolution_block()
|
||||
assert "plugins_dir" in block and "os.path.isabs" in block
|
||||
|
||||
|
||||
def test_absolute_plugins_directory_does_not_raise_unboundlocalerror():
|
||||
import os
|
||||
ns = {"os": os, "Path": Path, "__file__": str(APP_PY),
|
||||
"plugins_dir_name": "/opt/ledmatrix-plugins"}
|
||||
exec(compile(_resolution_block(), str(APP_PY), "exec"), ns) # noqa: S102
|
||||
assert ns["project_root"] == APP_PY.parent.parent
|
||||
assert ns["plugins_dir"] == Path("/opt/ledmatrix-plugins")
|
||||
|
||||
|
||||
def test_relative_plugins_directory_still_resolves_under_project_root():
|
||||
import os
|
||||
ns = {"os": os, "Path": Path, "__file__": str(APP_PY),
|
||||
"plugins_dir_name": "plugin-repos"}
|
||||
exec(compile(_resolution_block(), str(APP_PY), "exec"), ns) # noqa: S102
|
||||
assert ns["plugins_dir"] == ns["project_root"] / "plugin-repos"
|
||||
@@ -0,0 +1,64 @@
|
||||
"""composer_bp must actually be registered on the Flask app.
|
||||
|
||||
web_interface/blueprints/composer.py defines a full blueprint -- the
|
||||
/composer/ page, /composer/api/generate, /composer/api/install, etc. -- but
|
||||
nothing in web_interface/app.py imported or registered it. pages_v3 and
|
||||
api_v3 both go through the same "import, wire managers, register_blueprint"
|
||||
sequence; composer_bp had none of the three. The routes existed and worked
|
||||
in isolation (every other composer test builds its own minimal Flask app
|
||||
and registers composer_bp directly), but in the real running app every
|
||||
composer URL -- including the page itself -- was a 404, and the frontend's
|
||||
hardcoded '/composer/api/...' fetches (composer-app.js) had nothing to
|
||||
reach.
|
||||
|
||||
app.py has import-time side effects (it loads config, builds managers, and
|
||||
in some configurations starts background threads), so this scans the source
|
||||
text rather than importing the module -- the same approach already used for
|
||||
test_composer_js_contracts.py's structural checks.
|
||||
"""
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
APP_PY = Path(__file__).resolve().parent.parent / "web_interface" / "app.py"
|
||||
|
||||
|
||||
def _source() -> str:
|
||||
return APP_PY.read_text()
|
||||
|
||||
|
||||
def test_composer_blueprint_is_imported():
|
||||
src = _source()
|
||||
assert re.search(
|
||||
r"from web_interface\.blueprints\.composer import composer_bp", src
|
||||
), "app.py never imports composer_bp"
|
||||
|
||||
|
||||
def test_composer_blueprint_is_registered_under_composer_prefix():
|
||||
src = _source()
|
||||
match = re.search(
|
||||
r"app\.register_blueprint\(\s*composer_bp\s*,\s*url_prefix\s*=\s*"
|
||||
r"['\"](?P<prefix>[^'\"]*)['\"]",
|
||||
src,
|
||||
)
|
||||
assert match, "app.py never calls app.register_blueprint(composer_bp, ...)"
|
||||
# composer.html and composer-app.js hardcode '/composer/api/...' and
|
||||
# '/composer/api/fonts/...' -- any other prefix serves a page whose own
|
||||
# fetches and @font-face all 404.
|
||||
assert match.group("prefix") == "/composer"
|
||||
|
||||
|
||||
def test_composer_blueprint_managers_are_wired_before_registration():
|
||||
"""Registered with no managers set means every route 503s or crashes.
|
||||
|
||||
composer.py initialises config_manager/plugin_manager/plugins_dir/
|
||||
project_root to None at import time specifically so app.py can set them
|
||||
-- the same pattern api_v3 and pages_v3 use.
|
||||
"""
|
||||
src = _source()
|
||||
register_match = re.search(r"app\.register_blueprint\(\s*composer_bp", src)
|
||||
assert register_match, "app.py never registers composer_bp"
|
||||
before_registration = src[: register_match.start()]
|
||||
for attr in ("config_manager", "plugin_manager", "plugins_dir", "project_root"):
|
||||
assert re.search(rf"composer_bp\.{attr}\s*=", before_registration), (
|
||||
f"composer_bp.{attr} is never set before registration"
|
||||
)
|
||||
@@ -0,0 +1,323 @@
|
||||
"""The composer generates Python that the plugin loader imports and executes.
|
||||
|
||||
/api/install writes the generated manager.py into plugins_dir and the loader
|
||||
imports it, so anything the payload can splice into that source runs on the
|
||||
device. The ast.parse check in _generate_plugin_files rejects only *invalid*
|
||||
syntax -- an injected `import os` is perfectly valid and passed it.
|
||||
|
||||
Two ways in, both confirmed against the code before it was fixed:
|
||||
|
||||
metadata.name = a name containing a triple-quote, a newline, then
|
||||
`import os; PWNED = os.getuid()`, then another triple-quote
|
||||
-> closes the module docstring; the rest became module-level statements
|
||||
(spelled out rather than shown literally -- writing the payload into
|
||||
this docstring closes *this* file's docstring, which is the bug)
|
||||
|
||||
element x = '0 or __import__("os").system("id")'
|
||||
-> f-string interpolated it verbatim: x=0 or __import__("os").system("id")
|
||||
"""
|
||||
import ast
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from web_interface.blueprints import composer as C # noqa: E402
|
||||
|
||||
BASE_META = {"id": "test-plugin", "name": "Clock", "author": "a",
|
||||
"version": "1.0.0", "description": "d"}
|
||||
|
||||
#: Values that terminate a Python expression and start a new statement.
|
||||
EXPR_PAYLOADS = [
|
||||
'0 or __import__("os").system("id")',
|
||||
'0);import os;os.system("id");(',
|
||||
'__import__("subprocess").run(["id"])',
|
||||
"0 if False else exec('x=1')",
|
||||
"1e999", "nan", "0x41", "0__0",
|
||||
]
|
||||
|
||||
#: Values that close a string literal in the generated source.
|
||||
LITERAL_PAYLOADS = [
|
||||
'Clock"""\nimport os; PWNED = os.getuid()\n"""',
|
||||
"Clock'''\nimport os\n'''",
|
||||
'Clock" + __import__("os").system("id") + "',
|
||||
"Clock\\", "Clock\nimport os",
|
||||
]
|
||||
|
||||
|
||||
def _payload(**over):
|
||||
# dataModel.configVars is the key _generate_plugin_files reads; "config_vars"
|
||||
# was never looked at, so anything passed through it tested nothing.
|
||||
p = {"metadata": dict(BASE_META), "elements": [],
|
||||
"dataModel": {"configVars": over.pop("config_vars", [])}}
|
||||
p["metadata"].update(over.pop("metadata", {}))
|
||||
p.update(over)
|
||||
return p
|
||||
|
||||
|
||||
def _generated(payload):
|
||||
return C._generate_plugin_files(payload)["manager.py"]
|
||||
|
||||
|
||||
def _module_level_code(src):
|
||||
"""Statements at module level that are not the docstring/imports/classes."""
|
||||
tree = ast.parse(src)
|
||||
out = []
|
||||
for node in tree.body:
|
||||
if isinstance(node, (ast.ClassDef, ast.FunctionDef, ast.ImportFrom, ast.Import)):
|
||||
continue
|
||||
if isinstance(node, ast.Expr) and isinstance(node.value, ast.Constant):
|
||||
continue # the docstring
|
||||
out.append(ast.unparse(node))
|
||||
return out
|
||||
|
||||
|
||||
@pytest.mark.parametrize("payload", LITERAL_PAYLOADS)
|
||||
def test_a_name_that_breaks_out_of_a_literal_is_refused(payload):
|
||||
with pytest.raises(C.ComposerInputError):
|
||||
_generated(_payload(metadata={"name": payload}))
|
||||
|
||||
|
||||
#: Types with a drawing branch in manager.py.j2. An injection test using any
|
||||
#: other type proves nothing: _preprocess_elements drops it, so its values
|
||||
#: never reach the generated source and every assertion passes trivially.
|
||||
#: This test previously used "line", which has never had a branch.
|
||||
RENDERED_GEOMETRY_CASES = [
|
||||
("rectangle", {"x": 0, "y": 0, "width": 10, "height": 8}),
|
||||
("arc", {"x": 0, "y": 0, "width": 24, "height": 24}),
|
||||
("ellipse", {"x": 0, "y": 0, "width": 24, "height": 12}),
|
||||
("rounded_rectangle", {"x": 0, "y": 0, "width": 24, "height": 10}),
|
||||
("gauge", {"x": 0, "y": 0, "width": 32, "height": 32}),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("etype,base", RENDERED_GEOMETRY_CASES)
|
||||
@pytest.mark.parametrize("evil", EXPR_PAYLOADS)
|
||||
@pytest.mark.parametrize("field", ["x", "y", "width", "height"])
|
||||
def test_a_non_numeric_geometry_value_cannot_reach_the_source(etype, base, evil, field):
|
||||
"""width/height were interpolated raw into the generated source.
|
||||
|
||||
p['x2_expr'] = f"({x_expr}) + {w}" with w straight off the payload, so a
|
||||
rectangle with width='0 or __import__("os").system("id")' produced
|
||||
|
||||
[0, 0, (0) + 0 or __import__("os").system("id"), (0) + 8],
|
||||
|
||||
in a manager.py that /api/install writes to disk and the loader imports.
|
||||
"""
|
||||
el = {"type": etype, "id": "e1", **base}
|
||||
el[field] = evil
|
||||
src = _generated(_payload(elements=[el]))
|
||||
assert "__import__" not in src, f"{etype}.{field}={evil!r} reached the generated source"
|
||||
assert "os.system" not in src
|
||||
assert not _module_level_code(src), \
|
||||
f"{etype}.{field}={evil!r} produced module-level statements: {_module_level_code(src)}"
|
||||
|
||||
|
||||
def test_every_injection_case_uses_a_type_that_actually_renders():
|
||||
"""Guards against the whole suite quietly going vacuous again.
|
||||
|
||||
An element type with no template branch is dropped before generation, so
|
||||
an injection test written against one asserts nothing and still passes.
|
||||
"""
|
||||
used = {etype for etype, _ in RENDERED_GEOMETRY_CASES}
|
||||
missing = used - set(C._RENDERABLE_ELEMENT_TYPES)
|
||||
assert not missing, f"injection tests use non-rendering types: {sorted(missing)}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("evil", EXPR_PAYLOADS)
|
||||
@pytest.mark.parametrize("channel", ["r", "g", "b"])
|
||||
def test_a_non_numeric_colour_channel_cannot_reach_the_source(evil, channel):
|
||||
el = {"type": "text", "id": "t1", "x": 0, "y": 0, "text": "hi",
|
||||
"font": "press_start", "r": 255, "g": 255, "b": 255}
|
||||
el[channel] = evil
|
||||
src = _generated(_payload(elements=[el]))
|
||||
assert "__import__" not in src and "os.system" not in src
|
||||
assert not _module_level_code(src)
|
||||
|
||||
|
||||
def test_colour_channels_are_clamped_to_a_byte():
|
||||
el = {"type": "text", "id": "t1", "x": 0, "y": 0, "text": "hi",
|
||||
"font": "press_start", "r": 99999, "g": -5, "b": 128}
|
||||
src = _generated(_payload(elements=[el]))
|
||||
assert "(255, 0, 128)" in src, "channels were not clamped to 0-255"
|
||||
|
||||
|
||||
#: fillR/fillG/fillB (and outR/G/B, bgR/G/B, trackR/G/B) go through
|
||||
#: _as_fill_filter -> _as_rgb_filter, a separate path from _rgb_expr above.
|
||||
#: It used raw int() until it was found to raise ValueError on a non-numeric
|
||||
#: channel instead of clamping like every other coerced value in this module.
|
||||
@pytest.mark.parametrize("evil", EXPR_PAYLOADS)
|
||||
@pytest.mark.parametrize("channel", ["fillR", "fillG", "fillB"])
|
||||
def test_a_non_numeric_fill_channel_cannot_reach_the_source(evil, channel):
|
||||
el = {"type": "rectangle", "id": "r1", "x": 0, "y": 0, "width": 10, "height": 8,
|
||||
"fillR": 0, "fillG": 0, "fillB": 128}
|
||||
el[channel] = evil
|
||||
src = _generated(_payload(elements=[el]))
|
||||
assert "__import__" not in src and "os.system" not in src
|
||||
assert not _module_level_code(src)
|
||||
|
||||
|
||||
def test_fill_channels_are_clamped_to_a_byte():
|
||||
el = {"type": "rectangle", "id": "r1", "x": 0, "y": 0, "width": 10, "height": 8,
|
||||
"fillR": 99999, "fillG": -5, "fillB": 128}
|
||||
src = _generated(_payload(elements=[el]))
|
||||
assert "(255, 0, 128)" in src, "fill channels were not clamped to 0-255"
|
||||
|
||||
|
||||
def test_the_generated_module_still_has_no_top_level_statements():
|
||||
"""The clean case: a normal payload produces only imports and a class."""
|
||||
el = {"type": "text", "id": "t1", "x": 4, "y": 4, "text": "hi",
|
||||
"font": "press_start", "r": 1, "g": 2, "b": 3}
|
||||
src = _generated(_payload(elements=[el]))
|
||||
assert not _module_level_code(src)
|
||||
assert "(1, 2, 3)" in src
|
||||
|
||||
|
||||
# --- config variable keys ---------------------------------------------------
|
||||
|
||||
def _with_key(key):
|
||||
return {"metadata": dict(BASE_META), "elements": [],
|
||||
"dataModel": {"configVars": [{"key": key, "type": "string",
|
||||
"default": "x", "label": "L"}]}}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key", ["class", "def", "import", "None", "True",
|
||||
"lambda", "pass", "match", "case"])
|
||||
def test_a_keyword_config_key_is_named_in_the_error(key):
|
||||
"""ast.parse already rejected these, but as an unhelpful line number.
|
||||
|
||||
"Generated code has a syntax error: invalid syntax (line 17)" tells the
|
||||
user nothing about which field to fix.
|
||||
"""
|
||||
with pytest.raises(C.ComposerInputError) as exc:
|
||||
_generated(_with_key(key))
|
||||
assert key in str(exc.value) and "keyword" in str(exc.value).lower()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key", ["config", "logger", "display_manager",
|
||||
"cache_manager", "plugin_id", "enabled",
|
||||
"self", "update", "display"])
|
||||
def test_a_reserved_attribute_config_key_is_refused(key):
|
||||
"""These generate *valid* Python that silently clobbers plugin state.
|
||||
|
||||
The worst is `config`: the assignment lands right after super().__init__(),
|
||||
so `self.config = config.get("config", "x")` replaces the plugin's config
|
||||
dict with a string and every later self.config.get(...) fails at runtime.
|
||||
"""
|
||||
with pytest.raises(C.ComposerInputError) as exc:
|
||||
_generated(_with_key(key))
|
||||
assert key in str(exc.value) and "reserved" in str(exc.value).lower()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key", ["brightness", "my_var", "_private", "x1",
|
||||
"update_interval_seconds"])
|
||||
def test_ordinary_config_keys_are_still_accepted(key):
|
||||
src = _generated(_with_key(key))
|
||||
assert f"self.{key} = config.get(" in src
|
||||
|
||||
|
||||
def test_the_generated_config_assignment_does_not_precede_super_init():
|
||||
"""Guards the reasoning behind the reserved list, not just the list."""
|
||||
src = _generated(_with_key("brightness"))
|
||||
body = src.splitlines()
|
||||
super_at = next(i for i, line in enumerate(body) if "super().__init__(" in line)
|
||||
assign_at = next(i for i, line in enumerate(body)
|
||||
if "self.brightness = config.get(" in line)
|
||||
assert assign_at > super_at, (
|
||||
"config vars are assigned before super().__init__(); the reserved-name "
|
||||
"list assumes they land after it")
|
||||
|
||||
|
||||
# --- optional keys ----------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("el_type,missing", [
|
||||
("text", "text"), ("text", "text2"), ("clock", "format"),
|
||||
])
|
||||
def test_an_element_missing_an_optional_key_does_not_500(el_type, missing):
|
||||
"""`p` is a copy of the raw element, so an absent key stays absent.
|
||||
|
||||
The defaults were applied to locals only, so manager.py.j2 rendered
|
||||
`{{ el.text | tojson }}` over a jinja2.Undefined and tojson raised
|
||||
TypeError -- which no handler catches, making a missing key a 500 rather
|
||||
than a validation error or a sensible default.
|
||||
"""
|
||||
el = {"type": el_type, "id": "e1", "x": 0, "y": 0, "font": "press_start"}
|
||||
src = _generated(_payload(elements=[el]))
|
||||
ast.parse(src) # must still be valid Python
|
||||
assert "Undefined" not in src
|
||||
|
||||
|
||||
def test_a_clock_without_a_format_uses_the_documented_default():
|
||||
el = {"type": "clock", "id": "c1", "x": 0, "y": 0, "font": "press_start"}
|
||||
src = _generated(_payload(elements=[el]))
|
||||
assert '"%H:%M"' in src, "the %H:%M default did not reach the generated source"
|
||||
|
||||
|
||||
#: (element type, channel key, base element) for colour channels that were
|
||||
#: interpolated raw rather than through _rgb_expr/_safe_int. Prefixed channels
|
||||
#: (emptyR/G/B, labelR/G/B) were the ones the original r/g/b test never reached.
|
||||
RAW_COLOUR_CASES = [
|
||||
("progress_bar", "r", {"x": 0, "y": 0}),
|
||||
("progress_bar", "g", {"x": 0, "y": 0}),
|
||||
("pips", "b", {"x": 0, "y": 0}),
|
||||
("pips", "emptyR", {"x": 0, "y": 0}),
|
||||
("pips", "emptyG", {"x": 0, "y": 0}),
|
||||
("sparkline", "r", {"x": 0, "y": 0}),
|
||||
("gauge", "labelR", {"x": 0, "y": 0, "width": 32, "height": 32}),
|
||||
("gauge", "labelB", {"x": 0, "y": 0, "width": 32, "height": 32}),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("etype,channel,base", RAW_COLOUR_CASES)
|
||||
@pytest.mark.parametrize("evil", EXPR_PAYLOADS)
|
||||
def test_a_prefixed_colour_channel_cannot_reach_the_source(etype, channel, base, evil):
|
||||
"""Five tuples were built with f"({el.get('r', 100)}, ...)" -- no coercion.
|
||||
|
||||
The pre-existing colour test only covered r/g/b on a text element, so the
|
||||
prefixed channels and the four other types were never exercised.
|
||||
"""
|
||||
el = {"type": etype, "id": "e1", **base}
|
||||
el[channel] = evil
|
||||
src = _generated(_payload(elements=[el]))
|
||||
assert "__import__" not in src, f"{etype}.{channel}={evil!r} reached the source"
|
||||
assert "os.system" not in src
|
||||
assert not _module_level_code(src)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [float("inf"), float("-inf"), float("nan")])
|
||||
@pytest.mark.parametrize("field", ["x", "y", "width", "height"])
|
||||
def test_a_non_finite_dimension_does_not_escape_as_an_unhandled_error(field, value):
|
||||
"""json.loads accepts Infinity/NaN and Flask passes them through, so a
|
||||
payload can hand _safe_int a non-finite float. int(inf) raises
|
||||
OverflowError -- neither ValueError nor ComposerInputError -- so it escaped
|
||||
both handlers and surfaced as a 500 with a traceback instead of a 422."""
|
||||
el = {"type": "rectangle", "id": "r1", "x": 0, "y": 0, "width": 10, "height": 8}
|
||||
el[field] = value
|
||||
src = _generated(_payload(elements=[el])) # must not raise
|
||||
# A non-finite value must be replaced by the default, not spelled into the
|
||||
# source. Word-boundary match: "info" in self.logger.info contains "inf".
|
||||
assert not re.search(r"\b(inf|nan|Infinity|NaN)\b", src), \
|
||||
f"{field}={value!r} leaked a non-finite literal into the source"
|
||||
assert not _module_level_code(src)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad_id", [
|
||||
'x = __import__("os").system("id") #',
|
||||
"x\nimport os\n_y",
|
||||
"x[0]",
|
||||
"",
|
||||
"a" * 200,
|
||||
])
|
||||
def test_a_marquee_id_cannot_become_code(bad_id):
|
||||
"""data_key is spliced UNQUOTED into variable names
|
||||
(_{{ data_key }}_text = ...), so a non-identifier id landed in the source
|
||||
as code. ast.parse caught it, but the caller then got an opaque
|
||||
"Generated code has a syntax error" rather than being told the id is bad."""
|
||||
el = {"type": "marquee", "id": bad_id, "x": 0, "y": 0, "text": "hi"}
|
||||
src = _generated(_payload(elements=[el])) # must not raise
|
||||
assert "__import__(" not in src
|
||||
assert "os.system(" not in src
|
||||
assert not _module_level_code(src)
|
||||
@@ -0,0 +1,124 @@
|
||||
"""An element the template cannot draw must not produce an empty `if` block.
|
||||
|
||||
manager.py.j2 wraps each element in `if width >= N:` (breakpoint) and/or
|
||||
`if int(time.time() * 2) % 2:` (blink), and the body comes from the per-type
|
||||
branches. A type with no branch contributed nothing, so the wrapper opened a
|
||||
block with no statements in it. ast.parse in _generate_plugin_files then
|
||||
failed and the caller was told only:
|
||||
|
||||
Generated code has a syntax error: expected an indented block after
|
||||
'if' statement on line 49
|
||||
|
||||
which names a line of generated source the user never sees. Confirmed against
|
||||
the code before the fix with a `group` element carrying minWidth.
|
||||
|
||||
Two defences, both covered here: _preprocess_elements drops types the template
|
||||
has no branch for, and the template emits a `pass` fallback so a type added to
|
||||
the canvas before its branch exists degrades to a no-op instead of a broken
|
||||
plugin.
|
||||
"""
|
||||
import ast
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from web_interface.blueprints import composer as C # noqa: E402
|
||||
|
||||
TEMPLATE = (Path(__file__).resolve().parent.parent
|
||||
/ "web_interface/templates/v3/composer/manager.py.j2")
|
||||
|
||||
BASE_META = {"id": "test-plugin", "name": "Clock", "author": "a",
|
||||
"version": "1.0.0", "description": "d"}
|
||||
|
||||
|
||||
def generate(element):
|
||||
return C._generate_plugin_files({
|
||||
"metadata": BASE_META,
|
||||
"elements": [element],
|
||||
"dataModel": {"configVars": []},
|
||||
})
|
||||
|
||||
|
||||
@pytest.mark.parametrize("wrapper", [
|
||||
{"minWidth": 64}, # breakpoint block
|
||||
{"blink": True}, # blink block
|
||||
{"minWidth": 64, "blink": True}, # both, nested
|
||||
])
|
||||
@pytest.mark.parametrize("etype", ["group", "widget_9000", "section"])
|
||||
def test_undrawable_element_does_not_break_generation(etype, wrapper):
|
||||
element = {"type": etype, "x": 0, "y": 0, "color": "#ffffff", **wrapper}
|
||||
files = generate(element) # must not raise ComposerInputError
|
||||
assert "manager.py" in files
|
||||
|
||||
|
||||
def test_drawable_element_still_renders_inside_a_breakpoint():
|
||||
files = generate({"type": "text", "text": "hi", "x": 0, "y": 0,
|
||||
"minWidth": 64, "color": "#ffffff"})
|
||||
src = files["manager.py"]
|
||||
assert "if width >= 64:" in src
|
||||
assert "draw_text" in src
|
||||
|
||||
|
||||
def test_renderable_types_match_the_template_branches():
|
||||
"""The constant and the template must agree.
|
||||
|
||||
A type listed in the constant with no branch emits an empty block (the bug
|
||||
above); a type with a branch but missing from the constant is silently
|
||||
dropped from every generated plugin. Neither is visible without this check.
|
||||
"""
|
||||
branches = set(re.findall(r"el\.type == '([a-z_]+)'", TEMPLATE.read_text()))
|
||||
assert branches == set(C._RENDERABLE_ELEMENT_TYPES)
|
||||
|
||||
|
||||
def test_template_closes_the_branch_chain_with_a_fallback():
|
||||
"""Belt and braces: even if the constant drifts, no empty block escapes."""
|
||||
text = TEMPLATE.read_text()
|
||||
assert "{% else %}" in text
|
||||
assert "pass # element type" in text
|
||||
|
||||
|
||||
@pytest.mark.parametrize("wrapper", [
|
||||
{"minWidth": 64},
|
||||
{"blink": True},
|
||||
{"minWidth": 64, "blink": True},
|
||||
])
|
||||
def test_dynamic_text_with_non_config_binding_does_not_break_generation(wrapper):
|
||||
"""dynamic_text only renders a body for binding.source == 'config'.
|
||||
|
||||
_preprocess_elements accepts any string as binding.source (it just
|
||||
defaults a missing one to 'config'), so a 'live' or 'sensor' source --
|
||||
anything a client sends that isn't literally 'config' -- hit the same
|
||||
empty-if-block bug as an undrawable element type, just one level deeper:
|
||||
the branch is taken, but its own inner `if` produced nothing.
|
||||
"""
|
||||
element = {"type": "dynamic_text", "x": 0, "y": 0, "color": "#ffffff",
|
||||
"binding": {"source": "live", "key": "temperature"}, **wrapper}
|
||||
files = generate(element) # must not raise ComposerInputError
|
||||
assert "manager.py" in files
|
||||
assert "non-config dynamic_text binding draws nothing" in files["manager.py"]
|
||||
|
||||
|
||||
def test_binding_source_cannot_break_out_of_the_comment_it_lands_in():
|
||||
"""binding.source used to be interpolated straight into a Python comment
|
||||
(`pass # dynamic_text binding_source "{{ el.binding_source }}" draws
|
||||
nothing`) with no escaping. A source string carrying a newline closed the
|
||||
comment, and text on the following line(s), indented to match, became a
|
||||
real statement in the generated plugin -- CWE-94, and live once
|
||||
composer_bp was registered (confirmed: this exact payload produces a
|
||||
manager.py containing a clean, ast.parse-valid `import os` against the
|
||||
pre-fix template). The comment is now a fixed literal that never
|
||||
interpolates the value at all.
|
||||
"""
|
||||
payload = "foo\n import os\n os.system('id') # "
|
||||
element = {"type": "dynamic_text", "x": 0, "y": 0, "color": "#ffffff",
|
||||
"binding": {"source": payload, "key": "temperature"}}
|
||||
files = generate(element)
|
||||
src = files["manager.py"]
|
||||
assert "import os" not in src
|
||||
assert "os.system" not in src
|
||||
assert "non-config dynamic_text binding draws nothing" in src
|
||||
ast.parse(src) # belt and braces: generate() already enforces this
|
||||
@@ -0,0 +1,263 @@
|
||||
"""Structural checks on the composer's JavaScript.
|
||||
|
||||
There is no JS test runner in this repo, but three defects here are structural
|
||||
enough to assert on the parse tree, and each was a real bug:
|
||||
|
||||
- Stroke widths inside _drawElement were left in canvas pixels while the
|
||||
geometry around them scaled by SCALE, so at SCALE>1 every outline rendered
|
||||
thinner than one LED pixel and the preview stopped matching the panel.
|
||||
- The `line` branch drew raw el.x0/el.y0/el.x1/el.y1, ignoring the anchor that
|
||||
every other element type honours, so setting xAnchor moved everything except
|
||||
lines -- and getBoundingBox had the same omission, leaving the hit box behind.
|
||||
- Four methods mutated exactly what _snapshot() serialises (metadata,
|
||||
currentPreset) without calling it. _snapshot is the only caller of
|
||||
_debouncedAutosave, so those changes were lost on reload and could not be
|
||||
undone.
|
||||
"""
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
tree_sitter = pytest.importorskip("tree_sitter")
|
||||
tree_sitter_javascript = pytest.importorskip("tree_sitter_javascript")
|
||||
|
||||
JS_DIR = Path(__file__).resolve().parent.parent / "web_interface/static/v3/js/composer"
|
||||
CANVAS = JS_DIR / "composer-canvas.js"
|
||||
APP = JS_DIR / "composer-app.js"
|
||||
|
||||
|
||||
def _function_source(path: Path, name: str) -> str:
|
||||
"""Return the source of a top-level function declaration by name."""
|
||||
src = path.read_bytes()
|
||||
lang = tree_sitter.Language(tree_sitter_javascript.language())
|
||||
tree = tree_sitter.Parser(lang).parse(src)
|
||||
found = []
|
||||
|
||||
def walk(node):
|
||||
if node.type == "function_declaration":
|
||||
ident = node.child_by_field_name("name")
|
||||
if ident is not None and src[ident.start_byte:ident.end_byte].decode() == name:
|
||||
found.append(src[node.start_byte:node.end_byte].decode())
|
||||
for c in node.children:
|
||||
walk(c)
|
||||
|
||||
walk(tree.root_node)
|
||||
assert found, f"{name} not found in {path.name}"
|
||||
return found[0]
|
||||
|
||||
|
||||
def _method_source(path: Path, name: str) -> str:
|
||||
"""Return the source of a top-level object method by name."""
|
||||
src = path.read_bytes()
|
||||
lang = tree_sitter.Language(tree_sitter_javascript.language())
|
||||
tree = tree_sitter.Parser(lang).parse(src)
|
||||
found = []
|
||||
|
||||
def walk(node):
|
||||
if node.type == "method_definition":
|
||||
ident = node.child_by_field_name("name")
|
||||
if ident is not None and src[ident.start_byte:ident.end_byte].decode() == name:
|
||||
found.append(src[node.start_byte:node.end_byte].decode())
|
||||
for c in node.children:
|
||||
walk(c)
|
||||
|
||||
walk(tree.root_node)
|
||||
assert found, f"{name} not found in {path.name}"
|
||||
return found[0]
|
||||
|
||||
|
||||
def test_both_files_parse():
|
||||
lang = tree_sitter.Language(tree_sitter_javascript.language())
|
||||
parser = tree_sitter.Parser(lang)
|
||||
for path in (CANVAS, APP):
|
||||
tree = parser.parse(path.read_bytes())
|
||||
errors = []
|
||||
|
||||
def walk(node):
|
||||
if node.type == "ERROR" or node.is_missing:
|
||||
errors.append(node.start_point[0] + 1)
|
||||
for c in node.children:
|
||||
walk(c)
|
||||
|
||||
walk(tree.root_node)
|
||||
assert not errors, f"{path.name} has parse errors at lines {errors}"
|
||||
|
||||
|
||||
def test_element_strokes_scale_with_scale():
|
||||
"""No bare `ctx.lineWidth = 1` inside _drawElement.
|
||||
|
||||
Selection handles and the grid are drawn in canvas pixels deliberately and
|
||||
live in other functions, so this is scoped to the element drawing routine.
|
||||
"""
|
||||
body = _function_source(CANVAS, "_drawElement")
|
||||
offenders = re.findall(r"ctx\.lineWidth\s*=\s*1\s*;", body)
|
||||
assert not offenders, f"{len(offenders)} unscaled stroke width(s) in _drawElement"
|
||||
|
||||
|
||||
def test_line_branch_applies_the_anchor_offset():
|
||||
"""Scoped to _drawElement.
|
||||
|
||||
getBoundingBox has its own `case 'line': {` and appears first in the file,
|
||||
so searching the whole text found *that* branch -- this assertion passed
|
||||
with the draw branch's anchor offset removed. Verified: stripping it and
|
||||
re-running gave 11/11 green.
|
||||
"""
|
||||
body = _function_source(CANVAS, "_drawElement")
|
||||
line_branch = body[body.index("case 'line': {"):]
|
||||
line_branch = line_branch[:line_branch.index("case 'divider'")]
|
||||
assert "ax - el.x0" in line_branch and "ay - el.y0" in line_branch, \
|
||||
"line drawing ignores xAnchor/yAnchor"
|
||||
assert "moveTo(el.x0 * s" not in line_branch, \
|
||||
"line still drawn from unanchored endpoints"
|
||||
|
||||
|
||||
def test_line_bounding_box_applies_the_anchor_offset():
|
||||
"""The companion to the above: scoped to getBoundingBox specifically, so
|
||||
the two tests cannot both be satisfied by the same branch."""
|
||||
body = _function_source(CANVAS, "getBoundingBox")
|
||||
box = body[body.index("case 'line'"):]
|
||||
box = box[:box.index("case 'divider'")]
|
||||
assert "ax - el.x0" in box, "line bounding box ignores the anchor"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("method", [
|
||||
"onBgColorChange", # mutates metadata.bgColor
|
||||
"setCustomSize", # mutates currentPreset / MATRIX_W / MATRIX_H
|
||||
"changePreset", # mutates currentPreset / MATRIX_W / MATRIX_H
|
||||
"applyPresetLabel", # same, for sizes not in DISPLAY_PRESETS
|
||||
"onColorChange", # the one that was already fixed — keeps it fixed
|
||||
])
|
||||
def test_state_mutations_take_a_snapshot(method):
|
||||
body = _method_source(APP, method)
|
||||
assert "_snapshot()" in body, \
|
||||
f"{method} changes snapshotted state without calling _snapshot()"
|
||||
assert "isDirty = true" in body, f"{method} does not mark the design dirty"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("method", ["changePreset", "applyPresetLabel"])
|
||||
def test_restore_path_stays_snapshot_free(method):
|
||||
"""_applyState and loadTemplate call these with {silent: true} while
|
||||
restoring; snapshotting there would push restore steps onto the undo stack
|
||||
and re-autosave the state just loaded."""
|
||||
body = _method_source(APP, method)
|
||||
assert "opts.silent" in body, f"{method} lost its silent guard"
|
||||
snap = body.index("_snapshot()")
|
||||
guard = body.index("!opts.silent")
|
||||
assert guard < snap, f"{method} snapshots outside the !opts.silent guard"
|
||||
|
||||
|
||||
TEMPLATE_HTML = (Path(__file__).resolve().parent.parent
|
||||
/ "web_interface/templates/v3/composer.html")
|
||||
|
||||
#: The six toolbar buttons and the wrapper each must call.
|
||||
ALIGN_BUTTONS = ["alignLeft", "alignHCenter", "alignRight",
|
||||
"alignTop", "alignVCenter", "alignBottom"]
|
||||
|
||||
|
||||
def test_alignment_buttons_use_the_anchor_clearing_path():
|
||||
"""Two alignment implementations existed and the toolbar used the wrong one.
|
||||
|
||||
The legacy alignElement(dir) set el.x/el.y but left xAnchor/yAnchor in
|
||||
place. resolveAnchor turns anchor='right' into `dim - val`, so "align left"
|
||||
(el.x = 0) resolved to x = MATRIX_W -- the element jumped to the far right
|
||||
edge instead. _alignElement clears the anchor first, so the stored value is
|
||||
absolute, and it also updates el.x0/el.y0 so lines actually move.
|
||||
"""
|
||||
html = TEMPLATE_HTML.read_text()
|
||||
for wrapper in ALIGN_BUTTONS:
|
||||
assert f"{wrapper}()" in html, f"toolbar does not call {wrapper}()"
|
||||
assert not re.search(r"[^_]alignElement\(", html), \
|
||||
"toolbar still calls the legacy alignElement()"
|
||||
|
||||
|
||||
def test_the_legacy_alignelement_is_gone():
|
||||
"""Leaving it in place invites the toolbar drifting back to it."""
|
||||
src = APP.read_text()
|
||||
assert not re.search(r"^\s{4}alignElement\(dir\)", src, re.M), \
|
||||
"legacy alignElement(dir) still defined"
|
||||
|
||||
|
||||
def test_align_clears_the_anchor_and_moves_line_endpoints():
|
||||
body = _method_source(APP, "_alignElement")
|
||||
assert "xAnchor = null" in body and "yAnchor = null" in body, \
|
||||
"_alignElement no longer clears the anchor, so aligning an anchored " \
|
||||
"element resolves to the wrong edge"
|
||||
assert "el.x0" in body and "el.y0" in body, \
|
||||
"_alignElement no longer moves line endpoints"
|
||||
|
||||
|
||||
def test_align_translates_both_line_endpoints_not_just_the_start():
|
||||
"""Setting only x0 (or y0) left x1/y1 behind, so aligning a line changed
|
||||
its shape instead of moving it -- e.g. a line from x0=20 to x1=50 aligned
|
||||
right became x0=98, x1=50, stretching rather than translating it. Both
|
||||
endpoints must move by the same delta."""
|
||||
body = _method_source(APP, "_alignElement")
|
||||
x_branch = body[body.index("if (axis === 'x')"):body.index("} else {")]
|
||||
y_branch = body[body.index("} else {"):]
|
||||
assert "el.x1" in x_branch, \
|
||||
"_alignElement moves x0 but not x1 -- a line's shape changes, not its position"
|
||||
assert "el.y1" in y_branch, \
|
||||
"_alignElement moves y0 but not y1 -- a line's shape changes, not its position"
|
||||
|
||||
|
||||
def test_divider_stroke_is_centered_in_led_pixels():
|
||||
"""A 0.5 canvas-pixel offset (correct only at SCALE=1) was applied after
|
||||
scaling instead of before it, so at SCALE>1 the stroke bled into the
|
||||
preceding LED row/column instead of straddling its own."""
|
||||
body = _function_source(CANVAS, "_drawElement")
|
||||
divider_branch = body[body.index("case 'divider'"):]
|
||||
divider_branch = divider_branch[:divider_branch.index("case 'pips'")]
|
||||
assert "ay * s + 0.5" not in divider_branch, \
|
||||
"divider still offsets by 0.5 canvas pixels after scaling"
|
||||
assert "ax * s + 0.5" not in divider_branch, \
|
||||
"divider still offsets by 0.5 canvas pixels after scaling"
|
||||
assert "(ay + 0.5) * s" in divider_branch and "(ax + 0.5) * s" in divider_branch, \
|
||||
"divider stroke is not centered within its LED pixel"
|
||||
|
||||
|
||||
def test_gauge_radii_are_clamped_to_zero():
|
||||
"""An imported design can carry a small gauge with a wide lineWidth --
|
||||
width=1, height=1, lineWidth=3 sends a negative radius into
|
||||
ctx.ellipse(), which throws IndexSizeError and aborts render() for every
|
||||
element still to be drawn, not just the gauge."""
|
||||
body = _function_source(CANVAS, "_drawElement")
|
||||
gauge_branch = body[body.index("case 'gauge'"):]
|
||||
assert re.search(r"Math\.max\(0,\s*rx\s*-\s*lwPx\s*/\s*2\)", gauge_branch), \
|
||||
"gauge x-radius is not clamped to zero"
|
||||
assert re.search(r"Math\.max\(0,\s*ry\s*-\s*lwPx\s*/\s*2\)", gauge_branch), \
|
||||
"gauge y-radius is not clamped to zero"
|
||||
assert "rx - lwPx / 2" not in re.sub(r"Math\.max\(0,\s*rx\s*-\s*lwPx\s*/\s*2\)", "", gauge_branch), \
|
||||
"an unclamped gauge radius is still passed to ctx.ellipse()"
|
||||
|
||||
|
||||
#: ELEMENT_DEFAULTS in composer-canvas.js gives exactly these types a
|
||||
#: `binding` object; verified against that file rather than assumed.
|
||||
BOUND_TYPES = ["dynamic_text", "progress_bar", "countdown", "pips", "sparkline", "gauge"]
|
||||
|
||||
|
||||
def test_bound_types_constant_lists_every_bound_element_type():
|
||||
"""ELEMENT_DEFAULTS in composer-canvas.js gives dynamic_text, progress_bar,
|
||||
countdown, pips, sparkline and gauge a `binding` object. If a type is added
|
||||
to (or removed from) that list without updating APP's BOUND_TYPES, the
|
||||
checks below drift out of sync silently -- this pins the two together."""
|
||||
src = APP.read_text()
|
||||
match = re.search(r"const BOUND_TYPES = \[([^\]]*)\];", src)
|
||||
assert match, "BOUND_TYPES constant not found in composer-app.js"
|
||||
declared = {t.strip().strip("'\"") for t in match.group(1).split(",") if t.strip()}
|
||||
assert declared == set(BOUND_TYPES), \
|
||||
f"BOUND_TYPES {declared} does not match the bound element types {set(BOUND_TYPES)}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("method", ["_isBound", "removeConfigVar", "_validateBeforeExport"])
|
||||
def test_binding_checks_cover_every_bound_element_type(method):
|
||||
"""Only dynamic_text and progress_bar were checked, so a countdown, pips,
|
||||
sparkline or gauge element with an empty binding passed export validation
|
||||
silently, and deleting a config var still used by one of them gave no
|
||||
warning -- the plugin would read a now-missing key at runtime with no
|
||||
indication why."""
|
||||
body = _method_source(APP, method)
|
||||
assert "BOUND_TYPES.includes(e.type)" in body, \
|
||||
f"{method} does not check every bound element type via BOUND_TYPES"
|
||||
assert "e.type === 'dynamic_text'" not in body, \
|
||||
f"{method} still hardcodes only dynamic_text instead of BOUND_TYPES"
|
||||
@@ -0,0 +1,225 @@
|
||||
"""A composer plugin id must never resolve outside the plugins directory.
|
||||
|
||||
CodeQL reported sixteen high-severity py/path-injection alerts against
|
||||
web_interface/blueprints/composer.py: a request-supplied plugin_id reaching
|
||||
Path(plugins_dir) / plugin_id, which is then created, written to, deleted
|
||||
(shutil.rmtree) and read back.
|
||||
|
||||
The id was already validated by an anchored regex, so every traversal payload
|
||||
was in fact rejected. What was missing was the guarantee living *with* the path
|
||||
building rather than in a regex several hundred lines away -- loosen that regex
|
||||
later and the traversal opens silently, with nothing at the filesystem boundary
|
||||
to catch it. _plugin_dir() closes that, and is the form static analysis can see.
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from web_interface.blueprints import composer as C # noqa: E402
|
||||
|
||||
#: Anything that has ever been used to climb out of a directory.
|
||||
TRAVERSAL = [
|
||||
"../../etc/passwd", "..", ".", "a/../../etc", "good/../../..",
|
||||
"/etc/passwd", "//etc/passwd", "a\\..\\..", "a%2f..%2f..",
|
||||
"....//....//etc", "a/./../../etc", "~", "~root",
|
||||
"plugin/../../../../../../etc/shadow",
|
||||
]
|
||||
|
||||
#: Rejected for shape, not traversal -- but rejected all the same.
|
||||
MALFORMED = ["", "A-upper", "1-leading-digit", "-leading-dash", "has_underscore",
|
||||
"has space", "has.dot", "a" * 64, "plugin\n", "plugin\n../../etc",
|
||||
"\n", "plug\x00in"]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def plugins_dir(tmp_path, monkeypatch):
|
||||
base = tmp_path / "plugin-repos"
|
||||
base.mkdir()
|
||||
monkeypatch.setattr(C.composer_bp, "plugins_dir", str(base), raising=False)
|
||||
return base
|
||||
|
||||
|
||||
@pytest.mark.parametrize("payload", TRAVERSAL)
|
||||
def test_traversal_payloads_are_refused(plugins_dir, payload):
|
||||
assert C._plugin_dir(payload) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("payload", MALFORMED)
|
||||
def test_malformed_ids_are_refused(plugins_dir, payload):
|
||||
assert C._plugin_dir(payload) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("payload", ["a", "my-plugin", "x9", "a" * 63])
|
||||
def test_valid_ids_resolve_inside_the_base(plugins_dir, payload):
|
||||
resolved = C._plugin_dir(payload)
|
||||
assert resolved is not None, f"{payload!r} was rejected but is valid"
|
||||
assert resolved.parent == plugins_dir.resolve(), (
|
||||
f"{payload!r} resolved to {resolved}, outside {plugins_dir}")
|
||||
|
||||
|
||||
def test_no_payload_can_escape_even_if_the_regex_is_loosened(plugins_dir, monkeypatch):
|
||||
"""The containment check must stand on its own.
|
||||
|
||||
This is the whole point of resolving at the filesystem boundary: if the id
|
||||
pattern is ever relaxed, traversal must still be impossible. Replace the
|
||||
regex with one that permits slashes and dots, then re-run the payloads.
|
||||
"""
|
||||
import re
|
||||
monkeypatch.setattr(C, "_PLUGIN_ID_RE", re.compile(r"\A[\w./\\~-]+\Z"))
|
||||
import os
|
||||
escaped = []
|
||||
base = os.path.realpath(str(plugins_dir))
|
||||
for payload in TRAVERSAL:
|
||||
resolved = C._plugin_dir(payload)
|
||||
if resolved is None:
|
||||
continue
|
||||
real = os.path.realpath(str(resolved))
|
||||
if real != base and os.path.commonpath([base, real]) != base:
|
||||
escaped.append((payload, real))
|
||||
assert not escaped, f"these escaped the base with a loosened regex: {escaped}"
|
||||
|
||||
|
||||
def test_a_sibling_directory_with_a_shared_prefix_is_not_inside(tmp_path, monkeypatch):
|
||||
"""commonpath, not startswith.
|
||||
|
||||
"/x/plugins-evil" starts with "/x/plugins" but is a different directory, so
|
||||
a prefix test would accept it.
|
||||
"""
|
||||
base = tmp_path / "plugins"
|
||||
base.mkdir()
|
||||
(tmp_path / "plugins-evil").mkdir()
|
||||
monkeypatch.setattr(C.composer_bp, "plugins_dir", str(base), raising=False)
|
||||
import re
|
||||
# Neutralise the two layers in front so this exercises the containment
|
||||
# check itself; otherwise secure_filename rejects the payload first and a
|
||||
# startswith regression would go unnoticed here.
|
||||
monkeypatch.setattr(C, "_PLUGIN_ID_RE", re.compile(r"\A[\w./\\~-]+\Z"))
|
||||
monkeypatch.setattr(C, "secure_filename", lambda v: v)
|
||||
assert C._plugin_dir("../plugins-evil") is None
|
||||
|
||||
|
||||
def test_containment_still_holds_if_the_sanitiser_is_defeated(plugins_dir, monkeypatch):
|
||||
"""Each layer is tested on its own, not just the stack.
|
||||
|
||||
secure_filename's equality guard rejects every traversal payload before the
|
||||
containment check sees it, so removing containment does not fail the other
|
||||
tests -- which would make it look load-bearing when it is not. Neutralise
|
||||
the regex *and* the sanitiser, and the realpath/commonpath check must still
|
||||
refuse everything on its own.
|
||||
"""
|
||||
import re
|
||||
monkeypatch.setattr(C, "_PLUGIN_ID_RE", re.compile(r"\A[\w./\\~-]+\Z"))
|
||||
monkeypatch.setattr(C, "secure_filename", lambda v: v)
|
||||
import os
|
||||
base = os.path.realpath(str(plugins_dir))
|
||||
escaped = []
|
||||
for payload in TRAVERSAL:
|
||||
resolved = C._plugin_dir(payload)
|
||||
if resolved is None:
|
||||
continue
|
||||
real = os.path.realpath(str(resolved))
|
||||
# Inside the base is fine -- "...." and "~" are ordinary directory
|
||||
# names on Linux, so they are not escapes. What must never happen is
|
||||
# landing outside the base, or on the base itself: install() rmtrees
|
||||
# its target, so the plugins root resolving to a "plugin" would wipe
|
||||
# every installed plugin.
|
||||
if real == base or os.path.commonpath([base, real]) != base:
|
||||
escaped.append((payload, real))
|
||||
assert not escaped, f"containment alone let these through: {escaped}"
|
||||
|
||||
|
||||
def test_secure_filename_never_rewrites_an_accepted_id(plugins_dir):
|
||||
"""The sanitiser must be a no-op on everything the regex accepts.
|
||||
|
||||
If secure_filename ever altered an accepted id, _plugin_dir would resolve
|
||||
to a *different* plugin's directory than the caller asked for -- a silent
|
||||
redirect, which is worse than a refusal. The guard turns that into a
|
||||
refusal; this proves the guard never has to fire in practice.
|
||||
"""
|
||||
import random
|
||||
from werkzeug.utils import secure_filename
|
||||
random.seed(1)
|
||||
alphabet = "abcdefghijklmnopqrstuvwxyz0123456789-"
|
||||
altered = []
|
||||
for _ in range(2000):
|
||||
n = random.randint(1, 63)
|
||||
cand = random.choice("abcdefghijklmnopqrstuvwxyz") + "".join(
|
||||
random.choice(alphabet) for _ in range(n - 1))
|
||||
if C._PLUGIN_ID_RE.match(cand) and secure_filename(cand) != cand:
|
||||
altered.append((cand, secure_filename(cand)))
|
||||
assert not altered, f"secure_filename rewrote accepted ids: {altered[:5]}"
|
||||
|
||||
|
||||
def test_a_trailing_newline_is_not_a_valid_id():
|
||||
r"""Python's `$` also matches before a trailing newline, so the original
|
||||
`^...$` accepted "myplugin\n" and would have created a directory whose
|
||||
name ends in one. \Z does not."""
|
||||
assert C._PLUGIN_ID_RE.match("myplugin") is not None
|
||||
assert C._PLUGIN_ID_RE.match("myplugin\n") is None
|
||||
|
||||
|
||||
# --- font serving -----------------------------------------------------------
|
||||
|
||||
FONT_TRAVERSAL = [
|
||||
"../../../etc/passwd", "../config/config.json", "..%2f..%2fetc%2fpasswd",
|
||||
"PressStart2P-Regular.ttf/../../../etc/passwd", "/etc/passwd", "",
|
||||
"PressStart2P-Regular.TTF", # case differs -> not the allowlisted name
|
||||
"PressStart2P-Regular.ttf ", # trailing space
|
||||
]
|
||||
|
||||
|
||||
def test_serve_font_refuses_a_file_that_exists_but_is_not_allowlisted(monkeypatch, tmp_path):
|
||||
"""The allowlist must be what refuses it, not a missing file.
|
||||
|
||||
Asserting 404 on traversal payloads proves nothing here: Flask's router
|
||||
will not match a path segment containing '/', and everything else 404s
|
||||
simply because no such file exists. Put a real, readable file next to the
|
||||
fonts and confirm it is still refused -- that is the allowlist working.
|
||||
"""
|
||||
fonts = tmp_path / "assets" / "fonts"
|
||||
fonts.mkdir(parents=True)
|
||||
(fonts / "id_rsa.ttf").write_bytes(b"PRIVATE KEY")
|
||||
monkeypatch.setattr(C.composer_bp, "project_root", str(tmp_path), raising=False)
|
||||
app = __import__("flask").Flask(__name__)
|
||||
app.register_blueprint(C.composer_bp)
|
||||
with app.test_client() as client:
|
||||
resp = client.get("/api/fonts/id_rsa.ttf")
|
||||
assert resp.status_code == 404, (
|
||||
"a readable non-allowlisted file was served; the allowlist is not gating")
|
||||
assert b"PRIVATE KEY" not in resp.data
|
||||
|
||||
|
||||
@pytest.mark.parametrize("payload", FONT_TRAVERSAL)
|
||||
def test_serve_font_refuses_anything_not_allowlisted(payload, monkeypatch, tmp_path):
|
||||
"""The name reaching the filesystem must come from the allowlist constant.
|
||||
|
||||
_ALLOWED_FONTS gates this endpoint, so nothing here was ever exploitable.
|
||||
Building the path from the matched constant rather than the request value
|
||||
is what makes that provable -- and it is why CodeQL reported two
|
||||
high-severity py/path-injection alerts on an endpoint that was already
|
||||
safe.
|
||||
"""
|
||||
monkeypatch.setattr(C.composer_bp, "project_root", str(tmp_path), raising=False)
|
||||
app = C.composer_bp.name and __import__("flask").Flask(__name__)
|
||||
app.register_blueprint(C.composer_bp)
|
||||
with app.test_client() as client:
|
||||
resp = client.get(f"/api/fonts/{payload}")
|
||||
assert resp.status_code in (404, 405, 308), (
|
||||
f"{payload!r} was not refused (status {resp.status_code})")
|
||||
|
||||
|
||||
def test_serve_font_still_serves_each_allowlisted_font(monkeypatch, tmp_path):
|
||||
fonts = tmp_path / "assets" / "fonts"
|
||||
fonts.mkdir(parents=True)
|
||||
monkeypatch.setattr(C.composer_bp, "project_root", str(tmp_path), raising=False)
|
||||
app = __import__("flask").Flask(__name__)
|
||||
app.register_blueprint(C.composer_bp)
|
||||
for name in C._ALLOWED_FONTS:
|
||||
(fonts / name).write_bytes(b"\x00\x01ttf")
|
||||
with app.test_client() as client:
|
||||
resp = client.get(f"/api/fonts/{name}")
|
||||
assert resp.status_code == 200, f"{name} should be served, got {resp.status_code}"
|
||||
@@ -0,0 +1,122 @@
|
||||
"""The gate must read the core version from disk, not from its own import.
|
||||
|
||||
`from src import __version__` binds whatever the process loaded at start. The
|
||||
web UI is a long-lived service of its own (ledmatrix-web.service), and updating
|
||||
the core replaces files on disk without restarting it -- the update route says
|
||||
so and asks the user to restart, but its prompt named only the *display*
|
||||
service, so a user who followed it left the web process holding the old number.
|
||||
|
||||
The plugin store's gate lives in that web process, so being stale by exactly
|
||||
one release is the case that bites: every plugin flooring on the release you
|
||||
just installed is refused, with a message blaming a core version that is
|
||||
already correct on disk.
|
||||
|
||||
Observed on hardware: after updating a rig to 3.3.0 and restarting only the
|
||||
display service, all eight sports scoreboards were refused with
|
||||
"supports LEDMatrix >=3.3.0, but this system is running 3.2.0" while
|
||||
src/__init__.py on that machine read 3.3.0.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from src.plugin_system import compatibility
|
||||
|
||||
|
||||
class TestCurrentCoreVersion:
|
||||
def test_it_reads_the_file_rather_than_the_imported_value(self, monkeypatch, tmp_path):
|
||||
# Simulate a process whose import predates the update: the module
|
||||
# object says 3.2.0 while the file on disk says 3.3.0.
|
||||
import src
|
||||
monkeypatch.setattr(src, "__version__", "3.2.0")
|
||||
fake = tmp_path / "__init__.py"
|
||||
fake.write_text('__version__ = "3.3.0"\n', encoding="utf-8")
|
||||
monkeypatch.setattr(compatibility, "_VERSION_FILE", fake)
|
||||
assert compatibility.current_core_version() == "3.3.0"
|
||||
|
||||
def test_it_matches_the_real_file_by_default(self):
|
||||
import src
|
||||
assert compatibility.current_core_version() == src.__version__
|
||||
|
||||
@pytest.mark.parametrize("body", [
|
||||
"__version__ = '3.4.1'\n",
|
||||
'__version__="3.4.1"\n',
|
||||
'"""doc"""\n\n__version__ = "3.4.1" # trailing comment\n',
|
||||
])
|
||||
def test_it_tolerates_the_ways_that_line_gets_written(self, monkeypatch, tmp_path, body):
|
||||
fake = tmp_path / "__init__.py"
|
||||
fake.write_text(body, encoding="utf-8")
|
||||
monkeypatch.setattr(compatibility, "_VERSION_FILE", fake)
|
||||
assert compatibility.current_core_version() == "3.4.1"
|
||||
|
||||
def test_a_missing_file_falls_back_to_the_import(self, monkeypatch, tmp_path):
|
||||
# Never worse than before: an unreadable file returns what the old
|
||||
# code would have returned.
|
||||
import src
|
||||
monkeypatch.setattr(src, "__version__", "3.2.0")
|
||||
monkeypatch.setattr(compatibility, "_VERSION_FILE", tmp_path / "gone.py")
|
||||
assert compatibility.current_core_version() == "3.2.0"
|
||||
|
||||
def test_a_file_without_the_line_falls_back(self, monkeypatch, tmp_path):
|
||||
import src
|
||||
monkeypatch.setattr(src, "__version__", "3.2.0")
|
||||
fake = tmp_path / "__init__.py"
|
||||
fake.write_text("# no version here\n", encoding="utf-8")
|
||||
monkeypatch.setattr(compatibility, "_VERSION_FILE", fake)
|
||||
assert compatibility.current_core_version() == "3.2.0"
|
||||
|
||||
def test_it_never_raises(self, monkeypatch, tmp_path):
|
||||
# This runs on the install path; an exception here would surface as a
|
||||
# failed update rather than a version mismatch.
|
||||
bad = tmp_path / "__init__.py"
|
||||
bad.write_bytes(b"\xff\xfe\x00 not utf-8 \xff")
|
||||
monkeypatch.setattr(compatibility, "_VERSION_FILE", bad)
|
||||
assert isinstance(compatibility.current_core_version(), str)
|
||||
|
||||
|
||||
class TestTheBugItFixes:
|
||||
def test_a_stale_import_no_longer_refuses_a_compatible_plugin(self, monkeypatch, tmp_path):
|
||||
"""The exact hardware failure, as a test."""
|
||||
import src
|
||||
monkeypatch.setattr(src, "__version__", "3.2.0") # what the process holds
|
||||
fake = tmp_path / "__init__.py"
|
||||
fake.write_text('__version__ = "3.3.0"\n', encoding="utf-8") # what is on disk
|
||||
monkeypatch.setattr(compatibility, "_VERSION_FILE", fake)
|
||||
|
||||
manifest = {"name": "Hockey Scoreboard", "min_ledmatrix_version": "3.3.0"}
|
||||
|
||||
stale_ok, _ = compatibility.check(manifest, src.__version__)
|
||||
assert stale_ok is False, "precondition: the stale value is what refused it"
|
||||
|
||||
fresh_ok, reason = compatibility.check(
|
||||
manifest, compatibility.current_core_version())
|
||||
assert fresh_ok is True, f"the disk version must allow it, got: {reason}"
|
||||
|
||||
def test_it_still_refuses_when_the_core_really_is_too_old(self, monkeypatch, tmp_path):
|
||||
# The gate must not become permissive: a genuinely old core still says no.
|
||||
fake = tmp_path / "__init__.py"
|
||||
fake.write_text('__version__ = "3.2.0"\n', encoding="utf-8")
|
||||
monkeypatch.setattr(compatibility, "_VERSION_FILE", fake)
|
||||
ok, reason = compatibility.check(
|
||||
{"name": "Hockey", "min_ledmatrix_version": "3.3.0"},
|
||||
compatibility.current_core_version())
|
||||
assert ok is False
|
||||
assert "3.2.0" in (reason or "")
|
||||
|
||||
|
||||
class TestCallSites:
|
||||
@pytest.mark.parametrize("module", [
|
||||
"src.plugin_system.store_manager",
|
||||
"src.plugin_system.plugin_loader",
|
||||
])
|
||||
def test_no_gate_binds_the_version_at_import(self, module):
|
||||
"""Catch a future call site reintroducing the stale read."""
|
||||
import inspect
|
||||
mod = importlib.import_module(module)
|
||||
source = inspect.getsource(mod)
|
||||
assert "from src import __version__ as core_version" not in source, (
|
||||
f"{module} binds __version__ at import; use "
|
||||
f"compatibility.current_core_version() so a long-lived process "
|
||||
f"sees a core update.")
|
||||
@@ -8,11 +8,27 @@ ensure_logo_directory, and the download_missing_logo function path
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch, Mock, MagicMock
|
||||
|
||||
from src.logo_downloader import LogoDownloader
|
||||
from PIL import Image
|
||||
from PIL.PngImagePlugin import PngInfo
|
||||
|
||||
from src.logo_downloader import (
|
||||
PLACEHOLDER_BG,
|
||||
PLACEHOLDER_MARKER,
|
||||
PLACEHOLDER_RETRY_SECONDS,
|
||||
PLACEHOLDER_SIZE,
|
||||
LogoDownloader,
|
||||
download_missing_logo,
|
||||
is_placeholder_logo,
|
||||
placeholder_age_seconds,
|
||||
refresh_placeholder_timestamp,
|
||||
should_attempt_download,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -127,3 +143,211 @@ class TestEnsureLogoDirectory:
|
||||
with patch("builtins.open", side_effect=mock_open):
|
||||
result = downloader.ensure_logo_directory(test_dir)
|
||||
assert result is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Placeholder detection and retry
|
||||
#
|
||||
# A failed download used to be cached as a placeholder wearing the real logo's
|
||||
# filename, and download_missing_logo returned early on "the file exists". One
|
||||
# transient failure therefore pinned a team to a grey box permanently.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestPlaceholderLogos:
|
||||
def _placeholder(self, tmp_path, abbrev="COLL"):
|
||||
downloader = LogoDownloader()
|
||||
assert downloader.create_placeholder_logo(abbrev, str(tmp_path)) is True
|
||||
return tmp_path / f"{abbrev}.png"
|
||||
|
||||
def test_generated_placeholder_is_recognised(self, tmp_path):
|
||||
assert is_placeholder_logo(self._placeholder(tmp_path)) is True
|
||||
|
||||
def test_real_logo_is_not_a_placeholder(self, tmp_path):
|
||||
real = tmp_path / "REAL.png"
|
||||
Image.new("RGBA", (500, 500), (12, 34, 56, 255)).save(real)
|
||||
assert is_placeholder_logo(real) is False
|
||||
|
||||
def test_legacy_unmarked_placeholder_is_recognised(self, tmp_path):
|
||||
"""Placeholders written before the marker existed must still be caught.
|
||||
|
||||
They are already sitting on users' disks; if they were not recognised
|
||||
those teams would stay grey boxes forever even after this fix.
|
||||
"""
|
||||
legacy = tmp_path / "LEGACY.png"
|
||||
Image.new("RGBA", PLACEHOLDER_SIZE, PLACEHOLDER_BG).save(legacy)
|
||||
assert is_placeholder_logo(legacy) is True
|
||||
|
||||
def test_same_size_but_different_colour_is_not_a_placeholder(self, tmp_path):
|
||||
real = tmp_path / "SMALL.png"
|
||||
Image.new("RGBA", PLACEHOLDER_SIZE, (10, 200, 10, 255)).save(real)
|
||||
assert is_placeholder_logo(real) is False
|
||||
|
||||
def test_missing_file_is_not_a_placeholder(self, tmp_path):
|
||||
assert is_placeholder_logo(tmp_path / "nope.png") is False
|
||||
|
||||
def test_existing_real_logo_short_circuits_without_downloading(self, tmp_path):
|
||||
real = tmp_path / "REAL.png"
|
||||
Image.new("RGBA", (500, 500), (1, 2, 3, 255)).save(real)
|
||||
with patch.object(LogoDownloader, "download_logo") as download:
|
||||
assert download_missing_logo(
|
||||
"afl", "1", "REAL", real, logo_url="http://example/x.png") is True
|
||||
download.assert_not_called()
|
||||
|
||||
def _age_placeholder(self, path, seconds):
|
||||
"""Rewrite a placeholder's marker so it reads as `seconds` old."""
|
||||
metadata = PngInfo()
|
||||
metadata.add_text(PLACEHOLDER_MARKER, str(time.time() - seconds))
|
||||
with Image.open(path) as img:
|
||||
img.copy().save(path, "PNG", pnginfo=metadata)
|
||||
|
||||
def test_stale_placeholder_triggers_a_retry(self, tmp_path):
|
||||
path = self._placeholder(tmp_path)
|
||||
self._age_placeholder(path, PLACEHOLDER_RETRY_SECONDS + 60)
|
||||
assert placeholder_age_seconds(path) > PLACEHOLDER_RETRY_SECONDS
|
||||
|
||||
with patch.object(LogoDownloader, "download_logo", return_value=True) as download:
|
||||
assert download_missing_logo(
|
||||
"afl", "1", "COLL", path,
|
||||
logo_url="http://example/coll.png") is True
|
||||
download.assert_called_once()
|
||||
|
||||
def test_placeholder_age_survives_an_mtime_touch(self, tmp_path):
|
||||
"""The age comes from the stamp, not the filesystem.
|
||||
|
||||
Anything that rewrites file times -- a backup restore, an rsync, a
|
||||
permissions fix script -- would otherwise reset the retry clock.
|
||||
"""
|
||||
path = self._placeholder(tmp_path)
|
||||
self._age_placeholder(path, PLACEHOLDER_RETRY_SECONDS + 60)
|
||||
now = time.time()
|
||||
os.utime(path, (now, now))
|
||||
assert placeholder_age_seconds(path) > PLACEHOLDER_RETRY_SECONDS
|
||||
|
||||
def test_fresh_placeholder_does_not_retry(self, tmp_path):
|
||||
"""Rate limiting: a placeholder written seconds ago must not re-download.
|
||||
|
||||
Without this the fix would trade a permanent grey box for an ESPN
|
||||
request on every frame.
|
||||
"""
|
||||
path = self._placeholder(tmp_path)
|
||||
with patch.object(LogoDownloader, "download_logo") as download:
|
||||
assert download_missing_logo(
|
||||
"afl", "1", "COLL", path,
|
||||
logo_url="http://example/coll.png") is True
|
||||
download.assert_not_called()
|
||||
|
||||
|
||||
class TestDownloadEligibility:
|
||||
"""One rule, shared by every download site.
|
||||
|
||||
The two bulk loops and the single-logo path each had their own idea of what
|
||||
counted as "already have it", which is how one of them ended up retrying
|
||||
fresh placeholders and the other skipping stale ones forever.
|
||||
"""
|
||||
|
||||
def _placeholder(self, tmp_path, abbrev="COLL"):
|
||||
assert LogoDownloader().create_placeholder_logo(abbrev, str(tmp_path))
|
||||
return tmp_path / f"{abbrev}.png"
|
||||
|
||||
def _age(self, path, seconds):
|
||||
metadata = PngInfo()
|
||||
metadata.add_text(PLACEHOLDER_MARKER, str(time.time() - seconds))
|
||||
with Image.open(path) as img:
|
||||
img.copy().save(path, "PNG", pnginfo=metadata)
|
||||
|
||||
def test_missing_file_is_eligible(self, tmp_path):
|
||||
assert should_attempt_download(tmp_path / "nope.png") is True
|
||||
|
||||
def test_real_logo_is_not_eligible(self, tmp_path):
|
||||
real = tmp_path / "REAL.png"
|
||||
Image.new("RGBA", (500, 500), (1, 2, 3, 255)).save(real)
|
||||
assert should_attempt_download(real) is False
|
||||
|
||||
def test_force_download_beats_a_real_logo(self, tmp_path):
|
||||
real = tmp_path / "REAL.png"
|
||||
Image.new("RGBA", (500, 500), (1, 2, 3, 255)).save(real)
|
||||
assert should_attempt_download(real, force_download=True) is True
|
||||
|
||||
def test_fresh_placeholder_is_not_eligible(self, tmp_path):
|
||||
assert should_attempt_download(self._placeholder(tmp_path)) is False
|
||||
|
||||
def test_stale_placeholder_is_eligible(self, tmp_path):
|
||||
path = self._placeholder(tmp_path)
|
||||
self._age(path, PLACEHOLDER_RETRY_SECONDS + 60)
|
||||
assert should_attempt_download(path) is True
|
||||
|
||||
def test_league_bulk_loop_skips_a_fresh_placeholder(self, tmp_path):
|
||||
"""A bulk pass honours the same back-off as everything else."""
|
||||
self._placeholder(tmp_path, "AAA")
|
||||
downloader = LogoDownloader()
|
||||
teams = [{"abbreviation": "AAA", "display_name": "A", "logo_url": "http://x/a.png"}]
|
||||
with patch.object(LogoDownloader, "get_logo_directory", return_value=str(tmp_path)):
|
||||
with patch.object(LogoDownloader, "fetch_teams_data", return_value={"sports": [{}]}):
|
||||
with patch.object(LogoDownloader, "extract_teams_from_data", return_value=teams):
|
||||
with patch.object(LogoDownloader, "download_logo") as download:
|
||||
downloader.download_missing_logos_for_league("nfl")
|
||||
download.assert_not_called()
|
||||
|
||||
def test_league_bulk_loop_retries_a_stale_placeholder(self, tmp_path):
|
||||
path = self._placeholder(tmp_path, "AAA")
|
||||
self._age(path, PLACEHOLDER_RETRY_SECONDS + 60)
|
||||
downloader = LogoDownloader()
|
||||
teams = [{"abbreviation": "AAA", "display_name": "A", "logo_url": "http://x/a.png"}]
|
||||
with patch.object(LogoDownloader, "get_logo_directory", return_value=str(tmp_path)):
|
||||
with patch.object(LogoDownloader, "fetch_teams_data", return_value={"sports": [{}]}):
|
||||
with patch.object(LogoDownloader, "extract_teams_from_data", return_value=teams):
|
||||
with patch.object(LogoDownloader, "download_logo", return_value=True) as download:
|
||||
downloader.download_missing_logos_for_league("nfl")
|
||||
download.assert_called_once()
|
||||
|
||||
def test_ncaa_bulk_loop_retries_a_stale_placeholder(self, tmp_path):
|
||||
"""This loop skipped placeholders forever; it now shares the rule."""
|
||||
path = self._placeholder(tmp_path, "AAA")
|
||||
self._age(path, PLACEHOLDER_RETRY_SECONDS + 60)
|
||||
downloader = LogoDownloader()
|
||||
teams = [{"abbreviation": "AAA", "display_name": "A",
|
||||
"logo_url": "http://x/a.png", "category": "FBS",
|
||||
"conference": "SEC"}]
|
||||
with patch.object(LogoDownloader, "get_logo_directory", return_value=str(tmp_path)):
|
||||
with patch.object(LogoDownloader, "fetch_teams_data", return_value={"sports": [{}]}):
|
||||
with patch.object(LogoDownloader, "extract_teams_from_data", return_value=teams):
|
||||
with patch.object(LogoDownloader, "download_logo", return_value=True) as download:
|
||||
downloader.download_all_ncaa_football_logos()
|
||||
download.assert_called_once()
|
||||
|
||||
def test_ncaa_bulk_loop_skips_a_fresh_placeholder(self, tmp_path):
|
||||
self._placeholder(tmp_path, "AAA")
|
||||
downloader = LogoDownloader()
|
||||
teams = [{"abbreviation": "AAA", "display_name": "A",
|
||||
"logo_url": "http://x/a.png", "category": "FBS",
|
||||
"conference": "SEC"}]
|
||||
with patch.object(LogoDownloader, "get_logo_directory", return_value=str(tmp_path)):
|
||||
with patch.object(LogoDownloader, "fetch_teams_data", return_value={"sports": [{}]}):
|
||||
with patch.object(LogoDownloader, "extract_teams_from_data", return_value=teams):
|
||||
with patch.object(LogoDownloader, "download_logo") as download:
|
||||
downloader.download_all_ncaa_football_logos()
|
||||
download.assert_not_called()
|
||||
|
||||
|
||||
class TestRefreshPlaceholderTimestamp:
|
||||
def test_restarts_the_back_off(self, tmp_path):
|
||||
assert LogoDownloader().create_placeholder_logo("COLL", str(tmp_path))
|
||||
path = tmp_path / "COLL.png"
|
||||
metadata = PngInfo()
|
||||
metadata.add_text(PLACEHOLDER_MARKER, str(time.time() - (PLACEHOLDER_RETRY_SECONDS + 60)))
|
||||
with Image.open(path) as img:
|
||||
img.copy().save(path, "PNG", pnginfo=metadata)
|
||||
assert should_attempt_download(path) is True
|
||||
|
||||
assert refresh_placeholder_timestamp(path) is True
|
||||
assert should_attempt_download(path) is False
|
||||
|
||||
def test_refuses_to_touch_a_real_logo(self, tmp_path):
|
||||
real = tmp_path / "REAL.png"
|
||||
Image.new("RGBA", (500, 500), (1, 2, 3, 255)).save(real)
|
||||
before = real.read_bytes()
|
||||
assert refresh_placeholder_timestamp(real) is False
|
||||
assert real.read_bytes() == before
|
||||
|
||||
def test_missing_file_is_not_an_error(self, tmp_path):
|
||||
assert refresh_placeholder_timestamp(tmp_path / "nope.png") is False
|
||||
|
||||
@@ -421,3 +421,76 @@ class TestSessionConfiguration:
|
||||
def test_user_agent_and_accept_headers(self, helper):
|
||||
assert helper.session.headers["User-Agent"] == "LEDMatrix-Common/1.0"
|
||||
assert helper.session.headers["Accept"] == "image/*"
|
||||
|
||||
|
||||
class TestStalePlaceholderHandling:
|
||||
"""load_logo_with_download must not be fooled by a cached placeholder.
|
||||
|
||||
A placeholder wears the real logo's filename, so both the file cache and
|
||||
the in-memory cache can hold one and look like a hit.
|
||||
"""
|
||||
|
||||
def _placeholder(self, tmp_path, abbrev="COLL"):
|
||||
from src.logo_downloader import LogoDownloader
|
||||
assert LogoDownloader().create_placeholder_logo(abbrev, str(tmp_path))
|
||||
return tmp_path / f"{abbrev}.png"
|
||||
|
||||
def _make_stale(self, path):
|
||||
import time
|
||||
from PIL.PngImagePlugin import PngInfo
|
||||
from src.logo_downloader import PLACEHOLDER_MARKER, PLACEHOLDER_RETRY_SECONDS
|
||||
metadata = PngInfo()
|
||||
metadata.add_text(PLACEHOLDER_MARKER, str(time.time() - (PLACEHOLDER_RETRY_SECONDS + 60)))
|
||||
with Image.open(path) as img:
|
||||
img.copy().save(path, "PNG", pnginfo=metadata)
|
||||
|
||||
def test_fresh_placeholder_is_served_without_a_download(self, helper, tmp_path):
|
||||
path = self._placeholder(tmp_path)
|
||||
with patch.object(LogoHelper, "_download_logo") as download:
|
||||
assert helper.load_logo_with_download("COLL", path, "http://x/c.png") is not None
|
||||
download.assert_not_called()
|
||||
|
||||
def test_stale_placeholder_triggers_a_download(self, helper, tmp_path):
|
||||
path = self._placeholder(tmp_path)
|
||||
self._make_stale(path)
|
||||
with patch.object(LogoHelper, "_download_logo") as download:
|
||||
helper.load_logo_with_download("COLL", path, "http://x/c.png")
|
||||
download.assert_called_once()
|
||||
|
||||
def test_replacement_logo_is_not_masked_by_the_cached_placeholder(self, helper, tmp_path):
|
||||
"""The bug this guards: load_logo answers from cache before the disk.
|
||||
|
||||
Without invalidation the freshly downloaded logo would not appear until
|
||||
the process restarted.
|
||||
"""
|
||||
path = self._placeholder(tmp_path)
|
||||
first = helper.load_logo_with_download("COLL", path, "http://x/c.png")
|
||||
assert first is not None
|
||||
|
||||
self._make_stale(path)
|
||||
|
||||
def fake_download(_self, _url, file_path):
|
||||
Image.new("RGB", (500, 500), (7, 8, 9)).save(file_path, format="PNG")
|
||||
|
||||
with patch.object(LogoHelper, "_download_logo", fake_download):
|
||||
second = helper.load_logo_with_download("COLL", path, "http://x/c.png")
|
||||
|
||||
assert second is not None
|
||||
from src.logo_downloader import is_placeholder_logo
|
||||
assert is_placeholder_logo(path) is False
|
||||
assert second.getpixel((0, 0))[:3] == (7, 8, 9)
|
||||
|
||||
def test_failed_retry_restarts_the_back_off(self, helper, tmp_path):
|
||||
"""Otherwise a stale placeholder means a download attempt per call."""
|
||||
from src.logo_downloader import should_attempt_download
|
||||
path = self._placeholder(tmp_path)
|
||||
self._make_stale(path)
|
||||
assert should_attempt_download(path) is True
|
||||
|
||||
def boom(_self, _url, _file_path):
|
||||
raise OSError("network down")
|
||||
|
||||
with patch.object(LogoHelper, "_download_logo", boom):
|
||||
helper.load_logo_with_download("COLL", path, "http://x/c.png")
|
||||
|
||||
assert should_attempt_download(path) is False
|
||||
|
||||
@@ -155,8 +155,9 @@ class TestInstallGate:
|
||||
rediscovering it. `update_plugin` also has a git branch that pulls in
|
||||
place and never re-downloads; that one is gated separately by
|
||||
`_gate_pulled_commit` and pinned in `TestGitPullGate` below. A third
|
||||
route, `install_from_url`, is still ungated — sideloading from a URL
|
||||
checks required fields but not the floor.
|
||||
route, `install_from_url` (sideloading from a URL), is gated in that
|
||||
function and pinned in `TestSideloadGate`. All three refuse on the same
|
||||
rule.
|
||||
"""
|
||||
|
||||
def _install_with_manifest(self, store, manifest, core_version, monkeypatch):
|
||||
@@ -217,6 +218,73 @@ class TestInstallGate:
|
||||
assert (path / "manifest.json").exists()
|
||||
|
||||
|
||||
class TestSideloadGate:
|
||||
"""`install_from_url` never looked at the core version.
|
||||
|
||||
Sideloading is an explicit act rather than an automatic store update, so
|
||||
the argument for gating it is different: not "the user did not choose
|
||||
this", but that the floor states the plugin *cannot run here*. Letting it
|
||||
through produces the same silent PluginState.ERROR at load that the store
|
||||
gate exists to prevent, and the user who typed the URL is no better placed
|
||||
to diagnose it than one who pressed Update.
|
||||
"""
|
||||
|
||||
def _sideload(self, store, manifest, core_version, monkeypatch):
|
||||
mgr, plugins_dir = store
|
||||
plugin_id = manifest["id"]
|
||||
|
||||
def fake_clone(repo_url, target_path, branches=None):
|
||||
target_path.mkdir(parents=True, exist_ok=True)
|
||||
(target_path / "manifest.json").write_text(
|
||||
json.dumps(manifest), encoding="utf-8")
|
||||
(target_path / "manager.py").write_text(
|
||||
"class P: pass\n", encoding="utf-8")
|
||||
return "main"
|
||||
|
||||
monkeypatch.setattr(mgr, "_install_via_git", fake_clone)
|
||||
monkeypatch.setattr(mgr, "_install_dependencies", lambda *a, **k: True)
|
||||
|
||||
import src
|
||||
monkeypatch.setattr(src, "__version__", core_version)
|
||||
return mgr.install_from_url("https://example.invalid/plugin"), \
|
||||
plugins_dir / plugin_id
|
||||
|
||||
def test_refuses_a_plugin_that_needs_a_newer_core(self, store, monkeypatch):
|
||||
manifest = {
|
||||
"id": "sideload-newer", "name": "Sideload", "class_name": "P",
|
||||
"display_modes": ["a"], "min_ledmatrix_version": "9.9.9",
|
||||
}
|
||||
result, path = self._sideload(store, manifest, "3.2.0", monkeypatch)
|
||||
|
||||
assert result["success"] is False
|
||||
assert "9.9.9" in result["error"], result["error"]
|
||||
assert not path.exists(), (
|
||||
"a refused sideload must not leave the plugin installed")
|
||||
|
||||
def test_allows_a_compatible_plugin(self, store, monkeypatch):
|
||||
"""The guard against over-refusing: a gate that blocks everything
|
||||
passes the test above and breaks sideloading entirely."""
|
||||
manifest = {
|
||||
"id": "sideload-fine", "name": "Sideload", "class_name": "P",
|
||||
"display_modes": ["a"], "min_ledmatrix_version": "3.0.0",
|
||||
}
|
||||
result, path = self._sideload(store, manifest, "3.2.0", monkeypatch)
|
||||
|
||||
assert result["success"] is True, result.get("error")
|
||||
assert (path / "manifest.json").exists()
|
||||
|
||||
def test_untrustworthy_core_does_not_block_a_2_0_0_floor(
|
||||
self, store, monkeypatch):
|
||||
"""Same rule as the other two routes: a v3.1.0 release reports 1.0.0,
|
||||
and nearly every published manifest floors at 2.0.0."""
|
||||
manifest = {
|
||||
"id": "sideload-floored", "name": "Sideload", "class_name": "P",
|
||||
"display_modes": ["a"], "versions": [{"ledmatrix_min": "2.0.0"}],
|
||||
}
|
||||
result, _ = self._sideload(store, manifest, "1.0.0", monkeypatch)
|
||||
|
||||
assert result["success"] is True, result.get("error")
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# The git-pull update path — the one route that does not re-download
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
"""The card helpers the eight scoreboards now share.
|
||||
|
||||
These bodies lived in eight byte-identical copies. Moving them here means one
|
||||
fix reaches every scoreboard — and that a mistake does too, which is what this
|
||||
file guards. Each case below is one the plugins' own code already handled; the
|
||||
point is that it keeps handling it.
|
||||
|
||||
The functions take ``config``/``logger``/``fonts`` as arguments rather than
|
||||
reading them off an instance, so a plugin keeps its method and delegates the
|
||||
body. That is what let all eight adopt this with byte-identical renders.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import json
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from src.common import sports_card as C
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def log():
|
||||
return logging.getLogger("test_sports_card")
|
||||
|
||||
|
||||
class TestSettingsLookup:
|
||||
def test_reads_the_scroll_card_block(self):
|
||||
cfg = {"scroll_card": {"vs_text": "@"}}
|
||||
assert C.scroll_card_option(cfg, "vs_text", "VS") == "@"
|
||||
|
||||
@pytest.mark.parametrize("cfg", [None, {}, {"scroll_card": None},
|
||||
{"scroll_card": {"vs_text": None}}])
|
||||
def test_missing_or_null_falls_back(self, cfg):
|
||||
"""A null in config means "unset", not "empty string"."""
|
||||
assert C.scroll_card_option(cfg, "vs_text", "VS") == "VS"
|
||||
|
||||
def test_upcoming_center_rejects_unknown_modes(self):
|
||||
for bad in ("sideways", "", None, 7):
|
||||
assert C.upcoming_center_mode({"scroll_card": {"upcoming_center": bad}}) == "vs"
|
||||
assert C.upcoming_center_mode({"scroll_card": {"upcoming_center": "DATE_TIME"}}) == "date_time"
|
||||
|
||||
|
||||
class TestColour:
|
||||
def test_rgb_list_and_hex_both_work(self):
|
||||
assert C.element_color({"customization": {"score_text": {"text_color": [1, 2, 3]}}},
|
||||
"score_text") == (1, 2, 3)
|
||||
assert C.element_color({"customization": {"score_text": {"text_color": "#ff8000"}}},
|
||||
"score_text") == (255, 128, 0)
|
||||
|
||||
@pytest.mark.parametrize("value", ["nope", "#fff", [1, 2], None, ["a", "b", "c"]])
|
||||
def test_unusable_colour_falls_back(self, value):
|
||||
cfg = {"customization": {"score_text": {"text_color": value}}}
|
||||
assert C.element_color(cfg, "score_text", (9, 9, 9)) == (9, 9, 9)
|
||||
|
||||
def test_coerce_rgb_clamps_rather_than_rejecting(self):
|
||||
assert C.coerce_rgb([300, -5, 20], (0, 0, 0)) == (255, 0, 20)
|
||||
|
||||
def test_coerce_rgb_refuses_a_three_character_string(self):
|
||||
""""123" would otherwise iterate into three digits and yield a colour."""
|
||||
assert C.coerce_rgb("123", (7, 7, 7)) == (7, 7, 7)
|
||||
|
||||
def test_font_colour_is_resolved_by_identity(self):
|
||||
a, b = object(), object()
|
||||
cfg = {"customization": {"score_text": {"text_color": [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):
|
||||
"""One object used for two elements has no single right colour."""
|
||||
shared = object()
|
||||
cfg = {"customization": {"score_text": {"text_color": [4, 5, 6]}}}
|
||||
assert C.font_color(cfg, {"score": shared, "team": shared}, shared) == (255, 255, 255)
|
||||
|
||||
|
||||
class TestFavourites:
|
||||
GAME = {"home_abbr": "TB", "away_abbr": "NO", "home_score": "21", "away_score": "17"}
|
||||
|
||||
def test_win_loss_and_tie(self):
|
||||
cfg = {"favorite_teams": ["TB"]}
|
||||
assert C.favorite_result(cfg, self.GAME) == "win"
|
||||
assert C.favorite_result({"favorite_teams": ["NO"]}, self.GAME) == "loss"
|
||||
tied = dict(self.GAME, home_score="3", away_score="3")
|
||||
assert C.favorite_result(cfg, tied) == "tie"
|
||||
|
||||
def test_no_verdict_without_exactly_one_favourite_side(self):
|
||||
assert C.favorite_result({}, self.GAME) is None
|
||||
assert C.favorite_result({"favorite_teams": ["TB", "NO"]}, self.GAME) is None
|
||||
assert C.favorite_result({"favorite_teams": ["SEA"]}, self.GAME) is None
|
||||
|
||||
def test_unusable_scores_give_no_verdict(self):
|
||||
bad = dict(self.GAME, home_score="x")
|
||||
assert C.favorite_result({"favorite_teams": ["TB"]}, bad) is None
|
||||
|
||||
def test_nested_payload_shape_is_read_too(self):
|
||||
game = {"home_team": {"abbrev": "TB", "score": 9},
|
||||
"away_team": {"abbrev": "NO", "score": 2}}
|
||||
assert C.side_score(game, "home") == 9
|
||||
assert C.side_is_favorite(game, "home", {"TB"}) is True
|
||||
|
||||
def test_matches_on_id_where_abbreviations_collide(self):
|
||||
"""NRL keys favourites by ESPN id; abbreviations are not unique there."""
|
||||
game = {"home_abbr": "SYD", "home_id": "4321"}
|
||||
assert C.side_is_favorite(game, "home", {"4321"}) is True
|
||||
|
||||
def test_game_and_config_favourites_are_both_used(self):
|
||||
"""Games carry resolved dynamic groups; config catches later edits."""
|
||||
game = dict(self.GAME, favorite_teams=["NO"], league="nfl")
|
||||
assert set(C.favorite_teams_for({"nfl": {"favorite_teams": ["TB"]}}, game)) == {"NO", "TB"}
|
||||
|
||||
|
||||
class TestDateAndTime:
|
||||
def test_date_formats(self, log):
|
||||
for fmt, want in [("abbrev", "Sep 5"), ("numeric", "9/5"),
|
||||
("day_first", "5 Sep"), ("numeric_day_first", "5/9")]:
|
||||
cfg = {"scroll_card": {"date_format": fmt}}
|
||||
assert C.format_game_date(cfg, log, "9/5") == want
|
||||
|
||||
@pytest.mark.parametrize("raw", ["", "garbage", "13/40", "no/slash/here"])
|
||||
def test_unparseable_dates_pass_through(self, log, raw):
|
||||
assert C.format_game_date({}, log, raw) == raw.strip()
|
||||
|
||||
def test_24h_conversion(self):
|
||||
cfg = {"scroll_card": {"time_format": "24h"}}
|
||||
assert C.format_game_time(cfg, "7:30 PM") == "19:30"
|
||||
assert C.format_game_time(cfg, "12:00 AM") == "00:00"
|
||||
assert C.format_game_time(cfg, "12:15 PM") == "12:15"
|
||||
|
||||
def test_12h_is_left_alone_and_junk_survives(self):
|
||||
assert C.format_game_time({}, "7:30 PM") == "7:30 PM"
|
||||
assert C.format_game_time({"scroll_card": {"time_format": "24h"}}, "soon") == "soon"
|
||||
|
||||
def test_a_bad_timezone_falls_back_to_utc(self, log):
|
||||
"""A typo in config should not blank the card."""
|
||||
from datetime import timezone
|
||||
assert C.card_tzinfo({"timezone": "Not/AZone"}, log) is timezone.utc
|
||||
|
||||
|
||||
class TestFontSizing:
|
||||
def test_snaps_to_the_faces_pixel_grid(self):
|
||||
assert C.crisp_size("4x6-font.ttf", 6) == 7 # 7px grid
|
||||
assert C.crisp_size("PressStart2P-Regular.ttf", 10) == 8
|
||||
assert C.crisp_size("PressStart2P-Regular.ttf", 13) == 16
|
||||
|
||||
def test_an_unknown_face_is_never_second_guessed(self):
|
||||
assert C.crisp_size("SomeUserFont.ttf", 11) == 11
|
||||
|
||||
def test_aliases_resolve_before_the_grid_lookup(self):
|
||||
assert C.crisp_size("four_by_six", 6) == C.crisp_size("4x6-font.ttf", 6)
|
||||
|
||||
@pytest.mark.parametrize("desired", [0, -3, None])
|
||||
def test_unusable_sizes_pass_through_without_raising(self, desired):
|
||||
"""None reached this in the field; football's variant raised TypeError."""
|
||||
assert C.crisp_size("4x6-font.ttf", desired) == desired
|
||||
|
||||
def test_schema_cache_is_keyed_per_schema_not_globally(self, tmp_path):
|
||||
"""Two plugins declaring different defaults must not share an answer."""
|
||||
a, b = tmp_path / "a.json", tmp_path / "b.json"
|
||||
for path, size in ((a, 11), (b, 22)):
|
||||
path.write_text(json.dumps({"properties": {"customization": {"properties": {
|
||||
"score_text": {"properties": {"font_size": {"default": size}}}}}}}))
|
||||
assert C.schema_font_size(str(a), "score_text") == 11
|
||||
assert C.schema_font_size(str(b), "score_text") == 22
|
||||
|
||||
def test_a_missing_schema_is_not_an_error(self, tmp_path):
|
||||
assert C.schema_font_size(str(tmp_path / "nope.json"), "score_text") is None
|
||||
|
||||
def test_a_configured_size_matching_the_schema_default_is_not_a_choice(self, tmp_path):
|
||||
"""The web UI writes the whole default block on every save, so
|
||||
font_size == schema default carries no intent and must not pin the
|
||||
install to an off-grid size forever."""
|
||||
schema = tmp_path / "s.json"
|
||||
schema.write_text(json.dumps({"properties": {"customization": {"properties": {
|
||||
"score_text": {"properties": {"font_size": {"default": 10}}}}}}}))
|
||||
got = C.resolve_font_size(str(schema), {"font_size": 10}, "score_text", 10,
|
||||
"PressStart2P-Regular.ttf")
|
||||
assert got == 8, "a default-valued size should snap to the grid"
|
||||
|
||||
def test_a_real_choice_wins(self, tmp_path):
|
||||
schema = tmp_path / "s.json"
|
||||
schema.write_text(json.dumps({"properties": {"customization": {"properties": {
|
||||
"score_text": {"properties": {"font_size": {"default": 10}}}}}}}))
|
||||
got = C.resolve_font_size(str(schema), {"font_size": 13}, "score_text", 10,
|
||||
"PressStart2P-Regular.ttf")
|
||||
assert got == 13, "an explicit size the user chose is not second-guessed"
|
||||
|
||||
|
||||
class TestTables:
|
||||
def test_every_font_key_maps_to_an_element(self):
|
||||
assert set(C.ELEMENT_FOR_FONT) == {"score", "time", "team", "status", "detail", "rank"}
|
||||
|
||||
def test_result_colours_cover_every_verdict(self):
|
||||
assert set(C.FAVORITE_RESULT_COLOR_DEFAULTS) == {"win", "loss", "tie"}
|
||||
|
||||
def test_month_and_weekday_tables_are_complete(self):
|
||||
assert len(C.MONTH_ABBR) == 12 and len(C.WEEKDAY_ABBR) == 7
|
||||
@@ -0,0 +1,258 @@
|
||||
"""The shared card geometry, exercised against a host that supplies only what
|
||||
the mixin's contract names.
|
||||
|
||||
The point of these is the contract, not the arithmetic. The mixin reaches for
|
||||
``display_width``, ``fonts``, ``config`` and six ``sports_card`` delegations
|
||||
through ``self``, and the eight plugins are what actually provide them. A
|
||||
stub host that provides exactly the documented surface and nothing else is
|
||||
what catches the mixin quietly growing a dependency the plugins do not have.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
from src.common.sports_game_renderer import SportsGameRendererMixin
|
||||
|
||||
|
||||
class Host(SportsGameRendererMixin):
|
||||
"""The documented contract, and not one attribute more."""
|
||||
|
||||
def __init__(self, width=128, height=32, config=None, scroll=None):
|
||||
self.display_width = width
|
||||
self.display_height = height
|
||||
self.config = config or {}
|
||||
self.logger = _Logger()
|
||||
self._team_rankings_cache = {}
|
||||
self._scroll = scroll or {}
|
||||
font = ImageFont.load_default()
|
||||
self.fonts = {'score': font, 'time': font, 'detail': font}
|
||||
self.drawn = []
|
||||
|
||||
# -- the six sports_card delegations the mixin calls --
|
||||
def _scroll_card_option(self, key, default=None):
|
||||
return self._scroll.get(key, default)
|
||||
|
||||
def _upcoming_center_mode(self):
|
||||
return self._scroll.get('upcoming_center', 'vs')
|
||||
|
||||
def _vs_text(self):
|
||||
return self._scroll.get('vs_text', 'VS')
|
||||
|
||||
def _element_color(self, element):
|
||||
return (255, 255, 255)
|
||||
|
||||
def _format_game_date(self, raw, game):
|
||||
return raw
|
||||
|
||||
def _format_game_time(self, raw):
|
||||
return raw
|
||||
|
||||
# -- the one hook whose body genuinely varies per plugin --
|
||||
def _draw_text_with_outline(self, draw, text, position, font,
|
||||
fill=None, outline_color=(0, 0, 0)):
|
||||
self.drawn.append((text, position))
|
||||
|
||||
|
||||
class _Logger:
|
||||
def debug(self, *a, **k):
|
||||
pass
|
||||
|
||||
|
||||
def _draw():
|
||||
return ImageDraw.Draw(Image.new("RGB", (256, 64)))
|
||||
|
||||
|
||||
class TestCenterGap:
|
||||
def test_explicit_center_gap_wins_outright(self):
|
||||
assert Host(scroll={'center_gap': 31})._center_gap_width() == 31
|
||||
|
||||
def test_explicit_zero_restores_edge_to_edge_logos(self):
|
||||
# 0 is a real setting, not a falsy miss -- the guard is `>= 0`.
|
||||
assert Host(scroll={'center_gap': 0})._center_gap_width() == 0
|
||||
|
||||
def test_otherwise_it_scales_with_card_width_within_the_clamp(self):
|
||||
h = Host(width=512)
|
||||
# 512 * 0.28 = 143, clamped to the 40px ceiling.
|
||||
assert h._center_gap_width() >= h.CENTER_GAP_MIN_PX
|
||||
|
||||
def test_the_gap_never_ends_up_narrower_than_the_score(self):
|
||||
# This is the bug the measurement exists to prevent: a derived gap
|
||||
# smaller than the rendered score drew the score over the logos.
|
||||
h = Host(width=64)
|
||||
assert h._center_gap_width() >= h._score_reserve_width()
|
||||
|
||||
def test_a_junk_ratio_falls_back_to_the_floor(self):
|
||||
h = Host(scroll={'center_gap_ratio': 'wide'})
|
||||
assert h._center_gap_width() == h.CENTER_GAP_MIN_PX
|
||||
|
||||
|
||||
class TestNonFiniteSettings:
|
||||
"""inf reaches int() and raises OverflowError, which the old
|
||||
`except (TypeError, ValueError)` did not catch -- so one bad config value
|
||||
aborted the entire card render rather than falling back."""
|
||||
|
||||
@pytest.mark.parametrize("bad", [float("inf"), float("-inf")])
|
||||
def test_a_non_finite_center_gap_falls_back(self, bad):
|
||||
h = Host(scroll={'center_gap': bad})
|
||||
assert h._center_gap_width() >= h.CENTER_GAP_MIN_PX
|
||||
|
||||
@pytest.mark.parametrize("bad", [float("inf"), float("-inf"), float("nan")])
|
||||
def test_a_non_finite_ratio_falls_back_to_the_floor(self, bad):
|
||||
h = Host(scroll={'center_gap_ratio': bad})
|
||||
assert h._center_gap_width() == h.CENTER_GAP_MIN_PX
|
||||
|
||||
@pytest.mark.parametrize("bad", [float("inf"), float("-inf")])
|
||||
def test_non_finite_clamp_bounds_fall_back(self, bad):
|
||||
h = Host(scroll={'center_gap_min': bad, 'center_gap_max': bad})
|
||||
assert h._center_gap_width() == h.CENTER_GAP_MIN_PX
|
||||
|
||||
@pytest.mark.parametrize("bad", [float("inf"), float("-inf"), "inf", "-inf", "nan"])
|
||||
def test_a_non_finite_layout_offset_gives_the_default(self, bad):
|
||||
cfg = {'customization': {'layout': {'score': {'x_offset': bad}}}}
|
||||
assert Host(config=cfg)._layout_offset('score', 'x_offset', 7) == 7
|
||||
|
||||
def test_a_finite_value_is_still_honoured(self):
|
||||
# The guard must not swallow ordinary settings.
|
||||
assert Host(scroll={'center_gap': 31})._center_gap_width() == 31
|
||||
cfg = {'customization': {'layout': {'score': {'x_offset': -3}}}}
|
||||
assert Host(config=cfg)._layout_offset('score', 'x_offset', 7) == -3
|
||||
|
||||
|
||||
class TestScoreReserve:
|
||||
def test_it_measures_the_probe_plus_both_gutters(self):
|
||||
h = Host()
|
||||
assert h._score_reserve_width() > 2 * h._SCORE_LOGO_GUTTER_PX
|
||||
|
||||
def test_a_wider_probe_reserves_more(self):
|
||||
class Wide(Host):
|
||||
_SCORE_PROBE = "000-000"
|
||||
assert Wide()._score_reserve_width() > Host()._score_reserve_width()
|
||||
|
||||
def test_an_unmeasurable_font_reserves_nothing_rather_than_raising(self):
|
||||
h = Host()
|
||||
h.fonts = {'score': object()}
|
||||
assert h._score_reserve_width() == 0
|
||||
|
||||
|
||||
class TestLogoSlot:
|
||||
def test_the_slot_is_what_is_left_after_the_gap(self):
|
||||
h = Host(width=128, scroll={'center_gap': 40})
|
||||
assert h._logo_slot_width() == 44
|
||||
|
||||
def test_it_is_not_capped_at_the_card_height(self):
|
||||
# The height cap is what froze logos at 46px on a 128px card.
|
||||
h = Host(width=512, height=32, scroll={'center_gap': 40})
|
||||
assert h._logo_slot_width() > h.display_height
|
||||
|
||||
def test_a_gap_wider_than_the_card_still_leaves_a_usable_slot(self):
|
||||
assert Host(width=64, scroll={'center_gap': 200})._logo_slot_width() == 8
|
||||
|
||||
def test_the_cache_key_is_scoped_to_the_slot_not_just_the_name(self):
|
||||
# One cache dict is shared by renderers of different card widths.
|
||||
narrow = Host(width=64, scroll={'center_gap': 20})._logo_cache_key("NYY")
|
||||
wide = Host(width=256, scroll={'center_gap': 20})._logo_cache_key("NYY")
|
||||
assert narrow != wide
|
||||
|
||||
|
||||
class TestLayoutOffset:
|
||||
def _host(self, value):
|
||||
return Host(config={'customization': {'layout': {'score': {'x_offset': value}}}})
|
||||
|
||||
def test_it_reads_the_same_block_as_the_full_screen_scorebug(self):
|
||||
assert self._host(5)._layout_offset('score', 'x_offset') == 5
|
||||
|
||||
def test_a_string_offset_from_the_web_ui_is_coerced(self):
|
||||
assert self._host("-3")._layout_offset('score', 'x_offset') == -3
|
||||
|
||||
def test_a_bool_is_not_silently_an_offset_of_one(self):
|
||||
assert self._host(True)._layout_offset('score', 'x_offset', 9) == 9
|
||||
|
||||
@pytest.mark.parametrize("cfg", [{}, {'customization': {}},
|
||||
{'customization': {'layout': {}}}])
|
||||
def test_a_missing_block_gives_the_default(self, cfg):
|
||||
assert Host(config=cfg)._layout_offset('score', 'x_offset', 7) == 7
|
||||
|
||||
def test_an_unparseable_offset_gives_the_default(self):
|
||||
assert self._host("left")._layout_offset('score', 'x_offset', 4) == 4
|
||||
|
||||
|
||||
class TestUpcomingCenter:
|
||||
def test_none_draws_nothing(self):
|
||||
h = Host(scroll={'upcoming_center': 'none'})
|
||||
h._draw_upcoming_center(_draw(), {})
|
||||
assert h.drawn == []
|
||||
|
||||
def test_vs_is_the_default_and_never_a_score(self):
|
||||
# An upcoming game has not started; the extractor's 0-0 is noise.
|
||||
h = Host()
|
||||
h._draw_upcoming_center(_draw(), {'home_score': 0, 'away_score': 0})
|
||||
assert [t for t, _ in h.drawn] == ['VS']
|
||||
|
||||
def test_an_empty_vs_string_draws_nothing(self):
|
||||
h = Host(scroll={'vs_text': ''})
|
||||
h._draw_upcoming_center(_draw(), {})
|
||||
assert h.drawn == []
|
||||
|
||||
def test_date_time_stacks_both_lines(self):
|
||||
h = Host(scroll={'upcoming_center': 'date_time'})
|
||||
h._draw_upcoming_center(_draw(), {'game_date': 'Sep 19', 'game_time': '7:00 PM'})
|
||||
assert [t for t, _ in h.drawn] == ['Sep 19', '7:00 PM']
|
||||
|
||||
def test_hiding_both_lines_draws_nothing(self):
|
||||
h = Host(scroll={'upcoming_center': 'date_time',
|
||||
'show_date': False, 'show_time': False})
|
||||
h._draw_upcoming_center(_draw(), {'game_date': 'Sep 19', 'game_time': '7:00 PM'})
|
||||
assert h.drawn == []
|
||||
|
||||
|
||||
class TestUpcomingStatus:
|
||||
def test_time_on_top_and_date_below_by_default(self):
|
||||
h = Host()
|
||||
h._draw_upcoming_game_status(_draw(), {'game_date': 'Sep 19', 'game_time': '7:00 PM'})
|
||||
assert [t for t, _ in h.drawn] == ['7:00 PM', 'Sep 19']
|
||||
|
||||
def test_swap_date_time_reverses_them(self):
|
||||
h = Host(scroll={'swap_date_time': True})
|
||||
h._draw_upcoming_game_status(_draw(), {'game_date': 'Sep 19', 'game_time': '7:00 PM'})
|
||||
assert [t for t, _ in h.drawn] == ['Sep 19', '7:00 PM']
|
||||
|
||||
def test_it_stays_out_of_the_way_when_the_centre_already_has_them(self):
|
||||
# Otherwise the date and time print twice on the same card.
|
||||
h = Host(scroll={'upcoming_center': 'date_time'})
|
||||
h._draw_upcoming_game_status(_draw(), {'game_date': 'Sep 19', 'game_time': '7:00 PM'})
|
||||
assert h.drawn == []
|
||||
|
||||
def test_the_bottom_line_is_measured_not_a_fixed_offset(self):
|
||||
# A fixed -7 ran "Sep 19" past the card wherever the detail font is
|
||||
# 10px rather than 6px.
|
||||
h = Host(height=64)
|
||||
h._draw_upcoming_game_status(_draw(), {'game_date': 'Sep 19', 'game_time': '7:00 PM'})
|
||||
bottom_y = h.drawn[1][1][1]
|
||||
assert 0 <= bottom_y < h.display_height
|
||||
|
||||
|
||||
class TestRankings:
|
||||
def test_set_rankings_cache_replaces_the_cache(self):
|
||||
h = Host()
|
||||
h.set_rankings_cache({'UGA': 1})
|
||||
assert h._team_rankings_cache == {'UGA': 1}
|
||||
|
||||
|
||||
class TestContract:
|
||||
def test_the_mixin_carries_no_state_of_its_own(self):
|
||||
# Adoption must be one line on the class statement; a mixin with an
|
||||
# __init__ would force eight constructors to cooperate.
|
||||
assert '__init__' not in SportsGameRendererMixin.__dict__
|
||||
|
||||
def test_a_host_providing_the_documented_surface_needs_nothing_more(self):
|
||||
# Host defines exactly what the module docstring names. If the mixin
|
||||
# grows a new self.* dependency, this is what fails.
|
||||
h = Host()
|
||||
h._center_gap_width()
|
||||
h._logo_slot_width()
|
||||
h._logo_cache_key("X")
|
||||
h._layout_offset('score', 'x_offset')
|
||||
h._upcoming_date_and_time({})
|
||||
h._draw_upcoming_center(_draw(), {})
|
||||
h._draw_upcoming_game_status(_draw(), {})
|
||||
h.set_rankings_cache({})
|
||||
@@ -0,0 +1,376 @@
|
||||
"""The shared sports.py mixins: their host contract, and _plugin_dir.
|
||||
|
||||
Two things are worth testing here and the rest is not. The 45 method bodies
|
||||
moved verbatim from the plugins, so they are covered by the plugins' own tests
|
||||
and by 176 byte-identical safety-harness renders. What is genuinely new is:
|
||||
|
||||
1. The contract. Every ``self.<CONSTANT>`` a mixin reads must be defined on the
|
||||
mixin, or a host that does not happen to declare it raises AttributeError at
|
||||
runtime. Two were missed on the first pass (_QUALITY_CHOICES and
|
||||
_RANKING_COVERAGE_SECONDS); the eight plugins all declare them, so nothing
|
||||
failed -- it would only have bitten a ninth. The test derives the list rather
|
||||
than restating it, so the next omission fails here instead of in the field.
|
||||
|
||||
2. ``_plugin_dir``. This is the only line of genuinely new logic in the move. In
|
||||
sports.py these methods found config_schema.json with ``__file__``; here that
|
||||
is src/common/, so the plugin directory has to be recovered from the
|
||||
instance -- and getting it wrong is silent, costing grid-snapped font sizes
|
||||
(measured at 81% anti-aliased edges) rather than raising.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
from abc import ABC
|
||||
|
||||
import pytest
|
||||
|
||||
from src.common import sports_shared
|
||||
from src.common.sports_shared import (
|
||||
SportsCoreSharedMixin, SportsLiveSharedMixin, SportsRecentSharedMixin)
|
||||
|
||||
MIXINS = (SportsCoreSharedMixin, SportsLiveSharedMixin, SportsRecentSharedMixin)
|
||||
|
||||
|
||||
def _constants_read_by_mixins():
|
||||
"""Every ALL-CAPS ``self.X`` the mixin bodies read, found by parsing them."""
|
||||
tree = ast.parse(open(sports_shared.__file__).read())
|
||||
names = set()
|
||||
for node in ast.walk(tree):
|
||||
if (isinstance(node, ast.Attribute)
|
||||
and isinstance(node.value, ast.Name)
|
||||
and node.value.id == "self"
|
||||
and node.attr.upper() == node.attr):
|
||||
names.add(node.attr)
|
||||
return names
|
||||
|
||||
|
||||
class TestHostContract:
|
||||
def test_every_constant_read_is_also_defined(self):
|
||||
# Otherwise a host that does not declare it raises AttributeError the
|
||||
# first time the code path runs -- which for these is mid-render.
|
||||
missing = sorted(
|
||||
name for name in _constants_read_by_mixins()
|
||||
if not any(hasattr(m, name) for m in MIXINS))
|
||||
assert missing == [], (
|
||||
f"read but never defined on a mixin: {missing}. Give each a default "
|
||||
f"on SportsCoreSharedMixin and document it in the module docstring.")
|
||||
|
||||
@pytest.mark.parametrize("name,expected", [
|
||||
("_QUALITY_CHOICES", frozenset({"any", "ranked"})),
|
||||
("_RANKING_COVERAGE_SECONDS", 3600),
|
||||
("_SCORE_PROBE_TEXT", "00-00"),
|
||||
("_FONT_DESIGN_HEIGHT", 32),
|
||||
])
|
||||
def test_defaults_match_what_the_plugins_ship(self, name, expected):
|
||||
# The eight plugins declare their own copies, which shadow these. The
|
||||
# values must still agree, or a ninth plugin inheriting the default
|
||||
# behaves differently from the eight.
|
||||
assert getattr(SportsCoreSharedMixin, name) == expected
|
||||
|
||||
def test_only_the_recent_mixin_carries_a_constructor(self):
|
||||
# SportsCore and SportsLive keep their own __init__ -- those differ per
|
||||
# plugin. SportsRecent.__init__ was one of the 48 byte-identical bodies,
|
||||
# so it moved with the rest; that is deliberate, not an oversight.
|
||||
assert "__init__" not in SportsCoreSharedMixin.__dict__
|
||||
assert "__init__" not in SportsLiveSharedMixin.__dict__
|
||||
assert "__init__" in SportsRecentSharedMixin.__dict__
|
||||
|
||||
def test_the_recent_constructor_still_chains_to_the_host(self):
|
||||
"""Its zero-arg super() binds to where it is DEFINED, not where it is used.
|
||||
|
||||
Moving a body containing bare ``super()`` is the one move that can
|
||||
change meaning: the compiler closes over __class__ = the defining class,
|
||||
so after the move that is SportsRecentSharedMixin rather than the
|
||||
plugin's SportsRecent. It still works only because the mixin is listed
|
||||
first, leaving the host class next in the MRO -- adopt it in the other
|
||||
order and the chain silently skips the host's __init__.
|
||||
"""
|
||||
calls = []
|
||||
|
||||
class Host:
|
||||
def __init__(self, config, display_manager, cache_manager, logger, sport_key):
|
||||
calls.append(sport_key)
|
||||
self.mode_config = {}
|
||||
|
||||
class Recent(SportsRecentSharedMixin, Host):
|
||||
pass
|
||||
|
||||
inst = Recent({}, None, None, None, "nhl")
|
||||
assert calls == ["nhl"], "the host constructor must still run"
|
||||
assert inst.current_game_index == 0
|
||||
assert inst.update_interval == 3600
|
||||
assert inst._zero_clock_timestamps == {}
|
||||
|
||||
def test_adopting_the_recent_mixin_second_would_skip_the_host(self):
|
||||
# The failure mode the ordering above prevents, pinned so nobody
|
||||
# "tidies" the base list.
|
||||
calls = []
|
||||
|
||||
class Host:
|
||||
def __init__(self, *a):
|
||||
calls.append(a)
|
||||
self.mode_config = {}
|
||||
|
||||
class Wrong(Host, SportsRecentSharedMixin):
|
||||
pass
|
||||
|
||||
Wrong({}, None, None, None, "nhl")
|
||||
# Host.__init__ wins and the mixin's setup never runs at all.
|
||||
assert not hasattr(Wrong({}, None, None, None, "nhl"), "current_game_index")
|
||||
|
||||
|
||||
class _Host(SportsCoreSharedMixin):
|
||||
pass
|
||||
|
||||
|
||||
def _write_plugin(tmp_path, name="fakeplug", schema=True):
|
||||
"""A throwaway package on sys.path, with or without a config_schema.json."""
|
||||
d = tmp_path / name
|
||||
d.mkdir()
|
||||
(d / "__init__.py").write_text("")
|
||||
(d / "mod.py").write_text("class Leaf:\n pass\n")
|
||||
if schema:
|
||||
(d / "config_schema.json").write_text(
|
||||
'{"properties": {"customization": {"properties": '
|
||||
'{"score": {"properties": {"font_size": {"default": 16}}}}}}}')
|
||||
return d
|
||||
|
||||
|
||||
class TestPluginDir:
|
||||
def test_it_finds_the_directory_holding_config_schema_json(self, tmp_path, monkeypatch):
|
||||
d = _write_plugin(tmp_path)
|
||||
monkeypatch.syspath_prepend(str(tmp_path))
|
||||
mod = __import__("fakeplug.mod", fromlist=["Leaf"])
|
||||
host = type("H", (mod.Leaf, SportsCoreSharedMixin), {})()
|
||||
assert host._plugin_dir() == str(d)
|
||||
|
||||
def test_a_class_built_by_type_still_resolves(self, tmp_path, monkeypatch):
|
||||
# SportsCore is an ABC, so type(name, bases, ns) reports __module__ as
|
||||
# "abc" rather than the plugin -- which is exactly what the plugins'
|
||||
# own tests build. Walking the MRO is what steps past it.
|
||||
d = _write_plugin(tmp_path, "abcplug")
|
||||
monkeypatch.syspath_prepend(str(tmp_path))
|
||||
mod = __import__("abcplug.mod", fromlist=["Leaf"])
|
||||
|
||||
class Base(SportsCoreSharedMixin, mod.Leaf, ABC):
|
||||
pass
|
||||
|
||||
synthetic = type("Probe", (Base,), {})
|
||||
assert synthetic.__module__ == "abc", "precondition: the trap this guards"
|
||||
assert synthetic.__new__(synthetic)._plugin_dir() == str(d)
|
||||
|
||||
def test_it_returns_none_when_no_schema_is_anywhere_on_the_mro(self, tmp_path, monkeypatch):
|
||||
d = _write_plugin(tmp_path, "noschema", schema=False)
|
||||
monkeypatch.syspath_prepend(str(tmp_path))
|
||||
mod = __import__("noschema.mod", fromlist=["Leaf"])
|
||||
host = type("H", (mod.Leaf, SportsCoreSharedMixin), {})()
|
||||
# None rather than a wrong guess: _schema_font_size then caches empty
|
||||
# and every element keeps its own default.
|
||||
assert host._plugin_dir() is None
|
||||
|
||||
def test_it_never_returns_the_core_module_directory(self):
|
||||
# The bug this replaced: __file__ pointed at src/common/, so the schema
|
||||
# was never found and font sizes silently stopped snapping to the grid.
|
||||
host = _Host()
|
||||
core_common = os.path.dirname(os.path.abspath(sports_shared.__file__))
|
||||
assert host._plugin_dir() != core_common
|
||||
|
||||
def test_a_module_with_no_file_is_skipped_not_crashed_on(self, monkeypatch):
|
||||
# Namespace packages and some frozen/dynamic modules have no __file__.
|
||||
ghost = types.ModuleType("ghost_no_file")
|
||||
if hasattr(ghost, "__file__"):
|
||||
del ghost.__file__
|
||||
monkeypatch.setitem(sys.modules, "ghost_no_file", ghost)
|
||||
cls = type("H", (SportsCoreSharedMixin,), {"__module__": "ghost_no_file"})
|
||||
assert cls.__new__(cls)._plugin_dir() is None
|
||||
|
||||
|
||||
class TestSchemaFontSize:
|
||||
def test_it_reads_the_plugin_schema_not_the_cores(self, tmp_path, monkeypatch):
|
||||
d = _write_plugin(tmp_path, "sizeplug")
|
||||
monkeypatch.syspath_prepend(str(tmp_path))
|
||||
mod = __import__("sizeplug.mod", fromlist=["Leaf"])
|
||||
host = type("H", (mod.Leaf, SportsCoreSharedMixin), {})()
|
||||
assert host._schema_font_size("score") == 16
|
||||
|
||||
def test_an_unknown_element_is_none(self, tmp_path, monkeypatch):
|
||||
_write_plugin(tmp_path, "unkplug")
|
||||
monkeypatch.syspath_prepend(str(tmp_path))
|
||||
mod = __import__("unkplug.mod", fromlist=["Leaf"])
|
||||
host = type("H", (mod.Leaf, SportsCoreSharedMixin), {})()
|
||||
assert host._schema_font_size("nonesuch") is None
|
||||
|
||||
def test_an_empty_key_is_none_without_touching_the_disk(self):
|
||||
assert _Host()._schema_font_size("") is None
|
||||
|
||||
def test_a_missing_schema_degrades_to_none_rather_than_raising(self, tmp_path, monkeypatch):
|
||||
_write_plugin(tmp_path, "bareplug", schema=False)
|
||||
monkeypatch.syspath_prepend(str(tmp_path))
|
||||
mod = __import__("bareplug.mod", fromlist=["Leaf"])
|
||||
host = type("H", (mod.Leaf, SportsCoreSharedMixin), {})()
|
||||
assert host._schema_font_size("score") is None
|
||||
|
||||
|
||||
class _LiveHost(SportsLiveSharedMixin):
|
||||
"""The documented contract for the live mixin, and nothing else."""
|
||||
|
||||
def __init__(self, no_data_interval=300, stale_game_timeout=600, over=()):
|
||||
self.no_data_interval = no_data_interval
|
||||
self.stale_game_timeout = stale_game_timeout
|
||||
self.game_update_timestamps = {}
|
||||
self._over = set(over)
|
||||
|
||||
class _L:
|
||||
def __getattr__(self, _n):
|
||||
return lambda *a, **k: None
|
||||
self.logger = _L()
|
||||
|
||||
def _is_game_really_over(self, game):
|
||||
return game.get("id") in self._over
|
||||
|
||||
|
||||
class TestLiveMixin:
|
||||
"""These three moved to the core, so they are tested here.
|
||||
|
||||
They were already covered by hockey's and lacrosse's own tests, but those
|
||||
two plugins disable live mode in their safety-harness fixtures, so the 176
|
||||
renders never exercise this path. Testing the mixin directly means the
|
||||
coverage no longer depends on which plugin happens to have a unit test.
|
||||
"""
|
||||
|
||||
def test_a_stale_game_is_dropped_and_forgotten(self):
|
||||
h = _LiveHost(stale_game_timeout=600)
|
||||
import time as _t
|
||||
h.game_update_timestamps["g1"] = {"last_seen": _t.time() - 5000}
|
||||
games = [{"id": "g1", "home_abbr": "H", "away_abbr": "A"}]
|
||||
h._detect_stale_games(games)
|
||||
assert games == []
|
||||
assert "g1" not in h.game_update_timestamps, "its timestamp must go too"
|
||||
|
||||
def test_a_fresh_game_survives(self):
|
||||
h = _LiveHost(stale_game_timeout=600)
|
||||
import time as _t
|
||||
h.game_update_timestamps["g1"] = {"last_seen": _t.time() - 5}
|
||||
games = [{"id": "g1"}]
|
||||
h._detect_stale_games(games)
|
||||
assert len(games) == 1
|
||||
|
||||
def test_a_game_never_seen_is_not_treated_as_stale(self):
|
||||
# last_seen 0 means "no reading", not "seen at the epoch".
|
||||
h = _LiveHost()
|
||||
games = [{"id": "g1"}]
|
||||
h._detect_stale_games(games)
|
||||
assert len(games) == 1
|
||||
|
||||
def test_a_game_with_no_id_is_left_alone(self):
|
||||
h = _LiveHost()
|
||||
games = [{"home_abbr": "H"}]
|
||||
h._detect_stale_games(games)
|
||||
assert len(games) == 1
|
||||
|
||||
def test_a_finished_game_is_dropped_even_when_fresh(self):
|
||||
h = _LiveHost(over=("g2",))
|
||||
games = [{"id": "g1"}, {"id": "g2"}]
|
||||
h._detect_stale_games(games)
|
||||
assert [g["id"] for g in games] == ["g1"]
|
||||
|
||||
def test_removing_several_does_not_skip_any(self):
|
||||
# It iterates a copy for exactly this reason; mutating the live list
|
||||
# while looping would step over the element after each removal.
|
||||
h = _LiveHost(over=("g1", "g2", "g3"))
|
||||
games = [{"id": "g1"}, {"id": "g2"}, {"id": "g3"}]
|
||||
h._detect_stale_games(games)
|
||||
assert games == []
|
||||
|
||||
def test_the_idle_interval_escalates_with_the_empty_streak(self):
|
||||
h = _LiveHost(no_data_interval=60)
|
||||
h.live_idle_max_interval = 100000
|
||||
base = h._idle_live_interval()
|
||||
h._empty_live_streak = 6
|
||||
short = h._idle_live_interval()
|
||||
h._empty_live_streak = 24
|
||||
long = h._idle_live_interval()
|
||||
assert base < short < long
|
||||
|
||||
def test_the_ceiling_bounds_even_the_unescalated_interval(self):
|
||||
# base > ceiling is a reachable config: the two settings are
|
||||
# independent integers with no cross-validation. Returning base
|
||||
# unclamped made the wait SHRINK as the streak grew.
|
||||
h = _LiveHost(no_data_interval=3600)
|
||||
h.live_idle_max_interval = 900
|
||||
h._empty_live_streak = 0
|
||||
assert h._idle_live_interval() == 900
|
||||
h._empty_live_streak = 24
|
||||
assert h._idle_live_interval() == 900
|
||||
|
||||
def test_finding_a_live_game_resets_the_streak(self):
|
||||
h = _LiveHost()
|
||||
h._note_live_fetch(False)
|
||||
h._note_live_fetch(False)
|
||||
assert h._empty_live_streak == 2
|
||||
h._note_live_fetch(True)
|
||||
assert h._empty_live_streak == 0
|
||||
|
||||
def test_the_streak_starts_from_absent_state(self):
|
||||
# The host is not required to pre-declare _empty_live_streak.
|
||||
h = _LiveHost()
|
||||
assert not hasattr(h, "_empty_live_streak")
|
||||
h._note_live_fetch(False)
|
||||
assert h._empty_live_streak == 1
|
||||
|
||||
|
||||
class TestPluginDirIsToldNotDeduced:
|
||||
"""The regression that shipped: _plugin_dir returned None in production.
|
||||
|
||||
The first version walked the MRO for a class whose module sits beside a
|
||||
config_schema.json. That passes when a test imports the plugin directly --
|
||||
which is how it was verified -- and returns None under the real plugin
|
||||
loader, which imports modules by a path that leaves no such entry on the
|
||||
MRO.
|
||||
|
||||
Silent, and expensive: no schema means _schema_font_size returns None for
|
||||
every element, so a configured size equal to the schema default stops
|
||||
looking like a default, is treated as a deliberate choice, and skips the
|
||||
grid snap. 4x6-font.ttf then renders at 6 rather than 7 -- 3px-wide glyphs
|
||||
instead of 4px. On a 256x64 panel that made the odds, the records and the
|
||||
date row illegible. A user counted the pixels; no gate here caught it.
|
||||
"""
|
||||
|
||||
def test_a_declared_directory_is_used(self, tmp_path):
|
||||
d = _write_plugin(tmp_path, "declared")
|
||||
host = type("H", (SportsCoreSharedMixin,), {"_PLUGIN_DIR": str(d)})()
|
||||
assert host._plugin_dir() == str(d)
|
||||
|
||||
def test_it_works_when_no_module_on_the_mro_helps(self, tmp_path, monkeypatch):
|
||||
"""The production case: nothing on the MRO sits beside a schema."""
|
||||
d = _write_plugin(tmp_path, "loaderstyle")
|
||||
# A class whose module is not importable by name, as the loader produces.
|
||||
cls = type("Loaded", (SportsCoreSharedMixin,), {"_PLUGIN_DIR": str(d)})
|
||||
cls.__module__ = "a.module.name.that.is.not.in.sys.modules"
|
||||
assert cls.__new__(cls)._plugin_dir() == str(d), (
|
||||
"the declared directory must win when the MRO walk cannot help")
|
||||
|
||||
def test_without_it_the_mro_walk_would_have_failed(self):
|
||||
# Pin the precondition, so this test still means something if the
|
||||
# fallback is ever changed.
|
||||
cls = type("Orphan", (SportsCoreSharedMixin,), {})
|
||||
cls.__module__ = "not.a.real.module"
|
||||
assert cls.__new__(cls)._plugin_dir() is None
|
||||
|
||||
def test_a_declared_directory_without_a_schema_is_not_trusted(self, tmp_path):
|
||||
# A stale or wrong path must not shadow the fallback.
|
||||
empty = tmp_path / "noschema"
|
||||
empty.mkdir()
|
||||
d = _write_plugin(tmp_path, "realone")
|
||||
monkey = type("H", (SportsCoreSharedMixin,), {"_PLUGIN_DIR": str(empty)})
|
||||
assert monkey.__new__(monkey)._plugin_dir() != str(empty)
|
||||
|
||||
def test_the_font_size_consequence(self, tmp_path):
|
||||
"""End to end: a declared directory restores the schema lookup."""
|
||||
d = _write_plugin(tmp_path, "sizeconseq")
|
||||
host = type("H", (SportsCoreSharedMixin,), {"_PLUGIN_DIR": str(d)})()
|
||||
assert host._schema_font_size("score") == 16, (
|
||||
"without the schema this is None, which is what made a configured "
|
||||
"size look user-chosen and skipped the grid snap")
|
||||
@@ -83,11 +83,11 @@ plugin_system_config = config.get('plugin_system', {})
|
||||
plugins_dir_name = plugin_system_config.get('plugins_directory', 'plugin-repos')
|
||||
|
||||
# Resolve plugin directory - handle both absolute and relative paths
|
||||
project_root = Path(__file__).parent.parent
|
||||
if os.path.isabs(plugins_dir_name):
|
||||
plugins_dir = Path(plugins_dir_name)
|
||||
else:
|
||||
# If relative, resolve relative to the project root (LEDMatrix directory)
|
||||
project_root = Path(__file__).parent.parent
|
||||
plugins_dir = project_root / plugins_dir_name
|
||||
|
||||
plugin_manager = PluginManager(
|
||||
@@ -157,6 +157,7 @@ _health_monitor_initialized = False
|
||||
# Register blueprints
|
||||
from web_interface.blueprints.pages_v3 import pages_v3
|
||||
from web_interface.blueprints.api_v3 import api_v3
|
||||
from web_interface.blueprints.composer import composer_bp
|
||||
|
||||
# Initialize managers in blueprints
|
||||
pages_v3.config_manager = config_manager
|
||||
@@ -173,6 +174,11 @@ api_v3.operation_queue = operation_queue
|
||||
api_v3.plugin_state_manager = plugin_state_manager
|
||||
api_v3.operation_history = operation_history
|
||||
api_v3.health_monitor = health_monitor
|
||||
|
||||
composer_bp.config_manager = config_manager
|
||||
composer_bp.plugin_manager = plugin_manager
|
||||
composer_bp.plugins_dir = plugins_dir
|
||||
composer_bp.project_root = project_root
|
||||
# Initialize cache manager for API endpoints
|
||||
from src.cache_manager import CacheManager
|
||||
api_v3.cache_manager = CacheManager()
|
||||
@@ -200,6 +206,7 @@ except Exception as _hm_err: # pragma: no cover - defensive startup guard
|
||||
app.register_blueprint(pages_v3, url_prefix='')
|
||||
app.register_blueprint(pages_v3, url_prefix='/v3', name='pages_v3_legacy')
|
||||
app.register_blueprint(api_v3, url_prefix='/api/v3')
|
||||
app.register_blueprint(composer_bp, url_prefix='/composer')
|
||||
|
||||
# Route to serve plugin asset files (registered on main app, not blueprint, for /assets/... path)
|
||||
@app.route('/assets/plugins/<plugin_id>/uploads/<path:filename>', methods=['GET'])
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -426,6 +426,9 @@ a, button, input, select, textarea {
|
||||
.md\:hidden { display: none; }
|
||||
.md\:block { display: block; }
|
||||
.md\:w-auto { width: auto; }
|
||||
/* composer.html labels its toolbar buttons `hidden md:inline`, so without
|
||||
this the label is hidden at every width and the buttons stay icon-only. */
|
||||
.md\:inline { display: inline; }
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,817 @@
|
||||
/**
|
||||
* ComposerCanvas — stateless LED matrix canvas renderer.
|
||||
*
|
||||
* Coordinate system: LED pixels (integers). All drawing multiplies by SCALE.
|
||||
* PIL draw.text(x,y) is top-left; canvas fillText(x,y) is baseline.
|
||||
* → Canvas text cy = (actualY + fontSizePx) * SCALE
|
||||
*
|
||||
* Anchors: element x/y are offsets from their anchor point:
|
||||
* xAnchor=null/'left' → x is fixed offset from left
|
||||
* xAnchor='center' → x is offset from width/2
|
||||
* xAnchor='right' → x is offset inward from right edge
|
||||
* yAnchor follows the same pattern with 'top'/'middle'/'bottom'
|
||||
*
|
||||
* Breakpoints: elements with minWidth > currentMatrixW are rendered at 25% opacity.
|
||||
*
|
||||
* Resize handles: drawn on selected rectangles; 8 handles (corners + edge mids).
|
||||
*/
|
||||
/* eslint-disable security/detect-object-injection --
|
||||
Every hit in this file is either an array index (this.elements[idx],
|
||||
rawVals[i]) or a lookup on a module-private map keyed by an internal
|
||||
element type (_HANDLE_CURSORS, ELEMENT_DEFAULTS, previewValues). None of
|
||||
them takes an attacker-supplied property name, so none can reach a
|
||||
prototype. Disabled per file rather than eight times inline. */
|
||||
window.ComposerCanvas = (() => {
|
||||
'use strict';
|
||||
|
||||
let _canvas = null;
|
||||
let _ctx = null;
|
||||
let _showGrid = true;
|
||||
|
||||
//: Element types the canvas draws resize handles for. Exported because the
|
||||
//: editor has to gate its resize and hover behaviour on exactly this list --
|
||||
//: the two had drifted, so handles appeared on five shapes that could not
|
||||
//: actually be resized.
|
||||
const RESIZABLE_TYPES = ['rectangle', 'rounded_rectangle', 'ellipse', 'arc',
|
||||
'gauge', 'sparkline'];
|
||||
|
||||
const DISPLAY_PRESETS = [
|
||||
{ label: '64×32', w: 64, h: 32 },
|
||||
{ label: '128×32', w: 128, h: 32 },
|
||||
{ label: '128×64', w: 128, h: 64 },
|
||||
{ label: '256×32', w: 256, h: 32 },
|
||||
{ label: '256×64', w: 256, h: 64 },
|
||||
];
|
||||
|
||||
const FONT_MAP = {
|
||||
press_start: { family: "'PressStart2P', monospace", sizePx: 8, charW: 8 },
|
||||
four_by_six: { family: 'monospace', sizePx: 6, charW: 4 },
|
||||
five_by_seven: { family: 'monospace', sizePx: 7, charW: 5 },
|
||||
};
|
||||
|
||||
const ELEMENT_DEFAULTS = {
|
||||
text: {
|
||||
text: 'Hello', font: 'press_start',
|
||||
r: 255, g: 255, b: 255,
|
||||
text2: '', lineSpacing: 2, textAlign: 'left',
|
||||
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
||||
},
|
||||
dynamic_text: {
|
||||
binding: { source: 'config', key: '', format: null },
|
||||
font: 'press_start', textAlign: 'left',
|
||||
r: 255, g: 200, b: 100,
|
||||
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
||||
},
|
||||
clock: {
|
||||
format: '%H:%M', font: 'press_start',
|
||||
r: 100, g: 255, b: 100,
|
||||
format2: '', lineSpacing: 2, textAlign: 'left',
|
||||
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
||||
},
|
||||
rectangle: {
|
||||
width: 20, height: 8,
|
||||
fillR: 0, fillG: 0, fillB: 128, hasFill: true,
|
||||
outR: 255, outG: 255, outB: 255, hasOutline: true,
|
||||
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
||||
},
|
||||
ellipse: {
|
||||
width: 24, height: 12,
|
||||
fillR: 0, fillG: 100, fillB: 200, hasFill: true,
|
||||
outR: 100, outG: 180, outB: 255, hasOutline: true,
|
||||
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
||||
},
|
||||
arc: {
|
||||
width: 24, height: 24,
|
||||
startAngle: 0, endAngle: 270, lineWidth: 2,
|
||||
r: 255, g: 200, b: 0,
|
||||
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
||||
},
|
||||
pixel: {
|
||||
r: 255, g: 255, b: 255,
|
||||
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
||||
},
|
||||
rounded_rectangle: {
|
||||
width: 24, height: 10, borderRadius: 3,
|
||||
fillR: 0, fillG: 80, fillB: 180, hasFill: true,
|
||||
outR: 120, outG: 180, outB: 255, hasOutline: true,
|
||||
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
||||
},
|
||||
line: {
|
||||
x0: 0, y0: 16, x1: 63, y1: 16,
|
||||
r: 180, g: 180, b: 180, lineWidth: 1,
|
||||
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
||||
},
|
||||
divider: {
|
||||
orientation: 'horizontal', y: 16, x: 64,
|
||||
r: 100, g: 100, b: 100,
|
||||
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
||||
},
|
||||
progress_bar: {
|
||||
barWidth: 60, barHeight: 6,
|
||||
binding: { source: 'config', key: '', format: null },
|
||||
r: 80, g: 200, b: 80,
|
||||
bgR: 30, bgG: 30, bgB: 30, hasBg: true,
|
||||
outR: 100, outG: 100, outB: 100, hasOutline: true,
|
||||
previewPct: 65,
|
||||
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
||||
},
|
||||
countdown: {
|
||||
binding: { source: 'config', key: '', format: null },
|
||||
countdownFormat: 'dh',
|
||||
font: 'four_by_six', textAlign: 'left',
|
||||
r: 255, g: 180, b: 0,
|
||||
previewText: '42d 3h',
|
||||
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
||||
},
|
||||
marquee: {
|
||||
text: 'Scrolling text', font: 'press_start',
|
||||
r: 255, g: 255, b: 255,
|
||||
scrollSpeed: 1, gap: 16, direction: 'left',
|
||||
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
||||
},
|
||||
section: {
|
||||
label: 'Section',
|
||||
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
||||
},
|
||||
pips: {
|
||||
count: 5, filled: 3, pipSize: 4, pipSpacing: 2,
|
||||
r: 255, g: 200, b: 0,
|
||||
emptyR: 50, emptyG: 50, emptyB: 50, showEmpty: true,
|
||||
binding: { source: 'config', key: '', format: null },
|
||||
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
||||
},
|
||||
sparkline: {
|
||||
width: 40, height: 12,
|
||||
barCount: 8, barSpacing: 1,
|
||||
r: 80, g: 200, b: 120,
|
||||
bgR: 30, bgG: 30, bgB: 30, hasBg: false,
|
||||
binding: { source: 'config', key: '', format: null },
|
||||
previewData: '0.3,0.6,0.4,0.8,0.5,0.9,0.7,0.85',
|
||||
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
||||
},
|
||||
gauge: {
|
||||
width: 32, height: 32,
|
||||
startAngle: 135, endAngle: 45, lineWidth: 3,
|
||||
binding: { source: 'config', key: '', format: null },
|
||||
r: 80, g: 220, b: 80,
|
||||
trackR: 40, trackG: 40, trackB: 40, hasTrack: true,
|
||||
showLabel: true, font: 'four_by_six', labelR: 200, labelG: 200, labelB: 200,
|
||||
previewPct: 65,
|
||||
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
||||
},
|
||||
};
|
||||
|
||||
// ── Anchor resolution ────────────────────────────────────────────────
|
||||
function resolveAnchor(val, anchor, dim) {
|
||||
if (!anchor || anchor === 'left' || anchor === 'top') return val;
|
||||
if (anchor === 'center' || anchor === 'middle') return Math.floor(dim / 2) + val;
|
||||
if (anchor === 'right' || anchor === 'bottom') return dim - val;
|
||||
return val;
|
||||
}
|
||||
|
||||
function computeActualPos(el, matrixW, matrixH) {
|
||||
const ax = resolveAnchor(el.x ?? el.x0 ?? 0, el.xAnchor, matrixW);
|
||||
const ay = resolveAnchor(el.y ?? el.y0 ?? 0, el.yAnchor, matrixH);
|
||||
return { x: ax, y: ay };
|
||||
}
|
||||
|
||||
// ── Bounding box (LED pixel space) ──────────────────────────────────
|
||||
function getBoundingBox(el, matrixW, matrixH) {
|
||||
const { x: ax, y: ay } = computeActualPos(el, matrixW, matrixH);
|
||||
const finfo = FONT_MAP[el.font] || FONT_MAP.press_start;
|
||||
|
||||
switch (el.type) {
|
||||
case 'text': {
|
||||
const t1 = el.text || '', t2 = el.text2 || '';
|
||||
const w = Math.max(t1.length, t2.length) * finfo.charW;
|
||||
const h = t2 ? finfo.sizePx * 2 + (el.lineSpacing ?? 2) : finfo.sizePx;
|
||||
const bx = el.textAlign === 'center' ? ax - w / 2 : el.textAlign === 'right' ? ax - w : ax;
|
||||
return { x: bx, y: ay, w, h };
|
||||
}
|
||||
case 'dynamic_text': {
|
||||
const key = el.binding?.key || '?';
|
||||
const w = (`{${key}}`).length * finfo.charW;
|
||||
const bx = el.textAlign === 'center' ? ax - w / 2 : el.textAlign === 'right' ? ax - w : ax;
|
||||
return { x: bx, y: ay, w, h: finfo.sizePx };
|
||||
}
|
||||
case 'clock': {
|
||||
const t1 = el.format || '%H:%M', t2 = el.format2 || '';
|
||||
const w = Math.max(t1.length, t2.length) * finfo.charW;
|
||||
const h = t2 ? finfo.sizePx * 2 + (el.lineSpacing ?? 2) : finfo.sizePx;
|
||||
const bx = el.textAlign === 'center' ? ax - w / 2 : el.textAlign === 'right' ? ax - w : ax;
|
||||
return { x: bx, y: ay, w, h };
|
||||
}
|
||||
case 'countdown': {
|
||||
const pt = el.previewText || '--d --h';
|
||||
const w = pt.length * finfo.charW;
|
||||
const bx = el.textAlign === 'center' ? ax - w / 2 : el.textAlign === 'right' ? ax - w : ax;
|
||||
return { x: bx, y: ay, w, h: finfo.sizePx };
|
||||
}
|
||||
case 'rectangle':
|
||||
case 'rounded_rectangle':
|
||||
case 'ellipse':
|
||||
case 'arc':
|
||||
return { x: ax, y: ay, w: el.width, h: el.height };
|
||||
case 'pixel':
|
||||
return { x: ax, y: ay, w: 1, h: 1 };
|
||||
case 'line': {
|
||||
// Same anchor offset the draw path applies, or the hit box sits where
|
||||
// the line used to be.
|
||||
const dx = ax - el.x0, dy = ay - el.y0;
|
||||
return {
|
||||
x: Math.min(el.x0, el.x1) + dx, y: Math.min(el.y0, el.y1) + dy,
|
||||
w: Math.max(1, Math.abs(el.x1 - el.x0)),
|
||||
h: Math.max(1, Math.abs(el.y1 - el.y0)),
|
||||
};
|
||||
}
|
||||
case 'divider':
|
||||
return el.orientation === 'horizontal'
|
||||
? { x: 0, y: ay, w: matrixW, h: 1 }
|
||||
: { x: ax, y: 0, w: 1, h: matrixH };
|
||||
case 'progress_bar':
|
||||
return { x: ax, y: ay, w: el.barWidth ?? 60, h: el.barHeight ?? 6 };
|
||||
case 'marquee': {
|
||||
const mfinfo = FONT_MAP[el.font] || FONT_MAP.press_start;
|
||||
return { x: 0, y: ay, w: matrixW, h: mfinfo.sizePx };
|
||||
}
|
||||
case 'gauge':
|
||||
return { x: ax, y: ay, w: el.width ?? 32, h: el.height ?? 32 };
|
||||
case 'sparkline':
|
||||
return { x: ax, y: ay, w: el.width ?? 40, h: el.height ?? 12 };
|
||||
case 'pips': {
|
||||
const pc = el.count ?? 5, ps = el.pipSize ?? 4, pg = el.pipSpacing ?? 2;
|
||||
return { x: ax, y: ay, w: pc * ps + (pc - 1) * pg, h: ps };
|
||||
}
|
||||
case 'section': {
|
||||
// Was 0x0, so the element was unselectable except through the 3px
|
||||
// hit-test padding and drew nothing at all -- a user adding one from
|
||||
// the palette saw an empty canvas.
|
||||
// Same font resolution as the draw case below, or the box will not
|
||||
// match the glyphs: getBoundingBox's shared `finfo` falls back to
|
||||
// press_start, and a section has no font of its own.
|
||||
const sinfo = FONT_MAP[el.font] || FONT_MAP.four_by_six;
|
||||
const label = el.label || 'Section';
|
||||
return { x: ax, y: ay, w: label.length * sinfo.charW, h: sinfo.sizePx };
|
||||
}
|
||||
default:
|
||||
return { x: ax, y: ay, w: 4, h: 4 };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Resize handle support ─────────────────────────────────────────────
|
||||
// Returns 8 handle points for a rectangle in LED pixel space
|
||||
function _getRectHandles(el, matrixW, matrixH) {
|
||||
const { x: ax, y: ay } = computeActualPos(el, matrixW, matrixH);
|
||||
const w = el.width, h = el.height;
|
||||
const cx = ax + w / 2, cy = ay + h / 2;
|
||||
return {
|
||||
nw: { x: ax, y: ay },
|
||||
n: { x: cx, y: ay },
|
||||
ne: { x: ax + w, y: ay },
|
||||
w: { x: ax, y: cy },
|
||||
e: { x: ax + w, y: cy },
|
||||
sw: { x: ax, y: ay + h },
|
||||
s: { x: cx, y: ay + h },
|
||||
se: { x: ax + w, y: ay + h },
|
||||
};
|
||||
}
|
||||
|
||||
// Returns the handle direction under LED-space point (lx, ly), or null
|
||||
function getResizeHandle(el, lx, ly, matrixW, matrixH) {
|
||||
if (!RESIZABLE_TYPES.includes(el.type)) return null;
|
||||
const handles = _getRectHandles(el, matrixW, matrixH);
|
||||
const PAD = 4;
|
||||
for (const [dir, pt] of Object.entries(handles)) {
|
||||
if (Math.abs(lx - pt.x) <= PAD && Math.abs(ly - pt.y) <= PAD) return dir;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const _HANDLE_CURSORS = {
|
||||
nw: 'nw-resize', n: 'n-resize', ne: 'ne-resize',
|
||||
w: 'w-resize', e: 'e-resize',
|
||||
sw: 'sw-resize', s: 's-resize', se: 'se-resize',
|
||||
};
|
||||
function getCursorForHandle(handle) {
|
||||
return _HANDLE_CURSORS[handle] || 'crosshair';
|
||||
}
|
||||
|
||||
// ── Hit test ─────────────────────────────────────────────────────────
|
||||
function hitTest(el, lx, ly, matrixW, matrixH) {
|
||||
const PAD = 3;
|
||||
const bb = getBoundingBox(el, matrixW, matrixH);
|
||||
return (
|
||||
lx >= bb.x - PAD && lx <= bb.x + bb.w + PAD &&
|
||||
ly >= bb.y - PAD && ly <= bb.y + bb.h + PAD
|
||||
);
|
||||
}
|
||||
|
||||
// ── Draw a single element ─────────────────────────────────────────────
|
||||
function _drawElement(ctx, el, SCALE, matrixW, matrixH, opts = {}) {
|
||||
const s = SCALE;
|
||||
const { x: ax, y: ay } = computeActualPos(el, matrixW, matrixH);
|
||||
const belowBreakpoint = el.minWidth > 0 && matrixW < el.minWidth;
|
||||
const hidden = el.visible === false;
|
||||
|
||||
ctx.save();
|
||||
if (hidden) ctx.globalAlpha = 0.12;
|
||||
else if (belowBreakpoint) ctx.globalAlpha = 0.25;
|
||||
|
||||
// Blink animation: when blinkOff, fully hide blinking elements
|
||||
if (el.blink) {
|
||||
if (opts.blinkOff) { ctx.restore(); return; }
|
||||
ctx.globalAlpha *= 0.55;
|
||||
}
|
||||
|
||||
// Helper: compute draw X for text alignment
|
||||
// biome-ignore lint/correctness/useQwikValidLexicalScope: not Qwik -- this is an Alpine.js component, and the rule is about Qwik's $() serialization boundary, which does not exist here.
|
||||
const _textX = (text, finfo) => {
|
||||
const tw = text.length * finfo.charW * s;
|
||||
if (el.textAlign === 'center') return ax * s - tw / 2;
|
||||
if (el.textAlign === 'right') return ax * s - tw;
|
||||
return ax * s;
|
||||
};
|
||||
|
||||
try {
|
||||
switch (el.type) {
|
||||
case 'section': {
|
||||
// A design-time label: it marks a region for the author and is not
|
||||
// emitted into the generated plugin. There was no case here at all,
|
||||
// so adding "Section Label" from the palette drew nothing and left
|
||||
// the user with an apparently broken control.
|
||||
const sfinfo = FONT_MAP[el.font] || FONT_MAP.four_by_six;
|
||||
ctx.font = `${sfinfo.sizePx * s}px ${sfinfo.family}`;
|
||||
ctx.fillStyle = `rgba(${el.r ?? 120},${el.g ?? 120},${el.b ?? 120},0.85)`;
|
||||
ctx.textBaseline = 'top';
|
||||
ctx.fillText(el.label || 'Section', ax * s, ay * s);
|
||||
break;
|
||||
}
|
||||
case 'text':
|
||||
case 'dynamic_text':
|
||||
case 'clock': {
|
||||
const finfo = FONT_MAP[el.font] || FONT_MAP.press_start;
|
||||
const key = el.binding?.key || '?';
|
||||
const pv = opts.previewValues?.[key];
|
||||
// Substitute {variable} tokens in text using previewValues
|
||||
// biome-ignore lint/correctness/useQwikValidLexicalScope: not Qwik -- this is an Alpine.js component, and the rule is about Qwik's $() serialization boundary, which does not exist here.
|
||||
const _subVars = str => (str || '').replace(/\{(\w+)\}/g, (_, k) => {
|
||||
const v = opts.previewValues?.[k];
|
||||
return v !== undefined && v !== '' ? String(v) : `{${k}}`;
|
||||
});
|
||||
const displayText =
|
||||
el.type === 'text' ? _subVars(el.text || '')
|
||||
: el.type === 'clock' ? (el.format || '%H:%M')
|
||||
: (pv !== undefined && pv !== '' ? String(pv) : `{${key}}`);
|
||||
ctx.font = `${finfo.sizePx * s}px ${finfo.family}`;
|
||||
ctx.fillStyle = `rgb(${el.r},${el.g},${el.b})`;
|
||||
ctx.fillText(displayText, _textX(displayText, finfo), (ay + finfo.sizePx) * s);
|
||||
// Second line (text and clock)
|
||||
if (el.type === 'text' && el.text2) {
|
||||
const t2 = _subVars(el.text2);
|
||||
const y2 = ay + finfo.sizePx + (el.lineSpacing ?? 2);
|
||||
ctx.fillText(t2, _textX(t2, finfo), (y2 + finfo.sizePx) * s);
|
||||
}
|
||||
if (el.type === 'clock' && el.format2) {
|
||||
const y2 = ay + finfo.sizePx + (el.lineSpacing ?? 2);
|
||||
ctx.fillText(el.format2, _textX(el.format2, finfo), (y2 + finfo.sizePx) * s);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'countdown': {
|
||||
const finfo = FONT_MAP[el.font] || FONT_MAP.press_start;
|
||||
const t = el.previewText || '--d --h';
|
||||
ctx.font = `${finfo.sizePx * s}px ${finfo.family}`;
|
||||
ctx.fillStyle = `rgb(${el.r},${el.g},${el.b})`;
|
||||
ctx.fillText(t, _textX(t, finfo), (ay + finfo.sizePx) * s);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'rectangle': {
|
||||
const rx = ax * s, ry = ay * s;
|
||||
const rw = el.width * s, rh = el.height * s;
|
||||
if (el.hasFill) {
|
||||
ctx.fillStyle = `rgb(${el.fillR},${el.fillG},${el.fillB})`;
|
||||
ctx.fillRect(rx, ry, rw, rh);
|
||||
}
|
||||
if (el.hasOutline) {
|
||||
ctx.strokeStyle = `rgb(${el.outR},${el.outG},${el.outB})`;
|
||||
// 1 LED pixel, not 1 canvas pixel: at SCALE>1 an unscaled stroke
|
||||
// renders thinner than the geometry it outlines.
|
||||
ctx.lineWidth = s;
|
||||
ctx.strokeRect(rx, ry, rw, rh);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'ellipse': {
|
||||
const cx = (ax + el.width / 2) * s;
|
||||
const cy = (ay + el.height / 2) * s;
|
||||
const lwPx = s; // 1 LED pixel
|
||||
// Inset by half the stroke, which straddles the path, so the outline
|
||||
// stays within the element's bounds.
|
||||
const rx = Math.max(0, (el.width / 2) * s - (el.hasOutline ? lwPx / 2 : 0));
|
||||
const ry = Math.max(0, (el.height / 2) * s - (el.hasOutline ? lwPx / 2 : 0));
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2);
|
||||
if (el.hasFill) {
|
||||
ctx.fillStyle = `rgb(${el.fillR},${el.fillG},${el.fillB})`;
|
||||
ctx.fill();
|
||||
}
|
||||
if (el.hasOutline) {
|
||||
ctx.strokeStyle = `rgb(${el.outR},${el.outG},${el.outB})`;
|
||||
ctx.lineWidth = lwPx;
|
||||
ctx.stroke();
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'arc': {
|
||||
const cx = (ax + el.width / 2) * s;
|
||||
const cy = (ay + el.height / 2) * s;
|
||||
const lwPx = Math.max(1, el.lineWidth || 2) * s;
|
||||
const rx = Math.max(0, (el.width / 2) * s - lwPx / 2);
|
||||
const ry = Math.max(0, (el.height / 2) * s - lwPx / 2);
|
||||
// PIL: 0°=right, clockwise. Canvas: same with anticlockwise=false
|
||||
const startRad = (el.startAngle ?? 0) * Math.PI / 180;
|
||||
const endRad = (el.endAngle ?? 270) * Math.PI / 180;
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(cx, cy, rx, ry, 0, startRad, endRad, false);
|
||||
ctx.strokeStyle = `rgb(${el.r},${el.g},${el.b})`;
|
||||
ctx.lineWidth = lwPx;
|
||||
ctx.stroke();
|
||||
break;
|
||||
}
|
||||
|
||||
case 'pixel': {
|
||||
ctx.fillStyle = `rgb(${el.r},${el.g},${el.b})`;
|
||||
ctx.fillRect(ax * s, ay * s, s, s);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'rounded_rectangle': {
|
||||
const rx = ax * s, ry = ay * s;
|
||||
const rw = el.width * s, rh = el.height * s;
|
||||
const rad = Math.min((el.borderRadius ?? 3) * s, rw / 2, rh / 2);
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(rx, ry, rw, rh, rad);
|
||||
if (el.hasFill) {
|
||||
ctx.fillStyle = `rgb(${el.fillR},${el.fillG},${el.fillB})`;
|
||||
ctx.fill();
|
||||
}
|
||||
if (el.hasOutline) {
|
||||
ctx.strokeStyle = `rgb(${el.outR},${el.outG},${el.outB})`;
|
||||
ctx.lineWidth = s;
|
||||
ctx.stroke();
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'line': {
|
||||
// ax/ay resolve from el.x0 for a line, so this is the anchor offset.
|
||||
// Without it a line stayed put while every other type moved.
|
||||
const dx = ax - el.x0, dy = ay - el.y0;
|
||||
ctx.strokeStyle = `rgb(${el.r},${el.g},${el.b})`;
|
||||
ctx.lineWidth = Math.max(1, el.lineWidth || 1) * s;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo((el.x0 + dx) * s, (el.y0 + dy) * s);
|
||||
ctx.lineTo((el.x1 + dx) * s, (el.y1 + dy) * s);
|
||||
ctx.stroke();
|
||||
break;
|
||||
}
|
||||
|
||||
case 'divider': {
|
||||
const isH = (el.orientation || 'horizontal') === 'horizontal';
|
||||
ctx.strokeStyle = `rgb(${el.r},${el.g},${el.b})`;
|
||||
ctx.lineWidth = s;
|
||||
ctx.beginPath();
|
||||
if (isH) {
|
||||
ctx.moveTo(0, (ay + 0.5) * s);
|
||||
ctx.lineTo(_canvas.width, (ay + 0.5) * s);
|
||||
} else {
|
||||
ctx.moveTo((ax + 0.5) * s, 0);
|
||||
ctx.lineTo((ax + 0.5) * s, _canvas.height);
|
||||
}
|
||||
ctx.stroke();
|
||||
break;
|
||||
}
|
||||
|
||||
case 'pips': {
|
||||
const pipCount = Math.max(1, el.count ?? 5);
|
||||
const pvPips = opts.previewValues?.[el.binding?.key];
|
||||
const filledN = pvPips !== undefined
|
||||
? Math.max(0, Math.min(pipCount, Math.round(parseFloat(pvPips) || 0)))
|
||||
: Math.max(0, Math.min(pipCount, el.filled ?? 3));
|
||||
const ps = Math.max(1, el.pipSize ?? 4);
|
||||
const pg = Math.max(0, el.pipSpacing ?? 2);
|
||||
for (let i = 0; i < pipCount; i++) {
|
||||
const isFilled = i < filledN;
|
||||
if (!isFilled && !el.showEmpty) continue;
|
||||
ctx.fillStyle = isFilled
|
||||
? `rgb(${el.r},${el.g},${el.b})`
|
||||
: `rgb(${el.emptyR ?? 50},${el.emptyG ?? 50},${el.emptyB ?? 50})`;
|
||||
ctx.fillRect((ax + i * (ps + pg)) * s, ay * s, ps * s, ps * s);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'sparkline': {
|
||||
const slW = el.width ?? 40, slH = el.height ?? 12;
|
||||
const count = Math.max(1, el.barCount ?? 8);
|
||||
const spacing = el.barSpacing ?? 1;
|
||||
const barW = Math.max(1, Math.floor((slW - spacing * (count - 1)) / count));
|
||||
const rawVals = (el.previewData || '').split(',')
|
||||
.map(v => parseFloat(v.trim())).filter(n => !isNaN(n));
|
||||
while (rawVals.length < count) rawVals.push(0);
|
||||
const maxV = Math.max(...rawVals.slice(0, count), 0.001);
|
||||
const rx = ax * s, ry = ay * s;
|
||||
if (el.hasBg) {
|
||||
ctx.fillStyle = `rgb(${el.bgR ?? 30},${el.bgG ?? 30},${el.bgB ?? 30})`;
|
||||
ctx.fillRect(rx, ry, slW * s, slH * s);
|
||||
}
|
||||
ctx.fillStyle = `rgb(${el.r},${el.g},${el.b})`;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const norm = Math.max(0, Math.min(1, rawVals[i] / maxV));
|
||||
const barH = Math.max(1, Math.round(slH * norm));
|
||||
const bx = rx + (barW + spacing) * i * s;
|
||||
const by = ry + (slH - barH) * s;
|
||||
ctx.fillRect(bx, by, barW * s, barH * s);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'gauge': {
|
||||
const gw = (el.width ?? 32), gh = (el.height ?? 32);
|
||||
const cx = (ax + gw / 2) * s, cy = (ay + gh / 2) * s;
|
||||
const rx = (gw / 2) * s, ry = (gh / 2) * s;
|
||||
const lw = Math.max(1, (el.lineWidth ?? 3));
|
||||
// rx/ry are canvas pixels ((gw/2)*s) but lw is LED pixels, so
|
||||
// insetting by lw/2 under-corrected by the scale factor while the
|
||||
// stroke was drawn at lw*s -- the arc spilled outside the element's
|
||||
// reported bounding box at any SCALE > 1, and the preview stopped
|
||||
// matching the generated PIL output.
|
||||
const lwPx = lw * s;
|
||||
const startDeg = el.startAngle ?? 135;
|
||||
const endDeg = el.endAngle ?? 45;
|
||||
// Arc sweep: from startDeg clockwise to endDeg (PIL convention)
|
||||
const totalSweep = ((endDeg - startDeg) + 360) % 360 || 360;
|
||||
const pvGauge = opts.previewValues?.[el.binding?.key];
|
||||
const pct = pvGauge !== undefined
|
||||
? Math.max(0, Math.min(100, parseFloat(pvGauge) || 0)) / 100
|
||||
: Math.max(0, Math.min(100, el.previewPct ?? 65)) / 100;
|
||||
const fillSweep = totalSweep * pct;
|
||||
// A small imported gauge with a wide lineWidth would otherwise send
|
||||
// a negative radius into ctx.ellipse(), which throws IndexSizeError
|
||||
// and aborts the whole render() call.
|
||||
const arx = Math.max(0, rx - lwPx / 2), ary = Math.max(0, ry - lwPx / 2);
|
||||
// biome-ignore lint/correctness/useQwikValidLexicalScope: not Qwik -- this is an Alpine.js component, and the rule is about Qwik's $() serialization boundary, which does not exist here.
|
||||
const toRad = deg => (deg - 90) * Math.PI / 180; // canvas 0=top, PIL 0=right → offset -90
|
||||
|
||||
// Track arc
|
||||
if (el.hasTrack !== false) {
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(cx, cy, arx, ary, 0, toRad(startDeg), toRad(startDeg + totalSweep), false);
|
||||
ctx.strokeStyle = `rgb(${el.trackR ?? 40},${el.trackG ?? 40},${el.trackB ?? 40})`;
|
||||
ctx.lineWidth = lwPx;
|
||||
ctx.stroke();
|
||||
}
|
||||
// Fill arc
|
||||
if (pct > 0) {
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(cx, cy, arx, ary, 0, toRad(startDeg), toRad(startDeg + fillSweep), false);
|
||||
ctx.strokeStyle = `rgb(${el.r},${el.g},${el.b})`;
|
||||
ctx.lineWidth = lwPx;
|
||||
ctx.stroke();
|
||||
}
|
||||
// Centre label
|
||||
if (el.showLabel) {
|
||||
const gfinfo = FONT_MAP[el.font || 'four_by_six'] || FONT_MAP.four_by_six;
|
||||
const labelText = Math.round(pct * 100) + '%';
|
||||
ctx.font = `${gfinfo.sizePx * s}px ${gfinfo.family}`;
|
||||
ctx.fillStyle = `rgb(${el.labelR ?? 200},${el.labelG ?? 200},${el.labelB ?? 200})`;
|
||||
const ltw = ctx.measureText(labelText).width;
|
||||
ctx.fillText(labelText, cx - ltw / 2, cy + (gfinfo.sizePx * s) / 2);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'marquee': {
|
||||
const finfo = FONT_MAP[el.font] || FONT_MAP.press_start;
|
||||
const text = el.text || 'Scrolling text';
|
||||
const tw = text.length * finfo.charW * s;
|
||||
const gap = (el.gap ?? 16) * s;
|
||||
const totalW = tw + gap;
|
||||
const tick = opts.animTick ?? 0;
|
||||
const speed = (el.scrollSpeed ?? 1) * 2;
|
||||
const scrolled = (tick * speed) % totalW;
|
||||
// left: text enters from right; right: text enters from left
|
||||
const startX = el.direction === 'right'
|
||||
? scrolled - tw
|
||||
: matrixW * s - scrolled;
|
||||
ctx.font = `${finfo.sizePx * s}px ${finfo.family}`;
|
||||
ctx.fillStyle = `rgb(${el.r},${el.g},${el.b})`;
|
||||
// Clip to canvas width so text doesn't bleed outside
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.rect(0, ay * s - 1, matrixW * s, (finfo.sizePx + 2) * s);
|
||||
ctx.clip();
|
||||
for (let i = -1; i <= 2; i++) {
|
||||
ctx.fillText(text, startX + i * totalW, (ay + finfo.sizePx) * s);
|
||||
}
|
||||
ctx.restore();
|
||||
break;
|
||||
}
|
||||
|
||||
case 'progress_bar': {
|
||||
const bw = el.barWidth ?? 60, bh = el.barHeight ?? 6;
|
||||
const pvPb = opts.previewValues?.[el.binding?.key];
|
||||
const pct = pvPb !== undefined
|
||||
? Math.max(0, Math.min(100, parseFloat(pvPb) || 0)) / 100
|
||||
: Math.max(0, Math.min(100, el.previewPct ?? 65)) / 100;
|
||||
const rx = ax * s, ry = ay * s;
|
||||
if (el.hasBg) {
|
||||
ctx.fillStyle = `rgb(${el.bgR ?? 30},${el.bgG ?? 30},${el.bgB ?? 30})`;
|
||||
ctx.fillRect(rx, ry, bw * s, bh * s);
|
||||
}
|
||||
const fillW = Math.max(0, Math.round(bw * pct));
|
||||
if (fillW > 0) {
|
||||
ctx.fillStyle = `rgb(${el.r},${el.g},${el.b})`;
|
||||
ctx.fillRect(rx, ry, fillW * s, bh * s);
|
||||
}
|
||||
if (el.hasOutline) {
|
||||
ctx.strokeStyle = `rgb(${el.outR ?? 100},${el.outG ?? 100},${el.outB ?? 100})`;
|
||||
ctx.lineWidth = s;
|
||||
ctx.strokeRect(rx, ry, bw * s, bh * s);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (belowBreakpoint) {
|
||||
ctx.globalAlpha = 0.6;
|
||||
const bb = getBoundingBox(el, matrixW, matrixH);
|
||||
ctx.font = `${Math.max(8, s * 2)}px monospace`;
|
||||
ctx.fillStyle = '#facc15';
|
||||
ctx.fillText(`≥${el.minWidth}px`, bb.x * s, (bb.y + 4) * s);
|
||||
}
|
||||
} finally {
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Selection indicator ──────────────────────────────────────────────
|
||||
function _drawSelection(ctx, el, SCALE, matrixW, matrixH) {
|
||||
const bb = getBoundingBox(el, matrixW, matrixH);
|
||||
const PAD = 2, s = SCALE;
|
||||
const rx = bb.x * s - PAD, ry = bb.y * s - PAD;
|
||||
const rw = bb.w * s + PAD * 2, rh = bb.h * s + PAD * 2;
|
||||
|
||||
ctx.save();
|
||||
ctx.strokeStyle = '#3b82f6';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.setLineDash([3, 2]);
|
||||
ctx.strokeRect(rx, ry, rw, rh);
|
||||
ctx.setLineDash([]);
|
||||
|
||||
if (el.xAnchor || el.yAnchor) {
|
||||
ctx.font = `${Math.max(7, s)}px sans-serif`;
|
||||
ctx.fillStyle = '#a78bfa';
|
||||
const anchorText = [
|
||||
el.xAnchor ? `x:${el.xAnchor[0]}` : '',
|
||||
el.yAnchor ? `y:${el.yAnchor[0]}` : '',
|
||||
].filter(Boolean).join(' ');
|
||||
if (anchorText) ctx.fillText(anchorText, rx + 1, ry - 2);
|
||||
}
|
||||
|
||||
// Resize handles: on rect, rounded rect, ellipse
|
||||
if (RESIZABLE_TYPES.includes(el.type)) {
|
||||
const handles = _getRectHandles(el, matrixW, matrixH);
|
||||
const HS = 5;
|
||||
ctx.fillStyle = 'white';
|
||||
ctx.strokeStyle = '#2563eb';
|
||||
ctx.lineWidth = 1;
|
||||
for (const pt of Object.values(handles)) {
|
||||
const hx = pt.x * s - HS / 2;
|
||||
const hy = pt.y * s - HS / 2;
|
||||
ctx.fillRect(hx, hy, HS, HS);
|
||||
ctx.strokeRect(hx, hy, HS, HS);
|
||||
}
|
||||
} else {
|
||||
// Corner dots for non-rectangle elements
|
||||
ctx.fillStyle = '#3b82f6';
|
||||
const HS = 4;
|
||||
for (const [hx, hy] of [
|
||||
[rx - HS / 2, ry - HS / 2], [rx + rw - HS / 2, ry - HS / 2],
|
||||
[rx - HS / 2, ry + rh - HS / 2], [rx + rw - HS / 2, ry + rh - HS / 2],
|
||||
]) ctx.fillRect(hx, hy, HS, HS);
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// ── Dimension tooltip while dragging ─────────────────────────────────
|
||||
function drawDragTooltip(ctx, el, SCALE, matrixW, matrixH) {
|
||||
const bb = getBoundingBox(el, matrixW, matrixH);
|
||||
const label = el.type === 'rectangle'
|
||||
? `${el.width}×${el.height}`
|
||||
: `${bb.x},${bb.y}`;
|
||||
const s = SCALE;
|
||||
ctx.save();
|
||||
ctx.font = `${Math.max(9, s * 1.5)}px monospace`;
|
||||
const tw = ctx.measureText(label).width;
|
||||
const tx = bb.x * s, ty = (bb.y - 2) * s;
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.7)';
|
||||
ctx.fillRect(tx - 2, ty - 10, tw + 4, 12);
|
||||
ctx.fillStyle = 'white';
|
||||
ctx.fillText(label, tx, ty);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// ── Public API ───────────────────────────────────────────────────────
|
||||
|
||||
function init(canvasEl) {
|
||||
_canvas = canvasEl;
|
||||
_ctx = canvasEl.getContext('2d');
|
||||
}
|
||||
|
||||
function setGrid(show) { _showGrid = show; }
|
||||
|
||||
function updateCanvasSize(matrixW, matrixH, SCALE) {
|
||||
if (!_canvas) return;
|
||||
_canvas.width = matrixW * SCALE;
|
||||
_canvas.height = matrixH * SCALE;
|
||||
}
|
||||
|
||||
function render(elements, selectedId, matrixW, matrixH, SCALE, opts = {}) {
|
||||
if (!_ctx) return;
|
||||
const cW = matrixW * SCALE, cH = matrixH * SCALE;
|
||||
|
||||
const bg = opts.bgColor;
|
||||
_ctx.fillStyle = bg ? `rgb(${bg.r},${bg.g},${bg.b})` : '#000';
|
||||
_ctx.fillRect(0, 0, cW, cH);
|
||||
|
||||
if (_showGrid) {
|
||||
_ctx.strokeStyle = 'rgba(255,255,255,0.07)';
|
||||
_ctx.lineWidth = 0.5;
|
||||
for (let x = SCALE; x < cW; x += SCALE) {
|
||||
_ctx.beginPath(); _ctx.moveTo(x, 0); _ctx.lineTo(x, cH); _ctx.stroke();
|
||||
}
|
||||
for (let y = SCALE; y < cH; y += SCALE) {
|
||||
_ctx.beginPath(); _ctx.moveTo(0, y); _ctx.lineTo(cW, y); _ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
for (const el of elements) _drawElement(_ctx, el, SCALE, matrixW, matrixH, opts);
|
||||
|
||||
if (opts.showRuler) {
|
||||
_ctx.save();
|
||||
_ctx.fillStyle = 'rgba(255,255,255,0.08)';
|
||||
_ctx.fillRect(0, 0, cW, SCALE); // top strip
|
||||
_ctx.fillRect(0, 0, SCALE, cH); // left strip
|
||||
_ctx.strokeStyle = 'rgba(255,255,255,0.5)';
|
||||
_ctx.fillStyle = 'rgba(255,255,255,0.6)';
|
||||
_ctx.font = `${Math.max(5, SCALE - 1)}px monospace`;
|
||||
const step = SCALE >= 4 ? 8 : 16;
|
||||
for (let px = 0; px <= matrixW; px += step) {
|
||||
const cx = px * SCALE;
|
||||
const major = px % 32 === 0;
|
||||
_ctx.lineWidth = 0.5;
|
||||
_ctx.beginPath(); _ctx.moveTo(cx, 0); _ctx.lineTo(cx, major ? SCALE : SCALE * 0.5); _ctx.stroke();
|
||||
if (major && px > 0 && px < matrixW - 4) _ctx.fillText(String(px), cx + 1, SCALE - 1);
|
||||
}
|
||||
for (let py = 0; py <= matrixH; py += step) {
|
||||
const cy = py * SCALE;
|
||||
const major = py % 32 === 0;
|
||||
_ctx.beginPath(); _ctx.moveTo(0, cy); _ctx.lineTo(major ? SCALE : SCALE * 0.5, cy); _ctx.stroke();
|
||||
if (major && py > 0 && py < matrixH - 4) _ctx.fillText(String(py), 1, cy + SCALE - 1);
|
||||
}
|
||||
_ctx.restore();
|
||||
}
|
||||
|
||||
if (opts.showGuides) {
|
||||
_ctx.save();
|
||||
_ctx.strokeStyle = 'rgba(255,60,60,0.45)';
|
||||
_ctx.lineWidth = 1;
|
||||
_ctx.setLineDash([4, 3]);
|
||||
const mx = Math.floor(cW / 2) + 0.5;
|
||||
const my = Math.floor(cH / 2) + 0.5;
|
||||
_ctx.beginPath(); _ctx.moveTo(mx, 0); _ctx.lineTo(mx, cH); _ctx.stroke();
|
||||
_ctx.beginPath(); _ctx.moveTo(0, my); _ctx.lineTo(cW, my); _ctx.stroke();
|
||||
_ctx.setLineDash([]);
|
||||
_ctx.restore();
|
||||
}
|
||||
|
||||
const sel = selectedId != null ? elements.find(e => e.id === selectedId) : null;
|
||||
if (sel) {
|
||||
_drawSelection(_ctx, sel, SCALE, matrixW, matrixH);
|
||||
if (opts.showTooltip) drawDragTooltip(_ctx, sel, SCALE, matrixW, matrixH);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
init, render, setGrid, updateCanvasSize,
|
||||
hitTest, getBoundingBox, computeActualPos, resolveAnchor,
|
||||
getResizeHandle, getCursorForHandle,
|
||||
ELEMENT_DEFAULTS, FONT_MAP, DISPLAY_PRESETS, RESIZABLE_TYPES,
|
||||
};
|
||||
})();
|
||||
@@ -1164,9 +1164,16 @@
|
||||
// The pull replaced files on disk; the running services still
|
||||
// hold the code they loaded at boot. Ask for the restart that
|
||||
// makes the update actually take effect.
|
||||
//
|
||||
// Both services, not just the display. The plugin store's
|
||||
// compatibility gate runs in the web process, so a web service
|
||||
// still holding the previous version refuses every plugin that
|
||||
// floors on the release just installed -- blaming a core
|
||||
// version that is already correct on disk.
|
||||
if (data.restart_required && typeof window.showRestartPending === 'function') {
|
||||
window.showRestartPending(
|
||||
'Update installed \u2014 restart the display to run the new code');
|
||||
'Update installed \u2014 restart the display and web ' +
|
||||
'services to run the new code');
|
||||
}
|
||||
}
|
||||
if (typeof showNotification === 'function') {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,302 @@
|
||||
"""
|
||||
{{ plugin_name }} — LEDMatrix Plugin
|
||||
Generated by LEDMatrix Plugin Composer on {{ generated_date }}
|
||||
|
||||
Extension points:
|
||||
update() → add HTTP/MQTT data-fetching logic here
|
||||
_get_display_values() → map fetched data to display strings
|
||||
display() → add new elements or adapt layout per display size
|
||||
"""
|
||||
from src.plugin_system.base_plugin import BasePlugin
|
||||
{% if has_clock or has_countdown %}
|
||||
from datetime import datetime
|
||||
{% endif %}
|
||||
{% if has_blink %}
|
||||
import time
|
||||
{% endif %}
|
||||
{% if has_text_template %}
|
||||
from collections import defaultdict
|
||||
{% endif %}
|
||||
|
||||
|
||||
class {{ class_name }}(BasePlugin):
|
||||
|
||||
def __init__(self, plugin_id, config, display_manager, cache_manager, plugin_manager):
|
||||
super().__init__(plugin_id, config, display_manager, cache_manager, plugin_manager)
|
||||
{% for var in config_vars %}
|
||||
self.{{ var.key }} = config.get({{ var.key | tojson }}, {{ var.default | tojson }})
|
||||
{% endfor %}
|
||||
# Live data cache — populated by update(); always {} in static layouts
|
||||
self._data = {}
|
||||
|
||||
def update(self):
|
||||
"""Fetch and refresh display data.
|
||||
|
||||
For dynamic plugins: fetch from APIs/MQTT here and store in self._data.
|
||||
_get_display_values() will read self._data to produce display strings.
|
||||
"""
|
||||
# --- Data sources (add fetch logic here for dynamic plugins) ---
|
||||
pass
|
||||
|
||||
def _get_display_values(self):
|
||||
"""Map config variables and live data to display-ready strings.
|
||||
|
||||
This is the single extension point for v2 data sources:
|
||||
add self._data lookups here once update() populates them.
|
||||
"""
|
||||
return {
|
||||
{% for var in config_vars %}
|
||||
{{ var.key | tojson }}: str(self.{{ var.key }}),
|
||||
{% endfor %}
|
||||
}
|
||||
|
||||
def display(self, force_clear=False):
|
||||
try:
|
||||
{% if has_text_template %}
|
||||
values = defaultdict(str, self._get_display_values())
|
||||
{% else %}
|
||||
values = self._get_display_values()
|
||||
{% endif %}
|
||||
|
||||
if force_clear:
|
||||
self.display_manager.clear()
|
||||
|
||||
width = self.display_manager.width
|
||||
height = self.display_manager.height
|
||||
{% if bg_color %}
|
||||
|
||||
self.display_manager.draw.rectangle([0, 0, width, height], fill={{ bg_color }})
|
||||
{% endif %}
|
||||
|
||||
# ── Elements (rendered bottom to top) ──────────────────────────
|
||||
{% for el in elements %}
|
||||
{% set p = " " if el.min_width > 0 else " " %}
|
||||
{% set pi = (p + " ") if el.blink else p %}
|
||||
{% if el.min_width > 0 %}
|
||||
if width >= {{ el.min_width }}: # breakpoint: {{ el.min_width }}px+ displays only
|
||||
{% endif %}
|
||||
{% if el.blink %}
|
||||
{{ p }}if int(time.time() * 2) % 2:
|
||||
{% endif %}
|
||||
|
||||
{% if el.type == 'text' %}
|
||||
{{ pi }}self.display_manager.draw_text(
|
||||
{% if el.text_is_template %}
|
||||
{{ pi }} {{ el.text | tojson }}.format_map(values),
|
||||
{% else %}
|
||||
{{ pi }} {{ el.text | tojson }},
|
||||
{% endif %}
|
||||
{{ pi }} x={{ el.x_expr }}, y={{ el.y_expr }},
|
||||
{{ pi }} color={{ el.rgb_tuple }},
|
||||
{{ pi }} font=self.display_manager.{{ el.font_attr }},
|
||||
{{ pi }})
|
||||
{% if el.text2 %}
|
||||
{{ pi }}self.display_manager.draw_text(
|
||||
{% if el.text_is_template %}
|
||||
{{ pi }} {{ el.text2 | tojson }}.format_map(values),
|
||||
{% else %}
|
||||
{{ pi }} {{ el.text2 | tojson }},
|
||||
{% endif %}
|
||||
{{ pi }} x={{ el.x2_expr }}, y={{ el.y2_expr }},
|
||||
{{ pi }} color={{ el.rgb_tuple }},
|
||||
{{ pi }} font=self.display_manager.{{ el.font_attr }},
|
||||
{{ pi }})
|
||||
{% endif %}
|
||||
{% elif el.type == 'dynamic_text' %}
|
||||
{% if el.binding_source == 'config' %}
|
||||
{{ pi }}self.display_manager.draw_text(
|
||||
{{ pi }} values.get({{ el.binding_key | tojson }}, ''),
|
||||
{{ pi }} x={{ el.x_expr }}, y={{ el.y_expr }},
|
||||
{{ pi }} color={{ el.rgb_tuple }},
|
||||
{{ pi }} font=self.display_manager.{{ el.font_attr }},
|
||||
{{ pi }})
|
||||
{% else %}
|
||||
{{ pi }}pass # non-config dynamic_text binding draws nothing
|
||||
{% endif %}
|
||||
{% elif el.type == 'clock' %}
|
||||
{{ pi }}self.display_manager.draw_text(
|
||||
{{ pi }} datetime.now().strftime({{ el.format | tojson }}),
|
||||
{{ pi }} x={{ el.x_expr }}, y={{ el.y_expr }},
|
||||
{{ pi }} color={{ el.rgb_tuple }},
|
||||
{{ pi }} font=self.display_manager.{{ el.font_attr }},
|
||||
{{ pi }})
|
||||
{% if el.format2 %}
|
||||
{{ pi }}self.display_manager.draw_text(
|
||||
{{ pi }} datetime.now().strftime({{ el.format2 | tojson }}),
|
||||
{{ pi }} x={{ el.x2_expr }}, y={{ el.y2_expr }},
|
||||
{{ pi }} color={{ el.rgb_tuple }},
|
||||
{{ pi }} font=self.display_manager.{{ el.font_attr }},
|
||||
{{ pi }})
|
||||
{% endif %}
|
||||
{% elif el.type == 'countdown' %}
|
||||
{{ pi }}_cd_target = float(values.get({{ el.binding_key | tojson }}, 0) or 0)
|
||||
{{ pi }}_cd_secs = max(0.0, _cd_target - datetime.now().timestamp())
|
||||
{% if el.countdown_format == 'dhms' %}
|
||||
{{ pi }}_cd_d, _cd_rem = divmod(int(_cd_secs), 86400)
|
||||
{{ pi }}_cd_h, _cd_rem = divmod(_cd_rem, 3600)
|
||||
{{ pi }}_cd_m, _cd_s = divmod(_cd_rem, 60)
|
||||
{{ pi }}_cd_str = f'{_cd_d}d {_cd_h:02d}:{_cd_m:02d}:{_cd_s:02d}'
|
||||
{% elif el.countdown_format == 'hms' %}
|
||||
{{ pi }}_cd_h, _cd_rem = divmod(int(_cd_secs), 3600)
|
||||
{{ pi }}_cd_m, _cd_s = divmod(_cd_rem, 60)
|
||||
{{ pi }}_cd_str = f'{_cd_h}h {_cd_m:02d}:{_cd_s:02d}'
|
||||
{% elif el.countdown_format == 'dhm' %}
|
||||
{{ pi }}_cd_d, _cd_rem = divmod(int(_cd_secs), 86400)
|
||||
{{ pi }}_cd_h, _cd_m = divmod(_cd_rem // 60, 60)
|
||||
{{ pi }}_cd_str = f'{_cd_d}d {_cd_h:02d}h {_cd_m:02d}m'
|
||||
{% else %}
|
||||
{{ pi }}_cd_d, _cd_rem = divmod(int(_cd_secs), 86400)
|
||||
{{ pi }}_cd_h = _cd_rem // 3600
|
||||
{{ pi }}_cd_str = f'{_cd_d}d {_cd_h}h'
|
||||
{% endif %}
|
||||
{{ pi }}self.display_manager.draw_text(
|
||||
{{ pi }} _cd_str,
|
||||
{{ pi }} x={{ el.x_expr }}, y={{ el.y_expr }},
|
||||
{{ pi }} color={{ el.rgb_tuple }},
|
||||
{{ pi }} font=self.display_manager.{{ el.font_attr }},
|
||||
{{ pi }})
|
||||
{% elif el.type == 'rectangle' %}
|
||||
{{ pi }}self.display_manager.draw.rectangle(
|
||||
{{ pi }} [{{ el.x_expr }}, {{ el.y_expr }}, {{ el.x2_expr }}, {{ el.y2_expr }}],
|
||||
{{ pi }} fill={{ el.fill_tuple }},
|
||||
{{ pi }} outline={{ el.outline_tuple }},
|
||||
{{ pi }})
|
||||
{% elif el.type == 'arc' %}
|
||||
{{ pi }}self.display_manager.draw.arc(
|
||||
{{ pi }} [{{ el.x_expr }}, {{ el.y_expr }}, {{ el.x2_expr }}, {{ el.y2_expr }}],
|
||||
{{ pi }} start={{ el.start_angle }}, end={{ el.end_angle }},
|
||||
{{ pi }} fill={{ el.rgb_tuple }},
|
||||
{{ pi }} width={{ el.line_width }},
|
||||
{{ pi }})
|
||||
{% elif el.type == 'ellipse' %}
|
||||
{{ pi }}self.display_manager.draw.ellipse(
|
||||
{{ pi }} [{{ el.x_expr }}, {{ el.y_expr }}, {{ el.x2_expr }}, {{ el.y2_expr }}],
|
||||
{{ pi }} fill={{ el.fill_tuple }},
|
||||
{{ pi }} outline={{ el.outline_tuple }},
|
||||
{{ pi }})
|
||||
{% elif el.type == 'pixel' %}
|
||||
{{ pi }}self.display_manager.draw.point(
|
||||
{{ pi }} [{{ el.x_expr }}, {{ el.y_expr }}],
|
||||
{{ pi }} fill={{ el.rgb_tuple }},
|
||||
{{ pi }})
|
||||
{% elif el.type == 'rounded_rectangle' %}
|
||||
{{ pi }}self.display_manager.draw.rounded_rectangle(
|
||||
{{ pi }} [{{ el.x_expr }}, {{ el.y_expr }}, {{ el.x2_expr }}, {{ el.y2_expr }}],
|
||||
{{ pi }} radius={{ el.border_radius }},
|
||||
{{ pi }} fill={{ el.fill_tuple }},
|
||||
{{ pi }} outline={{ el.outline_tuple }},
|
||||
{{ pi }})
|
||||
{% elif el.type in ('line', 'divider') %}
|
||||
{{ pi }}self.display_manager.draw.line(
|
||||
{{ pi }} [{{ el.x0_expr }}, {{ el.y0_expr }}, {{ el.x1_expr }}, {{ el.y1_expr }}],
|
||||
{{ pi }} fill={{ el.rgb_tuple }},
|
||||
{{ pi }} width={{ el.line_width }},
|
||||
{{ pi }})
|
||||
{% elif el.type == 'pips' %}
|
||||
{{ pi }}_pip_filled = max(0, min({{ el.pip_count }}, int(float(values.get({{ el.binding_key | tojson }}, 0) or 0))))
|
||||
{{ pi }}for _pip_i in range({{ el.pip_count }}):
|
||||
{{ pi }} _pip_x = ({{ el.x_expr }}) + _pip_i * ({{ el.pip_size }} + {{ el.pip_spacing }})
|
||||
{{ pi }} _pip_color = {{ el.fill_tuple }} if _pip_i < _pip_filled else {{ el.empty_tuple }}
|
||||
{% if not el.show_empty %}
|
||||
{{ pi }} if _pip_i >= _pip_filled:
|
||||
{{ pi }} continue
|
||||
{% endif %}
|
||||
{{ pi }} self.display_manager.draw.rectangle(
|
||||
{{ pi }} [_pip_x, {{ el.y_expr }}, _pip_x + {{ el.pip_size }} - 1, ({{ el.y_expr }}) + {{ el.pip_size }} - 1],
|
||||
{{ pi }} fill=_pip_color,
|
||||
{{ pi }} )
|
||||
{% elif el.type == 'sparkline' %}
|
||||
{{ pi }}_sl_raw = str(values.get({{ el.binding_key | tojson }}, '') or '')
|
||||
{{ pi }}_sl_vals = [float(v.strip()) for v in _sl_raw.split(',') if v.strip()][:{{ el.bar_count }}]
|
||||
{{ pi }}_sl_vals += [0.0] * max(0, {{ el.bar_count }} - len(_sl_vals))
|
||||
{{ pi }}_sl_max = max(_sl_vals) if any(_sl_vals) else 1.0
|
||||
{{ pi }}_sl_bw = max(1, ({{ el.bar_width_px }} - {{ el.bar_spacing }} * ({{ el.bar_count }} - 1)) // {{ el.bar_count }})
|
||||
{% if el.bg_tuple != 'None' %}
|
||||
{{ pi }}self.display_manager.draw.rectangle(
|
||||
{{ pi }} [{{ el.x_expr }}, {{ el.y_expr }}, ({{ el.x_expr }}) + {{ el.bar_width_px }}, ({{ el.y_expr }}) + {{ el.bar_height_px }}],
|
||||
{{ pi }} fill={{ el.bg_tuple }},
|
||||
{{ pi }})
|
||||
{% endif %}
|
||||
{{ pi }}for _sl_i, _sl_v in enumerate(_sl_vals):
|
||||
{{ pi }} _sl_norm = max(0.0, min(1.0, _sl_v / (_sl_max or 1)))
|
||||
{{ pi }} _sl_bh = max(1, round({{ el.bar_height_px }} * _sl_norm))
|
||||
{{ pi }} _sl_bx = ({{ el.x_expr }}) + (_sl_bw + {{ el.bar_spacing }}) * _sl_i
|
||||
{{ pi }} _sl_by = ({{ el.y_expr }}) + {{ el.bar_height_px }} - _sl_bh
|
||||
{{ pi }} self.display_manager.draw.rectangle(
|
||||
{{ pi }} [_sl_bx, _sl_by, _sl_bx + _sl_bw - 1, _sl_by + _sl_bh - 1],
|
||||
{{ pi }} fill={{ el.fill_tuple }},
|
||||
{{ pi }} )
|
||||
{% elif el.type == 'gauge' %}
|
||||
{{ pi }}_gv = max(0.0, min(100.0, float(values.get({{ el.binding_key | tojson }}, 0) or 0)))
|
||||
{{ pi }}_g_total = (({{ el.end_angle }} - {{ el.start_angle }}) % 360) or 360
|
||||
{{ pi }}_g_sweep = _g_total * _gv / 100.0
|
||||
{% if el.track_tuple != 'None' %}
|
||||
{{ pi }}self.display_manager.draw.arc(
|
||||
{{ pi }} [{{ el.x_expr }}, {{ el.y_expr }}, {{ el.x2_expr }}, {{ el.y2_expr }}],
|
||||
{{ pi }} start={{ el.start_angle }}, end={{ el.start_angle }} + _g_total,
|
||||
{{ pi }} fill={{ el.track_tuple }},
|
||||
{{ pi }} width={{ el.line_width }},
|
||||
{{ pi }})
|
||||
{% endif %}
|
||||
{{ pi }}if _g_sweep > 0:
|
||||
{{ pi }} self.display_manager.draw.arc(
|
||||
{{ pi }} [{{ el.x_expr }}, {{ el.y_expr }}, {{ el.x2_expr }}, {{ el.y2_expr }}],
|
||||
{{ pi }} start={{ el.start_angle }}, end={{ el.start_angle }} + _g_sweep,
|
||||
{{ pi }} fill={{ el.rgb_tuple }},
|
||||
{{ pi }} width={{ el.line_width }},
|
||||
{{ pi }} )
|
||||
{% if el.show_label %}
|
||||
{{ pi }}_g_cx = ({{ el.x_expr }}) + ({{ el.x2_expr }} - ({{ el.x_expr }})) // 2
|
||||
{{ pi }}_g_cy = ({{ el.y_expr }}) + ({{ el.y2_expr }} - ({{ el.y_expr }})) // 2
|
||||
{{ pi }}self.display_manager.draw_text(
|
||||
{{ pi }} f'{int(_gv)}%',
|
||||
{{ pi }} x=_g_cx, y=_g_cy,
|
||||
{{ pi }} color={{ el.label_tuple }},
|
||||
{{ pi }} font=self.display_manager.{{ el.font_attr }},
|
||||
{{ pi }})
|
||||
{% endif %}
|
||||
{% elif el.type == 'marquee' %}
|
||||
{{ pi }}_{{ el.data_key }}_text = {{ el.text | tojson }}
|
||||
{{ pi }}_{{ el.data_key }}_tw = len(_{{ el.data_key }}_text) * {{ el.char_w }}
|
||||
{{ pi }}_{{ el.data_key }}_x = int(self._data.get({{ el.data_key | tojson }}, width))
|
||||
{% if el.direction == 'right' %}
|
||||
{{ pi }}_{{ el.data_key }}_x += {{ el.scroll_speed }}
|
||||
{{ pi }}if _{{ el.data_key }}_x > width:
|
||||
{{ pi }} _{{ el.data_key }}_x = -(_{{ el.data_key }}_tw + {{ el.gap }})
|
||||
{% else %}
|
||||
{{ pi }}_{{ el.data_key }}_x -= {{ el.scroll_speed }}
|
||||
{{ pi }}if _{{ el.data_key }}_x < -(_{{ el.data_key }}_tw + {{ el.gap }}):
|
||||
{{ pi }} _{{ el.data_key }}_x = width
|
||||
{% endif %}
|
||||
{{ pi }}self._data[{{ el.data_key | tojson }}] = _{{ el.data_key }}_x
|
||||
{{ pi }}self.display_manager.draw_text(
|
||||
{{ pi }} _{{ el.data_key }}_text,
|
||||
{{ pi }} x=_{{ el.data_key }}_x, y={{ el.y_expr }},
|
||||
{{ pi }} color={{ el.rgb_tuple }},
|
||||
{{ pi }} font=self.display_manager.{{ el.font_attr }},
|
||||
{{ pi }})
|
||||
{% elif el.type == 'progress_bar' %}
|
||||
{{ pi }}_pb_x = {{ el.x_expr }}
|
||||
{{ pi }}_pb_y = {{ el.y_expr }}
|
||||
{{ pi }}_pb_pct = max(0.0, min(100.0, float(values.get({{ el.binding_key | tojson }}, 0) or 0))) / 100.0
|
||||
{{ pi }}_pb_fill_w = int({{ el.bar_width }} * _pb_pct)
|
||||
{{ pi }}self.display_manager.draw.rectangle(
|
||||
{{ pi }} [_pb_x, _pb_y, _pb_x + {{ el.bar_width }}, _pb_y + {{ el.bar_height }}],
|
||||
{{ pi }} fill={{ el.bg_tuple }},
|
||||
{{ pi }} outline={{ el.outline_tuple }},
|
||||
{{ pi }})
|
||||
{{ pi }}if _pb_fill_w > 0:
|
||||
{{ pi }} self.display_manager.draw.rectangle(
|
||||
{{ pi }} [_pb_x, _pb_y, _pb_x + _pb_fill_w, _pb_y + {{ el.bar_height }}],
|
||||
{{ pi }} fill={{ el.fill_tuple }},
|
||||
{{ pi }} )
|
||||
{% else %}
|
||||
{{ pi }}pass # element type "{{ el.type }}" draws nothing
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
# ── End elements ───────────────────────────────────────────────
|
||||
|
||||
self.display_manager.update_display()
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error('Display error: %s', e, exc_info=True)
|
||||
Reference in New Issue
Block a user