From 21b82129f23c9d0c1394b85f89c6fa151b0e7829 Mon Sep 17 00:00:00 2001 From: Joan Date: Wed, 31 Dec 2025 13:29:41 +0100 Subject: [PATCH] Add Tesla view, MQTT camera alerts, Spanish translations --- .env.example | 11 +++ config.py | 11 +++ home_assistant.py | 19 ++++- matrix.py | 206 +++++++++++++++++++++++++++------------------- mqtt_listener.py | 59 +++++++++++++ requirements.txt | 1 + 6 files changed, 222 insertions(+), 85 deletions(-) create mode 100644 mqtt_listener.py diff --git a/.env.example b/.env.example index 6cd9877..c4ced4d 100644 --- a/.env.example +++ b/.env.example @@ -14,3 +14,14 @@ NETDATA_URL=http://your-netdata-host:19999 # LED Matrix Configuration LED_ROWS=64 LED_COLS=64 + +# Tesla Configuration +TESLA_BATTERY_ENTITY=sensor.lovelace_battery +TESLA_RANGE_ENTITY=sensor.lovelace_range +TESLA_CHARGING_ENTITY=binary_sensor.lovelace_charging +TESLA_PLUGGED_ENTITY=binary_sensor.lovelace_plugged_in + +# MQTT Configuration +MQTT_BROKER=10.2.10.165 +MQTT_PORT=1883 +MQTT_CAMERA_TOPIC=iSpy/cameras/exterior lliçà/alert diff --git a/config.py b/config.py index f30e515..405d578 100644 --- a/config.py +++ b/config.py @@ -24,3 +24,14 @@ NETDATA_URL = os.getenv('NETDATA_URL') # LED Matrix Configuration LED_ROWS = int(os.getenv('LED_ROWS', '64')) LED_COLS = int(os.getenv('LED_COLS', '64')) + +# Tesla Configuration +TESLA_BATTERY_ENTITY = os.getenv('TESLA_BATTERY_ENTITY', 'sensor.lovelace_battery') +TESLA_RANGE_ENTITY = os.getenv('TESLA_RANGE_ENTITY', 'sensor.lovelace_range') +TESLA_CHARGING_ENTITY = os.getenv('TESLA_CHARGING_ENTITY', 'binary_sensor.lovelace_charging') +TESLA_PLUGGED_ENTITY = os.getenv('TESLA_PLUGGED_ENTITY', 'binary_sensor.lovelace_plugged_in') + +# MQTT Configuration +MQTT_BROKER = os.getenv('MQTT_BROKER', '10.2.10.165') +MQTT_PORT = int(os.getenv('MQTT_PORT', '1883')) +MQTT_CAMERA_TOPIC = os.getenv('MQTT_CAMERA_TOPIC', 'iSpy/cameras/exterior lliçà/alert') diff --git a/home_assistant.py b/home_assistant.py index 0dac2cc..4b70e13 100644 --- a/home_assistant.py +++ b/home_assistant.py @@ -5,7 +5,9 @@ import requests from config import ( HA_TOKEN, HASS_URL, BRIGHTNESS_ENTITY_ID, WEATHER_ENTITY_ID, - INTERIOR_TEMP_ENTITY_ID, INTERIOR_HUMIDITY_ENTITY_ID + INTERIOR_TEMP_ENTITY_ID, INTERIOR_HUMIDITY_ENTITY_ID, + TESLA_BATTERY_ENTITY, TESLA_RANGE_ENTITY, + TESLA_CHARGING_ENTITY, TESLA_PLUGGED_ENTITY ) @@ -61,3 +63,18 @@ def get_brightness(): if brightness: return brightness.get("state") return None + + +def get_tesla_status(): + """Get Tesla Model 3 status from Home Assistant.""" + battery = get_entity_value(TESLA_BATTERY_ENTITY) + range_km = get_entity_value(TESLA_RANGE_ENTITY) + charging = get_entity_value(TESLA_CHARGING_ENTITY) + plugged = get_entity_value(TESLA_PLUGGED_ENTITY) + + return { + "battery": int(float(battery.get("state", 0))) if battery else None, + "range": int(float(range_km.get("state", 0))) if range_km else None, + "charging": charging.get("state") == "on" if charging else False, + "plugged": plugged.get("state") == "on" if plugged else False, + } diff --git a/matrix.py b/matrix.py index 1cb3093..2400bd2 100644 --- a/matrix.py +++ b/matrix.py @@ -3,14 +3,15 @@ Matrix64 LED Display with Multi-View Pagination Views: - 0: Main (Weather + Clock + Temps) - 1: HDD Details - 2: Date + Weather (Spanish) + 0: Principal (Clima + Reloj + Temps) + 1: Discos (HDD Details) + 2: Fecha (Date + Weather) + 3: Tesla (Model 3 Status) Buttons (GPIO 0 & 1): - - Button 1: Next view - - Button 2: Previous view - - Long press: Toggle auto-cycle + - Button 1: Siguiente vista + - Button 2: Vista anterior + - Mantener: Auto-ciclo """ from samplebase import SampleBase from rgbmatrix import graphics @@ -18,14 +19,16 @@ 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_interior_weather, get_brightness, get_tesla_status ) from netdata import get_hdd_temps +from mqtt_listener import MQTTListener # Set Spanish locale for dates try: @@ -34,19 +37,20 @@ except: try: locale.setlocale(locale.LC_TIME, 'es_ES') except: - pass # Fall back to default + pass # Number of views -NUM_VIEWS = 3 +NUM_VIEWS = 4 # View names (Spanish) VIEW_NAMES = { 0: "Principal", 1: "Discos", 2: "Fecha", + 3: "Tesla", } -# Spanish day names fallback +# Spanish translations SPANISH_DAYS = { 'Monday': 'Lunes', 'Tuesday': 'Martes', 'Wednesday': 'Miércoles', 'Thursday': 'Jueves', 'Friday': 'Viernes', 'Saturday': 'Sábado', 'Sunday': 'Domingo' @@ -58,7 +62,6 @@ SPANISH_MONTHS = { def format_temp(value): - """Format temperature to one decimal place.""" try: return f"{float(value):.1f}°" except (ValueError, TypeError): @@ -66,7 +69,6 @@ def format_temp(value): def format_humidity(value): - """Format humidity as integer percentage.""" try: return f"{int(float(value))}%" except (ValueError, TypeError): @@ -74,7 +76,6 @@ def format_humidity(value): def get_temperature_color(temp): - """Determine color based on temperature value.""" try: temp_value = float(temp) if isinstance(temp, (int, float)) else float(str(temp).replace('°', '').strip()) if temp_value < 0: @@ -92,31 +93,25 @@ def get_temperature_color(temp): def get_hdd_color(temp): - """Color for HDD temps: green <32, yellow 32-40, red >40.""" try: temp_value = int(temp) if temp_value < 32: - return graphics.Color(0, 255, 80) # Green + return graphics.Color(0, 255, 80) elif temp_value <= 40: - return graphics.Color(255, 200, 0) # Yellow + return graphics.Color(255, 200, 0) else: - return graphics.Color(255, 50, 50) # Red + return graphics.Color(255, 50, 50) except: return graphics.Color(255, 255, 255) def get_spanish_day(now): - """Get day name in Spanish.""" - day_en = now.strftime("%A") - return SPANISH_DAYS.get(day_en, day_en) + return SPANISH_DAYS.get(now.strftime("%A"), now.strftime("%A")) def get_spanish_date(now): - """Get date in Spanish format.""" - day = now.day - month_en = now.strftime("%b") - month = SPANISH_MONTHS.get(month_en, month_en) - return f"{day} {month}" + month = SPANISH_MONTHS.get(now.strftime("%b"), now.strftime("%b")) + return f"{now.day} {month}" class Matrix64Display(SampleBase): @@ -132,59 +127,52 @@ class Matrix64Display(SampleBase): 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 # seconds + self.view_cycle_interval = 5 - # Feedback message + # Feedback self.feedback_message = None self.feedback_until = 0 # Animation self.animation_frame = 0 - # Button setup + # MQTT listener + self.mqtt = MQTTListener() + self.mqtt.start() + + # Buttons self.button1 = Button(0, pull_up=True, hold_time=1.5) self.button2 = Button(1, pull_up=True, hold_time=1.5) - - # Button callbacks self.button1.when_pressed = self.on_button1_press self.button1.when_held = self.on_button_held self.button2.when_pressed = self.on_button2_press self.button2.when_held = self.on_button_held def show_feedback(self, message, duration=2): - """Show a feedback message for a duration.""" self.feedback_message = message self.feedback_until = time.time() + duration def on_button1_press(self): - """Next view.""" if not self.button1.is_held: self.current_view = (self.current_view + 1) % NUM_VIEWS self.last_view_change = time.time() self.show_feedback(VIEW_NAMES[self.current_view], 1.5) - print(f"View: {self.current_view}") def on_button2_press(self): - """Previous view.""" if not self.button2.is_held: self.current_view = (self.current_view - 1) % NUM_VIEWS self.last_view_change = time.time() self.show_feedback(VIEW_NAMES[self.current_view], 1.5) - print(f"View: {self.current_view}") def on_button_held(self): - """Toggle auto-cycle on long press.""" self.auto_cycle = not self.auto_cycle - if self.auto_cycle: - self.show_feedback("AUTO ON", 2) - else: - self.show_feedback("AUTO OFF", 2) - print(f"Auto-cycle: {self.auto_cycle}") + self.show_feedback("AUTO ON" if self.auto_cycle else "AUTO OFF", 2) def update_data(self): try: @@ -198,6 +186,8 @@ class Matrix64Display(SampleBase): 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}") @@ -206,44 +196,56 @@ class Matrix64Display(SampleBase): brightness = get_brightness() if brightness: self.matrix.brightness = int(float(brightness)) / 10 + 1 - except Exception as e: + 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 + + # Flashing red border + flash = int(time.time() * 4) % 2 + if flash: + border_color = graphics.Color(255, 0, 0) + for i in range(2): + graphics.DrawLine(canvas, i, 0, i, 63, border_color) + graphics.DrawLine(canvas, 63-i, 0, 63-i, 63, border_color) + graphics.DrawLine(canvas, 0, i, 63, i, border_color) + graphics.DrawLine(canvas, 0, 63-i, 63, 63-i, border_color) + + # Alert text + alert_color = graphics.Color(255, 50, 50) + graphics.DrawText(canvas, data_font, 8, 32, alert_color, "ALERTA") + graphics.DrawText(canvas, small_font, 6, 42, alert_color, "Camara") + def draw_feedback(self, canvas, fonts, colors): - """Draw feedback message overlay.""" time_font, data_font, small_font = fonts time_color, humidity_color, hdd_color, label_color, line_color = colors - # Dark background for y in range(24, 44): graphics.DrawLine(canvas, 0, y, 63, y, graphics.Color(0, 0, 0)) - # Message msg_color = graphics.Color(0, 255, 100) 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_view_0(self, canvas, fonts, colors): - """Main view: Weather + Clock + Temps.""" + """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 (with smooth sine wave animation) - import math anim_offset = int(math.sin(self.animation_frame * 0.3) * 2) draw_weather_icon(canvas, anim_offset, 0, self.weather_desc) - # Vertical separator graphics.DrawLine(canvas, 28, 0, 28, 25, line_color) - # HDD Temps (3 rows of 2) if self.hdd_temps and len(self.hdd_temps) > 1: temps = [str(int(t[0])) for t in self.hdd_temps[1:] if t] if len(temps) >= 2: @@ -253,41 +255,36 @@ class Matrix64Display(SampleBase): if len(temps) > 4: graphics.DrawText(canvas, data_font, 34, 24, hdd_color, " ".join(temps[4:6])) - # Horizontal lines around clock 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) - # Outdoor temp/humidity - graphics.DrawText(canvas, data_font, 0, 52, label_color, "Out") + 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, 20, 52, temp_color, format_temp(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, 48, 52, humidity_color, format_humidity(self.humidity)) + graphics.DrawText(canvas, data_font, 46, 52, humidity_color, format_humidity(self.humidity)) - # Indoor temp/humidity - graphics.DrawText(canvas, data_font, 0, 62, label_color, "In") + 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, 20, 62, temp_color, format_temp(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, 48, 62, humidity_color, format_humidity(self.interior_humidity)) + graphics.DrawText(canvas, data_font, 46, 62, humidity_color, format_humidity(self.interior_humidity)) def draw_view_1(self, canvas, fonts, colors): - """HDD Details view - 6 HDDs in 2 columns.""" + """Vista Discos""" time_font, data_font, small_font = fonts time_color, humidity_color, hdd_color, label_color, line_color = colors - # Title graphics.DrawText(canvas, data_font, 4, 8, time_color, "Discos") 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] - # Two columns: left (1-3), right (4-6) with bigger font y = 20 for idx, (num, temp_val) in enumerate(temps[:3]): temp_color = get_hdd_color(temp_val) @@ -302,45 +299,87 @@ class Matrix64Display(SampleBase): graphics.DrawText(canvas, data_font, 48, y, temp_color, f"{temp_val}C") y += 10 - # Auto-cycle indicator if self.auto_cycle: graphics.DrawText(canvas, small_font, 58, 62, label_color, "A") def draw_view_2(self, canvas, fonts, colors): - """Date + Weather view (Spanish).""" + """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 in Spanish + # Day name centered day_str = get_spanish_day(now) - graphics.DrawText(canvas, data_font, 4, 10, label_color, day_str) + 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 in Spanish + # Date centered date_str = get_spanish_date(now) - graphics.DrawText(canvas, time_font, 4, 24, time_color, date_str) + 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, 28, 63, 28, line_color) + graphics.DrawLine(canvas, 0, 30, 63, 30, line_color) - # Weather icon with smooth animation - import math anim_offset = int(math.sin(self.animation_frame * 0.3) * 2) - draw_weather_icon(canvas, 4 + anim_offset, 32, self.weather_desc) + draw_weather_icon(canvas, 4 + anim_offset, 34, self.weather_desc) - # Temperature if self.temperature is not None: temp_color = get_temperature_color(self.temperature) - graphics.DrawText(canvas, time_font, 32, 50, temp_color, f"{self.temperature:.0f}C") + 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 + + # Title + graphics.DrawText(canvas, data_font, 14, 8, time_color, "Tesla") + graphics.DrawLine(canvas, 0, 10, 63, 10, line_color) + + if self.tesla: + # Battery + 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, 0, 22, label_color, "Bat:") + graphics.DrawText(canvas, time_font, 24, 24, battery_color, f"{battery}%") + + # Range + range_km = self.tesla.get("range") + if range_km is not None: + graphics.DrawText(canvas, data_font, 0, 36, label_color, "Km:") + graphics.DrawText(canvas, time_font, 20, 38, time_color, f"{range_km}") + + # Status + 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) + + graphics.DrawText(canvas, data_font, 4, 54, status_color, status) + else: + graphics.DrawText(canvas, data_font, 4, 32, label_color, "Sin datos") - # Auto-cycle indicator if self.auto_cycle: graphics.DrawText(canvas, small_font, 58, 62, label_color, "A") def run(self): canvas = self.matrix.CreateFrameCanvas() - # Fonts font_dir = "/home/pi/rpi-rgb-led-matrix/fonts" time_font = graphics.Font() time_font.LoadFont(f"{font_dir}/7x13.bdf") @@ -351,7 +390,6 @@ class Matrix64Display(SampleBase): fonts = (time_font, data_font, small_font) - # Colors time_color = graphics.Color(30, 90, 220) humidity_color = graphics.Color(80, 160, 255) hdd_color = graphics.Color(140, 120, 100) @@ -360,7 +398,6 @@ class Matrix64Display(SampleBase): colors = (time_color, humidity_color, hdd_color, label_color, line_color) - # Initial fetch self.update_brightness() self.update_data() self.update_hdd_temps() @@ -368,33 +405,34 @@ class Matrix64Display(SampleBase): while True: current_time = time.time() - # Update data every 60 seconds if current_time - self.last_update >= 60: self.update_data() self.update_brightness() self.update_hdd_temps() self.last_update = current_time - # Auto-cycle views 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 - # Animation frame self.animation_frame += 1 canvas.Clear() - # 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) - # Draw feedback message if active - if self.feedback_message and current_time < self.feedback_until: + # Camera alert overlay (highest priority) + if self.mqtt.is_alert_active(): + self.draw_camera_alert(canvas, fonts) + # Feedback overlay + elif self.feedback_message and current_time < self.feedback_until: self.draw_feedback(canvas, fonts, colors) else: self.feedback_message = None diff --git a/mqtt_listener.py b/mqtt_listener.py new file mode 100644 index 0000000..2fdb013 --- /dev/null +++ b/mqtt_listener.py @@ -0,0 +1,59 @@ +""" +MQTT listener for camera alerts. +Runs in a background thread and sets alert flag when message received. +""" +import threading +import time +import paho.mqtt.client as mqtt +from config import MQTT_BROKER, MQTT_PORT, MQTT_CAMERA_TOPIC + + +class MQTTListener: + def __init__(self): + self.alert_active = False + self.alert_until = 0 + self.alert_duration = 5 # seconds + self.client = None + self.connected = False + + def on_connect(self, client, userdata, flags, rc): + if rc == 0: + print(f"MQTT connected to {MQTT_BROKER}:{MQTT_PORT}") + client.subscribe(MQTT_CAMERA_TOPIC) + print(f"Subscribed to: {MQTT_CAMERA_TOPIC}") + self.connected = True + else: + print(f"MQTT connection failed: {rc}") + + def on_message(self, client, userdata, msg): + print(f"MQTT Alert received: {msg.topic}") + self.alert_active = True + self.alert_until = time.time() + self.alert_duration + + def on_disconnect(self, client, userdata, rc): + print("MQTT disconnected") + self.connected = False + + def is_alert_active(self): + """Check if alert is currently active.""" + if self.alert_active and time.time() > self.alert_until: + self.alert_active = False + return self.alert_active + + def start(self): + """Start MQTT listener in background thread.""" + def run(): + self.client = mqtt.Client() + self.client.on_connect = self.on_connect + self.client.on_message = self.on_message + self.client.on_disconnect = self.on_disconnect + + try: + self.client.connect(MQTT_BROKER, MQTT_PORT, 60) + self.client.loop_forever() + except Exception as e: + print(f"MQTT error: {e}") + + thread = threading.Thread(target=run, daemon=True) + thread.start() + print("MQTT listener started") diff --git a/requirements.txt b/requirements.txt index f788a8a..7b68adc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ python-dotenv>=1.0.0 requests>=2.28.0 gpiozero>=1.6.0 +paho-mqtt>=1.6.0