6 Commits

Author SHA1 Message Date
Chuck
976c10c4ac fix(plugins): prevent module collision between plugins with shared module names (#265)
When plugins share identically-named local modules (scroll_display.py,
game_renderer.py, sports.py), the first plugin to load would populate
sys.modules with its version, and subsequent plugins would reuse it
instead of loading their own. This caused hockey-scoreboard to use
soccer-scoreboard's ScrollDisplay class, which passes unsupported kwargs
to ScrollHelper.__init__(), breaking Vegas scroll mode entirely.

Fix: evict stale bare-name module entries from sys.modules before each
plugin's exec_module, and delete bare entries after namespace isolation
so they can't leak to the next plugin.

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 17:22:55 -05:00
Chuck
b92ff3dfbd fix(schedule): hot-reload config in schedule/dim checks + normalize per-day mode variant (#266)
* fix(web): handle string boolean values in schedule-picker widget

The normalizeSchedule function used strict equality (===) to check the
enabled field, which would fail if the config value was a string "true"
instead of boolean true. This could cause the checkbox to always appear
unchecked even when the setting was enabled.

Added coerceToBoolean helper that properly handles:
- Boolean true/false (returns as-is)
- String "true", "1", "on" (case-insensitive) → true
- String "false" or other values → false

Applied to both main schedule enabled and per-day enabled fields.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: trim whitespace in coerceToBoolean string handling

* fix: normalize mode value to handle per_day and per-day variants

* fix: use hot-reload config for schedule and dim schedule checks

The display controller was caching the config at startup and not picking
up changes made via the web UI. Now _check_schedule and _check_dim_schedule
read from config_service.get_config() to get the latest configuration,
allowing schedule changes to take effect without restarting the service.

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-23 17:22:39 -05:00
Chuck
4c4efd614a fix(odds): use update_interval as cache TTL and fix live game cache refresh (#268)
* fix(odds): use 2-minute cache for live games instead of 30 minutes

Live game odds were being cached for 30 minutes because the cache key
didn't trigger the odds_live cache strategy. Added is_live parameter
to get_odds() and include 'live' suffix in cache key for live games,
which triggers the existing odds_live strategy (2 min TTL).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(base-odds): Use interval as TTL for cache operations

- Pass interval variable as TTL to cache_manager.set() calls
- Ensures cache expires after update interval, preventing stale data
- Removes dead code by actually using the computed interval value

* refactor(base-odds): Remove is_live parameter from base class for modularity

- Remove is_live parameter from get_odds() method signature
- Remove cache key modification logic from base class
- Remove is_live handling from get_odds_for_games()
- Keep base class minimal and generic for reuse by other plugins
- Plugin-specific is_live logic moved to odds-ticker plugin override

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-23 17:21:57 -05:00
Chuck
14b6a0c6a3 fix(web): handle dotted keys in schema/config path helpers (#260)
* fix(web): handle dotted keys in schema/config path helpers

Schema property names containing dots (e.g. "eng.1" for Premier League
in soccer-scoreboard) were being incorrectly split on the dot separator
in two path-navigation helpers:

- _get_schema_property: split "leagues.eng.1.favorite_teams" into 4
  segments and looked for "eng" in leagues.properties, which doesn't
  exist (the key is literally "eng.1"). Returned None, so the field
  type was unknown and values were not parsed correctly.

- _set_nested_value: split the same path into 4 segments and created
  config["leagues"]["eng"]["1"]["favorite_teams"] instead of the
  correct config["leagues"]["eng.1"]["favorite_teams"].

Both functions now use a greedy longest-match approach: at each level
they try progressively longer dot-joined candidates first (e.g. "eng.1"
before "eng"), so dotted property names are handled transparently.

Fixes favorite_teams (and other per-league fields) not saving via the
soccer-scoreboard plugin config UI.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: remove debug artifacts from merged branches

- Replace print() with logger.warning() for three error handlers in api_v3.py
  that bypassed the structured logging infrastructure
- Simplify dead if/else in loadInstalledPlugins() — both branches did the
  same window.installedPlugins assignment; collapse to single line
- Remove console.log registration line from schedule-picker widget that
  fired unconditionally on every page load

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 17:21:19 -05:00
Chuck
c2763d6447 Update Waveshare display information in README (#259)
Signed-off-by: Chuck <33324927+ChuckBuilds@users.noreply.github.com>
2026-02-23 16:38:49 -05:00
Chuck
1f0de9b354 fix(starlark): fix Python 3.13 importlib.reload() incompatibility (#258)
* fix(starlark): fix Python 3.13 importlib.reload() incompatibility

In Python 3.13, importlib.reload() raises ModuleNotFoundError for modules
loaded via spec_from_file_location when they aren't on sys.path, because
_bootstrap._find_spec() can no longer resolve them by name.

Replace the reload-on-cache-hit pattern in _get_tronbyte_repository_class()
and _get_pixlet_renderer_class() with a simple return of the cached class —
the reload was only useful for dev-time iteration and is unnecessary in
production (the service restarts clean on each deploy).

Also broaden the exception catch in upload_starlark_app() from
(ValueError, OSError, IOError) to Exception so that any unexpected error
(ImportError, ModuleNotFoundError, etc.) returns a proper JSON response
instead of an unhandled Flask 500.

Fixes: "Install failed: spec not found for the module 'tronbyte_repository'"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(starlark): use targeted exception handlers in upload_starlark_app()

Replace the broad `except Exception` catch-all with specific handlers:
- (OSError, IOError) for temp file creation/save failures
- ImportError for module loading failures (_get_pixlet_renderer_class)
- Exception as final catch-all that logs without leaking internals

All handlers use `err` (not unused `e`) in both the log message and
the JSON response body.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 16:37:48 -05:00
8 changed files with 157 additions and 77 deletions

View File

@@ -142,7 +142,7 @@ The system supports live, recent, and upcoming game information for multiple spo
(2x in a horizontal chain is recommended)
- [Adafruit 64×32](https://www.adafruit.com/product/2278) designed for 128×32 but works with dynamic scaling on many displays (pixel pitch is user preference)
- [Waveshare 64×32](https://amzn.to/3Kw55jK) - Does not require E addressable pad
- [Waveshare 92×46](https://amzn.to/4bydNcv) higher resolution, requires soldering the **E addressable pad** on the [Adafruit RGB Bonnet](https://www.adafruit.com/product/3211) to “8” **OR** toggling the DIP switch on the Adafruit Triple LED Matrix Bonnet *(no soldering required!)*
- [Waveshare 96×48](https://amzn.to/4bydNcv) higher resolution, requires soldering the **E addressable pad** on the [Adafruit RGB Bonnet](https://www.adafruit.com/product/3211) to “8” **OR** toggling the DIP switch on the Adafruit Triple LED Matrix Bonnet *(no soldering required!)*
> Amazon Affiliate Link ChuckBuilds receives a small commission on purchases
### Power Supply
@@ -156,7 +156,7 @@ The system supports live, recent, and upcoming game information for multiple spo
![DSC00079](https://github.com/user-attachments/assets/4282d07d-dfa2-4546-8422-ff1f3a9c0703)
## Possibly required depending on the display you are using.
- Some LED Matrix displays require an "E" addressable line to draw the display properly. The [64x32 Adafruit display](https://www.adafruit.com/product/2278) does NOT require the E addressable line, however the [92x46 Waveshare display](https://amzn.to/4pQdezE) DOES require the "E" Addressable line.
- Some LED Matrix displays require an "E" addressable line to draw the display properly. The [64x32 Adafruit display](https://www.adafruit.com/product/2278) does NOT require the E addressable line, however the [96x48 Waveshare display](https://amzn.to/4pQdezE) DOES require the "E" Addressable line.
- Various ways to enable this depending on your Bonnet / HAT.
Your display will look like it is "sort of" working but still messed up.

View File

@@ -415,7 +415,8 @@ class SportsCore(ABC):
sport=self.sport,
league=self.league,
event_id=game['id'],
update_interval_seconds=update_interval
update_interval_seconds=update_interval,
is_live=is_live
)
if odds_data:

View File

@@ -84,7 +84,7 @@ class BaseOddsManager:
except Exception as e:
self.logger.warning(f"Failed to load BaseOddsManager configuration: {e}")
def get_odds(self, sport: str | None, league: str | None, event_id: str,
def get_odds(self, sport: str | None, league: str | None, event_id: str,
update_interval_seconds: int = None) -> Optional[Dict[str, Any]]:
"""
Fetch odds data for a specific game.
@@ -94,13 +94,13 @@ class BaseOddsManager:
league: League name (e.g., 'nfl', 'nba')
event_id: ESPN event ID
update_interval_seconds: Override default update interval
Returns:
Dictionary containing odds data or None if unavailable
"""
if sport is None or league is None:
raise ValueError("Sport and League cannot be None")
# Use provided interval or default
interval = update_interval_seconds or self.update_interval
cache_key = f"odds_espn_{sport}_{league}_{event_id}"
@@ -143,12 +143,12 @@ class BaseOddsManager:
self.logger.debug("No odds data available for this game")
if odds_data:
self.cache_manager.set(cache_key, odds_data)
self.logger.info(f"Saved odds data to cache for {cache_key}")
self.cache_manager.set(cache_key, odds_data, ttl=interval)
self.logger.info(f"Saved odds data to cache for {cache_key} with TTL {interval}s")
else:
self.logger.debug(f"No odds data available for {cache_key}")
# Cache the fact that no odds are available to avoid repeated API calls
self.cache_manager.set(cache_key, {"no_odds": True})
self.cache_manager.set(cache_key, {"no_odds": True}, ttl=interval)
return odds_data
@@ -208,34 +208,34 @@ class BaseOddsManager:
def get_odds_for_games(self, games: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""
Fetch odds for multiple games efficiently.
Args:
games: List of game dictionaries with sport, league, and id
Returns:
List of games with odds data added
"""
games_with_odds = []
for game in games:
try:
sport = game.get('sport')
league = game.get('league')
event_id = game.get('id')
if sport and league and event_id:
odds_data = self.get_odds(sport, league, event_id)
game['odds'] = odds_data
else:
game['odds'] = None
games_with_odds.append(game)
except Exception as e:
self.logger.error(f"Error fetching odds for game {game.get('id', 'unknown')}: {e}")
game['odds'] = None
games_with_odds.append(game)
return games_with_odds
def is_odds_available(self, odds_data: Optional[Dict[str, Any]]) -> bool:

View File

@@ -436,14 +436,17 @@ class DisplayController:
def _check_schedule(self):
"""Check if display should be active based on schedule."""
schedule_config = self.config.get('schedule', {})
# Get fresh config from config_service to support hot-reload
current_config = self.config_service.get_config()
schedule_config = current_config.get('schedule', {})
# If schedule config doesn't exist or is empty, default to always active
if not schedule_config:
self.is_display_active = True
self._was_display_active = True # Track previous state for schedule change detection
return
# Check if schedule is explicitly disabled
# Default to True (schedule enabled) if 'enabled' key is missing for backward compatibility
if 'enabled' in schedule_config and not schedule_config.get('enabled', True):
@@ -453,7 +456,7 @@ class DisplayController:
return
# Get configured timezone, default to UTC
timezone_str = self.config.get('timezone', 'UTC')
timezone_str = current_config.get('timezone', 'UTC')
try:
tz = pytz.timezone(timezone_str)
except pytz.UnknownTimeZoneError:
@@ -551,15 +554,18 @@ class DisplayController:
Target brightness level (dim_brightness if in dim period,
normal brightness otherwise)
"""
# Get fresh config from config_service to support hot-reload
current_config = self.config_service.get_config()
# Get normal brightness from config
normal_brightness = self.config.get('display', {}).get('hardware', {}).get('brightness', 90)
normal_brightness = current_config.get('display', {}).get('hardware', {}).get('brightness', 90)
# If display is OFF via schedule, don't process dim schedule
if not self.is_display_active:
self.is_dimmed = False
return normal_brightness
dim_config = self.config.get('dim_schedule', {})
dim_config = current_config.get('dim_schedule', {})
# If dim schedule doesn't exist or is disabled, use normal brightness
if not dim_config or not dim_config.get('enabled', False):
@@ -567,7 +573,7 @@ class DisplayController:
return normal_brightness
# Get configured timezone
timezone_str = self.config.get('timezone', 'UTC')
timezone_str = current_config.get('timezone', 'UTC')
try:
tz = pytz.timezone(timezone_str)
except pytz.UnknownTimeZoneError:

View File

@@ -228,6 +228,43 @@ class PluginLoader:
continue
return result
def _evict_stale_bare_modules(self, plugin_dir: Path) -> dict:
"""Temporarily remove bare-name sys.modules entries from other plugins.
Before exec_module, scan the current plugin directory for .py files.
For each, if sys.modules has a bare-name entry whose ``__file__`` lives
in a *different* directory, remove it so Python's import system will
load the current plugin's version instead of reusing the stale cache.
Returns:
Dict mapping evicted module names to their module objects
(for restoration on error).
"""
resolved_dir = plugin_dir.resolve()
evicted: dict = {}
for py_file in plugin_dir.glob("*.py"):
mod_name = py_file.stem
if mod_name.startswith("_"):
continue
existing = sys.modules.get(mod_name)
if existing is None:
continue
existing_file = getattr(existing, "__file__", None)
if not existing_file:
continue
try:
if not Path(existing_file).resolve().is_relative_to(resolved_dir):
evicted[mod_name] = sys.modules.pop(mod_name)
self.logger.debug(
"Evicted stale module '%s' (from %s) before loading plugin in %s",
mod_name, existing_file, plugin_dir,
)
except (ValueError, TypeError):
continue
return evicted
def _namespace_plugin_modules(
self, plugin_id: str, plugin_dir: Path, before_keys: set
) -> None:
@@ -254,12 +291,13 @@ class PluginLoader:
for mod_name, mod in self._iter_plugin_bare_modules(plugin_dir, before_keys):
namespaced = f"_plg_{safe_id}_{mod_name}"
sys.modules[namespaced] = mod
# Keep sys.modules[mod_name] as an alias to the same object.
# Removing it would cause lazy intra-plugin imports (e.g. a
# deferred ``import scroll_display`` inside a method) to
# re-import from disk and create a second, inconsistent copy
# of the module. The next plugin's exec_module will naturally
# overwrite the bare entry with its own version.
# Remove the bare sys.modules entry. The module object stays
# alive via the namespaced key and all existing Python-level
# bindings (``from scroll_display import X`` already bound X
# to the class object). Leaving bare entries would cause the
# NEXT plugin's exec_module to find the cached entry and reuse
# it instead of loading its own version.
sys.modules.pop(mod_name, None)
namespaced_names.add(namespaced)
self.logger.debug(
"Namespace-isolated module '%s' -> '%s' for plugin %s",
@@ -345,6 +383,11 @@ class PluginLoader:
# _namespace_plugin_modules and error cleanup only target
# sub-modules, not the main module entry itself.
before_keys = set(sys.modules.keys())
# Evict stale bare-name modules from other plugin directories
# so Python's import system loads fresh copies from this plugin.
evicted = self._evict_stale_bare_modules(plugin_dir)
try:
spec.loader.exec_module(module)
@@ -352,6 +395,10 @@ class PluginLoader:
# cannot collide with identically-named modules from other plugins
self._namespace_plugin_modules(plugin_id, plugin_dir, before_keys)
except Exception:
# Restore evicted modules so other plugins are unaffected
for evicted_name, evicted_mod in evicted.items():
if evicted_name not in sys.modules:
sys.modules[evicted_name] = evicted_mod
# Clean up the partially-initialized main module and any
# bare-name sub-modules that were added during exec_module
# so they don't leak into subsequent plugin loads.

View File

@@ -885,7 +885,7 @@ def save_main_config():
if 'properties' in schema:
secret_fields = find_secret_fields(schema['properties'])
except Exception as e:
print(f"Error reading schema for secret detection: {e}")
logger.warning(f"Error reading schema for secret detection: {e}")
# Separate secrets from regular config (same logic as save_plugin_config)
def separate_secrets(config, secrets_set, prefix=''):
@@ -923,7 +923,7 @@ def save_main_config():
if 'enabled' not in regular_config:
regular_config['enabled'] = True
except Exception as e:
print(f"Error preserving enabled state for {plugin_id}: {e}")
logger.warning(f"Error preserving enabled state for {plugin_id}: {e}")
# Default to True on error to avoid disabling plugins
regular_config['enabled'] = True
@@ -958,7 +958,7 @@ def save_main_config():
plugin_instance.on_config_change(plugin_full_config)
except Exception as hook_err:
# Don't fail the save if hook fails
print(f"Warning: on_config_change failed for {plugin_id}: {hook_err}")
logger.warning(f"on_config_change failed for {plugin_id}: {hook_err}")
# Remove processed plugin keys from data (they're already in current_config)
for key in plugin_keys_to_remove:
@@ -3488,9 +3488,14 @@ def _get_schema_property(schema, key_path):
"""
Get the schema property for a given key path (supports dot notation).
Handles schema keys that themselves contain dots (e.g., "eng.1" in soccer
league configs) by trying progressively longer segment combinations when an
exact match for the current segment is not found.
Args:
schema: The JSON schema dict
key_path: Dot-separated path like "customization.time_text.font"
or "leagues.eng.1.favorite_teams" where "eng.1" is one key.
Returns:
The property schema dict or None if not found
@@ -3500,21 +3505,27 @@ 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:
return prop
# If this is an object with properties, navigate deeper
if isinstance(prop, dict) and 'properties' in prop:
current = prop['properties']
else:
while i < len(parts):
# Try progressively longer candidate keys starting at position i,
# longest first, to greedily match dotted property names (e.g. "eng.1").
matched = False
for j in range(len(parts), i, -1):
candidate = '.'.join(parts[i:j])
if candidate in current:
prop = current[candidate]
if j == len(parts):
return prop # Consumed all remaining parts — done
# Navigate deeper if this is an object with properties
if isinstance(prop, dict) and 'properties' in prop:
current = prop['properties']
i = j
matched = True
break
else:
return None # Can't navigate deeper
if not matched:
return None
return None
@@ -3691,30 +3702,52 @@ 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.
Handles config keys that themselves contain dots (e.g., "eng.1" in soccer
league configs) by trying progressively longer segment combinations against
existing dict keys before falling back to single-segment creation.
Args:
config: The config dict to modify
key_path: Dot-separated path (e.g., "customization.period_text.font")
key_path: Dot-separated path (e.g., "customization.period_text.font"
or "leagues.eng.1.favorite_teams" where "eng.1" is one key)
value: The value to set (or _SKIP_FIELD to skip setting)
"""
# Skip setting if value is the sentinel
if value is _SKIP_FIELD:
return
parts = key_path.split('.')
current = config
i = 0
# Navigate/create intermediate dicts
for i, part in enumerate(parts[:-1]):
if part not in current:
current[part] = {}
elif not isinstance(current[part], dict):
# If the existing value is not a dict, replace it with a dict
current[part] = {}
current = current[part]
# Navigate/create intermediate dicts, greedily matching dotted keys.
# We stop before the final part so we can set it as the leaf value.
while i < len(parts) - 1:
# Try progressively longer candidate keys (longest first) to match
# dict keys that contain dots themselves (e.g. "eng.1").
# Never consume the very last part (that's the leaf value key).
matched = False
for j in range(len(parts) - 1, i, -1):
candidate = '.'.join(parts[i:j])
if candidate in current and isinstance(current[candidate], dict):
current = current[candidate]
i = j
matched = True
break
if not matched:
# No existing dotted key matched; use single segment and create if needed
part = parts[i]
if part not in current:
current[part] = {}
elif not isinstance(current[part], dict):
current[part] = {}
current = current[part]
i += 1
# 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
# The remaining parts form the final key (may itself be dotted, e.g. "eng.1")
final_key = '.'.join(parts[i:])
if value is not None or final_key not in current:
current[final_key] = value
def _set_missing_booleans_to_false(config, schema_props, form_keys, prefix='', config_node=None):
@@ -6989,9 +7022,8 @@ def _get_tronbyte_repository_class() -> Type[Any]:
if not module_path.exists():
raise ImportError(f"TronbyteRepository module not found at {module_path}")
# If already imported, reload to pick up code changes
# If already imported, return cached class
if "tronbyte_repository" in sys.modules:
importlib.reload(sys.modules["tronbyte_repository"])
return sys.modules["tronbyte_repository"].TronbyteRepository
spec = importlib.util.spec_from_file_location("tronbyte_repository", str(module_path))
@@ -7016,9 +7048,8 @@ def _get_pixlet_renderer_class() -> Type[Any]:
if not module_path.exists():
raise ImportError(f"PixletRenderer module not found at {module_path}")
# If already imported, reload to pick up code changes
# If already imported, return cached class
if "pixlet_renderer" in sys.modules:
importlib.reload(sys.modules["pixlet_renderer"])
return sys.modules["pixlet_renderer"].PixletRenderer
spec = importlib.util.spec_from_file_location("pixlet_renderer", str(module_path))
@@ -7442,8 +7473,14 @@ def upload_starlark_app():
except OSError:
pass
except (ValueError, OSError, IOError) as e:
logger.exception("[Starlark] Error uploading starlark app")
except (OSError, IOError) as err:
logger.exception("[Starlark] File error uploading starlark app: %s", err)
return jsonify({'status': 'error', 'message': f'File error during upload: {err}'}), 500
except ImportError as err:
logger.exception("[Starlark] Module load error uploading starlark app: %s", err)
return jsonify({'status': 'error', 'message': f'Failed to load app module: {err}'}), 500
except Exception as err:
logger.exception("[Starlark] Unexpected error uploading starlark app: %s", err)
return jsonify({'status': 'error', 'message': 'Failed to upload app'}), 500

View File

@@ -567,5 +567,4 @@
window.LEDMatrixWidgets.get('schedule-picker').getDefaultSchedule = getDefaultSchedule;
window.LEDMatrixWidgets.get('schedule-picker').normalizeSchedule = normalizeSchedule;
console.log('[SchedulePickerWidget] Schedule picker widget registered');
})();

View File

@@ -1330,17 +1330,7 @@ function loadInstalledPlugins(forceRefresh = false) {
pluginLoadCache.timestamp = Date.now();
// Always update window.installedPlugins to ensure Alpine component can detect changes
const currentPlugins = Array.isArray(window.installedPlugins) ? window.installedPlugins : [];
const currentIds = currentPlugins.map(p => p.id).sort().join(',');
const newIds = installedPlugins.map(p => p.id).sort().join(',');
const pluginsChanged = currentIds !== newIds;
if (pluginsChanged) {
window.installedPlugins = installedPlugins;
} else {
// Even if IDs haven't changed, update the array reference to trigger Alpine reactivity
window.installedPlugins = installedPlugins;
}
window.installedPlugins = installedPlugins;
// Dispatch event to notify Alpine component to update tabs
document.dispatchEvent(new CustomEvent('pluginsUpdated', {