mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-22 19:08:17 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7cf94f20a |
@@ -18,7 +18,7 @@ tooling against it.
|
|||||||
| `web_display_autostart` | bool, `true` | Whether the web interface service starts with the system | `scripts/utils/start_web_conditionally.py` |
|
| `web_display_autostart` | bool, `true` | Whether the web interface service starts with the system | `scripts/utils/start_web_conditionally.py` |
|
||||||
| `timezone` | string, `"America/New_York"` | IANA timezone for schedules and displays | `ConfigManager.get_timezone()` |
|
| `timezone` | string, `"America/New_York"` | IANA timezone for schedules and displays | `ConfigManager.get_timezone()` |
|
||||||
| `target_fps` | int, `100` | Frame-rate ceiling for plugin rendering | `src/plugin_system/base_plugin.py`, `src/common/sports_scroll.py` |
|
| `target_fps` | int, `100` | Frame-rate ceiling for plugin rendering | `src/plugin_system/base_plugin.py`, `src/common/sports_scroll.py` |
|
||||||
| `location` | object | `city` / `state` / `country`. Supplies the **default** for a plugin's own `location_city` / `location_state` / `location_country` setting, so weather, radar and friends follow this device without being configured twice. A value saved on the plugin itself still overrides it. | `SchemaManager.apply_device_location()`, then plugins via merged config |
|
| `location` | object | `city` / `state` / `country`, offered to plugins that need a location (weather, etc.) | plugins via merged config |
|
||||||
|
|
||||||
## `schedule` — display on/off hours
|
## `schedule` — display on/off hours
|
||||||
|
|
||||||
|
|||||||
@@ -145,7 +145,8 @@ class SportsUpcoming(SportsCore):
|
|||||||
if (game['home_abbr'] in self.favorite_teams or
|
if (game['home_abbr'] in self.favorite_teams or
|
||||||
game['away_abbr'] in self.favorite_teams):
|
game['away_abbr'] in self.favorite_teams):
|
||||||
favorite_games_found += 1
|
favorite_games_found += 1
|
||||||
# Odds are NOT fetched here -- see after selection below.
|
if self.show_odds:
|
||||||
|
self._fetch_odds(game)
|
||||||
|
|
||||||
# Enhanced logging for debugging
|
# Enhanced logging for debugging
|
||||||
self.logger.info(f"Found {all_upcoming_games} total upcoming games in data")
|
self.logger.info(f"Found {all_upcoming_games} total upcoming games in data")
|
||||||
@@ -189,20 +190,6 @@ class SportsUpcoming(SportsCore):
|
|||||||
# Limit to the specified number of upcoming games
|
# Limit to the specified number of upcoming games
|
||||||
team_games = team_games[:self.upcoming_games_to_show]
|
team_games = team_games[:self.upcoming_games_to_show]
|
||||||
|
|
||||||
# Odds are fetched here, for the games that survived selection,
|
|
||||||
# rather than in the loop that collects them. That loop walks every
|
|
||||||
# upcoming game in the schedule window, and for a college league
|
|
||||||
# the window is enormous -- a live rig logged 946 upcoming games in
|
|
||||||
# one cycle and displayed 1 of them. The comment up there claimed
|
|
||||||
# odds were fetched "only for games that will be displayed", but
|
|
||||||
# the only narrowing it applied was show_favorite_teams_only, which
|
|
||||||
# is not the default; in the usual case nothing narrowed it at all
|
|
||||||
# and every game cost a separate ESPN request on a Pi that is also
|
|
||||||
# driving the panel.
|
|
||||||
if self.show_odds:
|
|
||||||
for game in team_games:
|
|
||||||
self._fetch_odds(game)
|
|
||||||
|
|
||||||
# Log changes or periodically
|
# Log changes or periodically
|
||||||
should_log = (
|
should_log = (
|
||||||
current_time - self.last_log_time >= self.log_interval or
|
current_time - self.last_log_time >= self.log_interval or
|
||||||
|
|||||||
@@ -328,6 +328,10 @@ class ScrollHelper:
|
|||||||
elapsed_time = current_time - (self.scroll_start_time or current_time)
|
elapsed_time = current_time - (self.scroll_start_time or current_time)
|
||||||
# The image already includes display_width padding, so we only need total_scroll_width
|
# The image already includes display_width padding, so we only need total_scroll_width
|
||||||
required_total_distance = self.total_scroll_width
|
required_total_distance = self.total_scroll_width
|
||||||
|
# Progress telemetry, emitted every few seconds for the whole of
|
||||||
|
# every scroll. It says how far along a marquee is, which is what
|
||||||
|
# you turn debug on to watch and not something an operator needs
|
||||||
|
# in the journal on a device that scrolls all day.
|
||||||
self.logger.debug(
|
self.logger.debug(
|
||||||
"Scroll progress: elapsed=%.2fs, target=%.2fs, total_scrolled=%.0f/%d px (%.1f%%)",
|
"Scroll progress: elapsed=%.2fs, target=%.2fs, total_scrolled=%.0f/%d px (%.1f%%)",
|
||||||
elapsed_time,
|
elapsed_time,
|
||||||
|
|||||||
@@ -71,15 +71,11 @@ class PluginManager:
|
|||||||
self.plugin_loader = PluginLoader(logger=self.logger)
|
self.plugin_loader = PluginLoader(logger=self.logger)
|
||||||
self.plugin_executor = PluginExecutor(default_timeout=30.0, logger=self.logger)
|
self.plugin_executor = PluginExecutor(default_timeout=30.0, logger=self.logger)
|
||||||
self.state_manager = PluginStateManager(logger=self.logger)
|
self.state_manager = PluginStateManager(logger=self.logger)
|
||||||
self.schema_manager = SchemaManager(plugins_dir=self.plugins_dir, logger=self.logger,
|
self.schema_manager = SchemaManager(plugins_dir=self.plugins_dir, logger=self.logger)
|
||||||
config_manager=self.config_manager)
|
|
||||||
|
|
||||||
# Lock protecting plugin_manifests and plugin_directories from
|
# Lock protecting plugin_manifests and plugin_directories from
|
||||||
# concurrent mutation (background reconciliation) and reads (requests).
|
# concurrent mutation (background reconciliation) and reads (requests).
|
||||||
self._discovery_lock = threading.RLock()
|
self._discovery_lock = threading.RLock()
|
||||||
#: Directories already reported as unloadable, so the warning is
|
|
||||||
#: emitted once rather than on every discovery scan.
|
|
||||||
self._skip_reported: set = set()
|
|
||||||
|
|
||||||
# Lock protecting plugin_last_update from concurrent mutation/iteration.
|
# Lock protecting plugin_last_update from concurrent mutation/iteration.
|
||||||
# It's written from run_scheduled_updates()/update_all_plugins() (main
|
# It's written from run_scheduled_updates()/update_all_plugins() (main
|
||||||
@@ -199,59 +195,18 @@ class PluginManager:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
manifest_path = item / "manifest.json"
|
manifest_path = item / "manifest.json"
|
||||||
if not manifest_path.exists():
|
if manifest_path.exists():
|
||||||
# Once per directory per process. Discovery runs on every
|
|
||||||
# web UI page load and every config reconcile, so warning
|
|
||||||
# unconditionally would put a line in the journal each
|
|
||||||
# time someone opened a page -- the same log-volume
|
|
||||||
# problem this is meant to help diagnose.
|
|
||||||
# A directory here that carries no manifest is not a
|
|
||||||
# plugin. Said once, because the alternative is a plugin
|
|
||||||
# that is enabled in config, enabled in plugin state,
|
|
||||||
# present on disk, and simply absent from the running
|
|
||||||
# process with nothing anywhere to say why. Working that
|
|
||||||
# out afterwards means reading cache-file mtimes.
|
|
||||||
if item.name not in self._skip_reported:
|
|
||||||
self._skip_reported.add(item.name)
|
|
||||||
self.logger.warning(
|
|
||||||
"Skipping %s: no manifest.json, so it cannot be "
|
|
||||||
"loaded as a plugin", item.name)
|
|
||||||
continue
|
|
||||||
try:
|
try:
|
||||||
with open(manifest_path, 'r', encoding='utf-8') as f:
|
with open(manifest_path, 'r', encoding='utf-8') as f:
|
||||||
manifest = json.load(f)
|
manifest = json.load(f)
|
||||||
except (json.JSONDecodeError, PermissionError, OSError) as e:
|
|
||||||
self.logger.warning("Error reading manifest from %s: %s", manifest_path, e, exc_info=True)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# json.load accepts any JSON value, so a manifest holding
|
|
||||||
# null, [] or "text" parses and then raises AttributeError on
|
|
||||||
# .get(). Nothing here catches that -- the outer handler takes
|
|
||||||
# OSError/PermissionError only -- so a single malformed
|
|
||||||
# manifest aborted the whole scan and every other plugin on
|
|
||||||
# disk, however healthy, silently failed to register.
|
|
||||||
if not isinstance(manifest, dict):
|
|
||||||
if item.name not in self._skip_reported:
|
|
||||||
self._skip_reported.add(item.name)
|
|
||||||
self.logger.warning(
|
|
||||||
"Skipping %s: its manifest.json is %s, not a JSON "
|
|
||||||
"object", item.name, type(manifest).__name__)
|
|
||||||
continue
|
|
||||||
|
|
||||||
plugin_id = manifest.get('id')
|
plugin_id = manifest.get('id')
|
||||||
if not plugin_id:
|
if plugin_id:
|
||||||
# Parsed but unusable. This was the quietest path of all:
|
|
||||||
# the manifest is read successfully and then dropped.
|
|
||||||
if item.name not in self._skip_reported:
|
|
||||||
self._skip_reported.add(item.name)
|
|
||||||
self.logger.warning(
|
|
||||||
"Skipping %s: its manifest.json has no \"id\", so "
|
|
||||||
"there is nothing to register it under", item.name)
|
|
||||||
continue
|
|
||||||
|
|
||||||
plugin_ids.append(plugin_id)
|
plugin_ids.append(plugin_id)
|
||||||
new_manifests[plugin_id] = manifest
|
new_manifests[plugin_id] = manifest
|
||||||
new_directories[plugin_id] = item
|
new_directories[plugin_id] = item
|
||||||
|
except (json.JSONDecodeError, PermissionError, OSError) as e:
|
||||||
|
self.logger.warning("Error reading manifest from %s: %s", manifest_path, e, exc_info=True)
|
||||||
|
continue
|
||||||
except (OSError, PermissionError) as e:
|
except (OSError, PermissionError) as e:
|
||||||
self.logger.error("Error scanning directory %s: %s", directory, e, exc_info=True)
|
self.logger.error("Error scanning directory %s: %s", directory, e, exc_info=True)
|
||||||
|
|
||||||
|
|||||||
@@ -26,25 +26,7 @@ class SchemaManager:
|
|||||||
- Cache invalidation on plugin changes
|
- Cache invalidation on plugin changes
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Plugin config keys that mean "where this device is". A plugin declaring
|
def __init__(self, plugins_dir: Optional[Path] = None, project_root: Optional[Path] = None, logger: Optional[logging.Logger] = None):
|
||||||
# any of these in its schema gets the device-wide ``location`` block from
|
|
||||||
# config.json as the *default* for that field, instead of whatever city the
|
|
||||||
# plugin author happened to ship. A value the user set on the plugin itself
|
|
||||||
# always wins -- this only ever replaces the schema default, so an explicit
|
|
||||||
# per-plugin location is still honoured.
|
|
||||||
#
|
|
||||||
# Only these fully-namespaced keys are substituted. A bare ``state`` or
|
|
||||||
# ``city`` key is deliberately left alone: plugins use those for unrelated
|
|
||||||
# things (ledmatrix-elections' ``state`` is a two-letter code, not a place
|
|
||||||
# name), and silently rewriting them would break those plugins.
|
|
||||||
DEVICE_LOCATION_KEYS: Dict[str, str] = {
|
|
||||||
'location_city': 'city',
|
|
||||||
'location_state': 'state',
|
|
||||||
'location_country': 'country',
|
|
||||||
}
|
|
||||||
|
|
||||||
def __init__(self, plugins_dir: Optional[Path] = None, project_root: Optional[Path] = None,
|
|
||||||
logger: Optional[logging.Logger] = None, config_manager: Optional[Any] = None):
|
|
||||||
"""
|
"""
|
||||||
Initialize the Schema Manager.
|
Initialize the Schema Manager.
|
||||||
|
|
||||||
@@ -52,14 +34,10 @@ class SchemaManager:
|
|||||||
plugins_dir: Base plugins directory path
|
plugins_dir: Base plugins directory path
|
||||||
project_root: Project root directory path
|
project_root: Project root directory path
|
||||||
logger: Optional logger instance
|
logger: Optional logger instance
|
||||||
config_manager: Optional config manager, used to resolve the
|
|
||||||
device-wide ``location`` that seeds plugin location defaults.
|
|
||||||
Omitting it simply leaves schema defaults untouched.
|
|
||||||
"""
|
"""
|
||||||
self.logger = logger or logging.getLogger(__name__)
|
self.logger = logger or logging.getLogger(__name__)
|
||||||
self.plugins_dir = plugins_dir
|
self.plugins_dir = plugins_dir
|
||||||
self.project_root = project_root or Path.cwd()
|
self.project_root = project_root or Path.cwd()
|
||||||
self.config_manager = config_manager
|
|
||||||
|
|
||||||
# Schema cache: plugin_id -> schema dict
|
# Schema cache: plugin_id -> schema dict
|
||||||
self._schema_cache: Dict[str, Dict[str, Any]] = {}
|
self._schema_cache: Dict[str, Dict[str, Any]] = {}
|
||||||
@@ -234,70 +212,10 @@ class SchemaManager:
|
|||||||
|
|
||||||
return defaults
|
return defaults
|
||||||
|
|
||||||
def get_device_location(self) -> Optional[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Return the device-wide ``location`` block from config.json, or None.
|
|
||||||
|
|
||||||
This is the City/State/Country the user sets once under General
|
|
||||||
settings. Returns None when there is no config manager wired, the
|
|
||||||
config can't be read, or no location has been configured.
|
|
||||||
"""
|
|
||||||
if self.config_manager is None:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
config = self.config_manager.load_config()
|
|
||||||
except Exception as e:
|
|
||||||
# A config that can't be read must never stop defaults being
|
|
||||||
# generated -- the plugin's own schema defaults still apply.
|
|
||||||
self.logger.debug(f"Could not read device location from config: {e}")
|
|
||||||
return None
|
|
||||||
if not isinstance(config, dict):
|
|
||||||
return None
|
|
||||||
location = config.get('location')
|
|
||||||
return location if isinstance(location, dict) else None
|
|
||||||
|
|
||||||
def apply_device_location(self, defaults: Dict[str, Any]) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Replace location-shaped schema defaults with the device's own location.
|
|
||||||
|
|
||||||
Without this, a plugin that ships ``"location_city": "Dallas"`` as its
|
|
||||||
schema default silently reports Dallas weather (and centres its radar
|
|
||||||
there) for every user who never opened that plugin's config form --
|
|
||||||
even though they set their real city under General settings. The
|
|
||||||
substituted value is still only a *default*: ``merge_with_defaults``
|
|
||||||
lets any per-plugin value the user saved win over it.
|
|
||||||
|
|
||||||
Mutates and returns ``defaults`` for convenience.
|
|
||||||
"""
|
|
||||||
if not defaults:
|
|
||||||
return defaults
|
|
||||||
if not any(key in defaults for key in self.DEVICE_LOCATION_KEYS):
|
|
||||||
return defaults
|
|
||||||
|
|
||||||
location = self.get_device_location()
|
|
||||||
if not location:
|
|
||||||
return defaults
|
|
||||||
|
|
||||||
for key, field in self.DEVICE_LOCATION_KEYS.items():
|
|
||||||
if key not in defaults:
|
|
||||||
continue
|
|
||||||
value = location.get(field)
|
|
||||||
# Only a non-empty string is a real answer; a blank or missing
|
|
||||||
# field means "not configured", which leaves the schema default.
|
|
||||||
if isinstance(value, str) and value.strip():
|
|
||||||
defaults[key] = value.strip()
|
|
||||||
|
|
||||||
return defaults
|
|
||||||
|
|
||||||
def generate_default_config(self, plugin_id: str, use_cache: bool = True) -> Dict[str, Any]:
|
def generate_default_config(self, plugin_id: str, use_cache: bool = True) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Generate default configuration for a plugin from its schema.
|
Generate default configuration for a plugin from its schema.
|
||||||
|
|
||||||
Location fields (see ``DEVICE_LOCATION_KEYS``) default to the device's
|
|
||||||
configured location rather than the plugin author's. That substitution
|
|
||||||
is applied on the way out rather than being cached, so changing the
|
|
||||||
device location takes effect without invalidating the defaults cache.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
plugin_id: Plugin identifier
|
plugin_id: Plugin identifier
|
||||||
use_cache: If True, return cached defaults if available
|
use_cache: If True, return cached defaults if available
|
||||||
@@ -307,7 +225,7 @@ class SchemaManager:
|
|||||||
"""
|
"""
|
||||||
# Check cache first
|
# Check cache first
|
||||||
if use_cache and plugin_id in self._defaults_cache:
|
if use_cache and plugin_id in self._defaults_cache:
|
||||||
return self.apply_device_location(self._defaults_cache[plugin_id].copy())
|
return self._defaults_cache[plugin_id].copy()
|
||||||
|
|
||||||
schema = self.load_schema(plugin_id, use_cache=use_cache)
|
schema = self.load_schema(plugin_id, use_cache=use_cache)
|
||||||
if not schema:
|
if not schema:
|
||||||
@@ -331,11 +249,10 @@ class SchemaManager:
|
|||||||
if 'live_priority' not in defaults:
|
if 'live_priority' not in defaults:
|
||||||
defaults['live_priority'] = schema.get('properties', {}).get('live_priority', {}).get('default', False)
|
defaults['live_priority'] = schema.get('properties', {}).get('live_priority', {}).get('default', False)
|
||||||
|
|
||||||
# Cache the defaults *before* the device location is layered on, so a
|
# Cache the defaults
|
||||||
# later change to the device location is picked up by the next call.
|
|
||||||
self._defaults_cache[plugin_id] = defaults.copy()
|
self._defaults_cache[plugin_id] = defaults.copy()
|
||||||
|
|
||||||
return self.apply_device_location(defaults)
|
return defaults
|
||||||
|
|
||||||
def validate_config_against_schema(self, config: Dict[str, Any], schema: Dict[str, Any],
|
def validate_config_against_schema(self, config: Dict[str, Any], schema: Dict[str, Any],
|
||||||
plugin_id: Optional[str] = None) -> Tuple[bool, List[str]]:
|
plugin_id: Optional[str] = None) -> Tuple[bool, List[str]]:
|
||||||
|
|||||||
@@ -31,6 +31,18 @@ if TYPE_CHECKING:
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
#: Degradation threshold, as a fraction of target_fps. A marquee jitters a
|
||||||
|
#: little all the time, so "anything under target" would report constantly and
|
||||||
|
#: mean nothing; 90% of target is the point where a shortfall is real. At a
|
||||||
|
#: 60fps target that is 54fps -- 55fps is a normal wobble and stays at DEBUG,
|
||||||
|
#: which is deliberate, not an off-by-one.
|
||||||
|
_FPS_HEALTHY_FRACTION = 0.9
|
||||||
|
|
||||||
|
#: A healthy marquee still reports this often, so silence means stopped
|
||||||
|
#: rather than fine.
|
||||||
|
_FPS_HEARTBEAT_INTERVAL = 300.0
|
||||||
|
|
||||||
|
|
||||||
def _percentile(ordered: List[float], fraction: float) -> float:
|
def _percentile(ordered: List[float], fraction: float) -> float:
|
||||||
"""Nearest-rank percentile of an already-sorted list.
|
"""Nearest-rank percentile of an already-sorted list.
|
||||||
|
|
||||||
@@ -96,6 +108,11 @@ class VegasModeCoordinator:
|
|||||||
self._is_active = False
|
self._is_active = False
|
||||||
self._is_paused = False
|
self._is_paused = False
|
||||||
self._should_stop = False
|
self._should_stop = False
|
||||||
|
# Frame-rate health, tracked across run_iteration() calls so the
|
||||||
|
# heartbeat is one-per-interval rather than one-per-cycle, and so a
|
||||||
|
# recovery spanning two cycles is still reported. Reset on start().
|
||||||
|
self._fps_last_health_log = 0.0
|
||||||
|
self._fps_was_degraded = False
|
||||||
self._state_lock = threading.Lock()
|
self._state_lock = threading.Lock()
|
||||||
|
|
||||||
# Live priority tracking
|
# Live priority tracking
|
||||||
@@ -248,6 +265,11 @@ class VegasModeCoordinator:
|
|||||||
self._is_active = True
|
self._is_active = True
|
||||||
self._should_stop = False
|
self._should_stop = False
|
||||||
self._start_time = time.time()
|
self._start_time = time.time()
|
||||||
|
# A fresh run starts with a clean health slate: no stale
|
||||||
|
# "was degraded" from the previous run, and a heartbeat that is
|
||||||
|
# due immediately so the first sample confirms the marquee is up.
|
||||||
|
self._fps_last_health_log = 0.0
|
||||||
|
self._fps_was_degraded = False
|
||||||
|
|
||||||
# Line up the next group immediately, so the first extension is already
|
# Line up the next group immediately, so the first extension is already
|
||||||
# warm rather than stalling the scroll to fetch it.
|
# warm rather than stalling the scroll to fetch it.
|
||||||
@@ -395,8 +417,18 @@ class VegasModeCoordinator:
|
|||||||
duration = self.render_pipeline.get_dynamic_duration()
|
duration = self.render_pipeline.get_dynamic_duration()
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
frame_count = 0
|
frame_count = 0
|
||||||
fps_log_interval = 5.0 # Log FPS every 5 seconds
|
fps_log_interval = 5.0 # Sample FPS every 5 seconds
|
||||||
last_fps_log_time = start_time
|
# Health state lives on the coordinator, not here: run_iteration() is
|
||||||
|
# called once per cycle, so locals reset every few seconds. That made
|
||||||
|
# `last_fps_health_log = 0.0` fire the "heartbeat" on the first sample
|
||||||
|
# of every iteration rather than once per interval, and a recovery
|
||||||
|
# that crossed an iteration boundary was never reported at all --
|
||||||
|
# was_degraded had already gone back to False.
|
||||||
|
# Monotonic, and deliberately not start_time: start_time is wall
|
||||||
|
# clock and is used below to report the iteration's duration. Mixing
|
||||||
|
# the two here would make every delta hugely negative and silence the
|
||||||
|
# frame-rate reporting altogether.
|
||||||
|
last_fps_log_time = time.monotonic()
|
||||||
fps_frame_count = 0
|
fps_frame_count = 0
|
||||||
# A mean hides stutter completely. At 120fps a five-second window is
|
# A mean hides stutter completely. At 120fps a five-second window is
|
||||||
# ~600 frames, so a 200ms freeze -- plainly visible on a marquee --
|
# ~600 frames, so a 200ms freeze -- plainly visible on a marquee --
|
||||||
@@ -408,7 +440,13 @@ class VegasModeCoordinator:
|
|||||||
logger.info("Starting Vegas iteration for %.1fs", duration)
|
logger.info("Starting Vegas iteration for %.1fs", duration)
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
frame_started = time.time()
|
# Monotonic, like the FPS window below. These devices have no RTC,
|
||||||
|
# so the wall clock jumps by however wrong boot time was the moment
|
||||||
|
# NTP first syncs. A backward jump makes frame_elapsed negative,
|
||||||
|
# and `frame_interval - frame_elapsed` then sleeps for longer than
|
||||||
|
# the whole budget -- the render loop stalls for the size of the
|
||||||
|
# correction. A forward jump inflates p99 and worst-frame instead.
|
||||||
|
frame_started = time.monotonic()
|
||||||
|
|
||||||
# Check for STATIC mode plugin that should pause scroll
|
# Check for STATIC mode plugin that should pause scroll
|
||||||
static_plugin = self._check_static_plugin_trigger()
|
static_plugin = self._check_static_plugin_trigger()
|
||||||
@@ -436,7 +474,7 @@ class VegasModeCoordinator:
|
|||||||
# quarter of the budget spent not rendering. Subtracting the work
|
# quarter of the budget spent not rendering. Subtracting the work
|
||||||
# already done keeps the pacing target while reclaiming that time,
|
# already done keeps the pacing target while reclaiming that time,
|
||||||
# and yields the GIL either way so other threads still run.
|
# and yields the GIL either way so other threads still run.
|
||||||
frame_elapsed = time.time() - frame_started
|
frame_elapsed = time.monotonic() - frame_started
|
||||||
time.sleep(max(0.0, frame_interval - frame_elapsed))
|
time.sleep(max(0.0, frame_interval - frame_elapsed))
|
||||||
|
|
||||||
# Measured before the sleep: time spent working, not pacing.
|
# Measured before the sleep: time spent working, not pacing.
|
||||||
@@ -448,16 +486,42 @@ class VegasModeCoordinator:
|
|||||||
frame_count += 1
|
frame_count += 1
|
||||||
fps_frame_count += 1
|
fps_frame_count += 1
|
||||||
|
|
||||||
# Periodic FPS logging
|
# Periodic FPS logging. Reported at INFO only when the frame rate
|
||||||
current_time = time.time()
|
# is actually worth an operator's attention -- a shortfall against
|
||||||
|
# target, or the recovery from one -- with a slow heartbeat so a
|
||||||
|
# healthy marquee still shows a pulse.
|
||||||
|
#
|
||||||
|
# Measured over two hours on a running rig: 1410 samples, 98.5%
|
||||||
|
# of them within 10% of target. The 1.5% that were not included a
|
||||||
|
# reading of 8.6fps against a target of 60 -- a real stall, and
|
||||||
|
# completely invisible inside 1389 lines reading "59.6".
|
||||||
|
# Monotonic: every use of this value in the block below is a
|
||||||
|
# duration, and these devices have no RTC, so the wall clock jumps
|
||||||
|
# by however wrong boot time was the moment NTP first syncs. That
|
||||||
|
# would not only mis-fire the heartbeat, it would corrupt the
|
||||||
|
# frame rate itself, since fps is frames divided by this delta.
|
||||||
|
current_time = time.monotonic()
|
||||||
if current_time - last_fps_log_time >= fps_log_interval:
|
if current_time - last_fps_log_time >= fps_log_interval:
|
||||||
fps = fps_frame_count / (current_time - last_fps_log_time)
|
fps = fps_frame_count / (current_time - last_fps_log_time)
|
||||||
p99 = _percentile(sorted(frame_times), 0.99)
|
p99 = _percentile(sorted(frame_times), 0.99)
|
||||||
|
target = self.vegas_config.target_fps
|
||||||
|
degraded = target > 0 and fps < target * _FPS_HEALTHY_FRACTION
|
||||||
|
due = (current_time - self._fps_last_health_log
|
||||||
|
>= _FPS_HEARTBEAT_INTERVAL)
|
||||||
|
if degraded or self._fps_was_degraded or due:
|
||||||
logger.info(
|
logger.info(
|
||||||
"Vegas FPS: %.1f (target: %d, frames: %d) p99 %.1fms worst %.1fms",
|
"Vegas FPS: %.1f (target: %d, frames: %d) p99 %.1fms worst %.1fms",
|
||||||
fps, self.vegas_config.target_fps, fps_frame_count,
|
fps, target, fps_frame_count,
|
||||||
p99 * 1000.0, frame_worst * 1000.0
|
p99 * 1000.0, frame_worst * 1000.0
|
||||||
)
|
)
|
||||||
|
self._fps_last_health_log = current_time
|
||||||
|
else:
|
||||||
|
logger.debug(
|
||||||
|
"Vegas FPS: %.1f (target: %d, frames: %d) p99 %.1fms worst %.1fms",
|
||||||
|
fps, target, fps_frame_count,
|
||||||
|
p99 * 1000.0, frame_worst * 1000.0
|
||||||
|
)
|
||||||
|
self._fps_was_degraded = degraded
|
||||||
last_fps_log_time = current_time
|
last_fps_log_time = current_time
|
||||||
fps_frame_count = 0
|
fps_frame_count = 0
|
||||||
frame_worst = 0.0
|
frame_worst = 0.0
|
||||||
|
|||||||
@@ -1,301 +0,0 @@
|
|||||||
"""The composer generates Python that the plugin loader imports and executes.
|
|
||||||
|
|
||||||
/api/install writes the generated manager.py into plugins_dir and the loader
|
|
||||||
imports it, so anything the payload can splice into that source runs on the
|
|
||||||
device. The ast.parse check in _generate_plugin_files rejects only *invalid*
|
|
||||||
syntax -- an injected `import os` is perfectly valid and passed it.
|
|
||||||
|
|
||||||
Two ways in, both confirmed against the code before it was fixed:
|
|
||||||
|
|
||||||
metadata.name = a name containing a triple-quote, a newline, then
|
|
||||||
`import os; PWNED = os.getuid()`, then another triple-quote
|
|
||||||
-> closes the module docstring; the rest became module-level statements
|
|
||||||
(spelled out rather than shown literally -- writing the payload into
|
|
||||||
this docstring closes *this* file's docstring, which is the bug)
|
|
||||||
|
|
||||||
element x = '0 or __import__("os").system("id")'
|
|
||||||
-> f-string interpolated it verbatim: x=0 or __import__("os").system("id")
|
|
||||||
"""
|
|
||||||
import ast
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
||||||
|
|
||||||
from web_interface.blueprints import composer as C # noqa: E402
|
|
||||||
|
|
||||||
BASE_META = {"id": "test-plugin", "name": "Clock", "author": "a",
|
|
||||||
"version": "1.0.0", "description": "d"}
|
|
||||||
|
|
||||||
#: Values that terminate a Python expression and start a new statement.
|
|
||||||
EXPR_PAYLOADS = [
|
|
||||||
'0 or __import__("os").system("id")',
|
|
||||||
'0);import os;os.system("id");(',
|
|
||||||
'__import__("subprocess").run(["id"])',
|
|
||||||
"0 if False else exec('x=1')",
|
|
||||||
"1e999", "nan", "0x41", "0__0",
|
|
||||||
]
|
|
||||||
|
|
||||||
#: Values that close a string literal in the generated source.
|
|
||||||
LITERAL_PAYLOADS = [
|
|
||||||
'Clock"""\nimport os; PWNED = os.getuid()\n"""',
|
|
||||||
"Clock'''\nimport os\n'''",
|
|
||||||
'Clock" + __import__("os").system("id") + "',
|
|
||||||
"Clock\\", "Clock\nimport os",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _payload(**over):
|
|
||||||
# dataModel.configVars is the key _generate_plugin_files reads; "config_vars"
|
|
||||||
# was never looked at, so anything passed through it tested nothing.
|
|
||||||
p = {"metadata": dict(BASE_META), "elements": [],
|
|
||||||
"dataModel": {"configVars": over.pop("config_vars", [])}}
|
|
||||||
p["metadata"].update(over.pop("metadata", {}))
|
|
||||||
p.update(over)
|
|
||||||
return p
|
|
||||||
|
|
||||||
|
|
||||||
def _generated(payload):
|
|
||||||
return C._generate_plugin_files(payload)["manager.py"]
|
|
||||||
|
|
||||||
|
|
||||||
def _module_level_code(src):
|
|
||||||
"""Statements at module level that are not the docstring/imports/classes."""
|
|
||||||
tree = ast.parse(src)
|
|
||||||
out = []
|
|
||||||
for node in tree.body:
|
|
||||||
if isinstance(node, (ast.ClassDef, ast.FunctionDef, ast.ImportFrom)):
|
|
||||||
continue
|
|
||||||
if isinstance(node, ast.Expr) and isinstance(node.value, ast.Constant):
|
|
||||||
continue # the docstring
|
|
||||||
out.append(ast.unparse(node))
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("payload", LITERAL_PAYLOADS)
|
|
||||||
def test_a_name_that_breaks_out_of_a_literal_is_refused(payload):
|
|
||||||
with pytest.raises(C.ComposerInputError):
|
|
||||||
_generated(_payload(metadata={"name": payload}))
|
|
||||||
|
|
||||||
|
|
||||||
#: Types with a drawing branch in manager.py.j2. An injection test using any
|
|
||||||
#: other type proves nothing: _preprocess_elements drops it, so its values
|
|
||||||
#: never reach the generated source and every assertion passes trivially.
|
|
||||||
#: This test previously used "line", which has never had a branch.
|
|
||||||
RENDERED_GEOMETRY_CASES = [
|
|
||||||
("rectangle", {"x": 0, "y": 0, "width": 10, "height": 8}),
|
|
||||||
("arc", {"x": 0, "y": 0, "width": 24, "height": 24}),
|
|
||||||
("ellipse", {"x": 0, "y": 0, "width": 24, "height": 12}),
|
|
||||||
("rounded_rectangle", {"x": 0, "y": 0, "width": 24, "height": 10}),
|
|
||||||
("gauge", {"x": 0, "y": 0, "width": 32, "height": 32}),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("etype,base", RENDERED_GEOMETRY_CASES)
|
|
||||||
@pytest.mark.parametrize("evil", EXPR_PAYLOADS)
|
|
||||||
@pytest.mark.parametrize("field", ["x", "y", "width", "height"])
|
|
||||||
def test_a_non_numeric_geometry_value_cannot_reach_the_source(etype, base, evil, field):
|
|
||||||
"""width/height were interpolated raw into the generated source.
|
|
||||||
|
|
||||||
p['x2_expr'] = f"({x_expr}) + {w}" with w straight off the payload, so a
|
|
||||||
rectangle with width='0 or __import__("os").system("id")' produced
|
|
||||||
|
|
||||||
[0, 0, (0) + 0 or __import__("os").system("id"), (0) + 8],
|
|
||||||
|
|
||||||
in a manager.py that /api/install writes to disk and the loader imports.
|
|
||||||
"""
|
|
||||||
el = {"type": etype, "id": "e1", **base}
|
|
||||||
el[field] = evil
|
|
||||||
src = _generated(_payload(elements=[el]))
|
|
||||||
assert "__import__" not in src, f"{etype}.{field}={evil!r} reached the generated source"
|
|
||||||
assert "os.system" not in src
|
|
||||||
assert not _module_level_code(src), \
|
|
||||||
f"{etype}.{field}={evil!r} produced module-level statements: {_module_level_code(src)}"
|
|
||||||
|
|
||||||
|
|
||||||
def test_every_injection_case_uses_a_type_that_actually_renders():
|
|
||||||
"""Guards against the whole suite quietly going vacuous again.
|
|
||||||
|
|
||||||
An element type with no template branch is dropped before generation, so
|
|
||||||
an injection test written against one asserts nothing and still passes.
|
|
||||||
"""
|
|
||||||
used = {etype for etype, _ in RENDERED_GEOMETRY_CASES}
|
|
||||||
missing = used - set(C._RENDERABLE_ELEMENT_TYPES)
|
|
||||||
assert not missing, f"injection tests use non-rendering types: {sorted(missing)}"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("evil", EXPR_PAYLOADS)
|
|
||||||
@pytest.mark.parametrize("channel", ["r", "g", "b"])
|
|
||||||
def test_a_non_numeric_colour_channel_cannot_reach_the_source(evil, channel):
|
|
||||||
el = {"type": "text", "id": "t1", "x": 0, "y": 0, "text": "hi",
|
|
||||||
"font": "press_start", "r": 255, "g": 255, "b": 255}
|
|
||||||
el[channel] = evil
|
|
||||||
src = _generated(_payload(elements=[el]))
|
|
||||||
assert "__import__" not in src and "os.system" not in src
|
|
||||||
assert not _module_level_code(src)
|
|
||||||
|
|
||||||
|
|
||||||
def test_colour_channels_are_clamped_to_a_byte():
|
|
||||||
el = {"type": "text", "id": "t1", "x": 0, "y": 0, "text": "hi",
|
|
||||||
"font": "press_start", "r": 99999, "g": -5, "b": 128}
|
|
||||||
src = _generated(_payload(elements=[el]))
|
|
||||||
assert "(255, 0, 128)" in src, "channels were not clamped to 0-255"
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_generated_module_still_has_no_top_level_statements():
|
|
||||||
"""The clean case: a normal payload produces only imports and a class."""
|
|
||||||
el = {"type": "text", "id": "t1", "x": 4, "y": 4, "text": "hi",
|
|
||||||
"font": "press_start", "r": 1, "g": 2, "b": 3}
|
|
||||||
src = _generated(_payload(elements=[el]))
|
|
||||||
assert not _module_level_code(src)
|
|
||||||
assert "(1, 2, 3)" in src
|
|
||||||
|
|
||||||
|
|
||||||
# --- config variable keys ---------------------------------------------------
|
|
||||||
|
|
||||||
def _with_key(key):
|
|
||||||
return {"metadata": dict(BASE_META), "elements": [],
|
|
||||||
"dataModel": {"configVars": [{"key": key, "type": "string",
|
|
||||||
"default": "x", "label": "L"}]}}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("key", ["class", "def", "import", "None", "True",
|
|
||||||
"lambda", "pass", "match", "case"])
|
|
||||||
def test_a_keyword_config_key_is_named_in_the_error(key):
|
|
||||||
"""ast.parse already rejected these, but as an unhelpful line number.
|
|
||||||
|
|
||||||
"Generated code has a syntax error: invalid syntax (line 17)" tells the
|
|
||||||
user nothing about which field to fix.
|
|
||||||
"""
|
|
||||||
with pytest.raises(C.ComposerInputError) as exc:
|
|
||||||
_generated(_with_key(key))
|
|
||||||
assert key in str(exc.value) and "keyword" in str(exc.value).lower()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("key", ["config", "logger", "display_manager",
|
|
||||||
"cache_manager", "plugin_id", "enabled",
|
|
||||||
"self", "update", "display"])
|
|
||||||
def test_a_reserved_attribute_config_key_is_refused(key):
|
|
||||||
"""These generate *valid* Python that silently clobbers plugin state.
|
|
||||||
|
|
||||||
The worst is `config`: the assignment lands right after super().__init__(),
|
|
||||||
so `self.config = config.get("config", "x")` replaces the plugin's config
|
|
||||||
dict with a string and every later self.config.get(...) fails at runtime.
|
|
||||||
"""
|
|
||||||
with pytest.raises(C.ComposerInputError) as exc:
|
|
||||||
_generated(_with_key(key))
|
|
||||||
assert key in str(exc.value) and "reserved" in str(exc.value).lower()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("key", ["brightness", "my_var", "_private", "x1",
|
|
||||||
"update_interval_seconds"])
|
|
||||||
def test_ordinary_config_keys_are_still_accepted(key):
|
|
||||||
src = _generated(_with_key(key))
|
|
||||||
assert f"self.{key} = config.get(" in src
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_generated_config_assignment_does_not_precede_super_init():
|
|
||||||
"""Guards the reasoning behind the reserved list, not just the list."""
|
|
||||||
src = _generated(_with_key("brightness"))
|
|
||||||
body = src.splitlines()
|
|
||||||
super_at = next(i for i, line in enumerate(body) if "super().__init__(" in line)
|
|
||||||
assign_at = next(i for i, line in enumerate(body)
|
|
||||||
if "self.brightness = config.get(" in line)
|
|
||||||
assert assign_at > super_at, (
|
|
||||||
"config vars are assigned before super().__init__(); the reserved-name "
|
|
||||||
"list assumes they land after it")
|
|
||||||
|
|
||||||
|
|
||||||
# --- optional keys ----------------------------------------------------------
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("el_type,missing", [
|
|
||||||
("text", "text"), ("text", "text2"), ("clock", "format"),
|
|
||||||
])
|
|
||||||
def test_an_element_missing_an_optional_key_does_not_500(el_type, missing):
|
|
||||||
"""`p` is a copy of the raw element, so an absent key stays absent.
|
|
||||||
|
|
||||||
The defaults were applied to locals only, so manager.py.j2 rendered
|
|
||||||
`{{ el.text | tojson }}` over a jinja2.Undefined and tojson raised
|
|
||||||
TypeError -- which no handler catches, making a missing key a 500 rather
|
|
||||||
than a validation error or a sensible default.
|
|
||||||
"""
|
|
||||||
el = {"type": el_type, "id": "e1", "x": 0, "y": 0, "font": "press_start"}
|
|
||||||
src = _generated(_payload(elements=[el]))
|
|
||||||
ast.parse(src) # must still be valid Python
|
|
||||||
assert "Undefined" not in src
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_clock_without_a_format_uses_the_documented_default():
|
|
||||||
el = {"type": "clock", "id": "c1", "x": 0, "y": 0, "font": "press_start"}
|
|
||||||
src = _generated(_payload(elements=[el]))
|
|
||||||
assert '"%H:%M"' in src, "the %H:%M default did not reach the generated source"
|
|
||||||
|
|
||||||
|
|
||||||
#: (element type, channel key, base element) for colour channels that were
|
|
||||||
#: interpolated raw rather than through _rgb_expr/_safe_int. Prefixed channels
|
|
||||||
#: (emptyR/G/B, labelR/G/B) were the ones the original r/g/b test never reached.
|
|
||||||
RAW_COLOUR_CASES = [
|
|
||||||
("progress_bar", "r", {"x": 0, "y": 0}),
|
|
||||||
("progress_bar", "g", {"x": 0, "y": 0}),
|
|
||||||
("pips", "b", {"x": 0, "y": 0}),
|
|
||||||
("pips", "emptyR", {"x": 0, "y": 0}),
|
|
||||||
("pips", "emptyG", {"x": 0, "y": 0}),
|
|
||||||
("sparkline", "r", {"x": 0, "y": 0}),
|
|
||||||
("gauge", "labelR", {"x": 0, "y": 0, "width": 32, "height": 32}),
|
|
||||||
("gauge", "labelB", {"x": 0, "y": 0, "width": 32, "height": 32}),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("etype,channel,base", RAW_COLOUR_CASES)
|
|
||||||
@pytest.mark.parametrize("evil", EXPR_PAYLOADS)
|
|
||||||
def test_a_prefixed_colour_channel_cannot_reach_the_source(etype, channel, base, evil):
|
|
||||||
"""Five tuples were built with f"({el.get('r', 100)}, ...)" -- no coercion.
|
|
||||||
|
|
||||||
The pre-existing colour test only covered r/g/b on a text element, so the
|
|
||||||
prefixed channels and the four other types were never exercised.
|
|
||||||
"""
|
|
||||||
el = {"type": etype, "id": "e1", **base}
|
|
||||||
el[channel] = evil
|
|
||||||
src = _generated(_payload(elements=[el]))
|
|
||||||
assert "__import__" not in src, f"{etype}.{channel}={evil!r} reached the source"
|
|
||||||
assert "os.system" not in src
|
|
||||||
assert not _module_level_code(src)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("value", [float("inf"), float("-inf"), float("nan")])
|
|
||||||
@pytest.mark.parametrize("field", ["x", "y", "width", "height"])
|
|
||||||
def test_a_non_finite_dimension_does_not_escape_as_an_unhandled_error(field, value):
|
|
||||||
"""json.loads accepts Infinity/NaN and Flask passes them through, so a
|
|
||||||
payload can hand _safe_int a non-finite float. int(inf) raises
|
|
||||||
OverflowError -- neither ValueError nor ComposerInputError -- so it escaped
|
|
||||||
both handlers and surfaced as a 500 with a traceback instead of a 422."""
|
|
||||||
el = {"type": "rectangle", "id": "r1", "x": 0, "y": 0, "width": 10, "height": 8}
|
|
||||||
el[field] = value
|
|
||||||
src = _generated(_payload(elements=[el])) # must not raise
|
|
||||||
# A non-finite value must be replaced by the default, not spelled into the
|
|
||||||
# source. Word-boundary match: "info" in self.logger.info contains "inf".
|
|
||||||
assert not re.search(r"\b(inf|nan|Infinity|NaN)\b", src), \
|
|
||||||
f"{field}={value!r} leaked a non-finite literal into the source"
|
|
||||||
assert not _module_level_code(src)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("bad_id", [
|
|
||||||
'x = __import__("os").system("id") #',
|
|
||||||
"x\nimport os\n_y",
|
|
||||||
"x[0]",
|
|
||||||
"",
|
|
||||||
"a" * 200,
|
|
||||||
])
|
|
||||||
def test_a_marquee_id_cannot_become_code(bad_id):
|
|
||||||
"""data_key is spliced UNQUOTED into variable names
|
|
||||||
(_{{ data_key }}_text = ...), so a non-identifier id landed in the source
|
|
||||||
as code. ast.parse caught it, but the caller then got an opaque
|
|
||||||
"Generated code has a syntax error" rather than being told the id is bad."""
|
|
||||||
el = {"type": "marquee", "id": bad_id, "x": 0, "y": 0, "text": "hi"}
|
|
||||||
src = _generated(_payload(elements=[el])) # must not raise
|
|
||||||
assert "__import__(" not in src
|
|
||||||
assert "os.system(" not in src
|
|
||||||
assert not _module_level_code(src)
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
"""An element the template cannot draw must not produce an empty `if` block.
|
|
||||||
|
|
||||||
manager.py.j2 wraps each element in `if width >= N:` (breakpoint) and/or
|
|
||||||
`if int(time.time() * 2) % 2:` (blink), and the body comes from the per-type
|
|
||||||
branches. A type with no branch contributed nothing, so the wrapper opened a
|
|
||||||
block with no statements in it. ast.parse in _generate_plugin_files then
|
|
||||||
failed and the caller was told only:
|
|
||||||
|
|
||||||
Generated code has a syntax error: expected an indented block after
|
|
||||||
'if' statement on line 49
|
|
||||||
|
|
||||||
which names a line of generated source the user never sees. Confirmed against
|
|
||||||
the code before the fix with a `group` element carrying minWidth.
|
|
||||||
|
|
||||||
Two defences, both covered here: _preprocess_elements drops types the template
|
|
||||||
has no branch for, and the template emits a `pass` fallback so a type added to
|
|
||||||
the canvas before its branch exists degrades to a no-op instead of a broken
|
|
||||||
plugin.
|
|
||||||
"""
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
||||||
|
|
||||||
from web_interface.blueprints import composer as C # noqa: E402
|
|
||||||
|
|
||||||
TEMPLATE = (Path(__file__).resolve().parent.parent
|
|
||||||
/ "web_interface/templates/v3/composer/manager.py.j2")
|
|
||||||
|
|
||||||
BASE_META = {"id": "test-plugin", "name": "Clock", "author": "a",
|
|
||||||
"version": "1.0.0", "description": "d"}
|
|
||||||
|
|
||||||
|
|
||||||
def generate(element):
|
|
||||||
return C._generate_plugin_files({
|
|
||||||
"metadata": BASE_META,
|
|
||||||
"elements": [element],
|
|
||||||
"dataModel": {"configVars": []},
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("wrapper", [
|
|
||||||
{"minWidth": 64}, # breakpoint block
|
|
||||||
{"blink": True}, # blink block
|
|
||||||
{"minWidth": 64, "blink": True}, # both, nested
|
|
||||||
])
|
|
||||||
@pytest.mark.parametrize("etype", ["group", "widget_9000", "section"])
|
|
||||||
def test_undrawable_element_does_not_break_generation(etype, wrapper):
|
|
||||||
element = {"type": etype, "x": 0, "y": 0, "color": "#ffffff", **wrapper}
|
|
||||||
files = generate(element) # must not raise ComposerInputError
|
|
||||||
assert "manager.py" in files
|
|
||||||
|
|
||||||
|
|
||||||
def test_drawable_element_still_renders_inside_a_breakpoint():
|
|
||||||
files = generate({"type": "text", "text": "hi", "x": 0, "y": 0,
|
|
||||||
"minWidth": 64, "color": "#ffffff"})
|
|
||||||
src = files["manager.py"]
|
|
||||||
assert "if width >= 64:" in src
|
|
||||||
assert "draw_text" in src
|
|
||||||
|
|
||||||
|
|
||||||
def test_renderable_types_match_the_template_branches():
|
|
||||||
"""The constant and the template must agree.
|
|
||||||
|
|
||||||
A type listed in the constant with no branch emits an empty block (the bug
|
|
||||||
above); a type with a branch but missing from the constant is silently
|
|
||||||
dropped from every generated plugin. Neither is visible without this check.
|
|
||||||
"""
|
|
||||||
branches = set(re.findall(r"el\.type == '([a-z_]+)'", TEMPLATE.read_text()))
|
|
||||||
assert branches == set(C._RENDERABLE_ELEMENT_TYPES)
|
|
||||||
|
|
||||||
|
|
||||||
def test_template_closes_the_branch_chain_with_a_fallback():
|
|
||||||
"""Belt and braces: even if the constant drifts, no empty block escapes."""
|
|
||||||
text = TEMPLATE.read_text()
|
|
||||||
assert "{% else %}" in text
|
|
||||||
assert "pass # element type" in text
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
"""Structural checks on the composer's JavaScript.
|
|
||||||
|
|
||||||
There is no JS test runner in this repo, but three defects here are structural
|
|
||||||
enough to assert on the parse tree, and each was a real bug:
|
|
||||||
|
|
||||||
- Stroke widths inside _drawElement were left in canvas pixels while the
|
|
||||||
geometry around them scaled by SCALE, so at SCALE>1 every outline rendered
|
|
||||||
thinner than one LED pixel and the preview stopped matching the panel.
|
|
||||||
- The `line` branch drew raw el.x0/el.y0/el.x1/el.y1, ignoring the anchor that
|
|
||||||
every other element type honours, so setting xAnchor moved everything except
|
|
||||||
lines -- and getBoundingBox had the same omission, leaving the hit box behind.
|
|
||||||
- Four methods mutated exactly what _snapshot() serialises (metadata,
|
|
||||||
currentPreset) without calling it. _snapshot is the only caller of
|
|
||||||
_debouncedAutosave, so those changes were lost on reload and could not be
|
|
||||||
undone.
|
|
||||||
"""
|
|
||||||
import re
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
tree_sitter = pytest.importorskip("tree_sitter")
|
|
||||||
tree_sitter_javascript = pytest.importorskip("tree_sitter_javascript")
|
|
||||||
|
|
||||||
JS_DIR = Path(__file__).resolve().parent.parent / "web_interface/static/v3/js/composer"
|
|
||||||
CANVAS = JS_DIR / "composer-canvas.js"
|
|
||||||
APP = JS_DIR / "composer-app.js"
|
|
||||||
|
|
||||||
|
|
||||||
def _function_source(path: Path, name: str) -> str:
|
|
||||||
"""Return the source of a top-level function declaration by name."""
|
|
||||||
src = path.read_bytes()
|
|
||||||
lang = tree_sitter.Language(tree_sitter_javascript.language())
|
|
||||||
tree = tree_sitter.Parser(lang).parse(src)
|
|
||||||
found = []
|
|
||||||
|
|
||||||
def walk(node):
|
|
||||||
if node.type == "function_declaration":
|
|
||||||
ident = node.child_by_field_name("name")
|
|
||||||
if ident is not None and src[ident.start_byte:ident.end_byte].decode() == name:
|
|
||||||
found.append(src[node.start_byte:node.end_byte].decode())
|
|
||||||
for c in node.children:
|
|
||||||
walk(c)
|
|
||||||
|
|
||||||
walk(tree.root_node)
|
|
||||||
assert found, f"{name} not found in {path.name}"
|
|
||||||
return found[0]
|
|
||||||
|
|
||||||
|
|
||||||
def _method_source(path: Path, name: str) -> str:
|
|
||||||
"""Return the source of a top-level object method by name."""
|
|
||||||
src = path.read_bytes()
|
|
||||||
lang = tree_sitter.Language(tree_sitter_javascript.language())
|
|
||||||
tree = tree_sitter.Parser(lang).parse(src)
|
|
||||||
found = []
|
|
||||||
|
|
||||||
def walk(node):
|
|
||||||
if node.type == "method_definition":
|
|
||||||
ident = node.child_by_field_name("name")
|
|
||||||
if ident is not None and src[ident.start_byte:ident.end_byte].decode() == name:
|
|
||||||
found.append(src[node.start_byte:node.end_byte].decode())
|
|
||||||
for c in node.children:
|
|
||||||
walk(c)
|
|
||||||
|
|
||||||
walk(tree.root_node)
|
|
||||||
assert found, f"{name} not found in {path.name}"
|
|
||||||
return found[0]
|
|
||||||
|
|
||||||
|
|
||||||
def test_both_files_parse():
|
|
||||||
lang = tree_sitter.Language(tree_sitter_javascript.language())
|
|
||||||
parser = tree_sitter.Parser(lang)
|
|
||||||
for path in (CANVAS, APP):
|
|
||||||
tree = parser.parse(path.read_bytes())
|
|
||||||
errors = []
|
|
||||||
|
|
||||||
def walk(node):
|
|
||||||
if node.type == "ERROR" or node.is_missing:
|
|
||||||
errors.append(node.start_point[0] + 1)
|
|
||||||
for c in node.children:
|
|
||||||
walk(c)
|
|
||||||
|
|
||||||
walk(tree.root_node)
|
|
||||||
assert not errors, f"{path.name} has parse errors at lines {errors}"
|
|
||||||
|
|
||||||
|
|
||||||
def test_element_strokes_scale_with_scale():
|
|
||||||
"""No bare `ctx.lineWidth = 1` inside _drawElement.
|
|
||||||
|
|
||||||
Selection handles and the grid are drawn in canvas pixels deliberately and
|
|
||||||
live in other functions, so this is scoped to the element drawing routine.
|
|
||||||
"""
|
|
||||||
body = _function_source(CANVAS, "_drawElement")
|
|
||||||
offenders = re.findall(r"ctx\.lineWidth\s*=\s*1\s*;", body)
|
|
||||||
assert not offenders, f"{len(offenders)} unscaled stroke width(s) in _drawElement"
|
|
||||||
|
|
||||||
|
|
||||||
def test_line_branch_applies_the_anchor_offset():
|
|
||||||
"""Scoped to _drawElement.
|
|
||||||
|
|
||||||
getBoundingBox has its own `case 'line': {` and appears first in the file,
|
|
||||||
so searching the whole text found *that* branch -- this assertion passed
|
|
||||||
with the draw branch's anchor offset removed. Verified: stripping it and
|
|
||||||
re-running gave 11/11 green.
|
|
||||||
"""
|
|
||||||
body = _function_source(CANVAS, "_drawElement")
|
|
||||||
line_branch = body[body.index("case 'line': {"):]
|
|
||||||
line_branch = line_branch[:line_branch.index("case 'divider'")]
|
|
||||||
assert "ax - el.x0" in line_branch and "ay - el.y0" in line_branch, \
|
|
||||||
"line drawing ignores xAnchor/yAnchor"
|
|
||||||
assert "moveTo(el.x0 * s" not in line_branch, \
|
|
||||||
"line still drawn from unanchored endpoints"
|
|
||||||
|
|
||||||
|
|
||||||
def test_line_bounding_box_applies_the_anchor_offset():
|
|
||||||
"""The companion to the above: scoped to getBoundingBox specifically, so
|
|
||||||
the two tests cannot both be satisfied by the same branch."""
|
|
||||||
body = _function_source(CANVAS, "getBoundingBox")
|
|
||||||
box = body[body.index("case 'line'"):]
|
|
||||||
box = box[:box.index("case 'divider'")]
|
|
||||||
assert "ax - el.x0" in box, "line bounding box ignores the anchor"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("method", [
|
|
||||||
"onBgColorChange", # mutates metadata.bgColor
|
|
||||||
"setCustomSize", # mutates currentPreset / MATRIX_W / MATRIX_H
|
|
||||||
"changePreset", # mutates currentPreset / MATRIX_W / MATRIX_H
|
|
||||||
"applyPresetLabel", # same, for sizes not in DISPLAY_PRESETS
|
|
||||||
"onColorChange", # the one that was already fixed — keeps it fixed
|
|
||||||
])
|
|
||||||
def test_state_mutations_take_a_snapshot(method):
|
|
||||||
body = _method_source(APP, method)
|
|
||||||
assert "_snapshot()" in body, \
|
|
||||||
f"{method} changes snapshotted state without calling _snapshot()"
|
|
||||||
assert "isDirty = true" in body, f"{method} does not mark the design dirty"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("method", ["changePreset", "applyPresetLabel"])
|
|
||||||
def test_restore_path_stays_snapshot_free(method):
|
|
||||||
"""_applyState and loadTemplate call these with {silent: true} while
|
|
||||||
restoring; snapshotting there would push restore steps onto the undo stack
|
|
||||||
and re-autosave the state just loaded."""
|
|
||||||
body = _method_source(APP, method)
|
|
||||||
assert "opts.silent" in body, f"{method} lost its silent guard"
|
|
||||||
snap = body.index("_snapshot()")
|
|
||||||
guard = body.index("!opts.silent")
|
|
||||||
assert guard < snap, f"{method} snapshots outside the !opts.silent guard"
|
|
||||||
@@ -1,225 +0,0 @@
|
|||||||
"""A composer plugin id must never resolve outside the plugins directory.
|
|
||||||
|
|
||||||
CodeQL reported sixteen high-severity py/path-injection alerts against
|
|
||||||
web_interface/blueprints/composer.py: a request-supplied plugin_id reaching
|
|
||||||
Path(plugins_dir) / plugin_id, which is then created, written to, deleted
|
|
||||||
(shutil.rmtree) and read back.
|
|
||||||
|
|
||||||
The id was already validated by an anchored regex, so every traversal payload
|
|
||||||
was in fact rejected. What was missing was the guarantee living *with* the path
|
|
||||||
building rather than in a regex several hundred lines away -- loosen that regex
|
|
||||||
later and the traversal opens silently, with nothing at the filesystem boundary
|
|
||||||
to catch it. _plugin_dir() closes that, and is the form static analysis can see.
|
|
||||||
"""
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import MagicMock
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
||||||
|
|
||||||
from web_interface.blueprints import composer as C # noqa: E402
|
|
||||||
|
|
||||||
#: Anything that has ever been used to climb out of a directory.
|
|
||||||
TRAVERSAL = [
|
|
||||||
"../../etc/passwd", "..", ".", "a/../../etc", "good/../../..",
|
|
||||||
"/etc/passwd", "//etc/passwd", "a\\..\\..", "a%2f..%2f..",
|
|
||||||
"....//....//etc", "a/./../../etc", "~", "~root",
|
|
||||||
"plugin/../../../../../../etc/shadow",
|
|
||||||
]
|
|
||||||
|
|
||||||
#: Rejected for shape, not traversal -- but rejected all the same.
|
|
||||||
MALFORMED = ["", "A-upper", "1-leading-digit", "-leading-dash", "has_underscore",
|
|
||||||
"has space", "has.dot", "a" * 64, "plugin\n", "plugin\n../../etc",
|
|
||||||
"\n", "plug\x00in"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def plugins_dir(tmp_path, monkeypatch):
|
|
||||||
base = tmp_path / "plugin-repos"
|
|
||||||
base.mkdir()
|
|
||||||
monkeypatch.setattr(C.composer_bp, "plugins_dir", str(base), raising=False)
|
|
||||||
return base
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("payload", TRAVERSAL)
|
|
||||||
def test_traversal_payloads_are_refused(plugins_dir, payload):
|
|
||||||
assert C._plugin_dir(payload) is None
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("payload", MALFORMED)
|
|
||||||
def test_malformed_ids_are_refused(plugins_dir, payload):
|
|
||||||
assert C._plugin_dir(payload) is None
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("payload", ["a", "my-plugin", "x9", "a" * 63])
|
|
||||||
def test_valid_ids_resolve_inside_the_base(plugins_dir, payload):
|
|
||||||
resolved = C._plugin_dir(payload)
|
|
||||||
assert resolved is not None, f"{payload!r} was rejected but is valid"
|
|
||||||
assert resolved.parent == plugins_dir.resolve(), (
|
|
||||||
f"{payload!r} resolved to {resolved}, outside {plugins_dir}")
|
|
||||||
|
|
||||||
|
|
||||||
def test_no_payload_can_escape_even_if_the_regex_is_loosened(plugins_dir, monkeypatch):
|
|
||||||
"""The containment check must stand on its own.
|
|
||||||
|
|
||||||
This is the whole point of resolving at the filesystem boundary: if the id
|
|
||||||
pattern is ever relaxed, traversal must still be impossible. Replace the
|
|
||||||
regex with one that permits slashes and dots, then re-run the payloads.
|
|
||||||
"""
|
|
||||||
import re
|
|
||||||
monkeypatch.setattr(C, "_PLUGIN_ID_RE", re.compile(r"\A[\w./\\~-]+\Z"))
|
|
||||||
import os
|
|
||||||
escaped = []
|
|
||||||
base = os.path.realpath(str(plugins_dir))
|
|
||||||
for payload in TRAVERSAL:
|
|
||||||
resolved = C._plugin_dir(payload)
|
|
||||||
if resolved is None:
|
|
||||||
continue
|
|
||||||
real = os.path.realpath(str(resolved))
|
|
||||||
if real != base and os.path.commonpath([base, real]) != base:
|
|
||||||
escaped.append((payload, real))
|
|
||||||
assert not escaped, f"these escaped the base with a loosened regex: {escaped}"
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_sibling_directory_with_a_shared_prefix_is_not_inside(tmp_path, monkeypatch):
|
|
||||||
"""commonpath, not startswith.
|
|
||||||
|
|
||||||
"/x/plugins-evil" starts with "/x/plugins" but is a different directory, so
|
|
||||||
a prefix test would accept it.
|
|
||||||
"""
|
|
||||||
base = tmp_path / "plugins"
|
|
||||||
base.mkdir()
|
|
||||||
(tmp_path / "plugins-evil").mkdir()
|
|
||||||
monkeypatch.setattr(C.composer_bp, "plugins_dir", str(base), raising=False)
|
|
||||||
import re
|
|
||||||
# Neutralise the two layers in front so this exercises the containment
|
|
||||||
# check itself; otherwise secure_filename rejects the payload first and a
|
|
||||||
# startswith regression would go unnoticed here.
|
|
||||||
monkeypatch.setattr(C, "_PLUGIN_ID_RE", re.compile(r"\A[\w./\\~-]+\Z"))
|
|
||||||
monkeypatch.setattr(C, "secure_filename", lambda v: v)
|
|
||||||
assert C._plugin_dir("../plugins-evil") is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_containment_still_holds_if_the_sanitiser_is_defeated(plugins_dir, monkeypatch):
|
|
||||||
"""Each layer is tested on its own, not just the stack.
|
|
||||||
|
|
||||||
secure_filename's equality guard rejects every traversal payload before the
|
|
||||||
containment check sees it, so removing containment does not fail the other
|
|
||||||
tests -- which would make it look load-bearing when it is not. Neutralise
|
|
||||||
the regex *and* the sanitiser, and the realpath/commonpath check must still
|
|
||||||
refuse everything on its own.
|
|
||||||
"""
|
|
||||||
import re
|
|
||||||
monkeypatch.setattr(C, "_PLUGIN_ID_RE", re.compile(r"\A[\w./\\~-]+\Z"))
|
|
||||||
monkeypatch.setattr(C, "secure_filename", lambda v: v)
|
|
||||||
import os
|
|
||||||
base = os.path.realpath(str(plugins_dir))
|
|
||||||
escaped = []
|
|
||||||
for payload in TRAVERSAL:
|
|
||||||
resolved = C._plugin_dir(payload)
|
|
||||||
if resolved is None:
|
|
||||||
continue
|
|
||||||
real = os.path.realpath(str(resolved))
|
|
||||||
# Inside the base is fine -- "...." and "~" are ordinary directory
|
|
||||||
# names on Linux, so they are not escapes. What must never happen is
|
|
||||||
# landing outside the base, or on the base itself: install() rmtrees
|
|
||||||
# its target, so the plugins root resolving to a "plugin" would wipe
|
|
||||||
# every installed plugin.
|
|
||||||
if real == base or os.path.commonpath([base, real]) != base:
|
|
||||||
escaped.append((payload, real))
|
|
||||||
assert not escaped, f"containment alone let these through: {escaped}"
|
|
||||||
|
|
||||||
|
|
||||||
def test_secure_filename_never_rewrites_an_accepted_id(plugins_dir):
|
|
||||||
"""The sanitiser must be a no-op on everything the regex accepts.
|
|
||||||
|
|
||||||
If secure_filename ever altered an accepted id, _plugin_dir would resolve
|
|
||||||
to a *different* plugin's directory than the caller asked for -- a silent
|
|
||||||
redirect, which is worse than a refusal. The guard turns that into a
|
|
||||||
refusal; this proves the guard never has to fire in practice.
|
|
||||||
"""
|
|
||||||
import random
|
|
||||||
from werkzeug.utils import secure_filename
|
|
||||||
random.seed(1)
|
|
||||||
alphabet = "abcdefghijklmnopqrstuvwxyz0123456789-"
|
|
||||||
altered = []
|
|
||||||
for _ in range(2000):
|
|
||||||
n = random.randint(1, 63)
|
|
||||||
cand = random.choice("abcdefghijklmnopqrstuvwxyz") + "".join(
|
|
||||||
random.choice(alphabet) for _ in range(n - 1))
|
|
||||||
if C._PLUGIN_ID_RE.match(cand) and secure_filename(cand) != cand:
|
|
||||||
altered.append((cand, secure_filename(cand)))
|
|
||||||
assert not altered, f"secure_filename rewrote accepted ids: {altered[:5]}"
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_trailing_newline_is_not_a_valid_id():
|
|
||||||
r"""Python's `$` also matches before a trailing newline, so the original
|
|
||||||
`^...$` accepted "myplugin\n" and would have created a directory whose
|
|
||||||
name ends in one. \Z does not."""
|
|
||||||
assert C._PLUGIN_ID_RE.match("myplugin") is not None
|
|
||||||
assert C._PLUGIN_ID_RE.match("myplugin\n") is None
|
|
||||||
|
|
||||||
|
|
||||||
# --- font serving -----------------------------------------------------------
|
|
||||||
|
|
||||||
FONT_TRAVERSAL = [
|
|
||||||
"../../../etc/passwd", "../config/config.json", "..%2f..%2fetc%2fpasswd",
|
|
||||||
"PressStart2P-Regular.ttf/../../../etc/passwd", "/etc/passwd", "",
|
|
||||||
"PressStart2P-Regular.TTF", # case differs -> not the allowlisted name
|
|
||||||
"PressStart2P-Regular.ttf ", # trailing space
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_serve_font_refuses_a_file_that_exists_but_is_not_allowlisted(monkeypatch, tmp_path):
|
|
||||||
"""The allowlist must be what refuses it, not a missing file.
|
|
||||||
|
|
||||||
Asserting 404 on traversal payloads proves nothing here: Flask's router
|
|
||||||
will not match a path segment containing '/', and everything else 404s
|
|
||||||
simply because no such file exists. Put a real, readable file next to the
|
|
||||||
fonts and confirm it is still refused -- that is the allowlist working.
|
|
||||||
"""
|
|
||||||
fonts = tmp_path / "assets" / "fonts"
|
|
||||||
fonts.mkdir(parents=True)
|
|
||||||
(fonts / "id_rsa.ttf").write_bytes(b"PRIVATE KEY")
|
|
||||||
monkeypatch.setattr(C.composer_bp, "project_root", str(tmp_path), raising=False)
|
|
||||||
app = __import__("flask").Flask(__name__)
|
|
||||||
app.register_blueprint(C.composer_bp)
|
|
||||||
with app.test_client() as client:
|
|
||||||
resp = client.get("/api/fonts/id_rsa.ttf")
|
|
||||||
assert resp.status_code == 404, (
|
|
||||||
"a readable non-allowlisted file was served; the allowlist is not gating")
|
|
||||||
assert b"PRIVATE KEY" not in resp.data
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("payload", FONT_TRAVERSAL)
|
|
||||||
def test_serve_font_refuses_anything_not_allowlisted(payload, monkeypatch, tmp_path):
|
|
||||||
"""The name reaching the filesystem must come from the allowlist constant.
|
|
||||||
|
|
||||||
_ALLOWED_FONTS gates this endpoint, so nothing here was ever exploitable.
|
|
||||||
Building the path from the matched constant rather than the request value
|
|
||||||
is what makes that provable -- and it is why CodeQL reported two
|
|
||||||
high-severity py/path-injection alerts on an endpoint that was already
|
|
||||||
safe.
|
|
||||||
"""
|
|
||||||
monkeypatch.setattr(C.composer_bp, "project_root", str(tmp_path), raising=False)
|
|
||||||
app = C.composer_bp.name and __import__("flask").Flask(__name__)
|
|
||||||
app.register_blueprint(C.composer_bp)
|
|
||||||
with app.test_client() as client:
|
|
||||||
resp = client.get(f"/api/fonts/{payload}")
|
|
||||||
assert resp.status_code in (404, 405, 308), (
|
|
||||||
f"{payload!r} was not refused (status {resp.status_code})")
|
|
||||||
|
|
||||||
|
|
||||||
def test_serve_font_still_serves_each_allowlisted_font(monkeypatch, tmp_path):
|
|
||||||
fonts = tmp_path / "assets" / "fonts"
|
|
||||||
fonts.mkdir(parents=True)
|
|
||||||
monkeypatch.setattr(C.composer_bp, "project_root", str(tmp_path), raising=False)
|
|
||||||
app = __import__("flask").Flask(__name__)
|
|
||||||
app.register_blueprint(C.composer_bp)
|
|
||||||
for name in C._ALLOWED_FONTS:
|
|
||||||
(fonts / name).write_bytes(b"\x00\x01ttf")
|
|
||||||
with app.test_client() as client:
|
|
||||||
resp = client.get(f"/api/fonts/{name}")
|
|
||||||
assert resp.status_code == 200, f"{name} should be served, got {resp.status_code}"
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Discovery must say when it skips a directory.
|
|
||||||
|
|
||||||
A plugin can be enabled in config, enabled in plugin state, present on disk
|
|
||||||
with a valid entry point -- and simply absent from the running process, with
|
|
||||||
nothing in the journal to say why. Working that out afterwards meant comparing
|
|
||||||
cache-file mtimes to find when it had last run.
|
|
||||||
|
|
||||||
Two paths were silent. A directory with no manifest.json was ignored, and --
|
|
||||||
quieter still -- a manifest that parsed but carried no "id" was read
|
|
||||||
successfully and then dropped on the floor.
|
|
||||||
"""
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import MagicMock
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
||||||
|
|
||||||
from src.plugin_system.plugin_manager import PluginManager # noqa: E402
|
|
||||||
|
|
||||||
|
|
||||||
def _manager(tmp_path):
|
|
||||||
pm = PluginManager.__new__(PluginManager)
|
|
||||||
pm.plugins_dir = tmp_path
|
|
||||||
pm.logger = logging.getLogger("test.discovery")
|
|
||||||
pm.plugin_manifests = {}
|
|
||||||
pm.plugin_directories = {}
|
|
||||||
pm._discovery_lock = __import__("threading").RLock()
|
|
||||||
pm._skip_reported = set()
|
|
||||||
pm.schema_manager = MagicMock()
|
|
||||||
return pm
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_directory_without_a_manifest_is_reported(tmp_path, caplog):
|
|
||||||
(tmp_path / "not-a-plugin").mkdir()
|
|
||||||
pm = _manager(tmp_path)
|
|
||||||
with caplog.at_level(logging.WARNING, logger="test.discovery"):
|
|
||||||
pm._scan_directory_for_plugins(tmp_path)
|
|
||||||
joined = " ".join(r.message for r in caplog.records)
|
|
||||||
assert "not-a-plugin" in joined and "manifest" in joined, (
|
|
||||||
f"skip was silent; log said: {joined!r}")
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_manifest_without_an_id_is_reported(tmp_path, caplog):
|
|
||||||
d = tmp_path / "idless"
|
|
||||||
d.mkdir()
|
|
||||||
(d / "manifest.json").write_text(json.dumps({"name": "No Id", "version": "1.0.0"}))
|
|
||||||
pm = _manager(tmp_path)
|
|
||||||
with caplog.at_level(logging.WARNING, logger="test.discovery"):
|
|
||||||
pm._scan_directory_for_plugins(tmp_path)
|
|
||||||
joined = " ".join(r.message for r in caplog.records)
|
|
||||||
assert "idless" in joined and "id" in joined, (
|
|
||||||
f"a parsed-but-unusable manifest vanished silently; log said: {joined!r}")
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_good_plugin_still_registers(tmp_path, caplog):
|
|
||||||
d = tmp_path / "real-plugin"
|
|
||||||
d.mkdir()
|
|
||||||
(d / "manifest.json").write_text(json.dumps(
|
|
||||||
{"id": "real-plugin", "name": "Real", "version": "1.0.0"}))
|
|
||||||
pm = _manager(tmp_path)
|
|
||||||
pm._scan_directory_for_plugins(tmp_path)
|
|
||||||
assert "real-plugin" in pm.plugin_manifests, "a valid plugin was not registered"
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_warning_does_not_repeat_on_every_scan(tmp_path, caplog):
|
|
||||||
"""Discovery runs on every web UI page load and every config reconcile.
|
|
||||||
|
|
||||||
Warning unconditionally would put a line in the journal each time someone
|
|
||||||
opened a page -- the same log-volume problem this is meant to help
|
|
||||||
diagnose.
|
|
||||||
"""
|
|
||||||
(tmp_path / "not-a-plugin").mkdir()
|
|
||||||
pm = _manager(tmp_path)
|
|
||||||
with caplog.at_level(logging.WARNING, logger="test.discovery"):
|
|
||||||
for _ in range(5):
|
|
||||||
pm._scan_directory_for_plugins(tmp_path)
|
|
||||||
hits = [r for r in caplog.records if "not-a-plugin" in r.message]
|
|
||||||
assert len(hits) == 1, f"warned {len(hits)} times across 5 scans"
|
|
||||||
|
|
||||||
|
|
||||||
def _plugin(tmp_path, name, body):
|
|
||||||
d = tmp_path / name
|
|
||||||
d.mkdir()
|
|
||||||
(d / "manifest.json").write_text(json.dumps(body))
|
|
||||||
return d
|
|
||||||
|
|
||||||
|
|
||||||
VALID = {"name": "V", "version": "1.0.0", "class_name": "X", "display_modes": ["m"]}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("body", [None, [1, 2], "not an object", 42, True])
|
|
||||||
def test_a_manifest_that_is_not_an_object_is_skipped_not_fatal(tmp_path, caplog, body):
|
|
||||||
"""json.load accepts any JSON value, not just objects.
|
|
||||||
|
|
||||||
manifest.get('id') then raised AttributeError, which nothing here caught --
|
|
||||||
the outer handler takes OSError/PermissionError only. A single malformed
|
|
||||||
manifest aborted the entire scan, so every other plugin on disk, however
|
|
||||||
healthy, silently failed to register.
|
|
||||||
"""
|
|
||||||
_plugin(tmp_path, "aaa-good", dict(VALID, id="aaa-good"))
|
|
||||||
_plugin(tmp_path, "mmm-bad", body)
|
|
||||||
_plugin(tmp_path, "zzz-good", dict(VALID, id="zzz-good"))
|
|
||||||
|
|
||||||
pm = _manager(tmp_path)
|
|
||||||
with caplog.at_level(logging.WARNING, logger="test.discovery"):
|
|
||||||
found = pm._scan_directory_for_plugins(tmp_path)
|
|
||||||
|
|
||||||
assert sorted(found) == ["aaa-good", "zzz-good"], (
|
|
||||||
"one unusable manifest took the healthy plugins down with it")
|
|
||||||
joined = " ".join(r.message for r in caplog.records)
|
|
||||||
assert "mmm-bad" in joined, f"the skip was silent; log said: {joined!r}"
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_bad_manifest_is_named_with_what_it_actually_was(tmp_path, caplog):
|
|
||||||
_plugin(tmp_path, "listy", [1, 2])
|
|
||||||
pm = _manager(tmp_path)
|
|
||||||
with caplog.at_level(logging.WARNING, logger="test.discovery"):
|
|
||||||
pm._scan_directory_for_plugins(tmp_path)
|
|
||||||
joined = " ".join(r.message for r in caplog.records)
|
|
||||||
assert "listy" in joined and "list" in joined, (
|
|
||||||
f"the warning does not say what the manifest was: {joined!r}")
|
|
||||||
@@ -1,179 +0,0 @@
|
|||||||
"""
|
|
||||||
Tests for the device-location default: a plugin that ships a location field in
|
|
||||||
its schema must default to the device's configured City/State/Country, not to
|
|
||||||
whatever place the plugin author hard-coded.
|
|
||||||
|
|
||||||
The bug this pins: ledmatrix-weather ships ``"location_city": "Dallas"`` as a
|
|
||||||
schema default, so a user who set Kansas City under General settings but never
|
|
||||||
opened the weather plugin's own config form got Dallas weather — and a radar
|
|
||||||
centred on Dallas — with nothing in config.json to explain it.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from src.plugin_system.schema_manager import SchemaManager
|
|
||||||
|
|
||||||
|
|
||||||
class FakeConfigManager:
|
|
||||||
"""Minimal stand-in exposing the load_config() SchemaManager relies on."""
|
|
||||||
|
|
||||||
def __init__(self, config):
|
|
||||||
self.config = config
|
|
||||||
self.load_count = 0
|
|
||||||
|
|
||||||
def load_config(self):
|
|
||||||
self.load_count += 1
|
|
||||||
return self.config
|
|
||||||
|
|
||||||
|
|
||||||
class ExplodingConfigManager:
|
|
||||||
def load_config(self):
|
|
||||||
raise OSError("config.json is unreadable")
|
|
||||||
|
|
||||||
|
|
||||||
WEATHER_SCHEMA = {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"location_city": {"type": "string", "default": "Dallas"},
|
|
||||||
"location_state": {"type": "string", "default": "Texas"},
|
|
||||||
"location_country": {"type": "string", "default": "US"},
|
|
||||||
"units": {"type": "string", "default": "imperial"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def write_plugin(plugins_dir, plugin_id, schema):
|
|
||||||
plugin_dir = plugins_dir / plugin_id
|
|
||||||
plugin_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
(plugin_dir / "config_schema.json").write_text(json.dumps(schema))
|
|
||||||
return plugin_dir
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def plugins_dir(tmp_path):
|
|
||||||
d = tmp_path / "plugin-repos"
|
|
||||||
d.mkdir()
|
|
||||||
return d
|
|
||||||
|
|
||||||
|
|
||||||
def make_sm(plugins_dir, tmp_path, location):
|
|
||||||
config = {} if location is None else {"location": location}
|
|
||||||
cm = FakeConfigManager(config)
|
|
||||||
sm = SchemaManager(plugins_dir=plugins_dir, project_root=tmp_path,
|
|
||||||
config_manager=cm)
|
|
||||||
return sm, cm
|
|
||||||
|
|
||||||
|
|
||||||
class TestDeviceLocationDefaults:
|
|
||||||
def test_device_location_replaces_plugin_default(self, plugins_dir, tmp_path):
|
|
||||||
write_plugin(plugins_dir, "ledmatrix-weather", WEATHER_SCHEMA)
|
|
||||||
sm, _ = make_sm(plugins_dir, tmp_path,
|
|
||||||
{"city": "Kansas City", "state": "Missouri", "country": "US"})
|
|
||||||
|
|
||||||
defaults = sm.generate_default_config("ledmatrix-weather")
|
|
||||||
|
|
||||||
assert defaults["location_city"] == "Kansas City"
|
|
||||||
assert defaults["location_state"] == "Missouri"
|
|
||||||
assert defaults["location_country"] == "US"
|
|
||||||
# Non-location defaults are untouched.
|
|
||||||
assert defaults["units"] == "imperial"
|
|
||||||
|
|
||||||
def test_user_set_plugin_value_still_wins(self, plugins_dir, tmp_path):
|
|
||||||
write_plugin(plugins_dir, "ledmatrix-weather", WEATHER_SCHEMA)
|
|
||||||
sm, _ = make_sm(plugins_dir, tmp_path,
|
|
||||||
{"city": "Kansas City", "state": "Missouri", "country": "US"})
|
|
||||||
|
|
||||||
defaults = sm.generate_default_config("ledmatrix-weather")
|
|
||||||
merged = sm.merge_with_defaults({"location_city": "Denver"}, defaults)
|
|
||||||
|
|
||||||
assert merged["location_city"] == "Denver"
|
|
||||||
# Fields the user did not override still follow the device.
|
|
||||||
assert merged["location_state"] == "Missouri"
|
|
||||||
|
|
||||||
def test_blank_and_missing_device_fields_leave_schema_default(self, plugins_dir, tmp_path):
|
|
||||||
write_plugin(plugins_dir, "ledmatrix-weather", WEATHER_SCHEMA)
|
|
||||||
sm, _ = make_sm(plugins_dir, tmp_path, {"city": "Kansas City", "state": " "})
|
|
||||||
|
|
||||||
defaults = sm.generate_default_config("ledmatrix-weather")
|
|
||||||
|
|
||||||
assert defaults["location_city"] == "Kansas City"
|
|
||||||
assert defaults["location_state"] == "Texas" # blank -> not configured
|
|
||||||
assert defaults["location_country"] == "US" # absent -> schema default
|
|
||||||
|
|
||||||
def test_no_device_location_configured_is_a_no_op(self, plugins_dir, tmp_path):
|
|
||||||
write_plugin(plugins_dir, "ledmatrix-weather", WEATHER_SCHEMA)
|
|
||||||
sm, _ = make_sm(plugins_dir, tmp_path, None)
|
|
||||||
|
|
||||||
defaults = sm.generate_default_config("ledmatrix-weather")
|
|
||||||
|
|
||||||
assert defaults["location_city"] == "Dallas"
|
|
||||||
|
|
||||||
def test_no_config_manager_is_a_no_op(self, plugins_dir, tmp_path):
|
|
||||||
write_plugin(plugins_dir, "ledmatrix-weather", WEATHER_SCHEMA)
|
|
||||||
sm = SchemaManager(plugins_dir=plugins_dir, project_root=tmp_path)
|
|
||||||
|
|
||||||
assert sm.generate_default_config("ledmatrix-weather")["location_city"] == "Dallas"
|
|
||||||
|
|
||||||
def test_unreadable_config_falls_back_to_schema_defaults(self, plugins_dir, tmp_path):
|
|
||||||
write_plugin(plugins_dir, "ledmatrix-weather", WEATHER_SCHEMA)
|
|
||||||
sm = SchemaManager(plugins_dir=plugins_dir, project_root=tmp_path,
|
|
||||||
config_manager=ExplodingConfigManager())
|
|
||||||
|
|
||||||
assert sm.generate_default_config("ledmatrix-weather")["location_city"] == "Dallas"
|
|
||||||
|
|
||||||
|
|
||||||
class TestScopedToNamespacedKeys:
|
|
||||||
def test_bare_state_key_is_not_rewritten(self, plugins_dir, tmp_path):
|
|
||||||
"""ledmatrix-elections' ``state`` is a two-letter code, not a place name."""
|
|
||||||
write_plugin(plugins_dir, "ledmatrix-elections", {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"state": {"type": "string", "default": "CA"},
|
|
||||||
"city": {"type": "string", "default": "Springfield"},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
sm, _ = make_sm(plugins_dir, tmp_path,
|
|
||||||
{"city": "Kansas City", "state": "Missouri", "country": "US"})
|
|
||||||
|
|
||||||
defaults = sm.generate_default_config("ledmatrix-elections")
|
|
||||||
|
|
||||||
assert defaults["state"] == "CA"
|
|
||||||
assert defaults["city"] == "Springfield"
|
|
||||||
|
|
||||||
def test_plugin_without_location_fields_never_reads_config(self, plugins_dir, tmp_path):
|
|
||||||
write_plugin(plugins_dir, "clock-simple", {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {"format": {"type": "string", "default": "12h"}},
|
|
||||||
})
|
|
||||||
sm, cm = make_sm(plugins_dir, tmp_path, {"city": "Kansas City"})
|
|
||||||
|
|
||||||
defaults = sm.generate_default_config("clock-simple")
|
|
||||||
|
|
||||||
assert defaults["format"] == "12h"
|
|
||||||
assert cm.load_count == 0
|
|
||||||
|
|
||||||
|
|
||||||
class TestCachingStaysFresh:
|
|
||||||
def test_location_change_is_picked_up_through_the_defaults_cache(self, plugins_dir, tmp_path):
|
|
||||||
write_plugin(plugins_dir, "ledmatrix-weather", WEATHER_SCHEMA)
|
|
||||||
sm, cm = make_sm(plugins_dir, tmp_path, {"city": "Kansas City"})
|
|
||||||
|
|
||||||
assert sm.generate_default_config("ledmatrix-weather")["location_city"] == "Kansas City"
|
|
||||||
|
|
||||||
cm.config["location"]["city"] = "Omaha"
|
|
||||||
|
|
||||||
# Second call is served from the defaults cache, but must not serve a
|
|
||||||
# stale location.
|
|
||||||
assert sm.generate_default_config("ledmatrix-weather")["location_city"] == "Omaha"
|
|
||||||
|
|
||||||
def test_cached_defaults_are_not_mutated_by_the_overlay(self, plugins_dir, tmp_path):
|
|
||||||
write_plugin(plugins_dir, "ledmatrix-weather", WEATHER_SCHEMA)
|
|
||||||
sm, cm = make_sm(plugins_dir, tmp_path, {"city": "Kansas City"})
|
|
||||||
|
|
||||||
sm.generate_default_config("ledmatrix-weather")
|
|
||||||
assert sm._defaults_cache["ledmatrix-weather"]["location_city"] == "Dallas"
|
|
||||||
|
|
||||||
cm.config.pop("location")
|
|
||||||
assert sm.generate_default_config("ledmatrix-weather")["location_city"] == "Dallas"
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
"""Odds must be fetched for the games shown, not every game in the window.
|
|
||||||
|
|
||||||
SportsUpcoming.update() collected every upcoming game in the schedule window
|
|
||||||
and called _fetch_odds() on each one *inside* that collection loop, narrowing
|
|
||||||
to upcoming_games_to_show only afterwards. The comment there said odds were
|
|
||||||
fetched "only for games that will be displayed", but the sole narrowing it
|
|
||||||
applied was show_favorite_teams_only, which is not the default -- so in the
|
|
||||||
usual configuration nothing narrowed it at all.
|
|
||||||
|
|
||||||
Measured on a live rig: a college league produced 946 upcoming games in one
|
|
||||||
cycle and displayed 1 of them. The same shape on the football plugin produced
|
|
||||||
a burst of 467 sequential ESPN requests that ran for 35s and blew that
|
|
||||||
plugin's 30s update budget, and it repeats every time the 1h odds TTL expires.
|
|
||||||
|
|
||||||
SportsLive is deliberately different: it walks the raw event list because it
|
|
||||||
has to find which games are live, but only fetches odds for a game that has
|
|
||||||
already passed the is_live/is_halftime test, so the fan-out is bounded by how
|
|
||||||
many games are actually in progress.
|
|
||||||
"""
|
|
||||||
import ast
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
MODES = (Path(__file__).resolve().parent.parent
|
|
||||||
/ "src" / "base_classes" / "sports" / "modes.py")
|
|
||||||
TREE = ast.parse(MODES.read_text(encoding="utf-8"))
|
|
||||||
|
|
||||||
|
|
||||||
def _fetch_sites():
|
|
||||||
"""(class name, method name, lineno) for every self._fetch_odds(...) call."""
|
|
||||||
calls = [n.lineno for n in ast.walk(TREE)
|
|
||||||
if isinstance(n, ast.Call) and isinstance(n.func, ast.Attribute)
|
|
||||||
and n.func.attr == "_fetch_odds"]
|
|
||||||
sites = []
|
|
||||||
for cls in [n for n in ast.walk(TREE) if isinstance(n, ast.ClassDef)]:
|
|
||||||
for fn in [n for n in cls.body if isinstance(n, ast.FunctionDef)]:
|
|
||||||
for lineno in calls:
|
|
||||||
if fn.lineno <= lineno <= (fn.end_lineno or fn.lineno):
|
|
||||||
sites.append((cls.name, fn.name, lineno))
|
|
||||||
assert len(sites) == len(calls), "a _fetch_odds call sits outside any method"
|
|
||||||
return sites
|
|
||||||
|
|
||||||
|
|
||||||
def _innermost_loop_iterable(lineno):
|
|
||||||
best = None
|
|
||||||
for node in ast.walk(TREE):
|
|
||||||
if isinstance(node, ast.For) and \
|
|
||||||
node.lineno <= lineno <= (node.end_lineno or node.lineno):
|
|
||||||
if best is None or node.lineno > best.lineno:
|
|
||||||
best = node
|
|
||||||
return None if best is None else ast.unparse(best.iter)
|
|
||||||
|
|
||||||
|
|
||||||
def _spans(body, lineno):
|
|
||||||
"""True when `lineno` falls inside this list of statements."""
|
|
||||||
return any(n.lineno <= lineno <= (n.end_lineno or n.lineno) for n in body)
|
|
||||||
|
|
||||||
|
|
||||||
def _parents(tree):
|
|
||||||
table = {}
|
|
||||||
for node in ast.walk(tree):
|
|
||||||
for child in ast.iter_child_nodes(node):
|
|
||||||
table[child] = node
|
|
||||||
return table
|
|
||||||
|
|
||||||
|
|
||||||
PARENTS = _parents(TREE)
|
|
||||||
|
|
||||||
|
|
||||||
def _mentions_positively(test, names):
|
|
||||||
"""True when `test` references every name, none of them under a `not`.
|
|
||||||
|
|
||||||
Structural, not textual. Matching the unparsed source would accept
|
|
||||||
`not (details["is_live"] or details["is_halftime"])` -- which selects
|
|
||||||
exactly the non-live games this guard exists to exclude -- because the
|
|
||||||
names still appear in the text.
|
|
||||||
"""
|
|
||||||
found = set()
|
|
||||||
for node in ast.walk(test):
|
|
||||||
if not (isinstance(node, ast.Constant) and node.value in names):
|
|
||||||
continue
|
|
||||||
negated = False
|
|
||||||
cursor = node
|
|
||||||
while cursor is not test and cursor in PARENTS:
|
|
||||||
cursor = PARENTS[cursor]
|
|
||||||
if isinstance(cursor, ast.UnaryOp) and isinstance(cursor.op, ast.Not):
|
|
||||||
negated = True
|
|
||||||
break
|
|
||||||
if not negated:
|
|
||||||
found.add(node.value)
|
|
||||||
return found >= set(names)
|
|
||||||
|
|
||||||
|
|
||||||
def _guarded_by_positive(lineno, names):
|
|
||||||
"""True when some enclosing `if` runs this line only if `names` hold.
|
|
||||||
|
|
||||||
Only the TRUE branch counts: an `if` whose `else` contains the call would
|
|
||||||
otherwise look like a guard while doing the opposite.
|
|
||||||
"""
|
|
||||||
for node in ast.walk(TREE):
|
|
||||||
if isinstance(node, ast.If) and _spans(node.body, lineno) \
|
|
||||||
and _mentions_positively(node.test, names):
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def test_every_fetch_site_is_accounted_for():
|
|
||||||
"""A new call site must be classified deliberately, not inherited silently."""
|
|
||||||
found = {(cls, fn) for cls, fn, _ in _fetch_sites()}
|
|
||||||
assert found == {("SportsUpcoming", "update"), ("SportsLive", "update")}, (
|
|
||||||
f"unexpected _fetch_odds call sites: {sorted(found)}. Each one is a "
|
|
||||||
"sequential ESPN request per game -- classify it here on purpose.")
|
|
||||||
|
|
||||||
|
|
||||||
def test_upcoming_fetches_only_the_selected_games():
|
|
||||||
for cls, _fn, lineno in _fetch_sites():
|
|
||||||
if cls != "SportsUpcoming":
|
|
||||||
continue
|
|
||||||
iterable = _innermost_loop_iterable(lineno)
|
|
||||||
assert iterable == "team_games", (
|
|
||||||
f"SportsUpcoming._fetch_odds at line {lineno} iterates over "
|
|
||||||
f"{iterable!r}. It must run over team_games -- already narrowed to "
|
|
||||||
"upcoming_games_to_show -- not over every event in the schedule "
|
|
||||||
"window. Each item costs one sequential ESPN request.")
|
|
||||||
|
|
||||||
|
|
||||||
def test_live_only_fetches_for_games_actually_in_progress():
|
|
||||||
for cls, _fn, lineno in _fetch_sites():
|
|
||||||
if cls != "SportsLive":
|
|
||||||
continue
|
|
||||||
assert _guarded_by_positive(lineno, {"is_live", "is_halftime"}), (
|
|
||||||
f"SportsLive._fetch_odds at line {lineno} does not sit in the true "
|
|
||||||
"branch of a test requiring the game to be in progress. Without "
|
|
||||||
"that, it fans out across the whole event list -- one sequential "
|
|
||||||
"ESPN request per game.")
|
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"""Frame pacing and FPS health reporting must not depend on the wall clock.
|
||||||
|
|
||||||
|
These devices have no RTC, so the system clock jumps by however wrong boot
|
||||||
|
time was the moment NTP first syncs. The render loop sleeps the *remainder*
|
||||||
|
of each frame budget:
|
||||||
|
|
||||||
|
frame_elapsed = <now> - frame_started
|
||||||
|
time.sleep(max(0.0, frame_interval - frame_elapsed))
|
||||||
|
|
||||||
|
With a wall-clock `now`, a backward jump makes frame_elapsed negative, so
|
||||||
|
`frame_interval - frame_elapsed` exceeds the whole budget and the render loop
|
||||||
|
stalls for the size of the correction. A forward jump instead inflates the
|
||||||
|
p99 and worst-frame numbers the telemetry reports.
|
||||||
|
"""
|
||||||
|
import ast
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
COORD = (Path(__file__).resolve().parent.parent
|
||||||
|
/ "src" / "vegas_mode" / "coordinator.py")
|
||||||
|
TREE = ast.parse(COORD.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def _assignments_of(name):
|
||||||
|
"""Every `name = <expr>` in the module, as unparsed source."""
|
||||||
|
out = []
|
||||||
|
for node in ast.walk(TREE):
|
||||||
|
if isinstance(node, ast.Assign):
|
||||||
|
for target in node.targets:
|
||||||
|
if isinstance(target, ast.Name) and target.id == name:
|
||||||
|
out.append((node.lineno, ast.unparse(node.value)))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def test_per_frame_timestamps_are_monotonic():
|
||||||
|
for name in ("frame_started", "frame_elapsed"):
|
||||||
|
assigns = _assignments_of(name)
|
||||||
|
assert assigns, f"{name} is no longer assigned -- has the loop changed?"
|
||||||
|
for lineno, expr in assigns:
|
||||||
|
assert "time.time()" not in expr, (
|
||||||
|
f"{name} at line {lineno} uses the wall clock ({expr!r}). A "
|
||||||
|
"backward NTP step makes the per-frame delta negative and the "
|
||||||
|
"loop then sleeps longer than the whole frame budget.")
|
||||||
|
assert "time.monotonic()" in expr, (
|
||||||
|
f"{name} at line {lineno} is {expr!r}, expected monotonic")
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_fps_window_is_monotonic():
|
||||||
|
for lineno, expr in _assignments_of("current_time"):
|
||||||
|
assert "time.monotonic()" in expr, (
|
||||||
|
f"current_time at line {lineno} is {expr!r}; fps is frames divided "
|
||||||
|
"by this delta, so a clock step would corrupt the rate itself")
|
||||||
|
|
||||||
|
|
||||||
|
def test_health_state_is_not_reset_every_iteration():
|
||||||
|
"""run_iteration() runs once per cycle -- locals here reset every few seconds.
|
||||||
|
|
||||||
|
As locals, `last_fps_health_log = 0.0` made the 300s heartbeat fire on the
|
||||||
|
first sample of every iteration, and a recovery spanning two iterations was
|
||||||
|
never reported because was_degraded had already gone back to False.
|
||||||
|
"""
|
||||||
|
run_iteration = next(
|
||||||
|
(n for n in ast.walk(TREE)
|
||||||
|
if isinstance(n, ast.FunctionDef) and n.name == "run_iteration"), None)
|
||||||
|
assert run_iteration is not None, "run_iteration() not found"
|
||||||
|
|
||||||
|
local_names = {t.id for n in ast.walk(run_iteration)
|
||||||
|
if isinstance(n, ast.Assign)
|
||||||
|
for t in n.targets if isinstance(t, ast.Name)}
|
||||||
|
for leaked in ("last_fps_health_log", "was_degraded"):
|
||||||
|
assert leaked not in local_names, (
|
||||||
|
f"{leaked} is a local of run_iteration() again, so it resets every "
|
||||||
|
"cycle -- the heartbeat degenerates to once per iteration")
|
||||||
|
|
||||||
|
body = ast.unparse(run_iteration)
|
||||||
|
assert "self._fps_last_health_log" in body and "self._fps_was_degraded" in body, (
|
||||||
|
"the health state should live on the coordinator, across iterations")
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_clears_stale_health_state():
|
||||||
|
"""A new run must not inherit "was degraded" from the previous one."""
|
||||||
|
start = next((n for n in ast.walk(TREE)
|
||||||
|
if isinstance(n, ast.FunctionDef) and n.name == "start"), None)
|
||||||
|
assert start is not None, "start() not found"
|
||||||
|
body = ast.unparse(start)
|
||||||
|
assert "self._fps_last_health_log" in body and "self._fps_was_degraded" in body, (
|
||||||
|
"start() does not reset the FPS health state")
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_degraded_threshold_is_documented():
|
||||||
|
"""The 90% band is deliberate; say so where the constant is defined."""
|
||||||
|
source = COORD.read_text(encoding="utf-8")
|
||||||
|
idx = source.index("_FPS_HEALTHY_FRACTION = ")
|
||||||
|
preamble = source[max(0, idx - 700):idx]
|
||||||
|
assert "90%" in preamble or "0.9" in preamble, (
|
||||||
|
"the degradation threshold is not explained at its definition, so "
|
||||||
|
"'below target' reads as a bug rather than a deliberate band")
|
||||||
@@ -118,8 +118,7 @@ saved_repositories_manager = SavedRepositoriesManager()
|
|||||||
schema_manager = SchemaManager(
|
schema_manager = SchemaManager(
|
||||||
plugins_dir=plugins_dir,
|
plugins_dir=plugins_dir,
|
||||||
project_root=project_root,
|
project_root=project_root,
|
||||||
logger=None,
|
logger=None
|
||||||
config_manager=config_manager
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Initialize operation queue for plugin operations
|
# Initialize operation queue for plugin operations
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -426,9 +426,6 @@ a, button, input, select, textarea {
|
|||||||
.md\:hidden { display: none; }
|
.md\:hidden { display: none; }
|
||||||
.md\:block { display: block; }
|
.md\:block { display: block; }
|
||||||
.md\:w-auto { width: auto; }
|
.md\:w-auto { width: auto; }
|
||||||
/* composer.html labels its toolbar buttons `hidden md:inline`, so without
|
|
||||||
this the label is hidden at every width and the buttons stay icon-only. */
|
|
||||||
.md\:inline { display: inline; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
@media (min-width: 1024px) {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,804 +0,0 @@
|
|||||||
/**
|
|
||||||
* ComposerCanvas — stateless LED matrix canvas renderer.
|
|
||||||
*
|
|
||||||
* Coordinate system: LED pixels (integers). All drawing multiplies by SCALE.
|
|
||||||
* PIL draw.text(x,y) is top-left; canvas fillText(x,y) is baseline.
|
|
||||||
* → Canvas text cy = (actualY + fontSizePx) * SCALE
|
|
||||||
*
|
|
||||||
* Anchors: element x/y are offsets from their anchor point:
|
|
||||||
* xAnchor=null/'left' → x is fixed offset from left
|
|
||||||
* xAnchor='center' → x is offset from width/2
|
|
||||||
* xAnchor='right' → x is offset inward from right edge
|
|
||||||
* yAnchor follows the same pattern with 'top'/'middle'/'bottom'
|
|
||||||
*
|
|
||||||
* Breakpoints: elements with minWidth > currentMatrixW are rendered at 25% opacity.
|
|
||||||
*
|
|
||||||
* Resize handles: drawn on selected rectangles; 8 handles (corners + edge mids).
|
|
||||||
*/
|
|
||||||
window.ComposerCanvas = (() => {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
let _canvas = null;
|
|
||||||
let _ctx = null;
|
|
||||||
let _showGrid = true;
|
|
||||||
|
|
||||||
//: Element types the canvas draws resize handles for. Exported because the
|
|
||||||
//: editor has to gate its resize and hover behaviour on exactly this list --
|
|
||||||
//: the two had drifted, so handles appeared on five shapes that could not
|
|
||||||
//: actually be resized.
|
|
||||||
const RESIZABLE_TYPES = ['rectangle', 'rounded_rectangle', 'ellipse', 'arc',
|
|
||||||
'gauge', 'sparkline'];
|
|
||||||
|
|
||||||
const DISPLAY_PRESETS = [
|
|
||||||
{ label: '64×32', w: 64, h: 32 },
|
|
||||||
{ label: '128×32', w: 128, h: 32 },
|
|
||||||
{ label: '128×64', w: 128, h: 64 },
|
|
||||||
{ label: '256×32', w: 256, h: 32 },
|
|
||||||
{ label: '256×64', w: 256, h: 64 },
|
|
||||||
];
|
|
||||||
|
|
||||||
const FONT_MAP = {
|
|
||||||
press_start: { family: "'PressStart2P', monospace", sizePx: 8, charW: 8 },
|
|
||||||
four_by_six: { family: 'monospace', sizePx: 6, charW: 4 },
|
|
||||||
five_by_seven: { family: 'monospace', sizePx: 7, charW: 5 },
|
|
||||||
};
|
|
||||||
|
|
||||||
const ELEMENT_DEFAULTS = {
|
|
||||||
text: {
|
|
||||||
text: 'Hello', font: 'press_start',
|
|
||||||
r: 255, g: 255, b: 255,
|
|
||||||
text2: '', lineSpacing: 2, textAlign: 'left',
|
|
||||||
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
|
||||||
},
|
|
||||||
dynamic_text: {
|
|
||||||
binding: { source: 'config', key: '', format: null },
|
|
||||||
font: 'press_start', textAlign: 'left',
|
|
||||||
r: 255, g: 200, b: 100,
|
|
||||||
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
|
||||||
},
|
|
||||||
clock: {
|
|
||||||
format: '%H:%M', font: 'press_start',
|
|
||||||
r: 100, g: 255, b: 100,
|
|
||||||
format2: '', lineSpacing: 2, textAlign: 'left',
|
|
||||||
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
|
||||||
},
|
|
||||||
rectangle: {
|
|
||||||
width: 20, height: 8,
|
|
||||||
fillR: 0, fillG: 0, fillB: 128, hasFill: true,
|
|
||||||
outR: 255, outG: 255, outB: 255, hasOutline: true,
|
|
||||||
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
|
||||||
},
|
|
||||||
ellipse: {
|
|
||||||
width: 24, height: 12,
|
|
||||||
fillR: 0, fillG: 100, fillB: 200, hasFill: true,
|
|
||||||
outR: 100, outG: 180, outB: 255, hasOutline: true,
|
|
||||||
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
|
||||||
},
|
|
||||||
arc: {
|
|
||||||
width: 24, height: 24,
|
|
||||||
startAngle: 0, endAngle: 270, lineWidth: 2,
|
|
||||||
r: 255, g: 200, b: 0,
|
|
||||||
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
|
||||||
},
|
|
||||||
pixel: {
|
|
||||||
r: 255, g: 255, b: 255,
|
|
||||||
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
|
||||||
},
|
|
||||||
rounded_rectangle: {
|
|
||||||
width: 24, height: 10, borderRadius: 3,
|
|
||||||
fillR: 0, fillG: 80, fillB: 180, hasFill: true,
|
|
||||||
outR: 120, outG: 180, outB: 255, hasOutline: true,
|
|
||||||
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
|
||||||
},
|
|
||||||
line: {
|
|
||||||
x0: 0, y0: 16, x1: 63, y1: 16,
|
|
||||||
r: 180, g: 180, b: 180, lineWidth: 1,
|
|
||||||
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
|
||||||
},
|
|
||||||
divider: {
|
|
||||||
orientation: 'horizontal', y: 16, x: 64,
|
|
||||||
r: 100, g: 100, b: 100,
|
|
||||||
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
|
||||||
},
|
|
||||||
progress_bar: {
|
|
||||||
barWidth: 60, barHeight: 6,
|
|
||||||
binding: { source: 'config', key: '', format: null },
|
|
||||||
r: 80, g: 200, b: 80,
|
|
||||||
bgR: 30, bgG: 30, bgB: 30, hasBg: true,
|
|
||||||
outR: 100, outG: 100, outB: 100, hasOutline: true,
|
|
||||||
previewPct: 65,
|
|
||||||
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
|
||||||
},
|
|
||||||
countdown: {
|
|
||||||
binding: { source: 'config', key: '', format: null },
|
|
||||||
countdownFormat: 'dh',
|
|
||||||
font: 'four_by_six', textAlign: 'left',
|
|
||||||
r: 255, g: 180, b: 0,
|
|
||||||
previewText: '42d 3h',
|
|
||||||
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
|
||||||
},
|
|
||||||
marquee: {
|
|
||||||
text: 'Scrolling text', font: 'press_start',
|
|
||||||
r: 255, g: 255, b: 255,
|
|
||||||
scrollSpeed: 1, gap: 16, direction: 'left',
|
|
||||||
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
|
||||||
},
|
|
||||||
section: {
|
|
||||||
label: 'Section',
|
|
||||||
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
|
||||||
},
|
|
||||||
pips: {
|
|
||||||
count: 5, filled: 3, pipSize: 4, pipSpacing: 2,
|
|
||||||
r: 255, g: 200, b: 0,
|
|
||||||
emptyR: 50, emptyG: 50, emptyB: 50, showEmpty: true,
|
|
||||||
binding: { source: 'config', key: '', format: null },
|
|
||||||
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
|
||||||
},
|
|
||||||
sparkline: {
|
|
||||||
width: 40, height: 12,
|
|
||||||
barCount: 8, barSpacing: 1,
|
|
||||||
r: 80, g: 200, b: 120,
|
|
||||||
bgR: 30, bgG: 30, bgB: 30, hasBg: false,
|
|
||||||
binding: { source: 'config', key: '', format: null },
|
|
||||||
previewData: '0.3,0.6,0.4,0.8,0.5,0.9,0.7,0.85',
|
|
||||||
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
|
||||||
},
|
|
||||||
gauge: {
|
|
||||||
width: 32, height: 32,
|
|
||||||
startAngle: 135, endAngle: 45, lineWidth: 3,
|
|
||||||
binding: { source: 'config', key: '', format: null },
|
|
||||||
r: 80, g: 220, b: 80,
|
|
||||||
trackR: 40, trackG: 40, trackB: 40, hasTrack: true,
|
|
||||||
showLabel: true, font: 'four_by_six', labelR: 200, labelG: 200, labelB: 200,
|
|
||||||
previewPct: 65,
|
|
||||||
xAnchor: null, yAnchor: null, minWidth: 0, locked: false, blink: false, visible: true,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Anchor resolution ────────────────────────────────────────────────
|
|
||||||
function resolveAnchor(val, anchor, dim) {
|
|
||||||
if (!anchor || anchor === 'left' || anchor === 'top') return val;
|
|
||||||
if (anchor === 'center' || anchor === 'middle') return Math.floor(dim / 2) + val;
|
|
||||||
if (anchor === 'right' || anchor === 'bottom') return dim - val;
|
|
||||||
return val;
|
|
||||||
}
|
|
||||||
|
|
||||||
function computeActualPos(el, matrixW, matrixH) {
|
|
||||||
const ax = resolveAnchor(el.x ?? el.x0 ?? 0, el.xAnchor, matrixW);
|
|
||||||
const ay = resolveAnchor(el.y ?? el.y0 ?? 0, el.yAnchor, matrixH);
|
|
||||||
return { x: ax, y: ay };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Bounding box (LED pixel space) ──────────────────────────────────
|
|
||||||
function getBoundingBox(el, matrixW, matrixH) {
|
|
||||||
const { x: ax, y: ay } = computeActualPos(el, matrixW, matrixH);
|
|
||||||
const finfo = FONT_MAP[el.font] || FONT_MAP.press_start;
|
|
||||||
|
|
||||||
switch (el.type) {
|
|
||||||
case 'text': {
|
|
||||||
const t1 = el.text || '', t2 = el.text2 || '';
|
|
||||||
const w = Math.max(t1.length, t2.length) * finfo.charW;
|
|
||||||
const h = t2 ? finfo.sizePx * 2 + (el.lineSpacing ?? 2) : finfo.sizePx;
|
|
||||||
const bx = el.textAlign === 'center' ? ax - w / 2 : el.textAlign === 'right' ? ax - w : ax;
|
|
||||||
return { x: bx, y: ay, w, h };
|
|
||||||
}
|
|
||||||
case 'dynamic_text': {
|
|
||||||
const key = el.binding?.key || '?';
|
|
||||||
const w = (`{${key}}`).length * finfo.charW;
|
|
||||||
const bx = el.textAlign === 'center' ? ax - w / 2 : el.textAlign === 'right' ? ax - w : ax;
|
|
||||||
return { x: bx, y: ay, w, h: finfo.sizePx };
|
|
||||||
}
|
|
||||||
case 'clock': {
|
|
||||||
const t1 = el.format || '%H:%M', t2 = el.format2 || '';
|
|
||||||
const w = Math.max(t1.length, t2.length) * finfo.charW;
|
|
||||||
const h = t2 ? finfo.sizePx * 2 + (el.lineSpacing ?? 2) : finfo.sizePx;
|
|
||||||
const bx = el.textAlign === 'center' ? ax - w / 2 : el.textAlign === 'right' ? ax - w : ax;
|
|
||||||
return { x: bx, y: ay, w, h };
|
|
||||||
}
|
|
||||||
case 'countdown': {
|
|
||||||
const pt = el.previewText || '--d --h';
|
|
||||||
const w = pt.length * finfo.charW;
|
|
||||||
const bx = el.textAlign === 'center' ? ax - w / 2 : el.textAlign === 'right' ? ax - w : ax;
|
|
||||||
return { x: bx, y: ay, w, h: finfo.sizePx };
|
|
||||||
}
|
|
||||||
case 'rectangle':
|
|
||||||
case 'rounded_rectangle':
|
|
||||||
case 'ellipse':
|
|
||||||
case 'arc':
|
|
||||||
return { x: ax, y: ay, w: el.width, h: el.height };
|
|
||||||
case 'pixel':
|
|
||||||
return { x: ax, y: ay, w: 1, h: 1 };
|
|
||||||
case 'line': {
|
|
||||||
// Same anchor offset the draw path applies, or the hit box sits where
|
|
||||||
// the line used to be.
|
|
||||||
const dx = ax - el.x0, dy = ay - el.y0;
|
|
||||||
return {
|
|
||||||
x: Math.min(el.x0, el.x1) + dx, y: Math.min(el.y0, el.y1) + dy,
|
|
||||||
w: Math.max(1, Math.abs(el.x1 - el.x0)),
|
|
||||||
h: Math.max(1, Math.abs(el.y1 - el.y0)),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
case 'divider':
|
|
||||||
return el.orientation === 'horizontal'
|
|
||||||
? { x: 0, y: ay, w: matrixW, h: 1 }
|
|
||||||
: { x: ax, y: 0, w: 1, h: matrixH };
|
|
||||||
case 'progress_bar':
|
|
||||||
return { x: ax, y: ay, w: el.barWidth ?? 60, h: el.barHeight ?? 6 };
|
|
||||||
case 'marquee': {
|
|
||||||
const mfinfo = FONT_MAP[el.font] || FONT_MAP.press_start;
|
|
||||||
return { x: 0, y: ay, w: matrixW, h: mfinfo.sizePx };
|
|
||||||
}
|
|
||||||
case 'gauge':
|
|
||||||
return { x: ax, y: ay, w: el.width ?? 32, h: el.height ?? 32 };
|
|
||||||
case 'sparkline':
|
|
||||||
return { x: ax, y: ay, w: el.width ?? 40, h: el.height ?? 12 };
|
|
||||||
case 'pips': {
|
|
||||||
const pc = el.count ?? 5, ps = el.pipSize ?? 4, pg = el.pipSpacing ?? 2;
|
|
||||||
return { x: ax, y: ay, w: pc * ps + (pc - 1) * pg, h: ps };
|
|
||||||
}
|
|
||||||
case 'section': {
|
|
||||||
// Was 0x0, so the element was unselectable except through the 3px
|
|
||||||
// hit-test padding and drew nothing at all -- a user adding one from
|
|
||||||
// the palette saw an empty canvas.
|
|
||||||
// Same font resolution as the draw case below, or the box will not
|
|
||||||
// match the glyphs: getBoundingBox's shared `finfo` falls back to
|
|
||||||
// press_start, and a section has no font of its own.
|
|
||||||
const sinfo = FONT_MAP[el.font] || FONT_MAP.four_by_six;
|
|
||||||
const label = el.label || 'Section';
|
|
||||||
return { x: ax, y: ay, w: label.length * sinfo.charW, h: sinfo.sizePx };
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return { x: ax, y: ay, w: 4, h: 4 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Resize handle support ─────────────────────────────────────────────
|
|
||||||
// Returns 8 handle points for a rectangle in LED pixel space
|
|
||||||
function _getRectHandles(el, matrixW, matrixH) {
|
|
||||||
const { x: ax, y: ay } = computeActualPos(el, matrixW, matrixH);
|
|
||||||
const w = el.width, h = el.height;
|
|
||||||
const cx = ax + w / 2, cy = ay + h / 2;
|
|
||||||
return {
|
|
||||||
nw: { x: ax, y: ay },
|
|
||||||
n: { x: cx, y: ay },
|
|
||||||
ne: { x: ax + w, y: ay },
|
|
||||||
w: { x: ax, y: cy },
|
|
||||||
e: { x: ax + w, y: cy },
|
|
||||||
sw: { x: ax, y: ay + h },
|
|
||||||
s: { x: cx, y: ay + h },
|
|
||||||
se: { x: ax + w, y: ay + h },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Returns the handle direction under LED-space point (lx, ly), or null
|
|
||||||
function getResizeHandle(el, lx, ly, matrixW, matrixH) {
|
|
||||||
if (!RESIZABLE_TYPES.includes(el.type)) return null;
|
|
||||||
const handles = _getRectHandles(el, matrixW, matrixH);
|
|
||||||
const PAD = 4;
|
|
||||||
for (const [dir, pt] of Object.entries(handles)) {
|
|
||||||
if (Math.abs(lx - pt.x) <= PAD && Math.abs(ly - pt.y) <= PAD) return dir;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const _HANDLE_CURSORS = {
|
|
||||||
nw: 'nw-resize', n: 'n-resize', ne: 'ne-resize',
|
|
||||||
w: 'w-resize', e: 'e-resize',
|
|
||||||
sw: 'sw-resize', s: 's-resize', se: 'se-resize',
|
|
||||||
};
|
|
||||||
function getCursorForHandle(handle) {
|
|
||||||
return _HANDLE_CURSORS[handle] || 'crosshair';
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Hit test ─────────────────────────────────────────────────────────
|
|
||||||
function hitTest(el, lx, ly, matrixW, matrixH) {
|
|
||||||
const PAD = 3;
|
|
||||||
const bb = getBoundingBox(el, matrixW, matrixH);
|
|
||||||
return (
|
|
||||||
lx >= bb.x - PAD && lx <= bb.x + bb.w + PAD &&
|
|
||||||
ly >= bb.y - PAD && ly <= bb.y + bb.h + PAD
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Draw a single element ─────────────────────────────────────────────
|
|
||||||
function _drawElement(ctx, el, SCALE, matrixW, matrixH, opts = {}) {
|
|
||||||
const s = SCALE;
|
|
||||||
const { x: ax, y: ay } = computeActualPos(el, matrixW, matrixH);
|
|
||||||
const belowBreakpoint = el.minWidth > 0 && matrixW < el.minWidth;
|
|
||||||
const hidden = el.visible === false;
|
|
||||||
|
|
||||||
ctx.save();
|
|
||||||
if (hidden) ctx.globalAlpha = 0.12;
|
|
||||||
else if (belowBreakpoint) ctx.globalAlpha = 0.25;
|
|
||||||
|
|
||||||
// Blink animation: when blinkOff, fully hide blinking elements
|
|
||||||
if (el.blink) {
|
|
||||||
if (opts.blinkOff) { ctx.restore(); return; }
|
|
||||||
ctx.globalAlpha *= 0.55;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper: compute draw X for text alignment
|
|
||||||
const _textX = (text, finfo) => {
|
|
||||||
const tw = text.length * finfo.charW * s;
|
|
||||||
if (el.textAlign === 'center') return ax * s - tw / 2;
|
|
||||||
if (el.textAlign === 'right') return ax * s - tw;
|
|
||||||
return ax * s;
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
switch (el.type) {
|
|
||||||
case 'section': {
|
|
||||||
// A design-time label: it marks a region for the author and is not
|
|
||||||
// emitted into the generated plugin. There was no case here at all,
|
|
||||||
// so adding "Section Label" from the palette drew nothing and left
|
|
||||||
// the user with an apparently broken control.
|
|
||||||
const sfinfo = FONT_MAP[el.font] || FONT_MAP.four_by_six;
|
|
||||||
ctx.font = `${sfinfo.sizePx * s}px ${sfinfo.family}`;
|
|
||||||
ctx.fillStyle = `rgba(${el.r ?? 120},${el.g ?? 120},${el.b ?? 120},0.85)`;
|
|
||||||
ctx.textBaseline = 'top';
|
|
||||||
ctx.fillText(el.label || 'Section', ax * s, ay * s);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'text':
|
|
||||||
case 'dynamic_text':
|
|
||||||
case 'clock': {
|
|
||||||
const finfo = FONT_MAP[el.font] || FONT_MAP.press_start;
|
|
||||||
const key = el.binding?.key || '?';
|
|
||||||
const pv = opts.previewValues?.[key];
|
|
||||||
// Substitute {variable} tokens in text using previewValues
|
|
||||||
const _subVars = str => (str || '').replace(/\{(\w+)\}/g, (_, k) => {
|
|
||||||
const v = opts.previewValues?.[k];
|
|
||||||
return v !== undefined && v !== '' ? String(v) : `{${k}}`;
|
|
||||||
});
|
|
||||||
const displayText =
|
|
||||||
el.type === 'text' ? _subVars(el.text || '')
|
|
||||||
: el.type === 'clock' ? (el.format || '%H:%M')
|
|
||||||
: (pv !== undefined && pv !== '' ? String(pv) : `{${key}}`);
|
|
||||||
ctx.font = `${finfo.sizePx * s}px ${finfo.family}`;
|
|
||||||
ctx.fillStyle = `rgb(${el.r},${el.g},${el.b})`;
|
|
||||||
ctx.fillText(displayText, _textX(displayText, finfo), (ay + finfo.sizePx) * s);
|
|
||||||
// Second line (text and clock)
|
|
||||||
if (el.type === 'text' && el.text2) {
|
|
||||||
const t2 = _subVars(el.text2);
|
|
||||||
const y2 = ay + finfo.sizePx + (el.lineSpacing ?? 2);
|
|
||||||
ctx.fillText(t2, _textX(t2, finfo), (y2 + finfo.sizePx) * s);
|
|
||||||
}
|
|
||||||
if (el.type === 'clock' && el.format2) {
|
|
||||||
const y2 = ay + finfo.sizePx + (el.lineSpacing ?? 2);
|
|
||||||
ctx.fillText(el.format2, _textX(el.format2, finfo), (y2 + finfo.sizePx) * s);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'countdown': {
|
|
||||||
const finfo = FONT_MAP[el.font] || FONT_MAP.press_start;
|
|
||||||
const t = el.previewText || '--d --h';
|
|
||||||
ctx.font = `${finfo.sizePx * s}px ${finfo.family}`;
|
|
||||||
ctx.fillStyle = `rgb(${el.r},${el.g},${el.b})`;
|
|
||||||
ctx.fillText(t, _textX(t, finfo), (ay + finfo.sizePx) * s);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'rectangle': {
|
|
||||||
const rx = ax * s, ry = ay * s;
|
|
||||||
const rw = el.width * s, rh = el.height * s;
|
|
||||||
if (el.hasFill) {
|
|
||||||
ctx.fillStyle = `rgb(${el.fillR},${el.fillG},${el.fillB})`;
|
|
||||||
ctx.fillRect(rx, ry, rw, rh);
|
|
||||||
}
|
|
||||||
if (el.hasOutline) {
|
|
||||||
ctx.strokeStyle = `rgb(${el.outR},${el.outG},${el.outB})`;
|
|
||||||
// 1 LED pixel, not 1 canvas pixel: at SCALE>1 an unscaled stroke
|
|
||||||
// renders thinner than the geometry it outlines.
|
|
||||||
ctx.lineWidth = s;
|
|
||||||
ctx.strokeRect(rx, ry, rw, rh);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'ellipse': {
|
|
||||||
const cx = (ax + el.width / 2) * s;
|
|
||||||
const cy = (ay + el.height / 2) * s;
|
|
||||||
const lwPx = s; // 1 LED pixel
|
|
||||||
// Inset by half the stroke, which straddles the path, so the outline
|
|
||||||
// stays within the element's bounds.
|
|
||||||
const rx = Math.max(0, (el.width / 2) * s - (el.hasOutline ? lwPx / 2 : 0));
|
|
||||||
const ry = Math.max(0, (el.height / 2) * s - (el.hasOutline ? lwPx / 2 : 0));
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2);
|
|
||||||
if (el.hasFill) {
|
|
||||||
ctx.fillStyle = `rgb(${el.fillR},${el.fillG},${el.fillB})`;
|
|
||||||
ctx.fill();
|
|
||||||
}
|
|
||||||
if (el.hasOutline) {
|
|
||||||
ctx.strokeStyle = `rgb(${el.outR},${el.outG},${el.outB})`;
|
|
||||||
ctx.lineWidth = lwPx;
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'arc': {
|
|
||||||
const cx = (ax + el.width / 2) * s;
|
|
||||||
const cy = (ay + el.height / 2) * s;
|
|
||||||
const lwPx = Math.max(1, el.lineWidth || 2) * s;
|
|
||||||
const rx = Math.max(0, (el.width / 2) * s - lwPx / 2);
|
|
||||||
const ry = Math.max(0, (el.height / 2) * s - lwPx / 2);
|
|
||||||
// PIL: 0°=right, clockwise. Canvas: same with anticlockwise=false
|
|
||||||
const startRad = (el.startAngle ?? 0) * Math.PI / 180;
|
|
||||||
const endRad = (el.endAngle ?? 270) * Math.PI / 180;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.ellipse(cx, cy, rx, ry, 0, startRad, endRad, false);
|
|
||||||
ctx.strokeStyle = `rgb(${el.r},${el.g},${el.b})`;
|
|
||||||
ctx.lineWidth = lwPx;
|
|
||||||
ctx.stroke();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'pixel': {
|
|
||||||
ctx.fillStyle = `rgb(${el.r},${el.g},${el.b})`;
|
|
||||||
ctx.fillRect(ax * s, ay * s, s, s);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'rounded_rectangle': {
|
|
||||||
const rx = ax * s, ry = ay * s;
|
|
||||||
const rw = el.width * s, rh = el.height * s;
|
|
||||||
const rad = Math.min((el.borderRadius ?? 3) * s, rw / 2, rh / 2);
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.roundRect(rx, ry, rw, rh, rad);
|
|
||||||
if (el.hasFill) {
|
|
||||||
ctx.fillStyle = `rgb(${el.fillR},${el.fillG},${el.fillB})`;
|
|
||||||
ctx.fill();
|
|
||||||
}
|
|
||||||
if (el.hasOutline) {
|
|
||||||
ctx.strokeStyle = `rgb(${el.outR},${el.outG},${el.outB})`;
|
|
||||||
ctx.lineWidth = s;
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'line': {
|
|
||||||
// ax/ay resolve from el.x0 for a line, so this is the anchor offset.
|
|
||||||
// Without it a line stayed put while every other type moved.
|
|
||||||
const dx = ax - el.x0, dy = ay - el.y0;
|
|
||||||
ctx.strokeStyle = `rgb(${el.r},${el.g},${el.b})`;
|
|
||||||
ctx.lineWidth = Math.max(1, el.lineWidth || 1) * s;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo((el.x0 + dx) * s, (el.y0 + dy) * s);
|
|
||||||
ctx.lineTo((el.x1 + dx) * s, (el.y1 + dy) * s);
|
|
||||||
ctx.stroke();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'divider': {
|
|
||||||
const isH = (el.orientation || 'horizontal') === 'horizontal';
|
|
||||||
ctx.strokeStyle = `rgb(${el.r},${el.g},${el.b})`;
|
|
||||||
ctx.lineWidth = s;
|
|
||||||
ctx.beginPath();
|
|
||||||
if (isH) {
|
|
||||||
ctx.moveTo(0, ay * s + 0.5);
|
|
||||||
ctx.lineTo(_canvas.width, ay * s + 0.5);
|
|
||||||
} else {
|
|
||||||
ctx.moveTo(ax * s + 0.5, 0);
|
|
||||||
ctx.lineTo(ax * s + 0.5, _canvas.height);
|
|
||||||
}
|
|
||||||
ctx.stroke();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'pips': {
|
|
||||||
const pipCount = Math.max(1, el.count ?? 5);
|
|
||||||
const pvPips = opts.previewValues?.[el.binding?.key];
|
|
||||||
const filledN = pvPips !== undefined
|
|
||||||
? Math.max(0, Math.min(pipCount, Math.round(parseFloat(pvPips) || 0)))
|
|
||||||
: Math.max(0, Math.min(pipCount, el.filled ?? 3));
|
|
||||||
const ps = Math.max(1, el.pipSize ?? 4);
|
|
||||||
const pg = Math.max(0, el.pipSpacing ?? 2);
|
|
||||||
for (let i = 0; i < pipCount; i++) {
|
|
||||||
const isFilled = i < filledN;
|
|
||||||
if (!isFilled && !el.showEmpty) continue;
|
|
||||||
ctx.fillStyle = isFilled
|
|
||||||
? `rgb(${el.r},${el.g},${el.b})`
|
|
||||||
: `rgb(${el.emptyR ?? 50},${el.emptyG ?? 50},${el.emptyB ?? 50})`;
|
|
||||||
ctx.fillRect((ax + i * (ps + pg)) * s, ay * s, ps * s, ps * s);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'sparkline': {
|
|
||||||
const slW = el.width ?? 40, slH = el.height ?? 12;
|
|
||||||
const count = Math.max(1, el.barCount ?? 8);
|
|
||||||
const spacing = el.barSpacing ?? 1;
|
|
||||||
const barW = Math.max(1, Math.floor((slW - spacing * (count - 1)) / count));
|
|
||||||
const rawVals = (el.previewData || '').split(',')
|
|
||||||
.map(v => parseFloat(v.trim())).filter(n => !isNaN(n));
|
|
||||||
while (rawVals.length < count) rawVals.push(0);
|
|
||||||
const maxV = Math.max(...rawVals.slice(0, count), 0.001);
|
|
||||||
const rx = ax * s, ry = ay * s;
|
|
||||||
if (el.hasBg) {
|
|
||||||
ctx.fillStyle = `rgb(${el.bgR ?? 30},${el.bgG ?? 30},${el.bgB ?? 30})`;
|
|
||||||
ctx.fillRect(rx, ry, slW * s, slH * s);
|
|
||||||
}
|
|
||||||
ctx.fillStyle = `rgb(${el.r},${el.g},${el.b})`;
|
|
||||||
for (let i = 0; i < count; i++) {
|
|
||||||
const norm = Math.max(0, Math.min(1, rawVals[i] / maxV));
|
|
||||||
const barH = Math.max(1, Math.round(slH * norm));
|
|
||||||
const bx = rx + (barW + spacing) * i * s;
|
|
||||||
const by = ry + (slH - barH) * s;
|
|
||||||
ctx.fillRect(bx, by, barW * s, barH * s);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'gauge': {
|
|
||||||
const gw = (el.width ?? 32), gh = (el.height ?? 32);
|
|
||||||
const cx = (ax + gw / 2) * s, cy = (ay + gh / 2) * s;
|
|
||||||
const rx = (gw / 2) * s, ry = (gh / 2) * s;
|
|
||||||
const lw = Math.max(1, (el.lineWidth ?? 3));
|
|
||||||
// rx/ry are canvas pixels ((gw/2)*s) but lw is LED pixels, so
|
|
||||||
// insetting by lw/2 under-corrected by the scale factor while the
|
|
||||||
// stroke was drawn at lw*s -- the arc spilled outside the element's
|
|
||||||
// reported bounding box at any SCALE > 1, and the preview stopped
|
|
||||||
// matching the generated PIL output.
|
|
||||||
const lwPx = lw * s;
|
|
||||||
const startDeg = el.startAngle ?? 135;
|
|
||||||
const endDeg = el.endAngle ?? 45;
|
|
||||||
// Arc sweep: from startDeg clockwise to endDeg (PIL convention)
|
|
||||||
const totalSweep = ((endDeg - startDeg) + 360) % 360 || 360;
|
|
||||||
const pvGauge = opts.previewValues?.[el.binding?.key];
|
|
||||||
const pct = pvGauge !== undefined
|
|
||||||
? Math.max(0, Math.min(100, parseFloat(pvGauge) || 0)) / 100
|
|
||||||
: Math.max(0, Math.min(100, el.previewPct ?? 65)) / 100;
|
|
||||||
const fillSweep = totalSweep * pct;
|
|
||||||
const toRad = deg => (deg - 90) * Math.PI / 180; // canvas 0=top, PIL 0=right → offset -90
|
|
||||||
|
|
||||||
// Track arc
|
|
||||||
if (el.hasTrack !== false) {
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.ellipse(cx, cy, rx - lwPx / 2, ry - lwPx / 2, 0, toRad(startDeg), toRad(startDeg + totalSweep), false);
|
|
||||||
ctx.strokeStyle = `rgb(${el.trackR ?? 40},${el.trackG ?? 40},${el.trackB ?? 40})`;
|
|
||||||
ctx.lineWidth = lwPx;
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
|
||||||
// Fill arc
|
|
||||||
if (pct > 0) {
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.ellipse(cx, cy, rx - lwPx / 2, ry - lwPx / 2, 0, toRad(startDeg), toRad(startDeg + fillSweep), false);
|
|
||||||
ctx.strokeStyle = `rgb(${el.r},${el.g},${el.b})`;
|
|
||||||
ctx.lineWidth = lwPx;
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
|
||||||
// Centre label
|
|
||||||
if (el.showLabel) {
|
|
||||||
const gfinfo = FONT_MAP[el.font || 'four_by_six'] || FONT_MAP.four_by_six;
|
|
||||||
const labelText = Math.round(pct * 100) + '%';
|
|
||||||
ctx.font = `${gfinfo.sizePx * s}px ${gfinfo.family}`;
|
|
||||||
ctx.fillStyle = `rgb(${el.labelR ?? 200},${el.labelG ?? 200},${el.labelB ?? 200})`;
|
|
||||||
const ltw = ctx.measureText(labelText).width;
|
|
||||||
ctx.fillText(labelText, cx - ltw / 2, cy + (gfinfo.sizePx * s) / 2);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'marquee': {
|
|
||||||
const finfo = FONT_MAP[el.font] || FONT_MAP.press_start;
|
|
||||||
const text = el.text || 'Scrolling text';
|
|
||||||
const tw = text.length * finfo.charW * s;
|
|
||||||
const gap = (el.gap ?? 16) * s;
|
|
||||||
const totalW = tw + gap;
|
|
||||||
const tick = opts.animTick ?? 0;
|
|
||||||
const speed = (el.scrollSpeed ?? 1) * 2;
|
|
||||||
const scrolled = (tick * speed) % totalW;
|
|
||||||
// left: text enters from right; right: text enters from left
|
|
||||||
const startX = el.direction === 'right'
|
|
||||||
? scrolled - tw
|
|
||||||
: matrixW * s - scrolled;
|
|
||||||
ctx.font = `${finfo.sizePx * s}px ${finfo.family}`;
|
|
||||||
ctx.fillStyle = `rgb(${el.r},${el.g},${el.b})`;
|
|
||||||
// Clip to canvas width so text doesn't bleed outside
|
|
||||||
ctx.save();
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.rect(0, ay * s - 1, matrixW * s, (finfo.sizePx + 2) * s);
|
|
||||||
ctx.clip();
|
|
||||||
for (let i = -1; i <= 2; i++) {
|
|
||||||
ctx.fillText(text, startX + i * totalW, (ay + finfo.sizePx) * s);
|
|
||||||
}
|
|
||||||
ctx.restore();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'progress_bar': {
|
|
||||||
const bw = el.barWidth ?? 60, bh = el.barHeight ?? 6;
|
|
||||||
const pvPb = opts.previewValues?.[el.binding?.key];
|
|
||||||
const pct = pvPb !== undefined
|
|
||||||
? Math.max(0, Math.min(100, parseFloat(pvPb) || 0)) / 100
|
|
||||||
: Math.max(0, Math.min(100, el.previewPct ?? 65)) / 100;
|
|
||||||
const rx = ax * s, ry = ay * s;
|
|
||||||
if (el.hasBg) {
|
|
||||||
ctx.fillStyle = `rgb(${el.bgR ?? 30},${el.bgG ?? 30},${el.bgB ?? 30})`;
|
|
||||||
ctx.fillRect(rx, ry, bw * s, bh * s);
|
|
||||||
}
|
|
||||||
const fillW = Math.max(0, Math.round(bw * pct));
|
|
||||||
if (fillW > 0) {
|
|
||||||
ctx.fillStyle = `rgb(${el.r},${el.g},${el.b})`;
|
|
||||||
ctx.fillRect(rx, ry, fillW * s, bh * s);
|
|
||||||
}
|
|
||||||
if (el.hasOutline) {
|
|
||||||
ctx.strokeStyle = `rgb(${el.outR ?? 100},${el.outG ?? 100},${el.outB ?? 100})`;
|
|
||||||
ctx.lineWidth = s;
|
|
||||||
ctx.strokeRect(rx, ry, bw * s, bh * s);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (belowBreakpoint) {
|
|
||||||
ctx.globalAlpha = 0.6;
|
|
||||||
const bb = getBoundingBox(el, matrixW, matrixH);
|
|
||||||
ctx.font = `${Math.max(8, s * 2)}px monospace`;
|
|
||||||
ctx.fillStyle = '#facc15';
|
|
||||||
ctx.fillText(`≥${el.minWidth}px`, bb.x * s, (bb.y + 4) * s);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
ctx.restore();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Selection indicator ──────────────────────────────────────────────
|
|
||||||
function _drawSelection(ctx, el, SCALE, matrixW, matrixH) {
|
|
||||||
const bb = getBoundingBox(el, matrixW, matrixH);
|
|
||||||
const PAD = 2, s = SCALE;
|
|
||||||
const rx = bb.x * s - PAD, ry = bb.y * s - PAD;
|
|
||||||
const rw = bb.w * s + PAD * 2, rh = bb.h * s + PAD * 2;
|
|
||||||
|
|
||||||
ctx.save();
|
|
||||||
ctx.strokeStyle = '#3b82f6';
|
|
||||||
ctx.lineWidth = 1;
|
|
||||||
ctx.setLineDash([3, 2]);
|
|
||||||
ctx.strokeRect(rx, ry, rw, rh);
|
|
||||||
ctx.setLineDash([]);
|
|
||||||
|
|
||||||
if (el.xAnchor || el.yAnchor) {
|
|
||||||
ctx.font = `${Math.max(7, s)}px sans-serif`;
|
|
||||||
ctx.fillStyle = '#a78bfa';
|
|
||||||
const anchorText = [
|
|
||||||
el.xAnchor ? `x:${el.xAnchor[0]}` : '',
|
|
||||||
el.yAnchor ? `y:${el.yAnchor[0]}` : '',
|
|
||||||
].filter(Boolean).join(' ');
|
|
||||||
if (anchorText) ctx.fillText(anchorText, rx + 1, ry - 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resize handles: on rect, rounded rect, ellipse
|
|
||||||
if (RESIZABLE_TYPES.includes(el.type)) {
|
|
||||||
const handles = _getRectHandles(el, matrixW, matrixH);
|
|
||||||
const HS = 5;
|
|
||||||
ctx.fillStyle = 'white';
|
|
||||||
ctx.strokeStyle = '#2563eb';
|
|
||||||
ctx.lineWidth = 1;
|
|
||||||
for (const pt of Object.values(handles)) {
|
|
||||||
const hx = pt.x * s - HS / 2;
|
|
||||||
const hy = pt.y * s - HS / 2;
|
|
||||||
ctx.fillRect(hx, hy, HS, HS);
|
|
||||||
ctx.strokeRect(hx, hy, HS, HS);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Corner dots for non-rectangle elements
|
|
||||||
ctx.fillStyle = '#3b82f6';
|
|
||||||
const HS = 4;
|
|
||||||
for (const [hx, hy] of [
|
|
||||||
[rx - HS / 2, ry - HS / 2], [rx + rw - HS / 2, ry - HS / 2],
|
|
||||||
[rx - HS / 2, ry + rh - HS / 2], [rx + rw - HS / 2, ry + rh - HS / 2],
|
|
||||||
]) ctx.fillRect(hx, hy, HS, HS);
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.restore();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Dimension tooltip while dragging ─────────────────────────────────
|
|
||||||
function drawDragTooltip(ctx, el, SCALE, matrixW, matrixH) {
|
|
||||||
const bb = getBoundingBox(el, matrixW, matrixH);
|
|
||||||
const label = el.type === 'rectangle'
|
|
||||||
? `${el.width}×${el.height}`
|
|
||||||
: `${bb.x},${bb.y}`;
|
|
||||||
const s = SCALE;
|
|
||||||
ctx.save();
|
|
||||||
ctx.font = `${Math.max(9, s * 1.5)}px monospace`;
|
|
||||||
const tw = ctx.measureText(label).width;
|
|
||||||
const tx = bb.x * s, ty = (bb.y - 2) * s;
|
|
||||||
ctx.fillStyle = 'rgba(0,0,0,0.7)';
|
|
||||||
ctx.fillRect(tx - 2, ty - 10, tw + 4, 12);
|
|
||||||
ctx.fillStyle = 'white';
|
|
||||||
ctx.fillText(label, tx, ty);
|
|
||||||
ctx.restore();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Public API ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function init(canvasEl) {
|
|
||||||
_canvas = canvasEl;
|
|
||||||
_ctx = canvasEl.getContext('2d');
|
|
||||||
}
|
|
||||||
|
|
||||||
function setGrid(show) { _showGrid = show; }
|
|
||||||
|
|
||||||
function updateCanvasSize(matrixW, matrixH, SCALE) {
|
|
||||||
if (!_canvas) return;
|
|
||||||
_canvas.width = matrixW * SCALE;
|
|
||||||
_canvas.height = matrixH * SCALE;
|
|
||||||
}
|
|
||||||
|
|
||||||
function render(elements, selectedId, matrixW, matrixH, SCALE, opts = {}) {
|
|
||||||
if (!_ctx) return;
|
|
||||||
const cW = matrixW * SCALE, cH = matrixH * SCALE;
|
|
||||||
|
|
||||||
const bg = opts.bgColor;
|
|
||||||
_ctx.fillStyle = bg ? `rgb(${bg.r},${bg.g},${bg.b})` : '#000';
|
|
||||||
_ctx.fillRect(0, 0, cW, cH);
|
|
||||||
|
|
||||||
if (_showGrid) {
|
|
||||||
_ctx.strokeStyle = 'rgba(255,255,255,0.07)';
|
|
||||||
_ctx.lineWidth = 0.5;
|
|
||||||
for (let x = SCALE; x < cW; x += SCALE) {
|
|
||||||
_ctx.beginPath(); _ctx.moveTo(x, 0); _ctx.lineTo(x, cH); _ctx.stroke();
|
|
||||||
}
|
|
||||||
for (let y = SCALE; y < cH; y += SCALE) {
|
|
||||||
_ctx.beginPath(); _ctx.moveTo(0, y); _ctx.lineTo(cW, y); _ctx.stroke();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const el of elements) _drawElement(_ctx, el, SCALE, matrixW, matrixH, opts);
|
|
||||||
|
|
||||||
if (opts.showRuler) {
|
|
||||||
_ctx.save();
|
|
||||||
_ctx.fillStyle = 'rgba(255,255,255,0.08)';
|
|
||||||
_ctx.fillRect(0, 0, cW, SCALE); // top strip
|
|
||||||
_ctx.fillRect(0, 0, SCALE, cH); // left strip
|
|
||||||
_ctx.strokeStyle = 'rgba(255,255,255,0.5)';
|
|
||||||
_ctx.fillStyle = 'rgba(255,255,255,0.6)';
|
|
||||||
_ctx.font = `${Math.max(5, SCALE - 1)}px monospace`;
|
|
||||||
const step = SCALE >= 4 ? 8 : 16;
|
|
||||||
for (let px = 0; px <= matrixW; px += step) {
|
|
||||||
const cx = px * SCALE;
|
|
||||||
const major = px % 32 === 0;
|
|
||||||
_ctx.lineWidth = 0.5;
|
|
||||||
_ctx.beginPath(); _ctx.moveTo(cx, 0); _ctx.lineTo(cx, major ? SCALE : SCALE * 0.5); _ctx.stroke();
|
|
||||||
if (major && px > 0 && px < matrixW - 4) _ctx.fillText(String(px), cx + 1, SCALE - 1);
|
|
||||||
}
|
|
||||||
for (let py = 0; py <= matrixH; py += step) {
|
|
||||||
const cy = py * SCALE;
|
|
||||||
const major = py % 32 === 0;
|
|
||||||
_ctx.beginPath(); _ctx.moveTo(0, cy); _ctx.lineTo(major ? SCALE : SCALE * 0.5, cy); _ctx.stroke();
|
|
||||||
if (major && py > 0 && py < matrixH - 4) _ctx.fillText(String(py), 1, cy + SCALE - 1);
|
|
||||||
}
|
|
||||||
_ctx.restore();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (opts.showGuides) {
|
|
||||||
_ctx.save();
|
|
||||||
_ctx.strokeStyle = 'rgba(255,60,60,0.45)';
|
|
||||||
_ctx.lineWidth = 1;
|
|
||||||
_ctx.setLineDash([4, 3]);
|
|
||||||
const mx = Math.floor(cW / 2) + 0.5;
|
|
||||||
const my = Math.floor(cH / 2) + 0.5;
|
|
||||||
_ctx.beginPath(); _ctx.moveTo(mx, 0); _ctx.lineTo(mx, cH); _ctx.stroke();
|
|
||||||
_ctx.beginPath(); _ctx.moveTo(0, my); _ctx.lineTo(cW, my); _ctx.stroke();
|
|
||||||
_ctx.setLineDash([]);
|
|
||||||
_ctx.restore();
|
|
||||||
}
|
|
||||||
|
|
||||||
const sel = selectedId != null ? elements.find(e => e.id === selectedId) : null;
|
|
||||||
if (sel) {
|
|
||||||
_drawSelection(_ctx, sel, SCALE, matrixW, matrixH);
|
|
||||||
if (opts.showTooltip) drawDragTooltip(_ctx, sel, SCALE, matrixW, matrixH);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
init, render, setGrid, updateCanvasSize,
|
|
||||||
hitTest, getBoundingBox, computeActualPos, resolveAnchor,
|
|
||||||
getResizeHandle, getCursorForHandle,
|
|
||||||
ELEMENT_DEFAULTS, FONT_MAP, DISPLAY_PRESETS, RESIZABLE_TYPES,
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,300 +0,0 @@
|
|||||||
"""
|
|
||||||
{{ plugin_name }} — LEDMatrix Plugin
|
|
||||||
Generated by LEDMatrix Plugin Composer on {{ generated_date }}
|
|
||||||
|
|
||||||
Extension points:
|
|
||||||
update() → add HTTP/MQTT data-fetching logic here
|
|
||||||
_get_display_values() → map fetched data to display strings
|
|
||||||
display() → add new elements or adapt layout per display size
|
|
||||||
"""
|
|
||||||
from src.plugin_system.base_plugin import BasePlugin
|
|
||||||
{% if has_clock or has_countdown %}
|
|
||||||
from datetime import datetime
|
|
||||||
{% endif %}
|
|
||||||
{% if has_blink %}
|
|
||||||
import time
|
|
||||||
{% endif %}
|
|
||||||
{% if has_text_template %}
|
|
||||||
from collections import defaultdict
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
|
|
||||||
class {{ class_name }}(BasePlugin):
|
|
||||||
|
|
||||||
def __init__(self, plugin_id, config, display_manager, cache_manager, plugin_manager):
|
|
||||||
super().__init__(plugin_id, config, display_manager, cache_manager, plugin_manager)
|
|
||||||
{% for var in config_vars %}
|
|
||||||
self.{{ var.key }} = config.get({{ var.key | tojson }}, {{ var.default | tojson }})
|
|
||||||
{% endfor %}
|
|
||||||
# Live data cache — populated by update(); always {} in static layouts
|
|
||||||
self._data = {}
|
|
||||||
|
|
||||||
def update(self):
|
|
||||||
"""Fetch and refresh display data.
|
|
||||||
|
|
||||||
For dynamic plugins: fetch from APIs/MQTT here and store in self._data.
|
|
||||||
_get_display_values() will read self._data to produce display strings.
|
|
||||||
"""
|
|
||||||
# --- Data sources (add fetch logic here for dynamic plugins) ---
|
|
||||||
pass
|
|
||||||
|
|
||||||
def _get_display_values(self):
|
|
||||||
"""Map config variables and live data to display-ready strings.
|
|
||||||
|
|
||||||
This is the single extension point for v2 data sources:
|
|
||||||
add self._data lookups here once update() populates them.
|
|
||||||
"""
|
|
||||||
return {
|
|
||||||
{% for var in config_vars %}
|
|
||||||
{{ var.key | tojson }}: str(self.{{ var.key }}),
|
|
||||||
{% endfor %}
|
|
||||||
}
|
|
||||||
|
|
||||||
def display(self, force_clear=False):
|
|
||||||
try:
|
|
||||||
{% if has_text_template %}
|
|
||||||
values = defaultdict(str, self._get_display_values())
|
|
||||||
{% else %}
|
|
||||||
values = self._get_display_values()
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
if force_clear:
|
|
||||||
self.display_manager.clear()
|
|
||||||
|
|
||||||
width = self.display_manager.width
|
|
||||||
height = self.display_manager.height
|
|
||||||
{% if bg_color %}
|
|
||||||
|
|
||||||
self.display_manager.draw.rectangle([0, 0, width, height], fill={{ bg_color }})
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
# ── Elements (rendered bottom to top) ──────────────────────────
|
|
||||||
{% for el in elements %}
|
|
||||||
{% set p = " " if el.min_width > 0 else " " %}
|
|
||||||
{% set pi = (p + " ") if el.blink else p %}
|
|
||||||
{% if el.min_width > 0 %}
|
|
||||||
if width >= {{ el.min_width }}: # breakpoint: {{ el.min_width }}px+ displays only
|
|
||||||
{% endif %}
|
|
||||||
{% if el.blink %}
|
|
||||||
{{ p }}if int(time.time() * 2) % 2:
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if el.type == 'text' %}
|
|
||||||
{{ pi }}self.display_manager.draw_text(
|
|
||||||
{% if el.text_is_template %}
|
|
||||||
{{ pi }} {{ el.text | tojson }}.format_map(values),
|
|
||||||
{% else %}
|
|
||||||
{{ pi }} {{ el.text | tojson }},
|
|
||||||
{% endif %}
|
|
||||||
{{ pi }} x={{ el.x_expr }}, y={{ el.y_expr }},
|
|
||||||
{{ pi }} color={{ el.rgb_tuple }},
|
|
||||||
{{ pi }} font=self.display_manager.{{ el.font_attr }},
|
|
||||||
{{ pi }})
|
|
||||||
{% if el.text2 %}
|
|
||||||
{{ pi }}self.display_manager.draw_text(
|
|
||||||
{% if el.text_is_template %}
|
|
||||||
{{ pi }} {{ el.text2 | tojson }}.format_map(values),
|
|
||||||
{% else %}
|
|
||||||
{{ pi }} {{ el.text2 | tojson }},
|
|
||||||
{% endif %}
|
|
||||||
{{ pi }} x={{ el.x2_expr }}, y={{ el.y2_expr }},
|
|
||||||
{{ pi }} color={{ el.rgb_tuple }},
|
|
||||||
{{ pi }} font=self.display_manager.{{ el.font_attr }},
|
|
||||||
{{ pi }})
|
|
||||||
{% endif %}
|
|
||||||
{% elif el.type == 'dynamic_text' %}
|
|
||||||
{% if el.binding_source == 'config' %}
|
|
||||||
{{ pi }}self.display_manager.draw_text(
|
|
||||||
{{ pi }} values.get({{ el.binding_key | tojson }}, ''),
|
|
||||||
{{ pi }} x={{ el.x_expr }}, y={{ el.y_expr }},
|
|
||||||
{{ pi }} color={{ el.rgb_tuple }},
|
|
||||||
{{ pi }} font=self.display_manager.{{ el.font_attr }},
|
|
||||||
{{ pi }})
|
|
||||||
{% endif %}
|
|
||||||
{% elif el.type == 'clock' %}
|
|
||||||
{{ pi }}self.display_manager.draw_text(
|
|
||||||
{{ pi }} datetime.now().strftime({{ el.format | tojson }}),
|
|
||||||
{{ pi }} x={{ el.x_expr }}, y={{ el.y_expr }},
|
|
||||||
{{ pi }} color={{ el.rgb_tuple }},
|
|
||||||
{{ pi }} font=self.display_manager.{{ el.font_attr }},
|
|
||||||
{{ pi }})
|
|
||||||
{% if el.format2 %}
|
|
||||||
{{ pi }}self.display_manager.draw_text(
|
|
||||||
{{ pi }} datetime.now().strftime({{ el.format2 | tojson }}),
|
|
||||||
{{ pi }} x={{ el.x2_expr }}, y={{ el.y2_expr }},
|
|
||||||
{{ pi }} color={{ el.rgb_tuple }},
|
|
||||||
{{ pi }} font=self.display_manager.{{ el.font_attr }},
|
|
||||||
{{ pi }})
|
|
||||||
{% endif %}
|
|
||||||
{% elif el.type == 'countdown' %}
|
|
||||||
{{ pi }}_cd_target = float(values.get({{ el.binding_key | tojson }}, 0) or 0)
|
|
||||||
{{ pi }}_cd_secs = max(0.0, _cd_target - datetime.now().timestamp())
|
|
||||||
{% if el.countdown_format == 'dhms' %}
|
|
||||||
{{ pi }}_cd_d, _cd_rem = divmod(int(_cd_secs), 86400)
|
|
||||||
{{ pi }}_cd_h, _cd_rem = divmod(_cd_rem, 3600)
|
|
||||||
{{ pi }}_cd_m, _cd_s = divmod(_cd_rem, 60)
|
|
||||||
{{ pi }}_cd_str = f'{_cd_d}d {_cd_h:02d}:{_cd_m:02d}:{_cd_s:02d}'
|
|
||||||
{% elif el.countdown_format == 'hms' %}
|
|
||||||
{{ pi }}_cd_h, _cd_rem = divmod(int(_cd_secs), 3600)
|
|
||||||
{{ pi }}_cd_m, _cd_s = divmod(_cd_rem, 60)
|
|
||||||
{{ pi }}_cd_str = f'{_cd_h}h {_cd_m:02d}:{_cd_s:02d}'
|
|
||||||
{% elif el.countdown_format == 'dhm' %}
|
|
||||||
{{ pi }}_cd_d, _cd_rem = divmod(int(_cd_secs), 86400)
|
|
||||||
{{ pi }}_cd_h, _cd_m = divmod(_cd_rem // 60, 60)
|
|
||||||
{{ pi }}_cd_str = f'{_cd_d}d {_cd_h:02d}h {_cd_m:02d}m'
|
|
||||||
{% else %}
|
|
||||||
{{ pi }}_cd_d, _cd_rem = divmod(int(_cd_secs), 86400)
|
|
||||||
{{ pi }}_cd_h = _cd_rem // 3600
|
|
||||||
{{ pi }}_cd_str = f'{_cd_d}d {_cd_h}h'
|
|
||||||
{% endif %}
|
|
||||||
{{ pi }}self.display_manager.draw_text(
|
|
||||||
{{ pi }} _cd_str,
|
|
||||||
{{ pi }} x={{ el.x_expr }}, y={{ el.y_expr }},
|
|
||||||
{{ pi }} color={{ el.rgb_tuple }},
|
|
||||||
{{ pi }} font=self.display_manager.{{ el.font_attr }},
|
|
||||||
{{ pi }})
|
|
||||||
{% elif el.type == 'rectangle' %}
|
|
||||||
{{ pi }}self.display_manager.draw.rectangle(
|
|
||||||
{{ pi }} [{{ el.x_expr }}, {{ el.y_expr }}, {{ el.x2_expr }}, {{ el.y2_expr }}],
|
|
||||||
{{ pi }} fill={{ el.fill_tuple }},
|
|
||||||
{{ pi }} outline={{ el.outline_tuple }},
|
|
||||||
{{ pi }})
|
|
||||||
{% elif el.type == 'arc' %}
|
|
||||||
{{ pi }}self.display_manager.draw.arc(
|
|
||||||
{{ pi }} [{{ el.x_expr }}, {{ el.y_expr }}, {{ el.x2_expr }}, {{ el.y2_expr }}],
|
|
||||||
{{ pi }} start={{ el.start_angle }}, end={{ el.end_angle }},
|
|
||||||
{{ pi }} fill={{ el.rgb_tuple }},
|
|
||||||
{{ pi }} width={{ el.line_width }},
|
|
||||||
{{ pi }})
|
|
||||||
{% elif el.type == 'ellipse' %}
|
|
||||||
{{ pi }}self.display_manager.draw.ellipse(
|
|
||||||
{{ pi }} [{{ el.x_expr }}, {{ el.y_expr }}, {{ el.x2_expr }}, {{ el.y2_expr }}],
|
|
||||||
{{ pi }} fill={{ el.fill_tuple }},
|
|
||||||
{{ pi }} outline={{ el.outline_tuple }},
|
|
||||||
{{ pi }})
|
|
||||||
{% elif el.type == 'pixel' %}
|
|
||||||
{{ pi }}self.display_manager.draw.point(
|
|
||||||
{{ pi }} [{{ el.x_expr }}, {{ el.y_expr }}],
|
|
||||||
{{ pi }} fill={{ el.rgb_tuple }},
|
|
||||||
{{ pi }})
|
|
||||||
{% elif el.type == 'rounded_rectangle' %}
|
|
||||||
{{ pi }}self.display_manager.draw.rounded_rectangle(
|
|
||||||
{{ pi }} [{{ el.x_expr }}, {{ el.y_expr }}, {{ el.x2_expr }}, {{ el.y2_expr }}],
|
|
||||||
{{ pi }} radius={{ el.border_radius }},
|
|
||||||
{{ pi }} fill={{ el.fill_tuple }},
|
|
||||||
{{ pi }} outline={{ el.outline_tuple }},
|
|
||||||
{{ pi }})
|
|
||||||
{% elif el.type in ('line', 'divider') %}
|
|
||||||
{{ pi }}self.display_manager.draw.line(
|
|
||||||
{{ pi }} [{{ el.x0_expr }}, {{ el.y0_expr }}, {{ el.x1_expr }}, {{ el.y1_expr }}],
|
|
||||||
{{ pi }} fill={{ el.rgb_tuple }},
|
|
||||||
{{ pi }} width={{ el.line_width }},
|
|
||||||
{{ pi }})
|
|
||||||
{% elif el.type == 'pips' %}
|
|
||||||
{{ pi }}_pip_filled = max(0, min({{ el.pip_count }}, int(float(values.get({{ el.binding_key | tojson }}, 0) or 0))))
|
|
||||||
{{ pi }}for _pip_i in range({{ el.pip_count }}):
|
|
||||||
{{ pi }} _pip_x = ({{ el.x_expr }}) + _pip_i * ({{ el.pip_size }} + {{ el.pip_spacing }})
|
|
||||||
{{ pi }} _pip_color = {{ el.fill_tuple }} if _pip_i < _pip_filled else {{ el.empty_tuple }}
|
|
||||||
{% if not el.show_empty %}
|
|
||||||
{{ pi }} if _pip_i >= _pip_filled:
|
|
||||||
{{ pi }} continue
|
|
||||||
{% endif %}
|
|
||||||
{{ pi }} self.display_manager.draw.rectangle(
|
|
||||||
{{ pi }} [_pip_x, {{ el.y_expr }}, _pip_x + {{ el.pip_size }} - 1, ({{ el.y_expr }}) + {{ el.pip_size }} - 1],
|
|
||||||
{{ pi }} fill=_pip_color,
|
|
||||||
{{ pi }} )
|
|
||||||
{% elif el.type == 'sparkline' %}
|
|
||||||
{{ pi }}_sl_raw = str(values.get({{ el.binding_key | tojson }}, '') or '')
|
|
||||||
{{ pi }}_sl_vals = [float(v.strip()) for v in _sl_raw.split(',') if v.strip()][:{{ el.bar_count }}]
|
|
||||||
{{ pi }}_sl_vals += [0.0] * max(0, {{ el.bar_count }} - len(_sl_vals))
|
|
||||||
{{ pi }}_sl_max = max(_sl_vals) if any(_sl_vals) else 1.0
|
|
||||||
{{ pi }}_sl_bw = max(1, ({{ el.bar_width_px }} - {{ el.bar_spacing }} * ({{ el.bar_count }} - 1)) // {{ el.bar_count }})
|
|
||||||
{% if el.bg_tuple != 'None' %}
|
|
||||||
{{ pi }}self.display_manager.draw.rectangle(
|
|
||||||
{{ pi }} [{{ el.x_expr }}, {{ el.y_expr }}, ({{ el.x_expr }}) + {{ el.bar_width_px }}, ({{ el.y_expr }}) + {{ el.bar_height_px }}],
|
|
||||||
{{ pi }} fill={{ el.bg_tuple }},
|
|
||||||
{{ pi }})
|
|
||||||
{% endif %}
|
|
||||||
{{ pi }}for _sl_i, _sl_v in enumerate(_sl_vals):
|
|
||||||
{{ pi }} _sl_norm = max(0.0, min(1.0, _sl_v / (_sl_max or 1)))
|
|
||||||
{{ pi }} _sl_bh = max(1, round({{ el.bar_height_px }} * _sl_norm))
|
|
||||||
{{ pi }} _sl_bx = ({{ el.x_expr }}) + (_sl_bw + {{ el.bar_spacing }}) * _sl_i
|
|
||||||
{{ pi }} _sl_by = ({{ el.y_expr }}) + {{ el.bar_height_px }} - _sl_bh
|
|
||||||
{{ pi }} self.display_manager.draw.rectangle(
|
|
||||||
{{ pi }} [_sl_bx, _sl_by, _sl_bx + _sl_bw - 1, _sl_by + _sl_bh - 1],
|
|
||||||
{{ pi }} fill={{ el.fill_tuple }},
|
|
||||||
{{ pi }} )
|
|
||||||
{% elif el.type == 'gauge' %}
|
|
||||||
{{ pi }}_gv = max(0.0, min(100.0, float(values.get({{ el.binding_key | tojson }}, 0) or 0)))
|
|
||||||
{{ pi }}_g_total = (({{ el.end_angle }} - {{ el.start_angle }}) % 360) or 360
|
|
||||||
{{ pi }}_g_sweep = _g_total * _gv / 100.0
|
|
||||||
{% if el.track_tuple != 'None' %}
|
|
||||||
{{ pi }}self.display_manager.draw.arc(
|
|
||||||
{{ pi }} [{{ el.x_expr }}, {{ el.y_expr }}, {{ el.x2_expr }}, {{ el.y2_expr }}],
|
|
||||||
{{ pi }} start={{ el.start_angle }}, end={{ el.start_angle }} + _g_total,
|
|
||||||
{{ pi }} fill={{ el.track_tuple }},
|
|
||||||
{{ pi }} width={{ el.line_width }},
|
|
||||||
{{ pi }})
|
|
||||||
{% endif %}
|
|
||||||
{{ pi }}if _g_sweep > 0:
|
|
||||||
{{ pi }} self.display_manager.draw.arc(
|
|
||||||
{{ pi }} [{{ el.x_expr }}, {{ el.y_expr }}, {{ el.x2_expr }}, {{ el.y2_expr }}],
|
|
||||||
{{ pi }} start={{ el.start_angle }}, end={{ el.start_angle }} + _g_sweep,
|
|
||||||
{{ pi }} fill={{ el.rgb_tuple }},
|
|
||||||
{{ pi }} width={{ el.line_width }},
|
|
||||||
{{ pi }} )
|
|
||||||
{% if el.show_label %}
|
|
||||||
{{ pi }}_g_cx = ({{ el.x_expr }}) + ({{ el.x2_expr }} - ({{ el.x_expr }})) // 2
|
|
||||||
{{ pi }}_g_cy = ({{ el.y_expr }}) + ({{ el.y2_expr }} - ({{ el.y_expr }})) // 2
|
|
||||||
{{ pi }}self.display_manager.draw_text(
|
|
||||||
{{ pi }} f'{int(_gv)}%',
|
|
||||||
{{ pi }} x=_g_cx, y=_g_cy,
|
|
||||||
{{ pi }} color={{ el.label_tuple }},
|
|
||||||
{{ pi }} font=self.display_manager.{{ el.font_attr }},
|
|
||||||
{{ pi }})
|
|
||||||
{% endif %}
|
|
||||||
{% elif el.type == 'marquee' %}
|
|
||||||
{{ pi }}_{{ el.data_key }}_text = {{ el.text | tojson }}
|
|
||||||
{{ pi }}_{{ el.data_key }}_tw = len(_{{ el.data_key }}_text) * {{ el.char_w }}
|
|
||||||
{{ pi }}_{{ el.data_key }}_x = int(self._data.get({{ el.data_key | tojson }}, width))
|
|
||||||
{% if el.direction == 'right' %}
|
|
||||||
{{ pi }}_{{ el.data_key }}_x += {{ el.scroll_speed }}
|
|
||||||
{{ pi }}if _{{ el.data_key }}_x > width:
|
|
||||||
{{ pi }} _{{ el.data_key }}_x = -(_{{ el.data_key }}_tw + {{ el.gap }})
|
|
||||||
{% else %}
|
|
||||||
{{ pi }}_{{ el.data_key }}_x -= {{ el.scroll_speed }}
|
|
||||||
{{ pi }}if _{{ el.data_key }}_x < -(_{{ el.data_key }}_tw + {{ el.gap }}):
|
|
||||||
{{ pi }} _{{ el.data_key }}_x = width
|
|
||||||
{% endif %}
|
|
||||||
{{ pi }}self._data[{{ el.data_key | tojson }}] = _{{ el.data_key }}_x
|
|
||||||
{{ pi }}self.display_manager.draw_text(
|
|
||||||
{{ pi }} _{{ el.data_key }}_text,
|
|
||||||
{{ pi }} x=_{{ el.data_key }}_x, y={{ el.y_expr }},
|
|
||||||
{{ pi }} color={{ el.rgb_tuple }},
|
|
||||||
{{ pi }} font=self.display_manager.{{ el.font_attr }},
|
|
||||||
{{ pi }})
|
|
||||||
{% elif el.type == 'progress_bar' %}
|
|
||||||
{{ pi }}_pb_x = {{ el.x_expr }}
|
|
||||||
{{ pi }}_pb_y = {{ el.y_expr }}
|
|
||||||
{{ pi }}_pb_pct = max(0.0, min(100.0, float(values.get({{ el.binding_key | tojson }}, 0) or 0))) / 100.0
|
|
||||||
{{ pi }}_pb_fill_w = int({{ el.bar_width }} * _pb_pct)
|
|
||||||
{{ pi }}self.display_manager.draw.rectangle(
|
|
||||||
{{ pi }} [_pb_x, _pb_y, _pb_x + {{ el.bar_width }}, _pb_y + {{ el.bar_height }}],
|
|
||||||
{{ pi }} fill={{ el.bg_tuple }},
|
|
||||||
{{ pi }} outline={{ el.outline_tuple }},
|
|
||||||
{{ pi }})
|
|
||||||
{{ pi }}if _pb_fill_w > 0:
|
|
||||||
{{ pi }} self.display_manager.draw.rectangle(
|
|
||||||
{{ pi }} [_pb_x, _pb_y, _pb_x + _pb_fill_w, _pb_y + {{ el.bar_height }}],
|
|
||||||
{{ pi }} fill={{ el.fill_tuple }},
|
|
||||||
{{ pi }} )
|
|
||||||
{% else %}
|
|
||||||
{{ pi }}pass # element type "{{ el.type }}" draws nothing
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
# ── End elements ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
self.display_manager.update_display()
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error('Display error: %s', e, exc_info=True)
|
|
||||||
@@ -95,7 +95,7 @@
|
|||||||
<!-- Location Information -->
|
<!-- Location Information -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 xl:grid-cols-3 2xl:grid-cols-3 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-3 xl:grid-cols-3 2xl:grid-cols-3 gap-4">
|
||||||
<div class="form-group" id="setting-general-city" data-setting-key="location.city">
|
<div class="form-group" id="setting-general-city" data-setting-key="location.city">
|
||||||
<label for="city" class="block text-sm font-medium text-gray-700">City{{ ui.help_tip('City used for weather, sunrise/sunset, radar, and other location-based content.\nExample: Kansas City.\nUsed as the default for the location_city setting on plugins that have one; a value saved on the plugin itself overrides it.', 'City') }}</label>
|
<label for="city" class="block text-sm font-medium text-gray-700">City{{ ui.help_tip('City used for weather, sunrise/sunset, and other location-based content.\nExample: Dallas.', 'City') }}</label>
|
||||||
<input type="text"
|
<input type="text"
|
||||||
id="city"
|
id="city"
|
||||||
name="city"
|
name="city"
|
||||||
@@ -104,7 +104,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group" id="setting-general-state" data-setting-key="location.state">
|
<div class="form-group" id="setting-general-state" data-setting-key="location.state">
|
||||||
<label for="state" class="block text-sm font-medium text-gray-700">State{{ ui.help_tip('State or region for your location.\nExample: Missouri. Improves location-lookup accuracy.\nUsed as the default for the location_state setting on plugins that have one.', 'State') }}</label>
|
<label for="state" class="block text-sm font-medium text-gray-700">State{{ ui.help_tip('State or region for your location.\nExample: Texas. Improves location-lookup accuracy.', 'State') }}</label>
|
||||||
<input type="text"
|
<input type="text"
|
||||||
id="state"
|
id="state"
|
||||||
name="state"
|
name="state"
|
||||||
@@ -113,7 +113,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group" id="setting-general-country" data-setting-key="location.country">
|
<div class="form-group" id="setting-general-country" data-setting-key="location.country">
|
||||||
<label for="country" class="block text-sm font-medium text-gray-700">Country{{ ui.help_tip('Country code or name for your location.\nExample: US. Used with City and State for weather, radar, and geolocation.\nUsed as the default for the location_country setting on plugins that have one.', 'Country') }}</label>
|
<label for="country" class="block text-sm font-medium text-gray-700">Country{{ ui.help_tip('Country code or name for your location.\nExample: US. Used with City and State for weather and geolocation.', 'Country') }}</label>
|
||||||
<input type="text"
|
<input type="text"
|
||||||
id="country"
|
id="country"
|
||||||
name="country"
|
name="country"
|
||||||
|
|||||||
Reference in New Issue
Block a user