Files
LEDMatrix/src/wifi_manager.py
T
ChuckandClaude Opus 5.5 7b90759252 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>
2026-09-24 17:32:29 -04:00

2661 lines
121 KiB
Python

"""
WiFi Manager for Raspberry Pi LED Matrix
Handles WiFi connection management, access point mode, and network scanning.
Only enables AP mode when there is no active WiFi connection.
Tested and optimized for:
- Raspberry Pi OS Trixie (Debian 13) with NetworkManager/Netplan
- Raspberry Pi OS Bookworm (Debian 12) with NetworkManager
- Raspberry Pi 3B+, 4, 5 with built-in WiFi
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
import json
import logging
import os
import time
import re
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)
# Default location, can be overridden
def get_wifi_config_path():
"""Get the WiFi configuration file path dynamically"""
# Try to determine project root
project_root = os.environ.get('LEDMATRIX_ROOT')
if not project_root:
# Try to find project root by looking for config directory
current = Path(__file__).resolve().parent.parent
if (current / 'config').exists():
project_root = str(current)
else:
# Fallback to common location
project_root = "/home/ledpi/LEDMatrix"
return Path(project_root) / "config" / "wifi_config.json"
def get_wifi_status_path() -> Path:
"""The status-message file WiFiManager writes and the display controller
reads (config/wifi_status.json, next to wifi_config.json)."""
return get_wifi_config_path().parent / "wifi_status.json"
HOSTAPD_CONFIG_PATH = Path("/etc/hostapd/hostapd.conf")
DNSMASQ_CONFIG_PATH = Path("/etc/dnsmasq.d/ledmatrix-captive.conf")
# Drop-in config for NetworkManager's built-in dnsmasq (ipv4.method=shared).
# Writing address=/#/<ap_ip> here causes NM to resolve every hostname to the AP,
# triggering the OS captive-portal popup automatically on iOS/Android/Windows/macOS.
NM_DNSMASQ_SHARED_DIR = Path("/etc/NetworkManager/dnsmasq-shared.d")
NM_DNSMASQ_SHARED_CONF = NM_DNSMASQ_SHARED_DIR / "ledmatrix-captive.conf"
HOSTAPD_SERVICE = "hostapd"
DNSMASQ_SERVICE = "dnsmasq"
# Default AP settings
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
@dataclass
class WiFiNetwork:
"""Represents a WiFi network"""
ssid: str
signal: int
security: str # 'open', 'wpa', 'wpa2', 'wpa3'
frequency: float = 0.0
bssid: str = ""
@dataclass
class WiFiStatus:
"""Current WiFi connection status"""
connected: bool
ssid: Optional[str] = None
ip_address: Optional[str] = None
signal: int = 0
ap_mode_active: bool = False
class WiFiManager:
"""Manages WiFi connections and access point mode"""
def __init__(self, config_path: Optional[Path] = None):
"""
Initialize WiFi Manager
Args:
config_path: Path to WiFi configuration file (defaults to project config directory)
"""
if config_path is None:
self.config_path = get_wifi_config_path()
else:
self.config_path = config_path
self.config_path.parent.mkdir(parents=True, exist_ok=True)
self._load_config()
# Set LED status file path (for display_controller integration)
global LED_STATUS_FILE
if LED_STATUS_FILE is None:
project_root = self.config_path.parent.parent
LED_STATUS_FILE = project_root / "config" / "wifi_status.json"
# Check which tools are available
self.has_nmcli = self._check_command("nmcli")
self.has_iwlist = self._check_command("iwlist")
self.has_hostapd = self._check_command("hostapd")
self.has_dnsmasq = self._check_command("dnsmasq")
# Discover WiFi interface (don't hardcode wlan0)
self._wifi_interface = self._discover_wifi_interface()
# Initialize disconnected check counter for grace period
# This prevents AP mode from enabling on transient network hiccups
self._disconnected_checks = 0
self._disconnected_checks_required = 3 # Require 3 consecutive disconnected checks (90 seconds at 30s interval)
# Timestamp set when AP mode is enabled; used for the idle-timeout check
self._ap_enabled_at: Optional[float] = None
# Which redirect backend was used (iptables/nftables/None); set per-instance
self._redirect_backend: Optional[str] = None
logger.info(f"WiFi Manager initialized - nmcli: {self.has_nmcli}, iwlist: {self.has_iwlist}, "
f"hostapd: {self.has_hostapd}, dnsmasq: {self.has_dnsmasq}, "
f"interface: {self._wifi_interface}")
# Once per process: remove a stale force-AP flag left by a prior crash.
# Guard with a class-level flag so the nmcli AP-state check only runs
# once even though WiFiManager is instantiated per-request.
if not WiFiManager._startup_cleanup_done:
WiFiManager._startup_cleanup_done = True
if self._FORCE_AP_FLAG_PATH.exists() and not self._is_ap_mode_active():
try:
self._FORCE_AP_FLAG_PATH.unlink(missing_ok=True)
logger.debug("Removed stale force-AP flag on startup (AP not active)")
except OSError as exc:
logger.warning(f"Could not remove stale force-AP flag: {exc}")
def _show_led_message(self, message: str, duration: int = 5):
"""
Show a WiFi status message on the LED display.
Writes to a JSON file that display_controller can read.
Args:
message: Text to display
duration: How long to show message (seconds)
"""
try:
if LED_STATUS_FILE is None:
return
status = {
'message': message,
'timestamp': time.time(),
'duration': duration
}
LED_STATUS_FILE.parent.mkdir(parents=True, exist_ok=True)
# Write-then-rename: the display reads this at ~1 Hz and deletes
# a file it can't parse, so a half-written one would lose the message.
tmp_path = LED_STATUS_FILE.with_name(LED_STATUS_FILE.name + '.tmp')
with open(tmp_path, 'w') as f:
json.dump(status, f)
os.replace(tmp_path, LED_STATUS_FILE)
logger.info(f"LED message: {message}")
except Exception as e:
logger.debug(f"Could not write LED status message: {e}")
def _clear_led_message(self):
"""Clear any WiFi status message from LED display."""
try:
if LED_STATUS_FILE and LED_STATUS_FILE.exists():
LED_STATUS_FILE.unlink()
except Exception as e:
logger.debug(f"Could not clear LED status message: {e}")
def _check_command(self, command: str) -> bool:
"""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]:
"""
Return the absolute path of a command, checking sbin locations that may not
be on PATH in restricted service environments. Returns None if not found.
"""
try:
result = subprocess.run(["which", command], capture_output=True,
text=True, timeout=2)
if result.returncode == 0 and result.stdout.strip():
return result.stdout.strip()
except (subprocess.TimeoutExpired, subprocess.SubprocessError, OSError):
pass
for path in [f"/usr/sbin/{command}", f"/sbin/{command}",
f"/usr/local/sbin/{command}"]:
if os.path.isfile(path) and os.access(path, os.X_OK):
return path
return None
def _discover_wifi_interface(self) -> str:
"""
Discover the primary WiFi interface name dynamically.
Returns the first WiFi interface found, or 'wlan0' as fallback.
Supports various interface naming schemes:
- Traditional: wlan0, wlan1
- Predictable: wlp2s0, wlx<mac>
- USB adapters: wlan1, wlx*
"""
try:
if self.has_nmcli:
# Use nmcli to find WiFi devices (most reliable on NetworkManager systems)
result = subprocess.run(
["nmcli", "-t", "-f", "DEVICE,TYPE", "device", "status"],
capture_output=True,
text=True,
timeout=5
)
if result.returncode == 0:
for line in result.stdout.strip().split('\n'):
if ':' in line:
parts = line.split(':')
if len(parts) >= 2 and parts[1].strip() == 'wifi':
interface = parts[0].strip()
logger.debug(f"Discovered WiFi interface via nmcli: {interface}")
return interface
# Fallback: Check /sys/class/net for wireless interfaces
net_path = Path("/sys/class/net")
if net_path.exists():
for iface in net_path.iterdir():
wireless_path = iface / "wireless"
if wireless_path.exists():
interface = iface.name
logger.debug(f"Discovered WiFi interface via /sys: {interface}")
return interface
# Last resort: Check common interface names
for iface in ["wlan0", "wlan1", "wlp2s0", "wlp3s0"]:
iface_path = Path(f"/sys/class/net/{iface}")
if iface_path.exists():
logger.debug(f"Found WiFi interface by name probe: {iface}")
return iface
except (subprocess.TimeoutExpired, subprocess.SubprocessError, OSError) as e:
logger.warning(f"Error discovering WiFi interface: {e}")
logger.warning("Could not discover WiFi interface, defaulting to wlan0")
return "wlan0"
def _load_config(self):
"""Load WiFi configuration from file"""
if self.config_path.exists():
try:
with open(self.config_path, 'r') as f:
self.config = json.load(f)
logger.info(f"Loaded WiFi config from {self.config_path}")
except Exception as e:
logger.warning(f"Failed to load WiFi config: {e}")
self.config = {}
else:
self.config = {
"ap_ssid": DEFAULT_AP_SSID,
"ap_channel": DEFAULT_AP_CHANNEL,
"auto_enable_ap_mode": True # Default: auto-enable when no network (safe due to grace period)
}
self._save_config()
# Ensure auto_enable_ap_mode exists in config (for existing configs)
if "auto_enable_ap_mode" not in self.config:
self.config["auto_enable_ap_mode"] = True # Default: auto-enable when no network (safe due to grace period)
self._save_config()
# Older versions stored every joined network's password here in
# plaintext and never read it back; scrub it from existing files.
if "saved_networks" in self.config:
del self.config["saved_networks"]
self._save_config()
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:
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:
"""
Get current WiFi connection status
Returns:
WiFiStatus object with connection information
"""
try:
if self.has_nmcli:
return self._get_status_nmcli()
else:
return self._get_status_iwconfig()
except Exception as e:
logger.error(f"Error getting WiFi status: {e}")
return WiFiStatus(connected=False)
def _get_status_nmcli(self) -> WiFiStatus:
"""Get WiFi status using nmcli"""
try:
# Check if connected - use device status first (more reliable)
result = subprocess.run(
["nmcli", "-t", "-f", "DEVICE,TYPE,STATE", "device", "status"],
capture_output=True,
text=True,
timeout=5
)
if result.returncode != 0:
logger.warning("nmcli device status failed, assuming disconnected")
return WiFiStatus(connected=False)
wifi_connected = False
ssid = None
ip_address = None
signal = 0
wlan_device = None
# Find WiFi device and check its state
for line in result.stdout.strip().split('\n'):
if not line:
continue
parts = line.split(':')
if len(parts) >= 3:
device = parts[0].strip()
dev_type = parts[1].strip().lower()
state = parts[2].strip().lower()
# Check if it's a WiFi device
if dev_type == "wifi" or device.startswith("wlan"):
wlan_device = device
if state == "connected":
wifi_connected = True
break
elif state in ["disconnected", "unavailable", "unmanaged"]:
# Explicitly disconnected
wifi_connected = False
break
# Get actual SSID and signal strength from WiFi device if connected
# Use device show to get the real SSID and signal, not the connection name
if wifi_connected and wlan_device:
# Get both SSID and signal in one query for efficiency
result = subprocess.run(
["nmcli", "-t", "-f", "802-11-wireless.ssid,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 '802-11-wireless.ssid:' in line:
ssid = line.split(':', 1)[1].strip()
elif 'WIFI.SIGNAL:' in line:
try:
signal = int(line.split(':', 1)[1].strip())
except (ValueError, IndexError):
pass
# Fallback: Get SSID from active WiFi connection list if not found
if not ssid:
result = subprocess.run(
["nmcli", "-t", "-f", "active,ssid", "device", "wifi"],
capture_output=True,
text=True,
timeout=5
)
if result.returncode == 0:
for line in result.stdout.strip().split('\n'):
parts = line.split(':')
if len(parts) >= 2 and parts[0].strip() == "yes":
ssid = parts[1].strip()
if ssid:
break
# Get IP address if connected
if wifi_connected and wlan_device:
result = subprocess.run(
["nmcli", "-t", "-f", "IP4.ADDRESS", "device", "show", wlan_device],
capture_output=True,
text=True,
timeout=5
)
if result.returncode == 0:
for line in result.stdout.strip().split('\n'):
if '/' in line:
# nmcli -t output is "IP4.ADDRESS[1]:x.x.x.x/prefix";
# bare "x.x.x.x/prefix" is also accepted defensively.
_, sep, rest = line.partition(':')
ip_address = (rest if sep else line).split('/')[0].strip()
break
# Final fallback: Get signal strength by matching SSID in WiFi list
# (Only if we still don't have signal from device properties)
if signal == 0 and ssid:
result = subprocess.run(
["nmcli", "-t", "-f", "SSID,SIGNAL", "device", "wifi"],
capture_output=True,
text=True,
timeout=5
)
if result.returncode == 0:
for line in result.stdout.strip().split('\n'):
parts = line.split(':')
if len(parts) >= 2:
line_ssid = parts[0].strip()
if line_ssid == ssid:
try:
signal = int(parts[1].strip())
break
except (ValueError, IndexError):
pass
# Check if AP mode is active
ap_active = self._is_ap_mode_active()
# wlan0 shows as "connected" in AP mode; clear client-station fields so
# callers don't mistake the AP for an outbound WiFi connection.
if ap_active and wifi_connected:
wifi_connected = False
ssid = None
ip_address = None
logger.debug(f"{wlan_device} is in AP mode — overriding wifi_connected to False")
return WiFiStatus(
connected=wifi_connected,
ssid=ssid,
ip_address=ip_address,
signal=signal,
ap_mode_active=ap_active
)
except Exception as e:
logger.error(f"Error getting status with nmcli: {e}")
return WiFiStatus(connected=False)
def _get_status_iwconfig(self) -> WiFiStatus:
"""Get WiFi status using iwconfig (fallback)"""
try:
result = subprocess.run(
["iwconfig", self._wifi_interface],
capture_output=True,
text=True,
timeout=5
)
if result.returncode != 0:
return WiFiStatus(connected=False)
output = result.stdout
connected = "ESSID:" in output and "not-associated" not in output
ssid = None
if connected:
match = re.search(r'ESSID:"([^"]+)"', output)
if match:
ssid = match.group(1)
# Get IP address
ip_address = None
if connected:
result = subprocess.run(
["hostname", "-I"],
capture_output=True,
text=True,
timeout=5
)
if result.returncode == 0:
ips = result.stdout.strip().split()
for ip in ips:
if ip != AP_IP:
ip_address = ip
break
ap_active = self._is_ap_mode_active()
return WiFiStatus(
connected=connected,
ssid=ssid,
ip_address=ip_address,
ap_mode_active=ap_active
)
except Exception as e:
logger.error(f"Error getting status with iwconfig: {e}")
return WiFiStatus(connected=False)
def _is_ethernet_connected(self) -> bool:
"""
Check if Ethernet connection is active
Returns:
True if Ethernet is connected and has an IP address
"""
try:
# Check for Ethernet interfaces (eth0, enp*, etc.)
# First try nmcli if available
if self.has_nmcli:
result = subprocess.run(
["nmcli", "-t", "-f", "DEVICE,TYPE,STATE", "device", "status"],
capture_output=True,
text=True,
timeout=5
)
if result.returncode == 0:
for line in result.stdout.strip().split('\n'):
parts = line.split(':')
if len(parts) >= 3:
device = parts[0].strip()
dev_type = parts[1].strip().lower()
state = parts[2].strip().lower()
# Check if it's an Ethernet interface and connected
if dev_type == "ethernet" and state == "connected":
# Verify it has an IP address
ip_result = subprocess.run(
["nmcli", "-t", "-f", "IP4.ADDRESS", "device", "show", device],
capture_output=True,
text=True,
timeout=5
)
if ip_result.returncode == 0 and ip_result.stdout.strip():
return True
# Fallback: Check using ip command
result = subprocess.run(
["ip", "addr", "show"],
capture_output=True,
text=True,
timeout=5
)
if result.returncode == 0:
# Look for Ethernet interfaces (eth0, enp*, etc.)
lines = result.stdout.split('\n')
in_ethernet = False
for line in lines:
# Check if line starts interface name (e.g., "2: eth0:")
if re.match(r'^\d+:\s+(eth\d+|enp\d+s\d+|enx[0-9a-f]+):', line):
in_ethernet = True
elif in_ethernet and 'inet ' in line and not '127.0.0.1' in line:
# Found an IP address on Ethernet interface
return True
elif re.match(r'^\d+:', line) and in_ethernet:
# Moved to next interface
in_ethernet = False
return False
except Exception as e:
logger.debug(f"Error checking Ethernet connection: {e}")
return False
def _has_connectivity_safety(self) -> bool:
"""
Check if there's a safe fallback connectivity option available.
Returns True if either:
- Ethernet is connected, OR
- WiFi radio is enabled (even if not connected to a network)
This helps prevent lockout scenarios where we might disable WiFi
without having Ethernet as backup.
Returns:
True if there's a safe connectivity option available
"""
try:
# Check if Ethernet is connected (safest fallback)
if self._is_ethernet_connected():
return True
# Check if WiFi radio is enabled (at least WiFi is available)
result = subprocess.run(
["nmcli", "radio", "wifi"],
capture_output=True,
text=True,
timeout=5
)
if result.returncode == 0:
status = result.stdout.strip().lower()
if status == "enabled":
return True
return False
except Exception as e:
logger.debug(f"Error checking connectivity safety: {e}")
# If we can't determine, assume unsafe to be conservative
return False
def _is_ap_mode_active(self) -> bool:
"""Check if access point mode is currently active"""
try:
# Check if hostapd is running (captive portal mode)
result = subprocess.run(
["systemctl", "is-active", HOSTAPD_SERVICE],
capture_output=True,
text=True,
timeout=2
)
if result.stdout.strip() == "active":
return True
# Check if nmcli hotspot is active (fallback mode)
hotspot_status = self._get_ap_status_nmcli()
if hotspot_status.get('active'):
return True
return False
except (subprocess.TimeoutExpired, subprocess.SubprocessError, OSError):
return False
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
_IP_FORWARD_SAVE_PATH = Path("/tmp/ledmatrix_ip_forward_saved") # nosec B108 - process-specific named file; device is single-user RPi
# Written when AP mode is manually force-enabled; prevents daemon auto-disable
_FORCE_AP_FLAG_PATH = Path("/tmp/ledmatrix_force_ap_active") # nosec B108 - process-specific named file; device is single-user RPi
# Written by the web process while connect_to_network runs. Joining a network
# from the setup AP takes the AP down first, and the monitor daemon (a separate
# process) would otherwise see "disconnected" on its next tick and bring the
# AP straight back up mid-connect.
_CONNECT_IN_PROGRESS_FLAG_PATH = Path("/tmp/ledmatrix_wifi_connect_in_progress") # nosec B108 - process-specific named file; device is single-user RPi
# Longest a connect can legitimately take (AP teardown, nmcli's 30s timeout,
# verification, restore). An older flag was left by a process that died.
_CONNECT_FLAG_MAX_AGE_SECONDS = 180
# Ensures the startup stale-flag cleanup runs once per process, not per instantiation
_startup_cleanup_done: bool = False
def _connect_in_progress(self) -> bool:
try:
age = time.time() - self._CONNECT_IN_PROGRESS_FLAG_PATH.stat().st_mtime
except OSError:
return False
return age < self._CONNECT_FLAG_MAX_AGE_SECONDS
def _validate_ap_config(self) -> Tuple[str, int]:
"""Return a sanitized (ssid, channel) pair from config, falling back to defaults."""
ssid = str(self.config.get("ap_ssid", DEFAULT_AP_SSID))
if not ssid or len(ssid) > 32 or not re.match(r'^[\x20-\x7E]+$', ssid):
logger.warning(f"AP SSID '{ssid}' is invalid, falling back to default")
ssid = DEFAULT_AP_SSID
try:
channel = int(self.config.get("ap_channel", DEFAULT_AP_CHANNEL))
if channel < 1 or channel > 14:
raise ValueError
except (TypeError, ValueError):
logger.warning("AP channel out of range, falling back to default")
channel = DEFAULT_AP_CHANNEL
return ssid, channel
def _setup_iptables_redirect(self) -> bool:
"""
Add port 80 → 5000 redirect rules for the captive portal.
Tries iptables first, falls back to nftables (used by Debian Trixie).
When neither tool is available, logs a warning and returns True — the AP
still works and DNS spoofing still triggers the OS popup; users just land
on port 5000 directly rather than being redirected from port 80.
Only returns False when a tool was found but the rule addition itself failed.
"""
try:
iptables = self._find_command_path("iptables")
nft = self._find_command_path("nft")
if not iptables and not nft:
logger.warning(
"Neither iptables nor nft found; captive portal port-80 redirect unavailable. "
"DNS spoofing will still trigger the OS popup but HTTP on port 80 won't reach Flask."
)
self._redirect_backend = None
return True # AP works; redirect is best-effort
if iptables:
return self._setup_iptables_redirect_iptables(iptables)
else:
return self._setup_iptables_redirect_nftables(nft)
except Exception as e:
logger.warning(f"Could not set up port redirect: {e}")
try:
self._teardown_iptables_redirect()
except Exception as cleanup_e:
logger.warning(f"Cleanup after redirect exception also failed: {cleanup_e}")
return False
def _setup_iptables_redirect_iptables(self, iptables: str) -> bool:
"""Set up port 80→5000 redirect using iptables."""
# Save ip_forward state before enabling
try:
current_fwd = Path("/proc/sys/net/ipv4/ip_forward").read_text().strip()
except OSError:
current_fwd = None
if current_fwd is not None:
try:
self._IP_FORWARD_SAVE_PATH.write_text(current_fwd)
except OSError:
current_fwd = None
logger.warning("Could not write ip_forward save file; state will not be restored")
if current_fwd != "1":
sysctl = self._find_command_path("sysctl")
sysctl_bin = sysctl if sysctl else "sysctl"
r = subprocess.run(["sudo", sysctl_bin, "-w", "net.ipv4.ip_forward=1"],
capture_output=True, text=True, timeout=5)
if r.returncode != 0:
logger.error(f"Failed to enable ip_forward: {r.stderr.strip()}")
self._teardown_iptables_redirect()
return False
if subprocess.run(
["sudo", iptables, "-t", "nat", "-C", "PREROUTING",
"-i", self._wifi_interface, "-p", "tcp", "--dport", "80",
"-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", str(PORTAL_PORT)],
capture_output=True, text=True, timeout=5
)
if r.returncode != 0:
logger.error(f"Failed to add PREROUTING rule: {r.stderr.strip()}")
self._teardown_iptables_redirect()
return False
if subprocess.run(
["sudo", iptables, "-C", "INPUT",
"-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", str(PORTAL_PORT), "-j", "ACCEPT"],
capture_output=True, text=True, timeout=5
)
if r.returncode != 0:
logger.error(f"Failed to add INPUT rule: {r.stderr.strip()}")
self._teardown_iptables_redirect()
return False
self._redirect_backend = "iptables"
logger.info(f"iptables: port 80→{PORTAL_PORT} redirect rules added")
return True
def _setup_iptables_redirect_nftables(self, nft: str) -> bool:
"""Set up port 80→5000 redirect using nftables (Debian Trixie / modern systems)."""
# NM's ipv4.method=shared already enables ip_forward; no sysctl needed.
cmds = [
["sudo", nft, "add", "table", "ip", "ledmatrix"],
["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", f":{PORTAL_PORT}"],
]
for cmd in cmds:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=5)
if r.returncode != 0:
# Table/chain may already exist — only fail on rule add
if "add rule" in " ".join(cmd):
logger.error(f"Failed to add nftables redirect rule: {r.stderr.strip()}")
self._teardown_iptables_redirect()
return False
logger.debug(f"nft cmd non-zero (may already exist): {r.stderr.strip()}")
self._redirect_backend = "nftables"
logger.info(f"nftables: port 80→{PORTAL_PORT} redirect rule added")
return True
def _teardown_iptables_redirect(self) -> None:
"""Remove the port 80→5000 redirect rules and restore ip_forward if saved."""
try:
backend = self._redirect_backend
self._redirect_backend = None
if backend == "iptables":
iptables = self._find_command_path("iptables")
if iptables:
subprocess.run(
["sudo", iptables, "-t", "nat", "-D", "PREROUTING",
"-i", self._wifi_interface, "-p", "tcp", "--dport", "80",
"-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", str(PORTAL_PORT),
"-j", "ACCEPT"],
capture_output=True, timeout=5
)
# Restore ip_forward only when we saved it
if self._IP_FORWARD_SAVE_PATH.exists():
try:
saved = self._IP_FORWARD_SAVE_PATH.read_text().strip()
self._IP_FORWARD_SAVE_PATH.unlink(missing_ok=True)
sysctl = self._find_command_path("sysctl")
sysctl_bin = sysctl if sysctl else "sysctl"
subprocess.run(["sudo", sysctl_bin, "-w", f"net.ipv4.ip_forward={saved}"],
capture_output=True, timeout=5)
logger.info(f"ip_forward restored to {saved}")
except OSError as e:
logger.warning(f"Could not restore ip_forward: {e}")
else:
logger.debug("ip_forward not modified by setup; leaving unchanged")
elif backend == "nftables":
nft = self._find_command_path("nft")
if nft:
subprocess.run(
["sudo", nft, "delete", "table", "ip", "ledmatrix"],
capture_output=True, timeout=5
)
logger.info("nftables ledmatrix table removed")
else:
# No redirect was set up (neither tool available); nothing to tear down
self._IP_FORWARD_SAVE_PATH.unlink(missing_ok=True)
except Exception as e:
logger.warning(f"Could not tear down port redirect: {e}")
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
popup automatically on iOS / Android / Windows / macOS as soon as the
device connects — no manual navigation required.
NetworkManager reads /etc/NetworkManager/dnsmasq-shared.d/*.conf when it
starts the dnsmasq instance for ipv4.method=shared connections.
"""
try:
content = f"# LEDMatrix captive portal: resolve all hostnames to AP\naddress=/#/{ap_ip}\n"
with open("/tmp/ledmatrix-nm-dnsmasq.conf", "w") as f: # nosec B108 - named file matches sudoers allowlist; single-user device
f.write(content)
subprocess.run(
["sudo", "mkdir", "-p", str(NM_DNSMASQ_SHARED_DIR)],
capture_output=True, timeout=5
)
subprocess.run(
["sudo", "cp", "/tmp/ledmatrix-nm-dnsmasq.conf", str(NM_DNSMASQ_SHARED_CONF)], # nosec B108
capture_output=True, timeout=5
)
logger.info(f"Wrote NM dnsmasq captive-portal config: {NM_DNSMASQ_SHARED_CONF}")
except Exception as e:
logger.warning(f"Could not write NM dnsmasq captive config: {e}")
def _remove_nm_dnsmasq_captive_conf(self) -> None:
"""Remove the NM dnsmasq-shared.d drop-in written by _write_nm_dnsmasq_captive_conf."""
try:
subprocess.run(
["sudo", "rm", "-f", str(NM_DNSMASQ_SHARED_CONF)],
capture_output=True, timeout=5
)
logger.info("Removed NM dnsmasq captive-portal config")
except Exception as e:
logger.warning(f"Could not remove NM dnsmasq captive config: {e}")
def _check_internet_connectivity(self, timeout: int = 5) -> bool:
"""
Test actual internet reachability — not just nmcli association state.
A device can be 'connected' in nmcli (associated with an AP) while the
router has no WAN link. This check catches that case so the daemon can
auto-enable AP mode even when nmcli reports a connection.
Returns True if at least one reachability method succeeds.
"""
try:
r = subprocess.run(
["ping", "-c", "1", "-W", str(timeout), "8.8.8.8"],
capture_output=True, timeout=timeout + 1
)
if r.returncode == 0:
logger.debug("Internet connectivity confirmed via ping 8.8.8.8")
return True
except (subprocess.SubprocessError, OSError):
pass
try:
import urllib.request as _ureq
_ureq.urlopen("http://connectivity-check.ubuntu.com/", timeout=timeout) # nosec B310 - hardcoded URL, no user input
logger.debug("Internet connectivity confirmed via HTTP check")
return True
except OSError:
pass
logger.debug("Internet connectivity check failed (both ping and HTTP)")
return False
def check_internet_connectivity(self, timeout: int = 5) -> bool:
"""Public wrapper around _check_internet_connectivity for use by the daemon."""
return self._check_internet_connectivity(timeout=timeout)
def _has_ap_clients(self) -> bool:
"""
Return True if at least one client is associated with the AP.
Uses 'iw dev <iface> station dump' which works for both hostapd and
nmcli AP modes.
"""
try:
result = subprocess.run(
["iw", "dev", self._wifi_interface, "station", "dump"],
capture_output=True, text=True, timeout=5
)
return bool(result.stdout.strip())
except Exception:
return False
def scan_networks(self, allow_cached: bool = True) -> Tuple[List[WiFiNetwork], bool]:
"""
Scan for available WiFi networks.
When AP mode is active, returns cached scan results instead of
disabling AP (which would disconnect the user). Cached results
come from either nmcli's internal cache or a pre-scan file saved
before AP mode was enabled.
Returns:
Tuple of (list of WiFiNetwork objects, was_cached bool)
"""
try:
ap_active = self._is_ap_mode_active()
if ap_active:
# Don't disable AP — user would lose their connection.
# Try nmcli cached results first (no rescan trigger).
logger.info("AP mode active — returning cached scan results")
networks = self._scan_nmcli_cached()
if not networks and allow_cached:
networks = self._load_cached_scan()
return networks, True
# Normal scan (not in AP mode)
if self.has_nmcli:
networks = self._scan_nmcli()
elif self.has_iwlist:
networks = self._scan_iwlist()
else:
logger.error("No WiFi scanning tools available")
networks = []
# Save results for later use in AP mode
if networks:
self._save_cached_scan(networks)
return networks, False
except Exception as e:
logger.error(f"Error scanning networks: {e}")
return [], False
def _scan_nmcli_cached(self) -> List[WiFiNetwork]:
"""Return nmcli's cached WiFi list without triggering a rescan."""
networks = []
try:
result = subprocess.run(
["nmcli", "-t", "-f", "SSID,SIGNAL,SECURITY,FREQ", "device", "wifi", "list"],
capture_output=True, text=True, timeout=5
)
if result.returncode != 0:
return []
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:
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 []
def _scan_nmcli(self) -> List[WiFiNetwork]:
"""Scan networks using nmcli"""
try:
# Trigger scan
subprocess.run(
["nmcli", "device", "wifi", "rescan"],
capture_output=True,
timeout=10
)
time.sleep(2) # Wait for scan to complete
# Get scan results
result = subprocess.run(
["nmcli", "-t", "-f", "SSID,SIGNAL,SECURITY,FREQ", "device", "wifi", "list"],
capture_output=True,
text=True,
timeout=10
)
if result.returncode != 0:
return []
return self._parse_nmcli_wifi_list(result.stdout)
except Exception as e:
logger.error(f"Error scanning with nmcli: {e}")
return []
def _scan_iwlist(self) -> List[WiFiNetwork]:
"""Scan networks using iwlist (fallback)"""
networks = []
try:
result = subprocess.run(
["iwlist", self._wifi_interface, "scan"],
capture_output=True,
text=True,
timeout=30
)
if result.returncode != 0:
return []
output = result.stdout
seen_ssids = set()
current_ssid = None
current_signal = 0
current_security = "open"
for line in output.split('\n'):
line = line.strip()
# Extract SSID
if 'ESSID:' in line:
match = re.search(r'ESSID:"([^"]+)"', line)
if match:
if current_ssid and current_ssid not in seen_ssids:
networks.append(WiFiNetwork(
ssid=current_ssid,
signal=current_signal,
security=current_security
))
seen_ssids.add(current_ssid)
current_ssid = match.group(1)
current_signal = 0
current_security = "open"
# Extract signal strength
elif 'Signal level=' in line:
match = re.search(r'Signal level=(-?\d+)', line)
if match:
# Convert to percentage (approximate)
dbm = int(match.group(1))
current_signal = max(0, min(100, (dbm + 100) * 2))
# Extract security
elif 'Encryption key:' in line:
if 'on' in line.lower():
current_security = "wpa" # Default, will check for WPA2/WPA3
elif 'WPA2' in line:
current_security = "wpa2"
elif 'WPA3' in line:
current_security = "wpa3"
# Add last network
if current_ssid and current_ssid not in seen_ssids:
networks.append(WiFiNetwork(
ssid=current_ssid,
signal=current_signal,
security=current_security
))
# Sort by signal strength
networks.sort(key=lambda x: x.signal, reverse=True)
return networks
except Exception as e:
logger.error(f"Error scanning with iwlist: {e}")
return []
def connect_to_network(self, ssid: str, password: str) -> Tuple[bool, str]:
"""
Connect to a WiFi network with failsafe to restore original connection on failure.
Args:
ssid: Network SSID
password: Network password (empty for open networks)
Returns:
Tuple of (success, message)
"""
# Both values arrive verbatim from POST /api/v3/wifi/connect and end up
# as nmcli argv entries. There is no shell here, so no metacharacter
# can start a second command -- but nmcli reads a leading "-" as an
# option, so an SSID of "--ask" or "-t" is a request to run nmcli
# differently rather than to join a network. See _validate_ssid.
ssid, error = self._validate_ssid(ssid)
if error:
logger.warning("Rejected WiFi connect request: %s", error)
return False, error
password, error = self._validate_wifi_password(password)
if error:
logger.warning("Rejected WiFi connect request: %s", error)
return False, error
try:
self._CONNECT_IN_PROGRESS_FLAG_PATH.touch()
except OSError as e:
logger.warning(f"Could not create connect-in-progress flag: {e}")
try:
return self._connect_validated(ssid, password)
finally:
try:
self._CONNECT_IN_PROGRESS_FLAG_PATH.unlink(missing_ok=True)
except OSError as e:
# Never mask the connect result; the age limit retires the flag.
logger.warning(f"Could not remove connect-in-progress flag: {e}")
def _connect_validated(self, ssid: str, password: str) -> Tuple[bool, str]:
"""connect_to_network after validation, with the in-progress flag held."""
# Save current connection info for failsafe restoration
original_connection = None
original_ssid = None
try:
status = self.get_wifi_status()
if status.connected and status.ssid:
original_ssid = status.ssid
# Get the active connection name/UUID for WiFi interface
result = subprocess.run(
["nmcli", "-t", "-f", "GENERAL.CONNECTION", "device", "show", self._wifi_interface],
capture_output=True,
text=True,
timeout=5
)
if result.returncode == 0:
for line in result.stdout.strip().split('\n'):
if 'GENERAL.CONNECTION:' in line:
connection_name = line.split(':', 1)[1].strip()
if connection_name and connection_name != '--':
original_connection = connection_name
break
# Fallback: try to find connection by SSID
if not original_connection:
result = subprocess.run(
["nmcli", "-t", "-f", "NAME", "connection", "show"],
capture_output=True,
text=True,
timeout=5
)
if result.returncode == 0:
for line in result.stdout.strip().split('\n'):
if original_ssid.lower() in line.lower():
original_connection = line.strip()
break
logger.info(f"Saving original connection for failsafe: {original_ssid} ({original_connection})")
except Exception as e:
logger.debug(f"Could not save original connection info: {e}")
try:
# Check if already connected to the target network
if original_ssid and original_ssid == ssid:
logger.info(f"Already connected to {ssid}, verifying connection...")
status = self.get_wifi_status()
if status.connected and status.ssid == ssid:
logger.info(f"Already connected to {ssid} with IP {status.ip_address}")
return True, f"Already connected to {ssid}"
else:
logger.warning(f"Status shows not connected to {ssid}, attempting reconnection...")
# First, disable AP mode if active
# This is critical - if AP mode is active, we must disable it before connecting
if self._is_ap_mode_active():
logger.info("AP mode is active, disabling before connecting to WiFi network...")
disable_success, disable_msg = self.disable_ap_mode()
if not disable_success:
error_msg = f"Failed to disable AP mode: {disable_msg}. Cannot connect to WiFi while AP mode is active."
logger.error(error_msg)
return False, error_msg
# Wait for NetworkManager to restart and stabilize (if it was restarted)
# NetworkManager restart can take 3-5 seconds, so wait a bit longer
logger.info("Waiting for NetworkManager to stabilize after AP mode disable...")
time.sleep(5)
# Verify AP mode is actually disabled
max_verify_attempts = 5
for attempt in range(max_verify_attempts):
if not self._is_ap_mode_active():
logger.info("AP mode successfully disabled, proceeding with connection")
break
if attempt < max_verify_attempts - 1:
logger.debug(f"AP mode still active, waiting... (attempt {attempt + 1}/{max_verify_attempts})")
time.sleep(2)
else:
error_msg = "AP mode disable reported success but AP mode is still active. Cannot connect to WiFi."
logger.error(error_msg)
return False, error_msg
# If we're currently connected to a different network, disconnect first
# This ensures a clean switch between networks
if original_ssid and original_ssid != ssid:
logger.info(f"Switching networks: disconnecting from {original_ssid} before connecting to {ssid}")
self._show_led_message("Switching networks...", duration=3)
# Skip AP mode check since we're about to connect to a new network
disconnect_success, disconnect_msg = self.disconnect_from_network(skip_ap_check=True)
if disconnect_success:
logger.info(f"Disconnected from {original_ssid}: {disconnect_msg}")
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}")
# Continue anyway - NetworkManager might handle it, but wait a bit
time.sleep(2)
# Ensure WiFi radio is enabled before attempting connection (safety measure)
if not self._ensure_wifi_radio_enabled():
logger.warning("WiFi radio enable check failed, but continuing with connection attempt")
if self.has_nmcli:
success, message = self._connect_nmcli(ssid, password)
# If connection failed, try to restore original connection
if not success and original_connection and original_ssid:
logger.warning(f"Connection to {ssid} failed, attempting to restore original connection: {original_ssid}")
self._show_led_message(f"Restoring {original_ssid}...", duration=5)
restore_success = self._restore_original_connection(original_connection, original_ssid)
if restore_success:
logger.info(f"Successfully restored original connection: {original_ssid}")
self._show_led_message("Restored!", duration=3)
return False, f"Failed to connect to {ssid}, restored {original_ssid}"
else:
logger.error(f"Failed to restore original connection: {original_ssid}")
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")
return self._failsafe_ap("Connection failed. AP mode enabled.",
"Connection failed and AP mode failed")
return success, message
else:
return self._connect_wpa_supplicant(ssid, password)
except Exception as e:
logger.error(f"Error connecting to network: {e}")
# Try to restore original connection on exception
if original_connection and original_ssid:
try:
logger.warning(f"Exception during connection, attempting to restore: {original_ssid}")
self._restore_original_connection(original_connection, original_ssid)
except Exception as restore_error:
logger.error(f"Failed to restore after exception: {restore_error}")
# Last resort: enable AP mode
try:
self.enable_ap_mode(force=True)
except Exception as ap_error: # nosec B110 - last-resort; do not re-raise, but log for debugging
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.
Args:
connection_name: NetworkManager connection name or UUID
ssid: SSID for verification
Returns:
True if restoration successful, False otherwise
"""
try:
logger.info(f"Attempting to restore connection: {connection_name} ({ssid})")
# Try to activate the connection
result = subprocess.run(
["nmcli", "connection", "up", connection_name],
capture_output=True,
text=True,
timeout=30
)
if result.returncode == 0:
# Wait for connection to stabilize
time.sleep(3)
# Verify connection
status = self.get_wifi_status()
if status.connected:
# Double-check SSID matches (if we can get it)
if status.ssid:
if status.ssid == ssid:
logger.info(f"Successfully restored connection to {ssid}")
return True
else:
logger.warning(f"Restored connection but SSID mismatch: expected {ssid}, got {status.ssid}")
# Still consider it success if we're connected
return True
else:
# Connected but can't verify SSID - assume success
logger.info("Restored connection (SSID verification unavailable)")
return True
else:
logger.warning("Connection activation succeeded but not connected")
return False
else:
error_msg = result.stderr.strip() or result.stdout.strip()
logger.error(f"Failed to restore connection {connection_name}: {error_msg}")
return False
except Exception as e:
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)
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...")
self._wait_for_device_idle(3)
result = subprocess.run(
["nmcli", "connection", "up", existing_conn_name],
capture_output=True,
text=True,
timeout=30
)
if result.returncode == 0:
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}")
return True, f"Connected to {ssid}"
else:
logger.warning(f"Connection activation succeeded but verification failed for {ssid}")
self._show_led_message("Verification failed", duration=5)
return False, "Connection activated but verification failed"
# No existing connection or activation failed, create new connection
logger.info(f"Creating new connection for {ssid}...")
# Connect using nmcli
if password:
cmd = ["nmcli", "device", "wifi", "connect", ssid, "password", password]
else:
cmd = ["nmcli", "device", "wifi", "connect", ssid]
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=30
)
if result.returncode == 0:
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}")
return True, f"Connected to {ssid}"
else:
self._show_led_message("Connection failed", duration=5)
return False, "Connection command succeeded but verification failed"
else:
error_msg = result.stderr.strip() or result.stdout.strip()
logger.error(f"Failed to connect to {ssid}: {error_msg}")
self._show_led_message("Connection failed", duration=5)
if self._is_wrong_password_error(error_msg):
return False, f"wrong_password: {error_msg}"
return False, error_msg
except Exception as e:
logger.error(f"Error connecting with nmcli: {e}")
self._show_led_message("Connection error", duration=5)
return False, str(e)
# 802.11 caps an SSID at 32 octets. Control characters cannot appear in a
# real one, and a leading "-" would be read by nmcli as an option rather
# than a network name.
_SSID_MAX_OCTETS = 32
# WPA-PSK passphrases are 8-63 printable ASCII characters, or a 64-char hex
# key. Anything outside that cannot authenticate, so refusing it early
# costs nothing and keeps argv clean.
_PSK_MIN_LEN = 8
_PSK_MAX_LEN = 63
@classmethod
def _validate_ssid(cls, ssid: Any) -> Tuple[str, Optional[str]]:
"""Return (ssid, None) for a usable SSID, or ('', reason) to refuse it.
Returns the value rather than a boolean so callers pass on what was
checked instead of re-reading the original.
"""
if not isinstance(ssid, str):
return '', "SSID must be text"
ssid = ssid.strip()
if not ssid:
return '', "SSID cannot be empty"
if len(ssid.encode('utf-8')) > cls._SSID_MAX_OCTETS:
return '', f"SSID is longer than {cls._SSID_MAX_OCTETS} bytes"
if any(ord(ch) < 0x20 or ord(ch) == 0x7F for ch in ssid):
return '', "SSID contains control characters"
if ssid.startswith('-'):
# nmcli would take this for an option, not a network name.
return '', "SSID cannot start with '-'"
return ssid, None
@classmethod
def _validate_wifi_password(cls, password: Any) -> Tuple[str, Optional[str]]:
"""Return (password, None) for a usable passphrase, or ('', reason).
An empty password means an open network and is allowed through.
"""
if password is None:
return '', None
if not isinstance(password, str):
return '', "Password must be text"
if password == '':
return '', None
if any(ord(ch) < 0x20 or ord(ch) == 0x7F for ch in password):
return '', "Password contains control characters"
if not password.isascii():
# WPA-PSK passphrases are printable ASCII only; NetworkManager
# rejects anything else.
return '', "Password must be ASCII"
if password.startswith('-'):
# Same reason as the SSID: nmcli would read it as an option.
return '', "Password cannot start with '-'"
is_hex_key = len(password) == 64 and all(c in '0123456789abcdefABCDEF' for c in password)
if not is_hex_key and not (cls._PSK_MIN_LEN <= len(password) <= cls._PSK_MAX_LEN):
return '', (
f"Password must be {cls._PSK_MIN_LEN}-{cls._PSK_MAX_LEN} characters "
f"(or a 64-character hex key)"
)
return password, None
@staticmethod
def _is_wrong_password_error(error_msg: str) -> bool:
"""Return True when nmcli's error output indicates an authentication failure."""
indicators = [
"secrets were required",
"no secret agent",
"802-11-wireless-security.psk",
"authentication rejected",
"association rejected",
]
lower = error_msg.lower()
return any(ind in lower for ind in indicators)
def _connect_wpa_supplicant(self, ssid: str, password: str) -> Tuple[bool, str]:
"""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]:
"""
Disconnect from the current WiFi network
Args:
skip_ap_check: If True, skip auto-enabling AP mode after disconnect
(useful when switching networks)
Returns:
Tuple of (success, message)
"""
try:
# Check if WiFi is connected
status = self.get_wifi_status()
if not status.connected:
return True, "Not connected to any WiFi network"
# Disconnect using nmcli
if self.has_nmcli:
# Take the profile down first, then the device, so the
# device ends up disconnected even when no profile is found.
if status.ssid:
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,
text=True,
timeout=10
)
if result.returncode == 0:
logger.info("Successfully disconnected from WiFi network")
# Wait longer for the disconnect to fully complete
time.sleep(2)
# Check if AP mode should be auto-enabled
# Skip if we're switching networks (skip_ap_check=True)
if not skip_ap_check:
auto_enable = self.config.get("auto_enable_ap_mode", True)
if auto_enable:
# Give it a moment, then check if we should enable AP mode
time.sleep(1)
self.check_and_manage_ap_mode()
else:
logger.debug("Skipping AP mode check (network switch in progress)")
return True, "Disconnected from WiFi network"
else:
error_msg = result.stderr.strip() or result.stdout.strip()
logger.error(f"Failed to disconnect from WiFi: {error_msg}")
return False, f"Failed to disconnect: {error_msg}"
else:
return False, "nmcli is required to disconnect from WiFi"
except Exception as e:
logger.error(f"Error disconnecting from WiFi: {e}")
return False, str(e)
def _ensure_wifi_radio_enabled(self, max_retries: int = 3) -> bool:
"""
Ensure WiFi radio is enabled (not soft-blocked) with retry logic and verification.
Args:
max_retries: Maximum number of retry attempts to enable WiFi radio
Returns:
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:
# Check if WiFi radio is enabled
result = subprocess.run(
["nmcli", "radio", "wifi"],
capture_output=True,
text=True,
timeout=5
)
if result.returncode == 0:
status = result.stdout.strip().lower()
if status == "enabled":
# Verify with rfkill as well
rfkill_result = subprocess.run(
["rfkill", "list", "wifi"],
capture_output=True,
text=True,
timeout=5
)
if "Soft blocked: yes" not in rfkill_result.stdout:
logger.debug(f"WiFi radio confirmed enabled (attempt {attempt + 1})")
return True
# If soft-blocked, continue to unblock logic below
if status == "disabled" or attempt > 0:
# Try to enable WiFi radio
if attempt == 0:
logger.info("WiFi radio is disabled, attempting to enable...")
else:
logger.info(f"WiFi radio still disabled, retry {attempt + 1}/{max_retries}...")
enable_result = subprocess.run(
["sudo", "nmcli", "radio", "wifi", "on"],
capture_output=True,
text=True,
timeout=10
)
if enable_result.returncode == 0:
# Also unblock via rfkill in case it's soft-blocked
subprocess.run(
["sudo", "rfkill", "unblock", "wifi"],
capture_output=True,
timeout=5
)
# Wait longer for it to actually enable
time.sleep(2)
# Verify it's actually enabled now
verify_result = subprocess.run(
["nmcli", "radio", "wifi"],
capture_output=True,
text=True,
timeout=5
)
if verify_result.returncode == 0 and verify_result.stdout.strip().lower() == "enabled":
logger.info("WiFi radio enabled and verified successfully")
return True
elif attempt < max_retries - 1:
logger.warning("WiFi radio enable command succeeded but not verified, will retry...")
time.sleep(1)
continue
else:
logger.warning(f"Failed to enable WiFi radio: {enable_result.stderr}")
if attempt < max_retries - 1:
time.sleep(1)
continue
return False
# Fallback: try rfkill
rfkill_result = subprocess.run(
["rfkill", "list", "wifi"],
capture_output=True,
text=True,
timeout=5
)
if "Soft blocked: yes" in rfkill_result.stdout:
logger.info("WiFi is soft-blocked, unblocking via rfkill...")
subprocess.run(
["sudo", "rfkill", "unblock", "wifi"],
capture_output=True,
timeout=5
)
time.sleep(2)
# Verify unblock worked
verify_rfkill = subprocess.run(
["rfkill", "list", "wifi"],
capture_output=True,
text=True,
timeout=5
)
if "Soft blocked: yes" not in verify_rfkill.stdout:
logger.info("WiFi unblocked via rfkill and verified")
return True
elif attempt < max_retries - 1:
time.sleep(1)
continue
# If we get here and haven't returned, assume enabled if we can't determine
if attempt == 0:
logger.debug("Could not determine WiFi radio status, assuming enabled")
return True
else:
time.sleep(1)
continue
except Exception as e:
logger.warning(f"Could not check/enable WiFi radio (attempt {attempt + 1}): {e}")
if attempt < max_retries - 1:
time.sleep(1)
continue
# On last attempt, assume enabled to avoid blocking operations
return True
logger.warning(f"Failed to enable WiFi radio after {max_retries} attempts")
return False
def get_wifi_radio_state(self) -> Dict:
"""
Report whether the WiFi radio is currently enabled, plus whether a wired
fallback exists. Used by the web UI's radio toggle so it can warn before
an action that could disconnect the browser.
Returns:
{
'enabled': Optional[bool], # True/False, or None if undeterminable
'ethernet_connected': bool, # wired fallback present
'available': bool, # nmcli present / radio state readable
}
"""
ethernet_connected = self._is_ethernet_connected()
enabled: Optional[bool] = None
available = False
try:
result = subprocess.run(
["nmcli", "radio", "wifi"],
capture_output=True,
text=True,
timeout=5
)
if result.returncode == 0:
status = result.stdout.strip().lower()
if status in ("enabled", "disabled"):
enabled = status == "enabled"
available = True
except Exception as e:
logger.debug(f"Could not read WiFi radio state: {e}")
return {
'enabled': enabled,
'ethernet_connected': ethernet_connected,
'available': available,
}
def set_wifi_radio(self, enabled: bool, force: bool = False) -> Tuple[bool, str, Optional[str]]:
"""
Turn the WiFi radio on or off.
Turning the radio OFF from the web interface is dangerous: if the device
is reachable only over WiFi, disabling it disconnects the very page that
issued the request. To prevent that lockout, disabling is refused unless a
wired (Ethernet) fallback is present, or the caller explicitly passes
force=True to acknowledge the risk.
Enabling reuses the hardened _ensure_wifi_radio_enabled() path (handles
rfkill soft-blocks + retries). Both directions rely only on
`nmcli radio wifi on|off`, which is already covered by the passwordless
sudoers allowlist (configure_wifi_permissions.sh) — no new privileged
command is introduced.
Returns:
(success, human-readable message, reason_code). reason_code is
'no_ethernet' when a disable is refused for lockout safety, or a
short failure code otherwise; None on success. The web UI keys on
'no_ethernet' to decide whether to offer a force-off prompt.
"""
if enabled:
if self._ensure_wifi_radio_enabled():
return True, "WiFi radio enabled.", None
return False, "Failed to enable WiFi radio. Check logs for details.", 'enable_failed'
# Disabling — guard against locking the user out of the web interface.
if not force and not self._is_ethernet_connected():
return False, (
"Refusing to disable WiFi: no wired (Ethernet) connection was "
"detected, so turning off WiFi would disconnect you from this "
"page. Connect Ethernet first, or force it if you're sure."
), 'no_ethernet'
try:
result = subprocess.run(
["sudo", "nmcli", "radio", "wifi", "off"],
capture_output=True,
text=True,
timeout=10
)
if result.returncode == 0:
logger.info("WiFi radio disabled via web interface (force=%s)", force)
return True, "WiFi radio disabled.", None
logger.warning("Failed to disable WiFi radio: %s", result.stderr.strip())
return False, "Failed to disable WiFi radio. Check logs for details.", 'command_failed'
except subprocess.TimeoutExpired:
return False, "Command timed out while disabling WiFi radio.", 'timeout'
except (OSError, subprocess.SubprocessError) as e:
logger.error("Error disabling WiFi radio: %s", e, exc_info=True)
return False, "An error occurred while disabling WiFi radio.", 'error'
def enable_ap_mode(self, force: bool = False) -> Tuple[bool, str]:
"""
Enable access point mode
Only enables AP mode if:
- WiFi is NOT connected AND
- Ethernet is NOT connected
Tries hostapd/dnsmasq first (captive portal), falls back to nmcli hotspot if that fails.
Returns:
Tuple of (success, message)
"""
try:
# Check if already in AP mode
if self._is_ap_mode_active():
return True, "AP mode already active"
# Ensure WiFi radio is enabled
if not self._ensure_wifi_radio_enabled():
return False, "WiFi radio is disabled and could not be enabled"
# Check if WiFi is connected (skip when force=True)
status = self.get_wifi_status()
if not force and status.connected:
return False, "Cannot enable AP mode while WiFi is connected"
# Check if Ethernet is connected (skip when force=True)
if not force and self._is_ethernet_connected():
return False, "Cannot enable AP mode while Ethernet is connected"
if force:
logger.debug(f"enable_ap_mode: force=True — WiFi/Ethernet guards bypassed; will create {self._FORCE_AP_FLAG_PATH}")
# Try hostapd/dnsmasq first (captive portal mode)
if self.has_hostapd and self.has_dnsmasq:
result = self._enable_ap_mode_hostapd()
if result[0]:
self._ap_enabled_at = time.time()
if force:
self._mark_forced()
return result
# Fallback to nmcli hotspot (simpler, no captive portal)
if self.has_nmcli:
logger.info("hostapd/dnsmasq failed or unavailable, trying nmcli hotspot fallback...")
self._show_led_message("Setup Mode", duration=5)
result = self._enable_ap_mode_nmcli_hotspot()
if result[0]:
self._ap_enabled_at = time.time()
if force:
self._mark_forced()
return result
return False, "No WiFi tools available (nmcli, hostapd, or dnsmasq required)"
except Exception as e:
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:
# Create hostapd config
self._create_hostapd_config()
# Create dnsmasq config
self._create_dnsmasq_config()
# Set up WiFi interface for AP mode
try:
# Disconnect from any existing WiFi network
subprocess.run(
["sudo", "nmcli", "device", "disconnect", self._wifi_interface],
capture_output=True,
timeout=10
)
# Set static IP for AP mode
subprocess.run(
["sudo", "ip", "addr", "flush", "dev", self._wifi_interface],
capture_output=True,
timeout=10
)
subprocess.run(
["sudo", "ip", "addr", "add", f"{AP_IP}/24", "dev", self._wifi_interface],
capture_output=True,
timeout=10
)
subprocess.run(
["sudo", "ip", "link", "set", self._wifi_interface, "up"],
capture_output=True,
timeout=10
)
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}")
# Start services
try:
# Start hostapd first (it sets up the AP)
result = subprocess.run(
["sudo", "systemctl", "start", HOSTAPD_SERVICE],
capture_output=True,
text=True,
timeout=15
)
if result.returncode != 0:
return False, f"Failed to start hostapd: {result.stderr}"
# Give hostapd time to initialize
time.sleep(1)
# Start dnsmasq
result = subprocess.run(
["sudo", "systemctl", "start", DNSMASQ_SERVICE],
capture_output=True,
text=True,
timeout=10
)
if result.returncode != 0:
# Stop hostapd if dnsmasq failed
subprocess.run(["sudo", "systemctl", "stop", HOSTAPD_SERVICE], timeout=5)
return False, f"Failed to start dnsmasq: {result.stderr}"
# Set up iptables port forwarding (port 80 → 5000) and save ip_forward state
if not self._setup_iptables_redirect():
logger.error("Captive-portal redirect setup failed; stopping AP services")
subprocess.run(["sudo", "systemctl", "stop", HOSTAPD_SERVICE],
capture_output=True, timeout=10)
subprocess.run(["sudo", "systemctl", "stop", DNSMASQ_SERVICE],
capture_output=True, timeout=10)
return False, "AP started but captive-portal redirect setup failed"
logger.info("AP mode enabled successfully")
# 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\n{AP_IP}:{PORTAL_PORT}", duration=10
)
return True, "AP mode enabled"
except Exception as e:
logger.error(f"Error starting AP services: {e}")
return False, str(e)
except Exception as e:
logger.error(f"Error enabling AP mode: {e}")
return False, str(e)
def _enable_ap_mode_nmcli_hotspot(self) -> Tuple[bool, str]:
"""
Enable AP mode using nmcli as an open (passwordless) access point.
Uses 'nmcli connection add type wifi 802-11-wireless.mode ap' instead of
'nmcli device wifi hotspot' because the hotspot subcommand always creates a
WPA2-protected network on Bookworm/Trixie and silently ignores attempts to
strip security after creation.
Tested for both Bookworm and Trixie (Netplan-based NetworkManager).
"""
try:
# Stop any existing connection
self.disconnect_from_network()
time.sleep(1)
ap_ssid, ap_channel = self._validate_ap_config()
# 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 AP_PROFILE_NAMES:
subprocess.run(["nmcli", "connection", "down", conn_name],
capture_output=True, timeout=5)
subprocess.run(["nmcli", "connection", "delete", conn_name],
capture_output=True, timeout=10)
time.sleep(1)
# Create an open AP connection profile from scratch.
# Using 'connection add' instead of 'device wifi hotspot' because the
# hotspot subcommand always attaches a WPA2 PSK on Bookworm/Trixie and
# ignores post-creation security modifications.
logger.info(f"Creating open AP with nmcli connection add: {ap_ssid} on "
f"{self._wifi_interface} (no password)")
cmd = [
"nmcli", "connection", "add",
"type", "wifi",
"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", f"{AP_IP}/24",
# No 802-11-wireless-security section → open network
]
# PMF (Protected Management Frames) is only meaningful for WPA2/WPA3.
# An open AP has no security section, so adding 802-11-wireless-security.pmf
# would cause NM to require key-mgmt too, breaking the connection add on
# Trixie NM 1.52+. Leave PMF untouched — open APs have no frame protection.
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
if result.returncode != 0:
error_msg = result.stderr.strip() or result.stdout.strip()
logger.error(f"Failed to create AP connection profile: {error_msg}")
self._show_led_message("AP mode failed", duration=5)
return False, f"Failed to create AP profile: {error_msg}"
# Write the NM dnsmasq-shared.d captive-portal config BEFORE bringing up
# the connection so NM's dnsmasq picks it up at start time.
# This causes every hostname DNS query from a connected device to resolve
# to 192.168.4.1, automatically triggering the OS captive-portal popup.
self._write_nm_dnsmasq_captive_conf()
logger.info("AP connection profile created, bringing it up...")
up_result = subprocess.run(
["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", 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}"
time.sleep(2)
# NM's ipv4.method=shared manages ip_forward automatically, so we only
# need to add the iptables port-redirect rules for the captive portal.
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", AP_PROFILE_NAME],
capture_output=True, timeout=10)
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"
# Verify the AP is actually running (retry up to 5x with 2s delay for NM async activation)
status = {}
for _attempt in range(5):
status = self._get_ap_status_nmcli()
if status.get('active'):
break
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', 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}:{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", AP_PROFILE_NAME],
capture_output=True, timeout=10)
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"
except Exception as e:
logger.error(f"Error starting AP mode with nmcli: {e}")
self._remove_nm_dnsmasq_captive_conf()
self._show_led_message("Setup mode error", duration=5)
return False, str(e)
def _get_ap_status_nmcli(self) -> Dict:
"""
Get AP status using nmcli (for hotspot mode).
Returns:
Dict with AP status info including active state, SSID, IP, and interface
"""
try:
# Check if hotspot connection is active
result = subprocess.run(
["nmcli", "-t", "-f", "NAME,TYPE,DEVICE", "connection", "show", "--active"],
capture_output=True,
text=True,
timeout=5
)
for line in result.stdout.strip().split('\n'):
parts = line.split(':')
if len(parts) < 2:
continue
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 == 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 = AP_IP
interface = parts[2] if len(parts) > 2 else self._wifi_interface
try:
ip_result = subprocess.run(
["nmcli", "-t", "-f", "IP4.ADDRESS", "device", "show", interface],
capture_output=True,
text=True,
timeout=5
)
if ip_result.returncode == 0:
for ip_line in ip_result.stdout.strip().split('\n'):
if '/' in ip_line:
ip = ip_line.split('/')[0].split(':')[-1].strip()
break
except (subprocess.TimeoutExpired, subprocess.SubprocessError):
pass
return {
'active': True,
'ssid': self.config.get("ap_ssid", DEFAULT_AP_SSID),
'ip': ip,
'interface': interface
}
return {'active': False}
except (subprocess.TimeoutExpired, subprocess.SubprocessError, OSError) as e:
logger.error(f"Error getting AP status with nmcli: {e}")
return {'active': False}
def disable_ap_mode(self) -> Tuple[bool, str]:
"""
Disable access point mode
Returns:
Tuple of (success, message)
"""
try:
if not self._is_ap_mode_active():
return True, "AP mode not active"
# Check which AP mode is active and disable accordingly
# First check if hostapd is running (captive portal mode)
hostapd_active = False
try:
result = subprocess.run(
["systemctl", "is-active", HOSTAPD_SERVICE],
capture_output=True,
text=True,
timeout=2
)
hostapd_active = result.stdout.strip() == "active"
except (subprocess.TimeoutExpired, subprocess.SubprocessError, OSError):
pass
# Stop services
try:
if hostapd_active:
# Disable hostapd/dnsmasq mode (captive portal)
subprocess.run(
["sudo", "systemctl", "stop", HOSTAPD_SERVICE],
capture_output=True,
timeout=10
)
subprocess.run(
["sudo", "systemctl", "stop", DNSMASQ_SERVICE],
capture_output=True,
timeout=10
)
else:
# Disable nmcli hotspot mode (fallback)
for conn_name in AP_PROFILE_NAMES:
subprocess.run(
["nmcli", "connection", "down", conn_name],
capture_output=True,
timeout=10
)
subprocess.run(
["nmcli", "connection", "delete", conn_name],
capture_output=True,
timeout=10
)
# Remove the drop-in captive portal config (only for hostapd mode)
if hostapd_active and DNSMASQ_CONFIG_PATH.exists():
try:
subprocess.run(
["sudo", "rm", "-f", str(DNSMASQ_CONFIG_PATH)],
capture_output=True, timeout=5
)
logger.info(f"Removed captive portal dnsmasq config: {DNSMASQ_CONFIG_PATH}")
except Exception as e:
logger.warning(f"Could not remove dnsmasq drop-in config: {e}")
# Remove iptables redirect rules and restore ip_forward state (hostapd mode only)
if hostapd_active:
self._teardown_iptables_redirect()
# Clean up WiFi interface IP configuration
subprocess.run(
["sudo", "ip", "addr", "del", f"{AP_IP}/24", "dev", self._wifi_interface],
capture_output=True,
timeout=10
)
# Only restart NetworkManager if hostapd was active (needed for hostapd/dnsmasq cleanup)
# Before restarting, ensure we have connectivity safety (Ethernet or WiFi enabled)
connectivity_safe = self._has_connectivity_safety()
if not connectivity_safe:
# Ensure WiFi radio is enabled before restart to maintain connectivity option
logger.warning("No connectivity safety detected (no Ethernet, WiFi may be disabled), ensuring WiFi radio enabled before restart")
self._ensure_wifi_radio_enabled()
logger.info("Restarting NetworkManager to restore normal WiFi operation after hostapd cleanup")
subprocess.run(
["sudo", "systemctl", "restart", "NetworkManager"],
capture_output=True,
timeout=15
)
# Give NetworkManager time to restart
time.sleep(2)
# Explicitly ensure WiFi radio is enabled after restart (with retries for safety)
wifi_enabled = self._ensure_wifi_radio_enabled(max_retries=5)
if not wifi_enabled:
logger.warning("WiFi radio may be disabled after NetworkManager restart - this could cause lockout if Ethernet not connected")
# Try one more time with rfkill as last resort
try:
subprocess.run(
["sudo", "rfkill", "unblock", "wifi"],
capture_output=True,
timeout=5
)
time.sleep(1)
logger.info("Attempted final WiFi radio unblock via rfkill")
except Exception as e:
logger.error(f"Final WiFi radio unblock attempt failed: {e}")
else:
# nmcli AP mode — NM's ipv4.method=shared manages ip_forward automatically,
# so we only need to remove the iptables redirect rules we added.
logger.info("Skipping NetworkManager restart (nmcli AP mode, restart not needed)")
self._teardown_iptables_redirect()
self._remove_nm_dnsmasq_captive_conf()
# Ensure WiFi radio is enabled after nmcli operations
wifi_enabled = self._ensure_wifi_radio_enabled(max_retries=3)
if not wifi_enabled:
logger.warning("WiFi radio may be disabled after nmcli AP cleanup")
self._ap_enabled_at = None
self._FORCE_AP_FLAG_PATH.unlink(missing_ok=True)
logger.info("AP mode disabled successfully")
return True, "AP mode disabled"
except Exception as e:
logger.error(f"Error stopping AP services: {e}")
return False, str(e)
except Exception as e:
logger.error(f"Error disabling AP mode: {e}")
return False, str(e)
def _create_hostapd_config(self):
"""Create hostapd configuration file"""
try:
config_dir = HOSTAPD_CONFIG_PATH.parent
config_dir.mkdir(parents=True, exist_ok=True)
# Use validated values — strips invalid chars and ensures channel is an int.
# Also strip newlines from SSID to prevent config-file injection.
ap_ssid, ap_channel = self._validate_ap_config()
ap_ssid = ap_ssid.replace('\n', '').replace('\r', '')
# Open network configuration (no password) for easy setup access
config_content = f"""interface={self._wifi_interface}
driver=nl80211
ssid={ap_ssid}
hw_mode=g
channel={ap_channel}
wmm_enabled=0
macaddr_acl=0
auth_algs=1
ignore_broadcast_ssid=0
# Open network - no WPA/WPA2 encryption
"""
# Write config (requires sudo)
with open("/tmp/hostapd.conf", 'w') as f: # nosec B108 - named file matches sudoers allowlist; single-user device
f.write(config_content)
# Copy to final location with sudo
subprocess.run(
["sudo", "cp", "/tmp/hostapd.conf", str(HOSTAPD_CONFIG_PATH)], # nosec B108
timeout=10
)
logger.info(f"Created hostapd config at {HOSTAPD_CONFIG_PATH} for {self._wifi_interface}")
except (OSError, subprocess.TimeoutExpired, subprocess.SubprocessError) as e:
logger.error(f"Error creating hostapd config: {e}")
raise
def _create_dnsmasq_config(self):
"""
Create dnsmasq drop-in configuration for captive portal DNS redirection.
Writes to /etc/dnsmasq.d/ledmatrix-captive.conf so we don't overwrite
the main /etc/dnsmasq.conf (preserves Pi-hole, etc.).
"""
try:
# Using a drop-in file in /etc/dnsmasq.d/ to avoid overwriting the
# main /etc/dnsmasq.conf (which may belong to Pi-hole or other services).
config_content = f"""interface={self._wifi_interface}
dhcp-range=192.168.4.2,192.168.4.20,255.255.255.0,24h
# Captive portal: Redirect all DNS queries to Pi
address=/#/{AP_IP}
# Captive portal detection endpoints
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)
with open("/tmp/dnsmasq.conf", 'w') as f: # nosec B108 - named file matches sudoers allowlist; single-user device
f.write(config_content)
# Copy to final location with sudo
subprocess.run(
["sudo", "cp", "/tmp/dnsmasq.conf", str(DNSMASQ_CONFIG_PATH)], # nosec B108
timeout=10
)
logger.info(f"Created dnsmasq config at {DNSMASQ_CONFIG_PATH} for {self._wifi_interface}")
except (OSError, subprocess.TimeoutExpired, subprocess.SubprocessError) as e:
logger.error(f"Error creating dnsmasq config: {e}")
raise
def check_and_manage_ap_mode(self) -> bool:
"""
Check WiFi and Ethernet connection status and enable/disable AP mode accordingly.
Only auto-enables AP mode if:
- auto_enable_ap_mode is enabled in config AND
- WiFi is NOT connected AND
- Ethernet is NOT connected AND
- Multiple consecutive disconnected checks (grace period to avoid false positives)
Always auto-disables AP mode when WiFi or Ethernet connects.
This should be called periodically by a background service.
Returns:
True if AP mode state changed, False otherwise
"""
changed, _status, _ethernet, _ap = self.check_and_manage_ap_mode_with_state()
return changed
def check_and_manage_ap_mode_with_state(self) -> Tuple[bool, WiFiStatus, bool, bool]:
"""Like check_and_manage_ap_mode, but also returns the state it
observed, so callers (the wifi monitor daemon) don't have to re-run
the same nmcli subprocess battery before AND after the check —
each status fetch is several process forks.
Returns:
(state_changed, WiFiStatus, ethernet_connected, ap_active_after)
"""
try:
# Get status with retry for more reliable detection
status = self._get_wifi_status_with_retry()
ethernet_connected = self._is_ethernet_connected()
ap_active = self._is_ap_mode_active()
changed = self._manage_ap_mode(status, ethernet_connected, ap_active)
# State only ever changes via one enable or one disable, so the
# post-state is the inverse of the pre-state when changed.
ap_after = (not ap_active) if changed else ap_active
return changed, status, ethernet_connected, ap_after
except Exception as e:
logger.error(f"Error checking AP mode: {e}", exc_info=True)
return False, WiFiStatus(connected=False), False, False
def _manage_ap_mode(self, status: WiFiStatus, ethernet_connected: bool, ap_active: bool) -> bool:
"""AP-mode decision logic against an already-fetched state snapshot."""
try:
auto_enable = self.config.get("auto_enable_ap_mode", True) # Default: True (safe due to grace period)
# Log current state for debugging
logger.debug(f"WiFi status: connected={status.connected}, SSID={status.ssid}, "
f"Ethernet={ethernet_connected}, AP_active={ap_active}, "
f"auto_enable={auto_enable}, disconnected_checks={self._disconnected_checks}")
# Determine if we should have AP mode active.
# AP-enable uses only the nmcli association state (fast, no network calls).
# This keeps the same reliable behaviour as before: momentary packet loss
# while on working WiFi does NOT trigger AP mode. The internet-reachability
# check is performed separately in the daemon watchdog for NM recovery.
is_disconnected = not status.connected and not ethernet_connected
if is_disconnected:
# Increment disconnected check counter
self._disconnected_checks += 1
logger.debug(f"Network disconnected (check {self._disconnected_checks}/{self._disconnected_checks_required})")
else:
# Reset counter if we're associated
if self._disconnected_checks > 0:
logger.debug("Network connected, resetting disconnected check counter")
self._disconnected_checks = 0
if self._connect_in_progress():
# A connect has just taken the AP down on purpose. Leave the
# radio alone, and restart the grace period so a failed attempt
# (which re-enables the AP itself) isn't followed by a flap.
logger.debug("WiFi connect in progress; skipping AP management this check")
self._disconnected_checks = 0
return False
# Only enable AP if we've had enough consecutive disconnected checks
should_have_ap = (auto_enable and
is_disconnected and
self._disconnected_checks >= self._disconnected_checks_required)
if should_have_ap and not ap_active:
# 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:
logger.info(f"Cached {len(networks)} networks for captive portal")
except Exception as scan_err:
logger.debug(f"Pre-AP scan failed (non-critical): {scan_err}")
logger.info(f"Enabling AP mode after {self._disconnected_checks} consecutive disconnected checks")
success, message = self.enable_ap_mode()
if success:
logger.info("Auto-enabled AP mode (no WiFi or Ethernet connection after grace period)")
self._disconnected_checks = 0 # Reset counter after enabling
return True
else:
logger.warning(f"Failed to enable AP mode: {message}")
elif not should_have_ap and ap_active:
# Should not have AP but do - check if it was manually force-enabled
force_active = self._FORCE_AP_FLAG_PATH.exists()
if status.connected:
# WiFi connected: always disable AP (user successfully configured WiFi)
success, message = self.disable_ap_mode()
if success:
logger.info("Auto-disabled AP mode (WiFi connected)")
self._disconnected_checks = 0
return True
else:
logger.warning(f"Failed to auto-disable AP mode: {message}")
elif ethernet_connected and not force_active:
# Ethernet connected, AP not manually forced: auto-disable
success, message = self.disable_ap_mode()
if success:
logger.info("Auto-disabled AP mode (Ethernet connected)")
self._disconnected_checks = 0
return True
else:
logger.warning(f"Failed to auto-disable AP mode: {message}")
elif ethernet_connected and force_active:
logger.debug("AP mode is force-active; Ethernet connected but auto-disable suppressed")
elif not auto_enable:
logger.debug("AP mode is active (manually enabled), keeping active")
# Idle-timeout check: disable AP if no client has connected within the window.
# Only applies when AP is active and we haven't just decided to enable/disable it.
if ap_active and self._ap_enabled_at is not None:
try:
idle_timeout_min = max(1, min(1440, int(self.config.get("ap_idle_timeout_minutes", 15))))
except (TypeError, ValueError):
idle_timeout_min = 15
elapsed = time.time() - self._ap_enabled_at
if elapsed > idle_timeout_min * 60 and not self._has_ap_clients():
logger.info(
f"AP idle timeout ({idle_timeout_min} min, no clients) — disabling AP"
)
success, message = self.disable_ap_mode()
if success:
return True
else:
logger.warning(f"Failed to disable AP on idle timeout: {message}")
return False
except Exception as e:
logger.error(f"Error checking AP mode: {e}", exc_info=True)
return False
def _get_wifi_status_with_retry(self, max_retries=2) -> WiFiStatus:
"""
Get WiFi status with retry logic to avoid false negatives.
Args:
max_retries: Number of retry attempts if first check fails
Returns:
WiFiStatus object
"""
for attempt in range(max_retries + 1):
status = self.get_wifi_status()
# If we get a connected status, trust it immediately
if status.connected:
return status
# If disconnected, wait a bit and retry (in case of transient issues)
if attempt < max_retries:
time.sleep(1)
logger.debug(f"WiFi status check attempt {attempt + 1}/{max_retries + 1}: disconnected, retrying...")
# Return the last status (disconnected)
return status