diff --git a/docs/onboarding.md b/docs/onboarding.md index 62d717b..f1ebdc3 100644 --- a/docs/onboarding.md +++ b/docs/onboarding.md @@ -16,6 +16,8 @@ If you omit the port, the CLI assumes the default local stack HTTPS port `555`. This is a standalone script — you can copy `start_onboarding.py` to any machine and run it with just `uv`. +If you omit the port, the CLI assumes the default local stack HTTPS port `555`. If your stack uses a custom HTTPS port, include it in `--server`, for example `api-roborock.example.com:8443`. + The guided CLI will: 1. Log into the main server with your admin password. @@ -82,7 +84,7 @@ Congrats! Once the script reports that the vacuum is connected to the local serv ## Web UI (start_onboarding_gui.py) -If you would rather not use the terminal, there is a web UI version of the same flow. It is a standalone script that runs a small local server on your machine and opens your browser automatically: +If you would rather not use the terminal, there is a web UI version of the same flow. It runs a small local server on your machine and opens your browser automatically: ```bash uv run start_onboarding_gui.py @@ -90,6 +92,8 @@ uv run start_onboarding_gui.py No CLI flags. All configuration happens in the browser form on first load. +Enter the same server host you use for `/admin`. If your stack runs on a custom HTTPS port, include it in the form, for example `api-roborock.example.com:8443`. + The main reason to use the GUI version is that this flow makes you switch your machine between your normal Wi-Fi and the vacuum's Wi-Fi hotspot several times. A browser talking to `127.0.0.1` keeps working through those switches. The CLI version can get into a bad state if a blocking network call hits while you are still on the vacuum hotspot. ### What it does on startup @@ -115,6 +119,8 @@ A live log pane below the stepper shows every packet, status check, and state tr Your inputs live only in memory for the duration of the run and are discarded when you click Quit or shut down the server. +Unlike `start_onboarding.py`, the GUI flow is a two-file standalone bundle: keep `start_onboarding_gui.py` and `ui.html` together. + ### Same caveats as the CLI Everything in "What To Expect" above still applies. Some vacuums need 2-4 cycles, the Wi-Fi reset on the vacuum is still manual, and the POSIX TZ examples are the same. Only the interface changed, the underlying packet flow is identical. diff --git a/start_onboarding_gui.py b/start_onboarding_gui.py index d4337cc..89371e5 100644 --- a/start_onboarding_gui.py +++ b/start_onboarding_gui.py @@ -48,6 +48,7 @@ CFGWIFI_UID = "1234567890" DEFAULT_COUNTRY_DOMAIN = "us" DEFAULT_TIMEZONE = "America/New_York" +DEFAULT_STACK_HTTPS_PORT = 555 POLL_INTERVAL_SECONDS = 5.0 POLL_TIMEOUT_SECONDS = 300.0 @@ -176,32 +177,49 @@ def recv_with_timeout(sock: socket.socket, timeout: float) -> bytes | None: def sanitize_stack_server(url: str) -> str: - value = str(url or "").strip() - for prefix in ("https://", "http://"): - if value.lower().startswith(prefix): - value = value[len(prefix) :] - value = value.strip().strip("/") - if value.lower().startswith("api-"): - value = value[4:] - if not value: + host, port = _parse_server_target(url, default_port=DEFAULT_STACK_HTTPS_PORT) + if host.lower().startswith("api-"): + host = host[4:] + authority = _format_authority(host, port=port, default_port=443) + if not authority: raise ValueError("A server host is required.") - return f"{value}/" + return f"{authority}/" def normalize_api_base_url(url: str) -> str: + host, port = _parse_server_target(url, default_port=DEFAULT_STACK_HTTPS_PORT) + if not host.lower().startswith("api-"): + host = f"api-{host}" + authority = _format_authority(host, port=port, default_port=443) + return f"https://{authority}" + + +def _parse_server_target(url: str, *, default_port: int | None = None) -> tuple[str, int | None]: value = str(url or "").strip() if not value: raise ValueError("A server host is required.") - for prefix in ("https://", "http://"): - if value.lower().startswith(prefix): - value = value[len(prefix) :] - break - value = value.strip().strip("/") - if not value: + parsed = parse.urlsplit(value if "://" in value else f"//{value}") + host = str(parsed.hostname or "").strip().strip("/") + if not host: raise ValueError("A server host is required.") - if not value.lower().startswith("api-"): - value = f"api-{value}" - return f"https://{value}" + try: + port = parsed.port + except ValueError as exc: + raise ValueError("Server port must be numeric.") from exc + if port is None: + port = default_port + return host, port + + +def _format_authority(host: str, *, port: int | None = None, default_port: int | None = None) -> str: + normalized_host = str(host or "").strip().strip("/") + if not normalized_host: + return "" + if port is None: + return normalized_host + if default_port is not None and port == default_port: + return normalized_host + return f"{normalized_host}:{port}" def _format_bool_label(value: bool, true_label: str, false_label: str) -> str: @@ -391,9 +409,9 @@ def onboard_once(config: GuidedOnboardingConfig, output: TextIO = sys.stdout) -> } wifi_pkt = build_wifi_packet(session_key, body) sock.sendto(wifi_pkt, target) - output.write(f"TOKEN_S=\n") + output.write(f"TOKEN_S={token_s}\n") output.write(f"TOKEN_T=\n") - redacted_body = {**body, "passwd": "", "token": {**body["token"], "s": "", "t": ""}} + redacted_body = {**body, "passwd": "", "token": {**body["token"], "t": ""}} output.write(f"WIFI_BODY_SENT={json.dumps(redacted_body, separators=(',', ':'))}\n") wifi_resp = recv_with_timeout(sock, CFGWIFI_TIMEOUT_SECONDS) @@ -1033,4 +1051,4 @@ def main() -> int: if __name__ == "__main__": - raise SystemExit(main()) \ No newline at end of file + raise SystemExit(main()) diff --git a/tests/test_onboarding_gui.py b/tests/test_onboarding_gui.py new file mode 100644 index 0000000..57b5adb --- /dev/null +++ b/tests/test_onboarding_gui.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +import pytest + +from start_onboarding_gui import normalize_api_base_url, sanitize_stack_server + + +@pytest.mark.parametrize( + ("server", "expected_api_base", "expected_stack_server"), + [ + ( + "api-roborock.example.com", + "https://api-roborock.example.com:555", + "roborock.example.com:555/", + ), + ( + "api-roborock.example.com:8443", + "https://api-roborock.example.com:8443", + "roborock.example.com:8443/", + ), + ( + "https://roborock.example.com:8443/", + "https://api-roborock.example.com:8443", + "roborock.example.com:8443/", + ), + ], +) +def test_gui_server_normalization_supports_default_and_custom_ports( + server: str, + expected_api_base: str, + expected_stack_server: str, +) -> None: + assert normalize_api_base_url(server) == expected_api_base + assert sanitize_stack_server(server) == expected_stack_server + + +def test_gui_server_normalization_rejects_non_numeric_port() -> None: + with pytest.raises(ValueError, match="Server port must be numeric."): + normalize_api_base_url("api-roborock.example.com:not-a-port") diff --git a/ui.html b/ui.html index c70890d..523f335 100644 --- a/ui.html +++ b/ui.html @@ -140,6 +140,16 @@ margin-top: 5px; } + .toggle-row { + display: inline-flex; + align-items: center; + gap: 6px; + margin-top: 6px; + color: var(--muted); + font-size: 12px; + cursor: pointer; + } + .row { display: grid; grid-template-columns: 1fr 1fr; @@ -408,6 +418,10 @@

Connection details

+
@@ -418,6 +432,10 @@

Connection details

+
@@ -521,6 +539,17 @@

Log

}[c])); } + function bindVisibilityToggle(toggleId, inputId) { + const toggle = $(toggleId); + const input = $(inputId); + if (!toggle || !input) return; + const sync = () => { + input.type = toggle.checked ? "text" : "password"; + }; + toggle.addEventListener("change", sync); + sync(); + } + function setStepper(phase) { const order = ["needs_config", "choosing_device", "awaiting_vacuum_wifi", "awaiting_normal_wifi", "done"]; const map = { @@ -787,6 +816,8 @@

Log

} $("cfg-submit").addEventListener("click", submitConfig); + bindVisibilityToggle("cfg-admin-show", "cfg-admin"); + bindVisibilityToggle("cfg-wifi-show", "cfg-wifi"); $("devices-refresh").addEventListener("click", () => api("POST", "/api/refresh-devices").catch((e) => alert(e.message))); $("devices-quit").addEventListener("click", quit); $("quit-link").addEventListener("click", (e) => { e.preventDefault(); quit(); }); @@ -796,4 +827,4 @@

Log

_pollInterval = setInterval(safePoll, 1000); - \ No newline at end of file +