Merge Stocks branch: Add stock display improvements and configuration updates

This commit is contained in:
ChuckBuilds
2025-04-11 13:41:11 -05:00
4 changed files with 529 additions and 97 deletions

0
assets/stocks/.gitkeep Normal file
View File

View 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:

View File

@@ -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)

View File

@@ -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