Files
LEDMatrix/plugin-repos/web-ui-info/manager.py
Chuck 68a38c39f7 fix(web-ui-info): syntax error and init order crash on fresh install (#312)
Two bugs that prevented the web-ui-info default plugin from loading:

1. Orphaned `except Exception` at line 203 with no matching `try` —
   caused a SyntaxError preventing the module from importing at all.
   Removed the orphan; the outer try/except already covers the block.

2. `_get_local_ip()` called in __init__ before `_ap_mode_cache_time`
   was initialized, causing AttributeError. Moved AP mode cache field
   initialization above the `_get_local_ip()` call.

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-19 11:56:05 -04:00

334 lines
12 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)
# AP mode cache (must be initialized before _get_local_ip)
self._ap_mode_cached = False
self._ap_mode_cache_time = 0.0
self._ap_mode_cache_ttl = 60.0
# 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
# 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
# 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