diff --git a/docs/CAMERA_PROTOCOL.md b/docs/CAMERA_PROTOCOL.md index 298e79379..0cc2ba266 100644 --- a/docs/CAMERA_PROTOCOL.md +++ b/docs/CAMERA_PROTOCOL.md @@ -129,6 +129,51 @@ Camera viewing is protected by a separate PIN / pattern lock configured in the m ## Commands Reference +### Library command interface + +Camera-equipped V1 vacuums expose `device.v1_properties.camera` after feature +discovery, gated by `is_video_monitor_supported`. It is `None` on unsupported +devices. The device manager also restricts V1 traits to V1 vacuum products. + +`CameraTrait` sends preview control, quality selection, TURN requests, SDP/ICE +exchange, and voice chat control through the existing cloud MQTT channel. +Cloud connectivity is required even when the robot is reachable locally. +PIN hashes and WebRTC signaling therefore pass through Roborock's cloud. +A single channel strategy prevents a PIN attempt that fails or times out on +one connection from being sent again through local/cloud fallback. + +It adds no WebRTC dependency and does not open a media connection. Callers +provide PIN/pattern digits, not a precomputed hash. Use a unique caller-owned +`client_id` for the session, and close any other viewer first. + +```python +from contextlib import suppress + +from roborock.exceptions import RoborockException + +camera = device.v1_properties.camera +if camera is not None: + await camera.start_preview(client_id="my-viewer", password=camera_pin) + try: + turn_server = await camera.get_turn_server() + # Create a WebRTC peer using turn_server, then exchange SDP and ICE. + # get_sdp_answer() returns None for "retry"; bound polling in the caller. + finally: + # Best-effort cleanup must not mask an earlier signaling failure. + with suppress(RoborockException): + await camera.stop_preview() +``` + +`stop_preview()` is not scoped to a client ID. Call it after a successful +start, rather than automatically stopping after an explicit start rejection. +If starting times out, the robot may already have activated the camera; +the caller must decide how to reconcile or clean up that ambiguous session. + +The caller owns polling deadlines, ICE deduplication and session teardown. +If voice chat is started, stop it before stopping preview. No polling tasks, +listeners, media objects or session credentials are retained by the trait. +The streaming/session manager is a separate layer to be added later. + See also [V1_API_COMMANDS.md](V1_API_COMMANDS.md) for general V1 device RPC references. > [!NOTE] diff --git a/roborock/callbacks.py b/roborock/callbacks.py index 6b3be39e5..d9d7a09b7 100644 --- a/roborock/callbacks.py +++ b/roborock/callbacks.py @@ -121,10 +121,11 @@ def decoder_callback( def wrapper(data: K) -> None: if not (messages := decoder(data)): - logger.debug("Failed to decode message: %s", data) + logger.debug("Failed to decode message of type %s", type(data).__name__) return for message in messages: - logger.debug("Decoded message: %s", message) + # Decrypted payloads can contain credentials; log only their shape. + logger.debug("Decoded message of type %s", type(message).__name__) safe_cb(message) return wrapper diff --git a/roborock/devices/rpc/v1_channel.py b/roborock/devices/rpc/v1_channel.py index 8259994ad..54355ca53 100644 --- a/roborock/devices/rpc/v1_channel.py +++ b/roborock/devices/rpc/v1_channel.py @@ -47,6 +47,12 @@ _T = TypeVar("_T", bound=RoborockBase) _TIMEOUT = 10.0 +_SENSITIVE_CAMERA_COMMANDS = { + RoborockCommand.CHECK_HOMESEC_PASSWORD, + RoborockCommand.START_CAMERA_PREVIEW, + RoborockCommand.SEND_SDP_TO_ROBOT, + RoborockCommand.SEND_ICE_TO_ROBOT, +} # Exponential backoff parameters for reconnecting to local @@ -126,7 +132,7 @@ async def _send_rpc( strategy.name, request.request_id, request.method, - request.params, + "" if request.method in _SENSITIVE_CAMERA_COMMANDS else request.params, ) message = strategy.encoder(request) @@ -135,7 +141,7 @@ def find_response(response_message: RoborockMessage) -> None: try: decoded = strategy.decoder(response_message) except RoborockException as ex: - logger.debug("Exception while decoding message (%s): %s", response_message, ex) + logger.debug("Exception while decoding message (seq=%s): %s", response_message.seq, type(ex).__name__) return if decoded is None: return @@ -530,13 +536,13 @@ def _should_use_cache(self, local_connect_failures: int) -> bool: def _on_mqtt_message(self, message: RoborockMessage) -> None: """Handle incoming MQTT messages.""" - self._logger.debug("V1Channel received MQTT message: %s", message) + self._logger.debug("V1Channel received MQTT message (seq=%s, protocol=%s)", message.seq, message.protocol) if self._callback: self._callback(message) try: datapoints = decode_data_protocol_message(message) except RoborockException as e: - self._logger.debug("Error decoding data protocol message: %s", e) + self._logger.debug("Error decoding data protocol message: %s", type(e).__name__) return if datapoints: @@ -544,7 +550,7 @@ def _on_mqtt_message(self, message: RoborockMessage) -> None: def _on_local_message(self, message: RoborockMessage) -> None: """Handle incoming local messages.""" - self._logger.debug("V1Channel received local message: %s", message) + self._logger.debug("V1Channel received local message (seq=%s, protocol=%s)", message.seq, message.protocol) if self._callback: self._callback(message) diff --git a/roborock/devices/traits/v1/__init__.py b/roborock/devices/traits/v1/__init__.py index d8af621b2..28c404d65 100644 --- a/roborock/devices/traits/v1/__init__.py +++ b/roborock/devices/traits/v1/__init__.py @@ -69,6 +69,7 @@ from roborock.web_api import UserWebApiClient from . import ( + camera, child_lock, clean_summary, command, @@ -94,6 +95,7 @@ volume, wash_towel_mode, ) +from .camera import CameraTrait from .child_lock import ChildLockTrait from .clean_summary import CleanSummaryTrait from .command import CommandTrait @@ -123,6 +125,7 @@ __all__ = [ "PropertiesApi", + "camera", "child_lock", "clean_summary", "command", @@ -173,6 +176,7 @@ class PropertiesApi(Trait): routines: RoutinesTrait # Optional features that may not be supported on all devices + camera: CameraTrait | None = None child_lock: ChildLockTrait | None = None led_status: LedStatusTrait | None = None flow_led_status: FlowLedStatusTrait | None = None diff --git a/roborock/devices/traits/v1/camera.py b/roborock/devices/traits/v1/camera.py new file mode 100644 index 000000000..14ead8f3d --- /dev/null +++ b/roborock/devices/traits/v1/camera.py @@ -0,0 +1,123 @@ +"""Camera control and WebRTC signaling through the existing V1 RPC channel.""" + +from roborock.data.camera import CameraIceCandidate, CameraQuality, CameraSdpDescription, CameraTurnServer +from roborock.exceptions import RoborockException, RoborockParsingException +from roborock.protocols.camera_signaling import ( + decode_ice_candidates, + decode_sdp_answer, + encode_ice_candidate, + encode_sdp_offer, + hash_camera_password, +) +from roborock.protocols.v1_protocol import ParamsType, V1RpcChannel +from roborock.roborock_typing import RoborockCommand + + +class CameraTrait: + """Control a camera session without owning a WebRTC connection. + + Callers own session ordering, bounded SDP/ICE polling and cleanup: pair + a successful ``start_preview`` with ``stop_preview`` in a ``finally`` block, and stop + voice chat if started. This trait creates no tasks, listeners or sockets. + Only one viewer can use the robot's camera at a time. + """ + + requires_feature = "is_video_monitor_supported" + # Use one existing channel strategy so a rejected PIN is not retried via + # local/cloud fallback. Local camera signaling can be exposed separately. + mqtt_rpc_channel = True + + def __init__(self) -> None: + """Initialize the trait; device discovery supplies its RPC channel.""" + self._rpc_channel: V1RpcChannel | None = None + + @property + def rpc_channel(self) -> V1RpcChannel: + """Return the channel supplied by the device.""" + if self._rpc_channel is None: + raise RoborockException("Camera trait has no RPC channel") + return self._rpc_channel + + async def check_password(self, password: str) -> None: + """Check PIN/pattern digits, raising if rejected; never retry a bad PIN.""" + await self._send_ack(RoborockCommand.CHECK_HOMESEC_PASSWORD, {"password": hash_camera_password(password)}) + + async def start_preview(self, client_id: str, password: str, quality: CameraQuality = CameraQuality.HD) -> None: + """Start preview using PIN/pattern digits and a caller-owned session ID.""" + if not client_id.strip(): + raise ValueError("Camera client ID cannot be empty") + await self._send_ack( + RoborockCommand.START_CAMERA_PREVIEW, + {"client_id": client_id, "quality": quality.value, "password": hash_camera_password(password)}, + ) + + async def stop_preview(self) -> None: + """Stop the active preview; the command is not scoped to a client ID.""" + await self._send_ack(RoborockCommand.STOP_CAMERA_PREVIEW) + + async def set_quality(self, quality: CameraQuality) -> None: + """Switch the active preview's video quality.""" + await self._send_ack(RoborockCommand.SWITCH_VIDEO_QUALITY, {"quality": quality.value}) + + async def get_turn_server(self) -> CameraTurnServer: + """Fetch ephemeral TURN credentials without retaining them on the trait.""" + response = await self.rpc_channel.send_command(RoborockCommand.GET_TURN_SERVER) + try: + if not isinstance(response, dict | list): + raise TypeError("Expected a TURN response object or list") + return CameraTurnServer.from_dict(response) + except (TypeError, ValueError): + # TURN parser errors may include credentials or URLs supplied by firmware. + raise RoborockParsingException( + trait_name=type(self).__name__, + command=RoborockCommand.GET_TURN_SERVER, + payload={"type": type(response).__name__}, + inner_error="Invalid TURN server response", + ) from None + + async def send_sdp_offer(self, sdp: str) -> None: + """Send an SDP offer, preserving its line endings.""" + await self._send_ack(RoborockCommand.SEND_SDP_TO_ROBOT, {"app_sdp": encode_sdp_offer(sdp)}) + + async def get_sdp_answer(self) -> CameraSdpDescription | None: + """Fetch once; return None while the robot reports ``retry``.""" + response = await self.rpc_channel.send_command(RoborockCommand.GET_DEVICE_SDP) + if not isinstance(response, str | dict | list): + raise RoborockParsingException( + trait_name=type(self).__name__, + command=RoborockCommand.GET_DEVICE_SDP, + payload={"type": type(response).__name__}, + inner_error="Expected an SDP response object, list or string", + ) + return decode_sdp_answer(response) + + async def send_ice_candidate(self, candidate: CameraIceCandidate) -> None: + """Send one local ICE candidate, including its media identifiers.""" + await self._send_ack(RoborockCommand.SEND_ICE_TO_ROBOT, {"app_ice": encode_ice_candidate(candidate)}) + + async def get_ice_candidates(self) -> list[CameraIceCandidate]: + """Fetch remote ICE candidates once; the caller handles deduplication.""" + response = await self.rpc_channel.send_command(RoborockCommand.GET_DEVICE_ICE) + if not isinstance(response, str | dict | list): + raise RoborockParsingException( + trait_name=type(self).__name__, + command=RoborockCommand.GET_DEVICE_ICE, + payload={"type": type(response).__name__}, + inner_error="Expected an ICE response object, list or string", + ) + return decode_ice_candidates(response) + + async def start_voice_chat(self) -> None: + """Enable the robot's microphone for the active WebRTC session.""" + await self._send_ack(RoborockCommand.START_VOICE_CHAT) + + async def stop_voice_chat(self) -> None: + """Stop voice chat for the active session.""" + await self._send_ack(RoborockCommand.STOP_VOICE_CHAT) + + async def _send_ack(self, command: RoborockCommand, params: ParamsType = None) -> None: + response = await self.rpc_channel.send_command(command, params=params) + # decode_rpc_response normalizes a bare "ok" to {}; other firmware + # sends ["ok"]. Do not treat arbitrary result dictionaries as success. + if response not in ({}, ["ok"]): + raise RoborockException(f"Camera command {command.value} was not accepted") diff --git a/roborock/protocols/v1_protocol.py b/roborock/protocols/v1_protocol.py index 7e5c857eb..f4d25d080 100644 --- a/roborock/protocols/v1_protocol.py +++ b/roborock/protocols/v1_protocol.py @@ -207,7 +207,8 @@ def decode_rpc_response(message: RoborockMessage) -> ResponseMessage: ) result = {} else: - _LOGGER.debug("Decoded V1 message result: %s", result) + # Camera results may contain TURN passwords or SDP/ICE credentials. + _LOGGER.debug("Decoded V1 message result type: %s", type(result).__name__) if isinstance(result, str): if result == "unknown_method": api_error = RoborockUnsupportedFeature("The method called is not recognized by the device.") diff --git a/tests/devices/rpc/test_v1_channel.py b/tests/devices/rpc/test_v1_channel.py index c153c752d..d676414a4 100644 --- a/tests/devices/rpc/test_v1_channel.py +++ b/tests/devices/rpc/test_v1_channel.py @@ -840,3 +840,85 @@ async def test_v1_channel_subscribe_failure_is_atomic( mock_mqtt_channel.notify_subscribers(TEST_RESPONSE) callback.assert_called_once_with(TEST_RESPONSE) unsub() + + +@pytest.mark.parametrize( + ("method", "params", "result", "secret"), + [ + (RoborockCommand.CHECK_HOMESEC_PASSWORD, {"password": "secret-pin-hash"}, ["ok"], "secret-pin-hash"), + ( + RoborockCommand.START_CAMERA_PREVIEW, + {"password": "secret-preview-hash", "quality": "HD", "client_id": "viewer"}, + ["ok"], + "secret-preview-hash", + ), + (RoborockCommand.SEND_SDP_TO_ROBOT, {"app_sdp": "secret-encoded-sdp"}, ["ok"], "secret-encoded-sdp"), + (RoborockCommand.SEND_ICE_TO_ROBOT, {"app_ice": "secret-encoded-ice"}, ["ok"], "secret-encoded-ice"), + (RoborockCommand.GET_TURN_SERVER, None, [{"url": "turn:example", "pwd": "secret-turn"}], "secret-turn"), + ], +) +async def test_camera_rpc_debug_logs_do_not_expose_secrets( + v1_channel: V1Channel, + mqtt_rpc_channel: V1RpcChannel, + mock_mqtt_channel: FakeChannel, + caplog: pytest.LogCaptureFixture, + method: RoborockCommand, + params: dict[str, str] | None, + result: list, + secret: str, +) -> None: + """Actual channel routing preserves wire values while excluding them from debug output.""" + caplog.set_level(logging.DEBUG) + mock_mqtt_channel.response_queue.append(TEST_NETWORK_INFO_RESPONSE) + unsubscribe = await v1_channel.subscribe(Mock()) + try: + caplog.clear() + mock_mqtt_channel.response_queue.append( + RoborockMessage( + protocol=RoborockMessageProtocol.RPC_RESPONSE, + payload=json.dumps({"dps": {"102": json.dumps({"id": 12346, "result": result})}}).encode(), + ) + ) + assert await mqtt_rpc_channel.send_command(method, params=params) == result + assert secret not in caplog.text + assert "request_id=12346" in caplog.text + request = mock_mqtt_channel.published_messages[-1] + assert request.payload is not None + encoded = json.loads(json.loads(request.payload)["dps"]["101"]) + assert encoded["params"] == (params if params is not None else []) + finally: + unsubscribe() + + +async def test_camera_decode_failure_does_not_log_payload( + v1_channel: V1Channel, + mqtt_rpc_channel: V1RpcChannel, + mock_mqtt_channel: FakeChannel, + caplog: pytest.LogCaptureFixture, +) -> None: + """A decoder exception containing raw JSON must not expose it through channel logs.""" + caplog.set_level(logging.DEBUG) + mock_mqtt_channel.response_queue.append(TEST_NETWORK_INFO_RESPONSE) + unsubscribe = await v1_channel.subscribe(Mock()) + secret = "secret-malformed-camera-response" + + async def reply(message: RoborockMessage) -> None: + malformed = {"id": 12346, "result": 1.5, "extra": secret} + mock_mqtt_channel.notify_subscribers( + RoborockMessage( + protocol=RoborockMessageProtocol.RPC_RESPONSE, + payload=json.dumps({"dps": {"102": json.dumps(malformed)}}).encode(), + ) + ) + mock_mqtt_channel.notify_subscribers(TEST_RESPONSE) + + mock_mqtt_channel.publish_handler = reply + try: + caplog.clear() + assert await mqtt_rpc_channel.send_command(RoborockCommand.GET_TURN_SERVER) == { + "state": RoborockStateCode.cleaning + } + assert "Exception while decoding message" in caplog.text + assert secret not in caplog.text + finally: + unsubscribe() diff --git a/tests/devices/traits/v1/test_camera.py b/tests/devices/traits/v1/test_camera.py new file mode 100644 index 000000000..159de9afa --- /dev/null +++ b/tests/devices/traits/v1/test_camera.py @@ -0,0 +1,185 @@ +"""Tests for camera commands through the V1 trait channel.""" + +import base64 +import json +from unittest.mock import AsyncMock + +import pytest + +from roborock.data import HomeDataDevice +from roborock.data.camera import CameraQuality, CameraSdpDescription, CameraTurnServer +from roborock.devices.device import RoborockDevice +from roborock.devices.traits.v1.camera import CameraTrait +from roborock.exceptions import RoborockException, RoborockParsingException, RoborockTimeout +from roborock.protocols.camera_signaling import parse_ice_candidate +from roborock.roborock_typing import RoborockCommand +from tests import mock_data + + +@pytest.fixture +def camera(mock_rpc_channel: AsyncMock) -> CameraTrait: + """Use the shared mocked V1 channel.""" + trait = CameraTrait() + trait._rpc_channel = mock_rpc_channel + mock_rpc_channel.send_command.return_value = ["ok"] + return trait + + +@pytest.mark.parametrize( + ("device_info", "supported"), + [ + (mock_data.DEVICE_PRODUCT_PAIRS["home_data_device_s5e.json"][0], False), + (mock_data.DEVICE_PRODUCT_PAIRS["home_data_device_saros_10r.json"][0], True), + ], +) +async def test_camera_discovery( + device: RoborockDevice, + mock_mqtt_rpc_channel: AsyncMock, + device_info: HomeDataDevice, + supported: bool, + discover_features_fixture: None, +) -> None: + """Feature discovery exposes camera commands only when video is supported.""" + assert device.v1_properties is not None + assert (device.v1_properties.camera is not None) is supported + if supported: + assert device.v1_properties.camera is not None + mock_mqtt_rpc_channel.send_command.return_value = ["ok"] + await device.v1_properties.camera.stop_preview() + mock_mqtt_rpc_channel.send_command.assert_called_once_with(RoborockCommand.STOP_CAMERA_PREVIEW, params=None) + + +async def test_start_preview(camera: CameraTrait, mock_rpc_channel: AsyncMock) -> None: + await camera.start_preview("viewer-id", "1234", CameraQuality.SD) + mock_rpc_channel.send_command.assert_called_once_with( + RoborockCommand.START_CAMERA_PREVIEW, + params={"client_id": "viewer-id", "password": "81dc9bdb52d04dc20036dbd8313ed055", "quality": "SD"}, + ) + + +async def test_check_password(camera: CameraTrait, mock_rpc_channel: AsyncMock) -> None: + await camera.check_password("1234") + mock_rpc_channel.send_command.assert_called_once_with( + RoborockCommand.CHECK_HOMESEC_PASSWORD, params={"password": "81dc9bdb52d04dc20036dbd8313ed055"} + ) + + +@pytest.mark.parametrize(("client_id", "password"), [(" ", "1234"), ("viewer", "abc"), ("viewer", "")]) +async def test_invalid_preview_inputs( + camera: CameraTrait, mock_rpc_channel: AsyncMock, client_id: str, password: str +) -> None: + with pytest.raises(ValueError): + await camera.start_preview(client_id, password) + mock_rpc_channel.send_command.assert_not_called() + + +@pytest.mark.parametrize("quality", list(CameraQuality)) +async def test_set_quality(camera: CameraTrait, mock_rpc_channel: AsyncMock, quality: CameraQuality) -> None: + await camera.set_quality(quality) + mock_rpc_channel.send_command.assert_called_once_with( + RoborockCommand.SWITCH_VIDEO_QUALITY, params={"quality": quality.value} + ) + + +@pytest.mark.parametrize( + ("method", "command"), + [ + ("stop_preview", RoborockCommand.STOP_CAMERA_PREVIEW), + ("start_voice_chat", RoborockCommand.START_VOICE_CHAT), + ("stop_voice_chat", RoborockCommand.STOP_VOICE_CHAT), + ], +) +@pytest.mark.parametrize("response", [["ok"], {}]) +async def test_control_commands( + camera: CameraTrait, mock_rpc_channel: AsyncMock, method: str, command: RoborockCommand, response: object +) -> None: + mock_rpc_channel.send_command.return_value = response + await getattr(camera, method)() + mock_rpc_channel.send_command.assert_called_once_with(command, params=None) + + +@pytest.mark.parametrize("wrapped", [False, True]) +async def test_turn_server(camera: CameraTrait, mock_rpc_channel: AsyncMock, wrapped: bool) -> None: + data = {"url": "turn:relay.example:3478", "user": "viewer", "pwd": "secret"} + mock_rpc_channel.send_command.return_value = [data] if wrapped else data + server = await camera.get_turn_server() + assert server == CameraTurnServer(urls=["turn:relay.example:3478"], username="viewer", credential="secret") + assert "secret" not in repr(server) + mock_rpc_channel.send_command.assert_called_once_with(RoborockCommand.GET_TURN_SERVER) + + +@pytest.mark.parametrize("response", [[], 1, {"url": "secret-invalid-url", "pwd": "secret-password"}]) +async def test_turn_parse_error_is_redacted(camera: CameraTrait, mock_rpc_channel: AsyncMock, response: object) -> None: + mock_rpc_channel.send_command.return_value = response + with pytest.raises(RoborockParsingException) as error: + await camera.get_turn_server() + assert "secret" not in str(error.value) + + +async def test_sdp_offer(camera: CameraTrait, mock_rpc_channel: AsyncMock) -> None: + await camera.send_sdp_offer("v=0\r\ns=offer\r\n") + command = mock_rpc_channel.send_command.call_args + assert command.args == (RoborockCommand.SEND_SDP_TO_ROBOT,) + assert json.loads(base64.b64decode(command.kwargs["params"]["app_sdp"])) == { + "sdp": "v=0\r\ns=offer\r\n", + "type": "offer", + } + + +@pytest.mark.parametrize("wrapped", [False, True]) +async def test_sdp_answer(camera: CameraTrait, mock_rpc_channel: AsyncMock, wrapped: bool) -> None: + answer = {"dev_sdp": base64.b64encode(b'{"sdp":"v=0\\r\\n", "type":"answer"}').decode()} + mock_rpc_channel.send_command.return_value = [answer] if wrapped else answer + assert await camera.get_sdp_answer() == CameraSdpDescription(sdp="v=0\r\n", type="answer") + + +async def test_sdp_pending_does_not_poll(camera: CameraTrait, mock_rpc_channel: AsyncMock) -> None: + mock_rpc_channel.send_command.return_value = [{"dev_sdp": "retry"}] + assert await camera.get_sdp_answer() is None + mock_rpc_channel.send_command.assert_called_once_with(RoborockCommand.GET_DEVICE_SDP) + + +async def test_ice_exchange(camera: CameraTrait, mock_rpc_channel: AsyncMock) -> None: + candidate = parse_ice_candidate("candidate:1 1 udp 123 192.0.2.1 1234 typ host", "video", 1) + await camera.send_ice_candidate(candidate) + call = mock_rpc_channel.send_command.call_args + assert call.args == (RoborockCommand.SEND_ICE_TO_ROBOT,) + encoded = call.kwargs["params"]["app_ice"] + assert json.loads(base64.b64decode(encoded)) == { + "candidate": candidate.to_sdp(), + "sdpMid": "video", + "sdpMLineIndex": 1, + } + mock_rpc_channel.send_command.return_value = [{"dev_ice": [encoded]}] + assert await camera.get_ice_candidates() == [candidate] + + +async def test_empty_remote_ice(camera: CameraTrait, mock_rpc_channel: AsyncMock) -> None: + mock_rpc_channel.send_command.return_value = {"dev_ice": []} + assert await camera.get_ice_candidates() == [] + + +@pytest.mark.parametrize("method", ["get_sdp_answer", "get_ice_candidates"]) +@pytest.mark.parametrize("response", [123, {}, ["not-base64"]]) +async def test_malformed_signaling_response( + camera: CameraTrait, mock_rpc_channel: AsyncMock, method: str, response: object +) -> None: + mock_rpc_channel.send_command.return_value = response + with pytest.raises(RoborockParsingException): + await getattr(camera, method)() + + +@pytest.mark.parametrize("response", [["error"], {"code": -1, "message": "secret"}, [], 0]) +async def test_command_rejection(camera: CameraTrait, mock_rpc_channel: AsyncMock, response: object) -> None: + mock_rpc_channel.send_command.return_value = response + with pytest.raises(RoborockException, match="was not accepted") as error: + await camera.check_password("1234") + assert "secret" not in str(error.value) + mock_rpc_channel.send_command.assert_called_once() + + +async def test_timeout_propagates_without_retry(camera: CameraTrait, mock_rpc_channel: AsyncMock) -> None: + mock_rpc_channel.send_command.side_effect = RoborockTimeout("timeout") + with pytest.raises(RoborockTimeout): + await camera.check_password("1234") + mock_rpc_channel.send_command.assert_called_once() diff --git a/tests/test_callbacks.py b/tests/test_callbacks.py index afd4698b5..3c90ab269 100644 --- a/tests/test_callbacks.py +++ b/tests/test_callbacks.py @@ -3,7 +3,9 @@ import logging from unittest.mock import Mock -from roborock.callbacks import CallbackList, CallbackMap, safe_callback +import pytest + +from roborock.callbacks import CallbackList, CallbackMap, decoder_callback, safe_callback def test_safe_callback_successful_execution(): @@ -247,3 +249,13 @@ def failing_callback(value): callback_list("test_value") mock_logger.error.assert_called_once() + + +def test_decoder_callback_does_not_log_decoded_payload(caplog: pytest.LogCaptureFixture) -> None: + """Decoded messages can contain credentials even when transport bytes were encrypted.""" + secret = "secret-decoded-credential" + received: list[str] = [] + with caplog.at_level(logging.DEBUG): + decoder_callback(lambda _: [secret], received.append)(b"encrypted") + assert received == [secret] + assert secret not in caplog.text