From 3bf280ad03a49bc8ec8abc1efe1ffa87dedf5b7d Mon Sep 17 00:00:00 2001 From: Luke Date: Fri, 18 Sep 2026 11:50:29 -0400 Subject: [PATCH 1/2] Prepare stable v1.1.0 and simplify V2 onboarding --- docs/home_assistant.md | 16 +-- docs/onboarding.md | 2 +- docs/tested_vacuums.md | 13 +-- docs/updating.md | 11 ++ docs/v2_onboarding.md | 107 ------------------ pyproject.toml | 2 +- roborock_local_server_addon/CHANGELOG.md | 8 ++ roborock_local_server_addon/config.yaml | 2 +- roborock_local_server_beta_addon/CHANGELOG.md | 2 + roborock_local_server_beta_addon/DOCS.md | 76 +------------ roborock_local_server_beta_addon/config.yaml | 2 +- src/roborock_local_server/__init__.py | 2 +- .../bundled_backend/shared/runtime_state.py | 42 ++----- tests/test_admin_api.py | 18 ++- tests/test_runtime_state.py | 58 ++++++++-- uv.lock | 2 +- 16 files changed, 112 insertions(+), 251 deletions(-) delete mode 100644 docs/v2_onboarding.md diff --git a/docs/home_assistant.md b/docs/home_assistant.md index 6072b27..aa4794d 100644 --- a/docs/home_assistant.md +++ b/docs/home_assistant.md @@ -60,19 +60,13 @@ After the dashboard opens, complete the same post-start steps as the Docker inst If you need the MITM protocol sync secret for the Roborock app flow, sign in to the admin page and open **Protocol Auth**. The dashboard shows the active `admin.session_secret`, so you do not need to inspect `/data/config.toml` manually. -### Opt-in Beta testing +### Beta add-on -The repository also provides **Roborock Local Server Beta** for prerelease -testing. Install it explicitly from the add-on store after its prerelease image -has been published. Stable installations remain on their stable image version. +**Roborock Local Server Beta** is currently unused and remains on `1.1.0-rc2`. +Install **Roborock Local Server** for the stable `1.1.0` release. -Beta has a separate add-on slug and persistent data, so the first installation -requires its own configuration and cloud import. If reusing the same hostname -and ports, stop the stable add-on and disable its start-on-boot setting while -testing. Later Beta versions use the normal Home Assistant update flow. - -See the [Beta setup and testing instructions](https://github.com/Python-roborock/local_roborock_server/blob/main/roborock_local_server_beta_addon/DOCS.md) -for installation, V2 checkpoints, and returning to stable. +Beta and stable keep separate data. See [switching from Beta to stable](updating.md#switching-from-beta-to-stable) +if you tested an RC using the Beta add-on. ### Add-on Behavior diff --git a/docs/onboarding.md b/docs/onboarding.md index ba89e04..72b6d1b 100644 --- a/docs/onboarding.md +++ b/docs/onboarding.md @@ -80,6 +80,7 @@ Japan (No DST): `JST-9` - The first successful attempt usually increases the query sample count. - If the sample count increases but the public key is still missing, run another cycle. +- Public-key recovery can take several minutes. On some newer models, the query sample count stays at zero during recovery; watch for **Public Key determined** instead. - Once the public key is ready, the script will tell you to do one final pairing cycle so the vacuum connects fully. - Some vacuums are slow on that final cycle and may take a few minutes before they say Wi-Fi connected or show up as connected in the server. - Some vacuums need 2-4 cycles total. @@ -144,7 +145,6 @@ Everything in "What To Expect" above still applies. Some vacuums need 2-4 cycles ## Related Docs -- [Experimental V2 public-key recovery](v2_onboarding.md) - [Installation](installation.md) - [Tested vacuums](tested_vacuums.md) - [Home Assistant](home_assistant.md) diff --git a/docs/tested_vacuums.md b/docs/tested_vacuums.md index 94a1798..0f44994 100644 --- a/docs/tested_vacuums.md +++ b/docs/tested_vacuums.md @@ -17,16 +17,13 @@ The following vacuums are not supported and may never be supported. - Roborock Q7 L5 - Roborock Q10 s5/ s5+ -These vacuums likely all use a newer firmware version that uses a v2 of the region endpoint. In order to support them, we need to get a firmware dump. If you would like to disassemble your vac, we can likely get this. Please reach out. +## Potentially Supported +These models have not yet been confirmed working. Version `1.1.0` adds V2 +onboarding support; please report your model, firmware, and certificate chain +if you try one. -- Roborock Saros 20 Sonic - Roborock Qrevo Curv 2 Pro - Saros 20 - -## Potentially Supported -These are (maybe) using the same firmware as the two above. Any vacuum released after Sep 2025 is likely not supported in its current form. Please give it a try and let me know so I can update the docs. - -- Roborock Qrevo Edge 2 - Roborock Qrevo S Pro - Qrevo Curv 2 Flow - Qrevo CurvX @@ -51,6 +48,8 @@ Legend: | Roborock S8 MaxV Ultra | Not reported | ✅ | ❓ | ❓ | ❓ | | Roborock S8 Pro Ultra (a70) | Not reported | ❓ | ❓ | ❓ | ❓ | | Roborock Saros 10R | `02.52.32` | ✅ | ❓ | ✅ | ❓ | +| [Roborock Saros 20 Sonic (a279)](https://github.com/Python-roborock/local_roborock_server/pull/84#issuecomment-5701936113) | `02.42.52` | ✅ | ❓ | ❓ | ❓ | +| [QRevo Edge 2 Set (a298)](https://github.com/Python-roborock/local_roborock_server/pull/84#issuecomment-5684924538) | `02.15.44` | ❓ | ❓ | ✅ | ❓ | | Roborock G30U | `02.52.32` | ✅ | ❓ | ❓ | ❓ | | Roborock Qrevo S5V | Not reported | ❓ | ❓ | ❓ | ❓ | | QRevo MaxV | Not reported | ✅ | ❓ | ✅ | ❓ | diff --git a/docs/updating.md b/docs/updating.md index 7667cb1..c469e5a 100644 --- a/docs/updating.md +++ b/docs/updating.md @@ -69,6 +69,17 @@ Update the add-on from the Home Assistant Add-on Store, then restart the add-on. The add-on does not automatically rewrite Home Assistant's Roborock integration entry. If you changed the stack hostname or ports during the update, repeat the endpoint edit in [Home Assistant](home_assistant.md). +### Switching From Beta To Stable + +The Beta add-on is currently unused. For `1.1.0`, use **Roborock Local Server**. +Back up Beta before switching, then stop it and disable **Start on boot** before +starting stable on the same ports. + +The two add-ons have separate settings and data; installing stable does not copy +Beta's configuration, cloud import, or recovered keys. If stable was already +configured, update and start it. Otherwise, follow the [Home Assistant setup](home_assistant.md). +You may need to pair the vacuum again and reconfigure clients for stable. + ## After Updating Open the admin dashboard and check: diff --git a/docs/v2_onboarding.md b/docs/v2_onboarding.md deleted file mode 100644 index 9cbb6ef..0000000 --- a/docs/v2_onboarding.md +++ /dev/null @@ -1,107 +0,0 @@ -# Experimental V2 onboarding - -The server includes automatic RSA public-key recovery for the V2 `GET /region` -request format observed on Saros model `roborock.vacuum.a279`, plus multipart -NC form parsing in `1.1.0-rc2`. These changes do not yet establish complete V2 -onboarding or MQTT connectivity. - -## Verified request format - -The captured requests carry `v: v2`, nonce `n`, timestamp `t`, and signature `s` -headers. The Base64-decoded signature is 512 bytes. The signed message is: - -```text -exact_query_without_&signature_suffix + ":" + header_n + ":" + header_t -``` - -Signatures use RSA-4096, public exponent 65537, and PKCS#1 v1.5 with SHA-384. -Query order, escaping, percent-escape case, and literal plus signs must remain -unchanged. A parsed query dictionary is not a substitute for the wire query. - -Three saved requests from one owner's Saros recovered its public modulus. -All 14 available signatures verified against that modulus, including the 11 -not used for recovery. Replaying those requests through the persistent cache -also recovered the same key after a cache reload. These were offline checks; -no vacuum or vendor service was contacted. The private captures are not included -in this branch. - -This recovers the public key needed to encrypt server replies. It does not -recover the device's private key or a symmetric secret, and the demonstrated -method does not require a firmware dump. - -## Server behavior - -- The server records the request version with each header-signature sample. -- Complete V2 `GET /region` or `GET /.roborock.com/region` samples select SHA-384 - recovery. The cache starts recovery after at least two distinct samples. -- The worker uses up to three samples for modulus recovery, then verifies every - sample in its snapshot before accepting the key. -- Pending recovery resumes when the persisted cache is loaded again. -- Legacy query-signature recovery keeps SHA-256 as its default. -- Unversioned header samples, POST requests, `/b/region`, and HMAC-sized tags - are excluded from this V2 contract. - -Old cache entries without version metadata cannot be automatically identified -as V2. New pairing attempts can supply versioned samples. Recovery may require -another pairing cycle once the public key is ready. - -## Multipart NC requests - -The available Q7 sc05 `03.01.74` firmware contains an alternate RSA/V2 request -builder that sends NC parameters as multipart form fields. Before rc2, the -server applied URL-encoded parsing to that body and missed its device ID and -provisioning fields. It could log `POST /nc` while returning a plaintext reply -and leaving the vacuum's NC step missing, even with a recovered public key. - -The server now parses multipart text fields so the NC handler can select the -device's key, preserve provisioning values, and associate the request with the -vacuum. Request body bytes remain unchanged for logs and signature metadata. - -[A QRevo Edge 2 Set tester](https://github.com/Python-roborock/local_roborock_server/pull/84#issuecomment-5671286284) -reported automatic key recovery and a subsequent `POST /nc`, but no working -MQTT connection. The multipart parsing gap reproduces that reported state in -server tests. The tester's exact a298 firmware and NC body were not available, -so whether multipart parsing resolves that device's failure still needs a -hardware test. The Q7's B01 response scheme is not evidence for changing V2 -response encryption. - -Keep existing samples and recovered keys when updating to rc2. Restart the -server after updating and repeat pairing. If a key was manually inserted into -the state file while the server was running, a restart is also needed to load -it into the live encryptor. - -## Remaining hardware validation - -The existing V2 unsupported onboarding status remains in place. In particular, -the UI still reports V2 as unsupported even after public-key recovery. This -branch is intended for protocol investigation, not confirmed V2 support. - -The server still uses its existing RSA-OAEP/SHA-1 bootstrap response encryption. -SHA-384 request signatures alone do not establish the response encryption -format. A real V2 device must still demonstrate: - -1. Acceptance of the encrypted `/region` reply and the supplied server URLs. -2. Progression to NC registration and acceptance of its device ID and local key. -3. TLS acceptance and authenticated MQTT connectivity. -4. Status messages, a command response, and reconnection after reboot. - -The next useful hardware test is a pairing attempt after successful key -recovery, checking that the NC request is associated with the correct vacuum, -its `response_json.result` is an encrypted string, and MQTT traffic follows. -An HTTP 200 or recorded NC step alone does not establish that the device -accepted the response. If it stops at NC, retain the request content type, -redacted field names, response shape, and MQTT/TLS logs from that time. -Further protocol changes and onboarding-status updates depend on that result. - -## Regression tests - -```console -uv sync --extra dev -uv run pytest -q tests/test_rsa_sampling.py tests/test_v2_region_recovery.py tests/test_nc_multipart.py tests/test_device_key_recovery.py tests/test_admin_api.py tests/test_runtime_state.py -``` - -The tests use synthetic signatures and temporary state. They cover RSA-2048 / -SHA-256 compatibility, RSA-4096 / SHA-384 recovery, exact query preservation, -protocol classification, version persistence, restart behavior, rejection of a -modified signature holdout, and NC form parsing with independently decrypted -replies and per-device onboarding state. They do not prove physical onboarding. diff --git a/pyproject.toml b/pyproject.toml index df9d57c..7e004af 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "roborock-local-server" -version = "1.1.0-rc2" +version = "1.1.0" description = "private local Roborock server stack." requires-python = ">=3.11,<3.14" readme = "README.md" diff --git a/roborock_local_server_addon/CHANGELOG.md b/roborock_local_server_addon/CHANGELOG.md index db3325e..d361e84 100644 --- a/roborock_local_server_addon/CHANGELOG.md +++ b/roborock_local_server_addon/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 1.1.0 + +- Added V2 onboarding support, including automatic public-key recovery and multipart NC registration. +- Removed the obsolete V2 unsupported status from the dashboard and guided onboarding. +- Confirmed working on QRevo Edge 2 Set and Saros 20 Sonic. See the tested-vacuum list for firmware and certificate details. +- Fixed certificate renewal handling when acme.sh reports that renewal is not yet needed. +- Existing settings and recovered keys are retained when updating. The Beta add-on is currently unused; use the stable add-on for this release. + ## 1.0.2 - Added external TLS support and basic reverse proxy support. diff --git a/roborock_local_server_addon/config.yaml b/roborock_local_server_addon/config.yaml index 45caa6e..061d316 100644 --- a/roborock_local_server_addon/config.yaml +++ b/roborock_local_server_addon/config.yaml @@ -1,5 +1,5 @@ name: Roborock Local Server -version: "1.0.2" +version: "1.1.0" slug: roborock_local_server description: Private Roborock HTTPS and MQTT stack for Home Assistant environments. url: "https://github.com/Python-roborock/local_roborock_server" diff --git a/roborock_local_server_beta_addon/CHANGELOG.md b/roborock_local_server_beta_addon/CHANGELOG.md index 9586899..efe924f 100644 --- a/roborock_local_server_beta_addon/CHANGELOG.md +++ b/roborock_local_server_beta_addon/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +Beta is currently unused. Use **Roborock Local Server** for the stable `1.1.0` release. + ## 1.1.0-rc2 - Parse multipart NC form fields so the server can identify the vacuum, preserve its provisioning values, encrypt the reply with its recovered public key, and record the NC onboarding step. diff --git a/roborock_local_server_beta_addon/DOCS.md b/roborock_local_server_beta_addon/DOCS.md index a0f214b..c32e644 100644 --- a/roborock_local_server_beta_addon/DOCS.md +++ b/roborock_local_server_beta_addon/DOCS.md @@ -1,73 +1,9 @@ # Roborock Local Server Beta -This is an opt-in prerelease for testing experimental changes. Version -`1.1.0-rc2` includes V2 public-key recovery and multipart NC form parsing. -The NC fix lets the server identify the vacuum and encrypt its reply when a -recovered public key is available. Complete V2 onboarding and MQTT connectivity -still need hardware tests. +**Currently unused.** Install **Roborock Local Server** for the stable `1.1.0` +release. This Beta entry remains on `1.1.0-rc2` for existing installations and +future prerelease testing. -The stable **Roborock Local Server** add-on remains on `1.0.2`. Installing or -updating that add-on does not select this prerelease. Beta is a separate add-on, -with its own settings, cloud import, keys, and persistent `/data` directory. - -## First installation - -1. Wait until the `v1.1.0-rc2` GitHub prerelease has published its container image. -2. Add the repository `https://github.com/Python-roborock/local_roborock_server` - in the Home Assistant add-on store if it is not already present, then refresh - the store and install **Roborock Local Server Beta**. -3. Back up the stable add-on if installed. If reusing its hostname and ports, - stop the stable add-on and turn off its **Start on boot** setting while testing. - The two add-ons use the same default ports (`555` and `8881`) and cannot run - together on those ports. Beta defaults to manual startup. -4. Enter the server hostname, ports, admin password, protocol email/PIN, and TLS - settings in Beta's configuration. Settings and private certificate files are - not automatically copied from the stable add-on; use certificate paths that - Beta can access, or configure certificate issuance for Beta. -5. Start Beta, open `https://YOUR_API_HOST:555/admin` using the configured port, - and perform cloud import so the test vacuum appears. Enable **Allow new app - logins, onboarding, and first-time vacuum connections**. -6. On a second computer with Wi-Fi, use the onboarding scripts from the - `v1.1.0-rc2` checkout and run: - - ```bash - uv run start_onboarding.py --server YOUR_API_HOST:555 - ``` - - Follow the model's Wi-Fi reset and hotspot prompts. The terminal tool allows - retrying while the server still marks V2 unsupported. - -## What to check - -When updating from rc1, keep the existing Beta data and recovered key. Update -and restart Beta, then repeat pairing. This fix does not require a state wipe or -another public-key recovery. If you inserted a key into the state file manually, -restart the server before pairing so the live encryptor loads it. - -The V2 query sample counter may remain zero because V2 uses header signatures. -Wait for **Public Key determined** in the admin dashboard, then perform another -pairing cycle and check for NC registration, authenticated MQTT traffic, status -updates, and a command response. A recovered key or HTTP 200 alone does not -establish successful onboarding. The unsupported banner remains in this build. - -For the NC check, look for a `POST /nc` attributed to the correct vacuum, a -string in `response_json.result` instead of a plaintext object, and an NC step -recorded for that vacuum. These show that the server handled the request; -MQTT traffic is still needed to establish that the vacuum accepted the reply. -If pairing stops at NC, retain its content type, redacted request field names, -response shape, and MQTT/TLS logs from the same time for investigation. - -Keep the saved Beta data between attempts. Report the model, firmware, Beta -version, recovery state, and furthest connection stage reached. Keep original -logs locally and redact credentials before sharing excerpts. - -## Later updates and returning to stable - -Subsequent prereleases appear as updates to **Roborock Local Server Beta** after -the store refreshes. Update that entry normally to keep its settings and samples. - -To return to stable, stop Beta and start the stable add-on. Restore its original -start-on-boot setting if desired. Stable's saved data is retained, but changes -made while testing Beta are not copied back. A vacuum paired to Beta may need -another onboarding cycle, and clients reconfigured for Beta may need to be -reconfigured for stable again. +Beta and stable have separate settings and data. Follow +[switching from Beta to stable](https://python-roborock.github.io/local_roborock_server/updating/#switching-from-beta-to-stable) +if you used Beta during RC testing. diff --git a/roborock_local_server_beta_addon/config.yaml b/roborock_local_server_beta_addon/config.yaml index 114def6..11b3428 100644 --- a/roborock_local_server_beta_addon/config.yaml +++ b/roborock_local_server_beta_addon/config.yaml @@ -1,7 +1,7 @@ name: Roborock Local Server Beta version: "1.1.0-rc2" slug: roborock_local_server_beta -description: Opt-in prerelease testing of the Roborock local server, including experimental V2 key recovery. +description: Currently unused. Install Roborock Local Server for the stable release. stage: experimental url: "https://github.com/Python-roborock/local_roborock_server" image: "ghcr.io/python-roborock/local_roborock_server" diff --git a/src/roborock_local_server/__init__.py b/src/roborock_local_server/__init__.py index c41efbb..56ab6de 100644 --- a/src/roborock_local_server/__init__.py +++ b/src/roborock_local_server/__init__.py @@ -2,4 +2,4 @@ __all__ = ["__version__"] -__version__ = "1.1.0-rc2" +__version__ = "1.1.0" diff --git a/src/roborock_local_server/bundled_backend/shared/runtime_state.py b/src/roborock_local_server/bundled_backend/shared/runtime_state.py index d9f4389..6d2ce87 100644 --- a/src/roborock_local_server/bundled_backend/shared/runtime_state.py +++ b/src/roborock_local_server/bundled_backend/shared/runtime_state.py @@ -30,10 +30,6 @@ "public_key": "Public key", "connected": "Connected", } -REGION_V2_UNSUPPORTED_GUIDANCE = ( - "This vacuum uses the v2 /region onboarding flow, which is not supported by local_roborock_server yet. " - "These models usually stop after /region and never reach NC Prepare." -) _TRACKED_ONBOARDING_STEPS = set(ONBOARDING_STEP_LABELS.keys()) _D_TOPIC_RE = re.compile(r"^rr/d/[io]/([^/]+)/([^/]+)$") _M_TOPIC_RE = re.compile(r"^rr/m/[io]/[^/]+/[^/]+/([^/]+)$") @@ -234,8 +230,6 @@ def record_http_event( vac["last_ip"] = ip if route_name == "region" and normalized_region_version: vac["last_region_version"] = normalized_region_version - if normalized_region_version == "v2": - vac["unsupported_reason"] = "region_v2" vac["last_http_at"] = event_time vac["last_http_route"] = route_name vac["last_http_path"] = clean_path @@ -509,7 +503,6 @@ def _ensure_vacuum_locked(self, duid: str) -> dict[str, Any]: "last_message_at": "", "last_message_source": "", "onboarding_steps": {}, - "unsupported_reason": "", "restored_activity": False, } self._vacuums[normalized] = created @@ -538,9 +531,8 @@ def _merge_vacuum_records_locked(self, *, source_key: str, target_key: str) -> d if not target.get(field) and source.get(field): target[field] = source[field] target["ips"].update(source.get("ips") or set()) - for field in ("last_region_version", "unsupported_reason"): - if not target.get(field) and source.get(field): - target[field] = source[field] + if not target.get("last_region_version") and source.get("last_region_version"): + target["last_region_version"] = source["last_region_version"] for step, step_time in (source.get("onboarding_steps") or {}).items(): existing_time = str(target["onboarding_steps"].get(step) or "") if not existing_time or _is_newer_timestamp(str(step_time or ""), existing_time): @@ -731,15 +723,10 @@ def _pairing_snapshot_locked(self) -> dict[str, Any]: checks = {key: bool(value) for key, value in step_times.items()} identity_conflict = str(session.get("identity_conflict") or "").strip() - unsupported_reason = self._unsupported_reason_locked(selected_vac, observed_vac) complete = bool(connected and has_public_key) if identity_conflict: status = "conflict" guidance = identity_conflict - elif unsupported_reason == "region_v2": - status = "unsupported" - guidance = REGION_V2_UNSUPPORTED_GUIDANCE - complete = False elif complete: status = "complete" guidance = "Device paired and connected." @@ -792,8 +779,8 @@ def _pairing_snapshot_locked(self) -> dict[str, Any]: "public_key_state": public_key_state, "connected": connected, "identity_conflict": identity_conflict, - "unsupported": bool(unsupported_reason), - "unsupported_reason": unsupported_reason, + "unsupported": False, + "unsupported_reason": "", "checks": checks, "steps": [ { @@ -809,16 +796,6 @@ def _pairing_snapshot_locked(self) -> dict[str, Any]: "target": dict(target_payload), } - @staticmethod - def _unsupported_reason_locked(*vacuums: dict[str, Any] | None) -> str: - for vac in vacuums: - if vac is None: - continue - reason = str(vac.get("unsupported_reason") or "").strip() - if reason: - return reason - return "" - def _pairing_step_details_locked(self, target_did: str) -> dict[str, str]: sample_count = self._pairing_public_key_sample_count_locked(target_did) sample_label = "sample" if sample_count == 1 else "samples" @@ -1147,12 +1124,7 @@ def _to_snapshot_locked( if not key_state and duid and duid in key_details: key_state = dict(key_details[duid]) public_key_ready = has_required_messages and has_public_key - unsupported_reason = str(vac.get("unsupported_reason") or "").strip() - unsupported = bool(unsupported_reason) - if unsupported_reason == "region_v2": - onboarding_status = "unsupported" - guidance = REGION_V2_UNSUPPORTED_GUIDANCE - elif public_key_ready: + if public_key_ready: onboarding_status = "ready" guidance = "Required onboarding messages captured and public key is available." elif has_required_messages and not has_public_key: @@ -1251,8 +1223,8 @@ def _to_snapshot_locked( "public_key_ready": public_key_ready, "status": onboarding_status, "guidance": guidance, - "unsupported": unsupported, - "unsupported_reason": unsupported_reason, + "unsupported": False, + "unsupported_reason": "", "key_state": key_state, }, } diff --git a/tests/test_admin_api.py b/tests/test_admin_api.py index 6dc632a..a23e7d0 100644 --- a/tests/test_admin_api.py +++ b/tests/test_admin_api.py @@ -615,7 +615,7 @@ def test_onboarding_signed_query_fallback_uses_active_target_and_triggers_recove assert recovery_calls == ["1103821560705"] -def test_region_v2_request_surfaces_unsupported_onboarding_alert(tmp_path: Path) -> None: +def test_region_v2_request_keeps_onboarding_in_progress(tmp_path: Path) -> None: config_file = write_release_config(tmp_path) config = load_config(config_file) paths = resolve_paths(config_file, config) @@ -658,7 +658,7 @@ def test_region_v2_request_surfaces_unsupported_onboarding_alert(tmp_path: Path) encoding="utf-8", ) supervisor = ReleaseSupervisor(config=config, paths=paths) - supervisor.runtime_state.start_onboarding_session( + started = supervisor.runtime_state.start_onboarding_session( target_duid="cloud-saros-a", target_name="Saros", target_did="1103821560705", @@ -692,10 +692,16 @@ def test_region_v2_request_surfaces_unsupported_onboarding_alert(tmp_path: Path) devices = client.get("/admin/api/onboarding/devices") assert devices.status_code == 200 [device] = devices.json()["devices"] - assert device["onboarding"]["status"] == "unsupported" - assert device["onboarding"]["unsupported"] is True - assert device["onboarding"]["unsupported_reason"] == "region_v2" - assert "v2 /region onboarding flow" in device["onboarding"]["guidance"] + assert device["onboarding"]["status"] == "collecting_messages" + assert device["onboarding"]["unsupported"] is False + assert device["onboarding"]["unsupported_reason"] == "" + assert "NC Prepare" in device["onboarding"]["guidance"] + + session = client.get(f"/admin/api/onboarding/sessions/{started['session_id']}") + assert session.status_code == 200 + assert session.json()["status"] == "in_progress" + assert session.json()["unsupported"] is False + assert session.json()["complete"] is False def test_core_only_mode_disables_standalone_admin_routes(tmp_path: Path) -> None: diff --git a/tests/test_runtime_state.py b/tests/test_runtime_state.py index ad84779..299ab52 100644 --- a/tests/test_runtime_state.py +++ b/tests/test_runtime_state.py @@ -314,8 +314,9 @@ def test_runtime_state_onboarding_device_mqtt_candidate_requires_matching_ip_and assert state.onboarding_device_mqtt_candidate(client_ip="192.168.8.11") is None -def test_runtime_state_marks_region_v2_onboarding_as_unsupported(tmp_path: Path) -> None: - state = RuntimeState(log_dir=tmp_path, key_state_file=None) +def test_runtime_state_completes_region_v2_onboarding(tmp_path: Path) -> None: + key_state_path = tmp_path / "device_key_state.json" + state = RuntimeState(log_dir=tmp_path, key_state_file=key_state_path) state.upsert_vacuum("cloud-saros-a", name="Saros", id_kind="duid") state.start_onboarding_session(target_duid="cloud-saros-a", target_name="Saros") @@ -334,12 +335,51 @@ def test_runtime_state_marks_region_v2_onboarding_as_unsupported(tmp_path: Path) [vacuum] = state.vacuum_snapshot() assert vacuum["last_region_version"] == "v2" - assert vacuum["onboarding"]["status"] == "unsupported" - assert vacuum["onboarding"]["unsupported"] is True - assert vacuum["onboarding"]["unsupported_reason"] == "region_v2" - assert "v2 /region onboarding flow" in vacuum["onboarding"]["guidance"] + assert vacuum["onboarding"]["status"] == "collecting_messages" + assert vacuum["onboarding"]["unsupported"] is False + assert vacuum["onboarding"]["unsupported_reason"] == "" + assert vacuum["onboarding"]["missing_steps"] == ["nc_prepare"] + + session = state.onboarding_session_snapshot() + assert session["status"] == "in_progress" + assert session["unsupported"] is False + assert session["unsupported_reason"] == "" + assert session["complete"] is False + # The status layer reads the result persisted by public-key recovery. + key_state_path.write_text( + json.dumps({"devices": {"1103821560705": {"modulus_hex": "ab"}}}), + encoding="utf-8", + ) + session = state.onboarding_session_snapshot() + assert session["has_public_key"] is True + assert session["complete"] is False + assert "one more pairing cycle" in session["guidance"] + + state.record_http_event( + event_time=datetime.now(timezone.utc).isoformat(), + route_name="nc_prepare", + clean_path="/nc", + raw_path="/nc", + method="POST", + host="api-roborock.example.com", + remote="192.168.8.10:54321", + did="1103821560705", + ) + [vacuum] = state.vacuum_snapshot() + assert vacuum["onboarding"]["status"] == "ready" + assert vacuum["onboarding"]["public_key_ready"] is True + assert state.onboarding_session_snapshot()["complete"] is False + + state.record_mqtt_connection(conn_id="v2", client_ip="192.168.8.10", client_port=54322) + state.record_mqtt_message( + conn_id="v2", + direction="c2b", + topic="rr/d/i/1103821560705/c25b14ceac358d2a", + payload_preview="{}", + ) session = state.onboarding_session_snapshot() - assert session["status"] == "unsupported" - assert session["unsupported"] is True - assert session["unsupported_reason"] == "region_v2" + assert session["status"] == "complete" + assert session["complete"] is True + assert session["unsupported"] is False + assert all(session["checks"].values()) diff --git a/uv.lock b/uv.lock index 4bb0deb..54b0ef6 100644 --- a/uv.lock +++ b/uv.lock @@ -1275,7 +1275,7 @@ wheels = [ [[package]] name = "roborock-local-server" -version = "1.1.0rc2" +version = "1.1.0" source = { editable = "." } dependencies = [ { name = "aiohttp" }, From b5437063e9ebbe6f1688c4dd8a6aeae291c77b5a Mon Sep 17 00:00:00 2001 From: Luke Date: Fri, 18 Sep 2026 11:51:18 -0400 Subject: [PATCH 2/2] Include recovery timestamp in V2 pairing regression fixture --- tests/test_runtime_state.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/tests/test_runtime_state.py b/tests/test_runtime_state.py index 299ab52..4ee07bd 100644 --- a/tests/test_runtime_state.py +++ b/tests/test_runtime_state.py @@ -348,7 +348,19 @@ def test_runtime_state_completes_region_v2_onboarding(tmp_path: Path) -> None: # The status layer reads the result persisted by public-key recovery. key_state_path.write_text( - json.dumps({"devices": {"1103821560705": {"modulus_hex": "ab"}}}), + json.dumps( + { + "devices": { + "1103821560705": { + "modulus_hex": "ab", + "recovery": { + "state": "recovered", + "finished_at": datetime.now(timezone.utc).isoformat(), + }, + } + } + } + ), encoding="utf-8", ) session = state.onboarding_session_snapshot()