mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-04-17 07:33:00 +00:00
Compare commits
6 Commits
ed90654bf2
...
976c10c4ac
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
976c10c4ac | ||
|
|
b92ff3dfbd | ||
|
|
4c4efd614a | ||
|
|
14b6a0c6a3 | ||
|
|
c2763d6447 | ||
|
|
1f0de9b354 |
@@ -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
|
||||

|
||||
|
||||
## 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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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');
|
||||
})();
|
||||
|
||||
@@ -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', {
|
||||
|
||||
Reference in New Issue
Block a user