mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-04-25 10:13:01 +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 stop dnsmasq
|
||||||
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart dnsmasq
|
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart dnsmasq
|
||||||
$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart NetworkManager
|
$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
|
EOF
|
||||||
|
|
||||||
echo "Generated sudoers configuration:"
|
echo "Generated sudoers configuration:"
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ Sudoers Requirements:
|
|||||||
ledpi ALL=(ALL) NOPASSWD: /usr/sbin/iptables
|
ledpi ALL=(ALL) NOPASSWD: /usr/sbin/iptables
|
||||||
ledpi ALL=(ALL) NOPASSWD: /usr/sbin/sysctl
|
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/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
|
import subprocess
|
||||||
@@ -58,7 +59,7 @@ def get_wifi_config_path():
|
|||||||
return Path(project_root) / "config" / "wifi_config.json"
|
return Path(project_root) / "config" / "wifi_config.json"
|
||||||
|
|
||||||
HOSTAPD_CONFIG_PATH = Path("/etc/hostapd/hostapd.conf")
|
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"
|
HOSTAPD_SERVICE = "hostapd"
|
||||||
DNSMASQ_SERVICE = "dnsmasq"
|
DNSMASQ_SERVICE = "dnsmasq"
|
||||||
|
|
||||||
@@ -658,33 +659,31 @@ class WiFiManager:
|
|||||||
except (subprocess.TimeoutExpired, subprocess.SubprocessError, OSError):
|
except (subprocess.TimeoutExpired, subprocess.SubprocessError, OSError):
|
||||||
return False
|
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
|
Scan for available WiFi networks.
|
||||||
|
|
||||||
If AP mode is active, it will be temporarily disabled during scanning
|
When AP mode is active, returns cached scan results instead of
|
||||||
and re-enabled afterward. This is necessary because WiFi interfaces
|
disabling AP (which would disconnect the user). Cached results
|
||||||
in AP mode cannot scan for other networks.
|
come from either nmcli's internal cache or a pre-scan file saved
|
||||||
|
before AP mode was enabled.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of WiFiNetwork objects
|
Tuple of (list of WiFiNetwork objects, was_cached bool)
|
||||||
"""
|
"""
|
||||||
ap_was_active = False
|
|
||||||
try:
|
try:
|
||||||
# Check if AP mode is active - if so, we need to disable it temporarily
|
ap_active = self._is_ap_mode_active()
|
||||||
ap_was_active = self._is_ap_mode_active()
|
|
||||||
|
if ap_active:
|
||||||
if ap_was_active:
|
# Don't disable AP — user would lose their connection.
|
||||||
logger.info("AP mode is active, temporarily disabling for WiFi scan...")
|
# Try nmcli cached results first (no rescan trigger).
|
||||||
success, message = self.disable_ap_mode()
|
logger.info("AP mode active — returning cached scan results")
|
||||||
if not success:
|
networks = self._scan_nmcli_cached()
|
||||||
logger.warning(f"Failed to disable AP mode for scanning: {message}")
|
if not networks and allow_cached:
|
||||||
# Continue anyway - scan might still work
|
networks = self._load_cached_scan()
|
||||||
else:
|
return networks, True
|
||||||
# Wait for interface to switch modes
|
|
||||||
time.sleep(3)
|
# Normal scan (not in AP mode)
|
||||||
|
|
||||||
# Perform the scan
|
|
||||||
if self.has_nmcli:
|
if self.has_nmcli:
|
||||||
networks = self._scan_nmcli()
|
networks = self._scan_nmcli()
|
||||||
elif self.has_iwlist:
|
elif self.has_iwlist:
|
||||||
@@ -692,24 +691,87 @@ class WiFiManager:
|
|||||||
else:
|
else:
|
||||||
logger.error("No WiFi scanning tools available")
|
logger.error("No WiFi scanning tools available")
|
||||||
networks = []
|
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:
|
except Exception as e:
|
||||||
logger.error(f"Error scanning networks: {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 []
|
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]:
|
def _scan_nmcli(self) -> List[WiFiNetwork]:
|
||||||
"""Scan networks using nmcli"""
|
"""Scan networks using nmcli"""
|
||||||
networks = []
|
networks = []
|
||||||
@@ -1999,26 +2061,16 @@ class WiFiManager:
|
|||||||
timeout=10
|
timeout=10
|
||||||
)
|
)
|
||||||
|
|
||||||
# Restore original dnsmasq config if backup exists (only for hostapd mode)
|
# Remove the drop-in captive portal config (only for hostapd mode)
|
||||||
if hostapd_active:
|
if hostapd_active and DNSMASQ_CONFIG_PATH.exists():
|
||||||
backup_path = f"{DNSMASQ_CONFIG_PATH}.backup"
|
try:
|
||||||
if os.path.exists(backup_path):
|
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["sudo", "cp", backup_path, str(DNSMASQ_CONFIG_PATH)],
|
["sudo", "rm", "-f", str(DNSMASQ_CONFIG_PATH)],
|
||||||
timeout=10
|
capture_output=True, timeout=5
|
||||||
)
|
)
|
||||||
logger.info("Restored original dnsmasq config from backup")
|
logger.info(f"Removed captive portal dnsmasq config: {DNSMASQ_CONFIG_PATH}")
|
||||||
else:
|
except Exception as e:
|
||||||
# No backup - clear the captive portal config
|
logger.warning(f"Could not remove dnsmasq drop-in config: {e}")
|
||||||
# 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")
|
|
||||||
|
|
||||||
# Remove iptables port forwarding rules and disable IP forwarding (only for hostapd mode)
|
# Remove iptables port forwarding rules and disable IP forwarding (only for hostapd mode)
|
||||||
if hostapd_active:
|
if hostapd_active:
|
||||||
@@ -2189,26 +2241,14 @@ ignore_broadcast_ssid=0
|
|||||||
|
|
||||||
def _create_dnsmasq_config(self):
|
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
|
Writes to /etc/dnsmasq.d/ledmatrix-captive.conf so we don't overwrite
|
||||||
(e.g., for Pi-hole), this may break that service. A backup is created.
|
the main /etc/dnsmasq.conf (preserves Pi-hole, etc.).
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Check for conflicts
|
# Using a drop-in file in /etc/dnsmasq.d/ to avoid overwriting the
|
||||||
conflict, conflict_msg = self._check_dnsmasq_conflict()
|
# main /etc/dnsmasq.conf (which may belong to Pi-hole or other services).
|
||||||
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")
|
|
||||||
|
|
||||||
config_content = f"""interface={self._wifi_interface}
|
config_content = f"""interface={self._wifi_interface}
|
||||||
dhcp-range=192.168.4.2,192.168.4.20,255.255.255.0,24h
|
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)
|
self._disconnected_checks >= self._disconnected_checks_required)
|
||||||
|
|
||||||
if should_have_ap and not ap_active:
|
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")
|
logger.info(f"Enabling AP mode after {self._disconnected_checks} consecutive disconnected checks")
|
||||||
success, message = self.enable_ap_mode()
|
success, message = self.enable_ap_mode()
|
||||||
if success:
|
if success:
|
||||||
|
|||||||
@@ -583,3 +583,130 @@ class TestAPIErrorHandling:
|
|||||||
response = client.get('/api/v3/display/on-demand/start')
|
response = client.get('/api/v3/display/on-demand/start')
|
||||||
|
|
||||||
assert response.status_code in [200, 405] # Depends on implementation
|
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
|
from flask import Flask, Blueprint, render_template, request, redirect, url_for, flash, jsonify, Response, send_from_directory
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -225,48 +226,62 @@ def serve_plugin_asset(plugin_id, filename):
|
|||||||
'message': 'Internal server error'
|
'message': 'Internal server error'
|
||||||
}), 500
|
}), 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():
|
def is_ap_mode_active():
|
||||||
"""
|
"""
|
||||||
Check if access point mode is currently active.
|
Check if access point mode is currently active (cached, 5s TTL).
|
||||||
|
Uses a direct systemctl check instead of instantiating WiFiManager.
|
||||||
Returns:
|
|
||||||
bool: True if AP mode is active, False otherwise.
|
|
||||||
Returns False on error to avoid breaking normal operation.
|
|
||||||
"""
|
"""
|
||||||
|
now = time.time()
|
||||||
|
if (now - _ap_mode_cache['timestamp']) < _AP_MODE_CACHE_TTL:
|
||||||
|
return _ap_mode_cache['value']
|
||||||
try:
|
try:
|
||||||
wifi_manager = WiFiManager()
|
result = subprocess.run(
|
||||||
return wifi_manager._is_ap_mode_active()
|
['systemctl', 'is-active', 'hostapd'],
|
||||||
except Exception as e:
|
capture_output=True, text=True, timeout=2
|
||||||
# Log error but don't break normal operation
|
)
|
||||||
# Default to False so normal web interface works even if check fails
|
active = result.stdout.strip() == 'active'
|
||||||
print(f"Warning: Could not check AP mode status: {e}")
|
_ap_mode_cache['value'] = active
|
||||||
return False
|
_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
|
# 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')
|
@app.route('/hotspot-detect.html')
|
||||||
def hotspot_detect():
|
def hotspot_detect():
|
||||||
"""iOS/macOS captive portal detection endpoint"""
|
"""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
|
return '<HTML><HEAD><TITLE>Success</TITLE></HEAD><BODY>Success</BODY></HTML>', 200
|
||||||
|
|
||||||
@app.route('/generate_204')
|
@app.route('/generate_204')
|
||||||
def generate_204():
|
def generate_204():
|
||||||
"""Android captive portal detection endpoint"""
|
"""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
|
return '', 204
|
||||||
|
|
||||||
@app.route('/connecttest.txt')
|
@app.route('/connecttest.txt')
|
||||||
def connecttest_txt():
|
def connecttest_txt():
|
||||||
"""Windows captive portal detection endpoint"""
|
"""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
|
return 'Microsoft Connect Test', 200
|
||||||
|
|
||||||
@app.route('/success.txt')
|
@app.route('/success.txt')
|
||||||
def success_txt():
|
def success_txt():
|
||||||
"""Firefox captive portal detection endpoint"""
|
"""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
|
return 'success', 200
|
||||||
|
|
||||||
# Initialize logging
|
# Initialize logging
|
||||||
@@ -367,10 +382,9 @@ def captive_portal_redirect():
|
|||||||
path = request.path
|
path = request.path
|
||||||
|
|
||||||
# List of paths that should NOT be redirected (allow normal operation)
|
# 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 = [
|
allowed_paths = [
|
||||||
'/v3', # Main interface and all sub-paths
|
'/v3', # Main interface and all sub-paths (includes /v3/setup)
|
||||||
'/api/v3/', # All API endpoints (plugins, config, wifi, stream, etc.)
|
'/api/v3/', # All API endpoints
|
||||||
'/static/', # Static files (CSS, JS, images)
|
'/static/', # Static files (CSS, JS, images)
|
||||||
'/hotspot-detect.html', # iOS/macOS detection
|
'/hotspot-detect.html', # iOS/macOS detection
|
||||||
'/generate_204', # Android detection
|
'/generate_204', # Android detection
|
||||||
@@ -378,17 +392,13 @@ def captive_portal_redirect():
|
|||||||
'/success.txt', # Firefox detection
|
'/success.txt', # Firefox detection
|
||||||
'/favicon.ico', # Favicon
|
'/favicon.ico', # Favicon
|
||||||
]
|
]
|
||||||
|
|
||||||
# Check if this path should be allowed
|
|
||||||
for allowed_path in allowed_paths:
|
for allowed_path in allowed_paths:
|
||||||
if path.startswith(allowed_path):
|
if path.startswith(allowed_path):
|
||||||
return None # Allow this request to proceed normally
|
return None
|
||||||
|
|
||||||
# For all other paths, redirect to main interface
|
# Redirect to lightweight captive portal setup page (not the full UI)
|
||||||
# This ensures users see the WiFi setup page when they try to access any website
|
return redirect(url_for('pages_v3.captive_setup'), code=302)
|
||||||
# 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)
|
|
||||||
|
|
||||||
# Add security headers and caching to all responses
|
# Add security headers and caching to all responses
|
||||||
@app.after_request
|
@app.after_request
|
||||||
|
|||||||
@@ -4024,225 +4024,100 @@ def save_plugin_config():
|
|||||||
|
|
||||||
# Post-process: Fix array fields that might have been incorrectly structured
|
# 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)
|
# 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=''):
|
def fix_array_structures(config_dict, schema_props):
|
||||||
"""Recursively fix array structures (convert dicts with numeric keys to arrays, fix length issues)"""
|
"""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():
|
for prop_key, prop_schema in schema_props.items():
|
||||||
prop_type = prop_schema.get('type')
|
prop_type = prop_schema.get('type')
|
||||||
|
|
||||||
if prop_type == 'array':
|
if prop_type == 'array':
|
||||||
# Navigate to the field location
|
if prop_key in config_dict:
|
||||||
if prefix:
|
current_value = config_dict[prop_key]
|
||||||
parent_parts = prefix.split('.')
|
# If it's a dict with numeric string keys, convert to array
|
||||||
parent = config_dict
|
if isinstance(current_value, dict) and not isinstance(current_value, list):
|
||||||
for part in parent_parts:
|
try:
|
||||||
if isinstance(parent, dict) and part in parent:
|
keys = list(current_value.keys())
|
||||||
parent = parent[part]
|
if keys and all(str(k).isdigit() for k in keys):
|
||||||
else:
|
sorted_keys = sorted(keys, key=lambda x: int(str(x)))
|
||||||
parent = None
|
array_value = [current_value[k] for k in sorted_keys]
|
||||||
break
|
# Convert array elements to correct types based on schema
|
||||||
|
items_schema = prop_schema.get('items', {})
|
||||||
if parent is not None and isinstance(parent, dict) and prop_key in parent:
|
item_type = items_schema.get('type')
|
||||||
current_value = parent[prop_key]
|
if item_type in ('number', 'integer'):
|
||||||
# If it's a dict with numeric string keys, convert to array
|
converted_array = []
|
||||||
if isinstance(current_value, dict) and not isinstance(current_value, list):
|
for v in array_value:
|
||||||
try:
|
if isinstance(v, str):
|
||||||
# Check if all keys are numeric strings (array indices)
|
try:
|
||||||
keys = [k for k in current_value.keys()]
|
if item_type == 'integer':
|
||||||
if all(k.isdigit() for k in keys):
|
converted_array.append(int(v))
|
||||||
# Convert to sorted array by index
|
else:
|
||||||
sorted_keys = sorted(keys, key=int)
|
converted_array.append(float(v))
|
||||||
array_value = [current_value[k] for k in sorted_keys]
|
except (ValueError, TypeError):
|
||||||
# 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)
|
converted_array.append(v)
|
||||||
array_value = converted_array
|
else:
|
||||||
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):
|
|
||||||
converted_array.append(v)
|
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)
|
converted_array.append(v)
|
||||||
parent[prop_key] = converted_array
|
else:
|
||||||
current_value = converted_array
|
converted_array.append(v)
|
||||||
|
config_dict[prop_key] = converted_array
|
||||||
|
current_value = converted_array
|
||||||
|
|
||||||
# Then check minItems
|
# Then check minItems
|
||||||
min_items = prop_schema.get('minItems')
|
min_items = prop_schema.get('minItems')
|
||||||
if min_items is not None and len(current_value) < min_items:
|
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')
|
||||||
default = prop_schema.get('default')
|
if default and isinstance(default, list) and len(default) >= min_items:
|
||||||
if default and isinstance(default, list) and len(default) >= min_items:
|
config_dict[prop_key] = default
|
||||||
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
|
|
||||||
|
|
||||||
# Recurse into nested objects
|
# Recurse into nested objects
|
||||||
elif prop_type == 'object' and 'properties' in prop_schema:
|
elif prop_type == 'object' and 'properties' in prop_schema:
|
||||||
nested_prefix = f"{prefix}.{prop_key}" if prefix else prop_key
|
nested_dict = config_dict.get(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)
|
|
||||||
|
|
||||||
if isinstance(nested_dict, dict):
|
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
|
# Also ensure array fields that are None get converted to empty arrays
|
||||||
def ensure_array_defaults(config_dict, schema_props, prefix=''):
|
def ensure_array_defaults(config_dict, schema_props):
|
||||||
"""Recursively ensure array fields have defaults if None"""
|
"""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():
|
for prop_key, prop_schema in schema_props.items():
|
||||||
prop_type = prop_schema.get('type')
|
prop_type = prop_schema.get('type')
|
||||||
|
|
||||||
if prop_type == 'array':
|
if prop_type == 'array':
|
||||||
if prefix:
|
if prop_key not in config_dict or config_dict[prop_key] is None:
|
||||||
parent_parts = prefix.split('.')
|
default = prop_schema.get('default', [])
|
||||||
parent = config_dict
|
config_dict[prop_key] = default if default else []
|
||||||
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 []
|
|
||||||
|
|
||||||
elif prop_type == 'object' and 'properties' in prop_schema:
|
elif prop_type == 'object' and 'properties' in prop_schema:
|
||||||
nested_prefix = f"{prefix}.{prop_key}" if prefix else prop_key
|
nested_dict = config_dict.get(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)
|
|
||||||
|
|
||||||
if nested_dict is None:
|
if nested_dict is None:
|
||||||
if prefix:
|
config_dict[prop_key] = {}
|
||||||
parent_parts = prefix.split('.')
|
nested_dict = config_dict[prop_key]
|
||||||
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]
|
|
||||||
|
|
||||||
if isinstance(nested_dict, dict):
|
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:
|
if schema and 'properties' in schema:
|
||||||
# First, fix any dict structures that should be arrays
|
# First, fix any dict structures that should be arrays
|
||||||
@@ -6488,24 +6363,17 @@ def get_wifi_status():
|
|||||||
|
|
||||||
@api_v3.route('/wifi/scan', methods=['GET'])
|
@api_v3.route('/wifi/scan', methods=['GET'])
|
||||||
def scan_wifi_networks():
|
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
|
When AP mode is active, returns cached scan results to avoid
|
||||||
and automatically re-enabled afterward. Users connected to the AP will
|
disconnecting the user from the setup network.
|
||||||
be briefly disconnected during this process.
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from src.wifi_manager import WiFiManager
|
from src.wifi_manager import WiFiManager
|
||||||
|
|
||||||
wifi_manager = 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 = [
|
networks_data = [
|
||||||
{
|
{
|
||||||
'ssid': net.ssid,
|
'ssid': net.ssid,
|
||||||
@@ -6518,16 +6386,14 @@ def scan_wifi_networks():
|
|||||||
|
|
||||||
response_data = {
|
response_data = {
|
||||||
'status': 'success',
|
'status': 'success',
|
||||||
'data': networks_data
|
'data': networks_data,
|
||||||
|
'cached': was_cached,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Inform user if AP mode was temporarily disabled
|
if was_cached and networks_data:
|
||||||
if ap_was_active:
|
response_data['message'] = f'Found {len(networks_data)} cached networks.'
|
||||||
response_data['message'] = (
|
elif was_cached and not networks_data:
|
||||||
f'Found {len(networks_data)} networks. '
|
response_data['message'] = 'No cached networks available. Enter your network name manually.'
|
||||||
'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.'
|
|
||||||
)
|
|
||||||
|
|
||||||
return jsonify(response_data)
|
return jsonify(response_data)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -296,6 +296,11 @@ def _load_raw_json_partial():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"Error: {str(e)}", 500
|
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():
|
def _load_wifi_partial():
|
||||||
"""Load WiFi setup partial"""
|
"""Load WiFi setup partial"""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -2265,29 +2265,39 @@ window.showPluginConfigModal = function(pluginId, config) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to get the full property object from schema
|
// 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) {
|
function getSchemaProperty(schema, path) {
|
||||||
if (!schema || !schema.properties) return null;
|
if (!schema || !schema.properties) return null;
|
||||||
|
|
||||||
const parts = path.split('.');
|
const parts = path.split('.');
|
||||||
let current = schema.properties;
|
let current = schema.properties;
|
||||||
|
let i = 0;
|
||||||
for (let i = 0; i < parts.length; i++) {
|
|
||||||
const part = parts[i];
|
while (i < parts.length) {
|
||||||
if (current && current[part]) {
|
let matched = false;
|
||||||
if (i === parts.length - 1) {
|
// Try progressively longer candidates, longest first
|
||||||
// Last part - return the property
|
for (let j = parts.length; j > i; j--) {
|
||||||
return current[part];
|
const candidate = parts.slice(i, j).join('.');
|
||||||
} else if (current[part].properties) {
|
if (current && current[candidate]) {
|
||||||
// Navigate into nested object
|
if (j === parts.length) {
|
||||||
current = current[part].properties;
|
// Consumed all remaining parts — done
|
||||||
} else {
|
return current[candidate];
|
||||||
return null;
|
}
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2311,23 +2321,70 @@ function escapeCssSelector(str) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to convert dot notation to nested object
|
// 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 = {};
|
const result = {};
|
||||||
|
|
||||||
for (const key in obj) {
|
for (const key in obj) {
|
||||||
const parts = key.split('.');
|
const parts = key.split('.');
|
||||||
let current = result;
|
let current = result;
|
||||||
|
let currentSchema = (schema && schema.properties) ? schema.properties : null;
|
||||||
for (let i = 0; i < parts.length - 1; i++) {
|
let i = 0;
|
||||||
if (!current[parts[i]]) {
|
|
||||||
current[parts[i]] = {};
|
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;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2350,42 +2407,20 @@ function collectBooleanFields(schema, prefix = '') {
|
|||||||
return boolFields;
|
return boolFields;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePluginConfigSubmit(e) {
|
/**
|
||||||
e.preventDefault();
|
* Normalize FormData from a plugin config form into a nested config object.
|
||||||
console.log('Form submitted');
|
* Handles _data JSON inputs, bracket-notation checkboxes, array-of-objects,
|
||||||
|
* file-upload widgets, proper checkbox DOM detection, unchecked boolean
|
||||||
if (!currentPluginConfig) {
|
* handling, and schema-aware dotted-key nesting.
|
||||||
showNotification('Plugin configuration not loaded', 'error');
|
*
|
||||||
return;
|
* @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
|
||||||
const pluginId = currentPluginConfig.pluginId;
|
*/
|
||||||
const schema = currentPluginConfig.schema;
|
function normalizeFormDataForConfig(form, 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 formData = new FormData(form);
|
const formData = new FormData(form);
|
||||||
const flatConfig = {};
|
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()) {
|
for (const [key, value] of formData.entries()) {
|
||||||
// Check if this is a patternProperties or array-of-objects hidden input (contains JSON data)
|
// 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'
|
// 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)
|
// Only treat as JSON-backed when it's a non-null object (null is typeof 'object' in JavaScript)
|
||||||
if (jsonValue !== null && typeof jsonValue === 'object') {
|
if (jsonValue !== null && typeof jsonValue === 'object') {
|
||||||
flatConfig[baseKey] = jsonValue;
|
flatConfig[baseKey] = jsonValue;
|
||||||
console.log(`JSON data field ${baseKey}: parsed ${Array.isArray(jsonValue) ? 'array' : 'object'}`, jsonValue);
|
|
||||||
continue; // Skip normal processing for JSON data fields
|
continue; // Skip normal processing for JSON data fields
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Not valid JSON, continue with normal processing
|
// Not valid JSON, continue with normal processing
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip checkbox-group inputs with bracket notation (they're handled by the hidden _data input)
|
// 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
|
// Pattern: fieldName[] - these are individual checkboxes, actual data is in fieldName_data
|
||||||
if (key.endsWith('[]')) {
|
if (key.endsWith('[]')) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip key_value pair inputs (they're handled by the hidden _data input)
|
// Skip key_value pair inputs (they're handled by the hidden _data input)
|
||||||
if (key.includes('[key_') || key.includes('[value_')) {
|
if (key.includes('[key_') || key.includes('[value_')) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip array-of-objects per-item inputs (they're handled by the hidden _data input)
|
// 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.
|
// Pattern: feeds_item_0_name, feeds_item_1_url, etc.
|
||||||
if (key.includes('_item_') && /_item_\d+_/.test(key)) {
|
if (key.includes('_item_') && /_item_\d+_/.test(key)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to get schema property - handle both dot notation and underscore notation
|
// Try to get schema property - handle both dot notation and underscore notation
|
||||||
let propSchema = getSchemaPropertyType(schema, key);
|
let propSchema = getSchemaPropertyType(schema, key);
|
||||||
let actualKey = key;
|
let actualKey = key;
|
||||||
let actualValue = value;
|
let actualValue = value;
|
||||||
|
|
||||||
// If not found with dots, try converting underscores to dots (for nested fields)
|
// If not found with dots, try converting underscores to dots (for nested fields)
|
||||||
if (!propSchema && key.includes('_')) {
|
if (!propSchema && key.includes('_')) {
|
||||||
const dotKey = key.replace(/_/g, '.');
|
const dotKey = key.replace(/_/g, '.');
|
||||||
@@ -2437,10 +2471,10 @@ function handlePluginConfigSubmit(e) {
|
|||||||
actualValue = value;
|
actualValue = value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (propSchema) {
|
if (propSchema) {
|
||||||
const propType = propSchema.type;
|
const propType = propSchema.type;
|
||||||
|
|
||||||
if (propType === 'array') {
|
if (propType === 'array') {
|
||||||
// Check if this is a file upload widget (JSON array)
|
// Check if this is a file upload widget (JSON array)
|
||||||
if (propSchema['x-widget'] === 'file-upload') {
|
if (propSchema['x-widget'] === 'file-upload') {
|
||||||
@@ -2454,11 +2488,10 @@ function handlePluginConfigSubmit(e) {
|
|||||||
tempDiv.innerHTML = actualValue;
|
tempDiv.innerHTML = actualValue;
|
||||||
decodedValue = tempDiv.textContent || tempDiv.innerText || actualValue;
|
decodedValue = tempDiv.textContent || tempDiv.innerText || actualValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const jsonValue = JSON.parse(decodedValue);
|
const jsonValue = JSON.parse(decodedValue);
|
||||||
if (Array.isArray(jsonValue)) {
|
if (Array.isArray(jsonValue)) {
|
||||||
flatConfig[actualKey] = jsonValue;
|
flatConfig[actualKey] = jsonValue;
|
||||||
console.log(`File upload array field ${actualKey}: parsed JSON array with ${jsonValue.length} items`);
|
|
||||||
} else {
|
} else {
|
||||||
// Fallback to comma-separated
|
// Fallback to comma-separated
|
||||||
const arrayValue = decodedValue ? decodedValue.split(',').map(v => v.trim()).filter(v => v) : [];
|
const arrayValue = decodedValue ? decodedValue.split(',').map(v => v.trim()).filter(v => v) : [];
|
||||||
@@ -2468,13 +2501,11 @@ function handlePluginConfigSubmit(e) {
|
|||||||
// Not JSON, use comma-separated
|
// Not JSON, use comma-separated
|
||||||
const arrayValue = actualValue ? actualValue.split(',').map(v => v.trim()).filter(v => v) : [];
|
const arrayValue = actualValue ? actualValue.split(',').map(v => v.trim()).filter(v => v) : [];
|
||||||
flatConfig[actualKey] = arrayValue;
|
flatConfig[actualKey] = arrayValue;
|
||||||
console.log(`Array field ${actualKey}: "${actualValue}" -> `, arrayValue);
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Regular array: convert comma-separated string to array
|
// Regular array: convert comma-separated string to array
|
||||||
const arrayValue = actualValue ? actualValue.split(',').map(v => v.trim()).filter(v => v) : [];
|
const arrayValue = actualValue ? actualValue.split(',').map(v => v.trim()).filter(v => v) : [];
|
||||||
flatConfig[actualKey] = arrayValue;
|
flatConfig[actualKey] = arrayValue;
|
||||||
console.log(`Array field ${actualKey}: "${actualValue}" -> `, arrayValue);
|
|
||||||
}
|
}
|
||||||
} else if (propType === 'integer') {
|
} else if (propType === 'integer') {
|
||||||
flatConfig[actualKey] = parseInt(actualValue, 10);
|
flatConfig[actualKey] = parseInt(actualValue, 10);
|
||||||
@@ -2485,14 +2516,13 @@ function handlePluginConfigSubmit(e) {
|
|||||||
// Escape special CSS selector characters in the name
|
// Escape special CSS selector characters in the name
|
||||||
const escapedKey = escapeCssSelector(key);
|
const escapedKey = escapeCssSelector(key);
|
||||||
const formElement = form.querySelector(`input[type="checkbox"][name="${escapedKey}"]`);
|
const formElement = form.querySelector(`input[type="checkbox"][name="${escapedKey}"]`);
|
||||||
|
|
||||||
if (formElement) {
|
if (formElement) {
|
||||||
// Element found - use its checked state
|
// Element found - use its checked state
|
||||||
flatConfig[actualKey] = formElement.checked;
|
flatConfig[actualKey] = formElement.checked;
|
||||||
} else {
|
} else {
|
||||||
// Element not found - normalize string booleans and check FormData value
|
// Element not found - normalize string booleans and check FormData value
|
||||||
// Checkboxes send "on" when checked, nothing when unchecked
|
// Checkboxes send "on" when checked, nothing when unchecked
|
||||||
// Normalize string representations of booleans
|
|
||||||
if (typeof actualValue === 'string') {
|
if (typeof actualValue === 'string') {
|
||||||
const lowerValue = actualValue.toLowerCase().trim();
|
const lowerValue = actualValue.toLowerCase().trim();
|
||||||
if (lowerValue === 'true' || lowerValue === '1' || lowerValue === 'on') {
|
if (lowerValue === 'true' || lowerValue === '1' || lowerValue === 'on') {
|
||||||
@@ -2500,13 +2530,11 @@ function handlePluginConfigSubmit(e) {
|
|||||||
} else if (lowerValue === 'false' || lowerValue === '0' || lowerValue === 'off' || lowerValue === '') {
|
} else if (lowerValue === 'false' || lowerValue === '0' || lowerValue === 'off' || lowerValue === '') {
|
||||||
flatConfig[actualKey] = false;
|
flatConfig[actualKey] = false;
|
||||||
} else {
|
} else {
|
||||||
// Non-empty string that's not a boolean representation - treat as truthy
|
|
||||||
flatConfig[actualKey] = true;
|
flatConfig[actualKey] = true;
|
||||||
}
|
}
|
||||||
} else if (actualValue === undefined || actualValue === null) {
|
} else if (actualValue === undefined || actualValue === null) {
|
||||||
flatConfig[actualKey] = false;
|
flatConfig[actualKey] = false;
|
||||||
} else {
|
} else {
|
||||||
// Non-string value - coerce to boolean
|
|
||||||
flatConfig[actualKey] = Boolean(actualValue);
|
flatConfig[actualKey] = Boolean(actualValue);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2523,10 +2551,9 @@ function handlePluginConfigSubmit(e) {
|
|||||||
const tempDiv = document.createElement('div');
|
const tempDiv = document.createElement('div');
|
||||||
tempDiv.innerHTML = actualValue;
|
tempDiv.innerHTML = actualValue;
|
||||||
decodedValue = tempDiv.textContent || tempDiv.innerText || actualValue;
|
decodedValue = tempDiv.textContent || tempDiv.innerText || actualValue;
|
||||||
|
|
||||||
const parsed = JSON.parse(decodedValue);
|
const parsed = JSON.parse(decodedValue);
|
||||||
flatConfig[actualKey] = parsed;
|
flatConfig[actualKey] = parsed;
|
||||||
console.log(`No schema for ${actualKey}, but parsed as JSON:`, parsed);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Not valid JSON, save as string
|
// Not valid JSON, save as string
|
||||||
flatConfig[actualKey] = actualValue;
|
flatConfig[actualKey] = actualValue;
|
||||||
@@ -2535,12 +2562,10 @@ function handlePluginConfigSubmit(e) {
|
|||||||
// No schema - try to detect checkbox by finding the element
|
// No schema - try to detect checkbox by finding the element
|
||||||
const escapedKey = escapeCssSelector(key);
|
const escapedKey = escapeCssSelector(key);
|
||||||
const formElement = form.querySelector(`input[type="checkbox"][name="${escapedKey}"]`);
|
const formElement = form.querySelector(`input[type="checkbox"][name="${escapedKey}"]`);
|
||||||
|
|
||||||
if (formElement && formElement.type === 'checkbox') {
|
if (formElement && formElement.type === 'checkbox') {
|
||||||
// Found checkbox element - use its checked state
|
|
||||||
flatConfig[actualKey] = formElement.checked;
|
flatConfig[actualKey] = formElement.checked;
|
||||||
} else {
|
} else {
|
||||||
// Not a checkbox or element not found - normalize string booleans
|
|
||||||
if (typeof actualValue === 'string') {
|
if (typeof actualValue === 'string') {
|
||||||
const lowerValue = actualValue.toLowerCase().trim();
|
const lowerValue = actualValue.toLowerCase().trim();
|
||||||
if (lowerValue === 'true' || lowerValue === '1' || lowerValue === 'on') {
|
if (lowerValue === 'true' || lowerValue === '1' || lowerValue === 'on') {
|
||||||
@@ -2548,18 +2573,16 @@ function handlePluginConfigSubmit(e) {
|
|||||||
} else if (lowerValue === 'false' || lowerValue === '0' || lowerValue === 'off' || lowerValue === '') {
|
} else if (lowerValue === 'false' || lowerValue === '0' || lowerValue === 'off' || lowerValue === '') {
|
||||||
flatConfig[actualKey] = false;
|
flatConfig[actualKey] = false;
|
||||||
} else {
|
} else {
|
||||||
// Non-empty string that's not a boolean representation - keep as string
|
|
||||||
flatConfig[actualKey] = actualValue;
|
flatConfig[actualKey] = actualValue;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Non-string value - use as-is
|
|
||||||
flatConfig[actualKey] = actualValue;
|
flatConfig[actualKey] = actualValue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle unchecked checkboxes (not in FormData) - including nested ones
|
// Handle unchecked checkboxes (not in FormData) - including nested ones
|
||||||
if (schema && schema.properties) {
|
if (schema && schema.properties) {
|
||||||
const allBoolFields = collectBooleanFields(schema);
|
const allBoolFields = collectBooleanFields(schema);
|
||||||
@@ -2569,11 +2592,43 @@ function handlePluginConfigSubmit(e) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert dot notation to nested object
|
// Convert dot notation to nested object
|
||||||
const config = dotToNested(flatConfig);
|
return dotToNested(flatConfig, schema);
|
||||||
|
}
|
||||||
console.log('Flat config:', flatConfig);
|
|
||||||
|
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);
|
console.log('Nested config to save:', config);
|
||||||
|
|
||||||
// Save the configuration
|
// Save the configuration
|
||||||
@@ -4418,42 +4473,9 @@ function switchPluginConfigView(view) {
|
|||||||
function syncFormToJson() {
|
function syncFormToJson() {
|
||||||
const form = document.getElementById('plugin-config-form');
|
const form = document.getElementById('plugin-config-form');
|
||||||
if (!form) return;
|
if (!form) return;
|
||||||
|
|
||||||
const formData = new FormData(form);
|
|
||||||
const config = {};
|
|
||||||
|
|
||||||
// Get schema for type conversion
|
|
||||||
const schema = currentPluginConfigState.schema;
|
const schema = currentPluginConfigState.schema;
|
||||||
|
const config = normalizeFormDataForConfig(form, 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deep merge with existing config to preserve nested structures
|
// Deep merge with existing config to preserve nested structures
|
||||||
function deepMerge(target, source) {
|
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 fontOverrides = window.fontOverrides;
|
||||||
var selectedFontFiles = window.selectedFontFiles;
|
var selectedFontFiles = window.selectedFontFiles;
|
||||||
|
|
||||||
// Base URL for API calls (shared scope)
|
|
||||||
var baseUrl = window.location.origin;
|
|
||||||
|
|
||||||
// Retry counter for initialization
|
// Retry counter for initialization
|
||||||
var initRetryCount = 0;
|
var initRetryCount = 0;
|
||||||
var MAX_INIT_RETRIES = 50; // 5 seconds max (50 * 100ms)
|
var MAX_INIT_RETRIES = 50; // 5 seconds max (50 * 100ms)
|
||||||
@@ -384,9 +381,9 @@ async function loadFontData() {
|
|||||||
try {
|
try {
|
||||||
// Use absolute URLs to ensure they work when loaded via HTMX
|
// Use absolute URLs to ensure they work when loaded via HTMX
|
||||||
const [catalogRes, tokensRes, overridesRes] = await Promise.all([
|
const [catalogRes, tokensRes, overridesRes] = await Promise.all([
|
||||||
fetch(`${baseUrl}/api/v3/fonts/catalog`),
|
fetch(`/api/v3/fonts/catalog`),
|
||||||
fetch(`${baseUrl}/api/v3/fonts/tokens`),
|
fetch(`/api/v3/fonts/tokens`),
|
||||||
fetch(`${baseUrl}/api/v3/fonts/overrides`)
|
fetch(`/api/v3/fonts/overrides`)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Check if all responses are successful
|
// Check if all responses are successful
|
||||||
@@ -558,7 +555,7 @@ async function deleteFont(fontFamily) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${baseUrl}/api/v3/fonts/${encodeURIComponent(fontFamily)}`, {
|
const response = await fetch(`/api/v3/fonts/${encodeURIComponent(fontFamily)}`, {
|
||||||
method: 'DELETE'
|
method: 'DELETE'
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -667,7 +664,7 @@ async function addFontOverride() {
|
|||||||
if (sizePx) overrideData.size_px = sizePx;
|
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',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -712,7 +709,7 @@ async function deleteFontOverride(elementKey) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${baseUrl}/api/v3/fonts/overrides/${elementKey}`, {
|
const response = await fetch(`/api/v3/fonts/overrides/${elementKey}`, {
|
||||||
method: 'DELETE'
|
method: 'DELETE'
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -860,7 +857,7 @@ async function updateFontPreview() {
|
|||||||
fg: 'ffffff'
|
fg: 'ffffff'
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await fetch(`${baseUrl}/api/v3/fonts/preview?${params}`);
|
const response = await fetch(`/api/v3/fonts/preview?${params}`);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
@@ -990,7 +987,7 @@ async function uploadSelectedFonts() {
|
|||||||
formData.append('font_file', file);
|
formData.append('font_file', file);
|
||||||
formData.append('font_family', i === 0 ? fontFamily : `${fontFamily}_${i + 1}`);
|
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',
|
method: 'POST',
|
||||||
body: formData
|
body: formData
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -114,7 +114,7 @@
|
|||||||
<input type="text"
|
<input type="text"
|
||||||
id="manual-ssid"
|
id="manual-ssid"
|
||||||
x-model="manualSSID"
|
x-model="manualSSID"
|
||||||
@input="selectedSSID = ''; selectedSSID = $event.target.value"
|
@input="selectedSSID = ''"
|
||||||
placeholder="Enter network name"
|
placeholder="Enter network name"
|
||||||
class="form-control">
|
class="form-control">
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user