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
32 changes: 30 additions & 2 deletions roborock/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
from collections.abc import Callable
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any, cast
from typing import Any, TextIO, cast

try:
import click
Expand All @@ -50,7 +50,7 @@
) from err

from roborock import RoborockCommand
from roborock.data import RoborockBase, UserData
from roborock.data import HomeDataSchedule, RoborockBase, UserData
from roborock.data.b01_q10.b01_q10_code_mappings import (
B01_Q10_DP,
YXCleanType,
Expand Down Expand Up @@ -428,6 +428,33 @@ async def execute_scene(ctx, scene_id):
await client.execute_scene(connection_cache.user_data, scene_id)


@click.command()
@click.option("--device_id", required=True)
@click.option(
"--job-file",
required=True,
type=click.File("r", encoding="utf-8"),
help="JSON file containing the schedule to update.",
)
@click.pass_context
@async_command
async def update_job(ctx: click.Context, device_id: str, job_file: TextIO) -> None:
"""Replace a cloud job using a complete schedule JSON file.

Include all existing settings in the file, including unchanged cleaning parameters.
"""
context: RoborockContext = ctx.obj
try:
job = HomeDataSchedule.from_dict(json.load(job_file))
connection_cache = context.connection_cache()
client = RoborockApiClient(connection_cache.email)
await client.update_job(connection_cache.user_data, device_id, job)
except Exception as err:
click.echo(f"Error: {err}", err=True)
raise SystemExit(1) from err
click.echo(f"Updated job {job.id} for device {device_id}")


async def _v1_trait(context: RoborockContext, device_id: str, display_func: Callable[[], V1TraitMixin]) -> Trait:
device_manager = await context.get_device_manager()
device = await device_manager.get_device(device_id)
Expand Down Expand Up @@ -1366,6 +1393,7 @@ def write_markdown_table(product_features: dict[str, dict[str, any]], all_featur
cli.add_command(list_devices)
cli.add_command(list_scenes)
cli.add_command(execute_scene)
cli.add_command(update_job)
cli.add_command(status)
cli.add_command(command)
cli.add_command(parser)
Expand Down
25 changes: 25 additions & 0 deletions roborock/web_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -768,6 +768,31 @@ async def create_job(self, user_data: UserData, device_id: str, job: dict) -> di
raise RoborockException(response)
return response

async def update_job(self, user_data: UserData, device_id: str, job: HomeDataSchedule) -> None:
"""Replace an existing cloud job with the full supplied schedule.

Pass a schedule returned by :meth:`get_schedules` with the desired changes.
The job ID identifies the URL; all other fields are sent as the replacement body.
"""
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.id}"
body = job.as_dict(exclude={"id"})
job_request = PreparedRequest(
rriot.r.a,
self.session,
{
"Authorization": _get_hawk_authentication(rriot, path, body=body),
"Content-Type": "application/json",
},
)
response = await job_request.request("put", path, data=_compact_json(body).encode())
if not response or not response.get("success"):
raise RoborockException(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
87 changes: 87 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
"""Tests for the command line interface."""

import json
from pathlib import Path
from typing import Any
from unittest.mock import Mock

import pytest
from click.testing import CliRunner

from roborock.cli import ConnectionCache, RoborockContext, cli
from roborock.data import HomeDataSchedule, UserData
from roborock.exceptions import RoborockException
from roborock.web_api import RoborockApiClient
from tests.mock_data import USER_DATA, USER_EMAIL


@pytest.fixture(autouse=True)
def isolated_login(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""Use temporary cached credentials so commands never load the user's login."""
login_file = tmp_path / ".roborock"
monkeypatch.setattr(RoborockContext, "roborock_file", login_file)
monkeypatch.setattr(RoborockContext, "roborock_cache_file", tmp_path / ".roborock.cache")
login_file.write_text(
json.dumps(ConnectionCache(user_data=UserData.from_dict(USER_DATA), email=USER_EMAIL).as_dict()),
encoding="utf-8",
)


@pytest.fixture
def mock_api(monkeypatch: pytest.MonkeyPatch) -> Mock:
"""Replace the cloud client, including all asynchronous network operations."""
factory = Mock(return_value=Mock(spec=RoborockApiClient))
monkeypatch.setattr("roborock.cli.RoborockApiClient", factory)
return factory


@pytest.fixture
def job_data() -> dict[str, Any]:
"""A schedule including model-specific cleaning settings."""
return {
"id": 3878757,
"cron": "05 10 * * ?",
"repeated": True,
"enabled": False,
"param": {"mapId": 0, "rooms": [1, 2], "custom_key": {"id": 7, "value": None}},
}


def test_update_job(mock_api: Mock, tmp_path: Path, job_data: dict[str, Any]) -> None:
"""The CLI parses JSON into a schedule and calls the API using cached credentials."""
job_file = tmp_path / "job.json"
job_file.write_text(json.dumps(job_data), encoding="utf-8")

result = CliRunner().invoke(cli, ["update-job", "--device_id", "123456", "--job-file", str(job_file)])

assert result.exit_code == 0, result.output
assert result.output == "Updated job 3878757 for device 123456\n"
mock_api.assert_called_once_with(USER_EMAIL)
mock_api.return_value.update_job.assert_awaited_once_with(
UserData.from_dict(USER_DATA), "123456", HomeDataSchedule(**job_data)
)


def test_update_job_invalid_json(mock_api: Mock, tmp_path: Path) -> None:
"""A JSON parsing failure is reported without calling the cloud API."""
job_file = tmp_path / "job.json"
job_file.write_text("[", encoding="utf-8")

result = CliRunner().invoke(cli, ["update-job", "--device_id", "123456", "--job-file", str(job_file)])

assert result.exit_code == 1
assert "Error: Expecting value" in result.output
mock_api.assert_not_called()


def test_update_job_api_error(mock_api: Mock, tmp_path: Path, job_data: dict[str, Any]) -> None:
"""A cloud rejection is reported with an unsuccessful command exit."""
mock_api.return_value.update_job.side_effect = RoborockException("Job not found")
job_file = tmp_path / "job.json"
job_file.write_text(json.dumps(job_data), encoding="utf-8")

result = CliRunner().invoke(cli, ["update-job", "--device_id", "123456", "--job-file", str(job_file)])

assert result.exit_code == 1
assert result.output == "Error: Job not found\n"
mock_api.return_value.update_job.assert_awaited_once()
99 changes: 98 additions & 1 deletion tests/test_web_api.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import json
import re
from typing import Any
from unittest.mock import AsyncMock, Mock
Expand All @@ -6,7 +7,7 @@
import pytest
from aioresponses.compat import normalize_url

from roborock import HomeData, HomeDataRoom, HomeDataScene, UserData
from roborock import HomeData, HomeDataRoom, HomeDataScene, HomeDataSchedule, UserData
from roborock.exceptions import RoborockAccountDoesNotExist, RoborockException, RoborockInvalidCredentials
from roborock.web_api import (
DEFAULT_AGREEMENT_MAJOR_VERSION,
Expand Down Expand Up @@ -483,6 +484,102 @@ async def test_create_job(mock_rest) -> None:
assert response["result"] == "done"


@pytest.mark.parametrize(
("enabled", "mac"),
[
(True, "fCO2ogG/WjxJjWEZw6l3ncC7cFChl/95jjjzu/YNRV0="),
(False, "GsGuHY1XF375VJRdhMmoIYI/AacILSNCFBU1+3G/O0Y="),
],
)
async def test_update_job(mock_rest, monkeypatch, enabled: bool, mac: str) -> None:
"""Updates send the full schedule as compact JSON with a matching body-aware Hawk MAC."""
api = RoborockApiClient(username="test_user@gmail.com")
ud = UserData.from_dict(USER_DATA)
monkeypatch.setattr("roborock.web_api.time.time", lambda: 1_700_000_000)
monkeypatch.setattr("roborock.web_api.secrets.token_urlsafe", lambda n: "fixednonce")
url = "https://api-us.roborock.com/user/devices/123456/jobs/3878757"
mock_rest.put(url, payload={"result": "done", "status": "ok", "success": True})
job = HomeDataSchedule(
id=3878757,
cron="05 10 * * ?",
repeated=True,
enabled=enabled,
param={"mapId": 0, "rooms": [1, 2], "roomCount": 2, "custom_key": {"id": 7, "value": None}},
)
original_job = job.as_dict()

await api.update_job(ud, "123456", job)

enabled_json = b"true" if enabled else b"false"
expected_body = (
b'{"cron":"05 10 * * ?","repeated":true,"enabled":'
+ enabled_json
+ b',"param":{"mapId":0,"rooms":[1,2],"roomCount":2,"custom_key":{"id":7,"value":null}}}'
)
mock_rest.assert_called_once()
request = mock_rest.requests[("put", normalize_url(url))][0]
assert request.kwargs["data"] == expected_body
assert request.kwargs["headers"] == {
"Authorization": f'Hawk id="user123",s="pass123",ts="1700000000",nonce="fixednonce",mac="{mac}"',
"Content-Type": "application/json",
}
assert request.kwargs["params"] is None
assert request.kwargs["json"] is None
assert job.as_dict() == original_job


@pytest.mark.parametrize("response", [{"success": False, "error": "Job not found"}, {}, None])
async def test_update_job_failed_response(mock_rest, response: dict | None) -> None:
"""Rejected and missing success responses raise the library's exception."""
api = RoborockApiClient(username="test_user@gmail.com")
ud = UserData.from_dict(USER_DATA)
mock_rest.put(
"https://api-us.roborock.com/user/devices/123456/jobs/3878757",
body=json.dumps(response),
content_type="application/json",
)
job = HomeDataSchedule(id=3878757, cron="05 10 * * ?", repeated=True, enabled=False)

with pytest.raises(RoborockException) as exc_info:
await api.update_job(ud, "123456", job)

assert exc_info.value.args == (response,)


@pytest.mark.parametrize("missing_field", ["rriot", "a"])
async def test_update_job_missing_credentials(mock_rest, missing_field: str) -> None:
"""Missing cloud credentials are rejected before making a request."""
api = RoborockApiClient(username="test_user@gmail.com")
ud = UserData.from_dict(USER_DATA)
if missing_field == "rriot":
ud.rriot = None
message = "rriot is none"
else:
assert ud.rriot is not None
ud.rriot.r.a = None
message = "Missing field 'a' in rriot reference"
job = HomeDataSchedule(id=3878757, cron="05 10 * * ?", repeated=True, enabled=False)

with pytest.raises(RoborockException, match=message):
await api.update_job(ud, "123456", job)

assert not mock_rest.requests


async def test_update_job_network_error(mock_rest) -> None:
"""Network failures surface as RoborockException through the shared request layer."""
api = RoborockApiClient(username="test_user@gmail.com")
ud = UserData.from_dict(USER_DATA)
mock_rest.put(
"https://api-us.roborock.com/user/devices/123456/jobs/3878757",
exception=aiohttp.ClientConnectionError("connection lost"),
)
job = HomeDataSchedule(id=3878757, cron="05 10 * * ?", repeated=True, enabled=False)

with pytest.raises(RoborockException, match="Network error contacting .*: connection lost"):
await api.update_job(ud, "123456", job)


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
Expand Down
Loading