From f59f75e191424a4fe825b1ddd0f6e507390e5b83 Mon Sep 17 00:00:00 2001 From: Immanuel Date: Sun, 25 Oct 2026 08:00:16 +0100 Subject: [PATCH 1/2] fix(api): add rriotid, categoryId, category code and agreements to /api/v5/product Opening a device in the Android app failed with error 21020 ("device initialization failed") and the device plugin never reached the map. The app database showed why: rr_product.rriotid was empty, rr_category.code was empty and rr_product.agreements was null. - rriotid: the app's API model (Product) reads the IoT product id under the JSON name "rriotid" (the DB entity calls the same field productId). The product lookup "select ... where rriotid=?" found nothing without it. The cloud /api/v5/product response carries this field. - categoryId: links the product to its category entry. - category.code: CategoryEntity.code is mapped into Product.code, which the device plugin receives as deviceCategory via PluginSDKModule.getConstants(). - agreements: PluginSDKModule.getProductAgreements returned null and the plugin's privacy check never continued to fetchDeviceLocation(). An empty list means "no product specific agreement required". Co-Authored-By: Claude Opus 5.5 --- .../routes/api/product_service.py | 11 ++ tests/test_product_response.py | 101 ++++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 tests/test_product_response.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 5ae00b50..a8761b4b 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 @@ -24,6 +24,9 @@ def build_product_response(ctx: ServerContext) -> dict[str, Any]: categories[category_name] = { "category": { "id": category_id, + # CategoryEntity.code becomes Product.code in the app, which + # the device plugin receives as deviceCategory. + "code": category_name, "displayName": category_name, "iconUrl": "", }, @@ -39,9 +42,17 @@ def build_product_response(ctx: ServerContext) -> dict[str, Any]: "id": product_id, "name": str(product.get("name") or default_product_name(model)), "model": model, + # The app's API model reads the IoT product id as "rriotid" and + # joins the home data device's productId against it. Without it the + # product lookup fails and opening the device shows error 21020. + "rriotid": raw_product_id, + "categoryId": categories[category_name]["category"]["id"], "packagename": f"com.roborock.{model.split('.')[-1]}", "ncMode": "global", "status": 10, + # Read by PluginSDKModule.getProductAgreements; a missing field is + # null and the device plugin's privacy check never completes. + "agreements": [], } icon_url = product.get("iconUrl") if isinstance(icon_url, str) and icon_url: diff --git a/tests/test_product_response.py b/tests/test_product_response.py new file mode 100644 index 00000000..cd4ab4da --- /dev/null +++ b/tests/test_product_response.py @@ -0,0 +1,101 @@ +import json +from pathlib import Path + +from roborock_local_server.backend import ( + ServerContext, + default_endpoint_rules, + resolve_route, +) + + +class _PairedRuntimeCredentials: + """Treat every inventory device as paired so home_payload keeps it.""" + + def resolve_device(self, *, duid: str = "", **_kwargs): + return {"did": f"did-{duid}", "device_mqtt_usr": "mqtt-user"} + + def __getattr__(self, _name): + return lambda *args, **kwargs: None + + +def _make_context(tmp_path: Path) -> ServerContext: + return ServerContext( + api_host="api.example.com", + mqtt_host="mqtt.example.com", + wood_host="wood.example.com", + region="us", + protocol_login_email="user@example.com", + localkey="key123", + duid="duid-1", + mqtt_usr="usr", + mqtt_passwd="pwd", + mqtt_clientid="cid", + https_port=443, + mqtt_tls_port=8883, + http_jsonl=tmp_path / "http.jsonl", + mqtt_jsonl=tmp_path / "mqtt.jsonl", + loggers={}, + runtime_credentials=_PairedRuntimeCredentials(), + ) + + +def _products_v5(tmp_path: Path, devices: list[dict[str, object]]) -> dict: + (tmp_path / "web_api_inventory.json").write_text( + json.dumps({"home": {"id": 424242, "name": "Test Home"}, "devices": devices}) + "\n", + encoding="utf-8", + ) + route, payload = resolve_route( + rules=default_endpoint_rules(), + context=_make_context(tmp_path), + clean_path="/api/v5/product", + query_params={}, + body_params={}, + method="GET", + ) + assert route == "get_products_v5" + return payload["data"] + + +_DEVICE = { + "duid": "device-a", + "name": "Vacuum A", + "model": "roborock.vacuum.a15", + "product_id": "iot-product-a", +} + + +def test_product_entry_carries_rriotid_category_id_and_agreements(tmp_path: Path) -> None: + data = _products_v5(tmp_path, [_DEVICE]) + + [category_detail] = data["categoryDetailList"] + category = category_detail["category"] + [product] = category_detail["productList"] + + # Product.rriotid is what the app joins the home data device's productId on. + assert product["rriotid"] == "iot-product-a" + assert product["categoryId"] == category["id"] + assert product["agreements"] == [] + + +def test_product_category_carries_code(tmp_path: Path) -> None: + data = _products_v5(tmp_path, [_DEVICE]) + + category = data["categoryDetailList"][0]["category"] + + assert category["code"] == "robot.vacuum.cleaner" + assert category["displayName"] == "robot.vacuum.cleaner" + + +def test_product_rriotid_matches_home_data_device_product_id(tmp_path: Path) -> None: + from roborock_local_server.bundled_backend.https_server.routes.user.homes.service import home_payload + + data = _products_v5(tmp_path, [_DEVICE]) + home = home_payload(_make_context(tmp_path)) + + device_product_ids = {device["productId"] for device in home["devices"]} + rriotids = { + product["rriotid"] + for category_detail in data["categoryDetailList"] + for product in category_detail["productList"] + } + assert device_product_ids == rriotids From 8a0309984ac7b3a050c8a75c2cf1e27d2de865e8 Mon Sep 17 00:00:00 2001 From: Immanuel Date: Sun, 25 Oct 2026 08:00:22 +0100 Subject: [PATCH 2/2] fix(api): use the app's lower-case "cardpicurl" product field name The Android app's Product serializer knows picurl, cardpicurl, mediumCardpicurl, resetwifipicurl, pluginPicUrl and pictures. "cardPicUrl" (capital P) is not one of them, so the card picture was never set; the cloud response uses the lower-case name as well. Co-Authored-By: Claude Opus 5.5 --- .../https_server/routes/api/product_service.py | 2 +- tests/test_product_response.py | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) 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 a8761b4b..0c0d2c62 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 @@ -57,7 +57,7 @@ def build_product_response(ctx: ServerContext) -> dict[str, Any]: icon_url = product.get("iconUrl") if isinstance(icon_url, str) and icon_url: product_entry["picurl"] = icon_url - product_entry["cardPicUrl"] = icon_url + product_entry["cardpicurl"] = icon_url product_entry["pluginPicUrl"] = icon_url categories[category_name]["productList"].append(product_entry) return {"categoryDetailList": list(categories.values())} diff --git a/tests/test_product_response.py b/tests/test_product_response.py index cd4ab4da..09f8b8d7 100644 --- a/tests/test_product_response.py +++ b/tests/test_product_response.py @@ -99,3 +99,15 @@ def test_product_rriotid_matches_home_data_device_product_id(tmp_path: Path) -> for product in category_detail["productList"] } assert device_product_ids == rriotids + + +def test_product_picture_fields_use_app_json_names(tmp_path: Path) -> None: + device = dict(_DEVICE, iconUrl="https://images.example.com/vacuum.png") + + data = _products_v5(tmp_path, [device]) + + [product] = data["categoryDetailList"][0]["productList"] + # Product$$serializer only knows the lower-case names picurl/cardpicurl. + assert product["picurl"] == "https://images.example.com/vacuum.png" + assert product["cardpicurl"] == "https://images.example.com/vacuum.png" + assert "cardPicUrl" not in product