roborock.data.camera

Data models for Roborock camera live view.

  1"""Data models for Roborock camera live view."""
  2
  3from dataclasses import dataclass, field
  4from enum import StrEnum
  5from typing import Any, Self
  6
  7from .containers import RoborockBase
  8
  9__all__ = ["CameraIceCandidate", "CameraQuality", "CameraSdpDescription", "CameraTurnServer"]
 10
 11_VALID_TURN_SCHEMES = ("turn:", "turns:", "stun:", "stuns:")
 12
 13
 14class CameraQuality(StrEnum):
 15    """Camera video streaming resolution setting."""
 16
 17    HD = "HD"
 18    SD = "SD"
 19    FHD = "FHD"
 20    AUTO = "auto"
 21
 22    @classmethod
 23    def from_str(cls, value: str) -> Self:
 24        """Parse CameraQuality case-insensitively."""
 25        normalized = value.strip()
 26        # "auto" is lowercase in the Roborock app enum, while HD/SD/FHD are uppercase
 27        for member in cls:
 28            if member.value.lower() == normalized.lower():
 29                return member
 30        valid = ", ".join(repr(m.value) for m in cls)
 31        raise ValueError(f"Invalid camera quality: {value!r}. Expected one of {valid}.")
 32
 33
 34@dataclass
 35class CameraTurnServer(RoborockBase):
 36    """Ephemeral TURN server credentials for WebRTC peer connection relay."""
 37
 38    urls: list[str]
 39    username: str | None = None
 40    credential: str | None = field(default=None, repr=False)
 41
 42    @classmethod
 43    def from_dict(cls, data: dict[str, Any] | list[dict[str, Any]]) -> Self:
 44        """Create CameraTurnServer from get_turn_server response or dict.
 45
 46        Handles both device RPC responses (single-item list or dict with 'url', 'user', 'pwd')
 47        and standard WebRTC configuration dicts ('urls', 'username', 'credential').
 48        """
 49        if isinstance(data, list):
 50            if not data:
 51                raise ValueError("Empty TURN server data list")
 52            item = data[0]
 53            if not isinstance(item, dict):
 54                raise TypeError(f"Expected dict inside TURN server list, got {type(item).__name__}")
 55            data = item
 56        if not isinstance(data, dict):
 57            raise TypeError(f"Expected dict or list of dicts for TURN server, got {type(data).__name__}")
 58
 59        if "urls" in data:
 60            urls_val = data["urls"]
 61        elif "url" in data:
 62            urls_val = data["url"]
 63        else:
 64            raise ValueError("Missing 'url' or 'urls' in TURN server response")
 65
 66        urls: list[str] = []
 67        if isinstance(urls_val, str):
 68            urls_clean = urls_val.strip()
 69            if not urls_clean:
 70                raise ValueError("TURN server URL cannot be empty")
 71            urls = [urls_clean]
 72        elif isinstance(urls_val, list):
 73            if not urls_val:
 74                raise ValueError("TURN server 'urls' list cannot be empty")
 75            for u in urls_val:
 76                if not isinstance(u, str) or not u.strip():
 77                    raise ValueError(f"Each URL in 'urls' must be a non-empty string, got {u!r}")
 78                urls.append(u.strip())
 79        else:
 80            raise TypeError(f"Expected str or list for urls, got {type(urls_val).__name__}")
 81
 82        # Validate URL scheme (stun:, stuns:, turn:, turns:)
 83        for u in urls:
 84            if not any(u.lower().startswith(scheme) for scheme in _VALID_TURN_SCHEMES):
 85                raise ValueError(f"Invalid ICE server URL scheme in {u!r}. Expected turn:, turns:, stun:, or stuns:")
 86
 87        user_val = data.get("username") if "username" in data else data.get("user")
 88        pwd_val = data.get("credential") if "credential" in data else data.get("pwd")
 89
 90        if user_val is not None and not isinstance(user_val, str):
 91            raise TypeError(f"Expected str for username, got {type(user_val).__name__}")
 92        if pwd_val is not None and not isinstance(pwd_val, str):
 93            raise TypeError(f"Expected str for credential, got {type(pwd_val).__name__}")
 94
 95        return cls(
 96            urls=urls,
 97            username=user_val,
 98            credential=pwd_val,
 99        )
