#!/usr/bin/env python3 """ Matrix64 LED Display with Multi-View Pagination Views: 0: Principal (Clima + Reloj + Temps) 1: Discos (HDD Details) 2: Fecha (Date + Weather) 3: Tesla (Model 3 Status) Buttons (GPIO 0 & 1): - Button 1: Siguiente vista - Button 2: Vista anterior - Mantener: Auto-ciclo """ from samplebase import SampleBase from rgbmatrix import graphics from gpiozero import Button import time import datetime import locale import math from config import LED_ROWS, LED_COLS from weather_icons import draw_weather_icon from home_assistant import ( get_weather, get_weather_description, get_interior_weather, get_brightness, get_tesla_status ) from netdata import get_hdd_temps from mqtt_listener import MQTTListener # Set Spanish locale for dates try: locale.setlocale(locale.LC_TIME, 'es_ES.UTF-8') except: try: locale.setlocale(locale.LC_TIME, 'es_ES') except: pass # Number of views NUM_VIEWS = 4 # View names (Spanish) VIEW_NAMES = { 0: "Principal", 1: "Discos", 2: "Fecha", 3: "Tesla", } # Spanish translations SPANISH_DAYS = { 'Monday': 'Lunes', 'Tuesday': 'Martes', 'Wednesday': 'Miércoles', 'Thursday': 'Jueves', 'Friday': 'Viernes', 'Saturday': 'Sábado', 'Sunday': 'Domingo' } SPANISH_MONTHS = { 'Jan': 'Ene', 'Feb': 'Feb', 'Mar': 'Mar', 'Apr': 'Abr', 'May': 'May', 'Jun': 'Jun', 'Jul': 'Jul', 'Aug': 'Ago', 'Sep': 'Sep', 'Oct': 'Oct', 'Nov': 'Nov', 'Dec': 'Dic' } def format_temp(value): try: return f"{float(value):.1f}°" except (ValueError, TypeError): return "?°" def format_humidity(value): try: return f"{int(float(value))}%" except (ValueError, TypeError): return "?%" def get_temperature_color(temp): try: temp_value = float(temp) if isinstance(temp, (int, float)) else float(str(temp).replace('°', '').strip()) if temp_value < 0: return graphics.Color(0, 100, 255) elif temp_value < 10: return graphics.Color(100, 180, 255) elif temp_value < 20: return graphics.Color(0, 255, 100) elif temp_value < 30: return graphics.Color(255, 200, 0) else: return graphics.Color(255, 50, 50) except ValueError: return graphics.Color(255, 255, 255) def get_hdd_color(temp): try: temp_value = int(temp) if temp_value < 32: return graphics.Color(0, 255, 80) elif temp_value <= 40: return graphics.Color(255, 200, 0) else: return graphics.Color(255, 50, 50) except: return graphics.Color(255, 255, 255) def get_spanish_day(now): return SPANISH_DAYS.get(now.strftime("%A"), now.strftime("%A")) def get_spanish_date(now): month = SPANISH_MONTHS.get(now.strftime("%b"), now.strftime("%b")) return f"{now.day} {month}" class Matrix64Display(SampleBase): def __init__(self, *args, **kwargs): super(Matrix64Display, self).__init__(*args, **kwargs) self.parser.add_argument("-t", "--text", help="Text", default="") # Data self.temperature = None self.humidity = None self.interior_temperature = None self.interior_humidity = None self.last_update = time.time() self.weather_desc = "unknown" self.hdd_temps = None self.tesla = None # View state self.current_view = 0 self.auto_cycle = False self.last_view_change = time.time() self.view_cycle_interval = 5 # Feedback self.feedback_message = None self.feedback_until = 0 # Animation self.animation_frame = 0 # MQTT listener self.mqtt = MQTTListener() self.mqtt.start() # Menu state self.menu_mode = False self.menu_selection = 0 # Swipe gesture detection self.last_button = None self.last_button_time = 0 self.swipe_timeout = 0.4 # seconds # Buttons self.button0 = Button(0, pull_up=True, hold_time=1.0) self.button1 = Button(1, pull_up=True, hold_time=1.0) self.button0.when_pressed = lambda: self.on_button_press(0) self.button1.when_pressed = lambda: self.on_button_press(1) self.button0.when_held = self.on_button0_held self.button1.when_held = self.on_button1_held def show_feedback(self, message, duration=2): self.feedback_message = message self.feedback_until = time.time() + duration def on_button_press(self, button_id): """Handle button press for swipe detection. Physical layout: Button 1 = left, Button 0 = right So swipe left→right = 1→0, swipe right→left = 0→1 """ now = time.time() # Check for swipe gesture if self.last_button is not None and (now - self.last_button_time) < self.swipe_timeout: if self.last_button == 1 and button_id == 0: # Swipe left→right (1→0): Next view / Menu Down if self.menu_mode: self.menu_selection = (self.menu_selection + 1) % NUM_VIEWS else: self.current_view = (self.current_view + 1) % NUM_VIEWS self.last_view_change = now self.show_feedback(VIEW_NAMES[self.current_view], 1.5) self.last_button = None return elif self.last_button == 0 and button_id == 1: # Swipe right→left (0→1): Prev view / Menu Up if self.menu_mode: self.menu_selection = (self.menu_selection - 1) % NUM_VIEWS else: self.current_view = (self.current_view - 1) % NUM_VIEWS self.last_view_change = now self.show_feedback(VIEW_NAMES[self.current_view], 1.5) self.last_button = None return # Record button press for potential swipe or single press self.last_button = button_id self.last_button_time = now def check_button_timeout(self): """Check if pending button press has timed out (single press).""" if self.last_button is not None: if (time.time() - self.last_button_time) > self.swipe_timeout: # Timeout reached, treat as single press if self.menu_mode and self.last_button == 1: # Single press on Left button (1) selects in menu self.on_menu_select() self.last_button = None def on_menu_select(self): """Called when user wants to select in menu (right button alone after timeout).""" if self.menu_mode: self.current_view = self.menu_selection self.menu_mode = False self.show_feedback(VIEW_NAMES[self.current_view], 1.5) def on_button0_held(self): """Long press right button = toggle auto-cycle.""" if not self.menu_mode: self.auto_cycle = not self.auto_cycle self.show_feedback("AUTO ON" if self.auto_cycle else "AUTO OFF", 2) self.last_button = None def on_button1_held(self): """Long press left button = open menu.""" self.menu_mode = not self.menu_mode if self.menu_mode: self.menu_selection = self.current_view self.show_feedback("Menú", 1) else: self.show_feedback("Cerrado", 1) self.last_button = None def update_data(self): try: weather = get_weather() if weather.get("temperature") is not None: self.temperature = float(weather.get("temperature")) self.humidity = weather.get("humidity") self.weather_desc = get_weather_description() interior = get_interior_weather() if interior.get("temperature") is not None: self.interior_temperature = float(interior.get("temperature")) self.interior_humidity = interior.get("humidity") self.tesla = get_tesla_status() except Exception as e: print(f"Error updating data: {e}") def update_brightness(self): try: brightness = get_brightness() if brightness: self.matrix.brightness = int(float(brightness)) / 10 + 1 except: self.matrix.brightness = 5 def update_hdd_temps(self): self.hdd_temps = get_hdd_temps() def draw_camera_alert(self, canvas, fonts): """Draw flashing camera alert overlay.""" time_font, data_font, small_font = fonts # Dark red background bg_color = graphics.Color(40, 0, 0) for y in range(20, 48): graphics.DrawLine(canvas, 0, y, 63, y, bg_color) # Flashing red border flash = int(time.time() * 4) % 2 border_color = graphics.Color(255, 0, 0) if flash else graphics.Color(100, 0, 0) graphics.DrawLine(canvas, 0, 20, 63, 20, border_color) graphics.DrawLine(canvas, 0, 47, 63, 47, border_color) # Alert text (centered) alert_color = graphics.Color(255, 80, 80) graphics.DrawText(canvas, data_font, 12, 32, alert_color, "ALERTA") graphics.DrawText(canvas, small_font, 10, 42, alert_color, "Camara ext") def draw_feedback(self, canvas, fonts, colors): time_font, data_font, small_font = fonts time_color, humidity_color, hdd_color, label_color, line_color = colors # Dark blue background bg_color = graphics.Color(0, 20, 40) for y in range(22, 46): graphics.DrawLine(canvas, 0, y, 63, y, bg_color) # Border lines border_color = graphics.Color(30, 90, 150) graphics.DrawLine(canvas, 0, 22, 63, 22, border_color) graphics.DrawLine(canvas, 0, 45, 63, 45, border_color) # Message msg_color = graphics.Color(100, 255, 150) msg_len = len(self.feedback_message) * 7 x = (64 - msg_len) // 2 graphics.DrawText(canvas, time_font, x, 38, msg_color, self.feedback_message) def draw_menu(self, canvas, fonts, colors): """Draw view selection menu with scrolling.""" time_font, data_font, small_font = fonts time_color, humidity_color, hdd_color, label_color, line_color = colors # Full black background for y in range(64): graphics.DrawLine(canvas, 0, y, 63, y, graphics.Color(0, 0, 0)) # Title title = "Vistas" title_len = len(title) * 5 title_x = (64 - title_len) // 2 graphics.DrawText(canvas, data_font, title_x, 8, time_color, title) graphics.DrawLine(canvas, 0, 10, 63, 10, line_color) # Menu items (3 visible at a time, selected centered) visible_items = 4 item_height = 12 start_y = 22 # Calculate scroll offset to keep selection centered center_slot = visible_items // 2 scroll_offset = max(0, min(self.menu_selection - center_slot, NUM_VIEWS - visible_items)) for i in range(visible_items): view_idx = scroll_offset + i if view_idx >= NUM_VIEWS: break y = start_y + i * item_height name = VIEW_NAMES[view_idx] if view_idx == self.menu_selection: # Highlight selected highlight_color = graphics.Color(0, 40, 80) for hy in range(y - 9, y + 2): if 0 <= hy < 64: graphics.DrawLine(canvas, 0, hy, 63, hy, highlight_color) text_color = graphics.Color(100, 255, 200) graphics.DrawText(canvas, data_font, 4, y, text_color, "> " + name) else: text_color = graphics.Color(100, 100, 100) graphics.DrawText(canvas, data_font, 8, y, text_color, name) # Scroll indicators if scroll_offset > 0: graphics.DrawText(canvas, small_font, 58, 18, label_color, "^") if scroll_offset + visible_items < NUM_VIEWS: graphics.DrawText(canvas, small_font, 58, 62, label_color, "v") def draw_view_0(self, canvas, fonts, colors): """Vista Principal""" time_font, data_font, small_font = fonts time_color, humidity_color, hdd_color, label_color, line_color = colors now = datetime.datetime.now() time_str = f"{now.hour:02d}:{now.minute:02d}:{now.second:02d}" # Weather Icon (cloud moves, sun/icon stays fixed) cloud_offset = int(math.sin(self.animation_frame * 0.3) * 2) draw_weather_icon(canvas, 0, 0, self.weather_desc, cloud_offset) graphics.DrawLine(canvas, 28, 0, 28, 25, line_color) if self.hdd_temps and len(self.hdd_temps) > 1: temps = [str(int(t[0])) for t in self.hdd_temps[1:] if t and t[0] is not None] if len(temps) >= 2: graphics.DrawText(canvas, data_font, 34, 8, hdd_color, " ".join(temps[:2])) if len(temps) >= 4: graphics.DrawText(canvas, data_font, 34, 16, hdd_color, " ".join(temps[2:4])) if len(temps) > 4: graphics.DrawText(canvas, data_font, 34, 24, hdd_color, " ".join(temps[4:6])) graphics.DrawLine(canvas, 0, 26, 63, 26, line_color) time_x = (64 - len(time_str) * 7) // 2 graphics.DrawText(canvas, time_font, time_x, 39, time_color, time_str) graphics.DrawLine(canvas, 0, 42, 63, 42, line_color) graphics.DrawText(canvas, data_font, 0, 52, label_color, "Ext") if self.temperature is not None: temp_color = get_temperature_color(self.temperature) graphics.DrawText(canvas, data_font, 18, 52, temp_color, format_temp(self.temperature)) if self.humidity is not None: graphics.DrawText(canvas, data_font, 46, 52, humidity_color, format_humidity(self.humidity)) graphics.DrawText(canvas, data_font, 0, 62, label_color, "Int") if self.interior_temperature is not None: temp_color = get_temperature_color(self.interior_temperature) graphics.DrawText(canvas, data_font, 18, 62, temp_color, format_temp(self.interior_temperature)) if self.interior_humidity is not None: graphics.DrawText(canvas, data_font, 46, 62, humidity_color, format_humidity(self.interior_humidity)) def draw_view_1(self, canvas, fonts, colors): """Vista Discos""" time_font, data_font, small_font = fonts time_color, humidity_color, hdd_color, label_color, line_color = colors # Centered title title = "Discos" title_len = len(title) * 5 title_x = (64 - title_len) // 2 graphics.DrawText(canvas, data_font, title_x, 8, time_color, title) graphics.DrawLine(canvas, 0, 10, 63, 10, line_color) if self.hdd_temps and len(self.hdd_temps) > 1: temps = [(i+1, int(t[0])) for i, t in enumerate(self.hdd_temps[1:7]) if t and t[0] is not None] y = 22 for idx, (num, temp_val) in enumerate(temps[:3]): temp_color = get_hdd_color(temp_val) graphics.DrawText(canvas, data_font, 2, y, label_color, f"{num}:") graphics.DrawText(canvas, data_font, 16, y, temp_color, f"{temp_val}C") y += 11 y = 22 for idx, (num, temp_val) in enumerate(temps[3:6]): temp_color = get_hdd_color(temp_val) graphics.DrawText(canvas, data_font, 34, y, label_color, f"{num}:") graphics.DrawText(canvas, data_font, 48, y, temp_color, f"{temp_val}C") y += 11 if self.auto_cycle: graphics.DrawText(canvas, small_font, 58, 62, label_color, "A") def draw_view_2(self, canvas, fonts, colors): """Vista Fecha""" time_font, data_font, small_font = fonts time_color, humidity_color, hdd_color, label_color, line_color = colors now = datetime.datetime.now() # Day name centered day_str = get_spanish_day(now) day_len = len(day_str) * 5 day_x = (64 - day_len) // 2 graphics.DrawText(canvas, data_font, day_x, 12, label_color, day_str) # Date centered date_str = get_spanish_date(now) date_len = len(date_str) * 7 date_x = (64 - date_len) // 2 graphics.DrawText(canvas, time_font, date_x, 26, time_color, date_str) graphics.DrawLine(canvas, 0, 30, 63, 30, line_color) # Weather icon (cloud animates, position fixed) cloud_offset = int(math.sin(self.animation_frame * 0.3) * 2) draw_weather_icon(canvas, 4, 34, self.weather_desc, cloud_offset) if self.temperature is not None: temp_color = get_temperature_color(self.temperature) graphics.DrawText(canvas, time_font, 32, 52, temp_color, f"{self.temperature:.0f}C") if self.auto_cycle: graphics.DrawText(canvas, small_font, 58, 62, label_color, "A") def draw_view_3(self, canvas, fonts, colors): """Vista Tesla""" time_font, data_font, small_font = fonts time_color, humidity_color, hdd_color, label_color, line_color = colors # Centered title title = "Tesla" title_len = len(title) * 5 title_x = (64 - title_len) // 2 graphics.DrawText(canvas, data_font, title_x, 8, time_color, title) graphics.DrawLine(canvas, 0, 10, 63, 10, line_color) if self.tesla: # Battery (with margin) battery = self.tesla.get("battery") if battery is not None: battery_color = graphics.Color(0, 255, 80) if battery > 20 else graphics.Color(255, 50, 50) graphics.DrawText(canvas, data_font, 4, 24, label_color, "Bat") graphics.DrawText(canvas, time_font, 26, 26, battery_color, f"{battery}%") # Range (with margin) range_km = self.tesla.get("range") if range_km is not None: graphics.DrawText(canvas, data_font, 4, 38, label_color, "Km") graphics.DrawText(canvas, time_font, 22, 40, time_color, f"{range_km}") # Status (centered) charging = self.tesla.get("charging", False) plugged = self.tesla.get("plugged", False) if charging: status = "Cargando" status_color = graphics.Color(0, 255, 100) elif plugged: status = "Conectado" status_color = graphics.Color(255, 200, 0) else: status = "Listo" status_color = graphics.Color(100, 100, 100) status_len = len(status) * 5 status_x = (64 - status_len) // 2 graphics.DrawText(canvas, data_font, status_x, 56, status_color, status) else: graphics.DrawText(canvas, data_font, 4, 32, label_color, "Sin datos") if self.auto_cycle: graphics.DrawText(canvas, small_font, 58, 62, label_color, "A") def run(self): canvas = self.matrix.CreateFrameCanvas() font_dir = "/home/pi/rpi-rgb-led-matrix/fonts" time_font = graphics.Font() time_font.LoadFont(f"{font_dir}/7x13.bdf") data_font = graphics.Font() data_font.LoadFont(f"{font_dir}/5x8.bdf") small_font = graphics.Font() small_font.LoadFont(f"{font_dir}/4x6.bdf") fonts = (time_font, data_font, small_font) time_color = graphics.Color(30, 90, 220) humidity_color = graphics.Color(80, 160, 255) hdd_color = graphics.Color(140, 120, 100) label_color = graphics.Color(120, 120, 120) line_color = graphics.Color(40, 40, 40) colors = (time_color, humidity_color, hdd_color, label_color, line_color) self.update_brightness() self.update_data() self.update_hdd_temps() while True: current_time = time.time() # Check for button timeout (single press detection) self.check_button_timeout() if current_time - self.last_update >= 60: self.update_data() self.update_hdd_temps() self.last_update = current_time if current_time - self.last_update >= 15: self.update_brightness() if self.auto_cycle and (current_time - self.last_view_change >= self.view_cycle_interval): self.current_view = (self.current_view + 1) % NUM_VIEWS self.last_view_change = current_time self.animation_frame += 1 canvas.Clear() # Menu mode takes priority over views if self.menu_mode: self.draw_menu(canvas, fonts, colors) else: # Draw current view if self.current_view == 0: self.draw_view_0(canvas, fonts, colors) elif self.current_view == 1: self.draw_view_1(canvas, fonts, colors) elif self.current_view == 2: self.draw_view_2(canvas, fonts, colors) elif self.current_view == 3: self.draw_view_3(canvas, fonts, colors) # Camera alert overlay (highest priority) if self.mqtt.is_alert_active(): self.draw_camera_alert(canvas, fonts) # Feedback overlay (only when not in menu) elif not self.menu_mode and self.feedback_message and current_time < self.feedback_until: self.draw_feedback(canvas, fonts, colors) else: self.feedback_message = None time.sleep(0.5) canvas = self.matrix.SwapOnVSync(canvas) if __name__ == "__main__": display = Matrix64Display() if not display.process(): display.print_help()