Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions docs/CAMERA_PROTOCOL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
5 changes: 3 additions & 2 deletions roborock/callbacks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
16 changes: 11 additions & 5 deletions roborock/devices/rpc/v1_channel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -126,7 +132,7 @@ async def _send_rpc(
strategy.name,
request.request_id,
request.method,
request.params,
"<redacted>" if request.method in _SENSITIVE_CAMERA_COMMANDS else request.params,
)

message = strategy.encoder(request)
Expand All @@ -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
Expand Down Expand Up @@ -530,21 +536,21 @@ 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:
self._dps_listeners(datapoints)

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)

Expand Down
4 changes: 4 additions & 0 deletions roborock/devices/traits/v1/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
from roborock.web_api import UserWebApiClient

from . import (
camera,
child_lock,
clean_summary,
command,
Expand All @@ -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
Expand Down Expand Up @@ -123,6 +125,7 @@

__all__ = [
"PropertiesApi",
"camera",
"child_lock",
"clean_summary",
"command",
Expand Down Expand Up @@ -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
Expand Down
123 changes: 123 additions & 0 deletions roborock/devices/traits/v1/camera.py
Original file line number Diff line number Diff line change
@@ -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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Layer 5 (Testing / Patch Coverage): This Camera trait has no RPC channel guard is the only uncovered line in the PR diff. Adding a unit test in tests/devices/traits/v1/test_camera.py that calls a command on a bare CameraTrait() instance before _rpc_channel is attached will exercise this branch and bring patch coverage to 100%.


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)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On the wire, stop_camera_preview accepts {"client_id": client_id} matching the client_id passed to start_camera_preview. Consider accepting an optional client_id: str | None = None here (sending {"client_id": client_id} when provided, or None when omitted) so callers can scope teardown to their session ID.

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)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On the wire, start_voice_chat accepts {"play": True, "record": True} (and stop_voice_chat sends {}). Consider accepting optional play: bool | None = None and record: bool | None = None parameters (or forwarding them when specified) so callers can explicitly set the playback and recording flags.

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")
3 changes: 2 additions & 1 deletion roborock/protocols/v1_protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.")
Expand Down
82 changes: 82 additions & 0 deletions tests/devices/rpc/test_v1_channel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Loading
Loading