mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-05 23:05:10 +00:00
Compare commits
19
Commits
main
...
53f4f1fdbf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53f4f1fdbf | ||
|
|
1a1a6f5c65 | ||
|
|
0b3fa712d0 | ||
|
|
fb9820b78e | ||
|
|
ad47f89066 | ||
|
|
3973f0c3c5 | ||
|
|
04cad60259 | ||
|
|
22b88c4677 | ||
|
|
a51305bb4f | ||
|
|
d4f828d189 | ||
|
|
ec1aa34f40 | ||
|
|
f96c815e0e | ||
|
|
559ef14a5c | ||
|
|
8cce532ea9 | ||
|
|
876130e93f | ||
|
|
701c220ad0 | ||
|
|
1160eb5efe | ||
|
|
6efea8c19a | ||
|
|
1252df9bad |
@@ -384,9 +384,49 @@ read any of them:
|
||||
the Vegas update tick runs every second instead of every four.
|
||||
- Web UI: "Update live content while it scrolls" under Vegas mode's Cycle
|
||||
Pacing (`display.vegas_scroll.live_refresh`).
|
||||
- **Live cards for the scoreboards (shared code).** New module
|
||||
`src/common/sports_vegas.py`: `game_key()`, `dedupe_games()`,
|
||||
`VegasCardCache` (draws a card only when its fingerprint changes) and
|
||||
`StickyOdds` (keeps a card's odds through a live poll that left them out),
|
||||
`finished_games()` and `with_finished_games()` (a game that just went final
|
||||
keeps its card, showing FINAL, where its live card was).
|
||||
`SportsScrollDisplay` gains `make_vegas_renderer()` (the override point; a
|
||||
sport that does not implement it keeps its ordinary Vegas content),
|
||||
`render_vegas_card()`, `vegas_separator()` and `build_vegas_elements()`,
|
||||
and `SportsScrollDisplayManager` gains `get_vegas_elements_for()`.
|
||||
`SportsLiveSharedMixin` gains `_record_finished_game()` /
|
||||
`finished_games_snapshot()`, so a game that goes final keeps a card to show
|
||||
FINAL on until the hourly recent list takes it over.
|
||||
- `scripts/render_plugin.py --vegas` renders a plugin's block of the Vegas
|
||||
strip as the ticker lays it out (live elements, or with `--no-live` its
|
||||
ordinary content) and writes the live elements' keys and columns beside
|
||||
it. `--timeline ROWS` stacks the block at successive moments as the
|
||||
ticker would update it in place (`--timeline-step`, and
|
||||
`--timeline-update` to run `update()` between rows).
|
||||
`render_vegas_strip()` and `render_vegas_timeline()` in
|
||||
`src/plugin_system/testing/vegas.py`; the join is now
|
||||
`render_pipeline.join_plugin_rows()`.
|
||||
- **Behaviour change: live games stay in the Vegas ticker by default.**
|
||||
`display.vegas_scroll.live_in_ticker` now defaults to `true`: the marquee
|
||||
keeps running through a live game, which takes extra turns in it, instead
|
||||
of giving way to the full-screen scoreboard. Existing configs all held the
|
||||
old `false`, copied from the template, so the first start turns it on once
|
||||
(`ConfigManager._migrate_live_in_ticker_default`; the previous config is
|
||||
kept as `config.json.backup` and `live_in_ticker_migrated` records that it
|
||||
ran). To keep the full-screen scoreboard, untick the new **Keep live games
|
||||
in the ticker** under Vegas mode; a `false` set after the migration stays.
|
||||
|
||||
### Scrolling
|
||||
|
||||
- A Vegas strip extension no longer costs a late frame. Appending the next
|
||||
group rebuilt the whole strip (`np.concatenate`, 2-2.6ms for a 10-14k px
|
||||
strip at 512x64 on a Pi 4) and trimming copied what was left (1.2-1.8ms),
|
||||
so on hdpi every extension frame missed its refresh. The strip now lives in
|
||||
a buffer with spare room (`ScrollHelper.STRIP_SPARE_FACTOR`): an append
|
||||
writes only the new columns (~0.2ms), a trim only moves the start, and the
|
||||
one full copy happens when the buffer is reallocated, about once every two
|
||||
strip-lengths scrolled. A strip set from outside (the multi-display
|
||||
follower's) is never written through.
|
||||
- A Vegas strip extension costs the render thread about a third of what it
|
||||
did. Appending the next group and trimming what has scrolled past each
|
||||
rebuilt the strip's PIL image from its numpy array in full
|
||||
|
||||
@@ -134,7 +134,7 @@
|
||||
"plugin_rotation_order": [],
|
||||
"use_short_date_format": true,
|
||||
"vegas_scroll": {
|
||||
"live_in_ticker": false,
|
||||
"live_in_ticker": true,
|
||||
"live_weight": 3,
|
||||
"favorite_live_weight": 5,
|
||||
"enabled": false,
|
||||
|
||||
@@ -75,17 +75,31 @@ total. See the full list in
|
||||
|
||||
### Live Content in the Ticker
|
||||
|
||||
By default, live content **preempts** Vegas mode: while any plugin reports
|
||||
live priority, the display controller refuses to run the ticker and shows
|
||||
that plugin's full-screen display instead. You get a big readable scoreboard,
|
||||
but the marquee stops entirely for the duration of the game.
|
||||
By default (since 3.8.0) live content **stays in the ticker** and takes
|
||||
**extra turns inside it**, and a scoreboard that supports live cards updates
|
||||
the score on a card already crossing the screen (`live_refresh`, "Update live
|
||||
content while it scrolls").
|
||||
|
||||
Set `live_in_ticker` to keep the ticker running and let live content take
|
||||
**extra turns inside it** instead:
|
||||
To get the old behaviour back -- live content **preempts** Vegas mode: while
|
||||
any plugin reports live priority the ticker stops and that plugin's
|
||||
full-screen display is shown instead -- untick **Keep live games in the
|
||||
ticker** under Vegas mode, or set `live_in_ticker` to `false`:
|
||||
|
||||
```json
|
||||
"vegas_scroll": {
|
||||
"live_in_ticker": false
|
||||
}
|
||||
```
|
||||
|
||||
Until 3.8.0 `false` was the default and every config held it, copied from
|
||||
the template. The first start on 3.8.0 turns it on once (a backup of the
|
||||
config is kept as `config.json.backup`, and `live_in_ticker_migrated` records
|
||||
that it ran); a `false` set after that is left alone.
|
||||
|
||||
The weights below apply while live content is in the ticker:
|
||||
|
||||
```json
|
||||
"vegas_scroll": {
|
||||
"live_in_ticker": true,
|
||||
"live_weight": 3,
|
||||
"favorite_live_weight": 5
|
||||
}
|
||||
|
||||
@@ -152,7 +152,7 @@ Read by `src/vegas_mode/config.py` (`VegasScrollConfig.from_config`). See
|
||||
| `max_cycle_duration` | int, `240` |
|
||||
| `frame_based_scrolling` | bool, `true` — does not step or set a frame rate; motion is by elapsed time either way. When `true`, `scroll_speed` passes through a clamp of 0.1–5 px per `scroll_delay` (see next row) |
|
||||
| `scroll_delay` | float, `0.02` — not a frame period. Only used with `frame_based_scrolling`: the applied speed is `clamp(scroll_speed × scroll_delay, 0.1, 5) / scroll_delay` px/s, so at `0.02` speeds under 5 px/s run at 5, and at `0.001` nothing runs slower than 100 px/s |
|
||||
| `live_in_ticker` | bool, `false` — keep scrolling during live games instead of handing the display to a full-screen scoreboard |
|
||||
| `live_in_ticker` | bool, `true` — keep scrolling during live games instead of handing the display to a full-screen scoreboard. `false` was the default before 3.8.0; the first start on 3.8.0 turns a stored `false` on once and sets `live_in_ticker_migrated` |
|
||||
| `live_weight` | int, `3` (1–10) — slots per cycle for a plugin with live content |
|
||||
| `favorite_live_weight` | int, `5` (1–10) — slots per cycle when a plugin reports a favorite team is live |
|
||||
|
||||
|
||||
@@ -336,6 +336,9 @@ to rebuild it twice, 1.7-3.8 ms each on a Pi 4). The `extend` row of
|
||||
work" table), and extensions no longer rebuilding the strip's PIL copy.
|
||||
3. **Live elements:** the plugin API, the records, the worker and in-place
|
||||
patches, with the sports scoreboards and the flight map adopting it.
|
||||
4. **Live games in the ticker by default:** `live_in_ticker` true, so a live
|
||||
game's cards update in the marquee instead of the full-screen scoreboard
|
||||
replacing it; existing configs are switched once (`ConfigManager`).
|
||||
|
||||
`display.vegas_scroll.offscreen_prefetch` (default `true`) restores the
|
||||
deferred path when `false`, and `display.vegas_scroll.live_refresh` (default
|
||||
|
||||
@@ -308,9 +308,9 @@ the core then falls back to its own live-content check — so a plugin whose
|
||||
weight calculation is broken still gets `live_weight` for a game that really
|
||||
is live, rather than being demoted to 1.
|
||||
|
||||
Only consulted when the user has set `vegas_scroll.live_in_ticker`. With the
|
||||
default (`false`) live content preempts Vegas entirely and there is no ticker
|
||||
to be weighted within. See
|
||||
Only consulted while `vegas_scroll.live_in_ticker` is on (the default since
|
||||
3.8.0). With it off live content preempts Vegas entirely and there is no
|
||||
ticker to be weighted within. See
|
||||
[ADVANCED_FEATURES.md](ADVANCED_FEATURES.md#live-content-in-the-ticker).
|
||||
|
||||
### Vegas scroll hooks
|
||||
|
||||
@@ -37,6 +37,7 @@ src/common/sports_celebration.py
|
||||
src/common/sports_fetch.py
|
||||
src/common/sports_scroll.py
|
||||
src/common/sports_timezone.py
|
||||
src/common/sports_vegas.py
|
||||
src/config_service.py
|
||||
src/core_config_keys.py
|
||||
src/deprecation.py
|
||||
|
||||
+54
-3
@@ -200,12 +200,21 @@ def api_plugin_defaults(plugin_id):
|
||||
return jsonify({'defaults': defaults})
|
||||
|
||||
|
||||
#: /api/render "vegas" values: the plugin's block of the Vegas strip, built
|
||||
#: from its live elements (falling back to its Vegas content, as the ticker
|
||||
#: does) or from its ordinary Vegas content only.
|
||||
VEGAS_VIEWS = ('live', 'plain')
|
||||
|
||||
|
||||
def _render_once(plugin_id, plugin_dir, manifest, config, mock_data, width, height,
|
||||
skip_update):
|
||||
skip_update, vegas=None):
|
||||
"""Render one plugin at one size. Returns the /api/render response dict.
|
||||
|
||||
A fresh plugin instance per call, mirroring the safety harness, so sizes
|
||||
never share state.
|
||||
never share state. With ``vegas`` set ('live' or 'plain') the image is the
|
||||
plugin's block of the Vegas strip instead of its display(), laid out by
|
||||
the ticker's own code (src/plugin_system/testing/vegas.py), and the
|
||||
response lists where each live element sits in it.
|
||||
"""
|
||||
from src.plugin_system.testing import VisualTestDisplayManager, MockCacheManager, MockPluginManager
|
||||
from src.plugin_system.plugin_loader import PluginLoader
|
||||
@@ -243,6 +252,10 @@ def _render_once(plugin_id, plugin_dir, manifest, config, mock_data, width, heig
|
||||
logger.warning("update() raised for plugin %s", plugin_id, exc_info=True)
|
||||
warnings.append(f"update() raised: {type(e).__name__} — see server log")
|
||||
|
||||
if vegas:
|
||||
return _vegas_response(plugin_id, plugin_instance, display_manager, vegas,
|
||||
start_time, errors, warnings)
|
||||
|
||||
# Run display()
|
||||
try:
|
||||
plugin_instance.display(force_clear=True)
|
||||
@@ -262,6 +275,40 @@ def _render_once(plugin_id, plugin_dir, manifest, config, mock_data, width, heig
|
||||
}
|
||||
|
||||
|
||||
def _vegas_response(plugin_id, plugin_instance, display_manager, vegas, start_time,
|
||||
errors, warnings):
|
||||
"""The /api/render response for the Vegas strip view."""
|
||||
import base64
|
||||
import io
|
||||
|
||||
from src.plugin_system.testing.vegas import render_vegas_strip
|
||||
|
||||
block, layout = None, []
|
||||
try:
|
||||
block, layout = render_vegas_strip(plugin_instance, plugin_id, display_manager,
|
||||
live=(vegas == 'live'))
|
||||
except Exception as e:
|
||||
logger.warning("Vegas render raised for plugin %s", plugin_id, exc_info=True)
|
||||
errors.append(f"Vegas render raised: {type(e).__name__} — see server log")
|
||||
if block is None:
|
||||
if not errors:
|
||||
errors.append("The plugin has no Vegas content")
|
||||
block = display_manager.image
|
||||
elif vegas == 'live' and not layout:
|
||||
warnings.append("No live elements: this is the plugin's ordinary Vegas content")
|
||||
buffer = io.BytesIO()
|
||||
block.convert('RGB').save(buffer, format='PNG')
|
||||
return {
|
||||
'image': 'data:image/png;base64,' + base64.b64encode(buffer.getvalue()).decode('ascii'),
|
||||
'width': block.width,
|
||||
'height': block.height,
|
||||
'render_time_ms': round((time.time() - start_time) * 1000, 1),
|
||||
'errors': errors,
|
||||
'warnings': warnings,
|
||||
'live_elements': [{'key': key, 'x': x, 'width': width} for x, key, width in layout],
|
||||
}
|
||||
|
||||
|
||||
def _trusted_plugin_dir(plugin_dir: Path) -> Optional[Path]:
|
||||
"""Re-derive a plugin directory from the search dirs' own listings.
|
||||
|
||||
@@ -333,6 +380,10 @@ def api_render():
|
||||
if not (MIN_HEIGHT <= height <= MAX_HEIGHT):
|
||||
return jsonify({'error': f'height must be between {MIN_HEIGHT} and {MAX_HEIGHT}'}), 400
|
||||
|
||||
vegas = data.get('vegas') or None
|
||||
if vegas is not None and vegas not in VEGAS_VIEWS:
|
||||
return jsonify({'error': f'vegas must be one of {", ".join(VEGAS_VIEWS)}'}), 400
|
||||
|
||||
try:
|
||||
plugin_dir, manifest, config, mock_data, skip_update = _parse_render_request(data)
|
||||
except LookupError:
|
||||
@@ -345,7 +396,7 @@ def api_render():
|
||||
|
||||
try:
|
||||
result = _render_once(data['plugin_id'], plugin_dir, manifest, config,
|
||||
mock_data, width, height, skip_update)
|
||||
mock_data, width, height, skip_update, vegas=vegas)
|
||||
except Exception:
|
||||
app.logger.exception('plugin load failed during render')
|
||||
return jsonify({'error': 'Failed to load plugin; see server log'}), 500
|
||||
|
||||
@@ -56,9 +56,33 @@ def main() -> int:
|
||||
help='Display mode to render, for plugins that declare '
|
||||
'more than one in their manifest (e.g. nrl_live). '
|
||||
'Omitted, the plugin picks its own default.')
|
||||
parser.add_argument('--vegas', action='store_true',
|
||||
help="Render the plugin's block of the Vegas ticker strip "
|
||||
"instead of display(): its live elements if it has "
|
||||
"them, else its Vegas content, laid out as the "
|
||||
"ticker lays them out. Also writes the live "
|
||||
"elements' keys and columns to <output>.json")
|
||||
parser.add_argument('--no-live', action='store_true',
|
||||
help="With --vegas: ignore live elements and render the "
|
||||
"plugin's ordinary Vegas content (for before/after)")
|
||||
parser.add_argument('--timeline', type=int, default=0, metavar='ROWS',
|
||||
help="With --vegas: render ROWS rows, each the block a "
|
||||
"--timeline-step later as the ticker would update it "
|
||||
"in place (animated elements redrawn for that moment)")
|
||||
parser.add_argument('--timeline-step', type=float, default=0.25, metavar='SECONDS',
|
||||
help="Seconds between --timeline rows (default 0.25)")
|
||||
parser.add_argument('--timeline-update', action='store_true',
|
||||
help="With --timeline: run update() before each row and "
|
||||
"redraw every live element from the new data")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.timeline > 1 and args.no_live:
|
||||
# A timeline shows live elements changing; plain content never does.
|
||||
parser.error("--timeline shows live elements; it cannot be combined with --no-live")
|
||||
if (args.timeline or args.no_live) and not args.vegas:
|
||||
parser.error("--timeline and --no-live need --vegas")
|
||||
|
||||
if not (MIN_DIMENSION <= args.width <= MAX_DIMENSION):
|
||||
print(f"Error: --width must be between {MIN_DIMENSION} and {MAX_DIMENSION} (got {args.width})")
|
||||
raise SystemExit(1)
|
||||
@@ -145,6 +169,39 @@ def main() -> int:
|
||||
except Exception as e:
|
||||
logger.warning("update() raised: %s — continuing to display()", e)
|
||||
|
||||
if args.vegas:
|
||||
Path(args.output).parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if args.vegas and args.timeline > 1:
|
||||
from src.plugin_system.testing.vegas import render_vegas_timeline
|
||||
image, rows = render_vegas_timeline(
|
||||
plugin_instance, args.plugin, display_manager, steps=args.timeline,
|
||||
step_seconds=args.timeline_step, run_update=args.timeline_update)
|
||||
if image is None:
|
||||
logger.error("Plugin '%s' has no Vegas content", args.plugin)
|
||||
return 1
|
||||
image.save(args.output)
|
||||
logger.info("Saved a %d-row Vegas timeline (%dx%d) to %s",
|
||||
rows, image.width, image.height, args.output)
|
||||
return 0
|
||||
|
||||
if args.vegas:
|
||||
from src.plugin_system.testing.vegas import render_vegas_strip
|
||||
block, layout = render_vegas_strip(
|
||||
plugin_instance, args.plugin, display_manager, live=not args.no_live)
|
||||
if block is None:
|
||||
logger.error("Plugin '%s' has no Vegas content", args.plugin)
|
||||
return 1
|
||||
block.save(args.output)
|
||||
sidecar = Path(args.output).with_suffix('.json')
|
||||
sidecar.write_text(json.dumps(
|
||||
{"width": block.width, "height": block.height,
|
||||
"live_elements": [{"key": k, "x": x, "width": w} for x, k, w in layout]},
|
||||
indent=2) + "\n", encoding="utf-8")
|
||||
logger.info("Saved Vegas strip %dx%d (%d live element(s)) to %s and %s",
|
||||
block.width, block.height, len(layout), args.output, sidecar)
|
||||
return 0
|
||||
|
||||
# A plugin that declares several display modes usually renders nothing
|
||||
# useful without being told which one to draw: the scoreboards keep their
|
||||
# state on per-mode sub-managers and their no-argument path returns False.
|
||||
|
||||
@@ -247,6 +247,19 @@
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- What to render: the plugin's screen, or its block of the Vegas strip -->
|
||||
<div class="flex items-center gap-2">
|
||||
<label for="viewSelect" class="text-xs whitespace-nowrap" style="color: var(--text-secondary);">View</label>
|
||||
<select id="viewSelect" onchange="onConfigChange()"
|
||||
class="flex-1 px-2 py-1.5 rounded-lg text-xs"
|
||||
style="background: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border-color);"
|
||||
title="Vegas strip: the plugin's block of the Vegas ticker, laid out as the ticker lays it out">
|
||||
<option value="">Display</option>
|
||||
<option value="live">Vegas strip (live elements)</option>
|
||||
<option value="plain">Vegas strip (plain Vegas content)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Render buttons -->
|
||||
<div class="flex gap-2">
|
||||
<button onclick="renderPlugin()" id="renderBtn"
|
||||
@@ -489,6 +502,7 @@
|
||||
width: width,
|
||||
height: height,
|
||||
mock_data: mockData,
|
||||
vegas: document.getElementById('viewSelect').value || null,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -510,8 +524,11 @@
|
||||
updateZoom();
|
||||
|
||||
// Show render time
|
||||
const live = data.live_elements;
|
||||
document.getElementById('renderTimeText').textContent =
|
||||
`${data.render_time_ms}ms`;
|
||||
`${data.render_time_ms}ms` + (live ? ` · ${data.width}px strip, ` +
|
||||
`${live.length} live element(s)` +
|
||||
(live.length ? `: ${live.map(e => e.key).join(', ')}` : '') : '');
|
||||
|
||||
// Show warnings/errors
|
||||
showMessages(data.errors || [], data.warnings || []);
|
||||
|
||||
@@ -45,6 +45,7 @@ Rules for the package:
|
||||
| [`sports_helpers`](#sports_helpers) | Small helpers every scoreboard `sports.py` copies | Yes (scoreboards) | 3.5.0 |
|
||||
| [`sports_scroll`](#sports_scroll) | Scoreboard scroll-display orchestration | Yes (scoreboards) | 3.2.0 |
|
||||
| [`sports_shared`](#sports_shared) | Sport-independent `sports.py` methods | Yes (scoreboards) | 3.3.0 |
|
||||
| [`sports_vegas`](#sports_vegas) | Live Vegas cards: keys, card cache, sticky odds, finished games | Yes (scoreboards) | 3.8.0 |
|
||||
| [`sports_timezone`](#sports_timezone) | Which timezone a scoreboard draws start times in | Yes (scoreboards) | 3.6.0 |
|
||||
| [`sync_manager`](#sync_manager) | Leader/follower sync between two displays | No, core-internal | n/a |
|
||||
| [`text_helper`](#text_helper) | Outlined text, wrapping, measurement | Yes | — |
|
||||
@@ -280,6 +281,17 @@ fonts, colours, dates, the switch-mode upcoming card). The docstring lists
|
||||
the attributes the host class must have and the three methods deliberately
|
||||
left out.
|
||||
|
||||
### sports_vegas
|
||||
|
||||
[`sports_vegas.py`](sports_vegas.py). What a scoreboard needs for live Vegas
|
||||
cards (one element per game, swapped in place while it scrolls):
|
||||
`game_key()`, `game_fingerprint()`, `dedupe_games()`, `VegasCardCache` (draws
|
||||
a card only when its fingerprint changes), `StickyOdds` (keeps a card's odds
|
||||
through a live poll that left them out), and `finished_games()` /
|
||||
`with_finished_games()` (a game that just went final keeps its card, showing
|
||||
FINAL). `SportsScrollDisplay.build_vegas_elements()` in `sports_scroll` puts
|
||||
them together; a scoreboard not built on it (UFC) uses them directly.
|
||||
|
||||
### sports_timezone
|
||||
|
||||
[`sports_timezone.py`](sports_timezone.py).
|
||||
|
||||
@@ -114,6 +114,18 @@ class ScrollHelper:
|
||||
self.cached_image = None # see the property below
|
||||
self.cached_array: Optional[np.ndarray] = None # Numpy array cache for fast operations
|
||||
self.total_scroll_width = 0
|
||||
# An extended strip lives in a buffer with spare room after it, and
|
||||
# cached_array is a view of the buffer's live columns: an append writes
|
||||
# only the new columns, and a trim only moves the view's start. See
|
||||
# append_content. _strip_view is the view this helper last made; a
|
||||
# cached_array that is anything else was set from outside and is not
|
||||
# written through.
|
||||
self._strip_buffer: Optional[np.ndarray] = None
|
||||
self._strip_view: Optional[np.ndarray] = None
|
||||
self._strip_start = 0
|
||||
#: Bytes the last append_content / drop_scrolled_prefix copied, for
|
||||
#: frame-timing attribution (src/common/frame_timing.py note_op).
|
||||
self.last_copy_bytes = 0
|
||||
|
||||
# Pre-allocated buffer for output frame (reused to avoid allocations)
|
||||
self._frame_buffer: Optional[np.ndarray] = None
|
||||
@@ -249,6 +261,7 @@ class ScrollHelper:
|
||||
self.total_scroll_width = 0
|
||||
self.cached_image = Image.new('RGB', (self.display_width, self.display_height), (0, 0, 0))
|
||||
self.cached_array = np.array(self.cached_image)
|
||||
self._forget_strip_buffer()
|
||||
self.scroll_position = 0.0
|
||||
self.total_distance_scrolled = 0.0
|
||||
self.scroll_complete = False
|
||||
@@ -284,6 +297,7 @@ class ScrollHelper:
|
||||
self.cached_image = full_image
|
||||
# Convert to numpy array for fast operations
|
||||
self.cached_array = np.array(full_image)
|
||||
self._forget_strip_buffer()
|
||||
actual_image_width = full_image.width
|
||||
self.total_scroll_width = actual_image_width
|
||||
|
||||
@@ -712,12 +726,12 @@ class ScrollHelper:
|
||||
addition.paste(img, (x, 0))
|
||||
x += img.width + element_gap
|
||||
|
||||
# numpy concatenate, and no conversion back: the strip can be tens of
|
||||
# thousands of columns wide and this runs on the render path. The PIL
|
||||
# image is built from the array only if something reads it (see the
|
||||
# cached_image property).
|
||||
self.cached_array = np.concatenate(
|
||||
(self.cached_array, np.array(addition)), axis=1)
|
||||
# Written into the spare room after the strip, when there is some:
|
||||
# the strip can be tens of thousands of columns wide and this runs on
|
||||
# the render thread, where copying all of it (2-3 ms at 512x64 on a
|
||||
# Pi 4) cost the frame after every extension. The PIL image is built
|
||||
# from the array only if something reads it (see cached_image).
|
||||
self.cached_array = self._extended_strip(np.asarray(addition))
|
||||
self._defer_image()
|
||||
self.total_scroll_width = self.cached_array.shape[1]
|
||||
self.scroll_complete = False
|
||||
@@ -729,6 +743,44 @@ class ScrollHelper:
|
||||
)
|
||||
return True
|
||||
|
||||
#: Room an extended strip's buffer is given, as a multiple of what it
|
||||
#: holds when (re)allocated. Trims free columns at the front and appends
|
||||
#: use them at the back, so with 3x the buffer is reallocated -- the one
|
||||
#: full copy -- about once every two strip-lengths scrolled.
|
||||
STRIP_SPARE_FACTOR = 3.0
|
||||
|
||||
def _extended_strip(self, addition: np.ndarray) -> np.ndarray:
|
||||
"""The strip with ``addition`` after it, written in place when it fits."""
|
||||
live = self.cached_array
|
||||
if live is None:
|
||||
# append_content builds a first strip itself and never comes here.
|
||||
raise RuntimeError("no strip to extend")
|
||||
width = live.shape[1]
|
||||
added = addition.shape[1]
|
||||
buffer = self._strip_buffer
|
||||
if (live is self._strip_view and buffer is not None
|
||||
and self._strip_start + width + added <= buffer.shape[1]):
|
||||
end = self._strip_start + width
|
||||
buffer[:, end:end + added] = addition
|
||||
self.last_copy_bytes = addition.nbytes
|
||||
else:
|
||||
total = width + added
|
||||
buffer = np.empty((live.shape[0], max(total + 1, int(total * self.STRIP_SPARE_FACTOR)))
|
||||
+ live.shape[2:], dtype=live.dtype)
|
||||
buffer[:, :width] = live
|
||||
buffer[:, width:total] = addition
|
||||
self._strip_buffer = buffer
|
||||
self._strip_start = 0
|
||||
self.last_copy_bytes = live.nbytes + addition.nbytes
|
||||
self._strip_view = buffer[:, self._strip_start:self._strip_start + width + added]
|
||||
return self._strip_view
|
||||
|
||||
def _forget_strip_buffer(self) -> None:
|
||||
"""A new strip replaces the extended one: let its buffer go."""
|
||||
self._strip_buffer = None
|
||||
self._strip_view = None
|
||||
self._strip_start = 0
|
||||
|
||||
def drop_scrolled_prefix(self, keep_before: int = 0) -> int:
|
||||
"""
|
||||
Discard columns that have already scrolled past, to bound memory.
|
||||
@@ -766,9 +818,18 @@ class ScrollHelper:
|
||||
if cut <= 0:
|
||||
return 0
|
||||
|
||||
# .copy() so the original buffer is released rather than kept alive by
|
||||
# a numpy view. The PIL image is deferred, as in append_content.
|
||||
self.cached_array = self.cached_array[:, cut:].copy()
|
||||
if self.cached_array is self._strip_view:
|
||||
# Only the view's start moves; the columns behind it are reused
|
||||
# when the buffer is next reallocated (append_content).
|
||||
self._strip_view = self.cached_array[:, cut:]
|
||||
self._strip_start += cut
|
||||
self.cached_array = self._strip_view
|
||||
self.last_copy_bytes = 0
|
||||
else:
|
||||
# Not a strip this helper extended: .copy() so the original
|
||||
# buffer is released rather than kept alive by a view.
|
||||
self.cached_array = self.cached_array[:, cut:].copy()
|
||||
self.last_copy_bytes = self.cached_array.nbytes
|
||||
self._defer_image()
|
||||
self.total_scroll_width = self.cached_array.shape[1]
|
||||
self.scroll_position -= cut
|
||||
@@ -881,6 +942,7 @@ class ScrollHelper:
|
||||
|
||||
# Convert to numpy array for fast operations (required for get_visible_portion)
|
||||
self.cached_array = np.array(image)
|
||||
self._forget_strip_buffer()
|
||||
|
||||
# Update scroll width
|
||||
self.total_scroll_width = image.width
|
||||
@@ -1138,6 +1200,7 @@ class ScrollHelper:
|
||||
"""
|
||||
self.cached_image = None
|
||||
self.cached_array = None
|
||||
self._forget_strip_buffer()
|
||||
self.total_scroll_width = 0
|
||||
self.scroll_position = 0.0
|
||||
self.total_distance_scrolled = 0.0
|
||||
|
||||
+184
-2
@@ -42,17 +42,19 @@ Usage::
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from src.common import scroll_config
|
||||
from src.common import scroll_config, sports_vegas
|
||||
from src.common.scroll_helper import ScrollHelper
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
#: Defaults every copy agreed on. A subclass overrides
|
||||
#: :meth:`SportsScrollDisplay.scroll_settings_defaults` to change them —
|
||||
#: the soccer lineage uses a 24px gap and min/max duration keys instead.
|
||||
@@ -413,6 +415,160 @@ class SportsScrollDisplay:
|
||||
"""Whether content is prepared and ready to scroll."""
|
||||
return bool(self.scroll_helper.cached_image)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Live Vegas cards
|
||||
# ------------------------------------------------------------------
|
||||
#
|
||||
# One live element per game (src/plugin_system/vegas_elements.py): the
|
||||
# ticker swaps a card in place when its game changes. A sport opts in by
|
||||
# implementing make_vegas_renderer(); everything else is here.
|
||||
|
||||
def make_vegas_renderer(self, card_width: int,
|
||||
rankings_cache: Optional[Dict[str, int]] = None) -> Any:
|
||||
"""The renderer this sport draws one game card with, at ``card_width``.
|
||||
|
||||
**Override point.** Return the object whose ``render_game_card(game,
|
||||
game_type)`` draws one card exactly ``card_width`` wide at the display's
|
||||
height -- the one prepare_scroll_content already builds -- without the
|
||||
black padding prepare_scroll_content adds around each card (the ticker
|
||||
adds its own). Raising NotImplementedError, the default, keeps the
|
||||
plugin on its ordinary Vegas content.
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
f"{type(self).__name__} has no live Vegas cards (make_vegas_renderer)")
|
||||
|
||||
def _determine_game_type(self, game: Dict[str, Any]) -> str:
|
||||
"""The card a game is drawn as: 'live', 'recent' or 'upcoming'.
|
||||
|
||||
From the game's state; a sport whose scroll display decides it
|
||||
differently (most define their own) overrides this.
|
||||
"""
|
||||
return {'in': 'live', 'post': 'recent'}.get(sports_vegas._state(game), 'upcoming')
|
||||
|
||||
def render_vegas_card(self, renderer: Any, game: Dict[str, Any]) -> Image.Image:
|
||||
"""Draw one game's card. Override only if the renderer is called differently."""
|
||||
card: Image.Image = renderer.render_game_card(game, self._determine_game_type(game))
|
||||
return card
|
||||
|
||||
def vegas_separator(self, league: str) -> Optional[Image.Image]:
|
||||
"""The league separator shown before a league's cards, if there is an icon."""
|
||||
icon = self._separator_icons.get(league)
|
||||
if icon is None:
|
||||
return None
|
||||
gap = self._vegas_settings(league).get("gap_between_games", 48)
|
||||
pad = max(4, int(gap) // 2)
|
||||
image = Image.new('RGB', (icon.width + pad * 2, self.display_height), (0, 0, 0))
|
||||
mask = icon if icon.mode == 'RGBA' else None
|
||||
image.paste(icon, (pad, (self.display_height - icon.height) // 2), mask)
|
||||
return image
|
||||
|
||||
def _vegas_memo(self) -> Dict[Any, Any]:
|
||||
"""Per-size, per-config memo for the live path; emptied when either changes."""
|
||||
stamp = (self.display_width, self.display_height, id(self.config))
|
||||
memo: Optional[Tuple[Any, Dict[Any, Any]]] = getattr(self, '_vegas_memo_store', None)
|
||||
if memo is None or memo[0] != stamp:
|
||||
memo = (stamp, {})
|
||||
self._vegas_memo_store = memo
|
||||
store: Dict[Any, Any] = memo[1]
|
||||
return store
|
||||
|
||||
def _vegas_settings(self, league: Optional[str]) -> Dict[str, Any]:
|
||||
"""A league's scroll settings, looked up once per size and config.
|
||||
|
||||
The live path asks after every update; a sport's settings lookup can
|
||||
be expensive (sizing the default card width builds probe renderers).
|
||||
"""
|
||||
memo = self._vegas_memo()
|
||||
key = ('settings', league)
|
||||
if key not in memo:
|
||||
memo[key] = dict(self._get_scroll_settings(league))
|
||||
settings: Dict[str, Any] = memo[key]
|
||||
return settings
|
||||
|
||||
def _vegas_renderer(self, card_width: int,
|
||||
rankings_cache: Optional[Dict[str, int]]) -> Any:
|
||||
"""The sport's renderer for one card width, built once rather than per slate.
|
||||
|
||||
Building one loads fonts and, for the default card width, probes the
|
||||
layout; the scroll path pays that on every prepare, which the live
|
||||
path would repeat on every update.
|
||||
"""
|
||||
memo = self._vegas_memo()
|
||||
key = ('renderer', card_width)
|
||||
if key not in memo:
|
||||
memo[key] = self.make_vegas_renderer(card_width, rankings_cache)
|
||||
renderer = memo[key]
|
||||
if hasattr(renderer, 'set_rankings_cache'):
|
||||
# Every time, empty included: the renderer is reused across
|
||||
# slates, and ranks cleared since must not stay drawn.
|
||||
renderer.set_rankings_cache(rankings_cache or {})
|
||||
return renderer
|
||||
|
||||
def build_vegas_elements(
|
||||
self,
|
||||
games: List[Dict[str, Any]],
|
||||
leagues: List[str],
|
||||
rankings_cache: Optional[Dict[str, int]] = None,
|
||||
fingerprint: Optional[Callable[[Dict[str, Any]], Any]] = None,
|
||||
now: Optional[float] = None,
|
||||
) -> Optional[List[Any]]:
|
||||
"""The slate as live Vegas elements: one card per game, separators between leagues.
|
||||
|
||||
Only cards whose fingerprint changed are drawn; the rest come from the
|
||||
cache. ``fingerprint(game)`` should return what the card draws (the
|
||||
plugin's own signature fields, the clock included for live games); by
|
||||
default the whole game dict is used, which redraws on any change. The
|
||||
teams' ranks from ``rankings_cache`` count too: the renderer draws
|
||||
them from there, not from the game.
|
||||
|
||||
Raises NotImplementedError when the sport has no make_vegas_renderer.
|
||||
"""
|
||||
from src.plugin_system.vegas_elements import VegasElement
|
||||
|
||||
games = sports_vegas.dedupe_games(games)
|
||||
if not games:
|
||||
return None
|
||||
# Settings follow each game's own league, not the slate's first one:
|
||||
# a card's width must not change because another league has no games
|
||||
# today (the ticker refuses a redraw of another width).
|
||||
first = self._vegas_settings(leagues[0] if leagues else None)
|
||||
|
||||
cards = getattr(self, '_vegas_cards', None)
|
||||
if cards is None:
|
||||
cards = self._vegas_cards = sports_vegas.VegasCardCache()
|
||||
odds = getattr(self, '_vegas_odds', None)
|
||||
if odds is None:
|
||||
odds = self._vegas_odds = sports_vegas.StickyOdds()
|
||||
fingerprint = fingerprint or sports_vegas.game_fingerprint
|
||||
|
||||
elements: List[Any] = []
|
||||
keys: List[str] = []
|
||||
current_league = None
|
||||
separators = 0
|
||||
for game in games:
|
||||
league = game.get("league")
|
||||
settings = self._vegas_settings(league) if league else first
|
||||
card_width = int(settings.get("game_card_width", self.display_width))
|
||||
if settings.get("show_league_separators", True) and league != current_league:
|
||||
separator = self.vegas_separator(league) if league else None
|
||||
if separator is not None:
|
||||
elements.append(VegasElement(
|
||||
key=f"sep:{separators}:{league}", image=separator, live=False))
|
||||
separators += 1
|
||||
current_league = league
|
||||
key = sports_vegas.game_key(game)
|
||||
drawn = odds.apply(key, game, now)
|
||||
ranks = (rankings_cache.get(str(drawn.get("home_abbr"))),
|
||||
rankings_cache.get(str(drawn.get("away_abbr")))) if rankings_cache else None
|
||||
renderer = self._vegas_renderer(card_width, rankings_cache)
|
||||
elements.append(cards.element(
|
||||
key, (fingerprint(drawn), ranks, card_width, self.display_height),
|
||||
functools.partial(self.render_vegas_card, renderer, drawn)))
|
||||
keys.append(key)
|
||||
cards.retain(keys)
|
||||
odds.retain(keys)
|
||||
return elements
|
||||
|
||||
def get_current_game_count(self) -> int:
|
||||
return len(self._current_games)
|
||||
|
||||
@@ -525,6 +681,32 @@ class SportsScrollDisplayManager:
|
||||
scroll_display.clear()
|
||||
self._current_game_type = ""
|
||||
|
||||
def get_vegas_elements_for(
|
||||
self,
|
||||
game_type: str,
|
||||
games: List[Dict[str, Any]],
|
||||
leagues: List[str],
|
||||
rankings_cache: Optional[Dict[str, int]] = None,
|
||||
fingerprint: Optional[Callable[[Dict[str, Any]], Any]] = None,
|
||||
) -> Optional[List[Any]]:
|
||||
"""Live Vegas cards for a slate, built on the ``game_type`` display.
|
||||
|
||||
None when the sport has no live cards (it does not implement
|
||||
make_vegas_renderer) or building them failed, so the plugin's
|
||||
get_vegas_elements() can return it and the ticker falls back to the
|
||||
plugin's ordinary Vegas content.
|
||||
"""
|
||||
scroll_display = self.get_scroll_display(game_type)
|
||||
try:
|
||||
return scroll_display.build_vegas_elements(
|
||||
games, leagues, rankings_cache, fingerprint)
|
||||
except NotImplementedError:
|
||||
return None
|
||||
except Exception:
|
||||
# Built straight from feed data, like prepare_scroll_content.
|
||||
self.logger.exception("Error building live Vegas cards")
|
||||
return None
|
||||
|
||||
def get_all_vegas_content_items(self) -> List[Image.Image]:
|
||||
"""Every display's Vegas items, for splicing into the marquee."""
|
||||
items: List[Image.Image] = []
|
||||
|
||||
@@ -1348,6 +1348,55 @@ class SportsLiveSharedMixin:
|
||||
or candidate < current):
|
||||
self._next_scheduled_start_ts = candidate
|
||||
|
||||
#: How long a game that finished live is still reported by
|
||||
#: finished_games_snapshot(): long enough for the recent-games list, which
|
||||
#: refreshes about hourly, to take it over well before most slates would.
|
||||
FINISHED_GAME_TTL = 900.0
|
||||
|
||||
def _record_finished_game(self, details: Dict) -> None:
|
||||
"""Remember a game that was live and has just gone final (or looks over).
|
||||
|
||||
A finished game leaves ``live_games`` at the next poll, and the recent
|
||||
list that will show it refreshes about hourly, so in between nothing
|
||||
holds the game's final score -- and a live Vegas card for it would keep
|
||||
its last live score. Call this wherever a poll drops a game as final
|
||||
or over. Only a game this manager had as live is taken; one already
|
||||
held takes the newer details (a game dropped by an "is it over"
|
||||
heuristic, then marked final by the feed) but keeps its expiry, so a
|
||||
feed that lists finals all day cannot keep one here all day.
|
||||
"""
|
||||
game_id = details.get("id") if isinstance(details, dict) else None
|
||||
if not game_id:
|
||||
return
|
||||
finished = self.__dict__.setdefault("_finished_games", {})
|
||||
held = finished.get(game_id)
|
||||
if held is not None:
|
||||
finished[game_id] = (held[0], dict(details))
|
||||
return
|
||||
if not any(g.get("id") == game_id for g in getattr(self, "live_games", ()) or ()):
|
||||
return
|
||||
finished[game_id] = (time.monotonic(), dict(details))
|
||||
|
||||
def finished_games_snapshot(self) -> List[Dict]:
|
||||
"""Games that went final here within FINISHED_GAME_TTL, newest data first.
|
||||
|
||||
Copies, safe to decorate. The caller dedupes them against its other
|
||||
lists (src/common/sports_vegas.dedupe_games keeps the liveliest copy,
|
||||
and a final beats nothing but a live one).
|
||||
"""
|
||||
finished = self.__dict__.get("_finished_games")
|
||||
if not finished:
|
||||
return []
|
||||
now = time.monotonic()
|
||||
# A copy first: a manager finishing its update in the background (off
|
||||
# the plugin's lock) may record a game while the ticker reads these.
|
||||
held = list(finished.items())
|
||||
for game_id, (seen, _game) in held:
|
||||
if now - seen > self.FINISHED_GAME_TTL:
|
||||
finished.pop(game_id, None)
|
||||
return [dict(game) for _id, (seen, game) in held
|
||||
if now - seen <= self.FINISHED_GAME_TTL]
|
||||
|
||||
def _note_live_fetch(self, found_live: bool) -> None:
|
||||
"""Record whether a look for live games found any."""
|
||||
if found_live:
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
"""Live Vegas cards for the sports scoreboards.
|
||||
|
||||
A scoreboard hands the Vegas ticker one card per game. As live elements
|
||||
(src/plugin_system/vegas_elements.py) those cards change on the panel while
|
||||
they scroll: a goal redraws its game's card and the ticker swaps it in place.
|
||||
This module is what every scoreboard needs for that and would otherwise write
|
||||
nine times:
|
||||
|
||||
- :func:`game_key` -- a stable key per game, so the ticker can tell which card
|
||||
a redraw belongs to however the slate is re-sorted.
|
||||
- :class:`VegasCardCache` -- draws a card only when what it shows changed
|
||||
(its fingerprint), so an unchanged slate costs a dictionary lookup per game
|
||||
and a changed one only the cards that changed.
|
||||
- :class:`StickyOdds` -- live odds are fetched only for games near the front
|
||||
of the rotation, so a card's odds come and go between polls; this keeps the
|
||||
last odds for a while instead of redrawing the card without them.
|
||||
- :func:`dedupe_games` -- a game present in two managers' lists (live and
|
||||
recent, around the final whistle) appears once, its liveliest copy.
|
||||
- :func:`finished_games` / :func:`with_finished_games` -- a game that has just
|
||||
gone final keeps its card, now showing FINAL, where its live card was,
|
||||
until the recent list (refreshed about hourly) takes it over.
|
||||
- :func:`game_fingerprint` -- what a card is redrawn on by default: the whole
|
||||
game dict, frozen hashable.
|
||||
|
||||
SportsScrollDisplay.build_vegas_elements (src/common/sports_scroll.py) puts
|
||||
them together; a plugin adopts it by implementing make_vegas_renderer().
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
from typing import Any, Callable, Dict, Hashable, Iterable, List, Optional, Tuple
|
||||
|
||||
from PIL import Image
|
||||
|
||||
#: Which copy of a duplicated game wins: the liveliest.
|
||||
_STATE_PRIORITY = {'in': 3, 'post': 2, 'pre': 1}
|
||||
|
||||
|
||||
def _state(game: Dict[str, Any]) -> str:
|
||||
status = game.get('status')
|
||||
state = status.get('state') if isinstance(status, dict) else status
|
||||
if isinstance(state, str):
|
||||
return state
|
||||
if game.get('is_live'):
|
||||
return 'in'
|
||||
if game.get('is_final'):
|
||||
return 'post'
|
||||
return 'pre'
|
||||
|
||||
|
||||
def _freeze(value: Any) -> Any:
|
||||
"""A hashable, order-stable copy of feed data."""
|
||||
if isinstance(value, dict):
|
||||
return tuple(sorted((str(k), _freeze(v)) for k, v in value.items()))
|
||||
if isinstance(value, (list, tuple)):
|
||||
return tuple(_freeze(v) for v in value)
|
||||
if isinstance(value, (str, int, float, bool)) or value is None:
|
||||
return value
|
||||
return repr(value)
|
||||
|
||||
|
||||
def game_fingerprint(game: Dict[str, Any]) -> Hashable:
|
||||
"""Everything in a game dict, hashable: a card drawn from it changes only if this does.
|
||||
|
||||
The default card version. Nothing a card could draw is left out, so no
|
||||
field is ever frozen on the panel; the cost is a redraw when a field the
|
||||
card does not draw changes too, which feed data rarely does between polls.
|
||||
"""
|
||||
frozen: Hashable = _freeze(game)
|
||||
return frozen
|
||||
|
||||
|
||||
def game_key(game: Dict[str, Any]) -> str:
|
||||
"""A key that names this game and nothing else, across polls.
|
||||
|
||||
``game:<league>:<id>`` from the feed's own id. A game without one falls
|
||||
back to its teams and start time, which is stable for the life of a game.
|
||||
"""
|
||||
league = game.get('league') or 'game'
|
||||
game_id = game.get('id') or game.get('game_id')
|
||||
if game_id not in (None, ''):
|
||||
return f"game:{league}:{game_id}"
|
||||
away = game.get('away_abbr') or game.get('away_team') or '?'
|
||||
home = game.get('home_abbr') or game.get('home_team') or '?'
|
||||
start = game.get('start_time_utc') or game.get('start_time') or ''
|
||||
return f"game:{league}:{away}@{home}:{start}"
|
||||
|
||||
|
||||
def dedupe_games(games: Iterable[Dict[str, Any]],
|
||||
key_fn: Callable[[Dict[str, Any]], str] = game_key) -> List[Dict[str, Any]]:
|
||||
"""Each game once, in first-seen order, keeping its liveliest copy.
|
||||
|
||||
Around a final whistle a game can be in the live list (last poll) and the
|
||||
recent list (next poll) at once; two cards with one key would be refused
|
||||
by the ticker, and showing the game twice is wrong anyway.
|
||||
"""
|
||||
chosen: "OrderedDict[str, Dict[str, Any]]" = OrderedDict()
|
||||
for game in games:
|
||||
key = key_fn(game)
|
||||
current = chosen.get(key)
|
||||
if current is None or _STATE_PRIORITY.get(_state(game), 0) > \
|
||||
_STATE_PRIORITY.get(_state(current), 0):
|
||||
chosen[key] = game
|
||||
return list(chosen.values())
|
||||
|
||||
|
||||
def finished_games(
|
||||
live_managers: Iterable[Tuple[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""Games that just left these live managers' lists, final ones as recent games.
|
||||
|
||||
``live_managers`` pairs each league with its live manager (None is
|
||||
skipped). Each manager reports what SportsLiveSharedMixin recorded
|
||||
(finished_games_snapshot, copies), with its league. A final game is
|
||||
drawn as a recent card. One a poll only judged over -- a tied end of
|
||||
regulation looks like that too -- keeps its last live state, so its card
|
||||
never says FINAL early; if play resumes the live list has it again, and
|
||||
dedupe_games keeps that copy.
|
||||
"""
|
||||
finished: List[Dict[str, Any]] = []
|
||||
for league, manager in live_managers:
|
||||
snapshot = getattr(manager, 'finished_games_snapshot', None)
|
||||
if not callable(snapshot):
|
||||
continue
|
||||
for game in snapshot():
|
||||
game['league'] = league
|
||||
if game.get('is_final'):
|
||||
status = game.get('status')
|
||||
status = dict(status) if isinstance(status, dict) else {}
|
||||
status['state'] = 'post'
|
||||
game.update(status=status, is_live=False)
|
||||
finished.append(game)
|
||||
return finished
|
||||
|
||||
|
||||
def with_finished_games(
|
||||
games: List[Dict[str, Any]], leagues: List[str],
|
||||
finished: List[Dict[str, Any]],
|
||||
) -> Tuple[List[Dict[str, Any]], List[str]]:
|
||||
"""The slate with games that just went final where their live cards were.
|
||||
|
||||
A slate lists each league's games together, live ones first. Each
|
||||
finished game goes after its league's live games, ahead of the rest; a
|
||||
league with no games left in the slate is added at the end. A finished
|
||||
game the slate also has (the recent list caught up) is left for
|
||||
dedupe_games, which keeps one copy.
|
||||
"""
|
||||
if not finished:
|
||||
return list(games), list(leagues)
|
||||
pending: "OrderedDict[Any, List[Dict[str, Any]]]" = OrderedDict()
|
||||
for game in finished:
|
||||
pending.setdefault(game.get('league'), []).append(game)
|
||||
merged: List[Dict[str, Any]] = []
|
||||
for index, game in enumerate(games):
|
||||
league = game.get('league')
|
||||
if league in pending and _state(game) != 'in':
|
||||
merged.extend(pending.pop(league))
|
||||
merged.append(game)
|
||||
following = games[index + 1] if index + 1 < len(games) else None
|
||||
if league in pending and (following is None or following.get('league') != league):
|
||||
merged.extend(pending.pop(league)) # the league's games were all live
|
||||
leagues = list(leagues)
|
||||
for league, rest in pending.items():
|
||||
merged.extend(rest)
|
||||
if league not in leagues:
|
||||
leagues.append(league)
|
||||
return merged, leagues
|
||||
|
||||
|
||||
class VegasCardCache:
|
||||
"""Cards drawn once per fingerprint, kept for as long as their game is.
|
||||
|
||||
``element(key, fingerprint, render)`` returns a VegasElement whose image is
|
||||
``render()``'s -- called only when the fingerprint differs from the one the
|
||||
cached card was drawn for. The fingerprint is also the element's version,
|
||||
so the ticker skips unchanged cards without comparing pixels.
|
||||
|
||||
Bounded: keys not passed to :meth:`retain` after a slate are dropped, and
|
||||
at most ``max_entries`` are ever held (oldest first).
|
||||
"""
|
||||
|
||||
def __init__(self, max_entries: int = 96) -> None:
|
||||
self.max_entries = max(1, int(max_entries))
|
||||
self._cards: "OrderedDict[str, Tuple[Hashable, Image.Image]]" = OrderedDict()
|
||||
self.renders = 0
|
||||
|
||||
def element(self, key: str, fingerprint: Hashable,
|
||||
render: Callable[[], Image.Image], live: bool = True) -> Any:
|
||||
from src.plugin_system.vegas_elements import VegasElement
|
||||
|
||||
cached = self._cards.get(key)
|
||||
if cached is not None and cached[0] == fingerprint:
|
||||
self._cards.move_to_end(key)
|
||||
image = cached[1]
|
||||
else:
|
||||
image = render()
|
||||
self.renders += 1
|
||||
self._cards[key] = (fingerprint, image)
|
||||
self._cards.move_to_end(key)
|
||||
while len(self._cards) > self.max_entries:
|
||||
self._cards.popitem(last=False)
|
||||
return VegasElement(key=key, image=image, version=fingerprint, live=live)
|
||||
|
||||
def retain(self, keys: Iterable[str]) -> None:
|
||||
"""Forget every card whose key is not in ``keys``."""
|
||||
keep = set(keys)
|
||||
for key in [k for k in self._cards if k not in keep]:
|
||||
self._cards.pop(key, None)
|
||||
|
||||
def clear(self) -> None:
|
||||
self._cards.clear()
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._cards)
|
||||
|
||||
|
||||
class StickyOdds:
|
||||
"""Keep a game's last odds on its card while a live poll leaves them out.
|
||||
|
||||
Live odds are fetched only for games near the front of the rotation
|
||||
(src/common/sports_fetch.py), so the same game's dict has odds on one poll
|
||||
and none on the next. Drawn as-is that redraws the card every poll with
|
||||
the odds flickering in and out. ``apply`` returns the game with its last
|
||||
non-empty odds put back, for up to ``ttl_s`` seconds after they were seen.
|
||||
"""
|
||||
|
||||
def __init__(self, ttl_s: float = 600.0) -> None:
|
||||
self.ttl_s = float(ttl_s)
|
||||
self._seen: Dict[str, Tuple[float, Any]] = {}
|
||||
|
||||
def apply(self, key: str, game: Dict[str, Any],
|
||||
now: Optional[float] = None) -> Dict[str, Any]:
|
||||
now = time.monotonic() if now is None else now
|
||||
odds = game.get('odds')
|
||||
if odds:
|
||||
self._seen[key] = (now, odds)
|
||||
return game
|
||||
seen = self._seen.get(key)
|
||||
if seen is None or now - seen[0] > self.ttl_s:
|
||||
self._seen.pop(key, None)
|
||||
return game
|
||||
refilled = dict(game)
|
||||
refilled['odds'] = seen[1]
|
||||
return refilled
|
||||
|
||||
def retain(self, keys: Iterable[str]) -> None:
|
||||
keep = set(keys)
|
||||
for key in [k for k in self._seen if k not in keep]:
|
||||
self._seen.pop(key, None)
|
||||
+45
-3
@@ -451,8 +451,10 @@ class ConfigManager:
|
||||
template_config = json.load(f)
|
||||
|
||||
# Check if migration is needed
|
||||
if self._config_needs_migration(self.config, template_config):
|
||||
self.logger.info("Config migration needed - adding new configuration items with defaults")
|
||||
needs_merge = self._config_needs_migration(self.config, template_config)
|
||||
if needs_merge or self._live_in_ticker_needs_migration():
|
||||
if needs_merge:
|
||||
self.logger.info("Config migration needed - adding new configuration items with defaults")
|
||||
|
||||
# Create backup of current config
|
||||
backup_path = f"{self.config_path}.backup"
|
||||
@@ -461,7 +463,9 @@ class ConfigManager:
|
||||
self.logger.info(f"Created backup of current config at {os.path.abspath(backup_path)}")
|
||||
|
||||
# Merge template defaults into current config
|
||||
self._merge_template_defaults(self.config, template_config)
|
||||
if needs_merge:
|
||||
self._merge_template_defaults(self.config, template_config)
|
||||
self._migrate_live_in_ticker_default()
|
||||
|
||||
# save_config_atomic strips the merged secrets back out and
|
||||
# keeps the file's owner and mode.
|
||||
@@ -482,6 +486,44 @@ class ConfigManager:
|
||||
self.logger.error(f"Error during config migration: {e}")
|
||||
# Don't raise - continue with current config
|
||||
|
||||
#: Set in display.vegas_scroll once _migrate_live_in_ticker_default() has
|
||||
#: run. Never in the template: the template merge would add it first, and
|
||||
#: the flip would then never run.
|
||||
LIVE_IN_TICKER_MARKER = 'live_in_ticker_migrated'
|
||||
|
||||
def _vegas_scroll_section(self) -> Optional[Dict[str, Any]]:
|
||||
display = self.config.get('display')
|
||||
vegas = display.get('vegas_scroll') if isinstance(display, dict) else None
|
||||
return vegas if isinstance(vegas, dict) else None
|
||||
|
||||
def _live_in_ticker_needs_migration(self) -> bool:
|
||||
vegas = self._vegas_scroll_section()
|
||||
return vegas is not None and not vegas.get(self.LIVE_IN_TICKER_MARKER)
|
||||
|
||||
def _migrate_live_in_ticker_default(self) -> None:
|
||||
"""Turn on live_in_ticker for a config that only ever had the old default. Once.
|
||||
|
||||
LEDMatrix 3.8.0 makes ``display.vegas_scroll.live_in_ticker`` true:
|
||||
live games stay in the Vegas ticker, their cards updating while they
|
||||
scroll, instead of the ticker giving way to the full-screen
|
||||
scoreboard. Every existing config holds an explicit ``false`` copied
|
||||
from the template -- there was no control for it -- and the template
|
||||
merge only adds missing keys, so the new default would reach nobody.
|
||||
This rewrites that ``false`` once and marks the config, so a
|
||||
``false`` chosen afterwards (the Vegas checkbox, or by hand) stays.
|
||||
"""
|
||||
vegas = self._vegas_scroll_section()
|
||||
if vegas is None or vegas.get(self.LIVE_IN_TICKER_MARKER):
|
||||
return
|
||||
vegas[self.LIVE_IN_TICKER_MARKER] = True
|
||||
if vegas.get('live_in_ticker') is False:
|
||||
vegas['live_in_ticker'] = True
|
||||
self.logger.info(
|
||||
"Vegas mode now keeps live games in the ticker (the new default): "
|
||||
"display.vegas_scroll.live_in_ticker turned on, once. Untick "
|
||||
"\"Keep live games in the ticker\" under Vegas mode for the "
|
||||
"full-screen scoreboard.")
|
||||
|
||||
def _config_needs_migration(self, current_config: Dict[str, Any], template_config: Dict[str, Any]) -> bool:
|
||||
"""Check if config needs migration by comparing with template."""
|
||||
return self._has_new_keys(current_config, template_config)
|
||||
|
||||
@@ -2206,7 +2206,7 @@ class DisplayController:
|
||||
"""Whether live content should stay in the ticker instead of preempting it."""
|
||||
coordinator = self.vegas_coordinator
|
||||
config = getattr(coordinator, 'vegas_config', None)
|
||||
return bool(getattr(config, 'live_in_ticker', False))
|
||||
return bool(getattr(config, 'live_in_ticker', True))
|
||||
|
||||
def _check_live_priority(self, advance=False):
|
||||
"""Return the live-priority mode to display, or None if nothing is live.
|
||||
|
||||
@@ -266,6 +266,91 @@ def _base_redraw():
|
||||
return BasePlugin.redraw_vegas_element
|
||||
|
||||
|
||||
def render_vegas_strip(plugin: Any, plugin_id: str, display_manager: Any,
|
||||
live: bool = True) -> Any:
|
||||
"""The plugin's block of the Vegas strip, laid out exactly as the ticker would.
|
||||
|
||||
Fetched through the ticker's own adapter (trimming, pinning, width
|
||||
budget) and joined with its own spacing, so what this draws is what
|
||||
scrolls. ``live=False`` shows the ordinary get_vegas_content() instead.
|
||||
|
||||
Returns ``(block, layout)`` -- layout a list of ``(x, key, width)`` for the
|
||||
live elements in the block -- or ``(None, [])`` when there is nothing.
|
||||
"""
|
||||
_adapter, block, layout = _vegas_block(plugin, plugin_id, display_manager, live)
|
||||
return block, [(x, meta.key, width) for x, meta, width in layout]
|
||||
|
||||
|
||||
def _vegas_block(plugin: Any, plugin_id: str, display_manager: Any, live: bool) -> Any:
|
||||
"""(adapter, block, layout) for a plugin's Vegas block, through the ticker's own code."""
|
||||
from src.vegas_mode.config import VegasModeConfig
|
||||
from src.vegas_mode.elements import LiveEpochs
|
||||
from src.vegas_mode.plugin_adapter import PluginAdapter
|
||||
from src.vegas_mode.render_pipeline import join_plugin_rows
|
||||
|
||||
config = VegasModeConfig()
|
||||
adapter = PluginAdapter(display_manager, config)
|
||||
adapter.live_elements_enabled = live
|
||||
adapter.live_epochs = LiveEpochs()
|
||||
images = adapter.get_content(plugin, plugin_id, offscreen_only=True)
|
||||
if not images:
|
||||
return adapter, None, []
|
||||
block, layout = join_plugin_rows(images, config)
|
||||
return adapter, block, layout
|
||||
|
||||
|
||||
def render_vegas_timeline(plugin: Any, plugin_id: str, display_manager: Any,
|
||||
steps: int = 8, step_seconds: float = 0.25,
|
||||
run_update: bool = False) -> Any:
|
||||
"""The plugin's Vegas block at successive moments, one row per step.
|
||||
|
||||
Row 0 is the block as placed (render_vegas_strip). Each later row is the
|
||||
same block ``step_seconds`` later, changed the way the ticker would change
|
||||
it in place: every live element with ``refresh_hz`` redrawn for that
|
||||
moment through redraw_vegas_element(), and -- with ``run_update`` -- the
|
||||
plugin's update() run first and every live element redrawn from the new
|
||||
data. A redraw of another width is left out, as the ticker refuses it.
|
||||
Rows are separated by a grey line.
|
||||
|
||||
Returns ``(image, rows)``, or ``(None, 0)`` when there is nothing.
|
||||
"""
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
|
||||
adapter, block, layout = _vegas_block(plugin, plugin_id, display_manager, True)
|
||||
if block is None:
|
||||
return None, 0
|
||||
base = np.array(block.convert('RGB'))
|
||||
rows = [base.copy()]
|
||||
height = base.shape[0]
|
||||
start = time.monotonic()
|
||||
for step in range(1, max(1, int(steps))):
|
||||
frame = rows[-1].copy()
|
||||
if run_update:
|
||||
plugin.update()
|
||||
adapter.live_epochs.bump(plugin_id)
|
||||
batch = adapter.render_live_elements(plugin, plugin_id, lock_timeout=5.0)
|
||||
rendered = batch[1] if batch is not None else {}
|
||||
for x, meta, width in layout:
|
||||
element = rendered.get(meta.key)
|
||||
if element is not None and element.width == width:
|
||||
frame[:, x:x + width] = element.pixels
|
||||
at = start + step * float(step_seconds)
|
||||
for x, meta, width in layout:
|
||||
if meta.refresh_hz > 0:
|
||||
element = adapter.redraw_live_element(plugin, plugin_id, meta.key,
|
||||
width, height, at)
|
||||
if element is not None and element.width == width:
|
||||
frame[:, x:x + width] = element.pixels
|
||||
rows.append(frame)
|
||||
divider = np.full((1, base.shape[1], 3), 60, dtype=np.uint8)
|
||||
stacked = [rows[0]]
|
||||
for row in rows[1:]:
|
||||
stacked.extend([divider, row])
|
||||
return Image.fromarray(np.concatenate(stacked, axis=0)), len(rows)
|
||||
|
||||
|
||||
def check_plugin_vegas_elements(plugin_id: str, plugin_dir: Any, config: dict,
|
||||
mock_data: dict, width: int, height: int,
|
||||
run_update: bool = True) -> VegasElementReport:
|
||||
|
||||
@@ -180,10 +180,13 @@ class VegasModeConfig:
|
||||
|
||||
# --- Live content in the ticker -------------------------------------
|
||||
#
|
||||
# By default a live game preempts Vegas entirely: the display controller
|
||||
# refuses to run the ticker while any plugin reports live priority, and you
|
||||
# get the full-screen scoreboard instead. Set live_in_ticker to keep the
|
||||
# marquee running and let live content take extra turns within it.
|
||||
# By default live content stays in the marquee and takes extra turns
|
||||
# within it, its cards updating while they scroll (live elements, below).
|
||||
# With live_in_ticker false a live game preempts Vegas entirely: the
|
||||
# display controller refuses to run the ticker while any plugin reports
|
||||
# live priority, and you get the full-screen scoreboard instead. (False
|
||||
# was the default until 3.8.0; ConfigManager turns it on once for configs
|
||||
# that still hold the old default.)
|
||||
#
|
||||
# The rotation is otherwise a strict round robin -- every plugin appears
|
||||
# exactly once per cycle -- so with a dozen plugins enabled a live score
|
||||
@@ -193,7 +196,7 @@ class VegasModeConfig:
|
||||
# Weights are per plugin, not per game: a scoreboard showing four live
|
||||
# games still occupies one slot at a time, and rotates its own games within
|
||||
# that slot using its own favorite_live_boost.
|
||||
live_in_ticker: bool = False
|
||||
live_in_ticker: bool = True
|
||||
|
||||
# Slots per cycle for a plugin reporting live content. 1 disables the boost
|
||||
# and restores the plain round robin.
|
||||
|
||||
@@ -32,6 +32,51 @@ logger = logging.getLogger(__name__)
|
||||
SYNC_SEND_INTERVAL = 1.0 / 90
|
||||
|
||||
|
||||
def join_plugin_rows(
|
||||
images: List[Image.Image], config: VegasModeConfig
|
||||
) -> Tuple[Image.Image, List[Tuple[int, ElementMeta, int]]]:
|
||||
"""Join one plugin's images into the block the strip will hold.
|
||||
|
||||
Returns ``(block, layout)``, layout being ``(x, meta, width)`` for every
|
||||
image tagged as a live element (src/vegas_mode/elements.py), x measured
|
||||
from the block's left edge. A single image is returned as it is.
|
||||
|
||||
A module function so tooling (scripts/render_plugin.py --vegas) lays a
|
||||
plugin out exactly as the ticker does.
|
||||
"""
|
||||
if len(images) == 1:
|
||||
meta = meta_of(images[0])
|
||||
layout = [(0, meta, images[0].width)] if meta is not None else []
|
||||
return images[0], layout
|
||||
|
||||
floor = max(0, config.intra_plugin_gap)
|
||||
target = max(0, config.min_content_separation)
|
||||
threshold = config.trim_threshold
|
||||
|
||||
# Space by measured separation, not a flat gap. Rows drawn flush to their
|
||||
# own edges (sports score cards) would otherwise end up nearly touching,
|
||||
# while rows that already carry wide margins would be pushed needlessly
|
||||
# further apart.
|
||||
gaps = [
|
||||
separation_gap(images[i], images[i + 1], target, floor, threshold)
|
||||
for i in range(len(images) - 1)
|
||||
]
|
||||
|
||||
width = sum(img.width for img in images) + sum(gaps)
|
||||
height = max(img.height for img in images)
|
||||
|
||||
block = Image.new('RGB', (width, height), (0, 0, 0))
|
||||
layout: List[Tuple[int, ElementMeta, int]] = []
|
||||
x = 0
|
||||
for i, img in enumerate(images):
|
||||
block.paste(img, (x, 0))
|
||||
meta = meta_of(img)
|
||||
if meta is not None:
|
||||
layout.append((x, meta, img.width))
|
||||
x += img.width + (gaps[i] if i < len(gaps) else 0)
|
||||
return block, layout
|
||||
|
||||
|
||||
class RenderPipeline:
|
||||
"""
|
||||
High-performance render pipeline for Vegas scroll mode.
|
||||
@@ -421,6 +466,11 @@ class RenderPipeline:
|
||||
if note is not None:
|
||||
note(kind, nbytes)
|
||||
|
||||
def _copied_bytes(self) -> int:
|
||||
"""Bytes the scroll helper's last append or trim copied (the strip, if it cannot say)."""
|
||||
copied = getattr(self.scroll_helper, 'last_copy_bytes', None)
|
||||
return int(copied) if isinstance(copied, int) else self._strip_nbytes()
|
||||
|
||||
def _strip_nbytes(self) -> int:
|
||||
array = self.scroll_helper.cached_array
|
||||
return int(array.nbytes) if array is not None else 0
|
||||
@@ -615,7 +665,7 @@ class RenderPipeline:
|
||||
element_gap=0,
|
||||
)
|
||||
if appended:
|
||||
self._note_op('extend', self._strip_nbytes())
|
||||
self._note_op('extend', self._copied_bytes())
|
||||
logger.info(
|
||||
"[%s] Appended deferred content: strip now %dpx, %dpx ahead",
|
||||
plugin_id, self.scroll_helper.total_scroll_width,
|
||||
@@ -725,7 +775,7 @@ class RenderPipeline:
|
||||
)
|
||||
if not appended:
|
||||
return False
|
||||
moved = self._strip_nbytes()
|
||||
moved = self._copied_bytes()
|
||||
|
||||
# Where each block starts, laid out as append_content does: a
|
||||
# separator before every block, or -- when there was no strip to
|
||||
@@ -744,9 +794,10 @@ class RenderPipeline:
|
||||
self._static_markers = tuple(
|
||||
(max(0, x - cut), pid) for x, pid in self._static_markers)
|
||||
self._forget_trimmed_records(cut)
|
||||
# The append built the whole strip anew, and a trim copies what is
|
||||
# left of it again: both land in the frame after this one.
|
||||
self._note_op('extend', moved + (self._strip_nbytes() if cut else 0))
|
||||
# Both land in the frame after this one: the append's new columns
|
||||
# (the whole strip when its buffer had to be reallocated), and a
|
||||
# trim's copy, if it made one.
|
||||
self._note_op('extend', moved + (self._copied_bytes() if cut else 0))
|
||||
|
||||
self._segments_in_scroll = [pid for pid, _ in grouped]
|
||||
self.stats['composition_count'] += 1
|
||||
@@ -787,42 +838,9 @@ class RenderPipeline:
|
||||
) -> Tuple[Image.Image, List[Tuple[int, ElementMeta, int]]]:
|
||||
"""_join_plugin_rows, plus where each live element landed in the block.
|
||||
|
||||
Returns ``(block, layout)``, layout being ``(x, meta, width)`` for
|
||||
every image tagged as a live element (src/vegas_mode/elements.py), x
|
||||
measured from the block's left edge. The offsets were always computed
|
||||
here; they used to be thrown away.
|
||||
See join_plugin_rows.
|
||||
"""
|
||||
if len(images) == 1:
|
||||
meta = meta_of(images[0])
|
||||
layout = [(0, meta, images[0].width)] if meta is not None else []
|
||||
return images[0], layout
|
||||
|
||||
floor = max(0, self.config.intra_plugin_gap)
|
||||
target = max(0, self.config.min_content_separation)
|
||||
threshold = self.config.trim_threshold
|
||||
|
||||
# Space by measured separation, not a flat gap. Rows drawn flush to
|
||||
# their own edges (sports score cards) would otherwise end up nearly
|
||||
# touching, while rows that already carry wide margins would be pushed
|
||||
# needlessly further apart.
|
||||
gaps = [
|
||||
separation_gap(images[i], images[i + 1], target, floor, threshold)
|
||||
for i in range(len(images) - 1)
|
||||
]
|
||||
|
||||
width = sum(img.width for img in images) + sum(gaps)
|
||||
height = max(img.height for img in images)
|
||||
|
||||
block = Image.new('RGB', (width, height), (0, 0, 0))
|
||||
layout: List[Tuple[int, ElementMeta, int]] = []
|
||||
x = 0
|
||||
for i, img in enumerate(images):
|
||||
block.paste(img, (x, 0))
|
||||
meta = meta_of(img)
|
||||
if meta is not None:
|
||||
layout.append((x, meta, img.width))
|
||||
x += img.width + (gaps[i] if i < len(gaps) else 0)
|
||||
return block, layout
|
||||
return join_plugin_rows(images, self.config)
|
||||
|
||||
# -- live element records ---------------------------------------------
|
||||
#
|
||||
|
||||
@@ -134,6 +134,20 @@ class TestJsonPartialSaves:
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert saved['config']['display']['vegas_scroll']['live_refresh'] is True
|
||||
|
||||
def test_live_games_can_be_kept_in_the_ticker_or_not(self, api_v3_client, saved):
|
||||
resp = _post_json(api_v3_client, {'vegas_live_in_ticker': False})
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert saved['config']['display']['vegas_scroll']['live_in_ticker'] is False
|
||||
# An unticked box is absent from a submitted form: that is false too.
|
||||
resp = _post_json(api_v3_client, {'__form_section': 'display',
|
||||
'vegas_scroll_speed': '50',
|
||||
'vegas_live_in_ticker': 'on'})
|
||||
assert saved['config']['display']['vegas_scroll']['live_in_ticker'] is True
|
||||
resp = _post_json(api_v3_client, {'__form_section': 'display',
|
||||
'vegas_scroll_speed': '50'})
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert saved['config']['display']['vegas_scroll']['live_in_ticker'] is False
|
||||
|
||||
def test_double_sided_axis_only_keeps_enabled(self, api_v3_client, saved):
|
||||
resp = _post_json(api_v3_client, {'double_sided_axis': 'horizontal'})
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"""live_in_ticker's new default reaches existing installs, once (src/config_manager.py).
|
||||
|
||||
3.8.0 makes display.vegas_scroll.live_in_ticker true. Every existing config
|
||||
holds an explicit false copied from the template, which the template merge
|
||||
never touches (it only adds missing keys), so ConfigManager turns that false
|
||||
on once and marks the config. A false chosen after that -- the new checkbox,
|
||||
or by hand -- must stay false.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
from src.config_manager import ConfigManager # noqa: E402
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
MARKER = ConfigManager.LIVE_IN_TICKER_MARKER
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def files(tmp_path):
|
||||
template = {"display": {"brightness": 90,
|
||||
"vegas_scroll": {"enabled": False, "live_in_ticker": True}}}
|
||||
paths = {name: tmp_path / f"{name}.json" for name in ("config", "secrets", "template")}
|
||||
paths["template"].write_text(json.dumps(template))
|
||||
paths["secrets"].write_text("{}")
|
||||
return paths
|
||||
|
||||
|
||||
def _load(files, config):
|
||||
files["config"].write_text(json.dumps(config))
|
||||
manager = ConfigManager(config_path=str(files["config"]),
|
||||
secrets_path=str(files["secrets"]))
|
||||
manager.template_path = str(files["template"])
|
||||
manager.load_config()
|
||||
return manager, json.loads(files["config"].read_text())
|
||||
|
||||
|
||||
def _vegas(saved):
|
||||
return saved["display"]["vegas_scroll"]
|
||||
|
||||
|
||||
def test_the_old_default_is_turned_on_once_and_marked(files):
|
||||
old = {"display": {"brightness": 90,
|
||||
"vegas_scroll": {"enabled": True, "live_in_ticker": False}}}
|
||||
manager, saved = _load(files, old)
|
||||
assert _vegas(saved)["live_in_ticker"] is True
|
||||
assert _vegas(saved)[MARKER] is True
|
||||
assert _vegas(manager.config)["live_in_ticker"] is True
|
||||
# The config as it was is kept beside it.
|
||||
backup = json.loads(Path(f"{files['config']}.backup").read_text())
|
||||
assert _vegas(backup)["live_in_ticker"] is False
|
||||
|
||||
|
||||
def test_false_chosen_after_the_migration_stays_false(files):
|
||||
chosen = {"display": {"brightness": 90,
|
||||
"vegas_scroll": {"enabled": True, "live_in_ticker": False,
|
||||
MARKER: True}}}
|
||||
_manager, saved = _load(files, chosen)
|
||||
assert _vegas(saved)["live_in_ticker"] is False
|
||||
|
||||
|
||||
def test_a_marked_config_is_not_written_again(files):
|
||||
marked = {"display": {"brightness": 90,
|
||||
"vegas_scroll": {"enabled": True, "live_in_ticker": True,
|
||||
MARKER: True}}}
|
||||
files["config"].write_text(json.dumps(marked))
|
||||
before = files["config"].stat().st_mtime_ns
|
||||
manager = ConfigManager(config_path=str(files["config"]),
|
||||
secrets_path=str(files["secrets"]))
|
||||
manager.template_path = str(files["template"])
|
||||
manager.load_config()
|
||||
assert files["config"].stat().st_mtime_ns == before
|
||||
assert not Path(f"{files['config']}.backup").exists()
|
||||
|
||||
|
||||
def test_a_config_without_vegas_settings_gets_the_new_default(files):
|
||||
_manager, saved = _load(files, {"display": {"brightness": 90}})
|
||||
assert _vegas(saved)["live_in_ticker"] is True
|
||||
assert _vegas(saved)[MARKER] is True
|
||||
|
||||
|
||||
def test_true_already_is_only_marked(files):
|
||||
on = {"display": {"brightness": 90,
|
||||
"vegas_scroll": {"enabled": True, "live_in_ticker": True}}}
|
||||
_manager, saved = _load(files, on)
|
||||
assert _vegas(saved)["live_in_ticker"] is True and _vegas(saved)[MARKER] is True
|
||||
|
||||
|
||||
def test_a_second_load_keeps_a_false_set_in_between(files):
|
||||
old = {"display": {"brightness": 90,
|
||||
"vegas_scroll": {"enabled": True, "live_in_ticker": False}}}
|
||||
_manager, saved = _load(files, old)
|
||||
_vegas(saved)["live_in_ticker"] = False # the checkbox, unticked
|
||||
_manager, saved = _load(files, saved)
|
||||
assert _vegas(saved)["live_in_ticker"] is False
|
||||
|
||||
|
||||
def test_the_shipped_template_has_the_new_default_and_never_the_marker():
|
||||
template = json.loads((REPO / "config" / "config.template.json").read_text(encoding="utf-8"))
|
||||
vegas = template["display"]["vegas_scroll"]
|
||||
assert vegas["live_in_ticker"] is True
|
||||
assert MARKER not in vegas
|
||||
@@ -0,0 +1,49 @@
|
||||
"""scripts/dev_server.py's Vegas strip view, on the stub fixture plugin."""
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
STUB = REPO / "test" / "fixtures" / "plugins" / "vegas-live-stub"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch):
|
||||
spec = importlib.util.spec_from_file_location("dev_server_vegas_under_test",
|
||||
REPO / "scripts" / "dev_server.py")
|
||||
dev_server = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(dev_server)
|
||||
monkeypatch.setattr(dev_server, "find_plugin_dir", lambda pid: STUB)
|
||||
monkeypatch.setattr(dev_server, "_trusted_plugin_dir", lambda d: STUB)
|
||||
return dev_server.app.test_client()
|
||||
|
||||
|
||||
def _render(client, **extra):
|
||||
return client.post("/api/render", json={"plugin_id": "vegas-live-stub", "width": 192,
|
||||
"height": 48, "config": {"enabled": True},
|
||||
**extra})
|
||||
|
||||
|
||||
def test_the_live_strip_lists_its_elements(client):
|
||||
data = _render(client, vegas="live").get_json()
|
||||
assert data["image"].startswith("data:image/png;base64,")
|
||||
assert data["height"] == 48 and data["width"] > 192
|
||||
keys = [e["key"] for e in data["live_elements"]]
|
||||
assert keys[:2] == ["card:0", "card:1"] and "map" in keys
|
||||
assert not data["errors"]
|
||||
|
||||
|
||||
def test_the_plain_strip_has_no_live_elements(client):
|
||||
data = _render(client, vegas="plain").get_json()
|
||||
assert data["live_elements"] == [] and not data["errors"]
|
||||
|
||||
|
||||
def test_the_display_view_is_unchanged(client):
|
||||
data = _render(client).get_json()
|
||||
assert (data["width"], data["height"]) == (192, 48)
|
||||
assert "live_elements" not in data
|
||||
|
||||
|
||||
def test_an_unknown_view_is_refused(client):
|
||||
assert _render(client, vegas="sideways").status_code == 400
|
||||
@@ -15,6 +15,7 @@ from src.plugin_system.testing.harness import _instantiate # noqa: E402
|
||||
from src.plugin_system.testing.loading import build_full_config, load_harness_spec, load_manifest # noqa: E402
|
||||
from src.plugin_system.testing.vegas import ( # noqa: E402
|
||||
check_vegas_elements, implements_vegas_elements, render_vegas_elements,
|
||||
render_vegas_strip, render_vegas_timeline,
|
||||
)
|
||||
from src.plugin_system.testing.visual_display_manager import VisualTestDisplayManager # noqa: E402
|
||||
from src.plugin_system.vegas_elements import VegasElement # noqa: E402
|
||||
@@ -178,3 +179,30 @@ def test_check_plugin_reports_a_failing_element_check_and_carries_on(monkeypatch
|
||||
False, None, None)
|
||||
vegas = [r for r in results if r.mode == "vegas elements"]
|
||||
assert len(vegas) == 1 and "boom" in vegas[0].error
|
||||
|
||||
|
||||
def test_a_timeline_moves_what_animates_and_nothing_else():
|
||||
import numpy as np
|
||||
plugin, dm = _stub(dot_speed=200)
|
||||
_block, layout = render_vegas_strip(plugin, "vegas-live-stub", dm)
|
||||
image, rows = render_vegas_timeline(plugin, "vegas-live-stub", dm, steps=3,
|
||||
step_seconds=0.5)
|
||||
assert rows == 3 and image.height == 3 * H + 2
|
||||
pixels = np.asarray(image)
|
||||
first, last = pixels[:H], pixels[2 * (H + 1):]
|
||||
columns = {key: (x, width) for x, key, width in layout}
|
||||
x, width = columns["map"]
|
||||
assert (first[:, x:x + width] != last[:, x:x + width]).any()
|
||||
x, width = columns["card:0"]
|
||||
assert (first[:, x:x + width] == last[:, x:x + width]).all()
|
||||
|
||||
|
||||
def test_a_timeline_with_updates_redraws_the_cards_in_place():
|
||||
import numpy as np
|
||||
plugin, dm = _stub(map_hz=0)
|
||||
_block, layout = render_vegas_strip(plugin, "vegas-live-stub", dm)
|
||||
image, rows = render_vegas_timeline(plugin, "vegas-live-stub", dm, steps=2,
|
||||
run_update=True)
|
||||
pixels = np.asarray(image)
|
||||
x, width = {key: (x, width) for x, key, width in layout}["card:0"]
|
||||
assert (pixels[:H, x:x + width] != pixels[H + 1:, x:x + width]).any()
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
"""ScrollHelper extends and trims a strip in place (src/common/scroll_helper.py).
|
||||
|
||||
Every extension of the Vegas strip used to rebuild it whole (np.concatenate)
|
||||
and every trim copied what was left: 3.5-4.5 ms on the render thread at 512x64
|
||||
on a Pi 4, so the frame after each extension was late. The strip now lives in
|
||||
a buffer with spare room: an append writes only the new columns, a trim only
|
||||
moves the view's start, and a full copy happens only when the buffer is
|
||||
reallocated. What a frame shows must not change at all.
|
||||
"""
|
||||
import random
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from src.common.scroll_helper import ScrollHelper # noqa: E402
|
||||
|
||||
W, H = 64, 16
|
||||
|
||||
|
||||
def _items(rng, n):
|
||||
out = []
|
||||
for _ in range(n):
|
||||
width = rng.randint(5, 60)
|
||||
out.append(Image.frombytes("RGB", (width, H),
|
||||
bytes(rng.randrange(256) for _ in range(width * H * 3))))
|
||||
return out
|
||||
|
||||
|
||||
def _helper():
|
||||
helper = ScrollHelper(W, H)
|
||||
helper.create_scrolling_image(_items(random.Random(1), 4), item_gap=3, lead_gap=0)
|
||||
return helper
|
||||
|
||||
|
||||
def _reference_append(strip, items, gap):
|
||||
"""What append_content used to do."""
|
||||
width = sum(i.width for i in items) + gap * len(items)
|
||||
addition = Image.new("RGB", (width, H))
|
||||
x = 0
|
||||
for item in items:
|
||||
x += gap
|
||||
addition.paste(item, (x, 0))
|
||||
x += item.width
|
||||
return np.concatenate((strip, np.array(addition)), axis=1)
|
||||
|
||||
|
||||
def test_an_append_writes_into_the_buffer_and_copies_only_the_new_columns():
|
||||
helper = _helper()
|
||||
helper.append_content(_items(random.Random(2), 2), item_gap=3) # allocates
|
||||
buffer = helper._strip_buffer
|
||||
before = helper.cached_array.shape[1]
|
||||
items = _items(random.Random(3), 2)
|
||||
helper.append_content(items, item_gap=3)
|
||||
assert helper._strip_buffer is buffer
|
||||
assert np.shares_memory(helper.cached_array, buffer)
|
||||
added = helper.cached_array.shape[1] - before
|
||||
assert helper.last_copy_bytes == added * H * 3
|
||||
|
||||
|
||||
def test_a_trim_copies_nothing():
|
||||
helper = _helper()
|
||||
helper.append_content(_items(random.Random(2), 3), item_gap=3)
|
||||
helper.scroll_position = 120.0
|
||||
cut = helper.drop_scrolled_prefix()
|
||||
assert cut == 120 and helper.last_copy_bytes == 0
|
||||
assert np.shares_memory(helper.cached_array, helper._strip_buffer)
|
||||
|
||||
|
||||
def test_the_buffer_is_reallocated_when_the_room_runs_out():
|
||||
helper = _helper()
|
||||
helper.append_content(_items(random.Random(2), 1), item_gap=3)
|
||||
first = helper._strip_buffer
|
||||
rng = random.Random(4)
|
||||
while helper._strip_buffer is first:
|
||||
helper.append_content(_items(rng, 3), item_gap=3)
|
||||
assert helper.last_copy_bytes == helper.cached_array.nbytes
|
||||
assert helper._strip_start == 0
|
||||
|
||||
|
||||
def test_a_strip_set_from_outside_is_never_written_through():
|
||||
# The multi-display follower adopts a read-only array straight from an image.
|
||||
helper = _helper()
|
||||
helper.append_content(_items(random.Random(2), 1), item_gap=3)
|
||||
adopted = np.asarray(Image.new("RGB", (300, H), (9, 9, 9)))
|
||||
helper.cached_array = adopted
|
||||
helper.total_scroll_width = 300
|
||||
helper.append_content(_items(random.Random(3), 1), item_gap=3)
|
||||
assert not np.shares_memory(helper.cached_array, adopted)
|
||||
assert (adopted == 9).all()
|
||||
helper.cached_array = adopted
|
||||
helper.scroll_position = 100.0
|
||||
helper.drop_scrolled_prefix()
|
||||
assert not np.shares_memory(helper.cached_array, adopted)
|
||||
|
||||
|
||||
def test_a_new_strip_lets_the_old_buffer_go():
|
||||
helper = _helper()
|
||||
helper.append_content(_items(random.Random(2), 1), item_gap=3)
|
||||
helper.create_scrolling_image(_items(random.Random(5), 2), item_gap=3, lead_gap=0)
|
||||
assert helper._strip_buffer is None
|
||||
helper.append_content(_items(random.Random(2), 1), item_gap=3)
|
||||
helper.clear_cache()
|
||||
assert helper._strip_buffer is None and helper._strip_view is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("seed", range(12))
|
||||
def test_every_frame_matches_the_old_copying_strip(seed):
|
||||
"""Random appends, trims, scrolling and patches, against a strip kept the old way."""
|
||||
rng = random.Random(seed)
|
||||
helper = _helper()
|
||||
reference = helper.cached_array.copy()
|
||||
for _ in range(60):
|
||||
op = rng.random()
|
||||
if op < 0.35:
|
||||
items = _items(rng, rng.randint(1, 3))
|
||||
gap = rng.randint(0, 6)
|
||||
helper.append_content(items, item_gap=gap)
|
||||
reference = _reference_append(reference, items, gap)
|
||||
elif op < 0.55:
|
||||
keep = rng.randint(0, W)
|
||||
before = helper.scroll_position
|
||||
cut = helper.drop_scrolled_prefix(keep_before=keep)
|
||||
reference = reference[:, cut:].copy()
|
||||
assert helper.scroll_position == before - cut
|
||||
elif op < 0.7 and helper.cached_array.shape[1] > 8:
|
||||
x = rng.randrange(helper.cached_array.shape[1] - 4)
|
||||
pixels = np.full((H, 4, 3), rng.randrange(256), dtype=np.uint8)
|
||||
helper.patch_columns(x, pixels)
|
||||
reference[:, x:x + 4] = pixels
|
||||
else:
|
||||
limit = max(0, helper.cached_array.shape[1] - W - 1)
|
||||
helper.scroll_position = float(rng.randint(0, limit)) if limit else 0.0
|
||||
assert helper.cached_array.shape == reference.shape
|
||||
assert (helper.cached_array == reference).all()
|
||||
assert helper.total_scroll_width == reference.shape[1]
|
||||
frame = np.asarray(helper.get_visible_portion())
|
||||
x = int(helper.scroll_position)
|
||||
if x + W <= reference.shape[1]:
|
||||
assert (frame == reference[:, x:x + W]).all()
|
||||
# The lazily built image is the strip as it stands.
|
||||
assert (np.asarray(helper.cached_image) == reference).all()
|
||||
@@ -0,0 +1,415 @@
|
||||
"""Live Vegas cards for the scoreboards (src/common/sports_vegas.py, sports_scroll).
|
||||
|
||||
A scoreboard adopts live cards by implementing make_vegas_renderer(); the
|
||||
shared code then draws one card per game, only when what the card shows
|
||||
changed, keyed by the game so the ticker can swap it in place.
|
||||
"""
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from src.common import sports_vegas # noqa: E402
|
||||
from src.common.sports_scroll import SportsScrollDisplay, SportsScrollDisplayManager # noqa: E402
|
||||
from src.common.sports_shared import SportsLiveSharedMixin # noqa: E402
|
||||
from src.plugin_system.vegas_elements import VegasElement # noqa: E402
|
||||
|
||||
W, H = 128, 32
|
||||
|
||||
|
||||
def _game(gid, league="nfl", home=0, away=0, state="in", clock="10:00", odds=None):
|
||||
return {"id": gid, "league": league, "home_abbr": "HOM", "away_abbr": "AWY",
|
||||
"home_score": home, "away_score": away,
|
||||
"status": {"state": state}, "clock": clock, "odds": odds}
|
||||
|
||||
|
||||
class _Renderer:
|
||||
def __init__(self, width):
|
||||
self.width = width
|
||||
self.calls = []
|
||||
self.rankings = None
|
||||
|
||||
def render_game_card(self, game, game_type):
|
||||
self.calls.append((game["id"], game_type, game.get("odds")))
|
||||
return Image.new("RGB", (self.width, H), (game["home_score"] * 10 % 255, 0, 0))
|
||||
|
||||
def set_rankings_cache(self, rankings):
|
||||
self.rankings = rankings
|
||||
|
||||
|
||||
class _Display(SportsScrollDisplay):
|
||||
made = 0
|
||||
|
||||
def make_vegas_renderer(self, card_width, rankings_cache=None):
|
||||
type(self).made += 1
|
||||
self.renderer = _Renderer(card_width)
|
||||
return self.renderer
|
||||
|
||||
def _load_separator_icons(self):
|
||||
self._separator_icons = {"nfl": Image.new("RGBA", (10, 10), (255, 255, 255, 255)),
|
||||
"ncaa_fb": Image.new("RGBA", (10, 10), (0, 255, 0, 255))}
|
||||
|
||||
|
||||
class _Plain(SportsScrollDisplay):
|
||||
pass
|
||||
|
||||
|
||||
def _dm():
|
||||
return SimpleNamespace(width=W, height=H, matrix=None, refresh_hz=100.0,
|
||||
set_scrolling_state=lambda *a, **k: None)
|
||||
|
||||
|
||||
def _display(cls=_Display, **config):
|
||||
return cls(_dm(), config)
|
||||
|
||||
|
||||
def _fp(game):
|
||||
return (game["home_score"], game["away_score"], game["clock"],
|
||||
bool(game.get("odds")))
|
||||
|
||||
|
||||
# -- building blocks ------------------------------------------------------------
|
||||
|
||||
|
||||
def test_game_keys_name_the_game():
|
||||
assert sports_vegas.game_key(_game("401")) == "game:nfl:401"
|
||||
assert sports_vegas.game_key({"league": "mlb", "away_abbr": "A", "home_abbr": "B",
|
||||
"start_time": "2026-09-30T19:00Z"}) \
|
||||
== "game:mlb:A@B:2026-09-30T19:00Z"
|
||||
|
||||
|
||||
def test_dedupe_keeps_the_liveliest_copy_in_first_seen_order():
|
||||
games = [_game("1", state="post", home=3), _game("2", state="pre"),
|
||||
_game("1", state="in", home=2)]
|
||||
kept = sports_vegas.dedupe_games(games)
|
||||
assert [g["id"] for g in kept] == ["1", "2"]
|
||||
assert kept[0]["status"]["state"] == "in"
|
||||
|
||||
|
||||
def test_the_card_cache_draws_only_on_a_new_fingerprint():
|
||||
cache = sports_vegas.VegasCardCache()
|
||||
drawn = []
|
||||
|
||||
def render(value):
|
||||
drawn.append(value)
|
||||
return Image.new("RGB", (4, H))
|
||||
|
||||
first = cache.element("k", 1, lambda: render(1))
|
||||
again = cache.element("k", 1, lambda: render(2))
|
||||
changed = cache.element("k", 2, lambda: render(3))
|
||||
assert drawn == [1, 3]
|
||||
assert isinstance(first, VegasElement) and first.version == 1
|
||||
assert again.image is first.image and changed.version == 2
|
||||
|
||||
|
||||
def test_the_card_cache_forgets_games_not_retained_and_is_bounded():
|
||||
cache = sports_vegas.VegasCardCache(max_entries=3)
|
||||
for key in "abcde":
|
||||
cache.element(key, 0, lambda: Image.new("RGB", (4, H)))
|
||||
assert len(cache) == 3
|
||||
cache.retain(["e"])
|
||||
assert len(cache) == 1
|
||||
|
||||
|
||||
def test_sticky_odds_fill_a_gap_and_then_expire():
|
||||
odds = sports_vegas.StickyOdds(ttl_s=60)
|
||||
game = _game("1", odds={"spread": -3})
|
||||
assert odds.apply("k", game, now=0)["odds"] == {"spread": -3}
|
||||
later = _game("1", odds=None)
|
||||
refilled = odds.apply("k", later, now=30)
|
||||
assert refilled["odds"] == {"spread": -3}
|
||||
assert later["odds"] is None # the feed's dict is untouched
|
||||
assert odds.apply("k", later, now=100)["odds"] is None
|
||||
|
||||
|
||||
# -- SportsScrollDisplay.build_vegas_elements ---------------------------------------
|
||||
|
||||
|
||||
def test_a_slate_becomes_keyed_cards_with_league_separators():
|
||||
display = _display()
|
||||
games = [_game("1"), _game("2"), _game("3", league="ncaa_fb")]
|
||||
elements = display.build_vegas_elements(games, ["nfl", "ncaa_fb"], fingerprint=_fp)
|
||||
keys = [e.key for e in elements]
|
||||
assert keys == ["sep:0:nfl", "game:nfl:1", "game:nfl:2",
|
||||
"sep:1:ncaa_fb", "game:ncaa_fb:3"]
|
||||
assert [e.live for e in elements] == [False, True, True, False, True]
|
||||
card = elements[1]
|
||||
assert card.image.size == (display._get_scroll_settings()["game_card_width"], H)
|
||||
|
||||
|
||||
def test_only_changed_games_are_redrawn():
|
||||
display = _display()
|
||||
games = [_game("1"), _game("2"), _game("3")]
|
||||
display.build_vegas_elements(games, ["nfl"], fingerprint=_fp)
|
||||
renderer = display.renderer
|
||||
assert len(renderer.calls) == 3
|
||||
display.build_vegas_elements(games, ["nfl"], fingerprint=_fp)
|
||||
assert len(renderer.calls) == 3 # nothing changed
|
||||
games[1] = _game("2", home=7)
|
||||
display.build_vegas_elements(games, ["nfl"], fingerprint=_fp)
|
||||
assert [c[0] for c in renderer.calls[3:]] == ["2"]
|
||||
|
||||
|
||||
def test_a_clock_change_redraws_that_card_when_the_fingerprint_has_the_clock():
|
||||
display = _display()
|
||||
display.build_vegas_elements([_game("1"), _game("2")], ["nfl"], fingerprint=_fp)
|
||||
display.build_vegas_elements([_game("1", clock="9:41"), _game("2")], ["nfl"],
|
||||
fingerprint=_fp)
|
||||
assert [c[0] for c in display.renderer.calls[2:]] == ["1"]
|
||||
|
||||
|
||||
def test_the_renderer_is_built_once_not_per_slate():
|
||||
_Display.made = 0
|
||||
display = _display()
|
||||
for _ in range(3):
|
||||
display.build_vegas_elements([_game("1")], ["nfl"], fingerprint=_fp)
|
||||
assert _Display.made == 1
|
||||
|
||||
|
||||
def test_a_card_keeps_its_odds_through_a_poll_without_them():
|
||||
display = _display()
|
||||
display.build_vegas_elements([_game("1", odds={"spread": -3})], ["nfl"], fingerprint=_fp)
|
||||
display.build_vegas_elements([_game("1", odds=None)], ["nfl"], fingerprint=_fp)
|
||||
assert len(display.renderer.calls) == 1 # no redraw without the odds
|
||||
|
||||
|
||||
def test_game_dicts_are_never_mutated():
|
||||
display = _display()
|
||||
game = _game("1", odds=None)
|
||||
snapshot = dict(game)
|
||||
display.build_vegas_elements([game], ["nfl"], fingerprint=_fp)
|
||||
assert game == snapshot
|
||||
|
||||
|
||||
def test_a_sport_without_a_renderer_stays_on_its_ordinary_content():
|
||||
manager = SportsScrollDisplayManager(_dm(), {})
|
||||
manager.display_class = _Plain
|
||||
assert manager.get_vegas_elements_for("mixed", [_game("1")], ["nfl"]) is None
|
||||
|
||||
|
||||
def test_a_renderer_that_fails_falls_back_instead_of_raising():
|
||||
class Broken(_Display):
|
||||
def render_vegas_card(self, renderer, game):
|
||||
raise KeyError("home_logo")
|
||||
|
||||
manager = SportsScrollDisplayManager(_dm(), {})
|
||||
manager.display_class = Broken
|
||||
assert manager.get_vegas_elements_for("mixed", [_game("1")], ["nfl"]) is None
|
||||
|
||||
|
||||
def test_the_manager_hands_back_the_elements():
|
||||
manager = SportsScrollDisplayManager(_dm(), {})
|
||||
manager.display_class = _Display
|
||||
elements = manager.get_vegas_elements_for("mixed", [_game("1")], ["nfl"],
|
||||
fingerprint=_fp)
|
||||
assert [e.key for e in elements] == ["sep:0:nfl", "game:nfl:1"]
|
||||
|
||||
|
||||
# -- the finished-game capture ------------------------------------------------------
|
||||
|
||||
|
||||
class _Live(SportsLiveSharedMixin):
|
||||
def __init__(self):
|
||||
self.live_games = [_game("1"), _game("2")]
|
||||
|
||||
|
||||
def test_a_game_that_was_live_is_remembered_when_it_ends():
|
||||
live = _Live()
|
||||
live._record_finished_game(_game("1", state="post", home=24))
|
||||
live._record_finished_game(_game("99", state="post")) # never live here
|
||||
finished = live.finished_games_snapshot()
|
||||
assert [g["id"] for g in finished] == ["1"]
|
||||
assert finished[0]["home_score"] == 24
|
||||
finished[0]["league"] = "changed"
|
||||
assert live.finished_games_snapshot()[0]["league"] == "nfl" # copies
|
||||
|
||||
|
||||
def test_a_finished_game_is_forgotten_after_its_ttl(monkeypatch):
|
||||
live = _Live()
|
||||
live._record_finished_game(_game("1", state="post"))
|
||||
real = time.monotonic
|
||||
monkeypatch.setattr(time, "monotonic", lambda: real() + live.FINISHED_GAME_TTL + 1)
|
||||
assert live.finished_games_snapshot() == []
|
||||
|
||||
|
||||
def test_final_replaces_the_live_card_in_place():
|
||||
# The same key, so the ticker swaps the card rather than adding one.
|
||||
display = _display()
|
||||
before = display.build_vegas_elements([_game("1", home=7)], ["nfl"], fingerprint=_fp)
|
||||
after = display.build_vegas_elements(
|
||||
sports_vegas.dedupe_games([_game("1", state="post", home=14)]), ["nfl"],
|
||||
fingerprint=_fp)
|
||||
assert before[-1].key == after[-1].key
|
||||
assert before[-1].version != after[-1].version
|
||||
|
||||
|
||||
def test_settings_are_looked_up_once_per_league_not_per_update(monkeypatch):
|
||||
display = _display()
|
||||
calls = []
|
||||
real = display._get_scroll_settings
|
||||
monkeypatch.setattr(display, "_get_scroll_settings",
|
||||
lambda league=None: calls.append(league) or real(league))
|
||||
slate = [_game("1"), _game("2", league="ncaa_fb")]
|
||||
for _ in range(3):
|
||||
display.build_vegas_elements(slate, ["nfl", "ncaa_fb"], fingerprint=_fp)
|
||||
assert sorted(calls) == ["ncaa_fb", "nfl"]
|
||||
|
||||
|
||||
def test_each_card_takes_its_own_leagues_width():
|
||||
display = _display(nfl={"scroll_settings": {"game_card_width": 100}},
|
||||
ncaa_fb={"scroll_settings": {"game_card_width": 140}})
|
||||
display.SCROLL_LEAGUE_KEYS = ("nfl", "ncaa_fb")
|
||||
slate = [_game("1"), _game("2", league="ncaa_fb")]
|
||||
widths = {e.key: e.image.width
|
||||
for e in display.build_vegas_elements(slate, ["nfl", "ncaa_fb"], fingerprint=_fp)
|
||||
if e.live}
|
||||
# ...and keeps it when the other league has nothing to show.
|
||||
alone = display.build_vegas_elements(slate[1:], ["ncaa_fb"], fingerprint=_fp)
|
||||
assert widths == {"game:nfl:1": 100, "game:ncaa_fb:2": 140}
|
||||
assert [e.image.width for e in alone if e.live] == [140]
|
||||
|
||||
|
||||
def test_a_rank_change_redraws_the_card():
|
||||
display = _display()
|
||||
display.build_vegas_elements([_game("1")], ["nfl"], {"HOM": 5}, fingerprint=_fp)
|
||||
drawn = display._vegas_cards.renders
|
||||
display.build_vegas_elements([_game("1")], ["nfl"], {"HOM": 5}, fingerprint=_fp)
|
||||
assert display._vegas_cards.renders == drawn
|
||||
display.build_vegas_elements([_game("1")], ["nfl"], {"HOM": 3}, fingerprint=_fp)
|
||||
assert display._vegas_cards.renders == drawn + 1
|
||||
|
||||
|
||||
def test_finished_games_come_back_as_recent_games_of_their_league():
|
||||
live = _Live()
|
||||
live._record_finished_game(dict(_game("1", home=24), is_final=True))
|
||||
finished = sports_vegas.finished_games([("ncaa_fb", live), ("nfl", None)])
|
||||
assert [(g["id"], g["league"], g["status"]["state"], g["is_live"])
|
||||
for g in finished] == [("1", "ncaa_fb", "post", False)]
|
||||
|
||||
|
||||
def test_a_game_only_judged_over_is_not_drawn_final():
|
||||
# A tied end of regulation looks over to the heuristics; overtime may follow.
|
||||
live = _Live()
|
||||
live._record_finished_game(_game("1", home=24, clock="0:00"))
|
||||
finished = sports_vegas.finished_games([("nfl", live)])
|
||||
assert finished[0]["status"]["state"] == "in"
|
||||
assert not finished[0].get("is_final")
|
||||
# ...and when play resumes, the live list's copy is the one kept.
|
||||
resumed = _game("1", home=24, clock="15:00")
|
||||
merged, _ = sports_vegas.with_finished_games([resumed], ["nfl"], finished)
|
||||
assert sports_vegas.dedupe_games(merged) == [resumed]
|
||||
|
||||
|
||||
def test_the_snapshot_copes_with_games_recorded_while_it_reads():
|
||||
# A baseball league finishing its update in the background records games
|
||||
# off the plugin's lock while the ticker takes snapshots.
|
||||
import sys
|
||||
import threading
|
||||
live = _Live()
|
||||
live.live_games = [_game(str(i)) for i in range(400)]
|
||||
errors = []
|
||||
stop = threading.Event()
|
||||
|
||||
def record():
|
||||
for i in range(400):
|
||||
live._record_finished_game(dict(_game(str(i)), is_final=True))
|
||||
stop.set()
|
||||
|
||||
def read():
|
||||
while not stop.is_set():
|
||||
try:
|
||||
live.finished_games_snapshot()
|
||||
except RuntimeError as exc:
|
||||
errors.append(exc)
|
||||
return
|
||||
|
||||
previous = sys.getswitchinterval()
|
||||
sys.setswitchinterval(1e-6)
|
||||
try:
|
||||
readers = [threading.Thread(target=read) for _ in range(2)]
|
||||
for t in readers:
|
||||
t.start()
|
||||
record()
|
||||
for t in readers:
|
||||
t.join(10)
|
||||
finally:
|
||||
sys.setswitchinterval(previous)
|
||||
assert not errors
|
||||
assert len(live.finished_games_snapshot()) == 400
|
||||
|
||||
|
||||
def test_a_held_game_takes_the_final_details_but_keeps_its_expiry(monkeypatch):
|
||||
live = _Live()
|
||||
live._record_finished_game(_game("1", home=24, clock="0:00")) # judged over
|
||||
live.live_games = [] # dropped
|
||||
now = time.monotonic()
|
||||
monkeypatch.setattr(time, "monotonic", lambda: now + 60)
|
||||
live._record_finished_game(dict(_game("1", home=27), is_final=True))
|
||||
finished = sports_vegas.finished_games([("nfl", live)])
|
||||
assert finished[0]["home_score"] == 27 and finished[0]["status"]["state"] == "post"
|
||||
monkeypatch.setattr(time, "monotonic", lambda: now + live.FINISHED_GAME_TTL + 1)
|
||||
assert live.finished_games_snapshot() == []
|
||||
|
||||
|
||||
def _slate(*games):
|
||||
return [_game(gid, league=league, state=state) for gid, league, state in games]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("games,leagues,expected_ids,expected_leagues", [
|
||||
# After the league's live games, ahead of its recent and upcoming ones.
|
||||
(_slate(("2", "nfl", "in"), ("3", "nfl", "post"), ("4", "nfl", "pre"),
|
||||
("5", "ncaa_fb", "in")),
|
||||
["nfl", "ncaa_fb"], ["2", "F", "3", "4", "5"], ["nfl", "ncaa_fb"]),
|
||||
# A league whose games were all live.
|
||||
(_slate(("2", "nfl", "in"), ("5", "ncaa_fb", "in")),
|
||||
["nfl", "ncaa_fb"], ["2", "F", "5"], ["nfl", "ncaa_fb"]),
|
||||
# No live games: first in its league.
|
||||
(_slate(("5", "ncaa_fb", "pre"), ("3", "nfl", "post")),
|
||||
["ncaa_fb", "nfl"], ["5", "F", "3"], ["ncaa_fb", "nfl"]),
|
||||
# Its league has nothing else to show.
|
||||
(_slate(("5", "ncaa_fb", "pre")), ["ncaa_fb"], ["5", "F"], ["ncaa_fb", "nfl"]),
|
||||
])
|
||||
def test_a_finished_game_goes_where_its_live_card_was(games, leagues, expected_ids,
|
||||
expected_leagues):
|
||||
final = _game("F", league="nfl", state="post")
|
||||
merged, merged_leagues = sports_vegas.with_finished_games(games, leagues, [final])
|
||||
assert [g["id"] for g in merged] == expected_ids
|
||||
assert merged_leagues == expected_leagues
|
||||
|
||||
|
||||
def test_the_default_fingerprint_follows_every_field_and_ignores_key_order():
|
||||
game = _game("1", odds={"spread": -3, "details": ["a", "b"]})
|
||||
same = dict(reversed(list(game.items())))
|
||||
assert sports_vegas.game_fingerprint(game) == sports_vegas.game_fingerprint(same)
|
||||
hash(sports_vegas.game_fingerprint(game))
|
||||
for field, value in [("clock", "9:59"), ("odds", {"spread": -3, "details": ["a"]}),
|
||||
("status", {"state": "post"})]:
|
||||
assert sports_vegas.game_fingerprint(dict(game, **{field: value})) != \
|
||||
sports_vegas.game_fingerprint(game)
|
||||
|
||||
|
||||
def test_no_finished_games_leaves_the_slate_alone():
|
||||
games = _slate(("2", "nfl", "in"))
|
||||
merged, leagues = sports_vegas.with_finished_games(games, ["nfl"], [])
|
||||
assert merged == games and merged is not games and leagues == ["nfl"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("state,expected", [("in", "live"), ("post", "recent"),
|
||||
("pre", "upcoming")])
|
||||
def test_the_default_card_type_follows_the_game_state(state, expected):
|
||||
display = _display()
|
||||
display.build_vegas_elements([_game("1", state=state)], ["nfl"], fingerprint=_fp)
|
||||
assert display.renderer.calls[0][1] == expected
|
||||
|
||||
|
||||
def test_ranks_cleared_since_are_not_kept_by_the_reused_renderer():
|
||||
display = _display()
|
||||
display.build_vegas_elements([_game("1")], ["nfl"], {"HOM": 5}, fingerprint=_fp)
|
||||
assert display.renderer.rankings == {"HOM": 5}
|
||||
display.build_vegas_elements([_game("1")], ["nfl"], None, fingerprint=_fp)
|
||||
assert display.renderer.rankings == {}
|
||||
@@ -67,10 +67,13 @@ class TestConfigWhileStopped:
|
||||
|
||||
|
||||
class TestLivePriorityThrottle:
|
||||
"""The full-screen takeover's live scan: live_in_ticker off, no longer the default."""
|
||||
|
||||
def test_scan_runs_at_most_once_per_interval(self, monkeypatch):
|
||||
now = [1000.0]
|
||||
monkeypatch.setattr(coordinator_module.time, 'monotonic', lambda: now[0])
|
||||
c = _coordinator()
|
||||
c.vegas_config.live_in_ticker = False
|
||||
c._live_priority_check = MagicMock(return_value=None)
|
||||
|
||||
for _ in range(10):
|
||||
@@ -84,6 +87,7 @@ class TestLivePriorityThrottle:
|
||||
def test_live_content_still_pauses_vegas(self, monkeypatch):
|
||||
monkeypatch.setattr(coordinator_module.time, 'monotonic', lambda: 1000.0)
|
||||
c = _coordinator()
|
||||
c.vegas_config.live_in_ticker = False
|
||||
c._live_priority_check = MagicMock(return_value='nfl_live')
|
||||
c.pause = MagicMock()
|
||||
assert c.run_frame() is False
|
||||
|
||||
@@ -58,11 +58,16 @@ def test_an_iteration_does_not_poll_every_plugin_for_its_mode(caplog):
|
||||
|
||||
|
||||
def _live_coordinator(live):
|
||||
"""A coordinator running the real run_frame(), with a switchable live check."""
|
||||
"""A coordinator running the real run_frame(), with a switchable live check.
|
||||
|
||||
live_in_ticker off: these pin the full-screen takeover, which is no longer
|
||||
the default.
|
||||
"""
|
||||
coord = _coordinator({})
|
||||
del coord.run_frame # the real one: it is what refuses frames while paused
|
||||
coord.vegas_config = VegasModeConfig.from_config({'display': {'vegas_scroll': {
|
||||
'enabled': True, 'max_cycle_duration': 0, 'continuous_scroll': True}}})
|
||||
'enabled': True, 'max_cycle_duration': 0, 'continuous_scroll': True,
|
||||
'live_in_ticker': False}}})
|
||||
coord.render_pipeline.has_deferred.return_value = False
|
||||
coord.render_pipeline.needs_extension.return_value = False
|
||||
coord.render_pipeline.render_frame.return_value = True
|
||||
|
||||
@@ -5,10 +5,10 @@ live content did not appear in it at all, because the display controller
|
||||
refused to run the ticker while anything was live. With a dozen plugins
|
||||
enabled that left a live score either absent or minutes stale.
|
||||
|
||||
Two things change, both off by default. `live_in_ticker` keeps the marquee
|
||||
running instead of yielding to a full-screen takeover, and the rotation is
|
||||
expanded by Smooth Weighted Round-Robin so a weighted plugin gets several
|
||||
slots per cycle, spaced through it rather than clumped.
|
||||
Two things change. `live_in_ticker` keeps the marquee running instead of
|
||||
yielding to a full-screen takeover (on by default since 3.8.0), and the
|
||||
rotation is expanded by Smooth Weighted Round-Robin so a weighted plugin gets
|
||||
several slots per cycle, spaced through it rather than clumped.
|
||||
|
||||
Weights are per plugin, not per game: a scoreboard showing four live games
|
||||
still occupies one slot at a time and rotates its own games within it.
|
||||
@@ -288,9 +288,9 @@ class TestTheSchedule:
|
||||
|
||||
|
||||
class TestConfigParsing:
|
||||
def test_defaults_preserve_todays_behaviour(self):
|
||||
def test_live_games_stay_in_the_ticker_by_default(self):
|
||||
cfg = VegasModeConfig.from_config({})
|
||||
assert cfg.live_in_ticker is False
|
||||
assert cfg.live_in_ticker is True
|
||||
assert cfg.live_weight == 3 and cfg.favorite_live_weight == 5
|
||||
|
||||
@pytest.mark.parametrize("given,expected", [(0, 1), (-4, 1), (99, 10), (4, 4)])
|
||||
|
||||
@@ -802,7 +802,8 @@ def save_main_config():
|
||||
'vegas_intra_plugin_gap', 'vegas_render_width_pct',
|
||||
'vegas_min_content_separation', 'vegas_min_cut_gap',
|
||||
'vegas_continuous_scroll', 'vegas_extend_threshold_screens',
|
||||
'vegas_smooth_scroll', 'vegas_overflow_mode', 'vegas_live_refresh']
|
||||
'vegas_smooth_scroll', 'vegas_overflow_mode', 'vegas_live_refresh',
|
||||
'vegas_live_in_ticker']
|
||||
|
||||
if any(k in data for k in vegas_fields):
|
||||
if 'display' not in current_config:
|
||||
@@ -823,6 +824,7 @@ def save_main_config():
|
||||
_set_checkbox(vegas_config, 'continuous_scroll', 'vegas_continuous_scroll')
|
||||
_set_checkbox(vegas_config, 'smooth_scroll', 'vegas_smooth_scroll')
|
||||
_set_checkbox(vegas_config, 'live_refresh', 'vegas_live_refresh')
|
||||
_set_checkbox(vegas_config, 'live_in_ticker', 'vegas_live_in_ticker')
|
||||
|
||||
# max_plugin_width_ratio is the one fractional setting, so it is
|
||||
# handled outside the integer loop below.
|
||||
|
||||
@@ -579,6 +579,18 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group mb-4" id="setting-display-vegas_live_in_ticker" data-setting-key="display.vegas_scroll.live_in_ticker">
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox"
|
||||
id="vegas_live_in_ticker"
|
||||
name="vegas_live_in_ticker"
|
||||
{% if main_config.display.get('vegas_scroll', {}).get('live_in_ticker', True) %}checked{% endif %}
|
||||
class="form-checkbox">
|
||||
<span class="ml-2 text-sm text-gray-700">Keep live games in the ticker{{ ui.help_tip('While a game is live, keep the ticker running and give the live scoreboard extra turns in it (Live Weight). Default: on.
|
||||
With this off a live game takes over the whole display with the full-screen scoreboard until it ends, and the ticker stops.', 'Live Games') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<div class="form-group" id="setting-display-vegas_extend_threshold_screens" data-setting-key="display.vegas_scroll.extend_threshold_screens">
|
||||
<label for="vegas_extend_threshold_screens" class="block text-sm font-medium text-gray-700">Extend When (screens left){{ ui.help_tip('How much unscrolled content triggers loading the next group, measured in screen widths (1.0–10.0).\nDefault: 2. Higher loads earlier and leaves more slack, at the cost of holding more content in memory. Only applies when Continuous Scroll is on.', 'Extend Threshold') }}</label>
|
||||
|
||||
Reference in New Issue
Block a user