Compare commits

...
Author SHA1 Message Date
ChuckandClaude Opus 5.5 e745ae8060 feat(display): cap malloc arenas in-process and malloc_trim between screens (#774)
* feat(display): cap malloc arenas in-process and malloc_trim between screens

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: malloc_tuning with ctypes mocked; add to the mypy ratchet

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(changelog): malloc arena cap and malloc_trim between screens

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(changelog): spacing

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 10:47:29 -04:00
ChuckandClaude Opus 5.5 a669d781f5 chore: prepare the 3.8.1 release (#756)
* chore: prepare the 3.8.1 release

Bumps src.__version__ to 3.8.1 and moves the Unreleased CHANGELOG entries
under a 3.8.1 heading, with an empty Unreleased above it.

The reason for the release is smooth scrolling at held-frame speeds. On
3.8.0 the default 50 px/s snapped to a stepped 48 px/s on a 120 Hz panel,
and any scroll slower than one pixel per refresh showed a half-pixel step
across the middle of the panel. Both are fixed on main (#710, #711) but
were in no release, so every stable-channel device still had them.

- #710's CHANGELOG entry had been filed under 3.8.0 although it merged
  after the v3.8.0 tag; it moves to 3.8.1.
- #711 had no CHANGELOG entry; it gets one.

scripts/check_release_version.py v3.8.1 passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: sports_game_over and draw_text_outlined ship in 3.8.1

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 10:11:17 -04:00
ChuckandClaude Opus 5.5 c20c0beac2 feat(web): the Display tab is an ES-module page, with a page-visibility service (stage 4) (#771)
* feat(web): the Display tab is an ES-module page, with a page-visibility service (stage 4)

- core/visibility.js: each page gets ctx.visibility (whileVisible, every,
  isVisible). Work registered there runs only while the page's tab is
  active and the browser tab visible, and ends when the page is swapped
  out. It reads the active tab from window.LEDVisibility, so it agrees
  with the classic partials. The registry gained a mountContext option for
  per-mount services.
- pages/display.js replaces display.html's two inline scripts. The 5 s
  sync status poll runs through ctx.visibility.every; the status and
  scroll-speed hint requests go through ctx.api with ctx.signal, as does
  the Vegas order widget's plugin-list request. The Advanced toggle is a
  delegated data-action; window.updateSyncUI is a deprecated alias.
- New DOM suites test_visibility_service.js and test_display_page.js;
  test_display_partial_ids.js imports the module.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(web): no computed keys in the Display page's readout and destroy

Codacy's object-injection rule flagged v[id] and ctx.state[name].

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:53:43 -04:00
ChuckandClaude Opus 5.5 6fb2dc3595 fix(sports): honour every pending kickoff in the idle back-off, not just the first (#772)
_note_scheduled_start_candidate kept one kickoff. While it was inside its
15-minute grace every later kickoff was refused, and by the time the grace
ended the later one had passed and was refused again as already past. So of
two favourites kicking off within 15 minutes of each other, the second lost
its own grace: if the first game was not live by then (a rain delay, a
postponement, ESPN slow to flip it) and ESPN had not flipped the second
either, the back-off went straight back to its ceiling and the second game
was noticed up to that late.

Later kickoffs now wait in a short queue (_later_scheduled_starts, the
earliest 8). When the current kickoff's grace ends, the earliest queued one
still inside its own grace takes over -- including one that has already
passed. A kickoff still holds the live cadence for at most its own grace, so
a postponed game costs the same quarter of an hour as before, and
_next_scheduled_start_ts keeps its meaning for anything that reads or sets
it. The promotion is a module function, so the mixin's method set is
unchanged.

Table tests replay the idle loop on a fake clock over kickoff schedules;
mutation-checked (the old code fails 10 of the new tests).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:52:22 -04:00
ChuckandClaude Opus 5.5 b638b91169 feat(common): sports_game_over -- the reconciled game-over check (sports family 5) (#770)
* feat(common): sports_game_over -- the reconciled game-over check (sports family 5)

New hardware-free module src/common/sports_game_over.py with
SportsGameOverMixin._is_game_really_over, the scoreboards' SportsLive check
that drops a game ESPN still lists as live, copied from ledmatrix-plugins
claude/family5-reconcile once the nine copies (five bodies) became one.

Over on a final period text; from period FINAL_PERIOD on, also on a 0:00
clock string unless the score is level (a tie at the end of regulation goes
to overtime; a game that ends tied ends on its final status). FINAL_PERIOD
is the one per-sport seam, a class attribute defaulting to None (the clock
never ends a game); the scoreboards declare 3 (hockey), 4 (basketball,
football, lacrosse) or None (afl, nrl, soccer, baseball, ufc).

- test/test_sports_game_over.py: the plugins' pinned matrix folded to the
  three FINAL_PERIOD values, edge shapes, the tie guard, ufc's recorded
  ESPN MMA states, an override deferring through super() (baseball), the
  base order with SportsLiveSharedMixin._detect_stale_games, host contract.
- test/test_sports_game_over_parity.py: with LEDMATRIX_PLUGINS, compares the
  body with every plugin copy (drift-report normalisation plus decorators)
  and each plugin's FINAL_PERIOD with the owner's decision.
- mypy ratchet, src/common/README.md, CHANGELOG (Unreleased, New modules).
- docs/SPORTS_UNIFICATION.md: family 5 status and decisions; the seam
  tables now match the code (FINAL_PERIOD defaults to None; the
  CLOCK_COUNTS_DOWN seam never existed and is gone from the doc).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: cite ledmatrix-plugins #625 for the family 5 reconcile

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:36:12 -04:00
ChuckandClaude Opus 5.5 bb475a79ea fix(plugins): keep a plugin's tokens and local files across store updates (#755)
* fix(plugins): keep a plugin's tokens and local files across store updates

A monorepo plugin update replaces the plugin directory with the fresh
download and deletes the old copy, taking with it everything the plugin
wrote beside itself. On 2026-10-04 updating calendar 1.2.9 -> 1.2.12 deleted
token.pickle and credentials.json, and the calendar stopped until they were
restored from a backup.

Before the set-aside copy is discarded (store update, reinstall over an
existing copy, install_from_url replace), carry over files the plugin's
.gitignore excludes plus known secret/state files (*.pickle, token.json,
credentials.json, config_secrets.json, .pkce_code_verifier). Files the new
release ships win; byte code and .git are not carried; if a copy fails the
old copy is kept.

The git-pull path no longer sweeps untracked tokens into its auto-stash,
which is never popped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(plugins): find the new copy via _existing_install, as install_plugin does

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(on-demand): find the write under test by key, not by position

TestARestoreWithNothingToResume took the last cache_manager.set call to be
the on-demand state, but the controller's font-usage publisher thread writes
font_usage_snapshot to the same mock, and on a slow runner it lands last.
Failing on main since #748 (Python 3.11 job). Same fix for the named-mode
restart test, which had the same race.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(starlark): fake only the editor launch, not every Popen in the request

TestPixletEditorHostDefaultsButDoesNotOverride patched subprocess.Popen for
the whole request. When the captive-portal before_request hook's 30s AP-mode
cache had expired, its `systemctl is-active hostapd` check went through
subprocess.run, got the fake process, and raised TypeError (run() uses the
process as a context manager): a 500 instead of 200. Seen on the Python 3.13
job; reproduced locally by forcing the cache to expire. Other calls now reach
the real Popen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:06:56 -04:00
32 changed files with 2923 additions and 427 deletions
+122 -12
View File
@@ -19,6 +19,93 @@ accepts both, but the store flags the old spelling as deprecated
## Unreleased ## Unreleased
### The display hands freed memory back to the OS
The display process's resident memory climbed in steps for hours while the
data it held stayed flat: glibc keeps what Python frees in per-thread malloc
arenas and returns little of it. `src/malloc_tuning.py` (new, standard library
only, a no-op off Linux/glibc) does two things in-process, so it reaches
devices without re-running the installer:
- **Arena cap at start-up.** `run.py` calls `mallopt(M_ARENA_MAX, 2)` before any
thread exists, the same cap as the unit's `Environment=MALLOC_ARENA_MAX=2`.
Units installed before that line never got it (systemd runs the copy in
`/etc/systemd/system`); a `MALLOC_ARENA_MAX` in the environment still wins.
- **`malloc_trim(0)` between screens**, at most every 5 minutes, from the top of
the render loop where no frame is being drawn. Measured on a Pi 4: 2-11 ms
per call.
On ledpi (Pi 4, 192x48, Vegas on, nine plugins, a unit without
`MALLOC_ARENA_MAX`), alternated main / branch / branch / main arms of 2.5 h:
two hours in, resident memory was 551 MB on main (the second main arm was
already at 651 MB after 1 h 44 min) against 412 and 386 MB with this change,
and the 20-minute frame soaks came out at 0.147-0.165% late against main's
0.151-0.188%.
## 3.8.1
Smooth scrolling at the slower speeds, and the fixes and performance work
since 3.8.0. Highlights: the default 50 px/s and every other held-frame speed
now scroll cleanly (below), Raspberry Pi OS Bookworm is supported alongside
Trixie, updates refresh the systemd units, the display control socket gains
stages 2 and 3, the shared fetch service lands (stages 1 and 2), and a run of
web UI and Plugin Manager fixes. One new module is for plugins:
`src.common.sports_game_over` (sports family 5), which the scoreboards adopt
by flooring on 3.8.1; the other new modules are core-internal and set no
`ledmatrix_min_version` floor.
### Scroll speed
These two entries were the reason for this release: on 3.8.0 a slow scroll
either stepped or showed a half-pixel tear across the middle of the panel,
so only speeds of one pixel per refresh looked right.
- The Vegas Scroll Speed slider now says what the panel will do with the speed
it is on, and offers the nearest smooth ones to click. Only speeds that advance
a whole number of pixels per refresh look smooth, and which those are depends
on the panel (`GET /api/v3/config/scroll-speed-advice`, built on
`scroll_config.speed_advice()`; it uses the refresh the display measured, not
the `limit_refresh_rate_hz` cap). The slider steps by 1 px/s instead of 5.
- The default 50 px/s no longer snaps to a stepped 48 px/s (2 px every 5
refreshes, 24 fps) on a 120 Hz panel: `solve_crisp()` now prefers 60 or 40 px/s,
which move one pixel at a time. 100 Hz panels are unaffected. (#710)
- A held-frame scroll (one pixel every two or more refreshes, such as 50 or
60 px/s on a 100-120 Hz panel) no longer shows a half-pixel step across the
middle of the panel. Scan-order compensation ran only at one frame per
refresh; a held frame is now presented as a sequence of swaps
(`scan_order.refresh_plan()`), so the half of the panel that scans later
steps one refresh after the rest. It is skipped when a blit takes more than
half a refresh, since the second blit has to land before the next vsync.
(#711)
### Web UI: the Display tab is an ES-module page, with a page-visibility service (stage 4)
- New `static/v3/js/core/visibility.js`: each page module gets
`ctx.visibility` with `whileVisible(start, stop)`, `every(ms, fn)` and
`isVisible()`. Work registered there runs only while the page's tab is the
active tab and the browser tab is visible, and ends when the page is
swapped out, with no teardown code in the page. It reads the active tab
from `window.LEDVisibility`, so it agrees with the classic partials that
still use that directly. The page registry gained a `mountContext` option
for services bound to one mounted page.
- The Display tab's inline scripts are now `static/v3/js/pages/display.js`.
The partial has no inline script, `onclick` or `onchange` any more. The
multi-display sync status poll (every 5 s) runs through
`ctx.visibility.every`; the status and scroll-speed hint requests go
through `core/api.js` with the page's abort signal, and so does the Vegas
order widget's plugin-list request.
- Behaviour differences: with sync on, opening the tab asks for the status
once instead of twice, and a Display tab loaded while not on screen waits
until it is. A login redirect during a poll no longer flashes "Sync status
unavailable". The `window.syncStatusInterval` timer id is gone (nothing
read it). A pending scroll-hint request or widget retry is dropped when
the partial is swapped out.
- `window.updateSyncUI` keeps working as a deprecated alias through
`window.LEDMatrix` (one console warning).
- New suites `test/js/dom/test_visibility_service.js` and
`dom/test_display_page.js`; `unit/test_display_partial_ids.js` imports the
module instead of slicing the template.
### Plugins ask for the screen in-process: `request_on_demand()` / `end_on_demand()` ### Plugins ask for the screen in-process: `request_on_demand()` / `end_on_demand()`
The in-process way in that stage 5 of the control socket needed The in-process way in that stage 5 of the control socket needed
@@ -194,6 +281,20 @@ Internal; no behaviour change. Stage 3 of `docs/RUN_LOOP_REDESIGN.md`.
(`refresh_registry_in_background()`, backing off for a minute after an (`refresh_registry_in_background()`, backing off for a minute after an
offline failure), so a later load has them. The store, install and update offline failure), so a later load has them. The store, install and update
paths still fetch as before. paths still fetch as before.
- A sports live manager's idle back-off now honours every pending kickoff,
not just the first. `_note_scheduled_start_candidate()` kept one kickoff
and, while it was inside its 15-minute grace, refused every later one; by
the time the grace ended the later one had passed and was refused again.
So of two favourites kicking off within 15 minutes of each other, the
second lost its own grace: if the first game was not live by then (a rain
delay, a postponement, ESPN slow to flip it) and ESPN had not flipped the
second either, the back-off went back to its ceiling and the second game
was noticed up to the ceiling (15 minutes by default) late. Later
kickoffs now wait in a short queue (`_later_scheduled_starts`, the
earliest 8) and each takes over with a grace of its own when the one
before it expires. A kickoff still
holds the live cadence for at most its own grace, so a postponed game
costs the same quarter of an hour as before.
### ESPN date-range fetches: fewer requests, fewer at once ### ESPN date-range fetches: fewer requests, fewer at once
@@ -668,6 +769,16 @@ policies are unchanged.
- `src/display_arbiter.py` -- the display loop's Arbiter (see Tooling). - `src/display_arbiter.py` -- the display loop's Arbiter (see Tooling).
Core-internal: plugins have no reason to import it, so it sets no Core-internal: plugins have no reason to import it, so it sets no
`ledmatrix_min_version` floor. `ledmatrix_min_version` floor.
- `src/common/sports_game_over.py` -- `SportsGameOverMixin`, sports
consolidation family 5: `_is_game_really_over`, the scoreboards'
`SportsLive` check that drops a game ESPN still lists as live, once the
plugins made their five bodies one. Over on a final period text, or on a
0:00 clock from period `FINAL_PERIOD` on unless the score is level (a tie
at the end of regulation goes to overtime). `FINAL_PERIOD` is the per-sport
class attribute, `None` by default (the clock never ends a game); the
scoreboards declare 3 (hockey), 4 (basketball, football, lacrosse) or
`None`. List the mixin before `SportsLiveSharedMixin`. A plugin may import
it once it floors on 3.8.1, and deletes its copy then. (#770)
### Tooling ### Tooling
@@ -1321,18 +1432,6 @@ guard the import, since the loader's version check is advisory).
processes, or turns the socket off with `off`. A non-root dev run uses a processes, or turns the socket off with `off`. A non-root dev run uses a
private per-user path under the temp directory. private per-user path under the temp directory.
### Scroll speed
- The Vegas Scroll Speed slider now says what the panel will do with the speed
it is on, and offers the nearest smooth ones to click. Only speeds that advance
a whole number of pixels per refresh look smooth, and which those are depends
on the panel (`GET /api/v3/config/scroll-speed-advice`, built on
`scroll_config.speed_advice()`; it uses the refresh the display measured, not
the `limit_refresh_rate_hz` cap). The slider steps by 1 px/s instead of 5.
- The default 50 px/s no longer snaps to a stepped 48 px/s (2 px every 5
refreshes, 24 fps) on a 120 Hz panel: `solve_crisp()` now prefers 60 or 40 px/s,
which move one pixel at a time. 100 Hz panels are unaffected.
### Update channels ### Update channels
- Devices no longer pick up every merge to `main`. A new setting, - Devices no longer pick up every merge to `main`. A new setting,
@@ -1590,6 +1689,17 @@ read any of them:
### Fixes ### Fixes
- Updating a plugin from the store no longer deletes the files it wrote
beside itself. A monorepo update replaces the plugin directory with the
fresh download and deletes the old copy, so calendar's Google OAuth files
(`token.pickle`, `credentials.json`) were lost on every update and the
calendar stopped until they were restored by hand. Before the old copy is
removed, the update now copies over anything the plugin's `.gitignore`
excludes plus known secret/state files (`*.pickle`, `token.json`,
`credentials.json`, `config_secrets.json`, `.pkce_code_verifier`); files the
new release ships are never overwritten, and byte code is not carried. A
plugin updated with `git pull` no longer sweeps an untracked token into the
auto-stash, which is never popped (`src/plugin_system/plugin_local_files.py`).
- Quieter routine logging. Every rotation logged each mode twice - Quieter routine logging. Every rotation logged each mode twice
("Switching to mode", then "Processing mode"), and a mode with nothing to ("Switching to mode", then "Processing mode"), and a mode with nothing to
show added "display() returned False" and "No content to display". Those show added "display() returned False" and "No content to display". Those
+36 -19
View File
@@ -91,6 +91,7 @@ more. Shared sports code lives in `src/common`:
| `sports_live_scroll.py` | next release | `SportsLiveScrollMixin` — rebuild a live scroll strip mid-cycle, keeping the marquee's place | | `sports_live_scroll.py` | next release | `SportsLiveScrollMixin` — rebuild a live scroll strip mid-cycle, keeping the marquee's place |
| `sports_display_rules.py` | next release | `SportsCardOptionsMixin`, `SportsGameRulesMixin` — scorebug date options, the no-favourites filter, non-favourite live dwell | | `sports_display_rules.py` | next release | `SportsCardOptionsMixin`, `SportsGameRulesMixin` — scorebug date options, the no-favourites filter, non-favourite live dwell |
| `sports_font_path.py` | next release | `resolve_font_path` — what the plugins' `_resolve_font_path` copies return | | `sports_font_path.py` | next release | `resolve_font_path` — what the plugins' `_resolve_font_path` copies return |
| `sports_game_over.py` | 3.8.1 | `SportsGameOverMixin` — `_is_game_really_over`, with the `FINAL_PERIOD` seam (family 5) |
Each is described in [src/common/README.md](../src/common/README.md). Each is described in [src/common/README.md](../src/common/README.md).
@@ -134,8 +135,7 @@ constants rather than behavior:
| Attribute | Meaning | Default | | Attribute | Meaning | Default |
|---|---|---| |---|---|---|
| `FINAL_PERIOD` | Period at/after which a zero clock can mean "over" | `4` (hockey overrides to `3`) | | `FINAL_PERIOD` | Period from which a 0:00 clock ends a game (`sports_game_over`) | `None`: the clock never ends a game (afl, nrl, soccer, baseball, ufc). Hockey sets `3`; basketball, football and lacrosse `4` |
| `CLOCK_COUNTS_DOWN` | Whether `0:00` means "expired" | `True` (soccer/afl/nrl override to `False` — their clocks count up, so `0:00` is kickoff) |
| `COALESCE_SCORING_SEQUENCE` | Fold score increments arriving during an active celebration into that one celebration | `False` (football overrides to `True` — a touchdown lands as +6, then +1 for the extra point) | | `COALESCE_SCORING_SEQUENCE` | Fold score increments arriving during an active celebration into that one celebration | `False` (football overrides to `True` — a touchdown lands as +6, then +1 for the extra point) |
### Why these are seams and not branches ### Why these are seams and not branches
@@ -146,11 +146,14 @@ so NRL matches favorites on team ID. Flattening every plugin to abbreviations
would silently select the wrong club for NRL users. The base declares the seam, would silently select the wrong club for NRL users. The base declares the seam,
NRL fills it, and core never learns the string `"nrl"`. NRL fills it, and core never learns the string `"nrl"`.
`CLOCK_COUNTS_DOWN` exists for the same reason in the opposite direction: a `FINAL_PERIOD` exists for the same reason in the opposite direction: a
soccer clock reading `0:00` means the match has not kicked off, so running the soccer clock reading `0:00` means the match has not kicked off, so running the
clock-expiry branch there would evict live games. clock-expiry rule there would evict live games. Those sports declare `None`,
and so do baseball (innings, not a clock) and ufc (a bout ends only on ESPN's
final status). One attribute covers both questions, whether the clock can end
a game and from which period, so no separate count-down flag was added.
`COALESCE_SCORING_SEQUENCE` is the third of the same kind. In football one `COALESCE_SCORING_SEQUENCE` is another of the same kind. In football one
scoring play arrives as two score updates, so the follow-up must be folded into scoring play arrives as two score updates, so the follow-up must be folded into
the first celebration; in soccer two increments a few seconds apart are two real the first celebration; in soccer two increments a few seconds apart are two real
goals, and folding them would swallow one. Neither default is "right" — which is goals, and folding them would swallow one. Neither default is "right" — which is
@@ -298,6 +301,20 @@ Left in the plugins, though identical:
renderers) is already core's, in `SportsHelpersMixin`; a renderer that renderers) is already core's, in `SportsHelpersMixin`; a renderer that
wants it can inherit that. wants it can inherit that.
### Family 5: the game-over check (core done; adoption waits for a release)
The pilot of the method below. ledmatrix-plugins `scripts/test_game_over_check.py`
(#621) pinned 3,115 answers across the nine plugins first; the reconcile
(ledmatrix-plugins #625) made the five bodies one and
changed only the cells the owner's decisions under
[Product decisions](#product-decisions-each-family-needs) explain: ufc's
clock rule (65 cells), baseball's dormant one (53, every one a game with a
`period` baseball's games never carry), and a level score at 0:00 (five
cells in hockey, basketball, football and lacrosse). The harness renders
were pixel-identical. `src/common/sports_game_over.py` holds the body;
`test/test_sports_game_over_parity.py` compares it, and each plugin's
`FINAL_PERIOD`, with the plugin copies.
### Why the method changes ### Why the method changes
Byte-identical promotion has nearly run dry. Measured on ledmatrix-plugins Byte-identical promotion has nearly run dry. Measured on ledmatrix-plugins
@@ -333,8 +350,8 @@ game-over check); the report measures each method in it. The procedure:
line in each plugin. line in each plugin.
- *A per-sport fact* (hockey ends in period 3; a soccer clock counts up). - *A per-sport fact* (hockey ends in period 3; a soccer clock counts up).
Make it a declared class constant or override point with a default, as Make it a declared class constant or override point with a default, as
`FINAL_PERIOD`, `CLOCK_COUNTS_DOWN`, `COALESCE_SCORING_SEQUENCE` and `FINAL_PERIOD`, `COALESCE_SCORING_SEQUENCE` and `_favorite_key` are,
`_favorite_key` are, and add it to the tables above. Never a sport-name and add it to the tables above. Never a sport-name
branch: core must not learn sport names. branch: core must not learn sport names.
- *A product difference*: anything a user can see (which games show, a - *A product difference*: anything a user can see (which games show, a
colour, a date, a badge, how long a screen stays). The owner picks the colour, a date, a badge, how long a screen stays). The owner picks the
@@ -382,7 +399,7 @@ release.
| # | Family | Methods (variants) | Why here | | # | Family | Methods (variants) | Why here |
|---|---|---|---| |---|---|---|---|
| 4 | Identical sweep | `manager.py`: `_dispatch_switch_refresh`, `_favorite_team_is_live`, `get_vegas_priority_weight`, `_game_involves`, `_favorite_scan_targets`, `_favorite_scan_games`, `_get_total_games_for_manager` (all nine, 1); the live-scroll helpers `_preserving_scroll_position`, `_refresh_live_scroll_managers`, `_live_scroll_managers`, `_note_live_scroll_built`, `_live_scroll_needs_rebuild`, `_live_scroll_fields` (eight, 1). `sports.py`: `_card_option`, `_filtered_or_all`, `_effective_live_duration`, `_recent_date_text` (eight, 1). 58 identical families in all | Nothing to decide; brings `manager.py` into core as a `SportsPluginHostMixin`. `_resolve_font_path` (identical in nine `sports.py` and eight renderers) becomes `sports_font_path.resolve_font_path`, not `font_layout.resolve_asset_path`, which skips the cwd. Core side done; see [Stage 4](#stage-4-the-identical-sweep-core-done-adoption-waits-for-a-release) | | 4 | Identical sweep | `manager.py`: `_dispatch_switch_refresh`, `_favorite_team_is_live`, `get_vegas_priority_weight`, `_game_involves`, `_favorite_scan_targets`, `_favorite_scan_games`, `_get_total_games_for_manager` (all nine, 1); the live-scroll helpers `_preserving_scroll_position`, `_refresh_live_scroll_managers`, `_live_scroll_managers`, `_note_live_scroll_built`, `_live_scroll_needs_rebuild`, `_live_scroll_fields` (eight, 1). `sports.py`: `_card_option`, `_filtered_or_all`, `_effective_live_duration`, `_recent_date_text` (eight, 1). 58 identical families in all | Nothing to decide; brings `manager.py` into core as a `SportsPluginHostMixin`. `_resolve_font_path` (identical in nine `sports.py` and eight renderers) becomes `sports_font_path.resolve_font_path`, not `font_layout.resolve_asset_path`, which skips the cwd. Core side done; see [Stage 4](#stage-4-the-identical-sweep-core-done-adoption-waits-for-a-release) |
| 5 | Game-over check | `SportsLive._is_game_really_over` (5) | Pure logic, no pixels; its seams (`FINAL_PERIOD`, `CLOCK_COUNTS_DOWN`) were designed in B1. The pilot for the procedure | | 5 | Game-over check | `SportsLive._is_game_really_over` (5) | Pure logic, no pixels; one seam, `FINAL_PERIOD`. The pilot for the procedure. Reconciled to one body and promoted as `sports_game_over`; adoption waits for the release that ships it. See [Family 5](#family-5-the-game-over-check-core-done-adoption-waits-for-a-release) |
| 6 | Favourite matching | `_is_favorite_game` (7 across three classes), `_select_games_for_display` (2: nrl), `_select_recent_games_for_display` (3) | Everything that asks "is this a favourite" goes through the 3.5.0 `_favorite_key` seam | | 6 | Favourite matching | `_is_favorite_game` (7 across three classes), `_select_games_for_display` (2: nrl), `_select_recent_games_for_display` (3) | Everything that asks "is this a favourite" goes through the 3.5.0 `_favorite_key` seam |
| 7 | Other-games rotation | `_by_importance`, `_other_games_window`, `_advance_other_games_if_due` (2 each: football), `_rotate_other_games_on_display` (2: ufc) | One outlier each; football carries two fixes the other eight lack | | 7 | Other-games rotation | `_by_importance`, `_other_games_window`, `_advance_other_games_if_due` (2 each: football), `_rotate_other_games_on_display` (2: ufc) | One outlier each; football carries two fixes the other eight lack |
| 8 | Rankings | `_fetch_team_rankings` (3), `_choose_poll` (3), `_load_division_team_ids`, `_passes_other_filters`, `_best_rank`, `_is_ranked_game` (2 each: football) | Needs 7; the rank badge and the "ranked only" filter read it | | 8 | Rankings | `_fetch_team_rankings` (3), `_choose_poll` (3), `_load_division_team_ids`, `_passes_other_filters`, `_best_rank`, `_is_ranked_game` (2 each: football) | Needs 7; the rank badge and the "ranked only" filter read it |
@@ -416,17 +433,17 @@ family 9 prepares.
Owner calls to make before (or while) reconciling. Items marked *verify* are Owner calls to make before (or while) reconciling. Items marked *verify* are
suspected behaviour that needs a payload or a rig to confirm first. suspected behaviour that needs a payload or a rig to confirm first.
- **5, game-over check.** Which rule each sport gets: the clock never ends a - **5, game-over check. Decided 2026-10-05, done:** one seam,
game in afl, nrl and soccer (`CLOCK_COUNTS_DOWN = False`); hockey ends at `FINAL_PERIOD`: hockey 3; basketball, football and lacrosse 4; `None` (the
0:00 from period 3, basketball, football and lacrosse from period 4. clock never ends a game) for afl, nrl and soccer (clocks that count up),
baseball and ufc share a copy that reads a missing clock as "0:00": dormant baseball (its games carry no `period`, so the old rule was dormant) and
in baseball (its games carry no `period`), and not triggered by ufc's round ufc (a bout ends only on ESPN's final status, which also closes the ~1 s
breaks either. ESPN sends a break as `STATUS_END_OF_ROUND` with displayClock window at the horn when the ticking clock reads `0:00`; ESPN's round-break
`-`, not `0:00` (verified against recorded payloads; ledmatrix-plugins#580 displayClock `-` was never a zero clock, ledmatrix-plugins#580). Only a
pins it). Whatever rule ufc gets must not read `-` as `0:00`. Decide ufc's non-empty clock string counts (the baseball/ufc copy read a missing clock
rule: no clock rule (ESPN's `STATUS_FINAL` is the only end signal it needs; as `0:00`). A score level at 0:00 is not over: the game stays live through
this also closes a ~1 s window at the horn when the ticking clock reads the break before overtime, and one that really ends tied ends on its final
`0:00`), or its own final period. status. Baseball keeps its postponed/suspended override in `BaseballLive`.
- **6, favourite matching.** NRL keeps matching favourites by team id - **6, favourite matching.** NRL keeps matching favourites by team id
(abbreviations collide: NEW, CAN), through `_favorite_key` rather than its (abbreviations collide: NEW, CAN), through `_favorite_key` rather than its
own copies of the selection methods. Six plugins log the recent-games own copies of the selection methods. Six plugins log the recent-games
+38 -4
View File
@@ -42,7 +42,8 @@ static/v3/js/
registry.js page lifecycle: init/destroy on htmx swaps registry.js page lifecycle: init/destroy on htmx swaps
api.js fetch wrapper for /api/v3 (JSON envelope, login redirect) api.js fetch wrapper for /api/v3 (JSON envelope, login redirect)
facade.js window.LEDMatrix and deprecated aliases facade.js window.LEDMatrix and deprecated aliases
(later) escape.js, notify.js, dialog.js, streams.js, visibility.js, visibility.js ctx.visibility: a page's timers run only while it is on screen
(later) escape.js, notify.js, dialog.js, streams.js,
store.js (the one installed-plugin store), form/renderer.js store.js (the one installed-plugin store), form/renderer.js
pages/ one module per tab partial pages/ one module per tab partial
cache.js export init(root, ctx), destroy(root, ctx) cache.js export init(root, ctx), destroy(root, ctx)
@@ -81,6 +82,12 @@ The conventions the converted pages share:
to the module's export of the same name and warns once. to the module's export of the same name and warns once.
- **Timers are cleared in `destroy()`**, the one thing `ctx.signal` cannot - **Timers are cleared in `destroy()`**, the one thing `ctx.signal` cannot
undo by itself. undo by itself.
- **Polling goes through `ctx.visibility`.** A refresh that repeats
(`ctx.visibility.every(ms, fn)`) or work that should run only while the
page is on screen (`ctx.visibility.whileVisible(start, stop)`) is
registered there, never with a bare `setInterval`. It runs only while the
page's tab is the active tab and the browser tab is visible, and it ends
when the page is destroyed, with no code in `destroy()`.
- **A page reports its own htmx saves.** A form whose result a page module - **A page reports its own htmx saves.** A form whose result a page module
shows (an `htmx:afterRequest` listener on the page root, in place of an shows (an `htmx:afterRequest` listener on the page root, in place of an
`hx-on` attribute naming a global) carries `data-reports-result`. `app.js` `hx-on` attribute naming a global) carries `data-reports-result`. `app.js`
@@ -111,6 +118,7 @@ Each mount gets a `ctx` object:
| `ctx.state` | A per-mount object for the page's own state | | `ctx.state` | A per-mount object for the page's own state |
| `ctx.api` | Shared service from `boot.js` | | `ctx.api` | Shared service from `boot.js` |
| `ctx.notify` | Shared service from `boot.js` | | `ctx.notify` | Shared service from `boot.js` |
| `ctx.visibility` | This page's handle on `core/visibility.js` (below), made per mount by `boot.js` through the registry's `mountContext` option |
A page that passes `{ signal: ctx.signal }` to `addEventListener` and A page that passes `{ signal: ctx.signal }` to `addEventListener` and
`fetch` needs no teardown code. Its listeners and in-flight requests go `fetch` needs no teardown code. Its listeners and in-flight requests go
@@ -119,6 +127,30 @@ example: its delete buttons use one delegated listener, rows are built with
`textContent` rather than markup strings, and a newer load supersedes an `textContent` rather than markup strings, and a newer load supersedes an
older one. older one.
### Page visibility
`core/visibility.js` gives each mounted page `ctx.visibility`:
| Member | What it does |
|---|---|
| `whileVisible(start, stop)` | Runs `start()` when the page comes on screen (at once, if it mounts on screen) and `stop()` when it leaves. Returns a function that ends the registration, running `stop()` first if needed |
| `every(ms, fn)` | `fn()` at once, then every `ms` while on screen. The interval is cleared while hidden and restarted, with an immediate `fn()`, when the page is back. Returns the same kind of end function |
| `isVisible()` | True while the page is on screen |
| `tab` | The tab the page belongs to: its name, or `forPage(ctx, { tab })` |
"On screen" means the page's tab is the active tab and the browser tab is
visible. Everything a page registered ends when its `ctx.signal` aborts,
after `destroy()`, so a swapped-out partial leaves no interval behind.
The answer comes from `window.LEDVisibility` (`app-shell.js`), read at call
time, so the page modules and the classic partials that still call it
(Overview, Logs, Tools) agree on the active tab, and the SSE streams keep
pausing with them. Each registration takes its own `LEDVisibility` key, so
registrations never replace each other or a classic partial's. Without
`LEDVisibility` (a page outside `base.html`), the browser tab's visibility
alone decides. Moving the tracker itself into the module (the shell table
below) changes only `core/visibility.js`.
### One facade ### One facade
`window.LEDMatrix` is the only global the module code adds: `window.LEDMatrix` is the only global the module code adds:
@@ -249,7 +281,7 @@ are the inline script in each partial today.
| 5 | Backup & Restore | 232 lines, now 0 | **Done in stage 2.** Its 5 globals (`exportBackup`, `loadBackupList`, `validateRestoreFile`, `clearRestore`, `runRestore`) are deprecated aliases; the buttons are delegated `data-action`s. Uploads go through `ctx.api.request(..., { body: formData })` (`api.js` gained a raw `body` option) | | 5 | Backup & Restore | 232 lines, now 0 | **Done in stage 2.** Its 5 globals (`exportBackup`, `loadBackupList`, `validateRestoreFile`, `clearRestore`, `runRestore`) are deprecated aliases; the buttons are delegated `data-action`s. Uploads go through `ctx.api.request(..., { body: formData })` (`api.js` gained a raw `body` option) |
| 6 | Schedule | 193 lines, now 0 | **Done in stage 3.** Its 2 `hx-on` response handlers (`handleScheduleResponse`, `handleDimScheduleResponse`) are one `htmx:afterRequest` listener on the page root, and deprecated aliases. The forms are marked `data-reports-result` so `app.js` does not repeat the server's message. The saved schedules reach the module as JSON in `data-schedule-config` / `data-dim-schedule-config` instead of being templated into the script | | 6 | Schedule | 193 lines, now 0 | **Done in stage 3.** Its 2 `hx-on` response handlers (`handleScheduleResponse`, `handleDimScheduleResponse`) are one `htmx:afterRequest` listener on the page root, and deprecated aliases. The forms are marked `data-reports-result` so `app.js` does not repeat the server's message. The saved schedules reach the module as JSON in `data-schedule-config` / `data-dim-schedule-config` instead of being templated into the script |
| 7 | General | 153 lines, now 0 | **Done in stage 3.** The Security section's three forms and two buttons are delegated `data-action`s (one submit and one click listener); `window.webLogin` is a deprecated alias of an object with its five methods. Login requests go through `ctx.api`, so the login redirect is quiet. The settings form keeps its `hx-on` call to the shared `showSaveResult`, as Rotation's does | | 7 | General | 153 lines, now 0 | **Done in stage 3.** The Security section's three forms and two buttons are delegated `data-action`s (one submit and one click listener); `window.webLogin` is a deprecated alias of an object with its five methods. Login requests go through `ctx.api`, so the login redirect is quiet. The settings form keeps its `hx-on` call to the shared `showSaveResult`, as Rotation's does |
| 8 | Display | 231 | First page with `LEDVisibility` timers: those move to a `ctx.visibility` service that stops on destroy | | 8 | Display | 292 lines (2 scripts), now 0 | **Done in stage 4.** The first page with a timer: the 5 s multi-display sync poll is `ctx.visibility.every(5000, ...)` (above), so it runs only while the tab is on screen and stops when the partial is swapped out. Its one global, `updateSyncUI` (the Role menu's `onchange`), is a deprecated alias; the Advanced section's `onclick` is a delegated `data-action="toggle-section"` that calls the shared `toggleSection`. The status poll and the scroll-speed hint go through `ctx.api` with `ctx.signal`, as does the Vegas order widget's plugin-list request. The settings form keeps its `hx-on` call to `showSaveResult` and its `onsubmit` call to `fixInvalidNumberInputs`, as Rotation's does |
| 9 | Overview | 410 (4 scripts) | First-run surface: Getting Started, update banner, live preview. Five globals | | 9 | Overview | 410 (4 scripts) | First-run surface: Getting Started, update banner, live preview. Five globals |
| 10 | WiFi | 364 | `x-data="wifiSetup()"` is defined by its own script. Moves to `Alpine.data()` registered from the module. AP-mode first screen, so it needs the AP-mode test on a real device | | 10 | WiFi | 364 | `x-data="wifiSetup()"` is defined by its own script. Moves to `Alpine.data()` registered from the module. AP-mode first screen, so it needs the AP-mode test on a real device |
| 11 | Fonts | 681 | Large, but self-contained (6 globals) | | 11 | Fonts | 681 | Large, but self-contained (6 globals) |
@@ -266,7 +298,7 @@ the order:
| `showNotification` | 4 versions | `core/notify.js` | | `showNotification` | 4 versions | `core/notify.js` |
| The modal helper | `utils/dialog.js` | `core/dialog.js` | | The modal helper | `utils/dialog.js` | `core/dialog.js` |
| SSE streams | `app-shell.js` | `core/streams.js` | | SSE streams | `app-shell.js` | `core/streams.js` |
| `LEDVisibility` | `app-shell.js` | `core/visibility.js` | | `LEDVisibility` | `app-shell.js` | `core/visibility.js` (the page-facing `ctx.visibility` is there since step 8; it reads the tracker from `app-shell.js`) |
Each move leaves the old global as an alias. When the last inline script is Each move leaves the old global as an alias. When the last inline script is
gone, the script re-execution in `htmx-config.js` and the "HTMX never gone, the script re-execution in `htmx-config.js` and the "HTMX never
@@ -286,13 +318,15 @@ Unit suites need only node. They import the shipped modules directly:
| Suite | Kind | What it covers | | Suite | Kind | What it covers |
|---|---|---| |---|---|---|
| `unit/test_page_registry.js` | Unit, minimal DOM shim | The lifecycle: one init per root, destroy on swap, a veto keeps the page, swaps elsewhere leave it alone, the sweep, lazy loading, a destroy while loading, error containment | | `unit/test_page_registry.js` | Unit, minimal DOM shim | The lifecycle: one init per root, destroy on swap, a veto keeps the page, swaps elsewhere leave it alone, the sweep, lazy loading, a destroy while loading, error containment, `mountContext` fields per mount |
| `dom/test_visibility_service.js` | DOM: real `LEDVisibility` from `app-shell.js`, real registry, no server | `whileVisible` and `every` start and stop with the active tab and the browser tab's visibility; no interval runs while hidden or after a swap-out; one interval after five swaps; registrations never replace each other or a classic partial's; a destroyed page registers nothing; a throwing `start()` is contained; the no-`LEDVisibility` fallback |
| `unit/test_core_modules.js` | Unit | `api.js` (envelope, errors, abort, login redirect, path check) and `facade.js` (facade, aliases) | | `unit/test_core_modules.js` | Unit | `api.js` (envelope, errors, abort, login redirect, path check) and `facade.js` (facade, aliases) |
| `dom/test_cache_page.js` | DOM: real partial, real API shape | No inline script; one request per swap and per Refresh after five swaps; a cancelled request draws nothing; hostile keys stay text; delete, empty, error, network and login states | | `dom/test_cache_page.js` | DOM: real partial, real API shape | No inline script; one request per swap and per Refresh after five swaps; a cancelled request draws nothing; hostile keys stay text; delete, empty, error, network and login states |
| `dom/test_durations_page.js` | DOM: real partial, real widget, real API shape | One plugin-list request per swap; Move down moves one place after five swaps; the swap cancels a request in flight; a late-loading widget is waited for, and a page swapped away while waiting starts nothing; hostile names stay text | | `dom/test_durations_page.js` | DOM: real partial, real widget, real API shape | One plugin-list request per swap; Move down moves one place after five swaps; the swap cancels a request in flight; a late-loading widget is waited for, and a page swapped away while waiting starts nothing; hostile names stay text |
| `dom/test_operation_history_page.js` | DOM: real partial, real API shape | One history request per swap and per Refresh; the plugin filter filled once (from `PluginAPI`'s cache when loaded); paging, filters, debounced search, Clear (one DELETE), error/network/login states, cancel on swap; hostile ids, users and errors stay text | | `dom/test_operation_history_page.js` | DOM: real partial, real API shape | One history request per swap and per Refresh; the plugin filter filled once (from `PluginAPI`'s cache when loaded); paging, filters, debounced search, Clear (one DELETE), error/network/login states, cancel on swap; hostile ids, users and errors stay text |
| `dom/test_raw_json_page.js` | DOM: real partial, real config | One POST per Save after five swaps, to the right file; Format and Validate act once; invalid JSON never sent and its message stays text; a save survives a swap and is still reported; the old globals' entry points | | `dom/test_raw_json_page.js` | DOM: real partial, real config | One POST per Save after five swaps, to the right file; Format and Validate act once; invalid JSON never sent and its message stays text; a save survives a swap and is still reported; the old globals' entry points |
| `dom/test_schedule_page.js` | DOM: real partial, real widget | Both pickers drawn once per swap from the saved config; after five swaps each form's answer is one notification (message, fallback, refused, non-JSON, `null`), a request from outside the forms none; the brightness label; a late widget waited for, a page swapped away while waiting draws nothing; the old globals' entry points | | `dom/test_schedule_page.js` | DOM: real partial, real widget | Both pickers drawn once per swap from the saved config; after five swaps each form's answer is one notification (message, fallback, refused, non-JSON, `null`), a request from outside the forms none; the brightness label; a late widget waited for, a page swapped away while waiting draws nothing; the old globals' entry points |
| `dom/test_display_page.js` | DOM: real partial, real widget, real `LEDVisibility`, real API shape | After five swaps one page, one sync interval, the Vegas order drawn once and each control acting once (brightness, resolution, the two show/hide toggles, the Advanced toggle, one debounced hint request); the sync poll only while on screen and never after a swap-out; sync states and hostile peer names as text, failure and login answers; a late widget waited for; `updateSyncUI`'s entry point |
| `dom/test_general_page.js` | DOM: real partial, real widget, real API shape | The timezone picker drawn once per swap with the saved zone; the settings form left to htmx; after five swaps each Security action makes one request (create, copy, revoke and its cancel, password and its mismatch); hostile token names stay text; refused, network and login answers; a create made before a swap is still reported and draws nothing; `webLogin`'s entry points | | `dom/test_general_page.js` | DOM: real partial, real widget, real API shape | The timezone picker drawn once per swap with the saved zone; the settings form left to htmx; after five swaps each Security action makes one request (create, copy, revoke and its cancel, password and its mismatch); hostile token names stay text; refused, network and login answers; a create made before a swap is still reported and draws nothing; `webLogin`'s entry points |
| `dom/test_backup_restore_page.js` | DOM: real partial, real API shape | One request per Refresh, Delete, Export (busy button ignores a second click), Inspect and Restore after five swaps; the upload's fields and the six restore options; reads cancelled by a swap, writes not; hostile file and host names stay text; the old globals' entry points | | `dom/test_backup_restore_page.js` | DOM: real partial, real API shape | One request per Refresh, Delete, Export (busy button ignores a second click), Inspect and Restore after five swaps; the upload's fields and the six restore options; reads cancelled by a swap, writes not; hostile file and host names stay text; the old globals' entry points |
| `test/web_interface/test_es_modules.py` | pytest | MIME type; `no-cache` without `?v` and immutable with it; `boot.js` loads last; every import resolves inside `core/` and `pages/`; the converted pages are exactly the registered ones, each with its module, `init`, and one root in the rendered partial; a converted partial has no `<script>` and no `onclick`; every moved global is aliased in `boot.js` and exported by its module, and no template defines it any more | | `test/web_interface/test_es_modules.py` | pytest | MIME type; `no-cache` without `?v` and immutable with it; `boot.js` loads last; every import resolves inside `core/` and `pages/`; the converted pages are exactly the registered ones, each with its module, `init`, and one root in the rendered partial; a converted partial has no `<script>` and no `onclick`; every moved global is aliased in `boot.js` and exported by its module, and no template defines it any more |
+2
View File
@@ -38,6 +38,7 @@ src/common/sports_celebration.py
src/common/sports_display_rules.py src/common/sports_display_rules.py
src/common/sports_fetch.py src/common/sports_fetch.py
src/common/sports_font_path.py src/common/sports_font_path.py
src/common/sports_game_over.py
src/common/sports_live_scroll.py src/common/sports_live_scroll.py
src/common/sports_plugin_host.py src/common/sports_plugin_host.py
src/common/sports_scroll.py src/common/sports_scroll.py
@@ -58,6 +59,7 @@ src/ipc/contract.py
src/ipc/server.py src/ipc/server.py
src/logging_config.py src/logging_config.py
src/logo_downloader.py src/logo_downloader.py
src/malloc_tuning.py
src/matrix_support.py src/matrix_support.py
src/pi5_matrix_support.py src/pi5_matrix_support.py
src/plugin_system/__init__.py src/plugin_system/__init__.py
+6
View File
@@ -14,6 +14,12 @@ project_dir = os.path.dirname(os.path.abspath(__file__))
if project_dir not in sys.path: if project_dir not in sys.path:
sys.path.insert(0, project_dir) sys.path.insert(0, project_dir)
# Cap glibc's malloc arenas before any thread exists (arenas already made
# stay): the in-process twin of the unit's MALLOC_ARENA_MAX=2, for units
# installed before that line. A no-op off glibc. See src/malloc_tuning.py.
from src import malloc_tuning
malloc_tuning.cap_arenas()
# Under systemd the watchdog clock is already running, and start-up (plugin # Under systemd the watchdog clock is already running, and start-up (plugin
# loads, initial updates) takes far longer than the render loop's limit. Widen # loads, initial updates) takes far longer than the render loop's limit. Widen
# it before anything slow is imported; the render loop narrows it again once # it before anything slow is imported; the render loop narrows it again once
+1 -1
View File
@@ -4,5 +4,5 @@ LEDMatrix Display System
Core source package for the LED Matrix Display project. Core source package for the LED Matrix Display project.
""" """
__version__ = "3.8.0" __version__ = "3.8.1"
+12 -1
View File
@@ -46,6 +46,7 @@ Rules for the package:
| [`sports_display_rules`](#sports_display_rules) | Which games a scoreboard shows, for how long, and its scorebug date line | Yes (scoreboards) | 3.8.0 | | [`sports_display_rules`](#sports_display_rules) | Which games a scoreboard shows, for how long, and its scorebug date line | Yes (scoreboards) | 3.8.0 |
| [`sports_fetch`](#sports_fetch) | Scoreboard season fetch, lookback and live-odds decisions | Yes (scoreboards) | 3.7.0 | | [`sports_fetch`](#sports_fetch) | Scoreboard season fetch, lookback and live-odds decisions | Yes (scoreboards) | 3.7.0 |
| [`sports_font_path`](#sports_font_path) | Find a scoreboard's bundled font whatever the cwd | Yes (scoreboards) | 3.8.0 | | [`sports_font_path`](#sports_font_path) | Find a scoreboard's bundled font whatever the cwd | Yes (scoreboards) | 3.8.0 |
| [`sports_game_over`](#sports_game_over) | Whether a game ESPN still lists as live has ended | Yes (scoreboards) | 3.8.1 |
| [`sports_game_renderer`](#sports_game_renderer) | Scoreboard scroll/Vegas card geometry | Yes (scoreboards) | 3.3.0 | | [`sports_game_renderer`](#sports_game_renderer) | Scoreboard scroll/Vegas card geometry | Yes (scoreboards) | 3.3.0 |
| [`sports_helpers`](#sports_helpers) | Small helpers every scoreboard `sports.py` copies | Yes (scoreboards) | 3.5.0 | | [`sports_helpers`](#sports_helpers) | Small helpers every scoreboard `sports.py` copies | Yes (scoreboards) | 3.5.0 |
| [`sports_live_scroll`](#sports_live_scroll) | Rebuild a live scroll strip mid-cycle without moving it | Yes (scoreboards) | 3.8.0 | | [`sports_live_scroll`](#sports_live_scroll) | Rebuild a live scroll strip mid-cycle without moving it | Yes (scoreboards) | 3.8.0 |
@@ -294,6 +295,16 @@ path as given when it exists (relative to the cwd), else
`font_layout.resolve_asset_path(path)`. What the scoreboards' `font_layout.resolve_asset_path(path)`. What the scoreboards'
`_resolve_font_path` copies return on a core that ships it. `_resolve_font_path` copies return on a core that ships it.
### sports_game_over
[`sports_game_over.py`](sports_game_over.py). `SportsGameOverMixin`:
`_is_game_really_over(game)`, the `SportsLive` check that drops a game ESPN
still lists as live (`SportsLiveSharedMixin._detect_stale_games` calls it).
Over on a final period text, or on a 0:00 clock from period `FINAL_PERIOD`
on unless the score is level. `FINAL_PERIOD` is a class attribute the host
sets per sport; the default `None` means the clock never ends a game. List
it before `SportsLiveSharedMixin`.
### sports_game_renderer ### sports_game_renderer
[`sports_game_renderer.py`](sports_game_renderer.py). [`sports_game_renderer.py`](sports_game_renderer.py).
@@ -391,7 +402,7 @@ Created by `DisplayController`; works with any plugin.
`draw_multiline_text()`, `create_text_image()`. `draw_multiline_text()`, `create_text_image()`.
`draw_text_outlined(draw, xy, text, font, fill, outline_color=(0, 0, 0), `draw_text_outlined(draw, xy, text, font, fill, outline_color=(0, 0, 0),
offsets=OUTLINE_SQUARE)` (Unreleased) draws the text in `outline_color` at offsets=OUTLINE_SQUARE)` (3.8.1) draws the text in `outline_color` at
each offset, then in `fill` on top: the same pixels as one `draw.text` per each offset, then in `fill` on top: the same pixels as one `draw.text` per
offset, but the string is rasterized once. `OUTLINE_SQUARE` is the offset, but the string is rasterized once. `OUTLINE_SQUARE` is the
eight-sided one-pixel outline the scoreboards draw, `OUTLINE_CROSS` the eight-sided one-pixel outline the scoreboards draw, `OUTLINE_CROSS` the
+125
View File
@@ -0,0 +1,125 @@
"""Whether a game ESPN still lists as live has in fact ended (sports family 5).
``SportsGameOverMixin._is_game_really_over`` is the scoreboards'
``SportsLive._is_game_really_over``, reconciled in ledmatrix-plugins
#625 from five bodies into one and copied here under
its existing name. ``SportsLiveSharedMixin._detect_stale_games``
(``src.common.sports_shared``) calls it on every live game, and the plugins'
live-priority filters call it too, to drop a game ESPN still reports as
in progress.
A game is over when its period text says final. From period ``FINAL_PERIOD``
on, a clock reading 0:00 ends it too, unless the score is level: a tie at the
end of regulation goes to overtime (or a shootout), and a game that does end
tied says final. Only a clock *string* is read ("0:00" and ":00" are zero;
":40", "0.0" and ESPN's "-" between MMA rounds are not), and a missing or
unreadable score leaves the decision to the clock.
``FINAL_PERIOD`` is the one per-sport fact, a class attribute rather than a
sport-name branch. The scoreboards declare it on their ``SportsLive``:
- 3: hockey;
- 4: basketball, football, lacrosse;
- ``None`` (this default; the clock never ends a game): afl, nrl and soccer,
whose clocks count up; baseball, which has innings; ufc, whose bouts end
only on ESPN's final status.
A sport can still override the method and defer to it, as baseball's
``BaseballLive`` does to end postponed and suspended games first.
A new module rather than another method on ``sports_shared``, for the reason
``sports_helpers`` gives: a missing module fails at load, where the version
checks see it; a missing method fails mid-update.
WHAT A HOST MUST PROVIDE
------------------------
Derived by walking every ``self.<attr>`` the mixin reads; the host-contract
test in ``test/test_sports_game_over.py`` fails if a read is added without
being listed here.
- ``logger`` -- a ``logging.Logger``; the method logs its verdict at DEBUG.
- ``FINAL_PERIOD`` -- defaulted here to ``None``; set it on the host class.
The method reads the game dict's ``away_abbr``, ``home_abbr``,
``period_text``, ``period``, ``clock``, ``away_score`` and ``home_score``
(``_extract_game_details_common``'s keys); any of them may be missing or
null.
BASE ORDER
----------
List the mixin before ``SportsLiveSharedMixin`` --
``class SportsLive(SportsGameOverMixin, SportsLiveSharedMixin, SportsCore)`` --
so the shared mixin's ``_detect_stale_games`` finds this method through the
MRO. Neither shared mixin defines it, so the order does not change which body
runs today; it keeps the method next to its caller should one ever be added
there. A method on the plugin's own class still wins, and its ``super()``
reaches this one. The mixin has no ``__init__`` and no state.
"""
import logging
from typing import Dict, Optional
class SportsGameOverMixin:
"""The live manager's "is this game really over?" check. See module docstring."""
# The host contract, declared for type checking only.
logger: logging.Logger
#: Period from which a 0:00 clock ends a game; None: the clock never does.
FINAL_PERIOD: Optional[int] = None
def _is_game_really_over(self, game: Dict) -> bool:
"""Whether a game ESPN still lists as live has in fact ended.
It has when its period text says final. From period ``FINAL_PERIOD``
on, a clock at 0:00 ends it too, unless the score is level: a tie at
the end of regulation goes to overtime, and a game that does end tied
says final. With ``FINAL_PERIOD = None`` the clock never ends a game.
"""
game_str = f"{game.get('away_abbr')}@{game.get('home_abbr')}"
# ESPN can send the key as null, and .get()'s default only covers a
# missing key, so a None here crashed the whole live update.
raw_period_text = game.get("period_text")
period_text = raw_period_text.lower() if isinstance(raw_period_text, str) else ""
if "final" in period_text:
self.logger.debug(
f"_is_game_really_over({game_str}): "
f"returning True - 'final' in period_text='{period_text}'"
)
return True
# Same for a null or non-numeric period: treat it as period 0.
try:
period = int(game.get("period") or 0)
except (TypeError, ValueError, OverflowError):
period = 0
# Only a clock string is read: "0:00" and ":00" are zero; ":40" is not.
clock = game.get("clock")
clock_at_zero = isinstance(clock, str) and clock.replace(":", "").strip() in ("000", "00")
if self.FINAL_PERIOD is not None and period >= self.FINAL_PERIOD and clock_at_zero:
try:
tied = int(game["away_score"]) == int(game["home_score"])
except (KeyError, TypeError, ValueError, OverflowError):
tied = False # a missing or unreadable score leaves it to the clock
if not tied:
self.logger.debug(
f"_is_game_really_over({game_str}): "
f"returning True - clock at 0:00 (clock='{clock}', period={period})"
)
return True
self.logger.debug(
f"_is_game_really_over({game_str}): "
f"returning False - tied at 0:00 (period={period}), overtime next"
)
return False
self.logger.debug(
f"_is_game_really_over({game_str}): returning False"
)
return False
__all__ = ["SportsGameOverMixin"]
+55 -7
View File
@@ -57,7 +57,9 @@ Methods that stay per-plugin, because they are not identical across the eight
``_get_layout_offset``, ``_by_importance``, ``_other_games_window``, ``_get_layout_offset``, ``_by_importance``, ``_other_games_window``,
``_upcoming_date_and_time_text``, ``_extract_game_details_common``, ``_upcoming_date_and_time_text``, ``_extract_game_details_common``,
``_load_division_team_ids``, ``_get_timezone``, ``_is_favorite_game``, ``_load_division_team_ids``, ``_get_timezone``, ``_is_favorite_game``,
``_is_game_really_over``, ``_is_ranked_game``, ``_passes_other_filters``. ``_is_ranked_game``, ``_passes_other_filters``. (``_is_game_really_over``,
which ``_detect_stale_games`` below calls, was here too until the plugins
reconciled it; it is now ``src.common.sports_game_over``.)
Of the fourteen shared class constants, thirteen are identical everywhere and Of the fourteen shared class constants, thirteen are identical everywhere and
live here. Only ``_SCORE_PROBE_TEXT`` varies -- afl and basketball reach three digits live here. Only ``_SCORE_PROBE_TEXT`` varies -- afl and basketball reach three digits
@@ -120,6 +122,32 @@ _DEFAULT_LIVE_IDLE_MAX_SECONDS = 900
_KICKOFF_GRACE_SECONDS = 900 _KICKOFF_GRACE_SECONDS = 900
#: Fallback cadence around a kickoff when the manager has no update_interval. #: Fallback cadence around a kickoff when the manager has no update_interval.
_KICKOFF_POLL_FLOOR = 30 _KICKOFF_POLL_FLOOR = 30
#: How many kickoffs after the current one a live manager remembers. Only the
#: earliest few can matter before the next look refreshes the list, so this
#: bounds the memory without dropping a kickoff the board would wait for.
_KICKOFF_QUEUE_MAX = 8
def _current_scheduled_start(host: Any, now: float) -> Optional[float]:
"""The kickoff a live manager is honouring now, promoting the next queued one.
``_next_scheduled_start_ts`` is the kickoff being honoured: the earliest
one ahead of us, or one that has just passed and is inside its grace.
Kickoffs behind it wait in ``_later_scheduled_starts``. When the current
one's grace runs out, the earliest queued kickoff that is not itself past
its grace takes over -- including one that has already passed, so a
second kickoff inside the first one's grace still gets a grace of its own.
"""
current: Optional[float] = getattr(host, "_next_scheduled_start_ts", None)
if current and current > now - _KICKOFF_GRACE_SECONDS:
return current
queued: Optional[List[float]] = getattr(host, "_later_scheduled_starts", None)
if queued:
alive = sorted(s for s in queued if s > now - _KICKOFF_GRACE_SECONDS)
current = alive.pop(0) if alive else None
host._later_scheduled_starts = alive
host._next_scheduled_start_ts = current
return current if current and current > now - _KICKOFF_GRACE_SECONDS else None
def _resolve_font_path(path: str) -> str: def _resolve_font_path(path: str) -> str:
@@ -1294,11 +1322,11 @@ class SportsLiveSharedMixin:
otherwise look like another empty check and escalate the back-off otherwise look like another empty check and escalate the back-off
again, right when the game is actually starting. again, right when the game is actually starting.
""" """
start = getattr(self, "_next_scheduled_start_ts", None) now = time.time()
start = _current_scheduled_start(self, now)
if not start: if not start:
return interval return interval
live = getattr(self, "update_interval", None) or _KICKOFF_POLL_FLOOR live = getattr(self, "update_interval", None) or _KICKOFF_POLL_FLOOR
now = time.time()
if now < start: if now < start:
return max(live, min(interval, int(start - now))) return max(live, min(interval, int(start - now)))
if now - start <= _KICKOFF_GRACE_SECONDS: if now - start <= _KICKOFF_GRACE_SECONDS:
@@ -1313,6 +1341,16 @@ class SportsLiveSharedMixin:
already has. Self-correcting: a stored start that has passed is already has. Self-correcting: a stored start that has passed is
replaced by the next one offered, so a postponed game cannot pin the replaced by the next one offered, so a postponed game cannot pin the
cadence to a kickoff that never happens. cadence to a kickoff that never happens.
Every pending kickoff is honoured, not just the first. A kickoff that
arrives while an earlier one is inside its grace is queued in
``_later_scheduled_starts`` (the earliest _KICKOFF_QUEUE_MAX of them)
and takes over when that grace ends, with a grace of its own. Keeping
only the one kickoff dropped the second of two favourites starting
within the grace of each other: it was refused while the first held
the slot, and refused again once it had passed, so if ESPN had not
flipped it live by the end of the first grace the back-off went
straight back to its ceiling and the game was noticed up to that late.
""" """
if not isinstance(details, dict): if not isinstance(details, dict):
return return
@@ -1329,7 +1367,7 @@ class SportsLiveSharedMixin:
now = time.time() now = time.time()
if candidate <= now: if candidate <= now:
return return
current = getattr(self, "_next_scheduled_start_ts", None) current = _current_scheduled_start(self, now)
# A kickoff that has only just passed is *kept*, not replaced by the # A kickoff that has only just passed is *kept*, not replaced by the
# next one on the card. Replacing it immediately is what made the grace # next one on the card. Replacing it immediately is what made the grace
# window in _clamp_to_scheduled_start dead code: the moment 13:00 came # window in _clamp_to_scheduled_start dead code: the moment 13:00 came
@@ -1339,10 +1377,20 @@ class SportsLiveSharedMixin:
# polled at 13:00:45, found nothing live because ESPN had not flipped # polled at 13:00:45, found nothing live because ESPN had not flipped
# the status yet, and then went quiet for the next quarter of an hour, # the status yet, and then went quiet for the next quarter of an hour,
# which is the behaviour this whole clamp exists to prevent. # which is the behaviour this whole clamp exists to prevent.
if (current is None #
or current <= now - _KICKOFF_GRACE_SECONDS # Nor is it forgotten: whichever kickoff loses is queued behind the
or candidate < current): # one honoured now, so it gets its own grace when that one's ends.
if current is None:
self._next_scheduled_start_ts = candidate self._next_scheduled_start_ts = candidate
return
if candidate == current:
return
if candidate < current:
self._next_scheduled_start_ts, candidate = candidate, current
queued = getattr(self, "_later_scheduled_starts", None) or []
if candidate not in queued:
self._later_scheduled_starts = sorted(
[*queued, candidate])[:_KICKOFF_QUEUE_MAX]
#: How long a game that finished live is still reported by #: How long a game that finished live is still reported by
#: finished_games_snapshot(): long enough for the recent-games list, which #: finished_games_snapshot(): long enough for the recent-games list, which
+7
View File
@@ -37,6 +37,7 @@ from concurrent.futures import ThreadPoolExecutor, as_completed # pylint: disab
import pytz import pytz
from src import display_watchdog from src import display_watchdog
from src.malloc_tuning import MallocTrimmer
from src.display_arbiter import ( from src.display_arbiter import (
Arbiter, ArbiterInputs, ArbiterState, FramePolicy, Arbiter, ArbiterInputs, ArbiterState, FramePolicy,
ScreenPlan, Source, WifiNotice, live_pick, live_takeover, on_demand_bound, rotation_plan, ScreenPlan, Source, WifiNotice, live_pick, live_takeover, on_demand_bound, rotation_plan,
@@ -4217,6 +4218,7 @@ class DisplayController:
logger.info(f"Initial mode set to: {self.current_display_mode} (index: {self.current_mode_index}, total modes: {len(self.available_modes)})") logger.info(f"Initial mode set to: {self.current_display_mode} (index: {self.current_mode_index}, total modes: {len(self.available_modes)})")
self._publish_current_mode_state() self._publish_current_mode_state()
runner = ScreenRunner(_MODULE_CLOCK, _ScreenHost(self), logger) runner = ScreenRunner(_MODULE_CLOCK, _ScreenHost(self), logger)
trimmer = MallocTrimmer()
while True: while True:
# Arms the watchdog after the first frame -- or after the # Arms the watchdog after the first frame -- or after the
@@ -4224,6 +4226,11 @@ class DisplayController:
# it from then on. # it from then on.
display_watchdog.watchdog.loop_pass() display_watchdog.watchdog.loop_pass()
# Between screens, nothing being drawn: every few minutes hand
# the memory glibc is holding for freed images back to the OS
# (src/malloc_tuning.py). A clock read when none is due.
trimmer.maybe_trim()
# Apply plugin enable/disable edits saved via the web UI. The # Apply plugin enable/disable edits saved via the web UI. The
# config-watcher thread only sets the flag; loading/unloading and # config-watcher thread only sets the flag; loading/unloading and
# rebuilding available_modes happens here on the render thread so # rebuilding available_modes happens here on the render thread so
+123
View File
@@ -0,0 +1,123 @@
"""Keep glibc's malloc from holding on to memory the display has freed.
The display process allocates and frees PIL images and numpy buffers all day
from a dozen threads. glibc gives each allocating thread its own malloc arena
(up to 8 x CPU count) and returns little of what is freed inside them to the
OS, so resident memory climbs for hours while the live data stays flat. Two
in-process remedies, both standard library only (ctypes) and both no-ops off
Linux/glibc:
* :func:`cap_arenas` -- ``mallopt(M_ARENA_MAX, 2)``, the in-process twin of the
unit's ``Environment=MALLOC_ARENA_MAX=2``. Units installed before that line
existed never got it (systemd runs the copy in /etc/systemd/system), so the
process applies it itself. Call it before any other thread starts: arenas
already created stay. A ``MALLOC_ARENA_MAX`` set in the environment wins.
* :class:`MallocTrimmer` -- ``malloc_trim(0)`` at most every few minutes,
called from the render loop between screens, where no frame is being drawn.
glibc 2.8+ releases free pages from the middle of every arena, not only the
top of the main heap.
Without glibc (macOS, Windows, musl, the dev server on any of them) nothing is
loaded and every call returns False.
"""
import ctypes
import logging
import os
import sys
import time
from typing import Any, Callable, Optional
logger = logging.getLogger(__name__)
#: glibc's mallopt() parameter number for the arena cap (malloc.h).
M_ARENA_MAX = -8
#: The arena cap applied when the environment does not set one; the same value
#: as the unit's ``MALLOC_ARENA_MAX``.
DEFAULT_ARENA_MAX = 2
#: Seconds between malloc_trim() calls. A trim takes about 1-20 ms on a Pi 4,
#: so this keeps it far from frame timing while still returning memory long
#: before it piles up.
TRIM_INTERVAL_SECONDS = 300.0
_UNLOADED = object()
_libc: Any = _UNLOADED
def _load_libc() -> Optional[Any]:
"""The process's C library if it is glibc with malloc_trim, else None."""
global _libc
if _libc is _UNLOADED:
_libc = None
if sys.platform.startswith('linux'):
try:
libc = ctypes.CDLL(None)
# gnu_get_libc_version is glibc-only, so musl (which has
# mallopt but no malloc_trim) is left alone as a whole.
if all(hasattr(libc, name) for name in
('gnu_get_libc_version', 'malloc_trim', 'mallopt')):
libc.malloc_trim.argtypes = [ctypes.c_size_t]
libc.malloc_trim.restype = ctypes.c_int
libc.mallopt.argtypes = [ctypes.c_int, ctypes.c_int]
libc.mallopt.restype = ctypes.c_int
_libc = libc
except (OSError, AttributeError, TypeError):
logger.debug("glibc malloc controls unavailable", exc_info=True)
return _libc
def cap_arenas(max_arenas: int = DEFAULT_ARENA_MAX) -> bool:
"""Cap glibc's malloc arenas at ``max_arenas``. True when the cap was set.
Skipped when ``MALLOC_ARENA_MAX`` is in the environment: glibc has read it
already, and an operator who set it chose that value.
"""
if os.environ.get('MALLOC_ARENA_MAX'):
return False
libc = _load_libc()
if libc is None:
return False
try:
return bool(libc.mallopt(M_ARENA_MAX, int(max_arenas)))
except Exception: # pylint: disable=broad-except
logger.debug("mallopt(M_ARENA_MAX) failed", exc_info=True)
return False
class MallocTrimmer:
"""Calls ``malloc_trim(0)`` at most once per ``interval`` seconds.
:meth:`maybe_trim` is meant for an idle point of the render loop; it costs
one clock read when no trim is due. The first trim comes one interval
after construction, so start-up's allocations have settled.
"""
def __init__(self, interval: float = TRIM_INTERVAL_SECONDS,
clock: Callable[[], float] = time.monotonic) -> None:
self._interval = interval
self._clock = clock
self._libc = _load_libc()
self._next = clock() + interval
@property
def available(self) -> bool:
return self._libc is not None
def maybe_trim(self) -> bool:
"""Trim if one is due. True when malloc_trim ran and released memory."""
if self._libc is None:
return False
now = self._clock()
if now < self._next:
return False
self._next = now + self._interval
try:
released = bool(self._libc.malloc_trim(0))
except Exception: # pylint: disable=broad-except
logger.debug("malloc_trim failed; not trying again", exc_info=True)
self._libc = None
return False
logger.debug("malloc_trim(0) took %.1f ms, released=%s",
(self._clock() - now) * 1000.0, released)
return released
+204
View File
@@ -0,0 +1,204 @@
"""
Files a plugin writes beside itself at runtime, which an update must keep.
A store update replaces a plugin's directory with a fresh download and then
deletes the old copy. Anything the plugin created there -- OAuth tokens, a
client-secrets file, a PKCE verifier, cached state -- is in no release, so the
fresh download does not contain it and deleting the old copy destroys it. On
2026-10-04 updating calendar 1.2.9 -> 1.2.12 that way deleted its
``token.pickle`` and ``credentials.json``, and the calendar stopped until they
were restored from a backup.
What counts as "the plugin's own local file" is the union of:
* :data:`KNOWN_STATE_PATTERNS` -- secret and state files plugins are known to
write, kept even when a plugin forgot to gitignore them; and
* whatever the plugin's own ``.gitignore`` (old copy or new) excludes. A file
the author ignores is by definition not part of a release.
A file the new release ships is never overwritten: tracked content wins. Byte
code (``__pycache__``, ``*.pyc``) and ``.git`` are never carried, since they
belong to the old code rather than to the user.
"""
from __future__ import annotations
import fnmatch
import os
import re
import shutil
from pathlib import Path
from typing import Iterable, List, Optional, Pattern, Tuple
__all__ = [
'KNOWN_STATE_PATTERNS',
'carry_over_local_files',
'is_known_state_file',
'local_files_to_keep',
]
# Basename globs. Kept even when the plugin's .gitignore does not list them.
KNOWN_STATE_PATTERNS: Tuple[str, ...] = (
'token.pickle',
'*.pickle',
'token.json',
'credentials.json',
'config_secrets.json',
'.pkce_code_verifier',
)
_NEVER_CARRY_DIRS = frozenset({'.git', '__pycache__'})
_NEVER_CARRY_SUFFIXES = ('.pyc', '.pyo')
def is_known_state_file(rel_path: str) -> bool:
"""True when ``rel_path``'s basename is a known secret/state file."""
name = rel_path.replace('\\', '/').rsplit('/', 1)[-1]
return any(fnmatch.fnmatchcase(name, p) for p in KNOWN_STATE_PATTERNS)
class _GitIgnore:
"""The subset of gitignore semantics plugin .gitignore files use.
Supports comments, ``!`` negation (last match wins), a trailing ``/`` for
directory-only patterns, anchoring by a leading or embedded ``/``, ``*``,
``?``, ``[...]`` and ``**``. As in git, a file under an ignored directory
is ignored regardless of later negations.
"""
def __init__(self, lines: Iterable[str]):
self._rules: List[Tuple[Pattern[str], bool, bool]] = []
for raw in lines:
line = raw.rstrip('\n').rstrip()
if not line or line.startswith('#'):
continue
negate = line.startswith('!')
if negate:
line = line[1:]
elif line.startswith('\\'):
line = line[1:]
dir_only = line.endswith('/')
line = line.rstrip('/')
if not line:
continue
anchored = '/' in line
line = line.lstrip('/')
body = self._translate(line)
regex = body if anchored else r'(?:.*/)?' + body
self._rules.append((re.compile(r'\A' + regex + r'\Z'), negate, dir_only))
@staticmethod
def _translate(pattern: str) -> str:
out, i, n = [], 0, len(pattern)
while i < n:
if pattern.startswith('**/', i):
out.append(r'(?:.*/)?')
i += 3
elif pattern.startswith('/**', i) and i + 3 == n:
out.append(r'/.*')
i += 3
elif pattern.startswith('**', i):
out.append(r'.*')
i += 2
elif pattern[i] == '*':
out.append(r'[^/]*')
i += 1
elif pattern[i] == '?':
out.append(r'[^/]')
i += 1
elif pattern[i] == '[':
end = pattern.find(']', i + 1)
if end == -1:
out.append(re.escape('['))
i += 1
else:
cls = pattern[i + 1:end]
if cls.startswith('!'):
cls = '^' + cls[1:]
out.append('[' + cls.replace('\\', '\\\\') + ']')
i = end + 1
else:
out.append(re.escape(pattern[i]))
i += 1
return ''.join(out)
def _decide(self, rel: str, is_dir: bool) -> Optional[bool]:
verdict = None
for regex, negate, dir_only in self._rules:
if dir_only and not is_dir:
continue
if regex.match(rel):
verdict = not negate
return verdict
def ignores(self, rel_path: str) -> bool:
if not self._rules:
return False
parts = rel_path.replace('\\', '/').split('/')
for depth in range(1, len(parts)):
if self._decide('/'.join(parts[:depth]), True):
return True
return bool(self._decide('/'.join(parts), False))
def _read_gitignore(plugin_dir: Path) -> List[str]:
try:
return (plugin_dir / '.gitignore').read_text(
encoding='utf-8', errors='replace').splitlines()
except OSError:
return []
def local_files_to_keep(old_dir: Path, new_dir: Path) -> List[str]:
"""Relative paths (``/``-separated) in ``old_dir`` to copy into ``new_dir``.
Regular files only; symlinks and anything the new release already ships
are skipped.
"""
old_dir, new_dir = Path(old_dir), Path(new_dir)
ignore = _GitIgnore(_read_gitignore(old_dir) + _read_gitignore(new_dir))
keep: List[str] = []
for root, dirs, files in os.walk(old_dir):
dirs[:] = sorted(d for d in dirs if d not in _NEVER_CARRY_DIRS
and not os.path.islink(os.path.join(root, d)))
rel_root = os.path.relpath(root, old_dir)
for name in sorted(files):
if name.endswith(_NEVER_CARRY_SUFFIXES):
continue
full = os.path.join(root, name)
if os.path.islink(full) or not os.path.isfile(full):
continue
rel = name if rel_root == '.' else f"{rel_root}/{name}".replace('\\', '/')
if not (is_known_state_file(rel) or ignore.ignores(rel)):
continue
if os.path.lexists(new_dir / rel):
continue
keep.append(rel)
return keep
def carry_over_local_files(
old_dir: Path, new_dir: Path
) -> Tuple[List[str], List[Tuple[str, str]]]:
"""Copy the plugin's local files from ``old_dir`` into ``new_dir``.
Copies rather than moves, so ``old_dir`` stays a complete copy until the
caller deletes it. Returns ``(copied, failed)`` where ``failed`` pairs a
relative path with the error; the caller should keep ``old_dir`` when
anything failed.
"""
copied: List[str] = []
failed: List[Tuple[str, str]] = []
try:
candidates = local_files_to_keep(old_dir, new_dir)
except OSError as e:
return copied, [('.', str(e))]
for rel in candidates:
dest = Path(new_dir) / rel
try:
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(Path(old_dir) / rel, dest)
copied.append(rel)
except OSError as e:
failed.append((rel, str(e)))
return copied, failed
+33 -4
View File
@@ -22,6 +22,7 @@ from src.plugin_system.plugin_loader import (
contained_plugin_dir, requirements_to_install, contained_plugin_dir, requirements_to_install,
) )
from src.plugin_system.plugin_dirs import BACKUP_MARKER from src.plugin_system.plugin_dirs import BACKUP_MARKER
from src.plugin_system.plugin_local_files import carry_over_local_files
from src.plugin_system.repo_urls import ( from src.plugin_system.repo_urls import (
USER_AGENT, github_api_headers, github_owner_repo, normalize_repo_url, USER_AGENT, github_api_headers, github_owner_repo, normalize_repo_url,
) )
@@ -92,7 +93,9 @@ class _InstallMixin:
raise raise
if installed: if installed:
self._discard_backup(plugin_id, backup_path, "install") self._discard_backup(
plugin_id, backup_path, "install",
new_path=self._existing_install(plugin_id) or plugin_path)
return True return True
self._restore_backup(plugin_id, plugin_path, backup_path, "Install") self._restore_backup(plugin_id, plugin_path, backup_path, "Install")
@@ -133,8 +136,33 @@ class _InstallMixin:
return f"could not set aside {plugin_path}: {e}" return f"could not set aside {plugin_path}: {e}"
return None return None
def _discard_backup(self, plugin_id: str, backup_path: Path, action: str) -> None: def _discard_backup(
"""Remove the set-aside copy after a successful (re)install.""" self, plugin_id: str, backup_path: Path, action: str,
new_path: Optional[Path] = None,
) -> None:
"""Remove the set-aside copy after a successful (re)install.
With ``new_path`` (where the new copy landed), first carries the
plugin's own runtime files -- OAuth tokens, client secrets, anything
its .gitignore excludes -- from the old copy into the new one: no
release contains them, so deleting the old copy would destroy them.
See src/plugin_system/plugin_local_files.py. If any could not be
copied the old copy is kept, so nothing is lost.
"""
if new_path is not None and new_path.is_dir():
copied, failed = carry_over_local_files(backup_path, new_path)
if copied:
self.logger.info(
"Kept %d local file(s) of %s across the %s: %s",
len(copied), plugin_id, action, ", ".join(copied))
if failed:
self.logger.error(
"Could not carry %s's local files into the new copy (%s); "
"the previous copy is kept at %s -- copy them back by hand",
plugin_id,
"; ".join(f"{rel}: {err}" for rel, err in failed),
backup_path)
return
if not self._safe_remove_directory(backup_path): if not self._safe_remove_directory(backup_path):
self.logger.warning( self.logger.warning(
"%s of %s succeeded but the previous copy at %s could not be " "%s of %s succeeded but the previous copy at %s could not be "
@@ -542,7 +570,8 @@ class _InstallMixin:
raise raise
temp_dir = None # Prevent cleanup since we moved it temp_dir = None # Prevent cleanup since we moved it
if backup_path is not None: if backup_path is not None:
self._discard_backup(plugin_id, backup_path, "install") self._discard_backup(
plugin_id, backup_path, "install", new_path=final_path)
# Install dependencies # Install dependencies
self._install_dependencies(final_path) self._install_dependencies(final_path)
+23 -4
View File
@@ -10,6 +10,9 @@ import subprocess # nosec B404 - list-form argv only, no shell # nosemgrep
from pathlib import Path from pathlib import Path
from typing import Dict, Optional, Tuple from typing import Dict, Optional, Tuple
from src.plugin_system.plugin_dirs import BACKUP_MARKER from src.plugin_system.plugin_dirs import BACKUP_MARKER
from src.plugin_system.plugin_local_files import (
KNOWN_STATE_PATTERNS, is_known_state_file,
)
from src.plugin_system.repo_urls import same_repo from src.plugin_system.repo_urls import same_repo
@@ -302,7 +305,11 @@ class _UpdateMixin:
installed = False installed = False
if installed: if installed:
self._discard_backup(plugin_id, backup_path, "update") # install_plugin may land the new copy under the manifest id
# rather than the old directory name.
self._discard_backup(
plugin_id, backup_path, "update",
new_path=self._existing_install(plugin_id) or plugin_path)
return True return True
# Bad network, registry error...: the user keeps a working plugin. # Bad network, registry error...: the user keeps a working plugin.
@@ -509,7 +516,11 @@ class _UpdateMixin:
for line in untracked_result.stdout.strip().split('\n'): for line in untracked_result.stdout.strip().split('\n'):
if line.startswith('??'): if line.startswith('??'):
# Untracked file # Untracked file
file_path = line[3:].strip() file_path = line[3:].strip().strip('"')
# Tokens and secrets stay out of the
# stash (see below), so they alone are
# not a reason to stash.
if not is_known_state_file(file_path):
untracked_files.append(file_path) untracked_files.append(file_path)
# Check for tracked file changes # Check for tracked file changes
@@ -537,9 +548,17 @@ class _UpdateMixin:
if has_changes: if has_changes:
self.logger.info(f"Stashing local changes in {plugin_id} before update") self.logger.info(f"Stashing local changes in {plugin_id} before update")
try: try:
# Use -u to include untracked files in stash # Use -u to include untracked files in stash --
# except the plugin's tokens and secrets, which a
# repo may have forgotten to gitignore. The stash
# is never popped, so a stashed token.pickle would
# vanish from the plugin and break it.
stash_cmd = (
['git', '-C', str(plugin_path), 'stash', 'push', '-u',
'-m', f'LEDMatrix auto-stash before update {plugin_id}', '--', '.']
+ [f':(exclude,glob)**/{p}' for p in KNOWN_STATE_PATTERNS])
stash_result = subprocess.run( stash_result = subprocess.run(
['git', '-C', str(plugin_path), 'stash', 'push', '-u', '-m', f'LEDMatrix auto-stash before update {plugin_id}'], stash_cmd,
capture_output=True, capture_output=True,
text=True, text=True,
timeout=30, timeout=30,
+3 -1
View File
@@ -59,7 +59,7 @@ server has none.
| `unit/test_page_registry.js` | no | The page lifecycle in `js/core/registry.js` (a minimal DOM shim): one `init` per `data-page` root, `destroy` and an aborted `ctx.signal` when htmx swaps it away, a vetoed swap keeps it, lazy page modules, a root removed without htmx swept on the next swap | | `unit/test_page_registry.js` | no | The page lifecycle in `js/core/registry.js` (a minimal DOM shim): one `init` per `data-page` root, `destroy` and an aborted `ctx.signal` when htmx swaps it away, a vetoed swap keeps it, lazy page modules, a root removed without htmx swept on the next swap |
| `unit/test_core_modules.js` | no | `js/core/api.js` (JSON envelope, HTTP/`status: error`/network errors, abort passthrough, the #683 login redirect, same-server paths only) and `js/core/facade.js` (`window.LEDMatrix`, deprecated aliases) | | `unit/test_core_modules.js` | no | `js/core/api.js` (JSON envelope, HTTP/`status: error`/network errors, abort passthrough, the #683 login redirect, same-server paths only) and `js/core/facade.js` (`window.LEDMatrix`, deprecated aliases) |
| `unit/test_overview_reconciliation_poll.js` | no | The Overview's reconciliation-banner poll from `partials/overview.html`, run in a vm: it gives up after a bounded number of requests when the status never says done, runs only while the Overview is on screen (`LEDVisibility`, its own key), and stops once the banner is shown | | `unit/test_overview_reconciliation_poll.js` | no | The Overview's reconciliation-banner poll from `partials/overview.html`, run in a vm: it gives up after a bounded number of requests when the status never says done, runs only while the Overview is on screen (`LEDVisibility`, its own key), and stops once the banner is shown |
| `unit/test_display_partial_ids.js` | no | `partials/display.html`: every literal `getElementById()` in its inline scripts names an id the partial renders, and moving the brightness slider (the shipped script, in a vm with a fake DOM) updates its label without throwing | | `unit/test_display_partial_ids.js` | no | `js/pages/display.js` started on a fake root that answers only for the ids `partials/display.html` renders: every id it looks up (with every listener and timer it set fired) exists, and moving the brightness slider updates its label without throwing |
| `unit/test_general_web_login_token.js` | no | `createToken` from `js/pages/general.js`, imported with a fake DOM and fetch: a created API token clears the form's `data-dirty` mark (so a reload does not ask "Leave site?"), a refused one keeps it | | `unit/test_general_web_login_token.js` | no | `createToken` from `js/pages/general.js`, imported with a fake DOM and fetch: a created API token clears the form's `data-dirty` mark (so a reload does not ask "Leave site?"), a refused one keeps it |
| `unit/test_plugin_action_delegation.js` | no | The document-level card-action delegation and `handlePluginAction` from `plugins_manager.js`, run with the handler inside an IIFE as in the real file: each action is handled once, a Starlark app uninstall goes to `DELETE /starlark/apps/<id>`, and an uninstall is confirmed once | | `unit/test_plugin_action_delegation.js` | no | The document-level card-action delegation and `handlePluginAction` from `plugins_manager.js`, run with the handler inside an IIFE as in the real file: each action is handled once, a Starlark app uninstall goes to `DELETE /starlark/apps/<id>`, and an uninstall is confirmed once |
| `dom/test_installed_dom.js` | yes | The toolbar in a real DOM: pill/search/sort interaction, the HTMX partial re-swap, and a `getComputedStyle` check that `.filter-pill[data-active]` really matches the emitted markup | | `dom/test_installed_dom.js` | yes | The toolbar in a real DOM: pill/search/sort interaction, the HTMX partial re-swap, and a `getComputedStyle` check that `.filter-pill[data-active]` really matches the emitted markup |
@@ -70,6 +70,8 @@ server has none.
| `dom/test_operation_history_page.js` | yes | The Operation History tab (`js/pages/operation-history.js`): one request per swap and per Refresh, the plugin filter filled once, paging, filters, search, Clear, error/login states, hostile values stay text | | `dom/test_operation_history_page.js` | yes | The Operation History tab (`js/pages/operation-history.js`): one request per swap and per Refresh, the plugin filter filled once, paging, filters, search, Clear, error/login states, hostile values stay text |
| `dom/test_raw_json_page.js` | yes | The Config Editor tab (`js/pages/raw-json.js`): one POST per Save after repeated swaps, Format/Validate, invalid JSON never sent, a save survives a swap, the old global entry points | | `dom/test_raw_json_page.js` | yes | The Config Editor tab (`js/pages/raw-json.js`): one POST per Save after repeated swaps, Format/Validate, invalid JSON never sent, a save survives a swap, the old global entry points |
| `dom/test_schedule_page.js` | yes | The Schedule tab (`js/pages/schedule.js`) with the real `schedule-picker` widget: both pickers drawn once per swap from the saved config, one notification per save answer after repeated swaps, the brightness label, a late widget waited for, the old global entry points | | `dom/test_schedule_page.js` | yes | The Schedule tab (`js/pages/schedule.js`) with the real `schedule-picker` widget: both pickers drawn once per swap from the saved config, one notification per save answer after repeated swaps, the brightness label, a late widget waited for, the old global entry points |
| `dom/test_visibility_service.js` | yes (no server) | `js/core/visibility.js` with the real `LEDVisibility` from `app-shell.js` and the real registry: start/stop with the active tab and the browser tab's visibility, no interval while hidden or after a swap-out, registrations independent, the no-`LEDVisibility` fallback |
| `dom/test_display_page.js` | yes | The Display tab (`js/pages/display.js`) with the real `plugin-order-list` widget and `LEDVisibility`: one page, one sync interval and one action per control after repeated swaps, the sync poll only while on screen and never after a swap-out, sync states as text, the debounced scroll-speed hint, `updateSyncUI`'s entry point |
| `dom/test_general_page.js` | yes | The General tab (`js/pages/general.js`) with the real `timezone-selector` widget: the picker drawn once per swap, one request per Security action after repeated swaps, hostile token names stay text, refused/network/login answers, a write survives a swap, `webLogin`'s entry points | | `dom/test_general_page.js` | yes | The General tab (`js/pages/general.js`) with the real `timezone-selector` widget: the picker drawn once per swap, one request per Security action after repeated swaps, hostile token names stay text, refused/network/login answers, a write survives a swap, `webLogin`'s entry points |
| `dom/test_backup_restore_page.js` | yes | The Backup & Restore tab (`js/pages/backup-restore.js`): one request per action after repeated swaps, the upload and restore options, reads cancelled and writes not on a swap, hostile names stay text, the old global entry points | | `dom/test_backup_restore_page.js` | yes | The Backup & Restore tab (`js/pages/backup-restore.js`): one request per action after repeated swaps, the upload and restore options, reads cancelled and writes not on a swap, hostile names stay text, the old global entry points |
| `dom/test_tools_sections.js` | yes | The Tools tab's MQTT bridge and Pixlet editor sections: form prefill, the write-only password (blank means unchanged), the running-session banner and countdown, and that the editor link points at the host you loaded the page from | | `dom/test_tools_sections.js` | yes | The Tools tab's MQTT bridge and Pixlet editor sections: form prefill, the write-only password (blank means unchanged), the running-session banner and countdown, and that the editor link points at the host you loaded the page from |
+325
View File
@@ -0,0 +1,325 @@
// The Display tab as a page module (static/v3/js/pages/display.js), in a
// real DOM (jsdom) with the real server-rendered partial, the real
// plugin-order-list widget, the real window.LEDVisibility (app-shell.js)
// behind ctx.visibility, and the real API's answer shapes. Built like
// test_cache_page.js:
//
// * the partial ships no <script> and no inline handlers; its root is
// data-page="display"
// * after five swaps: one mounted page, the Vegas order drawn once, each
// control acting once (brightness, resolution, Vegas and double-sided
// toggles, the Advanced section toggle, one debounced scroll-speed hint
// request)
// * the sync status is polled only while the Display tab is on screen and
// the browser tab visible: no interval runs while hidden, and none after
// the partial is swapped out
// * sync states drawn as text; a failed poll says "unavailable", a login
// redirect draws nothing
// * a widget that loads late is waited for, and a page swapped away while
// waiting starts nothing
// * window.updateSyncUI's entry point still works
const http = require('http');
const fs = require('fs');
const path = require('path');
const { pathToFileURL } = require('url');
const { JSDOM, VirtualConsole } = require('jsdom');
const BASE = process.env.BASE || 'http://localhost:5000';
const JS = path.resolve(__dirname, '../../../web_interface/static/v3/js');
const get = p => new Promise((res, rej) =>
http.get(BASE + p, r => { let d = ''; r.on('data', c => d += c); r.on('end', () => res(d)); }).on('error', rej));
const load = f => import(pathToFileURL(path.join(JS, f)).href);
const tick = ms => new Promise(r => setTimeout(r, ms || 0));
let pass = 0, fail = 0;
const ok = (l, c, x) => c ? (pass++, console.log(' ok ' + l))
: (fail++, console.log(' FAIL ' + l + (x !== undefined ? ' -> ' + JSON.stringify(x).slice(0, 300) : '')));
(async () => {
const partial = await get('/partials/display');
const realSync = JSON.parse(await get('/api/v3/sync/status'));
const smooth = JSON.parse(await get('/api/v3/config/scroll-speed-advice?speed=50&min=1&max=200'));
const rough = JSON.parse(await get('/api/v3/config/scroll-speed-advice?speed=37&min=1&max=200'));
const { createRegistry } = await load('core/registry.js');
const { createApi } = await load('core/api.js');
const { createVisibility } = await load('core/visibility.js');
const displayPage = await load('pages/display.js');
console.log('\n── Display tab: page module (real DOM) ──');
ok('the partial ships no inline script', !/<script/i.test(partial));
ok('the partial has no inline handlers', !/\son(click|change|input)=/i.test(partial));
ok('the partial root is data-page="display"', /data-page="display"/.test(partial));
ok('the Advanced toggle names its action',
/data-action="toggle-section"\s+data-section="display-section-advanced-hardware"/.test(partial));
ok('the real sync status answers in the shape the page reads',
realSync.status === 'success' && realSync.data && typeof realSync.data.state === 'string', realSync);
ok('the real scroll-speed advice answers in the shape the page reads',
smooth.status === 'success' && smooth.data.applied && Array.isArray(rough.data.alternatives)
&& rough.data.alternatives.length > 0, rough);
const errs = [];
const logged = [];
const vc = new VirtualConsole();
vc.on('jsdomError', e => errs.push(String(e.message || e).split('\n')[0]));
vc.on('error', (...a) => logged.push(a.join(' ')));
const dom = new JSDOM(`<!doctype html><html><body><div id="display-content">${partial}</div></body></html>`,
{ url: BASE + '/', virtualConsole: vc, runScripts: 'outside-only' });
const { window } = dom;
const doc = window.document;
const panel = doc.getElementById('display-content');
// The browser tab's visibility and the app's active tab, under test control.
let hidden = false;
Object.defineProperty(doc, 'hidden', { get: () => hidden, configurable: true });
const setHidden = v => { hidden = v; doc.dispatchEvent(new window.Event('visibilitychange')); };
const setTab = tab => doc.dispatchEvent(new window.CustomEvent('ledmatrix:tab-changed', { detail: { tab } }));
// Intervals, counted. Timeouts (the hint's debounce, the widget retry) are real.
const intervals = new Map();
let nextInterval = 1;
window.setInterval = (fn, ms) => { const id = nextInterval++; intervals.set(id, { fn, ms }); return id; };
window.clearInterval = id => { intervals.delete(id); };
const fireIntervals = () => [...intervals.values()].forEach(i => i.fn());
const HOSTILE = '<img src=x onerror="window.pwned=1">';
const plugins = [
{ id: 'clock', name: 'Clock', enabled: true },
{ id: 'weather', name: HOSTILE, enabled: true },
];
let syncAnswer = { status: 'success', data: { role: 'leader', state: 'no_peer' } };
let syncMode = 'ok';
let advice = smooth;
const requests = [];
function fakeFetch(url, init = {}) {
requests.push(url);
const respond = (status, body, headers) => Promise.resolve({
status, ok: status >= 200 && status < 300,
headers: { get: h => (headers || {})[h] || null },
json: () => Promise.resolve(body),
text: () => Promise.resolve(JSON.stringify(body)),
});
if (url === '/api/v3/plugins/installed') return respond(200, { status: 'success', data: { plugins } });
if (url.startsWith('/api/v3/config/scroll-speed-advice?')) return respond(200, advice);
if (url === '/api/v3/sync/status') {
if (syncMode === 'network') return Promise.reject(new TypeError('Failed to fetch'));
if (syncMode === 'login') return respond(401, { status: 'error' }, { 'X-LEDMatrix-Login': '/login' });
return respond(200, syncAnswer);
}
return respond(404, { status: 'error', message: 'unexpected ' + url });
}
window.fetch = fakeFetch;
// The shell: LEDVisibility (no Alpine here, so the active tab is the last
// ledmatrix:tab-changed; the SSE streams open stand-in EventSources), and
// the shared toggleSection the Advanced button calls.
window.getApp = () => null;
window.EventSource = class { addEventListener() {} removeEventListener() {} close() {} };
window.eval(fs.readFileSync(path.join(JS, 'app-shell.js'), 'utf8'));
const toggled = [];
window.toggleSection = id => toggled.push(id);
window.eval(fs.readFileSync(path.join(JS, 'widgets/plugin-order-list.js'), 'utf8'));
const widget = window.PluginOrderList;
ok('the widget script defines PluginOrderList', !!(widget && widget.init));
const visibility = createVisibility({ window });
const registry = createRegistry({
document: doc,
context: { api: createApi({ fetch: fakeFetch }), notify: () => {} },
mountContext: ctx => ({ visibility: visibility.forPage(ctx) }),
});
registry.register('display', displayPage);
const $ = id => doc.getElementById(id);
const root = () => doc.querySelector('[data-page="display"]');
const count = prefix => requests.filter(u => u.startsWith(prefix)).length;
const syncPolls = () => count('/api/v3/sync/status');
async function swap(html) {
panel.dispatchEvent(new window.CustomEvent('htmx:beforeSwap', { bubbles: true, detail: { target: panel, shouldSwap: true } }));
panel.innerHTML = html === undefined ? partial : html;
panel.dispatchEvent(new window.CustomEvent('htmx:afterSwap', { bubbles: true, detail: { target: panel } }));
await tick(20);
}
function fire(el, type) { el.dispatchEvent(new window.Event(type, { bubbles: true })); }
function setRole(role) { $('sync_role').value = role; fire($('sync_role'), 'change'); }
setTab('display');
await registry.start();
await tick(200);
// ── first load ──────────────────────────────────────────────────────────
ok('one plugin-list request on start', count('/api/v3/plugins/installed') === 1, requests);
ok('one scroll-speed hint request on start (after the debounce)', count('/api/v3/config/scroll-speed-advice') === 1, requests);
ok('the saved role is standalone: no sync request, no interval work',
$('sync_role').value === 'standalone' && syncPolls() === 0, [$('sync_role').value, syncPolls()]);
ok('the sync poll interval runs while the tab is on screen', intervals.size === 1
&& [...intervals.values()][0].ms === 5000, intervals.size);
ok('the status bar is hidden for standalone', $('sync_status_bar').classList.contains('hidden'));
// ── five swaps ──────────────────────────────────────────────────────────
for (let i = 0; i < 5; i++) await swap();
await tick(200);
ok('one mounted page after five swaps', registry.list().length === 1, registry.list().length);
ok('one sync interval, not six', intervals.size === 1, intervals.size);
ok('one plugin-list request per swap', count('/api/v3/plugins/installed') === 6, count('/api/v3/plugins/installed'));
ok('the Vegas order drawn once, not stacked', doc.querySelectorAll('#vegas_plugin_order .plugin-order-item').length === 2,
doc.querySelectorAll('#vegas_plugin_order .plugin-order-item').length);
ok('a hostile plugin name is shown as text', $('vegas_plugin_order').textContent.includes(HOSTILE)
&& !doc.querySelector('#vegas_plugin_order img') && !window.pwned);
// ── the controls ────────────────────────────────────────────────────────
$('brightness').value = '42';
fire($('brightness'), 'input');
ok('the brightness value follows the slider', $('brightness-value').textContent === '42', $('brightness-value').textContent);
$('rows').value = '32'; $('cols').value = '64'; $('chain_length').value = '3'; $('parallel').value = '2';
fire($('parallel'), 'input');
ok('the resolution readout is cols x chain by rows x parallel',
$('display-resolution-value').textContent === '192 × 64 pixels', $('display-resolution-value').textContent);
$('orientation').value = '90';
fire($('orientation'), 'change');
ok('...swapped for a 90-degree orientation', $('display-resolution-value').textContent === '64 × 192 pixels',
$('display-resolution-value').textContent);
$('rows').value = '';
fire($('rows'), 'input');
ok('...and a dash while a field is empty', $('display-resolution-value').textContent === '—');
for (const [box, settings, shown] of [['vegas_scroll_enabled', 'vegas_scroll_settings', 'block'],
['double_sided_enabled', 'double_sided_settings', 'grid']]) {
$(box).checked = true; fire($(box), 'change');
const on = $(settings).style.display;
$(box).checked = false; fire($(box), 'change');
ok(`${box} shows and hides its settings`, on === shown && $(settings).style.display === 'none',
[on, $(settings).style.display]);
}
root().querySelector('[data-action="toggle-section"]').click();
ok('the Advanced button toggles its section once', toggled.join() === 'display-section-advanced-hardware', toggled);
// ── the scroll-speed hint ───────────────────────────────────────────────
const hints = count('/api/v3/config/scroll-speed-advice');
advice = rough;
for (const v of ['36', '37', '38']) { $('vegas_scroll_speed').value = v; fire($('vegas_scroll_speed'), 'input'); }
ok('the speed value follows the slider', $('vegas_scroll_speed_value').textContent === '38');
await tick(250);
ok('three quick moves make one hint request', count('/api/v3/config/scroll-speed-advice') === hints + 1,
count('/api/v3/config/scroll-speed-advice') - hints);
ok('...for the last speed', requests.filter(u => u.includes('advice')).pop().includes('speed=38'));
const buttons = $('vegas_scroll_speed_hint').querySelectorAll('button');
ok('a rough speed offers the smooth ones', buttons.length === rough.data.alternatives.length
&& /will run as/.test($('vegas_scroll_speed_hint').textContent), $('vegas_scroll_speed_hint').textContent);
buttons[0].click();
ok('picking one sets the slider', $('vegas_scroll_speed').value === String(Math.round(rough.data.alternatives[0].pixels_per_second))
&& $('vegas_scroll_speed_value').textContent === $('vegas_scroll_speed').value, $('vegas_scroll_speed').value);
advice = smooth;
await tick(250);
ok('...and asks again', count('/api/v3/config/scroll-speed-advice') === hints + 2);
ok('a smooth speed says so', /^Smooth on this panel/.test($('vegas_scroll_speed_hint').textContent),
$('vegas_scroll_speed_hint').textContent);
// ── sync: the role ──────────────────────────────────────────────────────
setRole('leader');
await tick(20);
ok('choosing Leader shows the status bar', !$('sync_status_bar').classList.contains('hidden'));
ok('...hides Position', $('setting-display-sync_follower_position').style.display === 'none');
ok('...and asks for the status once', syncPolls() === 1, syncPolls());
ok('...drawn as text', $('sync_status_content').textContent.includes('No follower detected'),
$('sync_status_content').textContent);
setRole('follower');
await tick(20);
ok('choosing Follower shows Position', $('setting-display-sync_follower_position').style.display === '');
// ── sync: the poll runs only while on screen ────────────────────────────
let polls = syncPolls();
fireIntervals();
await tick(20);
ok('each interval tick polls once', syncPolls() === polls + 1, syncPolls() - polls);
setTab('logs');
ok('switching to another tab clears the interval', intervals.size === 0, intervals.size);
polls = syncPolls();
setTab('display');
await tick(20);
ok('switching back polls at once', syncPolls() === polls + 1 && intervals.size === 1, [syncPolls() - polls, intervals.size]);
setHidden(true);
ok('hiding the browser tab clears the interval', intervals.size === 0, intervals.size);
polls = syncPolls();
setHidden(false);
await tick(20);
ok('showing it polls at once', syncPolls() === polls + 1 && intervals.size === 1, [syncPolls() - polls, intervals.size]);
// ── sync: states ────────────────────────────────────────────────────────
async function poll() { fireIntervals(); await tick(20); return $('sync_status_content').textContent; }
syncAnswer = { status: 'success', data: { role: 'leader', state: 'connected', peer_ip: HOSTILE, peer_chain: 2 } };
ok('a connected follower, its address as text', (await poll()).includes('Follower connected — ' + HOSTILE)
&& !$('sync_status_content').querySelector('img'), $('sync_status_content').textContent);
syncAnswer = { status: 'success', data: { role: 'leader', state: 'incompatible', error: 'rows differ ' + HOSTILE } };
ok('incompatible panels show the reason as text', (await poll()).includes('incompatible')
&& !$('sync_error_detail').classList.contains('hidden') && $('sync_error_text').textContent === 'rows differ ' + HOSTILE
&& !$('sync_error_detail').querySelector('img'));
syncAnswer = { status: 'success', data: realSync.data };
ok('the real server\'s answer is drawn', (await poll()).length > 0, $('sync_status_content').textContent);
syncMode = 'network';
ok('a failed poll says unavailable', (await poll()).includes('Sync status unavailable'));
syncAnswer = { status: 'success', data: { role: 'follower', state: 'follower', leader_ip: '10.0.0.2' } };
syncMode = 'ok';
ok('receiving from a leader', (await poll()).includes('Receiving from leader — 10.0.0.2'));
syncMode = 'login';
ok('a login redirect draws nothing', (await poll()).includes('Receiving from leader'));
syncMode = 'ok';
// ── standalone stops asking ─────────────────────────────────────────────
setRole('standalone');
polls = syncPolls();
fireIntervals();
await tick(20);
ok('standalone hides the bar and the poll asks nothing',
$('sync_status_bar').classList.contains('hidden') && syncPolls() === polls, syncPolls() - polls);
// ── window.updateSyncUI ─────────────────────────────────────────────────
$('sync_role').value = 'leader';
polls = syncPolls();
displayPage.updateSyncUI();
await tick(20);
ok('updateSyncUI() applies the role and asks once',
!$('sync_status_bar').classList.contains('hidden') && syncPolls() === polls + 1, syncPolls() - polls);
// ── swapped out ─────────────────────────────────────────────────────────
await swap('<p>another tab</p>');
ok('nothing left mounted', registry.list().length === 0, registry.list().length);
ok('no interval left running', intervals.size === 0, intervals.size);
polls = syncPolls();
setTab('overview');
setTab('display');
setHidden(true);
setHidden(false);
await tick(20);
ok('a swapped-out page never polls again', syncPolls() === polls && intervals.size === 0, syncPolls() - polls);
const hintsGone = count('/api/v3/config/scroll-speed-advice');
await swap();
$('vegas_scroll_speed').value = '40';
fire($('vegas_scroll_speed'), 'input');
await swap('<p>another tab</p>');
await tick(250);
ok('a hint still waiting out its debounce at the swap is never asked for',
count('/api/v3/config/scroll-speed-advice') === hintsGone, count('/api/v3/config/scroll-speed-advice') - hintsGone);
// ── the widget loads late ───────────────────────────────────────────────
delete window.PluginOrderList;
const beforeLate = count('/api/v3/plugins/installed');
await swap();
ok('no list request while the widget is missing', count('/api/v3/plugins/installed') === beforeLate);
window.PluginOrderList = widget;
await tick(150);
ok('the list starts once the widget arrives', count('/api/v3/plugins/installed') === beforeLate + 1);
delete window.PluginOrderList;
await swap();
const beforeGone = count('/api/v3/plugins/installed');
await swap('<p>another tab</p>');
window.PluginOrderList = widget;
await tick(250);
ok('a page swapped away while waiting starts nothing', count('/api/v3/plugins/installed') === beforeGone);
ok('no console errors', logged.length === 0, logged);
ok('no DOM errors', errs.length === 0, errs);
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);
})().catch(e => { console.error(e); process.exit(1); });
+211
View File
@@ -0,0 +1,211 @@
// The page-visibility service (static/v3/js/core/visibility.js), in a real
// DOM (jsdom) with the real window.LEDVisibility from app-shell.js and the
// real page registry, wired the way core/boot.js wires them (each mount gets
// ctx.visibility from mountContext):
//
// * whileVisible(start, stop) runs start() only while the page's tab is the
// active tab AND the browser tab is visible, stop() when either changes
// * every(ms, fn) calls fn at once and then on an interval while visible;
// no interval is left running while hidden
// * everything a page registered stops when the page is swapped out, and a
// page that registers after it was destroyed starts nothing
// * registrations never replace each other (two timers on one page, two
// pages, or a classic partial's own LEDVisibility key)
// * without LEDVisibility, the browser tab's visibility alone decides
//
// Needs jsdom but no server.
const fs = require('fs');
const path = require('path');
const { pathToFileURL } = require('url');
const { JSDOM, VirtualConsole } = require('jsdom');
const JS = path.resolve(__dirname, '../../../web_interface/static/v3/js');
const load = f => import(pathToFileURL(path.join(JS, f)).href);
const tick = ms => new Promise(r => setTimeout(r, ms || 0));
let pass = 0, fail = 0;
const ok = (l, c, x) => c ? (pass++, console.log(' ok ' + l))
: (fail++, console.log(' FAIL ' + l + (x !== undefined ? ' -> ' + JSON.stringify(x).slice(0, 300) : '')));
(async () => {
const { createRegistry } = await load('core/registry.js');
const { createVisibility } = await load('core/visibility.js');
console.log('\n── Page visibility service (real DOM, real LEDVisibility) ──');
const errs = [];
const logged = [];
const vc = new VirtualConsole();
vc.on('jsdomError', e => errs.push(String(e.message || e).split('\n')[0]));
vc.on('error', (...a) => logged.push(a.join(' ')));
const dom = new JSDOM('<!doctype html><html><body><div id="display-content"></div><div id="logs-content"></div></body></html>',
{ url: 'http://localhost/', virtualConsole: vc, runScripts: 'outside-only' });
const { window } = dom;
const doc = window.document;
// The browser tab's visibility, under the test's control.
let hidden = false;
Object.defineProperty(doc, 'hidden', { get: () => hidden, configurable: true });
function setHidden(value) {
hidden = value;
doc.dispatchEvent(new window.Event('visibilitychange'));
}
function setTab(tab) {
doc.dispatchEvent(new window.CustomEvent('ledmatrix:tab-changed', { detail: { tab } }));
}
// Intervals, counted: the point is that none is left running.
const intervals = new Map();
let nextInterval = 1;
window.setInterval = (fn, ms) => { const id = nextInterval++; intervals.set(id, { fn, ms }); return id; };
window.clearInterval = id => { intervals.delete(id); };
const fireIntervals = () => [...intervals.values()].forEach(i => i.fn());
// The real LEDVisibility (app-shell.js). Alpine is absent, so the active
// tab is the last ledmatrix:tab-changed. The SSE streams are not under
// test: they open stand-in EventSources.
window.getApp = () => null;
window.EventSource = class { addEventListener() {} removeEventListener() {} close() {} };
window.eval(fs.readFileSync(path.join(JS, 'app-shell.js'), 'utf8'));
ok('app-shell.js defines LEDVisibility', !!(window.LEDVisibility && window.LEDVisibility.onActive));
const visibility = createVisibility({ window });
const registry = createRegistry({
document: doc,
context: {},
mountContext: ctx => ({ visibility: visibility.forPage(ctx) }),
});
const log = [];
let polls = 0;
const handles = [];
registry.register('display', {
init(root, ctx) {
handles.push(ctx.visibility);
ctx.visibility.whileVisible(() => log.push('start'), () => log.push('stop'));
ctx.visibility.every(5000, () => { polls++; });
},
});
let otherRuns = 0;
registry.register('logs', {
init(root, ctx) { ctx.visibility.every(1000, () => { otherRuns++; }); },
});
const panel = doc.getElementById('display-content');
async function swap(html) {
panel.dispatchEvent(new window.CustomEvent('htmx:beforeSwap', { bubbles: true, detail: { target: panel, shouldSwap: true } }));
panel.innerHTML = html;
panel.dispatchEvent(new window.CustomEvent('htmx:afterSwap', { bubbles: true, detail: { target: panel } }));
await tick(10);
}
// A classic partial's own registration, keyed by its tab name: the
// service's registrations must not replace it, nor it them.
let classic = 0;
window.LEDVisibility.onActive('display', () => { classic++; }, () => {});
setTab('overview');
await registry.start();
await swap('<div data-page="display"></div>');
// ── mounted on another tab ──────────────────────────────────────────────
ok('mounted while another tab is active: nothing starts', log.length === 0 && polls === 0, [log, polls]);
ok('...and no interval runs', intervals.size === 0, intervals.size);
ok('isVisible() is false', handles[0].isVisible() === false);
ok('the page\'s tab is its name', handles[0].tab === 'display');
// ── its tab comes on screen ─────────────────────────────────────────────
setTab('display');
ok('switching to the tab runs start()', log.join() === 'start', log);
ok('every() calls fn at once', polls === 1, polls);
ok('...and sets one interval at the asked period', intervals.size === 1 && [...intervals.values()][0].ms === 5000,
[...intervals.values()].map(i => i.ms));
ok('isVisible() is true', handles[0].isVisible() === true);
ok('the classic registration still runs alongside', classic === 1, classic);
fireIntervals();
fireIntervals();
ok('fn runs on each interval', polls === 3, polls);
// ── the browser tab is hidden, then shown ───────────────────────────────
setHidden(true);
ok('hiding the browser tab runs stop()', log.join() === 'start,stop', log);
ok('...and clears the interval', intervals.size === 0, intervals.size);
ok('isVisible() is false while hidden', handles[0].isVisible() === false);
setHidden(false);
ok('showing it again runs start()', log.join() === 'start,stop,start', log);
ok('...and fn at once, with one interval again', polls === 4 && intervals.size === 1, [polls, intervals.size]);
// ── another tab ─────────────────────────────────────────────────────────
setTab('logs');
ok('switching away runs stop() and clears the interval', log.join() === 'start,stop,start,stop' && intervals.size === 0,
[log, intervals.size]);
setTab('display');
ok('switching back restarts it', log.length === 5 && polls === 5 && intervals.size === 1, [log, polls]);
// ── the partial is swapped out while on screen ──────────────────────────
await swap('<p>no page here</p>');
ok('a swap-out stops it', log[log.length - 1] === 'stop', log);
ok('...and leaves no interval running', intervals.size === 0, intervals.size);
const before = [log.length, polls];
setTab('overview');
setTab('display');
setHidden(true);
setHidden(false);
ok('a destroyed page never starts again', log.length === before[0] && polls === before[1], [log, polls]);
ok('the classic registration keeps running after the swap', classic === 5, classic);
// ── five swaps, then one page ───────────────────────────────────────────
for (let i = 0; i < 5; i++) await swap('<div data-page="display"></div>');
ok('after five swaps one interval runs, not five', intervals.size === 1, intervals.size);
const pollsBefore = polls;
fireIntervals();
ok('...and one poll per tick', polls === pollsBefore + 1, polls - pollsBefore);
// ── two timers on one page, and an end function ─────────────────────────
const h = handles[handles.length - 1];
let a = 0, b = 0;
const endA = h.every(100, () => { a++; });
h.every(200, () => { b++; });
ok('two more timers on one page both start', a === 1 && b === 1 && intervals.size === 3, [a, b, intervals.size]);
endA();
endA();
ok('an end function stops just its own timer (twice is harmless)', intervals.size === 2, intervals.size);
// ── a page that registers after it was destroyed ────────────────────────
const gone = handles[handles.length - 1];
await swap('<p>gone</p>');
ok('the swap-out clears every timer the page had', intervals.size === 0, intervals.size);
let late = 0;
const end = gone.every(1000, () => { late++; });
ok('registering after destroy starts nothing', late === 0 && intervals.size === 0 && typeof end === 'function');
// ── a start() that throws ───────────────────────────────────────────────
await swap('<div data-page="display"></div>');
const h2 = handles[handles.length - 1];
const loggedBefore = logged.length;
let afterThrow = 0;
h2.whileVisible(() => { throw new Error('boom'); }, () => {});
h2.whileVisible(() => { afterThrow++; }, () => {});
ok('a throwing start() is logged', logged.length === loggedBefore + 1 && /boom|start failed/.test(logged.join()),
logged.slice(loggedBefore));
ok('...and the next registration still starts', afterThrow === 1);
await swap('<p>gone</p>');
// ── without LEDVisibility (a page outside base.html) ────────────────────
const bare = createVisibility({ window, tracker: () => null }).forPage({ name: 'standalone', signal: new window.AbortController().signal });
const seen = [];
bare.whileVisible(() => seen.push('start'), () => seen.push('stop'));
ok('without LEDVisibility, a visible document starts at once', seen.join() === 'start', seen);
setHidden(true);
ok('...and hiding it stops', seen.join() === 'start,stop', seen);
ok('...isVisible() follows the document', bare.isVisible() === false);
setHidden(false);
ok('...and showing it starts again', seen.join() === 'start,stop,start', seen);
ok('registrations need functions', (() => { try { h2.whileVisible(null, null); return false; } catch { return true; } })());
ok('every() needs a positive period', (() => { try { h2.every(0, () => {}); return false; } catch { return true; } })());
ok('the logs page was never on screen: it never ran', otherRuns === 0, otherRuns);
ok('no DOM errors', errs.length === 0, errs);
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);
})().catch(e => { console.error(e); process.exit(1); });
+2 -1
View File
@@ -37,7 +37,8 @@ const DOM = ['dom/test_installed_dom.js', 'dom/test_store_dom.js', 'dom/test_no_
'dom/test_tools_sections.js', 'dom/test_cache_page.js', 'dom/test_tools_sections.js', 'dom/test_cache_page.js',
'dom/test_durations_page.js', 'dom/test_operation_history_page.js', 'dom/test_durations_page.js', 'dom/test_operation_history_page.js',
'dom/test_raw_json_page.js', 'dom/test_backup_restore_page.js', 'dom/test_raw_json_page.js', 'dom/test_backup_restore_page.js',
'dom/test_schedule_page.js', 'dom/test_general_page.js']; 'dom/test_schedule_page.js', 'dom/test_general_page.js',
'dom/test_visibility_service.js', 'dom/test_display_page.js'];
function reachable(url) { function reachable(url) {
return new Promise(res => { return new Promise(res => {
+74 -66
View File
@@ -1,21 +1,22 @@
// The Display tab's inline script must only look up elements the partial // The Display tab's page module must only look up elements the partial
// renders. // renders.
// //
// Its brightness slider handler also wrote to #brightness-display, a "LED // Its brightness slider handler once also wrote to #brightness-display, a
// brightness: N%" line that #387 removed from partials/display.html. The // "LED brightness: N%" line that #387 removed from partials/display.html. The
// lookup returned null, so every movement of the slider threw a TypeError. // lookup returned null, so every movement of the slider threw a TypeError.
// This checks every literal getElementById() in the partial's inline scripts // This imports the shipped module (static/v3/js/pages/display.js), starts it
// against the ids its markup renders, and runs the shipped script in a vm // on a fake root that answers only for the ids the partial's markup renders
// with a fake DOM (null for an id the markup lacks, as in a browser) to move // (null for any other, as in a browser), fires every listener it registered,
// the slider. // and checks that every id it asked for exists. Then it moves the slider.
// //
// No jsdom and no server needed. // No jsdom and no server needed.
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const vm = require('vm'); const { pathToFileURL } = require('url');
const PARTIAL = path.resolve(__dirname, '../../../web_interface/templates/v3/partials/display.html'); const PARTIAL = path.resolve(__dirname, '../../../web_interface/templates/v3/partials/display.html');
const JS = path.resolve(__dirname, '../../../web_interface/static/v3/js');
let pass = 0, fail = 0; let pass = 0, fail = 0;
const ok = (label, cond, extra) => cond const ok = (label, cond, extra) => cond
@@ -23,86 +24,93 @@ const ok = (label, cond, extra) => cond
: (fail++, console.log(' FAIL ' + label + (extra !== undefined ? ' ' + JSON.stringify(extra) : ''))); : (fail++, console.log(' FAIL ' + label + (extra !== undefined ? ' ' + JSON.stringify(extra) : '')));
const html = fs.readFileSync(PARTIAL, 'utf8'); const html = fs.readFileSync(PARTIAL, 'utf8');
const blocks = [...html.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script[^>]*>/gi)]; const rendered = new Set([...html.matchAll(/\bid="([^"{}]+)"/g)].map(m => m[1]));
const scripts = blocks.map(m => m[1]);
// The markup is what lies between the script blocks (sliced around them, not
// a replace(), which CodeQL reads as an incomplete HTML sanitizer).
let markup = '';
let from = 0;
for (const m of blocks) {
markup += html.slice(from, m.index);
from = m.index + m[0].length;
}
markup += html.slice(from);
const rendered = new Set([...markup.matchAll(/\bid="([^"{}]+)"/g)].map(m => m[1]));
console.log('\n── Display partial: element lookups ──');
// 1. Static: every literal lookup names an id the partial renders.
const lookups = scripts.flatMap(s => [...s.matchAll(/getElementById\('([^']+)'\)/g)].map(m => m[1]));
const missing = [...new Set(lookups.filter(id => !rendered.has(id)))];
ok('the inline scripts look elements up', lookups.length > 0, lookups.length);
ok('every looked-up id is rendered by the partial', missing.length === 0, missing);
// 2. Behaviour: moving the brightness slider updates its label and throws nothing.
function fakeElement(id) { function fakeElement(id) {
const listeners = {}; const listeners = {};
const classes = new Set(); const classes = new Set();
return { return {
id, value: '', textContent: '', min: '', max: '', checked: false, id, value: '1', textContent: '', min: '', max: '', checked: false,
style: {}, dataset: {}, className: '', style: {}, dataset: {}, className: '',
classList: { classList: {
add: c => classes.add(c), remove: c => classes.delete(c), add: c => classes.add(c), remove: c => classes.delete(c),
toggle: (c, on) => (on === undefined ? (classes.has(c) ? classes.delete(c) : classes.add(c)) : (on ? classes.add(c) : classes.delete(c))),
contains: c => classes.has(c), contains: c => classes.has(c),
}, },
addEventListener: (type, fn) => { (listeners[type] ||= []).push(fn); }, addEventListener: (type, fn) => { (listeners[type] ||= []).push(fn); },
dispatchEvent() { return true; }, dispatchEvent() { return true; },
appendChild() {}, appendChild() {},
getAttribute: () => null,
listeners, listeners,
}; };
} }
const main = scripts.find(s => s.includes("getElementById('brightness')")); (async () => {
ok('found the script that wires the brightness slider', !!main); console.log('\n── Display page module: element lookups ──');
if (main) { const display = await import(pathToFileURL(path.join(JS, 'pages/display.js')).href);
const elements = new Map();
const document = {
readyState: 'complete',
hidden: false,
getElementById: id => {
if (!rendered.has(id)) return null;
if (!elements.has(id)) elements.set(id, fakeElement(id));
return elements.get(id);
},
createElement: () => fakeElement(''),
createTextNode: () => ({}),
addEventListener() {},
};
const window = {
LEDEscape: { html: v => String(v), attr: v => String(v) },
LEDVisibility: { onActive() {} },
};
const context = {
window, document, console, URLSearchParams,
fetch: () => new Promise(() => {}),
setTimeout: () => 0, clearTimeout() {}, setInterval: () => 0, clearInterval() {},
};
vm.createContext(context);
let loadError = null;
try { vm.runInContext(main, context); } catch (e) { loadError = e; }
ok('the script loads', !loadError, loadError && String(loadError));
const slider = elements.get('brightness'); const asked = new Set();
const handlers = (slider && slider.listeners.input) || []; const elements = new Map();
ok('the slider has an input handler', handlers.length > 0); const timers = [];
const win = {
setTimeout: fn => { timers.push(fn); return timers.length; },
clearTimeout() {},
URLSearchParams,
Event: class { constructor(type) { this.type = type; } },
PluginOrderList: { init() {} },
};
const doc = { defaultView: win, createElement: () => fakeElement(''), createTextNode: () => ({}) };
const rootListeners = {};
const root = {
ownerDocument: doc,
querySelector(sel) {
const m = /^#([\w-]+)$/.exec(sel);
if (!m) throw new Error('unexpected selector ' + sel);
asked.add(m[1]);
if (!rendered.has(m[1])) return null;
if (!elements.has(m[1])) elements.set(m[1], fakeElement(m[1]));
return elements.get(m[1]);
},
addEventListener: (type, fn) => { (rootListeners[type] ||= []).push(fn); },
contains: () => true,
};
const never = () => new Promise(() => {});
const polls = [];
const ctx = {
root, name: 'display', state: {}, signal: { aborted: false },
api: { get: never },
visibility: { every: (ms, fn) => { polls.push(ms); fn(); return () => {}; } },
};
let loadError = null;
try { display.init(root, ctx); } catch (e) { loadError = e; }
ok('init() runs', !loadError, loadError && String(loadError));
ok('the sync status is polled through ctx.visibility', polls.length === 1 && polls[0] === 5000, polls);
// Fire everything it wired, so every lookup it can make is made.
let thrown = null; let thrown = null;
try {
for (const el of elements.values()) {
for (const fns of Object.values(el.listeners)) fns.forEach(fn => fn.call(el, { target: el }));
}
while (timers.length) timers.shift()();
} catch (e) { thrown = e; }
ok('its listeners and timers run without throwing', !thrown, thrown && String(thrown));
const missing = [...asked].filter(id => !rendered.has(id));
ok('it looks elements up', asked.size > 10, asked.size);
ok('every looked-up id is rendered by the partial', missing.length === 0, missing);
const slider = elements.get('brightness') || fakeElement('brightness');
const handlers = slider.listeners.input || [];
ok('the slider has an input handler', handlers.length > 0);
slider.value = '42'; slider.value = '42';
thrown = null;
try { handlers.forEach(fn => fn.call(slider, { target: slider })); } catch (e) { thrown = e; } try { handlers.forEach(fn => fn.call(slider, { target: slider })); } catch (e) { thrown = e; }
ok('moving the slider throws nothing', !thrown, thrown && String(thrown)); ok('moving the slider throws nothing', !thrown, thrown && String(thrown));
ok('...and shows the new value', elements.get('brightness-value').textContent === '42', const label = elements.get('brightness-value');
elements.get('brightness-value').textContent); ok('...and shows the new value', !!label && label.textContent === '42', label && label.textContent);
}
display.destroy(root, ctx);
console.log(`\n${pass} passed, ${fail} failed\n`); console.log(`\n${pass} passed, ${fail} failed\n`);
process.exit(fail ? 1 : 0); process.exit(fail ? 1 : 0);
})().catch(e => { console.error(e); process.exit(1); });
+4 -5
View File
@@ -103,17 +103,16 @@ const ESCAPERS = [
'static/v3/js/widgets/text-input.js', 'function escapeHtml(text) {', 'escapeHtml', false], 'static/v3/js/widgets/text-input.js', 'function escapeHtml(text) {', 'escapeHtml', false],
['slider.js (escapeAttr)', ['slider.js (escapeAttr)',
'static/v3/js/widgets/slider.js', 'function escapeAttr(text) {', 'escapeAttr', false], 'static/v3/js/widgets/slider.js', 'function escapeAttr(text) {', 'escapeAttr', false],
['display.html (escapeAttr)',
'templates/v3/partials/display.html', 'function escapeAttr(text) {', 'escapeAttr', false],
['tools.html (escHtml)', ['tools.html (escHtml)',
'templates/v3/partials/tools.html', 'function escHtml(s) {', 'escHtml', false], 'templates/v3/partials/tools.html', 'function escHtml(s) {', 'escHtml', false],
['tools.html (phEscape)', ['tools.html (phEscape)',
'templates/v3/partials/tools.html', 'function phEscape(s) {', 'phEscape', false], 'templates/v3/partials/tools.html', 'function phEscape(s) {', 'phEscape', false],
['logs.html (escapeHtml)', ['logs.html (escapeHtml)',
'templates/v3/partials/logs.html', 'function escapeHtml(text) {', 'escapeHtml', false], 'templates/v3/partials/logs.html', 'function escapeHtml(text) {', 'escapeHtml', false],
// cache.html, backup_restore.html and operation_history.html have no // cache.html, backup_restore.html, operation_history.html and display.html
// script any more: their js/pages/ modules draw server data with // have no script any more (display.html's two escapers were never called):
// textContent, and each page's suite in test/js/dom/ checks a hostile value. // their js/pages/ modules draw server data with textContent, and each
// page's suite in test/js/dom/ checks a hostile value.
]; ];
// The breakout payload: closes a double-quoted attribute and opens an event // The breakout payload: closes a double-quoted attribute and opens an event
+40
View File
@@ -242,6 +242,46 @@ function recorder(log) {
ok('has()', reg.has('dup') && !reg.has('nope')); ok('has()', reg.has('dup') && !reg.has('nope'));
} }
console.log('\n10. mountContext adds per-mount fields, after the shared ones');
{
const doc = new Doc();
const panel = doc.body.appendChild(new El('div', { id: 'panel' }));
panel.appendChild(new El('div', { id: 'a', [PAGE_ATTRIBUTE]: 'demo' }));
const seen = [];
const made = [];
const reg = createRegistry({
document: doc, context: { api: 'shared' }, logger: quiet,
mountContext(ctx) {
made.push([ctx.name, ctx.root.getAttribute('id'), !!ctx.signal, ctx.api]);
return { bound: { root: ctx.root, signal: ctx.signal } };
},
});
reg.register('demo', { init(root, ctx) { seen.push(ctx); } });
await reg.start();
await tick();
ok('mountContext sees the mount\'s name, root, signal and the shared services',
made.length === 1 && made[0].join() === 'demo,a,true,shared', made);
ok('...and its fields reach init()', seen.length === 1 && seen[0].bound && seen[0].bound.root === seen[0].root, seen.length);
fire(panel, 'htmx:beforeSwap', { target: panel, shouldSwap: true });
panel.replaceChildren(new El('div', { id: 'b', [PAGE_ATTRIBUTE]: 'demo' }));
fire(panel, 'htmx:afterSwap', { target: panel });
await tick();
ok('called again for each new mount, with that mount\'s signal',
made.length === 2 && seen.length === 2 && !!seen[1].bound && seen[1].bound.signal === seen[1].signal
&& seen[0].signal.aborted, made);
const errors = [];
const doc2 = new Doc();
doc2.body.appendChild(new El('div', { id: 'c', [PAGE_ATTRIBUTE]: 'demo' }));
const reg2 = createRegistry({ document: doc2, logger: { error: (...a) => errors.push(a.join(' ')) },
mountContext() { throw new Error('boom'); } });
let started = 0;
reg2.register('demo', { init() { started++; } });
await reg2.start();
await tick();
ok('a throwing mountContext is logged and the page still starts', started === 1 && errors.length === 1, errors);
}
console.log(`\n${pass} passed, ${fail} failed`); console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0); process.exit(fail ? 1 : 0);
})().catch(e => { console.error(e); process.exit(1); }); })().catch(e => { console.error(e); process.exit(1); });
+206
View File
@@ -0,0 +1,206 @@
"""src/malloc_tuning.py: glibc arena cap and periodic malloc_trim, ctypes mocked."""
import ctypes
from pathlib import Path
from unittest import mock
import pytest
from src import malloc_tuning as mt
class FakeLibc:
"""Stands in for ctypes.CDLL(None) on glibc: records calls."""
def __init__(self, trim_result=1, glibc=True):
self.trims = []
self.mallopts = []
self._trim_result = trim_result
if glibc:
self.gnu_get_libc_version = lambda: b'2.41'
self.malloc_trim = mock.Mock(side_effect=self._trim)
self.mallopt = mock.Mock(side_effect=self._mallopt)
def _trim(self, pad):
self.trims.append(pad)
if isinstance(self._trim_result, Exception):
raise self._trim_result
return self._trim_result
def _mallopt(self, param, value):
self.mallopts.append((param, value))
return 1
@pytest.fixture(autouse=True)
def fresh_libc(monkeypatch):
"""Each test loads the C library itself; nothing real is called."""
monkeypatch.setattr(mt, '_libc', mt._UNLOADED)
monkeypatch.delenv('MALLOC_ARENA_MAX', raising=False)
yield
def _on_glibc(monkeypatch, libc):
monkeypatch.setattr(mt.sys, 'platform', 'linux')
cdll = mock.Mock(return_value=libc)
monkeypatch.setattr(mt.ctypes, 'CDLL', cdll)
return cdll
class Clock:
def __init__(self, t=1000.0):
self.t = t
def __call__(self):
return self.t
# -- loading ----------------------------------------------------------------
@pytest.mark.parametrize('platform', ['win32', 'darwin', 'freebsd14'])
def test_not_linux_loads_nothing(monkeypatch, platform):
monkeypatch.setattr(mt.sys, 'platform', platform)
cdll = mock.Mock(side_effect=AssertionError('must not load'))
monkeypatch.setattr(mt.ctypes, 'CDLL', cdll)
assert mt._load_libc() is None
assert mt.cap_arenas() is False
trimmer = mt.MallocTrimmer(interval=0)
assert not trimmer.available
assert trimmer.maybe_trim() is False
cdll.assert_not_called()
def test_linux_without_glibc_is_a_noop(monkeypatch):
"""musl: no gnu_get_libc_version (and no malloc_trim) -- nothing is called."""
libc = FakeLibc(glibc=False)
del libc.malloc_trim
_on_glibc(monkeypatch, libc)
assert mt._load_libc() is None
assert mt.cap_arenas() is False
assert mt.MallocTrimmer(interval=0).maybe_trim() is False
assert libc.mallopts == []
def test_cdll_failure_is_a_noop(monkeypatch):
monkeypatch.setattr(mt.sys, 'platform', 'linux')
monkeypatch.setattr(mt.ctypes, 'CDLL', mock.Mock(side_effect=OSError('no libc')))
assert mt._load_libc() is None
assert mt.cap_arenas() is False
def test_loads_once(monkeypatch):
cdll = _on_glibc(monkeypatch, FakeLibc())
mt._load_libc()
mt._load_libc()
mt.MallocTrimmer()
assert cdll.call_count == 1
def test_declares_c_signatures(monkeypatch):
libc = FakeLibc()
_on_glibc(monkeypatch, libc)
mt._load_libc()
assert libc.malloc_trim.argtypes == [ctypes.c_size_t]
assert libc.mallopt.argtypes == [ctypes.c_int, ctypes.c_int]
# -- cap_arenas ---------------------------------------------------------------
def test_cap_arenas_calls_mallopt(monkeypatch):
libc = FakeLibc()
_on_glibc(monkeypatch, libc)
assert mt.cap_arenas() is True
assert libc.mallopts == [(mt.M_ARENA_MAX, 2)]
assert mt.M_ARENA_MAX == -8 # glibc's malloc.h
def test_cap_arenas_defers_to_the_environment(monkeypatch):
libc = FakeLibc()
_on_glibc(monkeypatch, libc)
monkeypatch.setenv('MALLOC_ARENA_MAX', '4')
assert mt.cap_arenas() is False
assert libc.mallopts == []
def test_cap_arenas_swallows_errors(monkeypatch):
libc = FakeLibc()
libc.mallopt = mock.Mock(side_effect=RuntimeError('boom'))
_on_glibc(monkeypatch, libc)
assert mt.cap_arenas() is False
def test_cap_arenas_matches_the_unit():
"""The in-process default is the value the unit's MALLOC_ARENA_MAX carries."""
unit = (Path(__file__).resolve().parent.parent / 'systemd' / 'ledmatrix.service').read_text()
assert f'Environment=MALLOC_ARENA_MAX={mt.DEFAULT_ARENA_MAX}\n' in unit
# -- MallocTrimmer ------------------------------------------------------------
def test_trim_waits_one_interval_then_rate_limits(monkeypatch):
libc = FakeLibc()
_on_glibc(monkeypatch, libc)
clock = Clock()
trimmer = mt.MallocTrimmer(interval=300, clock=clock)
assert trimmer.available
assert trimmer.maybe_trim() is False # start-up: not yet
clock.t += 299.9
assert trimmer.maybe_trim() is False
clock.t += 0.1
assert trimmer.maybe_trim() is True
assert libc.trims == [0]
clock.t += 100
assert trimmer.maybe_trim() is False # rate-limited
clock.t += 200
assert trimmer.maybe_trim() is True
assert libc.trims == [0, 0]
def test_trim_reports_nothing_released(monkeypatch):
libc = FakeLibc(trim_result=0)
_on_glibc(monkeypatch, libc)
clock = Clock()
trimmer = mt.MallocTrimmer(interval=10, clock=clock)
clock.t += 10
assert trimmer.maybe_trim() is False
assert libc.trims == [0]
def test_trim_failure_disables_trimming(monkeypatch):
libc = FakeLibc(trim_result=RuntimeError('boom'))
_on_glibc(monkeypatch, libc)
clock = Clock()
trimmer = mt.MallocTrimmer(interval=10, clock=clock)
clock.t += 10
assert trimmer.maybe_trim() is False
clock.t += 10
assert trimmer.maybe_trim() is False
assert libc.trims == [0] # not retried
assert not trimmer.available
# -- wiring -------------------------------------------------------------------
def test_run_py_caps_arenas_before_threads():
"""run.py applies the cap before the watchdog or the controller import."""
src = (Path(__file__).resolve().parent.parent / 'run.py').read_text()
cap = src.index('malloc_tuning.cap_arenas()')
assert cap < src.index('display_watchdog.watchdog.begin_startup()')
assert cap < src.index('from src.display_controller import main')
def test_render_loop_trims_between_screens():
src = (Path(__file__).resolve().parent.parent / 'src' / 'display_controller.py').read_text()
loop = src.index('display_watchdog.watchdog.loop_pass()')
trim = src.index('trimmer.maybe_trim()')
assert loop < trim < src.index('outcome = runner.run(plan, manager_to_display)')
@pytest.mark.skipif(not mt.sys.platform.startswith('linux'), reason='glibc only')
def test_real_libc_on_linux():
"""On a real Linux C library the calls go through without raising."""
if mt._load_libc() is None:
pytest.skip('not glibc')
trimmer = mt.MallocTrimmer(interval=0)
assert trimmer.available
assert trimmer.maybe_trim() in (True, False)
assert trimmer.available # did not fail and disable itself
+275
View File
@@ -0,0 +1,275 @@
"""src.common.sports_game_over: behaviour, host contract and base order.
The matrix is ledmatrix-plugins' ``scripts/test_game_over_check.py`` (the
table the family 5 reconcile was checked against) folded to the three
``FINAL_PERIOD`` values the nine scoreboards declare: None (afl, baseball,
nrl, soccer, ufc), 4 (basketball, football, lacrosse) and 3 (hockey).
Baseball's postponed/suspended override stays in its plugin and is not here.
"""
import ast
import logging
import time
from pathlib import Path
import pytest
from src.common import sports_game_over
from src.common.sports_game_over import SportsGameOverMixin
from src.common.sports_shared import SportsLiveSharedMixin
LOG = logging.getLogger("test_sports_game_over")
def host(final_period):
"""A live manager stand-in declaring ``FINAL_PERIOD`` as a plugin does."""
cls = type("Live", (SportsGameOverMixin,), {"FINAL_PERIOD": final_period})
h = cls()
h.logger = LOG
return h
MISSING = object() # the key is absent from the game dict
def game(period_text="", period=MISSING, clock=MISSING, away="1", home="2"):
g = {"away_abbr": "AWY", "home_abbr": "HOM", "away_score": away,
"home_score": home, "period_text": period_text}
if period is not MISSING:
g["period"] = period
if clock is not MISSING:
g["clock"] = clock
return g
# ---------------------------------------------------------------------------
# The matrix: clock x period, for each FINAL_PERIOD. Scores 1-2.
# ---------------------------------------------------------------------------
FINAL_PERIODS = (None, 4, 3)
PERIODS = (MISSING, 1, 2, 3, 4, 5, 6)
CLOCKS = {"12:00": "12:00", "0:00": "0:00", ":00": ":00", "0.0": "0.0",
"-": "-", "''": "", "None": None, "missing": MISSING}
#: The period text each ESPN status carries. Only "Final" contains "final";
#: the method reads no status, so every other text answers the same row.
LIVE_TEXTS = {
"in progress": lambda p: "" if p is MISSING else f"P{p}",
"end of period": lambda p: "" if p is MISSING else f"End P{p}",
"halftime": lambda p: "Halftime",
"end of round": lambda p: "" if p is MISSING else f"End R{p}",
"postponed": lambda p: "Postponed",
}
#: clock -> one cell per period (missing, 1..6) for FINAL_PERIOD None, 4, 3.
EXPECTED_LIVE = {
"12:00": "....... ....... .......",
"0:00": "....... ....YYY ...YYYY",
":00": "....... ....YYY ...YYYY",
"0.0": "....... ....... .......",
"-": "....... ....... .......",
"''": "....... ....... .......",
"None": "....... ....... .......",
"missing": "....... ....... .......",
}
def row(text_for, clock):
return " ".join(
"".join("Y" if host(fp)._is_game_really_over(game(text_for(p), p, clock)) else "."
for p in PERIODS)
for fp in FINAL_PERIODS)
@pytest.mark.parametrize("status", sorted(LIVE_TEXTS))
@pytest.mark.parametrize("clock_label", sorted(CLOCKS))
def test_a_live_period_text(status, clock_label):
assert row(LIVE_TEXTS[status], CLOCKS[clock_label]) == EXPECTED_LIVE[clock_label]
@pytest.mark.parametrize("clock_label", sorted(CLOCKS))
def test_a_final_period_text_is_always_over(clock_label):
assert row(lambda p: "Final", CLOCKS[clock_label]) == "YYYYYYY YYYYYYY YYYYYYY"
#: label -> (game, one cell per FINAL_PERIOD None, 4, 3)
EDGES = {
"period_text None, P4 0:00": (game(None, 4, "0:00"), ".YY"),
"period None, 0:00": (game("", None, "0:00"), "..."),
"period 'OT', 0:00": (game("OT", "OT", "0:00"), "..."),
"period '4' (str), 0:00": (game("P4", "4", "0:00"), ".YY"),
"clock int 0, P4": (game("P4", 4, 0), "..."),
"clock float 0.0, P4": (game("P4", 4, 0.0), "..."),
"clock ' 0:00 ', P4": (game("P4", 4, " 0:00 "), ".YY"),
"clock '00:00', P4": (game("P4", 4, "00:00"), "..."),
"period_text 'Final/OT', P5 0:00": (game("Final/OT", 5, "0:00"), "YYY"),
"period_text 'FINAL', P1 12:00": (game("FINAL", 1, "12:00"), "YYY"),
}
@pytest.mark.parametrize("label", sorted(EDGES))
def test_edge_shapes(label):
g, want = EDGES[label]
got = "".join("Y" if host(fp)._is_game_really_over(dict(g)) else "." for fp in FINAL_PERIODS)
assert got == want
# ---------------------------------------------------------------------------
# The tie guard: level at 0:00 is overtime, not the end.
# ---------------------------------------------------------------------------
class TestTieGuard:
@pytest.mark.parametrize("fp,period", [(4, 4), (4, 5), (3, 3), (3, 4), (3, 5)])
def test_level_at_zero_is_not_over(self, fp, period):
assert host(fp)._is_game_really_over(game("", period, "0:00", "2", "2")) is False
def test_level_scores_compare_as_numbers(self):
assert host(4)._is_game_really_over(game("", 4, "0:00", 2, "2")) is False
assert host(4)._is_game_really_over(game("", 4, "0:00", " 2 ", "2")) is False
def test_a_game_that_ends_level_ends_on_final(self):
assert host(4)._is_game_really_over(game("Final/OT", 5, "0:00", "2", "2")) is True
def test_level_before_the_final_period_was_never_over(self):
assert host(4)._is_game_really_over(game("", 3, "0:00", "2", "2")) is False
@pytest.mark.parametrize("away,home", [
(MISSING, MISSING), (None, None), ("", ""), ("2", None),
("2.0", "2.0"), ({"value": 2}, {"value": 2}), ("inf", "inf"),
])
def test_an_unreadable_score_leaves_it_to_the_clock(self, away, home):
g = game("", 4, "0:00")
for key, value in (("away_score", away), ("home_score", home)):
if value is MISSING:
del g[key]
else:
g[key] = value
assert host(4)._is_game_really_over(g) is True
def test_float_infinity_does_not_raise(self):
assert host(4)._is_game_really_over(
game("", 4, "0:00", float("inf"), float("inf"))) is True
# ---------------------------------------------------------------------------
# ufc: ESPN MMA payloads, as ufc's _extract_game_details stores them
# (ledmatrix-plugins plugins/ufc-scoreboard/test/fixtures/espn_mma_round_states.json).
# ---------------------------------------------------------------------------
UFC_RECORDED = {
"in_round_3_of_3": ("R3", 3, "1:21"),
"break_after_round_1": ("R1", 1, "-"),
"end_of_round_after_stoppage": ("R2", 2, "0:51"),
"walkouts_five_rounder": ("", 0, "-"),
"final_five_round_decision": ("R5", 5, "5:00"),
"final_five_round_stoppage": ("R5", 5, "1:38"),
"final_three_round_decision": ("R3", 3, "5:00"),
"final_three_round_stoppage": ("R2", 2, "4:07"),
"break_after_round_4_of_5": ("R4", 4, "-"),
"end_of_round_5_awaiting_decision": ("R5", 5, "-"),
}
class TestUfc:
@pytest.mark.parametrize("name", sorted(UFC_RECORDED))
def test_no_recorded_state_is_over_here(self, name):
"""A finished bout leaves the live list on is_final, before this is asked."""
text, period, clock = UFC_RECORDED[name]
assert host(None)._is_game_really_over(game(text, period, clock, "0", "0")) is False
@pytest.mark.parametrize("fp", FINAL_PERIODS)
def test_a_round_break_dash_is_never_a_zero_clock(self, fp):
assert host(fp)._is_game_really_over(game("R4", 4, "-", "1", "2")) is False
@pytest.mark.parametrize("clock", ["0:00", None, MISSING])
def test_the_horn_does_not_end_a_bout(self, clock):
assert host(None)._is_game_really_over(game("R5", 5, clock, "1", "2")) is False
# ---------------------------------------------------------------------------
# Wiring: the default, overrides, and the live mixin's caller.
# ---------------------------------------------------------------------------
def test_the_default_is_no_clock_rule():
assert SportsGameOverMixin.FINAL_PERIOD is None
def test_an_override_defers_through_super():
"""baseball's BaseballLive: its own check first, then the shared one."""
class Baseballish(SportsGameOverMixin):
logger = LOG
def _is_game_really_over(self, game):
if game.get("status") == "status_postponed":
return True
return super()._is_game_really_over(game)
b = Baseballish()
assert b._is_game_really_over(dict(game("", 6, "0:00"), status="status_postponed")) is True
assert b._is_game_really_over(game("", 6, "0:00")) is False
assert b._is_game_really_over(game("Final", 9, None)) is True
class _Live(SportsGameOverMixin, SportsLiveSharedMixin):
"""A SportsLive stand-in in the documented base order."""
FINAL_PERIOD = 4
def __init__(self):
self.logger = LOG
self.stale_game_timeout = 600
self.game_update_timestamps = {}
class TestBaseOrder:
def test_the_documented_order_resolves_this_method(self):
assert _Live._is_game_really_over is SportsGameOverMixin._is_game_really_over
mro = _Live.__mro__
assert mro.index(SportsGameOverMixin) < mro.index(SportsLiveSharedMixin)
def test_neither_shared_mixin_defines_it(self):
"""So the base order cannot change which body runs."""
from src.common.sports_shared import SportsCoreSharedMixin
for mixin in (SportsLiveSharedMixin, SportsCoreSharedMixin):
assert "_is_game_really_over" not in vars(mixin)
def test_detect_stale_games_drops_an_over_game_through_it(self):
live = _Live()
live.game_update_timestamps = {"over": {"last_seen": time.time()},
"on": {"last_seen": time.time()}}
games = [dict(game("", 4, "0:00"), id="over"),
dict(game("", 4, "0:00", "2", "2"), id="on")]
live._detect_stale_games(games)
assert [g["id"] for g in games] == ["on"]
assert "over" not in live.game_update_timestamps
def test_the_class_value_wins_over_the_default(self):
assert _Live().FINAL_PERIOD == 4
assert _Live()._is_game_really_over(game("", 4, "0:00")) is True
# ---------------------------------------------------------------------------
# Host contract
# ---------------------------------------------------------------------------
def _self_reads():
tree = ast.parse(Path(sports_game_over.__file__).read_text(encoding="utf-8"))
cls = next(n for n in tree.body
if isinstance(n, ast.ClassDef) and n.name == "SportsGameOverMixin")
return {node.attr for node in ast.walk(cls)
if isinstance(node, ast.Attribute) and isinstance(node.ctx, ast.Load)
and isinstance(node.value, ast.Name) and node.value.id == "self"}
class TestHostContract:
def test_every_host_read_is_documented(self):
undocumented = sorted(n for n in _self_reads()
if f"``{n}``" not in sports_game_over.__doc__)
assert undocumented == [], f"read but not in the host contract: {undocumented}"
def test_the_mixin_creates_no_state(self):
assert "__init__" not in vars(SportsGameOverMixin)
assert not hasattr(SportsGameOverMixin, "logger")
assert sorted(n for n in vars(SportsGameOverMixin) if not n.startswith("__")) == [
"FINAL_PERIOD", "_is_game_really_over"]
+123
View File
@@ -0,0 +1,123 @@
"""sports_game_over still matches every plugin copy, and each plugin's FINAL_PERIOD.
``SportsGameOverMixin._is_game_really_over`` was copied from the scoreboards'
``SportsLive._is_game_really_over`` once family 5 had made the nine copies one
body. The plugins delete their copies once they floor on the release that
ships this module. Until each has, a copy that changes on its own is a fix one
side has and the other lacks.
Point LEDMATRIX_PLUGINS at a ledmatrix-plugins checkout and the method is
compared with every plugin copy using ``scripts/sports_drift_report.py``'s own
normalisation (the AST with docstrings and annotations dropped), plus the
decorators. A copy that is gone counts as adopted when the plugin's
``sports.py`` names the module. Each plugin's ``SportsLive.FINAL_PERIOD`` is
compared with the value the owner decided for its sport, which stays in the
plugin after adoption. Without the variable this skips: core CI has no plugins
checkout.
"""
import ast
import importlib.util
import os
from pathlib import Path
import pytest
from src.common import sports_game_over
REPO = Path(__file__).resolve().parents[1]
#: The owner's decision (docs/SPORTS_UNIFICATION.md, family 5): the period
#: from which a 0:00 clock ends a game, None where the clock never does.
FINAL_PERIOD = {
"afl": None, "baseball": None, "basketball": 4, "football": 4,
"hockey": 3, "lacrosse": 4, "nrl": None, "soccer": None, "ufc": None,
}
NAME = "_is_game_really_over"
def _drift_report():
"""scripts/sports_drift_report.py, loaded by path (scripts/ is no package)."""
spec = importlib.util.spec_from_file_location(
"sports_drift_report", REPO / "scripts" / "sports_drift_report.py")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
DRIFT = _drift_report()
def _plugins_root():
root = DRIFT.resolve_plugins_dir(os.environ.get("LEDMATRIX_PLUGINS"))
if root is None:
pytest.skip("set LEDMATRIX_PLUGINS to a ledmatrix-plugins checkout to "
"compare this module against the plugin copies")
return root
def _class(tree, name):
return next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == name)
def _method(cls):
return next((n for n in cls.body
if isinstance(n, ast.FunctionDef) and n.name == NAME), None)
def _fingerprint(node):
return (DRIFT._digest(node, DRIFT._Canonical()),
tuple(ast.unparse(d) for d in node.decorator_list))
def _final_period(cls):
for node in cls.body:
if (isinstance(node, (ast.Assign, ast.AnnAssign)) and node.value is not None):
target = node.targets[0] if isinstance(node, ast.Assign) else node.target
if isinstance(target, ast.Name) and target.id == "FINAL_PERIOD":
return ast.literal_eval(node.value)
raise AssertionError("SportsLive declares no FINAL_PERIOD")
def _ours():
tree = ast.parse(Path(sports_game_over.__file__).read_text(encoding="utf-8"))
return _class(tree, "SportsGameOverMixin")
def test_the_mixin_holds_one_method_and_the_default():
names = sorted(n.name if isinstance(n, ast.FunctionDef) else n.target.id
for n in _ours().body if isinstance(n, (ast.FunctionDef, ast.AnnAssign))
and (isinstance(n, ast.FunctionDef) or n.value is not None))
assert names == ["FINAL_PERIOD", NAME]
assert _final_period(_ours()) is None
@pytest.mark.parametrize("sport", sorted(FINAL_PERIOD))
def test_every_remaining_plugin_copy_matches(sport):
root = _plugins_root()
source = (root / f"{sport}-scoreboard" / "sports.py").read_text(encoding="utf-8")
live = _class(ast.parse(source), "SportsLive")
copy = _method(live)
if copy is None:
assert sports_game_over.__name__ in source, (
f"{sport}: no {NAME} on SportsLive and no {sports_game_over.__name__} import")
else:
assert _fingerprint(copy) == _fingerprint(_method(_ours())), (
f"{NAME} in {sport} differs from sports_game_over. "
f"Port the change to both, or stop treating it as shared.")
@pytest.mark.parametrize("sport", sorted(FINAL_PERIOD))
def test_every_plugin_declares_its_final_period(sport):
root = _plugins_root()
source = (root / f"{sport}-scoreboard" / "sports.py").read_text(encoding="utf-8")
assert _final_period(_class(ast.parse(source), "SportsLive")) == FINAL_PERIOD[sport]
def test_the_drift_report_still_calls_it_identical():
root = _plugins_root()
families = DRIFT.build(root, ("sports.py",))
rows = {(r["file"], r["family"]): r
for r in (DRIFT.summarise(k, v) for k, v in families.items())}
row = rows.get(("sports.py", NAME))
assert row is None or row["worst_class_variants"] == 1
+130
View File
@@ -465,6 +465,136 @@ class TestLiveMixin:
# The safety property that makes it correct: 30s beats 600s. # The safety property that makes it correct: 30s beats 600s.
assert h._idle_live_interval() == h.update_interval assert h._idle_live_interval() == h.update_interval
# ---- every pending kickoff is honoured, not just the first ------------
#
# One stored kickoff held the slot through its grace and refused every
# later one; a later one that had passed by the time the grace ended was
# refused again as "already past". So of two favourites kicking off ten
# minutes apart, the second lost its grace: if ESPN had not flipped it live
# by the end of the FIRST game's grace, the back-off returned to its
# ceiling and the game was noticed up to that late. That bites whenever the
# first game is not live by then -- a rain delay, a postponement, ESPN slow
# to flip it -- since a live first game keeps the live cadence anyway.
@staticmethod
def _replay(monkeypatch, kickoffs, flips, until, poll=30, ceiling=900):
"""Drive a live manager's idle loop over a schedule on a fake clock.
``kickoffs`` are start offsets in seconds from t=0 (the first look),
``flips`` how long after its start ESPN reports each game live (None:
postponed, never live). Every
look offers each not-yet-live game, as the live loop does, then sleeps
for whatever the back-off returns. Returns, per game, how long after it
went live it was first seen live -- None if never.
"""
clock = [1_800_000_000.0]
monkeypatch.setattr(sports_shared.time, "time", lambda: clock[0])
h = _LiveHost(no_data_interval=300)
h.live_idle_max_interval = ceiling
h.update_interval = poll
h._empty_live_streak = 30 # idle all morning: at the ceiling
t0 = clock[0]
seen = [None] * len(kickoffs)
while clock[0] - t0 < until:
now = clock[0] - t0
live = [f is not None and k + f <= now for k, f in zip(kickoffs, flips)]
for i, is_live in enumerate(live):
if is_live and seen[i] is None:
seen[i] = now - (kickoffs[i] + flips[i])
start = datetime.fromtimestamp(t0 + kickoffs[i], tz=timezone.utc)
h._note_scheduled_start_candidate(
{"is_live": is_live, "is_halftime": False,
"start_time_utc": start})
# A real board keeps polling at the live cadence while anything is
# live; a game here stays live for an hour after it flips.
on = any(f is not None and k + f <= now < k + f + 3600
for k, f in zip(kickoffs, flips))
h._note_live_fetch(on)
clock[0] += poll if on else h._idle_live_interval()
assert len(getattr(h, "_later_scheduled_starts", None) or ()) <= sports_shared._KICKOFF_QUEUE_MAX
return seen
@pytest.mark.parametrize("kickoffs,flips", [
# (start offsets, ESPN's flip delay per game), both in seconds.
pytest.param([1800], [120], id="one kickoff, flipped 2 min late"),
pytest.param([1800, 2400], [0, 840],
id="first live on time, the second flipped 14 min late"),
pytest.param([1800, 2400], [None, 840],
id="first postponed, the second 10 min later flipped 14 min late"),
pytest.param([1800, 2400], [1200, 840],
id="first in a 20 min delay, the second flipped 14 min late"),
pytest.param([1800, 2100, 2520], [None, None, 600],
id="three inside one grace, the last flipped 10 min late"),
pytest.param([1800, 1800, 2400], [None, None, 700],
id="two at the same time, then one 10 min later"),
pytest.param([1800, 2700], [None, 840],
id="second kickoff 15 min later, flipped 14 min late"),
pytest.param([1800 + 60 * i for i in range(20)],
[None] * 19 + [840],
id="twenty kickoffs a minute apart overflow the queue"),
])
def test_every_pending_kickoff_gets_its_grace(self, monkeypatch, kickoffs, flips):
seen = self._replay(monkeypatch, kickoffs, flips,
until=max(kickoffs) + 3 * 3600)
late = [s for s, f in zip(seen, flips)
if f is not None and (s is None or s > 30)]
assert not late, "games noticed late (s after going live): %r" % (seen,)
def test_a_kickoff_that_never_flips_costs_one_grace_then_backs_off(self, monkeypatch):
# A postponed game keeps the live cadence for its grace and no longer:
# remembering more kickoffs must not pin the poll to dead ones.
clock = [1_800_000_000.0]
monkeypatch.setattr(sports_shared.time, "time", lambda: clock[0])
h = self._idle_host()
t0 = clock[0]
for offset in (600, 900):
h._note_scheduled_start_candidate(
{"start_time_utc": datetime.fromtimestamp(t0 + offset, tz=timezone.utc)})
clock[0] = t0 + 900 + sports_shared._KICKOFF_GRACE_SECONDS - 1
assert h._idle_live_interval() == h.update_interval
clock[0] = t0 + 900 + sports_shared._KICKOFF_GRACE_SECONDS + 1
assert h._idle_live_interval() == 900
assert not getattr(h, "_later_scheduled_starts", None)
def test_a_queued_kickoff_past_its_own_grace_is_skipped(self, monkeypatch):
# After a long sleep (or a run of looks that never woke the manager)
# several queued kickoffs may have gone stale at once. The one still
# inside its grace must win, not the first stale one in the queue.
clock = [1_800_000_000.0]
monkeypatch.setattr(sports_shared.time, "time", lambda: clock[0])
h = self._idle_host()
t0 = clock[0]
for offset in (600, 700, 1500):
h._note_scheduled_start_candidate(
{"start_time_utc": datetime.fromtimestamp(t0 + offset, tz=timezone.utc)})
clock[0] = t0 + 1500 + 150 # 600 and 700 are past their grace
assert h._idle_live_interval() == h.update_interval
assert h._next_scheduled_start_ts == t0 + 1500
def test_the_queue_keeps_the_earliest_kickoffs(self, monkeypatch):
clock = [1_800_000_000.0]
monkeypatch.setattr(sports_shared.time, "time", lambda: clock[0])
h = self._idle_host()
t0 = clock[0]
cap = sports_shared._KICKOFF_QUEUE_MAX
for offset in reversed(range(1, cap + 6)): # latest first
h._note_scheduled_start_candidate(
{"start_time_utc": datetime.fromtimestamp(t0 + 600 * offset, tz=timezone.utc)})
assert h._next_scheduled_start_ts == t0 + 600
assert h._later_scheduled_starts == [t0 + 600 * i for i in range(2, cap + 2)]
def test_a_kickoff_offered_twice_is_kept_once(self, monkeypatch):
clock = [1_800_000_000.0]
monkeypatch.setattr(sports_shared.time, "time", lambda: clock[0])
h = self._idle_host()
t0 = clock[0]
for _ in range(3):
for offset in (600, 1200):
h._note_scheduled_start_candidate(
{"start_time_utc": datetime.fromtimestamp(t0 + offset, tz=timezone.utc)})
assert h._next_scheduled_start_ts == t0 + 600
assert h._later_scheduled_starts == [t0 + 1200]
def test_finding_a_live_game_resets_the_streak(self): def test_finding_a_live_game_resets_the_streak(self):
h = _LiveHost() h = _LiveHost()
h._note_live_fetch(False) h._note_live_fetch(False)
+244
View File
@@ -0,0 +1,244 @@
"""A plugin update must keep the files the plugin wrote beside itself.
Field incident, 2026-10-04: updating calendar 1.2.9 -> 1.2.12 from the web UI
replaced plugin-repos/calendar/ with the fresh download and deleted the old
copy -- and with it token.pickle and credentials.json, the plugin's Google
OAuth files. No release contains them (the repo gitignores them), so the hot
reload logged "Credentials file not found" and the calendar stayed broken
until the files were restored by hand.
Both update routes are covered: a monorepo plugin (registry ``plugin_path``),
which is reinstalled into a fresh directory, and a plugin installed from its
own git repository, which is updated with ``git pull`` after an auto-stash.
"""
import json
import shutil
import subprocess
import pytest
from src.plugin_system.plugin_local_files import (
is_known_state_file, local_files_to_keep,
)
from src.plugin_system.store_manager import PluginStoreManager
PLUGIN_ID = "calendar"
def _manifest(version):
return {"id": PLUGIN_ID, "name": "Calendar", "class_name": "CalendarPlugin",
"display_modes": ["calendar"], "version": version}
def _write_release(target, version):
"""What a download of ``version`` puts on disk."""
target.mkdir(parents=True, exist_ok=True)
(target / "manifest.json").write_text(json.dumps(_manifest(version)))
(target / "manager.py").write_text(f"VERSION = {version!r}\n")
(target / ".gitignore").write_text("credentials.json\ntoken.pickle\ncache/\n")
def _drop_local_files(plugin_dir):
"""What the plugin writes at runtime: OAuth files plus cached state."""
(plugin_dir / "token.pickle").write_bytes(b"\x80\x04oauth-token")
(plugin_dir / "credentials.json").write_text('{"installed": {}}')
(plugin_dir / "cache").mkdir()
(plugin_dir / "cache" / "events.json").write_text("[]")
def _assert_local_files_kept(plugin_dir):
assert (plugin_dir / "token.pickle").read_bytes() == b"\x80\x04oauth-token"
assert (plugin_dir / "credentials.json").read_text() == '{"installed": {}}'
assert (plugin_dir / "cache" / "events.json").read_text() == "[]"
def _leftover_backups(plugins_dir):
return [p.name for p in plugins_dir.iterdir() if "standalone-backup" in p.name]
@pytest.fixture
def store(tmp_path, monkeypatch):
mgr = PluginStoreManager(
plugins_dir=str(tmp_path / "plugin-repos"),
uninstalled_registry_path=str(tmp_path / "uninstalled.json"))
mgr.plugins_dir.mkdir(parents=True, exist_ok=True)
monkeypatch.setattr(mgr, "_install_dependencies", lambda *a, **k: True)
monkeypatch.setattr(mgr, "fetch_registry", lambda *a, **k: {"plugins": []})
return mgr
class TestMonorepoUpdate:
@pytest.fixture
def installed(self, store, monkeypatch):
registry_entry = {
"id": PLUGIN_ID, "repo": "https://github.com/ChuckBuilds/ledmatrix-plugins",
"plugin_path": "plugins/calendar", "branch": "main",
"latest_version": "1.2.9",
}
monkeypatch.setattr(store, "get_plugin_info", lambda *a, **k: registry_entry)
release = {"version": "1.2.9"}
def fake_monorepo_download(download_url, plugin_subpath, target):
assert plugin_subpath == "plugins/calendar"
_write_release(target, release["version"])
return True
monkeypatch.setattr(store, "_install_from_monorepo", fake_monorepo_download)
assert store.install_plugin(PLUGIN_ID) is True
def publish(version):
registry_entry["latest_version"] = release["version"] = version
return store, store.plugins_dir / PLUGIN_ID, publish
def test_update_keeps_token_and_gitignored_files(self, installed):
store, plugin_dir, publish = installed
_drop_local_files(plugin_dir)
publish("1.2.12")
assert store.update_plugin(PLUGIN_ID) is True
assert json.loads((plugin_dir / "manifest.json").read_text())["version"] == "1.2.12"
_assert_local_files_kept(plugin_dir)
assert _leftover_backups(store.plugins_dir) == []
def test_token_is_kept_even_when_the_release_does_not_gitignore_it(self, installed):
store, plugin_dir, publish = installed
(plugin_dir / ".gitignore").unlink()
(plugin_dir / "token.pickle").write_bytes(b"tok")
(plugin_dir / "config_secrets.json").write_text("{}")
publish("1.2.12")
assert store.update_plugin(PLUGIN_ID) is True
assert (plugin_dir / "token.pickle").read_bytes() == b"tok"
assert (plugin_dir / "config_secrets.json").read_text() == "{}"
def test_release_content_wins_and_old_code_is_not_carried(self, installed):
store, plugin_dir, publish = installed
# A file the old copy had that the new release dropped, byte code, and
# an old copy of a file the new release also ships.
(plugin_dir / "removed_module.py").write_text("OLD = True\n")
(plugin_dir / "__pycache__").mkdir()
(plugin_dir / "__pycache__" / "manager.cpython-313.pyc").write_bytes(b"pyc")
publish("1.2.12")
assert store.update_plugin(PLUGIN_ID) is True
assert not (plugin_dir / "removed_module.py").exists()
assert not (plugin_dir / "__pycache__").exists()
assert "1.2.12" in (plugin_dir / "manager.py").read_text()
def test_reinstall_over_an_existing_copy_keeps_them_too(self, installed):
store, plugin_dir, publish = installed
_drop_local_files(plugin_dir)
assert store.install_plugin(PLUGIN_ID) is True
_assert_local_files_kept(plugin_dir)
assert _leftover_backups(store.plugins_dir) == []
class TestInstallFromUrlReplace:
def test_replacing_an_installed_copy_keeps_the_token(self, store, monkeypatch):
plugin_dir = store.plugins_dir / PLUGIN_ID
_write_release(plugin_dir, "1.0.0")
_drop_local_files(plugin_dir)
def fake_clone(repo_url, target, branches):
_write_release(target, "2.0.0")
return "main"
monkeypatch.setattr(store, "_install_via_git", fake_clone)
result = store.install_from_url(
"https://github.com/example/ledmatrix-calendar", plugin_id=PLUGIN_ID)
assert result["success"] is True
assert json.loads((plugin_dir / "manifest.json").read_text())["version"] == "2.0.0"
_assert_local_files_kept(plugin_dir)
def _git(*args, cwd):
subprocess.run(["git", "-c", "user.email=t@example.com", "-c", "user.name=t",
"-c", "core.autocrlf=false", *args],
cwd=cwd, check=True, capture_output=True)
@pytest.mark.skipif(shutil.which("git") is None, reason="git not installed")
class TestGitRepoUpdate:
@pytest.fixture
def cloned(self, store, tmp_path, monkeypatch):
monkeypatch.setattr(store, "get_plugin_info", lambda *a, **k: None)
upstream = tmp_path / "upstream"
_write_release(upstream, "1.0.0")
# This repo does NOT gitignore the token: an untracked, non-ignored
# file is exactly what `git stash push -u` used to sweep away.
(upstream / ".gitignore").write_text("cache/\n")
_git("init", "-q", "-b", "main", cwd=upstream)
_git("add", ".", cwd=upstream)
_git("commit", "-qm", "1.0.0", cwd=upstream)
plugin_dir = store.plugins_dir / PLUGIN_ID
_git("clone", "-q", str(upstream), str(plugin_dir), cwd=tmp_path)
def publish(version):
(upstream / "manifest.json").write_text(json.dumps(_manifest(version)))
_git("commit", "-qam", version, cwd=upstream)
return store, plugin_dir, publish
def test_pull_update_keeps_untracked_token(self, cloned):
store, plugin_dir, publish = cloned
_drop_local_files(plugin_dir)
# An unrelated untracked file, so the update really does stash.
(plugin_dir / "notes.txt").write_text("scratch")
publish("1.1.0")
assert store.update_plugin(PLUGIN_ID) is True
assert json.loads((plugin_dir / "manifest.json").read_text())["version"] == "1.1.0"
_assert_local_files_kept(plugin_dir)
def test_token_alone_does_not_trigger_a_stash(self, cloned):
store, plugin_dir, publish = cloned
(plugin_dir / "token.pickle").write_bytes(b"tok")
publish("1.1.0")
assert store.update_plugin(PLUGIN_ID) is True
assert (plugin_dir / "token.pickle").read_bytes() == b"tok"
stashes = subprocess.run(["git", "-C", str(plugin_dir), "stash", "list"],
capture_output=True, text=True, check=True)
assert stashes.stdout.strip() == ""
class TestWhatIsKept:
@pytest.mark.parametrize("path,expected", [
("token.pickle", True),
("data/session.pickle", True),
("credentials.json", True),
("token.json", True),
("config_secrets.json", True),
(".pkce_code_verifier", True),
("manager.py", False),
("config.json", False),
])
def test_known_state_files(self, path, expected):
assert is_known_state_file(path) is expected
def test_gitignore_rules(self, tmp_path):
old, new = tmp_path / "old", tmp_path / "new"
new.mkdir()
for rel in ["a.log", "logs/x.txt", "sub/deep/b.log", "keep.log",
"anchored.txt", "sub/anchored.txt", "assets/x/y_backup/z.png",
"manager.py", "shipped.log"]:
(old / rel).parent.mkdir(parents=True, exist_ok=True)
(old / rel).write_text("x")
(new / "shipped.log").write_text("new")
(old / ".gitignore").write_text(
"# comment\n*.log\n!keep.log\nlogs/\n/anchored.txt\n"
"assets/**/*_backup/\n")
assert local_files_to_keep(old, new) == [
"a.log", "anchored.txt", "assets/x/y_backup/z.png",
"logs/x.txt", "sub/deep/b.log",
]
+4 -1
View File
@@ -43,7 +43,8 @@ def _url(path):
def test_the_module_directories_hold_modules(): def test_the_module_directories_hold_modules():
assert {p.name for p in MODULES} >= {"boot.js", "registry.js", "api.js", "facade.js", "cache.js", assert {p.name for p in MODULES} >= {"boot.js", "registry.js", "api.js", "facade.js", "cache.js",
"durations.js", "operation-history.js", "raw-json.js", "durations.js", "operation-history.js", "raw-json.js",
"backup-restore.js", "schedule.js", "general.js"} "backup-restore.js", "schedule.js", "general.js",
"visibility.js", "display.js"}
for directory in MODULE_DIRS: for directory in MODULE_DIRS:
# node needs this to import them in the JS tests; browsers ignore it. # node needs this to import them in the JS tests; browsers ignore it.
assert '"type": "module"' in (directory / "package.json").read_text(encoding="utf-8") assert '"type": "module"' in (directory / "package.json").read_text(encoding="utf-8")
@@ -106,6 +107,7 @@ CONVERTED = {
"backup-restore": ("backup_restore.html", "/partials/backup-restore"), "backup-restore": ("backup_restore.html", "/partials/backup-restore"),
"schedule": ("schedule.html", "/partials/schedule"), "schedule": ("schedule.html", "/partials/schedule"),
"general": ("general.html", "/partials/general"), "general": ("general.html", "/partials/general"),
"display": ("display.html", "/partials/display"),
} }
# Old window.* names that moved into a page module. Each stays as a # Old window.* names that moved into a page module. Each stays as a
@@ -118,6 +120,7 @@ ALIASES = {
"clearRestore", "runRestore"], "clearRestore", "runRestore"],
"schedule": ["handleScheduleResponse", "handleDimScheduleResponse"], "schedule": ["handleScheduleResponse", "handleDimScheduleResponse"],
"general": ["webLogin"], "general": ["webLogin"],
"display": ["updateSyncUI"],
} }
+6
View File
@@ -11,13 +11,17 @@
import { createApi } from './api.js'; import { createApi } from './api.js';
import { createFacade, installFacade } from './facade.js'; import { createFacade, installFacade } from './facade.js';
import { createRegistry } from './registry.js'; import { createRegistry } from './registry.js';
import { createVisibility } from './visibility.js';
const api = createApi(); const api = createApi();
const visibility = createVisibility({ window: window });
const registry = createRegistry({ const registry = createRegistry({
context: { context: {
api: api, api: api,
notify: function(message, type) { return window.LEDMatrix.notify(message, type); }, notify: function(message, type) { return window.LEDMatrix.notify(message, type); },
}, },
// Bound to one mounted page: its timers stop when it is destroyed.
mountContext: function(ctx) { return { visibility: visibility.forPage(ctx) }; },
}); });
const facade = installFacade(window, createFacade(window, api, registry)); const facade = installFacade(window, createFacade(window, api, registry));
@@ -45,6 +49,7 @@ const pages = {
'backup-restore': page(function() { return import('../pages/backup-restore.js'); }), 'backup-restore': page(function() { return import('../pages/backup-restore.js'); }),
'schedule': page(function() { return import('../pages/schedule.js'); }), 'schedule': page(function() { return import('../pages/schedule.js'); }),
'general': page(function() { return import('../pages/general.js'); }), 'general': page(function() { return import('../pages/general.js'); }),
'display': page(function() { return import('../pages/display.js'); }),
}; };
Object.keys(pages).forEach(function(name) { registry.register(name, pages[name]); }); Object.keys(pages).forEach(function(name) { registry.register(name, pages[name]); });
@@ -85,5 +90,6 @@ alias('cache', 'deleteCacheFile', "the Cache tab's Delete buttons");
}); });
aliasObject('general', 'webLogin', ['setPassword', 'disable', 'createToken', 'copyToken', 'revoke'], aliasObject('general', 'webLogin', ['setPassword', 'disable', 'createToken', 'copyToken', 'revoke'],
"the General tab's Security buttons"); "the General tab's Security buttons");
alias('display', 'updateSyncUI', "the Display tab's Role menu");
registry.start(); registry.start();
+14 -1
View File
@@ -8,7 +8,9 @@
* destroy(root, ctx) optional; undo anything `ctx.signal` does not. * destroy(root, ctx) optional; undo anything `ctx.signal` does not.
* *
* ctx is a per-mount object holding the shared services passed to * ctx is a per-mount object holding the shared services passed to
* createRegistry({ context }) (boot.js passes `api` and `notify`) plus: * createRegistry({ context }) (boot.js passes `api` and `notify`), the fields
* returned by createRegistry({ mountContext }) for this mount (boot.js adds
* `visibility`, core/visibility.js), plus:
* ctx.root the data-page element * ctx.root the data-page element
* ctx.name the page name * ctx.name the page name
* ctx.signal an AbortSignal aborted on destroy. Pass it to * ctx.signal an AbortSignal aborted on destroy. Pass it to
@@ -42,12 +44,16 @@ export const PAGE_ATTRIBUTE = 'data-page';
* @param {object} [options] * @param {object} [options]
* @param {Document} [options.document] the document to wire (default: globalThis.document) * @param {Document} [options.document] the document to wire (default: globalThis.document)
* @param {object} [options.context] services copied onto every page's ctx * @param {object} [options.context] services copied onto every page's ctx
* @param {Function} [options.mountContext] (ctx) => fields added to that mount's ctx;
* for services bound to one page (they see
* ctx.root, ctx.name and ctx.signal)
* @param {{error: Function}} [options.logger] * @param {{error: Function}} [options.logger]
*/ */
export function createRegistry(options = {}) { export function createRegistry(options = {}) {
const doc = options.document || globalThis.document; const doc = options.document || globalThis.document;
const logger = options.logger || console; const logger = options.logger || console;
const services = options.context || {}; const services = options.context || {};
const mountContext = options.mountContext || null;
// The document's own AbortController: an element only accepts a signal // The document's own AbortController: an element only accepts a signal
// from its own realm (it matters for jsdom in the tests, not in a browser). // from its own realm (it matters for jsdom in the tests, not in a browser).
const Controller = (doc && doc.defaultView && doc.defaultView.AbortController) || globalThis.AbortController; const Controller = (doc && doc.defaultView && doc.defaultView.AbortController) || globalThis.AbortController;
@@ -105,6 +111,13 @@ export function createRegistry(options = {}) {
const controller = new Controller(); const controller = new Controller();
const ctx = Object.assign({}, services, const ctx = Object.assign({}, services,
{ root: root, name: name, signal: controller.signal, state: {} }); { root: root, name: name, signal: controller.signal, state: {} });
if (mountContext) {
try {
Object.assign(ctx, mountContext(ctx));
} catch (error) {
logger.error('[LEDMatrix.pages] ' + name + ': mountContext failed:', error);
}
}
const entry = { name: name, root: root, ctx: ctx, controller: controller, const entry = { name: name, root: root, ctx: ctx, controller: controller,
module: null, initialised: false, destroyed: false, ready: null }; module: null, initialised: false, destroyed: false, ready: null };
mounted.set(root, entry); mounted.set(root, entry);
@@ -0,0 +1,144 @@
/*
* core/visibility.js -- run a page's timers and polling only while the page
* is on screen: its tab is the active tab AND the browser tab is visible.
*
* Each mounted page gets its own handle as ctx.visibility (the registry asks
* boot.js for it on every mount; see createRegistry's `mountContext`):
*
* ctx.visibility.whileVisible(start, stop)
* start() runs when the page comes on screen, stop() when it leaves.
* start() should refresh at once: it also runs when the page mounts
* already on screen. Returns a function that ends the registration
* (running stop() first if it is running).
* ctx.visibility.every(ms, fn)
* fn() at once and then every `ms` while on screen; the interval is
* cleared while hidden and restarted (with an immediate fn()) when the
* page is back. Returns the same kind of end function.
* ctx.visibility.isVisible()
* true while the page is on screen.
*
* Everything a page registered ends when the page is destroyed (its
* ctx.signal aborts, after destroy()), so a page needs no teardown for it:
* a swapped-out partial leaves no interval behind.
*
* Which tab: the page's name (ctx.name), or forPage(ctx, { tab }) when a
* page's tab is named differently.
*
* Where the answer comes from: window.LEDVisibility (app-shell.js), read at
* call time. It owns the one notion of "active tab" (Alpine's activeTab plus
* the ledmatrix:tab-changed event) and already pauses the SSE streams, so
* the classic partials that still call it and the page modules agree. Each
* registration here takes its own LEDVisibility key, so two pages (or two
* timers on one page) never replace each other. Without LEDVisibility (a
* page outside base.html) the browser tab's visibility alone decides.
*
* No DOM globals are read at import time, so node tests can import this file
* and hand createVisibility() a jsdom window.
*/
/**
* @param {object} [options]
* @param {Window} [options.window] default: globalThis
* @param {Function} [options.tracker] () => an LEDVisibility-shaped object or null
* (default: window.LEDVisibility at call time)
* @param {{error: Function}} [options.logger]
*/
export function createVisibility(options = {}) {
const win = options.window || globalThis;
const doc = win.document;
const logger = options.logger || win.console || console;
const tracker = options.tracker || function() { return win.LEDVisibility || null; };
let sequence = 0;
function call(fn, label) {
try {
fn();
} catch (error) {
logger.error('[LEDMatrix.visibility] ' + label + ' failed:', error);
}
}
/**
* Without a tracker: follow document.hidden only. Same contract as
* LEDVisibility.onActive: start/stop on change, returns an unregister fn.
*/
function onDocumentVisible(start, stop) {
let running = false;
function evaluate() {
const want = !doc.hidden;
if (want === running) return;
running = want;
call(want ? start : stop, want ? 'start' : 'stop');
}
doc.addEventListener('visibilitychange', evaluate);
evaluate();
return function() {
doc.removeEventListener('visibilitychange', evaluate);
if (running) { running = false; call(stop, 'stop'); }
};
}
/**
* The handle for one mounted page. Everything it registers ends when
* ctx.signal aborts.
*/
function forPage(ctx, pageOptions = {}) {
const tab = pageOptions.tab || ctx.name;
const signal = ctx.signal;
const live = new Set();
function whileVisible(start, stop) {
if (typeof start !== 'function' || typeof stop !== 'function') {
throw new TypeError('visibility.whileVisible(start, stop) needs two functions');
}
if (signal && signal.aborted) return function() {};
const t = tracker();
const key = 'page:' + ctx.name + ':' + (++sequence);
const unregister = t
? t.onActive(tab, start, stop, key)
: onDocumentVisible(start, stop);
let ended = false;
const end = function() {
if (ended) return;
ended = true;
live.delete(end);
if (typeof unregister === 'function') call(unregister, key + ' stop');
};
live.add(end);
return end;
}
function every(ms, fn) {
if (!(ms > 0) || typeof fn !== 'function') {
throw new TypeError('visibility.every(ms, fn) needs a positive interval and a function');
}
let timer = null;
function stop() {
if (timer !== null) {
win.clearInterval(timer);
timer = null;
}
}
return whileVisible(function() {
stop();
timer = win.setInterval(function() { call(fn, 'every(' + ms + ')'); }, ms);
fn();
}, stop);
}
function isVisible() {
const t = tracker();
return t ? t.isActive(tab) : !doc.hidden;
}
if (signal) {
signal.addEventListener('abort', function() {
Array.from(live).forEach(function(end) { end(); });
}, { once: true });
}
return Object.freeze({ tab: tab, whileVisible: whileVisible, every: every, isVisible: isVisible });
}
return { forPage: forPage };
}
+324
View File
@@ -0,0 +1,324 @@
/*
* pages/display.js -- the Display tab (templates/v3/partials/display.html):
* the panel hardware, Vegas scroll mode, double-sided and multi-display
* sync settings.
*
* The form itself stays plain htmx (hx-post with json-enc, and its hx-on
* call to the shared showSaveResult, as the Rotation and General forms
* keep). This module does what the partial's inline scripts did:
* - the brightness value next to its slider
* - the live "Your display: W x H pixels" readout
* - showing the Vegas and double-sided settings only while each is on
* - the scroll-speed hint (what the panel really does with a speed), asked
* of the server 150 ms after the slider stops; a newer answer wins
* - the Vegas plugin order (the shared plugin-order-list widget, as the
* Rotation tab uses; its plugin-list request carries ctx.signal)
* - the Advanced section's toggle button (data-action="toggle-section",
* calling the shared window.toggleSection the settings search also uses)
* - the multi-display sync role, and its status, polled every 5 s through
* ctx.visibility: only while the Display tab is on screen and the
* browser tab is visible, and never after the partial is swapped out
*
* The old global updateSyncUI is a deprecated alias made in core/boot.js; it
* forwards to the export of the same name at the bottom of this file.
*/
const SYNC_POLL_MS = 5000;
const HINT_DELAY_MS = 150;
const RETRY_MS = 100;
const MAX_TRIES = 50;
const RESOLUTION_INPUTS = ['rows', 'cols', 'chain_length', 'parallel'];
// The mounted page, for the deprecated alias (boot.js).
let active = null;
function quiet(error) {
return !!error && (error.name === 'AbortError' || error.loginRequired);
}
// ── the "Your display" readout ───────────────────────────────────────────────
// width = cols x chain_length, height = rows x parallel (the chain-length
// tooltip's math), swapped for a 90/270 orientation. A custom pixel mapper
// can change it further; src/display_geometry.py models those.
function showResolution(root) {
const out = root.querySelector('#display-resolution-value');
if (!out) return;
const read = function(id) {
const el = root.querySelector('#' + id);
return el ? parseInt(el.value, 10) : NaN;
};
const rows = read('rows');
const cols = read('cols');
const chain = read('chain_length');
const parallel = read('parallel');
if ([rows, cols, chain, parallel].some(function(n) { return !Number.isFinite(n) || n <= 0; })) {
out.textContent = '—';
return;
}
let w = cols * chain;
let h = rows * parallel;
const orientation = root.querySelector('#orientation');
if (orientation && (orientation.value === '90' || orientation.value === '270')) {
[w, h] = [h, w];
}
out.textContent = w + ' × ' + h + ' pixels';
}
// ── the scroll-speed hint ────────────────────────────────────────────────────
// Only speeds that advance a whole number of pixels per refresh look smooth,
// and which those are depends on the panel, so the server works it out.
function refreshScrollSpeedHint(root, ctx) {
const win = root.ownerDocument.defaultView;
const slider = root.querySelector('#vegas_scroll_speed');
win.clearTimeout(ctx.state.hintTimer);
ctx.state.hintTimer = win.setTimeout(function() {
ctx.state.hintTimer = null;
const hint = root.querySelector('#vegas_scroll_speed_hint');
if (!hint || !slider || ctx.signal.aborted) return;
ctx.state.hintSeq = (ctx.state.hintSeq || 0) + 1;
const seq = ctx.state.hintSeq;
const q = new win.URLSearchParams({ speed: slider.value, min: slider.min, max: slider.max });
ctx.api.get('/api/v3/config/scroll-speed-advice?' + q, { signal: ctx.signal })
.then(function(body) {
if (seq !== ctx.state.hintSeq || body.status !== 'success') return;
renderScrollSpeedHint(root, hint, slider, body.data);
})
.catch(function(error) {
// A refused request keeps the last hint, as before; a failed
// or unreadable one clears it.
if (quiet(error) || error.body) return;
hint.textContent = '';
});
}, HINT_DELAY_MS);
}
function renderScrollSpeedHint(root, hint, slider, a) {
const doc = root.ownerDocument;
const win = doc.defaultView;
const ap = a.applied;
const motion = ap.pixels_per_frame + ' px every ' + ap.frame_hold +
' refresh' + (ap.frame_hold === 1 ? '' : 'es');
hint.textContent = '';
hint.className = 'mt-1 text-xs ' + (a.smooth && a.exact ? 'text-green-700' : 'text-amber-700');
const line = doc.createElement('span');
if (a.smooth && a.exact) {
line.textContent = 'Smooth on this panel (' + motion + ', ' + a.refresh_hz + ' Hz).';
} else {
line.textContent = a.requested + ' px/s will run as ' + ap.pixels_per_second +
' px/s (' + motion + ', ' + ap.steppiness + ') on this ' + a.refresh_hz +
' Hz panel.' + (a.alternatives.length ? ' Smooth speeds: ' : '');
}
hint.appendChild(line);
a.alternatives.forEach(function(alt, i) {
if (i > 0) hint.appendChild(doc.createTextNode(' '));
const value = Math.round(alt.pixels_per_second);
const btn = doc.createElement('button');
btn.type = 'button';
btn.className = 'underline font-medium';
btn.textContent = value + ' px/s';
btn.addEventListener('click', function() {
slider.value = value;
slider.dispatchEvent(new win.Event('input', { bubbles: true }));
});
hint.appendChild(btn);
});
}
// ── the Vegas plugin order ───────────────────────────────────────────────────
// The widget is a classic deferred script and normally loads before this
// module runs; the short retry covers a page that arrives first anyway.
function startPluginOrder(root, ctx) {
const container = root.querySelector('#vegas_plugin_order');
if (!container) return;
const win = root.ownerDocument.defaultView;
let tries = 0;
function attempt() {
ctx.state.orderTimer = null;
if (ctx.signal.aborted) return;
const widget = win.PluginOrderList;
if (!widget) {
if (++tries < MAX_TRIES) {
ctx.state.orderTimer = win.setTimeout(attempt, RETRY_MS);
} else {
container.textContent = 'Could not load the reorder widget — reload the page to try again.';
container.className = 'text-sm text-red-500';
}
return;
}
widget.init({
containerId: 'vegas_plugin_order',
orderInputId: 'vegas_plugin_order_value',
excludedInputId: 'vegas_excluded_plugins_value',
showVegasModeBadge: true,
signal: ctx.signal,
});
}
attempt();
}
// ── multi-display sync ───────────────────────────────────────────────────────
function syncRole(root) {
const select = root.querySelector('#sync_role');
return select ? select.value : null;
}
/** Show the status bar and the follower's Position field for the chosen role. */
function showSyncRole(root) {
const role = syncRole(root);
const bar = root.querySelector('#sync_status_bar');
const position = root.querySelector('#setting-display-sync_follower_position');
const errorDetail = root.querySelector('#sync_error_detail');
if (!bar || !position) return role;
if (role === 'standalone') {
bar.classList.add('hidden');
if (errorDetail) errorDetail.classList.add('hidden');
position.style.display = 'none';
} else {
bar.classList.remove('hidden');
position.style.display = role === 'follower' ? '' : 'none';
}
return role;
}
function pollSyncStatus(root, ctx) {
const role = syncRole(root);
if (!role || role === 'standalone' || ctx.signal.aborted) return;
ctx.api.get('/api/v3/sync/status', { signal: ctx.signal })
.then(function(body) { renderSyncStatus(root, body.data || {}); })
.catch(function(error) {
if (!quiet(error)) renderSyncStatus(root, { state: 'unknown' });
});
}
function renderSyncStatus(root, d) {
const doc = root.ownerDocument;
const content = root.querySelector('#sync_status_content');
const errorDetail = root.querySelector('#sync_error_detail');
const errorText = root.querySelector('#sync_error_text');
if (!content) return;
const state = d.state || 'unknown';
const role = d.role || 'unknown';
let icon, colorClass, text;
if (state === 'connected' || state === 'follower') {
icon = '●';
colorClass = 'bg-green-50 border-green-200 text-green-800';
const peer = d.peer_ip || d.leader_ip || 'peer';
text = role === 'leader'
? 'Follower connected — ' + peer + ' (chain ' + (d.peer_chain || '?') + ')'
: 'Receiving from leader — ' + peer;
errorDetail.classList.add('hidden');
} else if (state === 'incompatible') {
icon = '⚠';
colorClass = 'bg-yellow-50 border-yellow-200 text-yellow-800';
text = 'Follower connected but incompatible panels';
if (d.error) {
errorText.textContent = d.error;
errorDetail.classList.remove('hidden');
}
} else if (state === 'no_peer' || state === 'standalone') {
icon = '○';
colorClass = 'bg-gray-50 border-gray-200 text-gray-600';
text = role === 'leader' ? 'No follower detected' : 'Searching for leader…';
errorDetail.classList.add('hidden');
} else if (state === 'starting') {
icon = '○';
colorClass = 'bg-gray-50 border-gray-200 text-gray-500';
text = 'Display process starting…';
errorDetail.classList.add('hidden');
} else {
icon = '✕';
colorClass = 'bg-red-50 border-red-200 text-red-700';
text = 'Sync status unavailable';
errorDetail.classList.add('hidden');
}
content.className = 'flex items-start space-x-2 p-3 rounded-lg border text-sm ' + colorClass;
content.textContent = '';
const iconSpan = doc.createElement('span');
iconSpan.className = 'font-bold text-lg leading-none';
iconSpan.textContent = icon;
const textSpan = doc.createElement('span');
textSpan.textContent = text;
content.appendChild(iconSpan);
content.appendChild(textSpan);
}
/** The Role menu changed: show what goes with the new role, and ask for its status now. */
function changeSyncRole(root, ctx) {
if (showSyncRole(root) !== 'standalone') pollSyncStatus(root, ctx);
}
// ── lifecycle ────────────────────────────────────────────────────────────────
export function init(root, ctx) {
const win = root.ownerDocument.defaultView;
const on = { signal: ctx.signal };
const $ = function(id) { return root.querySelector('#' + id); };
const brightness = $('brightness');
const brightnessValue = $('brightness-value');
if (brightness && brightnessValue) {
brightness.addEventListener('input', function() { brightnessValue.textContent = brightness.value; }, on);
}
RESOLUTION_INPUTS.forEach(function(id) {
const el = $(id);
if (el) el.addEventListener('input', function() { showResolution(root); }, on);
});
const orientation = $('orientation');
if (orientation) orientation.addEventListener('change', function() { showResolution(root); }, on);
showResolution(root);
// Hidden rather than disabled, so the fields keep submitting and the
// server still sees an "off" state to persist.
[['vegas_scroll_enabled', 'vegas_scroll_settings', 'block'],
['double_sided_enabled', 'double_sided_settings', 'grid']].forEach(function(spec) {
const box = $(spec[0]);
const settings = $(spec[1]);
if (!box || !settings) return;
box.addEventListener('change', function() { settings.style.display = box.checked ? spec[2] : 'none'; }, on);
});
const speed = $('vegas_scroll_speed');
const speedValue = $('vegas_scroll_speed_value');
if (speed && speedValue) {
speed.addEventListener('input', function() {
speedValue.textContent = speed.value;
refreshScrollSpeedHint(root, ctx);
}, on);
refreshScrollSpeedHint(root, ctx);
}
root.addEventListener('click', function(event) {
const button = event.target && event.target.closest ? event.target.closest('[data-action="toggle-section"]') : null;
if (!button || !root.contains(button)) return;
if (typeof win.toggleSection === 'function') win.toggleSection(button.getAttribute('data-section'));
}, on);
const role = $('sync_role');
if (role) role.addEventListener('change', function() { changeSyncRole(root, ctx); }, on);
showSyncRole(root);
// The first request goes out as soon as the tab is on screen (at once,
// when it already is), then every 5 s while it stays there.
ctx.visibility.every(SYNC_POLL_MS, function() { pollSyncStatus(root, ctx); });
startPluginOrder(root, ctx);
active = ctx;
}
export function destroy(root, ctx) {
const win = root.ownerDocument.defaultView;
win.clearTimeout(ctx.state.hintTimer);
win.clearTimeout(ctx.state.orderTimer);
ctx.state.hintTimer = null;
ctx.state.orderTimer = null;
// The sync poll is ctx.visibility's: it stops when ctx.signal aborts.
if (active === ctx) active = null;
}
// ── the old global, kept as a deprecated alias (boot.js) ─────────────────────
/** window.updateSyncUI(): the Role menu's old onchange handler. */
export function updateSyncUI() {
if (active) changeSyncRole(active.root, active);
}
@@ -1,5 +1,5 @@
{% import 'v3/partials/_macros.html' as ui %} {% import 'v3/partials/_macros.html' as ui %}
<div class="bg-white rounded-lg shadow p-6"> <div class="bg-white rounded-lg shadow p-6" data-page="display">
<div class="border-b border-gray-200 pb-4 mb-6"> <div class="border-b border-gray-200 pb-4 mb-6">
<h2 class="text-lg font-semibold text-gray-900">Display Settings</h2> <h2 class="text-lg font-semibold text-gray-900">Display Settings</h2>
<p class="mt-1 text-sm text-gray-600">Configure LED matrix hardware settings and display options.</p> <p class="mt-1 text-sm text-gray-600">Configure LED matrix hardware settings and display options.</p>
@@ -168,7 +168,8 @@
class="w-full bg-gray-100 hover:bg-gray-200 px-4 py-3 flex items-center justify-between text-left transition-colors rounded-t-lg" class="w-full bg-gray-100 hover:bg-gray-200 px-4 py-3 flex items-center justify-between text-left transition-colors rounded-t-lg"
aria-controls="display-section-advanced-hardware" aria-controls="display-section-advanced-hardware"
aria-expanded="false" aria-expanded="false"
onclick="toggleSection('display-section-advanced-hardware')"> data-action="toggle-section"
data-section="display-section-advanced-hardware">
<div class="flex-1"> <div class="flex-1">
<h4 class="font-semibold text-gray-900"> <h4 class="font-semibold text-gray-900">
<i class="fas fa-sliders-h mr-1 text-gray-500"></i>Advanced Hardware &amp; Display Options (15) <i class="fas fa-sliders-h mr-1 text-gray-500"></i>Advanced Hardware &amp; Display Options (15)
@@ -394,42 +395,6 @@
</div> </div>
</div> <!-- /#display-section-advanced-hardware (nested-content) --> </div> <!-- /#display-section-advanced-hardware (nested-content) -->
</div> <!-- /advanced hardware nested-section --> </div> <!-- /advanced hardware nested-section -->
<script>
// Live "Your display: W x H" readout - width = cols x chain_length,
// height = rows x parallel (same math as the chain-length tooltip),
// swapped for a 90/270 orientation. A custom pixel mapper config can
// change it further; src/display_geometry.py models those.
(function () {
const ids = ['rows', 'cols', 'chain_length', 'parallel'];
const out = document.getElementById('display-resolution-value');
if (!out) return;
function recompute() {
const v = {};
for (const id of ids) {
const el = document.getElementById(id);
v[id] = el ? parseInt(el.value, 10) : NaN;
}
if (Object.values(v).some(n => !Number.isFinite(n) || n <= 0)) {
out.textContent = '—';
return;
}
let w = v.cols * v.chain_length, h = v.rows * v.parallel;
const orientation = document.getElementById('orientation');
if (orientation && (orientation.value === '90' || orientation.value === '270')) {
[w, h] = [h, w];
}
out.textContent = w + ' × ' + h + ' pixels';
}
for (const id of ids) {
const el = document.getElementById(id);
if (el) el.addEventListener('input', recompute);
}
const orientationSelect = document.getElementById('orientation');
if (orientationSelect) orientationSelect.addEventListener('change', recompute);
recompute();
})();
</script>
</div> </div>
<!-- Vegas Scroll Mode Settings --> <!-- Vegas Scroll Mode Settings -->
@@ -830,7 +795,7 @@ With this off a live game takes over the whole display with the full-screen scor
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-group" id="setting-display-sync_role" data-setting-key="sync.role"> <div class="form-group" id="setting-display-sync_role" data-setting-key="sync.role">
<label for="sync_role" class="block text-sm font-medium text-gray-700">Role{{ ui.help_tip('This unit\'s part in a two-display setup.\nStandalone = sync off. Set one Pi to Leader (drives the scroll) and the other to Follower (receives frames). Restart required after changing.', 'Sync Role') }}</label> <label for="sync_role" class="block text-sm font-medium text-gray-700">Role{{ ui.help_tip('This unit\'s part in a two-display setup.\nStandalone = sync off. Set one Pi to Leader (drives the scroll) and the other to Follower (receives frames). Restart required after changing.', 'Sync Role') }}</label>
<select id="sync_role" name="sync_role" class="form-control" onchange="updateSyncUI()"> <select id="sync_role" name="sync_role" class="form-control">
<option value="standalone" {% if main_config.get('sync', {}).get('role', 'standalone') == 'standalone' %}selected{% endif %}>Standalone (disabled)</option> <option value="standalone" {% if main_config.get('sync', {}).get('role', 'standalone') == 'standalone' %}selected{% endif %}>Standalone (disabled)</option>
<option value="leader" {% if main_config.get('sync', {}).get('role', 'standalone') == 'leader' %}selected{% endif %}>Leader (drives scroll)</option> <option value="leader" {% if main_config.get('sync', {}).get('role', 'standalone') == 'leader' %}selected{% endif %}>Leader (drives scroll)</option>
<option value="follower" {% if main_config.get('sync', {}).get('role', 'standalone') == 'follower' %}selected{% endif %}>Follower (receives frames)</option> <option value="follower" {% if main_config.get('sync', {}).get('role', 'standalone') == 'follower' %}selected{% endif %}>Follower (receives frames)</option>
@@ -879,261 +844,3 @@ With this off a live game takes over the whole display with the full-screen scor
</div> </div>
</form> </form>
</div> </div>
<script>
// Update brightness display
document.getElementById('brightness').addEventListener('input', function() {
document.getElementById('brightness-value').textContent = this.value;
});
// Vegas Scroll Mode Settings
(function() {
function escapeHtml(text) { return window.LEDEscape.html(text); }
function escapeAttr(text) { return window.LEDEscape.attr(text); }
// Toggle settings visibility
const vegasEnabledCheckbox = document.getElementById('vegas_scroll_enabled');
const vegasSettings = document.getElementById('vegas_scroll_settings');
if (vegasEnabledCheckbox && vegasSettings) {
vegasEnabledCheckbox.addEventListener('change', function() {
vegasSettings.style.display = this.checked ? 'block' : 'none';
});
}
// Double-sided: copies/axis only mean anything while the feature is on.
// Hidden rather than disabled so the fields keep submitting and the server
// still sees an 'off' state to persist.
const doubleSidedCheckbox = document.getElementById('double_sided_enabled');
const doubleSidedSettings = document.getElementById('double_sided_settings');
if (doubleSidedCheckbox && doubleSidedSettings) {
doubleSidedCheckbox.addEventListener('change', function() {
doubleSidedSettings.style.display = this.checked ? 'grid' : 'none';
});
}
// Declared before first use: let is not hoisted usably.
let scrollHintTimer = null;
let scrollHintSeq = 0;
// Update scroll speed display
const scrollSpeedSlider = document.getElementById('vegas_scroll_speed');
const scrollSpeedValue = document.getElementById('vegas_scroll_speed_value');
if (scrollSpeedSlider && scrollSpeedValue) {
scrollSpeedSlider.addEventListener('input', function() {
scrollSpeedValue.textContent = this.value;
refreshScrollSpeedHint();
});
refreshScrollSpeedHint();
}
// Tell the user what the panel will really do with this speed. Only
// speeds that advance a whole number of pixels per refresh look smooth,
// and which those are depends on the panel, so the server works it out.
function refreshScrollSpeedHint() {
clearTimeout(scrollHintTimer);
scrollHintTimer = setTimeout(function() {
const hint = document.getElementById('vegas_scroll_speed_hint');
if (!hint) return;
const seq = ++scrollHintSeq;
const q = new URLSearchParams({
speed: scrollSpeedSlider.value,
min: scrollSpeedSlider.min,
max: scrollSpeedSlider.max
});
fetch('/api/v3/config/scroll-speed-advice?' + q)
.then(function(r) { return r.json(); })
.then(function(body) {
if (seq !== scrollHintSeq || body.status !== 'success') return;
renderScrollSpeedHint(hint, body.data);
})
.catch(function() { hint.textContent = ''; });
}, 150);
}
function renderScrollSpeedHint(hint, a) {
const ap = a.applied;
const motion = ap.pixels_per_frame + ' px every ' + ap.frame_hold +
' refresh' + (ap.frame_hold === 1 ? '' : 'es');
hint.textContent = '';
hint.className = 'mt-1 text-xs ' + (a.smooth && a.exact ? 'text-green-700' : 'text-amber-700');
const line = document.createElement('span');
if (a.smooth && a.exact) {
line.textContent = 'Smooth on this panel (' + motion + ', ' + a.refresh_hz + ' Hz).';
} else {
line.textContent = a.requested + ' px/s will run as ' + ap.pixels_per_second +
' px/s (' + motion + ', ' + ap.steppiness + ') on this ' + a.refresh_hz +
' Hz panel.' + (a.alternatives.length ? ' Smooth speeds: ' : '');
}
hint.appendChild(line);
a.alternatives.forEach(function(alt, i) {
if (i > 0) hint.appendChild(document.createTextNode(' '));
const value = Math.round(alt.pixels_per_second);
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'underline font-medium';
btn.textContent = value + ' px/s';
btn.addEventListener('click', function() {
scrollSpeedSlider.value = value;
scrollSpeedSlider.dispatchEvent(new Event('input', {bubbles: true}));
});
hint.appendChild(btn);
});
}
// Initialize plugin order list via the shared drag-and-drop module
// (static/v3/js/widgets/plugin-order-list.js) — the same component the
// Durations tab uses for the primary rotation order.
function initPluginOrderList(attempt) {
const container = document.getElementById('vegas_plugin_order');
if (!container) return;
if (!window.PluginOrderList) {
// Widget script is deferred; retry briefly, then surface a real
// error instead of waiting forever.
if ((attempt || 0) < 50) {
setTimeout(function() { initPluginOrderList((attempt || 0) + 1); }, 100);
} else {
container.textContent = 'Could not load the reorder widget — reload the page to try again.';
container.className = 'text-sm text-red-500';
}
return;
}
window.PluginOrderList.init({
containerId: 'vegas_plugin_order',
orderInputId: 'vegas_plugin_order_value',
excludedInputId: 'vegas_excluded_plugins_value',
showVegasModeBadge: true
});
}
// Initialize on DOM ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initPluginOrderList);
} else {
initPluginOrderList();
}
})();
// Multi-Display Sync UI
(function() {
function updateSyncUI() {
const role = document.getElementById('sync_role').value;
const bar = document.getElementById('sync_status_bar');
const posGroup = document.getElementById('setting-display-sync_follower_position');
if (role === 'standalone') {
bar.classList.add('hidden');
document.getElementById('sync_error_detail').classList.add('hidden');
posGroup.style.display = 'none';
} else {
bar.classList.remove('hidden');
posGroup.style.display = role === 'follower' ? '' : 'none';
pollSyncStatus();
}
}
window.updateSyncUI = updateSyncUI;
function pollSyncStatus() {
const role = document.getElementById('sync_role') && document.getElementById('sync_role').value;
if (!role || role === 'standalone') return;
fetch('/api/v3/sync/status')
.then(r => r.json())
.then(resp => {
const d = resp.data || {};
renderSyncStatus(d);
})
.catch(() => {
renderSyncStatus({state: 'unknown'});
});
}
function renderSyncStatus(d) {
const content = document.getElementById('sync_status_content');
const errorDetail = document.getElementById('sync_error_detail');
const errorText = document.getElementById('sync_error_text');
if (!content) return;
const state = d.state || 'unknown';
const role = d.role || 'unknown';
let icon, colorClass, text;
if (state === 'connected' || state === 'follower') {
icon = '●';
colorClass = 'bg-green-50 border-green-200 text-green-800';
const peer = d.peer_ip || d.leader_ip || 'peer';
text = role === 'leader'
? `Follower connected — ${peer} (chain ${d.peer_chain || '?'})`
: `Receiving from leader — ${peer}`;
errorDetail.classList.add('hidden');
} else if (state === 'incompatible') {
icon = '⚠';
colorClass = 'bg-yellow-50 border-yellow-200 text-yellow-800';
text = `Follower connected but incompatible panels`;
if (d.error) {
errorText.textContent = d.error;
errorDetail.classList.remove('hidden');
}
} else if (state === 'no_peer' || state === 'standalone') {
icon = '○';
colorClass = 'bg-gray-50 border-gray-200 text-gray-600';
text = role === 'leader' ? 'No follower detected' : 'Searching for leader…';
errorDetail.classList.add('hidden');
} else if (state === 'starting') {
icon = '○';
colorClass = 'bg-gray-50 border-gray-200 text-gray-500';
text = 'Display process starting…';
errorDetail.classList.add('hidden');
} else {
icon = '✕';
colorClass = 'bg-red-50 border-red-200 text-red-700';
text = 'Sync status unavailable';
errorDetail.classList.add('hidden');
}
content.className = `flex items-start space-x-2 p-3 rounded-lg border text-sm ${colorClass}`;
content.textContent = '';
const iconSpan = document.createElement('span');
iconSpan.className = 'font-bold text-lg leading-none';
iconSpan.textContent = icon;
const textSpan = document.createElement('span');
textSpan.textContent = text;
content.appendChild(iconSpan);
content.appendChild(textSpan);
}
// Initial UI state, then the 5s sync-status poll — only while the Display
// tab is active and the page is visible. LEDVisibility keys the
// registration, so a re-run of this partial replaces the previous timer.
function stopSyncTimer() {
if (window.syncStatusInterval) {
clearInterval(window.syncStatusInterval);
window.syncStatusInterval = null;
}
}
function startSyncTimer() {
stopSyncTimer();
pollSyncStatus();
window.syncStatusInterval = setInterval(pollSyncStatus, 5000);
}
function startSyncPolling() {
updateSyncUI();
window.LEDVisibility.onActive('display', startSyncTimer, stopSyncTimer, 'display-sync-status');
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', startSyncPolling);
} else {
startSyncPolling();
}
})();
</script>