3 Commits

Author SHA1 Message Date
Chuck
35df06b8e1 fix: resolve font upload "baseUrl is not defined" error (#235) (#297)
The baseUrl variable was declared inside an IIFE that skips re-execution
on HTMX reloads, so it became undefined when the fonts tab was reloaded.
Since baseUrl was just window.location.origin prepended to absolute paths
like /api/v3/fonts/upload, it was unnecessary — fetch() with a leading
slash already resolves against the current origin.

Remove baseUrl entirely and use relative URLs in all 7 fetch calls.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 15:24:29 -04:00
Chuck
77e9eba294 fix: overhaul WiFi captive portal for reliable setup (#296)
* fix: overhaul WiFi captive portal for reliable device detection and fast setup

The captive portal detection endpoints were returning "success" responses
that told every OS (iOS, Android, Windows, Firefox) that internet was
working — so the portal popup never appeared. This fixes the core issue
and improves the full setup flow:

- Return portal-triggering redirects when AP mode is active; normal
  success responses when not (no false popups on connected devices)
- Add lightweight self-contained setup page (9KB, no frameworks) for
  the captive portal webview instead of the full UI
- Cache AP mode check with 5s TTL (single systemctl call vs full
  WiFiManager instantiation per request)
- Stop disabling AP mode during WiFi scans (which disconnected users);
  serve cached/pre-scanned results instead
- Pre-scan networks before enabling AP mode so captive portal has
  results immediately
- Use dnsmasq.d drop-in config instead of overwriting /etc/dnsmasq.conf
  (preserves Pi-hole and other services)
- Fix manual SSID input bug that incorrectly overwrote dropdown selection

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address review findings for WiFi captive portal

- Remove orphaned comment left over from old scan_networks() finally block
- Add sudoers rules for dnsmasq drop-in copy/remove to install script
- Combine cached-network message into single showMsg call (was overwriting)
- Return (networks, was_cached) tuple from scan_networks() so API endpoint
  derives cached flag from the scan itself instead of a redundant AP check
- Narrow exception catch in AP mode cache to SubprocessError/OSError and
  log the failure for remote debugging
- Bound checkNewIP retries to 20 attempts (60s) before showing fallback

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 14:50:33 -04:00
Chuck
6eccb74415 fix: handle dotted schema keys in plugin settings save (#295)
* fix: handle dotted schema keys in plugin settings save (issue #254)

The soccer plugin uses dotted keys like "eng.1" for league identifiers.
PR #260 fixed backend helpers but the JS frontend still corrupted these
keys by naively splitting on dots. This fixes both the JS and remaining
Python code paths:

- JS getSchemaProperty(): greedy longest-match for dotted property names
- JS dotToNested(): schema-aware key grouping to preserve "eng.1" as one key
- Python fix_array_structures(): remove broken prefix re-navigation in recursion
- Python ensure_array_defaults(): same prefix navigation fix

Closes #254

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address review findings for dotted-key handling

- ensure_array_defaults: replace None nodes with {} so recursion
  proceeds into nested objects (was skipping when key existed as None)
- dotToNested: add tail-matching that checks the full remaining dotted
  tail against the current schema level before greedy intermediate
  matching, preventing leaf dotted keys from being split
- syncFormToJson: replace naive key.split('.') reconstruction with
  dotToNested(flatConfig, schema) and schema-aware getSchemaProperty()
  so the JSON tab save path produces the same correct nesting as the
  form submit path
- Add regression tests for dotted-key array normalization and None
  array default replacement

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address second round of review findings

- Tests: replace conditional `if response.status_code == 200` guards
  with unconditional `assert response.status_code == 200` so failures
  are not silently swallowed
- dotToNested: guard finalKey write with `if (i < parts.length)` to
  prevent empty-string key pollution when tail-matching consumed all
  parts
- Extract normalizeFormDataForConfig() helper from handlePluginConfigSubmit
  and call it from both handlePluginConfigSubmit and syncFormToJson so
  the JSON tab sync uses the same robust FormData processing (including
  _data JSON inputs, bracket-notation checkboxes, array-of-objects,
  file-upload widgets, checkbox DOM detection, and unchecked boolean
  handling via collectBooleanFields)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 11:12:31 -04:00
10 changed files with 783 additions and 453 deletions

View File

@@ -62,6 +62,11 @@ $WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH start dnsmasq
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH stop dnsmasq
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart dnsmasq
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart NetworkManager
# Allow copying hostapd and dnsmasq config files into place
$WEB_USER ALL=(ALL) NOPASSWD: /usr/bin/cp /tmp/hostapd.conf /etc/hostapd/hostapd.conf
$WEB_USER ALL=(ALL) NOPASSWD: /usr/bin/cp /tmp/dnsmasq.conf /etc/dnsmasq.d/ledmatrix-captive.conf
$WEB_USER ALL=(ALL) NOPASSWD: /usr/bin/rm -f /etc/dnsmasq.d/ledmatrix-captive.conf
EOF
echo "Generated sudoers configuration:"

View File

@@ -25,7 +25,8 @@ Sudoers Requirements:
ledpi ALL=(ALL) NOPASSWD: /usr/sbin/iptables
ledpi ALL=(ALL) NOPASSWD: /usr/sbin/sysctl
ledpi ALL=(ALL) NOPASSWD: /usr/bin/cp /tmp/hostapd.conf /etc/hostapd/hostapd.conf
ledpi ALL=(ALL) NOPASSWD: /usr/bin/cp /tmp/dnsmasq.conf /etc/dnsmasq.conf
ledpi ALL=(ALL) NOPASSWD: /usr/bin/cp /tmp/dnsmasq.conf /etc/dnsmasq.d/ledmatrix-captive.conf
ledpi ALL=(ALL) NOPASSWD: /usr/bin/rm -f /etc/dnsmasq.d/ledmatrix-captive.conf
"""
import subprocess
@@ -58,7 +59,7 @@ def get_wifi_config_path():
return Path(project_root) / "config" / "wifi_config.json"
HOSTAPD_CONFIG_PATH = Path("/etc/hostapd/hostapd.conf")
DNSMASQ_CONFIG_PATH = Path("/etc/dnsmasq.conf")
DNSMASQ_CONFIG_PATH = Path("/etc/dnsmasq.d/ledmatrix-captive.conf")
HOSTAPD_SERVICE = "hostapd"
DNSMASQ_SERVICE = "dnsmasq"
@@ -658,33 +659,31 @@ class WiFiManager:
except (subprocess.TimeoutExpired, subprocess.SubprocessError, OSError):
return False
def scan_networks(self) -> List[WiFiNetwork]:
def scan_networks(self, allow_cached: bool = True) -> Tuple[List[WiFiNetwork], bool]:
"""
Scan for available WiFi networks
If AP mode is active, it will be temporarily disabled during scanning
and re-enabled afterward. This is necessary because WiFi interfaces
in AP mode cannot scan for other networks.
Scan for available WiFi networks.
When AP mode is active, returns cached scan results instead of
disabling AP (which would disconnect the user). Cached results
come from either nmcli's internal cache or a pre-scan file saved
before AP mode was enabled.
Returns:
List of WiFiNetwork objects
Tuple of (list of WiFiNetwork objects, was_cached bool)
"""
ap_was_active = False
try:
# Check if AP mode is active - if so, we need to disable it temporarily
ap_was_active = self._is_ap_mode_active()
if ap_was_active:
logger.info("AP mode is active, temporarily disabling for WiFi scan...")
success, message = self.disable_ap_mode()
if not success:
logger.warning(f"Failed to disable AP mode for scanning: {message}")
# Continue anyway - scan might still work
else:
# Wait for interface to switch modes
time.sleep(3)
# Perform the scan
ap_active = self._is_ap_mode_active()
if ap_active:
# Don't disable AP — user would lose their connection.
# Try nmcli cached results first (no rescan trigger).
logger.info("AP mode active — returning cached scan results")
networks = self._scan_nmcli_cached()
if not networks and allow_cached:
networks = self._load_cached_scan()
return networks, True
# Normal scan (not in AP mode)
if self.has_nmcli:
networks = self._scan_nmcli()
elif self.has_iwlist:
@@ -692,24 +691,87 @@ class WiFiManager:
else:
logger.error("No WiFi scanning tools available")
networks = []
return networks
# Save results for later use in AP mode
if networks:
self._save_cached_scan(networks)
return networks, False
except Exception as e:
logger.error(f"Error scanning networks: {e}")
return [], False
def _scan_nmcli_cached(self) -> List[WiFiNetwork]:
"""Return nmcli's cached WiFi list without triggering a rescan."""
networks = []
try:
result = subprocess.run(
["nmcli", "-t", "-f", "SSID,SIGNAL,SECURITY,FREQ", "device", "wifi", "list"],
capture_output=True, text=True, timeout=5
)
if result.returncode != 0:
return []
seen_ssids = set()
for line in result.stdout.strip().split('\n'):
if not line or ':' not in line:
continue
parts = line.split(':')
if len(parts) >= 3:
ssid = parts[0].strip()
if not ssid or ssid in seen_ssids:
continue
seen_ssids.add(ssid)
try:
signal = int(parts[1].strip())
security = parts[2].strip() if len(parts) > 2 else "open"
frequency_str = parts[3].strip() if len(parts) > 3 else "0"
frequency_str = frequency_str.replace(" MHz", "").replace("MHz", "").strip()
frequency = float(frequency_str) if frequency_str else 0.0
if "WPA3" in security:
sec_type = "wpa3"
elif "WPA2" in security:
sec_type = "wpa2"
elif "WPA" in security:
sec_type = "wpa"
else:
sec_type = "open"
networks.append(WiFiNetwork(ssid=ssid, signal=signal, security=sec_type, frequency=frequency))
except (ValueError, IndexError):
continue
networks.sort(key=lambda x: x.signal, reverse=True)
except Exception as e:
logger.debug(f"nmcli cached list failed: {e}")
return networks
def _save_cached_scan(self, networks: List[WiFiNetwork]) -> None:
"""Save scan results to a cache file for use during AP mode."""
try:
cache_path = get_wifi_config_path().parent / "cached_networks.json"
data = [{"ssid": n.ssid, "signal": n.signal, "security": n.security, "frequency": n.frequency} for n in networks]
with open(cache_path, 'w') as f:
json.dump({"timestamp": time.time(), "networks": data}, f)
except Exception as e:
logger.debug(f"Failed to save cached scan: {e}")
def _load_cached_scan(self) -> List[WiFiNetwork]:
"""Load pre-cached scan results (saved before AP mode was enabled)."""
try:
cache_path = get_wifi_config_path().parent / "cached_networks.json"
if not cache_path.exists():
return []
with open(cache_path) as f:
data = json.load(f)
# Accept cache up to 10 minutes old
if time.time() - data.get("timestamp", 0) > 600:
return []
return [WiFiNetwork(ssid=n["ssid"], signal=n["signal"], security=n["security"], frequency=n.get("frequency", 0.0))
for n in data.get("networks", [])]
except Exception as e:
logger.debug(f"Failed to load cached scan: {e}")
return []
finally:
# Always try to restore AP mode if it was active before
if ap_was_active:
logger.info("Re-enabling AP mode after WiFi scan...")
time.sleep(1) # Brief delay before re-enabling
success, message = self.enable_ap_mode()
if success:
logger.info("AP mode re-enabled successfully after scan")
else:
logger.warning(f"Failed to re-enable AP mode after scan: {message}")
# Log but don't fail - user can manually re-enable if needed
def _scan_nmcli(self) -> List[WiFiNetwork]:
"""Scan networks using nmcli"""
networks = []
@@ -1999,26 +2061,16 @@ class WiFiManager:
timeout=10
)
# Restore original dnsmasq config if backup exists (only for hostapd mode)
if hostapd_active:
backup_path = f"{DNSMASQ_CONFIG_PATH}.backup"
if os.path.exists(backup_path):
# Remove the drop-in captive portal config (only for hostapd mode)
if hostapd_active and DNSMASQ_CONFIG_PATH.exists():
try:
subprocess.run(
["sudo", "cp", backup_path, str(DNSMASQ_CONFIG_PATH)],
timeout=10
["sudo", "rm", "-f", str(DNSMASQ_CONFIG_PATH)],
capture_output=True, timeout=5
)
logger.info("Restored original dnsmasq config from backup")
else:
# No backup - clear the captive portal config
# Create a minimal config that won't interfere
minimal_config = "# dnsmasq config - restored to minimal\n"
with open("/tmp/dnsmasq.conf", 'w') as f:
f.write(minimal_config)
subprocess.run(
["sudo", "cp", "/tmp/dnsmasq.conf", str(DNSMASQ_CONFIG_PATH)],
timeout=10
)
logger.info("Cleared dnsmasq captive portal config")
logger.info(f"Removed captive portal dnsmasq config: {DNSMASQ_CONFIG_PATH}")
except Exception as e:
logger.warning(f"Could not remove dnsmasq drop-in config: {e}")
# Remove iptables port forwarding rules and disable IP forwarding (only for hostapd mode)
if hostapd_active:
@@ -2189,26 +2241,14 @@ ignore_broadcast_ssid=0
def _create_dnsmasq_config(self):
"""
Create dnsmasq configuration file with captive portal DNS redirection.
Create dnsmasq drop-in configuration for captive portal DNS redirection.
Note: This will overwrite /etc/dnsmasq.conf. If dnsmasq is already in use
(e.g., for Pi-hole), this may break that service. A backup is created.
Writes to /etc/dnsmasq.d/ledmatrix-captive.conf so we don't overwrite
the main /etc/dnsmasq.conf (preserves Pi-hole, etc.).
"""
try:
# Check for conflicts
conflict, conflict_msg = self._check_dnsmasq_conflict()
if conflict:
logger.warning(f"dnsmasq conflict detected: {conflict_msg}")
logger.warning("Proceeding anyway - backup will be created")
# Backup existing config
if DNSMASQ_CONFIG_PATH.exists():
subprocess.run(
["sudo", "cp", str(DNSMASQ_CONFIG_PATH), f"{DNSMASQ_CONFIG_PATH}.backup"],
timeout=10
)
logger.info(f"Backed up existing dnsmasq config to {DNSMASQ_CONFIG_PATH}.backup")
# Using a drop-in file in /etc/dnsmasq.d/ to avoid overwriting the
# main /etc/dnsmasq.conf (which may belong to Pi-hole or other services).
config_content = f"""interface={self._wifi_interface}
dhcp-range=192.168.4.2,192.168.4.20,255.255.255.0,24h
@@ -2289,7 +2329,16 @@ address=/detectportal.firefox.com/192.168.4.1
self._disconnected_checks >= self._disconnected_checks_required)
if should_have_ap and not ap_active:
# Should have AP but don't - enable AP mode (only if auto-enable is on and grace period passed)
# Pre-cache a WiFi scan so the captive portal can show networks
try:
logger.info("Running pre-AP WiFi scan for captive portal cache...")
networks, _cached = self.scan_networks(allow_cached=False)
if networks:
self._save_cached_scan(networks)
logger.info(f"Cached {len(networks)} networks for captive portal")
except Exception as scan_err:
logger.debug(f"Pre-AP scan failed (non-critical): {scan_err}")
logger.info(f"Enabling AP mode after {self._disconnected_checks} consecutive disconnected checks")
success, message = self.enable_ap_mode()
if success:

View File

@@ -583,3 +583,130 @@ class TestAPIErrorHandling:
response = client.get('/api/v3/display/on-demand/start')
assert response.status_code in [200, 405] # Depends on implementation
class TestDottedKeyNormalization:
"""Regression tests for fix_array_structures / ensure_array_defaults with dotted schema keys."""
def test_save_plugin_config_dotted_key_arrays(self, client, mock_config_manager):
"""Nested dotted-key objects with numeric-keyed dicts are converted to arrays."""
from web_interface.blueprints.api_v3 import api_v3
api_v3.config_manager = mock_config_manager
mock_config_manager.load_config.return_value = {}
schema_mgr = MagicMock()
schema = {
'type': 'object',
'properties': {
'leagues': {
'type': 'object',
'properties': {
'eng.1': {
'type': 'object',
'properties': {
'enabled': {'type': 'boolean', 'default': True},
'favorite_teams': {
'type': 'array',
'items': {'type': 'string'},
'default': [],
},
},
},
},
},
},
}
schema_mgr.load_schema.return_value = schema
schema_mgr.generate_default_config.return_value = {
'leagues': {'eng.1': {'enabled': True, 'favorite_teams': []}},
}
schema_mgr.merge_with_defaults.side_effect = lambda config, defaults: {**defaults, **config}
schema_mgr.validate_config_against_schema.return_value = []
api_v3.schema_manager = schema_mgr
request_data = {
'plugin_id': 'soccer-scoreboard',
'config': {
'leagues': {
'eng.1': {
'enabled': True,
'favorite_teams': ['Arsenal', 'Chelsea'],
},
},
},
}
response = client.post(
'/api/v3/plugins/config',
data=json.dumps(request_data),
content_type='application/json',
)
assert response.status_code == 200, f"Expected 200, got {response.status_code}: {response.data}"
saved = mock_config_manager.save_config_atomic.call_args[0][0]
soccer_cfg = saved.get('soccer-scoreboard', {})
leagues = soccer_cfg.get('leagues', {})
assert 'eng.1' in leagues, f"Expected 'eng.1' key, got: {list(leagues.keys())}"
assert isinstance(leagues['eng.1'].get('favorite_teams'), list)
assert leagues['eng.1']['favorite_teams'] == ['Arsenal', 'Chelsea']
def test_save_plugin_config_none_array_gets_default(self, client, mock_config_manager):
"""None array fields under dotted-key parents are replaced with defaults."""
from web_interface.blueprints.api_v3 import api_v3
api_v3.config_manager = mock_config_manager
mock_config_manager.load_config.return_value = {}
schema_mgr = MagicMock()
schema = {
'type': 'object',
'properties': {
'leagues': {
'type': 'object',
'properties': {
'eng.1': {
'type': 'object',
'properties': {
'favorite_teams': {
'type': 'array',
'items': {'type': 'string'},
'default': [],
},
},
},
},
},
},
}
schema_mgr.load_schema.return_value = schema
schema_mgr.generate_default_config.return_value = {
'leagues': {'eng.1': {'favorite_teams': []}},
}
schema_mgr.merge_with_defaults.side_effect = lambda config, defaults: {**defaults, **config}
schema_mgr.validate_config_against_schema.return_value = []
api_v3.schema_manager = schema_mgr
request_data = {
'plugin_id': 'soccer-scoreboard',
'config': {
'leagues': {
'eng.1': {
'favorite_teams': None,
},
},
},
}
response = client.post(
'/api/v3/plugins/config',
data=json.dumps(request_data),
content_type='application/json',
)
assert response.status_code == 200, f"Expected 200, got {response.status_code}: {response.data}"
saved = mock_config_manager.save_config_atomic.call_args[0][0]
soccer_cfg = saved.get('soccer-scoreboard', {})
teams = soccer_cfg.get('leagues', {}).get('eng.1', {}).get('favorite_teams')
assert isinstance(teams, list), f"Expected list, got: {type(teams)}"
assert teams == [], f"Expected empty default list, got: {teams}"

View File

@@ -1,5 +1,6 @@
from flask import Flask, Blueprint, render_template, request, redirect, url_for, flash, jsonify, Response, send_from_directory
import json
import logging
import os
import sys
import subprocess
@@ -225,48 +226,62 @@ def serve_plugin_asset(plugin_id, filename):
'message': 'Internal server error'
}), 500
# Helper function to check if AP mode is active
# Cached AP mode check — avoids creating a WiFiManager per request
_ap_mode_cache = {'value': False, 'timestamp': 0}
_AP_MODE_CACHE_TTL = 5 # seconds
def is_ap_mode_active():
"""
Check if access point mode is currently active.
Returns:
bool: True if AP mode is active, False otherwise.
Returns False on error to avoid breaking normal operation.
Check if access point mode is currently active (cached, 5s TTL).
Uses a direct systemctl check instead of instantiating WiFiManager.
"""
now = time.time()
if (now - _ap_mode_cache['timestamp']) < _AP_MODE_CACHE_TTL:
return _ap_mode_cache['value']
try:
wifi_manager = WiFiManager()
return wifi_manager._is_ap_mode_active()
except Exception as e:
# Log error but don't break normal operation
# Default to False so normal web interface works even if check fails
print(f"Warning: Could not check AP mode status: {e}")
return False
result = subprocess.run(
['systemctl', 'is-active', 'hostapd'],
capture_output=True, text=True, timeout=2
)
active = result.stdout.strip() == 'active'
_ap_mode_cache['value'] = active
_ap_mode_cache['timestamp'] = now
return active
except (subprocess.SubprocessError, OSError) as e:
logging.getLogger('web_interface').error(f"AP mode check failed: {e}")
return _ap_mode_cache['value']
# Captive portal detection endpoints
# These help devices detect that a captive portal is active
# When AP mode is active, return responses that TRIGGER the captive portal popup.
# When not in AP mode, return normal "success" responses so connectivity checks pass.
@app.route('/hotspot-detect.html')
def hotspot_detect():
"""iOS/macOS captive portal detection endpoint"""
# Return simple HTML that redirects to setup page
if is_ap_mode_active():
# Non-"Success" title triggers iOS captive portal popup
return redirect(url_for('pages_v3.captive_setup'), code=302)
return '<HTML><HEAD><TITLE>Success</TITLE></HEAD><BODY>Success</BODY></HTML>', 200
@app.route('/generate_204')
def generate_204():
"""Android captive portal detection endpoint"""
# Return 204 No Content - Android checks for this
if is_ap_mode_active():
# Android expects 204 = "internet works". Non-204 triggers portal popup.
return redirect(url_for('pages_v3.captive_setup'), code=302)
return '', 204
@app.route('/connecttest.txt')
def connecttest_txt():
"""Windows captive portal detection endpoint"""
# Return simple text response
if is_ap_mode_active():
return redirect(url_for('pages_v3.captive_setup'), code=302)
return 'Microsoft Connect Test', 200
@app.route('/success.txt')
def success_txt():
"""Firefox captive portal detection endpoint"""
# Return simple text response
if is_ap_mode_active():
return redirect(url_for('pages_v3.captive_setup'), code=302)
return 'success', 200
# Initialize logging
@@ -367,10 +382,9 @@ def captive_portal_redirect():
path = request.path
# List of paths that should NOT be redirected (allow normal operation)
# This ensures the full web interface works normally when in AP mode
allowed_paths = [
'/v3', # Main interface and all sub-paths
'/api/v3/', # All API endpoints (plugins, config, wifi, stream, etc.)
'/v3', # Main interface and all sub-paths (includes /v3/setup)
'/api/v3/', # All API endpoints
'/static/', # Static files (CSS, JS, images)
'/hotspot-detect.html', # iOS/macOS detection
'/generate_204', # Android detection
@@ -378,17 +392,13 @@ def captive_portal_redirect():
'/success.txt', # Firefox detection
'/favicon.ico', # Favicon
]
# Check if this path should be allowed
for allowed_path in allowed_paths:
if path.startswith(allowed_path):
return None # Allow this request to proceed normally
# For all other paths, redirect to main interface
# This ensures users see the WiFi setup page when they try to access any website
# The main interface (/v3) is already in allowed_paths, so it won't redirect
# Static files (/static/) and API calls (/api/v3/) are also allowed
return redirect(url_for('pages_v3.index'), code=302)
return None
# Redirect to lightweight captive portal setup page (not the full UI)
return redirect(url_for('pages_v3.captive_setup'), code=302)
# Add security headers and caching to all responses
@app.after_request

View File

@@ -4024,225 +4024,100 @@ def save_plugin_config():
# Post-process: Fix array fields that might have been incorrectly structured
# This handles cases where array fields are stored as dicts (e.g., from indexed form fields)
def fix_array_structures(config_dict, schema_props, prefix=''):
"""Recursively fix array structures (convert dicts with numeric keys to arrays, fix length issues)"""
def fix_array_structures(config_dict, schema_props):
"""Recursively fix array structures (convert dicts with numeric keys to arrays, fix length issues).
config_dict is always the dict at the current nesting level."""
for prop_key, prop_schema in schema_props.items():
prop_type = prop_schema.get('type')
if prop_type == 'array':
# Navigate to the field location
if prefix:
parent_parts = prefix.split('.')
parent = config_dict
for part in parent_parts:
if isinstance(parent, dict) and part in parent:
parent = parent[part]
else:
parent = None
break
if parent is not None and isinstance(parent, dict) and prop_key in parent:
current_value = parent[prop_key]
# If it's a dict with numeric string keys, convert to array
if isinstance(current_value, dict) and not isinstance(current_value, list):
try:
# Check if all keys are numeric strings (array indices)
keys = [k for k in current_value.keys()]
if all(k.isdigit() for k in keys):
# Convert to sorted array by index
sorted_keys = sorted(keys, key=int)
array_value = [current_value[k] for k in sorted_keys]
# Convert array elements to correct types based on schema
items_schema = prop_schema.get('items', {})
item_type = items_schema.get('type')
if item_type in ('number', 'integer'):
converted_array = []
for v in array_value:
if isinstance(v, str):
try:
if item_type == 'integer':
converted_array.append(int(v))
else:
converted_array.append(float(v))
except (ValueError, TypeError):
converted_array.append(v)
else:
if prop_key in config_dict:
current_value = config_dict[prop_key]
# If it's a dict with numeric string keys, convert to array
if isinstance(current_value, dict) and not isinstance(current_value, list):
try:
keys = list(current_value.keys())
if keys and all(str(k).isdigit() for k in keys):
sorted_keys = sorted(keys, key=lambda x: int(str(x)))
array_value = [current_value[k] for k in sorted_keys]
# Convert array elements to correct types based on schema
items_schema = prop_schema.get('items', {})
item_type = items_schema.get('type')
if item_type in ('number', 'integer'):
converted_array = []
for v in array_value:
if isinstance(v, str):
try:
if item_type == 'integer':
converted_array.append(int(v))
else:
converted_array.append(float(v))
except (ValueError, TypeError):
converted_array.append(v)
array_value = converted_array
parent[prop_key] = array_value
current_value = array_value # Update for length check below
except (ValueError, KeyError, TypeError):
# Conversion failed, check if we should use default
pass
# If it's an array, ensure correct types and check minItems
if isinstance(current_value, list):
# First, ensure array elements are correct types
items_schema = prop_schema.get('items', {})
item_type = items_schema.get('type')
if item_type in ('number', 'integer'):
converted_array = []
for v in current_value:
if isinstance(v, str):
try:
if item_type == 'integer':
converted_array.append(int(v))
else:
converted_array.append(float(v))
except (ValueError, TypeError):
else:
converted_array.append(v)
else:
array_value = converted_array
config_dict[prop_key] = array_value
current_value = array_value # Update for length check below
except (ValueError, KeyError, TypeError) as e:
logger.debug(f"Failed to convert {prop_key} to array: {e}")
# If it's an array, ensure correct types and check minItems
if isinstance(current_value, list):
# First, ensure array elements are correct types
items_schema = prop_schema.get('items', {})
item_type = items_schema.get('type')
if item_type in ('number', 'integer'):
converted_array = []
for v in current_value:
if isinstance(v, str):
try:
if item_type == 'integer':
converted_array.append(int(v))
else:
converted_array.append(float(v))
except (ValueError, TypeError):
converted_array.append(v)
parent[prop_key] = converted_array
current_value = converted_array
else:
converted_array.append(v)
config_dict[prop_key] = converted_array
current_value = converted_array
# Then check minItems
min_items = prop_schema.get('minItems')
if min_items is not None and len(current_value) < min_items:
# Use default if available, otherwise keep as-is (validation will catch it)
default = prop_schema.get('default')
if default and isinstance(default, list) and len(default) >= min_items:
parent[prop_key] = default
else:
# Top-level field
if prop_key in config_dict:
current_value = config_dict[prop_key]
# If it's a dict with numeric string keys, convert to array
if isinstance(current_value, dict) and not isinstance(current_value, list):
try:
keys = list(current_value.keys())
if keys and all(str(k).isdigit() for k in keys):
sorted_keys = sorted(keys, key=lambda x: int(str(x)))
array_value = [current_value[k] for k in sorted_keys]
# Convert array elements to correct types based on schema
items_schema = prop_schema.get('items', {})
item_type = items_schema.get('type')
if item_type in ('number', 'integer'):
converted_array = []
for v in array_value:
if isinstance(v, str):
try:
if item_type == 'integer':
converted_array.append(int(v))
else:
converted_array.append(float(v))
except (ValueError, TypeError):
converted_array.append(v)
else:
converted_array.append(v)
array_value = converted_array
config_dict[prop_key] = array_value
current_value = array_value # Update for length check below
except (ValueError, KeyError, TypeError) as e:
logger.debug(f"Failed to convert {prop_key} to array: {e}")
pass
# If it's an array, ensure correct types and check minItems
if isinstance(current_value, list):
# First, ensure array elements are correct types
items_schema = prop_schema.get('items', {})
item_type = items_schema.get('type')
if item_type in ('number', 'integer'):
converted_array = []
for v in current_value:
if isinstance(v, str):
try:
if item_type == 'integer':
converted_array.append(int(v))
else:
converted_array.append(float(v))
except (ValueError, TypeError):
converted_array.append(v)
else:
converted_array.append(v)
config_dict[prop_key] = converted_array
current_value = converted_array
# Then check minItems
min_items = prop_schema.get('minItems')
if min_items is not None and len(current_value) < min_items:
default = prop_schema.get('default')
if default and isinstance(default, list) and len(default) >= min_items:
config_dict[prop_key] = default
# Then check minItems
min_items = prop_schema.get('minItems')
if min_items is not None and len(current_value) < min_items:
default = prop_schema.get('default')
if default and isinstance(default, list) and len(default) >= min_items:
config_dict[prop_key] = default
# Recurse into nested objects
elif prop_type == 'object' and 'properties' in prop_schema:
nested_prefix = f"{prefix}.{prop_key}" if prefix else prop_key
if prefix:
parent_parts = prefix.split('.')
parent = config_dict
for part in parent_parts:
if isinstance(parent, dict) and part in parent:
parent = parent[part]
else:
parent = None
break
nested_dict = parent.get(prop_key) if parent is not None and isinstance(parent, dict) else None
else:
nested_dict = config_dict.get(prop_key)
nested_dict = config_dict.get(prop_key)
if isinstance(nested_dict, dict):
fix_array_structures(nested_dict, prop_schema['properties'], nested_prefix)
fix_array_structures(nested_dict, prop_schema['properties'])
# Also ensure array fields that are None get converted to empty arrays
def ensure_array_defaults(config_dict, schema_props, prefix=''):
"""Recursively ensure array fields have defaults if None"""
def ensure_array_defaults(config_dict, schema_props):
"""Recursively ensure array fields have defaults if None.
config_dict is always the dict at the current nesting level."""
for prop_key, prop_schema in schema_props.items():
prop_type = prop_schema.get('type')
if prop_type == 'array':
if prefix:
parent_parts = prefix.split('.')
parent = config_dict
for part in parent_parts:
if isinstance(parent, dict) and part in parent:
parent = parent[part]
else:
parent = None
break
if parent is not None and isinstance(parent, dict):
if prop_key not in parent or parent[prop_key] is None:
default = prop_schema.get('default', [])
parent[prop_key] = default if default else []
else:
if prop_key not in config_dict or config_dict[prop_key] is None:
default = prop_schema.get('default', [])
config_dict[prop_key] = default if default else []
if prop_key not in config_dict or config_dict[prop_key] is None:
default = prop_schema.get('default', [])
config_dict[prop_key] = default if default else []
elif prop_type == 'object' and 'properties' in prop_schema:
nested_prefix = f"{prefix}.{prop_key}" if prefix else prop_key
if prefix:
parent_parts = prefix.split('.')
parent = config_dict
for part in parent_parts:
if isinstance(parent, dict) and part in parent:
parent = parent[part]
else:
parent = None
break
nested_dict = parent.get(prop_key) if parent is not None and isinstance(parent, dict) else None
else:
nested_dict = config_dict.get(prop_key)
nested_dict = config_dict.get(prop_key)
if nested_dict is None:
if prefix:
parent_parts = prefix.split('.')
parent = config_dict
for part in parent_parts:
if part not in parent:
parent[part] = {}
parent = parent[part]
if prop_key not in parent:
parent[prop_key] = {}
nested_dict = parent[prop_key]
else:
if prop_key not in config_dict:
config_dict[prop_key] = {}
nested_dict = config_dict[prop_key]
config_dict[prop_key] = {}
nested_dict = config_dict[prop_key]
if isinstance(nested_dict, dict):
ensure_array_defaults(nested_dict, prop_schema['properties'], nested_prefix)
ensure_array_defaults(nested_dict, prop_schema['properties'])
if schema and 'properties' in schema:
# First, fix any dict structures that should be arrays
@@ -6488,24 +6363,17 @@ def get_wifi_status():
@api_v3.route('/wifi/scan', methods=['GET'])
def scan_wifi_networks():
"""Scan for available WiFi networks
"""Scan for available WiFi networks.
If AP mode is active, it will be temporarily disabled during scanning
and automatically re-enabled afterward. Users connected to the AP will
be briefly disconnected during this process.
When AP mode is active, returns cached scan results to avoid
disconnecting the user from the setup network.
"""
try:
from src.wifi_manager import WiFiManager
wifi_manager = WiFiManager()
networks, was_cached = wifi_manager.scan_networks()
# Check if AP mode is active before scanning (for user notification)
ap_was_active = wifi_manager._is_ap_mode_active()
# Perform the scan (this will handle AP mode disabling/enabling internally)
networks = wifi_manager.scan_networks()
# Convert to dict format
networks_data = [
{
'ssid': net.ssid,
@@ -6518,16 +6386,14 @@ def scan_wifi_networks():
response_data = {
'status': 'success',
'data': networks_data
'data': networks_data,
'cached': was_cached,
}
# Inform user if AP mode was temporarily disabled
if ap_was_active:
response_data['message'] = (
f'Found {len(networks_data)} networks. '
'Note: AP mode was temporarily disabled during scanning and has been re-enabled. '
'If you were connected to the setup network, you may need to reconnect.'
)
if was_cached and networks_data:
response_data['message'] = f'Found {len(networks_data)} cached networks.'
elif was_cached and not networks_data:
response_data['message'] = 'No cached networks available. Enter your network name manually.'
return jsonify(response_data)
except Exception as e:

View File

@@ -296,6 +296,11 @@ def _load_raw_json_partial():
except Exception as e:
return f"Error: {str(e)}", 500
@pages_v3.route('/setup')
def captive_setup():
"""Lightweight captive portal setup page — self-contained, no frameworks."""
return render_template('v3/captive_setup.html')
def _load_wifi_partial():
"""Load WiFi setup partial"""
try:

View File

@@ -2265,29 +2265,39 @@ window.showPluginConfigModal = function(pluginId, config) {
}
// Helper function to get the full property object from schema
// Uses greedy longest-match to handle schema keys containing dots (e.g., "eng.1")
function getSchemaProperty(schema, path) {
if (!schema || !schema.properties) return null;
const parts = path.split('.');
let current = schema.properties;
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
if (current && current[part]) {
if (i === parts.length - 1) {
// Last part - return the property
return current[part];
} else if (current[part].properties) {
// Navigate into nested object
current = current[part].properties;
} else {
return null;
let i = 0;
while (i < parts.length) {
let matched = false;
// Try progressively longer candidates, longest first
for (let j = parts.length; j > i; j--) {
const candidate = parts.slice(i, j).join('.');
if (current && current[candidate]) {
if (j === parts.length) {
// Consumed all remaining parts — done
return current[candidate];
}
if (current[candidate].properties) {
current = current[candidate].properties;
i = j;
matched = true;
break;
} else {
return null; // Can't navigate deeper
}
}
} else {
}
if (!matched) {
return null;
}
}
return null;
}
@@ -2311,23 +2321,70 @@ function escapeCssSelector(str) {
}
// Helper function to convert dot notation to nested object
function dotToNested(obj) {
// Uses schema-aware greedy matching to preserve dotted keys (e.g., "eng.1")
function dotToNested(obj, schema) {
const result = {};
for (const key in obj) {
const parts = key.split('.');
let current = result;
for (let i = 0; i < parts.length - 1; i++) {
if (!current[parts[i]]) {
current[parts[i]] = {};
let currentSchema = (schema && schema.properties) ? schema.properties : null;
let i = 0;
while (i < parts.length - 1) {
let matched = false;
if (currentSchema) {
// First, check if the full remaining tail is a leaf property
// (e.g., "eng.1" as a complete dotted key with no sub-properties)
const tailCandidate = parts.slice(i).join('.');
if (tailCandidate in currentSchema) {
current[tailCandidate] = obj[key];
matched = true;
i = parts.length; // consumed all parts
break;
}
// Try progressively longer candidates (longest first) to greedily
// match dotted property names like "eng.1"
for (let j = parts.length - 1; j > i; j--) {
const candidate = parts.slice(i, j).join('.');
if (candidate in currentSchema) {
if (!current[candidate]) {
current[candidate] = {};
}
current = current[candidate];
const schemaProp = currentSchema[candidate];
currentSchema = (schemaProp && schemaProp.properties) ? schemaProp.properties : null;
i = j;
matched = true;
break;
}
}
}
if (!matched) {
// No schema match or no schema — use single segment
const part = parts[i];
if (!current[part]) {
current[part] = {};
}
current = current[part];
if (currentSchema) {
const schemaProp = currentSchema[part];
currentSchema = (schemaProp && schemaProp.properties) ? schemaProp.properties : null;
} else {
currentSchema = null;
}
i++;
}
current = current[parts[i]];
}
current[parts[parts.length - 1]] = obj[key];
// Set the final key (remaining parts joined — may itself be dotted)
// Skip if tail-matching already consumed all parts and wrote the value
if (i < parts.length) {
const finalKey = parts.slice(i).join('.');
current[finalKey] = obj[key];
}
}
return result;
}
@@ -2350,42 +2407,20 @@ function collectBooleanFields(schema, prefix = '') {
return boolFields;
}
function handlePluginConfigSubmit(e) {
e.preventDefault();
console.log('Form submitted');
if (!currentPluginConfig) {
showNotification('Plugin configuration not loaded', 'error');
return;
}
const pluginId = currentPluginConfig.pluginId;
const schema = currentPluginConfig.schema;
const form = e.target;
// Fix invalid hidden fields before submission
// This prevents "invalid form control is not focusable" errors
const allInputs = form.querySelectorAll('input[type="number"]');
allInputs.forEach(input => {
const min = parseFloat(input.getAttribute('min'));
const max = parseFloat(input.getAttribute('max'));
const value = parseFloat(input.value);
if (!isNaN(value)) {
if (!isNaN(min) && value < min) {
input.value = min;
} else if (!isNaN(max) && value > max) {
input.value = max;
}
}
});
/**
* Normalize FormData from a plugin config form into a nested config object.
* Handles _data JSON inputs, bracket-notation checkboxes, array-of-objects,
* file-upload widgets, proper checkbox DOM detection, unchecked boolean
* handling, and schema-aware dotted-key nesting.
*
* @param {HTMLFormElement} form - The form element (needed for checkbox DOM detection)
* @param {Object|null} schema - The plugin's JSON Schema
* @returns {Object} Nested config object ready for saving
*/
function normalizeFormDataForConfig(form, schema) {
const formData = new FormData(form);
const flatConfig = {};
console.log('Schema loaded:', schema ? 'Yes' : 'No');
// Process form data with type conversion (using dot notation for nested fields)
for (const [key, value] of formData.entries()) {
// Check if this is a patternProperties or array-of-objects hidden input (contains JSON data)
// Only match keys ending with '_data' to avoid false positives like 'meta_data_field'
@@ -2397,36 +2432,35 @@ function handlePluginConfigSubmit(e) {
// Only treat as JSON-backed when it's a non-null object (null is typeof 'object' in JavaScript)
if (jsonValue !== null && typeof jsonValue === 'object') {
flatConfig[baseKey] = jsonValue;
console.log(`JSON data field ${baseKey}: parsed ${Array.isArray(jsonValue) ? 'array' : 'object'}`, jsonValue);
continue; // Skip normal processing for JSON data fields
}
} catch (e) {
// Not valid JSON, continue with normal processing
}
}
// Skip checkbox-group inputs with bracket notation (they're handled by the hidden _data input)
// Pattern: fieldName[] - these are individual checkboxes, actual data is in fieldName_data
if (key.endsWith('[]')) {
continue;
}
// Skip key_value pair inputs (they're handled by the hidden _data input)
if (key.includes('[key_') || key.includes('[value_')) {
continue;
}
// Skip array-of-objects per-item inputs (they're handled by the hidden _data input)
// Pattern: feeds_item_0_name, feeds_item_1_url, etc.
if (key.includes('_item_') && /_item_\d+_/.test(key)) {
continue;
}
// Try to get schema property - handle both dot notation and underscore notation
let propSchema = getSchemaPropertyType(schema, key);
let actualKey = key;
let actualValue = value;
// If not found with dots, try converting underscores to dots (for nested fields)
if (!propSchema && key.includes('_')) {
const dotKey = key.replace(/_/g, '.');
@@ -2437,10 +2471,10 @@ function handlePluginConfigSubmit(e) {
actualValue = value;
}
}
if (propSchema) {
const propType = propSchema.type;
if (propType === 'array') {
// Check if this is a file upload widget (JSON array)
if (propSchema['x-widget'] === 'file-upload') {
@@ -2454,11 +2488,10 @@ function handlePluginConfigSubmit(e) {
tempDiv.innerHTML = actualValue;
decodedValue = tempDiv.textContent || tempDiv.innerText || actualValue;
}
const jsonValue = JSON.parse(decodedValue);
if (Array.isArray(jsonValue)) {
flatConfig[actualKey] = jsonValue;
console.log(`File upload array field ${actualKey}: parsed JSON array with ${jsonValue.length} items`);
} else {
// Fallback to comma-separated
const arrayValue = decodedValue ? decodedValue.split(',').map(v => v.trim()).filter(v => v) : [];
@@ -2468,13 +2501,11 @@ function handlePluginConfigSubmit(e) {
// Not JSON, use comma-separated
const arrayValue = actualValue ? actualValue.split(',').map(v => v.trim()).filter(v => v) : [];
flatConfig[actualKey] = arrayValue;
console.log(`Array field ${actualKey}: "${actualValue}" -> `, arrayValue);
}
} else {
// Regular array: convert comma-separated string to array
const arrayValue = actualValue ? actualValue.split(',').map(v => v.trim()).filter(v => v) : [];
flatConfig[actualKey] = arrayValue;
console.log(`Array field ${actualKey}: "${actualValue}" -> `, arrayValue);
}
} else if (propType === 'integer') {
flatConfig[actualKey] = parseInt(actualValue, 10);
@@ -2485,14 +2516,13 @@ function handlePluginConfigSubmit(e) {
// Escape special CSS selector characters in the name
const escapedKey = escapeCssSelector(key);
const formElement = form.querySelector(`input[type="checkbox"][name="${escapedKey}"]`);
if (formElement) {
// Element found - use its checked state
flatConfig[actualKey] = formElement.checked;
} else {
// Element not found - normalize string booleans and check FormData value
// Checkboxes send "on" when checked, nothing when unchecked
// Normalize string representations of booleans
if (typeof actualValue === 'string') {
const lowerValue = actualValue.toLowerCase().trim();
if (lowerValue === 'true' || lowerValue === '1' || lowerValue === 'on') {
@@ -2500,13 +2530,11 @@ function handlePluginConfigSubmit(e) {
} else if (lowerValue === 'false' || lowerValue === '0' || lowerValue === 'off' || lowerValue === '') {
flatConfig[actualKey] = false;
} else {
// Non-empty string that's not a boolean representation - treat as truthy
flatConfig[actualKey] = true;
}
} else if (actualValue === undefined || actualValue === null) {
flatConfig[actualKey] = false;
} else {
// Non-string value - coerce to boolean
flatConfig[actualKey] = Boolean(actualValue);
}
}
@@ -2523,10 +2551,9 @@ function handlePluginConfigSubmit(e) {
const tempDiv = document.createElement('div');
tempDiv.innerHTML = actualValue;
decodedValue = tempDiv.textContent || tempDiv.innerText || actualValue;
const parsed = JSON.parse(decodedValue);
flatConfig[actualKey] = parsed;
console.log(`No schema for ${actualKey}, but parsed as JSON:`, parsed);
} catch (e) {
// Not valid JSON, save as string
flatConfig[actualKey] = actualValue;
@@ -2535,12 +2562,10 @@ function handlePluginConfigSubmit(e) {
// No schema - try to detect checkbox by finding the element
const escapedKey = escapeCssSelector(key);
const formElement = form.querySelector(`input[type="checkbox"][name="${escapedKey}"]`);
if (formElement && formElement.type === 'checkbox') {
// Found checkbox element - use its checked state
flatConfig[actualKey] = formElement.checked;
} else {
// Not a checkbox or element not found - normalize string booleans
if (typeof actualValue === 'string') {
const lowerValue = actualValue.toLowerCase().trim();
if (lowerValue === 'true' || lowerValue === '1' || lowerValue === 'on') {
@@ -2548,18 +2573,16 @@ function handlePluginConfigSubmit(e) {
} else if (lowerValue === 'false' || lowerValue === '0' || lowerValue === 'off' || lowerValue === '') {
flatConfig[actualKey] = false;
} else {
// Non-empty string that's not a boolean representation - keep as string
flatConfig[actualKey] = actualValue;
}
} else {
// Non-string value - use as-is
flatConfig[actualKey] = actualValue;
}
}
}
}
}
// Handle unchecked checkboxes (not in FormData) - including nested ones
if (schema && schema.properties) {
const allBoolFields = collectBooleanFields(schema);
@@ -2569,11 +2592,43 @@ function handlePluginConfigSubmit(e) {
}
});
}
// Convert dot notation to nested object
const config = dotToNested(flatConfig);
console.log('Flat config:', flatConfig);
return dotToNested(flatConfig, schema);
}
function handlePluginConfigSubmit(e) {
e.preventDefault();
console.log('Form submitted');
if (!currentPluginConfig) {
showNotification('Plugin configuration not loaded', 'error');
return;
}
const pluginId = currentPluginConfig.pluginId;
const schema = currentPluginConfig.schema;
const form = e.target;
// Fix invalid hidden fields before submission
// This prevents "invalid form control is not focusable" errors
const allInputs = form.querySelectorAll('input[type="number"]');
allInputs.forEach(input => {
const min = parseFloat(input.getAttribute('min'));
const max = parseFloat(input.getAttribute('max'));
const value = parseFloat(input.value);
if (!isNaN(value)) {
if (!isNaN(min) && value < min) {
input.value = min;
} else if (!isNaN(max) && value > max) {
input.value = max;
}
}
});
const config = normalizeFormDataForConfig(form, schema);
console.log('Nested config to save:', config);
// Save the configuration
@@ -4418,42 +4473,9 @@ function switchPluginConfigView(view) {
function syncFormToJson() {
const form = document.getElementById('plugin-config-form');
if (!form) return;
const formData = new FormData(form);
const config = {};
// Get schema for type conversion
const schema = currentPluginConfigState.schema;
for (let [key, value] of formData.entries()) {
if (key === 'enabled') continue; // Skip enabled, managed separately
// Handle nested keys (dot notation)
const keys = key.split('.');
let current = config;
for (let i = 0; i < keys.length - 1; i++) {
if (!current[keys[i]]) {
current[keys[i]] = {};
}
current = current[keys[i]];
}
const finalKey = keys[keys.length - 1];
const prop = schema?.properties?.[finalKey] || (keys.length > 1 ? null : schema?.properties?.[key]);
// Type conversion based on schema
if (prop?.type === 'array') {
current[finalKey] = value.split(',').map(item => item.trim()).filter(item => item.length > 0);
} else if (prop?.type === 'integer' || key === 'display_duration') {
current[finalKey] = parseInt(value) || 0;
} else if (prop?.type === 'number') {
current[finalKey] = parseFloat(value) || 0;
} else if (prop?.type === 'boolean') {
current[finalKey] = value === 'true' || value === true;
} else {
current[finalKey] = value;
}
}
const config = normalizeFormDataForConfig(form, schema);
// Deep merge with existing config to preserve nested structures
function deepMerge(target, source) {

View File

@@ -0,0 +1,249 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LEDMatrix WiFi Setup</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#f3f4f6;color:#1f2937;padding:16px;max-width:480px;margin:0 auto}
h1{font-size:20px;margin-bottom:4px}
.subtitle{color:#6b7280;font-size:13px;margin-bottom:20px}
.card{background:#fff;border-radius:12px;padding:20px;box-shadow:0 1px 3px rgba(0,0,0,.1);margin-bottom:16px}
label{display:block;font-size:13px;font-weight:600;color:#374151;margin-bottom:6px}
select,input[type=text],input[type=password]{width:100%;padding:10px 12px;border:1px solid #d1d5db;border-radius:8px;font-size:15px;background:#fff;-webkit-appearance:none;appearance:none}
select{background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%236b7280' stroke-width='1.5' fill='none'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 12px center}
select:focus,input:focus{outline:none;border-color:#3b82f6;box-shadow:0 0 0 3px rgba(59,130,246,.15)}
.btn{display:block;width:100%;padding:12px;border:none;border-radius:8px;font-size:15px;font-weight:600;cursor:pointer;text-align:center;transition:background .15s}
.btn-primary{background:#2563eb;color:#fff}.btn-primary:hover{background:#1d4ed8}
.btn-scan{background:#e5e7eb;color:#374151}.btn-scan:hover{background:#d1d5db}
.btn:disabled{background:#d1d5db;color:#9ca3af;cursor:not-allowed}
.row{display:flex;gap:8px;margin-bottom:16px}
.row>*:first-child{flex:1}
.msg{padding:12px;border-radius:8px;font-size:13px;margin-bottom:12px;display:none}
.msg-ok{background:#d1fae5;color:#065f46;display:block}
.msg-err{background:#fee2e2;color:#991b1b;display:block}
.msg-info{background:#dbeafe;color:#1e40af;display:block}
.step{font-size:12px;color:#6b7280;text-transform:uppercase;letter-spacing:.5px;margin-bottom:4px}
.sep{margin:16px 0;border:none;border-top:1px solid #e5e7eb}
.spinner{display:inline-block;width:14px;height:14px;border:2px solid rgba(255,255,255,.3);border-top-color:#fff;border-radius:50%;animation:spin .6s linear infinite;vertical-align:middle;margin-right:6px}
@keyframes spin{to{transform:rotate(360deg)}}
.footer{text-align:center;margin-top:20px;font-size:12px;color:#9ca3af}
.footer a{color:#3b82f6;text-decoration:none}
.success-box{text-align:center;padding:24px}
.success-box .icon{font-size:48px;margin-bottom:12px}
.success-box .ip{font-size:18px;font-weight:700;color:#2563eb;word-break:break-all}
.hidden{display:none}
.or-divider{text-align:center;color:#9ca3af;font-size:12px;margin:12px 0;position:relative}
.or-divider::before,.or-divider::after{content:'';position:absolute;top:50%;width:40%;height:1px;background:#e5e7eb}
.or-divider::before{left:0}
.or-divider::after{right:0}
</style>
</head>
<body>
<h1>LEDMatrix WiFi Setup</h1>
<p class="subtitle">Connect your device to a WiFi network</p>
<div id="msg" class="msg"></div>
<div id="setup-form">
<div class="card">
<div class="step">Step 1 &mdash; Choose Network</div>
<label for="net-select">Available Networks</label>
<div class="row">
<select id="net-select" onchange="onSelectNetwork()">
<option value="">-- Scan to find networks --</option>
</select>
<button class="btn btn-scan" id="btn-scan" onclick="doScan()" style="width:auto;padding:10px 16px">
Scan
</button>
</div>
<div class="or-divider">or enter manually</div>
<input type="text" id="manual-ssid" placeholder="Network name (SSID)" oninput="onManualInput()">
</div>
<div class="card">
<div class="step">Step 2 &mdash; Password</div>
<label for="password">WiFi Password</label>
<input type="password" id="password" placeholder="Leave empty for open networks">
</div>
<div class="card">
<button class="btn btn-primary" id="btn-connect" onclick="doConnect()" disabled>
Connect
</button>
</div>
</div>
<div id="success-view" class="card hidden">
<div class="success-box">
<div class="icon">&#10003;</div>
<p style="font-size:16px;font-weight:600;margin-bottom:8px">Connected!</p>
<p style="font-size:13px;color:#6b7280;margin-bottom:12px">Your device is now on the network. Access the full interface at:</p>
<p class="ip" id="new-ip"></p>
<p style="font-size:12px;color:#9ca3af;margin-top:12px">You may need to reconnect your phone to the same WiFi network.</p>
</div>
</div>
<div class="footer">
<a href="/v3">Open Full Interface</a>
</div>
<script>
var selectedSSID = '';
var scanning = false;
var connecting = false;
function $(id) { return document.getElementById(id); }
function showMsg(text, type) {
var el = $('msg');
el.textContent = text;
el.className = 'msg msg-' + (type || 'info');
if (type === 'ok') setTimeout(function() { el.style.display = 'none'; }, 8000);
}
function clearMsg() { $('msg').className = 'msg'; }
function updateConnectBtn() {
var ssid = $('net-select').value || $('manual-ssid').value.trim();
$('btn-connect').disabled = !ssid || connecting;
}
function onSelectNetwork() {
$('manual-ssid').value = '';
selectedSSID = $('net-select').value;
updateConnectBtn();
}
function onManualInput() {
$('net-select').value = '';
selectedSSID = '';
updateConnectBtn();
}
function doScan() {
if (scanning) return;
scanning = true;
var btn = $('btn-scan');
btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span>Scanning';
clearMsg();
fetch('/api/v3/wifi/scan')
.then(function(r) { return r.json(); })
.then(function(data) {
var sel = $('net-select');
sel.innerHTML = '<option value="">-- Select a network --</option>';
if (data.status === 'success' && Array.isArray(data.data)) {
var nets = data.data;
for (var i = 0; i < nets.length; i++) {
var n = nets[i];
var opt = document.createElement('option');
opt.value = n.ssid;
opt.textContent = n.ssid + ' (' + n.signal + '% - ' + n.security + ')';
sel.appendChild(opt);
}
if (nets.length > 0) {
var msg = 'Found ' + nets.length + ' network' + (nets.length > 1 ? 's' : '');
if (data.cached) {
msg += ' \u2014 Showing cached networks. Connect to see the latest.';
}
showMsg(msg, data.cached ? 'info' : 'ok');
} else {
showMsg('No networks found. ' + (data.cached ? 'Enter your network name manually.' : 'Try scanning again.'), 'info');
}
} else {
showMsg(data.message || 'Scan failed', 'err');
}
})
.catch(function(e) {
showMsg('Scan failed: ' + e.message, 'err');
})
.finally(function() {
scanning = false;
btn.disabled = false;
btn.innerHTML = 'Scan';
updateConnectBtn();
});
}
function doConnect() {
var ssid = $('net-select').value || $('manual-ssid').value.trim();
if (!ssid || connecting) return;
connecting = true;
var btn = $('btn-connect');
btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span>Connecting...';
clearMsg();
showMsg('Connecting to ' + ssid + '... This may take 15-30 seconds.', 'info');
fetch('/api/v3/wifi/connect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ssid: ssid, password: $('password').value || '' })
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.status === 'success') {
clearMsg();
// Poll for the new IP
setTimeout(function() { checkNewIP(ssid); }, 3000);
} else {
showMsg(data.message || 'Connection failed', 'err');
connecting = false;
btn.disabled = false;
btn.innerHTML = 'Connect';
}
})
.catch(function(e) {
// Connection may drop if AP mode was disabled — that's expected
clearMsg();
showMsg('Connection attempt sent. If the page stops responding, the device is connecting to ' + ssid + '.', 'info');
setTimeout(function() { showSuccessFallback(ssid); }, 5000);
});
}
var MAX_IP_RETRIES = 20;
function checkNewIP(ssid, retriesLeft) {
if (retriesLeft === undefined) retriesLeft = MAX_IP_RETRIES;
if (retriesLeft <= 0) {
showSuccessFallback(ssid);
return;
}
fetch('/api/v3/wifi/status')
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.status === 'success' && data.data && data.data.connected && data.data.ip_address) {
showSuccess(data.data.ip_address);
} else {
setTimeout(function() { checkNewIP(ssid, retriesLeft - 1); }, 3000);
}
})
.catch(function() {
// AP likely down — show fallback
showSuccessFallback(ssid);
});
}
function showSuccess(ip) {
$('setup-form').classList.add('hidden');
$('success-view').classList.remove('hidden');
$('new-ip').textContent = 'http://' + ip + ':5000';
$('msg').className = 'msg';
}
function showSuccessFallback(ssid) {
$('setup-form').classList.add('hidden');
$('success-view').classList.remove('hidden');
$('new-ip').textContent = 'Check your router for the device IP';
$('msg').className = 'msg';
}
// Auto-scan on load
doScan();
</script>
</body>
</html>

View File

@@ -215,9 +215,6 @@
var fontOverrides = window.fontOverrides;
var selectedFontFiles = window.selectedFontFiles;
// Base URL for API calls (shared scope)
var baseUrl = window.location.origin;
// Retry counter for initialization
var initRetryCount = 0;
var MAX_INIT_RETRIES = 50; // 5 seconds max (50 * 100ms)
@@ -384,9 +381,9 @@ async function loadFontData() {
try {
// Use absolute URLs to ensure they work when loaded via HTMX
const [catalogRes, tokensRes, overridesRes] = await Promise.all([
fetch(`${baseUrl}/api/v3/fonts/catalog`),
fetch(`${baseUrl}/api/v3/fonts/tokens`),
fetch(`${baseUrl}/api/v3/fonts/overrides`)
fetch(`/api/v3/fonts/catalog`),
fetch(`/api/v3/fonts/tokens`),
fetch(`/api/v3/fonts/overrides`)
]);
// Check if all responses are successful
@@ -558,7 +555,7 @@ async function deleteFont(fontFamily) {
}
try {
const response = await fetch(`${baseUrl}/api/v3/fonts/${encodeURIComponent(fontFamily)}`, {
const response = await fetch(`/api/v3/fonts/${encodeURIComponent(fontFamily)}`, {
method: 'DELETE'
});
@@ -667,7 +664,7 @@ async function addFontOverride() {
if (sizePx) overrideData.size_px = sizePx;
}
const response = await fetch(`${baseUrl}/api/v3/fonts/overrides`, {
const response = await fetch(`/api/v3/fonts/overrides`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -712,7 +709,7 @@ async function deleteFontOverride(elementKey) {
}
try {
const response = await fetch(`${baseUrl}/api/v3/fonts/overrides/${elementKey}`, {
const response = await fetch(`/api/v3/fonts/overrides/${elementKey}`, {
method: 'DELETE'
});
@@ -860,7 +857,7 @@ async function updateFontPreview() {
fg: 'ffffff'
});
const response = await fetch(`${baseUrl}/api/v3/fonts/preview?${params}`);
const response = await fetch(`/api/v3/fonts/preview?${params}`);
if (!response.ok) {
const text = await response.text();
@@ -990,7 +987,7 @@ async function uploadSelectedFonts() {
formData.append('font_file', file);
formData.append('font_family', i === 0 ? fontFamily : `${fontFamily}_${i + 1}`);
const response = await fetch(`${baseUrl}/api/v3/fonts/upload`, {
const response = await fetch(`/api/v3/fonts/upload`, {
method: 'POST',
body: formData
});

View File

@@ -114,7 +114,7 @@
<input type="text"
id="manual-ssid"
x-model="manualSSID"
@input="selectedSSID = ''; selectedSSID = $event.target.value"
@input="selectedSSID = ''"
placeholder="Enter network name"
class="form-control">
</div>