100
101
102@dataclass
103class CameraIceCandidate(RoborockBase):
104    """Parsed WebRTC ICE candidate representation (RFC 5245 / RFC 8839)."""
105
106    foundation: str
107    component: int
108    protocol: str
109    priority: int
110    ip: str
111    port: int
112    candidate_type: str
113    related_address: str | None = None
114    related_port: int | None = None
115    tcp_type: str | None = None
116    sdp_mid: str | None = None
117    sdp_mline_index: int | None = None
118    extensions: dict[str, str] = field(default_factory=dict)
119
120    def to_sdp(self) -> str:
121        """Format candidate as an SDP attribute line value (RFC 5245 / RFC 8839)."""
122        parts = [
123            f"candidate:{self.foundation}",
124            str(self.component),
125            self.protocol.lower(),
126            str(self.priority),
127            self.ip,
128            str(self.port),
129            "typ",
130            self.candidate_type.lower(),
131        ]
132        if self.related_address is not None and self.related_port is not None:
133            parts.extend(["raddr", self.related_address, "rport", str(self.related_port)])
134        if self.tcp_type is not None:
135            parts.extend(["tcptype", self.tcp_type.lower()])
136        for ext_k, ext_v in self.extensions.items():
137            parts.extend([ext_k, ext_v])
138        return " ".join(parts)
139
140    def to_signaling_dict(self) -> dict[str, str | int]:
141        """Convert to Roborock camera signaling dictionary format."""
142        return {
143            "candidate": self.to_sdp(),
144            "sdpMid": self.sdp_mid if self.sdp_mid is not None else "0",
145            "sdpMLineIndex": self.sdp_mline_index if self.sdp_mline_index is not None else 0,
146        }
147
148    def __str__(self) -> str:
149        """Return the SDP candidate string."""
150        return self.to_sdp()
151
152
153@dataclass
154class CameraSdpDescription(RoborockBase):
155    """WebRTC session description exchanged during camera signaling."""
156
157    sdp: str
158    type: str
@dataclass
class CameraIceCandidate(roborock.data.containers.RoborockBase):
103@dataclass
104class CameraIceCandidate(RoborockBase):
105    """Parsed WebRTC ICE candidate representation (RFC 5245 / RFC 8839)."""
106
107    foundation: str
108    component: int
109    protocol: str
110    priority: int
111    ip: str
112    port: int
113    candidate_type: str
114    related_address: str | None = None
115    related_port: int | None = None
116    tcp_type: str | None = None
117    sdp_mid: str | None = None
118    sdp_mline_index: int | None = None
119    extensions: dict[str, str] = field(default_factory=dict)
120
121    def to_sdp(self) -> str:
122        """Format candidate as an SDP attribute line value (RFC 5245 / RFC 8839)."""
123        parts = [
124            f"candidate:{self.foundation}",
125            str(self.component),
126            self.protocol.lower(),
127            str(self.priority),
128            self.ip,
129            str(self.port),
130            "typ",
131            self.candidate_type.lower(),
132        ]
133        if self.related_address is not None and self.related_port is not None:
134            parts.extend(["raddr", self.related_address, "rport", str(self.related_port)])
135        if self.tcp_type is not None:
136            parts.extend(["tcptype", self.tcp_type.lower()])
137        for ext_k, ext_v in self.extensions.items():
138            parts.extend([ext_k, ext_v])
139        return " ".join(parts)
140
141    def to_signaling_dict(self) -> dict[str, str | int]:
142        """Convert to Roborock camera signaling dictionary format."""
143        return {
144            "candidate": self.to_sdp(),
145            "sdpMid": self.sdp_mid if self.sdp_mid is not None else "0",
146            "sdpMLineIndex": self.sdp_mline_index if self.sdp_mline_index is not None else 0,
147        }
148
149    def __str__(self) -> str:
150        """Return the SDP candidate string."""
151        return self.to_sdp()

Parsed WebRTC ICE candidate representation (RFC 5245 / RFC 8839).

CameraIceCandidate( foundation: str, component: int, protocol: str, priority: int, ip: str, port: int, candidate_type: str, related_address: str | None = None, related_port: int | None = None, tcp_type: str | None = None, sdp_mid: str | None = None, sdp_mline_index: int | None = None, extensions: dict[str, str] = <factory>)
foundation: str
component: int
protocol: str
priority: int
ip: str
port: int
candidate_type: str
related_address: str | None = None
related_port: int | None = None
tcp_type: str | None = None
sdp_mid: str | None = None
sdp_mline_index: int | None = None
extensions: dict[str, str]
def to_sdp(self) -> str:
121    def to_sdp(self) -> str:
122        """Format candidate as an SDP attribute line value (RFC 5245 / RFC 8839)."""
123        parts = [
124            f"candidate:{self.foundation}",
125            str(self.component),
126            self.protocol.lower(),
127            str(self.priority),
128            self.ip,
129            str(self.port),
130            "typ",
131            self.candidate_type.lower(),
132        ]
133        if self.related_address is not None and self.related_port is not None:
134            parts.extend(["raddr", self.related_address, "rport", str(self.related_port)])
135        if self.tcp_type is not None:
136            parts.extend(["tcptype", self.tcp_type.lower()])
137        for ext_k, ext_v in self.extensions.items():
138            parts.extend([ext_k, ext_v])
139        return " ".join(parts)

