mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 10:51:36 -04:00
Introduce an onboarding system in the Controller that guides users through selecting and mounting their primary media drive. This is the fallback UI flow used when the main React app is unavailable. Key changes: - Add onboarding API endpoints and schemas (controller/api/onboarding.py) - Add onboarding logic to detect drives, handle already-mounted drives (needed for RPi which auto-mounts), and save primary drive selection to the controller config when no mounts are configured - Extend the Controller's index.html template with the onboarding UI - Skip root cert creation until onboarding is complete - Update first_startup.sh to support the onboarding/repair flow - Add comprehensive tests for onboarding logic and API
171 lines
5.2 KiB
Python
171 lines
5.2 KiB
Python
"""
|
|
Configuration management for the Controller.
|
|
|
|
Controller uses code-defined defaults and optionally loads overrides
|
|
from controller.yaml on the WROLPi drive after it's mounted.
|
|
"""
|
|
|
|
import copy
|
|
import logging
|
|
import os
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import yaml
|
|
|
|
from controller.defaults import DEFAULT_CONFIG
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Path to config file on the WROLPi drive
|
|
CONFIG_PATH_ON_DRIVE = Path("/media/wrolpi/config/controller.yaml")
|
|
|
|
# Runtime config - starts as defaults, updated after drive mount
|
|
_runtime_config: dict = copy.deepcopy(DEFAULT_CONFIG)
|
|
|
|
|
|
def get_config() -> dict:
|
|
"""Get the current runtime configuration."""
|
|
return _runtime_config
|
|
|
|
|
|
def get_config_value(key: str, default: Any = None) -> Any:
|
|
"""Get a specific config value by dot-notation key (e.g., 'drives.auto_mount')."""
|
|
keys = key.split(".")
|
|
value = _runtime_config
|
|
for k in keys:
|
|
if isinstance(value, dict) and k in value:
|
|
value = value[k]
|
|
else:
|
|
return default
|
|
return value
|
|
|
|
|
|
def is_docker_mode() -> bool:
|
|
"""Check if running in Docker container."""
|
|
return os.environ.get("DOCKERIZED", "").lower() == "true"
|
|
|
|
|
|
# Raspberry Pi detection
|
|
RPI_DEVICE_PATH = Path('/proc/device-tree/model')
|
|
_RPI_DEVICE_MODEL_CONTENTS = RPI_DEVICE_PATH.read_text() if RPI_DEVICE_PATH.is_file() else None
|
|
|
|
|
|
def is_rpi4() -> bool:
|
|
"""Check if running on Raspberry Pi 4."""
|
|
return 'Raspberry Pi 4' in _RPI_DEVICE_MODEL_CONTENTS if _RPI_DEVICE_MODEL_CONTENTS else False
|
|
|
|
|
|
def is_rpi5() -> bool:
|
|
"""Check if running on Raspberry Pi 5."""
|
|
return 'Raspberry Pi 5' in _RPI_DEVICE_MODEL_CONTENTS if _RPI_DEVICE_MODEL_CONTENTS else False
|
|
|
|
|
|
def is_rpi() -> bool:
|
|
"""Check if running on any Raspberry Pi."""
|
|
return is_rpi4() or is_rpi5()
|
|
|
|
|
|
def get_media_directory() -> Path:
|
|
"""Get the media directory path."""
|
|
return Path(os.environ.get("MEDIA_DIRECTORY", "/media/wrolpi"))
|
|
|
|
|
|
def is_primary_drive_mounted() -> bool:
|
|
"""Check if the primary WROLPi drive is mounted.
|
|
|
|
Checks for controller.yaml rather than just the config directory,
|
|
because generate_certificates.sh may create /media/wrolpi/config/ssl/
|
|
on the root filesystem before the real drive is mounted.
|
|
"""
|
|
return CONFIG_PATH_ON_DRIVE.exists()
|
|
|
|
|
|
def reload_config_from_drive() -> bool:
|
|
"""
|
|
Load config from the mounted WROLPi drive and merge with defaults.
|
|
Called after the primary drive is mounted.
|
|
|
|
Returns True if config was loaded, False if no config file found.
|
|
"""
|
|
global _runtime_config
|
|
|
|
if not CONFIG_PATH_ON_DRIVE.exists():
|
|
# No config file on drive - keep defaults
|
|
return False
|
|
|
|
try:
|
|
with open(CONFIG_PATH_ON_DRIVE) as f:
|
|
drive_config = yaml.safe_load(f) or {}
|
|
except (IOError, yaml.YAMLError) as e:
|
|
# Failed to load - keep defaults
|
|
logger.warning("Failed to load controller.yaml: %s", e)
|
|
return False
|
|
|
|
# Deep merge: drive_config overrides defaults
|
|
_runtime_config = _deep_merge(copy.deepcopy(DEFAULT_CONFIG), drive_config)
|
|
return True
|
|
|
|
|
|
def save_config() -> None:
|
|
"""
|
|
Save current runtime config to the WROLPi drive.
|
|
Only saves the diff from defaults to keep the file clean.
|
|
Always writes controller.yaml (even if empty) so it serves as a
|
|
marker that the primary drive has been set up.
|
|
"""
|
|
# Check that the config directory exists (drive must be mounted)
|
|
config_dir = CONFIG_PATH_ON_DRIVE.parent
|
|
if not config_dir.exists():
|
|
raise RuntimeError("Cannot save config: primary drive not mounted")
|
|
|
|
# Calculate diff from defaults
|
|
diff = _get_config_diff(_runtime_config, DEFAULT_CONFIG)
|
|
|
|
config_dir.mkdir(parents=True, exist_ok=True)
|
|
with open(CONFIG_PATH_ON_DRIVE, "w") as f:
|
|
yaml.dump(diff if diff else {}, f, default_flow_style=False, sort_keys=False)
|
|
|
|
|
|
def update_config(key: str, value: Any) -> None:
|
|
"""
|
|
Update a config value by dot-notation key.
|
|
Does not automatically save - call save_config() after updates.
|
|
"""
|
|
keys = key.split(".")
|
|
config = _runtime_config
|
|
|
|
# Navigate to parent of target key
|
|
for k in keys[:-1]:
|
|
if k not in config:
|
|
config[k] = {}
|
|
config = config[k]
|
|
|
|
# Set the value
|
|
config[keys[-1]] = value
|
|
|
|
|
|
def _deep_merge(base: dict, override: dict) -> dict:
|
|
"""Deep merge two dicts. Override values take precedence."""
|
|
result = base.copy()
|
|
for key, value in override.items():
|
|
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
|
|
result[key] = _deep_merge(result[key], value)
|
|
else:
|
|
result[key] = value
|
|
return result
|
|
|
|
|
|
def _get_config_diff(current: dict, default: dict) -> dict:
|
|
"""Get the difference between current config and defaults."""
|
|
diff = {}
|
|
for key, value in current.items():
|
|
if key not in default:
|
|
diff[key] = value
|
|
elif isinstance(value, dict) and isinstance(default.get(key), dict):
|
|
nested_diff = _get_config_diff(value, default[key])
|
|
if nested_diff:
|
|
diff[key] = nested_diff
|
|
elif value != default.get(key):
|
|
diff[key] = value
|
|
return diff
|