mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-05 14:55:08 +00:00
fix: /errors stack traces, Wi-Fi disconnect and save, plugin fonts, API cache TTL (#636)
* fix(errors): record the exception's own stack trace record_error() called traceback.format_exc(), which only sees an exception while its except block is running. plugin_executor records exceptions caught on a worker thread after that block has ended, so every trace on /errors read "NoneType: None". The trace is now built from the exception's __traceback__. The executor's log call had the same problem with exc_info=True and now passes the exception. record_error() also merged LEDMatrixError context into the caller's dict in place; it now works on a copy. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(wifi): point at configure_wifi_permissions.sh instead of a sudoers list The module docstring told users to grant NOPASSWD sudo on iptables and ip. configure_wifi_permissions.sh refuses those grants on purpose: a wildcard rule for either runs an arbitrary program as root. Point at the script and say why it leaves them out. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(wifi): disconnect finds the saved profile by SSID disconnect_from_network() asked `nmcli -f NAME,802-11-wireless.ssid connection show` for the profile to take down, but nmcli rejects that column for `connection show`, so the lookup always failed and only the device was disconnected. The per-profile lookup _connect_nmcli() already used is now _find_profile_for_ssid(), and both callers share it. It also splits terse output on the last colon and unescapes "\:", so a profile name containing a colon is found. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(wifi): write wifi_config.json atomically and report a failed save _save_config() opened the file for writing in place and swallowed any error, so a wifi_config.json left owned by root made the web toggle for auto-enabling AP mode report success while nothing was saved, and a crash mid-write could truncate the file. It now uses atomic_write_json, which also keeps the file's owner and shared group when root saves it, and returns False on failure. POST /wifi/ap/auto-enable answers 500 in that case. The file is now written with indent=4, like the other config files. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(fonts): resolve plugin:// fonts in the plugin's own directory FontManager looked for a plugin's bundled fonts under Path("plugins") / plugin_id: relative to the process cwd, and not the default install directory (plugin-repos/), so a manifest's plugin:// fonts never loaded. register_plugin_fonts() takes an optional plugin_dir, and PluginManager passes the directory it loaded the plugin from. Callers that omit it get a lookup in the configured plugin_system.plugins_directory, then plugins/, resolved against the install root. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(api-helper): cache responses for the requested cache_ttl APIHelper.get(cache_ttl=...) and set_cache(ttl=...) dropped the ttl on the claim that CacheManager does not support one, but CacheManager.set() takes a ttl, stores it with the entry, and both cache tiers honour it over a reader's max_age. Without it every response expired after the 300-second default read age, whatever the plugin asked for. The ttl is now passed through, and the cache read passes cache_ttl as max_age for entries written without one. The class docstring describes what the helper actually does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(style): one scale range for the schema, element_scale and LogoHelper The generated Scale field allowed 0.1 to 10, element_style's reader capped at 10 with no floor, and LogoHelper accepted 0.05 to 8 and reset anything else to 1.0. A logo scale of 9, which the form accepts, drew at the shipped size. MIN_ELEMENT_SCALE / MAX_ELEMENT_SCALE (0.1, 10.0) in src.element_style are now the schema bounds and the clamp every reader applies through coerce_scale(): a positive number outside the range is clamped, and anything that is not a finite positive number means the default. That also stops element_scale() passing NaN through, since min(nan, 10.0) is nan. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(logos): placeholder lands at the requested path; empty logos list download_missing_logo() wrote its fallback placeholder to <normalize_abbreviation(abbr)>.png in the logo directory rather than to the logo_path the caller passed, so it could return True while nothing existed where the plugin looks (e.g. "TA&M.png" vs "TAANDM.png"). create_placeholder_logo() takes an optional filepath, and download_missing_logo passes the requested one. download_missing_logo_for_team() only caught KeyError, so a team whose "logos" list is empty raised IndexError; it now treats KeyError, IndexError and TypeError as "no logo URL". The placeholder is drawn with PLACEHOLDER_SIZE / PLACEHOLDER_BG, the constants is_placeholder_logo() recognises it by, instead of repeated literals. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(fonts): resolve bundled font paths against the install root TextHelper's default font_dir, the logo placeholder's font and FontManager's font_overrides.json were all relative to the process cwd, so a process started anywhere but the install root (the plugin safety harness, a manual run, a unit without WorkingDirectory) drew with PIL's default face and read no overrides. They now go through font_layout.resolve_asset_path; the overrides file sits in the install root's config/. The resolver docstrings described an order the code does not follow: resolve_asset_path never consults the cwd, and sports_shared's _resolve_font_path tries the cwd first. Both docstrings now say what the code does, and _resolve_font_path calls resolve_asset_path instead of probing FontManager for it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(sync): the web UI reads the sync status file the display writes sync_manager writes its status to tempfile.gettempdir(), but GET /api/v3/sync/status read a hardcoded /tmp/led_matrix_sync_status.json and defaulted the port to a literal 5765. Wherever TMPDIR is set (or on any non-/tmp host) the page only ever showed "starting". The endpoint now uses sync_manager.STATUS_FILE and SYNC_PORT. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(http): the rankings resolver sends the project's User-Agent DynamicTeamResolver fetched ESPN rankings with a bare requests.get, so it sent python-requests' default User-Agent, which ESPN rejects; the AP_TOP_N favourites then resolved to nothing. It now sends DEFAULT_HTTP_HEADERS. BaseOddsManager carried its own copy of the User-Agent string and now uses the same shared headers (which also adds Accept-Language). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(backup): record the core release and read the configured plugin dir The manifest's ledmatrix_version came from a VERSION file that does not exist, then from .git/HEAD: a 12-character sha, or "ref: refs/he" when the branch's ref was packed. It is now src.__version__. list_installed_plugins() scanned a hardcoded plugin-repos/, so on an install whose plugin_system.plugins_directory points elsewhere, plugins missing from plugin_state.json were left out of the backup. It now reads the configured directory from config/config.json, defaulting to plugin-repos. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(startup): report a missing display section once A config without a display section produced three errors for the one problem ("Missing required configuration key: display", "Display configuration is missing or empty" and "Display configuration is missing"), and an empty one produced two. _validate_config now reports it once, as a missing key or an empty section, and _validate_display_config leaves it to that. The module docstring said the validator fails fast; nothing in the display service calls raise_on_errors(), so it now says the errors are reported and startup continues. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(wifi): share the copied blocks and name the AP constants - _parse_nmcli_wifi_list() is the one parser behind _scan_nmcli and _scan_nmcli_cached. - _verify_connected(), _wait_for_device_idle(), _failsafe_ap() and _mark_forced() replace blocks that were pasted two or three times in the connect and enable-AP paths. The device-idle wait now checks before its first one-second sleep instead of after it. - _check_command() calls _find_command_path() instead of repeating it. - AP_IP, PORTAL_PORT, AP_PROFILE_NAME and AP_PROFILE_NAMES name values that were spelled out 14, 12, 8 and 2 times; the two deletion loops now walk the same tuple. The iwconfig status path compares the AP address exactly: startswith() also skipped 192.168.4.10-19. - Dropped a second WIFI.SIGNAL query that repeated the first, a no-op "if ssid: continue", the try/except around _connect_wpa_supplicant's constant return, and a second save of a scan scan_networks already saves. - _ensure_wifi_radio_enabled's docstring says it returns True when the radio state cannot be read at all. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(config): drop dead branches and history comments in ConfigManager - The module docstring pointed plugin authors at update_plugin_config(), which does not exist; it now names save_config_atomic() and save_raw_file_content(). - load_config's FileNotFoundError handler tested the message for "config_secrets.json", but a missing secrets file is handled where it is read, so only config.json reaches it; the check is gone. - save_raw_file_content's `file_type == "main" or "secrets"` guard was always true (anything else raised earlier). - get_raw_file_content('secrets') already returns {} for a missing file, so the os.path.exists() in front of two calls to it is gone. - Comments that narrated earlier behaviour are rewritten as what the code does now. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(background-data): present-tense comments, drop unused API - Comments that told the history of each fix (what "used to" happen, "the old per-delivery release") now state the invariant the code keeps. - get_statistics() no longer reports a constant 'queue_size': 0, and the uncalled clear_completed_requests() is gone (_cleanup_completed_requests does that job on every completion). Neither is referenced in core, the web UI or the plugin monorepo. shutdown_background_service() has no production caller either, but it is the only way to tear down the get_background_service() singleton, which the tests rely on, so it stays. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(odds): drop the unread cache_ttl and merge the odds_data branches BaseOddsManager loaded base_odds_manager.cache_ttl from config and never used it: cached odds live for the update interval (get_odds' ttl=interval). No core or monorepo code reads the attribute, so it is gone along with its log line. The two consecutive `if odds_data:` blocks are one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(backup): one table for the single-file sections config, secrets, wifi and ytm_auth were each spelled out in create, preview, validate and restore. _SINGLE_FILE_SECTIONS lists them once, with the RestoreOptions flag that restores each, and all four walk it. Restore error messages keep their wording ("Failed to restore <file name>"). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(fonts): drop FontManager's write-only state and duplicate logs - fonts_config, font_metadata and font_dependencies were written and never read; the performance_stats keys font_load_times, render_times, total_renders and the per-call "resolve" timings (_record_performance_metric) likewise. get_performance_stats() reads only the counters that remain. Nothing in core or the plugin monorepo references any of them. - A failed BDF load was logged twice, by _load_bdf_font and again by get_font; get_font's line is the one kept. - Removed "NEW:" and commented-out cozette entries, the "Copy font to assets/fonts" comment on code that copies nothing, and local imports of names the module already imports. The deprecated add_font() now resolves assets/fonts against the install root. The @deprecated methods stay. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(text-helper): cache loaded fonts; drop the pre-textlength fallback TextHelper declared _font_cache, cleared it and reported its size, but never stored anything in it. load_fonts() now keeps each (file, size) it loads there, so clear_font_cache() and get_font_cache_stats() mean what they say and repeated load_fonts() calls reuse the fonts. get_text_width() no longer catches AttributeError for Pillow releases without ImageDraw.textlength; requirements.txt pins Pillow>=12.2. The class docstring describes what the helper does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(common): fix wrong docstrings in api_helper, permission_utils, snapshot_policy - permission_utils called 0o2775 "sticky bit"; the 2 is setgid, which is what makes new files take the directory's group. - snapshot_policy pointed at web_interface/blueprints/api_v3.py, which is a package now; the health check is in api_v3/misc.py. - APIHelper.clear_cache() lost a history note and a fallback to a clear() method that neither CacheManager nor the testing MockCacheManager has. The session headers are built from DEFAULT_HTTP_HEADERS instead of a copy of them, and the module docstring says what the module offers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(sports): present-tense comments in the shared scoreboard renderers - sports_scroll and sports_game_renderer comments that referred to "this PR", "the old flat 128px card" or what the renderer "previously" did now describe the current behaviour and its reason. - The block explaining why non-finite settings are rejected sat above _score_reserve_width; it describes _center_gap_width and now lives in it. - unshare_element_fonts wrapped its import of font_layout.load_truetype in an `except ImportError` that cannot fire inside core; the import stays at call time so tests can spy on the pinned loader. - sports_card docstrings that told the history of a fix say what the code does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(sports-shared): drop dead code, name the ESPN limit - _get_weeks_data asked for limit=1000, which fetch_espn_scoreboard clamps to ESPN_MAX_LIMIT anyway; it now names that constant. Its unused `immediate_events = []` is gone. - _get_season_schedule_dates() returned ("", "") and has no caller in core or the plugin monorepo. - _should_log keeps its warning_type parameter (part of the inherited signature, though nothing in core or the monorepo calls it) and its docstring says the cooldown is shared across types. - An unused ImageFont import is gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(sync): one follower-mode switch, shared panel defaults - The class docstring said the leader sends PNG frames. Frames go over UDP as raw RGB; PNG is only the Vegas scroll image sent over TCP. It now describes both paths. - _enter_follower_mode() replaces the two copies of "note the leader, switch from standalone to follower, log, write status" in the frame and scroll-position handlers. - The rows/cols fallbacks use DEFAULT_ROWS / DEFAULT_COLS from src.display_geometry, as chain_length already did. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(style): drop _layout_axis, name the layout group title - ElementStyleResolver._layout_axis() had no caller in core or the plugin monorepo. - _element_block_from_spec checked spec['size'] was a dict again after size_spec already had; it reads size_spec. - The "Layout Offsets" title written into three generated schema blocks is _LAYOUT_TITLE. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(logo-helper): say what the placeholder draws; name the 1.5 box factor - _create_placeholder_logo's docstring said it draws the team abbreviation; it draws an outlined grey box and nothing else. The docstring says so, and the "in a real implementation you'd want text" comments are gone. - The 1.5 x panel default logo box, written out six times, is DEFAULT_LOGO_BOX_FACTOR. - ImageDraw is imported with Image at the top of the module. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(logos): drop dead code and a duplicate regex in logo_downloader - _SAFE_LEAGUE_CODE_RE was the same pattern as _SAFE_LEAGUE_RE; both checks use the one. - get_logo_filename_variations reassigned the TA&M case to the list it already had; the function returns the two names directly. - _get_team_name_variations() had no caller in core or the plugin monorepo. - fetch_single_team's docstring was copied from fetch_teams_data; a log message read "for{team_id}". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor: drop the Pillow<9.1 resample shim and a catch-and-reraise - adaptive_images fell back to Image.LANCZOS/NEAREST for Pillow < 9.1; requirements.txt pins Pillow>=12.2. RESAMPLE_LANCZOS and RESAMPLE_NEAREST keep their names (src.common re-exports them). - CacheManager.save_cache caught CacheError only to re-raise it; the disk write is now called directly, with the same result. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(api-helper): stop the real CacheManager's cleanup thread The cache-lifetime tests built a CacheManager and left its cleanup thread's class-wide claim on the directory in place, which broke test_cache_cleanup_thread_ownership when it ran later in the session. The fixture now stops the thread on teardown. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(changelog): core-common Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+266
-347
@@ -9,24 +9,18 @@ Tested and optimized for:
|
||||
- Raspberry Pi OS Bookworm (Debian 12) with NetworkManager
|
||||
- Raspberry Pi 3B+, 4, 5 with built-in WiFi
|
||||
|
||||
Sudoers Requirements:
|
||||
The following sudoers entries are required for passwordless operation.
|
||||
Add to /etc/sudoers.d/ledmatrix_wifi:
|
||||
|
||||
ledpi ALL=(ALL) NOPASSWD: /usr/bin/nmcli
|
||||
ledpi ALL=(ALL) NOPASSWD: /usr/bin/systemctl start hostapd
|
||||
ledpi ALL=(ALL) NOPASSWD: /usr/bin/systemctl stop hostapd
|
||||
ledpi ALL=(ALL) NOPASSWD: /usr/bin/systemctl start dnsmasq
|
||||
ledpi ALL=(ALL) NOPASSWD: /usr/bin/systemctl stop dnsmasq
|
||||
ledpi ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart NetworkManager
|
||||
ledpi ALL=(ALL) NOPASSWD: /usr/sbin/ip
|
||||
ledpi ALL=(ALL) NOPASSWD: /sbin/ip
|
||||
ledpi ALL=(ALL) NOPASSWD: /usr/sbin/rfkill
|
||||
ledpi ALL=(ALL) NOPASSWD: /usr/sbin/iptables
|
||||
ledpi ALL=(ALL) NOPASSWD: /usr/sbin/sysctl
|
||||
ledpi ALL=(ALL) NOPASSWD: /usr/bin/cp /tmp/hostapd.conf /etc/hostapd/hostapd.conf
|
||||
ledpi ALL=(ALL) NOPASSWD: /usr/bin/cp /tmp/dnsmasq.conf /etc/dnsmasq.d/ledmatrix-captive.conf
|
||||
ledpi ALL=(ALL) NOPASSWD: /usr/bin/rm -f /etc/dnsmasq.d/ledmatrix-captive.conf
|
||||
Privileges:
|
||||
The web interface runs as an unprivileged user and reaches nmcli,
|
||||
systemctl, sysctl, nft and rfkill through exact-command sudo rules.
|
||||
scripts/install/configure_wifi_permissions.sh writes those rules (and a
|
||||
PolicyKit rule for NetworkManager); first_time_install.sh runs it. Use
|
||||
that script rather than granting commands by hand. It deliberately
|
||||
grants neither ``iptables`` nor ``ip``: their rules take a live interface
|
||||
name, so they would need a wildcard, and ``iptables --modprobe=<path>``
|
||||
and ``ip netns exec`` both run an arbitrary program as root. The code
|
||||
paths that call them with sudo therefore only work where the user has
|
||||
broader sudo rights (a stock Raspberry Pi image grants the default user
|
||||
blanket NOPASSWD).
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
@@ -39,6 +33,8 @@ from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from dataclasses import dataclass
|
||||
|
||||
from src.config_manager_atomic import atomic_write_json
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Path for storing WiFi configuration (will be set dynamically)
|
||||
@@ -78,6 +74,22 @@ DNSMASQ_SERVICE = "dnsmasq"
|
||||
DEFAULT_AP_SSID = "LEDMatrix-Setup"
|
||||
DEFAULT_AP_CHANNEL = 7
|
||||
|
||||
#: The access point's own address. Clients get 192.168.4.2-20 from dnsmasq
|
||||
#: (hostapd mode) and every DNS name resolves here, which is what makes phones
|
||||
#: show the captive-portal page.
|
||||
AP_IP = "192.168.4.1"
|
||||
|
||||
#: The web interface's port. The captive portal redirects port 80 to it.
|
||||
PORTAL_PORT = 5000
|
||||
|
||||
#: The NetworkManager profile this module creates for the access point.
|
||||
AP_PROFILE_NAME = "LEDMatrix-Setup-AP"
|
||||
|
||||
#: AP profiles taken down and deleted before a new one is created and when AP
|
||||
#: mode ends: ours, NetworkManager's default hotspot name, and an older name.
|
||||
#: Deleted by name only, never by SSID, so a saved home network is never hit.
|
||||
AP_PROFILE_NAMES = (AP_PROFILE_NAME, "Hotspot", "TickerSetup-AP")
|
||||
|
||||
# LED status message file (for display_controller integration)
|
||||
LED_STATUS_FILE = None # Will be set dynamically
|
||||
|
||||
@@ -198,30 +210,8 @@ class WiFiManager:
|
||||
logger.debug(f"Could not clear LED status message: {e}")
|
||||
|
||||
def _check_command(self, command: str) -> bool:
|
||||
"""Check if a command is available"""
|
||||
try:
|
||||
# First try 'which' command
|
||||
result = subprocess.run(
|
||||
["which", command],
|
||||
capture_output=True,
|
||||
timeout=2
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return True
|
||||
|
||||
# Check common sbin paths (not in standard user PATH)
|
||||
sbin_paths = [
|
||||
f"/usr/sbin/{command}",
|
||||
f"/sbin/{command}",
|
||||
f"/usr/local/sbin/{command}"
|
||||
]
|
||||
for path in sbin_paths:
|
||||
if os.path.isfile(path) and os.access(path, os.X_OK):
|
||||
return True
|
||||
|
||||
return False
|
||||
except (subprocess.TimeoutExpired, subprocess.SubprocessError, OSError):
|
||||
return False
|
||||
"""Whether ``command`` is installed (see _find_command_path)."""
|
||||
return self._find_command_path(command) is not None
|
||||
|
||||
def _find_command_path(self, command: str) -> Optional[str]:
|
||||
"""
|
||||
@@ -321,14 +311,22 @@ class WiFiManager:
|
||||
del self.config["saved_networks"]
|
||||
self._save_config()
|
||||
|
||||
def _save_config(self):
|
||||
"""Save WiFi configuration to file"""
|
||||
def _save_config(self) -> bool:
|
||||
"""Write ``self.config`` to ``self.config_path``.
|
||||
|
||||
The write is atomic and keeps the file's owner and shared group (see
|
||||
atomic_write_json), so a save by the root display service does not
|
||||
lock the web user out of the file. Returns False when the file could
|
||||
not be written, for example when an older root-run save left it
|
||||
owned by root; the in-memory config is kept either way.
|
||||
"""
|
||||
try:
|
||||
with open(self.config_path, 'w') as f:
|
||||
json.dump(self.config, f, indent=2)
|
||||
logger.info(f"Saved WiFi config to {self.config_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save WiFi config: {e}")
|
||||
atomic_write_json(self.config_path, self.config)
|
||||
except (OSError, TypeError, ValueError) as e:
|
||||
logger.error(f"Failed to save WiFi config to {self.config_path}: {e}")
|
||||
return False
|
||||
logger.info(f"Saved WiFi config to {self.config_path}")
|
||||
return True
|
||||
|
||||
def get_wifi_status(self) -> WiFiStatus:
|
||||
"""
|
||||
@@ -402,8 +400,6 @@ class WiFiManager:
|
||||
for line in result.stdout.strip().split('\n'):
|
||||
if '802-11-wireless.ssid:' in line:
|
||||
ssid = line.split(':', 1)[1].strip()
|
||||
if ssid:
|
||||
continue
|
||||
elif 'WIFI.SIGNAL:' in line:
|
||||
try:
|
||||
signal = int(line.split(':', 1)[1].strip())
|
||||
@@ -425,24 +421,7 @@ class WiFiManager:
|
||||
ssid = parts[1].strip()
|
||||
if ssid:
|
||||
break
|
||||
|
||||
# Fallback: Get signal strength if not already retrieved
|
||||
if signal == 0 and wlan_device:
|
||||
result = subprocess.run(
|
||||
["nmcli", "-t", "-f", "WIFI.SIGNAL", "device", "show", wlan_device],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
if result.returncode == 0:
|
||||
for line in result.stdout.strip().split('\n'):
|
||||
if 'WIFI.SIGNAL:' in line:
|
||||
try:
|
||||
signal = int(line.split(':', 1)[1].strip())
|
||||
break
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
|
||||
|
||||
# Get IP address if connected
|
||||
if wifi_connected and wlan_device:
|
||||
result = subprocess.run(
|
||||
@@ -536,7 +515,7 @@ class WiFiManager:
|
||||
if result.returncode == 0:
|
||||
ips = result.stdout.strip().split()
|
||||
for ip in ips:
|
||||
if not ip.startswith('192.168.4.1'): # Exclude AP IP
|
||||
if ip != AP_IP:
|
||||
ip_address = ip
|
||||
break
|
||||
|
||||
@@ -778,13 +757,13 @@ class WiFiManager:
|
||||
if subprocess.run(
|
||||
["sudo", iptables, "-t", "nat", "-C", "PREROUTING",
|
||||
"-i", self._wifi_interface, "-p", "tcp", "--dport", "80",
|
||||
"-j", "REDIRECT", "--to-port", "5000"],
|
||||
"-j", "REDIRECT", "--to-port", str(PORTAL_PORT)],
|
||||
capture_output=True, timeout=5
|
||||
).returncode != 0:
|
||||
r = subprocess.run(
|
||||
["sudo", iptables, "-t", "nat", "-A", "PREROUTING",
|
||||
"-i", self._wifi_interface, "-p", "tcp", "--dport", "80",
|
||||
"-j", "REDIRECT", "--to-port", "5000"],
|
||||
"-j", "REDIRECT", "--to-port", str(PORTAL_PORT)],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
if r.returncode != 0:
|
||||
@@ -794,12 +773,12 @@ class WiFiManager:
|
||||
|
||||
if subprocess.run(
|
||||
["sudo", iptables, "-C", "INPUT",
|
||||
"-i", self._wifi_interface, "-p", "tcp", "--dport", "5000", "-j", "ACCEPT"],
|
||||
"-i", self._wifi_interface, "-p", "tcp", "--dport", str(PORTAL_PORT), "-j", "ACCEPT"],
|
||||
capture_output=True, timeout=5
|
||||
).returncode != 0:
|
||||
r = subprocess.run(
|
||||
["sudo", iptables, "-A", "INPUT",
|
||||
"-i", self._wifi_interface, "-p", "tcp", "--dport", "5000", "-j", "ACCEPT"],
|
||||
"-i", self._wifi_interface, "-p", "tcp", "--dport", str(PORTAL_PORT), "-j", "ACCEPT"],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
if r.returncode != 0:
|
||||
@@ -808,7 +787,7 @@ class WiFiManager:
|
||||
return False
|
||||
|
||||
self._redirect_backend = "iptables"
|
||||
logger.info("iptables: port 80→5000 redirect rules added")
|
||||
logger.info(f"iptables: port 80→{PORTAL_PORT} redirect rules added")
|
||||
return True
|
||||
|
||||
def _setup_iptables_redirect_nftables(self, nft: str) -> bool:
|
||||
@@ -819,7 +798,7 @@ class WiFiManager:
|
||||
["sudo", nft, "add", "chain", "ip", "ledmatrix", "prerouting",
|
||||
"{", "type", "nat", "hook", "prerouting", "priority", "-100", ";", "}"],
|
||||
["sudo", nft, "add", "rule", "ip", "ledmatrix", "prerouting",
|
||||
"iif", self._wifi_interface, "tcp", "dport", "80", "redirect", "to", ":5000"],
|
||||
"iif", self._wifi_interface, "tcp", "dport", "80", "redirect", "to", f":{PORTAL_PORT}"],
|
||||
]
|
||||
for cmd in cmds:
|
||||
r = subprocess.run(cmd, capture_output=True, text=True, timeout=5)
|
||||
@@ -832,7 +811,7 @@ class WiFiManager:
|
||||
logger.debug(f"nft cmd non-zero (may already exist): {r.stderr.strip()}")
|
||||
|
||||
self._redirect_backend = "nftables"
|
||||
logger.info("nftables: port 80→5000 redirect rule added")
|
||||
logger.info(f"nftables: port 80→{PORTAL_PORT} redirect rule added")
|
||||
return True
|
||||
|
||||
def _teardown_iptables_redirect(self) -> None:
|
||||
@@ -847,12 +826,12 @@ class WiFiManager:
|
||||
subprocess.run(
|
||||
["sudo", iptables, "-t", "nat", "-D", "PREROUTING",
|
||||
"-i", self._wifi_interface, "-p", "tcp", "--dport", "80",
|
||||
"-j", "REDIRECT", "--to-port", "5000"],
|
||||
"-j", "REDIRECT", "--to-port", str(PORTAL_PORT)],
|
||||
capture_output=True, timeout=5
|
||||
)
|
||||
subprocess.run(
|
||||
["sudo", iptables, "-D", "INPUT",
|
||||
"-i", self._wifi_interface, "-p", "tcp", "--dport", "5000",
|
||||
"-i", self._wifi_interface, "-p", "tcp", "--dport", str(PORTAL_PORT),
|
||||
"-j", "ACCEPT"],
|
||||
capture_output=True, timeout=5
|
||||
)
|
||||
@@ -887,7 +866,7 @@ class WiFiManager:
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not tear down port redirect: {e}")
|
||||
|
||||
def _write_nm_dnsmasq_captive_conf(self, ap_ip: str = "192.168.4.1") -> None:
|
||||
def _write_nm_dnsmasq_captive_conf(self, ap_ip: str = AP_IP) -> None:
|
||||
"""
|
||||
Write the NM dnsmasq-shared.d drop-in that makes NM's built-in dnsmasq
|
||||
resolve every hostname to the AP IP. This triggers the OS captive-portal
|
||||
@@ -1026,39 +1005,53 @@ class WiFiManager:
|
||||
)
|
||||
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)
|
||||
networks = self._parse_nmcli_wifi_list(result.stdout)
|
||||
except Exception as e:
|
||||
logger.debug(f"nmcli cached list failed: {e}")
|
||||
return networks
|
||||
|
||||
@staticmethod
|
||||
def _parse_nmcli_wifi_list(stdout: str) -> List[WiFiNetwork]:
|
||||
"""Parse ``nmcli -t -f SSID,SIGNAL,SECURITY,FREQ device wifi list``.
|
||||
|
||||
One entry per SSID (the first line seen for it; hidden networks with
|
||||
an empty SSID are skipped), security reduced to wpa3/wpa2/wpa/open,
|
||||
sorted strongest first. Unparseable lines are skipped.
|
||||
"""
|
||||
networks = []
|
||||
seen_ssids = set()
|
||||
for line in stdout.strip().split('\n'):
|
||||
if not line or ':' not in line:
|
||||
continue
|
||||
parts = line.split(':')
|
||||
if len(parts) < 3:
|
||||
continue
|
||||
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()
|
||||
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
|
||||
except (ValueError, IndexError) as e:
|
||||
logger.debug(f"Skipping network line due to parsing error: {line[:50]}... Error: {e}")
|
||||
continue
|
||||
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))
|
||||
networks.sort(key=lambda x: x.signal, reverse=True)
|
||||
return networks
|
||||
|
||||
def _save_cached_scan(self, networks: List[WiFiNetwork]) -> None:
|
||||
"""Save scan results to a cache file for use during AP mode."""
|
||||
try:
|
||||
@@ -1088,7 +1081,6 @@ class WiFiManager:
|
||||
|
||||
def _scan_nmcli(self) -> List[WiFiNetwork]:
|
||||
"""Scan networks using nmcli"""
|
||||
networks = []
|
||||
try:
|
||||
# Trigger scan
|
||||
subprocess.run(
|
||||
@@ -1108,52 +1100,7 @@ class WiFiManager:
|
||||
|
||||
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"
|
||||
|
||||
# Parse frequency - strip " MHz" if present
|
||||
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
|
||||
|
||||
# Normalize security type
|
||||
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) as e:
|
||||
logger.debug(f"Skipping network line due to parsing error: {line[:50]}... Error: {e}")
|
||||
continue
|
||||
|
||||
# Sort by signal strength
|
||||
networks.sort(key=lambda x: x.signal, reverse=True)
|
||||
return networks
|
||||
return self._parse_nmcli_wifi_list(result.stdout)
|
||||
except Exception as e:
|
||||
logger.error(f"Error scanning with nmcli: {e}")
|
||||
return []
|
||||
@@ -1357,27 +1304,7 @@ class WiFiManager:
|
||||
disconnect_success, disconnect_msg = self.disconnect_from_network(skip_ap_check=True)
|
||||
if disconnect_success:
|
||||
logger.info(f"Disconnected from {original_ssid}: {disconnect_msg}")
|
||||
# Wait for device to be ready for new connection
|
||||
# Check device state before proceeding
|
||||
max_wait = 5
|
||||
wait_count = 0
|
||||
while wait_count < max_wait:
|
||||
time.sleep(1)
|
||||
result = subprocess.run(
|
||||
["nmcli", "-t", "-f", "STATE", "device", "status", self._wifi_interface],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
if result.returncode == 0:
|
||||
state = result.stdout.strip().split(':')[-1] if ':' in result.stdout else result.stdout.strip()
|
||||
# Device is ready if it's disconnected or unavailable (not connecting/connected)
|
||||
if state in ["disconnected", "unavailable", "unmanaged"]:
|
||||
logger.info(f"Device ready for new connection (state: {state})")
|
||||
break
|
||||
wait_count += 1
|
||||
|
||||
if wait_count >= max_wait:
|
||||
if not self._wait_for_device_idle(5):
|
||||
logger.warning("Device may not be ready, but proceeding with connection attempt")
|
||||
else:
|
||||
logger.warning(f"Failed to disconnect from {original_ssid}: {disconnect_msg}")
|
||||
@@ -1403,26 +1330,15 @@ class WiFiManager:
|
||||
return False, f"Failed to connect to {ssid}, restored {original_ssid}"
|
||||
else:
|
||||
logger.error(f"Failed to restore original connection: {original_ssid}")
|
||||
# Trigger AP mode as last resort
|
||||
self._show_led_message("Enabling AP mode...", duration=5)
|
||||
ap_success, ap_msg = self.enable_ap_mode(force=True)
|
||||
if ap_success:
|
||||
logger.info("AP mode enabled as failsafe")
|
||||
return False, "Connection failed and restoration failed. AP mode enabled."
|
||||
else:
|
||||
logger.error(f"Failed to enable AP mode: {ap_msg}")
|
||||
return False, f"Connection failed, restoration failed, and AP mode failed: {ap_msg}"
|
||||
return self._failsafe_ap(
|
||||
"Connection failed and restoration failed. AP mode enabled.",
|
||||
"Connection failed, restoration failed, and AP mode failed")
|
||||
|
||||
# If connection failed and no original connection to restore, enable AP mode
|
||||
elif not success:
|
||||
logger.warning(f"Connection to {ssid} failed and no original connection to restore")
|
||||
self._show_led_message("Enabling AP mode...", duration=5)
|
||||
ap_success, ap_msg = self.enable_ap_mode(force=True)
|
||||
if ap_success:
|
||||
logger.info("AP mode enabled as failsafe")
|
||||
return False, "Connection failed. AP mode enabled."
|
||||
else:
|
||||
return False, f"Connection failed and AP mode failed: {ap_msg}"
|
||||
return self._failsafe_ap("Connection failed. AP mode enabled.",
|
||||
"Connection failed and AP mode failed")
|
||||
|
||||
return success, message
|
||||
else:
|
||||
@@ -1443,6 +1359,22 @@ class WiFiManager:
|
||||
logger.error("Last-resort AP mode enable failed in recovery path: %s", ap_error, exc_info=True)
|
||||
return False, str(e)
|
||||
|
||||
def _failsafe_ap(self, enabled_msg: str, failed_msg: str) -> Tuple[bool, str]:
|
||||
"""Force the setup AP up after a connect that left no working network,
|
||||
so the user can still reach the device.
|
||||
|
||||
Returns the (False, message) result for connect_to_network:
|
||||
``enabled_msg`` when the AP came up, else ``failed_msg`` plus the
|
||||
reason it did not.
|
||||
"""
|
||||
self._show_led_message("Enabling AP mode...", duration=5)
|
||||
ap_success, ap_msg = self.enable_ap_mode(force=True)
|
||||
if ap_success:
|
||||
logger.info("AP mode enabled as failsafe")
|
||||
return False, enabled_msg
|
||||
logger.error(f"Failed to enable AP mode: {ap_msg}")
|
||||
return False, f"{failed_msg}: {ap_msg}"
|
||||
|
||||
def _restore_original_connection(self, connection_name: str, ssid: str) -> bool:
|
||||
"""
|
||||
Restore a previously active WiFi connection.
|
||||
@@ -1496,68 +1428,96 @@ class WiFiManager:
|
||||
logger.error(f"Error restoring connection: {e}")
|
||||
return False
|
||||
|
||||
def _find_profile_for_ssid(self, ssid: str) -> Optional[str]:
|
||||
"""Name of the saved NetworkManager profile for ``ssid``, or None.
|
||||
|
||||
``802-11-wireless.ssid`` is not a column ``nmcli connection show``
|
||||
can list, so this lists the Wi-Fi profiles and asks each one for its
|
||||
SSID. A profile named after the SSID is the fallback, for when the
|
||||
listing fails.
|
||||
"""
|
||||
list_result = subprocess.run( # nosec B603 B607 - fixed args, no user input
|
||||
["nmcli", "-t", "-f", "NAME,TYPE", "connection", "show"],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
if list_result.returncode == 0:
|
||||
for line in list_result.stdout.strip().split('\n'):
|
||||
# Terse output escapes a colon inside a field as "\:". TYPE
|
||||
# never contains one, so the last colon ends the name.
|
||||
conn_name, sep, conn_type = line.rpartition(':')
|
||||
if not sep or conn_type.strip() != '802-11-wireless':
|
||||
continue
|
||||
conn_name = conn_name.replace('\\:', ':').replace('\\\\', '\\')
|
||||
ssid_r = subprocess.run( # nosec B603 B607 - conn_name from nmcli output, not user input
|
||||
["nmcli", "-g", "802-11-wireless.ssid", "connection", "show", conn_name],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
if ssid_r.returncode == 0 and ssid_r.stdout.strip() == ssid:
|
||||
return conn_name
|
||||
|
||||
direct_check = subprocess.run( # nosec B603 B607 - list args, no shell
|
||||
["nmcli", "connection", "show", ssid],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
if direct_check.returncode == 0:
|
||||
return ssid
|
||||
return None
|
||||
|
||||
def _wait_for_device_idle(self, attempts: int) -> bool:
|
||||
"""Poll the Wi-Fi device, once a second for up to ``attempts`` checks,
|
||||
until it is disconnected, unavailable or unmanaged: a profile
|
||||
activated while the device is still connecting or tearing down an
|
||||
old link can fail. True if it went idle, False on timeout."""
|
||||
for attempt in range(attempts):
|
||||
result = subprocess.run(
|
||||
["nmcli", "-t", "-f", "STATE", "device", "status", self._wifi_interface],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
if result.returncode == 0:
|
||||
state = result.stdout.strip().split(':')[-1]
|
||||
if state in ("disconnected", "unavailable", "unmanaged"):
|
||||
logger.debug(f"Wi-Fi device idle (state: {state})")
|
||||
return True
|
||||
if attempt < attempts - 1:
|
||||
time.sleep(1)
|
||||
return False
|
||||
|
||||
def _verify_connected(self, ssid: str, attempts: int = 5, delay: float = 2.0,
|
||||
stop_on_other_network: bool = False) -> Optional[WiFiStatus]:
|
||||
"""Wait for the device to report a connection to ``ssid``.
|
||||
|
||||
nmcli returns before DHCP finishes, so the status is polled every
|
||||
``delay`` seconds, up to ``attempts`` times. Returns that status, or
|
||||
None if it never showed ``ssid``. With ``stop_on_other_network`` a
|
||||
connection to a different SSID ends the wait at once as a failure.
|
||||
"""
|
||||
for _ in range(attempts):
|
||||
time.sleep(delay)
|
||||
status = self.get_wifi_status()
|
||||
if not status.connected:
|
||||
continue
|
||||
if status.ssid == ssid:
|
||||
return status
|
||||
if stop_on_other_network and status.ssid:
|
||||
logger.warning(f"Connected to wrong network: {status.ssid} instead of {ssid}")
|
||||
return None
|
||||
return None
|
||||
|
||||
def _connect_nmcli(self, ssid: str, password: str) -> Tuple[bool, str]:
|
||||
"""Connect using nmcli"""
|
||||
try:
|
||||
# Show LED message
|
||||
self._show_led_message(f"Connecting to {ssid}...", duration=10)
|
||||
|
||||
# Find existing NM connection for this SSID.
|
||||
# 802-11-wireless.ssid is not a valid column in 'nmcli connection show',
|
||||
# so list all wifi connections then query each one's SSID individually.
|
||||
list_result = subprocess.run( # nosec B603 B607 - fixed args, no user input
|
||||
["nmcli", "-t", "-f", "NAME,TYPE", "connection", "show"],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
existing_conn_name = None
|
||||
if list_result.returncode == 0:
|
||||
for line in list_result.stdout.strip().split('\n'):
|
||||
if ':' not in line:
|
||||
continue
|
||||
parts = line.split(':')
|
||||
if len(parts) < 2 or parts[1].strip() != '802-11-wireless':
|
||||
continue
|
||||
conn_name = parts[0].strip()
|
||||
ssid_r = subprocess.run( # nosec B603 B607 - conn_name from nmcli output, not user input
|
||||
["nmcli", "-g", "802-11-wireless.ssid", "connection", "show", conn_name],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
if ssid_r.returncode == 0 and ssid_r.stdout.strip() == ssid:
|
||||
existing_conn_name = conn_name
|
||||
break
|
||||
|
||||
# Also try direct lookup by SSID (in case connection name matches SSID)
|
||||
if not existing_conn_name:
|
||||
direct_check = subprocess.run(
|
||||
["nmcli", "connection", "show", ssid],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
if direct_check.returncode == 0:
|
||||
existing_conn_name = ssid
|
||||
|
||||
existing_conn_name = self._find_profile_for_ssid(ssid)
|
||||
if existing_conn_name:
|
||||
# Connection exists, try to activate it first (faster and more reliable)
|
||||
logger.info(f"Found existing connection for {ssid}, activating...")
|
||||
|
||||
# Ensure device is ready before activating
|
||||
# Wait for device to be in disconnected/unavailable state
|
||||
max_wait = 3
|
||||
for wait_attempt in range(max_wait):
|
||||
device_result = subprocess.run(
|
||||
["nmcli", "-t", "-f", "STATE", "device", "status", self._wifi_interface],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
if device_result.returncode == 0:
|
||||
state = device_result.stdout.strip().split(':')[-1] if ':' in device_result.stdout else device_result.stdout.strip()
|
||||
if state in ["disconnected", "unavailable", "unmanaged"]:
|
||||
break
|
||||
if wait_attempt < max_wait - 1:
|
||||
time.sleep(1)
|
||||
|
||||
self._wait_for_device_idle(3)
|
||||
|
||||
result = subprocess.run(
|
||||
["nmcli", "connection", "up", existing_conn_name],
|
||||
capture_output=True,
|
||||
@@ -1566,19 +1526,8 @@ class WiFiManager:
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
# Wait longer for connection to stabilize and verify multiple times
|
||||
max_verification_attempts = 5
|
||||
verification_delay = 2
|
||||
connected = False
|
||||
|
||||
for attempt in range(max_verification_attempts):
|
||||
time.sleep(verification_delay)
|
||||
status = self.get_wifi_status()
|
||||
if status.connected and status.ssid == ssid:
|
||||
connected = True
|
||||
break
|
||||
|
||||
if connected:
|
||||
status = self._verify_connected(ssid)
|
||||
if status is not None:
|
||||
ip = status.ip_address or "Unknown"
|
||||
self._show_led_message(f"Connected! {ip}", duration=5)
|
||||
logger.info(f"Successfully connected to {ssid} with IP {ip}")
|
||||
@@ -1605,25 +1554,8 @@ class WiFiManager:
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
# Wait longer for connection to stabilize and verify multiple times
|
||||
max_verification_attempts = 5
|
||||
verification_delay = 2
|
||||
connected = False
|
||||
|
||||
for attempt in range(max_verification_attempts):
|
||||
time.sleep(verification_delay)
|
||||
status = self.get_wifi_status()
|
||||
if status.connected:
|
||||
# Verify we're connected to the correct SSID
|
||||
if status.ssid == ssid:
|
||||
connected = True
|
||||
break
|
||||
elif status.ssid:
|
||||
# Connected to different network - this is a failure
|
||||
logger.warning(f"Connected to wrong network: {status.ssid} instead of {ssid}")
|
||||
break
|
||||
|
||||
if connected:
|
||||
status = self._verify_connected(ssid, stop_on_other_network=True)
|
||||
if status is not None:
|
||||
ip = status.ip_address or "Unknown"
|
||||
self._show_led_message(f"Connected! {ip}", duration=5)
|
||||
logger.info(f"Successfully connected to {ssid} with IP {ip}")
|
||||
@@ -1717,14 +1649,10 @@ class WiFiManager:
|
||||
return any(ind in lower for ind in indicators)
|
||||
|
||||
def _connect_wpa_supplicant(self, ssid: str, password: str) -> Tuple[bool, str]:
|
||||
"""Connect using wpa_supplicant (fallback)"""
|
||||
try:
|
||||
# This would require modifying /etc/wpa_supplicant/wpa_supplicant.conf
|
||||
# For now, return not implemented
|
||||
return False, "wpa_supplicant connection not yet implemented. Please use NetworkManager (nmcli)."
|
||||
except Exception as e:
|
||||
logger.error(f"Error connecting with wpa_supplicant: {e}")
|
||||
return False, str(e)
|
||||
"""Without NetworkManager there is no supported way to connect: doing it
|
||||
through wpa_supplicant would mean editing its config file, which is
|
||||
not implemented. Always returns (False, reason)."""
|
||||
return False, "wpa_supplicant connection not yet implemented. Please use NetworkManager (nmcli)."
|
||||
|
||||
def disconnect_from_network(self, skip_ap_check: bool = False) -> Tuple[bool, str]:
|
||||
"""
|
||||
@@ -1745,33 +1673,18 @@ class WiFiManager:
|
||||
|
||||
# Disconnect using nmcli
|
||||
if self.has_nmcli:
|
||||
# Try to disconnect the specific connection first (more reliable)
|
||||
# Take the profile down first, then the device, so the
|
||||
# device ends up disconnected even when no profile is found.
|
||||
if status.ssid:
|
||||
# Find the connection name for this SSID
|
||||
conn_result = subprocess.run(
|
||||
["nmcli", "-t", "-f", "NAME,802-11-wireless.ssid", "connection", "show"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
if conn_result.returncode == 0:
|
||||
for line in conn_result.stdout.strip().split('\n'):
|
||||
if ':' in line:
|
||||
parts = line.split(':')
|
||||
if len(parts) >= 2:
|
||||
conn_name = parts[0].strip()
|
||||
conn_ssid = parts[1].strip() if len(parts) > 1 else ""
|
||||
if conn_ssid == status.ssid:
|
||||
# Disconnect this specific connection
|
||||
subprocess.run(
|
||||
["nmcli", "connection", "down", conn_name],
|
||||
capture_output=True,
|
||||
timeout=10
|
||||
)
|
||||
logger.info(f"Disconnected connection {conn_name} for {status.ssid}")
|
||||
break
|
||||
|
||||
# Also disconnect the device to ensure clean state
|
||||
conn_name = self._find_profile_for_ssid(status.ssid)
|
||||
if conn_name:
|
||||
subprocess.run( # nosec B603 B607 - list args, no shell
|
||||
["nmcli", "connection", "down", conn_name],
|
||||
capture_output=True,
|
||||
timeout=10
|
||||
)
|
||||
logger.info(f"Disconnected connection {conn_name} for {status.ssid}")
|
||||
|
||||
result = subprocess.run(
|
||||
["nmcli", "device", "disconnect", self._wifi_interface],
|
||||
capture_output=True,
|
||||
@@ -1814,7 +1727,11 @@ class WiFiManager:
|
||||
max_retries: Maximum number of retry attempts to enable WiFi radio
|
||||
|
||||
Returns:
|
||||
True if WiFi is enabled or was successfully enabled, False otherwise
|
||||
True if the radio is enabled or was enabled here. Also True when
|
||||
the state could not be checked at all (nmcli or rfkill raised on
|
||||
every attempt): callers go ahead rather than refusing to act on a
|
||||
radio that is probably fine. False only when the radio was seen
|
||||
disabled or blocked and could not be turned on.
|
||||
"""
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
@@ -2062,11 +1979,7 @@ class WiFiManager:
|
||||
if result[0]:
|
||||
self._ap_enabled_at = time.time()
|
||||
if force:
|
||||
try:
|
||||
self._FORCE_AP_FLAG_PATH.touch()
|
||||
logger.debug(f"Force-AP flag created: {self._FORCE_AP_FLAG_PATH}")
|
||||
except OSError as exc:
|
||||
logger.warning(f"Failed to create force-AP flag {self._FORCE_AP_FLAG_PATH}: {exc}")
|
||||
self._mark_forced()
|
||||
return result
|
||||
|
||||
# Fallback to nmcli hotspot (simpler, no captive portal)
|
||||
@@ -2077,11 +1990,7 @@ class WiFiManager:
|
||||
if result[0]:
|
||||
self._ap_enabled_at = time.time()
|
||||
if force:
|
||||
try:
|
||||
self._FORCE_AP_FLAG_PATH.touch()
|
||||
logger.debug(f"Force-AP flag created: {self._FORCE_AP_FLAG_PATH}")
|
||||
except OSError as exc:
|
||||
logger.warning(f"Failed to create force-AP flag {self._FORCE_AP_FLAG_PATH}: {exc}")
|
||||
self._mark_forced()
|
||||
return result
|
||||
|
||||
return False, "No WiFi tools available (nmcli, hostapd, or dnsmasq required)"
|
||||
@@ -2089,6 +1998,15 @@ class WiFiManager:
|
||||
logger.error(f"Error in enable_ap_mode: {e}")
|
||||
return False, str(e)
|
||||
|
||||
def _mark_forced(self) -> None:
|
||||
"""Record that AP mode was forced on, so the periodic check leaves it
|
||||
up even when Ethernet is connected (see _manage_ap_mode)."""
|
||||
try:
|
||||
self._FORCE_AP_FLAG_PATH.touch()
|
||||
logger.debug(f"Force-AP flag created: {self._FORCE_AP_FLAG_PATH}")
|
||||
except OSError as exc:
|
||||
logger.warning(f"Failed to create force-AP flag {self._FORCE_AP_FLAG_PATH}: {exc}")
|
||||
|
||||
def _enable_ap_mode_hostapd(self) -> Tuple[bool, str]:
|
||||
"""Enable AP mode using hostapd and dnsmasq (captive portal)"""
|
||||
try:
|
||||
@@ -2115,7 +2033,7 @@ class WiFiManager:
|
||||
timeout=10
|
||||
)
|
||||
subprocess.run(
|
||||
["sudo", "ip", "addr", "add", "192.168.4.1/24", "dev", self._wifi_interface],
|
||||
["sudo", "ip", "addr", "add", f"{AP_IP}/24", "dev", self._wifi_interface],
|
||||
capture_output=True,
|
||||
timeout=10
|
||||
)
|
||||
@@ -2124,7 +2042,7 @@ class WiFiManager:
|
||||
capture_output=True,
|
||||
timeout=10
|
||||
)
|
||||
logger.info(f"Configured {self._wifi_interface} with IP 192.168.4.1 for AP mode")
|
||||
logger.info(f"Configured {self._wifi_interface} with IP {AP_IP} for AP mode")
|
||||
except (subprocess.TimeoutExpired, subprocess.SubprocessError, OSError) as e:
|
||||
logger.warning(f"Error setting up {self._wifi_interface} IP: {e}")
|
||||
|
||||
@@ -2168,7 +2086,7 @@ class WiFiManager:
|
||||
# Use the validated SSID so the displayed name matches what hostapd broadcast
|
||||
ap_ssid, _ = self._validate_ap_config()
|
||||
self._show_led_message(
|
||||
f"WiFi Setup\n{ap_ssid}\nNo password\n192.168.4.1:5000", duration=10
|
||||
f"WiFi Setup\n{ap_ssid}\nNo password\n{AP_IP}:{PORTAL_PORT}", duration=10
|
||||
)
|
||||
return True, "AP mode enabled"
|
||||
except Exception as e:
|
||||
@@ -2198,7 +2116,7 @@ class WiFiManager:
|
||||
|
||||
# Delete only the specific application-managed AP profiles by name.
|
||||
# Never delete by SSID — that would destroy a user's saved home network.
|
||||
for conn_name in ["Hotspot", "LEDMatrix-Setup-AP", "TickerSetup-AP"]:
|
||||
for conn_name in AP_PROFILE_NAMES:
|
||||
subprocess.run(["nmcli", "connection", "down", conn_name],
|
||||
capture_output=True, timeout=5)
|
||||
subprocess.run(["nmcli", "connection", "delete", conn_name],
|
||||
@@ -2215,14 +2133,14 @@ class WiFiManager:
|
||||
cmd = [
|
||||
"nmcli", "connection", "add",
|
||||
"type", "wifi",
|
||||
"con-name", "LEDMatrix-Setup-AP",
|
||||
"con-name", AP_PROFILE_NAME,
|
||||
"ifname", self._wifi_interface,
|
||||
"ssid", ap_ssid,
|
||||
"802-11-wireless.mode", "ap",
|
||||
"802-11-wireless.band", "bg", # 2.4 GHz for maximum compatibility
|
||||
"802-11-wireless.channel", str(ap_channel),
|
||||
"ipv4.method", "shared",
|
||||
"ipv4.addresses", "192.168.4.1/24",
|
||||
"ipv4.addresses", f"{AP_IP}/24",
|
||||
# No 802-11-wireless-security section → open network
|
||||
]
|
||||
|
||||
@@ -2247,14 +2165,14 @@ class WiFiManager:
|
||||
|
||||
logger.info("AP connection profile created, bringing it up...")
|
||||
up_result = subprocess.run(
|
||||
["nmcli", "connection", "up", "LEDMatrix-Setup-AP"],
|
||||
["nmcli", "connection", "up", AP_PROFILE_NAME],
|
||||
capture_output=True, text=True, timeout=20
|
||||
)
|
||||
if up_result.returncode != 0:
|
||||
error_msg = up_result.stderr.strip() or up_result.stdout.strip()
|
||||
logger.error(f"Failed to bring up AP connection: {error_msg}")
|
||||
self._remove_nm_dnsmasq_captive_conf()
|
||||
subprocess.run(["nmcli", "connection", "delete", "LEDMatrix-Setup-AP"],
|
||||
subprocess.run(["nmcli", "connection", "delete", AP_PROFILE_NAME],
|
||||
capture_output=True, timeout=10)
|
||||
self._show_led_message("AP mode failed", duration=5)
|
||||
return False, f"Failed to start AP: {error_msg}"
|
||||
@@ -2266,9 +2184,9 @@ class WiFiManager:
|
||||
if not self._setup_iptables_redirect():
|
||||
logger.error("Captive-portal redirect setup failed; rolling back AP profile")
|
||||
self._remove_nm_dnsmasq_captive_conf()
|
||||
subprocess.run(["nmcli", "connection", "down", "LEDMatrix-Setup-AP"],
|
||||
subprocess.run(["nmcli", "connection", "down", AP_PROFILE_NAME],
|
||||
capture_output=True, timeout=10)
|
||||
subprocess.run(["nmcli", "connection", "delete", "LEDMatrix-Setup-AP"],
|
||||
subprocess.run(["nmcli", "connection", "delete", AP_PROFILE_NAME],
|
||||
capture_output=True, timeout=10)
|
||||
self._clear_led_message()
|
||||
return False, "AP started but captive-portal redirect setup failed"
|
||||
@@ -2282,17 +2200,17 @@ class WiFiManager:
|
||||
logger.debug(f"AP verification attempt {_attempt + 1}/5 not yet active, waiting 2s")
|
||||
time.sleep(2)
|
||||
if status.get('active'):
|
||||
ip = status.get('ip', '192.168.4.1')
|
||||
ip = status.get('ip', AP_IP)
|
||||
logger.info(f"AP mode confirmed active at {ip} (open network, no password)")
|
||||
self._show_led_message(f"WiFi Setup\n{ap_ssid}\nNo password\n{ip}:5000", duration=10)
|
||||
return True, f"AP mode enabled (open network) - Access at {ip}:5000"
|
||||
self._show_led_message(f"WiFi Setup\n{ap_ssid}\nNo password\n{ip}:{PORTAL_PORT}", duration=10)
|
||||
return True, f"AP mode enabled (open network) - Access at {ip}:{PORTAL_PORT}"
|
||||
else:
|
||||
logger.error("AP mode started but not verified by status check — rolling back")
|
||||
self._teardown_iptables_redirect()
|
||||
self._remove_nm_dnsmasq_captive_conf()
|
||||
subprocess.run(["nmcli", "connection", "down", "LEDMatrix-Setup-AP"],
|
||||
subprocess.run(["nmcli", "connection", "down", AP_PROFILE_NAME],
|
||||
capture_output=True, timeout=10)
|
||||
subprocess.run(["nmcli", "connection", "delete", "LEDMatrix-Setup-AP"],
|
||||
subprocess.run(["nmcli", "connection", "delete", AP_PROFILE_NAME],
|
||||
capture_output=True, timeout=10)
|
||||
self._clear_led_message()
|
||||
return False, "AP mode started but verification failed"
|
||||
@@ -2326,9 +2244,9 @@ class WiFiManager:
|
||||
conn_name = parts[0].strip()
|
||||
conn_type = parts[1].strip().lower()
|
||||
# Match our known AP profile name OR the legacy nmcli hotspot type
|
||||
if conn_name == "LEDMatrix-Setup-AP" or 'hotspot' in conn_type:
|
||||
if conn_name == AP_PROFILE_NAME or 'hotspot' in conn_type:
|
||||
# Get actual IP address (may be 192.168.4.1 or 10.42.0.1 depending on config)
|
||||
ip = '192.168.4.1'
|
||||
ip = AP_IP
|
||||
interface = parts[2] if len(parts) > 2 else self._wifi_interface
|
||||
try:
|
||||
ip_result = subprocess.run(
|
||||
@@ -2399,7 +2317,7 @@ class WiFiManager:
|
||||
)
|
||||
else:
|
||||
# Disable nmcli hotspot mode (fallback)
|
||||
for conn_name in ["LEDMatrix-Setup-AP", "Hotspot", "TickerSetup-AP"]:
|
||||
for conn_name in AP_PROFILE_NAMES:
|
||||
subprocess.run(
|
||||
["nmcli", "connection", "down", conn_name],
|
||||
capture_output=True,
|
||||
@@ -2428,7 +2346,7 @@ class WiFiManager:
|
||||
|
||||
# Clean up WiFi interface IP configuration
|
||||
subprocess.run(
|
||||
["sudo", "ip", "addr", "del", "192.168.4.1/24", "dev", self._wifi_interface],
|
||||
["sudo", "ip", "addr", "del", f"{AP_IP}/24", "dev", self._wifi_interface],
|
||||
capture_output=True,
|
||||
timeout=10
|
||||
)
|
||||
@@ -2541,13 +2459,13 @@ ignore_broadcast_ssid=0
|
||||
dhcp-range=192.168.4.2,192.168.4.20,255.255.255.0,24h
|
||||
|
||||
# Captive portal: Redirect all DNS queries to Pi
|
||||
address=/#/192.168.4.1
|
||||
address=/#/{AP_IP}
|
||||
|
||||
# Captive portal detection endpoints
|
||||
address=/captive.apple.com/192.168.4.1
|
||||
address=/connectivitycheck.gstatic.com/192.168.4.1
|
||||
address=/www.msftconnecttest.com/192.168.4.1
|
||||
address=/detectportal.firefox.com/192.168.4.1
|
||||
address=/captive.apple.com/{AP_IP}
|
||||
address=/connectivitycheck.gstatic.com/{AP_IP}
|
||||
address=/www.msftconnecttest.com/{AP_IP}
|
||||
address=/detectportal.firefox.com/{AP_IP}
|
||||
"""
|
||||
|
||||
# Write config (requires sudo)
|
||||
@@ -2651,9 +2569,10 @@ address=/detectportal.firefox.com/192.168.4.1
|
||||
# Pre-cache a WiFi scan so the captive portal can show networks
|
||||
try:
|
||||
logger.info("Running pre-AP WiFi scan for captive portal cache...")
|
||||
# AP mode is not up yet, so this is a live scan, and
|
||||
# scan_networks saves its result for the portal.
|
||||
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}")
|
||||
|
||||
Reference in New Issue
Block a user