mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-03 01:38:06 +00:00
Give plugins access to device-wide config, and a global scroll frame rate (#424)
* Give plugins access to device-wide config, and a global scroll frame rate
The sports scoreboards read `getattr(self, 'global_config', {})` to find a
shared scroll frame rate, but nothing ever set that attribute: the loader
constructs plugins with only plugin_id/config/display_manager/cache_manager/
plugin_manager (plugin_loader.py:671), `global_config` appears nowhere in
src/, no plugin manager assigns it, and BasePlugin has no __getattr__ to
synthesize it. The lookup always returned {}, so the ten scroll_display.py
copies that thread target_fps through to ScrollHelper could never fire on any
core. There was also no global target_fps to find -- the only one in the
template is display.vegas_scroll.target_fps, which is Vegas-scoped.
Adds the missing half:
- `BasePlugin.global_config` resolves the full config via
plugin_manager.config_manager, then cache_manager.config_manager, then {}.
Same order the sports timezone helpers already use. Exceptions are swallowed
to debug so an unreadable config can never stop a plugin loading, and a
non-dict result is rejected rather than handed to callers that will .get()
it and feed the result to numeric code.
- A top-level `target_fps` (default 100), exposed on the General tab and
validated 30-200 on save to match ScrollHelper.set_target_fps -- which
clamps silently, so a rejected save reports a value that would otherwise
appear to save and then behave differently.
The property has a setter deliberately. news, stock-news, ledmatrix-stocks,
ledmatrix-elections, ledmatrix-leaderboard and nfl-draft all assign
`self.global_config = config.get('global', {})`; without a setter that raises
"property has no setter" and those six plugins stop loading. Reproduced, then
pinned with a test.
target_fps is also kept out of the `is_general_update` key list: that branch
treats a missing web_display_autostart as an unchecked box, so counting a
target_fps-only POST as a General save would silently switch autostart off.
Verified end to end: config.json -> BasePlugin.global_config ->
scroll_display's existing block -> ScrollHelper.target_fps 120 -> 100, with no
plugin-side change needed. Suite 1441 passed; the 4 failures
(test_display_dirty_tracking, test_web_api::test_get_system_status, two in
test_state_reconciliation) are pre-existing and reproduce identically on a
clean tree.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
* Address review findings on target_fps validation and config resolution
- Reject floats and bools before int() in the target_fps save path. A JSON
body can carry them, where int(90.5) silently stored 90 and true stored 1.
Form posts send strings, so '90.5' already failed in int().
- Assert the template's target_fps is 100, not merely an int, so the
documented default is actually pinned.
- Empty-config precedence: keeping the `and config` check deliberately, now
spelled out in the comment and covered by a test. Both managers default to
the same config/config.json, so falling through cannot pick up a different
file's settings; treating {} as an answer would instead return {} when the
first manager simply hasn't loaded yet, silently disabling every setting
read through the property -- the failure this property exists to fix.
Suite 1446 passed. The float-rejection test was checked to fail without the
guard.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
---------
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+121
-1
@@ -250,5 +250,125 @@ class TestBasePlugin:
|
||||
|
||||
config = {"enabled": True, "live_priority": True}
|
||||
plugin = ConcretePlugin("test", config, mock_display_manager, mock_cache_manager, None)
|
||||
|
||||
|
||||
assert plugin.has_live_priority() is True
|
||||
|
||||
|
||||
class TestBasePluginGlobalConfig:
|
||||
"""global_config exposes device-wide settings that self.config cannot.
|
||||
|
||||
The sports scoreboards read `getattr(self, 'global_config', {})` to find
|
||||
the shared target_fps; before this property existed nothing ever set that
|
||||
attribute, so the lookup silently returned {} and the setting could never
|
||||
take effect on any core.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _plugin(display_manager, cache_manager, plugin_manager=None):
|
||||
from src.plugin_system.base_plugin import BasePlugin
|
||||
|
||||
class ConcretePlugin(BasePlugin):
|
||||
def update(self): pass
|
||||
def display(self, force_clear=False): pass
|
||||
|
||||
return ConcretePlugin(
|
||||
"test", {"enabled": True}, display_manager, cache_manager, plugin_manager
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _manager_with(config):
|
||||
"""A stand-in manager exposing config_manager.get_config()."""
|
||||
manager = MagicMock()
|
||||
manager.config_manager.get_config.return_value = config
|
||||
return manager
|
||||
|
||||
def test_reads_config_from_plugin_manager(self, mock_display_manager, mock_cache_manager):
|
||||
plugin = self._plugin(
|
||||
mock_display_manager, mock_cache_manager,
|
||||
self._manager_with({"target_fps": 100}),
|
||||
)
|
||||
assert plugin.global_config["target_fps"] == 100
|
||||
|
||||
def test_falls_back_to_cache_manager(self, mock_display_manager):
|
||||
# The core that hangs config_manager off the cache manager instead.
|
||||
cache_manager = self._manager_with({"target_fps": 75})
|
||||
plugin = self._plugin(mock_display_manager, cache_manager, plugin_manager=None)
|
||||
assert plugin.global_config["target_fps"] == 75
|
||||
|
||||
def test_plugin_manager_wins_over_cache_manager(self, mock_display_manager):
|
||||
plugin = self._plugin(
|
||||
mock_display_manager,
|
||||
self._manager_with({"target_fps": 75}),
|
||||
self._manager_with({"target_fps": 100}),
|
||||
)
|
||||
assert plugin.global_config["target_fps"] == 100
|
||||
|
||||
def test_empty_plugin_manager_config_falls_through(self, mock_display_manager):
|
||||
"""An empty first source means "not loaded yet", not "the answer".
|
||||
|
||||
Both managers default to the same config/config.json, so falling
|
||||
through cannot pick up a different file. Returning {} here instead
|
||||
would silently disable every setting read through this property --
|
||||
the exact failure this property exists to fix.
|
||||
"""
|
||||
plugin = self._plugin(
|
||||
mock_display_manager,
|
||||
self._manager_with({"target_fps": 100}), # cache_manager
|
||||
self._manager_with({}), # plugin_manager: empty
|
||||
)
|
||||
assert plugin.global_config["target_fps"] == 100
|
||||
|
||||
def test_returns_empty_dict_when_no_config_manager(self, mock_display_manager):
|
||||
# Plain objects: no config_manager attribute at all.
|
||||
plugin = self._plugin(mock_display_manager, object(), object())
|
||||
assert plugin.global_config == {}
|
||||
|
||||
def test_unreadable_config_does_not_raise(self, mock_display_manager):
|
||||
# A plugin must still load when the config on disk is broken.
|
||||
broken = MagicMock()
|
||||
broken.config_manager.get_config.side_effect = OSError("unreadable")
|
||||
plugin = self._plugin(mock_display_manager, broken, broken)
|
||||
assert plugin.global_config == {}
|
||||
|
||||
def test_non_dict_config_is_rejected(self, mock_display_manager):
|
||||
# A stub or half-built manager can return a non-mapping; handing that
|
||||
# back would blow up later in numeric code, far from the cause.
|
||||
plugin = self._plugin(
|
||||
mock_display_manager, object(), self._manager_with("not-a-dict")
|
||||
)
|
||||
assert plugin.global_config == {}
|
||||
|
||||
def test_missing_property_degrades_to_default(self, mock_display_manager, mock_cache_manager):
|
||||
# How plugins actually call it, so a plugin written against this core
|
||||
# still loads on one that predates the property.
|
||||
plugin = self._plugin(mock_display_manager, mock_cache_manager, object())
|
||||
assert getattr(plugin, "global_config", {}).get("target_fps") is None
|
||||
|
||||
def test_plugin_may_still_assign_global_config(self, mock_display_manager, mock_cache_manager):
|
||||
# news, stock-news, ledmatrix-stocks, ledmatrix-elections,
|
||||
# ledmatrix-leaderboard and nfl-draft all do exactly this. Without a
|
||||
# setter the property raises "has no setter" and those plugins stop
|
||||
# loading entirely.
|
||||
from src.plugin_system.base_plugin import BasePlugin
|
||||
|
||||
class AssigningPlugin(BasePlugin):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.global_config = self.config.get("global", {})
|
||||
|
||||
def update(self): pass
|
||||
def display(self, force_clear=False): pass
|
||||
|
||||
plugin = AssigningPlugin(
|
||||
"news", {"enabled": True, "global": {"scroll_speed": 2}},
|
||||
mock_display_manager, mock_cache_manager, self._manager_with({"target_fps": 100}),
|
||||
)
|
||||
# The plugin's own value wins over the resolved config.
|
||||
assert plugin.global_config == {"scroll_speed": 2}
|
||||
|
||||
def test_template_ships_a_global_target_fps(self):
|
||||
# The plumbing is useless if the setting isn't in the shipped config.
|
||||
import json
|
||||
with open("config/config.template.json") as fh:
|
||||
template = json.load(fh)
|
||||
assert template.get("target_fps") == 100
|
||||
|
||||
Reference in New Issue
Block a user