From 4d7d6d9d1b2347811c63171d91a3af8ffa9d7883 Mon Sep 17 00:00:00 2001 From: Colin Haven <10933733+virtitnerd@users.noreply.github.com> Date: Sun, 27 Sep 2026 10:26:35 -0400 Subject: [PATCH 01/28] Add ROBOROCK_SERVER_* env var config generation Lets Docker Compose deployments generate config.toml from environment variables alone, covering the network/broker/storage/tls sections plus the admin credentials needed for a bootable config. Mirrors the existing Home Assistant add-on options.json -> config.toml flow in ha_addon.py, but supports external broker mode and external_tls reverse-proxy setups that the add-on intentionally doesn't need. Co-Authored-By: Claude Sonnet 5 (cherry picked from commit 2c64eef0f6a546bee4492361da9f1959e564ea7e) --- src/roborock_local_server/env_config.py | 374 ++++++++++++++++++++++++ tests/test_env_config.py | 250 ++++++++++++++++ 2 files changed, 624 insertions(+) create mode 100644 src/roborock_local_server/env_config.py create mode 100644 tests/test_env_config.py diff --git a/src/roborock_local_server/env_config.py b/src/roborock_local_server/env_config.py new file mode 100644 index 0000000..0c406c8 --- /dev/null +++ b/src/roborock_local_server/env_config.py @@ -0,0 +1,374 @@ +"""Generate config.toml from ROBOROCK_SERVER_* environment variables. + +This lets Docker Compose (and similar) deployments boot straight from +environment variables, without running the host-side `configure` wizard +first. It intentionally covers only the deployment/infrastructure-shaped +settings (network, broker, storage, tls) plus the admin credentials needed +to produce a bootable config; it is not meant to grow a variable for every +config.toml key. +""" + +from __future__ import annotations + +from dataclasses import dataclass +import ipaddress +import json +import os +from pathlib import Path +import re +import secrets +import tomllib +from typing import Mapping +from urllib.parse import urlsplit + +from .configure import hash_password + +ENV_PREFIX = "ROBOROCK_SERVER_" + +_HOST_RE = re.compile(r"^[a-z0-9.-]+$") + +DEFAULT_CONFIG_PATH = Path("/data/config.toml") +DEFAULT_CLOUDFLARE_TOKEN_PATH = Path("/run/secrets/cloudflare_token") +DEFAULT_ACME_EAB_KID_PATH = Path("/run/secrets/acme_eab_kid") +DEFAULT_ACME_EAB_HMAC_KEY_PATH = Path("/run/secrets/acme_eab_hmac_key") + + +def _toml_string(value: str) -> str: + return json.dumps(value) + + +def _toml_bool(value: bool) -> str: + return "true" if value else "false" + + +def _get(env: Mapping[str, str], name: str, default: str = "") -> str: + return str(env.get(f"{ENV_PREFIX}{name}", "") or "").strip() or default + + +def _normalize_hostname(raw_value: str, *, field_name: str, require_api_prefix: bool = False) -> str: + text = str(raw_value or "").strip() + if not text: + raise ValueError(f"{field_name} is required") + if "://" in text: + parsed = urlsplit(text) + candidate = parsed.hostname or "" + else: + candidate = text.split("/", 1)[0].strip() + if ":" in candidate: + candidate = candidate.split(":", 1)[0].strip() + normalized = candidate.strip().strip(".").lower() + if normalized.startswith("*."): + normalized = normalized[2:].strip() + if not normalized: + raise ValueError(f"{field_name} is required") + if " " in normalized or not _HOST_RE.fullmatch(normalized): + raise ValueError(f"{field_name} must be a hostname without a scheme or path") + if "." not in normalized: + raise ValueError(f"{field_name} must be a fully qualified domain name") + if require_api_prefix and not normalized.startswith("api-"): + raise ValueError(f"{field_name} must start with api-") + return normalized + + +def _as_port(value: str, *, field_name: str, default: int) -> int: + if not value: + return default + try: + candidate = int(value) + except ValueError as exc: + raise ValueError(f"{field_name} must be an integer") from exc + if not (1 <= candidate <= 65535): + raise ValueError(f"{field_name} must be between 1 and 65535") + return candidate + + +def _as_bool(value: str, *, default: bool) -> bool: + if not value: + return default + lowered = value.strip().lower() + if lowered in {"1", "true", "yes", "on"}: + return True + if lowered in {"0", "false", "no", "off"}: + return False + raise ValueError(f"expected a boolean value, got {value!r}") + + +def _as_trusted_proxies(value: str, *, default: tuple[str, ...]) -> tuple[str, ...]: + if not value: + return default + entries: list[str] = [] + for item in value.split(","): + text = item.strip() + if not text: + continue + try: + ipaddress.ip_network(text, strict=False) + except ValueError as exc: + raise ValueError(f"trusted_proxies entry {text!r} is not a valid IP address or CIDR network") from exc + entries.append(text) + return tuple(entries) if entries else default + + +def _require_non_empty(value: str, *, field_name: str) -> str: + text = str(value or "").strip() + if not text: + raise ValueError(f"{ENV_PREFIX}{field_name} is required") + return text + + +def _require_email(value: str, *, field_name: str) -> str: + text = _require_non_empty(value, field_name=field_name) + if "@" not in text: + raise ValueError(f"{ENV_PREFIX}{field_name} must be an email address") + return text + + +def _require_pin(value: str, *, field_name: str) -> str: + text = _require_non_empty(value, field_name=field_name) + if len(text) != 6 or not text.isdigit(): + raise ValueError(f"{ENV_PREFIX}{field_name} must be exactly 6 digits") + return text + + +def _normalize_acme_server(value: str) -> str: + normalized = value.strip().lower() or "zerossl" + if normalized not in {"zerossl", "actalis"}: + raise ValueError(f"{ENV_PREFIX}ACME_SERVER must be 'zerossl' or 'actalis'") + return normalized + + +def _load_existing_admin_session_secret(config_path: Path) -> str: + if not config_path.exists(): + return "" + try: + parsed = tomllib.loads(config_path.read_text(encoding="utf-8")) + except (OSError, tomllib.TOMLDecodeError): + return "" + admin = parsed.get("admin") + if not isinstance(admin, dict): + return "" + secret = str(admin.get("session_secret", "") or "").strip() + return secret if len(secret) >= 24 else "" + + +@dataclass(frozen=True) +class SecretPaths: + """Where env-supplied plaintext secrets get written, and the Docker-secret + style *_FILE overrides that skip writing entirely.""" + + cloudflare_token_file: Path = DEFAULT_CLOUDFLARE_TOKEN_PATH + acme_eab_kid_file: Path = DEFAULT_ACME_EAB_KID_PATH + acme_eab_hmac_key_file: Path = DEFAULT_ACME_EAB_HMAC_KEY_PATH + + +def has_env_config(env: Mapping[str, str]) -> bool: + """True when enough ROBOROCK_SERVER_* vars are present to attempt generation.""" + return bool(_get(env, "STACK_FQDN")) + + +def render_config_toml_from_env( + env: Mapping[str, str], + *, + config_path: Path, + secret_paths: SecretPaths = SecretPaths(), +) -> tuple[str, dict[Path, str]]: + stack_fqdn = _normalize_hostname(_get(env, "STACK_FQDN"), field_name="STACK_FQDN", require_api_prefix=True) + listener_mode = _get(env, "LISTENER_MODE", "local_tls").lower() + if listener_mode not in {"local_tls", "external_tls"}: + raise ValueError(f"{ENV_PREFIX}LISTENER_MODE must be 'local_tls' or 'external_tls'") + bind_host = _get(env, "BIND_HOST", "0.0.0.0") + region = _get(env, "REGION", "us").lower() + https_port = _as_port(_get(env, "HTTPS_PORT"), field_name=f"{ENV_PREFIX}HTTPS_PORT", default=555) + mqtt_tls_port = _as_port(_get(env, "MQTT_TLS_PORT"), field_name=f"{ENV_PREFIX}MQTT_TLS_PORT", default=8881) + advertised_https_port = _as_port( + _get(env, "ADVERTISED_HTTPS_PORT"), + field_name=f"{ENV_PREFIX}ADVERTISED_HTTPS_PORT", + default=https_port, + ) + advertised_mqtt_tls_port = _as_port( + _get(env, "ADVERTISED_MQTT_TLS_PORT"), + field_name=f"{ENV_PREFIX}ADVERTISED_MQTT_TLS_PORT", + default=mqtt_tls_port, + ) + trusted_proxies = _as_trusted_proxies(_get(env, "TRUSTED_PROXIES"), default=("127.0.0.1", "::1")) + + broker_mode = _get(env, "BROKER_MODE", "embedded").lower() + if broker_mode not in {"embedded", "external"}: + raise ValueError(f"{ENV_PREFIX}BROKER_MODE must be 'embedded' or 'external'") + if broker_mode == "embedded": + broker_host = _get(env, "BROKER_HOST", "127.0.0.1") + broker_port = _as_port(_get(env, "BROKER_PORT"), field_name=f"{ENV_PREFIX}BROKER_PORT", default=18830) + else: + broker_host = _require_non_empty(_get(env, "BROKER_HOST"), field_name="BROKER_HOST") + broker_port = _as_port(_get(env, "BROKER_PORT"), field_name=f"{ENV_PREFIX}BROKER_PORT", default=1883) + + data_dir = _get(env, "DATA_DIR", "/data") + + tls_mode = _get(env, "TLS_MODE", "cloudflare_acme").lower() + if tls_mode not in {"cloudflare_acme", "provided"}: + raise ValueError(f"{ENV_PREFIX}TLS_MODE must be 'cloudflare_acme' or 'provided'") + if listener_mode == "external_tls" and tls_mode != "provided": + raise ValueError( + f"{ENV_PREFIX}LISTENER_MODE='external_tls' requires {ENV_PREFIX}TLS_MODE='provided' " + "(the proxy terminates TLS; the server does not issue certificates)" + ) + + tls_base_domain = "" + tls_email = "" + acme_server = "zerossl" + cloudflare_token_file = "" + acme_eab_kid_file = "" + acme_eab_hmac_key_file = "" + cert_file = "" + key_file = "" + secrets_to_write: dict[Path, str] = {} + + if listener_mode == "local_tls": + if tls_mode == "cloudflare_acme": + tls_base_domain = _normalize_hostname(_get(env, "TLS_BASE_DOMAIN"), field_name="TLS_BASE_DOMAIN") + tls_email = _require_non_empty(_get(env, "TLS_EMAIL"), field_name="TLS_EMAIL") + acme_server = _normalize_acme_server(_get(env, "ACME_SERVER", "zerossl")) + + cloudflare_token_file_override = _get(env, "CLOUDFLARE_TOKEN_FILE") + cloudflare_token = _get(env, "CLOUDFLARE_TOKEN") + if cloudflare_token_file_override: + cloudflare_token_file = cloudflare_token_file_override + elif cloudflare_token: + cloudflare_token_file = str(secret_paths.cloudflare_token_file) + secrets_to_write[secret_paths.cloudflare_token_file] = cloudflare_token + else: + raise ValueError( + f"{ENV_PREFIX}CLOUDFLARE_TOKEN or {ENV_PREFIX}CLOUDFLARE_TOKEN_FILE is required " + f"when {ENV_PREFIX}TLS_MODE='cloudflare_acme'" + ) + + if acme_server == "actalis": + eab_kid_file_override = _get(env, "ACME_EAB_KID_FILE") + eab_kid = _get(env, "ACME_EAB_KID") + eab_hmac_file_override = _get(env, "ACME_EAB_HMAC_KEY_FILE") + eab_hmac = _get(env, "ACME_EAB_HMAC_KEY") + if not (eab_kid_file_override or eab_kid): + raise ValueError(f"Actalis requires {ENV_PREFIX}ACME_EAB_KID or {ENV_PREFIX}ACME_EAB_KID_FILE") + if not (eab_hmac_file_override or eab_hmac): + raise ValueError( + f"Actalis requires {ENV_PREFIX}ACME_EAB_HMAC_KEY or {ENV_PREFIX}ACME_EAB_HMAC_KEY_FILE" + ) + acme_eab_kid_file = eab_kid_file_override or str(secret_paths.acme_eab_kid_file) + if not eab_kid_file_override: + secrets_to_write[secret_paths.acme_eab_kid_file] = eab_kid + acme_eab_hmac_key_file = eab_hmac_file_override or str(secret_paths.acme_eab_hmac_key_file) + if not eab_hmac_file_override: + secrets_to_write[secret_paths.acme_eab_hmac_key_file] = eab_hmac + else: + cert_file = _require_non_empty(_get(env, "CERT_FILE"), field_name="CERT_FILE") + key_file = _require_non_empty(_get(env, "KEY_FILE"), field_name="KEY_FILE") + + admin_password = _require_non_empty(_get(env, "ADMIN_PASSWORD"), field_name="ADMIN_PASSWORD") + admin_session_secret = ( + _get(env, "ADMIN_SESSION_SECRET") + or _load_existing_admin_session_secret(config_path) + or secrets.token_urlsafe(32) + ) + if len(admin_session_secret) < 24: + raise ValueError(f"{ENV_PREFIX}ADMIN_SESSION_SECRET must be at least 24 characters when set") + new_connections_enabled = _as_bool(_get(env, "NEW_CONNECTIONS_ENABLED"), default=True) + protocol_login_email = _require_email(_get(env, "PROTOCOL_LOGIN_EMAIL"), field_name="PROTOCOL_LOGIN_EMAIL") + protocol_login_pin = _require_pin(_get(env, "PROTOCOL_LOGIN_PIN"), field_name="PROTOCOL_LOGIN_PIN") + + password_hash = hash_password(admin_password) + protocol_login_pin_hash = hash_password(protocol_login_pin) + + lines = [ + "[network]", + f"stack_fqdn = {_toml_string(stack_fqdn)}", + f"listener_mode = {_toml_string(listener_mode)}", + f"bind_host = {_toml_string(bind_host)}", + f"https_port = {https_port}", + f"mqtt_tls_port = {mqtt_tls_port}", + f"advertised_https_port = {advertised_https_port}", + f"advertised_mqtt_tls_port = {advertised_mqtt_tls_port}", + f"region = {_toml_string(region)}", + f"trusted_proxies = [{', '.join(_toml_string(p) for p in trusted_proxies)}]", + "", + "[broker]", + f"mode = {_toml_string(broker_mode)}", + f"host = {_toml_string(broker_host)}", + f"port = {broker_port}", + 'mosquitto_binary = "mosquitto"', + "enable_topic_bridge = true", + "", + "[storage]", + f"data_dir = {_toml_string(data_dir)}", + "", + "[tls]", + f"mode = {_toml_string(tls_mode)}", + ] + if listener_mode == "local_tls" and tls_mode == "cloudflare_acme": + lines.extend( + [ + f"base_domain = {_toml_string(tls_base_domain)}", + f"email = {_toml_string(tls_email)}", + f"cloudflare_token_file = {_toml_string(cloudflare_token_file)}", + "renew_days_before = 30", + "renew_check_seconds = 43200", + f"acme_server = {_toml_string(acme_server)}", + 'acme_eab_kid = ""', + 'acme_eab_hmac_key = ""', + f"acme_eab_kid_file = {_toml_string(acme_eab_kid_file)}", + f"acme_eab_hmac_key_file = {_toml_string(acme_eab_hmac_key_file)}", + ] + ) + else: + lines.extend( + [ + 'base_domain = ""', + 'email = ""', + 'cloudflare_token_file = ""', + "renew_days_before = 30", + "renew_check_seconds = 43200", + f"acme_server = {_toml_string(acme_server)}", + 'acme_eab_kid = ""', + 'acme_eab_hmac_key = ""', + 'acme_eab_kid_file = ""', + 'acme_eab_hmac_key_file = ""', + f"cert_file = {_toml_string(cert_file)}", + f"key_file = {_toml_string(key_file)}", + ] + ) + + lines.extend( + [ + "", + "[admin]", + f"password_hash = {_toml_string(password_hash)}", + f"session_secret = {_toml_string(admin_session_secret)}", + "session_ttl_seconds = 86400", + "protocol_auth_enabled = true", + f"new_connections_enabled = {_toml_bool(new_connections_enabled)}", + f"protocol_login_email = {_toml_string(protocol_login_email)}", + f"protocol_login_pin_hash = {_toml_string(protocol_login_pin_hash)}", + "", + ] + ) + return "\n".join(lines), secrets_to_write + + +def write_config_from_env( + env: Mapping[str, str], + *, + config_path: Path = DEFAULT_CONFIG_PATH, + secret_paths: SecretPaths = SecretPaths(), +) -> Path: + config_text, secrets_to_write = render_config_toml_from_env( + env, + config_path=config_path, + secret_paths=secret_paths, + ) + config_path.parent.mkdir(parents=True, exist_ok=True) + config_path.write_text(config_text, encoding="utf-8") + for path, contents in secrets_to_write.items(): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(contents, encoding="utf-8") + if os.name != "nt": + path.chmod(0o600) + return config_path diff --git a/tests/test_env_config.py b/tests/test_env_config.py new file mode 100644 index 0000000..79e7bb2 --- /dev/null +++ b/tests/test_env_config.py @@ -0,0 +1,250 @@ +from __future__ import annotations + +from pathlib import Path +import tomllib + +import pytest + +from roborock_local_server.env_config import SecretPaths, has_env_config, write_config_from_env + +_BASE_ENV = { + "ROBOROCK_SERVER_STACK_FQDN": "api-roborock.example.com", + "ROBOROCK_SERVER_TLS_MODE": "provided", + "ROBOROCK_SERVER_CERT_FILE": "/ssl/fullchain.pem", + "ROBOROCK_SERVER_KEY_FILE": "/ssl/privkey.pem", + "ROBOROCK_SERVER_ADMIN_PASSWORD": "super-secret-password", + "ROBOROCK_SERVER_PROTOCOL_LOGIN_EMAIL": "user@example.com", + "ROBOROCK_SERVER_PROTOCOL_LOGIN_PIN": "123456", +} + + +def test_has_env_config_requires_stack_fqdn() -> None: + assert has_env_config({}) is False + assert has_env_config({"ROBOROCK_SERVER_STACK_FQDN": "api-roborock.example.com"}) is True + + +def test_write_config_from_env_provided_tls(tmp_path: Path) -> None: + config_path = tmp_path / "config.toml" + + write_config_from_env(_BASE_ENV, config_path=config_path) + + parsed = tomllib.loads(config_path.read_text(encoding="utf-8")) + assert parsed["network"]["stack_fqdn"] == "api-roborock.example.com" + assert parsed["network"]["listener_mode"] == "local_tls" + assert parsed["network"]["https_port"] == 555 + assert parsed["network"]["mqtt_tls_port"] == 8881 + assert parsed["network"]["advertised_https_port"] == 555 + assert parsed["network"]["advertised_mqtt_tls_port"] == 8881 + assert parsed["network"]["trusted_proxies"] == ["127.0.0.1", "::1"] + assert parsed["broker"]["mode"] == "embedded" + assert parsed["broker"]["host"] == "127.0.0.1" + assert parsed["broker"]["port"] == 18830 + assert parsed["tls"]["mode"] == "provided" + assert parsed["tls"]["cert_file"] == "/ssl/fullchain.pem" + assert parsed["tls"]["key_file"] == "/ssl/privkey.pem" + assert parsed["admin"]["protocol_auth_enabled"] is True + assert parsed["admin"]["new_connections_enabled"] is True + assert parsed["admin"]["protocol_login_email"] == "user@example.com" + assert len(str(parsed["admin"]["session_secret"])) >= 24 + assert str(parsed["admin"]["password_hash"]).startswith("pbkdf2_sha256$") + assert str(parsed["admin"]["protocol_login_pin_hash"]).startswith("pbkdf2_sha256$") + + +def test_write_config_from_env_reverse_proxy_ports(tmp_path: Path) -> None: + config_path = tmp_path / "config.toml" + env = dict(_BASE_ENV) + env["ROBOROCK_SERVER_ADVERTISED_HTTPS_PORT"] = "443" + env["ROBOROCK_SERVER_ADVERTISED_MQTT_TLS_PORT"] = "8883" + + write_config_from_env(env, config_path=config_path) + + parsed = tomllib.loads(config_path.read_text(encoding="utf-8")) + assert parsed["network"]["https_port"] == 555 + assert parsed["network"]["advertised_https_port"] == 443 + assert parsed["network"]["advertised_mqtt_tls_port"] == 8883 + + +def test_write_config_from_env_external_tls_requires_provided(tmp_path: Path) -> None: + config_path = tmp_path / "config.toml" + env = dict(_BASE_ENV) + env["ROBOROCK_SERVER_LISTENER_MODE"] = "external_tls" + env["ROBOROCK_SERVER_TLS_MODE"] = "cloudflare_acme" + del env["ROBOROCK_SERVER_CERT_FILE"] + del env["ROBOROCK_SERVER_KEY_FILE"] + + with pytest.raises(ValueError, match="external_tls"): + write_config_from_env(env, config_path=config_path) + + +def test_write_config_from_env_external_broker_requires_host(tmp_path: Path) -> None: + config_path = tmp_path / "config.toml" + env = dict(_BASE_ENV) + env["ROBOROCK_SERVER_BROKER_MODE"] = "external" + + with pytest.raises(ValueError, match="BROKER_HOST"): + write_config_from_env(env, config_path=config_path) + + +def test_write_config_from_env_external_broker(tmp_path: Path) -> None: + config_path = tmp_path / "config.toml" + env = dict(_BASE_ENV) + env["ROBOROCK_SERVER_BROKER_MODE"] = "external" + env["ROBOROCK_SERVER_BROKER_HOST"] = "mqtt.internal" + + write_config_from_env(env, config_path=config_path) + + parsed = tomllib.loads(config_path.read_text(encoding="utf-8")) + assert parsed["broker"]["mode"] == "external" + assert parsed["broker"]["host"] == "mqtt.internal" + assert parsed["broker"]["port"] == 1883 + + +def test_write_config_from_env_cloudflare_writes_secret(tmp_path: Path) -> None: + config_path = tmp_path / "config.toml" + token_path = tmp_path / "run" / "secrets" / "cloudflare_token" + env = dict(_BASE_ENV) + env["ROBOROCK_SERVER_TLS_MODE"] = "cloudflare_acme" + env["ROBOROCK_SERVER_TLS_BASE_DOMAIN"] = "example.com" + env["ROBOROCK_SERVER_TLS_EMAIL"] = "acme@example.com" + env["ROBOROCK_SERVER_CLOUDFLARE_TOKEN"] = "cloudflare-token-123" + del env["ROBOROCK_SERVER_CERT_FILE"] + del env["ROBOROCK_SERVER_KEY_FILE"] + + write_config_from_env( + env, + config_path=config_path, + secret_paths=SecretPaths(cloudflare_token_file=token_path), + ) + + parsed = tomllib.loads(config_path.read_text(encoding="utf-8")) + assert parsed["tls"]["mode"] == "cloudflare_acme" + assert parsed["tls"]["base_domain"] == "example.com" + assert parsed["tls"]["cloudflare_token_file"] == str(token_path) + assert token_path.read_text(encoding="utf-8") == "cloudflare-token-123" + + +def test_write_config_from_env_cloudflare_token_file_override_skips_write(tmp_path: Path) -> None: + config_path = tmp_path / "config.toml" + existing_token_path = tmp_path / "existing-token" + env = dict(_BASE_ENV) + env["ROBOROCK_SERVER_TLS_MODE"] = "cloudflare_acme" + env["ROBOROCK_SERVER_TLS_BASE_DOMAIN"] = "example.com" + env["ROBOROCK_SERVER_TLS_EMAIL"] = "acme@example.com" + env["ROBOROCK_SERVER_CLOUDFLARE_TOKEN_FILE"] = str(existing_token_path) + del env["ROBOROCK_SERVER_CERT_FILE"] + del env["ROBOROCK_SERVER_KEY_FILE"] + + write_config_from_env(env, config_path=config_path) + + parsed = tomllib.loads(config_path.read_text(encoding="utf-8")) + assert parsed["tls"]["cloudflare_token_file"] == str(existing_token_path) + assert existing_token_path.exists() is False + + +def test_write_config_from_env_actalis_requires_eab(tmp_path: Path) -> None: + config_path = tmp_path / "config.toml" + env = dict(_BASE_ENV) + env["ROBOROCK_SERVER_TLS_MODE"] = "cloudflare_acme" + env["ROBOROCK_SERVER_TLS_BASE_DOMAIN"] = "example.com" + env["ROBOROCK_SERVER_TLS_EMAIL"] = "acme@example.com" + env["ROBOROCK_SERVER_ACME_SERVER"] = "actalis" + env["ROBOROCK_SERVER_CLOUDFLARE_TOKEN"] = "cloudflare-token-123" + del env["ROBOROCK_SERVER_CERT_FILE"] + del env["ROBOROCK_SERVER_KEY_FILE"] + + with pytest.raises(ValueError, match="ACME_EAB_KID"): + write_config_from_env(env, config_path=config_path) + + +def test_write_config_from_env_actalis_writes_eab(tmp_path: Path) -> None: + config_path = tmp_path / "config.toml" + token_path = tmp_path / "cloudflare_token" + kid_path = tmp_path / "acme_eab_kid" + hmac_path = tmp_path / "acme_eab_hmac_key" + env = dict(_BASE_ENV) + env["ROBOROCK_SERVER_TLS_MODE"] = "cloudflare_acme" + env["ROBOROCK_SERVER_TLS_BASE_DOMAIN"] = "example.com" + env["ROBOROCK_SERVER_TLS_EMAIL"] = "acme@example.com" + env["ROBOROCK_SERVER_ACME_SERVER"] = "actalis" + env["ROBOROCK_SERVER_ACME_EAB_KID"] = "kid-123" + env["ROBOROCK_SERVER_ACME_EAB_HMAC_KEY"] = "hmac-456" + env["ROBOROCK_SERVER_CLOUDFLARE_TOKEN"] = "cloudflare-token-123" + del env["ROBOROCK_SERVER_CERT_FILE"] + del env["ROBOROCK_SERVER_KEY_FILE"] + + write_config_from_env( + env, + config_path=config_path, + secret_paths=SecretPaths( + cloudflare_token_file=token_path, + acme_eab_kid_file=kid_path, + acme_eab_hmac_key_file=hmac_path, + ), + ) + + parsed = tomllib.loads(config_path.read_text(encoding="utf-8")) + assert parsed["tls"]["acme_server"] == "actalis" + assert parsed["tls"]["acme_eab_kid_file"] == str(kid_path) + assert parsed["tls"]["acme_eab_hmac_key_file"] == str(hmac_path) + assert kid_path.read_text(encoding="utf-8") == "kid-123" + assert hmac_path.read_text(encoding="utf-8") == "hmac-456" + + +def test_write_config_from_env_requires_admin_password(tmp_path: Path) -> None: + config_path = tmp_path / "config.toml" + env = dict(_BASE_ENV) + del env["ROBOROCK_SERVER_ADMIN_PASSWORD"] + + with pytest.raises(ValueError, match="ADMIN_PASSWORD is required"): + write_config_from_env(env, config_path=config_path) + + +def test_write_config_from_env_requires_api_prefix(tmp_path: Path) -> None: + config_path = tmp_path / "config.toml" + env = dict(_BASE_ENV) + env["ROBOROCK_SERVER_STACK_FQDN"] = "roborock.example.com" + + with pytest.raises(ValueError, match="STACK_FQDN must start with api-"): + write_config_from_env(env, config_path=config_path) + + +def test_write_config_from_env_rejects_bad_pin(tmp_path: Path) -> None: + config_path = tmp_path / "config.toml" + env = dict(_BASE_ENV) + env["ROBOROCK_SERVER_PROTOCOL_LOGIN_PIN"] = "12" + + with pytest.raises(ValueError, match="PROTOCOL_LOGIN_PIN must be exactly 6 digits"): + write_config_from_env(env, config_path=config_path) + + +def test_write_config_from_env_reuses_existing_session_secret(tmp_path: Path) -> None: + config_path = tmp_path / "config.toml" + + write_config_from_env(_BASE_ENV, config_path=config_path) + first_secret = tomllib.loads(config_path.read_text(encoding="utf-8"))["admin"]["session_secret"] + + write_config_from_env(_BASE_ENV, config_path=config_path) + second_secret = tomllib.loads(config_path.read_text(encoding="utf-8"))["admin"]["session_secret"] + + assert len(str(first_secret)) >= 24 + assert second_secret == first_secret + + +def test_write_config_from_env_trusted_proxies(tmp_path: Path) -> None: + config_path = tmp_path / "config.toml" + env = dict(_BASE_ENV) + env["ROBOROCK_SERVER_TRUSTED_PROXIES"] = "10.0.0.0/8, 192.168.1.5" + + write_config_from_env(env, config_path=config_path) + + parsed = tomllib.loads(config_path.read_text(encoding="utf-8")) + assert parsed["network"]["trusted_proxies"] == ["10.0.0.0/8", "192.168.1.5"] + + +def test_write_config_from_env_rejects_invalid_trusted_proxy(tmp_path: Path) -> None: + config_path = tmp_path / "config.toml" + env = dict(_BASE_ENV) + env["ROBOROCK_SERVER_TRUSTED_PROXIES"] = "not-an-ip" + + with pytest.raises(ValueError, match="not a valid IP address"): + write_config_from_env(env, config_path=config_path) From 02e7a1d99551e5768811a4a5c6b9b7d1b7094230 Mon Sep 17 00:00:00 2001 From: Colin Haven <10933733+virtitnerd@users.noreply.github.com> Date: Sun, 27 Sep 2026 10:26:41 -0400 Subject: [PATCH 02/28] Wire env var config generation into the container entrypoint Falls back to ROBOROCK_SERVER_* env vars only when no config.toml (compose-mounted or previously generated) and no HA add-on options.json are present, so an existing hand-edited config is never clobbered. Co-Authored-By: Claude Sonnet 5 (cherry picked from commit 19acdea61b55948d9579e234d5ed6ce21b31e780) --- .../container_entrypoint.py | 9 ++- tests/test_container_entrypoint.py | 78 +++++++++++++++++++ 2 files changed, 86 insertions(+), 1 deletion(-) diff --git a/src/roborock_local_server/container_entrypoint.py b/src/roborock_local_server/container_entrypoint.py index 3d423ea..ba8a9a7 100644 --- a/src/roborock_local_server/container_entrypoint.py +++ b/src/roborock_local_server/container_entrypoint.py @@ -5,6 +5,7 @@ import os from pathlib import Path +from .env_config import has_env_config, write_config_from_env from .ha_addon import write_config_from_home_assistant_options @@ -32,8 +33,14 @@ def _run_entrypoint(*, compose_config: Path, data_config: Path, addon_options: P _exec_server(data_config) return + if has_env_config(os.environ): + write_config_from_env(os.environ, config_path=data_config) + _exec_server(data_config) + return + raise SystemExit( - "No config file found. Expected /app/config.toml, /data/config.toml, or /data/options.json." + "No config file found. Expected /app/config.toml, /data/config.toml, /data/options.json, " + "or ROBOROCK_SERVER_* environment variables (see docs/installation.md)." ) diff --git a/tests/test_container_entrypoint.py b/tests/test_container_entrypoint.py index c224947..424c64f 100644 --- a/tests/test_container_entrypoint.py +++ b/tests/test_container_entrypoint.py @@ -65,3 +65,81 @@ def test_run_entrypoint_prefers_compose_config_when_present( ) assert calls == [compose_config] + + +def test_run_entrypoint_prefers_existing_data_config_over_env_vars( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + compose_config = tmp_path / "app-config.toml" + data_config = tmp_path / "data-config.toml" + addon_options = tmp_path / "options.json" + data_config.write_text("existing = true\n", encoding="utf-8") + + monkeypatch.setenv("ROBOROCK_SERVER_STACK_FQDN", "api-roborock.example.com") + calls: list[Path] = [] + monkeypatch.setattr( + container_entrypoint, + "_exec_server", + lambda config_path: calls.append(config_path), + ) + monkeypatch.setattr( + container_entrypoint, + "write_config_from_env", + lambda *args, **kwargs: pytest.fail("should not regenerate an existing config.toml"), + ) + + container_entrypoint._run_entrypoint( + compose_config=compose_config, + data_config=data_config, + addon_options=addon_options, + ) + + assert calls == [data_config] + + +def test_run_entrypoint_generates_config_from_env_vars( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + compose_config = tmp_path / "app-config.toml" + data_config = tmp_path / "data-config.toml" + addon_options = tmp_path / "options.json" + + monkeypatch.setenv("ROBOROCK_SERVER_STACK_FQDN", "api-roborock.example.com") + calls: list[tuple[str, Path]] = [] + monkeypatch.setattr( + container_entrypoint, + "write_config_from_env", + lambda env, *, config_path: calls.append(("write", config_path)), + ) + monkeypatch.setattr( + container_entrypoint, + "_exec_server", + lambda config_path: calls.append(("exec", config_path)), + ) + + container_entrypoint._run_entrypoint( + compose_config=compose_config, + data_config=data_config, + addon_options=addon_options, + ) + + assert calls == [("write", data_config), ("exec", data_config)] + + +def test_run_entrypoint_errors_without_config_or_env_vars( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + compose_config = tmp_path / "app-config.toml" + data_config = tmp_path / "data-config.toml" + addon_options = tmp_path / "options.json" + monkeypatch.delenv("ROBOROCK_SERVER_STACK_FQDN", raising=False) + + with pytest.raises(SystemExit): + container_entrypoint._run_entrypoint( + compose_config=compose_config, + data_config=data_config, + addon_options=addon_options, + ) From 0d5c5e8a7376732e99064b07eb9c21ff61105ccc Mon Sep 17 00:00:00 2001 From: Colin Haven <10933733+virtitnerd@users.noreply.github.com> Date: Sun, 27 Sep 2026 10:27:25 -0400 Subject: [PATCH 03/28] Document the env-var-only Docker Compose bootstrap path Explains the required/optional ROBOROCK_SERVER_* variables and the config.toml bind-mount gotcha (Docker creates an empty file for a nonexistent host path, which the entrypoint then treats as an existing but invalid config instead of falling through to env vars). Co-Authored-By: Claude Sonnet 5 (cherry picked from commit 94262cc6ed729d5a0d8ebba48cf4418dfef5bac3) --- compose.yaml | 3 +++ docs/installation.md | 28 ++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/compose.yaml b/compose.yaml index f83b329..6e367ea 100644 --- a/compose.yaml +++ b/compose.yaml @@ -9,6 +9,9 @@ services: - "${ROBOROCK_SERVER_HTTPS_PORT:-555}:${ROBOROCK_SERVER_HTTPS_PORT:-555}" - "${ROBOROCK_SERVER_MQTT_TLS_PORT:-8881}:${ROBOROCK_SERVER_MQTT_TLS_PORT:-8881}" volumes: + # Remove this line if you're generating config.toml from ROBOROCK_SERVER_* + # env vars instead of running `configure` first — see the "Environment + # Variables Only" section in docs/installation.md. - ./config.toml:/app/config.toml:ro - ./data:/data - ./secrets:/run/secrets:ro diff --git a/docs/installation.md b/docs/installation.md index b933197..77e7a57 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -139,6 +139,34 @@ If your model already has certificate notes on the tested-vacuums page, follow t For reverse proxy setups, keep `network.https_port` and `network.mqtt_tls_port` set to the backend listener ports and use `network.advertised_https_port` / `network.advertised_mqtt_tls_port` for the public ports. +### Alternative: Environment Variables Only (No `uv` Toolchain) + +If you don't want to install `uv`/Python on the host just to run `configure`, you can set `ROBOROCK_SERVER_*` environment variables instead. On first boot, if `/data/config.toml` doesn't already exist, the container generates one from these variables and starts normally. If `config.toml` already exists (mounted or previously generated), it always wins and the env vars are ignored — this never overwrites a config you've already set up. + +Required variables: + +- `ROBOROCK_SERVER_STACK_FQDN` +- `ROBOROCK_SERVER_ADMIN_PASSWORD` +- `ROBOROCK_SERVER_PROTOCOL_LOGIN_EMAIL` +- `ROBOROCK_SERVER_PROTOCOL_LOGIN_PIN` (6 digits) +- Either `ROBOROCK_SERVER_CERT_FILE` + `ROBOROCK_SERVER_KEY_FILE` (bring your own certificate), or `ROBOROCK_SERVER_TLS_MODE=cloudflare_acme` + `ROBOROCK_SERVER_TLS_BASE_DOMAIN` + `ROBOROCK_SERVER_TLS_EMAIL` + `ROBOROCK_SERVER_CLOUDFLARE_TOKEN` (or `_CLOUDFLARE_TOKEN_FILE` for a Docker secret) + +Commonly-set optional variables: `ROBOROCK_SERVER_HTTPS_PORT`, `ROBOROCK_SERVER_MQTT_TLS_PORT`, `ROBOROCK_SERVER_ADVERTISED_HTTPS_PORT`, `ROBOROCK_SERVER_ADVERTISED_MQTT_TLS_PORT`, `ROBOROCK_SERVER_LISTENER_MODE`, `ROBOROCK_SERVER_BROKER_MODE` + `ROBOROCK_SERVER_BROKER_HOST`. See `env_config.py` for the full list. + +**Important:** if you go this route, remove (or comment out) the `./config.toml:/app/config.toml:ro` line from `compose.yaml`. Docker will otherwise bind-mount a nonexistent host path as an empty file, which the container treats as an existing (but invalid) config and never falls through to the env vars. Keep the `./data:/data` and `./secrets:/run/secrets:ro` mounts. + +```bash +export ROBOROCK_SERVER_STACK_FQDN=api-roborock.example.com +export ROBOROCK_SERVER_ADMIN_PASSWORD=super-secret-password +export ROBOROCK_SERVER_PROTOCOL_LOGIN_EMAIL=user@example.com +export ROBOROCK_SERVER_PROTOCOL_LOGIN_PIN=123456 +export ROBOROCK_SERVER_TLS_MODE=cloudflare_acme +export ROBOROCK_SERVER_TLS_BASE_DOMAIN=example.com +export ROBOROCK_SERVER_TLS_EMAIL=acme@example.com +export ROBOROCK_SERVER_CLOUDFLARE_TOKEN=your-cloudflare-api-token +docker compose up -d --build +``` + ## Method 2: Home Assistant Add-on Use [Home Assistant](home_assistant.md) as the installation guide if you want to run the stack as a Home Assistant add-on instead of Docker Compose. From f2544bc98f7d42314884bac49b5752de486ba5bf Mon Sep 17 00:00:00 2001 From: Colin Haven <10933733+virtitnerd@users.noreply.github.com> Date: Sun, 27 Sep 2026 10:37:07 -0400 Subject: [PATCH 04/28] Let configure answers carry an external broker host The CLI wizard has always left broker.host blank for external brokers, requiring a manual config.toml edit afterward. Add an optional ConfigureAnswers.broker_host so callers that already have the host (the upcoming web setup wizard) can write a ready-to-serve config in one step; the CLI wizard's own behavior is unchanged since it still passes broker_host="". Co-Authored-By: Claude Sonnet 5 (cherry picked from commit 017e9e0a0d38a74c74cf973ad5817137a43caa63) --- src/roborock_local_server/configure.py | 13 ++++++++++--- tests/test_configure.py | 19 +++++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/roborock_local_server/configure.py b/src/roborock_local_server/configure.py index d830acf..7cf4652 100644 --- a/src/roborock_local_server/configure.py +++ b/src/roborock_local_server/configure.py @@ -46,6 +46,7 @@ class ConfigureAnswers: https_port: int mqtt_tls_port: int broker_mode: str + broker_host: str tls_mode: str base_domain: str email: str @@ -227,6 +228,7 @@ def _validated_answers(answers: ConfigureAnswers) -> ConfigureAnswers: https_port=answers.https_port, mqtt_tls_port=answers.mqtt_tls_port, broker_mode=answers.broker_mode, + broker_host=answers.broker_host, tls_mode=answers.tls_mode, base_domain=answers.base_domain, email=answers.email, @@ -282,6 +284,7 @@ def collect_configure_answers() -> ConfigureAnswers: https_port=https_port, mqtt_tls_port=mqtt_tls_port, broker_mode=broker_mode, + broker_host="", tls_mode=tls_mode, base_domain=base_domain, email=email, @@ -322,8 +325,12 @@ def render_config_toml(answers: ConfigureAnswers) -> str: else: lines.extend( [ - "# Fill this in with your existing MQTT broker hostname or IP before starting the stack.", - 'host = ""', + *( + ["# Fill this in with your existing MQTT broker hostname or IP before starting the stack."] + if not answers.broker_host + else [] + ), + f"host = {_toml_string(answers.broker_host)}", "port = 1883", 'mosquitto_binary = "mosquitto"', "enable_topic_bridge = true", @@ -448,7 +455,7 @@ def write_config_setup( cloudflare_token_file=written_token_path, actalis_eab_kid_file=written_actalis_kid_path, actalis_eab_hmac_key_file=written_actalis_hmac_path, - broker_template_needs_edit=answers.broker_mode == "external", + broker_template_needs_edit=answers.broker_mode == "external" and not answers.broker_host, ) diff --git a/tests/test_configure.py b/tests/test_configure.py index 31a9d3d..85bc1b0 100644 --- a/tests/test_configure.py +++ b/tests/test_configure.py @@ -12,6 +12,7 @@ def _answers( https_port: int = 555, mqtt_tls_port: int = 8881, broker_mode: str = "embedded", + broker_host: str = "", tls_mode: str = "cloudflare_acme", acme_server: str = "zerossl", acme_eab_kid: str = "", @@ -22,6 +23,7 @@ def _answers( https_port=https_port, mqtt_tls_port=mqtt_tls_port, broker_mode=broker_mode, + broker_host=broker_host, tls_mode=tls_mode, base_domain="example.com" if tls_mode == "cloudflare_acme" else "", email="you@example.com" if tls_mode == "cloudflare_acme" else "", @@ -85,6 +87,23 @@ def test_write_config_setup_external_broker_requires_host_before_serve(tmp_path: load_config(config_file) +def test_write_config_setup_external_broker_with_host_needs_no_manual_edit(tmp_path: Path) -> None: + config_file = tmp_path / "config.toml" + + result = write_config_setup( + config_file=config_file, + answers=_answers(broker_mode="external", broker_host="mqtt.internal", tls_mode="provided"), + ) + + assert not result.broker_template_needs_edit + rendered = config_file.read_text(encoding="utf-8") + assert 'host = "mqtt.internal"' in rendered + assert "# Fill this in" not in rendered + + config = load_config(config_file) + assert config.broker.host == "mqtt.internal" + + def test_write_config_setup_refuses_overwrite_without_force(tmp_path: Path) -> None: config_file = tmp_path / "config.toml" write_config_setup(config_file=config_file, answers=_answers()) From 9fcaf318e456807e474f9036b2d1a564287d06e7 Mon Sep 17 00:00:00 2001 From: Colin Haven <10933733+virtitnerd@users.noreply.github.com> Date: Sun, 27 Sep 2026 10:50:57 -0400 Subject: [PATCH 05/28] Add the bootstrap /admin setup wizard New always-reachable /admin: when config.toml doesn't exist yet, it serves a browser-based setup wizard (the same fields as `configure`, reusing write_config_setup so both paths produce identical output) over plain HTTP on the same https_port the real stack uses once configured. Once ReleaseSupervisor's own TLS'd /admin takes over that port after a restart, this is out of the picture entirely - there is never a second, separate admin surface running at the same time. Co-Authored-By: Claude Sonnet 5 (cherry picked from commit 3db3e9625ed0aa9bc4b6393b53d33e15b6067c29) --- src/roborock_local_server/management_app.py | 326 ++++++++++++++++++++ tests/test_management_app.py | 149 +++++++++ 2 files changed, 475 insertions(+) create mode 100644 src/roborock_local_server/management_app.py create mode 100644 tests/test_management_app.py diff --git a/src/roborock_local_server/management_app.py b/src/roborock_local_server/management_app.py new file mode 100644 index 0000000..5177832 --- /dev/null +++ b/src/roborock_local_server/management_app.py @@ -0,0 +1,326 @@ +"""Bootstrap /admin app, served before config.toml exists. + +There's only one admin surface, at /admin. Before setup, this app serves the +Setup Wizard in plain HTTP on the same https_port the real stack will use +once configured (it can't depend on anything that requires a config - TLS +certs, ReleaseSupervisor, admin auth). Once the wizard writes config.toml, +the process restarts and ReleaseSupervisor's own /admin (standalone_admin.py, +with TLS if local_tls) takes over that same port for good. +""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping +import json +from pathlib import Path +import secrets +from textwrap import dedent +from typing import Any + +from fastapi import FastAPI, Request +from fastapi.responses import HTMLResponse, JSONResponse + +from .configure import ( + ConfigureAnswers, + _normalize_acme_server, + _normalize_hostname, + _validate_protocol_login_pin, + hash_password, + write_config_setup, +) + +DEFAULT_BOOTSTRAP_HTTPS_PORT = 555 +_ENV_HTTPS_PORT = "ROBOROCK_SERVER_HTTPS_PORT" + + +def bootstrap_port_from_env(env: Mapping[str, str]) -> int: + """The port the setup wizard listens on, before config.toml sets https_port. + + Reuses ROBOROCK_SERVER_HTTPS_PORT, the same env var already used for the + Docker Compose port mapping, so there's one port/one env var either way. + """ + raw_value = str(env.get(_ENV_HTTPS_PORT, "") or "").strip() + if not raw_value: + return DEFAULT_BOOTSTRAP_HTTPS_PORT + try: + port = int(raw_value) + except ValueError as exc: + raise ValueError(f"{_ENV_HTTPS_PORT} must be an integer") from exc + if not (1 <= port <= 65535): + raise ValueError(f"{_ENV_HTTPS_PORT} must be between 1 and 65535") + return port + + +def _as_port(value: Any, *, field_name: str, default: int) -> int: + if value in (None, ""): + return default + try: + candidate = int(value) + except (TypeError, ValueError) as exc: + raise ValueError(f"{field_name} must be an integer") from exc + if not (1 <= candidate <= 65535): + raise ValueError(f"{field_name} must be between 1 and 65535") + return candidate + + +def _answers_from_payload(body: Mapping[str, Any]) -> ConfigureAnswers: + def s(key: str, default: str = "") -> str: + return str(body.get(key, default) or "").strip() + + stack_fqdn = _normalize_hostname(s("stack_fqdn"), field_name="stack_fqdn", require_api_prefix=True) + https_port = _as_port(body.get("https_port"), field_name="https_port", default=555) + mqtt_tls_port = _as_port(body.get("mqtt_tls_port"), field_name="mqtt_tls_port", default=8881) + + broker_mode = s("broker_mode", "embedded").lower() + if broker_mode not in {"embedded", "external"}: + raise ValueError("broker_mode must be 'embedded' or 'external'") + broker_host = s("broker_host") + if broker_mode == "external" and not broker_host: + raise ValueError("broker_host is required when broker_mode is 'external'") + + tls_mode = s("tls_mode", "cloudflare_acme").lower() + if tls_mode not in {"cloudflare_acme", "provided"}: + raise ValueError("tls_mode must be 'cloudflare_acme' or 'provided'") + + base_domain = "" + email = "" + acme_server = "zerossl" + acme_eab_kid = "" + acme_eab_hmac_key = "" + cloudflare_token = "" + if tls_mode == "cloudflare_acme": + base_domain = _normalize_hostname(s("base_domain"), field_name="base_domain") + email = s("email") + if not email: + raise ValueError("email is required") + acme_server = _normalize_acme_server(s("acme_server", "zerossl")) + cloudflare_token = s("cloudflare_token") + if not cloudflare_token: + raise ValueError("cloudflare_token is required") + if acme_server == "actalis": + acme_eab_kid = s("acme_eab_kid") + acme_eab_hmac_key = s("acme_eab_hmac_key") + if not acme_eab_kid or not acme_eab_hmac_key: + raise ValueError("Actalis requires both acme_eab_kid and acme_eab_hmac_key") + + admin_password = s("admin_password") + if not admin_password: + raise ValueError("admin_password is required") + protocol_login_email = s("protocol_login_email") + if "@" not in protocol_login_email: + raise ValueError("protocol_login_email must be an email address") + protocol_login_pin = _validate_protocol_login_pin(s("protocol_login_pin")) + + return ConfigureAnswers( + stack_fqdn=stack_fqdn, + https_port=https_port, + mqtt_tls_port=mqtt_tls_port, + broker_mode=broker_mode, + broker_host=broker_host, + tls_mode=tls_mode, + base_domain=base_domain, + email=email, + acme_server=acme_server, + acme_eab_kid=acme_eab_kid, + acme_eab_hmac_key=acme_eab_hmac_key, + cloudflare_token=cloudflare_token, + password_hash=hash_password(admin_password), + session_secret=secrets.token_urlsafe(32), + protocol_login_email=protocol_login_email, + protocol_login_pin_hash=hash_password(protocol_login_pin), + ) + + +def _status_html() -> str: + return dedent( + """\ + +

