diff --git a/src/roborock_local_server/bundled_backend/https_server/routes/api/card_spec.py b/src/roborock_local_server/bundled_backend/https_server/routes/api/card_spec.py new file mode 100644 index 0000000..98c16f9 --- /dev/null +++ b/src/roborock_local_server/bundled_backend/https_server/routes/api/card_spec.py @@ -0,0 +1,77 @@ +"""Home card specs (CardSpecV2) per product category. + +The Android app only shows the scene quick-start tiles of a device when its +category carries a card spec (RRDeviceBeanV2.supportScene() returns false when +CategoryEntity.cardSpec is null). CardSpecV2 holds four data points directly +as fields - state, extra, battery, offline - each {"dps", "desc", "value"}. +Note that the template bundled with the app wraps these in {"data": {...}}; +the API field holds the inner object. + +Labels are English only. The app's bundled template carries ~30 languages. +""" + +from __future__ import annotations + +import copy +from typing import Any + + +def _state(label: str, *codes: int) -> dict[str, Any]: + return {"desc": {"en": label}, "value": list(codes)} + + +_ROBOT_VACUUM_CARD_SPEC: dict[str, Any] = { + "state": { + "dps": 121, + "desc": {"en": "State"}, + "value": [ + _state("Sleeping", 2), + _state("Ready", 3), + _state("Cleaning", 5, 11, 17, 18, 102, 103, 104), + _state("Return to the dock", 6), + _state("Remote Control", 7, 39), + _state("Charging", 8), + _state("Charging error", 9), + _state("Pause", 10), + _state("Error", 12), + _state("Washing the mop", 13), + _state("Updating", 14), + _state("Return to the dock", 15), + _state("Pin n Go", 16), + _state("Emptying", 22), + _state("Washing the mop", 23), + _state("Return to the dock", 26), + _state("In call", 28), + _state("Mapping", 29), + _state("On cruise", 32), + _state("Reinstalling mop cloth mounts", 33), + _state("Detaching mop cloth mounts", 34), + _state("Demo Mode", 36, 37), + _state("Housework in progress", 38), + _state("Emergency Stop", 40), + _state("Positioning", 101), + _state("Charge Pending", 108), + ], + }, + "extra": {"dps": 128}, + "battery": {"dps": 122}, + "offline": { + "dps": 135, + "desc": {"en": "Offline State"}, + "value": [ + _state("Manual Shutdown", 1), + _state("Sleep & Powered Off", 2, 34), + _state("Low Battery & Powered Off", 3, 51), + _state("Updating & Restarting", 4, 103), + ], + }, +} + +CATEGORY_CARD_SPECS: dict[str, dict[str, Any]] = { + "robot.vacuum.cleaner": _ROBOT_VACUUM_CARD_SPEC, +} + + +def card_spec_for_category(category_name: str) -> dict[str, Any] | None: + spec = CATEGORY_CARD_SPECS.get(category_name) + return copy.deepcopy(spec) if spec is not None else None 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 0c0d2c6..fd85aa0 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 @@ -7,6 +7,7 @@ from shared.data_helpers import as_int, default_product_name, stable_int from ..user.homes.service import home_payload +from .card_spec import card_spec_for_category def build_product_response(ctx: ServerContext) -> dict[str, Any]: @@ -21,15 +22,23 @@ def build_product_response(ctx: ServerContext) -> dict[str, Any]: category_name = str(product.get("category") or "robot.vacuum.cleaner") if category_name not in categories: category_id = len(categories) + 1 + category_entry: dict[str, Any] = { + "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": "", + } + card_spec = card_spec_for_category(category_name) + if card_spec is not None: + # Required for the scene tiles. CategoryEntity declares both + # spellings; which one is the JSON name could not be decided + # from the bytecode, so both are sent. + category_entry["cardspec"] = card_spec + category_entry["cardSpec"] = card_spec 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": "", - }, + "category": category_entry, "productList": [], } model = str(product.get("model") or "roborock.vacuum.a117") @@ -53,6 +62,17 @@ def build_product_response(ctx: ServerContext) -> dict[str, Any]: # Read by PluginSDKModule.getProductAgreements; a missing field is # null and the device plugin's privacy check never completes. "agreements": [], + # ProductEntity.sceneProduct() looks up the "scene_2.0" tag; without + # it the scene tiles stay hidden. The app builds productTagsMap from + # this list itself. + "productTags": [ + { + "name": "scene_2.0", + "forceShow": True, + "pluginLevel": 0, + "requirePlugin": False, + } + ], } 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 index 09f8b8d..527c885 100644 --- a/tests/test_product_response.py +++ b/tests/test_product_response.py @@ -111,3 +111,48 @@ 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_product_entry_enables_scene_tiles(tmp_path: Path) -> None: + data = _products_v5(tmp_path, [_DEVICE]) + + category_detail = data["categoryDetailList"][0] + [product] = category_detail["productList"] + category = category_detail["category"] + + # ProductEntity.sceneProduct() looks up exactly this tag. + assert {"name": "scene_2.0", "forceShow": True, "pluginLevel": 0, "requirePlugin": False} in product[ + "productTags" + ] + # CardSpecV2 holds the four data points directly, without a "data" wrapper. + for key in ("cardspec", "cardSpec"): + card_spec = category[key] + assert set(card_spec) == {"state", "extra", "battery", "offline"} + assert card_spec["state"]["dps"] == 121 + assert card_spec["battery"]["dps"] == 122 + assert card_spec["extra"]["dps"] == 128 + assert card_spec["offline"]["dps"] == 135 + for entry in card_spec["state"]["value"]: + assert set(entry) == {"desc", "value"} + assert entry["desc"]["en"] + assert all(isinstance(code, int) for code in entry["value"]) + + +def test_card_spec_is_not_shared_between_responses(tmp_path: Path) -> None: + first = _products_v5(tmp_path, [_DEVICE]) + first["categoryDetailList"][0]["category"]["cardSpec"]["state"]["value"].clear() + + second = _products_v5(tmp_path, [_DEVICE]) + + assert second["categoryDetailList"][0]["category"]["cardSpec"]["state"]["value"] + + +def test_unknown_category_has_no_card_spec(tmp_path: Path) -> None: + device = dict(_DEVICE, category="other.category") + + data = _products_v5(tmp_path, [device]) + + category = data["categoryDetailList"][0]["category"] + assert category["displayName"] == "other.category" + assert "cardSpec" not in category + assert "cardspec" not in category