mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-04-10 13:02:59 +00:00
371 lines
15 KiB
Python
371 lines
15 KiB
Python
from rgbmatrix import RGBMatrix, RGBMatrixOptions
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
import time
|
|
from typing import Dict, Any, List, Tuple
|
|
import logging
|
|
import math
|
|
from .weather_icons import WeatherIcons
|
|
|
|
# Configure logging
|
|
logging.basicConfig(level=logging.INFO)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
class DisplayManager:
|
|
_instance = None
|
|
_initialized = False
|
|
|
|
def __new__(cls, *args, **kwargs):
|
|
if cls._instance is None:
|
|
cls._instance = super(DisplayManager, cls).__new__(cls)
|
|
return cls._instance
|
|
|
|
def __init__(self, config: Dict[str, Any] = None):
|
|
self.config = config or {}
|
|
self._setup_matrix()
|
|
self._load_fonts()
|
|
|
|
def _setup_matrix(self):
|
|
"""Initialize the RGB matrix with configuration settings."""
|
|
options = RGBMatrixOptions()
|
|
|
|
# Hardware configuration
|
|
hardware_config = self.config.get('hardware', {})
|
|
options.rows = hardware_config.get('rows', 32)
|
|
options.cols = hardware_config.get('cols', 64)
|
|
options.chain_length = hardware_config.get('chain_length', 2)
|
|
options.parallel = hardware_config.get('parallel', 1)
|
|
options.hardware_mapping = hardware_config.get('hardware_mapping', 'adafruit-hat-pwm')
|
|
|
|
# Optimize display settings for performance
|
|
options.brightness = 100
|
|
options.pwm_bits = 8 # Reduced for better performance
|
|
options.pwm_lsb_nanoseconds = 100 # Reduced for faster updates
|
|
options.led_rgb_sequence = 'RGB'
|
|
options.pixel_mapper_config = ''
|
|
options.row_address_type = 0
|
|
options.multiplexing = 0
|
|
options.disable_hardware_pulsing = True # Disable pulsing for better performance
|
|
options.show_refresh_rate = False
|
|
options.limit_refresh_rate_hz = 120 # Increased refresh rate
|
|
options.gpio_slowdown = 1 # Reduced slowdown for better performance
|
|
|
|
# Initialize the matrix
|
|
self.matrix = RGBMatrix(options=options)
|
|
|
|
# Create double buffer for smooth updates
|
|
self.offscreen_canvas = self.matrix.CreateFrameCanvas()
|
|
self.current_canvas = self.matrix.CreateFrameCanvas()
|
|
|
|
# Create image with full chain width
|
|
self.image = Image.new('RGB', (self.matrix.width, self.matrix.height))
|
|
self.draw = ImageDraw.Draw(self.image)
|
|
|
|
# Initialize font with Press Start 2P
|
|
try:
|
|
self.font = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 10)
|
|
logger.info("Initial font loaded successfully")
|
|
except Exception as e:
|
|
logger.error(f"Failed to load initial font: {e}")
|
|
self.font = ImageFont.load_default()
|
|
|
|
# Draw a test pattern
|
|
self._draw_test_pattern()
|
|
|
|
def _draw_test_pattern(self):
|
|
"""Draw a test pattern to verify the display is working."""
|
|
self.clear()
|
|
|
|
# Draw a red rectangle border
|
|
self.draw.rectangle([0, 0, self.matrix.width-1, self.matrix.height-1], outline=(255, 0, 0))
|
|
|
|
# Draw a diagonal line
|
|
self.draw.line([0, 0, self.matrix.width-1, self.matrix.height-1], fill=(0, 255, 0))
|
|
|
|
# Draw some text
|
|
self.draw.text((10, 10), "TEST", font=self.font, fill=(0, 0, 255))
|
|
|
|
# Update the display once after everything is drawn
|
|
self.update_display()
|
|
time.sleep(2)
|
|
|
|
def update_display(self):
|
|
"""Update the display using double buffering with proper sync."""
|
|
try:
|
|
# Copy the current image to the offscreen canvas
|
|
self.offscreen_canvas.SetImage(self.image)
|
|
|
|
# Swap buffers immediately without waiting for vsync
|
|
self.matrix.SwapOnVSync(self.offscreen_canvas, False)
|
|
|
|
# Swap our canvas references
|
|
self.offscreen_canvas, self.current_canvas = self.current_canvas, self.offscreen_canvas
|
|
|
|
# No delay needed since we're not waiting for vsync
|
|
except Exception as e:
|
|
logger.error(f"Error updating display: {e}")
|
|
|
|
def clear(self):
|
|
"""Clear the display completely."""
|
|
try:
|
|
# Create a new black image
|
|
self.image = Image.new('RGB', (self.matrix.width, self.matrix.height))
|
|
self.draw = ImageDraw.Draw(self.image)
|
|
|
|
# Clear both canvases
|
|
self.offscreen_canvas.Clear()
|
|
self.current_canvas.Clear()
|
|
|
|
# Update the display to show the clear
|
|
self.update_display()
|
|
except Exception as e:
|
|
logger.error(f"Error clearing display: {e}")
|
|
|
|
def _load_fonts(self):
|
|
"""Load fonts optimized for LED matrix display."""
|
|
try:
|
|
# Use Press Start 2P font - perfect for LED matrix displays
|
|
font_path = "assets/fonts/PressStart2P-Regular.ttf"
|
|
|
|
# For 32px height matrix, optimized sizes for pixel-perfect display
|
|
large_size = 10 # Large text for time and main info
|
|
small_size = 8 # Small text for secondary information
|
|
|
|
try:
|
|
self.font = ImageFont.truetype(font_path, large_size)
|
|
self.small_font = ImageFont.truetype(font_path, small_size)
|
|
logger.info(f"Loaded Press Start 2P font: {font_path} (large: {large_size}px, small: {small_size}px)")
|
|
except Exception as e:
|
|
logger.warning(f"Failed to load Press Start 2P font, falling back to default: {e}")
|
|
self.font = ImageFont.load_default()
|
|
self.small_font = ImageFont.load_default()
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error in font loading: {e}")
|
|
self.font = ImageFont.load_default()
|
|
self.small_font = self.font
|
|
|
|
def draw_text(self, text: str, x: int = None, y: int = None, color: Tuple[int, int, int] = (255, 255, 255), small_font: bool = False) -> None:
|
|
"""Draw text on the display with improved clarity."""
|
|
font = self.small_font if small_font else self.font
|
|
|
|
# Get text dimensions including ascenders and descenders
|
|
bbox = self.draw.textbbox((0, 0), text, font=font)
|
|
text_width = bbox[2] - bbox[0]
|
|
text_height = bbox[3] - bbox[1]
|
|
|
|
# Add padding to prevent cutoff
|
|
padding = 1 # Reduced padding since Press Start 2P has built-in spacing
|
|
|
|
# Center text horizontally if x not specified
|
|
if x is None:
|
|
x = (self.matrix.width - text_width) // 2
|
|
|
|
# Center text vertically if y not specified, with padding
|
|
if y is None:
|
|
y = (self.matrix.height - text_height) // 2
|
|
else:
|
|
# Ensure text doesn't get cut off at bottom
|
|
max_y = self.matrix.height - text_height - padding
|
|
y = min(y, max_y)
|
|
y = max(y, padding) # Ensure text doesn't get cut off at top
|
|
|
|
# Press Start 2P is pixel-perfect, so we can draw directly without any adjustments
|
|
self.draw.text((x, y), text, font=font, fill=color)
|
|
|
|
def draw_sun(self, x: int, y: int, size: int = 16):
|
|
"""Draw a sun icon using yellow circles and lines."""
|
|
center = (x + size//2, y + size//2)
|
|
radius = size//3
|
|
|
|
# Draw the center circle
|
|
self.draw.ellipse([center[0]-radius, center[1]-radius,
|
|
center[0]+radius, center[1]+radius],
|
|
fill=(255, 255, 0)) # Yellow
|
|
|
|
# Draw the rays
|
|
ray_length = size//4
|
|
for angle in range(0, 360, 45):
|
|
rad = math.radians(angle)
|
|
start_x = center[0] + (radius * math.cos(rad))
|
|
start_y = center[1] + (radius * math.sin(rad))
|
|
end_x = center[0] + ((radius + ray_length) * math.cos(rad))
|
|
end_y = center[1] + ((radius + ray_length) * math.sin(rad))
|
|
self.draw.line([start_x, start_y, end_x, end_y], fill=(255, 255, 0), width=2)
|
|
|
|
def draw_cloud(self, x: int, y: int, size: int = 16, color=(200, 200, 200)):
|
|
"""Draw a cloud icon."""
|
|
# Draw multiple circles to form a cloud shape
|
|
self.draw.ellipse([x+size//4, y+size//3, x+size//4+size//2, y+size//3+size//2], fill=color)
|
|
self.draw.ellipse([x+size//2, y+size//3, x+size//2+size//2, y+size//3+size//2], fill=color)
|
|
self.draw.ellipse([x+size//3, y+size//6, x+size//3+size//2, y+size//6+size//2], fill=color)
|
|
|
|
def draw_rain(self, x: int, y: int, size: int = 16):
|
|
"""Draw rain icon with cloud and droplets."""
|
|
# Draw cloud
|
|
self.draw_cloud(x, y, size)
|
|
|
|
# Draw rain drops
|
|
drop_color = (0, 0, 255) # Blue
|
|
drop_size = size//6
|
|
for i in range(3):
|
|
drop_x = x + size//4 + (i * size//3)
|
|
drop_y = y + size//2
|
|
self.draw.line([drop_x, drop_y, drop_x, drop_y+drop_size],
|
|
fill=drop_color, width=2)
|
|
|
|
def draw_snow(self, x: int, y: int, size: int = 16):
|
|
"""Draw snow icon with cloud and snowflakes."""
|
|
# Draw cloud
|
|
self.draw_cloud(x, y, size)
|
|
|
|
# Draw snowflakes
|
|
snow_color = (200, 200, 255) # Light blue
|
|
for i in range(3):
|
|
center_x = x + size//4 + (i * size//3)
|
|
center_y = y + size//2 + size//4
|
|
# Draw a small star shape
|
|
for angle in range(0, 360, 60):
|
|
rad = math.radians(angle)
|
|
end_x = center_x + (size//8 * math.cos(rad))
|
|
end_y = center_y + (size//8 * math.sin(rad))
|
|
self.draw.line([center_x, center_y, end_x, end_y],
|
|
fill=snow_color, width=1)
|
|
|
|
# Weather icon color constants
|
|
WEATHER_COLORS = {
|
|
'sun': (255, 200, 0), # Bright yellow
|
|
'cloud': (200, 200, 200), # Light gray
|
|
'rain': (0, 100, 255), # Light blue
|
|
'snow': (220, 220, 255), # Ice blue
|
|
'storm': (255, 255, 0) # Lightning yellow
|
|
}
|
|
|
|
def _draw_sun(self, x: int, y: int, size: int) -> None:
|
|
"""Draw a sun icon with rays."""
|
|
center_x, center_y = x + size//2, y + size//2
|
|
radius = size//4
|
|
ray_length = size//3
|
|
|
|
# Draw the main sun circle
|
|
self.draw.ellipse([center_x - radius, center_y - radius,
|
|
center_x + radius, center_y + radius],
|
|
fill=self.WEATHER_COLORS['sun'])
|
|
|
|
# Draw sun rays
|
|
for angle in range(0, 360, 45):
|
|
rad = math.radians(angle)
|
|
start_x = center_x + int((radius + 2) * math.cos(rad))
|
|
start_y = center_y + int((radius + 2) * math.sin(rad))
|
|
end_x = center_x + int((radius + ray_length) * math.cos(rad))
|
|
end_y = center_y + int((radius + ray_length) * math.sin(rad))
|
|
self.draw.line([start_x, start_y, end_x, end_y],
|
|
fill=self.WEATHER_COLORS['sun'], width=2)
|
|
|
|
def _draw_cloud(self, x: int, y: int, size: int) -> None:
|
|
"""Draw a cloud using multiple circles."""
|
|
cloud_color = self.WEATHER_COLORS['cloud']
|
|
base_y = y + size//2
|
|
|
|
# Draw main cloud body (3 overlapping circles)
|
|
circle_radius = size//4
|
|
positions = [
|
|
(x + size//3, base_y), # Left circle
|
|
(x + size//2, base_y - size//6), # Top circle
|
|
(x + 2*size//3, base_y) # Right circle
|
|
]
|
|
|
|
for cx, cy in positions:
|
|
self.draw.ellipse([cx - circle_radius, cy - circle_radius,
|
|
cx + circle_radius, cy + circle_radius],
|
|
fill=cloud_color)
|
|
|
|
def _draw_rain(self, x: int, y: int, size: int) -> None:
|
|
"""Draw rain drops falling from a cloud."""
|
|
self._draw_cloud(x, y, size)
|
|
rain_color = self.WEATHER_COLORS['rain']
|
|
|
|
# Draw rain drops at an angle
|
|
drop_size = size//8
|
|
drops = [
|
|
(x + size//4, y + 2*size//3),
|
|
(x + size//2, y + 3*size//4),
|
|
(x + 3*size//4, y + 2*size//3)
|
|
]
|
|
|
|
for dx, dy in drops:
|
|
# Draw angled rain drops
|
|
self.draw.line([dx, dy, dx - drop_size//2, dy + drop_size],
|
|
fill=rain_color, width=2)
|
|
|
|
def _draw_snow(self, x: int, y: int, size: int) -> None:
|
|
"""Draw snowflakes falling from a cloud."""
|
|
self._draw_cloud(x, y, size)
|
|
snow_color = self.WEATHER_COLORS['snow']
|
|
|
|
# Draw snowflakes
|
|
flake_size = size//6
|
|
flakes = [
|
|
(x + size//4, y + 2*size//3),
|
|
(x + size//2, y + 3*size//4),
|
|
(x + 3*size//4, y + 2*size//3)
|
|
]
|
|
|
|
for fx, fy in flakes:
|
|
# Draw a snowflake (six-pointed star)
|
|
for angle in range(0, 360, 60):
|
|
rad = math.radians(angle)
|
|
end_x = fx + int(flake_size * math.cos(rad))
|
|
end_y = fy + int(flake_size * math.sin(rad))
|
|
self.draw.line([fx, fy, end_x, end_y],
|
|
fill=snow_color, width=1)
|
|
|
|
def _draw_storm(self, x: int, y: int, size: int) -> None:
|
|
"""Draw a storm cloud with lightning bolt."""
|
|
self._draw_cloud(x, y, size)
|
|
|
|
# Draw lightning bolt
|
|
bolt_color = self.WEATHER_COLORS['storm']
|
|
bolt_points = [
|
|
(x + size//2, y + size//2), # Top
|
|
(x + 3*size//5, y + 2*size//3), # Middle right
|
|
(x + 2*size//5, y + 2*size//3), # Middle left
|
|
(x + size//2, y + 5*size//6) # Bottom
|
|
]
|
|
self.draw.polygon(bolt_points, fill=bolt_color)
|
|
|
|
def draw_weather_icon(self, condition: str, x: int, y: int, size: int = 16) -> None:
|
|
"""Draw a weather icon based on the condition."""
|
|
if condition.lower() in ['clear', 'sunny']:
|
|
self._draw_sun(x, y, size)
|
|
elif condition.lower() in ['clouds', 'cloudy', 'partly cloudy']:
|
|
self._draw_cloud(x, y, size)
|
|
elif condition.lower() in ['rain', 'drizzle', 'shower']:
|
|
self._draw_rain(x, y, size)
|
|
elif condition.lower() in ['snow', 'sleet', 'hail']:
|
|
self._draw_snow(x, y, size)
|
|
elif condition.lower() in ['thunderstorm', 'storm']:
|
|
self._draw_storm(x, y, size)
|
|
else:
|
|
self._draw_sun(x, y, size)
|
|
# Note: No update_display() here - let the caller handle the update
|
|
|
|
def draw_text_with_icons(self, text: str, icons: List[tuple] = None, x: int = None, y: int = None,
|
|
color: tuple = (255, 255, 255)):
|
|
"""Draw text with weather icons at specified positions."""
|
|
# Draw the text
|
|
self.draw_text(text, x, y, color)
|
|
|
|
# Draw any icons
|
|
if icons:
|
|
for icon_type, icon_x, icon_y in icons:
|
|
self.draw_weather_icon(icon_type, icon_x, icon_y)
|
|
|
|
# Update the display once after everything is drawn
|
|
self.update_display()
|
|
|
|
def cleanup(self):
|
|
"""Clean up resources."""
|
|
self.matrix.Clear()
|
|
# Reset the singleton state when cleaning up
|
|
DisplayManager._instance = None
|
|
DisplayManager._initialized = False |