diff --git a/config/config.template.json b/config/config.template.json index 07a72b60..b3f63589 100644 --- a/config/config.template.json +++ b/config/config.template.json @@ -121,6 +121,7 @@ "axis": "horizontal" }, "display_durations": {}, + "plugin_rotation_order": [], "use_short_date_format": true, "vegas_scroll": { "enabled": false, diff --git a/docs/widget-guide.md b/docs/widget-guide.md index dadda4cf..7a1ed850 100644 --- a/docs/widget-guide.md +++ b/docs/widget-guide.md @@ -206,6 +206,40 @@ To use an existing widget in your plugin's `config_schema.json`, simply add the The widget will be automatically rendered when the plugin configuration form is loaded. +## Marking Fields as Advanced (`x-advanced`) + +Add `"x-advanced": true` to any top-level, non-object property to move it out +of the main form and into a single collapsed **Advanced Settings** section at +the bottom of the plugin's configuration page: + +```json +{ + "properties": { + "city": { + "type": "string", + "title": "City" + }, + "request_timeout": { + "type": "integer", + "default": 10, + "description": "HTTP timeout in seconds", + "x-advanced": true + } + } +} +``` + +Guidelines: + +- Use it for fine-tuning knobs most users never touch (timeouts, retry + behavior, cache TTLs, styling overrides). Anything a first-time user must + set to get the plugin working should stay basic. +- Nothing is hidden permanently — the section expands on click, and the + settings search finds and auto-expands advanced fields like any others. +- The flag is ignored on `object`-type properties (they already render as + their own collapsible sections) and is safely ignored by older cores, so + adding it never breaks compatibility. + ## Creating Custom Widgets ### Step 1: Create Widget File diff --git a/src/display_controller.py b/src/display_controller.py index ace55b58..5a341902 100644 --- a/src/display_controller.py +++ b/src/display_controller.py @@ -381,6 +381,10 @@ class DisplayController: logger.debug("%d plugin(s) disabled in config", disabled_count) logger.info("Plugin system initialized in %.3f seconds", time.time() - plugin_time) + # Parallel loading appends modes in load-completion order, which + # varies between restarts; apply the user's configured rotation + # order (no-op when not configured). + self._apply_plugin_rotation_order() logger.info("Total available modes: %d", len(self.available_modes)) logger.info("Available modes: %s", self.available_modes) @@ -2843,11 +2847,52 @@ class DisplayController: except Exception as e: logger.error("Plugin reconcile: error enabling %s: %s", plugin_id, e, exc_info=True) + # Newly enabled plugins were appended at the end; put them in the + # configured rotation slot before resyncing the index. + self._apply_plugin_rotation_order() self._resync_mode_index_after_change(previous_mode) - logger.info("Plugin reconcile complete: +%s -%s (%d modes)", + logger.info("[DisplayController] Plugin reconcile complete: +%s -%s (%d modes)", sorted(to_add), sorted(to_remove), len(self.available_modes)) return True + def _apply_plugin_rotation_order(self) -> None: + """Reorder available_modes to follow display.plugin_rotation_order. + + The configured value is a list of plugin ids; their modes rotate in + that order (each plugin's own modes keep their declared order), with + any enabled-but-unlisted plugins appended afterwards in their current + relative order. An empty/missing list leaves available_modes exactly + as built (today's behavior). Mirrors vegas_mode/config.py's + get_ordered_plugins() semantics for the primary rotation. + """ + configured = (self.config.get("display", {}) or {}).get("plugin_rotation_order", []) or [] + # Defensive: hand-edited or migrated configs may hold a non-list or + # non-string entries; keep the existing rotation rather than applying + # a garbage order. + if not isinstance(configured, list): + logger.warning("[DisplayController] Ignoring invalid plugin_rotation_order (not a list): %r", + type(configured).__name__) + return + configured = [p for p in configured if isinstance(p, str)] + if not configured or not self.available_modes: + return + + ordered_ids = [p for p in configured if p in self.plugin_display_modes] + new_modes: List[str] = [] + for plugin_id in ordered_ids: + for mode in self.plugin_display_modes[plugin_id]: + if mode in self.available_modes and mode not in new_modes: + new_modes.append(mode) + # Unlisted plugins' modes (and any mode not attributable to a plugin) + # follow in their existing relative order. + for mode in self.available_modes: + if mode not in new_modes: + new_modes.append(mode) + if new_modes != self.available_modes: + self.available_modes = new_modes + logger.info("[DisplayController] Applied plugin rotation order %s -> modes: %s", + configured, self.available_modes) + def _resync_mode_index_after_change(self, previous_mode: Optional[str]) -> None: """Clamp rotation state after available_modes changed. Stays on the previous mode if it survived, otherwise restarts cleanly within range.""" diff --git a/test/test_web_smoke.py b/test/test_web_smoke.py new file mode 100644 index 00000000..d0fd30de --- /dev/null +++ b/test/test_web_smoke.py @@ -0,0 +1,184 @@ +""" +Web-UI smoke tests: every page, partial, and critical static asset must render. + +These boot the pages blueprint with the same dual registration app.py uses +(un-prefixed primary + /v3 legacy alias) and assert each surface returns 200 +with its load-bearing markers present. They exist to catch, in CI, the class +of regression that only shows up when a real request renders a real template: +a broken partial, a missing tab wiring, a renamed element id that JS depends +on, or a static asset that stopped being served. +""" + +import sys +from pathlib import Path +from unittest.mock import MagicMock + +import pytest +from flask import Flask + +PROJECT_ROOT = Path(__file__).parent.parent +sys.path.insert(0, str(PROJECT_ROOT)) + + +SMOKE_CONFIG = { + "web_display_autostart": True, + "timezone": "America/Chicago", + "location": {"city": "Dallas", "state": "Texas", "country": "US"}, + "plugin_system": { + "auto_discover": True, + "auto_load_enabled": True, + "development_mode": False, + "plugins_directory": "plugin-repos", + }, + "schedule": {}, + "dim_schedule": {"dim_brightness": 30}, + "sync": {"role": "standalone", "port": 5765, "follower_position": "left"}, + "clock": {"enabled": True}, + "ledmatrix-weather": {"enabled": True}, + "display": { + "hardware": { + "rows": 32, "cols": 64, "chain_length": 2, "parallel": 1, + "brightness": 95, "hardware_mapping": "adafruit-hat-pwm", + "led_rgb_sequence": "RGB", "multiplexing": 0, "panel_type": "", + "row_address_type": 0, "scan_mode": 0, "pwm_bits": 9, + "pwm_dither_bits": 1, "pwm_lsb_nanoseconds": 130, + "limit_refresh_rate_hz": 120, "disable_hardware_pulsing": False, + "inverse_colors": False, "show_refresh_rate": False, + }, + "runtime": {"gpio_slowdown": 3, "rp1_rio": 0}, + "double_sided": {"enabled": False, "copies": 2, "axis": "horizontal"}, + "use_short_date_format": False, + "dynamic_duration": {"max_duration_seconds": 180}, + "vegas_scroll": { + "enabled": False, "scroll_speed": 50, "separator_width": 32, + "target_fps": 125, "buffer_ahead": 2, + "plugin_order": [], "excluded_plugins": [], + }, + "display_durations": {"stale_saved_mode": 45}, + "plugin_rotation_order": ["ledmatrix-weather", "clock"], + }, +} + +PLUGIN_MODES = { + "clock": ["clock"], + "ledmatrix-weather": ["weather_current", "weather_daily"], +} + + +@pytest.fixture +def client(): + base = PROJECT_ROOT / "web_interface" + app = Flask( + __name__, + template_folder=str(base / "templates"), + static_folder=str(base / "static"), + ) + app.config["TESTING"] = True + + from web_interface.blueprints import pages_v3 as pv + + # pages_v3 is a module-level Blueprint singleton shared by the whole test + # process (test_web_settings_ui.py mutates the same attributes) - save + # the originals and restore them on teardown so this fixture can't leak + # its mocks into tests that run afterward. + original_config_manager = getattr(pv.pages_v3, "config_manager", None) + original_plugin_manager = getattr(pv.pages_v3, "plugin_manager", None) + + mock_cm = MagicMock() + mock_cm.load_config.return_value = SMOKE_CONFIG + mock_cm.get_raw_file_content.return_value = SMOKE_CONFIG + mock_cm.get_config_path.return_value = "config/config.json" + mock_cm.get_secrets_path.return_value = "config/config_secrets.json" + pv.pages_v3.config_manager = mock_cm + + mock_pm = MagicMock() + mock_pm.plugins = {} + mock_pm.get_all_plugin_info.return_value = [ + {"id": "clock", "name": "Clock"}, + {"id": "ledmatrix-weather", "name": "Weather"}, + ] + mock_pm.get_plugin_display_modes.side_effect = ( + lambda pid: PLUGIN_MODES.get(pid, []) + ) + pv.pages_v3.plugin_manager = mock_pm + + # Same dual registration as web_interface/app.py: un-prefixed primary, + # /v3 kept as a working legacy alias. + app.register_blueprint(pv.pages_v3, url_prefix="") + app.register_blueprint(pv.pages_v3, url_prefix="/v3", name="pages_v3_legacy") + try: + yield app.test_client() + finally: + pv.pages_v3.config_manager = original_config_manager + pv.pages_v3.plugin_manager = original_plugin_manager + + +# (path, [markers that must appear in the body]) +PAGES = [ + ("/", ["site-nav", "mobileNavOpen", 'rel="manifest"', + "restart-pending-banner", "activeTab = 'durations'"]), + ("/partials/overview", ["getting-started-card", "displayImage"]), + ("/partials/general", ["timezone"]), + ("/partials/display", ["display-section-advanced-hardware", + "display-resolution-value", "vegas_scroll_label"]), + ("/partials/durations", ["rotation_plugin_order", "duration__clock", + "duration__weather_current", + "duration__stale_saved_mode"]), + ("/partials/schedule", ["schedule"]), +] + + +@pytest.mark.parametrize("path,markers", PAGES, ids=[p for p, _ in PAGES]) +def test_page_renders_with_markers(client, path, markers): + resp = client.get(path) + assert resp.status_code == 200, f"{path} -> {resp.status_code}" + body = resp.get_data(as_text=True) + for marker in markers: + assert marker in body, f"{path}: missing marker {marker!r}" + + +@pytest.mark.parametrize("path", [p for p, _ in PAGES if p != "/"]) +def test_legacy_v3_alias_serves_the_same_partials(client, path): + assert client.get("/v3" + path).status_code == 200 + + +STATIC_ASSETS = [ + "/static/v3/app.css", + "/static/v3/app.js", + "/static/v3/manifest.json", + "/static/v3/icons/icon-192.png", + "/static/v3/js/app-shell.js", + "/static/v3/js/app-early.js", + "/static/v3/js/htmx-config.js", + "/static/v3/js/widgets/plugin-order-list.js", + "/static/v3/js/widgets/notification.js", + "/static/v3/vendor/fontawesome/css/all.min.css", + "/static/v3/vendor/codemirror/codemirror.min.js", +] + + +@pytest.mark.parametrize("asset", STATIC_ASSETS) +def test_static_asset_served(client, asset): + resp = client.get(asset) + assert resp.status_code == 200, f"{asset} -> {resp.status_code}" + assert len(resp.data) > 0 + + +def test_durations_page_groups_by_plugin(client): + """One duration input per display mode of each enabled plugin, plus the + leftover group for saved keys no enabled plugin owns.""" + body = client.get("/partials/durations").get_data(as_text=True) + assert body.count("duration__") >= 2 * len( + [m for modes in PLUGIN_MODES.values() for m in modes] + ) # each mode: id= and name= + assert "Other saved entries" in body + + +def test_display_advanced_section_contains_tuning_fields(client): + body = client.get("/partials/display").get_data(as_text=True) + adv = body.find('id="display-section-advanced-hardware"') + adv_close = body.find("/#display-section-advanced-hardware") + assert 0 < adv < adv_close + for field in ["multiplexing", "pwm_bits", "inverse_colors"]: + pos = body.find(f'name="{field}"') + assert adv < pos < adv_close, f"{field} not inside the advanced section" diff --git a/test/test_web_static_audit.py b/test/test_web_static_audit.py new file mode 100644 index 00000000..4d9bae1f --- /dev/null +++ b/test/test_web_static_audit.py @@ -0,0 +1,85 @@ +""" +Static-analysis audits for the web UI, as tests so CI enforces them. + +1. Breakpoint utility audit: app.css hand-maintains a Tailwind-style utility + subset, so a template can reference a responsive class (e.g. sm:block) + that no CSS rule defines — it silently no-ops. This once left the header + search box and system stats invisible at every screen width. The audit + diffs classes used in templates against classes defined in app.css. + +2. Asset reference audit: every url_for('static', filename=...) in the + templates must point to a file that exists, so a renamed/moved asset + can't ship as a broken - - - - - - - - + - + - + - - - - + + + + - - + + @@ -874,14 +296,25 @@
+ +

- LED Matrix Control - v3 + LED Matrix Control

-
+
+ +
+
+
+
+
+
+
+
+
+
+
@@ -1429,3340 +952,7 @@
- + @@ -4791,6 +981,7 @@ + @@ -4822,269 +1013,9 @@ - - + +
@@ -454,6 +470,48 @@
+ +
+

Double-Sided Display

+

Show the same content on every panel in the chain — e.g. two 64×32 panels mirrored, or four panels as two identical screens. Rendered once and duplicated, so it adds no extra CPU. Takes effect after a display restart.

+ +
+
+ +
+ +
+ + +
+ +
+ + +
+
+
+ + +
@@ -583,183 +641,32 @@ if (typeof window.fixInvalidNumberInputs !== 'function') { }); } - // Initialize plugin order list - function initPluginOrderList() { + // Initialize plugin order list via the shared drag-and-drop module + // (static/v3/js/widgets/plugin-order-list.js) — the same component the + // Durations tab uses for the primary rotation order. + function initPluginOrderList(attempt) { const container = document.getElementById('vegas_plugin_order'); if (!container) return; - - // Fetch available plugins - fetch('/api/v3/plugins/installed') - .then(response => response.json()) - .then(data => { - // Handle both {data: {plugins: []}} and {plugins: []} response formats - const allPlugins = data.data?.plugins || data.plugins || []; - if (!allPlugins || allPlugins.length === 0) { - container.innerHTML = '

