mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-04-10 13:02:59 +00:00
Compare commits
3 Commits
2c2fca2219
...
35df06b8e1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35df06b8e1 | ||
|
|
77e9eba294 | ||
|
|
6eccb74415 |
@@ -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:"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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) {
|
||||
|
||||
249
web_interface/templates/v3/captive_setup.html
Normal file
249
web_interface/templates/v3/captive_setup.html
Normal 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 — 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 — 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">✓</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>
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user