Roborock Local Server

+

Setup is complete. The stack is restarting into the full dashboard at this same address - + reload in a few seconds.

+ + """ + ) + + +def _setup_wizard_html() -> str: + return dedent( + """\ + + Roborock Local Server Setup + + +

Roborock Local Server Setup

+

This runs once, the first time the stack boots without a config.toml. Fill this in, submit, and the + container will restart into the full HTTPS/MQTT stack.

+
+
+ Network + +
+
+
+
+
+ +
+ MQTT Broker + + + +
+ +
+ Certificates + + +
+ + + + + +
+ +
+ +
+ Admin Access + + +
+ +
+ App / Home Assistant Login + + + +
+ + +
+

+        
+        
+        
+        """
+    )
+
+
+def create_management_app(
+    *,
+    config_file: Path,
+    on_configured: Callable[[], None] | None = None,
+) -> FastAPI:
+    app = FastAPI(title="Roborock Local Server Setup", docs_url=None, redoc_url=None, openapi_url=None)
+
+    @app.get("/", response_class=HTMLResponse)
+    @app.get("/admin", response_class=HTMLResponse)
+    async def index() -> HTMLResponse:
+        return HTMLResponse(_status_html() if config_file.exists() else _setup_wizard_html())
+
+    @app.get("/admin/api/setup/status")
+    async def setup_status() -> JSONResponse:
+        return JSONResponse({"configured": config_file.exists()})
+
+    @app.post("/admin/api/setup")
+    async def submit_setup(request: Request) -> JSONResponse:
+        if config_file.exists():
+            return JSONResponse({"error": "Already configured."}, status_code=409)
+        try:
+            raw_body = await request.body()
+            body = json.loads(raw_body or b"{}")
+            if not isinstance(body, dict):
+                raise ValueError("Request body must be a JSON object.")
+            answers = _answers_from_payload(body)
+            result = write_config_setup(config_file=config_file, answers=answers)
+        except (ValueError, FileExistsError) as exc:
+            return JSONResponse({"error": str(exc)}, status_code=400)
+        if on_configured is not None:
+            on_configured()
+        return JSONResponse({"ok": True, "config_file": str(result.config_file)})
+
+    return app
diff --git a/tests/test_management_app.py b/tests/test_management_app.py
new file mode 100644
index 0000000..d805a07
--- /dev/null
+++ b/tests/test_management_app.py
@@ -0,0 +1,149 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+from fastapi.testclient import TestClient
+import pytest
+
+from roborock_local_server.config import load_config
+from roborock_local_server.management_app import (
+    DEFAULT_BOOTSTRAP_HTTPS_PORT,
+    bootstrap_port_from_env,
+    create_management_app,
+)
+
+_VALID_PAYLOAD = {
+    "stack_fqdn": "api-roborock.example.com",
+    "https_port": 555,
+    "mqtt_tls_port": 8881,
+    "broker_mode": "embedded",
+    "tls_mode": "provided",
+    "admin_password": "super-secret-password",
+    "protocol_login_email": "user@example.com",
+    "protocol_login_pin": "123456",
+}
+
+
+def test_bootstrap_port_from_env_default() -> None:
+    assert bootstrap_port_from_env({}) == DEFAULT_BOOTSTRAP_HTTPS_PORT
+
+
+def test_bootstrap_port_from_env_reuses_https_port_var() -> None:
+    assert bootstrap_port_from_env({"ROBOROCK_SERVER_HTTPS_PORT": "9090"}) == 9090
+
+
+def test_bootstrap_port_from_env_rejects_bad_value() -> None:
+    with pytest.raises(ValueError, match="must be an integer"):
+        bootstrap_port_from_env({"ROBOROCK_SERVER_HTTPS_PORT": "not-a-port"})
+
+
+def test_index_serves_setup_wizard_when_unconfigured(tmp_path: Path) -> None:
+    config_file = tmp_path / "config.toml"
+    client = TestClient(create_management_app(config_file=config_file))
+
+    response = client.get("/")
+
+    assert response.status_code == 200
+    assert "Roborock Local Server Setup" in response.text
+
+
+def test_index_serves_status_page_when_configured(tmp_path: Path) -> None:
+    config_file = tmp_path / "config.toml"
+    config_file.write_text("already = true\n", encoding="utf-8")
+    client = TestClient(create_management_app(config_file=config_file))
+
+    response = client.get("/")
+
+    assert response.status_code == 200
+    assert "Setup is complete" in response.text
+
+
+def test_setup_status_reflects_config_existence(tmp_path: Path) -> None:
+    config_file = tmp_path / "config.toml"
+    client = TestClient(create_management_app(config_file=config_file))
+
+    assert client.get("/admin/api/setup/status").json() == {"configured": False}
+
+    config_file.write_text("done = true\n", encoding="utf-8")
+    assert client.get("/admin/api/setup/status").json() == {"configured": True}
+
+
+def test_submit_setup_writes_bootable_config(tmp_path: Path) -> None:
+    config_file = tmp_path / "config.toml"
+    calls: list[str] = []
+    client = TestClient(create_management_app(config_file=config_file, on_configured=lambda: calls.append("done")))
+
+    response = client.post("/admin/api/setup", json=_VALID_PAYLOAD)
+
+    assert response.status_code == 200
+    assert response.json()["ok"] is True
+    assert calls == ["done"]
+
+    config = load_config(config_file)
+    assert config.network.stack_fqdn == "api-roborock.example.com"
+    assert config.admin.protocol_login_email == "user@example.com"
+
+
+def test_submit_setup_with_external_broker_needs_no_manual_edit(tmp_path: Path) -> None:
+    config_file = tmp_path / "config.toml"
+    client = TestClient(create_management_app(config_file=config_file))
+    payload = dict(_VALID_PAYLOAD, broker_mode="external", broker_host="mqtt.internal")
+
+    response = client.post("/admin/api/setup", json=payload)
+
+    assert response.status_code == 200
+    config = load_config(config_file)
+    assert config.broker.mode == "external"
+    assert config.broker.host == "mqtt.internal"
+
+
+def test_submit_setup_rejects_missing_external_broker_host(tmp_path: Path) -> None:
+    config_file = tmp_path / "config.toml"
+    client = TestClient(create_management_app(config_file=config_file))
+    payload = dict(_VALID_PAYLOAD, broker_mode="external")
+
+    response = client.post("/admin/api/setup", json=payload)
+
+    assert response.status_code == 400
+    assert "broker_host" in response.json()["error"]
+    assert config_file.exists() is False
+
+
+def test_submit_setup_rejects_bad_stack_fqdn(tmp_path: Path) -> None:
+    config_file = tmp_path / "config.toml"
+    client = TestClient(create_management_app(config_file=config_file))
+    payload = dict(_VALID_PAYLOAD, stack_fqdn="roborock.example.com")
+
+    response = client.post("/admin/api/setup", json=payload)
+
+    assert response.status_code == 400
+    assert "must start with api-" in response.json()["error"]
+
+
+def test_submit_setup_refuses_to_overwrite_existing_config(tmp_path: Path) -> None:
+    config_file = tmp_path / "config.toml"
+    config_file.write_text("existing = true\n", encoding="utf-8")
+    client = TestClient(create_management_app(config_file=config_file))
+
+    response = client.post("/admin/api/setup", json=_VALID_PAYLOAD)
+
+    assert response.status_code == 409
+    assert config_file.read_text(encoding="utf-8") == "existing = true\n"
+
+
+def test_submit_setup_cloudflare_actalis_requires_eab(tmp_path: Path) -> None:
+    config_file = tmp_path / "config.toml"
+    client = TestClient(create_management_app(config_file=config_file))
+    payload = dict(
+        _VALID_PAYLOAD,
+        tls_mode="cloudflare_acme",
+        base_domain="example.com",
+        email="acme@example.com",
+        cloudflare_token="cf-token",
+        acme_server="actalis",
+    )
+
+    response = client.post("/admin/api/setup", json=payload)
+
+    assert response.status_code == 400
+    assert "Actalis" in response.json()["error"]

From 7769809375416e202f9167a7eeefa04d7976e8df Mon Sep 17 00:00:00 2001
From: Colin Haven <10933733+virtitnerd@users.noreply.github.com>
Date: Sun, 27 Sep 2026 10:51:03 -0400
Subject: [PATCH 06/28] Fall into the setup wizard when config.toml is missing
 or invalid

run_server() now catches load_config() failures (missing file, bad
TOML, failed validation) and serves only the bootstrap /admin wizard
instead of crashing, exiting once the wizard writes a config so
Docker's restart policy brings up the full stack. The container
entrypoint no longer errors out when no config source is found at all
- it always execs into serve, which now handles that case gracefully.

Co-Authored-By: Claude Sonnet 5 
(cherry picked from commit 3146e373ca34582853a5d3fa16a054e0ea5bb2d7)
---
 .../container_entrypoint.py                   | 18 ++----
 src/roborock_local_server/server.py           | 55 ++++++++++++++++-
 tests/test_container_entrypoint.py            | 60 ++-----------------
 3 files changed, 64 insertions(+), 69 deletions(-)

diff --git a/src/roborock_local_server/container_entrypoint.py b/src/roborock_local_server/container_entrypoint.py
index ba8a9a7..518bbaf 100644
--- a/src/roborock_local_server/container_entrypoint.py
+++ b/src/roborock_local_server/container_entrypoint.py
@@ -29,19 +29,11 @@ def _run_entrypoint(*, compose_config: Path, data_config: Path, addon_options: P
         _exec_server(data_config)
         return
 
-    if data_config.exists():
-        _exec_server(data_config)
-        return
-
-    if has_env_config(os.environ):
-        write_config_from_env(os.environ, config_path=data_config)
-        _exec_server(data_config)
-        return
-
-    raise SystemExit(
-        "No config file found. Expected /app/config.toml, /data/config.toml, /data/options.json, "
-        "or ROBOROCK_SERVER_* environment variables (see docs/installation.md)."
-    )
+    # No config anywhere yet. `serve` itself now handles this: it serves the
+    # setup wizard on the management port until /data/config.toml exists,
+    # then a container restart (Docker's restart policy) picks up the full
+    # stack once the wizard writes it.
+    _exec_server(data_config)
 
 
 def main() -> int:
diff --git a/src/roborock_local_server/server.py b/src/roborock_local_server/server.py
index 28a5765..0d5fc41 100644
--- a/src/roborock_local_server/server.py
+++ b/src/roborock_local_server/server.py
@@ -9,6 +9,7 @@
 import hashlib
 import json
 import logging
+import os
 from pathlib import Path
 import secrets
 import signal
@@ -30,6 +31,7 @@
 from .bundled_backend.shared.runtime_state import ONBOARDING_STEP_LABELS, REQUIRED_ONBOARDING_STEPS
 from .cloud import CloudImportManager
 from .config import AppConfig, AppPaths, load_config, resolve_paths
+from .management_app import bootstrap_port_from_env, create_management_app
 from .turn_relay import EmbeddedTurnRelay
 from .standalone_admin import register_standalone_admin_routes
 from .backend import (
@@ -2036,8 +2038,59 @@ def request_shutdown() -> None:
         return 0
 
 
+async def _run_setup_wizard_only(*, config_file: Path) -> int:
+    """Serve only /admin's setup wizard, in plain HTTP, until config.toml exists.
+
+    Listens on the same https_port the real stack will use once configured
+    (ROBOROCK_SERVER_HTTPS_PORT, same env var as the compose port mapping).
+    Exits (0) once the wizard writes a config, or on SIGINT/SIGTERM. Either
+    way, Docker's restart policy is what brings the full stack - and its own
+    TLS'd /admin - up on the next boot, once load_config() succeeds.
+    """
+    logger = logging.getLogger(__name__)
+    port = bootstrap_port_from_env(os.environ)
+    stop_event = asyncio.Event()
+    configured_event = asyncio.Event()
+
+    app = create_management_app(config_file=config_file, on_configured=configured_event.set)
+    bootstrap_server = ManagedFastApiServer(
+        app=app,
+        bind_host="0.0.0.0",
+        port=port,
+        tls_enabled=False,
+    )
+    await bootstrap_server.start()
+    logger.warning(
+        "%s not found. Open http://:%d/admin to finish setup.",
+        config_file,
+        port,
+    )
+
+    def request_shutdown() -> None:
+        stop_event.set()
+
+    loop = asyncio.get_running_loop()
+    for sig in (signal.SIGINT, signal.SIGTERM):
+        try:
+            loop.add_signal_handler(sig, request_shutdown)
+        except NotImplementedError:
+            pass
+
+    await asyncio.wait(
+        {asyncio.create_task(stop_event.wait()), asyncio.create_task(configured_event.wait())},
+        return_when=asyncio.FIRST_COMPLETED,
+    )
+    await bootstrap_server.stop()
+    return 0
+
+
 async def run_server(*, config_file: Path, enable_standalone_admin: bool = True) -> int:
-    config = load_config(config_file)
+    try:
+        config = load_config(config_file)
+    except (OSError, ValueError) as exc:
+        logging.getLogger(__name__).warning("Could not load %s: %s", config_file, exc)
+        return await _run_setup_wizard_only(config_file=config_file)
+
     paths = resolve_paths(config_file, config)
     supervisor = ReleaseSupervisor(
         config=config,
diff --git a/tests/test_container_entrypoint.py b/tests/test_container_entrypoint.py
index 424c64f..efd8262 100644
--- a/tests/test_container_entrypoint.py
+++ b/tests/test_container_entrypoint.py
@@ -67,27 +67,24 @@ def test_run_entrypoint_prefers_compose_config_when_present(
     assert calls == [compose_config]
 
 
-def test_run_entrypoint_prefers_existing_data_config_over_env_vars(
+def test_run_entrypoint_execs_server_even_without_any_config_present(
     tmp_path: Path,
     monkeypatch: pytest.MonkeyPatch,
 ) -> None:
+    # No config source at all, and no ROBOROCK_SERVER_* env vars either: the
+    # entrypoint no longer errors out here, since `serve` now falls into the
+    # Setup Wizard for exactly this case.
     compose_config = tmp_path / "app-config.toml"
     data_config = tmp_path / "data-config.toml"
     addon_options = tmp_path / "options.json"
-    data_config.write_text("existing = true\n", encoding="utf-8")
+    monkeypatch.delenv("ROBOROCK_SERVER_STACK_FQDN", raising=False)
 
-    monkeypatch.setenv("ROBOROCK_SERVER_STACK_FQDN", "api-roborock.example.com")
     calls: list[Path] = []
     monkeypatch.setattr(
         container_entrypoint,
         "_exec_server",
         lambda config_path: calls.append(config_path),
     )
-    monkeypatch.setattr(
-        container_entrypoint,
-        "write_config_from_env",
-        lambda *args, **kwargs: pytest.fail("should not regenerate an existing config.toml"),
-    )
 
     container_entrypoint._run_entrypoint(
         compose_config=compose_config,
@@ -96,50 +93,3 @@ def test_run_entrypoint_prefers_existing_data_config_over_env_vars(
     )
 
     assert calls == [data_config]
-
-
-def test_run_entrypoint_generates_config_from_env_vars(
-    tmp_path: Path,
-    monkeypatch: pytest.MonkeyPatch,
-) -> None:
-    compose_config = tmp_path / "app-config.toml"
-    data_config = tmp_path / "data-config.toml"
-    addon_options = tmp_path / "options.json"
-
-    monkeypatch.setenv("ROBOROCK_SERVER_STACK_FQDN", "api-roborock.example.com")
-    calls: list[tuple[str, Path]] = []
-    monkeypatch.setattr(
-        container_entrypoint,
-        "write_config_from_env",
-        lambda env, *, config_path: calls.append(("write", config_path)),
-    )
-    monkeypatch.setattr(
-        container_entrypoint,
-        "_exec_server",
-        lambda config_path: calls.append(("exec", config_path)),
-    )
-
-    container_entrypoint._run_entrypoint(
-        compose_config=compose_config,
-        data_config=data_config,
-        addon_options=addon_options,
-    )
-
-    assert calls == [("write", data_config), ("exec", data_config)]
-
-
-def test_run_entrypoint_errors_without_config_or_env_vars(
-    tmp_path: Path,
-    monkeypatch: pytest.MonkeyPatch,
-) -> None:
-    compose_config = tmp_path / "app-config.toml"
-    data_config = tmp_path / "data-config.toml"
-    addon_options = tmp_path / "options.json"
-    monkeypatch.delenv("ROBOROCK_SERVER_STACK_FQDN", raising=False)
-
-    with pytest.raises(SystemExit):
-        container_entrypoint._run_entrypoint(
-            compose_config=compose_config,
-            data_config=data_config,
-            addon_options=addon_options,
-        )

From 36dc9dd6abb2a5d3b1ae261518c7be6303cb4336 Mon Sep 17 00:00:00 2001
From: Colin Haven <10933733+virtitnerd@users.noreply.github.com>
Date: Sun, 27 Sep 2026 10:51:08 -0400
Subject: [PATCH 07/28] Document the browser-based setup wizard as an
 alternative to the CLI

Co-Authored-By: Claude Sonnet 5 
(cherry picked from commit e0e3dd0ec75d5c6e2145779a4056398196149a3a)
---
 docs/installation.md | 19 ++++++++++++++-----
 1 file changed, 14 insertions(+), 5 deletions(-)

diff --git a/docs/installation.md b/docs/installation.md
index 77e7a57..499b64a 100644
--- a/docs/installation.md
+++ b/docs/installation.md
@@ -92,11 +92,20 @@ If your model already has certificate notes on the tested-vacuums page, follow t
    uv sync
    ```
 
-3. Run the setup wizard:
+3. Run the setup wizard.
 
-   ```bash
-   uv run roborock-local-server configure
-   ```
+   You have two options here, and they write the same `config.toml`:
+
+   - **In your browser (no `uv`/Python needed on the host):** skip straight to step 6 and start the container
+     with no `config.toml` present. It boots into a setup wizard, served in plain HTTP at `/admin` on your
+     configured HTTPS port (`http://:555/admin` by default), that asks for the same fields as the
+     CLI wizard below. Submitting it writes `config.toml`, and the container restarts itself into the full
+     HTTPS/MQTT stack - reload the page after a few seconds and you're at the real (now HTTPS) admin login.
+   - **On the command line:**
+
+     ```bash
+     uv run roborock-local-server configure
+     ```
 
    The wizard asks for:
 
@@ -111,7 +120,7 @@ If your model already has certificate notes on the tested-vacuums page, follow t
 
    It then writes `config.toml`, generates `admin.password_hash` and `admin.session_secret`, and if you chose Cloudflare it also writes `secrets/cloudflare_token`. If you also chose `acme_server = actalis`, it writes `secrets/acme_eab_kid` and `secrets/acme_eab_hmac_key`.
 
-4. If you chose external MQTT, fill in `broker.host` in `config.toml` before starting the stack. See [Custom MQTT](custom_mqtt.md).
+4. If you chose external MQTT with the CLI wizard, fill in `broker.host` in `config.toml` before starting the stack (the browser wizard asks for the broker host directly, so this step doesn't apply there). See [Custom MQTT](custom_mqtt.md).
 
 5. If you skipped Cloudflare, put your certificate files in `data/certs/fullchain.pem` and `data/certs/privkey.pem` (relative to the repository root on the host, which maps to `/data/certs/` inside the container). This is the path to use when your vacuum works better with a certificate chain you manage yourself. See [Custom certificate management](custom_cert_management.md).
 

From f3b09b0eddb68908cc295ed810cc7b2a3c48986a Mon Sep 17 00:00:00 2001
From: Colin Haven <10933733+virtitnerd@users.noreply.github.com>
Date: Sun, 27 Sep 2026 11:00:15 -0400
Subject: [PATCH 08/28] Point reverse-proxy users at the env-var config path

Neither the CLI configure wizard nor the browser Setup Wizard can
generate listener_mode, advertised_*_port, or trusted_proxies - they
only produce the simple local_tls case. Those are exactly the
deployment-topology settings the ROBOROCK_SERVER_* env vars already
cover (external_tls, advertised ports, trusted proxies), so a
SWAG/Caddy/Traefik reverse-proxy setup needs no manual config.toml
edits: just set the equivalent env vars instead of running either
wizard.

Co-Authored-By: Claude Sonnet 5 
(cherry picked from commit e7cb4201072e18da79dcd768c3d43a94d09c4e4a)
---
 docs/reverse_proxy.md | 10 +++++++++-
 1 file changed, 9 insertions(+), 1 deletion(-)

diff --git a/docs/reverse_proxy.md b/docs/reverse_proxy.md
index 4f42c6b..19abe17 100644
--- a/docs/reverse_proxy.md
+++ b/docs/reverse_proxy.md
@@ -1,6 +1,8 @@
 # Reverse Proxy
 
-Reverse proxy support is mainly useful when public or LAN clients reach the stack on different ports than the backend listeners, or when you already run a proxy (Caddy, Traefik, nginx) that owns your TLS certificates. I do not use a reverse proxy for my own setup, so please report any issues.
+Reverse proxy support is mainly useful when public or LAN clients reach the stack on different ports than the backend listeners, or when you already run a proxy (Caddy, Traefik, nginx, SWAG) that owns your TLS certificates. I do not use a reverse proxy for my own setup, so please report any issues.
+
+> **Neither the `configure` CLI wizard nor the browser Setup Wizard (`/admin` on first boot) can generate `listener_mode`, `advertised_*_port`, or `trusted_proxies` today** — they only produce the simple `local_tls` case. If your setup needs any of the settings on this page (which it will, for a proxy like SWAG that terminates TLS itself), generate `config.toml` from `ROBOROCK_SERVER_*` environment variables instead (see [Installation](installation.md)) - that path covers everything below directly, with no manual `config.toml` edits needed. The TOML shown on this page is what those env vars produce; it's included so you can see (or hand-edit) the result either way.
 
 Whatever endpoint a vacuum or the Roborock app connects to **must present a valid, trusted TLS certificate** — vacuums refuse to connect otherwise. That endpoint can be the server itself or the proxy in front of it; the rest of this page is about choosing which one terminates TLS.
 
@@ -29,6 +31,8 @@ With that config the server listens on `*:555` / `*:8881`, but responses adverti
 - `https://api-roborock.example.com`
 - `ssl://api-roborock.example.com:8883`
 
+Equivalent env vars: `ROBOROCK_SERVER_ADVERTISED_HTTPS_PORT=443`, `ROBOROCK_SERVER_ADVERTISED_MQTT_TLS_PORT=8883`.
+
 ## TLS Termination Modes
 
 ### `local_tls` (default) — the server terminates TLS
@@ -60,6 +64,8 @@ listener_mode = "external_tls"
 mode = "provided"
 ```
 
+Equivalent env vars: `ROBOROCK_SERVER_LISTENER_MODE=external_tls`, `ROBOROCK_SERVER_TLS_MODE=provided`.
+
 Requirements:
 
 - HTTPS: the proxy terminates TLS, preserves the original `Host` header, and forwards plain HTTP to `https_port`.
@@ -133,6 +139,8 @@ List every proxy or SNAT address that forwards traffic to the server in `trusted
 trusted_proxies = ["10.42.0.0/16", "10.1.1.10"]
 ```
 
+Equivalent env var: `ROBOROCK_SERVER_TRUSTED_PROXIES=10.42.0.0/16,10.1.1.10` (comma-separated).
+
 - **HTTP:** requests from a trusted proxy use the `X-Forwarded-For` header to find the vacuum's real IP. The header is ignored from any other client, so it can't be spoofed. Traefik and Caddy set it by default; nginx needs `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;`.
 - **MQTT:** during an active onboarding session, a new MQTT login is normally accepted only from the IP that completed `/region` and `/nc`. A connection from a trusted proxy is also accepted, because a stream proxy hides the vacuum's address. The session must still have completed `/region` and `/nc`, and the first publish must be on that vacuum's topic.
 

From bfb94d7c258792cd9911109319c3801dad8b6dc1 Mon Sep 17 00:00:00 2001
From: Colin Haven <10933733+virtitnerd@users.noreply.github.com>
Date: Sun, 27 Sep 2026 11:14:27 -0400
Subject: [PATCH 09/28] Make the [admin] env vars optional as a group

STACK_FQDN plus network/broker/tls env vars alone now produce a valid
partial config.toml with no [admin] section, so a deployment can set
just the infra/TLS side (including external_tls reverse-proxy setups)
and finish credentials in the Setup Wizard - rather than requiring all
of ADMIN_PASSWORD/PROTOCOL_LOGIN_EMAIL/PROTOCOL_LOGIN_PIN up front or
not being able to use env vars at all. Setting some but not all three
is rejected with a clear error, since a half-set credential is never
what was intended.

Co-Authored-By: Claude Sonnet 5 
(cherry picked from commit 15bddb9d514268ab0d399976956bf53606e70d7d)
---
 src/roborock_local_server/env_config.py | 82 +++++++++++++++----------
 tests/test_env_config.py                | 30 ++++++++-
 2 files changed, 79 insertions(+), 33 deletions(-)

diff --git a/src/roborock_local_server/env_config.py b/src/roborock_local_server/env_config.py
index 0c406c8..12758d3 100644
--- a/src/roborock_local_server/env_config.py
+++ b/src/roborock_local_server/env_config.py
@@ -3,9 +3,14 @@
 This lets Docker Compose (and similar) deployments boot straight from
 environment variables, without running the host-side `configure` wizard
 first. It intentionally covers only the deployment/infrastructure-shaped
-settings (network, broker, storage, tls) plus the admin credentials needed
-to produce a bootable config; it is not meant to grow a variable for every
-config.toml key.
+settings (network, broker, storage, tls); it is not meant to grow a
+variable for every config.toml key.
+
+The [admin] credentials (password, protocol login email/PIN) are optional
+as a group: set none of them and the container boots straight into the
+Setup Wizard (/admin) with network/broker/storage/tls already filled in
+from these env vars - only the credentials are still asked for. Set all
+of them for a fully headless boot with no browser step at all.
 """
 
 from __future__ import annotations
@@ -263,20 +268,21 @@ def render_config_toml_from_env(
             cert_file = _require_non_empty(_get(env, "CERT_FILE"), field_name="CERT_FILE")
             key_file = _require_non_empty(_get(env, "KEY_FILE"), field_name="KEY_FILE")
 
-    admin_password = _require_non_empty(_get(env, "ADMIN_PASSWORD"), field_name="ADMIN_PASSWORD")
-    admin_session_secret = (
-        _get(env, "ADMIN_SESSION_SECRET")
-        or _load_existing_admin_session_secret(config_path)
-        or secrets.token_urlsafe(32)
-    )
-    if len(admin_session_secret) < 24:
-        raise ValueError(f"{ENV_PREFIX}ADMIN_SESSION_SECRET must be at least 24 characters when set")
-    new_connections_enabled = _as_bool(_get(env, "NEW_CONNECTIONS_ENABLED"), default=True)
-    protocol_login_email = _require_email(_get(env, "PROTOCOL_LOGIN_EMAIL"), field_name="PROTOCOL_LOGIN_EMAIL")
-    protocol_login_pin = _require_pin(_get(env, "PROTOCOL_LOGIN_PIN"), field_name="PROTOCOL_LOGIN_PIN")
-
-    password_hash = hash_password(admin_password)
-    protocol_login_pin_hash = hash_password(protocol_login_pin)
+    # The [admin] fields are optional as a group: set none of them to leave
+    # credentials to the Setup Wizard (it fills in just [admin] on top of
+    # this network/broker/storage/tls config), or set all of them for a
+    # fully headless, no-browser-step boot.
+    raw_admin_password = _get(env, "ADMIN_PASSWORD")
+    raw_protocol_login_email = _get(env, "PROTOCOL_LOGIN_EMAIL")
+    raw_protocol_login_pin = _get(env, "PROTOCOL_LOGIN_PIN")
+    admin_fields_present = (bool(raw_admin_password), bool(raw_protocol_login_email), bool(raw_protocol_login_pin))
+    if any(admin_fields_present) and not all(admin_fields_present):
+        raise ValueError(
+            f"Set all of {ENV_PREFIX}ADMIN_PASSWORD, {ENV_PREFIX}PROTOCOL_LOGIN_EMAIL, and "
+            f"{ENV_PREFIX}PROTOCOL_LOGIN_PIN together, or none of them (and finish setup in the "
+            "Setup Wizard at /admin instead)."
+        )
+    include_admin = all(admin_fields_present)
 
     lines = [
         "[network]",
@@ -336,20 +342,34 @@ def render_config_toml_from_env(
             ]
         )
 
-    lines.extend(
-        [
-            "",
-            "[admin]",
-            f"password_hash = {_toml_string(password_hash)}",
-            f"session_secret = {_toml_string(admin_session_secret)}",
-            "session_ttl_seconds = 86400",
-            "protocol_auth_enabled = true",
-            f"new_connections_enabled = {_toml_bool(new_connections_enabled)}",
-            f"protocol_login_email = {_toml_string(protocol_login_email)}",
-            f"protocol_login_pin_hash = {_toml_string(protocol_login_pin_hash)}",
-            "",
-        ]
-    )
+    if include_admin:
+        admin_session_secret = (
+            _get(env, "ADMIN_SESSION_SECRET")
+            or _load_existing_admin_session_secret(config_path)
+            or secrets.token_urlsafe(32)
+        )
+        if len(admin_session_secret) < 24:
+            raise ValueError(f"{ENV_PREFIX}ADMIN_SESSION_SECRET must be at least 24 characters when set")
+        new_connections_enabled = _as_bool(_get(env, "NEW_CONNECTIONS_ENABLED"), default=True)
+        protocol_login_email = _require_email(raw_protocol_login_email, field_name="PROTOCOL_LOGIN_EMAIL")
+        protocol_login_pin = _require_pin(raw_protocol_login_pin, field_name="PROTOCOL_LOGIN_PIN")
+        password_hash = hash_password(raw_admin_password)
+        protocol_login_pin_hash = hash_password(protocol_login_pin)
+
+        lines.extend(
+            [
+                "",
+                "[admin]",
+                f"password_hash = {_toml_string(password_hash)}",
+                f"session_secret = {_toml_string(admin_session_secret)}",
+                "session_ttl_seconds = 86400",
+                "protocol_auth_enabled = true",
+                f"new_connections_enabled = {_toml_bool(new_connections_enabled)}",
+                f"protocol_login_email = {_toml_string(protocol_login_email)}",
+                f"protocol_login_pin_hash = {_toml_string(protocol_login_pin_hash)}",
+                "",
+            ]
+        )
     return "\n".join(lines), secrets_to_write
 
 
diff --git a/tests/test_env_config.py b/tests/test_env_config.py
index 79e7bb2..fcab6b1 100644
--- a/tests/test_env_config.py
+++ b/tests/test_env_config.py
@@ -190,15 +190,41 @@ def test_write_config_from_env_actalis_writes_eab(tmp_path: Path) -> None:
     assert hmac_path.read_text(encoding="utf-8") == "hmac-456"
 
 
-def test_write_config_from_env_requires_admin_password(tmp_path: Path) -> None:
+def test_write_config_from_env_rejects_partial_admin_fields(tmp_path: Path) -> None:
     config_path = tmp_path / "config.toml"
     env = dict(_BASE_ENV)
     del env["ROBOROCK_SERVER_ADMIN_PASSWORD"]
 
-    with pytest.raises(ValueError, match="ADMIN_PASSWORD is required"):
+    with pytest.raises(ValueError, match="Set all of .*ADMIN_PASSWORD.*or none of them"):
         write_config_from_env(env, config_path=config_path)
 
 
+def test_write_config_from_env_omits_admin_section_when_all_admin_fields_absent(tmp_path: Path) -> None:
+    config_path = tmp_path / "config.toml"
+    env = dict(_BASE_ENV)
+    del env["ROBOROCK_SERVER_ADMIN_PASSWORD"]
+    del env["ROBOROCK_SERVER_PROTOCOL_LOGIN_EMAIL"]
+    del env["ROBOROCK_SERVER_PROTOCOL_LOGIN_PIN"]
+
+    write_config_from_env(env, config_path=config_path)
+
+    parsed = tomllib.loads(config_path.read_text(encoding="utf-8"))
+    assert "admin" not in parsed
+    # Network/broker/storage/tls are still fully written - the Setup Wizard
+    # only needs to fill in [admin] on top of this.
+    assert parsed["network"]["stack_fqdn"] == "api-roborock.example.com"
+    assert parsed["tls"]["mode"] == "provided"
+
+
+def test_write_config_from_env_writes_admin_section_when_all_admin_fields_present(tmp_path: Path) -> None:
+    config_path = tmp_path / "config.toml"
+
+    write_config_from_env(_BASE_ENV, config_path=config_path)
+
+    parsed = tomllib.loads(config_path.read_text(encoding="utf-8"))
+    assert parsed["admin"]["protocol_login_email"] == "user@example.com"
+
+
 def test_write_config_from_env_requires_api_prefix(tmp_path: Path) -> None:
     config_path = tmp_path / "config.toml"
     env = dict(_BASE_ENV)

From 66c468d954447b3b738dc97ed555bb6b99c2c783 Mon Sep 17 00:00:00 2001
From: Colin Haven <10933733+virtitnerd@users.noreply.github.com>
Date: Sun, 27 Sep 2026 11:15:25 -0400
Subject: [PATCH 10/28] Split load_config() so [admin] can be diagnosed
 independently

Extracts network/broker/storage/tls parsing+validation into its own
function so it can succeed or fail on its own, separately from [admin].
Adds diagnose_config(), which classifies a config.toml as "ok",
"missing_admin" (everything but [admin] validates - e.g. a partial
env-var-generated config still waiting on the Setup Wizard for
credentials), or "invalid" (something else is wrong, or the file is
missing). load_config()'s own behavior for existing valid/invalid
configs is unchanged.

Co-Authored-By: Claude Sonnet 5 
(cherry picked from commit e39dd211a1065b753b348228237bdaccff5adf17)
---
 src/roborock_local_server/config.py | 280 ++++++++++++++++------------
 tests/test_config.py                |  53 +++++-
 2 files changed, 214 insertions(+), 119 deletions(-)

diff --git a/src/roborock_local_server/config.py b/src/roborock_local_server/config.py
index 37a2cb6..213cd3b 100644
--- a/src/roborock_local_server/config.py
+++ b/src/roborock_local_server/config.py
@@ -232,17 +232,13 @@ def _as_bool(value: object, default: bool) -> bool:
     return bool(value)
 
 
-def load_config(path: str | Path) -> AppConfig:
-    config_path = Path(path).resolve()
-    parsed = tomllib.loads(config_path.read_text(encoding="utf-8"))
-
+def _load_network_broker_storage_tls(
+    parsed: dict[str, object],
+) -> tuple[NetworkConfig, BrokerConfig, StorageConfig, TlsConfig]:
     network = _get_section(parsed, "network")
     broker = _get_section(parsed, "broker")
     storage = _get_section(parsed, "storage")
     tls = _get_section(parsed, "tls")
-    admin = _get_section(parsed, "admin")
-    turn = _get_section(parsed, "turn")
-    turn_mode = str(turn.get("mode", "external" if _as_bool(turn.get("enabled"), False) else "disabled")).strip().lower()
     broker_mode = str(broker.get("mode", "embedded")).strip().lower()
     if broker_mode not in {"embedded", "external"}:
         raise ValueError("broker.mode must be 'embedded' or 'external'")
@@ -263,144 +259,192 @@ def load_config(path: str | Path) -> AppConfig:
     https_port = _as_port(network.get("https_port"), "network.https_port", 555)
     mqtt_tls_port = _as_port(network.get("mqtt_tls_port"), "network.mqtt_tls_port", 8881)
 
-    config = AppConfig(
-        network=NetworkConfig(
-            stack_fqdn=_require_stack_fqdn(network.get("stack_fqdn"), "network.stack_fqdn"),
-            listener_mode=listener_mode,
-            bind_host=str(network.get("bind_host", "0.0.0.0")).strip() or "0.0.0.0",
-            https_port=https_port,
-            mqtt_tls_port=mqtt_tls_port,
-            advertised_https_port=_as_port(
-                network.get("advertised_https_port"),
-                "network.advertised_https_port",
-                https_port,
-            ),
-            advertised_mqtt_tls_port=_as_port(
-                network.get("advertised_mqtt_tls_port"),
-                "network.advertised_mqtt_tls_port",
-                mqtt_tls_port,
-            ),
-            region=str(network.get("region", "us")).strip().lower() or "us",
-            localkey=str(network.get("localkey", "")).strip(),
-            duid=str(network.get("duid", "")).strip(),
-            mqtt_username=str(network.get("mqtt_username", "")).strip(),
-            mqtt_password=str(network.get("mqtt_password", "")).strip(),
-            mqtt_client_id=str(network.get("mqtt_client_id", "")).strip(),
-            trusted_proxies=_as_trusted_proxies(
-                network.get("trusted_proxies"),
-                "network.trusted_proxies",
-                NetworkConfig.trusted_proxies,
-            ),
-            timezone=_as_timezone(
-                network.get("timezone", "") or parsed.get("timezone", ""),
-                "network.timezone",
-            ),
+    network_config = NetworkConfig(
+        stack_fqdn=_require_stack_fqdn(network.get("stack_fqdn"), "network.stack_fqdn"),
+        listener_mode=listener_mode,
+        bind_host=str(network.get("bind_host", "0.0.0.0")).strip() or "0.0.0.0",
+        https_port=https_port,
+        mqtt_tls_port=mqtt_tls_port,
+        advertised_https_port=_as_port(
+            network.get("advertised_https_port"),
+            "network.advertised_https_port",
+            https_port,
         ),
-        broker=BrokerConfig(
-            mode=broker_mode,
-            host=broker_host,
-            port=_as_port(broker.get("port"), "broker.port", broker_port_default),
-            mosquitto_binary=str(broker.get("mosquitto_binary", "mosquitto")).strip() or "mosquitto",
-            enable_topic_bridge=_as_bool(broker.get("enable_topic_bridge"), True),
+        advertised_mqtt_tls_port=_as_port(
+            network.get("advertised_mqtt_tls_port"),
+            "network.advertised_mqtt_tls_port",
+            mqtt_tls_port,
         ),
-        storage=StorageConfig(
-            data_dir=str(storage.get("data_dir", "/data")).strip() or "/data",
+        region=str(network.get("region", "us")).strip().lower() or "us",
+        localkey=str(network.get("localkey", "")).strip(),
+        duid=str(network.get("duid", "")).strip(),
+        mqtt_username=str(network.get("mqtt_username", "")).strip(),
+        mqtt_password=str(network.get("mqtt_password", "")).strip(),
+        mqtt_client_id=str(network.get("mqtt_client_id", "")).strip(),
+        trusted_proxies=_as_trusted_proxies(
+            network.get("trusted_proxies"),
+            "network.trusted_proxies",
+            NetworkConfig.trusted_proxies,
         ),
-        tls=TlsConfig(
-            mode=tls_mode,
-            base_domain=(
-                _normalize_hostname(tls.get("base_domain"), "tls.base_domain")
-                if str(tls.get("base_domain", "")).strip()
-                else ""
-            ),
-            email=str(tls.get("email", "")).strip(),
-            cloudflare_token_file=str(tls.get("cloudflare_token_file", "")).strip(),
-            renew_days_before=_as_int(tls.get("renew_days_before"), "tls.renew_days_before", 30),
-            renew_check_seconds=_as_int(tls.get("renew_check_seconds"), "tls.renew_check_seconds", 43200),
-            acme_server=_normalize_acme_server(tls.get("acme_server", "zerossl"), "tls.acme_server"),
-            acme_eab_kid=str(tls.get("acme_eab_kid", "")).strip(),
-            acme_eab_hmac_key=str(tls.get("acme_eab_hmac_key", "")).strip(),
-            acme_eab_kid_file=str(tls.get("acme_eab_kid_file", "")).strip(),
-            acme_eab_hmac_key_file=str(tls.get("acme_eab_hmac_key_file", "")).strip(),
-            cert_file=str(tls.get("cert_file", "")).strip(),
-            key_file=str(tls.get("key_file", "")).strip(),
+        timezone=_as_timezone(
+            network.get("timezone", "") or parsed.get("timezone", ""),
+            "network.timezone",
         ),
-        admin=AdminConfig(
-            password_hash=_require_non_empty(admin.get("password_hash"), "admin.password_hash"),
-            session_secret=_require_non_empty(admin.get("session_secret"), "admin.session_secret"),
-            session_ttl_seconds=_as_int(admin.get("session_ttl_seconds"), "admin.session_ttl_seconds", 86400),
-            protocol_auth_enabled=_as_bool(admin.get("protocol_auth_enabled"), True),
-            new_connections_enabled=_as_bool(admin.get("new_connections_enabled"), True),
-            protocol_login_email=_require_non_empty(admin.get("protocol_login_email"), "admin.protocol_login_email"),
-            protocol_login_pin_hash=_require_non_empty(
-                admin.get("protocol_login_pin_hash"),
-                "admin.protocol_login_pin_hash",
-            ),
-        ),
-        turn=TurnConfig(
-            mode=turn_mode,
-            host=str(turn.get("host", "")).strip(),
-            port=_as_port(turn.get("port"), "turn.port", 3478),
-            username=str(turn.get("username", "") or ("roborock" if turn_mode == "provided" else "")).strip(),
-            password=str(turn.get("password", "")).strip(),
-            realm=str(turn.get("realm", "") or turn.get("host", "")).strip(),
-            ttl=_as_int(turn.get("ttl"), "turn.ttl", 86400),
+    )
+    broker_config = BrokerConfig(
+        mode=broker_mode,
+        host=broker_host,
+        port=_as_port(broker.get("port"), "broker.port", broker_port_default),
+        mosquitto_binary=str(broker.get("mosquitto_binary", "mosquitto")).strip() or "mosquitto",
+        enable_topic_bridge=_as_bool(broker.get("enable_topic_bridge"), True),
+    )
+    storage_config = StorageConfig(
+        data_dir=str(storage.get("data_dir", "/data")).strip() or "/data",
+    )
+    tls_config = TlsConfig(
+        mode=tls_mode,
+        base_domain=(
+            _normalize_hostname(tls.get("base_domain"), "tls.base_domain")
+            if str(tls.get("base_domain", "")).strip()
+            else ""
         ),
+        email=str(tls.get("email", "")).strip(),
+        cloudflare_token_file=str(tls.get("cloudflare_token_file", "")).strip(),
+        renew_days_before=_as_int(tls.get("renew_days_before"), "tls.renew_days_before", 30),
+        renew_check_seconds=_as_int(tls.get("renew_check_seconds"), "tls.renew_check_seconds", 43200),
+        acme_server=_normalize_acme_server(tls.get("acme_server", "zerossl"), "tls.acme_server"),
+        acme_eab_kid=str(tls.get("acme_eab_kid", "")).strip(),
+        acme_eab_hmac_key=str(tls.get("acme_eab_hmac_key", "")).strip(),
+        acme_eab_kid_file=str(tls.get("acme_eab_kid_file", "")).strip(),
+        acme_eab_hmac_key_file=str(tls.get("acme_eab_hmac_key_file", "")).strip(),
+        cert_file=str(tls.get("cert_file", "")).strip(),
+        key_file=str(tls.get("key_file", "")).strip(),
     )
 
-    if len(config.admin.session_secret) < 24:
-        raise ValueError("admin.session_secret must be at least 24 characters")
-    if "@" not in config.admin.protocol_login_email:
-        raise ValueError("admin.protocol_login_email must be an email address")
-
-    if config.broker.mode == "external":
-        _require_non_empty(config.broker.host, "broker.host")
-
-    if config.turn.mode not in {"disabled", "provided", "external"}:
-        raise ValueError("turn.mode must be 'disabled', 'provided', or 'external'")
-    if config.turn.mode != "disabled":
-        _require_non_empty(config.turn.host, "turn.host")
-        _require_non_empty(config.turn.username, "turn.username")
-        _require_non_empty(config.turn.password, "turn.password")
-        _require_non_empty(config.turn.realm, "turn.realm")
-        if config.turn.ttl <= 0:
-            raise ValueError("turn.ttl must be greater than 0")
-    if config.turn.mode == "provided":
-        for name in ("username", "password", "realm"):
-            if any(character in getattr(config.turn, name) for character in "\r\n:="):
-                raise ValueError(f"turn.{name} cannot contain newlines, colons, or equals signs in provided mode")
+    if broker_config.mode == "external":
+        _require_non_empty(broker_config.host, "broker.host")
 
     # In external_tls the proxy terminates TLS and presents the cert to clients,
     # so the server itself needs no certificate material.
-    if config.network.listener_mode == "local_tls":
-        if config.tls.mode == "cloudflare_acme":
-            _normalize_hostname(config.tls.base_domain, "tls.base_domain")
-            _require_non_empty(config.tls.email, "tls.email")
-            _require_non_empty(config.tls.cloudflare_token_file, "tls.cloudflare_token_file")
-            has_kid = bool(config.tls.acme_eab_kid or config.tls.acme_eab_kid_file)
-            has_hmac = bool(config.tls.acme_eab_hmac_key or config.tls.acme_eab_hmac_key_file)
+    if network_config.listener_mode == "local_tls":
+        if tls_config.mode == "cloudflare_acme":
+            _normalize_hostname(tls_config.base_domain, "tls.base_domain")
+            _require_non_empty(tls_config.email, "tls.email")
+            _require_non_empty(tls_config.cloudflare_token_file, "tls.cloudflare_token_file")
+            has_kid = bool(tls_config.acme_eab_kid or tls_config.acme_eab_kid_file)
+            has_hmac = bool(tls_config.acme_eab_hmac_key or tls_config.acme_eab_hmac_key_file)
             if has_kid != has_hmac:
                 raise ValueError(
                     "tls.acme_eab_kid/tls.acme_eab_kid_file and "
                     "tls.acme_eab_hmac_key/tls.acme_eab_hmac_key_file must be set together"
                 )
-            if config.tls.acme_server == "actalis":
+            if tls_config.acme_server == "actalis":
                 if not has_kid:
                     raise ValueError(
                         "Actalis requires tls.acme_eab_kid or tls.acme_eab_kid_file, "
                         "and tls.acme_eab_hmac_key or tls.acme_eab_hmac_key_file"
                     )
         else:
-            _require_non_empty(config.tls.cert_file, "tls.cert_file")
-            _require_non_empty(config.tls.key_file, "tls.key_file")
-    elif config.tls.mode == "cloudflare_acme":
+            _require_non_empty(tls_config.cert_file, "tls.cert_file")
+            _require_non_empty(tls_config.key_file, "tls.key_file")
+    elif tls_config.mode == "cloudflare_acme":
         # external_tls never issues or renews certificates, so cloudflare_acme
         # would be a silent no-op. Require the proxy-managed 'provided' mode.
         raise ValueError(
             "network.listener_mode='external_tls' requires tls.mode='provided' "
             "(the proxy terminates TLS; the server does not issue certificates)"
         )
-    return config
+
+    return network_config, broker_config, storage_config, tls_config
+
+
+def _load_turn(parsed: dict[str, object]) -> TurnConfig:
+    turn = _get_section(parsed, "turn")
+    mode = str(turn.get("mode", "external" if _as_bool(turn.get("enabled"), False) else "disabled")).strip().lower()
+    turn_config = TurnConfig(
+        mode=mode,
+        host=str(turn.get("host", "")).strip(),
+        port=_as_port(turn.get("port"), "turn.port", 3478),
+        username=str(turn.get("username", "") or ("roborock" if mode == "provided" else "")).strip(),
+        password=str(turn.get("password", "")).strip(),
+        realm=str(turn.get("realm", "") or turn.get("host", "")).strip(),
+        ttl=_as_int(turn.get("ttl"), "turn.ttl", 86400),
+    )
+    if turn_config.mode not in {"disabled", "provided", "external"}:
+        raise ValueError("turn.mode must be 'disabled', 'provided', or 'external'")
+    if turn_config.mode != "disabled":
+        _require_non_empty(turn_config.host, "turn.host")
+        _require_non_empty(turn_config.username, "turn.username")
+        _require_non_empty(turn_config.password, "turn.password")
+        _require_non_empty(turn_config.realm, "turn.realm")
+        if turn_config.ttl <= 0:
+            raise ValueError("turn.ttl must be greater than 0")
+    if turn_config.mode == "provided":
+        for name in ("username", "password", "realm"):
+            if any(character in getattr(turn_config, name) for character in "\r\n:="):
+                raise ValueError(f"turn.{name} cannot contain newlines, colons, or equals signs in provided mode")
+    return turn_config
+
+
+def _load_admin(parsed: dict[str, object]) -> AdminConfig:
+    admin = _get_section(parsed, "admin")
+    admin_config = AdminConfig(
+        password_hash=_require_non_empty(admin.get("password_hash"), "admin.password_hash"),
+        session_secret=_require_non_empty(admin.get("session_secret"), "admin.session_secret"),
+        session_ttl_seconds=_as_int(admin.get("session_ttl_seconds"), "admin.session_ttl_seconds", 86400),
+        protocol_auth_enabled=_as_bool(admin.get("protocol_auth_enabled"), True),
+        new_connections_enabled=_as_bool(admin.get("new_connections_enabled"), True),
+        protocol_login_email=_require_non_empty(admin.get("protocol_login_email"), "admin.protocol_login_email"),
+        protocol_login_pin_hash=_require_non_empty(
+            admin.get("protocol_login_pin_hash"),
+            "admin.protocol_login_pin_hash",
+        ),
+    )
+    if len(admin_config.session_secret) < 24:
+        raise ValueError("admin.session_secret must be at least 24 characters")
+    if "@" not in admin_config.protocol_login_email:
+        raise ValueError("admin.protocol_login_email must be an email address")
+    return admin_config
+
+
+def load_config(path: str | Path) -> AppConfig:
+    config_path = Path(path).resolve()
+    parsed = tomllib.loads(config_path.read_text(encoding="utf-8"))
+    network_config, broker_config, storage_config, tls_config = _load_network_broker_storage_tls(parsed)
+    turn_config = _load_turn(parsed)
+    admin_config = _load_admin(parsed)
+    return AppConfig(
+        network=network_config,
+        broker=broker_config,
+        storage=storage_config,
+        tls=tls_config,
+        admin=admin_config,
+        turn=turn_config,
+    )
+
+
+def diagnose_config(path: str | Path) -> str:
+    """Classify config.toml's state for the setup wizard.
+
+    Returns "ok" (loads cleanly), "missing_admin" (network/broker/storage/tls
+    all validate, but [admin] doesn't - e.g. a partial env-var-generated
+    config still waiting on the wizard for credentials), or "invalid" (file
+    missing/unreadable, or something other than [admin] is wrong).
+    """
+    try:
+        parsed = tomllib.loads(Path(path).read_text(encoding="utf-8"))
+    except (OSError, tomllib.TOMLDecodeError):
+        return "invalid"
+    try:
+        _load_network_broker_storage_tls(parsed)
+        _load_turn(parsed)
+    except ValueError:
+        return "invalid"
+    try:
+        _load_admin(parsed)
+    except ValueError:
+        return "missing_admin"
+    return "ok"
 
 
 def resolve_paths(config_file: str | Path, config: AppConfig) -> AppPaths:
diff --git a/tests/test_config.py b/tests/test_config.py
index 7db8ee3..83ad9e2 100644
--- a/tests/test_config.py
+++ b/tests/test_config.py
@@ -1,7 +1,7 @@
 from pathlib import Path
 import pytest
 
-from roborock_local_server.config import load_config, resolve_paths
+from roborock_local_server.config import diagnose_config, load_config, resolve_paths
 from conftest import write_release_config
 
 
@@ -533,3 +533,54 @@ def test_load_config_rejects_invalid_trusted_proxy(tmp_path: Path) -> None:
 
     with pytest.raises(ValueError, match="network.trusted_proxies entry 'traefik'"):
         load_config(config_file)
+
+
+_VALID_NETWORK_BROKER_STORAGE_TLS = """
+[network]
+stack_fqdn = "api-roborock.example.com"
+
+[broker]
+mode = "embedded"
+
+[storage]
+data_dir = "data"
+
+[tls]
+mode = "provided"
+cert_file = "certs/fullchain.pem"
+key_file = "certs/privkey.pem"
+"""
+
+_VALID_ADMIN = """
+[admin]
+password_hash = "pbkdf2_sha256$600000$abc$def"
+session_secret = "abcdefghijklmnopqrstuvwxyz123456"
+protocol_login_email = "user@example.com"
+protocol_login_pin_hash = "pbkdf2_sha256$600000$ghi$jkl"
+"""
+
+
+def test_diagnose_config_ok(tmp_path: Path) -> None:
+    config_file = tmp_path / "config.toml"
+    config_file.write_text((_VALID_NETWORK_BROKER_STORAGE_TLS + _VALID_ADMIN).strip(), encoding="utf-8")
+
+    assert diagnose_config(config_file) == "ok"
+
+
+def test_diagnose_config_missing_admin_when_only_admin_section_absent(tmp_path: Path) -> None:
+    config_file = tmp_path / "config.toml"
+    config_file.write_text(_VALID_NETWORK_BROKER_STORAGE_TLS.strip(), encoding="utf-8")
+
+    assert diagnose_config(config_file) == "missing_admin"
+
+
+def test_diagnose_config_invalid_when_file_does_not_exist(tmp_path: Path) -> None:
+    assert diagnose_config(tmp_path / "does-not-exist.toml") == "invalid"
+
+
+def test_diagnose_config_invalid_when_network_is_broken_even_if_admin_is_fine(tmp_path: Path) -> None:
+    config_file = tmp_path / "config.toml"
+    broken_network = _VALID_NETWORK_BROKER_STORAGE_TLS.replace('mode = "embedded"', 'mode = "bogus"')
+    config_file.write_text((broken_network + _VALID_ADMIN).strip(), encoding="utf-8")
+
+    assert diagnose_config(config_file) == "invalid"

From d15fcbc9de9108e3f715ebfb70fade393063998e Mon Sep 17 00:00:00 2001
From: Colin Haven <10933733+virtitnerd@users.noreply.github.com>
Date: Sun, 27 Sep 2026 11:23:52 -0400
Subject: [PATCH 11/28] Support completing a partial (env-var-generated) config
 in the wizard

Uses diagnose_config() to tell three states apart at /admin: "ok"
(status page), "missing_admin" (a trimmed wizard asking only for admin
credentials, appending just [admin] to the existing config.toml rather
than regenerating it), and anything else (the full wizard, as before -
this also covers a corrupt hand-edited file, which is correctly
refused rather than silently overwritten).

This is what makes the env-var path from feat/env-var-config and this
wizard actually compose: set only FQDN/TLS/network vars and finish
credentials in the browser, instead of an all-or-nothing choice
between the two.

Co-Authored-By: Claude Sonnet 5 
(cherry picked from commit c93a28b617e59d366d5a380eb57df107260e5820)
---
 src/roborock_local_server/management_app.py | 159 +++++++++++++++++++-
 tests/test_management_app.py                | 125 ++++++++++++++-
 2 files changed, 270 insertions(+), 14 deletions(-)

diff --git a/src/roborock_local_server/management_app.py b/src/roborock_local_server/management_app.py
index 5177832..421130d 100644
--- a/src/roborock_local_server/management_app.py
+++ b/src/roborock_local_server/management_app.py
@@ -20,6 +20,7 @@
 from fastapi import FastAPI, Request
 from fastapi.responses import HTMLResponse, JSONResponse
 
+from .config import diagnose_config
 from .configure import (
     ConfigureAnswers,
     _normalize_acme_server,
@@ -131,6 +132,56 @@ def s(key: str, default: str = "") -> str:
     )
 
 
+def _toml_string(value: str) -> str:
+    return json.dumps(value)
+
+
+def _admin_fields_from_payload(body: Mapping[str, Any]) -> tuple[str, str, str]:
+    def s(key: str, default: str = "") -> str:
+        return str(body.get(key, default) or "").strip()
+
+    admin_password = s("admin_password")
+    if not admin_password:
+        raise ValueError("admin_password is required")
+    protocol_login_email = s("protocol_login_email")
+    if "@" not in protocol_login_email:
+        raise ValueError("protocol_login_email must be an email address")
+    protocol_login_pin = _validate_protocol_login_pin(s("protocol_login_pin"))
+    return admin_password, protocol_login_email, protocol_login_pin
+
+
+def complete_admin_section(
+    *,
+    config_file: Path,
+    admin_password: str,
+    protocol_login_email: str,
+    protocol_login_pin: str,
+) -> None:
+    """Append [admin] to a partial config.toml (network/broker/storage/tls
+    already written, e.g. by env_config.py) that's only missing credentials.
+
+    Appending is safe here because env_config.py never writes an [admin]
+    section itself when these vars are unset - there's nothing to collide
+    with or overwrite.
+    """
+    admin_block = "\n".join(
+        [
+            "",
+            "[admin]",
+            f"password_hash = {_toml_string(hash_password(admin_password))}",
+            f"session_secret = {_toml_string(secrets.token_urlsafe(32))}",
+            "session_ttl_seconds = 86400",
+            "protocol_auth_enabled = true",
+            "new_connections_enabled = true",
+            f"protocol_login_email = {_toml_string(protocol_login_email)}",
+            f"protocol_login_pin_hash = {_toml_string(hash_password(protocol_login_pin))}",
+            "",
+        ]
+    )
+    with config_file.open("a", encoding="utf-8") as handle:
+        handle.write(admin_block)
+
+
 def _status_html() -> str:
     return dedent(
         """\
@@ -290,6 +341,84 @@ def _setup_wizard_html() -> str:
     )
 
 
+def _admin_only_wizard_html() -> str:
+    return dedent(
+        """\
+        
+        Roborock Local Server Setup
+        
+        
+        

Roborock Local Server Setup

+

Network, broker, and certificate settings are already set (from environment variables). + Just add admin credentials to finish setup.

+
+
+ Admin Access + + +
+ +
+ App / Home Assistant Login + + + +
+ + +
+

+        
+        
+        
+        """
+    )
+
+
 def create_management_app(
     *,
     config_file: Path,
@@ -300,27 +429,45 @@ def create_management_app(
     @app.get("/", response_class=HTMLResponse)
     @app.get("/admin", response_class=HTMLResponse)
     async def index() -> HTMLResponse:
-        return HTMLResponse(_status_html() if config_file.exists() else _setup_wizard_html())
+        status = diagnose_config(config_file)
+        if status == "ok":
+            return HTMLResponse(_status_html())
+        if status == "missing_admin":
+            return HTMLResponse(_admin_only_wizard_html())
+        return HTMLResponse(_setup_wizard_html())
 
     @app.get("/admin/api/setup/status")
     async def setup_status() -> JSONResponse:
-        return JSONResponse({"configured": config_file.exists()})
+        return JSONResponse({"status": diagnose_config(config_file)})
 
     @app.post("/admin/api/setup")
     async def submit_setup(request: Request) -> JSONResponse:
-        if config_file.exists():
+        status = diagnose_config(config_file)
+        if status == "ok":
             return JSONResponse({"error": "Already configured."}, status_code=409)
+
         try:
             raw_body = await request.body()
             body = json.loads(raw_body or b"{}")
             if not isinstance(body, dict):
                 raise ValueError("Request body must be a JSON object.")
-            answers = _answers_from_payload(body)
-            result = write_config_setup(config_file=config_file, answers=answers)
+
+            if status == "missing_admin":
+                admin_password, protocol_login_email, protocol_login_pin = _admin_fields_from_payload(body)
+                complete_admin_section(
+                    config_file=config_file,
+                    admin_password=admin_password,
+                    protocol_login_email=protocol_login_email,
+                    protocol_login_pin=protocol_login_pin,
+                )
+                result_config_file = config_file
+            else:
+                answers = _answers_from_payload(body)
+                result_config_file = write_config_setup(config_file=config_file, answers=answers).config_file
         except (ValueError, FileExistsError) as exc:
             return JSONResponse({"error": str(exc)}, status_code=400)
         if on_configured is not None:
             on_configured()
-        return JSONResponse({"ok": True, "config_file": str(result.config_file)})
+        return JSONResponse({"ok": True, "config_file": str(result_config_file)})
 
     return app
diff --git a/tests/test_management_app.py b/tests/test_management_app.py
index d805a07..e787a8a 100644
--- a/tests/test_management_app.py
+++ b/tests/test_management_app.py
@@ -5,7 +5,7 @@
 from fastapi.testclient import TestClient
 import pytest
 
-from roborock_local_server.config import load_config
+from roborock_local_server.config import diagnose_config, load_config
 from roborock_local_server.management_app import (
     DEFAULT_BOOTSTRAP_HTTPS_PORT,
     bootstrap_port_from_env,
@@ -23,6 +23,44 @@
     "protocol_login_pin": "123456",
 }
 
+_FULL_CONFIG = """
+[network]
+stack_fqdn = "api-roborock.example.com"
+
+[broker]
+mode = "embedded"
+
+[storage]
+data_dir = "data"
+
+[tls]
+mode = "provided"
+cert_file = "certs/fullchain.pem"
+key_file = "certs/privkey.pem"
+
+[admin]
+password_hash = "pbkdf2_sha256$600000$abc$def"
+session_secret = "abcdefghijklmnopqrstuvwxyz123456"
+protocol_login_email = "user@example.com"
+protocol_login_pin_hash = "pbkdf2_sha256$600000$ghi$jkl"
+""".strip()
+
+_PARTIAL_CONFIG_MISSING_ADMIN = """
+[network]
+stack_fqdn = "api-roborock.example.com"
+
+[broker]
+mode = "embedded"
+
+[storage]
+data_dir = "data"
+
+[tls]
+mode = "provided"
+cert_file = "certs/fullchain.pem"
+key_file = "certs/privkey.pem"
+""".strip()
+
 
 def test_bootstrap_port_from_env_default() -> None:
     assert bootstrap_port_from_env({}) == DEFAULT_BOOTSTRAP_HTTPS_PORT
@@ -49,7 +87,7 @@ def test_index_serves_setup_wizard_when_unconfigured(tmp_path: Path) -> None:
 
 def test_index_serves_status_page_when_configured(tmp_path: Path) -> None:
     config_file = tmp_path / "config.toml"
-    config_file.write_text("already = true\n", encoding="utf-8")
+    config_file.write_text(_FULL_CONFIG, encoding="utf-8")
     client = TestClient(create_management_app(config_file=config_file))
 
     response = client.get("/")
@@ -58,14 +96,29 @@ def test_index_serves_status_page_when_configured(tmp_path: Path) -> None:
     assert "Setup is complete" in response.text
 
 
-def test_setup_status_reflects_config_existence(tmp_path: Path) -> None:
+def test_index_serves_admin_only_wizard_when_only_admin_missing(tmp_path: Path) -> None:
     config_file = tmp_path / "config.toml"
+    config_file.write_text(_PARTIAL_CONFIG_MISSING_ADMIN, encoding="utf-8")
     client = TestClient(create_management_app(config_file=config_file))
 
-    assert client.get("/admin/api/setup/status").json() == {"configured": False}
+    response = client.get("/admin")
+
+    assert response.status_code == 200
+    assert "Just add admin credentials" in response.text
+    assert 'name="stack_fqdn"' not in response.text
 
-    config_file.write_text("done = true\n", encoding="utf-8")
-    assert client.get("/admin/api/setup/status").json() == {"configured": True}
+
+def test_setup_status_reflects_config_state(tmp_path: Path) -> None:
+    config_file = tmp_path / "config.toml"
+    client = TestClient(create_management_app(config_file=config_file))
+
+    assert client.get("/admin/api/setup/status").json() == {"status": "invalid"}
+
+    config_file.write_text(_PARTIAL_CONFIG_MISSING_ADMIN, encoding="utf-8")
+    assert client.get("/admin/api/setup/status").json() == {"status": "missing_admin"}
+
+    config_file.write_text(_FULL_CONFIG, encoding="utf-8")
+    assert client.get("/admin/api/setup/status").json() == {"status": "ok"}
 
 
 def test_submit_setup_writes_bootable_config(tmp_path: Path) -> None:
@@ -120,17 +173,73 @@ def test_submit_setup_rejects_bad_stack_fqdn(tmp_path: Path) -> None:
     assert "must start with api-" in response.json()["error"]
 
 
-def test_submit_setup_refuses_to_overwrite_existing_config(tmp_path: Path) -> None:
+def test_submit_setup_refuses_to_overwrite_a_fully_configured_stack(tmp_path: Path) -> None:
     config_file = tmp_path / "config.toml"
-    config_file.write_text("existing = true\n", encoding="utf-8")
+    config_file.write_text(_FULL_CONFIG, encoding="utf-8")
     client = TestClient(create_management_app(config_file=config_file))
 
     response = client.post("/admin/api/setup", json=_VALID_PAYLOAD)
 
     assert response.status_code == 409
+    assert config_file.read_text(encoding="utf-8") == _FULL_CONFIG
+
+
+def test_submit_setup_refuses_to_overwrite_an_existing_broken_config(tmp_path: Path) -> None:
+    config_file = tmp_path / "config.toml"
+    config_file.write_text("existing = true\n", encoding="utf-8")
+    client = TestClient(create_management_app(config_file=config_file))
+
+    response = client.post("/admin/api/setup", json=_VALID_PAYLOAD)
+
+    assert response.status_code == 400
+    assert "Refusing to overwrite" in response.json()["error"]
     assert config_file.read_text(encoding="utf-8") == "existing = true\n"
 
 
+def test_submit_setup_completes_admin_only_config(tmp_path: Path) -> None:
+    config_file = tmp_path / "config.toml"
+    config_file.write_text(_PARTIAL_CONFIG_MISSING_ADMIN, encoding="utf-8")
+    calls: list[str] = []
+    client = TestClient(create_management_app(config_file=config_file, on_configured=lambda: calls.append("done")))
+
+    response = client.post(
+        "/admin/api/setup",
+        json={
+            "admin_password": "super-secret-password",
+            "protocol_login_email": "user@example.com",
+            "protocol_login_pin": "123456",
+        },
+    )
+
+    assert response.status_code == 200
+    assert calls == ["done"]
+
+    config = load_config(config_file)
+    # Network/broker/storage/tls came from the pre-existing partial config, untouched.
+    assert config.network.stack_fqdn == "api-roborock.example.com"
+    assert config.admin.protocol_login_email == "user@example.com"
+    assert str(config.admin.password_hash).startswith("pbkdf2_sha256$")
+
+
+def test_submit_setup_admin_only_rejects_bad_pin(tmp_path: Path) -> None:
+    config_file = tmp_path / "config.toml"
+    config_file.write_text(_PARTIAL_CONFIG_MISSING_ADMIN, encoding="utf-8")
+    client = TestClient(create_management_app(config_file=config_file))
+
+    response = client.post(
+        "/admin/api/setup",
+        json={
+            "admin_password": "super-secret-password",
+            "protocol_login_email": "user@example.com",
+            "protocol_login_pin": "12",
+        },
+    )
+
+    assert response.status_code == 400
+    assert "6 digits" in response.json()["error"]
+    assert diagnose_config(config_file) == "missing_admin"
+
+
 def test_submit_setup_cloudflare_actalis_requires_eab(tmp_path: Path) -> None:
     config_file = tmp_path / "config.toml"
     client = TestClient(create_management_app(config_file=config_file))

From 9a91992e7dc343eccc2c86d567fd0802c973e7d5 Mon Sep 17 00:00:00 2001
From: Colin Haven <10933733+virtitnerd@users.noreply.github.com>
Date: Sun, 27 Sep 2026 11:24:37 -0400
Subject: [PATCH 12/28] Document the hybrid env-vars + wizard flow

Co-Authored-By: Claude Sonnet 5 
(cherry picked from commit 7f83688b5c991908d21e50b113ab75a1a089ad55)
---
 docs/installation.md | 7 +++++++
 1 file changed, 7 insertions(+)

diff --git a/docs/installation.md b/docs/installation.md
index 499b64a..6d56b8e 100644
--- a/docs/installation.md
+++ b/docs/installation.md
@@ -107,6 +107,13 @@ If your model already has certificate notes on the tested-vacuums page, follow t
      uv run roborock-local-server configure
      ```
 
+   You can also combine the two: if `ROBOROCK_SERVER_*` env vars generate `config.toml`'s network/broker/TLS
+   settings but the admin ones (`ROBOROCK_SERVER_ADMIN_PASSWORD`, `ROBOROCK_SERVER_PROTOCOL_LOGIN_EMAIL`,
+   `ROBOROCK_SERVER_PROTOCOL_LOGIN_PIN`) are left unset, the container still boots into `/admin`, but the
+   wizard notices the rest is already configured and only asks for admin credentials. Useful for a
+   reverse-proxy or ACME setup you'd rather express as env vars/IaC, while still picking the admin password
+   in a browser instead of a compose file.
+
    The wizard asks for:
 
    - `stack_fqdn` (must start with `api-`)

From 0b96769102380a3ef10a4fdb3e767af206f2d479 Mon Sep 17 00:00:00 2001
From: Colin Haven <10933733+virtitnerd@users.noreply.github.com>
Date: Sun, 27 Sep 2026 11:25:28 -0400
Subject: [PATCH 13/28] Document the admin-optional env vars and hybrid setup
 flow

Co-Authored-By: Claude Sonnet 5 
(cherry picked from commit 1a2e24752de9b218bc047d66e639cf6412f20b2c)
---
 docs/installation.md | 26 +++++++++++++++++++++++---
 1 file changed, 23 insertions(+), 3 deletions(-)

diff --git a/docs/installation.md b/docs/installation.md
index 6d56b8e..5a5cb99 100644
--- a/docs/installation.md
+++ b/docs/installation.md
@@ -162,15 +162,24 @@ If you don't want to install `uv`/Python on the host just to run `configure`, yo
 Required variables:
 
 - `ROBOROCK_SERVER_STACK_FQDN`
-- `ROBOROCK_SERVER_ADMIN_PASSWORD`
-- `ROBOROCK_SERVER_PROTOCOL_LOGIN_EMAIL`
-- `ROBOROCK_SERVER_PROTOCOL_LOGIN_PIN` (6 digits)
 - Either `ROBOROCK_SERVER_CERT_FILE` + `ROBOROCK_SERVER_KEY_FILE` (bring your own certificate), or `ROBOROCK_SERVER_TLS_MODE=cloudflare_acme` + `ROBOROCK_SERVER_TLS_BASE_DOMAIN` + `ROBOROCK_SERVER_TLS_EMAIL` + `ROBOROCK_SERVER_CLOUDFLARE_TOKEN` (or `_CLOUDFLARE_TOKEN_FILE` for a Docker secret)
 
+The admin credentials are optional as a group:
+
+- Set all three of `ROBOROCK_SERVER_ADMIN_PASSWORD`, `ROBOROCK_SERVER_PROTOCOL_LOGIN_EMAIL`, and
+  `ROBOROCK_SERVER_PROTOCOL_LOGIN_PIN` (6 digits) for a fully headless boot straight into the running stack.
+- Leave all three unset, and the container still generates `config.toml`'s network/broker/TLS settings from
+  the env vars above, then boots into the setup wizard at `/admin` - which notices the rest is already
+  configured and asks only for admin credentials. Handy if you're driving the network/TLS/reverse-proxy side
+  from env vars or IaC, but would still rather pick the admin password in a browser.
+- Setting some but not all three is rejected with a clear error, to avoid a half-set credential.
+
 Commonly-set optional variables: `ROBOROCK_SERVER_HTTPS_PORT`, `ROBOROCK_SERVER_MQTT_TLS_PORT`, `ROBOROCK_SERVER_ADVERTISED_HTTPS_PORT`, `ROBOROCK_SERVER_ADVERTISED_MQTT_TLS_PORT`, `ROBOROCK_SERVER_LISTENER_MODE`, `ROBOROCK_SERVER_BROKER_MODE` + `ROBOROCK_SERVER_BROKER_HOST`. See `env_config.py` for the full list.
 
 **Important:** if you go this route, remove (or comment out) the `./config.toml:/app/config.toml:ro` line from `compose.yaml`. Docker will otherwise bind-mount a nonexistent host path as an empty file, which the container treats as an existing (but invalid) config and never falls through to the env vars. Keep the `./data:/data` and `./secrets:/run/secrets:ro` mounts.
 
+Fully headless (no browser step at all):
+
 ```bash
 export ROBOROCK_SERVER_STACK_FQDN=api-roborock.example.com
 export ROBOROCK_SERVER_ADMIN_PASSWORD=super-secret-password
@@ -183,6 +192,17 @@ export ROBOROCK_SERVER_CLOUDFLARE_TOKEN=your-cloudflare-api-token
 docker compose up -d --build
 ```
 
+Hybrid (network/TLS from env vars, admin credentials in the browser wizard at `/admin`):
+
+```bash
+export ROBOROCK_SERVER_STACK_FQDN=api-roborock.example.com
+export ROBOROCK_SERVER_TLS_MODE=cloudflare_acme
+export ROBOROCK_SERVER_TLS_BASE_DOMAIN=example.com
+export ROBOROCK_SERVER_TLS_EMAIL=acme@example.com
+export ROBOROCK_SERVER_CLOUDFLARE_TOKEN=your-cloudflare-api-token
+docker compose up -d --build
+```
+
 ## Method 2: Home Assistant Add-on
 
 Use [Home Assistant](home_assistant.md) as the installation guide if you want to run the stack as a Home Assistant add-on instead of Docker Compose.

From a3af14935c987c89f3123257a11d27af08600744 Mon Sep 17 00:00:00 2001
From: Colin Haven <10933733+virtitnerd@users.noreply.github.com>
Date: Sun, 27 Sep 2026 11:51:12 -0400
Subject: [PATCH 14/28] Add a redacted activity-log reader over the HTTP/MQTT
 jsonl logs

decompiled_http.jsonl / decompiled_mqtt.jsonl have no rotation and can
carry sensitive data (auth headers, onboarding localKey/passwords in
HTTP bodies, fully-decrypted device RPC payloads in MQTT entries). This
tails both files (they grow unbounded, so a full read is out) and
returns metadata-only summaries by default - method/path/topic/RPC
method name, sizes, timestamps, never raw headers/bodies/payloads.

ROBOROCK_SERVER_ACTIVITY_RAW=1 switches to complete, unredacted
entries - an explicit deployment-time opt-in, matching how rethink
exposes its own traffic view, rather than a per-view UI toggle.

Co-Authored-By: Claude Sonnet 5 
(cherry picked from commit 9c9943d5b1cbc92368e08066a0b83b24e4ac7133)
---
 src/roborock_local_server/activity_log.py | 133 ++++++++++++++++
 tests/test_activity_log.py                | 181 ++++++++++++++++++++++
 2 files changed, 314 insertions(+)
 create mode 100644 src/roborock_local_server/activity_log.py
 create mode 100644 tests/test_activity_log.py

diff --git a/src/roborock_local_server/activity_log.py b/src/roborock_local_server/activity_log.py
new file mode 100644
index 0000000..e3e22fc
--- /dev/null
+++ b/src/roborock_local_server/activity_log.py
@@ -0,0 +1,133 @@
+"""Redacted summary view over the HTTP/MQTT protocol jsonl logs, for the
+admin dashboard's Activity panel.
+
+decompiled_http.jsonl / decompiled_mqtt.jsonl (see AppPaths in config.py)
+have no rotation or size cap, and can contain sensitive data: HTTP request
+headers and onboarding-adjacent bodies can carry localKey/passwords, and
+MQTT entries carry fully-decrypted device RPC payloads. So by default this
+module only ever returns safe metadata (method/path/topic/RPC method name,
+sizes, timestamps) - never raw headers, bodies, or payloads.
+
+Set ROBOROCK_SERVER_ACTIVITY_RAW=1 to return complete, unredacted entries
+instead. This is an explicit, deployment-time operator opt-in (matching how
+rethink exposes its own traffic view), not a per-view toggle in the UI.
+"""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+from typing import Any, Mapping
+
+ENV_ACTIVITY_RAW = "ROBOROCK_SERVER_ACTIVITY_RAW"
+DEFAULT_LIMIT = 200
+MAX_LIMIT = 500
+
+_CHUNK_SIZE = 65536
+_MAX_TAIL_BYTES = 8_000_000
+
+
+def activity_raw_enabled(env: Mapping[str, str]) -> bool:
+    return str(env.get(ENV_ACTIVITY_RAW, "") or "").strip().lower() in {"1", "true", "yes", "on"}
+
+
+def _tail_lines(path: Path, max_lines: int) -> list[str]:
+    """The last `max_lines` lines of `path`, without reading the whole file.
+
+    These jsonl files grow unbounded, so a plain read-then-split is out.
+    """
+    if max_lines <= 0 or not path.exists():
+        return []
+    with path.open("rb") as handle:
+        handle.seek(0, 2)
+        position = handle.tell()
+        buffer = b""
+        bytes_read = 0
+        while position > 0 and buffer.count(b"\n") <= max_lines and bytes_read < _MAX_TAIL_BYTES:
+            read_size = min(_CHUNK_SIZE, position)
+            position -= read_size
+            handle.seek(position)
+            buffer = handle.read(read_size) + buffer
+            bytes_read += read_size
+    lines = buffer.split(b"\n")
+    if position > 0 and lines:
+        # The first entry is a partial line, unless we reached the start of the file.
+        lines = lines[1:]
+    text_lines = [line.decode("utf-8", errors="replace") for line in lines if line.strip()]
+    return text_lines[-max_lines:]
+
+
+def _parse_jsonl_lines(lines: list[str]) -> list[dict[str, Any]]:
+    entries: list[dict[str, Any]] = []
+    for line in lines:
+        try:
+            parsed = json.loads(line)
+        except json.JSONDecodeError:
+            continue
+        if isinstance(parsed, dict):
+            entries.append(parsed)
+    return entries
+
+
+def _summarize_http_entry(raw: dict[str, Any]) -> dict[str, Any]:
+    return {
+        "time": raw.get("time"),
+        "source": "http",
+        "method": raw.get("method"),
+        "path": raw.get("clean_path") or raw.get("raw_path"),
+        "route": raw.get("route"),
+        "did": raw.get("did"),
+        "pid": raw.get("pid"),
+        "remote": raw.get("remote"),
+        "body_len": raw.get("body_len"),
+    }
+
+
+def _summarize_mqtt_entry(raw: dict[str, Any]) -> dict[str, Any]:
+    decoded_messages = raw.get("decoded_messages")
+    rpc_methods: list[str] = []
+    if isinstance(decoded_messages, list):
+        for decoded in decoded_messages:
+            if not isinstance(decoded, dict):
+                continue
+            rpc = decoded.get("rpc")
+            if isinstance(rpc, dict) and rpc.get("method"):
+                rpc_methods.append(str(rpc.get("method")))
+    payload_hex = raw.get("payload_hex")
+    payload_len = len(payload_hex) // 2 if isinstance(payload_hex, str) else None
+    return {
+        "time": raw.get("time"),
+        "source": "mqtt",
+        "direction": raw.get("direction"),
+        "topic": raw.get("topic"),
+        "rpc_methods": rpc_methods,
+        "decoded": bool(decoded_messages),
+        "payload_len": payload_len,
+    }
+
+
+def read_recent_activity(
+    *,
+    http_jsonl_path: Path,
+    mqtt_jsonl_path: Path,
+    limit: int = DEFAULT_LIMIT,
+    raw: bool = False,
+) -> list[dict[str, Any]]:
+    """Most recent `limit` HTTP+MQTT activity entries, newest first.
+
+    `raw=True` returns complete, unredacted entries (see module docstring);
+    the default returns metadata-only summaries with no header/body/payload
+    content at all.
+    """
+    http_entries = _parse_jsonl_lines(_tail_lines(http_jsonl_path, limit))
+    mqtt_entries = _parse_jsonl_lines(_tail_lines(mqtt_jsonl_path, limit))
+
+    if raw:
+        combined = [{"source": "http", **entry} for entry in http_entries]
+        combined += [{"source": "mqtt", **entry} for entry in mqtt_entries]
+    else:
+        combined = [_summarize_http_entry(entry) for entry in http_entries]
+        combined += [_summarize_mqtt_entry(entry) for entry in mqtt_entries]
+
+    combined.sort(key=lambda entry: str(entry.get("time") or ""), reverse=True)
+    return combined[:limit]
diff --git a/tests/test_activity_log.py b/tests/test_activity_log.py
new file mode 100644
index 0000000..9f9d2bb
--- /dev/null
+++ b/tests/test_activity_log.py
@@ -0,0 +1,181 @@
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+from roborock_local_server.activity_log import (
+    _tail_lines,
+    activity_raw_enabled,
+    read_recent_activity,
+)
+
+
+def _write_jsonl(path: Path, entries: list[dict]) -> None:
+    path.write_text("\n".join(json.dumps(entry) for entry in entries) + "\n", encoding="utf-8")
+
+
+def test_activity_raw_enabled_default_false() -> None:
+    assert activity_raw_enabled({}) is False
+
+
+def test_activity_raw_enabled_true_values() -> None:
+    for value in ("1", "true", "TRUE", "yes", "on"):
+        assert activity_raw_enabled({"ROBOROCK_SERVER_ACTIVITY_RAW": value}) is True
+
+
+def test_activity_raw_enabled_false_values() -> None:
+    for value in ("0", "false", "no", "off", ""):
+        assert activity_raw_enabled({"ROBOROCK_SERVER_ACTIVITY_RAW": value}) is False
+
+
+def test_tail_lines_missing_file_returns_empty(tmp_path: Path) -> None:
+    assert _tail_lines(tmp_path / "missing.jsonl", 10) == []
+
+
+def test_tail_lines_returns_last_n_in_order(tmp_path: Path) -> None:
+    path = tmp_path / "log.jsonl"
+    path.write_text("\n".join(f"line-{i}" for i in range(1, 21)) + "\n", encoding="utf-8")
+
+    assert _tail_lines(path, 3) == ["line-18", "line-19", "line-20"]
+
+
+def test_tail_lines_handles_chunk_boundary(tmp_path: Path) -> None:
+    # Force multiple internal read chunks by writing a file bigger than _CHUNK_SIZE.
+    path = tmp_path / "big.jsonl"
+    lines = [f"line-{i}-{'x' * 200}" for i in range(1, 2000)]
+    path.write_text("\n".join(lines) + "\n", encoding="utf-8")
+
+    tailed = _tail_lines(path, 5)
+
+    assert tailed == lines[-5:]
+
+
+def test_tail_lines_zero_or_negative_limit(tmp_path: Path) -> None:
+    path = tmp_path / "log.jsonl"
+    path.write_text("line-1\n", encoding="utf-8")
+
+    assert _tail_lines(path, 0) == []
+
+
+def test_read_recent_activity_redacts_by_default(tmp_path: Path) -> None:
+    http_path = tmp_path / "http.jsonl"
+    mqtt_path = tmp_path / "mqtt.jsonl"
+    _write_jsonl(
+        http_path,
+        [
+            {
+                "time": "2026-01-01T00:00:00Z",
+                "method": "POST",
+                "clean_path": "/api/v1/user/login",
+                "headers": {"authorization": "Bearer super-secret"},
+                "body_json": {"localkey": "abc123", "password": "hunter2"},
+                "body_len": 42,
+                "route": "login",
+                "remote": "10.0.0.5:443",
+            }
+        ],
+    )
+    _write_jsonl(
+        mqtt_path,
+        [
+            {
+                "time": "2026-01-01T00:00:01Z",
+                "direction": "in",
+                "topic": "rr/abc/duid1",
+                "payload_hex": "aabbcc",
+                "decoded_messages": [{"rpc": {"method": "get_status", "params": ["secret-token"]}}],
+            }
+        ],
+    )
+
+    entries = read_recent_activity(http_jsonl_path=http_path, mqtt_jsonl_path=mqtt_path)
+
+    assert len(entries) == 2
+    serialized = json.dumps(entries)
+    assert "super-secret" not in serialized
+    assert "hunter2" not in serialized
+    assert "abc123" not in serialized
+    assert "secret-token" not in serialized
+
+    http_entry = next(e for e in entries if e["source"] == "http")
+    assert http_entry["method"] == "POST"
+    assert http_entry["path"] == "/api/v1/user/login"
+    assert "headers" not in http_entry
+    assert "body_json" not in http_entry
+
+    mqtt_entry = next(e for e in entries if e["source"] == "mqtt")
+    assert mqtt_entry["topic"] == "rr/abc/duid1"
+    assert mqtt_entry["rpc_methods"] == ["get_status"]
+    assert "payload_hex" not in mqtt_entry
+    assert "decoded_messages" not in mqtt_entry
+
+
+def test_read_recent_activity_raw_mode_returns_full_entries(tmp_path: Path) -> None:
+    http_path = tmp_path / "http.jsonl"
+    mqtt_path = tmp_path / "mqtt.jsonl"
+    _write_jsonl(
+        http_path,
+        [
+            {
+                "time": "2026-01-01T00:00:00Z",
+                "method": "POST",
+                "headers": {"authorization": "Bearer super-secret"},
+            }
+        ],
+    )
+    _write_jsonl(mqtt_path, [])
+
+    entries = read_recent_activity(http_jsonl_path=http_path, mqtt_jsonl_path=mqtt_path, raw=True)
+
+    assert len(entries) == 1
+    assert entries[0]["source"] == "http"
+    assert entries[0]["headers"] == {"authorization": "Bearer super-secret"}
+
+
+def test_read_recent_activity_sorts_newest_first_across_sources(tmp_path: Path) -> None:
+    http_path = tmp_path / "http.jsonl"
+    mqtt_path = tmp_path / "mqtt.jsonl"
+    _write_jsonl(http_path, [{"time": "2026-01-01T00:00:00Z", "method": "GET", "clean_path": "/a"}])
+    _write_jsonl(mqtt_path, [{"time": "2026-01-01T00:00:05Z", "topic": "rr/x"}])
+
+    entries = read_recent_activity(http_jsonl_path=http_path, mqtt_jsonl_path=mqtt_path)
+
+    assert [entry["source"] for entry in entries] == ["mqtt", "http"]
+
+
+def test_read_recent_activity_missing_files_returns_empty(tmp_path: Path) -> None:
+    entries = read_recent_activity(
+        http_jsonl_path=tmp_path / "missing_http.jsonl",
+        mqtt_jsonl_path=tmp_path / "missing_mqtt.jsonl",
+    )
+    assert entries == []
+
+
+def test_read_recent_activity_skips_malformed_lines(tmp_path: Path) -> None:
+    http_path = tmp_path / "http.jsonl"
+    mqtt_path = tmp_path / "mqtt.jsonl"
+    http_path.write_text(
+        "not json at all\n" + json.dumps({"time": "2026-01-01T00:00:00Z", "method": "GET"}) + "\n",
+        encoding="utf-8",
+    )
+    _write_jsonl(mqtt_path, [])
+
+    entries = read_recent_activity(http_jsonl_path=http_path, mqtt_jsonl_path=mqtt_path)
+
+    assert len(entries) == 1
+    assert entries[0]["method"] == "GET"
+
+
+def test_read_recent_activity_respects_limit(tmp_path: Path) -> None:
+    http_path = tmp_path / "http.jsonl"
+    mqtt_path = tmp_path / "mqtt.jsonl"
+    _write_jsonl(
+        http_path,
+        [{"time": f"2026-01-01T00:00:{i:02d}Z", "method": "GET", "clean_path": "/a"} for i in range(10)],
+    )
+    _write_jsonl(mqtt_path, [])
+
+    entries = read_recent_activity(http_jsonl_path=http_path, mqtt_jsonl_path=mqtt_path, limit=3)
+
+    assert len(entries) == 3
+    assert entries[0]["time"] == "2026-01-01T00:00:09Z"

From 28557f91dc5a3b03c16de38d0d0151c939ac1fb5 Mon Sep 17 00:00:00 2001
From: Colin Haven <10933733+virtitnerd@users.noreply.github.com>
Date: Sun, 27 Sep 2026 11:51:21 -0400
Subject: [PATCH 15/28] Wire the Activity panel into the admin dashboard

Adds ReleaseSupervisor._activity_payload() and GET /admin/api/activity
(auth-gated like every other admin route, ?limit= clamped to 1-500),
plus an Activity section on the dashboard following the existing
2s-polling pattern - there's no real-time push mechanism in this app
today (websockets/SSE), so this matches how every other panel already
refreshes.

Note: a full-suite run flagged test_admin_onboarding_start_is_blocked_when_new_connections_disabled
as failing once (256 passed/1 failed), but it passes reliably alone,
with just this file plus test_activity_log.py, and on a clean rerun of
the entire suite (257/257). AdminSessionManager (security.py) is
stateless and per-instance with no shared/global auth state for this
change to touch, so this looks like a pre-existing flaky/timing-
sensitive test that more total tests nudged into failing once, not a
regression from this change.

Co-Authored-By: Claude Sonnet 5 
(cherry picked from commit dd825d22f001c7cdfd88f8ae92d30b8997c8f64a)
---
 src/roborock_local_server/server.py           |  11 ++
 src/roborock_local_server/standalone_admin.py |  63 +++++++++++
 tests/test_admin_api.py                       | 104 ++++++++++++++++++
 3 files changed, 178 insertions(+)

diff --git a/src/roborock_local_server/server.py b/src/roborock_local_server/server.py
index 0d5fc41..8375e06 100644
--- a/src/roborock_local_server/server.py
+++ b/src/roborock_local_server/server.py
@@ -24,6 +24,7 @@
 import uvicorn
 from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
 
+from .activity_log import activity_raw_enabled, read_recent_activity
 from .certs import CertificateManager
 from .bundled_backend.shared.constants import DEFAULT_PRODUCT_SCHEMA
 from .bundled_backend.shared.data_helpers import utcnow_iso
@@ -1492,6 +1493,16 @@ def _pending_device_mqtt_recovery_payload(self) -> list[dict[str, str]]:
             )
         return devices
 
+    def _activity_payload(self, *, limit: int) -> dict[str, Any]:
+        raw = activity_raw_enabled(os.environ)
+        entries = read_recent_activity(
+            http_jsonl_path=self.paths.http_jsonl_path,
+            mqtt_jsonl_path=self.paths.mqtt_jsonl_path,
+            limit=limit,
+            raw=raw,
+        )
+        return {"raw": raw, "entries": entries}
+
     def _auth_payload(self) -> dict[str, Any]:
         sessions = [
             session
diff --git a/src/roborock_local_server/standalone_admin.py b/src/roborock_local_server/standalone_admin.py
index e1c24dd..32191f5 100644
--- a/src/roborock_local_server/standalone_admin.py
+++ b/src/roborock_local_server/standalone_admin.py
@@ -75,6 +75,11 @@ def _admin_dashboard_html(project_support: dict[str, Any]) -> str:
           
Loading sessions...
+

Activity

+
+
+
+

Health

Vacuums

' + +NAV_HTML = dedent( + """\ + + """ +) + +STYLE_CSS = dedent( + """\ + :root { + --rls-bg: #f3f5f8; + --rls-surface: #ffffff; + --rls-nav: #12263a; + --rls-accent: #2f6fed; + --rls-accent-dark: #1c4fc4; + --rls-text: #1a1f29; + --rls-text-muted: #5a6572; + --rls-border: #e2e6ea; + --rls-danger-bg: #fdeced; + --rls-danger-text: #8a1620; + --rls-danger-border: #f5c2c7; + --rls-success-bg: #e8f8ee; + --rls-success-text: #0f5a33; + --rls-success-border: #b7e4c7; + --rls-warn-bg: #fff7ed; + --rls-warn-text: #7c2d12; + --rls-warn-border: #fdd8ab; + color-scheme: light; + } + + @media (prefers-color-scheme: dark) { + :root { + --rls-bg: #12161d; + --rls-surface: #1a1f28; + --rls-nav: #0c1520; + --rls-accent: #6d9bff; + --rls-accent-dark: #8fb1ff; + --rls-text: #e6e9ef; + --rls-text-muted: #93a0b3; + --rls-border: #2a303c; + --rls-danger-bg: #3a1a1e; + --rls-danger-text: #ff9aa4; + --rls-danger-border: #5c2830; + --rls-success-bg: #16301f; + --rls-success-text: #7fe0a4; + --rls-success-border: #285c3a; + --rls-warn-bg: #3a2a1a; + --rls-warn-text: #ffb066; + --rls-warn-border: #5c4222; + color-scheme: dark; + } + + .modal, .modal .modal-footer, .dropdown-content { background-color: var(--rls-surface); } + .dropdown-content li:hover, .dropdown-content li.active { background-color: var(--rls-border); } + .input-field input[type="text"]:not(.valid), + .input-field input[type="password"]:not(.valid), + .input-field input[type="email"]:not(.valid), + .input-field input[type="number"]:not(.valid) { + border-bottom-color: var(--rls-border); + color: var(--rls-text); + } + .card, .card-panel { background-color: var(--rls-surface); color: var(--rls-text); } + label { color: var(--rls-text-muted); } + } + + body { + background: var(--rls-bg); + color: var(--rls-text); + font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + } + + /* Materialize's nav is a fixed 56px and never grows for a real title - see rethink's own + style.css for the same fix (verified against Materialize 1.0.0's actual CSS). */ + nav { + background: var(--rls-nav) !important; + box-shadow: 0 2px 8px rgba(18, 38, 58, .25); + height: auto; + min-height: 56px; + } + .nav-wrapper { height: auto; min-height: 56px; } + nav .brand-logo { + position: static; left: auto; transform: none; + display: flex; align-items: center; flex-wrap: wrap; gap: .4em; + font-weight: 600; font-size: 1.25rem; letter-spacing: .01em; padding: .6em 0; + } + nav .brand-logo .material-icons { color: var(--rls-accent); } + + .container:not(.nav-wrapper) { + background: var(--rls-surface); + border: 1px solid var(--rls-border); + border-radius: 10px; + padding: 1.25em 1.5em; + margin-top: 1.25em; + margin-bottom: 1.25em; + box-shadow: 0 1px 3px rgba(26, 31, 41, .06); + } + + .header.orange-text, h4.header { + color: var(--rls-accent-dark) !important; + font-weight: 600; + font-size: 1.2rem; + letter-spacing: .01em; + } + + .btn, .btn-large, .btn-small { + background-color: var(--rls-accent); + border-radius: 7px; + box-shadow: none; + text-transform: none; + font-weight: 500; + } + .btn:hover, .btn-large:hover, .btn-small:hover { + background-color: var(--rls-accent-dark); + box-shadow: 0 2px 6px rgba(28, 79, 196, .35); + } + .btn:focus-visible, .btn-large:focus-visible, .btn-small:focus-visible, .btn-flat:focus-visible { + outline: 2px solid var(--rls-accent); + outline-offset: 2px; + } + .btn-flat { color: var(--rls-accent-dark); } + + .input-field input:focus + label, + .input-field input.valid:focus { color: var(--rls-accent-dark) !important; } + .input-field input:focus { + border-bottom: 1px solid var(--rls-accent-dark) !important; + box-shadow: 0 1px 0 0 var(--rls-accent-dark) !important; + } + .switch label input[type="checkbox"]:checked + .lever { background-color: rgba(47, 111, 237, .5); } + .switch label input[type="checkbox"]:checked + .lever:after { background-color: var(--rls-accent-dark); } + + table.highlight { border-collapse: collapse; } + table.highlight thead th { + color: var(--rls-text-muted); font-size: .78rem; text-transform: uppercase; + letter-spacing: .04em; border-bottom: 2px solid var(--rls-border); + } + table.highlight tbody tr { border-bottom: 1px solid var(--rls-border); } + table.highlight tbody tr:hover { background: rgba(47, 111, 237, .06); } + + .alert-banner { + border-radius: 8px; + padding: .7em .9em; + margin-top: .9em; + font-size: .92em; + line-height: 1.5; + white-space: pre-wrap; + display: none; + } + .alert-banner.shown { display: block; } + .alert-banner.error { color: var(--rls-danger-text); background: var(--rls-danger-bg); border: 1px solid var(--rls-danger-border); } + .alert-banner.success { color: var(--rls-success-text); background: var(--rls-success-bg); border: 1px solid var(--rls-success-border); } + + .rls-muted { color: var(--rls-text-muted); } + .rls-card-empty { color: var(--rls-text-muted); } + .rls-badge { + display: inline-block; text-transform: uppercase; font-size: .68rem; font-weight: 600; + letter-spacing: .03em; border-radius: 4px; padding: .15em .5em; + } + .rls-badge.mqtt { background: #e4ecff; color: #2a4d8f; } + .rls-badge.http { background: #eef0f3; color: #3d4a5c; } + .rls-badge.mitm { background: #fdece0; color: #a2540f; } + @media (prefers-color-scheme: dark) { + .rls-badge.mqtt { background: #1c2a4a; color: #9db8f5; } + .rls-badge.http { background: #262c36; color: #b7c0cc; } + .rls-badge.mitm { background: #3a2a1a; color: #ffb066; } + } + + .rls-activity-list { max-height: 26em; overflow-y: auto; } + .rls-activity-entry { + display: flex; align-items: baseline; gap: .6em; padding: .35em 0; + border-bottom: 1px solid var(--rls-border); font-size: .85em; flex-wrap: wrap; + } + .rls-activity-entry:last-child { border-bottom: none; } + .rls-activity-time { color: var(--rls-text-muted); flex-shrink: 0; font-family: monospace; font-size: .92em; } + .rls-activity-detail { word-break: break-word; } + .rls-activity-raw { width: 100%; margin: .3em 0 0; font-family: monospace; font-size: .85em; white-space: pre-wrap; } + + .rls-pre { + background: var(--rls-bg); border: 1px solid var(--rls-border); border-radius: 8px; + padding: .8em 1em; font-family: monospace; font-size: .85em; white-space: pre-wrap; + word-break: break-word; max-height: 22em; overflow: auto; color: var(--rls-text); + } + + @media (max-width: 480px) { + .container:not(.nav-wrapper) { padding: 1em; margin-top: .75em; margin-bottom: .75em; } + } + """ +) + + +def register_theme_routes(app: FastAPI) -> None: + @app.get(STYLE_CSS_PATH) + async def admin_style_css() -> PlainTextResponse: + return PlainTextResponse(STYLE_CSS, media_type="text/css") From 255617e5824cc270d3dc79846fc6ba1188773a06 Mon Sep 17 00:00:00 2001 From: Colin Haven <10933733+virtitnerd@users.noreply.github.com> Date: Sun, 27 Sep 2026 17:32:29 -0400 Subject: [PATCH 21/28] Redesign the Setup Wizard with the shared theme Real Materialize form fields/radios/select/buttons instead of hand- built fieldset/input markup, using the same nav/theme as the real dashboard. All required page text and element IDs the wizard's own JS/tests depend on are unchanged; only markup and styling moved. Co-Authored-By: Claude Sonnet 5 (cherry picked from commit 6b1b00b5875d7703bc1a7acaab152219da941c75) --- src/roborock_local_server/management_app.py | 538 ++++++++++++-------- 1 file changed, 318 insertions(+), 220 deletions(-) diff --git a/src/roborock_local_server/management_app.py b/src/roborock_local_server/management_app.py index 421130d..76e0e74 100644 --- a/src/roborock_local_server/management_app.py +++ b/src/roborock_local_server/management_app.py @@ -29,6 +29,7 @@ hash_password, write_config_setup, ) +from .web_theme import HEAD_ASSETS, NAV_HTML, SCRIPT_ASSETS, register_theme_routes DEFAULT_BOOTSTRAP_HTTPS_PORT = 555 _ENV_HTTPS_PORT = "ROBOROCK_SERVER_HTTPS_PORT" @@ -183,239 +184,335 @@ def complete_admin_section( def _status_html() -> str: - return dedent( - """\ - -

Roborock Local Server

-

Setup is complete. The stack is restarting into the full dashboard at this same address - - reload in a few seconds.

- - """ + return ( + dedent( + """\ + + Roborock Local Server + """ + ) + + HEAD_ASSETS + + dedent( + """\ + + """ + ) + + NAV_HTML + + dedent( + """\ +
+

Setup is complete

+

The stack is restarting into the full dashboard at this same address - reload in a few + seconds.

+
+ """ + ) + + SCRIPT_ASSETS + + "\n\n" ) +_WIZARD_SCRIPT_HEAD = dedent( + """\ + + """ +) + + def _setup_wizard_html() -> str: - return dedent( - """\ - - Roborock Local Server Setup - - -

Roborock Local Server Setup

-

This runs once, the first time the stack boots without a config.toml. Fill this in, submit, and the - container will restart into the full HTTPS/MQTT stack.

-
-
- Network - -
-
-
-
-
- -
- MQTT Broker - - - -
- -
- Certificates - - -
- - - - -
- -
- Admin Access - - -
- -
- App / Home Assistant Login - - - -
- - - -

-        
-        
-        
-        """
+
+            """
+        )
+        + _WIZARD_SCRIPT_SUBMIT_COMMON
+        + "\n"
     )
 
 
 def _admin_only_wizard_html() -> str:
-    return dedent(
-        """\
-        
-        Roborock Local Server Setup
-        
-        
-        

Roborock Local Server Setup

-

Network, broker, and certificate settings are already set (from environment variables). - Just add admin credentials to finish setup.

-
-
- Admin Access - - -
- -
- App / Home Assistant Login - - - -
- - -
-

-        
-        
-        
-        """
+    return (
+        dedent(
+            """\
+            
+            Roborock Local Server Setup
+            """
+        )
+        + HEAD_ASSETS
+        + dedent(
+            """\
+            
+            """
+        )
+        + NAV_HTML
+        + dedent(
+            """\
+            
+

Network, broker, and certificate settings are already set (from environment + variables). Just add admin credentials to finish setup.

+
+
Admin Access
+
+
+ + +
+
+ + +
+
+
+ +
App / Home Assistant Login
+
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ """ + ) + + SCRIPT_ASSETS + + "\n" + + _WIZARD_SCRIPT_HEAD + + _WIZARD_SCRIPT_SUBMIT_COMMON + + "\n" ) @@ -425,6 +522,7 @@ def create_management_app( on_configured: Callable[[], None] | None = None, ) -> FastAPI: app = FastAPI(title="Roborock Local Server Setup", docs_url=None, redoc_url=None, openapi_url=None) + register_theme_routes(app) @app.get("/", response_class=HTMLResponse) @app.get("/admin", response_class=HTMLResponse) From bc0486d7019ce1708e2b4cac55ace6b4382269ae Mon Sep 17 00:00:00 2001 From: Colin Haven <10933733+virtitnerd@users.noreply.github.com> Date: Sun, 27 Sep 2026 17:32:37 -0400 Subject: [PATCH 22/28] Redesign the admin dashboard with the shared theme Login page and dashboard now use the same Materialize theme as the Setup Wizard - real form fields, buttons, nav, cards - instead of raw inline-styled markup. Every element ID the existing JS (refresh(), renderAuth(), renderActivity(), etc.) depends on is unchanged; only markup/styling moved. Dynamically-created cards (vacuum summary, session list, activity feed) now use theme CSS variables instead of hardcoded light-mode hex colors, so they stay readable in dark mode. Also fixes a real XSS-shaped bug introduced partway through this redesign and caught before commit: the activity feed's per-entry badge was briefly built via innerHTML with server-reported traffic fields (method/path/topic/host) interpolated directly in - the same bug class already found and fixed in rethink's own device table. Rewritten with DOM APIs (createElement/textContent) only; no interpolated HTML anywhere in this file. Co-Authored-By: Claude Sonnet 5 (cherry picked from commit c045dca63de30052845f2d5e6eaa563a72555264) --- src/roborock_local_server/standalone_admin.py | 277 ++++++++++++------ 1 file changed, 185 insertions(+), 92 deletions(-) diff --git a/src/roborock_local_server/standalone_admin.py b/src/roborock_local_server/standalone_admin.py index 17c91cb..f5a741e 100644 --- a/src/roborock_local_server/standalone_admin.py +++ b/src/roborock_local_server/standalone_admin.py @@ -10,33 +10,63 @@ from fastapi.responses import HTMLResponse, JSONResponse from .security import verify_password +from .web_theme import HEAD_ASSETS, NAV_HTML, SCRIPT_ASSETS, register_theme_routes def _admin_login_html() -> str: - return dedent( - """\ - -

Roborock Local Server

-

Sign in to manage the stack.

- - -

-        
-        """
+    return (
+        dedent(
+            """\
+            
+            Roborock Local Server
+            """
+        )
+        + HEAD_ASSETS
+        + dedent(
+            """\
+            
+            """
+        )
+        + NAV_HTML
+        + dedent(
+            """\
+            
+

Sign in to manage the stack.

+
+
+ + +
+
+ +
+
+ """ + ) + + SCRIPT_ASSETS + + dedent( + """\ + + """ + ) ) @@ -44,44 +74,92 @@ def _admin_dashboard_html(project_support: dict[str, Any]) -> str: support_payload = json.dumps(project_support) return dedent( f"""\ - -
-

Roborock Local Server

-
Loading
+ + Roborock Local Server + {HEAD_ASSETS} + + + +
+

Vacuums

+
Loading vacuums...
+
+ +
+

+

+
-

Vacuums

Loading vacuums...
-

-

Cloud Import

- - - - -
No cloud request yet.
-
-

New Connections

- - -
Loading connection state...
-
+ +
+

Cloud Import

+
+
+ + +
+
+ +
+
+
+
+ + +
+
+ +
+
+
No cloud request yet.
+
+ +
+

New Connections

+

+ +

+ +
Loading connection state...
+
Protocol Sync Secret
-
- - +
+
+ +
+
+ +
-
Use this with mitm_redirect.py --sync-secret ....
+
Use this with mitm_redirect.py --sync-secret ....
-
-
Protocol Sessions
+
+
Protocol Sessions
Loading sessions...
-
+
-

Activity

-
-
-
+
+

Activity

+
+
+
-

Health

-

Vacuums

+
+

Health

+

+        
+
+

Vacuums (raw)

+

+        
+ {SCRIPT_ASSETS}