Skip to content
Merged
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
46 changes: 43 additions & 3 deletions roborock/web_api.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import base64
import hashlib
import hmac
import json
import logging
import math
import secrets
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
[
Expand All @@ -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()
Expand Down
45 changes: 44 additions & 1 deletion tests/test_web_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -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",
[
Expand Down
Loading