mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-06-20 19:48:38 +00:00
Compare commits
3 Commits
d22d0a3754
...
d297dd6217
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d297dd6217 | ||
|
|
974d7ea57a | ||
|
|
ab0cfd2362 |
@@ -75,12 +75,20 @@ def install_via_pip(package_name):
|
||||
Debian/Ubuntu-based systems without a virtual environment.
|
||||
--prefer-binary prefers pre-built wheels over source distributions to avoid
|
||||
exhausting /tmp space during compilation.
|
||||
--ignore-installed stops pip from trying to *uninstall* packages that were
|
||||
installed by apt (e.g. python3-requests). Those Debian packages ship no
|
||||
pip RECORD file, so an uninstall attempt fails with "uninstall-no-record-file"
|
||||
and aborts the whole install. With --ignore-installed, pip lays the new
|
||||
version down in /usr/local where it shadows the apt copy instead of removing
|
||||
it. This matters when a pip dependency (google-api-python-client pulls a
|
||||
newer requests) needs to upgrade an apt-managed package.
|
||||
|
||||
Returns (success, output).
|
||||
"""
|
||||
print(f"Installing {package_name} via pip...")
|
||||
success, output = _run([
|
||||
sys.executable, '-m', 'pip', 'install', '--break-system-packages', '--prefer-binary', package_name
|
||||
sys.executable, '-m', 'pip', 'install',
|
||||
'--break-system-packages', '--prefer-binary', '--ignore-installed', package_name
|
||||
])
|
||||
if success:
|
||||
print(f"Successfully installed {package_name} via pip")
|
||||
@@ -200,7 +208,7 @@ def main():
|
||||
if setup_py.exists():
|
||||
# Try installing - use regular install, not editable mode
|
||||
# This is optional for web interface and should already be installed in Step 6
|
||||
ok, output = _run([sys.executable, '-m', 'pip', 'install', '--break-system-packages', str(rgbmatrix_path)])
|
||||
ok, output = _run([sys.executable, '-m', 'pip', 'install', '--break-system-packages', '--ignore-installed', str(rgbmatrix_path)])
|
||||
if ok:
|
||||
print("rgbmatrix module installed successfully")
|
||||
else:
|
||||
|
||||
@@ -1505,30 +1505,68 @@ class DisplayController:
|
||||
logger.info("Live priority ended - resuming rotation at %s", self.current_display_mode)
|
||||
self._live_resume_index = None
|
||||
|
||||
def _check_live_priority(self):
|
||||
"""
|
||||
Check all plugins for live priority content.
|
||||
Returns the mode that should be displayed if live content is found, None otherwise.
|
||||
def _collect_live_modes(self):
|
||||
"""Return every currently live-priority mode, in registration order.
|
||||
|
||||
Scans all registered plugin modes; for each plugin that has live
|
||||
priority *and* live content, collects the specific live mode(s) it
|
||||
reports via get_live_modes() (only those actually registered), falling
|
||||
back to the scanned mode name when it ends in '_live'. Deduplicated,
|
||||
preserving order. A plugin registered under several mode keys (the
|
||||
sports plugins register one per league) contributes each live mode once.
|
||||
"""
|
||||
live = []
|
||||
seen = set()
|
||||
for mode_name, plugin_instance in self.plugin_modes.items():
|
||||
if hasattr(plugin_instance, 'has_live_priority') and hasattr(plugin_instance, 'has_live_content'):
|
||||
if not (hasattr(plugin_instance, 'has_live_priority')
|
||||
and hasattr(plugin_instance, 'has_live_content')):
|
||||
continue
|
||||
try:
|
||||
if plugin_instance.has_live_priority() and plugin_instance.has_live_content():
|
||||
# Get the specific live mode from the plugin if available
|
||||
if not (plugin_instance.has_live_priority()
|
||||
and plugin_instance.has_live_content()):
|
||||
continue
|
||||
resolved = []
|
||||
if hasattr(plugin_instance, 'get_live_modes'):
|
||||
live_modes = plugin_instance.get_live_modes()
|
||||
if live_modes and len(live_modes) > 0:
|
||||
# Verify the mode actually exists before returning it
|
||||
for suggested_mode in live_modes:
|
||||
for suggested_mode in (plugin_instance.get_live_modes() or []):
|
||||
if suggested_mode in self.plugin_modes:
|
||||
return suggested_mode
|
||||
# If suggested modes don't exist, fall through to check current mode
|
||||
# Fallback: if this mode ends with _live, return it
|
||||
if mode_name.endswith('_live'):
|
||||
return mode_name
|
||||
resolved.append(suggested_mode)
|
||||
if not resolved and mode_name.endswith('_live'):
|
||||
resolved.append(mode_name)
|
||||
for m in resolved:
|
||||
if m not in seen:
|
||||
seen.add(m)
|
||||
live.append(m)
|
||||
except Exception as e:
|
||||
logger.warning("Error checking live priority for %s: %s", mode_name, e)
|
||||
return live
|
||||
|
||||
def _check_live_priority(self, advance=False):
|
||||
"""Return the live-priority mode to display, or None if nothing is live.
|
||||
|
||||
When several plugins report live content at once (e.g. a baseball game
|
||||
and a soccer match), this round-robins between them so the display
|
||||
alternates each dwell instead of pinning to whichever plugin is first in
|
||||
registration order.
|
||||
|
||||
advance=False (default): a non-advancing peek — returns the live mode
|
||||
already on screen if it is still live, otherwise the first live mode.
|
||||
Used by the Vegas coordinator and the vegas-active check, which only
|
||||
need to know whether *any* game is live (and must not spin the cursor).
|
||||
|
||||
advance=True: the rotation pick — returns the live mode *after* the one
|
||||
currently shown, so each dwell advances to the next live game. The
|
||||
currently-displayed mode is the cursor, so this stays correct as games
|
||||
start and end (no separate index to keep in sync).
|
||||
"""
|
||||
live_modes = self._collect_live_modes()
|
||||
if not live_modes:
|
||||
return None
|
||||
if self.current_display_mode in live_modes:
|
||||
if advance:
|
||||
idx = live_modes.index(self.current_display_mode)
|
||||
return live_modes[(idx + 1) % len(live_modes)]
|
||||
return self.current_display_mode
|
||||
return live_modes[0]
|
||||
|
||||
def run(self):
|
||||
"""Run the display controller, switching between displays."""
|
||||
@@ -1689,9 +1727,11 @@ class DisplayController:
|
||||
# Display failed, clear the status and continue normally
|
||||
wifi_status_data = None
|
||||
|
||||
# Check for live priority content and switch to it immediately
|
||||
# Check for live priority content and switch to it immediately.
|
||||
# advance=True so multiple simultaneously-live games take turns
|
||||
# (round-robin) instead of pinning to the first plugin.
|
||||
if not self.on_demand_active and not wifi_status_data:
|
||||
live_priority_mode = self._check_live_priority()
|
||||
live_priority_mode = self._check_live_priority(advance=True)
|
||||
self._apply_live_priority(live_priority_mode)
|
||||
|
||||
# Vegas scroll mode - continuous ticker across all plugins
|
||||
|
||||
@@ -214,6 +214,104 @@ class TestDisplayControllerLivePriority:
|
||||
assert controller.current_mode_index == 1
|
||||
assert controller.current_display_mode == "b"
|
||||
|
||||
# --- Round-robin between multiple simultaneous live games --------------
|
||||
|
||||
@staticmethod
|
||||
def _live_plugin(live_modes):
|
||||
"""A mock plugin that is live and reports the given live mode names."""
|
||||
p = MagicMock()
|
||||
p.has_live_priority = MagicMock(return_value=True)
|
||||
p.has_live_content = MagicMock(return_value=True)
|
||||
p.get_live_modes = MagicMock(return_value=list(live_modes))
|
||||
return p
|
||||
|
||||
def test_collect_live_modes_dedupes_multi_mode_plugin(self, test_display_controller):
|
||||
"""A sports plugin registered under several mode keys (one per league)
|
||||
contributes each live mode once, in registration order; plugins with no
|
||||
live content are skipped."""
|
||||
controller = test_display_controller
|
||||
baseball = self._live_plugin(["baseball_live"])
|
||||
soccer = self._live_plugin(["soccer_fifa.world_live"])
|
||||
idle = MagicMock()
|
||||
idle.has_live_priority = MagicMock(return_value=True)
|
||||
idle.has_live_content = MagicMock(return_value=False)
|
||||
controller.plugin_modes = {
|
||||
"baseball_live": baseball,
|
||||
"baseball_recent": baseball,
|
||||
"soccer_fifa.world_live": soccer,
|
||||
"soccer_usa.1_live": soccer,
|
||||
"soccer_recent": soccer,
|
||||
"clock": idle,
|
||||
}
|
||||
assert controller._collect_live_modes() == [
|
||||
"baseball_live", "soccer_fifa.world_live"
|
||||
]
|
||||
|
||||
def test_round_robin_alternates_between_simultaneous_live_games(self, test_display_controller):
|
||||
"""Regression: with two games live at once, the live-priority pick
|
||||
round-robins each dwell instead of pinning to the first plugin in
|
||||
registration order (the bug where a baseball game hid a live World Cup
|
||||
match)."""
|
||||
controller = test_display_controller
|
||||
baseball = self._live_plugin(["baseball_live"])
|
||||
soccer = self._live_plugin(["soccer_fifa.world_live"])
|
||||
controller.plugin_modes = {
|
||||
"baseball_live": baseball,
|
||||
"soccer_fifa.world_live": soccer,
|
||||
}
|
||||
# First entry into live priority from an ambient mode -> first live game.
|
||||
controller.current_display_mode = "clock"
|
||||
assert controller._check_live_priority(advance=True) == "baseball_live"
|
||||
# The controller switches to it; the next dwell advances to the other.
|
||||
controller.current_display_mode = "baseball_live"
|
||||
assert controller._check_live_priority(advance=True) == "soccer_fifa.world_live"
|
||||
# And wraps back again.
|
||||
controller.current_display_mode = "soccer_fifa.world_live"
|
||||
assert controller._check_live_priority(advance=True) == "baseball_live"
|
||||
|
||||
def test_single_live_game_holds_without_flipping(self, test_display_controller):
|
||||
"""One live game: advancing returns the same mode, so the hold is stable."""
|
||||
controller = test_display_controller
|
||||
controller.plugin_modes = {"baseball_live": self._live_plugin(["baseball_live"])}
|
||||
controller.current_display_mode = "baseball_live"
|
||||
assert controller._check_live_priority(advance=True) == "baseball_live"
|
||||
|
||||
def test_non_advancing_peek_does_not_rotate(self, test_display_controller):
|
||||
"""The default (advance=False) peek used by the Vegas coordinator must
|
||||
not spin the cursor: it returns the live mode already on screen."""
|
||||
controller = test_display_controller
|
||||
controller.plugin_modes = {
|
||||
"baseball_live": self._live_plugin(["baseball_live"]),
|
||||
"soccer_fifa.world_live": self._live_plugin(["soccer_fifa.world_live"]),
|
||||
}
|
||||
controller.current_display_mode = "soccer_fifa.world_live"
|
||||
assert controller._check_live_priority() == "soccer_fifa.world_live"
|
||||
assert controller._check_live_priority() == "soccer_fifa.world_live"
|
||||
# From an ambient mode the peek reports the first live game (truthy).
|
||||
controller.current_display_mode = "clock"
|
||||
assert controller._check_live_priority() == "baseball_live"
|
||||
|
||||
def test_no_live_content_returns_none(self, test_display_controller):
|
||||
controller = test_display_controller
|
||||
idle = MagicMock()
|
||||
idle.has_live_priority = MagicMock(return_value=True)
|
||||
idle.has_live_content = MagicMock(return_value=False)
|
||||
controller.plugin_modes = {"clock": idle}
|
||||
controller.current_display_mode = "clock"
|
||||
assert controller._check_live_priority(advance=True) is None
|
||||
|
||||
def test_fallback_to_mode_name_when_get_live_modes_unhelpful(self, test_display_controller):
|
||||
"""A live plugin whose get_live_modes returns nothing registered falls
|
||||
back to its own '_live' mode name (legacy behavior preserved)."""
|
||||
controller = test_display_controller
|
||||
legacy = MagicMock()
|
||||
legacy.has_live_priority = MagicMock(return_value=True)
|
||||
legacy.has_live_content = MagicMock(return_value=True)
|
||||
legacy.get_live_modes = MagicMock(return_value=["unregistered_mode"])
|
||||
controller.plugin_modes = {"hockey_live": legacy}
|
||||
controller.current_display_mode = "clock"
|
||||
assert controller._check_live_priority(advance=True) == "hockey_live"
|
||||
|
||||
|
||||
class TestDisplayControllerDynamicDuration:
|
||||
"""Test dynamic duration handling."""
|
||||
|
||||
93
test/web_interface/test_dotted_league_keys.py
Normal file
93
test/web_interface/test_dotted_league_keys.py
Normal file
@@ -0,0 +1,93 @@
|
||||
"""
|
||||
Regression test for saving plugin config fields whose schema keys contain dots
|
||||
(e.g. soccer league keys like "fifa.world", "eng.1", "usa.1").
|
||||
|
||||
Bug: the web config form posts form-data with dotted paths such as
|
||||
"leagues.fifa.world.enabled". The helpers that resolve those paths split on every
|
||||
dot, so the dotted league key "fifa.world" was mistaken for nested "fifa" ->
|
||||
"world" objects. Per-league edits (enable, favorite_teams, nested booleans) were
|
||||
written to a fabricated "leagues.fifa.world" branch while the real league object
|
||||
was never updated, so the save silently dropped the change and the saved config
|
||||
came out byte-identical.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
|
||||
from web_interface.blueprints.api_v3 import (
|
||||
_get_schema_property,
|
||||
_set_nested_value,
|
||||
_parse_form_value_with_schema,
|
||||
)
|
||||
|
||||
|
||||
SCHEMA = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"leagues": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"fifa.world": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"type": "boolean"},
|
||||
"favorite_teams": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
},
|
||||
"display_modes": {
|
||||
"type": "object",
|
||||
"properties": {"live": {"type": "boolean"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class TestDottedLeagueKeys(unittest.TestCase):
|
||||
def test_schema_lookup_resolves_dotted_league_key(self):
|
||||
prop = _get_schema_property(SCHEMA, "leagues.fifa.world.favorite_teams")
|
||||
self.assertIsNotNone(prop, "dotted league key path should resolve")
|
||||
self.assertEqual(prop.get("type"), "array")
|
||||
|
||||
def test_schema_lookup_resolves_nested_object_beneath_dotted_key(self):
|
||||
live = _get_schema_property(SCHEMA, "leagues.fifa.world.display_modes.live")
|
||||
self.assertIsNotNone(live)
|
||||
self.assertEqual(live.get("type"), "boolean")
|
||||
|
||||
def test_parse_typed_value_for_dotted_key(self):
|
||||
# Comma-separated text input "USA" must become an array, not the raw string.
|
||||
parsed = _parse_form_value_with_schema(
|
||||
"USA", "leagues.fifa.world.favorite_teams", SCHEMA
|
||||
)
|
||||
self.assertEqual(parsed, ["USA"])
|
||||
|
||||
def test_set_value_updates_real_league_not_fabricated_branch(self):
|
||||
config = {"leagues": {"fifa.world": {"enabled": False, "favorite_teams": []}}}
|
||||
_set_nested_value(config, "leagues.fifa.world.enabled", True)
|
||||
_set_nested_value(config, "leagues.fifa.world.favorite_teams", ["USA"])
|
||||
|
||||
self.assertTrue(config["leagues"]["fifa.world"]["enabled"])
|
||||
self.assertEqual(config["leagues"]["fifa.world"]["favorite_teams"], ["USA"])
|
||||
# The real league must be updated and no fabricated "fifa" branch created.
|
||||
self.assertNotIn("fifa", config["leagues"])
|
||||
|
||||
def test_set_value_into_missing_leaf_lands_in_real_league(self):
|
||||
# A leaf that does not exist yet still resolves into the real dotted league.
|
||||
config = {"leagues": {"fifa.world": {"enabled": False}}}
|
||||
_set_nested_value(config, "leagues.fifa.world.display_modes.live", True)
|
||||
self.assertTrue(
|
||||
config["leagues"]["fifa.world"]["display_modes"]["live"]
|
||||
)
|
||||
self.assertNotIn("fifa", config["leagues"])
|
||||
|
||||
def test_plain_nested_paths_still_work(self):
|
||||
config = {}
|
||||
_set_nested_value(config, "customization.text.font", "small")
|
||||
self.assertEqual(config["customization"]["text"]["font"], "small")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -3558,21 +3558,29 @@ def _get_schema_property(schema, key_path):
|
||||
|
||||
parts = key_path.split('.')
|
||||
current = schema['properties']
|
||||
i = 0
|
||||
|
||||
for i, part in enumerate(parts):
|
||||
if part not in current:
|
||||
return None
|
||||
|
||||
prop = current[part]
|
||||
|
||||
# If this is the last part, return the property
|
||||
if i == len(parts) - 1:
|
||||
while i < len(parts):
|
||||
# Try progressively longer candidates, longest first, so schema keys that
|
||||
# themselves contain dots (e.g. league keys like "fifa.world") are matched
|
||||
# instead of being mistaken for nested "fifa" -> "world" objects.
|
||||
matched = False
|
||||
for j in range(len(parts), i, -1):
|
||||
candidate = '.'.join(parts[i:j])
|
||||
if isinstance(current, dict) and candidate in current:
|
||||
prop = current[candidate]
|
||||
# Consumed all remaining parts — this is the target property.
|
||||
if j == len(parts):
|
||||
return prop
|
||||
|
||||
# If this is an object with properties, navigate deeper
|
||||
# Navigate deeper through an object with properties.
|
||||
if isinstance(prop, dict) and 'properties' in prop:
|
||||
current = prop['properties']
|
||||
else:
|
||||
i = j
|
||||
matched = True
|
||||
break
|
||||
# Matched a non-object before consuming the path — can't go deeper.
|
||||
return None
|
||||
if not matched:
|
||||
return None
|
||||
|
||||
return None
|
||||
@@ -3745,10 +3753,45 @@ def _parse_form_value_with_schema(value, key_path, schema):
|
||||
return value
|
||||
|
||||
|
||||
def _resolve_key_segments(key_path, config):
|
||||
"""Split a dot-notation path into segments, greedily preserving keys that
|
||||
themselves contain dots (e.g. league keys like "fifa.world").
|
||||
|
||||
At each level the longest candidate that matches a key already present in the
|
||||
config wins; otherwise the path splits on the next dot (the normal
|
||||
nested-create case). Because dotted keys such as ``leagues."fifa.world"``
|
||||
always exist in the saved config being updated, this routes the value to the
|
||||
real league object instead of fabricating a ``leagues.fifa.world`` tree.
|
||||
"""
|
||||
parts = key_path.split('.')
|
||||
segments = []
|
||||
node = config
|
||||
i = 0
|
||||
while i < len(parts):
|
||||
matched = False
|
||||
if isinstance(node, dict):
|
||||
for j in range(len(parts), i, -1):
|
||||
candidate = '.'.join(parts[i:j])
|
||||
if candidate in node:
|
||||
segments.append(candidate)
|
||||
node = node[candidate]
|
||||
i = j
|
||||
matched = True
|
||||
break
|
||||
if not matched:
|
||||
part = parts[i]
|
||||
segments.append(part)
|
||||
node = node.get(part) if isinstance(node, dict) else None
|
||||
i += 1
|
||||
return segments
|
||||
|
||||
|
||||
def _set_nested_value(config, key_path, value):
|
||||
"""
|
||||
Set a value in a nested dict using dot notation path.
|
||||
Handles existing nested dicts correctly by merging instead of replacing.
|
||||
Keys containing dots (e.g. league keys like "fifa.world") are preserved when
|
||||
they already exist in the config rather than being split into nested objects.
|
||||
|
||||
Args:
|
||||
config: The config dict to modify
|
||||
@@ -3759,21 +3802,21 @@ def _set_nested_value(config, key_path, value):
|
||||
if value is _SKIP_FIELD:
|
||||
return
|
||||
|
||||
parts = key_path.split('.')
|
||||
segments = _resolve_key_segments(key_path, config)
|
||||
current = config
|
||||
|
||||
# Navigate/create intermediate dicts
|
||||
for i, part in enumerate(parts[:-1]):
|
||||
if part not in current:
|
||||
current[part] = {}
|
||||
elif not isinstance(current[part], dict):
|
||||
for seg in segments[:-1]:
|
||||
if seg not in current:
|
||||
current[seg] = {}
|
||||
elif not isinstance(current[seg], dict):
|
||||
# If the existing value is not a dict, replace it with a dict
|
||||
current[part] = {}
|
||||
current = current[part]
|
||||
current[seg] = {}
|
||||
current = current[seg]
|
||||
|
||||
# Set the final value (don't overwrite with empty dict if value is None and we want to preserve structure)
|
||||
if value is not None or parts[-1] not in current:
|
||||
current[parts[-1]] = value
|
||||
if value is not None or segments[-1] not in current:
|
||||
current[segments[-1]] = value
|
||||
|
||||
|
||||
def _set_missing_booleans_to_false(config, schema_props, form_keys, prefix='', config_node=None):
|
||||
|
||||
Reference in New Issue
Block a user