diff --git a/.gitignore b/.gitignore index 3637755ae..67ebd71e8 100644 --- a/.gitignore +++ b/.gitignore @@ -172,3 +172,7 @@ mock_display_output/ # Static Files added by install/update_vendors.sh /src/static/scripts/ /src/static/styles/ +._* +.DS_Store +src/config/device.json +src/static/images/current_image.png diff --git a/src/blueprints/apikeys.py b/src/blueprints/apikeys.py index 3a87fc1fb..a2850c37d 100644 --- a/src/blueprints/apikeys.py +++ b/src/blueprints/apikeys.py @@ -34,6 +34,8 @@ def write_env_file(filepath, entries): f.write("# InkyPi API Keys and Secrets\n") f.write("# Managed via web interface\n\n") for key, value in entries: + # Strip newlines to prevent injection of extra entries + value = value.replace('\n', '').replace('\r', '') # Quote values with spaces or special characters if ' ' in value or '"' in value or "'" in value: value = f'"{value}"' diff --git a/src/blueprints/plugin.py b/src/blueprints/plugin.py index b7a80d860..6f32e1d21 100644 --- a/src/blueprints/plugin.py +++ b/src/blueprints/plugin.py @@ -235,7 +235,9 @@ def update_now(): try: plugin_settings = parse_form(request.form) plugin_settings.update(handle_request_files(request.files)) - plugin_id = plugin_settings.pop("plugin_id") + plugin_id = plugin_settings.pop("plugin_id", None) + if not plugin_id: + return jsonify({"error": "plugin_id is required"}), 400 # Check if refresh task is running if refresh_task.running: @@ -249,7 +251,7 @@ def update_now(): plugin = get_plugin_instance(plugin_config) image = plugin.generate_image(plugin_settings, device_config) - display_manager.display_image(image, image_settings=plugin_config.get("image_settings", [])) + display_manager.display_image(image, image_settings=plugin_config.get("image_settings") or []) except Exception as e: logger.exception(f"Error in update_now: {str(e)}") diff --git a/src/blueprints/settings.py b/src/blueprints/settings.py index 6f3c013e4..a402378bf 100644 --- a/src/blueprints/settings.py +++ b/src/blueprints/settings.py @@ -1,7 +1,7 @@ from flask import Blueprint, request, jsonify, current_app, render_template, Response from utils.time_utils import calculate_seconds from datetime import datetime, timedelta -import os +import subprocess import pytz import logging import io @@ -86,10 +86,10 @@ def shutdown(): data = request.get_json() or {} if data.get("reboot"): logger.info("Reboot requested") - os.system("sudo reboot") + subprocess.Popen(["sudo", "reboot"]) else: logger.info("Shutdown requested") - os.system("sudo shutdown -h now") + subprocess.Popen(["sudo", "shutdown", "-h", "now"]) return jsonify({"success": True}) @settings_bp.route('/download-logs') diff --git a/src/config.py b/src/config.py index 4761f4592..623d5dd5b 100644 --- a/src/config.py +++ b/src/config.py @@ -20,6 +20,7 @@ class Config: plugin_image_dir = os.path.join(BASE_DIR, "static", "images", "plugins") def __init__(self): + load_dotenv(override=True) self.config = self.read_config() self.plugins_list = self.read_plugins_list() self.playlist_manager = self.load_playlist_manager() @@ -60,10 +61,10 @@ def write_config(self): with open(self.config_file, 'w') as outfile: json.dump(self.config, outfile, indent=4) - def get_config(self, key=None, default={}): + def get_config(self, key=None, default=None): """Gets the value of a specific configuration key or returns the entire config if none provided.""" if key is not None: - return self.config.get(key, default) + return self.config.get(key, default if default is not None else {}) return self.config def get_plugins(self): @@ -114,7 +115,6 @@ def update_value(self, key, value, write=False): def load_env_key(self, key): """Loads an environment variable using dotenv and returns its value.""" - load_dotenv(override=True) return os.getenv(key) def load_playlist_manager(self): diff --git a/src/display/abstract_display.py b/src/display/abstract_display.py index e05108772..3a269c74b 100644 --- a/src/display/abstract_display.py +++ b/src/display/abstract_display.py @@ -30,7 +30,7 @@ def initialize_display(self): """ raise NotImplementedError("Method 'initialize_display(...) must be provided in a subclass.") - def display_image(self, image, image_settings=[]): + def display_image(self, image, image_settings=None): """ Abstract method to display an image on the screen. Implementations of this method should handle the device specific operations. diff --git a/src/display/display_manager.py b/src/display/display_manager.py index 71d9459f3..a0eea91b8 100644 --- a/src/display/display_manager.py +++ b/src/display/display_manager.py @@ -54,7 +54,7 @@ def __init__(self, device_config): else: raise ValueError(f"Unsupported display type: {display_type}") - def display_image(self, image, image_settings=[]): + def display_image(self, image, image_settings=None): """ Delegates image rendering to the appropriate display instance. @@ -67,6 +67,9 @@ def display_image(self, image, image_settings=[]): ValueError: If no valid display instance is found. """ + if image_settings is None: + image_settings = [] + if not hasattr(self, "display"): raise ValueError("No valid display instance initialized.") diff --git a/src/display/inky_display.py b/src/display/inky_display.py index a4c8bccb8..ec23f9faf 100644 --- a/src/display/inky_display.py +++ b/src/display/inky_display.py @@ -37,7 +37,7 @@ def initialize_display(self): [int(self.inky_display.width), int(self.inky_display.height)], write=True) - def display_image(self, image, image_settings=[]): + def display_image(self, image, image_settings=None): """ Displays the provided image on the Inky display. diff --git a/src/display/mock_display.py b/src/display/mock_display.py index de67fa2e4..5fe9a1419 100644 --- a/src/display/mock_display.py +++ b/src/display/mock_display.py @@ -20,7 +20,7 @@ def initialize_display(self): """Initialize mock display (no-op for development).""" logger.info(f"Mock display initialized: {self.width}x{self.height}") - def display_image(self, image, image_settings=[]): + def display_image(self, image, image_settings=None): timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") filepath = os.path.join(self.output_dir, f"display_{timestamp}.png") image.save(filepath, "PNG") diff --git a/src/display/waveshare_display.py b/src/display/waveshare_display.py index 96dc01af6..5d428f59b 100644 --- a/src/display/waveshare_display.py +++ b/src/display/waveshare_display.py @@ -100,7 +100,7 @@ def initialize_display(self): write=True) - def display_image(self, image, image_settings=[]): + def display_image(self, image, image_settings=None): """ Displays an image on the Waveshare display. diff --git a/src/inkypi.py b/src/inkypi.py index 5dc4de57b..e5f37ad04 100755 --- a/src/inkypi.py +++ b/src/inkypi.py @@ -11,13 +11,8 @@ import warnings warnings.filterwarnings("ignore", message=".*Busy Wait: Held high.*") -import os -import random -import time import sys -import json import logging -import threading import argparse from utils.app_utils import generate_startup_image from flask import Flask, request, send_from_directory @@ -98,7 +93,7 @@ try: # Run the Flask app - app.secret_key = str(random.randint(100000,999999)) + app.secret_key = os.urandom(24).hex() # Get local IP address for display (only in dev mode when running on non-Pi) if DEV_MODE: @@ -109,7 +104,7 @@ local_ip = s.getsockname()[0] s.close() logger.info(f"Serving on http://{local_ip}:{PORT}") - except: + except OSError: pass # Ignore if we can't get the IP serve(app, host="0.0.0.0", port=PORT, threads=1) diff --git a/src/model.py b/src/model.py index df2f4b1cf..93debba81 100644 --- a/src/model.py +++ b/src/model.py @@ -67,9 +67,9 @@ class PlaylistManager: DEFAULT_PLAYLIST_START = "00:00" DEFAULT_PLAYLIST_END = "24:00" - def __init__(self, playlists=[], active_playlist=None): + def __init__(self, playlists=None, active_playlist=None): """Initialize PlaylistManager with a list of playlists.""" - self.playlists = playlists + self.playlists = playlists if playlists is not None else [] self.active_playlist = active_playlist def get_playlist_names(self): @@ -306,14 +306,6 @@ def should_refresh(self, current_time): return True # Check for scheduled refresh (HH:MM format) - if "scheduled" in self.refresh: - scheduled_time_str = self.refresh.get("scheduled") - latest_refresh_str = latest_refresh_dt.strftime("%H:%M") - - # If the latest refresh is before the scheduled time today - if latest_refresh_str < scheduled_time_str: - return True - if "scheduled" in self.refresh: scheduled_time_str = self.refresh.get("scheduled") scheduled_time = datetime.strptime(scheduled_time_str, "%H:%M").time() diff --git a/src/plugins/base_plugin/base_plugin.py b/src/plugins/base_plugin/base_plugin.py index 6a0ec2aad..0934087a3 100644 --- a/src/plugins/base_plugin/base_plugin.py +++ b/src/plugins/base_plugin/base_plugin.py @@ -84,7 +84,9 @@ def generate_settings_template(self): template_params['frame_styles'] = FRAME_STYLES return template_params - def render_image(self, dimensions, html_file, css_file=None, template_params={}): + def render_image(self, dimensions, html_file, css_file=None, template_params=None): + if template_params is None: + template_params = {} # load the base plugin and current plugin css files css_files = [os.path.join(BASE_PLUGIN_RENDER_DIR, "plugin.css")] if css_file: diff --git a/src/plugins/calendar/calendar.py b/src/plugins/calendar/calendar.py index 67d5b008c..c009c85ac 100644 --- a/src/plugins/calendar/calendar.py +++ b/src/plugins/calendar/calendar.py @@ -74,7 +74,11 @@ def fetch_ics_events(self, calendar_urls, colors, tz, start_range, end_range): parsed_events = [] for calendar_url, color in zip(calendar_urls, colors): - cal = self.fetch_calendar(calendar_url) + try: + cal = self.fetch_calendar(calendar_url) + except RuntimeError as e: + logger.warning(f"Skipping calendar due to fetch error: {e}") + continue events = recurring_ical_events.of(cal).between(start_range, end_range) contrast_color = self.get_contrast_color(color) diff --git a/src/plugins/weather/weather.py b/src/plugins/weather/weather.py index e5d829e83..6804b2f52 100644 --- a/src/plugins/weather/weather.py +++ b/src/plugins/weather/weather.py @@ -340,16 +340,17 @@ def parse_open_meteo_forecast(self, daily_data, units, tz, is_day, lat): forecast = [] - for i in range(0, len(times)): - dt = datetime.fromisoformat(times[i]).replace(tzinfo=timezone.utc).astimezone(tz) - day_label = dt.strftime("%a") + for i in range(0, len(times)): + forecast_date = date.fromisoformat(times[i]) + day_label = forecast_date.strftime("%a") code = weather_codes[i] if i < len(weather_codes) else 0 weather_icon = self.map_weather_code_to_icon(code, is_day=1) weather_icon_path = self.get_plugin_dir(f"icons/{weather_icon}.png") - timestamp = int(dt.replace(hour=12, minute=0, second=0).timestamp()) - target_date: date = dt.date() + timedelta(days=1) + dt = datetime(forecast_date.year, forecast_date.month, forecast_date.day, 12, 0, 0, tzinfo=tz) + timestamp = int(dt.timestamp()) + target_date: date = forecast_date try: phase_age = moon.phase(target_date) diff --git a/src/refresh_task.py b/src/refresh_task.py index f554e2adb..ab689dd2b 100644 --- a/src/refresh_task.py +++ b/src/refresh_task.py @@ -143,7 +143,8 @@ def manual_update(self, refresh_action): self.condition.notify_all() # Wake the thread to process manual update - self.refresh_event.wait() + if not self.refresh_event.wait(timeout=300): + raise TimeoutError("Manual update timed out after 300 seconds") if self.refresh_result.get("exception"): raise self.refresh_result.get("exception") else: diff --git a/src/utils/app_utils.py b/src/utils/app_utils.py index 24a7ce6cd..36fd7e8c1 100644 --- a/src/utils/app_utils.py +++ b/src/utils/app_utils.py @@ -142,7 +142,9 @@ def parse_form(request_form): request_dict[key] = request_form.getlist(key) return request_dict -def handle_request_files(request_files, form_data={}): +def handle_request_files(request_files, form_data=None): + if form_data is None: + form_data = {} allowed_file_extensions = {'pdf', 'png', 'avif', 'jpg', 'jpeg', 'gif', 'webp', 'heif', 'heic'} file_location_map = {} # handle existing file locations being provided as part of the form data diff --git a/src/utils/image_utils.py b/src/utils/image_utils.py index 383ba1646..9870f2b46 100644 --- a/src/utils/image_utils.py +++ b/src/utils/image_utils.py @@ -20,21 +20,26 @@ def get_image(image_url): return img def change_orientation(image, orientation, inverted=False): - if orientation == 'horizontal': - angle = 0 - elif orientation == 'vertical': + if orientation == 'vertical': angle = 90 + else: + angle = 0 if inverted: angle = (angle + 180) % 360 return image.rotate(angle, expand=1) -def resize_image(image, desired_size, image_settings=[]): +def resize_image(image, desired_size, image_settings=None): + if image_settings is None: + image_settings = [] img_width, img_height = image.size desired_width, desired_height = desired_size desired_width, desired_height = int(desired_width), int(desired_height) + if img_height == 0 or desired_height == 0: + raise ValueError("Image dimensions must be non-zero") + img_ratio = img_width / img_height desired_ratio = desired_width / desired_height @@ -61,7 +66,9 @@ def resize_image(image, desired_size, image_settings=[]): # Step 3: Resize to the exact desired dimensions (if necessary) return image.resize((desired_width, desired_height), Image.LANCZOS) -def apply_image_enhancement(img, image_settings={}): +def apply_image_enhancement(img, image_settings=None): + if image_settings is None: + image_settings = {} # Convert image to RGB mode if necessary for enhancement operations # ImageEnhance requires RGB mode for operations like blend if img.mode not in ('RGB', 'L'):