diff --git a/roborock/web_api.py b/roborock/web_api.py index 72840cae..b13b8e06 100644 --- a/roborock/web_api.py +++ b/roborock/web_api.py @@ -1,6 +1,7 @@ import base64 import hashlib import hmac +import json import logging import math import secrets @@ -646,6 +647,31 @@ async def get_schedules(self, user_data: UserData, device_id: str) -> list[HomeD else: raise RoborockException(f"schedule_response result was an unexpected type: {schedules}") + async def create_job(self, user_data: UserData, device_id: str, job: dict) -> dict: + """Create a /jobs entry (schedule or one-time room clean) on a B01 device. + + Body-bearing writes must sign the request body in the Hawk payload slot and send those same + compact bytes via ``data=``; ``json=`` would re-serialize with spaces and break the MAC. + """ + rriot = user_data.rriot + if rriot is None: + raise RoborockException("rriot is none") + if rriot.r.a is None: + raise RoborockException("Missing field 'a' in rriot reference") + path = f"/user/devices/{device_id}/jobs" + job_request = PreparedRequest( + rriot.r.a, + self.session, + { + "Authorization": _get_hawk_authentication(rriot, path, body=job), + "Content-Type": "application/json", + }, + ) + response = await job_request.request("post", path, data=_compact_json(job).encode()) + if not response.get("success"): + raise RoborockException(response) + return response + async def get_products(self, user_data: UserData) -> ProductResponse: """Gets all products and their schemas, good for determining status codes and model numbers.""" base_url = await self.base_url @@ -738,11 +764,25 @@ def _process_extra_hawk_values(values: dict | None) -> str: return hashlib.md5("&".join(result).encode()).hexdigest() -def _get_hawk_authentication(rriot: RRiot, url: str, formdata: dict | None = None, params: dict | None = None) -> str: +def _compact_json(body: dict) -> str: + """Serialize a JSON body to the exact compact bytes that are both signed and sent.""" + return json.dumps(body, separators=(",", ":")) + + +def _get_hawk_authentication( + rriot: RRiot, + url: str, + formdata: dict | None = None, + params: dict | None = None, + body: dict | None = None, +) -> str: timestamp = math.floor(time.time()) nonce = secrets.token_urlsafe(6) - formdata_str = _process_extra_hawk_values(formdata) params_str = _process_extra_hawk_values(params) + if body is not None: + payload_str = hashlib.md5(_compact_json(body).encode()).hexdigest() + else: + payload_str = _process_extra_hawk_values(formdata) prestr = ":".join( [ @@ -752,7 +792,7 @@ def _get_hawk_authentication(rriot: RRiot, url: str, formdata: dict | None = Non str(timestamp), hashlib.md5(url.encode()).hexdigest(), params_str, - formdata_str, + payload_str, ] ) mac = base64.b64encode(hmac.new(rriot.h.encode(), prestr.encode(), hashlib.sha256).digest()).decode() diff --git a/tests/test_web_api.py b/tests/test_web_api.py index 1685d6f1..3cb9d3d6 100644 --- a/tests/test_web_api.py +++ b/tests/test_web_api.py @@ -8,7 +8,14 @@ from roborock import HomeData, HomeDataRoom, HomeDataScene, UserData from roborock.exceptions import RoborockAccountDoesNotExist, RoborockException, RoborockInvalidCredentials -from roborock.web_api import IotLoginInfo, PreparedRequest, RoborockApiClient, UserWebApiClient +from roborock.web_api import ( + IotLoginInfo, + PreparedRequest, + RoborockApiClient, + UserWebApiClient, + _compact_json, + _get_hawk_authentication, +) from tests.mock_data import HOME_DATA_RAW, USER_DATA pytest_plugins = [ @@ -377,6 +384,42 @@ async def test_get_schedules(mock_rest) -> None: assert schedule.enabled is True +async def test_create_job(mock_rest) -> None: + """A /jobs write (schedule or one-time clean) POSTs the body and returns the parsed result.""" + api = RoborockApiClient(username="test_user@gmail.com") + ud = await api.pass_login("password") + + mock_rest.post( + "https://api-us.roborock.com/user/devices/123456/jobs", + status=200, + payload={"api": None, "result": "done", "status": "ok", "success": True}, + ) + + job = {"cron": "05 10 * * ?", "repeated": True, "enabled": True, "param": {"rooms": [1, 2], "roomCount": 2}} + response = await api.create_job(ud, "123456", job) + assert response["success"] is True + assert response["result"] == "done" + + +def test_hawk_authentication_signs_body(monkeypatch) -> None: + """Body-bearing writes sign md5(compact JSON body) in the Hawk payload slot; GET is unchanged.""" + from roborock import web_api + + monkeypatch.setattr(web_api.time, "time", lambda: 1_700_000_000) + monkeypatch.setattr(web_api.secrets, "token_urlsafe", lambda n: "fixednonce") + rriot = UserData.from_dict(USER_DATA).rriot + assert rriot is not None + + body = {"b": 2, "a": 1} + signed = _get_hawk_authentication(rriot, "/p", body=body) + unsigned = _get_hawk_authentication(rriot, "/p") + formdata_signed = _get_hawk_authentication(rriot, "/p", formdata=body) + + assert signed != unsigned # the body is actually covered by the MAC + assert signed != formdata_signed # body-signing differs from formdata-signing + assert _compact_json(body) == '{"b":2,"a":1}' + + @pytest.mark.parametrize( "result_payload", [