mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +00:00
* fix(web): drop repeats from uniqueItems lists before validating a plugin save dedup_unique_arrays lost its only caller in #330, so submitting a value a uniqueItems list already holds (a stock symbol saved once and posted again) failed the whole save with a validation error. _prepare_plugin_config_for_save runs it again just before validation, which covers both POST /plugins/config and plugin sections posted to /config/main. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): /health counts the discovered plugins and logs the checks it fails The plugin check counted plugin_manager.get_available_plugins(), which PluginManager does not have, behind a hasattr guard that made plugin_count 0 on every device. It now counts the discovered manifests, discovering first when nothing has been scanned yet. The config, plugin and hardware checks answered "see logs for details" without logging anything. Each now logs a warning with the traceback. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): store refresh no longer claims a commit-metadata refresh POST /plugins/store/refresh read fetch_commit_info (or fetch_latest_versions) only to append "(with refreshed commit metadata from GitHub)" to its message. It never fetched any: the route re-downloads the registry and nothing else. search_plugins takes the flag, but it reads commit info through its cache, so passing it on would not refresh anything either. The flag is ignored now and the message says what happened. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): refuse a malformed Vegas plugin order instead of clearing it A vegas_plugin_order or vegas_excluded_plugins value that was not JSON, or not a list, was stored as [] and the save answered 200, so a bad value wiped the saved order or exclusions. Both now answer 400 and save nothing, the way plugin_rotation_order already did; the three share one parser. A list that holds anything but plugin-id strings is refused as well. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): per-plugin health and metrics read the display service's latest GET /plugins/health/<id> and /plugins/metrics/<id> called get_health_summary and get_metrics_summary without force_reload, so they answered with whatever the web process read first and kept in memory, while the display service kept writing newer state. They now pass force_reload=True, as the list routes do. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): plugin config reset saves through the shared atomic save POST /plugins/config/reset called config_manager.save_config directly, so it took no backup, and a failed write escaped as an unhandled exception. It then handed on_config_change the raw stored section, not the prepared config a loaded plugin runs with. It now saves through _save_config_atomic with a backup, answers CONFIG_SAVE_FAILED when that fails, and notifies with _prepared_plugin_config, as POST /plugins/config does. POST /plugins/toggle carried its own copy of _save_config_atomic's save_config_atomic-or-save_config fallback; it calls the shared helper now. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): one reading and one "unavailable" for each system metric system_metrics.collect_system_metrics() promised None for a metric it could not read, but returned cpu_temp as 0 off a Pi, and the whole no-psutil fallback as zeros. GET /system/status measured the same numbers a second time with its own code, and answered None there. Now both come from collect_system_metrics(), and "unavailable" is None everywhere. /system/status keeps its 0.1s CPU sample and its 10s cache, and gains nothing it did not already send. Two differences: without psutil it answers 200 with null metrics instead of 503, and a disk it cannot stat is null instead of a 500. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): /display/current sends the snapshot as-is and logs a failed read GET /display/current PIL-decoded the preview snapshot and re-encoded it before base64-ing it, spending CPU on the Pi to send the same picture, and dropped any failure with `except Exception: pass`. The /stream/display SSE stream already passed the PNG's bytes straight through. Both now read through web_interface/display_preview.py and answer with the same payload. A missing snapshot is still a null image; any other read failure is logged as a warning. /health reads the snapshot path from the same module. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(web): one helper puts a submitted plugin config's lists back The plugin-config save turned position-keyed dicts ({"0": ..., "1": ...}) back into lists in five copies: four in the form path's fix_array_structures (whose prefix branches never ran, since no caller passed one), and _fix_json_arrays on the JSON path. It then force-fixed the news plugin's feeds.custom_feeds by name, in case the generic pass had missed it. src/web_interface/config_arrays.coerce_array_shapes now does it for both paths, custom_feeds included. ensure_array_defaults duplicated _fix_none_arrays and is gone. In the same function: the union-type re-checks that the null handling above them made unreachable, the "(temporary)" random_seed debug log, and a commented-out log line are removed. A failed validation is logged once as a warning, not four ERROR lines and a WARNING. Element types are left to normalize_config_values, which already converted them for both paths. One difference: the form path no longer adds an empty {} for a nested object the post left out that has no defaults. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(web): import at module top and log through the module logger The web_interface.cache imports in config.py and fonts.py were wrapped in `except ImportError` fallbacks. It is an in-repo module that imports nothing from the project, so it cannot fail to import; it is imported once at module top, as system.py now does. cache.py's docstring said blueprints import it lazily "to avoid circular imports"; it now says why that is unnecessary. Five logging.error calls in the dim-schedule GET and three logging.warning calls in plugins.py went to the root logger; they use the module logger. Function-local re-imports of json, os, shutil, logging and Path, all already imported by the module, are gone. The `import os` inside two except blocks of save_plugin_config also made os a local name for the whole function. execute_plugin_action's step-1 handler gets a comment saying why it stays: it looks like a copy of the blueprint handler, but without it a TimeoutExpired from the plugin's script would reach the route's own `except subprocess.TimeoutExpired` and be answered as a 408. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(web): app.py loses dead CSRF and reconciliation state, comments fixed - csrf was always None, so `if csrf: csrf.exempt(...)` never ran, and its note that the api_v3 blueprint "is exempted above" named an exemption that does not exist. Both are gone; the reason there is no CSRF protection stays, shortened. - The SSE rate-limit comment called the default "tight" at 20 per minute. The default is 1000 per minute and the streams' 200 is the tighter one; the comment now says so. The limits are unchanged. - _reconciliation_done was written and never read. The docstring that explains why reconciliation runs once keeps its reason, in the present tense. - Removed: a dangling "import cache functions" comment with no import under it, a "security check ... within project_root" label on an existence check, the "(simplified version)" narration, and the note that no redirect route is needed. The preview loop's sleep comment no longer mentions a PIL encode that the loop does not do. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(web): api_v3 comments name the package __init__, not a _common module Every route module's docstring said the shared blueprint comes "from ._common", a module the package split never created; they name the package __init__. The PROJECT_ROOT comment described the path from _common.py; it now describes this package and keeps the incident it guards against. The "(corrected) in this commit" note in resolve_pull_command and the /health comment the split's mechanical time -> _pkg.time rewrite garbled ("Stamp the start _pkg.time") read correctly again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(web): drop hasattr checks for attributes PluginManager always has PluginManager.__init__ sets health_tracker and resource_monitor (to None until they are configured), so the seven hasattr(api_v3.plugin_manager, ...) guards in the health, metrics and limits routes were always true. The falsy checks that do the work stay. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(web): pages_v3 dispatches partials from a dict with one error handler load_partial chose a loader through a fourteen-branch if/elif, and thirteen of the loaders then wrapped themselves in the same try/except, logging "Error loading partial" without saying which. The route now looks the name up in _PARTIAL_LOADERS and has the one handler, which logs the partial's name. The loaders just render. _load_tools_partial keeps its own messages. The search index's _partial_html already catches a loader that raises. serve_plugin_web_ui repeated _plugin_dir_for inline (containment plus the ledmatrix- prefix fallback); it calls it now. Also removed: the unused markupsafe.escape import, function-local json/Path re-imports, and unused exception bindings. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(web): remove unused imports, locals and a try that cannot fail - get_error_aggregator was imported by the api_v3 package and used by no one; seven names config.py imported, and Path in misc.py and logging in plugins.py, likewise. - branch_info in install_plugin was built and never logged; test_config in /health was bound and never read (the load_config call is the check). - An f-string with no placeholders in the asset upload route. - _installed_plugin_ids wrapped list(manifests.keys()) in try/except; _discovered_plugin_manifests always returns a dict. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(web): start.py logs its startup lines and drops unreachable branches The startup banner went to stdout with print(); it goes through a logger now, which the app import has already configured, so it reaches the journal with a level and timestamp like every other line. The "no addresses" branch is gone: get_local_ips() always returns at least "localhost". The except around app.run re-raised "only if it's not a client disconnection error" from inside the branch that had just established it was one, so that raise could not run. It is one check now, on a named tuple of the errnos, which the werkzeug log filter uses too. The comment on threaded=True counts three SSE endpoints, which is how many there are. Trailing whitespace is stripped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(web): save_main_config names its General fields once The General tab's field names were listed twice, once to detect a General form post and again, with four more, to keep the remaining-keys merge from storing them as top-level keys. GENERAL_FIELDS and _MAPPED_TOP_LEVEL_FIELDS hold them now, and the four per-section skip checks are one set. The comment on that merge said plugin configs are handled "here too", and "(including plugin keys)". Plugin sections are handled and removed from the body before it runs; the comment says so. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(web): plugin directories come from the plugin manager only Six lookups fell back to PROJECT_ROOT/plugins/<id> when there was no plugin manager: GET /plugins/config's of-the-day data, POST /plugins/action, the plugin static-file route, the calendar credentials upload and the calendar OAuth routes. The loader never scans plugins/ (PluginManager.discover_plugins reads only the configured directory, plugin-repos by default), so what they found there was a plugin that never runs. _plugin_directory() asks the manager and answers None without one, which each route already reports as "not found". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(changelog): web-backend Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
452 lines
16 KiB
Python
452 lines
16 KiB
Python
"""Wi-Fi scanning, connection and status routes.
|
|
|
|
Routes decorate the shared `api_v3` Blueprint from the package `__init__`,
|
|
so their endpoint names are unchanged by living here.
|
|
"""
|
|
import threading
|
|
import time
|
|
|
|
from web_interface.blueprints.api_v3 import (
|
|
api_v3, describe_exception, jsonify, logger, request,
|
|
)
|
|
|
|
# Joining a network while the setup AP is up has to take the AP down first,
|
|
# and the caller is almost always a phone connected *through* that AP. Run
|
|
# synchronously, the request outlives the link it arrived on: the browser
|
|
# never gets an answer and the Connect button appears to do nothing. So in AP
|
|
# mode the connect runs in the background and the result is kept here, where
|
|
# /wifi/status can report it once the user is back on either network.
|
|
_connect_lock = threading.Lock()
|
|
_last_connect_attempt = None
|
|
# Lets the 202 reach the phone before hostapd stops.
|
|
_AP_HANDOFF_DELAY_SECONDS = 2.0
|
|
|
|
|
|
def _last_connect_snapshot():
|
|
with _connect_lock:
|
|
return dict(_last_connect_attempt) if _last_connect_attempt else None
|
|
|
|
|
|
def _spawn(target):
|
|
threading.Thread(target=target, name='wifi-connect', daemon=True).start()
|
|
|
|
|
|
def _connect_result_payload(ssid, success, message):
|
|
"""Shape a connect_to_network result for the client.
|
|
|
|
The manager flags a rejected passphrase by prefixing the message with
|
|
"wrong_password:"; the setup pages key off `error_type` rather than
|
|
parsing nmcli's wording.
|
|
"""
|
|
if success:
|
|
return {'status': 'success', 'message': message}
|
|
payload = {'status': 'error', 'message': message or 'Failed to connect to network'}
|
|
if message and message.startswith('wrong_password:'):
|
|
payload['error_type'] = 'wrong_password'
|
|
payload['message'] = f'Incorrect password for {ssid}'
|
|
return payload
|
|
|
|
|
|
def _record_connect_result(ssid, payload):
|
|
global _last_connect_attempt
|
|
with _connect_lock:
|
|
_last_connect_attempt = {
|
|
'ssid': ssid,
|
|
'state': 'success' if payload['status'] == 'success' else 'failed',
|
|
'message': payload['message'],
|
|
'error_type': payload.get('error_type'),
|
|
'finished_at': time.time(),
|
|
}
|
|
|
|
|
|
def _run_background_connect(ssid, password):
|
|
try:
|
|
time.sleep(_AP_HANDOFF_DELAY_SECONDS)
|
|
from src.wifi_manager import WiFiManager
|
|
success, message = WiFiManager().connect_to_network(ssid, password)
|
|
payload = _connect_result_payload(ssid, success, message)
|
|
except Exception as e:
|
|
logger.error("Background WiFi connect failed", exc_info=True)
|
|
payload = {'status': 'error', 'message': describe_exception(e)}
|
|
_record_connect_result(ssid, payload)
|
|
|
|
|
|
def _parse_bool_ish(value):
|
|
"""Coerce a JSON value that is supposed to be a boolean.
|
|
|
|
A JSON boolean arrives as a real Python bool, but these routes are a
|
|
public HTTP contract and not every caller sends one. `bool(value)` gets
|
|
two common cases wrong: `bool("false")` is True (a non-empty string is
|
|
always truthy), and a plain int does not match an `is True` check
|
|
(`1 is True` is False, since `True` is a distinct singleton from the int
|
|
`1`) -- so a caller sending `{"enabled": 1}` was silently treated as
|
|
False. Recognizes a real bool, "true"/"false"/"1"/"0"/"yes"/"no"
|
|
case-insensitively, and int 1/0.
|
|
|
|
Returns None for anything else, rather than guessing. A supplied-but-
|
|
unrecognized value (e.g. a typo) used to silently become False here,
|
|
which for `enabled` on the radio route could disconnect Wi-Fi the caller
|
|
never asked to turn off -- callers must treat None as a validation
|
|
error, not a real False.
|
|
"""
|
|
if isinstance(value, bool):
|
|
return value
|
|
if isinstance(value, str):
|
|
lowered = value.strip().lower()
|
|
if lowered in ('true', '1', 'yes'):
|
|
return True
|
|
if lowered in ('false', '0', 'no'):
|
|
return False
|
|
return None
|
|
if isinstance(value, int):
|
|
if value == 1:
|
|
return True
|
|
if value == 0:
|
|
return False
|
|
return None
|
|
return None
|
|
|
|
|
|
# WiFi Management Endpoints
|
|
@api_v3.route('/wifi/status', methods=['GET'])
|
|
def get_wifi_status():
|
|
"""Get current WiFi connection status"""
|
|
from src.wifi_manager import WiFiManager
|
|
|
|
wifi_manager = WiFiManager()
|
|
status = wifi_manager.get_wifi_status()
|
|
|
|
# Get auto-enable setting from config
|
|
auto_enable_ap = wifi_manager.config.get("auto_enable_ap_mode", True) # Default: True (safe due to grace period)
|
|
|
|
return jsonify({
|
|
'status': 'success',
|
|
'data': {
|
|
'connected': status.connected,
|
|
'ssid': status.ssid,
|
|
'ip_address': status.ip_address,
|
|
'signal': status.signal,
|
|
'ap_mode_active': status.ap_mode_active,
|
|
'auto_enable_ap_mode': auto_enable_ap,
|
|
'last_connect_attempt': _last_connect_snapshot(),
|
|
}
|
|
})
|
|
@api_v3.route('/wifi/scan', methods=['GET'])
|
|
def scan_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.
|
|
"""
|
|
try:
|
|
from src.wifi_manager import WiFiManager
|
|
|
|
wifi_manager = WiFiManager()
|
|
|
|
# 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, _was_cached = wifi_manager.scan_networks()
|
|
|
|
# Convert to dict format
|
|
networks_data = [
|
|
{
|
|
'ssid': net.ssid,
|
|
'signal': net.signal,
|
|
'security': net.security,
|
|
'frequency': net.frequency
|
|
}
|
|
for net in networks
|
|
]
|
|
|
|
response_data = {
|
|
'status': 'success',
|
|
'data': networks_data
|
|
}
|
|
|
|
# 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.'
|
|
)
|
|
|
|
return jsonify(response_data)
|
|
except Exception as e:
|
|
logger.error("Error scanning WiFi networks", exc_info=True)
|
|
error_message = 'An error occurred while scanning WiFi networks; see logs for details'
|
|
|
|
# Provide more specific error messages for common issues
|
|
error_str = str(e).lower()
|
|
if 'permission' in error_str or 'sudo' in error_str:
|
|
error_message = (
|
|
'Permission error while scanning. '
|
|
'The WiFi scan requires appropriate permissions. '
|
|
'Please ensure the application has necessary privileges.'
|
|
)
|
|
elif 'timeout' in error_str:
|
|
error_message = (
|
|
'WiFi scan timed out. '
|
|
'The scan took too long to complete. '
|
|
'This may happen if the WiFi interface is busy or in use.'
|
|
)
|
|
elif 'no wifi' in error_str or 'not available' in error_str:
|
|
error_message = (
|
|
'WiFi scanning tools are not available. '
|
|
'Please ensure NetworkManager (nmcli) or iwlist is installed.'
|
|
)
|
|
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': error_message
|
|
}), 500
|
|
@api_v3.route('/wifi/connect', methods=['POST'])
|
|
def connect_wifi():
|
|
"""Connect to a WiFi network.
|
|
|
|
With the setup AP active this answers 202 at once and connects in the
|
|
background (see _last_connect_attempt); otherwise it waits for the result.
|
|
"""
|
|
global _last_connect_attempt
|
|
from src.wifi_manager import WiFiManager
|
|
|
|
data = request.get_json(silent=True)
|
|
if not data:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'Request body is required'
|
|
}), 400
|
|
|
|
if 'ssid' not in data:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'SSID is required'
|
|
}), 400
|
|
|
|
ssid = data['ssid']
|
|
if not ssid or not ssid.strip():
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'SSID cannot be empty'
|
|
}), 400
|
|
|
|
ssid = ssid.strip()
|
|
password = data.get('password', '') or ''
|
|
|
|
wifi_manager = WiFiManager()
|
|
ap_mode_active = wifi_manager._is_ap_mode_active()
|
|
|
|
# One attempt at a time on either path: concurrent connects fight over
|
|
# the radio, and the first to finish would clear the in-progress flag
|
|
# the monitor daemon still needs for the other. The check can't depend
|
|
# on AP state either -- a background attempt takes the AP down long
|
|
# before it finishes.
|
|
with _connect_lock:
|
|
if _last_connect_attempt and _last_connect_attempt['state'] == 'pending':
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': f"Already connecting to {_last_connect_attempt['ssid']}"
|
|
}), 409
|
|
_last_connect_attempt = {
|
|
'ssid': ssid, 'state': 'pending', 'message': None,
|
|
'error_type': None, 'finished_at': None,
|
|
}
|
|
|
|
if ap_mode_active:
|
|
try:
|
|
_spawn(lambda: _run_background_connect(ssid, password))
|
|
except Exception:
|
|
# Nothing will ever finish this attempt; don't leave every
|
|
# later request refused.
|
|
with _connect_lock:
|
|
_last_connect_attempt = None
|
|
raise
|
|
return jsonify({
|
|
'status': 'pending',
|
|
'message': (
|
|
f'Connecting to {ssid}. The LEDMatrix-Setup network will turn off, '
|
|
'so this page will lose its connection.'
|
|
),
|
|
'data': {'ssid': ssid},
|
|
}), 202
|
|
|
|
try:
|
|
success, message = wifi_manager.connect_to_network(ssid, password)
|
|
except Exception as e:
|
|
_record_connect_result(ssid, {'status': 'error', 'message': describe_exception(e)})
|
|
raise
|
|
payload = _connect_result_payload(ssid, success, message)
|
|
_record_connect_result(ssid, payload)
|
|
return jsonify(payload), (200 if success else 400)
|
|
@api_v3.route('/wifi/disconnect', methods=['POST'])
|
|
def disconnect_wifi():
|
|
"""Disconnect from the current WiFi network"""
|
|
from src.wifi_manager import WiFiManager
|
|
|
|
wifi_manager = WiFiManager()
|
|
success, message = wifi_manager.disconnect_from_network()
|
|
|
|
if success:
|
|
return jsonify({
|
|
'status': 'success',
|
|
'message': message
|
|
})
|
|
else:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': message or 'Failed to disconnect from network'
|
|
}), 400
|
|
@api_v3.route('/wifi/ap/enable', methods=['POST'])
|
|
def enable_ap_mode():
|
|
"""Enable access point mode"""
|
|
from src.wifi_manager import WiFiManager
|
|
|
|
wifi_manager = WiFiManager()
|
|
_force_raw = (request.get_json(silent=True) or {}).get('force', False)
|
|
force = _force_raw is True or (isinstance(_force_raw, str) and _force_raw.lower() in ('true', '1'))
|
|
success, message = wifi_manager.enable_ap_mode(force=force)
|
|
|
|
if success:
|
|
return jsonify({
|
|
'status': 'success',
|
|
'message': message
|
|
})
|
|
else:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': message
|
|
}), 400
|
|
@api_v3.route('/wifi/ap/disable', methods=['POST'])
|
|
def disable_ap_mode():
|
|
"""Disable access point mode"""
|
|
from src.wifi_manager import WiFiManager
|
|
|
|
wifi_manager = WiFiManager()
|
|
success, message = wifi_manager.disable_ap_mode()
|
|
|
|
if success:
|
|
return jsonify({
|
|
'status': 'success',
|
|
'message': message
|
|
})
|
|
else:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': message
|
|
}), 400
|
|
@api_v3.route('/wifi/ap/auto-enable', methods=['GET'])
|
|
def get_auto_enable_ap_mode():
|
|
"""Get auto-enable AP mode setting"""
|
|
from src.wifi_manager import WiFiManager
|
|
|
|
wifi_manager = WiFiManager()
|
|
auto_enable = wifi_manager.config.get("auto_enable_ap_mode", True) # Default: True (safe due to grace period)
|
|
|
|
return jsonify({
|
|
'status': 'success',
|
|
'data': {
|
|
'auto_enable_ap_mode': auto_enable
|
|
}
|
|
})
|
|
@api_v3.route('/wifi/ap/auto-enable', methods=['POST'])
|
|
def set_auto_enable_ap_mode():
|
|
"""Set auto-enable AP mode setting"""
|
|
from src.wifi_manager import WiFiManager
|
|
|
|
data = request.get_json(silent=True)
|
|
if data is None or 'auto_enable_ap_mode' not in data:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'auto_enable_ap_mode is required'
|
|
}), 400
|
|
|
|
auto_enable = _parse_bool_ish(data['auto_enable_ap_mode'])
|
|
if auto_enable is None:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'auto_enable_ap_mode must be a boolean'
|
|
}), 400
|
|
|
|
wifi_manager = WiFiManager()
|
|
wifi_manager.config["auto_enable_ap_mode"] = auto_enable
|
|
if not wifi_manager._save_config():
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': (f'Could not save the setting to {wifi_manager.config_path}; '
|
|
'check that the web interface user can write it.'),
|
|
}), 500
|
|
|
|
return jsonify({
|
|
'status': 'success',
|
|
'message': f'Auto-enable AP mode set to {auto_enable}',
|
|
'data': {
|
|
'auto_enable_ap_mode': auto_enable
|
|
}
|
|
})
|
|
@api_v3.route('/wifi/radio', methods=['GET'])
|
|
def get_wifi_radio():
|
|
"""Get current WiFi radio state (enabled/disabled) and wired-fallback status."""
|
|
from src.wifi_manager import WiFiManager
|
|
|
|
wifi_manager = WiFiManager()
|
|
state = wifi_manager.get_wifi_radio_state()
|
|
|
|
return jsonify({
|
|
'status': 'success',
|
|
'data': state
|
|
})
|
|
@api_v3.route('/wifi/radio', methods=['POST'])
|
|
def set_wifi_radio():
|
|
"""Turn the WiFi radio on or off.
|
|
|
|
Body: {"enabled": bool, "force": bool (optional)}. Disabling is refused
|
|
unless Ethernet is connected or force=True, to avoid locking the user out
|
|
of this web interface.
|
|
"""
|
|
from src.wifi_manager import WiFiManager
|
|
|
|
data = request.get_json(silent=True) or {}
|
|
if 'enabled' not in data:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'enabled is required'
|
|
}), 400
|
|
|
|
# Parse defensively: bool("false") is True and a plain int never
|
|
# matches `is True`, so `_parse_bool_ish` handles bool, string and
|
|
# int 1/0 — the endpoint is a public contract, not just the shipped
|
|
# UI (which always sends real JSON booleans). An unrecognized value
|
|
# must be rejected, not silently disable the radio: this is the
|
|
# route that can drop the caller's own connection to this interface.
|
|
enabled = _parse_bool_ish(data['enabled'])
|
|
if enabled is None:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'enabled must be a boolean'
|
|
}), 400
|
|
force = _parse_bool_ish(data.get('force', False))
|
|
if force is None:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'force must be a boolean'
|
|
}), 400
|
|
|
|
wifi_manager = WiFiManager()
|
|
success, message, reason = wifi_manager.set_wifi_radio(enabled, force=force)
|
|
|
|
if success:
|
|
return jsonify({
|
|
'status': 'success',
|
|
'message': message,
|
|
'data': wifi_manager.get_wifi_radio_state()
|
|
})
|
|
else:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': message,
|
|
'reason': reason
|
|
}), 400
|