diff --git a/roborock/cli.py b/roborock/cli.py index 5d1e1cbc..c797213c 100644 --- a/roborock/cli.py +++ b/roborock/cli.py @@ -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 @@ -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, @@ -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) @@ -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) diff --git a/roborock/web_api.py b/roborock/web_api.py index 88ed2b1c..cdadb904 100644 --- a/roborock/web_api.py +++ b/roborock/web_api.py @@ -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 diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 00000000..2dc63835 --- /dev/null +++ b/tests/test_cli.py @@ -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() diff --git a/tests/test_web_api.py b/tests/test_web_api.py index a4e38ec7..72038435 100644 --- a/tests/test_web_api.py +++ b/tests/test_web_api.py @@ -1,3 +1,4 @@ +import json import re from typing import Any from unittest.mock import AsyncMock, Mock @@ -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, @@ -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