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 <noreply@anthropic.com>
This commit is contained in:
Roland 2026-10-13 16:26:24 -06:00
parent b83a9cf017
commit dfa03c662e
No known key found for this signature in database
GPG key ID: 1BA620CE6C9A4680
7 changed files with 172 additions and 4 deletions

View file

@ -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

View file

@ -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.

View file

@ -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": {

View file

@ -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.

View file

@ -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 = {}

View file

@ -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

@ -1 +1 @@
Subproject commit 0bb877057b1e5f29cbd20ee049f6c2f0804a7dba
Subproject commit 90d4aaa861fd633fee65d66cbce973d4e7c7561f