Skip to content

Commit b52d817

Browse files
committed
chore: clean up security location
1 parent d57db0e commit b52d817

6 files changed

Lines changed: 131 additions & 51 deletions

File tree

roborock/devices/device_manager.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -252,7 +252,6 @@ def device_creator(home_data: HomeData, device: HomeDataDevice, product: HomeDat
252252
device_cache=device_cache,
253253
map_parser_config=map_parser_config,
254254
region=user_data.region,
255-
security_data=channel.security_data,
256255
)
257256
case DeviceVersion.A01:
258257
channel = create_mqtt_channel(user_data, mqtt_params, mqtt_session, device)

roborock/devices/rpc/v1_channel.py

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,50 @@ def find_response(response_message: RoborockMessage) -> None:
162162
return result
163163

164164

165+
class BlobRpcChannel(V1RpcChannel):
166+
"""RPC channel that adds the security envelope required for blob requests."""
167+
168+
def __init__(
169+
self,
170+
rpc_channel: V1RpcChannel,
171+
raw_blob_rpc_channel: V1RpcChannel,
172+
security_data: SecurityData,
173+
) -> None:
174+
"""Initialize the blob RPC channel."""
175+
self._rpc_channel = rpc_channel
176+
self._raw_blob_rpc_channel = raw_blob_rpc_channel
177+
self._security_data = security_data
178+
179+
async def send_command(
180+
self,
181+
method: CommandType,
182+
*,
183+
response_type: type[_T] | None = None,
184+
params: ParamsType = None,
185+
) -> _T | Any:
186+
"""Send a blob command after adding the device security parameters."""
187+
public_key = await self._rpc_channel.send_command(RoborockCommand.GET_RANDOM_PKEY)
188+
if not isinstance(public_key, dict) or not isinstance(public_key.get("pub_key"), dict):
189+
raise RoborockException("get_random_pkey response did not contain a public key")
190+
security = self._security_data.to_dict()["security"]
191+
blob_params = {
192+
"security": {
193+
"pub_key": public_key["pub_key"],
194+
"cipher_suite": 0,
195+
},
196+
"endpoint": security["endpoint"],
197+
"nonce": security["nonce"],
198+
"data_filter": params,
199+
}
200+
if response_type is not None:
201+
return await self._raw_blob_rpc_channel.send_command(
202+
method,
203+
response_type=response_type,
204+
params=blob_params,
205+
)
206+
return await self._raw_blob_rpc_channel.send_command(method, params=blob_params)
207+
208+
165209
class V1Channel(Channel):
166210
"""Unified V1 protocol channel with automatic MQTT/local connection handling.
167211
@@ -214,11 +258,6 @@ def is_mqtt_connected(self) -> bool:
214258
"""
215259
return self._mqtt_channel.is_connected and self._mqtt_unsub is not None
216260

