From 17238cdfddc3db950d2c22aca511f4b70a36860f Mon Sep 17 00:00:00 2001 From: Nuno Duarte Date: Mon, 22 Jun 2026 23:09:09 +0100 Subject: [PATCH 1/7] Initial widgets implementation --- src/blueprints/plugin.py | 5 + src/blueprints/widget.py | 168 ++++++++++ src/config.py | 26 ++ src/inkypi.py | 5 + src/refresh_task.py | 12 +- src/static/icons/widgets.svg | 5 + src/templates/inky.html | 3 + src/templates/widget_settings.html | 84 +++++ src/templates/widgets.html | 322 +++++++++++++++++++ src/utils/image_utils.py | 22 +- src/utils/widget_utils.py | 103 ++++++ src/widgets/__init__.py | 0 src/widgets/base_widget/base_widget.py | 25 ++ src/widgets/base_widget/settings.html | 0 src/widgets/date_widget/date_widget.py | 58 ++++ src/widgets/date_widget/settings.html | 36 +++ src/widgets/date_widget/widget-info.json | 7 + src/widgets/static_message/settings.html | 6 + src/widgets/static_message/static_message.py | 41 +++ src/widgets/static_message/widget-info.json | 7 + src/widgets/widget_registry.py | 49 +++ 21 files changed, 981 insertions(+), 3 deletions(-) create mode 100644 src/blueprints/widget.py create mode 100644 src/static/icons/widgets.svg create mode 100644 src/templates/widget_settings.html create mode 100644 src/templates/widgets.html create mode 100644 src/utils/widget_utils.py create mode 100644 src/widgets/__init__.py create mode 100644 src/widgets/base_widget/base_widget.py create mode 100644 src/widgets/base_widget/settings.html create mode 100644 src/widgets/date_widget/date_widget.py create mode 100644 src/widgets/date_widget/settings.html create mode 100644 src/widgets/date_widget/widget-info.json create mode 100644 src/widgets/static_message/settings.html create mode 100644 src/widgets/static_message/static_message.py create mode 100644 src/widgets/static_message/widget-info.json create mode 100644 src/widgets/widget_registry.py diff --git a/src/blueprints/plugin.py b/src/blueprints/plugin.py index b7a80d860..6b0ff2f84 100644 --- a/src/blueprints/plugin.py +++ b/src/blueprints/plugin.py @@ -1,6 +1,7 @@ from flask import Blueprint, request, jsonify, current_app, render_template, send_from_directory from plugins.plugin_registry import get_plugin_instance from utils.app_utils import resolve_path, handle_request_files, parse_form +from utils.widget_utils import generate_and_apply_widgets from refresh_task import ManualRefresh, PlaylistRefresh import json import os @@ -249,6 +250,10 @@ def update_now(): plugin = get_plugin_instance(plugin_config) image = plugin.generate_image(plugin_settings, device_config) + + # Apply widgets + image = generate_and_apply_widgets(image, device_config) + display_manager.display_image(image, image_settings=plugin_config.get("image_settings", [])) except Exception as e: diff --git a/src/blueprints/widget.py b/src/blueprints/widget.py new file mode 100644 index 000000000..cebad6f57 --- /dev/null +++ b/src/blueprints/widget.py @@ -0,0 +1,168 @@ +from flask import Blueprint, request, jsonify, current_app, render_template +from utils.app_utils import parse_form, handle_request_files +from widgets.widget_registry import get_widget_instance +import logging + +logger = logging.getLogger(__name__) +widget_bp = Blueprint("widget", __name__) + +@widget_bp.route('/widgets') +def widgets_page(): + """Display widget management page.""" + device_config = current_app.config['DEVICE_CONFIG'] + all_widgets = device_config.get_widgets() + + enabled_widget_ids = device_config.get_config('widget_settings', {}).get('enabled_widgets', []) + enabled_widgets = [o for o in all_widgets if o['id'] in enabled_widget_ids] + # Maintain order from enabled_widgets list + enabled_widgets.sort(key=lambda x: enabled_widget_ids.index(x['id'])) + available_widgets = [o for o in all_widgets if o['id'] not in enabled_widget_ids] + + widget_settings = device_config.get_config('widget_settings', {}) + + return render_template( + 'widgets.html', + enabled_widgets=enabled_widgets, + available_widgets=available_widgets, + widget_settings=widget_settings + ) + +@widget_bp.route('/api/widgets/reorder', methods=['POST']) +def reorder_widgets(): + """Reorder enabled widgets.""" + device_config = current_app.config['DEVICE_CONFIG'] + try: + data = request.get_json() + new_order = data.get('order', []) + + # Validate that all IDs are valid widgets + widgets = device_config.get_widgets() + all_widget_ids = {w['id'] for w in widgets} + + if not all(w_id in all_widget_ids for w_id in new_order): + return jsonify({"error": "Invalid widget IDs"}), 400 + + widget_settings = device_config.get_config('widget_settings', {}) + widget_settings['enabled_widgets'] = new_order + device_config.update_value('widget_settings', widget_settings, write=True) + + logger.info(f"Widget order updated: {new_order}") + return jsonify({"success": True, "message": "Widget order updated"}), 200 + except Exception as e: + logger.exception(f"Error reordering widgets: {str(e)}") + return jsonify({"error": f"An error occurred: {str(e)}"}), 500 + +@widget_bp.route('/api/widgets/toggle', methods=['POST']) +def toggle_widget(): + """Enable or disable a widget.""" + device_config = current_app.config['DEVICE_CONFIG'] + try: + data = request.get_json() + widget_id = data.get('widget_id') + enable = data.get('enable', True) + + # Validate widget exists + widget_config = device_config.get_widget(widget_id) + if not widget_config: + return jsonify({"error": f"Widget '{widget_id}' not found"}), 404 + + widget_settings = device_config.get_config('widget_settings', {}) + enabled_widgets = widget_settings.get('enabled_widgets', []) + + if enable and widget_id not in enabled_widgets: + enabled_widgets.append(widget_id) + elif not enable and widget_id in enabled_widgets: + enabled_widgets.remove(widget_id) + + widget_settings['enabled_widgets'] = enabled_widgets + device_config.update_value('widget_settings', widget_settings, write=True) + + logger.info(f"Widget '{widget_id}' {'enabled' if enable else 'disabled'}") + return jsonify({"success": True, "message": f"Widget {'enabled' if enable else 'disabled'}"}), 200 + except Exception as e: + logger.exception(f"Error toggling widget: {str(e)}") + return jsonify({"error": f"An error occurred: {str(e)}"}), 500 + +@widget_bp.route('/api/widgets/settings', methods=['POST']) +def save_widget_settings(): + """Save widget positioning and spacing settings.""" + device_config = current_app.config['DEVICE_CONFIG'] + try: + data = request.get_json() + + widget_settings = device_config.get_config('widget_settings', {}) + widget_settings['corner'] = data.get('corner', widget_settings.get('corner', 'top-left')) + widget_settings['orientation'] = data.get('orientation', widget_settings.get('orientation', 'horizontal')) + widget_settings['spacing'] = int(data.get('spacing', widget_settings.get('spacing', 10))) + widget_settings['margin'] = int(data.get('margin', widget_settings.get('margin', 10))) + + device_config.update_value('widget_settings', widget_settings, write=True) + + logger.info(f"Widget settings updated: corner={widget_settings['corner']}, orientation={widget_settings['orientation']}, spacing={widget_settings['spacing']}, margin={widget_settings['margin']}") + return jsonify({"success": True, "message": "Widget settings saved"}), 200 + except Exception as e: + logger.exception(f"Error saving widget settings: {str(e)}") + return jsonify({"error": f"An error occurred: {str(e)}"}), 500 + +@widget_bp.route('/api/widgets//settings', methods=['POST']) +def save_widget_config(widget_id): + """Save configuration for a specific widget.""" + device_config = current_app.config['DEVICE_CONFIG'] + try: + # Get widget settings structure + widget_settings = device_config.get_config('widget_settings', {}) + if 'widgets' not in widget_settings: + widget_settings['widgets'] = {} + + plugin_settings = parse_form(request.form) + plugin_settings.update(handle_request_files(request.files, request.form)) + + # Remove plugin_id if it crept in + plugin_settings.pop('plugin_id', None) + plugin_settings.pop('widget_id', None) + + # Check contrast color value + use_contrast_color = ( + plugin_settings.pop('use_contrast_color', 'false') == 'true' + ) + + # Save + widget_settings['widgets'][widget_id] = plugin_settings + widget_settings['widgets'][widget_id]['use_contrast_color'] = use_contrast_color + device_config.update_value('widget_settings', widget_settings, write=True) + + logger.info(f"Saved settings for widget '{widget_id}'") + return jsonify({"success": True, "message": "Widget settings saved"}), 200 + except Exception as e: + logger.exception(f"Error saving widget settings: {str(e)}") + return jsonify({"error": f"An error occurred: {str(e)}"}), 500 + +@widget_bp.route('/widgets/') +def widget_settings_page(widget_id): + device_config = current_app.config['DEVICE_CONFIG'] + + # Find the widget by id + widget_config = device_config.get_widget(widget_id) + if widget_config: + try: + widget = get_widget_instance(widget_config) + + template_params = widget.generate_settings_template() + template_params.setdefault("plugin_settings", {}) + + # Load settings from widget config + widget_settings = device_config.get_config('widget_settings', {}) + specific_widget_settings = widget_settings.get('widgets', {}).get(widget_id) + + # Update template_params with specific_widget_settings to ensure values like use_contrast_color are correct + if specific_widget_settings is not None: + template_params.update(specific_widget_settings) + else: + specific_widget_settings = {} + + return render_template('widget_settings.html', widget=widget_config, widget_settings=specific_widget_settings, **template_params) + except Exception as e: + logger.exception("EXCEPTION CAUGHT: " + str(e)) + return jsonify({"error": f"An error occurred: {str(e)}"}), 500 + else: + return "Widget not found", 404 diff --git a/src/config.py b/src/config.py index 4761f4592..cf1b331e3 100644 --- a/src/config.py +++ b/src/config.py @@ -22,6 +22,7 @@ class Config: def __init__(self): self.config = self.read_config() self.plugins_list = self.read_plugins_list() + self.widgets_list = self.read_widgets_list() self.playlist_manager = self.load_playlist_manager() self.refresh_info = self.load_refresh_info() @@ -52,6 +53,23 @@ def read_plugins_list(self): return plugins_list + def read_widgets_list(self): + """Reads the widget-info.json config JSON from each widget folder.""" + # Iterate over all widget folders + widgets_list = [] + for widget in sorted(os.listdir(os.path.join(self.BASE_DIR, "widgets"))): + widget_path = os.path.join(self.BASE_DIR, "widgets", widget) + if os.path.isdir(widget_path) and widget != "__pycache__": + # Check if the widget-info.json file exists + widget_info_file = os.path.join(widget_path, "widget-info.json") + if os.path.isfile(widget_info_file): + logger.debug(f"Reading widget info from {widget_info_file}") + with open(widget_info_file) as f: + widget_info = json.load(f) + widgets_list.append(widget_info) + + return widgets_list + def write_config(self): """Updates the cached config from the model objects and writes to the config file.""" logger.debug(f"Writing device config to {self.config_file}") @@ -95,6 +113,14 @@ def get_plugin(self, plugin_id): """Finds and returns a plugin config by its ID.""" return next((plugin for plugin in self.plugins_list if plugin['id'] == plugin_id), None) + def get_widgets(self): + """Returns the list of widget configurations.""" + return self.widgets_list + + def get_widget(self, widget_id): + """Finds and returns a widget config by its ID.""" + return next((widget for widget in self.widgets_list if widget['id'] == widget_id), None) + def get_resolution(self): """Returns the display resolution as a tuple (width, height) from the configuration.""" resolution = self.get_config("resolution") diff --git a/src/inkypi.py b/src/inkypi.py index 5dc4de57b..814898e1b 100755 --- a/src/inkypi.py +++ b/src/inkypi.py @@ -28,10 +28,12 @@ from blueprints.main import main_bp from blueprints.settings import settings_bp from blueprints.plugin import plugin_bp +from blueprints.widget import widget_bp from blueprints.playlist import playlist_bp from blueprints.apikeys import apikeys_bp from jinja2 import ChoiceLoader, FileSystemLoader from plugins.plugin_registry import load_plugins +from widgets.widget_registry import load_widgets from waitress import serve @@ -57,6 +59,7 @@ template_dirs = [ os.path.join(os.path.dirname(__file__), "templates"), # Default template folder os.path.join(os.path.dirname(__file__), "plugins"), # Plugin templates + os.path.join(os.path.dirname(__file__), "widgets"), # Widget templates ] app.jinja_loader = ChoiceLoader([FileSystemLoader(directory) for directory in template_dirs]) @@ -65,6 +68,7 @@ refresh_task = RefreshTask(device_config, display_manager) load_plugins(device_config.get_plugins()) +load_widgets(device_config.get_widgets()) # Store dependencies app.config['DEVICE_CONFIG'] = device_config @@ -78,6 +82,7 @@ app.register_blueprint(main_bp) app.register_blueprint(settings_bp) app.register_blueprint(plugin_bp) +app.register_blueprint(widget_bp) app.register_blueprint(playlist_bp) app.register_blueprint(apikeys_bp) diff --git a/src/refresh_task.py b/src/refresh_task.py index f554e2adb..0e8a6b1e2 100644 --- a/src/refresh_task.py +++ b/src/refresh_task.py @@ -7,9 +7,11 @@ from datetime import datetime, timezone from plugins.plugin_registry import get_plugin_instance from utils.image_utils import compute_image_hash +from utils.widget_utils import generate_and_apply_widgets from model import RefreshInfo, PlaylistManager from PIL import Image + logger = logging.getLogger(__name__) class RefreshTask: @@ -231,7 +233,9 @@ def __init__(self, plugin_id: str, plugin_settings: dict): def execute(self, plugin, device_config, current_dt: datetime): """Performs a manual refresh using the stored plugin ID and settings.""" - return plugin.generate_image(self.plugin_settings, device_config) + image = plugin.generate_image(self.plugin_settings, device_config) + image = generate_and_apply_widgets(image, device_config) + return image def get_refresh_info(self): """Return refresh metadata as a dictionary.""" @@ -277,6 +281,8 @@ def execute(self, plugin, device_config, current_dt: datetime): logger.info(f"Refreshing plugin instance. | plugin_instance: '{self.plugin_instance.name}'") # Generate a new image image = plugin.generate_image(self.plugin_instance.settings, device_config) + # Apply widgets + image = generate_and_apply_widgets(image, device_config) image.save(plugin_image_path) self.plugin_instance.latest_refresh_time = current_dt.isoformat() else: @@ -284,5 +290,7 @@ def execute(self, plugin, device_config, current_dt: datetime): # Load the existing image from disk with Image.open(plugin_image_path) as img: image = img.copy() + # Apply widgets to cached image + image = generate_and_apply_widgets(image, device_config) - return image \ No newline at end of file + return image diff --git a/src/static/icons/widgets.svg b/src/static/icons/widgets.svg new file mode 100644 index 000000000..8ef5a8da2 --- /dev/null +++ b/src/static/icons/widgets.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/templates/inky.html b/src/templates/inky.html index 97b85a697..0a319694e 100644 --- a/src/templates/inky.html +++ b/src/templates/inky.html @@ -68,6 +68,9 @@

{{ config.name }}

playlists icon + + widgets icon + settings icon diff --git a/src/templates/widget_settings.html b/src/templates/widget_settings.html new file mode 100644 index 000000000..3b207d768 --- /dev/null +++ b/src/templates/widget_settings.html @@ -0,0 +1,84 @@ + + + + + + {{ widget.name }} Settings + + + + + + + + + +
+ + +
+
+
+

{{ widget.name }}

+
+
+
+
+ +
+
+
+ +
+ + +
+
+ + {% with plugin_settings=widget_settings %} + {% include settings_template %} + {% endwith %} +
+ +
+ +
+
+
+ + {% include 'response_modal.html' %} + + \ No newline at end of file diff --git a/src/templates/widgets.html b/src/templates/widgets.html new file mode 100644 index 000000000..8da613823 --- /dev/null +++ b/src/templates/widgets.html @@ -0,0 +1,322 @@ + + + + + + Widgets + + + + + + + + + +
+ + + +
+
+
+ playlist icon +

Widgets

+
+
+
+
+ + +
+ +

Enabled Widgets

+ +
    + {% for widget in enabled_widgets %} +
  • + {{ widget.name }} +
    + Settings + +
    +
  • + {% endfor %} + {% if not enabled_widgets %} +
  • + No active widgets +
  • + {% endif %} +
+ +
+

Available Widgets

+ + {% if not available_widgets %} +
    +
  • + All widgets are enabled. +
  • +
+ {% else %} +
    + {% for widget in available_widgets %} +
  • + {{ widget.name }} + +
  • + {% endfor %} +
+ {% endif %} + +
+

Global Settings

+ + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ +
+ +
+
+ + {% include 'response_modal.html' %} + + diff --git a/src/utils/image_utils.py b/src/utils/image_utils.py index 383ba1646..fd7148fe2 100644 --- a/src/utils/image_utils.py +++ b/src/utils/image_utils.py @@ -1,5 +1,5 @@ import requests -from PIL import Image, ImageEnhance, ImageOps, ImageFilter +from PIL import Image, ImageEnhance, ImageOps, ImageFilter, ImageStat from io import BytesIO import os import logging @@ -180,3 +180,23 @@ def pad_image_blur(img: Image, dimensions: tuple[int, int]) -> Image: img_size = img.size bkg.paste(img, ((dimensions[0] - img_size[0]) // 2, (dimensions[1] - img_size[1]) // 2)) return bkg + +def calculate_contrast_color(image: Image.Image, box: tuple[int, int, int, int]) -> str: + """ + Calculates the average brightness of the specified region in the image + and returns 'black' or 'white' to ensure good contrast. + """ + try: + # Crop the region + region = image.crop(box) + # Convert to grayscale + grayscale = region.convert("L") + # Calculate average brightness + stat = ImageStat.Stat(grayscale) + avg_brightness = stat.mean[0] + + # If brightness > 128 (light), return black, else white + return "#000000" if avg_brightness > 128 else 'white' + except Exception as e: + logger.warning(f"Error calculating contrast: {e}") + return "#FFFFFF" # Default diff --git a/src/utils/widget_utils.py b/src/utils/widget_utils.py new file mode 100644 index 000000000..af975ce90 --- /dev/null +++ b/src/utils/widget_utils.py @@ -0,0 +1,103 @@ +import logging +from widgets.widget_registry import get_widget_instance +from utils.image_utils import calculate_contrast_color + +logger = logging.getLogger(__name__) + +def generate_and_apply_widgets(main_image, device_config): + """ + Generates enabled widgets and applies them to the main image, + calculating contrast against the background. + """ + widget_settings = device_config.get_config('widget_settings', {}) + enabled_widgets = widget_settings.get('enabled_widgets', []) + + corner = widget_settings.get('corner', 'top-left') + orientation = widget_settings.get('orientation', 'horizontal') + spacing = widget_settings.get('spacing', 10) + margin = widget_settings.get('margin', 10) + + width, height = main_image.size + + # Calculate start position + x_start, y_start = { + 'top-left': (0+margin, 0+margin), + 'top-right': (width-margin, 0+margin), + 'bottom-left': (0+margin, height-margin), + 'bottom-right': (width-margin, height-margin) + }[corner] + + current_x, current_y = x_start, y_start + + for widget_id in enabled_widgets: + widget_config = device_config.get_widget(widget_id) + if not widget_config: + logger.warning(f"Widget config not found for enabled widget {widget_id}") + continue + + # Get stored settings for this widget + specific_widget_settings = widget_settings.get('widgets', {}).get(widget_id, {}) + + # Determine check box for contrast + check_size = 100 + box = (0, 0, check_size, check_size) # Default + + if corner == 'top-left': + box = (current_x, current_y, current_x + check_size, current_y + check_size) + elif corner == 'top-right': + box = (current_x - check_size, current_y, current_x, current_y + check_size) + elif corner == 'bottom-left': + box = (current_x, current_y - check_size, current_x + check_size, current_y) + elif corner == 'bottom-right': + box = (current_x - check_size, current_y - check_size, current_x, current_y) + + # Clamp to image bounds + box = ( + max(0, int(box[0])), max(0, int(box[1])), + min(width, int(box[2])), min(height, int(box[3])) + ) + + # Calculate contrast + contrast_color = calculate_contrast_color(main_image, box) + specific_widget_settings['contrast_color'] = contrast_color + + # Generate widget + try: + widget_instance = get_widget_instance(widget_config) + widget_img = widget_instance.generate_image(specific_widget_settings, device_config) + + ov_w, ov_h = widget_img.size + paste_x, paste_y = current_x, current_y + + if orientation == 'horizontal': + if corner in ['top-right', 'bottom-right']: + paste_x = current_x - ov_w + + if corner in ['bottom-left', 'bottom-right']: + paste_y = current_y - ov_h + + main_image.paste(widget_img, (int(paste_x), int(paste_y)), widget_img) + + if corner in ['top-left', 'bottom-left']: + current_x += ov_w + spacing + else: + current_x -= (ov_w + spacing) + + else: # Vertical + if corner in ['bottom-left', 'bottom-right']: + paste_y = current_y - ov_h + + if corner in ['top-right', 'bottom-right']: + paste_x = current_x - ov_w + + main_image.paste(widget_img, (int(paste_x), int(paste_y)), widget_img) + + if corner in ['top-left', 'top-right']: + current_y += ov_h + spacing + else: + current_y -= (ov_h + spacing) + + except Exception as e: + logger.error(f"Error generating/applying widget {widget_id}: {e}") + + return main_image diff --git a/src/widgets/__init__.py b/src/widgets/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/widgets/base_widget/base_widget.py b/src/widgets/base_widget/base_widget.py new file mode 100644 index 000000000..18c5d70a2 --- /dev/null +++ b/src/widgets/base_widget/base_widget.py @@ -0,0 +1,25 @@ +import os +from pathlib import Path + +from plugins.base_plugin.base_plugin import BasePlugin +from utils.app_utils import resolve_path, get_fonts + +WIDGETS_DIR = resolve_path("widgets") + +class BaseWidget(BasePlugin): + """Base class for all widgets.""" + def get_plugin_dir(self, path=None): + plugin_dir = os.path.join(WIDGETS_DIR, self.get_plugin_id()) + if path: + plugin_dir = os.path.join(plugin_dir, path) + return plugin_dir + + def generate_settings_template(self): + template_params = {"settings_template": "base_widget/settings.html"} + + settings_path = self.get_plugin_dir("settings.html") + if Path(settings_path).is_file(): + template_params["settings_template"] = f"{self.get_plugin_id()}/settings.html" + + template_params['use_contrast_color'] = True + return template_params \ No newline at end of file diff --git a/src/widgets/base_widget/settings.html b/src/widgets/base_widget/settings.html new file mode 100644 index 000000000..e69de29bb diff --git a/src/widgets/date_widget/date_widget.py b/src/widgets/date_widget/date_widget.py new file mode 100644 index 000000000..4bb688c36 --- /dev/null +++ b/src/widgets/date_widget/date_widget.py @@ -0,0 +1,58 @@ +from widgets.base_widget.base_widget import BaseWidget +from utils.app_utils import get_font +from PIL import Image, ImageDraw, ImageFont +from datetime import datetime +import pytz +import logging + +class DateWidget(BaseWidget): + def generate_settings_template(self): + template_params = super().generate_settings_template() + template_params['date_DMY'] = datetime.now().strftime('%d-%m-%Y') + template_params['date_YMD'] = datetime.now().strftime('%Y-%m-%d') + template_params['date_MDY'] = datetime.now().strftime('%m-%d-%Y') + return template_params + + def generate_image(self, settings, device_config): + # Generate a small overlay image (e.g., 150x80) + overlay = Image.new('RGBA', (150, 80), (0, 0, 0, 0)) # Transparent + draw = ImageDraw.Draw(overlay) + + font_size = int(settings.get('font_size', 18)) + font = get_font("Jost", font_size) or ImageFont.load_default() + + tz = pytz.timezone(device_config.get_config('timezone', 'UTC')) + now = datetime.now(tz) + + date_format = settings.get('date_format', 'YMD') + format_map = { + 'DMY': '%d-%m-%Y', + 'YMD': '%Y-%m-%d', + 'MDY': '%m-%d-%Y' + } + strftime_format = format_map.get(date_format, '%Y-%m-%d') + date_str = now.strftime(strftime_format) + + use_contrast_color = settings.get('use_contrast_color', False) + if use_contrast_color: + text_color = settings.get('contrast_color', "#FFFFFF") + else: + text_color = settings.get('text_color', "#FFFFFF") + + # Get text size for centering and calculating required space + bbox = draw.textbbox((0, 0), date_str, font=font) + text_width = bbox[2] - bbox[0] + text_height = bbox[3] - bbox[1] + + # Expand overlay if text is larger than default 150x80 + if text_width > 150 or text_height > 80: + overlay = Image.new('RGBA', (max(150, text_width), max(80, text_height)), (0, 0, 0, 0)) + draw = ImageDraw.Draw(overlay) + + logging.log(logging.INFO, f"text_width = [{text_width}] :: text_height = [{text_height}]") + + draw.text((0, 0), date_str, fill=text_color, font=font) + + # Crop to actual text size + bbox = draw.textbbox((0, 0), date_str, font=font) + return overlay.crop(bbox) diff --git a/src/widgets/date_widget/settings.html b/src/widgets/date_widget/settings.html new file mode 100644 index 000000000..61b9cec03 --- /dev/null +++ b/src/widgets/date_widget/settings.html @@ -0,0 +1,36 @@ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + + diff --git a/src/widgets/date_widget/widget-info.json b/src/widgets/date_widget/widget-info.json new file mode 100644 index 000000000..5233bca24 --- /dev/null +++ b/src/widgets/date_widget/widget-info.json @@ -0,0 +1,7 @@ +{ + "id": "date_widget", + "name": "Date Widget", + "description": "Displays current date", + "type": "widget", + "class": "DateWidget" +} \ No newline at end of file diff --git a/src/widgets/static_message/settings.html b/src/widgets/static_message/settings.html new file mode 100644 index 000000000..b54aeb184 --- /dev/null +++ b/src/widgets/static_message/settings.html @@ -0,0 +1,6 @@ +
+
+ + +
+
\ No newline at end of file diff --git a/src/widgets/static_message/static_message.py b/src/widgets/static_message/static_message.py new file mode 100644 index 000000000..4d4623815 --- /dev/null +++ b/src/widgets/static_message/static_message.py @@ -0,0 +1,41 @@ +from widgets.base_widget.base_widget import BaseWidget +from utils.app_utils import get_font +from PIL import Image, ImageDraw, ImageFont +import logging + +class StaticMessage(BaseWidget): + def generate_settings_template(self): + template_params = super().generate_settings_template() + return template_params + + def generate_image(self, settings, device_config): + # Generate a small overlay image (e.g., 150x80) + overlay = Image.new('RGBA', (150, 80), (0, 0, 0, 0)) # Transparent + draw = ImageDraw.Draw(overlay) + + font_size = int(settings.get('font_size', 18)) + font = get_font("Jost", font_size) or ImageFont.load_default() + + # Use text color from settings, default to black + use_contrast_color = settings.get('use_contrast_color', False) + if use_contrast_color: + text_color = settings.get('contrast_color', "#FFFFFF") + else: + text_color = settings.get('text_color', "#FFFFFF") + static_message = settings.get('static_message', "Hello Widget") + + # Get text size for centering and calculating required space + bbox = draw.textbbox((0, 0), static_message, font=font) + text_width = bbox[2] - bbox[0] + text_height = bbox[3] - bbox[1] + + # Expand overlay if text is larger than default 150x80 + if text_width > 150 or text_height > 80: + overlay = Image.new('RGBA', (max(150, text_width), max(80, text_height)), (0, 0, 0, 0)) + draw = ImageDraw.Draw(overlay) + + draw.text((0, 0), static_message, fill=text_color, font=font) + + # Crop to actual text size + bbox = draw.textbbox((0, 0), static_message, font=font) + return overlay.crop(bbox) diff --git a/src/widgets/static_message/widget-info.json b/src/widgets/static_message/widget-info.json new file mode 100644 index 000000000..4cc4dce2a --- /dev/null +++ b/src/widgets/static_message/widget-info.json @@ -0,0 +1,7 @@ +{ + "id": "static_message", + "name": "Static Message Widget", + "description": "Displays a static message", + "type": "widget", + "class": "StaticMessage" +} \ No newline at end of file diff --git a/src/widgets/widget_registry.py b/src/widgets/widget_registry.py new file mode 100644 index 000000000..8c1c6d551 --- /dev/null +++ b/src/widgets/widget_registry.py @@ -0,0 +1,49 @@ +import importlib +import logging +from utils.app_utils import resolve_path +from pathlib import Path + +logger = logging.getLogger(__name__) +WIDGETS_DIR = 'widgets' +WIDGET_CLASSES = {} + +def load_widgets(widgets_config): + widgets_module_path = Path(resolve_path(WIDGETS_DIR)) + for widget in widgets_config: + widget_id = widget.get('id') + if widget.get("disabled", False): + logging.info(f"Widget {widget_id} is disabled, skipping.") + continue + + widget_dir = widgets_module_path / widget_id + if not widget_dir.is_dir(): + logging.error(f"Could not find widget directory {widget_dir} for '{widget_id}', skipping.") + continue + + module_path = widget_dir / f"{widget_id}.py" + if not module_path.is_file(): + logging.error(f"Could not find module path {module_path} for '{widget_id}', skipping.") + continue + + module_name = f"widgets.{widget_id}.{widget_id}" + try: + module = importlib.import_module(module_name) + widget_class = getattr(module, widget.get("class"), None) + + if widget_class: + # Create an instance of the widget class and add it to the widget_classes dictionary + WIDGET_CLASSES[widget_id] = widget_class(widget) + + except ImportError as e: + logging.error(f"Failed to import widget module {module_name}: {e}") + +def get_widget_instance(widget_config): + widget_id = widget_config.get("id") + # Retrieve the widget class factory function + widget_data = WIDGET_CLASSES.get(widget_id) + + if widget_data: + # Initialize the widget with its configuration + return widget_data + else: + raise ValueError(f"Widget '{widget_id}' is not registered.") From 92b569554e62d5958e4d280181c2bd2d4c7fb0df Mon Sep 17 00:00:00 2001 From: Nuno Duarte Date: Wed, 29 Jul 2026 22:55:58 +0100 Subject: [PATCH 2/7] Add IP address widget and improve widget system robustness --- src/blueprints/widget.py | 64 ++++- src/static/icons/widgets.svg | 5 +- src/templates/widgets.html | 17 +- src/utils/image_utils.py | 4 +- src/utils/widget_utils.py | 50 ++-- src/widgets/base_widget/base_widget.py | 2 +- src/widgets/date_widget/widget-info.json | 2 +- src/widgets/ip_address/ip_address.py | 58 ++++ src/widgets/ip_address/widget-info.json | 7 + src/widgets/static_message/widget-info.json | 2 +- src/widgets/widget_registry.py | 8 +- tests/test_widgets.py | 298 ++++++++++++++++++++ 12 files changed, 466 insertions(+), 51 deletions(-) create mode 100644 src/widgets/ip_address/ip_address.py create mode 100644 src/widgets/ip_address/widget-info.json create mode 100644 tests/test_widgets.py diff --git a/src/blueprints/widget.py b/src/blueprints/widget.py index cebad6f57..3f11b897b 100644 --- a/src/blueprints/widget.py +++ b/src/blueprints/widget.py @@ -1,7 +1,8 @@ -from flask import Blueprint, request, jsonify, current_app, render_template -from utils.app_utils import parse_form, handle_request_files +from flask import Blueprint, request, jsonify, current_app, render_template, send_from_directory +from utils.app_utils import parse_form, handle_request_files, resolve_path from widgets.widget_registry import get_widget_instance import logging +import os logger = logging.getLogger(__name__) widget_bp = Blueprint("widget", __name__) @@ -32,7 +33,10 @@ def reorder_widgets(): """Reorder enabled widgets.""" device_config = current_app.config['DEVICE_CONFIG'] try: - data = request.get_json() + data = request.get_json(silent=True) + if data is None: + return jsonify({"error": "Invalid JSON in request body"}), 400 + new_order = data.get('order', []) # Validate that all IDs are valid widgets @@ -88,13 +92,20 @@ def save_widget_settings(): """Save widget positioning and spacing settings.""" device_config = current_app.config['DEVICE_CONFIG'] try: - data = request.get_json() + data = request.get_json(silent=True) + if data is None: + return jsonify({"error": "Invalid JSON in request body"}), 400 widget_settings = device_config.get_config('widget_settings', {}) widget_settings['corner'] = data.get('corner', widget_settings.get('corner', 'top-left')) widget_settings['orientation'] = data.get('orientation', widget_settings.get('orientation', 'horizontal')) - widget_settings['spacing'] = int(data.get('spacing', widget_settings.get('spacing', 10))) - widget_settings['margin'] = int(data.get('margin', widget_settings.get('margin', 10))) + + # Validate and sanitize numeric fields + try: + widget_settings['spacing'] = int(data.get('spacing', widget_settings.get('spacing', 10))) + widget_settings['margin'] = int(data.get('margin', widget_settings.get('margin', 10))) + except (ValueError, TypeError): + return jsonify({"error": "Invalid numeric value for spacing or margin"}), 400 device_config.update_value('widget_settings', widget_settings, write=True) @@ -148,17 +159,13 @@ def widget_settings_page(widget_id): widget = get_widget_instance(widget_config) template_params = widget.generate_settings_template() - template_params.setdefault("plugin_settings", {}) # Load settings from widget config widget_settings = device_config.get_config('widget_settings', {}) - specific_widget_settings = widget_settings.get('widgets', {}).get(widget_id) - - # Update template_params with specific_widget_settings to ensure values like use_contrast_color are correct - if specific_widget_settings is not None: - template_params.update(specific_widget_settings) - else: - specific_widget_settings = {} + specific_widget_settings = widget_settings.get('widgets', {}).get(widget_id, {}) + + # Pass plugin_settings separately to avoid overwriting template metadata + template_params["plugin_settings"] = specific_widget_settings return render_template('widget_settings.html', widget=widget_config, widget_settings=specific_widget_settings, **template_params) except Exception as e: @@ -166,3 +173,32 @@ def widget_settings_page(widget_id): return jsonify({"error": f"An error occurred: {str(e)}"}), 500 else: return "Widget not found", 404 + +@widget_bp.route('/widget_assets//') +def widget_asset(widget_id, filename): + """Serve static assets for widgets (images, CSS, etc.).""" + # Resolve widgets directory dynamically + widgets_dir = resolve_path("widgets") + + # Construct the full path to the widget's file + widget_dir = os.path.join(widgets_dir, widget_id) + + # Security check to prevent directory traversal + safe_path = os.path.abspath(os.path.join(widget_dir, filename)) + if not safe_path.startswith(os.path.abspath(widgets_dir)): + return "Invalid path", 403 + + # Convert to absolute path for send_from_directory + abs_widget_dir = os.path.abspath(widget_dir) + + # Check if the directory and file exist + if not os.path.isdir(abs_widget_dir): + logger.error(f"Widget directory not found: {abs_widget_dir}") + return "Widget directory not found", 404 + + if not os.path.isfile(safe_path): + logger.error(f"File not found: {safe_path}") + return "File not found", 404 + + # Serve the file from the widget directory + return send_from_directory(abs_widget_dir, filename) diff --git a/src/static/icons/widgets.svg b/src/static/icons/widgets.svg index 8ef5a8da2..05367ccdc 100644 --- a/src/static/icons/widgets.svg +++ b/src/static/icons/widgets.svg @@ -1,5 +1,6 @@ - - + + + diff --git a/src/templates/widgets.html b/src/templates/widgets.html index 8da613823..ba7fcde71 100644 --- a/src/templates/widgets.html +++ b/src/templates/widgets.html @@ -18,6 +18,13 @@