Skip to content

Commit c18baff

Browse files
committed
fix: handle different error format for map status
1 parent a69286f commit c18baff

File tree

4 files changed

+111
-12
lines changed

4 files changed

+111
-12
lines changed

roborock/devices/traits/v1/home.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,20 @@
3737
MAP_SLEEP = 3
3838

3939

40+
def _extract_api_error_code(err: RoborockException) -> int | None:
41+
"""Extract an API error code from a RoborockException, if present.
42+
43+
V1 RPC error responses typically look like: {"code": -10007, "message": "..."}.
44+
"""
45+
if not err.args:
46+
return None
47+
payload = err.args[0]
48+
if isinstance(payload, dict):
49+
code = payload.get("code")
50+
return code if isinstance(code, int) else None
51+
return None
52+
53+
4054
class HomeTrait(RoborockBase, common.V1TraitMixin):
4155
"""Trait that represents a full view of the home layout."""
4256

@@ -150,7 +164,15 @@ async def _build_home_map_info(self) -> tuple[dict[int, CombinedMapInfo], dict[i
150164
# We need to load each map to get its room data
151165
if len(sorted_map_infos) > 1:
152166
_LOGGER.debug("Loading map %s", map_info.map_flag)
153-
await self._maps_trait.set_current_map(map_info.map_flag)
167+
try:
168+
await self._maps_trait.set_current_map(map_info.map_flag)
169+
except RoborockException as ex:
170+
# Some firmware revisions return -10007 ("invalid status"/action locked) when attempting
171+
# to switch maps while the device is in a state that forbids it. Treat this as a
172+
# "busy" condition so callers can fall back to refreshing the current map only.
173+
if _extract_api_error_code(ex) == -10007:
174+
raise RoborockDeviceBusy("Cannot switch maps right now (device action locked)") from ex
175+
raise
154176
await asyncio.sleep(MAP_SLEEP)
155177

156178
map_content = await self._refresh_map_content()

roborock/protocols/v1_protocol.py

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -154,26 +154,37 @@ def decode_rpc_response(message: RoborockMessage) -> ResponseMessage:
154154
) from e
155155

156156
request_id: int | None = data_point_response.get("id")
157-
exc: RoborockException | None = None
157+
api_error: RoborockException | None = None
158158
if error := data_point_response.get("error"):
159-
exc = RoborockException(error)
159+
api_error = RoborockException(error)
160+
160161
if (result := data_point_response.get("result")) is None:
161-
exc = RoborockException(f"Invalid V1 message format: missing 'result' in data point for {message.payload!r}")
162+
# Some firmware versions return an error-only response (no "result" key).
163+
# Preserve that error instead of overwriting it with a parsing exception.
164+
if api_error is None:
165+
api_error = RoborockException(
166+
f"Invalid V1 message format: missing 'result' in data point for {message.payload!r}"
167+
)
162168
else:
163169
_LOGGER.debug("Decoded V1 message result: %s", result)
164170
if isinstance(result, str):
165171
if result == "unknown_method":
166-
exc = RoborockUnsupportedFeature("The method called is not recognized by the device.")
172+
api_error = RoborockUnsupportedFeature("The method called is not recognized by the device.")
167173
elif result != "ok":
168-
exc = RoborockException(f"Unexpected API Result: {result}")
174+
api_error = RoborockException(f"Unexpected API Result: {result}")
169175
result = {}
170176
if not isinstance(result, dict | list | int):
171-
raise RoborockException(
172-
f"Invalid V1 message format: 'result' was unexpected type {type(result)}. {message.payload!r}"
173-
)
174-
if not request_id and exc:
175-
raise exc
176-
return ResponseMessage(request_id=request_id, data=result, api_error=exc)
177+
# If we already have an API error, prefer returning a response object
178+
# rather than failing to decode the message entirely.
179+
if api_error is None:
180+
raise RoborockException(
181+
f"Invalid V1 message format: 'result' was unexpected type {type(result)}. {message.payload!r}"
182+
)
183+
result = {}
184+
185+
if not request_id and api_error:
186+
raise api_error
187+
return ResponseMessage(request_id=request_id, data=result, api_error=api_error)
177188

178189

179190
@dataclass

tests/devices/traits/v1/test_home.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -538,6 +538,50 @@ async def test_discover_home_device_busy_cleaning(
538538
assert len(device_cache_data.home_map_content_base64) == 2
539539

540540

541+
async def test_refresh_falls_back_when_map_switch_action_locked(
542+
status_trait: StatusTrait,
543+
home_trait: HomeTrait,
544+
mock_rpc_channel: AsyncMock,
545+
mock_mqtt_rpc_channel: AsyncMock,
546+
mock_map_rpc_channel: AsyncMock,
547+
device_cache: DeviceCache,
548+
) -> None:
549+
"""Test that refresh falls back to current map when map switching is locked."""
550+
# Discovery attempt: we can list maps, but switching maps fails with -10007.
551+
mock_mqtt_rpc_channel.send_command.side_effect = [
552+
MULTI_MAP_LIST_DATA, # Maps refresh during discover_home()
553+
RoborockException({"code": -10007, "message": "invalid status"}), # LOAD_MULTI_MAP action locked
554+
MULTI_MAP_LIST_DATA, # Maps refresh during refresh() fallback
555+
]
556+
557+
# Fallback refresh should still be able to refresh the current map.
558+
mock_rpc_channel.send_command.side_effect = [
559+
ROOM_MAPPING_DATA_MAP_0, # Rooms for current map
560+
]
561+
mock_map_rpc_channel.send_command.side_effect = [
562+
MAP_BYTES_RESPONSE_1, # Map bytes for current map
563+
]
564+
565+
await home_trait.refresh()
566+
567+
current_data = home_trait.current_map_data
568+
assert current_data is not None
569+
assert current_data.map_flag == 0
570+
assert current_data.name == "Ground Floor"
571+
572+
assert home_trait.home_map_info is not None
573+
assert home_trait.home_map_info.keys() == {0}
574+
assert home_trait.home_map_content is not None
575+
assert home_trait.home_map_content.keys() == {0}
576+
map_0_content = home_trait.home_map_content[0]
577+
assert map_0_content.image_content == TEST_IMAGE_CONTENT_1
578+
579+
# Discovery did not complete, so the persistent cache should not be updated.
580+
cache_data = await device_cache.get()
581+
assert not cache_data.home_map_info
582+
assert not cache_data.home_map_content_base64
583+
584+
541585
async def test_single_map_no_switching(
542586
home_trait: HomeTrait,
543587
mock_rpc_channel: AsyncMock,

tests/protocols/test_v1_protocol.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,28 @@ def test_decode_no_request_id():
275275
decode_rpc_response(message)
276276

277277

278+
def test_decode_error_without_result() -> None:
279+
"""Test decoding an error-only V1 RPC response (no 'result' key)."""
280+
payload = (
281+
b'{"t":1768419740,"dps":{"102":"{\\"id\\":20062,\\"error\\":{\\"code\\":-10007,'
282+
b'\\"message\\":\\"invalid status\\"}}"}}'
283+
)
284+
message = RoborockMessage(
285+
protocol=RoborockMessageProtocol.GENERAL_RESPONSE,
286+
payload=payload,
287+
seq=12750,
288+
version=b"1.0",
289+
random=97431,
290+
timestamp=1768419740,
291+
)
292+
decoded_message = decode_rpc_response(message)
293+
assert decoded_message.request_id == 20062
294+
assert decoded_message.data == {}
295+
assert decoded_message.api_error
296+
assert isinstance(decoded_message.api_error.args[0], dict)
297+
assert decoded_message.api_error.args[0]["code"] == -10007
298+
299+
278300
def test_invalid_unicode() -> None:
279301
"""Test an error while decoding unicode bytes"""
280302
message = RoborockMessage(

0 commit comments

Comments
 (0)