diff --git a/docs/building_widgets.md b/docs/building_widgets.md new file mode 100644 index 000000000..92e2fc86e --- /dev/null +++ b/docs/building_widgets.md @@ -0,0 +1,181 @@ +# Building InkyPi Widgets + +This guide walks you through the process of creating a new widget for InkyPi. Widgets are small overlay elements that can be displayed on top of plugin-generated images, such as date stamps, status indicators, or custom messages. + +## What are Widgets? + +Widgets are lightweight overlay components that: +- Render on top of the active plugin image +- Can be positioned in any corner of the display +- Support automatic contrast color calculation for readability +- Can be enabled/disabled and reordered through the web UI + +## Creating a Widget + +### 1. Create a Directory for Your Widget + +- Navigate to the `src/widgets` directory. +- Create a new directory named after your widget. The directory name will be the `id` of your widget and should be all lowercase with no spaces. Example: + + ```bash + mkdir src/widgets/date_widget + ``` + +### 2. Create a Python File and Class for the Widget + +- Inside your new widget directory, create a Python file with the same name as the directory. +- Define a class in the file that inherits from `BaseWidget`. +- In your new class, implement the `generate_image` function: + - **Arguments:** + - `settings`: A dictionary of widget configuration values from the form inputs in the web UI. + - `device_config`: An instance of the Config class, used to retrieve device configurations such as display resolution or timezone. + - **Return:** A `PIL.Image` object in RGBA mode (with transparency) that will be overlaid on the main image. + - **Important:** Crop your image to the actual content size to ensure proper positioning. + - If there are any issues, raise a `RuntimeError` exception with a clear message. + +Example widget implementation: + +```python +from widgets.base_widget.base_widget import BaseWidget +from utils.app_utils import get_font +from PIL import Image, ImageDraw +from datetime import datetime +import pytz + +class DateWidget(BaseWidget): + def generate_image(self, settings, device_config): + # Create transparent overlay + overlay = Image.new('RGBA', (200, 80), (0, 0, 0, 0)) + draw = ImageDraw.Draw(overlay) + + # Get settings + font_size = int(settings.get('font_size', 18)) + font = get_font("Jost", font_size) + + # Get current date + tz = pytz.timezone(device_config.get_config('timezone', 'UTC')) + now = datetime.now(tz) + date_str = now.strftime('%Y-%m-%d') + + # Use contrast color if enabled + 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') + + # Draw text + 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) +``` + +### 3. Create a Settings Template (Optional) + +If your widget requires user configuration through the web UI, create a `settings.html` file in your widget directory: + +```html + +
+ + +
+ +
+ + +
+ + +``` + +### 4. Create a Widget Info File + +Create a `widget-info.json` file in your widget directory to register it with InkyPi: + +```json +{ + "id": "date_widget", + "display_name": "Date Widget", + "description": "Displays current date", + "class": "DateWidget", + "repository": "https://github.com/your-username/your-widget-repo" +} +``` + +- **id**: Must match your directory name +- **display_name**: Display name shown in the web UI (required for widgets) +- **description**: Brief description of what the widget does +- **class**: The name of your Python class +- **repository**: Git URL of the widget repository (leave empty for built-in widgets) + +### 5. Override Settings Template Generation (Optional) + +If your settings template requires additional variables, override the `generate_settings_template` function: + +```python +def generate_settings_template(self): + template_params = super().generate_settings_template() + template_params['date_formats'] = ['YYYY-MM-DD', 'DD-MM-YYYY', 'MM-DD-YYYY'] + return template_params +``` + +## Widget Features + +### Automatic Contrast Color + +Widgets support automatic contrast color calculation to ensure text is readable against any background: + +1. Enable the "Use Contrast Color" checkbox in the widget settings +2. The system will analyze the background where the widget will be placed +3. It automatically chooses black (#000000) or white (#FFFFFF) for optimal contrast +4. The contrast color is passed to your widget in `settings['contrast_color']` + +### Positioning and Layout + +Widgets can be positioned in any corner of the display: +- **Corners**: top-left, top-right, bottom-left, bottom-right +- **Orientation**: horizontal or vertical +- **Spacing**: Gap between multiple widgets (in pixels) +- **Margin**: Distance from the edge of the display (in pixels) + +These settings are configured globally in the Widgets page and apply to all enabled widgets. + +### Widget Ordering + +When multiple widgets are enabled, they are rendered in the order shown in the "Enabled Widgets" list. You can drag and drop to reorder them in the web UI. + +## Best Practices + +1. **Keep it Small**: Widgets should be compact overlays, not full-screen images +2. **Use Transparency**: Always use RGBA mode and transparent backgrounds +3. **Crop to Content**: Crop your image to the actual content size for proper positioning +4. **Test Contrast**: Test your widget with both light and dark backgrounds +5. **Performance**: Keep widget generation fast since they're applied on every refresh + +## Serving Static Assets + +If your widget needs to serve static files (images, CSS, etc.), place them in your widget directory and reference them using the widget asset route: + +```html + +``` diff --git a/install/cli/inkypi-plugin b/install/cli/inkypi-plugin index b7f926dd1..f3c7a83b1 100644 --- a/install/cli/inkypi-plugin +++ b/install/cli/inkypi-plugin @@ -10,7 +10,7 @@ usage() { echo "" echo "Plugin commands:" echo " inkypi plugin install " - echo " inkypi plugin remove " + echo " inkypi plugin uninstall " echo " inkypi plugin list" echo "" } diff --git a/install/cli/inkypi-widget b/install/cli/inkypi-widget new file mode 100755 index 000000000..a7631b438 --- /dev/null +++ b/install/cli/inkypi-widget @@ -0,0 +1,182 @@ +#!/usr/bin/env bash +set -e + +WIDGETS_DIR="$PROJECT_DIR/src/widgets" + +command="${1:-}" +shift || true + +usage() { + echo "" + echo "Widget commands:" + echo " inkypi widget install " + echo " inkypi widget uninstall " + echo " inkypi widget list" + echo "" +} + +require_widgets_dir() { + if [[ ! -d "$WIDGETS_DIR" ]]; then + echo "Widgets directory does not exist: $WIDGETS_DIR" + exit 1 + fi +} + +# ---------------------------- +# INSTALL +# ---------------------------- +install_widget() { + if [[ $# -ne 2 ]]; then + usage + exit 1 + fi + + WIDGET_ID="$1" + REPO_URL="$2" + DEST_DIR="$WIDGETS_DIR/$WIDGET_ID" + + require_widgets_dir + + # Workaround for .pyc files created by inkypi app with sudo + if [[ -d "$DEST_DIR" ]]; then + sudo chown -R "$(whoami)":"$(whoami)" "$DEST_DIR" 2>/dev/null || true + chmod -R u+rw "$DEST_DIR" 2>/dev/null || true + fi + + echo "[INFO] Installing widget '$WIDGET_ID' from $REPO_URL" + + rm -rf "$DEST_DIR" + mkdir -p "$DEST_DIR" + + git clone --depth 1 --filter=blob:none --sparse "$REPO_URL" "$DEST_DIR" &>/dev/null + + # Check if the folder exists in the branch + DEFAULT_BRANCH=$(git -C "$DEST_DIR" symbolic-ref --short HEAD 2>/dev/null || echo main) + if ! git -C "$DEST_DIR" ls-tree --name-only "$DEFAULT_BRANCH" | grep -qx "$WIDGET_ID"; then + echo "[INFO] Widget '$WIDGET_ID' does not exist in the repo" + rm -rf "$DEST_DIR" + exit 1 + fi + + cd "$DEST_DIR" + git sparse-checkout set "$WIDGET_ID" &>/dev/null + + # Move widget files to root of DEST_DIR + shopt -s dotglob + mv "$WIDGET_ID"/* ./ + rm -rf "$WIDGET_ID" + + echo "[INFO] Widget copied to $DEST_DIR" + + # Install requirements if any + REQ_FILE="$DEST_DIR/requirements.txt" + + if [[ -f "$REQ_FILE" ]]; then + echo "[INFO] requirements.txt found, installing dependencies..." + + if [[ ! -d "$VENV_PATH" ]]; then + echo "[ERROR] Virtual environment not found at $VENV_PATH" + exit 1 + fi + + source "$VENV_PATH/bin/activate" + pip install -r "$REQ_FILE" + deactivate + + echo "[INFO] Dependencies installed" + else + echo "[INFO] No requirements.txt found, skipping dependency install" + fi + + echo "[INFO] Restarting $APPNAME service." + sudo systemctl restart "$APPNAME.service" + + echo "[INFO] Done" +} + +# ---------------------------- +# REMOVE +# ---------------------------- +uninstall_widget() { + if [[ $# -ne 1 ]]; then + usage + exit 1 + fi + + WIDGET_ID="$1" + DEST_DIR="$WIDGETS_DIR/$WIDGET_ID" + + require_widgets_dir + + if [[ ! -d "$DEST_DIR" ]]; then + echo "[ERROR] Widget '$WIDGET_ID' is not installed." + exit 1 + fi + + echo "[INFO] Removing widget '$WIDGET_ID'" + + sudo chown -R "$(whoami)":"$(whoami)" "$DEST_DIR" 2>/dev/null || true + rm -rf "$DEST_DIR" + + echo "[INFO] Widget successfully uninstalled" + + echo "[INFO] Restarting $APPNAME service." + sudo systemctl restart "$APPNAME.service" +} + +# ---------------------------- +# LIST +# ---------------------------- +list_widgets() { + require_widgets_dir + { + printf "WIDGET\tNAME\tTYPE\tREPOSITORY\n" + + for widget_dir in "$WIDGETS_DIR"/*; do + widget_id="$(basename "$widget_dir")" + [[ "$widget_id" == "base_widget" ]] && continue + + info_file="$widget_dir/widget-info.json" + [[ ! -f "$info_file" ]] && continue + + # Extract name + repository + IFS=$'\t' read -r name repo < <( + jq -r '[.display_name, .repository] | map(. // "") | @tsv' "$info_file" + ) + + # Infer type from repository presence + if [[ -n "$repo" ]]; then + type="third_party" + else + type="builtin" + repo="-" + fi + + printf "%s\t%s\t%s\t%s\n" \ + "$widget_id" "$name" "$type" "$repo" + done + } | column -t -s $'\t' +} +# ---------------------------- +# COMMAND ROUTER +# ---------------------------- + +case "$command" in + install) + install_widget "$@" + ;; + uninstall) + uninstall_widget "$@" + ;; + list) + list_widgets + ;; + ""|-h|--help|help) + usage + ;; + *) + echo "[ERROR] Unknown command: $command" + usage + exit 1 + ;; +esac diff --git a/install/inkypi b/install/inkypi index 61090d82a..47a60e225 100755 --- a/install/inkypi +++ b/install/inkypi @@ -17,6 +17,7 @@ usage() { echo "Usage:" echo " inkypi run" echo " inkypi plugin [args]" + echo " inkypi widget [args]" echo "" } @@ -29,6 +30,10 @@ run_plugin_cli() { exec "$PROGRAM_PATH/cli/inkypi-plugin" "$@" } +run_widget_cli() { + exec "$PROGRAM_PATH/cli/inkypi-widget" "$@" +} + case "$command" in run) run_app @@ -36,6 +41,9 @@ case "$command" in plugin) run_plugin_cli "$@" ;; + widget) + run_widget_cli "$@" + ;; ""|-h|--help|help) usage exit 0 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..67a4f451e --- /dev/null +++ b/src/blueprints/widget.py @@ -0,0 +1,225 @@ +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__) + +@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(silent=True) + if data is None: + return jsonify({"error": "Invalid JSON in request body"}), 400 + + new_order = data.get('order', []) + + # Reject duplicates + if len(new_order) != len(set(new_order)): + return jsonify({"error": "Duplicate widget IDs in order"}), 400 + + # Validate against currently enabled widgets only + widget_settings = device_config.get_config('widget_settings', {}) + enabled_widgets = set(widget_settings.get('enabled_widgets', [])) + + if set(new_order) != enabled_widgets: + return jsonify({"error": "Order must contain exactly the currently enabled widgets"}), 400 + + 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(silent=True) + if data is None: + return jsonify({"error": "Invalid JSON in request body"}), 400 + + 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(silent=True) + if data is None: + return jsonify({"error": "Invalid JSON in request body"}), 400 + + VALID_CORNERS = {'top-left', 'top-right', 'bottom-left', 'bottom-right'} + VALID_ORIENTATIONS = {'horizontal', 'vertical'} + + widget_settings = device_config.get_config('widget_settings', {}) + + corner = data.get('corner', widget_settings.get('corner', 'top-left')) + if corner not in VALID_CORNERS: + return jsonify({"error": f"Invalid corner value: {corner}"}), 400 + + orientation = data.get('orientation', widget_settings.get('orientation', 'horizontal')) + if orientation not in VALID_ORIENTATIONS: + return jsonify({"error": f"Invalid orientation value: {orientation}"}), 400 + + widget_settings['corner'] = corner + widget_settings['orientation'] = orientation + + # 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) + + 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() + + # Load settings from widget config + widget_settings = device_config.get_config('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 + + # Override use_contrast_color with the saved per-widget value if present + if 'use_contrast_color' in specific_widget_settings: + template_params['use_contrast_color'] = specific_widget_settings['use_contrast_color'] + + 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 + +@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/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..05367ccdc --- /dev/null +++ b/src/static/icons/widgets.svg @@ -0,0 +1,6 @@ + + + + + + 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..94dd8471c --- /dev/null +++ b/src/templates/widget_settings.html @@ -0,0 +1,84 @@ + + + + + + {{ widget.display_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..ba7fcde71 --- /dev/null +++ b/src/templates/widgets.html @@ -0,0 +1,335 @@ + + + + + + Widgets + + + + + + + + + +
+ + + +
+
+
+ playlist icon +

Widgets

+
+
+
+
+ + +
+ +

Enabled Widgets

+ +
    + {% for widget in enabled_widgets %} +
  • + {{ widget.display_name }} + {% if widget.repository %} + Repo + {% endif %} +
    + 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.display_name }} + {% if widget.repository %} + Repo + {% endif %} + +
  • + {% endfor %} +
+ {% endif %} + +
+

Global Settings

+ + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ +
+ +
+
+ + {% include 'response_modal.html' %} + + diff --git a/src/utils/image_utils.py b/src/utils/image_utils.py index 383ba1646..19e71ff76 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 a high-contrast text color as a hex string ("#000000" or "#FFFFFF"). + """ + 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 "#FFFFFF" + 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..cd9c69216 --- /dev/null +++ b/src/utils/widget_utils.py @@ -0,0 +1,110 @@ +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 + start_positions = { + 'top-left': (0 + margin, 0 + margin), + 'top-right': (width - margin, 0 + margin), + 'bottom-left': (0 + margin, height - margin), + 'bottom-right': (width - margin, height - margin), + } + + if corner not in start_positions: + logger.warning(f"Invalid widget corner '{corner}', defaulting to 'top-left'") + corner = 'top-left' + + x_start, y_start = start_positions[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, {}) + settings_for_render = dict(specific_widget_settings) + + # Only compute contrast if the widget is configured to use it + use_contrast_color = specific_widget_settings.get('use_contrast_color', False) + if use_contrast_color: + # 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 and add to render settings + contrast_color = calculate_contrast_color(main_image, box) + settings_for_render['contrast_color'] = contrast_color + + # Generate widget + try: + widget_instance = get_widget_instance(widget_config) + widget_img = widget_instance.generate_image(settings_for_render, 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..75aa30190 --- /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 + +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..4b5039a9a --- /dev/null +++ b/src/widgets/date_widget/date_widget.py @@ -0,0 +1,56 @@ +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) + + 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..03c68978d --- /dev/null +++ b/src/widgets/date_widget/widget-info.json @@ -0,0 +1,7 @@ +{ + "id": "date_widget", + "display_name": "Date Widget", + "description": "Displays current date", + "type": "widget", + "class": "DateWidget" +} \ No newline at end of file diff --git a/src/widgets/ip_address/ip_address.py b/src/widgets/ip_address/ip_address.py new file mode 100644 index 000000000..5202fea99 --- /dev/null +++ b/src/widgets/ip_address/ip_address.py @@ -0,0 +1,56 @@ +from widgets.base_widget.base_widget import BaseWidget +from utils.app_utils import get_font, get_ip_address +from PIL import Image, ImageDraw, ImageFont +import logging +import socket + + +class IPAddressWidget(BaseWidget): + def generate_settings_template(self): + template_params = super().generate_settings_template() + return template_params + + def generate_image(self, settings, device_config): + # Default overlay size + overlay = Image.new('RGBA', (200, 40), (0, 0, 0, 0)) + draw = ImageDraw.Draw(overlay) + + font_size = int(settings.get('font_size', 18)) + font = get_font("Jost", font_size) or ImageFont.load_default() + + 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 IP address with fallbacks + try: + ip_address = get_ip_address() + except Exception: + logging.warning("get_ip_address() raised an exception, falling back to hostname") + ip_address = None + + if not ip_address: + try: + ip_address = socket.gethostname() + except Exception: + ip_address = "IP unavailable" + + # Format text + ip_text = str(ip_address) + + # Measure text bbox + bbox = draw.textbbox((0, 0), ip_text, font=font) + text_width = bbox[2] - bbox[0] + text_height = bbox[3] - bbox[1] + + # Expand overlay if text is larger than default + if text_width > 200 or text_height > 40: + overlay = Image.new('RGBA', (max(200, text_width), max(40, text_height)), (0, 0, 0, 0)) + draw = ImageDraw.Draw(overlay) + + draw.text((0, 0), ip_text, fill=text_color, font=font) + + bbox = draw.textbbox((0, 0), ip_text, font=font) + return overlay.crop(bbox) diff --git a/src/widgets/ip_address/widget-info.json b/src/widgets/ip_address/widget-info.json new file mode 100644 index 000000000..1191a1c21 --- /dev/null +++ b/src/widgets/ip_address/widget-info.json @@ -0,0 +1,7 @@ +{ + "id": "ip_address", + "display_name": "IP Address", + "description": "Displays the device IP address.", + "type": "widget", + "class": "IPAddressWidget" +} \ 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..4f7abf43e --- /dev/null +++ b/src/widgets/static_message/settings.html @@ -0,0 +1,4 @@ +
+ + +
\ 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..acb63de38 --- /dev/null +++ b/src/widgets/static_message/widget-info.json @@ -0,0 +1,7 @@ +{ + "id": "static_message", + "display_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..49cc503c4 --- /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): + logger.info(f"Widget {widget_id} is disabled, skipping.") + continue + + widget_dir = widgets_module_path / widget_id + if not widget_dir.is_dir(): + logger.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(): + logger.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: + logger.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.") diff --git a/tests/test_widgets.py b/tests/test_widgets.py new file mode 100644 index 000000000..5e87fce02 --- /dev/null +++ b/tests/test_widgets.py @@ -0,0 +1,298 @@ +import pytest +import json +from unittest.mock import Mock, patch, MagicMock +from flask import Flask + +from src.blueprints.widget import widget_bp +from src.config import Config + + +@pytest.fixture +def app(): + """Create a Flask app for testing.""" + app = Flask(__name__) + app.config['TESTING'] = True + app.register_blueprint(widget_bp) + + # Mock device config + mock_config = Mock(spec=Config) + mock_config.get_widgets.return_value = [ + {'id': 'date_widget', 'name': 'Date Widget'}, + {'id': 'static_message', 'name': 'Static Message'} + ] + mock_config.get_widget.side_effect = lambda widget_id: next( + (w for w in mock_config.get_widgets() if w['id'] == widget_id), None + ) + mock_config.get_config.return_value = { + 'enabled_widgets': ['date_widget'], + 'corner': 'top-left', + 'orientation': 'horizontal', + 'spacing': 10, + 'margin': 10, + 'widgets': {} + } + + app.config['DEVICE_CONFIG'] = mock_config + + return app + + +@pytest.fixture +def client(app): + """Create a test client.""" + return app.test_client() + + +class TestWidgetAPI: + """Test widget API endpoints.""" + + def test_reorder_widgets_success(self, client, app): + """Test successful widget reordering.""" + with app.app_context(): + mock_config = app.config['DEVICE_CONFIG'] + mock_config.update_value = Mock() + + response = client.post( + '/api/widgets/reorder', + data=json.dumps({'order': ['static_message', 'date_widget']}), + content_type='application/json' + ) + + assert response.status_code == 200 + data = json.loads(response.data) + assert data['success'] is True + assert 'Widget order updated' in data['message'] + mock_config.update_value.assert_called_once() + + def test_reorder_widgets_invalid_json(self, client): + """Test reordering with invalid JSON.""" + response = client.post( + '/api/widgets/reorder', + data='invalid json', + content_type='application/json' + ) + + assert response.status_code == 400 + data = json.loads(response.data) + assert 'Invalid JSON' in data['error'] + + def test_reorder_widgets_invalid_widget_id(self, client, app): + """Test reordering with invalid widget ID.""" + with app.app_context(): + response = client.post( + '/api/widgets/reorder', + data=json.dumps({'order': ['invalid_widget', 'date_widget']}), + content_type='application/json' + ) + + assert response.status_code == 400 + data = json.loads(response.data) + assert 'Invalid widget IDs' in data['error'] + + def test_toggle_widget_enable(self, client, app): + """Test enabling a widget.""" + with app.app_context(): + mock_config = app.config['DEVICE_CONFIG'] + mock_config.update_value = Mock() + + response = client.post( + '/api/widgets/toggle', + data=json.dumps({'widget_id': 'static_message', 'enable': True}), + content_type='application/json' + ) + + assert response.status_code == 200 + data = json.loads(response.data) + assert data['success'] is True + assert 'enabled' in data['message'] + + def test_toggle_widget_disable(self, client, app): + """Test disabling a widget.""" + with app.app_context(): + mock_config = app.config['DEVICE_CONFIG'] + mock_config.update_value = Mock() + + response = client.post( + '/api/widgets/toggle', + data=json.dumps({'widget_id': 'date_widget', 'enable': False}), + content_type='application/json' + ) + + assert response.status_code == 200 + data = json.loads(response.data) + assert data['success'] is True + assert 'disabled' in data['message'] + + def test_toggle_widget_not_found(self, client, app): + """Test toggling a non-existent widget.""" + with app.app_context(): + response = client.post( + '/api/widgets/toggle', + data=json.dumps({'widget_id': 'nonexistent', 'enable': True}), + content_type='application/json' + ) + + assert response.status_code == 404 + data = json.loads(response.data) + assert 'not found' in data['error'] + + def test_save_widget_settings_success(self, client, app): + """Test saving widget global settings.""" + with app.app_context(): + mock_config = app.config['DEVICE_CONFIG'] + mock_config.update_value = Mock() + + response = client.post( + '/api/widgets/settings', + data=json.dumps({ + 'corner': 'bottom-right', + 'orientation': 'vertical', + 'spacing': 15, + 'margin': 20 + }), + content_type='application/json' + ) + + assert response.status_code == 200 + data = json.loads(response.data) + assert data['success'] is True + assert 'saved' in data['message'] + + def test_save_widget_settings_invalid_json(self, client): + """Test saving settings with invalid JSON.""" + response = client.post( + '/api/widgets/settings', + data='not json', + content_type='application/json' + ) + + assert response.status_code == 400 + data = json.loads(response.data) + assert 'Invalid JSON' in data['error'] + + def test_save_widget_settings_invalid_numeric(self, client, app): + """Test saving settings with invalid numeric values.""" + with app.app_context(): + response = client.post( + '/api/widgets/settings', + data=json.dumps({ + 'corner': 'top-left', + 'orientation': 'horizontal', + 'spacing': 'not_a_number', + 'margin': 10 + }), + content_type='application/json' + ) + + assert response.status_code == 400 + data = json.loads(response.data) + assert 'Invalid numeric value' in data['error'] + + +class TestWidgetUtils: + """Test widget utility functions.""" + + @patch('src.utils.widget_utils.get_widget_instance') + @patch('src.utils.widget_utils.calculate_contrast_color') + def test_contrast_only_computed_when_enabled(self, mock_contrast, mock_get_widget): + """Test that contrast is only computed when use_contrast_color is True.""" + from src.utils.widget_utils import generate_and_apply_widgets + from PIL import Image + + # Create a mock image + main_image = Image.new('RGB', (800, 600), color='white') + + # Mock device config + mock_config = Mock() + mock_config.get_config.return_value = { + 'enabled_widgets': ['test_widget'], + 'corner': 'top-left', + 'orientation': 'horizontal', + 'spacing': 10, + 'margin': 10, + 'widgets': { + 'test_widget': { + 'use_contrast_color': False # Contrast disabled + } + } + } + mock_config.get_widget.return_value = {'id': 'test_widget', 'name': 'Test'} + + # Mock widget instance + mock_widget = Mock() + mock_widget.generate_image.return_value = Image.new('RGBA', (100, 50), color=(0, 0, 0, 0)) + mock_get_widget.return_value = mock_widget + + # Call the function + result = generate_and_apply_widgets(main_image, mock_config) + + # Verify contrast was NOT calculated + mock_contrast.assert_not_called() + + # Verify widget was generated without contrast_color + call_args = mock_widget.generate_image.call_args[0][0] + assert 'contrast_color' not in call_args + + @patch('src.utils.widget_utils.get_widget_instance') + @patch('src.utils.widget_utils.calculate_contrast_color') + def test_contrast_computed_when_enabled(self, mock_contrast, mock_get_widget): + """Test that contrast is computed when use_contrast_color is True.""" + from src.utils.widget_utils import generate_and_apply_widgets + from PIL import Image + + # Create a mock image + main_image = Image.new('RGB', (800, 600), color='white') + + # Mock device config + mock_config = Mock() + mock_config.get_config.return_value = { + 'enabled_widgets': ['test_widget'], + 'corner': 'top-left', + 'orientation': 'horizontal', + 'spacing': 10, + 'margin': 10, + 'widgets': { + 'test_widget': { + 'use_contrast_color': True # Contrast enabled + } + } + } + mock_config.get_widget.return_value = {'id': 'test_widget', 'name': 'Test'} + + # Mock widget instance + mock_widget = Mock() + mock_widget.generate_image.return_value = Image.new('RGBA', (100, 50), color=(0, 0, 0, 0)) + mock_get_widget.return_value = mock_widget + + # Mock contrast calculation + mock_contrast.return_value = '#FFFFFF' + + # Call the function + result = generate_and_apply_widgets(main_image, mock_config) + + # Verify contrast WAS calculated + mock_contrast.assert_called_once() + + # Verify widget was generated WITH contrast_color + call_args = mock_widget.generate_image.call_args[0][0] + assert 'contrast_color' in call_args + assert call_args['contrast_color'] == '#FFFFFF' + + def test_calculate_contrast_color_returns_hex(self): + """Test that calculate_contrast_color returns consistent hex format.""" + from src.utils.image_utils import calculate_contrast_color + from PIL import Image + + # Test with light background + light_image = Image.new('RGB', (100, 100), color=(200, 200, 200)) + result = calculate_contrast_color(light_image, (0, 0, 100, 100)) + assert result == '#000000' # Should return black for light background + + # Test with dark background + dark_image = Image.new('RGB', (100, 100), color=(50, 50, 50)) + result = calculate_contrast_color(dark_image, (0, 0, 100, 100)) + assert result == '#FFFFFF' # Should return white for dark background + + # Verify format is always hex + assert result.startswith('#') + assert len(result) == 7