fix: reduce CPU usage, fix Vegas refresh, throttle high-FPS ticks (#304)

* 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>
This commit is contained in:
Chuck
2026-04-02 08:46:52 -04:00
committed by GitHub
parent 5ea2acd897
commit efe6b1fe23
3 changed files with 133 additions and 91 deletions

View File

@@ -47,26 +47,55 @@ class WebUIInfoPlugin(BasePlugin):
# IP refresh tracking
self.last_ip_refresh = time.time()
self.ip_refresh_interval = 30.0 # Refresh IP every 30 seconds
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.
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:
# Check if hostapd service is running
result = subprocess.run(
["systemctl", "is-active", "hostapd"],
capture_output=True,
@@ -74,9 +103,10 @@ class WebUIInfoPlugin(BasePlugin):
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
# Check if wlan0 has AP mode IP (192.168.4.1)
result = subprocess.run(
["ip", "addr", "show", "wlan0"],
capture_output=True,
@@ -84,18 +114,24 @@ class WebUIInfoPlugin(BasePlugin):
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
"""
@@ -103,9 +139,23 @@ class WebUIInfoPlugin(BasePlugin):
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 using 'hostname -I' first (fastest, gets all IPs)
# 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,
@@ -114,13 +164,12 @@ class WebUIInfoPlugin(BasePlugin):
)
if result.returncode == 0:
ips = result.stdout.strip().split()
# Filter out loopback and AP mode IPs
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"],
@@ -132,22 +181,18 @@ class WebUIInfoPlugin(BasePlugin):
current_interface = None
for line in result.stdout.split('\n'):
line = line.strip()
# Check for interface name
if ':' in line and not line.startswith('inet'):
parts = line.split(':')
if len(parts) >= 2:
current_interface = parts[1].strip().split('@')[0]
# Check for inet address
elif line.startswith('inet '):
parts = line.split()
if len(parts) >= 2:
ip_with_cidr = parts[1]
ip = ip_with_cidr.split('/')[0]
# Skip loopback and AP mode IPs
if not ip.startswith("127.") and ip != "192.168.4.1":
# Prefer eth0/ethernet interfaces, then wlan0, then others
if current_interface and (
current_interface.startswith("eth") or
current_interface.startswith("eth") or
current_interface.startswith("enp")
):
self.logger.debug(f"Found Ethernet IP: {ip} on {current_interface}")
@@ -155,19 +200,6 @@ class WebUIInfoPlugin(BasePlugin):
elif current_interface == "wlan0":
self.logger.debug(f"Found WiFi IP: {ip} on {current_interface}")
return ip
# Fallback: Try socket method (requires internet connectivity)
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
# Connect to a public DNS server (doesn't actually connect)
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
@@ -190,24 +222,24 @@ class WebUIInfoPlugin(BasePlugin):
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:
# Refresh IP address to handle network state changes
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
"""
@@ -215,93 +247,66 @@ class WebUIInfoPlugin(BasePlugin):
# Check if we need to rotate between hostname and IP
current_time = time.time()
if current_time - self.last_rotation_time >= self.rotation_interval:
# Switch display mode
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)
# Try to load a small font
# Try to find project root and use assets/fonts
font_small = None
try:
# Try to find project root (parent of plugins directory)
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():
font_small = ImageFont.truetype(str(font_path), 6)
else:
# Try relative path from current working directory
font_path = "assets/fonts/4x6-font.ttf"
if os.path.exists(font_path):
font_small = ImageFont.truetype(font_path, 6)
else:
font_small = ImageFont.load_default()
except Exception as e:
self.logger.debug(f"Could not load custom font: {e}, using default")
font_small = ImageFont.load_default()
# Determine which address to display
if self.current_display_mode == "ip":
address = self.device_ip
else:
address = self.device_id
# Prepare text to display
lines = [
"visit web ui",
f"at {address}:5000"
]
# Calculate text positions (centered)
y_start = 5
line_height = 8
total_height = len(lines) * line_height
# Draw each line
for i, line in enumerate(lines):
# Get text size for centering
bbox = draw.textbbox((0, 0), line, font=font_small)
bbox = draw.textbbox((0, 0), line, font=self._font_small)
text_width = bbox[2] - bbox[0]
text_height = bbox[3] - bbox[1]
# Center horizontally
x = (width - text_width) // 2
y = y_start + (i * line_height)
# Draw text in white
draw.text((x, y), line, font=font_small, fill=(255, 255, 255))
# Set the image on the display manager
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
# Update the display
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}")
# Fallback: just clear the display
try:
self.display_manager.clear()
self.display_manager.update_display()
except:
except Exception:
pass
def get_display_duration(self) -> float: