diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6cd9877 --- /dev/null +++ b/.env.example @@ -0,0 +1,16 @@ +# Home Assistant Configuration +HA_TOKEN=your_home_assistant_long_lived_access_token +HASS_URL=https://your-home-assistant-url.com + +# Entity IDs +BRIGHTNESS_ENTITY_ID=sensor.your_brightness_sensor +WEATHER_ENTITY_ID=weather.forecast_home +INTERIOR_TEMP_ENTITY_ID=sensor.your_temperature_sensor +INTERIOR_HUMIDITY_ENTITY_ID=sensor.your_humidity_sensor + +# Netdata Configuration +NETDATA_URL=http://your-netdata-host:19999 + +# LED Matrix Configuration +LED_ROWS=64 +LED_COLS=64 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3043af5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +# Environment variables (contains secrets) +.env + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so + +# Virtual environments +venv/ +.venv/ +ENV/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db diff --git a/config.py b/config.py new file mode 100644 index 0000000..f30e515 --- /dev/null +++ b/config.py @@ -0,0 +1,26 @@ +""" +Configuration module for Matrix64 LED display. +Loads environment variables from .env file. +""" +import os +from dotenv import load_dotenv + +# Load environment variables from .env file +load_dotenv() + +# Home Assistant Configuration +HA_TOKEN = os.getenv('HA_TOKEN') +HASS_URL = os.getenv('HASS_URL') + +# Entity IDs +BRIGHTNESS_ENTITY_ID = os.getenv('BRIGHTNESS_ENTITY_ID') +WEATHER_ENTITY_ID = os.getenv('WEATHER_ENTITY_ID') +INTERIOR_TEMP_ENTITY_ID = os.getenv('INTERIOR_TEMP_ENTITY_ID') +INTERIOR_HUMIDITY_ENTITY_ID = os.getenv('INTERIOR_HUMIDITY_ENTITY_ID') + +# Netdata Configuration +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')) diff --git a/home_assistant.py b/home_assistant.py new file mode 100644 index 0000000..0dac2cc --- /dev/null +++ b/home_assistant.py @@ -0,0 +1,63 @@ +""" +Home Assistant API client for Matrix64 LED display. +""" +import requests +from config import ( + HA_TOKEN, HASS_URL, + BRIGHTNESS_ENTITY_ID, WEATHER_ENTITY_ID, + INTERIOR_TEMP_ENTITY_ID, INTERIOR_HUMIDITY_ENTITY_ID +) + + +def get_entity_value(entity_id): + """Fetch entity state from Home Assistant.""" + headers = { + "Authorization": f"Bearer {HA_TOKEN}", + "Content-Type": "application/json", + } + url = f"{HASS_URL}/api/states/{entity_id}" + try: + response = requests.get(url, headers=headers, timeout=10) + if response.status_code == 200: + return response.json() + else: + print(f"Failed to retrieve entity {entity_id}: {response.text}") + return None + except Exception as e: + print(f"Error fetching {entity_id}: {e}") + return None + + +def get_weather(): + """Get weather attributes from Home Assistant.""" + weather = get_entity_value(WEATHER_ENTITY_ID) + if weather: + return weather.get("attributes", {}) + return {} + + +def get_weather_description(): + """Get current weather state/condition.""" + weather = get_entity_value(WEATHER_ENTITY_ID) + if weather: + return weather.get("state", "unknown") + return "unknown" + + +def get_interior_weather(): + """Get interior temperature and humidity from BTH01-3132 sensor.""" + temp_data = get_entity_value(INTERIOR_TEMP_ENTITY_ID) + humidity_data = get_entity_value(INTERIOR_HUMIDITY_ENTITY_ID) + + temperature = temp_data.get("state") if temp_data else None + humidity = humidity_data.get("state") if humidity_data else None + + return {"humidity": humidity, "temperature": temperature} + + +def get_brightness(): + """Get screen brightness value.""" + brightness = get_entity_value(BRIGHTNESS_ENTITY_ID) + if brightness: + return brightness.get("state") + return None diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..9dd20ef --- /dev/null +++ b/install.sh @@ -0,0 +1,56 @@ +#!/bin/bash +# Installation script for Matrix64 LED Display +# Run this once on your Raspberry Pi + +set -e + +INSTALL_DIR="/opt/matrix64" +REPO_URL="https://gitlab.kingstudio.es/jocaru/matrix64" + +echo "=== Matrix64 LED Display Installation ===" + +# Clone or update repository +if [ -d "$INSTALL_DIR" ]; then + echo "Directory exists, updating..." + cd "$INSTALL_DIR" + git pull origin master +else + echo "Cloning repository..." + git clone "$REPO_URL" "$INSTALL_DIR" + cd "$INSTALL_DIR" +fi + +# Install Python dependencies +echo "Installing Python dependencies..." +pip3 install -r requirements.txt + +# Create .env from example if not exists +if [ ! -f "$INSTALL_DIR/.env" ]; then + echo "Creating .env from template..." + cp "$INSTALL_DIR/.env.example" "$INSTALL_DIR/.env" + echo "IMPORTANT: Edit $INSTALL_DIR/.env with your actual values!" +fi + +# Install systemd service +echo "Installing systemd service..." +cp "$INSTALL_DIR/matrix64.service" /etc/systemd/system/ +systemctl daemon-reload +systemctl enable matrix64 + +# Make update script executable +chmod +x "$INSTALL_DIR/update.sh" + +# Set up cron job for auto-updates (every 5 minutes) +CRON_JOB="*/5 * * * * $INSTALL_DIR/update.sh" +(crontab -l 2>/dev/null | grep -v "matrix64/update.sh"; echo "$CRON_JOB") | crontab - + +echo "" +echo "=== Installation Complete ===" +echo "" +echo "Next steps:" +echo "1. Edit /opt/matrix64/.env with your Home Assistant token and URLs" +echo "2. Start the service: sudo systemctl start matrix64" +echo "3. Check status: sudo systemctl status matrix64" +echo "4. View logs: sudo journalctl -u matrix64 -f" +echo "" +echo "Auto-updates are enabled (every 5 minutes via cron)" diff --git a/matrix.py b/matrix.py new file mode 100644 index 0000000..0d8680a --- /dev/null +++ b/matrix.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +""" +Matrix64 LED Display - Main Entry Point + +Displays Home Assistant and Netdata data on a 64x64 LED matrix. +""" +from samplebase import SampleBase +from rgbmatrix import graphics +import time +import datetime + +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 +) +from netdata import get_hdd_temps + + +def get_temperature_color(temp): + """Determine color based on temperature value.""" + try: + temp_value = float(str(temp).replace('°C', '').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) + + +class Matrix64Display(SampleBase): + """Main display class for the LED matrix.""" + + def __init__(self, *args, **kwargs): + super(Matrix64Display, self).__init__(*args, **kwargs) + self.parser.add_argument( + "-t", "--text", + help="The text to scroll on the RGB LED panel", + default="Hello world!" + ) + 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 + + def update_data(self): + """Fetch all data from APIs.""" + try: + # Weather data + weather = get_weather() + if weather.get("temperature") is not None: + self.temperature = f'{weather.get("temperature")}°C' + self.humidity = weather.get("humidity") + self.weather_desc = get_weather_description() + + # Interior data + interior = get_interior_weather() + if interior.get("temperature") is not None: + self.interior_temperature = f'{interior.get("temperature")}°C' + self.interior_humidity = interior.get("humidity") + + except Exception as e: + print(f"Error updating data: {e}") + + def update_brightness(self): + """Update LED matrix brightness.""" + try: + brightness = get_brightness() + if brightness: + self.matrix.brightness = int(float(brightness)) / 10 + except Exception as e: + self.matrix.brightness = 5 + print(f"Error updating brightness: {e}") + + def update_hdd_temps(self): + """Update HDD temperatures.""" + self.hdd_temps = get_hdd_temps() + + def run(self): + """Main display loop.""" + canvas = self.matrix.CreateFrameCanvas() + + # Load fonts + font = graphics.Font() + font.LoadFont("../../../fonts/7x13.bdf") + temp_font = graphics.Font() + temp_font.LoadFont("../../../fonts/5x8.bdf") + + text_color = graphics.Color(20, 75, 200) + + # Initial data fetch + self.update_brightness() + self.update_data() + self.update_hdd_temps() + + while True: + now = datetime.datetime.now() + time_str = f"{now.hour:02d}:{now.minute:02d}:{now.second:02d}" + current_time = time.time() + + # Update 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 + + canvas.Clear() + + # Time display + graphics.DrawText(canvas, font, 4, 42, text_color, time_str) + + # Weather icon + draw_weather_icon(canvas, 0, 0, self.weather_desc) + + # Outdoor temperature (right-aligned) + if self.temperature: + color = get_temperature_color(self.temperature) + length = graphics.DrawText(canvas, temp_font, 0, 0, color, self.temperature) + graphics.DrawText(canvas, temp_font, 64 - length, 8, color, self.temperature) + + # Interior temperature (right-aligned) + if self.interior_temperature: + color = get_temperature_color(self.interior_temperature) + length = graphics.DrawText(canvas, temp_font, 0, 0, color, self.interior_temperature) + graphics.DrawText(canvas, temp_font, 64 - length, 16, color, self.interior_temperature) + + # HDD temperatures + if self.hdd_temps and len(self.hdd_temps) > 4: + row1 = " ".join(str(t[0])[:2] for t in self.hdd_temps[1:4]) + row2 = " ".join(str(t[0])[:2] for t in self.hdd_temps[4:]) + graphics.DrawText(canvas, temp_font, 12, 52, text_color, row1) + graphics.DrawText(canvas, temp_font, 12, 60, text_color, row2) + + time.sleep(0.5) + canvas = self.matrix.SwapOnVSync(canvas) + + +if __name__ == "__main__": + display = Matrix64Display() + if not display.process(): + display.print_help() \ No newline at end of file diff --git a/matrix64.service b/matrix64.service new file mode 100644 index 0000000..aaaa1f2 --- /dev/null +++ b/matrix64.service @@ -0,0 +1,17 @@ +[Unit] +Description=Matrix64 LED Display +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=root +WorkingDirectory=/opt/matrix64 +ExecStart=/usr/bin/python3 /opt/matrix64/matrix.py --led-rows=64 --led-cols=64 +Restart=always +RestartSec=10 +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target diff --git a/netdata.py b/netdata.py new file mode 100644 index 0000000..16b7f04 --- /dev/null +++ b/netdata.py @@ -0,0 +1,17 @@ +""" +Netdata API client for Matrix64 LED display. +""" +import requests +from config import NETDATA_URL + + +def get_hdd_temps(): + """Get HDD temperatures from Netdata.""" + try: + url = f"{NETDATA_URL}/api/v2/data?contexts=hddtemp.disk_temperature&points=1&group_by=instance" + response = requests.get(url, timeout=10) + hdd_temps = response.json()['result']['data'][0] + return hdd_temps + except Exception as e: + print(f"Error fetching HDD temps: {e}") + return None diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..0ba7f26 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +python-dotenv>=1.0.0 +requests>=2.28.0 diff --git a/update.sh b/update.sh new file mode 100644 index 0000000..6d3887a --- /dev/null +++ b/update.sh @@ -0,0 +1,29 @@ +#!/bin/bash +# Update script for Matrix64 LED Display +# Pulls latest changes from git and restarts service if updated + +INSTALL_DIR="/opt/matrix64" +LOGFILE="/var/log/matrix64-update.log" + +cd "$INSTALL_DIR" || exit 1 + +# Fetch latest changes +git fetch origin + +# Check if there are updates +LOCAL=$(git rev-parse HEAD) +REMOTE=$(git rev-parse origin/master) + +if [ "$LOCAL" != "$REMOTE" ]; then + echo "$(date): Updates found, pulling changes..." >> "$LOGFILE" + git pull origin master >> "$LOGFILE" 2>&1 + + # Install any new dependencies + pip3 install -r requirements.txt >> "$LOGFILE" 2>&1 + + # Restart the service + systemctl restart matrix64 + echo "$(date): Service restarted" >> "$LOGFILE" +else + echo "$(date): No updates" >> "$LOGFILE" +fi diff --git a/weather_icons.py b/weather_icons.py new file mode 100644 index 0000000..917caf9 --- /dev/null +++ b/weather_icons.py @@ -0,0 +1,236 @@ +""" +Weather icon drawing functions for 64x64 LED matrix. +All icons are designed for a 24x24 pixel area. +""" +from rgbmatrix import graphics + + +def draw_sunny(canvas, x, y): + """Draw a sun with rays - clear/sunny weather.""" + sun_color = graphics.Color(255, 200, 0) + ray_color = graphics.Color(255, 140, 0) + + # Draw sun rays (8 directions) + graphics.DrawLine(canvas, x + 12, y + 2, x + 12, y + 5, ray_color) + graphics.DrawLine(canvas, x + 12, y + 19, x + 12, y + 22, ray_color) + graphics.DrawLine(canvas, x + 2, y + 12, x + 5, y + 12, ray_color) + graphics.DrawLine(canvas, x + 19, y + 12, x + 22, y + 12, ray_color) + graphics.DrawLine(canvas, x + 5, y + 5, x + 7, y + 7, ray_color) + graphics.DrawLine(canvas, x + 17, y + 17, x + 19, y + 19, ray_color) + graphics.DrawLine(canvas, x + 5, y + 19, x + 7, y + 17, ray_color) + graphics.DrawLine(canvas, x + 17, y + 7, x + 19, y + 5, ray_color) + + # Draw filled sun + for r in range(6, 0, -1): + graphics.DrawCircle(canvas, x + 12, y + 12, r, sun_color) + + +def draw_clear_night(canvas, x, y): + """Draw a crescent moon with stars.""" + moon_color = graphics.Color(255, 255, 200) + + # Draw moon + for r in range(7, 0, -1): + graphics.DrawCircle(canvas, x + 10, y + 12, r, moon_color) + + # Draw stars + canvas.SetPixel(x + 20, y + 6, 255, 255, 255) + canvas.SetPixel(x + 18, y + 10, 255, 255, 255) + canvas.SetPixel(x + 22, y + 14, 255, 255, 255) + canvas.SetPixel(x + 19, y + 18, 255, 255, 255) + + +def _draw_cloud_shape(canvas, x, y, color): + """Helper to draw a cloud shape.""" + for r in range(4, 0, -1): + graphics.DrawCircle(canvas, x + 4, y + 4, r, color) + for r in range(5, 0, -1): + graphics.DrawCircle(canvas, x + 10, y + 3, r, color) + for r in range(4, 0, -1): + graphics.DrawCircle(canvas, x + 15, y + 5, r, color) + for r in range(3, 0, -1): + graphics.DrawCircle(canvas, x + 6, y + 7, r, color) + for r in range(3, 0, -1): + graphics.DrawCircle(canvas, x + 12, y + 7, r, color) + + +def draw_partly_cloudy(canvas, x, y): + """Draw sun partially covered by cloud.""" + sun_color = graphics.Color(255, 200, 0) + ray_color = graphics.Color(255, 140, 0) + cloud_color = graphics.Color(220, 220, 220) + + # Draw partial sun + graphics.DrawLine(canvas, x + 8, y + 2, x + 8, y + 4, ray_color) + graphics.DrawLine(canvas, x + 2, y + 8, x + 4, y + 8, ray_color) + graphics.DrawLine(canvas, x + 3, y + 3, x + 5, y + 5, ray_color) + for r in range(4, 0, -1): + graphics.DrawCircle(canvas, x + 8, y + 8, r, sun_color) + + # Draw cloud + _draw_cloud_shape(canvas, x + 6, y + 10, cloud_color) + + +def draw_partly_cloudy_night(canvas, x, y): + """Draw moon partially covered by cloud.""" + moon_color = graphics.Color(255, 255, 200) + cloud_color = graphics.Color(180, 180, 200) + + for r in range(4, 0, -1): + graphics.DrawCircle(canvas, x + 8, y + 8, r, moon_color) + _draw_cloud_shape(canvas, x + 6, y + 10, cloud_color) + + +def draw_cloudy(canvas, x, y): + """Draw overcast clouds.""" + cloud_light = graphics.Color(200, 200, 200) + cloud_dark = graphics.Color(150, 150, 150) + + _draw_cloud_shape(canvas, x + 2, y + 6, cloud_dark) + _draw_cloud_shape(canvas, x + 6, y + 10, cloud_light) + + +def draw_rainy(canvas, x, y): + """Draw cloud with rain drops.""" + cloud_color = graphics.Color(150, 150, 170) + rain_color = graphics.Color(100, 150, 255) + + _draw_cloud_shape(canvas, x + 4, y + 4, cloud_color) + + for i in range(4): + x_start = x + 6 + i * 4 + graphics.DrawLine(canvas, x_start, y + 16, x_start - 2, y + 20, rain_color) + graphics.DrawLine(canvas, x_start + 1, y + 18, x_start - 1, y + 22, rain_color) + + +def draw_pouring(canvas, x, y): + """Draw cloud with heavy rain.""" + cloud_color = graphics.Color(100, 100, 120) + rain_color = graphics.Color(80, 130, 255) + + _draw_cloud_shape(canvas, x + 4, y + 2, cloud_color) + + for i in range(5): + x_start = x + 4 + i * 4 + graphics.DrawLine(canvas, x_start, y + 14, x_start - 3, y + 22, rain_color) + + +def draw_snowy(canvas, x, y): + """Draw cloud with snowflakes.""" + cloud_color = graphics.Color(180, 180, 200) + + _draw_cloud_shape(canvas, x + 4, y + 4, cloud_color) + + for i in range(3): + sx = x + 8 + i * 5 + sy = y + 18 + (i % 2) * 3 + canvas.SetPixel(sx, sy, 255, 255, 255) + canvas.SetPixel(sx - 1, sy, 255, 255, 255) + canvas.SetPixel(sx + 1, sy, 255, 255, 255) + canvas.SetPixel(sx, sy - 1, 255, 255, 255) + canvas.SetPixel(sx, sy + 1, 255, 255, 255) + + +def draw_thunderstorm(canvas, x, y): + """Draw cloud with lightning bolt.""" + cloud_color = graphics.Color(80, 80, 100) + lightning_color = graphics.Color(255, 255, 0) + + _draw_cloud_shape(canvas, x + 4, y + 2, cloud_color) + + graphics.DrawLine(canvas, x + 12, y + 12, x + 10, y + 16, lightning_color) + graphics.DrawLine(canvas, x + 10, y + 16, x + 14, y + 16, lightning_color) + graphics.DrawLine(canvas, x + 14, y + 16, x + 10, y + 22, lightning_color) + + +def draw_foggy(canvas, x, y): + """Draw horizontal fog lines.""" + fog_color = graphics.Color(180, 180, 180) + + for i in range(5): + y_pos = y + 6 + i * 3 + graphics.DrawLine(canvas, x + 2, y_pos, x + 20, y_pos, fog_color) + + +def draw_windy(canvas, x, y): + """Draw wind lines.""" + wind_color = graphics.Color(150, 200, 255) + + graphics.DrawLine(canvas, x + 2, y + 8, x + 18, y + 8, wind_color) + graphics.DrawLine(canvas, x + 18, y + 8, x + 20, y + 6, wind_color) + graphics.DrawLine(canvas, x + 4, y + 12, x + 22, y + 12, wind_color) + graphics.DrawLine(canvas, x + 22, y + 12, x + 23, y + 10, wind_color) + graphics.DrawLine(canvas, x + 2, y + 16, x + 16, y + 16, wind_color) + graphics.DrawLine(canvas, x + 16, y + 16, x + 18, y + 14, wind_color) + + +def draw_hail(canvas, x, y): + """Draw cloud with hail.""" + cloud_color = graphics.Color(140, 140, 160) + hail_color = graphics.Color(200, 220, 255) + + _draw_cloud_shape(canvas, x + 4, y + 4, cloud_color) + + for i in range(3): + hx = x + 8 + i * 5 + hy = y + 18 + (i % 2) * 2 + graphics.DrawCircle(canvas, hx, hy, 2, hail_color) + + +def draw_unknown(canvas, x, y): + """Draw question mark for unknown weather.""" + color = graphics.Color(255, 255, 255) + + graphics.DrawCircle(canvas, x + 12, y + 8, 4, color) + canvas.SetPixel(x + 12, y + 14, 255, 255, 255) + canvas.SetPixel(x + 12, y + 17, 255, 255, 255) + + +# Weather state to icon function mapping +WEATHER_ICONS = { + 'sunny': draw_sunny, + 'clear-day': draw_sunny, + 'clear-night': draw_clear_night, + 'partlycloudy': draw_partly_cloudy, + 'partly-cloudy-day': draw_partly_cloudy, + 'partly-cloudy-night': draw_partly_cloudy_night, + 'cloudy': draw_cloudy, + 'overcast': draw_cloudy, + 'rainy': draw_rainy, + 'rain': draw_rainy, + 'showers': draw_rainy, + 'pouring': draw_pouring, + 'snowy': draw_snowy, + 'snow': draw_snowy, + 'snowy-rainy': draw_snowy, + 'sleet': draw_snowy, + 'hail': draw_hail, + 'lightning': draw_thunderstorm, + 'lightning-rainy': draw_thunderstorm, + 'thunderstorm': draw_thunderstorm, + 'fog': draw_foggy, + 'foggy': draw_foggy, + 'mist': draw_foggy, + 'hazy': draw_foggy, + 'windy': draw_windy, + 'windy-variant': draw_windy, + 'exceptional': draw_unknown, +} + + +def draw_weather_icon(canvas, x, y, weather_state): + """Draw weather icon based on state string.""" + weather_lower = weather_state.lower() if weather_state else '' + + # Try exact match + if weather_lower in WEATHER_ICONS: + WEATHER_ICONS[weather_lower](canvas, x, y) + return + + # Try partial match + for key, draw_func in WEATHER_ICONS.items(): + if key in weather_lower or weather_lower in key: + draw_func(canvas, x, y) + return + + draw_unknown(canvas, x, y)