217-
@property
218-
def security_data(self) -> SecurityData:
219-
"""Return security data used for map/blob RPC commands."""
220-
return self._security_data
221-
222261
@property
223262
def rpc_channel(self) -> V1RpcChannel:
224263
"""Return the combined RPC channel that prefers local with a fallback to MQTT.
@@ -255,7 +294,8 @@ def map_rpc_channel(self) -> V1RpcChannel:
255294
def blob_rpc_channel(self) -> V1RpcChannel:
256295
"""Return the blob RPC channel used for fetching binary content."""
257296
decoder = create_blob_response_decoder()
258-
return RpcChannel(lambda: [self._create_mqtt_rpc_strategy(decoder)], self._logger)
297+
raw_blob_rpc_channel = RpcChannel(lambda: [self._create_mqtt_rpc_strategy(decoder)], self._logger)
298+
return BlobRpcChannel(self.rpc_channel, raw_blob_rpc_channel, self._security_data)
259299

260300
def _create_local_rpc_strategy(self) -> RpcStrategy | None:
261301
"""Create the RPC strategy for local transport."""

roborock/devices/traits/v1/__init__.py

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@
6464
from roborock.devices.traits import Trait
6565
from roborock.exceptions import RoborockException
6666
from roborock.map.map_parser import MapParserConfig
67-
from roborock.protocols.v1_protocol import SecurityData, V1RpcChannel, decode_data_protocol_message
67+
from roborock.protocols.v1_protocol import V1RpcChannel, decode_data_protocol_message
6868
from roborock.roborock_message import RoborockDataProtocol, RoborockMessage
6969
from roborock.web_api import UserWebApiClient
7070

@@ -190,15 +190,13 @@ def __init__(
190190
device_cache: DeviceCache,
191191
map_parser_config: MapParserConfig | None = None,
192192
region: str | None = None,
193-
security_data: SecurityData | None = None,
194193
) -> None:
195194
"""Initialize the V1TraitProps."""
196195
self._device_uid = device_uid
197196
self._rpc_channel = rpc_channel
198197
self._mqtt_rpc_channel = mqtt_rpc_channel
199198
self._map_rpc_channel = map_rpc_channel
200199
self._blob_rpc_channel = blob_rpc_channel
201-
self._security_data = security_data
202200
self._web_api = web_api
203201
self._device_cache = device_cache
204202
self._region = region
@@ -282,12 +280,8 @@ async def discover_features(self) -> None:
282280
wash_towel_mode._rpc_channel = self._get_rpc_channel(wash_towel_mode) # type: ignore[assignment]
283281
self.wash_towel_mode = wash_towel_mode
284282

285-
if (
286-
self.obstacle_photos is None
287-
and self._security_data is not None
288-
and self._is_supported(ObstaclePhotoTrait, "obstacle_photos", dock_features)
289-
):
290-
obstacle_photos = ObstaclePhotoTrait(self._rpc_channel, self._security_data)
283+
if self.obstacle_photos is None and self._is_supported(ObstaclePhotoTrait, "obstacle_photos", dock_features):
284+
obstacle_photos = ObstaclePhotoTrait(self._rpc_channel)
291285
obstacle_photos._rpc_channel = self._get_rpc_channel(obstacle_photos)
292286
self.obstacle_photos = obstacle_photos
293287

@@ -396,7 +390,6 @@ def create(
396390
device_cache: DeviceCache,
397391
map_parser_config: MapParserConfig | None = None,
398392
region: str | None = None,
399-
security_data: SecurityData | None = None,
400393
) -> PropertiesApi:
401394
"""Create traits for V1 devices."""
402395
return PropertiesApi(
@@ -412,5 +405,4 @@ def create(
412405
device_cache,
413406
map_parser_config,
414407
region=region,
415-
security_data=security_data,
416408
)

roborock/devices/traits/v1/obstacle_photos.py

Lines changed: 3 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from roborock.data import RoborockBase
66
from roborock.devices.traits.v1 import common
77
from roborock.exceptions import RoborockException
8-
from roborock.protocols.v1_protocol import SecurityData, V1RpcChannel
8+
from roborock.protocols.v1_protocol import V1RpcChannel
99
from roborock.roborock_typing import RoborockCommand
1010

1111
_PHOTO_TYPE_SMALL = 1
@@ -76,11 +76,10 @@ class ObstaclePhotoTrait(RoborockBase, common.V1TraitMixin):
7676
blob_rpc_channel = True
7777
requires_feature = "is_ai_recognition_obstacle_supported"
7878

79-
def __init__(self, standard_rpc_channel: V1RpcChannel, security_data: SecurityData) -> None:
79+
def __init__(self, standard_rpc_channel: V1RpcChannel) -> None:
8080
"""Initialize the obstacle photo trait."""
8181
super().__init__()
8282
self._standard_rpc_channel = standard_rpc_channel
83-
self._security_data = security_data
8483

8584
async def get_enabled(self) -> bool:
8685
"""Return whether map object photo capture is enabled on the vacuum."""
@@ -91,21 +90,9 @@ async def get_enabled(self) -> bool:
9190

9291
async def get_photo(self, photo_id: str, photo_type: int = _PHOTO_TYPE_SMALL) -> ObstaclePhoto:
9392
"""Fetch an obstacle photo by its map photo id."""
94-
public_key = await self._standard_rpc_channel.send_command(RoborockCommand.GET_RANDOM_PKEY)
95-
if not isinstance(public_key, dict) or not isinstance(public_key.get("pub_key"), dict):
96-
raise RoborockException("get_random_pkey response did not contain a public key")
97-
security = self._security_data.to_dict()["security"]
9893
response = await self.rpc_channel.send_command(
9994
self.command,
100-
params={
101-
"security": {
102-
"pub_key": public_key["pub_key"],
103-
"cipher_suite": 0,
104-
},
105-
"endpoint": security["endpoint"],
106-
"nonce": security["nonce"],
107-
"data_filter": {"img_id": photo_id, "type": photo_type},
108-
},
95+
params={"img_id": photo_id, "type": photo_type},
10996
)
11097
photo = self.converter.convert(response)
11198
photo.photo_id = photo_id

tests/devices/rpc/test_v1_channel.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
and failure modes, ensuring the V1Channel behaves correctly in various scenarios.
55
"""
66