Format candidate as an SDP attribute line value (RFC 5245 / RFC 8839).

def to_signaling_dict(self) -> dict[str, str | int]:
141    def to_signaling_dict(self) -> dict[str, str | int]:
142        """Convert to Roborock camera signaling dictionary format."""
143        return {
144            "candidate": self.to_sdp(),
145            "sdpMid": self.sdp_mid if self.sdp_mid is not None else "0",
146            "sdpMLineIndex": self.sdp_mline_index if self.sdp_mline_index is not None else 0,
147        }

Convert to Roborock camera signaling dictionary format.

class CameraQuality(enum.StrEnum):
15class CameraQuality(StrEnum):
16    """Camera video streaming resolution setting."""
17
18    HD = "HD"
19    SD = "SD"
20    FHD = "FHD"
21    AUTO = "auto"
22
23    @classmethod
24    def from_str(cls, value: str) -> Self:
25        """Parse CameraQuality case-insensitively."""
26        normalized = value.strip()
27        # "auto" is lowercase in the Roborock app enum, while HD/SD/FHD are uppercase
28        for member in cls:
29            if member.value.lower() == normalized.lower():
30                return member
31        valid = ", ".join(repr(m.value) for m in cls)
32        raise ValueError(f"Invalid camera quality: {value!r}. Expected one of {valid}.")

Camera video streaming resolution setting.

HD = <CameraQuality.HD: 'HD'>
SD = <CameraQuality.SD: 'SD'>
FHD = <CameraQuality.FHD: 'FHD'>
AUTO = <CameraQuality.AUTO: 'auto'>
@classmethod
def from_str(cls, value: str) -> Self:
23    @classmethod
24    def from_str(cls, value: str) -> Self:
25        """Parse CameraQuality case-insensitively."""
26        normalized = value.strip()
27        # "auto" is lowercase in the Roborock app enum, while HD/SD/FHD are uppercase
28        for member in cls:
29            if member.value.lower() == normalized.lower():
30                return member
31        valid = ", ".join(repr(m.value) for m in cls)
32        raise ValueError(f"Invalid camera quality: {value!r}. Expected one of {valid}.")

Parse CameraQuality case-insensitively.

@dataclass
class CameraSdpDescription(roborock.data.containers.RoborockBase):
154@dataclass
155class CameraSdpDescription(RoborockBase):
156    """WebRTC session description exchanged during camera signaling."""
157
158    sdp: str
159    type: str

WebRTC session description exchanged during camera signaling.

CameraSdpDescription(sdp: str, type: str)
sdp: str
type: str
@dataclass
class CameraTurnServer(roborock.data.containers.RoborockBase):
 35@dataclass
 36class CameraTurnServer(RoborockBase):
 37    """Ephemeral TURN server credentials for WebRTC peer connection relay."""
 38
 39    urls: list[str]
 40    username: str | None = None
 41    credential: str | None = field(default=None, repr=False)
 42
 43    @classmethod
 44    def from_dict(cls, data: dict[str, Any] | list[dict[str, Any]]) -> Self:
 45        """Create CameraTurnServer from get_turn_server response or dict.
 46
 47        Handles both device RPC responses (single-item list or dict with 'url', 'user', 'pwd')
 48        and standard WebRTC configuration dicts ('urls', 'username', 'credential').
 49        """
 50        if isinstance(data, list):
 51            if not data:
 52                raise ValueError("Empty TURN server data list")
 53            item = data[0]
 54            if not isinstance(item, dict):
 55                raise TypeError(f"Expected dict inside TURN server list, got {type(item).__name__}")
 56            data = item
 57        if not isinstance(data, dict):
 58            raise TypeError(f"Expected dict or list of dicts for TURN server, got {type(data).__name__}")
 59
 60        if "urls" in data:
 61            urls_val = data["urls"]
 62        elif "url" in data:
 63            urls_val = data["url"]
 64        else:
 65            raise ValueError("Missing 'url' or 'urls' in TURN server response")
 66
 67        urls: list[str] = []
 68        if isinstance(urls_val, str):
 69            urls_clean = urls_val.strip()
 70            if not urls_clean:
 71                raise ValueError("TURN server URL cannot be empty")
 72            urls = [urls_clean]
 73        elif isinstance(urls_val, list):
 74            if not urls_val:
 75                raise ValueError("TURN server 'urls' list cannot be empty")
 76            for u in urls_val:
 77                if not isinstance(u, str) or not u.strip():
 78                    raise ValueError(f"Each URL in 'urls' must be a non-empty string, got {u!r}")
 79                urls.append(u.strip())
 80        else:
 81            raise TypeError(f"Expected str or list for urls, got {type(urls_val).__name__}")
 82
 83        # Validate URL scheme (stun:, stuns:, turn:, turns:)
 84        for u in urls:
 85            if not any(u.lower().startswith(scheme) for scheme in _VALID_TURN_SCHEMES):
 86                raise ValueError(f"Invalid ICE server URL scheme in {u!r}. Expected turn:, turns:, stun:, or stuns:")
 87
 88        user_val = data.get("username") if "username" in data else data.get("user")
 89        pwd_val = data.get("credential") if "credential" in data else data.get("pwd")
 90
 91        if user_val is not None and not isinstance(user_val, str):
 92            raise TypeError(f"Expected str for username, got {type(user_val).__name__}")
 93        if pwd_val is not None and not isinstance(pwd_val, str):
 94            raise TypeError(f"Expected str for credential, got {type(pwd_val).__name__}")
 95
 96        return cls(
 97            urls=urls,
 98            username=user_val,
 99            credential=pwd_val,
100        )

