mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-04-10 13:02:59 +00:00
Merge Stocks branch: Add stock display improvements and configuration updates
This commit is contained in:
0
assets/stocks/.gitkeep
Normal file
0
assets/stocks/.gitkeep
Normal file
@@ -17,11 +17,23 @@ class DisplayController:
|
||||
self.config_manager = ConfigManager()
|
||||
self.config = self.config_manager.load_config()
|
||||
self.display_manager = DisplayManager(self.config.get('display', {}))
|
||||
self.clock = Clock(display_manager=self.display_manager)
|
||||
self.weather = WeatherManager(self.config, self.display_manager)
|
||||
self.stocks = StockManager(self.config, self.display_manager)
|
||||
self.news = StockNewsManager(self.config, self.display_manager)
|
||||
self.current_display = 'clock'
|
||||
|
||||
# Only initialize enabled modules
|
||||
self.clock = Clock(display_manager=self.display_manager) if self.config.get('clock', {}).get('enabled', False) else None
|
||||
self.weather = WeatherManager(self.config, self.display_manager) if self.config.get('weather', {}).get('enabled', False) else None
|
||||
self.stocks = StockManager(self.config, self.display_manager) if self.config.get('stocks', {}).get('enabled', False) else None
|
||||
self.news = StockNewsManager(self.config, self.display_manager) if self.config.get('stock_news', {}).get('enabled', False) else None
|
||||
|
||||
# Set initial display to first enabled module
|
||||
self.current_display = 'clock' # Default
|
||||
if not self.clock:
|
||||
if self.weather:
|
||||
self.current_display = 'weather'
|
||||
elif self.stocks:
|
||||
self.current_display = 'stocks'
|
||||
elif self.news:
|
||||
self.current_display = 'stock_news'
|
||||
|
||||
self.weather_mode = 'current' # current, hourly, or daily
|
||||
self.last_switch = time.time()
|
||||
self.force_clear = True # Start with a clear screen
|
||||
@@ -58,12 +70,12 @@ class DisplayController:
|
||||
next_display = None
|
||||
|
||||
if self.current_display == 'clock':
|
||||
if self.config.get('weather', {}).get('enabled', False):
|
||||
if self.weather:
|
||||
next_display = 'weather'
|
||||
self.weather_mode = 'current'
|
||||
elif self.config.get('stocks', {}).get('enabled', False):
|
||||
elif self.stocks:
|
||||
next_display = 'stocks'
|
||||
elif self.config.get('stock_news', {}).get('enabled', False):
|
||||
elif self.news:
|
||||
next_display = 'stock_news'
|
||||
else:
|
||||
next_display = 'clock'
|
||||
@@ -76,34 +88,34 @@ class DisplayController:
|
||||
next_display = 'weather'
|
||||
self.weather_mode = 'daily'
|
||||
else: # daily
|
||||
if self.config.get('stocks', {}).get('enabled', False):
|
||||
if self.stocks:
|
||||
next_display = 'stocks'
|
||||
elif self.config.get('stock_news', {}).get('enabled', False):
|
||||
elif self.news:
|
||||
next_display = 'stock_news'
|
||||
elif self.config.get('clock', {}).get('enabled', False):
|
||||
elif self.clock:
|
||||
next_display = 'clock'
|
||||
else:
|
||||
next_display = 'weather'
|
||||
self.weather_mode = 'current'
|
||||
|
||||
elif self.current_display == 'stocks':
|
||||
if self.config.get('stock_news', {}).get('enabled', False):
|
||||
if self.news:
|
||||
next_display = 'stock_news'
|
||||
elif self.config.get('clock', {}).get('enabled', False):
|
||||
elif self.clock:
|
||||
next_display = 'clock'
|
||||
elif self.config.get('weather', {}).get('enabled', False):
|
||||
elif self.weather:
|
||||
next_display = 'weather'
|
||||
self.weather_mode = 'current'
|
||||
else:
|
||||
next_display = 'stocks'
|
||||
|
||||
else: # stock_news
|
||||
if self.config.get('clock', {}).get('enabled', False):
|
||||
if self.clock:
|
||||
next_display = 'clock'
|
||||
elif self.config.get('weather', {}).get('enabled', False):
|
||||
elif self.weather:
|
||||
next_display = 'weather'
|
||||
self.weather_mode = 'current'
|
||||
elif self.config.get('stocks', {}).get('enabled', False):
|
||||
elif self.stocks:
|
||||
next_display = 'stocks'
|
||||
else:
|
||||
next_display = 'stock_news'
|
||||
@@ -117,10 +129,10 @@ class DisplayController:
|
||||
|
||||
# Display current screen
|
||||
try:
|
||||
if self.current_display == 'clock' and self.config.get('clock', {}).get('enabled', False):
|
||||
if self.current_display == 'clock' and self.clock:
|
||||
self.clock.display_time(force_clear=self.force_clear)
|
||||
time.sleep(self.update_interval)
|
||||
elif self.current_display == 'weather' and self.config.get('weather', {}).get('enabled', False):
|
||||
elif self.current_display == 'weather' and self.weather:
|
||||
if self.weather_mode == 'current':
|
||||
self.weather.display_weather(force_clear=self.force_clear)
|
||||
elif self.weather_mode == 'hourly':
|
||||
@@ -128,10 +140,10 @@ class DisplayController:
|
||||
else: # daily
|
||||
self.weather.display_daily_forecast(force_clear=self.force_clear)
|
||||
time.sleep(self.update_interval)
|
||||
elif self.current_display == 'stocks' and self.config.get('stocks', {}).get('enabled', False):
|
||||
elif self.current_display == 'stocks' and self.stocks:
|
||||
# For stocks, we want to update as fast as possible without delay
|
||||
self.stocks.display_stocks(force_clear=self.force_clear)
|
||||
time.sleep(self.update_interval)
|
||||
elif self.current_display == 'stock_news' and self.config.get('stock_news', {}).get('enabled', False):
|
||||
elif self.current_display == 'stock_news' and self.news:
|
||||
# For news, we want to update as fast as possible without delay
|
||||
self.news.display_news()
|
||||
except Exception as e:
|
||||
|
||||
@@ -62,7 +62,7 @@ class DisplayManager:
|
||||
|
||||
# Initialize font with Press Start 2P
|
||||
try:
|
||||
self.font = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 10)
|
||||
self.font = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8) # Reduced from 10 to 8
|
||||
logger.info("Initial font loaded successfully")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load initial font: {e}")
|
||||
@@ -81,17 +81,17 @@ class DisplayManager:
|
||||
# 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))
|
||||
# Draw some text - changed from "TEST" to "Initializing" with smaller font
|
||||
self.draw.text((10, 10), "Initializing", font=self.font, fill=(0, 0, 255))
|
||||
|
||||
# Update the display once after everything is drawn
|
||||
self.update_display()
|
||||
time.sleep(2)
|
||||
time.sleep(0.5) # Reduced from 1 second to 0.5 seconds for faster animation
|
||||
|
||||
def update_display(self):
|
||||
"""Update the display using double buffering with proper sync."""
|
||||
try:
|
||||
# Copy the current image to the offscreen canvas
|
||||
# Copy the current image to the offscreen canvas
|
||||
self.offscreen_canvas.SetImage(self.image)
|
||||
|
||||
# Swap buffers immediately
|
||||
@@ -119,32 +119,25 @@ class DisplayManager:
|
||||
logger.error(f"Error clearing display: {e}")
|
||||
|
||||
def _load_fonts(self):
|
||||
"""Load fonts optimized for LED matrix display."""
|
||||
"""Load fonts with proper error handling."""
|
||||
try:
|
||||
# Use Press Start 2P font - perfect for LED matrix displays
|
||||
font_path = "assets/fonts/PressStart2P-Regular.ttf"
|
||||
# Load regular font (Press Start 2P)
|
||||
self.regular_font = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 10)
|
||||
logger.info("Regular font loaded successfully")
|
||||
|
||||
# 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
|
||||
# Load small font (Press Start 2P at smaller size)
|
||||
self.small_font = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8)
|
||||
logger.info("Small font loaded successfully")
|
||||
|
||||
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
|
||||
# Fallback to default font
|
||||
self.regular_font = ImageFont.load_default()
|
||||
self.small_font = self.regular_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
|
||||
font = self.small_font if small_font else self.regular_font
|
||||
|
||||
# Get text dimensions including ascenders and descenders
|
||||
bbox = self.draw.textbbox((0, 0), text, font=font)
|
||||
|
||||
@@ -8,6 +8,9 @@ from datetime import datetime
|
||||
import os
|
||||
import urllib.parse
|
||||
import re
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
import numpy as np
|
||||
import hashlib
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
@@ -21,7 +24,51 @@ class StockManager:
|
||||
self.last_update = 0
|
||||
self.stock_data = {}
|
||||
self.current_stock_index = 0
|
||||
self.display_mode = 'info' # 'info' or 'chart'
|
||||
self.scroll_position = 0
|
||||
self.cached_text_image = None
|
||||
self.cached_text = None
|
||||
|
||||
# Get scroll settings from config with faster defaults
|
||||
self.scroll_speed = self.stocks_config.get('scroll_speed', 1)
|
||||
self.scroll_delay = self.stocks_config.get('scroll_delay', 0.001)
|
||||
|
||||
# Initialize frame rate tracking
|
||||
self.frame_count = 0
|
||||
self.last_frame_time = time.time()
|
||||
self.last_fps_log_time = time.time()
|
||||
self.frame_times = []
|
||||
|
||||
# Try to use the assets/stocks directory from the repository
|
||||
self.logo_dir = os.path.join('assets', 'stocks')
|
||||
self.use_temp_dir = False
|
||||
|
||||
# Check if we can write to the logo directory
|
||||
try:
|
||||
if not os.path.exists(self.logo_dir):
|
||||
try:
|
||||
os.makedirs(self.logo_dir, mode=0o755, exist_ok=True)
|
||||
logger.info(f"Created logo directory: {self.logo_dir}")
|
||||
except (PermissionError, OSError) as e:
|
||||
logger.warning(f"Cannot create logo directory: {str(e)}. Using temporary directory instead.")
|
||||
self.use_temp_dir = True
|
||||
elif not os.access(self.logo_dir, os.W_OK):
|
||||
logger.warning(f"Cannot write to logo directory: {self.logo_dir}. Using temporary directory instead.")
|
||||
self.use_temp_dir = True
|
||||
|
||||
# If we need to use a temporary directory, create it
|
||||
if self.use_temp_dir:
|
||||
import tempfile
|
||||
self.logo_dir = tempfile.mkdtemp(prefix='stock_logos_')
|
||||
logger.info(f"Using temporary directory for logos: {self.logo_dir}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting up logo directory: {str(e)}")
|
||||
# Fall back to using a temporary directory
|
||||
import tempfile
|
||||
self.logo_dir = tempfile.mkdtemp(prefix='stock_logos_')
|
||||
self.use_temp_dir = True
|
||||
logger.info(f"Using temporary directory for logos: {self.logo_dir}")
|
||||
|
||||
self.headers = {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
|
||||
}
|
||||
@@ -179,8 +226,8 @@ class StockManager:
|
||||
)
|
||||
|
||||
# Calculate chart dimensions
|
||||
chart_height = 22 # Increased height since we're using smaller fonts
|
||||
chart_y = 7 # Start closer to symbol due to smaller font
|
||||
chart_height = 16 # Reduced from 22 to make chart smaller
|
||||
chart_y = 8 # Slightly adjusted starting position
|
||||
width = self.display_manager.matrix.width
|
||||
|
||||
# Get min and max prices for scaling
|
||||
@@ -213,7 +260,7 @@ class StockManager:
|
||||
price_text = f"${data['price']:.2f} ({data['change']:+.1f}%)"
|
||||
self.display_manager.draw_text(
|
||||
price_text,
|
||||
y=30, # Near bottom
|
||||
y=28, # Moved down slightly from 30 to give more space
|
||||
color=color,
|
||||
small_font=True # Use small font
|
||||
)
|
||||
@@ -275,8 +322,367 @@ class StockManager:
|
||||
else:
|
||||
logger.error("Failed to fetch data for any configured stocks")
|
||||
|
||||
def _download_stock_logo(self, symbol: str) -> str:
|
||||
"""Download and save stock logo for a given symbol.
|
||||
|
||||
Args:
|
||||
symbol: Stock symbol (e.g., 'AAPL', 'MSFT')
|
||||
|
||||
Returns:
|
||||
Path to the saved logo image, or None if download failed
|
||||
"""
|
||||
try:
|
||||
# Create a filename based on the symbol
|
||||
filename = f"{symbol.lower()}.png"
|
||||
filepath = os.path.join(self.logo_dir, filename)
|
||||
|
||||
# Check if we already have the logo
|
||||
if os.path.exists(filepath):
|
||||
return filepath
|
||||
|
||||
# Try to find logo from various sources
|
||||
# 1. Yahoo Finance
|
||||
yahoo_url = f"https://logo.clearbit.com/{symbol.lower()}.com"
|
||||
|
||||
# 2. Alternative source if Yahoo fails
|
||||
alt_url = f"https://storage.googleapis.com/iex/api/logos/{symbol}.png"
|
||||
|
||||
# Try Yahoo first
|
||||
response = requests.get(yahoo_url, headers=self.headers, timeout=5)
|
||||
if response.status_code == 200 and 'image' in response.headers.get('Content-Type', ''):
|
||||
try:
|
||||
# Verify it's a valid image before saving
|
||||
from io import BytesIO
|
||||
img = Image.open(BytesIO(response.content))
|
||||
img.verify() # Verify it's a valid image
|
||||
|
||||
# Try to save the file
|
||||
try:
|
||||
with open(filepath, 'wb') as f:
|
||||
f.write(response.content)
|
||||
logger.info(f"Downloaded logo for {symbol} from Yahoo")
|
||||
return filepath
|
||||
except PermissionError:
|
||||
logger.warning(f"Permission denied when saving logo for {symbol}. Using in-memory logo instead.")
|
||||
# Return a temporary path that won't be used for saving
|
||||
return f"temp_{symbol.lower()}.png"
|
||||
except Exception as e:
|
||||
logger.warning(f"Invalid image data from Yahoo for {symbol}: {str(e)}")
|
||||
|
||||
# Try alternative source
|
||||
response = requests.get(alt_url, headers=self.headers, timeout=5)
|
||||
if response.status_code == 200 and 'image' in response.headers.get('Content-Type', ''):
|
||||
try:
|
||||
# Verify it's a valid image before saving
|
||||
from io import BytesIO
|
||||
img = Image.open(BytesIO(response.content))
|
||||
img.verify() # Verify it's a valid image
|
||||
|
||||
# Try to save the file
|
||||
try:
|
||||
with open(filepath, 'wb') as f:
|
||||
f.write(response.content)
|
||||
logger.info(f"Downloaded logo for {symbol} from alternative source")
|
||||
return filepath
|
||||
except PermissionError:
|
||||
logger.warning(f"Permission denied when saving logo for {symbol}. Using in-memory logo instead.")
|
||||
# Return a temporary path that won't be used for saving
|
||||
return f"temp_{symbol.lower()}.png"
|
||||
except Exception as e:
|
||||
logger.warning(f"Invalid image data from alternative source for {symbol}: {str(e)}")
|
||||
|
||||
# Try a third source - company.com domain
|
||||
company_url = f"https://logo.clearbit.com/{symbol.lower()}.com"
|
||||
if company_url != yahoo_url: # Avoid duplicate request
|
||||
response = requests.get(company_url, headers=self.headers, timeout=5)
|
||||
if response.status_code == 200 and 'image' in response.headers.get('Content-Type', ''):
|
||||
try:
|
||||
# Verify it's a valid image before saving
|
||||
from io import BytesIO
|
||||
img = Image.open(BytesIO(response.content))
|
||||
img.verify() # Verify it's a valid image
|
||||
|
||||
# Try to save the file
|
||||
try:
|
||||
with open(filepath, 'wb') as f:
|
||||
f.write(response.content)
|
||||
logger.info(f"Downloaded logo for {symbol} from company domain")
|
||||
return filepath
|
||||
except PermissionError:
|
||||
logger.warning(f"Permission denied when saving logo for {symbol}. Using in-memory logo instead.")
|
||||
# Return a temporary path that won't be used for saving
|
||||
return f"temp_{symbol.lower()}.png"
|
||||
except Exception as e:
|
||||
logger.warning(f"Invalid image data from company domain for {symbol}: {str(e)}")
|
||||
|
||||
logger.warning(f"Could not download logo for {symbol}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error downloading logo for {symbol}: {str(e)}")
|
||||
return None
|
||||
|
||||
def _get_stock_logo(self, symbol: str) -> Image.Image:
|
||||
"""Get stock logo image, or create a text-based fallback.
|
||||
|
||||
Args:
|
||||
symbol: Stock symbol (e.g., 'AAPL', 'MSFT')
|
||||
|
||||
Returns:
|
||||
PIL Image of the logo or text-based fallback
|
||||
"""
|
||||
# Try to download the logo if we don't have it
|
||||
logo_path = self._download_stock_logo(symbol)
|
||||
|
||||
if logo_path:
|
||||
try:
|
||||
# Check if this is a temporary path (in-memory logo)
|
||||
if logo_path.startswith("temp_"):
|
||||
# For temporary paths, we need to download the logo again
|
||||
# since we couldn't save it to disk
|
||||
symbol_lower = symbol.lower()
|
||||
yahoo_url = f"https://logo.clearbit.com/{symbol_lower}.com"
|
||||
alt_url = f"https://storage.googleapis.com/iex/api/logos/{symbol}.png"
|
||||
company_url = f"https://logo.clearbit.com/{symbol_lower}.com"
|
||||
|
||||
# Try all sources
|
||||
for url in [yahoo_url, alt_url, company_url]:
|
||||
try:
|
||||
response = requests.get(url, headers=self.headers, timeout=5)
|
||||
if response.status_code == 200 and 'image' in response.headers.get('Content-Type', ''):
|
||||
try:
|
||||
# Create image from response content
|
||||
from io import BytesIO
|
||||
logo = Image.open(BytesIO(response.content))
|
||||
# Verify it's a valid image
|
||||
logo.verify()
|
||||
# Reopen after verify
|
||||
logo = Image.open(BytesIO(response.content))
|
||||
|
||||
# Convert to RGBA if not already
|
||||
if logo.mode != 'RGBA':
|
||||
logo = logo.convert('RGBA')
|
||||
|
||||
# Resize to fit in the display (assuming square logo)
|
||||
max_size = min(self.display_manager.matrix.width // 2,
|
||||
self.display_manager.matrix.height // 2)
|
||||
logo = logo.resize((max_size, max_size), Image.LANCZOS)
|
||||
|
||||
return logo
|
||||
except Exception as e:
|
||||
logger.warning(f"Invalid image data from {url} for {symbol}: {str(e)}")
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.warning(f"Error downloading from {url} for {symbol}: {str(e)}")
|
||||
continue
|
||||
else:
|
||||
# Normal case: open the saved logo file
|
||||
try:
|
||||
logo = Image.open(logo_path)
|
||||
# Verify it's a valid image
|
||||
logo.verify()
|
||||
# Reopen after verify
|
||||
logo = Image.open(logo_path)
|
||||
|
||||
# Convert to RGBA if not already
|
||||
if logo.mode != 'RGBA':
|
||||
logo = logo.convert('RGBA')
|
||||
|
||||
# Resize to fit in the display (assuming square logo)
|
||||
max_size = min(self.display_manager.matrix.width // 2,
|
||||
self.display_manager.matrix.height // 2)
|
||||
logo = logo.resize((max_size, max_size), Image.LANCZOS)
|
||||
|
||||
return logo
|
||||
except Exception as e:
|
||||
logger.warning(f"Invalid image file for {symbol}: {str(e)}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing logo for {symbol}: {str(e)}")
|
||||
|
||||
# Fallback to text-based logo
|
||||
return None
|
||||
|
||||
def _create_stock_display(self, symbol: str, price: float, change: float, change_percent: float) -> Image.Image:
|
||||
"""Create a display image for a stock with logo, symbol, price, and change.
|
||||
|
||||
Args:
|
||||
symbol: Stock symbol (e.g., 'AAPL', 'MSFT')
|
||||
price: Current stock price
|
||||
change: Price change
|
||||
change_percent: Percentage change
|
||||
|
||||
Returns:
|
||||
PIL Image of the stock display
|
||||
"""
|
||||
# Create a wider image for scrolling
|
||||
width = self.display_manager.matrix.width * 2 # Reduced from 3x to 2x since we'll handle spacing in display_stocks
|
||||
height = self.display_manager.matrix.height
|
||||
image = Image.new('RGB', (width, height), color=(0, 0, 0))
|
||||
draw = ImageDraw.Draw(image)
|
||||
|
||||
# Draw large stock logo on the left
|
||||
logo = self._get_stock_logo(symbol)
|
||||
if logo:
|
||||
# Position logo on the left side with minimal spacing
|
||||
logo_x = 0 # Reduced from 2 to 0
|
||||
logo_y = (height - logo.height) // 2
|
||||
image.paste(logo, (logo_x, logo_y), logo)
|
||||
|
||||
# Draw symbol, price, and change in the center
|
||||
# Use the fonts from display_manager
|
||||
regular_font = self.display_manager.regular_font
|
||||
small_font = self.display_manager.small_font
|
||||
|
||||
# Create smaller versions of the fonts for symbol and price
|
||||
symbol_font = ImageFont.truetype(self.display_manager.regular_font.path,
|
||||
int(self.display_manager.regular_font.size * 0.8)) # 80% of regular size
|
||||
price_font = ImageFont.truetype(self.display_manager.regular_font.path,
|
||||
int(self.display_manager.regular_font.size * 0.8)) # 80% of regular size
|
||||
|
||||
# Calculate text dimensions for proper spacing
|
||||
symbol_text = symbol
|
||||
price_text = f"${price:.2f}"
|
||||
change_text = f"{change:+.2f} ({change_percent:+.1f}%)"
|
||||
|
||||
# Get the height of each text element
|
||||
symbol_bbox = draw.textbbox((0, 0), symbol_text, font=symbol_font)
|
||||
price_bbox = draw.textbbox((0, 0), price_text, font=price_font)
|
||||
change_bbox = draw.textbbox((0, 0), change_text, font=small_font)
|
||||
|
||||
symbol_height = symbol_bbox[3] - symbol_bbox[1]
|
||||
price_height = price_bbox[3] - price_bbox[1]
|
||||
change_height = change_bbox[3] - change_bbox[1]
|
||||
|
||||
# Calculate total height needed for all text
|
||||
total_text_height = symbol_height + price_height + change_height + 2 # 2 pixels for spacing
|
||||
|
||||
# Calculate starting y position to center the text block
|
||||
start_y = (height - total_text_height) // 2
|
||||
|
||||
# Draw symbol - moved even closer to the logo
|
||||
symbol_width = symbol_bbox[2] - symbol_bbox[0]
|
||||
symbol_x = width // 4 # Moved from width//3 to width//4 to bring text even closer to logo
|
||||
symbol_y = start_y
|
||||
draw.text((symbol_x, symbol_y), symbol_text, font=symbol_font, fill=(255, 255, 255))
|
||||
|
||||
# Draw price - aligned with symbol
|
||||
price_width = price_bbox[2] - price_bbox[0]
|
||||
price_x = symbol_x + (symbol_width - price_width) // 2 # Center price under symbol
|
||||
price_y = symbol_y + symbol_height + 1 # 1 pixel spacing
|
||||
draw.text((price_x, price_y), price_text, font=price_font, fill=(255, 255, 255))
|
||||
|
||||
# Draw change with color based on value - aligned with price
|
||||
change_width = change_bbox[2] - change_bbox[0]
|
||||
change_x = price_x + (price_width - change_width) // 2 # Center change under price
|
||||
change_y = price_y + price_height + 1 # 1 pixel spacing
|
||||
change_color = (0, 255, 0) if change >= 0 else (255, 0, 0)
|
||||
draw.text((change_x, change_y), change_text, font=small_font, fill=change_color)
|
||||
|
||||
# Draw mini chart on the right
|
||||
if symbol in self.stock_data and 'price_history' in self.stock_data[symbol]:
|
||||
price_history = self.stock_data[symbol]['price_history']
|
||||
if len(price_history) >= 2: # Need at least 2 points to draw a line
|
||||
# Extract prices from price history
|
||||
chart_data = [p['price'] for p in price_history]
|
||||
|
||||
# Calculate chart dimensions - 50% wider
|
||||
chart_width = int(width // 2.5) # Reduced from width//2 to width//2.5 (20% smaller)
|
||||
chart_height = height // 1.5
|
||||
chart_x = width - chart_width + 5 # Keep the same right margin
|
||||
chart_y = (height - chart_height) // 2
|
||||
|
||||
# Find min and max prices for scaling
|
||||
min_price = min(chart_data)
|
||||
max_price = max(chart_data)
|
||||
|
||||
# Add padding to avoid flat lines when prices are very close
|
||||
price_range = max_price - min_price
|
||||
if price_range < 0.01: # If prices are very close
|
||||
min_price -= 0.01
|
||||
max_price += 0.01
|
||||
price_range = 0.02
|
||||
|
||||
# Calculate points for the line
|
||||
points = []
|
||||
for i, price in enumerate(chart_data):
|
||||
x = chart_x + (i * chart_width) // (len(chart_data) - 1)
|
||||
# Invert y-axis (higher price = lower y value)
|
||||
y = chart_y + chart_height - ((price - min_price) / price_range * chart_height)
|
||||
points.append((x, y))
|
||||
|
||||
# Draw the line
|
||||
if len(points) >= 2:
|
||||
draw.line(points, fill=(0, 255, 0) if change >= 0 else (255, 0, 0), width=2)
|
||||
|
||||
# Draw dots at each point
|
||||
for point in points:
|
||||
draw.ellipse([point[0]-2, point[1]-2, point[0]+2, point[1]+2],
|
||||
fill=(0, 255, 0) if change >= 0 else (255, 0, 0))
|
||||
|
||||
# Return the full image without cropping
|
||||
return image
|
||||
|
||||
def _update_stock_display(self, symbol: str, data: Dict[str, Any], width: int, height: int) -> None:
|
||||
"""Update the stock display with smooth scrolling animation."""
|
||||
try:
|
||||
# Create the full scrolling image
|
||||
full_image = self._create_stock_display(symbol, data['price'], data['change'], data['change'] / data['open'] * 100)
|
||||
scroll_width = width * 2 # Double width for smooth scrolling
|
||||
|
||||
# Scroll the image smoothly
|
||||
for scroll_pos in range(0, scroll_width - width, 15): # Increased scroll speed to match news ticker
|
||||
# Create visible portion
|
||||
visible_image = full_image.crop((scroll_pos, 0, scroll_pos + width, height))
|
||||
|
||||
# Convert to RGB and create numpy array
|
||||
rgb_image = visible_image.convert('RGB')
|
||||
image_array = np.array(rgb_image)
|
||||
|
||||
# Update display
|
||||
self.display_manager.update_display(image_array)
|
||||
|
||||
# Small delay for smooth animation
|
||||
time.sleep(0.01) # Reduced delay to 10ms for smoother scrolling
|
||||
|
||||
# Show final position briefly
|
||||
final_image = full_image.crop((scroll_width - width, 0, scroll_width, height))
|
||||
rgb_image = final_image.convert('RGB')
|
||||
image_array = np.array(rgb_image)
|
||||
self.display_manager.update_display(image_array)
|
||||
time.sleep(0.5) # Brief pause at the end
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating stock display for {symbol}: {str(e)}")
|
||||
# Show error state
|
||||
self._show_error_state(width, height)
|
||||
|
||||
def _log_frame_rate(self):
|
||||
"""Log frame rate statistics."""
|
||||
current_time = time.time()
|
||||
|
||||
# Calculate instantaneous frame time
|
||||
frame_time = current_time - self.last_frame_time
|
||||
self.frame_times.append(frame_time)
|
||||
|
||||
# Keep only last 100 frames for average
|
||||
if len(self.frame_times) > 100:
|
||||
self.frame_times.pop(0)
|
||||
|
||||
# Log FPS every second
|
||||
if current_time - self.last_fps_log_time >= 1.0:
|
||||
avg_frame_time = sum(self.frame_times) / len(self.frame_times)
|
||||
avg_fps = 1.0 / avg_frame_time if avg_frame_time > 0 else 0
|
||||
instant_fps = 1.0 / frame_time if frame_time > 0 else 0
|
||||
|
||||
logger.info(f"Frame stats - Avg FPS: {avg_fps:.1f}, Current FPS: {instant_fps:.1f}, Frame time: {frame_time*1000:.2f}ms")
|
||||
self.last_fps_log_time = current_time
|
||||
self.frame_count = 0
|
||||
|
||||
self.last_frame_time = current_time
|
||||
self.frame_count += 1
|
||||
|
||||
def display_stocks(self, force_clear: bool = False):
|
||||
"""Display stock information on the LED matrix."""
|
||||
"""Display stock information with continuous scrolling animation."""
|
||||
if not self.stocks_config.get('enabled', False):
|
||||
return
|
||||
|
||||
@@ -288,59 +694,80 @@ class StockManager:
|
||||
logger.warning("No stock data available to display")
|
||||
return
|
||||
|
||||
# Get the current stock to display
|
||||
# Get all symbols
|
||||
symbols = list(self.stock_data.keys())
|
||||
if not symbols:
|
||||
return
|
||||
|
||||
current_symbol = symbols[self.current_stock_index]
|
||||
data = self.stock_data[current_symbol]
|
||||
# Create a continuous scrolling image if needed
|
||||
if self.cached_text_image is None or force_clear:
|
||||
# Create a very wide image that contains all stocks in sequence
|
||||
width = self.display_manager.matrix.width
|
||||
height = self.display_manager.matrix.height
|
||||
|
||||
# Calculate total width needed for all stocks
|
||||
# Each stock needs width*2 for scrolling, plus consistent gaps between elements
|
||||
stock_gap = width // 3 # Gap between stocks
|
||||
element_gap = width // 6 # Gap between elements within a stock
|
||||
total_width = sum(width * 2 for _ in symbols) + stock_gap * (len(symbols) - 1) + element_gap * (len(symbols) * 2 - 1)
|
||||
|
||||
# Create the full image
|
||||
full_image = Image.new('RGB', (total_width, height), (0, 0, 0))
|
||||
draw = ImageDraw.Draw(full_image)
|
||||
|
||||
# Draw each stock in sequence with consistent spacing
|
||||
current_x = 0
|
||||
for symbol in symbols:
|
||||
data = self.stock_data[symbol]
|
||||
|
||||
# Create stock display for this symbol
|
||||
stock_image = self._create_stock_display(symbol, data['price'], data['change'], data['change'] / data['open'] * 100)
|
||||
|
||||
# Paste this stock image into the full image
|
||||
full_image.paste(stock_image, (current_x, 0))
|
||||
|
||||
# Move to next position with consistent spacing
|
||||
current_x += width * 2 + element_gap
|
||||
|
||||
# Add extra gap between stocks
|
||||
if symbol != symbols[-1]: # Don't add gap after the last stock
|
||||
current_x += stock_gap
|
||||
|
||||
# Cache the full image
|
||||
self.cached_text_image = full_image
|
||||
self.scroll_position = 0
|
||||
self.last_update = time.time()
|
||||
|
||||
# Toggle between info and chart display
|
||||
if self.display_mode == 'info':
|
||||
# Clear the display
|
||||
# Clear the display if requested
|
||||
if force_clear:
|
||||
self.display_manager.clear()
|
||||
|
||||
# Draw the stock symbol at the top with regular font
|
||||
self.display_manager.draw_text(
|
||||
data['symbol'],
|
||||
y=2, # Near top
|
||||
color=(255, 255, 255) # White for symbol
|
||||
)
|
||||
|
||||
# Draw the price in the middle with small font
|
||||
price_text = f"${data['price']:.2f}"
|
||||
self.display_manager.draw_text(
|
||||
price_text,
|
||||
y=14, # Middle
|
||||
color=(0, 255, 0) if data['change'] >= 0 else (255, 0, 0), # Green for up, red for down
|
||||
small_font=True # Use small font
|
||||
)
|
||||
|
||||
# Draw the change percentage at the bottom with small font
|
||||
change_text = f"({data['change']:+.1f}%)"
|
||||
self.display_manager.draw_text(
|
||||
change_text,
|
||||
y=24, # Near bottom
|
||||
color=(0, 255, 0) if data['change'] >= 0 else (255, 0, 0), # Green for up, red for down
|
||||
small_font=True # Use small font
|
||||
)
|
||||
|
||||
# Update the display
|
||||
self.display_manager.update_display()
|
||||
|
||||
# Switch to chart mode next time
|
||||
self.display_mode = 'chart'
|
||||
else: # chart mode
|
||||
self._draw_chart(current_symbol, data)
|
||||
# Switch back to info mode next time
|
||||
self.display_mode = 'info'
|
||||
self.scroll_position = 0
|
||||
|
||||
# Add a delay to make each display visible
|
||||
time.sleep(3)
|
||||
# Calculate the visible portion of the image
|
||||
width = self.display_manager.matrix.width
|
||||
total_width = self.cached_text_image.width
|
||||
|
||||
# Move to next stock for next update
|
||||
self.current_stock_index = (self.current_stock_index + 1) % len(symbols)
|
||||
# Update scroll position with small increments
|
||||
self.scroll_position = (self.scroll_position + self.scroll_speed) % total_width
|
||||
|
||||
# If we've shown all stocks, signal completion by returning True
|
||||
return self.current_stock_index == 0
|
||||
# Calculate the visible portion
|
||||
visible_portion = self.cached_text_image.crop((
|
||||
self.scroll_position, 0,
|
||||
self.scroll_position + width, self.display_manager.matrix.height
|
||||
))
|
||||
|
||||
# Copy the visible portion to the display
|
||||
self.display_manager.image.paste(visible_portion, (0, 0))
|
||||
self.display_manager.update_display()
|
||||
|
||||
# Log frame rate
|
||||
self._log_frame_rate()
|
||||
|
||||
# Add a small delay between frames
|
||||
time.sleep(self.scroll_delay)
|
||||
|
||||
# If we've scrolled through the entire image, reset
|
||||
if self.scroll_position == 0:
|
||||
return True
|
||||
|
||||
return False
|
||||
Reference in New Issue
Block a user