From 7272d2ff2ef130abe9d7fbd8bcda3cc1b4fb8d52 Mon Sep 17 00:00:00 2001 From: Alex Babel Date: Fri, 30 Oct 2026 21:17:17 +0100 Subject: [PATCH] fix(plugin): resolve string product ids and serve the iOS app plugin The cloud lists newer models (e.g. roborock.vacuum.a226) under a string product id, so the import never fetched their app plugin and /api/v5/product answered with a hashed id that matches no plugin. The app then fails with "device initialization failed". The import now fetches the cloud's /api/v5/product to map models to their numeric id, and fetches /api/v1/appplugin for both type 2 (Android) and type 1 (iOS), which get different zips. The appplugin route answers iOS requests from the iOS catalog, falling back to the existing one. Fixes #221 --- .../routes/api/product_service.py | 10 +++- .../https_server/routes/api/v1/appplugin.py | 25 ++++++++- .../https_server/routes/plugin/common.py | 4 ++ .../import_web_inventory_from_cloud.py | 41 ++++++++------ .../bundled_backend/shared/data_helpers.py | 13 +++++ tests/test_cloud_plugin_catalogs.py | 53 +++++++++++++++++++ tests/test_plugin_routes.py | 41 ++++++++++++++ tests/test_product_response.py | 30 +++++++++++ 8 files changed, 196 insertions(+), 21 deletions(-) create mode 100644 tests/test_cloud_plugin_catalogs.py diff --git a/src/roborock_local_server/bundled_backend/https_server/routes/api/product_service.py b/src/roborock_local_server/bundled_backend/https_server/routes/api/product_service.py index 0c0d2c62..e450d8e9 100644 --- a/src/roborock_local_server/bundled_backend/https_server/routes/api/product_service.py +++ b/src/roborock_local_server/bundled_backend/https_server/routes/api/product_service.py @@ -4,7 +4,9 @@ from shared.constants import MODEL_PRODUCT_ID_OVERRIDES from shared.context import ServerContext -from shared.data_helpers import as_int, default_product_name, stable_int +from shared.data_helpers import as_int, default_product_name, model_product_ids, stable_int + +from ..plugin.common import snapshot_plugin_catalog_data from ..user.homes.service import home_payload @@ -13,6 +15,7 @@ def build_product_response(ctx: ServerContext) -> dict[str, Any]: home_data = home_payload(ctx) products_value = home_data.get("products") products = products_value if isinstance(products_value, list) else [] + cloud_product_ids = model_product_ids(snapshot_plugin_catalog_data(ctx, "product")) categories: dict[str, dict[str, Any]] = {} for product in products: if not isinstance(product, dict): @@ -34,9 +37,12 @@ def build_product_response(ctx: ServerContext) -> dict[str, Any]: } model = str(product.get("model") or "roborock.vacuum.a117") model_key = model.strip().lower() + # String product ids (e.g. a226) take the cloud's numeric id for the model; + # plugins are keyed by it, so a hashed id gets the app no device plugin. product_id = MODEL_PRODUCT_ID_OVERRIDES.get( model_key, - as_int(raw_product_id, stable_int(raw_product_id or category_name) % 1_000_000), + cloud_product_ids.get(model_key) + or as_int(raw_product_id, stable_int(raw_product_id or category_name) % 1_000_000), ) product_entry = { "id": product_id, diff --git a/src/roborock_local_server/bundled_backend/https_server/routes/api/v1/appplugin.py b/src/roborock_local_server/bundled_backend/https_server/routes/api/v1/appplugin.py index b4a4edc5..6f1b74a5 100644 --- a/src/roborock_local_server/bundled_backend/https_server/routes/api/v1/appplugin.py +++ b/src/roborock_local_server/bundled_backend/https_server/routes/api/v1/appplugin.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json from typing import Any from shared.context import ServerContext @@ -7,17 +8,37 @@ from ...auth.service import ok from ...plugin.common import APPPLUGIN_LIST, plugin_records_with_snapshot_fallback, proxied_plugin_records +# Request "type" of the iOS app; Android sends 2. +IOS_APP_TYPE = 1 + def match(path: str) -> bool: return path.rstrip("/") == "/api/v1/appplugin" +def _request_app_type(body_params: dict[str, list[str]]) -> int | None: + for raw in body_params.get("__json") or []: + try: + body = json.loads(raw) + except (TypeError, json.JSONDecodeError): + continue + if isinstance(body, dict): + try: + return int(body.get("type")) + except (TypeError, ValueError): + return None + return None + + def build( ctx: ServerContext, _query_params: dict[str, list[str]], - _body_params: dict[str, list[str]], + body_params: dict[str, list[str]], _clean_path: str, ) -> dict[str, Any]: records = plugin_records_with_snapshot_fallback(ctx, "appplugin", APPPLUGIN_LIST) + if _request_app_type(body_params) == IOS_APP_TYPE: + # Same plugin version, different zip: the iOS app cannot load the Android + # one. Imports from before the iOS catalog existed fall back to Android. + records = plugin_records_with_snapshot_fallback(ctx, "appplugin_ios", records) return ok(proxied_plugin_records(ctx, records)) - diff --git a/src/roborock_local_server/bundled_backend/https_server/routes/plugin/common.py b/src/roborock_local_server/bundled_backend/https_server/routes/plugin/common.py index 8a502853..1783813d 100644 --- a/src/roborock_local_server/bundled_backend/https_server/routes/plugin/common.py +++ b/src/roborock_local_server/bundled_backend/https_server/routes/plugin/common.py @@ -156,6 +156,10 @@ def _dict_records(value: Any) -> list[dict[str, Any]]: return [dict(item) for item in value if isinstance(item, dict)] +def snapshot_plugin_catalog_data(ctx: ServerContext, catalog_name: str) -> Any: + return _catalog_data(_snapshot_plugin_catalogs(ctx), catalog_name) + + def snapshot_plugin_records( ctx: ServerContext, catalog_name: str, diff --git a/src/roborock_local_server/bundled_backend/import_web_inventory_from_cloud.py b/src/roborock_local_server/bundled_backend/import_web_inventory_from_cloud.py index b79dfd03..84259a26 100644 --- a/src/roborock_local_server/bundled_backend/import_web_inventory_from_cloud.py +++ b/src/roborock_local_server/bundled_backend/import_web_inventory_from_cloud.py @@ -20,6 +20,8 @@ from roborock.exceptions import RoborockRateLimit from roborock.web_api import PreparedRequest, RoborockApiClient, _get_hawk_authentication +from shared.data_helpers import model_product_ids + def _find_repo_root() -> Path: current = Path(__file__).resolve().parent @@ -376,13 +378,18 @@ async def _request_cloud_api_data( return response.get("data") -def _plugin_product_ids(home_data: HomeData) -> list[int]: +def _plugin_product_ids(home_data: HomeData, cloud_products: Any = None) -> list[int]: + # Newer models (e.g. roborock.vacuum.a226) carry a string product id in home + # data; the numeric id the plugin catalog is keyed by comes from /api/v5/product. + ids_by_model = model_product_ids(cloud_products) product_ids: set[int] = set() for product in home_data.products or []: try: product_ids.add(int(product.id)) except (TypeError, ValueError): - continue + model_id = ids_by_model.get(str(product.model or "").strip().lower()) + if model_id: + product_ids.add(model_id) for device in _all_home_devices(home_data): try: product_ids.add(int(getattr(device, "product_id", 0) or 0)) @@ -442,21 +449,21 @@ async def fetch_catalog( "/api/v1/plugins", params={"type": 2, "apiLevel": 10043}, ) - product_ids = _plugin_product_ids(home_data) - if product_ids: - await fetch_catalog( - "appplugin", - "/api/v1/appplugin", - method="post", - json_body={"type": 2, "apilevel": 10043, "productids": product_ids}, - ) - else: - catalogs["appplugin"] = { - "path": "/api/v1/appplugin", - "method": "POST", - "json": {"type": 2, "apilevel": 10043, "productids": []}, - "error": "no product ids available", - } + await fetch_catalog("product", "/api/v5/product") + product_ids = _plugin_product_ids(home_data, catalogs["product"].get("data")) + # The cloud serves a separate zip per platform for the same plugin version: + # type 2 is Android, type 1 is iOS. + for name, app_type in (("appplugin", 2), ("appplugin_ios", 1)): + json_body = {"type": app_type, "apilevel": 10043, "productids": product_ids} + if product_ids: + await fetch_catalog(name, "/api/v1/appplugin", method="post", json_body=json_body) + else: + catalogs[name] = { + "path": "/api/v1/appplugin", + "method": "POST", + "json": json_body, + "error": "no product ids available", + } return catalogs diff --git a/src/roborock_local_server/bundled_backend/shared/data_helpers.py b/src/roborock_local_server/bundled_backend/shared/data_helpers.py index 836b808d..b0b90629 100644 --- a/src/roborock_local_server/bundled_backend/shared/data_helpers.py +++ b/src/roborock_local_server/bundled_backend/shared/data_helpers.py @@ -44,6 +44,19 @@ def stable_int(seed: str) -> int: return int(hashlib.sha256(seed.encode("utf-8")).hexdigest()[:12], 16) +def model_product_ids(cloud_products: Any) -> dict[str, int]: + """Map model -> numeric product id from a cloud /api/v5/product payload.""" + ids: dict[str, int] = {} + category_details = cloud_products.get("categoryDetailList") if isinstance(cloud_products, dict) else None + for category_detail in category_details or []: + for product in (category_detail or {}).get("productList") or []: + model = str(product.get("model") or "").strip().lower() + product_id = as_int(product.get("id"), 0) + if model and product_id > 0: + ids[model] = product_id + return ids + + def default_home_id(ctx: ServerContext) -> int: return stable_int(f"{ctx.duid}:home") diff --git a/tests/test_cloud_plugin_catalogs.py b/tests/test_cloud_plugin_catalogs.py new file mode 100644 index 00000000..8f8a1699 --- /dev/null +++ b/tests/test_cloud_plugin_catalogs.py @@ -0,0 +1,53 @@ +import asyncio +from typing import Any + +from roborock.data import HomeData + +import roborock_local_server.backend # noqa: F401 (puts the bundled backend on sys.path) +import import_web_inventory_from_cloud as importer + + +CLOUD_PRODUCTS = { + "categoryDetailList": [ + { + "category": {"id": 1, "code": "robot.vacuum.cleaner"}, + "productList": [{"id": 199, "model": "roborock.vacuum.a226"}], + } + ] +} + + +def _home_data() -> HomeData: + # Home data lists newer models under a string product id on both the product + # and the device. + return HomeData.from_dict( + { + "id": 1, + "name": "Home", + "products": [{"id": "30uADinyLKa3Qpga60B1Ke", "name": "Qrevo Curv 2 Pro", "model": "roborock.vacuum.a226", "category": "robot.vacuum.cleaner"}], + "devices": [{"duid": "duid-1", "name": "Vacuum", "productId": "30uADinyLKa3Qpga60B1Ke", "localKey": "key"}], + } + ) + + +def test_plugin_catalogs_resolve_string_product_ids_and_fetch_both_platforms(monkeypatch) -> None: + requests: list[tuple[str, dict[str, Any] | None]] = [] + + async def fake_request(_api, _user_data, path, *, method="get", params=None, json_body=None): + requests.append((path, json_body)) + if path == "/api/v5/product": + return CLOUD_PRODUCTS + if path == "/api/v1/appplugin": + return [{"productid": 199, "url": f"https://app-files.roborock.com/iot/plugin/type{json_body['type']}.zip"}] + return {} + + monkeypatch.setattr(importer, "_request_cloud_api_data", fake_request) + + catalogs = asyncio.run(importer._fetch_plugin_catalogs(None, None, _home_data())) + + assert catalogs["product"]["data"] == CLOUD_PRODUCTS + app_plugin_bodies = [body for path, body in requests if path == "/api/v1/appplugin"] + assert sorted(body["type"] for body in app_plugin_bodies) == [1, 2] + assert all(body["productids"] == [199] for body in app_plugin_bodies) + assert catalogs["appplugin"]["data"][0]["url"].endswith("/type2.zip") + assert catalogs["appplugin_ios"]["data"][0]["url"].endswith("/type1.zip") diff --git a/tests/test_plugin_routes.py b/tests/test_plugin_routes.py index 2d42ae92..686a59ea 100644 --- a/tests/test_plugin_routes.py +++ b/tests/test_plugin_routes.py @@ -247,3 +247,44 @@ def test_download_plugin_zip_still_rejects_oversized_payload(monkeypatch) -> Non with pytest.raises(RuntimeError, match="plugin too large"): asyncio.run(plugin_common.download_plugin_zip("https://files.roborock.com/plugin.zip")) + + +def _write_app_plugin_snapshot(supervisor: ReleaseSupervisor, catalogs: dict) -> None: + snapshot = {"web_api_cache": {"plugin_catalogs": catalogs}} + supervisor.paths.cloud_snapshot_path.write_text(json.dumps(snapshot) + "\n", encoding="utf-8") + + +def _app_plugin_catalog(url: str) -> dict: + return {"data": [{"version": 6846, "url": url, "pluginLevel": 3002, "productid": 199, "apilevel": 10030}]} + + +ANDROID_ZIP = "https://app-files.roborock.com/iot/plugin/android.zip" +IOS_ZIP = "https://app-files.roborock.com/iot/plugin/ios.zip" + + +@pytest.mark.parametrize(("app_type", "expected"), [(1, IOS_ZIP), (2, ANDROID_ZIP)]) +def test_app_plugin_answers_per_platform_type(tmp_path: Path, app_type: int, expected: str) -> None: + # The cloud serves a different zip for the same plugin version to iOS (type 1) + # and Android (type 2); the iOS app cannot load the Android one. + supervisor = _build_supervisor(tmp_path) + _write_app_plugin_snapshot( + supervisor, + {"appplugin": _app_plugin_catalog(ANDROID_ZIP), "appplugin_ios": _app_plugin_catalog(IOS_ZIP)}, + ) + client = TestClient(supervisor.app) + + response = client.post("/api/v1/appplugin", json={"type": app_type, "apilevel": "40740", "productids": [199]}) + + [record] = response.json()["data"] + assert _source_from_proxied_url(record["url"]) == expected + + +def test_app_plugin_ios_falls_back_to_android_catalog_from_older_imports(tmp_path: Path) -> None: + supervisor = _build_supervisor(tmp_path) + _write_app_plugin_snapshot(supervisor, {"appplugin": _app_plugin_catalog(ANDROID_ZIP)}) + client = TestClient(supervisor.app) + + response = client.post("/api/v1/appplugin", json={"type": 1, "productids": [199]}) + + [record] = response.json()["data"] + assert _source_from_proxied_url(record["url"]) == ANDROID_ZIP diff --git a/tests/test_product_response.py b/tests/test_product_response.py index 09f8b8d7..35c05ebb 100644 --- a/tests/test_product_response.py +++ b/tests/test_product_response.py @@ -111,3 +111,33 @@ def test_product_picture_fields_use_app_json_names(tmp_path: Path) -> None: assert product["picurl"] == "https://images.example.com/vacuum.png" assert product["cardpicurl"] == "https://images.example.com/vacuum.png" assert "cardPicUrl" not in product + + +def test_string_product_id_uses_cloud_numeric_id_by_model(tmp_path: Path) -> None: + # The cloud lists newer models (e.g. a226) under a string product id in home + # data. The numeric id the app requests plugins with comes from the cloud's + # /api/v5/product, which the import stores in the snapshot. + snapshot = { + "web_api_cache": { + "plugin_catalogs": { + "product": { + "data": { + "categoryDetailList": [ + { + "category": {"id": 1, "code": "robot.vacuum.cleaner"}, + "productList": [{"id": 199, "model": "roborock.vacuum.a226"}], + } + ] + } + } + } + } + } + (tmp_path / "web_api_inventory_full_snapshot.json").write_text(json.dumps(snapshot), encoding="utf-8") + device = dict(_DEVICE, model="roborock.vacuum.a226", product_id="30uADinyLKa3Qpga60B1Ke") + + data = _products_v5(tmp_path, [device]) + + [product] = data["categoryDetailList"][0]["productList"] + assert product["id"] == 199 + assert product["rriotid"] == "30uADinyLKa3Qpga60B1Ke"