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:
@@ -145,6 +145,77 @@ class BasePlugin(ABC):
|
||||
"""
|
||||
raise NotImplementedError("Plugins must implement display()")
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Global (whole-device) configuration
|
||||
# -------------------------------------------------------------------------
|
||||
@property
|
||||
def global_config(self) -> Dict[str, Any]:
|
||||
"""
|
||||
The full LEDMatrix configuration, for reading device-wide settings.
|
||||
|
||||
``self.config`` is only this plugin's own slice, so cross-cutting
|
||||
settings — ``target_fps``, ``timezone``, ``location`` — were previously
|
||||
unreachable from a plugin without reaching into a manager by hand.
|
||||
|
||||
Resolution order mirrors the timezone helpers the sports plugins
|
||||
already ship: ``plugin_manager.config_manager`` first (the cores that
|
||||
hang it there), then ``cache_manager.config_manager``. Returns ``{}``
|
||||
when neither is available, so callers can use plain ``.get()`` without
|
||||
guarding, and a plugin on a core that predates this property still
|
||||
loads — ``getattr(self, 'global_config', {})`` simply yields the
|
||||
default.
|
||||
|
||||
Treat as read-only: the returned dict is the live config the core is
|
||||
using, so mutating it edits every other consumer's view and can be
|
||||
persisted back to disk.
|
||||
|
||||
Assignment is still allowed and wins over the resolved value. Several
|
||||
shipped plugins (news, stock-news, ledmatrix-stocks, ledmatrix-
|
||||
elections, ledmatrix-leaderboard, nfl-draft) set
|
||||
``self.global_config`` to their own ``config['global']`` sub-dict; a
|
||||
property without a setter would raise AttributeError and stop those
|
||||
plugins loading.
|
||||
|
||||
Example:
|
||||
fps = self.global_config.get('target_fps')
|
||||
"""
|
||||
override = getattr(self, '_global_config_override', None)
|
||||
if override is not None:
|
||||
return override
|
||||
for owner in (self.plugin_manager, self.cache_manager):
|
||||
config_manager = getattr(owner, 'config_manager', None)
|
||||
if config_manager is None:
|
||||
continue
|
||||
try:
|
||||
config = config_manager.get_config()
|
||||
except Exception:
|
||||
# A broken or unreadable config must never stop a plugin from
|
||||
# loading; fall through to the next source, then to {}.
|
||||
self.logger.debug(
|
||||
"Could not read global config from %s",
|
||||
type(owner).__name__, exc_info=True,
|
||||
)
|
||||
continue
|
||||
# Only a real mapping is usable: callers do .get() on this and feed
|
||||
# the result to numeric code, so handing back whatever a stub or a
|
||||
# half-built manager returned would fail later and further away.
|
||||
#
|
||||
# An empty dict is treated as "nothing here yet" rather than a
|
||||
# valid answer, so resolution continues to the next source. Both
|
||||
# managers default to the same config/config.json, so falling
|
||||
# through cannot pick up a different file's settings -- but it does
|
||||
# rescue the case where the first manager simply hasn't loaded yet,
|
||||
# which would otherwise return {} and silently disable every
|
||||
# setting read through this property.
|
||||
if isinstance(config, dict) and config:
|
||||
return config
|
||||
return {}
|
||||
|
||||
@global_config.setter
|
||||
def global_config(self, value: Dict[str, Any]) -> None:
|
||||
"""Let a plugin substitute its own view (see the getter's docstring)."""
|
||||
self._global_config_override = value
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Adaptive layout support (opt-in)
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user