diff --git a/src/cache_manager.py b/src/cache_manager.py new file mode 100644 index 00000000..29d01140 --- /dev/null +++ b/src/cache_manager.py @@ -0,0 +1,166 @@ +import json +import os +import time +from datetime import datetime +import pytz +from typing import Any, Dict, Optional +import logging +import stat + +class CacheManager: + def __init__(self, cache_dir: str = "cache"): + self.cache_dir = cache_dir + self._ensure_cache_dir() + self.logger = logging.getLogger(__name__) + + def _ensure_cache_dir(self) -> None: + """Ensure the cache directory exists with proper permissions.""" + if not os.path.exists(self.cache_dir): + try: + # Create directory with 755 permissions (rwxr-xr-x) + os.makedirs(self.cache_dir, mode=0o755, exist_ok=True) + + # If running as sudo, change ownership to the real user + if os.geteuid() == 0: # Check if running as root + import pwd + # Get the real user (not root) + real_user = os.environ.get('SUDO_USER') + if real_user: + uid = pwd.getpwnam(real_user).pw_uid + gid = pwd.getpwnam(real_user).pw_gid + os.chown(self.cache_dir, uid, gid) + self.logger.info(f"Changed cache directory ownership to {real_user}") + except Exception as e: + self.logger.error(f"Error setting up cache directory: {e}") + # Fall back to current directory if we can't create the cache directory + self.cache_dir = "." + + def _get_cache_path(self, data_type: str) -> str: + """Get the path for a specific cache file.""" + return os.path.join(self.cache_dir, f"{data_type}_cache.json") + + def load_cache(self, data_type: str) -> Optional[Dict[str, Any]]: + """Load cached data for a specific type.""" + cache_path = self._get_cache_path(data_type) + try: + if os.path.exists(cache_path): + with open(cache_path, 'r') as f: + return json.load(f) + except Exception as e: + self.logger.error(f"Error loading cache for {data_type}: {e}") + return None + + def save_cache(self, data_type: str, data: Dict[str, Any]) -> bool: + """Save data to cache with proper permissions.""" + cache_path = self._get_cache_path(data_type) + try: + # Create a temporary file first + temp_path = cache_path + '.tmp' + with open(temp_path, 'w') as f: + json.dump(data, f) + + # Set proper permissions (644 - rw-r--r--) + os.chmod(temp_path, 0o644) + + # If running as sudo, change ownership to the real user + if os.geteuid() == 0: # Check if running as root + import pwd + real_user = os.environ.get('SUDO_USER') + if real_user: + uid = pwd.getpwnam(real_user).pw_uid + gid = pwd.getpwnam(real_user).pw_gid + os.chown(temp_path, uid, gid) + + # Rename temp file to actual cache file (atomic operation) + os.replace(temp_path, cache_path) + return True + except Exception as e: + self.logger.error(f"Error saving cache for {data_type}: {e}") + # Clean up temp file if it exists + if os.path.exists(temp_path): + try: + os.remove(temp_path) + except: + pass + return False + + def has_data_changed(self, data_type: str, new_data: Dict[str, Any]) -> bool: + """Check if data has changed from cached version.""" + cached_data = self.load_cache(data_type) + if not cached_data: + return True + + if data_type == 'weather': + return self._has_weather_changed(cached_data, new_data) + elif data_type == 'stocks': + return self._has_stocks_changed(cached_data, new_data) + elif data_type == 'stock_news': + return self._has_news_changed(cached_data, new_data) + elif data_type == 'nhl': + return self._has_nhl_changed(cached_data, new_data) + + return True + + def _has_weather_changed(self, cached: Dict[str, Any], new: Dict[str, Any]) -> bool: + """Check if weather data has changed.""" + return (cached.get('temp') != new.get('temp') or + cached.get('condition') != new.get('condition')) + + def _has_stocks_changed(self, cached: Dict[str, Any], new: Dict[str, Any]) -> bool: + """Check if stock data has changed.""" + if not self._is_market_open(): + return False + return cached.get('price') != new.get('price') + + def _has_news_changed(self, cached: Dict[str, Any], new: Dict[str, Any]) -> bool: + """Check if news data has changed.""" + cached_headlines = set(h.get('id') for h in cached.get('headlines', [])) + new_headlines = set(h.get('id') for h in new.get('headlines', [])) + return not cached_headlines.issuperset(new_headlines) + + def _has_nhl_changed(self, cached: Dict[str, Any], new: Dict[str, Any]) -> bool: + """Check if NHL data has changed.""" + return (cached.get('game_status') != new.get('game_status') or + cached.get('score') != new.get('score')) + + def _is_market_open(self) -> bool: + """Check if the US stock market is currently open.""" + et_tz = pytz.timezone('America/New_York') + now = datetime.now(et_tz) + + # Check if it's a weekday + if now.weekday() >= 5: # 5 = Saturday, 6 = Sunday + return False + + # Convert current time to ET + current_time = now.time() + market_open = datetime.strptime('09:30', '%H:%M').time() + market_close = datetime.strptime('16:00', '%H:%M').time() + + return market_open <= current_time <= market_close + + def update_cache(self, data_type: str, data: Dict[str, Any]) -> bool: + """Update cache with new data.""" + cache_data = { + 'data': data, + 'timestamp': time.time() + } + return self.save_cache(data_type, cache_data) + + def get_cached_data(self, data_type: str) -> Optional[Dict[str, Any]]: + """Get cached data if it exists and is still valid.""" + cached = self.load_cache(data_type) + if not cached: + return None + + # Check if cache is still valid based on data type + if data_type == 'weather' and time.time() - cached['timestamp'] > 600: # 10 minutes + return None + elif data_type == 'stocks' and time.time() - cached['timestamp'] > 300: # 5 minutes + return None + elif data_type == 'stock_news' and time.time() - cached['timestamp'] > 800: # ~13 minutes + return None + elif data_type == 'nhl' and time.time() - cached['timestamp'] > 60: # 1 minute + return None + + return cached['data'] \ No newline at end of file diff --git a/src/stock_manager.py b/src/stock_manager.py index e9c9aee2..c978fcd7 100644 --- a/src/stock_manager.py +++ b/src/stock_manager.py @@ -11,6 +11,7 @@ import re from PIL import Image, ImageDraw, ImageFont import numpy as np import hashlib +from .cache_manager import CacheManager # Configure logging logging.basicConfig(level=logging.INFO) @@ -27,6 +28,7 @@ class StockManager: self.scroll_position = 0 self.cached_text_image = None self.cached_text = None + self.cache_manager = CacheManager() # Get scroll settings from config with faster defaults self.scroll_speed = self.stocks_config.get('scroll_speed', 1) @@ -125,6 +127,12 @@ class StockManager: def _fetch_stock_data(self, symbol: str) -> Dict[str, Any]: """Fetch stock data from Yahoo Finance public API.""" + # Try to get cached data first + cached_data = self.cache_manager.get_cached_data('stocks') + if cached_data and symbol in cached_data: + logger.info(f"Using cached data for {symbol}") + return cached_data[symbol] + try: # Use Yahoo Finance query1 API for chart data encoded_symbol = urllib.parse.quote(symbol) @@ -174,7 +182,7 @@ class StockManager: logger.debug(f"Processed data for {symbol}: price={current_price}, change={change_pct}%") - return { + stock_data = { "symbol": symbol, "name": name, "price": current_price, @@ -183,8 +191,20 @@ class StockManager: "price_history": price_history } + # Cache the new data + if cached_data is None: + cached_data = {} + cached_data[symbol] = stock_data + self.cache_manager.update_cache('stocks', cached_data) + + return stock_data + except requests.exceptions.RequestException as e: logger.error(f"Network error fetching data for {symbol}: {e}") + # Try to use cached data as fallback + if cached_data and symbol in cached_data: + logger.info(f"Using cached data as fallback for {symbol}") + return cached_data[symbol] return None except (ValueError, IndexError, KeyError) as e: logger.error(f"Error parsing data for {symbol}: {e}") @@ -264,47 +284,42 @@ class StockManager: logger.info(f"Stock symbols changed. New symbols: {new_symbols}") def update_stock_data(self): - """Update stock data if enough time has passed.""" + """Update stock data for all configured symbols.""" current_time = time.time() - update_interval = self.stocks_config.get('update_interval', 60) + update_interval = self.stocks_config.get('update_interval', 300) - # If not enough time has passed, keep using existing data - if current_time - self.last_update < update_interval + random.uniform(0, 2): - return + # Check if we need to update based on time + if current_time - self.last_update > update_interval: + symbols = self.stocks_config.get('symbols', []) + if not symbols: + logger.warning("No stock symbols configured") + return - # Reload config to check for symbol changes - self._reload_config() + # Get cached data + cached_data = self.cache_manager.get_cached_data('stocks') - # Get symbols from config - symbols = self.stocks_config.get('symbols', []) - if not symbols: - logger.warning("No stock symbols configured") - return - - # If symbols is a list of strings, convert to list of dicts - if isinstance(symbols[0], str): - symbols = [{"symbol": symbol} for symbol in symbols] - - # Create temporary storage for new data - new_data = {} - success = False - - for stock in symbols: - symbol = stock['symbol'] - # Add a small delay between requests to avoid rate limiting - time.sleep(random.uniform(0.1, 0.3)) # Reduced delay - data = self._fetch_stock_data(symbol) - if data: - new_data[symbol] = data - success = True - logger.info(f"Updated {symbol}: ${data['price']:.2f} ({data['change']:+.2f}%)") - - if success: - # Only update the displayed data when we have new data - self.stock_data.update(new_data) + # Check if market is open + if cached_data and not self.cache_manager._is_market_open(): + logger.info("Market is closed, using cached data") + self.stock_data = cached_data + self.last_update = current_time + return + + # Update each symbol + for symbol in symbols: + # Check if data has changed before fetching + if cached_data and symbol in cached_data: + current_state = cached_data[symbol] + if not self.cache_manager.has_data_changed('stocks', current_state): + logger.info(f"Stock data hasn't changed for {symbol}, using existing data") + self.stock_data[symbol] = current_state + continue + + data = self._fetch_stock_data(symbol) + if data: + self.stock_data[symbol] = data + self.last_update = current_time - else: - logger.error("Failed to fetch data for any configured stocks") def _get_stock_logo(self, symbol: str) -> Image.Image: """Get stock logo image from local ticker icons directory. diff --git a/src/stock_news_manager.py b/src/stock_news_manager.py index 0252f049..9fa57b33 100644 --- a/src/stock_news_manager.py +++ b/src/stock_news_manager.py @@ -10,6 +10,7 @@ import urllib.parse import re from src.config_manager import ConfigManager from PIL import Image, ImageDraw +from .cache_manager import CacheManager # Configure logging logging.basicConfig(level=logging.INFO) @@ -28,7 +29,7 @@ class StockNewsManager: self.scroll_position = 0 self.cached_text_image = None # Cache for the text image self.cached_text = None # Cache for the text string - + self.cache_manager = CacheManager() # Get scroll settings from config with faster defaults self.scroll_speed = self.stock_news_config.get('scroll_speed', 1) @@ -51,6 +52,12 @@ class StockNewsManager: def _fetch_news_for_symbol(self, symbol: str) -> List[Dict[str, Any]]: """Fetch news headlines for a stock symbol.""" + # Try to get cached data first + cached_data = self.cache_manager.get_cached_data('stock_news') + if cached_data and symbol in cached_data: + logger.info(f"Using cached news data for {symbol}") + return cached_data[symbol] + try: # Using Yahoo Finance API to get news encoded_symbol = urllib.parse.quote(symbol) @@ -75,10 +82,21 @@ class StockNewsManager: }) logger.info(f"Fetched {len(formatted_news)} news items for {symbol}") + + # Cache the new data + if cached_data is None: + cached_data = {} + cached_data[symbol] = formatted_news + self.cache_manager.update_cache('stock_news', cached_data) + return formatted_news except requests.exceptions.RequestException as e: logger.error(f"Network error fetching news for {symbol}: {e}") + # Try to use cached data as fallback + if cached_data and symbol in cached_data: + logger.info(f"Using cached news data as fallback for {symbol}") + return cached_data[symbol] return [] except (ValueError, IndexError, KeyError) as e: logger.error(f"Error parsing news data for {symbol}: {e}") @@ -90,37 +108,46 @@ class StockNewsManager: def update_news_data(self): """Update news data for all configured stock symbols.""" current_time = time.time() - update_interval = self.stock_news_config.get('update_interval', 300) # Default to 5 minutes + update_interval = self.stock_news_config.get('update_interval', 300) - # If not enough time has passed, keep using existing data - if current_time - self.last_update < update_interval: - return + # Check if we need to update based on time + if current_time - self.last_update > update_interval: + symbols = self.stocks_config.get('symbols', []) + if not symbols: + logger.warning("No stock symbols configured for news") + return + + # Get cached data + cached_data = self.cache_manager.get_cached_data('stock_news') - # Get symbols from config - symbols = self.stocks_config.get('symbols', []) - if not symbols: - logger.warning("No stock symbols configured for news") - return + # Update each symbol + new_data = {} + success = False - # Create temporary storage for new data - new_data = {} - success = False - - for symbol in symbols: - # Add a small delay between requests to avoid rate limiting - time.sleep(random.uniform(0.1, 0.3)) - news_items = self._fetch_news_for_symbol(symbol) - if news_items: - new_data[symbol] = news_items - success = True - - if success: - # Only update the displayed data when we have new data - self.news_data = new_data - self.last_update = current_time - logger.info(f"Updated news data for {len(new_data)} symbols") - else: - logger.error("Failed to fetch news for any configured stocks") + for symbol in symbols: + # Check if data has changed before fetching + if cached_data and symbol in cached_data: + current_state = cached_data[symbol] + if not self.cache_manager.has_data_changed('stock_news', current_state): + logger.info(f"News data hasn't changed for {symbol}, using existing data") + new_data[symbol] = current_state + success = True + continue + + # Add a small delay between requests to avoid rate limiting + time.sleep(random.uniform(0.1, 0.3)) + news_items = self._fetch_news_for_symbol(symbol) + if news_items: + new_data[symbol] = news_items + success = True + + if success: + # Only update the displayed data when we have new data + self.news_data = new_data + self.last_update = current_time + logger.info(f"Updated news data for {len(new_data)} symbols") + else: + logger.error("Failed to fetch news for any configured stocks") def _create_text_image(self, text: str, color: Tuple[int, int, int] = (255, 255, 255)) -> Image.Image: """Create an image containing the text for efficient scrolling.""" diff --git a/src/weather_manager.py b/src/weather_manager.py index 6c9cf62a..b2c30dd0 100644 --- a/src/weather_manager.py +++ b/src/weather_manager.py @@ -4,6 +4,7 @@ from datetime import datetime from typing import Dict, Any, List from PIL import Image, ImageDraw from .weather_icons import WeatherIcons +from .cache_manager import CacheManager class WeatherManager: # Weather condition to larger colored icons (we'll use these as placeholders until you provide custom ones) @@ -36,6 +37,7 @@ class WeatherManager: self.hourly_forecast = None self.daily_forecast = None self.last_draw_time = 0 + self.cache_manager = CacheManager() # Layout constants self.PADDING = 1 self.ICON_SIZE = { @@ -65,6 +67,17 @@ class WeatherManager: print("No API key configured for weather") return + # Try to get cached data first + cached_data = self.cache_manager.get_cached_data('weather') + if cached_data: + self.weather_data = cached_data.get('current') + self.forecast_data = cached_data.get('forecast') + if self.weather_data and self.forecast_data: + self._process_forecast_data(self.forecast_data) + self.last_update = time.time() + print("Using cached weather data") + return + city = self.location['city'] state = self.location['state'] country = self.location['country'] @@ -103,12 +116,27 @@ class WeatherManager: # Process forecast data self._process_forecast_data(self.forecast_data) + # Cache the new data + cache_data = { + 'current': self.weather_data, + 'forecast': self.forecast_data + } + self.cache_manager.update_cache('weather', cache_data) + self.last_update = time.time() print("Weather data updated successfully") except Exception as e: print(f"Error fetching weather data: {e}") - self.weather_data = None - self.forecast_data = None + # If we have cached data, use it as fallback + if cached_data: + self.weather_data = cached_data.get('current') + self.forecast_data = cached_data.get('forecast') + if self.weather_data and self.forecast_data: + self._process_forecast_data(self.forecast_data) + print("Using cached weather data as fallback") + else: + self.weather_data = None + self.forecast_data = None def _process_forecast_data(self, forecast_data: Dict[str, Any]) -> None: """Process forecast data into hourly and daily forecasts.""" @@ -170,9 +198,20 @@ class WeatherManager: def get_weather(self) -> Dict[str, Any]: """Get current weather data, fetching new data if needed.""" current_time = time.time() + update_interval = self.weather_config.get('update_interval', 300) + + # Check if we need to update based on time or if we have no data if (not self.weather_data or - current_time - self.last_update > self.weather_config.get('update_interval', 300)): + current_time - self.last_update > update_interval): + + # Check if data has changed before fetching + current_state = self._get_weather_state() + if current_state and not self.cache_manager.has_data_changed('weather', current_state): + print("Weather data hasn't changed, using existing data") + return self.weather_data + self._fetch_weather() + return self.weather_data def _get_weather_state(self) -> Dict[str, Any]: