diff --git a/scripts/download_pixlet.sh b/scripts/download_pixlet.sh index b3d4070a..53a38d22 100755 --- a/scripts/download_pixlet.sh +++ b/scripts/download_pixlet.sh @@ -118,7 +118,7 @@ total_count=${#ARCHITECTURES[@]} for arch in "${!ARCHITECTURES[@]}"; do if download_binary "$arch" "${ARCHITECTURES[$arch]}"; then - ((success_count++)) + success_count=$((success_count + 1)) fi done diff --git a/web_interface/app.py b/web_interface/app.py index cf2a58a3..b88df468 100644 --- a/web_interface/app.py +++ b/web_interface/app.py @@ -226,13 +226,24 @@ def serve_plugin_asset(plugin_id, filename): 'message': 'Internal server error' }), 500 +# Prime psutil CPU measurement once at startup so interval=None returns a real value +try: + import psutil as _psutil_prime + _psutil_prime.cpu_percent(interval=None) +except ImportError: + pass + # Cached AP mode check — avoids creating a WiFiManager per request _ap_mode_cache = {'value': False, 'timestamp': 0} -_AP_MODE_CACHE_TTL = 5 # seconds +_AP_MODE_CACHE_TTL = 30 # seconds — AP mode is user-initiated; 30s is fine + +# Cached ledmatrix service status for SSE stats stream +_ledmatrix_service_cache = {'active': False, 'timestamp': 0} +_LEDMATRIX_SERVICE_CACHE_TTL = 15 # seconds def is_ap_mode_active(): """ - Check if access point mode is currently active (cached, 5s TTL). + Check if access point mode is currently active (cached, 30s TTL). Uses a direct systemctl check instead of instantiating WiFiManager. """ now = time.time() @@ -444,10 +455,11 @@ def system_status_generator(): # Try to import psutil for system stats try: import psutil - cpu_percent = round(psutil.cpu_percent(interval=1), 1) + # interval=None is non-blocking; primed at module startup above + cpu_percent = round(psutil.cpu_percent(interval=None), 1) memory = psutil.virtual_memory() memory_used_percent = round(memory.percent, 1) - + # Try to get CPU temperature (Raspberry Pi specific) cpu_temp = 0 try: @@ -455,20 +467,23 @@ def system_status_generator(): cpu_temp = round(float(f.read()) / 1000.0, 1) except (OSError, ValueError): pass - + except ImportError: cpu_percent = 0 memory_used_percent = 0 cpu_temp = 0 - - # Check if display service is running - service_active = False - try: - result = subprocess.run(['systemctl', 'is-active', 'ledmatrix'], - capture_output=True, text=True, timeout=2) - service_active = result.stdout.strip() == 'active' - except (subprocess.SubprocessError, OSError): - pass + + # Check if display service is running (cached to avoid per-client subprocess forks) + now = time.time() + if (now - _ledmatrix_service_cache['timestamp']) >= _LEDMATRIX_SERVICE_CACHE_TTL: + try: + result = subprocess.run(['systemctl', 'is-active', 'ledmatrix'], + capture_output=True, text=True, timeout=2) + _ledmatrix_service_cache['active'] = result.stdout.strip() == 'active' + except (subprocess.SubprocessError, OSError): + pass + _ledmatrix_service_cache['timestamp'] = now + service_active = _ledmatrix_service_cache['active'] status = { 'timestamp': time.time(), @@ -546,7 +561,7 @@ def display_preview_generator(): except Exception as e: yield {'error': str(e)} - time.sleep(0.5) # Check 2 times per second (reduced frequency for better performance) + time.sleep(1.0) # Check once per second — halves PIL encode overhead vs 0.5s # Logs generator for SSE def logs_generator(): diff --git a/web_interface/blueprints/api_v3.py b/web_interface/blueprints/api_v3.py index cc551588..126db266 100644 --- a/web_interface/blueprints/api_v3.py +++ b/web_interface/blueprints/api_v3.py @@ -7577,6 +7577,126 @@ _STARLARK_APPS_DIR = PROJECT_ROOT / 'starlark-apps' _STARLARK_MANIFEST_FILE = _STARLARK_APPS_DIR / 'manifest.json' +def _find_pixlet_binary(explicit_path: Optional[str] = None) -> Optional[str]: + """Find pixlet binary: explicit path → bundled binary → system PATH.""" + import platform + if explicit_path and os.path.isfile(explicit_path) and os.access(explicit_path, os.X_OK): + return explicit_path + bin_dir = PROJECT_ROOT / "bin" / "pixlet" + system = platform.system().lower() + machine = platform.machine().lower() + if system == "linux": + if "aarch64" in machine or "arm64" in machine: + name = "pixlet-linux-arm64" + elif "x86_64" in machine or "amd64" in machine: + name = "pixlet-linux-amd64" + else: + name = None + elif system == "darwin": + name = "pixlet-darwin-arm64" if "arm64" in machine else "pixlet-darwin-amd64" + else: + name = None + if name: + bundled = bin_dir / name + if bundled.is_file(): + if os.access(str(bundled), os.X_OK): + return str(bundled) + try: + bundled.chmod(0o755) + except OSError: + logger.warning("Could not make pixlet bundled binary executable (%s); falling back to PATH", bundled) + else: + if os.access(str(bundled), os.X_OK): + return str(bundled) + logger.warning("Pixlet bundled binary still not executable after chmod (%s); falling back to PATH", bundled) + return shutil.which("pixlet") + + +def _standalone_render_starlark_app(app_id: str) -> Tuple[bool, int, Optional[str]]: + """Render a Starlark app via pixlet directly (no plugin required). + + Reads the .star file and config from starlark-apps/{app_id}/, runs pixlet, + and saves the output to cached_render.webp in the same directory. + This is the web-service fallback when starlark-apps plugin is not loaded. + + Returns (success, http_status_code, error_message). + """ + manifest = _read_starlark_manifest() + if not isinstance(manifest, dict): + return False, 400, "Invalid manifest shape: expected object with 'apps' mapping" + apps = manifest.get('apps', {}) + if not isinstance(apps, dict): + return False, 400, "Invalid manifest shape: expected object with 'apps' mapping" + app_data = apps.get(app_id) + if not app_data: + return False, 404, f"App not found: {app_id}" + + app_dir = _STARLARK_APPS_DIR / app_id + star_file = app_dir / app_data.get('star_file', f'{app_id}.star') + if not star_file.exists(): + return False, 404, f"Star file not found: {star_file}" + + full_config = api_v3.config_manager.load_config() if api_v3.config_manager else {} + plugin_config = full_config.get('starlark-apps', {}) + + pixlet_path = _find_pixlet_binary(plugin_config.get('pixlet_path')) + if not pixlet_path: + return False, 503, "Pixlet binary not found — install pixlet first" + + magnify = plugin_config.get('magnify') + if magnify is None: + hw = full_config.get('display', {}).get('hardware', {}) + cols = hw.get('cols', 64) + chain = hw.get('chain_length', 1) + rows = hw.get('rows', 32) + magnify = max(1, min(8, int(min((cols * chain) / 64, rows / 32)))) + else: + try: + magnify = max(1, min(8, int(magnify))) + except (ValueError, TypeError): + magnify = 1 + + config_file = app_dir / 'config.json' + app_config: Dict[str, Any] = {} + if config_file.exists(): + try: + with open(config_file) as f: + app_config = json.load(f) + except json.JSONDecodeError as e: + return False, 400, f"Invalid config.json for {app_id} ({config_file}): {e}" + except OSError as e: + return False, 400, f"Cannot read config.json for {app_id} ({config_file}): {e}" + if not isinstance(app_config, dict): + return False, 400, ( + f"config.json for {app_id} must be a JSON object, " + f"got {type(app_config).__name__}" + ) + + INTERNAL_KEYS = {'render_interval', 'display_duration'} + pixlet_config = {k: v for k, v in app_config.items() if k not in INTERNAL_KEYS} + + output_path = str(app_dir / 'cached_render.webp') + cmd = [pixlet_path, 'render', str(star_file)] + for key, value in pixlet_config.items(): + if not re.match(r'^[a-zA-Z_][a-zA-Z0-9_]*$', key): + continue + value_str = 'true' if value is True else 'false' if value is False else str(value) + if re.search(r'[`$|<>&;\x00]|\$\(', value_str): + continue + cmd.append(f'{key}={value_str}') + cmd.extend(['-o', output_path, '-m', str(magnify)]) + + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=30, cwd=str(app_dir)) + if result.returncode == 0 and os.path.isfile(output_path): + return True, 200, None + return False, 502, f"Pixlet failed (exit {result.returncode}): {result.stderr.strip()}" + except subprocess.TimeoutExpired: + return False, 504, "Render timed out after 30s" + except Exception as e: + return False, 500, f"Render error: {e}" + + def _read_starlark_manifest() -> Dict[str, Any]: """Read the starlark-apps manifest.json directly from disk.""" try: @@ -7696,24 +7816,11 @@ def get_starlark_status(): 'display_info': magnify_info }) - # Plugin not loaded - check Pixlet availability directly - import shutil - import platform - - system = platform.system().lower() - machine = platform.machine().lower() - bin_dir = PROJECT_ROOT / 'bin' / 'pixlet' - - pixlet_binary = None - if system == "linux": - if "aarch64" in machine or "arm64" in machine: - pixlet_binary = bin_dir / "pixlet-linux-arm64" - elif "x86_64" in machine or "amd64" in machine: - pixlet_binary = bin_dir / "pixlet-linux-amd64" - elif system == "darwin": - pixlet_binary = bin_dir / ("pixlet-darwin-arm64" if "arm64" in machine else "pixlet-darwin-amd64") - - pixlet_available = (pixlet_binary and pixlet_binary.exists()) or shutil.which('pixlet') is not None + # Plugin not loaded - check Pixlet availability via shared resolver + # (respects user-configured pixlet_path, bundled binary, and system PATH) + full_config = api_v3.config_manager.load_config() if api_v3.config_manager else {} + pixlet_path = _find_pixlet_binary(full_config.get('starlark-apps', {}).get('pixlet_path')) + pixlet_available = pixlet_path is not None # Read app counts from manifest manifest = _read_starlark_manifest() @@ -8166,20 +8273,27 @@ def toggle_starlark_app(app_id): def render_starlark_app(app_id): """Force render a Starlark app.""" try: + is_valid, err = _validate_starlark_app_path(app_id) + if not is_valid: + return jsonify({'status': 'error', 'message': err}), 400 + starlark_plugin = _get_starlark_plugin() - if not starlark_plugin: - return jsonify({'status': 'error', 'message': 'Rendering requires the main LEDMatrix service (plugin not loaded in web service)'}), 503 - - app = starlark_plugin.apps.get(app_id) - if not app: - return jsonify({'status': 'error', 'message': f'App not found: {app_id}'}), 404 - - success = starlark_plugin._render_app(app, force=True) - if success: - return jsonify({'status': 'success', 'message': 'App rendered', 'frame_count': len(app.frames) if app.frames else 0}) - else: + if starlark_plugin: + app = starlark_plugin.apps.get(app_id) + if not app: + return jsonify({'status': 'error', 'message': f'App not found: {app_id}'}), 404 + success = starlark_plugin._render_app(app, force=True) + if success: + return jsonify({'status': 'success', 'message': 'App rendered', + 'frame_count': len(app.frames) if app.frames else 0}) return jsonify({'status': 'error', 'message': 'Failed to render app'}), 500 + # Web-service context: plugin not loaded, call pixlet directly + success, status_code, error = _standalone_render_starlark_app(app_id) + if success: + return jsonify({'status': 'success', 'message': 'App rendered successfully', 'frame_count': 0}), status_code + return jsonify({'status': 'error', 'message': error or 'Render failed', 'frame_count': 0}), status_code + except Exception as e: logger.exception("[Starlark] render_starlark_app failed") return jsonify({'status': 'error', 'message': 'Failed to render Starlark app'}), 500