mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-06-02 00:43:33 +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)
|
(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)
|
- [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 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
|
> Amazon Affiliate Link – ChuckBuilds receives a small commission on purchases
|
||||||
|
|
||||||
### Power Supply
|
### 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.
|
## 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.
|
- Various ways to enable this depending on your Bonnet / HAT.
|
||||||
|
|
||||||
Your display will look like it is "sort of" working but still messed up.
|
Your display will look like it is "sort of" working but still messed up.
|
||||||
|
|||||||
@@ -415,7 +415,8 @@ class SportsCore(ABC):
|
|||||||
sport=self.sport,
|
sport=self.sport,
|
||||||
league=self.league,
|
league=self.league,
|
||||||
event_id=game['id'],
|
event_id=game['id'],
|
||||||
update_interval_seconds=update_interval
|
update_interval_seconds=update_interval,
|
||||||
|
is_live=is_live
|
||||||
)
|
)
|
||||||
|
|
||||||
if odds_data:
|
if odds_data:
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ class BaseOddsManager:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.warning(f"Failed to load BaseOddsManager configuration: {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]]:
|
update_interval_seconds: int = None) -> Optional[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Fetch odds data for a specific game.
|
Fetch odds data for a specific game.
|
||||||
@@ -94,13 +94,13 @@ class BaseOddsManager:
|
|||||||
league: League name (e.g., 'nfl', 'nba')
|
league: League name (e.g., 'nfl', 'nba')
|
||||||
event_id: ESPN event ID
|
event_id: ESPN event ID
|
||||||
update_interval_seconds: Override default update interval
|
update_interval_seconds: Override default update interval
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary containing odds data or None if unavailable
|
Dictionary containing odds data or None if unavailable
|
||||||
"""
|
"""
|
||||||
if sport is None or league is None:
|
if sport is None or league is None:
|
||||||
raise ValueError("Sport and League cannot be None")
|
raise ValueError("Sport and League cannot be None")
|
||||||
|
|
||||||
# Use provided interval or default
|
# Use provided interval or default
|
||||||
interval = update_interval_seconds or self.update_interval
|
interval = update_interval_seconds or self.update_interval
|
||||||
cache_key = f"odds_espn_{sport}_{league}_{event_id}"
|
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")
|
self.logger.debug("No odds data available for this game")
|
||||||
|
|
||||||
if odds_data:
|
if odds_data:
|
||||||
self.cache_manager.set(cache_key, odds_data)
|
self.cache_manager.set(cache_key, odds_data, ttl=interval)
|
||||||
self.logger.info(f"Saved odds data to cache for {cache_key}")
|
self.logger.info(f"Saved odds data to cache for {cache_key} with TTL {interval}s")
|
||||||
else:
|
else:
|
||||||
self.logger.debug(f"No odds data available for {cache_key}")
|
self.logger.debug(f"No odds data available for {cache_key}")
|
||||||
# Cache the fact that no odds are available to avoid repeated API calls
|
# 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
|
return odds_data
|
||||||
|
|
||||||
@@ -208,34 +208,34 @@ class BaseOddsManager:
|
|||||||
def get_odds_for_games(self, games: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
def get_odds_for_games(self, games: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Fetch odds for multiple games efficiently.
|
Fetch odds for multiple games efficiently.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
games: List of game dictionaries with sport, league, and id
|
games: List of game dictionaries with sport, league, and id
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of games with odds data added
|
List of games with odds data added
|
||||||
"""
|
"""
|
||||||
games_with_odds = []
|
games_with_odds = []
|
||||||
|
|
||||||
for game in games:
|
for game in games:
|
||||||
try:
|
try:
|
||||||
sport = game.get('sport')
|
sport = game.get('sport')
|
||||||
league = game.get('league')
|
league = game.get('league')
|
||||||
event_id = game.get('id')
|
event_id = game.get('id')
|
||||||
|
|
||||||
if sport and league and event_id:
|
if sport and league and event_id:
|
||||||
odds_data = self.get_odds(sport, league, event_id)
|
odds_data = self.get_odds(sport, league, event_id)
|
||||||
game['odds'] = odds_data
|
game['odds'] = odds_data
|
||||||
else:
|
else:
|
||||||
game['odds'] = None
|
game['odds'] = None
|
||||||
|
|
||||||
games_with_odds.append(game)
|
games_with_odds.append(game)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"Error fetching odds for game {game.get('id', 'unknown')}: {e}")
|
self.logger.error(f"Error fetching odds for game {game.get('id', 'unknown')}: {e}")
|
||||||
game['odds'] = None
|
game['odds'] = None
|
||||||
games_with_odds.append(game)
|
games_with_odds.append(game)
|
||||||
|
|
||||||
return games_with_odds
|
return games_with_odds
|
||||||
|
|
||||||
def is_odds_available(self, odds_data: Optional[Dict[str, Any]]) -> bool:
|
def is_odds_available(self, odds_data: Optional[Dict[str, Any]]) -> bool:
|
||||||
|
|||||||
@@ -436,14 +436,17 @@ class DisplayController:
|
|||||||
|
|
||||||
def _check_schedule(self):
|
def _check_schedule(self):
|
||||||
"""Check if display should be active based on schedule."""
|
"""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 schedule config doesn't exist or is empty, default to always active
|
||||||
if not schedule_config:
|
if not schedule_config:
|
||||||
self.is_display_active = True
|
self.is_display_active = True
|
||||||
self._was_display_active = True # Track previous state for schedule change detection
|
self._was_display_active = True # Track previous state for schedule change detection
|
||||||
return
|
return
|
||||||
|
|
||||||
# Check if schedule is explicitly disabled
|
# Check if schedule is explicitly disabled
|
||||||
# Default to True (schedule enabled) if 'enabled' key is missing for backward compatibility
|
# 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):
|
if 'enabled' in schedule_config and not schedule_config.get('enabled', True):
|
||||||
@@ -453,7 +456,7 @@ class DisplayController:
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Get configured timezone, default to UTC
|
# Get configured timezone, default to UTC
|
||||||
timezone_str = self.config.get('timezone', 'UTC')
|
timezone_str = current_config.get('timezone', 'UTC')
|
||||||
try:
|
try:
|
||||||
tz = pytz.timezone(timezone_str)
|
tz = pytz.timezone(timezone_str)
|
||||||
except pytz.UnknownTimeZoneError:
|
except pytz.UnknownTimeZoneError:
|
||||||
@@ -551,15 +554,18 @@ class DisplayController:
|
|||||||
Target brightness level (dim_brightness if in dim period,
|
Target brightness level (dim_brightness if in dim period,
|
||||||
normal brightness otherwise)
|
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
|
# 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 display is OFF via schedule, don't process dim schedule
|
||||||
if not self.is_display_active:
|
if not self.is_display_active:
|
||||||
self.is_dimmed = False
|
self.is_dimmed = False
|
||||||
return normal_brightness
|
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 dim schedule doesn't exist or is disabled, use normal brightness
|
||||||
if not dim_config or not dim_config.get('enabled', False):
|
if not dim_config or not dim_config.get('enabled', False):
|
||||||
@@ -567,7 +573,7 @@ class DisplayController:
|
|||||||
return normal_brightness
|
return normal_brightness
|
||||||
|
|
||||||
# Get configured timezone
|
# Get configured timezone
|
||||||
timezone_str = self.config.get('timezone', 'UTC')
|
timezone_str = current_config.get('timezone', 'UTC')
|
||||||
try:
|
try:
|
||||||
tz = pytz.timezone(timezone_str)
|
tz = pytz.timezone(timezone_str)
|
||||||
except pytz.UnknownTimeZoneError:
|
except pytz.UnknownTimeZoneError:
|
||||||
|
|||||||
@@ -228,6 +228,43 @@ class PluginLoader:
|
|||||||
continue
|
continue
|
||||||
return result
|
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(
|
def _namespace_plugin_modules(
|
||||||
self, plugin_id: str, plugin_dir: Path, before_keys: set
|
self, plugin_id: str, plugin_dir: Path, before_keys: set
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -254,12 +291,13 @@ class PluginLoader:
|
|||||||
for mod_name, mod in self._iter_plugin_bare_modules(plugin_dir, before_keys):
|
for mod_name, mod in self._iter_plugin_bare_modules(plugin_dir, before_keys):
|
||||||
namespaced = f"_plg_{safe_id}_{mod_name}"
|
namespaced = f"_plg_{safe_id}_{mod_name}"
|
||||||
sys.modules[namespaced] = mod
|
sys.modules[namespaced] = mod
|
||||||
# Keep sys.modules[mod_name] as an alias to the same object.
|
# Remove the bare sys.modules entry. The module object stays
|
||||||
# Removing it would cause lazy intra-plugin imports (e.g. a
|
# alive via the namespaced key and all existing Python-level
|
||||||
# deferred ``import scroll_display`` inside a method) to
|
# bindings (``from scroll_display import X`` already bound X
|
||||||
# re-import from disk and create a second, inconsistent copy
|
# to the class object). Leaving bare entries would cause the
|
||||||
# of the module. The next plugin's exec_module will naturally
|
# NEXT plugin's exec_module to find the cached entry and reuse
|
||||||
# overwrite the bare entry with its own version.
|
# it instead of loading its own version.
|
||||||
|
sys.modules.pop(mod_name, None)
|
||||||
namespaced_names.add(namespaced)
|
namespaced_names.add(namespaced)
|
||||||
self.logger.debug(
|
self.logger.debug(
|
||||||
"Namespace-isolated module '%s' -> '%s' for plugin %s",
|
"Namespace-isolated module '%s' -> '%s' for plugin %s",
|
||||||
@@ -345,6 +383,11 @@ class PluginLoader:
|
|||||||
# _namespace_plugin_modules and error cleanup only target
|
# _namespace_plugin_modules and error cleanup only target
|
||||||
# sub-modules, not the main module entry itself.
|
# sub-modules, not the main module entry itself.
|
||||||
before_keys = set(sys.modules.keys())
|
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:
|
try:
|
||||||
spec.loader.exec_module(module)
|
spec.loader.exec_module(module)
|
||||||
|
|
||||||
@@ -352,6 +395,10 @@ class PluginLoader:
|
|||||||
# cannot collide with identically-named modules from other plugins
|
# cannot collide with identically-named modules from other plugins
|
||||||
self._namespace_plugin_modules(plugin_id, plugin_dir, before_keys)
|
self._namespace_plugin_modules(plugin_id, plugin_dir, before_keys)
|
||||||
except Exception:
|
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
|
# Clean up the partially-initialized main module and any
|
||||||
# bare-name sub-modules that were added during exec_module
|
# bare-name sub-modules that were added during exec_module
|
||||||
# so they don't leak into subsequent plugin loads.
|
# so they don't leak into subsequent plugin loads.
|
||||||
|
|||||||
@@ -885,7 +885,7 @@ def save_main_config():
|
|||||||
if 'properties' in schema:
|
if 'properties' in schema:
|
||||||
secret_fields = find_secret_fields(schema['properties'])
|
secret_fields = find_secret_fields(schema['properties'])
|
||||||
except Exception as e:
|
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)
|
# Separate secrets from regular config (same logic as save_plugin_config)
|
||||||
def separate_secrets(config, secrets_set, prefix=''):
|
def separate_secrets(config, secrets_set, prefix=''):
|
||||||
@@ -923,7 +923,7 @@ def save_main_config():
|
|||||||
if 'enabled' not in regular_config:
|
if 'enabled' not in regular_config:
|
||||||
regular_config['enabled'] = True
|
regular_config['enabled'] = True
|
||||||
except Exception as e:
|
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
|
# Default to True on error to avoid disabling plugins
|
||||||
regular_config['enabled'] = True
|
regular_config['enabled'] = True
|
||||||
|
|
||||||
@@ -958,7 +958,7 @@ def save_main_config():
|
|||||||
plugin_instance.on_config_change(plugin_full_config)
|
plugin_instance.on_config_change(plugin_full_config)
|
||||||
except Exception as hook_err:
|
except Exception as hook_err:
|
||||||
# Don't fail the save if hook fails
|
# 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)
|
# Remove processed plugin keys from data (they're already in current_config)
|
||||||
for key in plugin_keys_to_remove:
|
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).
|
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:
|
Args:
|
||||||
schema: The JSON schema dict
|
schema: The JSON schema dict
|
||||||
key_path: Dot-separated path like "customization.time_text.font"
|
key_path: Dot-separated path like "customization.time_text.font"
|
||||||
|
or "leagues.eng.1.favorite_teams" where "eng.1" is one key.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The property schema dict or None if not found
|
The property schema dict or None if not found
|
||||||
@@ -3500,21 +3505,27 @@ def _get_schema_property(schema, key_path):
|
|||||||
|
|
||||||
parts = key_path.split('.')
|
parts = key_path.split('.')
|
||||||
current = schema['properties']
|
current = schema['properties']
|
||||||
|
i = 0
|
||||||
|
|
||||||
for i, part in enumerate(parts):
|
while i < len(parts):
|
||||||
if part not in current:
|
# Try progressively longer candidate keys starting at position i,
|
||||||
return None
|
# longest first, to greedily match dotted property names (e.g. "eng.1").
|
||||||
|
matched = False
|
||||||
prop = current[part]
|
for j in range(len(parts), i, -1):
|
||||||
|
candidate = '.'.join(parts[i:j])
|
||||||
# If this is the last part, return the property
|
if candidate in current:
|
||||||
if i == len(parts) - 1:
|
prop = current[candidate]
|
||||||
return prop
|
if j == len(parts):
|
||||||
|
return prop # Consumed all remaining parts — done
|
||||||
# If this is an object with properties, navigate deeper
|
# Navigate deeper if this is an object with properties
|
||||||
if isinstance(prop, dict) and 'properties' in prop:
|
if isinstance(prop, dict) and 'properties' in prop:
|
||||||
current = prop['properties']
|
current = prop['properties']
|
||||||
else:
|
i = j
|
||||||
|
matched = True
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
return None # Can't navigate deeper
|
||||||
|
if not matched:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
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.
|
Set a value in a nested dict using dot notation path.
|
||||||
Handles existing nested dicts correctly by merging instead of replacing.
|
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:
|
Args:
|
||||||
config: The config dict to modify
|
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)
|
value: The value to set (or _SKIP_FIELD to skip setting)
|
||||||
"""
|
"""
|
||||||
# Skip setting if value is the sentinel
|
# Skip setting if value is the sentinel
|
||||||
if value is _SKIP_FIELD:
|
if value is _SKIP_FIELD:
|
||||||
return
|
return
|
||||||
|
|
||||||
parts = key_path.split('.')
|
parts = key_path.split('.')
|
||||||
current = config
|
current = config
|
||||||
|
i = 0
|
||||||
|
|
||||||
# Navigate/create intermediate dicts
|
# Navigate/create intermediate dicts, greedily matching dotted keys.
|
||||||
for i, part in enumerate(parts[:-1]):
|
# We stop before the final part so we can set it as the leaf value.
|
||||||
if part not in current:
|
while i < len(parts) - 1:
|
||||||
current[part] = {}
|
# Try progressively longer candidate keys (longest first) to match
|
||||||
elif not isinstance(current[part], dict):
|
# dict keys that contain dots themselves (e.g. "eng.1").
|
||||||
# If the existing value is not a dict, replace it with a dict
|
# Never consume the very last part (that's the leaf value key).
|
||||||
current[part] = {}
|
matched = False
|
||||||
current = current[part]
|
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)
|
# The remaining parts form the final key (may itself be dotted, e.g. "eng.1")
|
||||||
if value is not None or parts[-1] not in current:
|
final_key = '.'.join(parts[i:])
|
||||||
current[parts[-1]] = value
|
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):
|
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():
|
if not module_path.exists():
|
||||||
raise ImportError(f"TronbyteRepository module not found at {module_path}")
|
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:
|
if "tronbyte_repository" in sys.modules:
|
||||||
importlib.reload(sys.modules["tronbyte_repository"])
|
|
||||||
return sys.modules["tronbyte_repository"].TronbyteRepository
|
return sys.modules["tronbyte_repository"].TronbyteRepository
|
||||||
|
|
||||||
spec = importlib.util.spec_from_file_location("tronbyte_repository", str(module_path))
|
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():
|
if not module_path.exists():
|
||||||
raise ImportError(f"PixletRenderer module not found at {module_path}")
|
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:
|
if "pixlet_renderer" in sys.modules:
|
||||||
importlib.reload(sys.modules["pixlet_renderer"])
|
|
||||||
return sys.modules["pixlet_renderer"].PixletRenderer
|
return sys.modules["pixlet_renderer"].PixletRenderer
|
||||||
|
|
||||||
spec = importlib.util.spec_from_file_location("pixlet_renderer", str(module_path))
|
spec = importlib.util.spec_from_file_location("pixlet_renderer", str(module_path))
|
||||||
@@ -7442,8 +7473,14 @@ def upload_starlark_app():
|
|||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
except (ValueError, OSError, IOError) as e:
|
except (OSError, IOError) as err:
|
||||||
logger.exception("[Starlark] Error uploading starlark app")
|
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
|
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').getDefaultSchedule = getDefaultSchedule;
|
||||||
window.LEDMatrixWidgets.get('schedule-picker').normalizeSchedule = normalizeSchedule;
|
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();
|
pluginLoadCache.timestamp = Date.now();
|
||||||
|
|
||||||
// Always update window.installedPlugins to ensure Alpine component can detect changes
|
// Always update window.installedPlugins to ensure Alpine component can detect changes
|
||||||
const currentPlugins = Array.isArray(window.installedPlugins) ? window.installedPlugins : [];
|
window.installedPlugins = 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dispatch event to notify Alpine component to update tabs
|
// Dispatch event to notify Alpine component to update tabs
|
||||||
document.dispatchEvent(new CustomEvent('pluginsUpdated', {
|
document.dispatchEvent(new CustomEvent('pluginsUpdated', {
|
||||||
|
|||||||
Reference in New Issue
Block a user