Skip to content

Commit 3f262f2

Browse files
ximexclaude
andcommitted
feat(web_api): add firmware/OTA info, update trigger and silent-OTA toggle
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 9f1a4e0 commit 3f262f2

4 files changed

Lines changed: 153 additions & 1 deletion

File tree

roborock/data/containers.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,24 @@ class HomeDataScene(RoborockBase):
328328
name: str
329329

330330

331+
@dataclass
332+
class FirmwareInfo(RoborockBase):
333+
"""Firmware/OTA info from the cloud (`ota/firmware/{duid}/updatev2`)."""
334+
335+
version: str | None = None
336+
"""Latest available firmware version."""
337+
current_version: str | None = None
338+
"""Currently installed firmware version."""
339+
updatable: bool | None = None
340+
"""Whether a newer firmware is available to install."""
341+
desc: str | None = None
342+
"""Release notes / description."""
343+
release_time: str | None = None
344+
"""Release timestamp of the available firmware."""
345+
force_update: bool | None = None
346+
"""Whether the update is mandatory (cannot be skipped)."""
347+
348+
331349
@dataclass
332350
class HomeDataSchedule(RoborockBase):
333351
id: int

roborock/web_api.py

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from pyrate_limiter import Duration, Limiter, Rate
1616

1717
from roborock import HomeDataSchedule
18-
from roborock.data import HomeData, HomeDataRoom, HomeDataScene, ProductResponse, RRiot, UserData
18+
from roborock.data import FirmwareInfo, HomeData, HomeDataRoom, HomeDataScene, ProductResponse, RRiot, UserData
1919
from roborock.exceptions import (
2020
RoborockAccountDoesNotExist,
2121
RoborockException,
@@ -608,6 +608,81 @@ async def get_scenes(self, user_data: UserData, device_id: str) -> list[HomeData
608608
else:
609609
raise RoborockException("scene_response result was an unexpected type")
610610

611+
async def get_firmware_info(self, user_data: UserData, device_id: str) -> FirmwareInfo:
612+
"""Get firmware/OTA info for a device (latest version + updatable flag).
613+
614+
Uses the rriot API host (``user_data.rriot.r.a``, e.g. ``api-eu.roborock.com``)
615+
with Hawk-signed authentication, identical to the home-data endpoints. The
616+
IoT host (``usiot.roborock.com``) returns an empty body for this path.
617+
"""
618+
rriot = user_data.rriot
619+
if rriot is None:
620+
raise RoborockException("rriot is none")
621+
if rriot.r.a is None:
622+
raise RoborockException("Missing field 'a' in rriot reference")
623+
path = f"/ota/firmware/{device_id}/updatev2"
624+
params = {"lang": "en"}
625+
firmware_request = PreparedRequest(
626+
rriot.r.a,
627+
self.session,
628+
{
629+
"Authorization": _get_hawk_authentication(rriot, path, params=params),
630+
},
631+
)
632+
firmware_response = await firmware_request.request("get", path, params=params)
633+
if not firmware_response or not firmware_response.get("success"):
634+
raise RoborockException(firmware_response)
635+
return FirmwareInfo.from_dict(firmware_response.get("result") or {})
636+
637+
async def start_firmware_update(self, user_data: UserData, device_id: str) -> None:
638+
"""Trigger the (irreversible) firmware update for a device.
639+
640+
Empty-body POST to the rriot API host with Hawk auth. The device then
641+
downloads and flashes the latest firmware reported by
642+
:meth:`get_firmware_info`.
643+
"""
644+
rriot = user_data.rriot
645+
if rriot is None:
646+
raise RoborockException("rriot is none")
647+
if rriot.r.a is None:
648+
raise RoborockException("Missing field 'a' in rriot reference")
649+
path = f"/ota/device/{device_id}/upgrade"
650+
upgrade_request = PreparedRequest(
651+
rriot.r.a,
652+
self.session,
653+
{
654+
"Authorization": _get_hawk_authentication(rriot, path),
655+
},
656+
)
657+
upgrade_response = await upgrade_request.request("post", path)
658+
if not upgrade_response or not upgrade_response.get("success"):
659+
raise RoborockException(upgrade_response)
660+
661+
async def set_silent_ota(self, user_data: UserData, device_id: str, enabled: bool) -> None:
662+
"""Enable/disable automatic (silent) firmware updates for a device.
663+
664+
Form-encoded PUT to the rriot API host with Hawk auth (the form field is
665+
part of the signed MAC). The current value is reported as
666+
``silent_ota_switch`` on the home-data device.
667+
"""
668+
rriot = user_data.rriot
669+
if rriot is None:
670+
raise RoborockException("rriot is none")
671+
if rriot.r.a is None:
672+
raise RoborockException("Missing field 'a' in rriot reference")
673+
path = f"/user/devices/{device_id}"
674+
formdata = {"silentOtaSwitch": "true" if enabled else "false"}
675+
silent_ota_request = PreparedRequest(
676+
rriot.r.a,
677+
self.session,
678+
{
679+
"Authorization": _get_hawk_authentication(rriot, path, formdata=formdata),
680+
},
681+
)
682+
silent_ota_response = await silent_ota_request.request("put", path, data=formdata)
683+
if not silent_ota_response or not silent_ota_response.get("success"):
684+
raise RoborockException(silent_ota_response)
685+
611686
async def execute_scene(self, user_data: UserData, scene_id: int) -> None:
612687
rriot = user_data.rriot
613688
if rriot is None:

tests/fixtures/web_api_fixtures.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,11 +132,40 @@ def mock_rest_fixture(skip_rate_limit: Any, home_data: dict[str, Any]) -> aiores
132132
status=200,
133133
payload={"api": None, "code": 200, "result": HOME_DATA_SCENES_RAW, "status": "ok", "success": True},
134134
)
135+
mocked.get(
136+
re.compile(r"https://.*roborock\.com/ota/firmware/.*/updatev2"),
137+
status=200,
138+
payload={
139+
"api": None,
140+
"code": 200,
141+
"result": {
142+
"version": "1.2.3",
143+
"currentVersion": "1.2.0",
144+
"updatable": True,
145+
"desc": "Bug fixes and improvements",
146+
"releaseTime": "2026-05-01",
147+
"forceUpdate": False,
148+
},
149+
"status": "ok",
150+
"success": True,
151+
},
152+
)
135153
mocked.post(
136154
re.compile(r"https://api-.*\.roborock\.com/user/scene/.*/execute"),
137155
status=200,
138156
payload={"api": None, "code": 200, "result": None, "status": "ok", "success": True},
139157
)
158+
mocked.post(
159+
re.compile(r"https://.*roborock\.com/ota/device/.*/upgrade"),
160+
status=200,
161+
payload={"api": None, "result": None, "status": "ok", "success": True},
162+
)
163+
mocked.put(
164+
re.compile(r"https://.*roborock\.com/user/devices/[^/]+$"),
165+
status=200,
166+
payload={"api": "修改设备信息", "result": None, "status": "ok", "success": True},
167+
repeat=True,
168+
)
140169
mocked.post(
141170
re.compile(r"https://.*iot\.roborock\.com/api/v4/email/code/send.*"),
142171
status=200,

tests/test_web_api.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,36 @@ async def test_get_scenes():
8181
]
8282

8383

84+
async def test_get_firmware_info():
85+
"""Test that we can get firmware/OTA info for a device."""
86+
api = RoborockApiClient(username="test_user@gmail.com")
87+
ud = await api.pass_login("password")
88+
fw = await api.get_firmware_info(ud, "abc123")
89+
assert fw.version == "1.2.3"
90+
assert fw.current_version == "1.2.0"
91+
assert fw.updatable is True
92+
assert fw.desc == "Bug fixes and improvements"
93+
assert fw.release_time == "2026-05-01"
94+
assert fw.force_update is False
95+
96+
97+
async def test_start_firmware_update():
98+
"""Test that we can trigger a firmware update for a device."""
99+
api = RoborockApiClient(username="test_user@gmail.com")
100+
ud = await api.pass_login("password")
101+
# Should not raise when the API reports success.
102+
await api.start_firmware_update(ud, "abc123")
103+
104+
105+
async def test_set_silent_ota():
106+
"""Test that we can toggle automatic firmware updates for a device."""
107+
api = RoborockApiClient(username="test_user@gmail.com")
108+
ud = await api.pass_login("password")
109+
# Should not raise when the API reports success.
110+
await api.set_silent_ota(ud, "abc123", True)
111+
await api.set_silent_ota(ud, "abc123", False)
112+
113+
84114
async def test_execute_scene(mock_rest):
85115
"""Test that we can execute a scene"""
86116
api = RoborockApiClient(username="test_user@gmail.com")

0 commit comments

Comments
 (0)