mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -04:00
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:
parent
b83a9cf017
commit
dfa03c662e
7 changed files with 172 additions and 4 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 = {}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Loading…
Add table
Add a link
Reference in a new issue