Ephemeral TURN server credentials for WebRTC peer connection relay.

CameraTurnServer( urls: list[str], username: str | None = None, credential: str | None = None)
urls: list[str]
username: str | None = None
credential: str | None = None
@classmethod
def from_dict(cls, data: dict[str, typing.Any] | list[dict[str, typing.Any]]) -> Self:
 43    @classmethod
 44    def from_dict(cls, data: dict[str, Any] | list[dict[str, Any]]) -> Self:
 45        """Create CameraTurnServer from get_turn_server response or dict.
 46
 47        Handles both device RPC responses (single-item list or dict with 'url', 'user', 'pwd')
 48        and standard WebRTC configuration dicts ('urls', 'username', 'credential').
 49        """
 50        if isinstance(data, list):
 51            if not data:
 52                raise ValueError("Empty TURN server data list")
 53            item = data[0]
 54            if not isinstance(item, dict):
 55                raise TypeError(f"Expected dict inside TURN server list, got {type(item).__name__}")
 56            data = item
 57        if not isinstance(data, dict):
 58            raise TypeError(f"Expected dict or list of dicts for TURN server, got {type(data).__name__}")
 59
 60        if "urls" in data:
 61            urls_val = data["urls"]
 62        elif "url" in data:
 63            urls_val = data["url"]
 64        else:
 65            raise ValueError("Missing 'url' or 'urls' in TURN server response")
 66
 67        urls: list[str] = []
 68        if isinstance(urls_val, str):
 69            urls_clean = urls_val.strip()
 70            if not urls_clean:
 71                raise ValueError("TURN server URL cannot be empty")
 72            urls = [urls_clean]
 73        elif isinstance(urls_val, list):
 74            if not urls_val:
 75                raise ValueError("TURN server 'urls' list cannot be empty")
 76            for u in urls_val:
 77                if not isinstance(u, str) or not u.strip():
 78                    raise ValueError(f"Each URL in 'urls' must be a non-empty string, got {u!r}")
 79                urls.append(u.strip())
 80        else:
 81            raise TypeError(f"Expected str or list for urls, got {type(urls_val).__name__}")
 82
 83        # Validate URL scheme (stun:, stuns:, turn:, turns:)
 84        for u in urls:
 85            if not any(u.lower().startswith(scheme) for scheme in _VALID_TURN_SCHEMES):
 86                raise ValueError(f"Invalid ICE server URL scheme in {u!r}. Expected turn:, turns:, stun:, or stuns:")
 87
 88        user_val = data.get("username") if "username" in data else data.get("user")
 89        pwd_val = data.get("credential") if "credential" in data else data.get("pwd")
 90
 91        if user_val is not None and not isinstance(user_val, str):
 92            raise TypeError(f"Expected str for username, got {type(user_val).__name__}")
 93        if pwd_val is not None and not isinstance(pwd_val, str):
 94            raise TypeError(f"Expected str for credential, got {type(pwd_val).__name__}")
 95
 96        return cls(
 97            urls=urls,
 98            username=user_val,
 99            credential=pwd_val,
100        )

Create CameraTurnServer from get_turn_server response or dict.

Handles both device RPC responses (single-item list or dict with 'url', 'user', 'pwd') and standard WebRTC configuration dicts ('urls', 'username', 'credential').