Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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):
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,23 +1,44 @@
from __future__ import annotations

import json
from typing import Any

from shared.context import ServerContext

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))

Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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


Expand Down
13 changes: 13 additions & 0 deletions src/roborock_local_server/bundled_backend/shared/data_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down
53 changes: 53 additions & 0 deletions tests/test_cloud_plugin_catalogs.py
Original file line number Diff line number Diff line change
@@ -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")
41 changes: 41 additions & 0 deletions tests/test_plugin_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
30 changes: 30 additions & 0 deletions tests/test_product_response.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Loading