mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-05 14:55:08 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e745ae8060 | ||
|
|
a669d781f5 | ||
|
|
c20c0beac2 | ||
|
|
6fb2dc3595 | ||
|
|
b638b91169 | ||
|
|
bb475a79ea |
+122
-12
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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 |
|
||||||
|
|||||||
@@ -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); });
|
||||||
@@ -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
@@ -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 => {
|
||||||
|
|||||||
@@ -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); });
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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); });
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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 & Display Options (15)
|
<i class="fas fa-sliders-h mr-1 text-gray-500"></i>Advanced Hardware & 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>
|
|
||||||
|
|||||||
Reference in New Issue
Block a user