Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 2 additions & 0 deletions src/blueprints/apikeys.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}"'
Expand Down
6 changes: 4 additions & 2 deletions src/blueprints/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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)}")
Expand Down
6 changes: 3 additions & 3 deletions src/blueprints/settings.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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')
Expand Down
6 changes: 3 additions & 3 deletions src/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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):
Expand Down
2 changes: 1 addition & 1 deletion src/display/abstract_display.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 4 additions & 1 deletion src/display/display_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.")

Expand Down
2 changes: 1 addition & 1 deletion src/display/inky_display.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion src/display/mock_display.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
2 changes: 1 addition & 1 deletion src/display/waveshare_display.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 2 additions & 7 deletions src/inkypi.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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)
Expand Down
12 changes: 2 additions & 10 deletions src/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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()
Expand Down
4 changes: 3 additions & 1 deletion src/plugins/base_plugin/base_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 5 additions & 1 deletion src/plugins/calendar/calendar.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
11 changes: 6 additions & 5 deletions src/plugins/weather/weather.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion src/refresh_task.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 3 additions & 1 deletion src/utils/app_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 12 additions & 5 deletions src/utils/image_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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'):
Expand Down