mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-04-10 13:02:59 +00:00
536 lines
26 KiB
Python
536 lines
26 KiB
Python
import os
|
|
import json
|
|
import logging
|
|
from datetime import datetime, date
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
import numpy as np
|
|
from rgbmatrix import graphics
|
|
import pytz
|
|
from src.config_manager import ConfigManager
|
|
import time
|
|
import freetype
|
|
|
|
# Configure logger for this module
|
|
logger = logging.getLogger(__name__)
|
|
logger.setLevel(logging.DEBUG)
|
|
|
|
class OfTheDayManager:
|
|
def __init__(self, display_manager, config):
|
|
logger.info("Initializing OfTheDayManager")
|
|
self.display_manager = display_manager
|
|
self.config = config
|
|
self.of_the_day_config = config.get('of_the_day', {})
|
|
self.enabled = self.of_the_day_config.get('enabled', False)
|
|
self.update_interval = self.of_the_day_config.get('update_interval', 3600) # 1 hour default
|
|
self.subtitle_rotate_interval = self.of_the_day_config.get('subtitle_rotate_interval', 10) # 10 seconds default
|
|
self.display_rotate_interval = self.of_the_day_config.get('display_rotate_interval', 30) # 30 seconds default
|
|
self.last_update = 0
|
|
self.last_display_log = 0
|
|
self.current_day = None
|
|
self.current_items = {}
|
|
self.current_item_index = 0
|
|
self.current_category_index = 0
|
|
self.last_drawn_category_index = -1
|
|
self.last_drawn_day = None
|
|
self.force_clear = False
|
|
self.rotation_state = 0 # 0 = subtitle, 1 = description
|
|
self.last_rotation_time = time.time()
|
|
self.last_category_rotation_time = time.time()
|
|
|
|
# Load fonts with robust path resolution and fallbacks
|
|
try:
|
|
# Try multiple font directory locations
|
|
script_dir = os.path.dirname(os.path.abspath(__file__))
|
|
possible_font_dirs = [
|
|
os.path.abspath(os.path.join(script_dir, '..', 'assets', 'fonts')), # Relative to src/
|
|
os.path.abspath(os.path.join(os.getcwd(), 'assets', 'fonts')), # Relative to project root
|
|
'assets/fonts' # Simple relative path
|
|
]
|
|
|
|
font_dir = None
|
|
for potential_dir in possible_font_dirs:
|
|
if os.path.exists(potential_dir):
|
|
font_dir = potential_dir
|
|
logger.debug(f"Found font directory at: {font_dir}")
|
|
break
|
|
|
|
if font_dir is None:
|
|
logger.warning("No font directory found, using fallback fonts")
|
|
raise FileNotFoundError("Font directory not found")
|
|
|
|
def _safe_load_bdf_font(filename):
|
|
try:
|
|
font_path = os.path.abspath(os.path.join(font_dir, filename))
|
|
if not os.path.exists(font_path):
|
|
logger.debug(f"Font file not found: {font_path}")
|
|
return None
|
|
logger.debug(f"Loading BDF font: {font_path}")
|
|
return freetype.Face(font_path)
|
|
except Exception as e:
|
|
logger.debug(f"Failed to load BDF font '{filename}': {e}")
|
|
return None
|
|
|
|
self.title_font = _safe_load_bdf_font('ic8x8u.bdf')
|
|
self.body_font = _safe_load_bdf_font('MatrixLight6.bdf')
|
|
|
|
# Fallbacks if BDF fonts aren't available
|
|
if self.title_font is None:
|
|
self.title_font = getattr(self.display_manager, 'bdf_5x7_font', None) or getattr(self.display_manager, 'small_font', ImageFont.load_default())
|
|
logger.info("Using fallback font for title in OfTheDayManager")
|
|
if self.body_font is None:
|
|
self.body_font = getattr(self.display_manager, 'bdf_5x7_font', None) or getattr(self.display_manager, 'small_font', ImageFont.load_default())
|
|
logger.info("Using fallback font for body in OfTheDayManager")
|
|
|
|
# Log font types for debugging
|
|
logger.debug(f"Title font type: {type(self.title_font).__name__}")
|
|
logger.debug(f"Body font type: {type(self.body_font).__name__}")
|
|
except Exception as e:
|
|
logger.warning(f"Error during font initialization, using fallbacks: {e}")
|
|
# Last-resort fallback
|
|
self.title_font = getattr(self.display_manager, 'small_font', ImageFont.load_default())
|
|
self.body_font = getattr(self.display_manager, 'small_font', ImageFont.load_default())
|
|
|
|
# Load categories and their data
|
|
self.categories = self.of_the_day_config.get('categories', {})
|
|
self.category_order = self.of_the_day_config.get('category_order', [])
|
|
|
|
# Display properties
|
|
self.title_color = (255, 255, 255) # White
|
|
self.subtitle_color = (200, 200, 200) # Light gray
|
|
self.background_color = (0, 0, 0) # Black
|
|
|
|
# State management
|
|
self.force_clear = False
|
|
self.last_drawn_category_index = -1
|
|
self.last_drawn_day = None
|
|
|
|
# Load data files
|
|
self.data_files = {}
|
|
logger.info("Loading data files for OfTheDayManager...")
|
|
self._load_data_files()
|
|
logger.info(f"Loaded {len(self.data_files)} data files: {list(self.data_files.keys())}")
|
|
|
|
logger.info(f"OfTheDayManager configuration: enabled={self.enabled}, categories={list(self.categories.keys())}")
|
|
|
|
if self.enabled:
|
|
logger.info("OfTheDayManager is enabled, loading today's items...")
|
|
self._load_todays_items()
|
|
logger.info(f"After loading, current_items has {len(self.current_items)} items: {list(self.current_items.keys())}")
|
|
else:
|
|
logger.warning("OfTheDayManager is disabled in configuration")
|
|
|
|
def _load_data_files(self):
|
|
"""Load all data files for enabled categories."""
|
|
if not self.enabled:
|
|
logger.debug("OfTheDayManager is disabled, skipping data file loading")
|
|
return
|
|
|
|
logger.info(f"Loading data files for {len(self.categories)} categories")
|
|
logger.info(f"Current working directory: {os.getcwd()}")
|
|
logger.info(f"Script directory: {os.path.dirname(__file__)}")
|
|
|
|
for category_name, category_config in self.categories.items():
|
|
logger.debug(f"Processing category: {category_name}")
|
|
if not category_config.get('enabled', True):
|
|
logger.debug(f"Skipping disabled category: {category_name}")
|
|
continue
|
|
|
|
data_file = category_config.get('data_file')
|
|
if not data_file:
|
|
logger.warning(f"No data file specified for category: {category_name}")
|
|
continue
|
|
|
|
try:
|
|
# Try multiple possible paths for data files
|
|
script_dir = os.path.dirname(os.path.abspath(__file__))
|
|
possible_paths = []
|
|
|
|
if os.path.isabs(data_file):
|
|
possible_paths.append(data_file)
|
|
else:
|
|
# If data_file already contains 'of_the_day/', use it as is
|
|
if data_file.startswith('of_the_day/'):
|
|
possible_paths.extend([
|
|
os.path.join(script_dir, '..', data_file),
|
|
os.path.join(os.getcwd(), data_file),
|
|
data_file
|
|
])
|
|
else:
|
|
possible_paths.extend([
|
|
os.path.join(script_dir, '..', 'of_the_day', data_file),
|
|
os.path.join(os.getcwd(), 'of_the_day', data_file),
|
|
os.path.join('of_the_day', data_file)
|
|
])
|
|
|
|
file_path = None
|
|
for potential_path in possible_paths:
|
|
abs_path = os.path.abspath(potential_path)
|
|
if os.path.exists(abs_path):
|
|
file_path = abs_path
|
|
logger.debug(f"Found data file for {category_name} at: {file_path}")
|
|
break
|
|
|
|
if file_path is None:
|
|
# Use the first attempted path for error reporting
|
|
file_path = os.path.abspath(possible_paths[0])
|
|
logger.debug(f"No data file found for {category_name}, tried: {[os.path.abspath(p) for p in possible_paths]}")
|
|
|
|
logger.debug(f"Attempting to load {category_name} from: {file_path}")
|
|
|
|
if os.path.exists(file_path):
|
|
logger.debug(f"File exists, checking permissions...")
|
|
if not os.access(file_path, os.R_OK):
|
|
logger.error(f"File exists but is not readable: {file_path}")
|
|
self.data_files[category_name] = {}
|
|
continue
|
|
|
|
# Get file size for debugging
|
|
file_size = os.path.getsize(file_path)
|
|
logger.debug(f"File size: {file_size} bytes")
|
|
|
|
with open(file_path, 'r', encoding='utf-8') as f:
|
|
self.data_files[category_name] = json.load(f)
|
|
|
|
logger.info(f"Loaded data file for {category_name}: {len(self.data_files[category_name])} items")
|
|
logger.debug(f"Sample keys from {category_name}: {list(self.data_files[category_name].keys())[:5]}")
|
|
|
|
# Validate that we have data
|
|
if not self.data_files[category_name]:
|
|
logger.warning(f"Loaded data file for {category_name} but it's empty!")
|
|
|
|
else:
|
|
logger.error(f"Data file not found for {category_name}: {file_path}")
|
|
if os.path.exists(os.path.dirname(file_path)):
|
|
dir_contents = os.listdir(os.path.dirname(file_path))
|
|
logger.error(f"Directory contents: {dir_contents}")
|
|
else:
|
|
logger.error(f"Parent directory does not exist: {os.path.dirname(file_path)}")
|
|
logger.error(f"Tried paths: {[os.path.abspath(p) for p in possible_paths]}")
|
|
self.data_files[category_name] = {}
|
|
|
|
except json.JSONDecodeError as e:
|
|
logger.error(f"JSON decode error loading data file for {category_name}: {e}")
|
|
logger.error(f"File path: {file_path}")
|
|
self.data_files[category_name] = {}
|
|
except UnicodeDecodeError as e:
|
|
logger.error(f"Unicode decode error loading data file for {category_name}: {e}")
|
|
logger.error(f"File path: {file_path}")
|
|
self.data_files[category_name] = {}
|
|
except Exception as e:
|
|
logger.error(f"Unexpected error loading data file for {category_name}: {e}")
|
|
logger.error(f"File path: {file_path}")
|
|
import traceback
|
|
logger.error(f"Traceback: {traceback.format_exc()}")
|
|
self.data_files[category_name] = {}
|
|
|
|
def _load_todays_items(self):
|
|
"""Load items for today based on the current date."""
|
|
if not self.enabled:
|
|
return
|
|
|
|
today = date.today()
|
|
day_of_year = today.timetuple().tm_yday
|
|
logger.info(f"Loading items for day {day_of_year} of the year")
|
|
logger.debug(f"Available data files: {list(self.data_files.keys())}")
|
|
|
|
self.current_items = {}
|
|
|
|
for category_name, category_config in self.categories.items():
|
|
if not category_config.get('enabled', True):
|
|
logger.debug(f"Skipping disabled category: {category_name}")
|
|
continue
|
|
|
|
data = self.data_files.get(category_name, {})
|
|
if not data:
|
|
logger.warning(f"No data loaded for category: {category_name}")
|
|
logger.debug(f"Data files available: {list(self.data_files.keys())}")
|
|
logger.debug(f"Category config: {category_config}")
|
|
continue
|
|
|
|
logger.debug(f"Checking category {category_name} for day {day_of_year}")
|
|
logger.debug(f"Data file contains {len(data)} items")
|
|
|
|
# Get item for today (day of year)
|
|
item = data.get(str(day_of_year))
|
|
if item:
|
|
self.current_items[category_name] = item
|
|
logger.info(f"Loaded {category_name} item for day {day_of_year}: {item.get('title', 'No title')}")
|
|
else:
|
|
logger.warning(f"No item found for {category_name} on day {day_of_year}")
|
|
# Show more detailed information about available days
|
|
available_days = [k for k in data.keys() if k.isdigit()]
|
|
nearby_days = [k for k in available_days if abs(int(k) - day_of_year) <= 5]
|
|
logger.debug(f"Available days in {category_name}: {sorted(available_days)[:10]}...")
|
|
logger.debug(f"Days near {day_of_year}: {sorted(nearby_days)}")
|
|
|
|
self.current_day = today
|
|
self.current_category_index = 0
|
|
self.current_item_index = 0
|
|
|
|
def update(self, current_time):
|
|
"""Update items if needed (daily or on interval)."""
|
|
if not self.enabled:
|
|
logger.debug("OfTheDayManager is disabled, skipping update")
|
|
return
|
|
|
|
today = date.today()
|
|
|
|
# Check if we need to load new items (new day or first time)
|
|
if self.current_day != today:
|
|
logger.info("New day detected, loading new items")
|
|
self._load_todays_items()
|
|
|
|
# Check if we need to update based on interval
|
|
if current_time - self.last_update > self.update_interval:
|
|
logger.debug("OfTheDayManager update interval reached")
|
|
self.last_update = current_time
|
|
|
|
def _draw_bdf_text(self, draw, face, text, x, y, color=(255,255,255)):
|
|
"""Draw text for both BDF (FreeType Face) and PIL TTF fonts."""
|
|
try:
|
|
# If we have a PIL font, use native text rendering
|
|
if not isinstance(face, freetype.Face):
|
|
draw.text((x, y), text, fill=color, font=face)
|
|
return
|
|
|
|
# Otherwise, render BDF glyphs manually
|
|
for char in text:
|
|
face.load_char(char)
|
|
bitmap = face.glyph.bitmap
|
|
|
|
for i in range(bitmap.rows):
|
|
for j in range(bitmap.width):
|
|
try:
|
|
byte_index = i * bitmap.pitch + (j // 8)
|
|
if byte_index < len(bitmap.buffer):
|
|
byte = bitmap.buffer[byte_index]
|
|
if byte & (1 << (7 - (j % 8))):
|
|
draw_y = y - face.glyph.bitmap_top + i
|
|
draw_x = x + face.glyph.bitmap_left + j
|
|
draw.point((draw_x, draw_y), fill=color)
|
|
except IndexError:
|
|
logger.warning(f"Index out of range for char '{char}' at position ({i}, {j})")
|
|
continue
|
|
x += face.glyph.advance.x >> 6
|
|
except Exception as e:
|
|
logger.error(f"Error in _draw_bdf_text: {e}", exc_info=True)
|
|
|
|
def draw_item(self, category_name, item):
|
|
try:
|
|
title = item.get('title', 'No Title')
|
|
subtitle = item.get('subtitle', '')
|
|
description = item.get('description', '')
|
|
draw = ImageDraw.Draw(self.display_manager.image)
|
|
matrix_width = self.display_manager.matrix.width
|
|
matrix_height = self.display_manager.matrix.height
|
|
title_font = self.title_font
|
|
body_font = self.body_font
|
|
|
|
# Get font heights using DisplayManager helpers (handles BDF and PIL fonts)
|
|
try:
|
|
title_height = self.display_manager.get_font_height(title_font)
|
|
except Exception:
|
|
title_height = 8
|
|
try:
|
|
body_height = self.display_manager.get_font_height(body_font)
|
|
except Exception:
|
|
body_height = 8
|
|
|
|
# --- Draw Title (always at top) ---
|
|
title_y = title_height # Position title so its bottom is at title_height
|
|
|
|
# Calculate title width for centering (robust to font type)
|
|
try:
|
|
title_width = self.display_manager.get_text_width(title, title_font)
|
|
except Exception:
|
|
title_width = len(title) * 6
|
|
|
|
# Center the title
|
|
title_x = (matrix_width - title_width) // 2
|
|
self._draw_bdf_text(draw, title_font, title, title_x, title_y, color=self.title_color)
|
|
|
|
# Underline below title (centered)
|
|
underline_y = title_height + 1
|
|
underline_x_start = title_x
|
|
underline_x_end = title_x + title_width
|
|
draw.line([(underline_x_start, underline_y), (underline_x_end, underline_y)], fill=self.title_color, width=1)
|
|
|
|
# --- Draw Subtitle or Description (rotating) ---
|
|
# Start subtitle/description below the title and underline
|
|
# Account for title height + underline + spacing
|
|
y_start = title_height + body_height + 4 # Space for underline
|
|
available_height = matrix_height - y_start
|
|
available_width = matrix_width - 2
|
|
|
|
if self.rotation_state == 0 and subtitle:
|
|
# Show subtitle
|
|
wrapped = self._wrap_text(subtitle, available_width, body_font, max_lines=3, line_height=body_height, max_height=available_height)
|
|
for i, line in enumerate(wrapped):
|
|
if line.strip(): # Only draw non-empty lines
|
|
# Center each line of body text
|
|
try:
|
|
line_width = self.display_manager.get_text_width(line, body_font)
|
|
except Exception:
|
|
line_width = len(line) * 6
|
|
line_x = (matrix_width - line_width) // 2
|
|
# Add one pixel buffer between lines
|
|
line_y = y_start + i * (body_height + 1)
|
|
self._draw_bdf_text(draw, body_font, line, line_x, line_y, color=self.subtitle_color)
|
|
elif self.rotation_state == 1 and description:
|
|
# Show description
|
|
wrapped = self._wrap_text(description, available_width, body_font, max_lines=3, line_height=body_height, max_height=available_height)
|
|
for i, line in enumerate(wrapped):
|
|
if line.strip(): # Only draw non-empty lines
|
|
# Center each line of body text
|
|
try:
|
|
line_width = self.display_manager.get_text_width(line, body_font)
|
|
except Exception:
|
|
line_width = len(line) * 6
|
|
line_x = (matrix_width - line_width) // 2
|
|
# Add one pixel buffer between lines
|
|
line_y = y_start + i * (body_height + 1)
|
|
self._draw_bdf_text(draw, body_font, line, line_x, line_y, color=self.subtitle_color)
|
|
# else: nothing to show
|
|
return True
|
|
except Exception as e:
|
|
logger.error(f"Error drawing 'of the day' item: {e}", exc_info=True)
|
|
return False
|
|
|
|
def _wrap_text(self, text, max_width, face, max_lines=3, line_height=8, max_height=24):
|
|
if not text:
|
|
return [""]
|
|
lines = []
|
|
current_line = []
|
|
words = text.split()
|
|
for word in words:
|
|
test_line = ' '.join(current_line + [word]) if current_line else word
|
|
try:
|
|
text_width = self.display_manager.get_text_width(test_line, face)
|
|
except Exception:
|
|
text_width = len(test_line) * 6
|
|
if text_width <= max_width:
|
|
current_line.append(word)
|
|
else:
|
|
if current_line:
|
|
lines.append(' '.join(current_line))
|
|
current_line = [word]
|
|
else:
|
|
truncated = word
|
|
while len(truncated) > 0:
|
|
try:
|
|
test_width = self.display_manager.get_text_width(truncated + "...", face)
|
|
except Exception:
|
|
test_width = len(truncated) * 6
|
|
if test_width <= max_width:
|
|
lines.append(truncated + "...")
|
|
break
|
|
truncated = truncated[:-1]
|
|
if not truncated:
|
|
lines.append(word[:10] + "...")
|
|
# Check if we've filled all lines (accounting for line spacing)
|
|
if len(lines) * line_height >= max_height or len(lines) >= max_lines:
|
|
break
|
|
if current_line and (len(lines) * line_height < max_height and len(lines) < max_lines):
|
|
lines.append(' '.join(current_line))
|
|
while len(lines) < max_lines:
|
|
lines.append("")
|
|
return lines[:max_lines]
|
|
|
|
def display(self, force_clear=False):
|
|
if not self.enabled:
|
|
return
|
|
if not self.current_items:
|
|
current_time = time.time()
|
|
if not hasattr(self, 'last_warning_time') or current_time - self.last_warning_time > 10:
|
|
logger.warning(f"OfTheDayManager has no current items.")
|
|
self.last_warning_time = current_time
|
|
return
|
|
now = time.time()
|
|
# Handle subtitle/description rotation
|
|
if now - self.last_rotation_time > self.subtitle_rotate_interval:
|
|
self.rotation_state = (self.rotation_state + 1) % 2
|
|
self.last_rotation_time = now
|
|
# Force redraw
|
|
self.last_drawn_category_index = -1
|
|
self.last_drawn_day = None
|
|
# Handle OTD category rotation
|
|
if now - self.last_category_rotation_time > self.display_rotate_interval:
|
|
# Find the next category with valid data
|
|
original_index = self.current_category_index
|
|
self.current_category_index = (self.current_category_index + 1) % len(self.current_items)
|
|
|
|
# If we've cycled through all categories and none have data, reset to first
|
|
if self.current_category_index == original_index:
|
|
logger.warning("No categories have valid data, staying on current category")
|
|
else:
|
|
logger.info(f"Internal rotation: from category index {original_index} to {self.current_category_index}")
|
|
logger.info(f"Available categories with data: {list(self.current_items.keys())}")
|
|
|
|
self.last_category_rotation_time = now
|
|
# Reset subtitle/description rotation when switching category
|
|
self.rotation_state = 0
|
|
self.last_rotation_time = now
|
|
# Force redraw
|
|
self.last_drawn_category_index = -1
|
|
self.last_drawn_day = None
|
|
content_has_changed = self.current_category_index != self.last_drawn_category_index or self.current_day != self.last_drawn_day
|
|
if not content_has_changed and not force_clear:
|
|
return
|
|
try:
|
|
category_names = list(self.current_items.keys())
|
|
if not category_names or self.current_category_index >= len(category_names):
|
|
self.current_category_index = 0
|
|
if not category_names: return
|
|
current_category = category_names[self.current_category_index]
|
|
current_item = self.current_items[current_category]
|
|
current_time = time.time()
|
|
if current_time - self.last_display_log > 5:
|
|
logger.info(f"Displaying {current_category}: {current_item.get('title', 'No Title')}")
|
|
self.last_display_log = current_time
|
|
self.display_manager.clear()
|
|
self.draw_item(current_category, current_item)
|
|
self.display_manager.update_display()
|
|
self.last_drawn_category_index = self.current_category_index
|
|
self.last_drawn_day = self.current_day
|
|
except Exception as e:
|
|
logger.error(f"Error displaying 'of the day' item: {e}", exc_info=True)
|
|
|
|
def advance_item(self):
|
|
"""Advance to the next item. Called by DisplayController when display time is up."""
|
|
if not self.enabled:
|
|
logger.debug("OfTheDayManager is disabled, skipping item advance")
|
|
return
|
|
|
|
# Check if internal rotation should happen first
|
|
now = time.time()
|
|
if now - self.last_category_rotation_time > self.display_rotate_interval:
|
|
# Let the internal rotation handle it
|
|
logger.debug("Internal rotation timer triggered, skipping external advance")
|
|
return
|
|
|
|
# Only advance if internal rotation hasn't happened recently
|
|
# Add a buffer to prevent conflicts
|
|
if now - self.last_category_rotation_time < self.display_rotate_interval - 5:
|
|
logger.debug("Too close to internal rotation time, skipping external advance")
|
|
return
|
|
|
|
category_names = list(self.current_items.keys())
|
|
if not category_names:
|
|
return
|
|
|
|
# Advance to next category
|
|
original_index = self.current_category_index
|
|
self.current_category_index = (self.current_category_index + 1) % len(category_names)
|
|
|
|
# Update rotation time to prevent immediate internal rotation
|
|
self.last_category_rotation_time = now
|
|
|
|
# Reset subtitle/description rotation when switching category
|
|
self.rotation_state = 0
|
|
self.last_rotation_time = now
|
|
|
|
# Force redraw
|
|
self.last_drawn_category_index = -1
|
|
self.last_drawn_day = None
|
|
|
|
logger.debug(f"OfTheDayManager externally advanced from category index {original_index} to {self.current_category_index}") |