7+
import gzip
78
import json
89
import logging
910
from collections.abc import Iterator
@@ -163,6 +164,12 @@ def setup_map_rpc_channel(v1_channel: V1Channel) -> V1RpcChannel:
163164
return v1_channel.map_rpc_channel
164165

165166

167+
@pytest.fixture(name="blob_rpc_channel")
168+
def setup_blob_rpc_channel(v1_channel: V1Channel) -> V1RpcChannel:
169+
"""Fixture to set up the Blob RPC channel for tests."""
170+
return v1_channel.blob_rpc_channel
171+
172+
166173
@pytest.fixture(name="warning_caplog")
167174
def setup_warning_caplog(caplog: pytest.LogCaptureFixture) -> pytest.LogCaptureFixture:
168175
"""Fixture to capture warning messages."""
@@ -632,6 +639,75 @@ async def test_v1_channel_send_map_command(
632639
assert result == decompressed_map_data
633640

634641

642+
async def test_v1_channel_send_blob_command(
643+
blob_rpc_channel: V1RpcChannel,
644+
mock_mqtt_channel: FakeChannel,
645+
) -> None:
646+
"""Test that the blob channel adds security parameters and decodes the response."""
647+
public_key = {"n": "abc", "e": "010001"}
648+
mock_mqtt_channel.response_queue.append(
649+
RoborockMessage(
650+
protocol=RoborockMessageProtocol.RPC_RESPONSE,
651+
payload=json.dumps({"dps": {"102": json.dumps({"id": 12345, "result": {"pub_key": public_key}})}}).encode(),
652+
)
653+
)
654+
655+
blob_data = b"blob response"
656+
compressed = gzip.compress(blob_data)
657+
header_size = 24
658+
payload = bytearray(header_size + len(compressed))
659+
payload[:8] = b"ROBOROCK"
660+
payload[8:12] = (12346).to_bytes(4, "little")
661+
payload[16:18] = header_size.to_bytes(2, "little")
662+
payload[20:24] = len(compressed).to_bytes(4, "little")
663+
payload[header_size:] = compressed
664+
mock_mqtt_channel.response_queue.append(
665+
RoborockMessage(protocol=RoborockMessageProtocol.MAP_RESPONSE, payload=bytes(payload))
666+
)
667+
668+
result = await blob_rpc_channel.send_command(
669+
RoborockCommand.GET_PHOTO,
670+
params={"img_id": "photo-id", "type": 1},
671+
)
672+
673+
assert result == blob_data
674+
sent_payload = mock_mqtt_channel.published_messages[-1].payload
675+
assert sent_payload is not None
676+
request_payload = json.loads(sent_payload)
677+
request = json.loads(request_payload["dps"]["101"])
678+
assert request["method"] == RoborockCommand.GET_PHOTO
679+
assert request["params"] == {
680+
"security": {
681+
"pub_key": public_key,
682+
"cipher_suite": 0,
683+
},
684+
"endpoint": TEST_SECURITY_DATA.endpoint,
685+
"nonce": TEST_SECURITY_DATA.nonce.hex(),
686+
"data_filter": {"img_id": "photo-id", "type": 1},
687+
}
688+
689+
690+
async def test_v1_channel_send_blob_command_rejects_invalid_public_key(
691+
blob_rpc_channel: V1RpcChannel,
692+
mock_mqtt_channel: FakeChannel,
693+
) -> None:
694+
"""Test that the blob channel rejects an invalid public key response."""
695+
mock_mqtt_channel.response_queue.append(
696+
RoborockMessage(
697+
protocol=RoborockMessageProtocol.RPC_RESPONSE,
698+
payload=json.dumps({"dps": {"102": json.dumps({"id": 12345, "result": {}})}}).encode(),
699+
)
700+
)
701+
702+
with pytest.raises(RoborockException, match="did not contain a public key"):
703+
await blob_rpc_channel.send_command(
704+
RoborockCommand.GET_PHOTO,
705+
params={"img_id": "photo-id", "type": 1},
706+
)
707+
708+
assert len(mock_mqtt_channel.published_messages) == 1
709+
710+
635711
async def test_v1_channel_add_dps_listener(
636712
v1_channel: V1Channel,
637713
mock_mqtt_channel: FakeChannel,

tests/devices/traits/v1/test_obstacle_photos.py

Lines changed: 3 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
parse_photo_data,
1212
)
1313
from roborock.exceptions import RoborockException
14-
from roborock.protocols.v1_protocol import SecurityData, create_blob_response_decoder
14+
from roborock.protocols.v1_protocol import create_blob_response_decoder
1515
from roborock.roborock_message import RoborockMessage, RoborockMessageProtocol
1616
from roborock.roborock_typing import RoborockCommand
1717

@@ -33,10 +33,7 @@ def _block(block_type: int, payload: bytes, header_size: int = 8) -> bytes:
3333
def obstacle_photo_trait(device: RoborockDevice, mock_blob_rpc_channel: AsyncMock) -> ObstaclePhotoTrait:
3434
"""Create an ObstaclePhotoTrait instance with mocked dependencies."""
3535
assert device.v1_properties
36-
trait = ObstaclePhotoTrait(
37-
device.v1_properties.status.rpc_channel,
38-
SecurityData(endpoint="endpoint", nonce=b"1234567890abcdef"),
39-
)
36+
trait = ObstaclePhotoTrait(device.v1_properties.status.rpc_channel)
4037
trait._rpc_channel = mock_blob_rpc_channel
4138
return trait
4239

@@ -101,26 +98,15 @@ async def test_get_enabled(obstacle_photo_trait: ObstaclePhotoTrait, mock_rpc_ch
10198
async def test_get_photo(
10299
obstacle_photo_trait: ObstaclePhotoTrait,
103100
mock_blob_rpc_channel: AsyncMock,
104-
mock_rpc_channel: AsyncMock,
105101
) -> None:
106102
"""Test fetching and parsing obstacle photo content."""
107-
mock_rpc_channel.send_command.return_value = {"pub_key": {"n": "abc", "e": "010001"}}
108103
mock_blob_rpc_channel.send_command.return_value = _block(3, PNG_BYTES)
109104

110105
photo = await obstacle_photo_trait.get_photo("photo-id")
111106

112107
assert photo.photo_id == "photo-id"
113108
assert photo.image_content == PNG_BYTES
114-
mock_rpc_channel.send_command.assert_called_once_with(RoborockCommand.GET_RANDOM_PKEY)
115109
mock_blob_rpc_channel.send_command.assert_called_once_with(
116110
RoborockCommand.GET_PHOTO,
117-
params={
118-
"security": {
119-
"pub_key": {"n": "abc", "e": "010001"},
120-
"cipher_suite": 0,
121-
},
122-
"endpoint": "endpoint",
123-
"nonce": "31323334353637383930616263646566",
124-
"data_filter": {"img_id": "photo-id", "type": 1},
125-
},
111+
params={"img_id": "photo-id", "type": 1},
126112
)

0 commit comments

Comments
 (0)