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..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 @@ -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,14 +42,22 @@ 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: 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 new file mode 100644 index 00000000..09f8b8d7 --- /dev/null +++ b/tests/test_product_response.py @@ -0,0 +1,113 @@ +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 + + +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