mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-05-14 09:33:32 +00:00
fix: pixlet install false-failure, force render in web service, web UI perf (#328)
* fix: pixlet install false-failure, force render in web service, web UI perf Fixes three user-reported issues: 1. Pixlet install reported failure even on success — `set -e` in download_pixlet.sh caused the script to exit non-zero when `((success_count++))` evaluated the post-increment old value (0), which bash treats as a failed command. Fixed with shell arithmetic assignment instead. 2. Force render returned 503 "plugin not loaded in web service" — the web service runs its own PluginManager with display_manager=None, so the starlark-apps plugin can never be loaded there. Added a standalone render path (_standalone_render_starlark_app) that calls pixlet directly from the web service, writing to cached_render.webp. The main-service plugin path is preserved and tried first. 3. Web UI sluggishness — the SSE /stream/stats generator was blocking a Flask thread for 1s per tick via psutil.cpu_percent(interval=1). Switched to non-blocking interval=None (primed at startup). Also cached the per-client ledmatrix systemctl check (15s TTL), raised the AP mode check TTL from 5s to 30s, and halved the display preview poll rate from 0.5s to 1s. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(starlark): surface config errors, deduplicate pixlet lookup, uniform response shape - _standalone_render_starlark_app: split silent except into separate json.JSONDecodeError and OSError handlers that return (False, message) with the file path and parse error, so callers know when config.json is unreadable rather than silently rendering with empty config - get_starlark_status: replace 14-line inline platform/shutil pixlet lookup with _find_pixlet_binary(), which also checks the user- configured starlark-apps.pixlet_path — the old code never consulted that setting so the status endpoint could wrongly report pixlet unavailable even when a custom path was configured - render_starlark_app standalone branch: add frame_count: 0 to success and error responses so both branches return the same payload shape; 0 is accurate since standalone render writes cached_render.webp but does not extract frames into memory Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(starlark): safe chmod fallback in pixlet lookup, semantic HTTP codes in standalone render _find_pixlet_binary: bundled.chmod(0o755) could raise OSError (e.g. file owned by root) and abort the entire resolution chain, skipping the PATH fallback. Now checks os.access first; if chmod is needed, wraps it in try/except OSError, logs a warning, and falls through to shutil.which so PATH is always tried. _standalone_render_starlark_app: expanded return type from (bool, str) to (bool, int, str) so each failure mode carries a semantic HTTP status code: app/star file missing → 404 invalid/unreadable config.json → 400 pixlet binary missing → 503 pixlet non-zero exit → 502 subprocess timeout → 504 unexpected exception → 500 success → 200 Call site updated to unpack (success, status_code, error) and forward status_code directly in both success and error responses. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(starlark): validate config.json is a dict before iterating items json.load succeeds on valid JSON that isn't an object (e.g. an array or bare string), leaving app_config as a non-dict and causing an AttributeError on the subsequent .items() call. Added isinstance check immediately after json.load; non-dict values return (False, 400, ...) with the actual type name in the message. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(starlark): harden pixlet binary check and manifest shape validation _find_pixlet_binary: switched bundled.exists() to bundled.is_file() so a directory named pixlet-linux-arm64 (traversable, hence X_OK) is no longer returned as a binary; re-check os.access after chmod succeeds so we only return the path when executability is confirmed, with a separate warning if it still isn't executable after chmod. _standalone_render_starlark_app: added isinstance guards on the manifest and apps values returned by _read_starlark_manifest(); a manifest.json containing valid non-object JSON (e.g. an array) would previously raise AttributeError on .get(); invalid shape now returns (False, 400, ...) instead. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Chuck <chuck@example.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -118,7 +118,7 @@ total_count=${#ARCHITECTURES[@]}
|
|||||||
|
|
||||||
for arch in "${!ARCHITECTURES[@]}"; do
|
for arch in "${!ARCHITECTURES[@]}"; do
|
||||||
if download_binary "$arch" "${ARCHITECTURES[$arch]}"; then
|
if download_binary "$arch" "${ARCHITECTURES[$arch]}"; then
|
||||||
((success_count++))
|
success_count=$((success_count + 1))
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
|
|||||||
@@ -226,13 +226,24 @@ def serve_plugin_asset(plugin_id, filename):
|
|||||||
'message': 'Internal server error'
|
'message': 'Internal server error'
|
||||||
}), 500
|
}), 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
|
# Cached AP mode check — avoids creating a WiFiManager per request
|
||||||
_ap_mode_cache = {'value': False, 'timestamp': 0}
|
_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():
|
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.
|
Uses a direct systemctl check instead of instantiating WiFiManager.
|
||||||
"""
|
"""
|
||||||
now = time.time()
|
now = time.time()
|
||||||
@@ -444,10 +455,11 @@ def system_status_generator():
|
|||||||
# Try to import psutil for system stats
|
# Try to import psutil for system stats
|
||||||
try:
|
try:
|
||||||
import psutil
|
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 = psutil.virtual_memory()
|
||||||
memory_used_percent = round(memory.percent, 1)
|
memory_used_percent = round(memory.percent, 1)
|
||||||
|
|
||||||
# Try to get CPU temperature (Raspberry Pi specific)
|
# Try to get CPU temperature (Raspberry Pi specific)
|
||||||
cpu_temp = 0
|
cpu_temp = 0
|
||||||
try:
|
try:
|
||||||
@@ -455,20 +467,23 @@ def system_status_generator():
|
|||||||
cpu_temp = round(float(f.read()) / 1000.0, 1)
|
cpu_temp = round(float(f.read()) / 1000.0, 1)
|
||||||
except (OSError, ValueError):
|
except (OSError, ValueError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
except ImportError:
|
except ImportError:
|
||||||
cpu_percent = 0
|
cpu_percent = 0
|
||||||
memory_used_percent = 0
|
memory_used_percent = 0
|
||||||
cpu_temp = 0
|
cpu_temp = 0
|
||||||
|
|
||||||
# Check if display service is running
|
# Check if display service is running (cached to avoid per-client subprocess forks)
|
||||||
service_active = False
|
now = time.time()
|
||||||
try:
|
if (now - _ledmatrix_service_cache['timestamp']) >= _LEDMATRIX_SERVICE_CACHE_TTL:
|
||||||
result = subprocess.run(['systemctl', 'is-active', 'ledmatrix'],
|
try:
|
||||||
capture_output=True, text=True, timeout=2)
|
result = subprocess.run(['systemctl', 'is-active', 'ledmatrix'],
|
||||||
service_active = result.stdout.strip() == 'active'
|
capture_output=True, text=True, timeout=2)
|
||||||
except (subprocess.SubprocessError, OSError):
|
_ledmatrix_service_cache['active'] = result.stdout.strip() == 'active'
|
||||||
pass
|
except (subprocess.SubprocessError, OSError):
|
||||||
|
pass
|
||||||
|
_ledmatrix_service_cache['timestamp'] = now
|
||||||
|
service_active = _ledmatrix_service_cache['active']
|
||||||
|
|
||||||
status = {
|
status = {
|
||||||
'timestamp': time.time(),
|
'timestamp': time.time(),
|
||||||
@@ -546,7 +561,7 @@ def display_preview_generator():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
yield {'error': str(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
|
# Logs generator for SSE
|
||||||
def logs_generator():
|
def logs_generator():
|
||||||
|
|||||||
@@ -7577,6 +7577,126 @@ _STARLARK_APPS_DIR = PROJECT_ROOT / 'starlark-apps'
|
|||||||
_STARLARK_MANIFEST_FILE = _STARLARK_APPS_DIR / 'manifest.json'
|
_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]:
|
def _read_starlark_manifest() -> Dict[str, Any]:
|
||||||
"""Read the starlark-apps manifest.json directly from disk."""
|
"""Read the starlark-apps manifest.json directly from disk."""
|
||||||
try:
|
try:
|
||||||
@@ -7696,24 +7816,11 @@ def get_starlark_status():
|
|||||||
'display_info': magnify_info
|
'display_info': magnify_info
|
||||||
})
|
})
|
||||||
|
|
||||||
# Plugin not loaded - check Pixlet availability directly
|
# Plugin not loaded - check Pixlet availability via shared resolver
|
||||||
import shutil
|
# (respects user-configured pixlet_path, bundled binary, and system PATH)
|
||||||
import platform
|
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'))
|
||||||
system = platform.system().lower()
|
pixlet_available = pixlet_path is not None
|
||||||
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
|
|
||||||
|
|
||||||
# Read app counts from manifest
|
# Read app counts from manifest
|
||||||
manifest = _read_starlark_manifest()
|
manifest = _read_starlark_manifest()
|
||||||
@@ -8166,20 +8273,27 @@ def toggle_starlark_app(app_id):
|
|||||||
def render_starlark_app(app_id):
|
def render_starlark_app(app_id):
|
||||||
"""Force render a Starlark app."""
|
"""Force render a Starlark app."""
|
||||||
try:
|
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()
|
starlark_plugin = _get_starlark_plugin()
|
||||||
if not starlark_plugin:
|
if 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:
|
||||||
app = starlark_plugin.apps.get(app_id)
|
return jsonify({'status': 'error', 'message': f'App not found: {app_id}'}), 404
|
||||||
if not app:
|
success = starlark_plugin._render_app(app, force=True)
|
||||||
return jsonify({'status': 'error', 'message': f'App not found: {app_id}'}), 404
|
if success:
|
||||||
|
return jsonify({'status': 'success', 'message': 'App rendered',
|
||||||
success = starlark_plugin._render_app(app, force=True)
|
'frame_count': len(app.frames) if app.frames else 0})
|
||||||
if success:
|
|
||||||
return jsonify({'status': 'success', 'message': 'App rendered', 'frame_count': len(app.frames) if app.frames else 0})
|
|
||||||
else:
|
|
||||||
return jsonify({'status': 'error', 'message': 'Failed to render app'}), 500
|
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:
|
except Exception as e:
|
||||||
logger.exception("[Starlark] render_starlark_app failed")
|
logger.exception("[Starlark] render_starlark_app failed")
|
||||||
return jsonify({'status': 'error', 'message': 'Failed to render Starlark app'}), 500
|
return jsonify({'status': 'error', 'message': 'Failed to render Starlark app'}), 500
|
||||||
|
|||||||
Reference in New Issue
Block a user