From dfa03c662eaae1a97027f3588f92bb1f23c390b4 Mon Sep 17 00:00:00 2001 From: Roland Date: Tue, 13 Oct 2026 16:26:24 -0600 Subject: [PATCH] The hotspot starts on boot Nothing started the hotspot since hotspot_on_startup moved out of the API, and NetworkManager does not autoconnect the Hotspot connection. The Controller now starts it once per boot unless hotspot.on_startup is false; a legacy wrolpi.yaml hotspot_on_startup: false is migrated. Co-Authored-By: Claude Opus 5.5 --- INSTALL.md | 4 +- controller/conftest.py | 10 ++++ controller/defaults.py | 2 + controller/lib/admin.py | 60 ++++++++++++++++++++++ controller/main.py | 4 ++ controller/test/test_admin.py | 94 ++++++++++++++++++++++++++++++++++- docker/help/wrolpi-help | 2 +- 7 files changed, 172 insertions(+), 4 deletions(-) diff --git a/INSTALL.md b/INSTALL.md index 15ae55f5..f5f20c8f 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -82,8 +82,8 @@ Install WROLPi onto a Raspberry Pi. An external drive is recommended for your l These steps are the same for every install. -1. Join the WROLPi hotspot, or connect your WROLPi to your network, then browse to https://wrolpi.local (or the IP - address of your WROLPi). +1. Join the WROLPi hotspot (network **WROLPi**, password **wrolpi hotspot**; it starts on every boot), or connect + your WROLPi to your network, then browse to https://wrolpi.local (or the IP address of your WROLPi). 2. **Choose your drive.** * **Raspberry Pi and Debian:** until a drive is set up, you will see the **Controller** with a *Welcome to WROLPi* wizard (also at http://wrolpi.local). Select the drive which will hold your library; WROLPi mounts it diff --git a/controller/conftest.py b/controller/conftest.py index c357188b..d5177fc0 100644 --- a/controller/conftest.py +++ b/controller/conftest.py @@ -67,6 +67,16 @@ def _isolate_dnsmasq_include(tmp_path_factory, monkeypatch): captive_portal.reset_acknowledged_clients() +@pytest.fixture(autouse=True) +def _isolate_hotspot_boot_marker(tmp_path_factory, monkeypatch): + """Tests never start a real hotspot, nor write /run, when the Controller app starts up. The marker exists, so + start_hotspot_on_boot does nothing unless a test points it elsewhere.""" + import controller.lib.admin as admin + marker = tmp_path_factory.mktemp("run") / "hotspot-on-startup" + marker.touch() + monkeypatch.setattr(admin, "HOTSPOT_BOOT_MARKER", marker) + + @pytest.fixture(autouse=True) def _patch_status_worker(): """Replace the real status worker with a no-op for every test. diff --git a/controller/defaults.py b/controller/defaults.py index 2e469170..616e7542 100644 --- a/controller/defaults.py +++ b/controller/defaults.py @@ -124,6 +124,8 @@ DEFAULT_CONFIG = { "protocol": "wpa2", # wpa2 (wpa-psk) or wpa3 (sae); see get_device_hotspot_protocols # Show a landing page (with this WROLPi's address) to devices that join the hotspot. "captive_portal": True, + # Start the hotspot when WROLPi boots. Set false to never start it unless asked. + "on_startup": True, }, "throttle": { diff --git a/controller/lib/admin.py b/controller/lib/admin.py index 6efc31ae..53c7f620 100644 --- a/controller/lib/admin.py +++ b/controller/lib/admin.py @@ -9,11 +9,13 @@ import enum import json import logging import subprocess +import time from pathlib import Path import yaml from controller.lib import config as config_lib +from controller.lib import fstab as fstab_lib from controller.lib import captive_portal as captive_portal_lib from controller.lib.config import is_docker_mode, get_config_value, get_media_directory @@ -263,6 +265,13 @@ def migrate_hotspot_settings_from_wrolpi_config(): config_lib.update_config(f'hotspot.{controller_key}', value) changed = True + # A user who never wants the hotspot (e.g. to avoid broadcasting) set this false. + on_startup = wrolpi_config.get('hotspot_on_startup') + if isinstance(on_startup, bool) and 'on_startup' not in explicit \ + and on_startup != get_config_value('hotspot.on_startup'): + config_lib.update_config('hotspot.on_startup', on_startup) + changed = True + if changed: try: config_lib.save_config() @@ -271,6 +280,57 @@ def migrate_hotspot_settings_from_wrolpi_config(): logger.warning("Could not save migrated hotspot settings: %s", e) +# In /run so it is cleared by every reboot. +HOTSPOT_BOOT_MARKER = Path("/run/wrolpi-controller/hotspot-on-startup") +HOTSPOT_BOOT_ATTEMPTS = 3 +HOTSPOT_BOOT_RETRY_SECONDS = 10 + + +def should_start_hotspot_on_boot() -> bool: + """True unless the user set `hotspot.on_startup` false. + + The user's choice is in controller.yaml on the primary drive. When it cannot be read, the hotspot is only + started on a fresh install (no drive in /etc/fstab), which needs it to reach the onboarding wizard; a configured + drive which failed to mount must not start a hotspot the user disabled. + """ + if is_docker_mode(): + return False + if not config_lib.CONFIG_PATH_ON_DRIVE.exists(): + media_directory = str(get_media_directory()) + try: + entries = fstab_lib.parse_fstab() + except OSError as e: + logger.warning("Cannot read fstab to decide on the hotspot: %s", e) + return False + return not any(e.get("mount_point") == media_directory for e in entries) + return get_config_value("hotspot.on_startup", True) is True + + +def start_hotspot_on_boot(): + """Start the hotspot once per boot (not when the Controller restarts), unless the user disabled it.""" + if HOTSPOT_BOOT_MARKER.exists(): + return + try: + HOTSPOT_BOOT_MARKER.parent.mkdir(parents=True, exist_ok=True) + HOTSPOT_BOOT_MARKER.touch() + except OSError as e: + logger.warning("Cannot write %s, the hotspot may restart with the Controller: %s", HOTSPOT_BOOT_MARKER, e) + + if not should_start_hotspot_on_boot(): + logger.info("Not starting the hotspot on boot") + return + + # The WiFi device may still be starting early in boot. + for attempt in range(1, HOTSPOT_BOOT_ATTEMPTS + 1): + result = start_hotspot() + if result.get("success"): + logger.info("Started the hotspot on boot") + return + logger.warning("Failed to start the hotspot on boot (attempt %s): %s", attempt, result.get("error")) + if attempt < HOTSPOT_BOOT_ATTEMPTS: + time.sleep(HOTSPOT_BOOT_RETRY_SECONDS) + + def get_wifi_devices() -> list[str]: """ List WiFi device names (e.g. wlan0, wlp2s0) using nmcli. diff --git a/controller/main.py b/controller/main.py index 755f6218..84de6025 100644 --- a/controller/main.py +++ b/controller/main.py @@ -123,6 +123,10 @@ async def lifespan(app: FastAPI): else: logger.info("Primary drive not mounted - using default configuration") + # After the config (and any migrated hotspot settings) are loaded. Starting can take a while; do not block. + from controller.lib.admin import start_hotspot_on_boot + app.state.hotspot_on_boot_task = asyncio.create_task(asyncio.to_thread(start_hotspot_on_boot)) + # Initialize cached status app.state.cached_status = {} diff --git a/controller/test/test_admin.py b/controller/test/test_admin.py index 61b1d37b..656dfafc 100644 --- a/controller/test/test_admin.py +++ b/controller/test/test_admin.py @@ -7,7 +7,7 @@ from unittest import mock import pytest import yaml -from controller.lib.config import reload_config_from_drive +from controller.lib.config import get_media_directory, reload_config_from_drive from controller.lib import captive_portal from controller.lib.admin import ( apply_timezone_from_config, @@ -29,6 +29,8 @@ from controller.lib.admin import ( get_hotspot_ssid, get_wifi_devices, migrate_hotspot_settings_from_wrolpi_config, + should_start_hotspot_on_boot, + start_hotspot_on_boot, reboot_system, restart_all_services, set_timezone, @@ -627,6 +629,17 @@ class TestHotspotSettingsMigration: assert get_hotspot_device() == 'wlan0' assert yaml.safe_load(mock_config_path.read_text()) == {} + def test_migrates_hotspot_on_startup_false(self, test_directory, test_config_directory, mock_config_path, + reset_runtime_config): + """A user who never wants the hotspot keeps that choice in controller.yaml.""" + mock_config_path.write_text("{}\n") + self._write_wrolpi_yaml(test_config_directory, hotspot_on_startup='false') + + with mock.patch("controller.lib.admin.get_media_directory", return_value=test_directory): + migrate_hotspot_settings_from_wrolpi_config() + + assert yaml.safe_load(mock_config_path.read_text()) == {'hotspot': {'on_startup': False}} + def test_noop_when_drive_not_mounted(self, test_directory, test_config_directory, mock_config_path, reset_runtime_config): """Nothing happens when controller.yaml does not exist (drive not mounted).""" @@ -1828,3 +1841,82 @@ class TestVncStartStop: assert result["success"] is False assert result["error"] == "No VNC server installed" + + +class TestHotspotOnBoot: + """The hotspot is started once per boot unless the user disabled it.""" + + @pytest.fixture + def fstab(self, tmp_path): + path = tmp_path / "fstab" + path.write_text("proc /proc proc defaults 0 0\n") + with mock.patch("controller.lib.fstab.FSTAB_PATH", str(path)): + yield path + + @pytest.fixture + def boot_marker(self, tmp_path): + path = tmp_path / "run" / "hotspot-on-startup" + with mock.patch("controller.lib.admin.HOTSPOT_BOOT_MARKER", path): + yield path + + def test_on_by_default(self, mock_docker_mode, mock_config_path, reset_runtime_config, fstab): + mock_config_path.write_text("{}\n") + reload_config_from_drive() + assert should_start_hotspot_on_boot() is True + + def test_disabled_by_user(self, mock_docker_mode, mock_config_path, reset_runtime_config, fstab): + mock_config_path.write_text("hotspot:\n on_startup: false\n") + reload_config_from_drive() + assert should_start_hotspot_on_boot() is False + + def test_never_in_docker(self, mock_docker_mode_enabled, mock_config_path, reset_runtime_config, fstab): + mock_config_path.write_text("{}\n") + reload_config_from_drive() + assert should_start_hotspot_on_boot() is False + + def test_fresh_install(self, mock_docker_mode, mock_config_path, reset_runtime_config, fstab): + """Without controller.yaml or a configured drive, the hotspot leads to the onboarding wizard.""" + assert not mock_config_path.exists() + assert should_start_hotspot_on_boot() is True + + def test_configured_drive_missing(self, mock_docker_mode, mock_config_path, reset_runtime_config, fstab): + """A drive which failed to mount hides the user's choice, so the hotspot is not started.""" + fstab.write_text(f"UUID=abcd {get_media_directory()} ext4 defaults,nofail 0 0\n") + assert not mock_config_path.exists() + assert should_start_hotspot_on_boot() is False + + def test_starts_once_per_boot(self, mock_docker_mode, mock_config_path, reset_runtime_config, fstab, + boot_marker): + """Restarting the Controller (upgrade, repair) does not restart the hotspot.""" + mock_config_path.write_text("{}\n") + reload_config_from_drive() + + with mock.patch("controller.lib.admin.start_hotspot", return_value={"success": True}) as start: + start_hotspot_on_boot() + start_hotspot_on_boot() + + start.assert_called_once_with() + assert boot_marker.exists() + + def test_not_started_when_disabled(self, mock_docker_mode, mock_config_path, reset_runtime_config, fstab, + boot_marker): + mock_config_path.write_text("hotspot:\n on_startup: false\n") + reload_config_from_drive() + + with mock.patch("controller.lib.admin.start_hotspot") as start: + start_hotspot_on_boot() + + start.assert_not_called() + + def test_retries_while_wifi_starts(self, mock_docker_mode, mock_config_path, reset_runtime_config, fstab, + boot_marker): + """The WiFi device may not be ready early in boot.""" + mock_config_path.write_text("{}\n") + reload_config_from_drive() + + results = [{"success": False, "error": "not ready"}, {"success": True}] + with mock.patch("controller.lib.admin.start_hotspot", side_effect=results) as start, \ + mock.patch("controller.lib.admin.HOTSPOT_BOOT_RETRY_SECONDS", 0): + start_hotspot_on_boot() + + assert start.call_count == 2 diff --git a/docker/help/wrolpi-help b/docker/help/wrolpi-help index 0bb87705..90d4aaa8 160000 --- a/docker/help/wrolpi-help +++ b/docker/help/wrolpi-help @@ -1 +1 @@ -Subproject commit 0bb877057b1e5f29cbd20ee049f6c2f0804a7dba +Subproject commit 90d4aaa861fd633fee65d66cbce973d4e7c7561f