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