No plugins available

'; - return; - } - - // Get current order and exclusions - const orderInput = document.getElementById('vegas_plugin_order_value'); - const excludedInput = document.getElementById('vegas_excluded_plugins_value'); - let currentOrder = []; - let excluded = []; - - try { - currentOrder = JSON.parse(orderInput.value || '[]'); - excluded = JSON.parse(excludedInput.value || '[]'); - } catch (e) { - console.error('Error parsing vegas config:', e); - } - - // Build ordered plugin list (only enabled plugins) - const plugins = allPlugins.filter(p => p.enabled); - const orderedPlugins = []; - - // First add plugins in current order - currentOrder.forEach(id => { - const plugin = plugins.find(p => p.id === id); - if (plugin) orderedPlugins.push(plugin); - }); - - // Then add remaining plugins - plugins.forEach(plugin => { - if (!orderedPlugins.find(p => p.id === plugin.id)) { - orderedPlugins.push(plugin); - } - }); - - // Build HTML with display mode indicators - let html = ''; - orderedPlugins.forEach((plugin, index) => { - const isExcluded = excluded.includes(plugin.id); - // Determine display mode (from plugin config or default) - const vegasMode = plugin.vegas_mode || plugin.vegas_content_type || 'fixed'; - const modeLabels = { - 'scroll': { label: 'Scroll', icon: 'fa-scroll', color: 'text-blue-600' }, - 'fixed': { label: 'Fixed', icon: 'fa-square', color: 'text-green-600' }, - 'static': { label: 'Static', icon: 'fa-pause', color: 'text-orange-600' } - }; - const modeInfo = modeLabels[vegasMode] || modeLabels['fixed']; - // Escape plugin metadata to prevent XSS - const safePluginId = escapeAttr(plugin.id); - const safePluginName = escapeHtml(plugin.name || plugin.id); - html += ` -
- - - - ${modeInfo.label} - -
- `; - }); - - container.innerHTML = html || '

No enabled plugins

'; - - // Setup drag and drop - setupDragAndDrop(container); - - // Setup checkbox handlers - container.querySelectorAll('.vegas-plugin-include').forEach(checkbox => { - checkbox.addEventListener('change', updatePluginConfig); - }); - - // Initialize hidden inputs with current state - updatePluginConfig(); - }) - .catch(error => { - console.error('Error fetching plugins:', error); - container.innerHTML = '

Error loading plugins

'; - }); - } - - function setupDragAndDrop(container) { - let draggedItem = null; - - container.querySelectorAll('.vegas-plugin-item').forEach(item => { - item.addEventListener('dragstart', function(e) { - draggedItem = this; - this.style.opacity = '0.5'; - e.dataTransfer.effectAllowed = 'move'; - }); - - item.addEventListener('dragend', function() { - this.style.opacity = '1'; - draggedItem = null; - updatePluginConfig(); - }); - - item.addEventListener('dragover', function(e) { - e.preventDefault(); - e.dataTransfer.dropEffect = 'move'; - - const rect = this.getBoundingClientRect(); - const midY = rect.top + rect.height / 2; - - if (e.clientY < midY) { - this.style.borderTop = '2px solid #3b82f6'; - this.style.borderBottom = ''; - } else { - this.style.borderBottom = '2px solid #3b82f6'; - this.style.borderTop = ''; - } - }); - - item.addEventListener('dragleave', function() { - this.style.borderTop = ''; - this.style.borderBottom = ''; - }); - - item.addEventListener('drop', function(e) { - e.preventDefault(); - this.style.borderTop = ''; - this.style.borderBottom = ''; - - if (draggedItem && draggedItem !== this) { - const rect = this.getBoundingClientRect(); - const midY = rect.top + rect.height / 2; - - if (e.clientY < midY) { - container.insertBefore(draggedItem, this); - } else { - container.insertBefore(draggedItem, this.nextSibling); - } - } - }); - }); - } - - function updatePluginConfig() { - const container = document.getElementById('vegas_plugin_order'); - const orderInput = document.getElementById('vegas_plugin_order_value'); - const excludedInput = document.getElementById('vegas_excluded_plugins_value'); - - if (!container || !orderInput || !excludedInput) return; - - const order = []; - const excluded = []; - - container.querySelectorAll('.vegas-plugin-item').forEach(item => { - const pluginId = item.dataset.pluginId; - const checkbox = item.querySelector('.vegas-plugin-include'); - - order.push(pluginId); - if (checkbox && !checkbox.checked) { - excluded.push(pluginId); + if (!window.PluginOrderList) { + // Widget script is deferred; retry briefly, then surface a real + // error instead of waiting forever. + if ((attempt || 0) < 50) { + setTimeout(function() { initPluginOrderList((attempt || 0) + 1); }, 100); + } else { + container.textContent = 'Could not load the reorder widget — reload the page to try again.'; + container.className = 'text-sm text-red-500'; } + return; + } + window.PluginOrderList.init({ + containerId: 'vegas_plugin_order', + orderInputId: 'vegas_plugin_order_value', + excludedInputId: 'vegas_excluded_plugins_value', + showVegasModeBadge: true }); - - orderInput.value = JSON.stringify(order); - excludedInput.value = JSON.stringify(excluded); } + // Initialize on DOM ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initPluginOrderList); diff --git a/web_interface/templates/v3/partials/durations.html b/web_interface/templates/v3/partials/durations.html index 0a1f05aa..e05f8aa1 100644 --- a/web_interface/templates/v3/partials/durations.html +++ b/web_interface/templates/v3/partials/durations.html @@ -1,8 +1,8 @@ {% import 'v3/partials/_macros.html' as ui %}
-

Display Durations

-

Configure how long each screen is shown before switching. Values in seconds.

+

Rotation & Durations

+

Set the order plugins rotate on the display and how long each screen is shown. Durations are in seconds.

{{ ui.settings_filter() }} @@ -16,22 +16,53 @@ novalidate onsubmit="fixInvalidNumberInputs(this); return true;"> -
- {% for key, value in main_config.display.display_durations.items() %} -
- - + +
+

Rotation Order

+

Drag plugins to set the order they rotate on the display. Each plugin's screens keep their own order within its turn. Takes effect after saving and restarting the display.

+
+

Loading plugins…

+
+ +
+ + {% if duration_groups %} +
+
+

Screen Durations

+

How long each screen stays on before rotating to the next one, in seconds (5–600, default 30).

+
+ {% for group in duration_groups %} +
+

{{ group.plugin_name }}

+
+ {% for mode in group.modes %} +
+ + +
+ {% endfor %} +
{% endfor %}
+ {% else %} +
+

No enabled plugins found — enable a plugin in the Plugin Manager to set its screen durations here.

+
+ {% endif %}
@@ -43,3 +74,34 @@
+ + diff --git a/web_interface/templates/v3/partials/overview.html b/web_interface/templates/v3/partials/overview.html index cc74120a..47713d8d 100644 --- a/web_interface/templates/v3/partials/overview.html +++ b/web_interface/templates/v3/partials/overview.html @@ -61,6 +61,149 @@ }()); + +{% set _hw = main_config.display.hardware if main_config and main_config.display else {} %} +{% set _hw_done = (_hw.rows or 0) > 0 and (_hw.cols or 0) > 0 and (_hw.chain_length or 0) > 0 %} +{% set _loc = main_config.location if main_config and main_config.location else {} %} +{% set _loc_done = (main_config.timezone and main_config.timezone != 'America/New_York') + or (_loc.city and _loc.city != 'Tampa') %} + + +

System Overview

@@ -221,7 +364,10 @@

Live Display Preview

-
+ +
+
+ {% endif %} {% else %} {# No schema - render simple form from config #} {% if config %} diff --git a/web_interface/templates/v3/partials/plugins.html b/web_interface/templates/v3/partials/plugins.html index cf6aa726..ba7f0c88 100644 --- a/web_interface/templates/v3/partials/plugins.html +++ b/web_interface/templates/v3/partials/plugins.html @@ -466,65 +466,6 @@
- - -
-