From 6865a5cb6c2cc72f196a0185306e3956e662dca9 Mon Sep 17 00:00:00 2001 From: ratel-dev Date: Fri, 23 Oct 2026 21:48:53 +0200 Subject: [PATCH 1/2] fix(routes): support /devices/{did}/sh endpoint for access renewal on newer firmware --- .../https_server/routes/fw/check.py | 38 ++++++++++++++----- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/src/roborock_local_server/bundled_backend/https_server/routes/fw/check.py b/src/roborock_local_server/bundled_backend/https_server/routes/fw/check.py index 5e1a000e..01702d92 100644 --- a/src/roborock_local_server/bundled_backend/https_server/routes/fw/check.py +++ b/src/roborock_local_server/bundled_backend/https_server/routes/fw/check.py @@ -1,4 +1,4 @@ -"""Local answer for the firmware ``POST /f/c`` "rrcheck" access renewal. +"""Local answer for firmware access renewal (rrcheck) via ``POST /f/c`` and ``GET /devices/{did}/sh``. Background (issue #120): the vacuum periodically calls ``/f/c`` to renew a local access grant. When the server had no route for it, the request fell through to the @@ -19,13 +19,11 @@ same RSA private key it uses for ``/region``, so the public key the server already recovered during onboarding is exactly what we need -- no Roborock key is involved. -Note: the ``{k, d}`` / ``{f, c, t}`` shape and the absence of a response signature -are verified only on S7 firmware. If a newer model (e.g. a298) expects a different -shape or checks a signature, this response is simply treated as another failed check --- identical to the previous catchall behaviour -- so enabling it cannot regress a -working device. See the issue for the one-capture validation that would confirm the -a298 contract. If we have no recovered key for the device we fall back to the -catchall so behaviour is unchanged. +Note: the ``{k, d}`` / ``{f, c, t}`` shape is verified on S7 firmware (via ``/f/c``) +and on the Saros 10R / a143 (via ``/devices/{did}/sh``). For ``/sh`` requests, +the payload is wrapped in the expected response envelope. Other newer models +querying ``/sh`` fall into this same flow. If we have no recovered key for the +device we fall back to the catchall so behaviour is unchanged. """ from __future__ import annotations @@ -57,7 +55,12 @@ def match(path: str, _method: str = "POST") -> bool: - return path.rstrip("/") in _MATCH_PATHS + clean = path.rstrip("/") + if clean in _MATCH_PATHS: + return True + if clean.startswith("/devices/") and clean.endswith("/sh"): + return True + return False def build_fw_check_payload(pubkey: Any, country: str, now: int) -> dict[str, str]: @@ -100,12 +103,27 @@ def build( clean_path: str, ) -> dict[str, Any]: did = ctx.extract_did(query_params, body_params) + if not did and clean_path.startswith("/devices/"): + parts = clean_path.strip("/").split("/") + if len(parts) >= 2: + did = parts[1] pubkey = ctx.device_public_key(did) if did else None if pubkey is None: # No recovered key -> we cannot forge a valid grant; keep prior behaviour. return _build_catchall(ctx, query_params, body_params, clean_path) country = (getattr(ctx, "region", "") or "us").upper() try: - return build_fw_check_payload(pubkey, country, int(time.time())) + payload = build_fw_check_payload(pubkey, country, int(time.time())) + if clean_path.rstrip("/").endswith("/sh"): + return { + "k": payload["k"], + "d": payload["d"], + "success": True, + "code": 200, + "msg": "success", + "data": payload, + "result": payload, + } + return payload except Exception: return _build_catchall(ctx, query_params, body_params, clean_path) From b3c115d64a8334a57353adc1bab791ec1770d09b Mon Sep 17 00:00:00 2001 From: ratel-dev Date: Mon, 2 Nov 2026 21:09:06 +0100 Subject: [PATCH 2/2] use extract_explicit_did before falling back to path --- .../bundled_backend/https_server/routes/fw/check.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/roborock_local_server/bundled_backend/https_server/routes/fw/check.py b/src/roborock_local_server/bundled_backend/https_server/routes/fw/check.py index 01702d92..6bd3834b 100644 --- a/src/roborock_local_server/bundled_backend/https_server/routes/fw/check.py +++ b/src/roborock_local_server/bundled_backend/https_server/routes/fw/check.py @@ -102,11 +102,12 @@ def build( body_params: dict[str, list[str]], clean_path: str, ) -> dict[str, Any]: - did = ctx.extract_did(query_params, body_params) + did = ctx.extract_explicit_did(query_params, body_params) if not did and clean_path.startswith("/devices/"): parts = clean_path.strip("/").split("/") if len(parts) >= 2: did = parts[1] + did = did or ctx.duid pubkey = ctx.device_public_key(did) if did else None if pubkey is None: # No recovered key -> we cannot forge a valid grant; keep prior behaviour.