mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-04-10 21:03:01 +00:00
* fix: reduce CPU usage, fix Vegas mid-cycle refresh, and throttle high-FPS plugin ticks Web UI Info plugin was causing 90%+ CPU on RPi4 due to frequent subprocess calls and re-rendering. Fixed by: trying socket-based IP detection first (zero subprocess overhead), caching AP mode checks with 60s TTL, reducing IP refresh from 30s to 5m, caching rendered display images, and loading fonts once at init. Vegas mode was not updating the display mid-cycle because hot_swap_content() reset the scroll position to 0 on every recomposition. Now saves and restores scroll position for mid-cycle updates. High-FPS display loop was calling _tick_plugin_updates() 125x/sec with no benefit. Added throttled wrapper that limits to 1 call/sec. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address PR review — respect plugin update_interval, narrow exception handlers Make _tick_plugin_updates_throttled default to no-throttle (min_interval=0) so plugin-configured update_interval values are never silently capped. The high-FPS call site passes an explicit 1.0s interval. Narrow _load_font exception handler from bare Exception to FileNotFoundError | OSError so unexpected errors surface. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(vegas): scale scroll position proportionally on mid-cycle hot-swap When content width changes during a mid-cycle recomposition (e.g., a plugin gains or loses items), blindly restoring the old scroll_position and total_distance_scrolled could overshoot the new total_scroll_width and trigger immediate false completion. Scale both values proportionally to the new width and clamp scroll_position to stay in bounds. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Chuck <chuck@example.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
336 lines
13 KiB
Python
336 lines
13 KiB
Python
"""
|
|
Web UI Info Plugin for LEDMatrix
|
|
|
|
A simple plugin that displays the web UI URL for easy access.
|
|
Shows "visit web ui at http://[deviceID]:5000"
|
|
|
|
API Version: 1.0.0
|
|
"""
|
|
|
|
import logging
|
|
import os
|
|
import socket
|
|
import subprocess
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Dict, Any, Optional
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
|
|
from src.plugin_system.base_plugin import BasePlugin
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class WebUIInfoPlugin(BasePlugin):
|
|
"""
|
|
Web UI Info plugin that displays the web UI URL.
|
|
|
|
Configuration options:
|
|
display_duration (float): Display duration in seconds (default: 10)
|
|
enabled (bool): Enable/disable plugin (default: true)
|
|
"""
|
|
|
|
def __init__(self, plugin_id: str, config: Dict[str, Any],
|
|
display_manager, cache_manager, plugin_manager):
|
|
"""Initialize the Web UI Info plugin."""
|
|
super().__init__(plugin_id, config, display_manager, cache_manager, plugin_manager)
|
|
|
|
# Get device hostname
|
|
try:
|
|
self.device_id = socket.gethostname()
|
|
except Exception as e:
|
|
self.logger.warning(f"Could not get hostname: {e}, using 'localhost'")
|
|
self.device_id = "localhost"
|
|
|
|
# Get device IP address
|
|
self.device_ip = self._get_local_ip()
|
|
|
|
# IP refresh tracking
|
|
self.last_ip_refresh = time.time()
|
|
self.ip_refresh_interval = 300.0 # Refresh IP every 5 minutes
|
|
|
|
# AP mode cache
|
|
self._ap_mode_cached = False
|
|
self._ap_mode_cache_time = 0.0
|
|
self._ap_mode_cache_ttl = 60.0 # Cache AP mode check for 60 seconds
|
|
|
|
# Rotation state
|
|
self.current_display_mode = "hostname" # "hostname" or "ip"
|
|
self.last_rotation_time = time.time()
|
|
self.rotation_interval = 10.0 # Rotate every 10 seconds
|
|
|
|
self.web_ui_url = f"http://{self.device_id}:5000"
|
|
|
|
# Display cache - avoid re-rendering when nothing changed
|
|
self._cached_display_image = None
|
|
self._display_dirty = True
|
|
self._font_small = self._load_font()
|
|
|
|
self.logger.info(f"Web UI Info plugin initialized - Hostname: {self.device_id}, IP: {self.device_ip}")
|
|
|
|
def _load_font(self):
|
|
"""Load and cache the display font."""
|
|
try:
|
|
current_dir = Path(__file__).resolve().parent
|
|
project_root = current_dir.parent.parent
|
|
font_path = project_root / "assets" / "fonts" / "4x6-font.ttf"
|
|
if font_path.exists():
|
|
return ImageFont.truetype(str(font_path), 6)
|
|
font_path = "assets/fonts/4x6-font.ttf"
|
|
if os.path.exists(font_path):
|
|
return ImageFont.truetype(font_path, 6)
|
|
return ImageFont.load_default()
|
|
except (FileNotFoundError, OSError) as e:
|
|
self.logger.debug(f"Could not load custom font: {e}, using default")
|
|
return ImageFont.load_default()
|
|
|
|
def _is_ap_mode_active(self) -> bool:
|
|
"""
|
|
Check if AP mode is currently active (cached with TTL).
|
|
|
|
Returns:
|
|
bool: True if AP mode is active, False otherwise
|
|
"""
|
|
current_time = time.time()
|
|
if current_time - self._ap_mode_cache_time < self._ap_mode_cache_ttl:
|
|
return self._ap_mode_cached
|
|
|
|
try:
|
|
result = subprocess.run(
|
|
["systemctl", "is-active", "hostapd"],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=2
|
|
)
|
|
if result.returncode == 0 and result.stdout.strip() == "active":
|
|
self._ap_mode_cached = True
|
|
self._ap_mode_cache_time = current_time
|
|
return True
|
|
|
|
result = subprocess.run(
|
|
["ip", "addr", "show", "wlan0"],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=2
|
|
)
|
|
if result.returncode == 0 and "192.168.4.1" in result.stdout:
|
|
self._ap_mode_cached = True
|
|
self._ap_mode_cache_time = current_time
|
|
return True
|
|
|
|
self._ap_mode_cached = False
|
|
self._ap_mode_cache_time = current_time
|
|
return False
|
|
except Exception as e:
|
|
self.logger.debug(f"Error checking AP mode status: {e}")
|
|
self._ap_mode_cached = False
|
|
self._ap_mode_cache_time = current_time
|
|
return False
|
|
|
|
def _get_local_ip(self) -> str:
|
|
"""
|
|
Get the local IP address of the device using network interfaces.
|
|
Handles AP mode, no internet connectivity, and network state changes.
|
|
|
|
Returns:
|
|
str: Local IP address, or "localhost" if unable to determine
|
|
"""
|
|
# First check if AP mode is active
|
|
if self._is_ap_mode_active():
|
|
self.logger.debug("AP mode detected, returning AP IP: 192.168.4.1")
|
|
return "192.168.4.1"
|
|
|
|
try:
|
|
# Try socket method first (zero subprocess overhead, fastest)
|
|
try:
|
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
try:
|
|
s.connect(('8.8.8.8', 80))
|
|
ip = s.getsockname()[0]
|
|
if ip and not ip.startswith("127.") and ip != "192.168.4.1":
|
|
self.logger.debug(f"Found IP via socket method: {ip}")
|
|
return ip
|
|
finally:
|
|
s.close()
|
|
except Exception:
|
|
pass
|
|
|
|
# Fallback: Try using 'hostname -I'
|
|
result = subprocess.run(
|
|
["hostname", "-I"],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=2
|
|
)
|
|
if result.returncode == 0:
|
|
ips = result.stdout.strip().split()
|
|
for ip in ips:
|
|
ip = ip.strip()
|
|
if ip and not ip.startswith("127.") and ip != "192.168.4.1":
|
|
self.logger.debug(f"Found IP via hostname -I: {ip}")
|
|
return ip
|
|
|
|
# Fallback: Use 'ip addr show' to get interface IPs
|
|
result = subprocess.run(
|
|
["ip", "-4", "addr", "show"],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=3
|
|
)
|
|
if result.returncode == 0:
|
|
current_interface = None
|
|
for line in result.stdout.split('\n'):
|
|
line = line.strip()
|
|
if ':' in line and not line.startswith('inet'):
|
|
parts = line.split(':')
|
|
if len(parts) >= 2:
|
|
current_interface = parts[1].strip().split('@')[0]
|
|
elif line.startswith('inet '):
|
|
parts = line.split()
|
|
if len(parts) >= 2:
|
|
ip_with_cidr = parts[1]
|
|
ip = ip_with_cidr.split('/')[0]
|
|
if not ip.startswith("127.") and ip != "192.168.4.1":
|
|
if current_interface and (
|
|
current_interface.startswith("eth") or
|
|
current_interface.startswith("enp")
|
|
):
|
|
self.logger.debug(f"Found Ethernet IP: {ip} on {current_interface}")
|
|
return ip
|
|
elif current_interface == "wlan0":
|
|
self.logger.debug(f"Found WiFi IP: {ip} on {current_interface}")
|
|
return ip
|
|
except Exception:
|
|
pass
|
|
|
|
# Last resort: try hostname resolution (often returns 127.0.0.1)
|
|
try:
|
|
ip = socket.gethostbyname(socket.gethostname())
|
|
if ip and not ip.startswith("127.") and ip != "192.168.4.1":
|
|
self.logger.debug(f"Found IP via hostname resolution: {ip}")
|
|
return ip
|
|
except Exception:
|
|
pass
|
|
|
|
self.logger.warning("Could not determine IP address, using 'localhost'")
|
|
return "localhost"
|
|
|
|
except Exception as e:
|
|
self.logger.warning(f"Error getting IP address: {e}, using 'localhost'")
|
|
return "localhost"
|
|
|
|
def update(self) -> None:
|
|
"""
|
|
Update method - refreshes IP address periodically to handle network state changes.
|
|
|
|
The hostname is determined at initialization and doesn't change,
|
|
but IP address can change when network state changes (WiFi connect/disconnect, AP mode, etc.)
|
|
"""
|
|
current_time = time.time()
|
|
if current_time - self.last_ip_refresh >= self.ip_refresh_interval:
|
|
new_ip = self._get_local_ip()
|
|
if new_ip != self.device_ip:
|
|
self.logger.info(f"IP address changed from {self.device_ip} to {new_ip}")
|
|
self.device_ip = new_ip
|
|
self._display_dirty = True
|
|
self.last_ip_refresh = current_time
|
|
|
|
def display(self, force_clear: bool = False) -> None:
|
|
"""
|
|
Display the web UI URL message.
|
|
Rotates between hostname and IP address every 10 seconds.
|
|
|
|
Args:
|
|
force_clear: If True, clear display before rendering
|
|
"""
|
|
try:
|
|
# Check if we need to rotate between hostname and IP
|
|
current_time = time.time()
|
|
if current_time - self.last_rotation_time >= self.rotation_interval:
|
|
if self.current_display_mode == "hostname":
|
|
self.current_display_mode = "ip"
|
|
else:
|
|
self.current_display_mode = "hostname"
|
|
self.last_rotation_time = current_time
|
|
self._display_dirty = True
|
|
self.logger.debug(f"Rotated to display mode: {self.current_display_mode}")
|
|
|
|
if force_clear:
|
|
self.display_manager.clear()
|
|
self._display_dirty = True
|
|
|
|
# Use cached image if nothing changed
|
|
if not self._display_dirty and self._cached_display_image is not None:
|
|
self.display_manager.image = self._cached_display_image
|
|
self.display_manager.update_display()
|
|
return
|
|
|
|
# Get display dimensions
|
|
width = self.display_manager.matrix.width
|
|
height = self.display_manager.matrix.height
|
|
|
|
# Create a new image for the display
|
|
img = Image.new('RGB', (width, height), (0, 0, 0))
|
|
draw = ImageDraw.Draw(img)
|
|
|
|
# Determine which address to display
|
|
if self.current_display_mode == "ip":
|
|
address = self.device_ip
|
|
else:
|
|
address = self.device_id
|
|
|
|
lines = [
|
|
"visit web ui",
|
|
f"at {address}:5000"
|
|
]
|
|
|
|
y_start = 5
|
|
line_height = 8
|
|
|
|
for i, line in enumerate(lines):
|
|
bbox = draw.textbbox((0, 0), line, font=self._font_small)
|
|
text_width = bbox[2] - bbox[0]
|
|
x = (width - text_width) // 2
|
|
y = y_start + (i * line_height)
|
|
draw.text((x, y), line, font=self._font_small, fill=(255, 255, 255))
|
|
|
|
self._cached_display_image = img
|
|
self._display_dirty = False
|
|
self.display_manager.image = img
|
|
self.display_manager.update_display()
|
|
|
|
self.logger.debug(f"Displayed web UI info: {address}:5000 (mode: {self.current_display_mode})")
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Error displaying web UI info: {e}")
|
|
try:
|
|
self.display_manager.clear()
|
|
self.display_manager.update_display()
|
|
except Exception:
|
|
pass
|
|
|
|
def get_display_duration(self) -> float:
|
|
"""Get display duration from config."""
|
|
return self.config.get('display_duration', 10.0)
|
|
|
|
def validate_config(self) -> bool:
|
|
"""Validate plugin configuration."""
|
|
# Call parent validation first
|
|
if not super().validate_config():
|
|
return False
|
|
|
|
# No additional validation needed - this is a simple plugin
|
|
return True
|
|
|
|
def get_info(self) -> Dict[str, Any]:
|
|
"""Return plugin info for web UI."""
|
|
info = super().get_info()
|
|
info.update({
|
|
'device_id': self.device_id,
|
|
'device_ip': self.device_ip,
|
|
'web_ui_url': self.web_ui_url,
|
|
'current_display_mode': self.current_display_mode
|
|
})
|
|
return info
|
|
|