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 @@