From 214bb6bb8b6304ae6acf3e82c7a65c3057b8190e Mon Sep 17 00:00:00 2001 From: Luke Date: Sun, 11 Oct 2026 17:12:49 -0400 Subject: [PATCH 1/3] feat(camera): Add camera signaling models, ICE parsing utilities, and payload serialization --- roborock/camera_signaling.py | 584 ++++++++++++++++++++++++++ roborock/data/__init__.py | 1 + roborock/data/camera.py | 27 ++ tests/test_camera_signaling.py | 735 +++++++++++++++++++++++++++++++++ 4 files changed, 1347 insertions(+) create mode 100644 roborock/camera_signaling.py create mode 100644 roborock/data/camera.py create mode 100644 tests/test_camera_signaling.py diff --git a/roborock/camera_signaling.py b/roborock/camera_signaling.py new file mode 100644 index 000000000..99d54564c --- /dev/null +++ b/roborock/camera_signaling.py @@ -0,0 +1,584 @@ +"""Camera signaling models, ICE parsing utilities, and payload serialization for Roborock vacuums. + +This module provides pure Python standard library data models and helpers for Roborock +camera live view WebRTC signaling, including TURN server mapping, ICE candidate parsing/formatting, +password hashing, and base64-JSON payload encoding/decoding. +""" + +from __future__ import annotations + +import base64 +import binascii +import hashlib +import json +import logging +import re +from dataclasses import dataclass, field +from enum import StrEnum +from typing import Any, Self + +_LOGGER = logging.getLogger(__name__) + +__all__ = [ + "CameraIceCandidate", + "CameraQuality", + "CameraTurnServer", + "decode_ice_candidates", + "decode_sdp_answer", + "encode_ice_candidate", + "encode_sdp_offer", + "hash_camera_password", + "parse_ice_candidate", +] + +# RFC 5245 / RFC 8839: foundation is 1 to 32 ice-char (ALPHA / DIGIT / "+" / "/" / "-" / "_") +_ICE_FOUNDATION_PATTERN = re.compile(r"^[A-Za-z0-9+/_\-]{1,32}$") +_VALID_TCP_TYPES = frozenset({"active", "passive", "so"}) +_VALID_TURN_SCHEMES = ("turn:", "turns:", "stun:", "stuns:") + + +class CameraQuality(StrEnum): + """Camera video streaming resolution setting.""" + + HD = "HD" + SD = "SD" + + @classmethod + def from_str(cls, value: str) -> Self: + """Parse CameraQuality case-insensitively.""" + if not isinstance(value, str): + raise TypeError(f"Expected str for camera quality, got {type(value).__name__}") + normalized = value.strip().upper() + try: + return cls(normalized) + except ValueError as err: + raise ValueError(f"Invalid camera quality: {value!r}. Expected 'HD' or 'SD'.") from err + + +@dataclass +class CameraTurnServer: + """Ephemeral TURN server credentials for WebRTC peer connection relay.""" + + urls: list[str] + username: str | None = None + credential: str | None = field(default=None, repr=False) + + @classmethod + def from_dict(cls, data: dict[str, Any] | list[dict[str, Any]]) -> Self: + """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'). + """ + if isinstance(data, list): + if not data: + raise ValueError("Empty TURN server data list") + item = data[0] + if not isinstance(item, dict): + raise TypeError(f"Expected dict inside TURN server list, got {type(item).__name__}") + data = item + if not isinstance(data, dict): + raise TypeError(f"Expected dict or list of dicts for TURN server, got {type(data).__name__}") + + urls_val = data.get("urls") or data.get("url") + if not urls_val: + raise ValueError("Missing 'url' or 'urls' in TURN server response") + + urls: list[str] = [] + if isinstance(urls_val, str): + urls_clean = urls_val.strip() + if not urls_clean: + raise ValueError("TURN server URL cannot be empty") + urls = [urls_clean] + elif isinstance(urls_val, list): + for u in urls_val: + if not isinstance(u, str) or not u.strip(): + raise ValueError(f"Each URL in 'urls' must be a non-empty string, got {u!r}") + urls.append(u.strip()) + if not urls: + raise ValueError("TURN server 'urls' list cannot be empty") + else: + raise TypeError(f"Expected str or list for urls, got {type(urls_val).__name__}") + + # Validate URL scheme (stun:, stuns:, turn:, turns:) + for u in urls: + if not any(u.lower().startswith(scheme) for scheme in _VALID_TURN_SCHEMES): + raise ValueError(f"Invalid ICE server URL scheme in {u!r}. Expected turn:, turns:, stun:, or stuns:") + + user_val = data.get("username") if "username" in data else data.get("user") + pwd_val = data.get("credential") if "credential" in data else data.get("pwd") + + username = str(user_val) if user_val is not None else None + credential = str(pwd_val) if pwd_val is not None else None + + return cls( + urls=urls, + username=username, + credential=credential, + ) + + def to_dict(self) -> dict[str, Any]: + """Convert to standard WebRTC RTCIceServer configuration dictionary.""" + result: dict[str, Any] = {"urls": list(self.urls)} + if self.username is not None: + result["username"] = self.username + if self.credential is not None: + result["credential"] = self.credential + return result + + +@dataclass +class CameraIceCandidate: + """Parsed WebRTC ICE candidate representation (RFC 5245 / RFC 8839).""" + + 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] = field(default_factory=dict) + raw: str | None = field(default=None, repr=False, compare=False) + + def to_sdp(self) -> str: + """Format candidate as an SDP attribute line value (RFC 5245 / RFC 8839).""" + if self.raw is not None: + return self.raw + + parts = [ + f"candidate:{self.foundation}", + str(self.component), + self.protocol.lower(), + str(self.priority), + self.ip, + str(self.port), + "typ", + self.candidate_type.lower(), + ] + if self.related_address is not None and self.related_port is not None: + parts.extend(["raddr", self.related_address, "rport", str(self.related_port)]) + if self.tcp_type is not None: + parts.extend(["tcptype", self.tcp_type.lower()]) + for ext_k, ext_v in self.extensions.items(): + parts.extend([ext_k, ext_v]) + return " ".join(parts) + + def to_signaling_dict(self) -> dict[str, Any]: + """Convert to Roborock camera signaling dictionary format.""" + return { + "candidate": self.to_sdp(), + "sdpMid": self.sdp_mid if self.sdp_mid is not None else "0", + "sdpMLineIndex": self.sdp_mline_index if self.sdp_mline_index is not None else 0, + } + + def __str__(self) -> str: + """Return the SDP candidate string.""" + return self.to_sdp() + + +def parse_ice_candidate( + candidate_str: str, + sdp_mid: str | None = None, + sdp_mline_index: int | None = None, +) -> CameraIceCandidate: + """Parse a raw SDP candidate string into a CameraIceCandidate object. + + Handles standard candidate strings with optional 'a=' or 'candidate:' prefixes, + IPv4, IPv6, and FQDN/mDNS addresses, transport protocols, candidate types, and + optional extension attributes such as raddr, rport, tcptype, generation, etc. + """ + if not isinstance(candidate_str, str): + raise TypeError(f"Expected str for ICE candidate, got {type(candidate_str).__name__}") + + # Strip trailing \r\n and whitespace + cand_clean = candidate_str.rstrip("\r\n").strip() + if not cand_clean: + raise ValueError("Empty ICE candidate string") + + # Preserve canonical SDP candidate string for raw attribute + raw_sdp: str = cand_clean + if raw_sdp.startswith("a="): + raw_sdp = raw_sdp[2:].strip() + if not raw_sdp.startswith("candidate:"): + raw_sdp = f"candidate:{raw_sdp}" + + tokens_str = cand_clean + if tokens_str.startswith("a="): + tokens_str = tokens_str[2:].strip() + if tokens_str.startswith("candidate:"): + tokens_str = tokens_str[10:].strip() + + tokens = tokens_str.split() + if len(tokens) < 8: + raise ValueError(f"Malformed ICE candidate string (expected at least 8 tokens): {candidate_str!r}") + + foundation = tokens[0] + if not _ICE_FOUNDATION_PATTERN.match(foundation): + raise ValueError(f"Invalid ICE foundation {foundation!r}: must be 1-32 ice characters") + + component_str = tokens[1] + if not (component_str.isascii() and component_str.isdigit()): + raise ValueError(f"Invalid ICE component {component_str!r}: must be ASCII decimal digits") + component = int(component_str) + if not (1 <= component <= 256): + raise ValueError(f"ICE component {component} out of valid RFC 8839 range [1, 256]") + + protocol = tokens[2].lower() + + priority_str = tokens[3] + if not (priority_str.isascii() and priority_str.isdigit()): + raise ValueError(f"Invalid ICE priority {priority_str!r}: must be ASCII decimal digits") + priority = int(priority_str) + if not (1 <= priority <= 2147483647): + raise ValueError(f"ICE priority {priority} out of valid RFC 8445 range [1, 2147483647]") + + ip = tokens[4] + + port_str = tokens[5] + if not (port_str.isascii() and port_str.isdigit()): + raise ValueError(f"Invalid ICE port {port_str!r}: must be ASCII decimal digits") + port = int(port_str) + if not (0 <= port <= 65535): + raise ValueError(f"ICE port {port} out of valid range [0, 65535]") + + typ_keyword = tokens[6].lower() + if typ_keyword != "typ": + raise ValueError( + f"Malformed ICE candidate string (expected 'typ' keyword at position 7, got {tokens[6]!r}): " + f"{candidate_str!r}" + ) + + cand_type = tokens[7].lower() + + related_address: str | None = None + related_port: int | None = None + tcp_type: str | None = None + extensions: dict[str, str] = {} + + i = 8 + while i < len(tokens): + attr_name = tokens[i].lower() + if i + 1 >= len(tokens): + break + attr_val = tokens[i + 1] + if attr_name == "raddr": + related_address = attr_val + elif attr_name == "rport": + if not (attr_val.isascii() and attr_val.isdigit()): + raise ValueError(f"Invalid ICE related port {attr_val!r}: must be ASCII decimal digits") + related_port = int(attr_val) + if not (0 <= related_port <= 65535): + raise ValueError(f"ICE related port {related_port} out of valid range [0, 65535]") + elif attr_name == "tcptype": + tcp_type = attr_val.lower() + if tcp_type not in _VALID_TCP_TYPES: + raise ValueError(f"Invalid ICE TCP type {attr_val!r}: expected 'active', 'passive', or 'so'") + else: + extensions[tokens[i]] = attr_val + i += 2 + + # Invariants for related address / related port: + if (related_address is not None and related_port is None) or (related_address is None and related_port is not None): + raise ValueError("ICE candidate must have both 'raddr' and 'rport' specified together") + + # Invariants for TCP candidates: + if protocol == "tcp" and tcp_type is None: + raise ValueError("TCP ICE candidate must specify 'tcptype' attribute (RFC 6544)") + + return CameraIceCandidate( + foundation=foundation, + component=component, + protocol=protocol, + priority=priority, + ip=ip, + port=port, + candidate_type=cand_type, + related_address=related_address, + related_port=related_port, + tcp_type=tcp_type, + sdp_mid=sdp_mid, + sdp_mline_index=sdp_mline_index, + extensions=extensions, + raw=raw_sdp, + ) + + +def hash_camera_password(password: str | int) -> str: + """Derive the lowercase hexadecimal MD5 digest of camera password digits. + + The Roborock camera password is a PIN or 3x3 pattern lock represented as + a sequence of numeric ASCII digits (e.g. '1234'). + """ + if isinstance(password, int): + password_str = str(password) + elif isinstance(password, str): + password_str = password.strip() + else: + raise TypeError(f"Camera password must be a str or int, got {type(password).__name__}") + + if not password_str: + raise ValueError("Camera password cannot be empty") + if not (password_str.isascii() and password_str.isdigit()): + raise ValueError(f"Camera password must contain only ASCII digits, got {password!r}") + + return hashlib.md5(password_str.encode("utf-8"), usedforsecurity=False).hexdigest().lower() + + +def encode_sdp_offer(sdp: str | dict[str, Any]) -> str: + """Package an SDP offer into a base64-encoded UTF-8 JSON payload for send_sdp_to_robot. + + Accepts an SDP description string or a dict containing an 'sdp' key. + Returns the base64-encoded string to be passed under the 'app_sdp' parameter. + """ + sdp_str: str + if isinstance(sdp, str): + if not sdp.strip(): + raise ValueError("SDP offer string cannot be empty") + sdp_str = sdp + elif isinstance(sdp, dict): + if "sdp" not in sdp or not isinstance(sdp["sdp"], str): + raise ValueError("SDP dictionary must contain a non-empty string 'sdp' key") + if not sdp["sdp"].strip(): + raise ValueError("SDP offer string cannot be empty") + offer_type = sdp.get("type", "offer") + if not isinstance(offer_type, str) or offer_type.lower() != "offer": + raise ValueError(f"SDP offer payload type must be 'offer', got {offer_type!r}") + sdp_str = sdp["sdp"] + else: + raise TypeError(f"Expected str or dict for SDP offer, got {type(sdp).__name__}") + + payload = {"sdp": sdp_str, "type": "offer"} + json_bytes = json.dumps(payload, separators=(",", ":")).encode("utf-8") + return base64.b64encode(json_bytes).decode("ascii") + + +def decode_sdp_answer(payload: str | dict[str, Any] | list[Any]) -> dict[str, str] | None: + """Unpackage a base64-encoded UTF-8 JSON SDP answer payload from get_device_sdp. + + Accepts the raw device response list/dict (e.g. [{'dev_sdp': '...'}]) or a base64 string. + Returns a dictionary with 'sdp' and 'type' keys, or None if the device returned 'retry'. + """ + raw_b64: str + if isinstance(payload, list): + if not payload: + raise ValueError("Empty response list for SDP answer") + item = payload[0] + if not isinstance(item, dict): + raise TypeError(f"Expected dict inside SDP answer list, got {type(item).__name__}") + payload = item + if isinstance(payload, dict): + if "dev_sdp" in payload: + val = payload["dev_sdp"] + if not isinstance(val, str): + raise TypeError(f"Expected str for 'dev_sdp', got {type(val).__name__}") + raw_b64 = val + elif "sdp" in payload: + sdp_val = payload["sdp"] + if not isinstance(sdp_val, str) or not sdp_val.strip(): + raise ValueError(f"SDP dictionary missing or empty 'sdp' string: {payload}") + ans_type = payload.get("type", "answer") + if not isinstance(ans_type, str) or ans_type.lower() not in ("answer", "pranswer"): + raise ValueError(f"SDP answer has invalid 'type': {ans_type!r}") + return {"sdp": sdp_val, "type": ans_type.lower()} + else: + raise ValueError(f"Dictionary missing 'dev_sdp' or 'sdp' key: {payload}") + elif isinstance(payload, str): + raw_b64 = payload + else: + raise TypeError(f"Expected str, dict, or list for SDP answer payload, got {type(payload).__name__}") + + raw_b64 = raw_b64.strip() + if raw_b64 == "retry": + return None + + try: + json_bytes = base64.b64decode(raw_b64, validate=True) + data = json.loads(json_bytes.decode("utf-8")) + except (binascii.Error, ValueError, json.JSONDecodeError, UnicodeDecodeError) as err: + raise ValueError(f"Failed to decode base64 JSON SDP answer: {err}") from err + + if not isinstance(data, dict): + raise ValueError(f"Decoded SDP answer JSON must be an object, got {type(data).__name__}") + + if "sdp" not in data or not isinstance(data["sdp"], str) or not data["sdp"].strip(): + raise ValueError(f"Decoded SDP answer missing or empty 'sdp' field: {data}") + + answer_type = data.get("type", "answer") + if not isinstance(answer_type, str) or answer_type.lower() not in ("answer", "pranswer"): + raise ValueError(f"Decoded SDP answer has invalid 'type': {answer_type!r}") + + return { + "sdp": data["sdp"], + "type": answer_type.lower(), + } + + +def encode_ice_candidate( + candidate: CameraIceCandidate | str | dict[str, Any], + sdp_mid: str | None = None, + sdp_mline_index: int | None = None, +) -> str: + """Package an ICE candidate into a base64-encoded UTF-8 JSON payload for send_ice_to_robot. + + Accepts a CameraIceCandidate, raw candidate string, or dict. + Returns the base64-encoded string to be passed under the 'app_ice' parameter. + """ + candidate_str: str + mid: str | None = sdp_mid + mline: int | None = sdp_mline_index + + if isinstance(candidate, CameraIceCandidate): + candidate_str = candidate.to_sdp() + if mid is None: + mid = candidate.sdp_mid + if mline is None: + mline = candidate.sdp_mline_index + elif isinstance(candidate, dict): + cand_val = candidate.get("candidate") + if not isinstance(cand_val, str) or not cand_val.strip(): + raise ValueError(f"Candidate dict missing or invalid 'candidate' string: {candidate}") + candidate_str = cand_val.strip() + if mid is None: + mid = candidate.get("sdpMid") + if mline is None: + mline = candidate.get("sdpMLineIndex") + elif isinstance(candidate, str): + candidate_str = candidate.strip() + else: + raise TypeError(f"Expected CameraIceCandidate, dict, or str, got {type(candidate).__name__}") + + if not candidate_str: + raise ValueError("ICE candidate string cannot be empty") + + if not candidate_str.startswith("candidate:"): + candidate_str = f"candidate:{candidate_str}" + + if mline is not None and not isinstance(mline, int): + try: + mline = int(mline) + except (ValueError, TypeError) as err: + raise ValueError(f"Invalid sdpMLineIndex {mline!r}: must be an integer") from err + + payload: dict[str, Any] = { + "candidate": candidate_str, + "sdpMid": str(mid) if mid is not None else "0", + "sdpMLineIndex": mline if mline is not None else 0, + } + + json_bytes = json.dumps(payload, separators=(",", ":")).encode("utf-8") + return base64.b64encode(json_bytes).decode("ascii") + + +def decode_ice_candidates( + payload: str | list[Any] | dict[str, Any], + *, + ignore_invalid: bool = False, +) -> list[CameraIceCandidate]: + """Unpackage base64-encoded UTF-8 JSON remote ICE candidates from get_device_ice. + + Accepts the raw device response list/dict (e.g. [{'dev_ice': ['...']}]) or a list of base64 strings. + Returns a list of parsed CameraIceCandidate objects. + If ignore_invalid is True, invalid candidates are logged and skipped rather than raising ValueError. + """ + raw_list: list[Any] + if isinstance(payload, list): + if len(payload) == 1 and isinstance(payload[0], dict) and "dev_ice" in payload[0]: + dev_ice_val = payload[0]["dev_ice"] + if not isinstance(dev_ice_val, list): + raise TypeError(f"Expected list in 'dev_ice', got {type(dev_ice_val).__name__}") + raw_list = dev_ice_val + else: + raw_list = payload + elif isinstance(payload, dict): + if "dev_ice" in payload: + dev_ice_val = payload["dev_ice"] + if not isinstance(dev_ice_val, list): + raise TypeError(f"Expected list in 'dev_ice', got {type(dev_ice_val).__name__}") + raw_list = dev_ice_val + else: + raw_list = [payload] + elif isinstance(payload, str): + raw_list = [payload] + else: + raise TypeError(f"Expected str, list, or dict for ICE candidates payload, got {type(payload).__name__}") + + candidates: list[CameraIceCandidate] = [] + for item in raw_list: + if isinstance(item, CameraIceCandidate): + candidates.append(item) + continue + if isinstance(item, dict): + cand_val = item.get("candidate") + if not isinstance(cand_val, str) or not cand_val.strip(): + if ignore_invalid: + _LOGGER.warning("Skipping candidate dict missing 'candidate' string: %s", item) + continue + raise ValueError(f"Candidate dict missing 'candidate' string: {item}") + mid = item.get("sdpMid") + mline = item.get("sdpMLineIndex") + try: + candidates.append( + parse_ice_candidate( + cand_val, + sdp_mid=str(mid) if mid is not None else None, + sdp_mline_index=int(mline) if mline is not None else None, + ) + ) + except ValueError as err: + if ignore_invalid: + _LOGGER.warning("Skipping invalid ICE candidate: %s", err) + continue + raise + continue + if isinstance(item, str): + item_str = item.strip() + if not item_str: + continue + cand_str_to_parse: str = item_str + sdp_mid_to_parse: str | None = None + sdp_mline_to_parse: int | None = None + try: + decoded_bytes = base64.b64decode(item_str, validate=True) + data = json.loads(decoded_bytes.decode("utf-8")) + if isinstance(data, dict): + if "candidate" not in data or not isinstance(data["candidate"], str): + raise ValueError(f"Decoded candidate JSON missing 'candidate' string: {data}") + cand_str_to_parse = data["candidate"] + mid = data.get("sdpMid") + mline = data.get("sdpMLineIndex") + sdp_mid_to_parse = str(mid) if mid is not None else None + sdp_mline_to_parse = int(mline) if mline is not None else None + except (binascii.Error, ValueError, json.JSONDecodeError, UnicodeDecodeError) as err: + # If this was clearly an attempted base64 JSON payload (starts with 'ey'), + # don't fall back to treating it as a raw candidate string + if item_str.startswith("ey"): + if ignore_invalid: + _LOGGER.warning("Skipping corrupted base64 ICE candidate payload: %s", err) + continue + raise ValueError(f"Failed to decode base64 JSON ICE candidate: {err}") from err + cand_str_to_parse = item_str + + try: + candidates.append( + parse_ice_candidate( + cand_str_to_parse, + sdp_mid=sdp_mid_to_parse, + sdp_mline_index=sdp_mline_to_parse, + ) + ) + except ValueError as err: + if ignore_invalid: + _LOGGER.warning("Skipping invalid ICE candidate: %s", err) + continue + raise + continue + + raise TypeError(f"Unexpected candidate item type: {type(item).__name__}") + + return candidates diff --git a/roborock/data/__init__.py b/roborock/data/__init__.py index 121f8b3cd..82be80c49 100644 --- a/roborock/data/__init__.py +++ b/roborock/data/__init__.py @@ -2,6 +2,7 @@ from .b01_q7 import * from .b01_q10 import * +from .camera import * from .code_mappings import * from .containers import * from .dyad import * diff --git a/roborock/data/camera.py b/roborock/data/camera.py new file mode 100644 index 000000000..ee90cc054 --- /dev/null +++ b/roborock/data/camera.py @@ -0,0 +1,27 @@ +"""Camera data models and utilities.""" + +from __future__ import annotations + +from roborock.camera_signaling import ( + CameraIceCandidate, + CameraQuality, + CameraTurnServer, + decode_ice_candidates, + decode_sdp_answer, + encode_ice_candidate, + encode_sdp_offer, + hash_camera_password, + parse_ice_candidate, +) + +__all__ = [ + "CameraIceCandidate", + "CameraQuality", + "CameraTurnServer", + "decode_ice_candidates", + "decode_sdp_answer", + "encode_ice_candidate", + "encode_sdp_offer", + "hash_camera_password", + "parse_ice_candidate", +] diff --git a/tests/test_camera_signaling.py b/tests/test_camera_signaling.py new file mode 100644 index 000000000..080fe434d --- /dev/null +++ b/tests/test_camera_signaling.py @@ -0,0 +1,735 @@ +"""Unit tests for camera signaling models, ICE parsing utilities, and payload serialization.""" + +from __future__ import annotations + +import base64 +import json + +import pytest + +from roborock.camera_signaling import ( + CameraIceCandidate, + CameraQuality, + CameraTurnServer, + decode_ice_candidates, + decode_sdp_answer, + encode_ice_candidate, + encode_sdp_offer, + hash_camera_password, + parse_ice_candidate, +) +from roborock.data import ( + CameraIceCandidate as DataCameraIceCandidate, +) +from roborock.data import ( + CameraQuality as DataCameraQuality, +) +from roborock.data import ( + CameraTurnServer as DataCameraTurnServer, +) +from roborock.data.camera import ( + CameraIceCandidate as CameraModIceCandidate, +) +from roborock.data.camera import ( + CameraQuality as CameraModQuality, +) +from roborock.data.camera import ( + CameraTurnServer as CameraModTurnServer, +) + +# ============================================================================ +# Re-export and import smoke tests +# ============================================================================ + + +def test_reexports_match() -> None: + """Verify camera signaling items are correctly re-exported across modules.""" + assert DataCameraQuality is CameraQuality + assert DataCameraTurnServer is CameraTurnServer + assert DataCameraIceCandidate is CameraIceCandidate + + assert CameraModQuality is CameraQuality + assert CameraModTurnServer is CameraTurnServer + assert CameraModIceCandidate is CameraIceCandidate + + +# ============================================================================ +# CameraQuality tests +# ============================================================================ + + +def test_camera_quality_values() -> None: + """Test CameraQuality enum values and string behavior.""" + assert CameraQuality.HD == "HD" + assert CameraQuality.SD == "SD" + assert str(CameraQuality.HD) == "HD" + assert CameraQuality("HD") is CameraQuality.HD + assert CameraQuality("SD") is CameraQuality.SD + + +def test_camera_quality_from_str() -> None: + """Test CameraQuality.from_str case insensitivity and error handling.""" + assert CameraQuality.from_str("hd") is CameraQuality.HD + assert CameraQuality.from_str("HD") is CameraQuality.HD + assert CameraQuality.from_str("sd") is CameraQuality.SD + assert CameraQuality.from_str(" SD ") is CameraQuality.SD + + with pytest.raises(ValueError, match="Invalid camera quality"): + CameraQuality.from_str("4K") + + with pytest.raises(TypeError, match="Expected str"): + CameraQuality.from_str(123) # type: ignore[arg-type] + + +# ============================================================================ +# CameraTurnServer tests +# ============================================================================ + + +def test_camera_turn_server_from_device_rpc_list() -> None: + """Test parsing get_turn_server response from single-item list.""" + payload = [{"url": "turn:turn-us.roborock.com:3478", "user": "roborock_user", "pwd": "secret_password"}] + turn = CameraTurnServer.from_dict(payload) + + assert turn.urls == ["turn:turn-us.roborock.com:3478"] + assert turn.username == "roborock_user" + assert turn.credential == "secret_password" + + # Verify credential redaction in repr + assert "secret_password" not in repr(turn) + + rtc_dict = turn.to_dict() + assert rtc_dict == { + "urls": ["turn:turn-us.roborock.com:3478"], + "username": "roborock_user", + "credential": "secret_password", + } + + +def test_camera_turn_server_from_device_rpc_dict() -> None: + """Test parsing get_turn_server response from bare dict.""" + payload = {"url": "turn:turn-eu.roborock.com:3478", "user": "eu_user", "pwd": "eu_password"} + turn = CameraTurnServer.from_dict(payload) + + assert turn.urls == ["turn:turn-eu.roborock.com:3478"] + assert turn.username == "eu_user" + assert turn.credential == "eu_password" + assert "eu_password" not in repr(turn) + + +def test_camera_turn_server_from_standard_webrtc_dict() -> None: + """Test parsing standard WebRTC RTCIceServer dict format.""" + payload = { + "urls": ["stun:stun.l.google.com:19302", "turns:turn.example.com:443"], + "username": "user123", + "credential": "pass123", + } + turn = CameraTurnServer.from_dict(payload) + + assert turn.urls == ["stun:stun.l.google.com:19302", "turns:turn.example.com:443"] + assert turn.username == "user123" + assert turn.credential == "pass123" + + +def test_camera_turn_server_without_credentials() -> None: + """Test TURN server with optional credentials absent.""" + payload = {"urls": "turn:turn.example.com:3478"} + turn = CameraTurnServer.from_dict(payload) + + assert turn.urls == ["turn:turn.example.com:3478"] + assert turn.username is None + assert turn.credential is None + assert turn.to_dict() == {"urls": ["turn:turn.example.com:3478"]} + + +def test_camera_turn_server_invalid_payloads() -> None: + """Test invalid payloads raise appropriate errors.""" + with pytest.raises(ValueError, match="Empty TURN server data list"): + CameraTurnServer.from_dict([]) + + with pytest.raises(TypeError, match="Expected dict inside TURN server list"): + CameraTurnServer.from_dict(["not_a_dict"]) # type: ignore[list-item] + + with pytest.raises(TypeError, match="Expected dict or list"): + CameraTurnServer.from_dict("not_a_dict_or_list") # type: ignore[arg-type] + + with pytest.raises(ValueError, match="Missing 'url' or 'urls'"): + CameraTurnServer.from_dict({"user": "user_only"}) + + with pytest.raises(TypeError, match="Expected str or list for urls"): + CameraTurnServer.from_dict({"urls": 12345}) # type: ignore[arg-type] + + with pytest.raises(ValueError, match="non-empty string"): + CameraTurnServer.from_dict({"urls": [""]}) + + with pytest.raises(ValueError, match="non-empty string"): + CameraTurnServer.from_dict({"urls": [None]}) # type: ignore[list-item] + + with pytest.raises(ValueError, match="Invalid ICE server URL scheme"): + CameraTurnServer.from_dict({"url": "http://invalidscheme.com"}) + + +# ============================================================================ +# parse_ice_candidate tests +# ============================================================================ + + +def test_parse_ice_candidate_host_udp() -> None: + """Test parsing standard host UDP candidate.""" + cand_str = "candidate:4234997325 1 udp 2122260223 192.168.1.100 54321 typ host" + cand = parse_ice_candidate(cand_str, sdp_mid="0", sdp_mline_index=0) + + assert cand.foundation == "4234997325" + assert cand.component == 1 + assert cand.protocol == "udp" + assert cand.priority == 2122260223 + assert cand.ip == "192.168.1.100" + assert cand.port == 54321 + assert cand.candidate_type == "host" + assert cand.related_address is None + assert cand.related_port is None + assert cand.tcp_type is None + assert cand.sdp_mid == "0" + assert cand.sdp_mline_index == 0 + assert cand.to_sdp() == cand_str + assert str(cand) == cand_str + + +def test_parse_ice_candidate_srflx_with_raddr() -> None: + """Test parsing srflx candidate with raddr and rport.""" + cand_str = "candidate:123456 1 udp 1686052607 203.0.113.1 54321 typ srflx raddr 192.168.1.100 rport 54321" + cand = parse_ice_candidate(cand_str) + + assert cand.foundation == "123456" + assert cand.component == 1 + assert cand.protocol == "udp" + assert cand.priority == 1686052607 + assert cand.ip == "203.0.113.1" + assert cand.port == 54321 + assert cand.candidate_type == "srflx" + assert cand.related_address == "192.168.1.100" + assert cand.related_port == 54321 + assert cand.tcp_type is None + assert cand.to_sdp() == cand_str + + +def test_parse_ice_candidate_relay() -> None: + """Test parsing relay candidate.""" + cand_str = "candidate:789012 1 udp 33562367 198.51.100.2 3478 typ relay raddr 203.0.113.1 rport 54321" + cand = parse_ice_candidate(cand_str) + + assert cand.foundation == "789012" + assert cand.protocol == "udp" + assert cand.candidate_type == "relay" + assert cand.ip == "198.51.100.2" + assert cand.port == 3478 + assert cand.related_address == "203.0.113.1" + assert cand.related_port == 54321 + assert cand.to_sdp() == cand_str + + +def test_parse_ice_candidate_tcp_with_tcptype() -> None: + """Test parsing TCP candidate with tcptype.""" + cand_str = "candidate:345678 1 tcp 1518280447 192.168.1.100 9 typ host tcptype active" + cand = parse_ice_candidate(cand_str) + + assert cand.foundation == "345678" + assert cand.protocol == "tcp" + assert cand.candidate_type == "host" + assert cand.port == 9 + assert cand.tcp_type == "active" + assert cand.to_sdp() == cand_str + + +def test_parse_ice_candidate_ipv6() -> None: + """Test parsing IPv6 candidate.""" + cand_str = "candidate:1 1 udp 2122260223 2001:db8::1 50000 typ host" + cand = parse_ice_candidate(cand_str) + + assert cand.ip == "2001:db8::1" + assert cand.port == 50000 + assert cand.to_sdp() == cand_str + + +def test_parse_ice_candidate_mdns_hostname() -> None: + """Test parsing mDNS .local hostname candidate from modern browsers.""" + cand_str = "candidate:1 1 udp 2122260223 d79c09bf-323e-4610-8f92-5eb3fa7e4a1a.local 50000 typ host" + cand = parse_ice_candidate(cand_str) + + assert cand.ip == "d79c09bf-323e-4610-8f92-5eb3fa7e4a1a.local" + assert cand.to_sdp() == cand_str + + +def test_parse_ice_candidate_prefixes_and_extra_attrs() -> None: + """Test candidate with a= prefix, no prefix, CRLF, and extra SDP attributes like generation.""" + # a=candidate: prefix and trailing CRLF + cand1 = parse_ice_candidate("a=candidate:1 1 udp 100 1.2.3.4 5000 typ host generation 0 network-id 1\r\n") + assert cand1.foundation == "1" + assert cand1.candidate_type == "host" + assert cand1.ip == "1.2.3.4" + assert cand1.extensions == {"generation": "0", "network-id": "1"} + + # Bare tokens without candidate: prefix + cand2 = parse_ice_candidate("1 1 udp 100 1.2.3.4 5000 typ host") + assert cand2.foundation == "1" + assert cand2.candidate_type == "host" + + +def test_parse_ice_candidate_numeric_and_lexical_bounds() -> None: + """Test strict RFC 8839 / RFC 8445 component, priority, and foundation bounds.""" + # Component: valid boundary 1 and 256 + c1 = parse_ice_candidate("candidate:1 1 udp 100 1.1.1.1 200 typ host") + assert c1.component == 1 + c256 = parse_ice_candidate("candidate:1 256 udp 100 1.1.1.1 200 typ host") + assert c256.component == 256 + + # Component: out of bounds (0 or 257) + with pytest.raises(ValueError, match="RFC 8839 range"): + parse_ice_candidate("candidate:1 0 udp 100 1.1.1.1 200 typ host") + with pytest.raises(ValueError, match="RFC 8839 range"): + parse_ice_candidate("candidate:1 257 udp 100 1.1.1.1 200 typ host") + + # Priority: valid boundary 1 and 2147483647 + p1 = parse_ice_candidate("candidate:1 1 udp 1 1.1.1.1 200 typ host") + assert p1.priority == 1 + p_max = parse_ice_candidate("candidate:1 1 udp 2147483647 1.1.1.1 200 typ host") + assert p_max.priority == 2147483647 + + # Priority: out of bounds (0 or 2147483648) + with pytest.raises(ValueError, match="RFC 8445 range"): + parse_ice_candidate("candidate:1 1 udp 0 1.1.1.1 200 typ host") + with pytest.raises(ValueError, match="RFC 8445 range"): + parse_ice_candidate("candidate:1 1 udp 2147483648 1.1.1.1 200 typ host") + + # Non-decimal syntax: plus signs or underscores rejected + with pytest.raises(ValueError, match="ASCII decimal digits"): + parse_ice_candidate("candidate:1 +1 udp 100 1.1.1.1 200 typ host") + with pytest.raises(ValueError, match="ASCII decimal digits"): + parse_ice_candidate("candidate:1 1 udp 1_000 1.1.1.1 200 typ host") + + # Foundation: must be 1-32 ice characters + with pytest.raises(ValueError, match="Invalid ICE foundation"): + parse_ice_candidate(f"candidate:{'a' * 33} 1 udp 100 1.1.1.1 200 typ host") + with pytest.raises(ValueError, match="Invalid ICE foundation"): + parse_ice_candidate("candidate:invalid@char 1 udp 100 1.1.1.1 200 typ host") + + +def test_parse_ice_candidate_invariants() -> None: + """Test RFC 6544 TCP and RFC 8839 related address invariants.""" + # TCP candidate requires tcptype + with pytest.raises(ValueError, match="TCP ICE candidate must specify 'tcptype'"): + parse_ice_candidate("candidate:1 1 tcp 100 1.1.1.1 9 typ host") + + # Invalid tcptype + with pytest.raises(ValueError, match="Invalid ICE TCP type"): + parse_ice_candidate("candidate:1 1 tcp 100 1.1.1.1 9 typ host tcptype invalid") + + # raddr without rport + with pytest.raises(ValueError, match="must have both 'raddr' and 'rport' specified together"): + parse_ice_candidate("candidate:1 1 udp 100 1.1.1.1 200 typ srflx raddr 2.2.2.2") + + # rport without raddr + with pytest.raises(ValueError, match="must have both 'raddr' and 'rport' specified together"): + parse_ice_candidate("candidate:1 1 udp 100 1.1.1.1 200 typ srflx rport 5000") + + +def test_candidate_to_signaling_dict() -> None: + """Test converting CameraIceCandidate to signaling dict.""" + cand = CameraIceCandidate( + foundation="1", + component=1, + protocol="udp", + priority=100, + ip="1.2.3.4", + port=5000, + candidate_type="host", + sdp_mid="video", + sdp_mline_index=1, + ) + sig_dict = cand.to_signaling_dict() + assert sig_dict == { + "candidate": "candidate:1 1 udp 100 1.2.3.4 5000 typ host", + "sdpMid": "video", + "sdpMLineIndex": 1, + } + + +# ============================================================================ +# hash_camera_password tests +# ============================================================================ + + +def test_hash_camera_password_valid() -> None: + """Test deriving lowercase hex MD5 of digits.""" + # 1234 -> 81dc9bdb52d04dc20036dbd8313ed055 + assert hash_camera_password("1234") == "81dc9bdb52d04dc20036dbd8313ed055" + assert hash_camera_password(1234) == "81dc9bdb52d04dc20036dbd8313ed055" + + # 9876 -> 912e79cd13c64069d91da65d62fbb78c + assert hash_camera_password("9876") == "912e79cd13c64069d91da65d62fbb78c" + + # Leading zero in digits preserved + assert hash_camera_password("0123") == "eb62f6b9306db575c2d596b1279627a4" + + +def test_hash_camera_password_invalid() -> None: + """Test invalid password formats raise ValueError or TypeError.""" + with pytest.raises(ValueError, match="cannot be empty"): + hash_camera_password("") + + with pytest.raises(ValueError, match="only ASCII digits"): + hash_camera_password("12a4") + + # Full-width digits should be rejected + with pytest.raises(ValueError, match="only ASCII digits"): + hash_camera_password("1234") + + with pytest.raises(ValueError, match="only ASCII digits"): + hash_camera_password("password") + + with pytest.raises(TypeError, match="must be a str or int"): + hash_camera_password(None) # type: ignore[arg-type] + + with pytest.raises(TypeError, match="must be a str or int"): + hash_camera_password(["1", "2", "3", "4"]) # type: ignore[arg-type] + + +# ============================================================================ +# SDP offer/answer packaging tests +# ============================================================================ + + +def test_encode_sdp_offer_and_decode_sdp_answer_roundtrip() -> None: + """Test round-trip SDP offer encoding and answer decoding.""" + raw_sdp = "v=0\r\no=- 12345 2 IN IP4 127.0.0.1\r\ns=-\r\nt=0 0\r\nm=video 9 UDP/TLS/RTP/SAVPF 96\r\n" + encoded_offer = encode_sdp_offer(raw_sdp) + + # Offer decoded from base64 JSON + decoded_json = json.loads(base64.b64decode(encoded_offer).decode("utf-8")) + assert decoded_json == {"sdp": raw_sdp, "type": "offer"} + + # Simulate device answer payload + answer_payload = { + "sdp": "v=0\r\no=- 67890 2 IN IP4 127.0.0.1\r\ns=-\r\nm=video 9 UDP/TLS/RTP/SAVPF 96\r\n", + "type": "answer", + } + answer_b64 = base64.b64encode(json.dumps(answer_payload).encode("utf-8")).decode("ascii") + + # Decode from bare string + res_str = decode_sdp_answer(answer_b64) + assert res_str == answer_payload + + # Decode from device dict response: {"dev_sdp": "..."} + res_dict = decode_sdp_answer({"dev_sdp": answer_b64}) + assert res_dict == answer_payload + + # Decode from device single-item list response: [{"dev_sdp": "..."}] + res_list = decode_sdp_answer([{"dev_sdp": answer_b64}]) + assert res_list == answer_payload + + +def test_encode_sdp_offer_from_dict() -> None: + """Test encoding SDP offer from dictionary.""" + offer_dict = {"sdp": "v=0\r\no=- 1 1 IN IP4 0.0.0.0", "type": "offer"} + b64_str = encode_sdp_offer(offer_dict) + decoded = json.loads(base64.b64decode(b64_str).decode("utf-8")) + assert decoded["sdp"] == offer_dict["sdp"] + assert decoded["type"] == "offer" + + +def test_decode_sdp_answer_retry() -> None: + """Test that retry string returns None.""" + assert decode_sdp_answer("retry") is None + assert decode_sdp_answer({"dev_sdp": "retry"}) is None + assert decode_sdp_answer([{"dev_sdp": "retry"}]) is None + + +def test_encode_sdp_offer_errors() -> None: + """Test error handling in encode_sdp_offer.""" + with pytest.raises(ValueError, match="cannot be empty"): + encode_sdp_offer("") + + with pytest.raises(ValueError, match="must contain a non-empty string 'sdp' key"): + encode_sdp_offer({}) + + with pytest.raises(ValueError, match="must contain a non-empty string 'sdp' key"): + encode_sdp_offer({"sdp": None}) # type: ignore[dict-item] + + with pytest.raises(ValueError, match="cannot be empty"): + encode_sdp_offer({"sdp": " "}) + + with pytest.raises(ValueError, match="type must be 'offer'"): + encode_sdp_offer({"sdp": "v=0...", "type": "answer"}) + + with pytest.raises(TypeError, match="Expected str or dict"): + encode_sdp_offer(12345) # type: ignore[arg-type] + + +def test_decode_sdp_answer_already_decoded_dict() -> None: + """Test decoding when already a dict with sdp key.""" + res = decode_sdp_answer({"sdp": "v=0...", "type": "answer"}) + assert res == {"sdp": "v=0...", "type": "answer"} + + with pytest.raises(ValueError, match="invalid 'type'"): + decode_sdp_answer({"sdp": "v=0...", "type": "offer"}) + + with pytest.raises(ValueError, match="missing or empty 'sdp'"): + decode_sdp_answer({"sdp": "", "type": "answer"}) + + +def test_decode_sdp_answer_errors_and_edge_cases() -> None: + """Test invalid payloads in decode_sdp_answer raise ValueError or TypeError.""" + with pytest.raises(ValueError, match="Empty response list"): + decode_sdp_answer([]) + + with pytest.raises(TypeError, match="Expected dict inside SDP answer list"): + decode_sdp_answer(["not_dict"]) # type: ignore[list-item] + + with pytest.raises(ValueError, match="missing 'dev_sdp' or 'sdp'"): + decode_sdp_answer({"invalid_key": "abc"}) + + with pytest.raises(TypeError, match="Expected str for 'dev_sdp'"): + decode_sdp_answer({"dev_sdp": 12345}) # type: ignore[dict-item] + + with pytest.raises(TypeError, match="Expected str, dict, or list"): + decode_sdp_answer(12345) # type: ignore[arg-type] + + with pytest.raises(ValueError, match="Failed to decode base64 JSON"): + decode_sdp_answer("invalid_base64!@#$") + + # Valid base64, but invalid JSON + invalid_json_b64 = base64.b64encode(b"not json").decode("ascii") + with pytest.raises(ValueError, match="Failed to decode base64 JSON"): + decode_sdp_answer(invalid_json_b64) + + # Valid JSON, but not an object + not_obj_b64 = base64.b64encode(b"[1, 2, 3]").decode("ascii") + with pytest.raises(ValueError, match="must be an object"): + decode_sdp_answer(not_obj_b64) + + # Valid JSON object, but missing 'sdp' + missing_sdp_b64 = base64.b64encode(b'{"type": "answer"}').decode("ascii") + with pytest.raises(ValueError, match="missing or empty 'sdp' field"): + decode_sdp_answer(missing_sdp_b64) + + +# ============================================================================ +# ICE candidate packaging tests +# ============================================================================ + + +def test_encode_ice_candidate_and_decode_roundtrip() -> None: + """Test round-trip encoding and decoding of ICE candidates.""" + cand = CameraIceCandidate( + foundation="cand1", + component=1, + protocol="udp", + priority=2122260223, + ip="192.168.1.50", + port=55555, + candidate_type="host", + sdp_mid="0", + sdp_mline_index=0, + ) + + encoded = encode_ice_candidate(cand) + + # Verify JSON structure inside base64 + obj = json.loads(base64.b64decode(encoded).decode("utf-8")) + assert obj["candidate"] == cand.to_sdp() + assert obj["sdpMid"] == "0" + assert obj["sdpMLineIndex"] == 0 + + # Decode from list of base64 strings + decoded_list = decode_ice_candidates([encoded]) + assert len(decoded_list) == 1 + assert decoded_list[0] == cand + + # Decode from get_device_ice dict: {"dev_ice": [...]} + decoded_dict = decode_ice_candidates({"dev_ice": [encoded]}) + assert len(decoded_dict) == 1 + assert decoded_dict[0] == cand + + # Decode from get_device_ice single-item list: [{"dev_ice": [...]}] + decoded_resp = decode_ice_candidates([{"dev_ice": [encoded]}]) + assert len(decoded_resp) == 1 + assert decoded_resp[0] == cand + + +def test_encode_ice_candidate_from_str_and_dict() -> None: + """Test encoding from raw candidate string and dictionary.""" + raw_sdp = "candidate:1 1 udp 100 1.1.1.1 200 typ host" + b64_str = encode_ice_candidate(raw_sdp, sdp_mid="1", sdp_mline_index=1) + obj = json.loads(base64.b64decode(b64_str).decode("utf-8")) + assert obj["candidate"] == raw_sdp + assert obj["sdpMid"] == "1" + assert obj["sdpMLineIndex"] == 1 + + # From string without candidate: prefix + b64_str2 = encode_ice_candidate("1 1 udp 100 1.1.1.1 200 typ host") + obj2 = json.loads(base64.b64decode(b64_str2).decode("utf-8")) + assert obj2["candidate"] == raw_sdp + + # From dict + dict_cand = {"candidate": raw_sdp, "sdpMid": "0", "sdpMLineIndex": 0} + b64_dict = encode_ice_candidate(dict_cand) + obj3 = json.loads(base64.b64decode(b64_dict).decode("utf-8")) + assert obj3["candidate"] == raw_sdp + + +def test_decode_ice_candidates_empty_or_multiple() -> None: + """Test decoding empty list and multiple candidates.""" + assert decode_ice_candidates([]) == [] + assert decode_ice_candidates({"dev_ice": []}) == [] + assert decode_ice_candidates([{"dev_ice": []}]) == [] + + cand1 = "candidate:1 1 udp 100 10.0.0.1 5000 typ host" + cand2 = "candidate:2 1 udp 200 10.0.0.2 5000 typ host" + enc1 = encode_ice_candidate(cand1, sdp_mid="0", sdp_mline_index=0) + enc2 = encode_ice_candidate(cand2, sdp_mid="0", sdp_mline_index=0) + + results = decode_ice_candidates([{"dev_ice": [enc1, enc2]}]) + assert len(results) == 2 + assert results[0].foundation == "1" + assert results[1].foundation == "2" + + +def test_decode_ice_candidates_errors() -> None: + """Test invalid payloads in decode_ice_candidates raise TypeError or ValueError.""" + with pytest.raises(TypeError, match="Expected str, list, or dict"): + decode_ice_candidates(12345) # type: ignore[arg-type] + + with pytest.raises(TypeError, match="Expected list in 'dev_ice'"): + decode_ice_candidates({"dev_ice": "not_a_list"}) + + with pytest.raises(TypeError, match="Unexpected candidate item type"): + decode_ice_candidates([12345]) # type: ignore[list-item] + + with pytest.raises(ValueError, match="missing 'candidate' string"): + decode_ice_candidates([{"sdpMid": "0"}]) + + with pytest.raises(TypeError, match="Expected list in 'dev_ice'"): + decode_ice_candidates([{"dev_ice": "not_a_list"}]) + + +def test_encode_ice_candidate_errors() -> None: + """Test error handling in encode_ice_candidate.""" + with pytest.raises(ValueError, match="cannot be empty"): + encode_ice_candidate("") + + with pytest.raises(ValueError, match="missing or invalid 'candidate'"): + encode_ice_candidate({}) + + with pytest.raises(TypeError, match="Expected CameraIceCandidate, dict, or str"): + encode_ice_candidate(12345) # type: ignore[arg-type] + + +def test_decode_ice_candidates_edge_cases() -> None: + """Test edge cases in decode_ice_candidates.""" + # Single string directly + cand_str = "candidate:1 1 udp 100 1.2.3.4 5000 typ host" + res1 = decode_ice_candidates(cand_str) + assert len(res1) == 1 + assert res1[0].foundation == "1" + + # Dict without dev_ice (single candidate dict) + res2 = decode_ice_candidates({"candidate": cand_str, "sdpMid": "0", "sdpMLineIndex": 0}) + assert len(res2) == 1 + assert res2[0].foundation == "1" + + # Item is already CameraIceCandidate + existing_cand = parse_ice_candidate(cand_str) + res3 = decode_ice_candidates([existing_cand]) + assert res3 == [existing_cand] + + # Item is raw dict + res4 = decode_ice_candidates([{"candidate": cand_str, "sdpMid": "video", "sdpMLineIndex": 1}]) + assert len(res4) == 1 + assert res4[0].sdp_mid == "video" + assert res4[0].sdp_mline_index == 1 + + # Empty string in list ignored + res5 = decode_ice_candidates(["", " "]) + assert res5 == [] + + # Non-base64 raw candidate string in list + res6 = decode_ice_candidates([cand_str]) + assert len(res6) == 1 + assert res6[0].foundation == "1" + + # Corrupted base64 payload starting with ey raises ValueError + bad_base64_json = "eyJjYW5kaWRhdGUiOiA=" # incomplete json + with pytest.raises(ValueError, match="Failed to decode base64 JSON"): + decode_ice_candidates([bad_base64_json]) + + # ignore_invalid skips bad candidate + res7 = decode_ice_candidates([bad_base64_json, cand_str], ignore_invalid=True) + assert len(res7) == 1 + assert res7[0].foundation == "1" + + +def test_parse_ice_candidate_dangling_token() -> None: + """Test candidate with an odd trailing token (dangling attribute name).""" + cand = parse_ice_candidate("candidate:1 1 udp 100 1.2.3.4 5000 typ host dangling_token") + assert cand.foundation == "1" + assert cand.candidate_type == "host" + + +def test_candidate_to_sdp_manual_without_raw() -> None: + """Test to_sdp formatting when candidate is manually constructed without raw.""" + cand = CameraIceCandidate( + foundation="1", + component=1, + protocol="udp", + priority=100, + ip="1.2.3.4", + port=5000, + candidate_type="srflx", + related_address="10.0.0.1", + related_port=5000, + extensions={"generation": "0"}, + raw=None, + ) + assert cand.to_sdp() == "candidate:1 1 udp 100 1.2.3.4 5000 typ srflx raddr 10.0.0.1 rport 5000 generation 0" + + +def test_camera_turn_server_empty_urls_error() -> None: + """Test empty URL string or empty list in CameraTurnServer raises ValueError.""" + with pytest.raises(ValueError, match="Missing 'url' or 'urls'"): + CameraTurnServer.from_dict({"url": ""}) + + with pytest.raises(ValueError, match="Missing 'url' or 'urls'"): + CameraTurnServer.from_dict({"urls": []}) + + +def test_encode_ice_candidate_mline_string_and_errors() -> None: + """Test sdp_mline_index coercion and error handling.""" + raw_sdp = "candidate:1 1 udp 100 1.1.1.1 200 typ host" + # string integer + b64_str = encode_ice_candidate(raw_sdp, sdp_mline_index="1") # type: ignore[arg-type] + obj = json.loads(base64.b64decode(b64_str).decode("utf-8")) + assert obj["sdpMLineIndex"] == 1 + + # invalid string + with pytest.raises(ValueError, match="Invalid sdpMLineIndex"): + encode_ice_candidate(raw_sdp, sdp_mline_index="not_a_number") # type: ignore[arg-type] + + +def test_decode_ice_candidates_ignore_invalid_variations() -> None: + """Test ignore_invalid handles bad candidate dicts and bad strings.""" + # Dict missing candidate string with ignore_invalid=True + res1 = decode_ice_candidates([{"sdpMid": "0"}], ignore_invalid=True) + assert res1 == [] + + # Dict with invalid candidate string with ignore_invalid=True + res2 = decode_ice_candidates([{"candidate": "not a candidate"}], ignore_invalid=True) + assert res2 == [] + + # Raw string with invalid candidate and ignore_invalid=True + res3 = decode_ice_candidates(["not a candidate"], ignore_invalid=True) + assert res3 == [] + + # Base64 JSON missing candidate key + no_cand_json = base64.b64encode(b'{"sdpMid": "0"}').decode("ascii") + with pytest.raises(ValueError, match="missing 'candidate' string"): + decode_ice_candidates([no_cand_json]) From db2ae30dffcf90dd5b0acd096d1baad639a92de4 Mon Sep 17 00:00:00 2001 From: Luke Date: Mon, 12 Oct 2026 13:40:34 -0400 Subject: [PATCH 2/3] fix(camera): address review feedback on signaling models, parsing, and tests --- docs/CAMERA_PROTOCOL.md | 2 +- roborock/camera_signaling.py | 186 ++++++++++++++++++--------------- tests/test_camera_signaling.py | 176 +++++++++++++++---------------- 3 files changed, 187 insertions(+), 177 deletions(-) diff --git a/docs/CAMERA_PROTOCOL.md b/docs/CAMERA_PROTOCOL.md index 756c256b3..298e79379 100644 --- a/docs/CAMERA_PROTOCOL.md +++ b/docs/CAMERA_PROTOCOL.md @@ -141,7 +141,7 @@ See also [V1_API_COMMANDS.md](V1_API_COMMANDS.md) for general V1 device RPC refe | `check_homesec_password` | `{"password": ""}` | `["ok"]` | Validates the camera security PIN/pattern hash. | | `start_camera_preview` | `{"client_id": "...", "quality": "HD", "password": ""}` | `["ok"]` | Activates camera preview mode on the robot. | | `stop_camera_preview` | `[]` | `["ok"]` | Deactivates camera preview and powers down the camera sensor. | -| `switch_video_quality` | `{"quality": "HD"}` or `{"quality": "SD"}` | `["ok"]` | Switches streaming resolution (`HD` or `SD`). | +| `switch_video_quality` | `{"quality": "HD"}` / `{"quality": "SD"}` / `{"quality": "FHD"}` / `{"quality": "auto"}` | `["ok"]` | Switches streaming resolution (`HD`, `SD`, `FHD`, or `auto`). | ### WebRTC Signaling diff --git a/roborock/camera_signaling.py b/roborock/camera_signaling.py index 99d54564c..16a7efb79 100644 --- a/roborock/camera_signaling.py +++ b/roborock/camera_signaling.py @@ -1,7 +1,7 @@ """Camera signaling models, ICE parsing utilities, and payload serialization for Roborock vacuums. -This module provides pure Python standard library data models and helpers for Roborock -camera live view WebRTC signaling, including TURN server mapping, ICE candidate parsing/formatting, +This module provides data models and helpers for Roborock camera live view WebRTC +signaling, including TURN server mapping, ICE candidate parsing/formatting, password hashing, and base64-JSON payload encoding/decoding. """ @@ -17,6 +17,8 @@ from enum import StrEnum from typing import Any, Self +from .data.containers import RoborockBase + _LOGGER = logging.getLogger(__name__) __all__ = [ @@ -42,21 +44,25 @@ class CameraQuality(StrEnum): HD = "HD" SD = "SD" + FHD = "FHD" + AUTO = "auto" @classmethod def from_str(cls, value: str) -> Self: """Parse CameraQuality case-insensitively.""" if not isinstance(value, str): raise TypeError(f"Expected str for camera quality, got {type(value).__name__}") - normalized = value.strip().upper() - try: - return cls(normalized) - except ValueError as err: - raise ValueError(f"Invalid camera quality: {value!r}. Expected 'HD' or 'SD'.") from err + normalized = value.strip() + # "auto" is lowercase in the Roborock app enum, while HD/SD/FHD are uppercase + for member in cls: + if member.value.lower() == normalized.lower(): + return member + valid = ", ".join(repr(m.value) for m in cls) + raise ValueError(f"Invalid camera quality: {value!r}. Expected one of {valid}.") @dataclass -class CameraTurnServer: +class CameraTurnServer(RoborockBase): """Ephemeral TURN server credentials for WebRTC peer connection relay.""" urls: list[str] @@ -80,8 +86,11 @@ def from_dict(cls, data: dict[str, Any] | list[dict[str, Any]]) -> Self: if not isinstance(data, dict): raise TypeError(f"Expected dict or list of dicts for TURN server, got {type(data).__name__}") - urls_val = data.get("urls") or data.get("url") - if not urls_val: + if "urls" in data: + urls_val = data["urls"] + elif "url" in data: + urls_val = data["url"] + else: raise ValueError("Missing 'url' or 'urls' in TURN server response") urls: list[str] = [] @@ -91,12 +100,12 @@ def from_dict(cls, data: dict[str, Any] | list[dict[str, Any]]) -> Self: raise ValueError("TURN server URL cannot be empty") urls = [urls_clean] elif isinstance(urls_val, list): + if not urls_val: + raise ValueError("TURN server 'urls' list cannot be empty") for u in urls_val: if not isinstance(u, str) or not u.strip(): raise ValueError(f"Each URL in 'urls' must be a non-empty string, got {u!r}") urls.append(u.strip()) - if not urls: - raise ValueError("TURN server 'urls' list cannot be empty") else: raise TypeError(f"Expected str or list for urls, got {type(urls_val).__name__}") @@ -108,13 +117,15 @@ def from_dict(cls, data: dict[str, Any] | list[dict[str, Any]]) -> Self: user_val = data.get("username") if "username" in data else data.get("user") pwd_val = data.get("credential") if "credential" in data else data.get("pwd") - username = str(user_val) if user_val is not None else None - credential = str(pwd_val) if pwd_val is not None else None + if user_val is not None and not isinstance(user_val, str): + raise TypeError(f"Expected str for username, got {type(user_val).__name__}") + if pwd_val is not None and not isinstance(pwd_val, str): + raise TypeError(f"Expected str for credential, got {type(pwd_val).__name__}") return cls( urls=urls, - username=username, - credential=credential, + username=user_val, + credential=pwd_val, ) def to_dict(self) -> dict[str, Any]: @@ -144,13 +155,9 @@ class CameraIceCandidate: sdp_mid: str | None = None sdp_mline_index: int | None = None extensions: dict[str, str] = field(default_factory=dict) - raw: str | None = field(default=None, repr=False, compare=False) def to_sdp(self) -> str: """Format candidate as an SDP attribute line value (RFC 5245 / RFC 8839).""" - if self.raw is not None: - return self.raw - parts = [ f"candidate:{self.foundation}", str(self.component), @@ -196,18 +203,10 @@ def parse_ice_candidate( if not isinstance(candidate_str, str): raise TypeError(f"Expected str for ICE candidate, got {type(candidate_str).__name__}") - # Strip trailing \r\n and whitespace cand_clean = candidate_str.rstrip("\r\n").strip() if not cand_clean: raise ValueError("Empty ICE candidate string") - # Preserve canonical SDP candidate string for raw attribute - raw_sdp: str = cand_clean - if raw_sdp.startswith("a="): - raw_sdp = raw_sdp[2:].strip() - if not raw_sdp.startswith("candidate:"): - raw_sdp = f"candidate:{raw_sdp}" - tokens_str = cand_clean if tokens_str.startswith("a="): tokens_str = tokens_str[2:].strip() @@ -261,11 +260,15 @@ def parse_ice_candidate( tcp_type: str | None = None extensions: dict[str, str] = {} + if (len(tokens) - 8) % 2 != 0: + raise ValueError( + f"Malformed ICE candidate extension attributes: odd number of tokens ({len(tokens) - 8}) " + f"after candidate type: {candidate_str!r}" + ) + i = 8 while i < len(tokens): attr_name = tokens[i].lower() - if i + 1 >= len(tokens): - break attr_val = tokens[i + 1] if attr_name == "raddr": related_address = attr_val @@ -305,29 +308,25 @@ def parse_ice_candidate( sdp_mid=sdp_mid, sdp_mline_index=sdp_mline_index, extensions=extensions, - raw=raw_sdp, ) -def hash_camera_password(password: str | int) -> str: +def hash_camera_password(password: str) -> str: """Derive the lowercase hexadecimal MD5 digest of camera password digits. The Roborock camera password is a PIN or 3x3 pattern lock represented as a sequence of numeric ASCII digits (e.g. '1234'). """ - if isinstance(password, int): - password_str = str(password) - elif isinstance(password, str): - password_str = password.strip() - else: - raise TypeError(f"Camera password must be a str or int, got {type(password).__name__}") + if not isinstance(password, str): + raise TypeError(f"Camera password must be a str, got {type(password).__name__}") + password_str = password.strip() if not password_str: raise ValueError("Camera password cannot be empty") if not (password_str.isascii() and password_str.isdigit()): raise ValueError(f"Camera password must contain only ASCII digits, got {password!r}") - return hashlib.md5(password_str.encode("utf-8"), usedforsecurity=False).hexdigest().lower() + return hashlib.md5(password_str.encode("utf-8"), usedforsecurity=False).hexdigest() def encode_sdp_offer(sdp: str | dict[str, Any]) -> str: @@ -429,48 +428,57 @@ def encode_ice_candidate( Accepts a CameraIceCandidate, raw candidate string, or dict. Returns the base64-encoded string to be passed under the 'app_ice' parameter. """ - candidate_str: str - mid: str | None = sdp_mid - mline: int | None = sdp_mline_index - if isinstance(candidate, CameraIceCandidate): - candidate_str = candidate.to_sdp() - if mid is None: - mid = candidate.sdp_mid - if mline is None: - mline = candidate.sdp_mline_index + # Override mid/mline if explicitly passed to encode_ice_candidate + payload = candidate.to_signaling_dict() + if sdp_mid is not None: + payload["sdpMid"] = str(sdp_mid) + if sdp_mline_index is not None: + if not isinstance(sdp_mline_index, int): + try: + sdp_mline_index = int(sdp_mline_index) + except (ValueError, TypeError) as err: + raise ValueError(f"Invalid sdpMLineIndex {sdp_mline_index!r}: must be an integer") from err + payload["sdpMLineIndex"] = sdp_mline_index elif isinstance(candidate, dict): cand_val = candidate.get("candidate") if not isinstance(cand_val, str) or not cand_val.strip(): raise ValueError(f"Candidate dict missing or invalid 'candidate' string: {candidate}") candidate_str = cand_val.strip() - if mid is None: - mid = candidate.get("sdpMid") - if mline is None: - mline = candidate.get("sdpMLineIndex") + mid = sdp_mid if sdp_mid is not None else candidate.get("sdpMid") + mline = sdp_mline_index if sdp_mline_index is not None else candidate.get("sdpMLineIndex") + if not candidate_str.startswith("candidate:"): + candidate_str = f"candidate:{candidate_str}" + if mline is not None and not isinstance(mline, int): + try: + mline = int(mline) + except (ValueError, TypeError) as err: + raise ValueError(f"Invalid sdpMLineIndex {mline!r}: must be an integer") from err + payload = { + "candidate": candidate_str, + "sdpMid": str(mid) if mid is not None else "0", + "sdpMLineIndex": mline if mline is not None else 0, + } elif isinstance(candidate, str): candidate_str = candidate.strip() + if not candidate_str: + raise ValueError("ICE candidate string cannot be empty") + if not candidate_str.startswith("candidate:"): + candidate_str = f"candidate:{candidate_str}" + mline = sdp_mline_index + if mline is not None and not isinstance(mline, int): + try: + mline = int(mline) + except (ValueError, TypeError) as err: + raise ValueError(f"Invalid sdpMLineIndex {mline!r}: must be an integer") from err + payload = { + "candidate": candidate_str, + "sdpMid": str(sdp_mid) if sdp_mid is not None else "0", + "sdpMLineIndex": mline if mline is not None else 0, + } else: raise TypeError(f"Expected CameraIceCandidate, dict, or str, got {type(candidate).__name__}") - if not candidate_str: - raise ValueError("ICE candidate string cannot be empty") - - if not candidate_str.startswith("candidate:"): - candidate_str = f"candidate:{candidate_str}" - - if mline is not None and not isinstance(mline, int): - try: - mline = int(mline) - except (ValueError, TypeError) as err: - raise ValueError(f"Invalid sdpMLineIndex {mline!r}: must be an integer") from err - - payload: dict[str, Any] = { - "candidate": candidate_str, - "sdpMid": str(mid) if mid is not None else "0", - "sdpMLineIndex": mline if mline is not None else 0, - } - json_bytes = json.dumps(payload, separators=(",", ":")).encode("utf-8") return base64.b64encode(json_bytes).decode("ascii") @@ -484,7 +492,7 @@ def decode_ice_candidates( Accepts the raw device response list/dict (e.g. [{'dev_ice': ['...']}]) or a list of base64 strings. Returns a list of parsed CameraIceCandidate objects. - If ignore_invalid is True, invalid candidates are logged and skipped rather than raising ValueError. + If ignore_invalid is True, invalid candidates are logged and skipped rather than raising ValueError or TypeError. """ raw_list: list[Any] if isinstance(payload, list): @@ -523,14 +531,17 @@ def decode_ice_candidates( mid = item.get("sdpMid") mline = item.get("sdpMLineIndex") try: + mline_int: int | None = None + if mline is not None: + mline_int = int(mline) candidates.append( parse_ice_candidate( cand_val, sdp_mid=str(mid) if mid is not None else None, - sdp_mline_index=int(mline) if mline is not None else None, + sdp_mline_index=mline_int, ) ) - except ValueError as err: + except (ValueError, TypeError) as err: if ignore_invalid: _LOGGER.warning("Skipping invalid ICE candidate: %s", err) continue @@ -546,20 +557,23 @@ def decode_ice_candidates( try: decoded_bytes = base64.b64decode(item_str, validate=True) data = json.loads(decoded_bytes.decode("utf-8")) - if isinstance(data, dict): - if "candidate" not in data or not isinstance(data["candidate"], str): - raise ValueError(f"Decoded candidate JSON missing 'candidate' string: {data}") - cand_str_to_parse = data["candidate"] - mid = data.get("sdpMid") - mline = data.get("sdpMLineIndex") - sdp_mid_to_parse = str(mid) if mid is not None else None - sdp_mline_to_parse = int(mline) if mline is not None else None - except (binascii.Error, ValueError, json.JSONDecodeError, UnicodeDecodeError) as err: + if not isinstance(data, dict): + raise ValueError(f"Decoded candidate JSON must be a dict object, got {type(data).__name__}") + if "candidate" not in data or not isinstance(data["candidate"], str): + raise ValueError(f"Decoded candidate JSON missing 'candidate' string: {data}") + cand_str_to_parse = data["candidate"] + mid = data.get("sdpMid") + mline = data.get("sdpMLineIndex") + sdp_mid_to_parse = str(mid) if mid is not None else None + sdp_mline_to_parse = int(mline) if mline is not None else None + except (binascii.Error, ValueError, json.JSONDecodeError, UnicodeDecodeError, TypeError) as err: # If this was clearly an attempted base64 JSON payload (starts with 'ey'), - # don't fall back to treating it as a raw candidate string - if item_str.startswith("ey"): + # or valid base64 that decoded to non-dict, do not fall back to treating it as raw candidate + if item_str.startswith("ey") or ( + not isinstance(err, binascii.Error) and not isinstance(err, UnicodeDecodeError) + ): if ignore_invalid: - _LOGGER.warning("Skipping corrupted base64 ICE candidate payload: %s", err) + _LOGGER.warning("Skipping invalid base64 JSON ICE candidate payload: %s", err) continue raise ValueError(f"Failed to decode base64 JSON ICE candidate: {err}") from err cand_str_to_parse = item_str @@ -572,7 +586,7 @@ def decode_ice_candidates( sdp_mline_index=sdp_mline_to_parse, ) ) - except ValueError as err: + except (ValueError, TypeError) as err: if ignore_invalid: _LOGGER.warning("Skipping invalid ICE candidate: %s", err) continue diff --git a/tests/test_camera_signaling.py b/tests/test_camera_signaling.py index 080fe434d..fdcbe8598 100644 --- a/tests/test_camera_signaling.py +++ b/tests/test_camera_signaling.py @@ -62,18 +62,36 @@ def test_camera_quality_values() -> None: """Test CameraQuality enum values and string behavior.""" assert CameraQuality.HD == "HD" assert CameraQuality.SD == "SD" + assert CameraQuality.FHD == "FHD" + assert CameraQuality.AUTO == "auto" assert str(CameraQuality.HD) == "HD" assert CameraQuality("HD") is CameraQuality.HD assert CameraQuality("SD") is CameraQuality.SD + assert CameraQuality("FHD") is CameraQuality.FHD + assert CameraQuality("auto") is CameraQuality.AUTO + + +@pytest.mark.parametrize( + ("input_str", "expected"), + [ + ("hd", CameraQuality.HD), + ("HD", CameraQuality.HD), + ("sd", CameraQuality.SD), + (" SD ", CameraQuality.SD), + ("fhd", CameraQuality.FHD), + ("FHD", CameraQuality.FHD), + ("auto", CameraQuality.AUTO), + ("AUTO", CameraQuality.AUTO), + (" auto ", CameraQuality.AUTO), + ], +) +def test_camera_quality_from_str(input_str: str, expected: CameraQuality) -> None: + """Test CameraQuality.from_str case insensitivity and whitespace trimming.""" + assert CameraQuality.from_str(input_str) is expected -def test_camera_quality_from_str() -> None: - """Test CameraQuality.from_str case insensitivity and error handling.""" - assert CameraQuality.from_str("hd") is CameraQuality.HD - assert CameraQuality.from_str("HD") is CameraQuality.HD - assert CameraQuality.from_str("sd") is CameraQuality.SD - assert CameraQuality.from_str(" SD ") is CameraQuality.SD - +def test_camera_quality_from_str_invalid() -> None: + """Test CameraQuality.from_str error handling for invalid values.""" with pytest.raises(ValueError, match="Invalid camera quality"): CameraQuality.from_str("4K") @@ -168,6 +186,18 @@ def test_camera_turn_server_invalid_payloads() -> None: with pytest.raises(ValueError, match="Invalid ICE server URL scheme"): CameraTurnServer.from_dict({"url": "http://invalidscheme.com"}) + with pytest.raises(ValueError, match="TURN server URL cannot be empty"): + CameraTurnServer.from_dict({"url": ""}) + + with pytest.raises(ValueError, match="TURN server 'urls' list cannot be empty"): + CameraTurnServer.from_dict({"urls": []}) + + with pytest.raises(TypeError, match="Expected str for username"): + CameraTurnServer.from_dict({"url": "turn:example.com", "user": 123}) # type: ignore[dict-item] + + with pytest.raises(TypeError, match="Expected str for credential"): + CameraTurnServer.from_dict({"url": "turn:example.com", "pwd": 456}) # type: ignore[dict-item] + # ============================================================================ # parse_ice_candidate tests @@ -262,14 +292,12 @@ def test_parse_ice_candidate_mdns_hostname() -> None: def test_parse_ice_candidate_prefixes_and_extra_attrs() -> None: """Test candidate with a= prefix, no prefix, CRLF, and extra SDP attributes like generation.""" - # a=candidate: prefix and trailing CRLF cand1 = parse_ice_candidate("a=candidate:1 1 udp 100 1.2.3.4 5000 typ host generation 0 network-id 1\r\n") assert cand1.foundation == "1" assert cand1.candidate_type == "host" assert cand1.ip == "1.2.3.4" assert cand1.extensions == {"generation": "0", "network-id": "1"} - # Bare tokens without candidate: prefix cand2 = parse_ice_candidate("1 1 udp 100 1.2.3.4 5000 typ host") assert cand2.foundation == "1" assert cand2.candidate_type == "host" @@ -277,37 +305,31 @@ def test_parse_ice_candidate_prefixes_and_extra_attrs() -> None: def test_parse_ice_candidate_numeric_and_lexical_bounds() -> None: """Test strict RFC 8839 / RFC 8445 component, priority, and foundation bounds.""" - # Component: valid boundary 1 and 256 c1 = parse_ice_candidate("candidate:1 1 udp 100 1.1.1.1 200 typ host") assert c1.component == 1 c256 = parse_ice_candidate("candidate:1 256 udp 100 1.1.1.1 200 typ host") assert c256.component == 256 - # Component: out of bounds (0 or 257) with pytest.raises(ValueError, match="RFC 8839 range"): parse_ice_candidate("candidate:1 0 udp 100 1.1.1.1 200 typ host") with pytest.raises(ValueError, match="RFC 8839 range"): parse_ice_candidate("candidate:1 257 udp 100 1.1.1.1 200 typ host") - # Priority: valid boundary 1 and 2147483647 p1 = parse_ice_candidate("candidate:1 1 udp 1 1.1.1.1 200 typ host") assert p1.priority == 1 p_max = parse_ice_candidate("candidate:1 1 udp 2147483647 1.1.1.1 200 typ host") assert p_max.priority == 2147483647 - # Priority: out of bounds (0 or 2147483648) with pytest.raises(ValueError, match="RFC 8445 range"): parse_ice_candidate("candidate:1 1 udp 0 1.1.1.1 200 typ host") with pytest.raises(ValueError, match="RFC 8445 range"): parse_ice_candidate("candidate:1 1 udp 2147483648 1.1.1.1 200 typ host") - # Non-decimal syntax: plus signs or underscores rejected with pytest.raises(ValueError, match="ASCII decimal digits"): parse_ice_candidate("candidate:1 +1 udp 100 1.1.1.1 200 typ host") with pytest.raises(ValueError, match="ASCII decimal digits"): parse_ice_candidate("candidate:1 1 udp 1_000 1.1.1.1 200 typ host") - # Foundation: must be 1-32 ice characters with pytest.raises(ValueError, match="Invalid ICE foundation"): parse_ice_candidate(f"candidate:{'a' * 33} 1 udp 100 1.1.1.1 200 typ host") with pytest.raises(ValueError, match="Invalid ICE foundation"): @@ -316,19 +338,15 @@ def test_parse_ice_candidate_numeric_and_lexical_bounds() -> None: def test_parse_ice_candidate_invariants() -> None: """Test RFC 6544 TCP and RFC 8839 related address invariants.""" - # TCP candidate requires tcptype with pytest.raises(ValueError, match="TCP ICE candidate must specify 'tcptype'"): parse_ice_candidate("candidate:1 1 tcp 100 1.1.1.1 9 typ host") - # Invalid tcptype with pytest.raises(ValueError, match="Invalid ICE TCP type"): parse_ice_candidate("candidate:1 1 tcp 100 1.1.1.1 9 typ host tcptype invalid") - # raddr without rport with pytest.raises(ValueError, match="must have both 'raddr' and 'rport' specified together"): parse_ice_candidate("candidate:1 1 udp 100 1.1.1.1 200 typ srflx raddr 2.2.2.2") - # rport without raddr with pytest.raises(ValueError, match="must have both 'raddr' and 'rport' specified together"): parse_ice_candidate("candidate:1 1 udp 100 1.1.1.1 200 typ srflx rport 5000") @@ -354,6 +372,24 @@ def test_candidate_to_signaling_dict() -> None: } +def test_candidate_mutation_reflected_in_to_sdp() -> None: + """Verify that mutating a parsed candidate updates to_sdp output (no stale raw cached).""" + cand = parse_ice_candidate("candidate:1 1 udp 100 192.168.1.100 5000 typ host") + assert cand.port == 5000 + assert cand.to_sdp() == "candidate:1 1 udp 100 192.168.1.100 5000 typ host" + + cand.port = 9999 + assert cand.to_sdp() == "candidate:1 1 udp 100 192.168.1.100 9999 typ host" + + +def test_candidate_equality_and_to_sdp_consistency() -> None: + """Verify candidates that compare equal produce identical to_sdp output.""" + cand1 = parse_ice_candidate("candidate:1 1 udp 100 1.2.3.4 5000 typ host") + cand2 = parse_ice_candidate("candidate:1 1 udp 100 1.2.3.4 5000 typ host") + assert cand1 == cand2 + assert cand1.to_sdp() == cand2.to_sdp() + + # ============================================================================ # hash_camera_password tests # ============================================================================ @@ -363,11 +399,8 @@ def test_hash_camera_password_valid() -> None: """Test deriving lowercase hex MD5 of digits.""" # 1234 -> 81dc9bdb52d04dc20036dbd8313ed055 assert hash_camera_password("1234") == "81dc9bdb52d04dc20036dbd8313ed055" - assert hash_camera_password(1234) == "81dc9bdb52d04dc20036dbd8313ed055" - # 9876 -> 912e79cd13c64069d91da65d62fbb78c assert hash_camera_password("9876") == "912e79cd13c64069d91da65d62fbb78c" - # Leading zero in digits preserved assert hash_camera_password("0123") == "eb62f6b9306db575c2d596b1279627a4" @@ -387,10 +420,13 @@ def test_hash_camera_password_invalid() -> None: with pytest.raises(ValueError, match="only ASCII digits"): hash_camera_password("password") - with pytest.raises(TypeError, match="must be a str or int"): + with pytest.raises(TypeError, match="must be a str"): hash_camera_password(None) # type: ignore[arg-type] - with pytest.raises(TypeError, match="must be a str or int"): + with pytest.raises(TypeError, match="must be a str"): + hash_camera_password(1234) # type: ignore[arg-type] + + with pytest.raises(TypeError, match="must be a str"): hash_camera_password(["1", "2", "3", "4"]) # type: ignore[arg-type] @@ -404,26 +440,21 @@ def test_encode_sdp_offer_and_decode_sdp_answer_roundtrip() -> None: raw_sdp = "v=0\r\no=- 12345 2 IN IP4 127.0.0.1\r\ns=-\r\nt=0 0\r\nm=video 9 UDP/TLS/RTP/SAVPF 96\r\n" encoded_offer = encode_sdp_offer(raw_sdp) - # Offer decoded from base64 JSON decoded_json = json.loads(base64.b64decode(encoded_offer).decode("utf-8")) assert decoded_json == {"sdp": raw_sdp, "type": "offer"} - # Simulate device answer payload answer_payload = { "sdp": "v=0\r\no=- 67890 2 IN IP4 127.0.0.1\r\ns=-\r\nm=video 9 UDP/TLS/RTP/SAVPF 96\r\n", "type": "answer", } answer_b64 = base64.b64encode(json.dumps(answer_payload).encode("utf-8")).decode("ascii") - # Decode from bare string res_str = decode_sdp_answer(answer_b64) assert res_str == answer_payload - # Decode from device dict response: {"dev_sdp": "..."} res_dict = decode_sdp_answer({"dev_sdp": answer_b64}) assert res_dict == answer_payload - # Decode from device single-item list response: [{"dev_sdp": "..."}] res_list = decode_sdp_answer([{"dev_sdp": answer_b64}]) assert res_list == answer_payload @@ -497,17 +528,14 @@ def test_decode_sdp_answer_errors_and_edge_cases() -> None: with pytest.raises(ValueError, match="Failed to decode base64 JSON"): decode_sdp_answer("invalid_base64!@#$") - # Valid base64, but invalid JSON invalid_json_b64 = base64.b64encode(b"not json").decode("ascii") with pytest.raises(ValueError, match="Failed to decode base64 JSON"): decode_sdp_answer(invalid_json_b64) - # Valid JSON, but not an object not_obj_b64 = base64.b64encode(b"[1, 2, 3]").decode("ascii") with pytest.raises(ValueError, match="must be an object"): decode_sdp_answer(not_obj_b64) - # Valid JSON object, but missing 'sdp' missing_sdp_b64 = base64.b64encode(b'{"type": "answer"}').decode("ascii") with pytest.raises(ValueError, match="missing or empty 'sdp' field"): decode_sdp_answer(missing_sdp_b64) @@ -534,23 +562,19 @@ def test_encode_ice_candidate_and_decode_roundtrip() -> None: encoded = encode_ice_candidate(cand) - # Verify JSON structure inside base64 obj = json.loads(base64.b64decode(encoded).decode("utf-8")) assert obj["candidate"] == cand.to_sdp() assert obj["sdpMid"] == "0" assert obj["sdpMLineIndex"] == 0 - # Decode from list of base64 strings decoded_list = decode_ice_candidates([encoded]) assert len(decoded_list) == 1 assert decoded_list[0] == cand - # Decode from get_device_ice dict: {"dev_ice": [...]} decoded_dict = decode_ice_candidates({"dev_ice": [encoded]}) assert len(decoded_dict) == 1 assert decoded_dict[0] == cand - # Decode from get_device_ice single-item list: [{"dev_ice": [...]}] decoded_resp = decode_ice_candidates([{"dev_ice": [encoded]}]) assert len(decoded_resp) == 1 assert decoded_resp[0] == cand @@ -565,12 +589,10 @@ def test_encode_ice_candidate_from_str_and_dict() -> None: assert obj["sdpMid"] == "1" assert obj["sdpMLineIndex"] == 1 - # From string without candidate: prefix b64_str2 = encode_ice_candidate("1 1 udp 100 1.1.1.1 200 typ host") obj2 = json.loads(base64.b64decode(b64_str2).decode("utf-8")) assert obj2["candidate"] == raw_sdp - # From dict dict_cand = {"candidate": raw_sdp, "sdpMid": "0", "sdpMLineIndex": 0} b64_dict = encode_ice_candidate(dict_cand) obj3 = json.loads(base64.b64decode(b64_dict).decode("utf-8")) @@ -608,9 +630,6 @@ def test_decode_ice_candidates_errors() -> None: with pytest.raises(ValueError, match="missing 'candidate' string"): decode_ice_candidates([{"sdpMid": "0"}]) - with pytest.raises(TypeError, match="Expected list in 'dev_ice'"): - decode_ice_candidates([{"dev_ice": "not_a_list"}]) - def test_encode_ice_candidate_errors() -> None: """Test error handling in encode_ice_candidate.""" @@ -626,110 +645,87 @@ def test_encode_ice_candidate_errors() -> None: def test_decode_ice_candidates_edge_cases() -> None: """Test edge cases in decode_ice_candidates.""" - # Single string directly cand_str = "candidate:1 1 udp 100 1.2.3.4 5000 typ host" res1 = decode_ice_candidates(cand_str) assert len(res1) == 1 assert res1[0].foundation == "1" - # Dict without dev_ice (single candidate dict) res2 = decode_ice_candidates({"candidate": cand_str, "sdpMid": "0", "sdpMLineIndex": 0}) assert len(res2) == 1 assert res2[0].foundation == "1" - # Item is already CameraIceCandidate existing_cand = parse_ice_candidate(cand_str) res3 = decode_ice_candidates([existing_cand]) assert res3 == [existing_cand] - # Item is raw dict res4 = decode_ice_candidates([{"candidate": cand_str, "sdpMid": "video", "sdpMLineIndex": 1}]) assert len(res4) == 1 assert res4[0].sdp_mid == "video" assert res4[0].sdp_mline_index == 1 - # Empty string in list ignored res5 = decode_ice_candidates(["", " "]) assert res5 == [] - # Non-base64 raw candidate string in list res6 = decode_ice_candidates([cand_str]) assert len(res6) == 1 assert res6[0].foundation == "1" - # Corrupted base64 payload starting with ey raises ValueError - bad_base64_json = "eyJjYW5kaWRhdGUiOiA=" # incomplete json + bad_base64_json = "eyJjYW5kaWRhdGUiOiA=" with pytest.raises(ValueError, match="Failed to decode base64 JSON"): decode_ice_candidates([bad_base64_json]) - # ignore_invalid skips bad candidate res7 = decode_ice_candidates([bad_base64_json, cand_str], ignore_invalid=True) assert len(res7) == 1 assert res7[0].foundation == "1" -def test_parse_ice_candidate_dangling_token() -> None: - """Test candidate with an odd trailing token (dangling attribute name).""" - cand = parse_ice_candidate("candidate:1 1 udp 100 1.2.3.4 5000 typ host dangling_token") - assert cand.foundation == "1" - assert cand.candidate_type == "host" - - -def test_candidate_to_sdp_manual_without_raw() -> None: - """Test to_sdp formatting when candidate is manually constructed without raw.""" - cand = CameraIceCandidate( - foundation="1", - component=1, - protocol="udp", - priority=100, - ip="1.2.3.4", - port=5000, - candidate_type="srflx", - related_address="10.0.0.1", - related_port=5000, - extensions={"generation": "0"}, - raw=None, - ) - assert cand.to_sdp() == "candidate:1 1 udp 100 1.2.3.4 5000 typ srflx raddr 10.0.0.1 rport 5000 generation 0" - - -def test_camera_turn_server_empty_urls_error() -> None: - """Test empty URL string or empty list in CameraTurnServer raises ValueError.""" - with pytest.raises(ValueError, match="Missing 'url' or 'urls'"): - CameraTurnServer.from_dict({"url": ""}) - - with pytest.raises(ValueError, match="Missing 'url' or 'urls'"): - CameraTurnServer.from_dict({"urls": []}) +def test_parse_ice_candidate_dangling_token_rejected() -> None: + """Test candidate with an odd trailing token raises ValueError.""" + with pytest.raises(ValueError, match="odd number of tokens"): + parse_ice_candidate("candidate:1 1 udp 100 1.2.3.4 5000 typ host dangling_token") def test_encode_ice_candidate_mline_string_and_errors() -> None: """Test sdp_mline_index coercion and error handling.""" raw_sdp = "candidate:1 1 udp 100 1.1.1.1 200 typ host" - # string integer b64_str = encode_ice_candidate(raw_sdp, sdp_mline_index="1") # type: ignore[arg-type] obj = json.loads(base64.b64decode(b64_str).decode("utf-8")) assert obj["sdpMLineIndex"] == 1 - # invalid string with pytest.raises(ValueError, match="Invalid sdpMLineIndex"): encode_ice_candidate(raw_sdp, sdp_mline_index="not_a_number") # type: ignore[arg-type] -def test_decode_ice_candidates_ignore_invalid_variations() -> None: - """Test ignore_invalid handles bad candidate dicts and bad strings.""" - # Dict missing candidate string with ignore_invalid=True +def test_decode_ice_candidates_ignore_invalid_variations(caplog: pytest.LogCaptureFixture) -> None: + """Test ignore_invalid handles bad candidate dicts, bad strings, and TypeError from malformed mline.""" res1 = decode_ice_candidates([{"sdpMid": "0"}], ignore_invalid=True) assert res1 == [] - # Dict with invalid candidate string with ignore_invalid=True res2 = decode_ice_candidates([{"candidate": "not a candidate"}], ignore_invalid=True) assert res2 == [] - # Raw string with invalid candidate and ignore_invalid=True res3 = decode_ice_candidates(["not a candidate"], ignore_invalid=True) assert res3 == [] + # TypeError from non-int coercible sdpMLineIndex (e.g. list) + res4 = decode_ice_candidates( + [{"candidate": "candidate:1 1 udp 100 1.2.3.4 5000 typ host", "sdpMLineIndex": [1]}], + ignore_invalid=True, + ) + assert res4 == [] + + # Valid base64 that decodes to non-dict JSON (e.g. JSON list) + non_dict_json_b64 = base64.b64encode(b"[1, 2, 3]").decode("ascii") + with pytest.raises(ValueError, match="Failed to decode base64 JSON"): + decode_ice_candidates([non_dict_json_b64]) + + res5 = decode_ice_candidates([non_dict_json_b64], ignore_invalid=True) + assert res5 == [] + # Base64 JSON missing candidate key no_cand_json = base64.b64encode(b'{"sdpMid": "0"}').decode("ascii") - with pytest.raises(ValueError, match="missing 'candidate' string"): + with pytest.raises(ValueError, match="Failed to decode base64 JSON"): decode_ice_candidates([no_cand_json]) + + # Check that caplog captured warnings + assert any("Skipping" in record.message for record in caplog.records) From 9fea843db4ad7fa2cd8e1539d5cd220aa95ba5bb Mon Sep 17 00:00:00 2001 From: Luke Date: Wed, 21 Oct 2026 20:17:33 -0400 Subject: [PATCH 3/3] fix(camera): align signaling with repository conventions --- roborock/camera_signaling.py | 598 ------------------ roborock/data/camera.py | 185 +++++- roborock/protocols/camera_signaling.py | 311 ++++++++++ tests/data/test_camera.py | 277 +++++++++ tests/protocols/test_camera_signaling.py | 479 +++++++++++++++ tests/test_camera_signaling.py | 731 ----------------------- 6 files changed, 1225 insertions(+), 1356 deletions(-) delete mode 100644 roborock/camera_signaling.py create mode 100644 roborock/protocols/camera_signaling.py create mode 100644 tests/data/test_camera.py create mode 100644 tests/protocols/test_camera_signaling.py delete mode 100644 tests/test_camera_signaling.py diff --git a/roborock/camera_signaling.py b/roborock/camera_signaling.py deleted file mode 100644 index 16a7efb79..000000000 --- a/roborock/camera_signaling.py +++ /dev/null @@ -1,598 +0,0 @@ -"""Camera signaling models, ICE parsing utilities, and payload serialization for Roborock vacuums. - -This module provides data models and helpers for Roborock camera live view WebRTC -signaling, including TURN server mapping, ICE candidate parsing/formatting, -password hashing, and base64-JSON payload encoding/decoding. -""" - -from __future__ import annotations - -import base64 -import binascii -import hashlib -import json -import logging -import re -from dataclasses import dataclass, field -from enum import StrEnum -from typing import Any, Self - -from .data.containers import RoborockBase - -_LOGGER = logging.getLogger(__name__) - -__all__ = [ - "CameraIceCandidate", - "CameraQuality", - "CameraTurnServer", - "decode_ice_candidates", - "decode_sdp_answer", - "encode_ice_candidate", - "encode_sdp_offer", - "hash_camera_password", - "parse_ice_candidate", -] - -# RFC 5245 / RFC 8839: foundation is 1 to 32 ice-char (ALPHA / DIGIT / "+" / "/" / "-" / "_") -_ICE_FOUNDATION_PATTERN = re.compile(r"^[A-Za-z0-9+/_\-]{1,32}$") -_VALID_TCP_TYPES = frozenset({"active", "passive", "so"}) -_VALID_TURN_SCHEMES = ("turn:", "turns:", "stun:", "stuns:") - - -class CameraQuality(StrEnum): - """Camera video streaming resolution setting.""" - - HD = "HD" - SD = "SD" - FHD = "FHD" - AUTO = "auto" - - @classmethod - def from_str(cls, value: str) -> Self: - """Parse CameraQuality case-insensitively.""" - if not isinstance(value, str): - raise TypeError(f"Expected str for camera quality, got {type(value).__name__}") - normalized = value.strip() - # "auto" is lowercase in the Roborock app enum, while HD/SD/FHD are uppercase - for member in cls: - if member.value.lower() == normalized.lower(): - return member - valid = ", ".join(repr(m.value) for m in cls) - raise ValueError(f"Invalid camera quality: {value!r}. Expected one of {valid}.") - - -@dataclass -class CameraTurnServer(RoborockBase): - """Ephemeral TURN server credentials for WebRTC peer connection relay.""" - - urls: list[str] - username: str | None = None - credential: str | None = field(default=None, repr=False) - - @classmethod - def from_dict(cls, data: dict[str, Any] | list[dict[str, Any]]) -> Self: - """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'). - """ - if isinstance(data, list): - if not data: - raise ValueError("Empty TURN server data list") - item = data[0] - if not isinstance(item, dict): - raise TypeError(f"Expected dict inside TURN server list, got {type(item).__name__}") - data = item - if not isinstance(data, dict): - raise TypeError(f"Expected dict or list of dicts for TURN server, got {type(data).__name__}") - - if "urls" in data: - urls_val = data["urls"] - elif "url" in data: - urls_val = data["url"] - else: - raise ValueError("Missing 'url' or 'urls' in TURN server response") - - urls: list[str] = [] - if isinstance(urls_val, str): - urls_clean = urls_val.strip() - if not urls_clean: - raise ValueError("TURN server URL cannot be empty") - urls = [urls_clean] - elif isinstance(urls_val, list): - if not urls_val: - raise ValueError("TURN server 'urls' list cannot be empty") - for u in urls_val: - if not isinstance(u, str) or not u.strip(): - raise ValueError(f"Each URL in 'urls' must be a non-empty string, got {u!r}") - urls.append(u.strip()) - else: - raise TypeError(f"Expected str or list for urls, got {type(urls_val).__name__}") - - # Validate URL scheme (stun:, stuns:, turn:, turns:) - for u in urls: - if not any(u.lower().startswith(scheme) for scheme in _VALID_TURN_SCHEMES): - raise ValueError(f"Invalid ICE server URL scheme in {u!r}. Expected turn:, turns:, stun:, or stuns:") - - user_val = data.get("username") if "username" in data else data.get("user") - pwd_val = data.get("credential") if "credential" in data else data.get("pwd") - - if user_val is not None and not isinstance(user_val, str): - raise TypeError(f"Expected str for username, got {type(user_val).__name__}") - if pwd_val is not None and not isinstance(pwd_val, str): - raise TypeError(f"Expected str for credential, got {type(pwd_val).__name__}") - - return cls( - urls=urls, - username=user_val, - credential=pwd_val, - ) - - def to_dict(self) -> dict[str, Any]: - """Convert to standard WebRTC RTCIceServer configuration dictionary.""" - result: dict[str, Any] = {"urls": list(self.urls)} - if self.username is not None: - result["username"] = self.username - if self.credential is not None: - result["credential"] = self.credential - return result - - -@dataclass -class CameraIceCandidate: - """Parsed WebRTC ICE candidate representation (RFC 5245 / RFC 8839).""" - - 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] = field(default_factory=dict) - - def to_sdp(self) -> str: - """Format candidate as an SDP attribute line value (RFC 5245 / RFC 8839).""" - parts = [ - f"candidate:{self.foundation}", - str(self.component), - self.protocol.lower(), - str(self.priority), - self.ip, - str(self.port), - "typ", - self.candidate_type.lower(), - ] - if self.related_address is not None and self.related_port is not None: - parts.extend(["raddr", self.related_address, "rport", str(self.related_port)]) - if self.tcp_type is not None: - parts.extend(["tcptype", self.tcp_type.lower()]) - for ext_k, ext_v in self.extensions.items(): - parts.extend([ext_k, ext_v]) - return " ".join(parts) - - def to_signaling_dict(self) -> dict[str, Any]: - """Convert to Roborock camera signaling dictionary format.""" - return { - "candidate": self.to_sdp(), - "sdpMid": self.sdp_mid if self.sdp_mid is not None else "0", - "sdpMLineIndex": self.sdp_mline_index if self.sdp_mline_index is not None else 0, - } - - def __str__(self) -> str: - """Return the SDP candidate string.""" - return self.to_sdp() - - -def parse_ice_candidate( - candidate_str: str, - sdp_mid: str | None = None, - sdp_mline_index: int | None = None, -) -> CameraIceCandidate: - """Parse a raw SDP candidate string into a CameraIceCandidate object. - - Handles standard candidate strings with optional 'a=' or 'candidate:' prefixes, - IPv4, IPv6, and FQDN/mDNS addresses, transport protocols, candidate types, and - optional extension attributes such as raddr, rport, tcptype, generation, etc. - """ - if not isinstance(candidate_str, str): - raise TypeError(f"Expected str for ICE candidate, got {type(candidate_str).__name__}") - - cand_clean = candidate_str.rstrip("\r\n").strip() - if not cand_clean: - raise ValueError("Empty ICE candidate string") - - tokens_str = cand_clean - if tokens_str.startswith("a="): - tokens_str = tokens_str[2:].strip() - if tokens_str.startswith("candidate:"): - tokens_str = tokens_str[10:].strip() - - tokens = tokens_str.split() - if len(tokens) < 8: - raise ValueError(f"Malformed ICE candidate string (expected at least 8 tokens): {candidate_str!r}") - - foundation = tokens[0] - if not _ICE_FOUNDATION_PATTERN.match(foundation): - raise ValueError(f"Invalid ICE foundation {foundation!r}: must be 1-32 ice characters") - - component_str = tokens[1] - if not (component_str.isascii() and component_str.isdigit()): - raise ValueError(f"Invalid ICE component {component_str!r}: must be ASCII decimal digits") - component = int(component_str) - if not (1 <= component <= 256): - raise ValueError(f"ICE component {component} out of valid RFC 8839 range [1, 256]") - - protocol = tokens[2].lower() - - priority_str = tokens[3] - if not (priority_str.isascii() and priority_str.isdigit()): - raise ValueError(f"Invalid ICE priority {priority_str!r}: must be ASCII decimal digits") - priority = int(priority_str) - if not (1 <= priority <= 2147483647): - raise ValueError(f"ICE priority {priority} out of valid RFC 8445 range [1, 2147483647]") - - ip = tokens[4] - - port_str = tokens[5] - if not (port_str.isascii() and port_str.isdigit()): - raise ValueError(f"Invalid ICE port {port_str!r}: must be ASCII decimal digits") - port = int(port_str) - if not (0 <= port <= 65535): - raise ValueError(f"ICE port {port} out of valid range [0, 65535]") - - typ_keyword = tokens[6].lower() - if typ_keyword != "typ": - raise ValueError( - f"Malformed ICE candidate string (expected 'typ' keyword at position 7, got {tokens[6]!r}): " - f"{candidate_str!r}" - ) - - cand_type = tokens[7].lower() - - related_address: str | None = None - related_port: int | None = None - tcp_type: str | None = None - extensions: dict[str, str] = {} - - if (len(tokens) - 8) % 2 != 0: - raise ValueError( - f"Malformed ICE candidate extension attributes: odd number of tokens ({len(tokens) - 8}) " - f"after candidate type: {candidate_str!r}" - ) - - i = 8 - while i < len(tokens): - attr_name = tokens[i].lower() - attr_val = tokens[i + 1] - if attr_name == "raddr": - related_address = attr_val - elif attr_name == "rport": - if not (attr_val.isascii() and attr_val.isdigit()): - raise ValueError(f"Invalid ICE related port {attr_val!r}: must be ASCII decimal digits") - related_port = int(attr_val) - if not (0 <= related_port <= 65535): - raise ValueError(f"ICE related port {related_port} out of valid range [0, 65535]") - elif attr_name == "tcptype": - tcp_type = attr_val.lower() - if tcp_type not in _VALID_TCP_TYPES: - raise ValueError(f"Invalid ICE TCP type {attr_val!r}: expected 'active', 'passive', or 'so'") - else: - extensions[tokens[i]] = attr_val - i += 2 - - # Invariants for related address / related port: - if (related_address is not None and related_port is None) or (related_address is None and related_port is not None): - raise ValueError("ICE candidate must have both 'raddr' and 'rport' specified together") - - # Invariants for TCP candidates: - if protocol == "tcp" and tcp_type is None: - raise ValueError("TCP ICE candidate must specify 'tcptype' attribute (RFC 6544)") - - return CameraIceCandidate( - foundation=foundation, - component=component, - protocol=protocol, - priority=priority, - ip=ip, - port=port, - candidate_type=cand_type, - related_address=related_address, - related_port=related_port, - tcp_type=tcp_type, - sdp_mid=sdp_mid, - sdp_mline_index=sdp_mline_index, - extensions=extensions, - ) - - -def hash_camera_password(password: str) -> str: - """Derive the lowercase hexadecimal MD5 digest of camera password digits. - - The Roborock camera password is a PIN or 3x3 pattern lock represented as - a sequence of numeric ASCII digits (e.g. '1234'). - """ - if not isinstance(password, str): - raise TypeError(f"Camera password must be a str, got {type(password).__name__}") - - password_str = password.strip() - if not password_str: - raise ValueError("Camera password cannot be empty") - if not (password_str.isascii() and password_str.isdigit()): - raise ValueError(f"Camera password must contain only ASCII digits, got {password!r}") - - return hashlib.md5(password_str.encode("utf-8"), usedforsecurity=False).hexdigest() - - -def encode_sdp_offer(sdp: str | dict[str, Any]) -> str: - """Package an SDP offer into a base64-encoded UTF-8 JSON payload for send_sdp_to_robot. - - Accepts an SDP description string or a dict containing an 'sdp' key. - Returns the base64-encoded string to be passed under the 'app_sdp' parameter. - """ - sdp_str: str - if isinstance(sdp, str): - if not sdp.strip(): - raise ValueError("SDP offer string cannot be empty") - sdp_str = sdp - elif isinstance(sdp, dict): - if "sdp" not in sdp or not isinstance(sdp["sdp"], str): - raise ValueError("SDP dictionary must contain a non-empty string 'sdp' key") - if not sdp["sdp"].strip(): - raise ValueError("SDP offer string cannot be empty") - offer_type = sdp.get("type", "offer") - if not isinstance(offer_type, str) or offer_type.lower() != "offer": - raise ValueError(f"SDP offer payload type must be 'offer', got {offer_type!r}") - sdp_str = sdp["sdp"] - else: - raise TypeError(f"Expected str or dict for SDP offer, got {type(sdp).__name__}") - - payload = {"sdp": sdp_str, "type": "offer"} - json_bytes = json.dumps(payload, separators=(",", ":")).encode("utf-8") - return base64.b64encode(json_bytes).decode("ascii") - - -def decode_sdp_answer(payload: str | dict[str, Any] | list[Any]) -> dict[str, str] | None: - """Unpackage a base64-encoded UTF-8 JSON SDP answer payload from get_device_sdp. - - Accepts the raw device response list/dict (e.g. [{'dev_sdp': '...'}]) or a base64 string. - Returns a dictionary with 'sdp' and 'type' keys, or None if the device returned 'retry'. - """ - raw_b64: str - if isinstance(payload, list): - if not payload: - raise ValueError("Empty response list for SDP answer") - item = payload[0] - if not isinstance(item, dict): - raise TypeError(f"Expected dict inside SDP answer list, got {type(item).__name__}") - payload = item - if isinstance(payload, dict): - if "dev_sdp" in payload: - val = payload["dev_sdp"] - if not isinstance(val, str): - raise TypeError(f"Expected str for 'dev_sdp', got {type(val).__name__}") - raw_b64 = val - elif "sdp" in payload: - sdp_val = payload["sdp"] - if not isinstance(sdp_val, str) or not sdp_val.strip(): - raise ValueError(f"SDP dictionary missing or empty 'sdp' string: {payload}") - ans_type = payload.get("type", "answer") - if not isinstance(ans_type, str) or ans_type.lower() not in ("answer", "pranswer"): - raise ValueError(f"SDP answer has invalid 'type': {ans_type!r}") - return {"sdp": sdp_val, "type": ans_type.lower()} - else: - raise ValueError(f"Dictionary missing 'dev_sdp' or 'sdp' key: {payload}") - elif isinstance(payload, str): - raw_b64 = payload - else: - raise TypeError(f"Expected str, dict, or list for SDP answer payload, got {type(payload).__name__}") - - raw_b64 = raw_b64.strip() - if raw_b64 == "retry": - return None - - try: - json_bytes = base64.b64decode(raw_b64, validate=True) - data = json.loads(json_bytes.decode("utf-8")) - except (binascii.Error, ValueError, json.JSONDecodeError, UnicodeDecodeError) as err: - raise ValueError(f"Failed to decode base64 JSON SDP answer: {err}") from err - - if not isinstance(data, dict): - raise ValueError(f"Decoded SDP answer JSON must be an object, got {type(data).__name__}") - - if "sdp" not in data or not isinstance(data["sdp"], str) or not data["sdp"].strip(): - raise ValueError(f"Decoded SDP answer missing or empty 'sdp' field: {data}") - - answer_type = data.get("type", "answer") - if not isinstance(answer_type, str) or answer_type.lower() not in ("answer", "pranswer"): - raise ValueError(f"Decoded SDP answer has invalid 'type': {answer_type!r}") - - return { - "sdp": data["sdp"], - "type": answer_type.lower(), - } - - -def encode_ice_candidate( - candidate: CameraIceCandidate | str | dict[str, Any], - sdp_mid: str | None = None, - sdp_mline_index: int | None = None, -) -> str: - """Package an ICE candidate into a base64-encoded UTF-8 JSON payload for send_ice_to_robot. - - Accepts a CameraIceCandidate, raw candidate string, or dict. - Returns the base64-encoded string to be passed under the 'app_ice' parameter. - """ - if isinstance(candidate, CameraIceCandidate): - # Override mid/mline if explicitly passed to encode_ice_candidate - payload = candidate.to_signaling_dict() - if sdp_mid is not None: - payload["sdpMid"] = str(sdp_mid) - if sdp_mline_index is not None: - if not isinstance(sdp_mline_index, int): - try: - sdp_mline_index = int(sdp_mline_index) - except (ValueError, TypeError) as err: - raise ValueError(f"Invalid sdpMLineIndex {sdp_mline_index!r}: must be an integer") from err - payload["sdpMLineIndex"] = sdp_mline_index - elif isinstance(candidate, dict): - cand_val = candidate.get("candidate") - if not isinstance(cand_val, str) or not cand_val.strip(): - raise ValueError(f"Candidate dict missing or invalid 'candidate' string: {candidate}") - candidate_str = cand_val.strip() - mid = sdp_mid if sdp_mid is not None else candidate.get("sdpMid") - mline = sdp_mline_index if sdp_mline_index is not None else candidate.get("sdpMLineIndex") - if not candidate_str.startswith("candidate:"): - candidate_str = f"candidate:{candidate_str}" - if mline is not None and not isinstance(mline, int): - try: - mline = int(mline) - except (ValueError, TypeError) as err: - raise ValueError(f"Invalid sdpMLineIndex {mline!r}: must be an integer") from err - payload = { - "candidate": candidate_str, - "sdpMid": str(mid) if mid is not None else "0", - "sdpMLineIndex": mline if mline is not None else 0, - } - elif isinstance(candidate, str): - candidate_str = candidate.strip() - if not candidate_str: - raise ValueError("ICE candidate string cannot be empty") - if not candidate_str.startswith("candidate:"): - candidate_str = f"candidate:{candidate_str}" - mline = sdp_mline_index - if mline is not None and not isinstance(mline, int): - try: - mline = int(mline) - except (ValueError, TypeError) as err: - raise ValueError(f"Invalid sdpMLineIndex {mline!r}: must be an integer") from err - payload = { - "candidate": candidate_str, - "sdpMid": str(sdp_mid) if sdp_mid is not None else "0", - "sdpMLineIndex": mline if mline is not None else 0, - } - else: - raise TypeError(f"Expected CameraIceCandidate, dict, or str, got {type(candidate).__name__}") - - json_bytes = json.dumps(payload, separators=(",", ":")).encode("utf-8") - return base64.b64encode(json_bytes).decode("ascii") - - -def decode_ice_candidates( - payload: str | list[Any] | dict[str, Any], - *, - ignore_invalid: bool = False, -) -> list[CameraIceCandidate]: - """Unpackage base64-encoded UTF-8 JSON remote ICE candidates from get_device_ice. - - Accepts the raw device response list/dict (e.g. [{'dev_ice': ['...']}]) or a list of base64 strings. - Returns a list of parsed CameraIceCandidate objects. - If ignore_invalid is True, invalid candidates are logged and skipped rather than raising ValueError or TypeError. - """ - raw_list: list[Any] - if isinstance(payload, list): - if len(payload) == 1 and isinstance(payload[0], dict) and "dev_ice" in payload[0]: - dev_ice_val = payload[0]["dev_ice"] - if not isinstance(dev_ice_val, list): - raise TypeError(f"Expected list in 'dev_ice', got {type(dev_ice_val).__name__}") - raw_list = dev_ice_val - else: - raw_list = payload - elif isinstance(payload, dict): - if "dev_ice" in payload: - dev_ice_val = payload["dev_ice"] - if not isinstance(dev_ice_val, list): - raise TypeError(f"Expected list in 'dev_ice', got {type(dev_ice_val).__name__}") - raw_list = dev_ice_val - else: - raw_list = [payload] - elif isinstance(payload, str): - raw_list = [payload] - else: - raise TypeError(f"Expected str, list, or dict for ICE candidates payload, got {type(payload).__name__}") - - candidates: list[CameraIceCandidate] = [] - for item in raw_list: - if isinstance(item, CameraIceCandidate): - candidates.append(item) - continue - if isinstance(item, dict): - cand_val = item.get("candidate") - if not isinstance(cand_val, str) or not cand_val.strip(): - if ignore_invalid: - _LOGGER.warning("Skipping candidate dict missing 'candidate' string: %s", item) - continue - raise ValueError(f"Candidate dict missing 'candidate' string: {item}") - mid = item.get("sdpMid") - mline = item.get("sdpMLineIndex") - try: - mline_int: int | None = None - if mline is not None: - mline_int = int(mline) - candidates.append( - parse_ice_candidate( - cand_val, - sdp_mid=str(mid) if mid is not None else None, - sdp_mline_index=mline_int, - ) - ) - except (ValueError, TypeError) as err: - if ignore_invalid: - _LOGGER.warning("Skipping invalid ICE candidate: %s", err) - continue - raise - continue - if isinstance(item, str): - item_str = item.strip() - if not item_str: - continue - cand_str_to_parse: str = item_str - sdp_mid_to_parse: str | None = None - sdp_mline_to_parse: int | None = None - try: - decoded_bytes = base64.b64decode(item_str, validate=True) - data = json.loads(decoded_bytes.decode("utf-8")) - if not isinstance(data, dict): - raise ValueError(f"Decoded candidate JSON must be a dict object, got {type(data).__name__}") - if "candidate" not in data or not isinstance(data["candidate"], str): - raise ValueError(f"Decoded candidate JSON missing 'candidate' string: {data}") - cand_str_to_parse = data["candidate"] - mid = data.get("sdpMid") - mline = data.get("sdpMLineIndex") - sdp_mid_to_parse = str(mid) if mid is not None else None - sdp_mline_to_parse = int(mline) if mline is not None else None - except (binascii.Error, ValueError, json.JSONDecodeError, UnicodeDecodeError, TypeError) as err: - # If this was clearly an attempted base64 JSON payload (starts with 'ey'), - # or valid base64 that decoded to non-dict, do not fall back to treating it as raw candidate - if item_str.startswith("ey") or ( - not isinstance(err, binascii.Error) and not isinstance(err, UnicodeDecodeError) - ): - if ignore_invalid: - _LOGGER.warning("Skipping invalid base64 JSON ICE candidate payload: %s", err) - continue - raise ValueError(f"Failed to decode base64 JSON ICE candidate: {err}") from err - cand_str_to_parse = item_str - - try: - candidates.append( - parse_ice_candidate( - cand_str_to_parse, - sdp_mid=sdp_mid_to_parse, - sdp_mline_index=sdp_mline_to_parse, - ) - ) - except (ValueError, TypeError) as err: - if ignore_invalid: - _LOGGER.warning("Skipping invalid ICE candidate: %s", err) - continue - raise - continue - - raise TypeError(f"Unexpected candidate item type: {type(item).__name__}") - - return candidates diff --git a/roborock/data/camera.py b/roborock/data/camera.py index ee90cc054..2ea63eb26 100644 --- a/roborock/data/camera.py +++ b/roborock/data/camera.py @@ -1,27 +1,158 @@ -"""Camera data models and utilities.""" - -from __future__ import annotations - -from roborock.camera_signaling import ( - CameraIceCandidate, - CameraQuality, - CameraTurnServer, - decode_ice_candidates, - decode_sdp_answer, - encode_ice_candidate, - encode_sdp_offer, - hash_camera_password, - parse_ice_candidate, -) - -__all__ = [ - "CameraIceCandidate", - "CameraQuality", - "CameraTurnServer", - "decode_ice_candidates", - "decode_sdp_answer", - "encode_ice_candidate", - "encode_sdp_offer", - "hash_camera_password", - "parse_ice_candidate", -] +"""Data models for Roborock camera live view.""" + +from dataclasses import dataclass, field +from enum import StrEnum +from typing import Any, Self + +from .containers import RoborockBase + +__all__ = ["CameraIceCandidate", "CameraQuality", "CameraSdpDescription", "CameraTurnServer"] + +_VALID_TURN_SCHEMES = ("turn:", "turns:", "stun:", "stuns:") + + +class CameraQuality(StrEnum): + """Camera video streaming resolution setting.""" + + HD = "HD" + SD = "SD" + FHD = "FHD" + AUTO = "auto" + + @classmethod + def from_str(cls, value: str) -> Self: + """Parse CameraQuality case-insensitively.""" + normalized = value.strip() + # "auto" is lowercase in the Roborock app enum, while HD/SD/FHD are uppercase + for member in cls: + if member.value.lower() == normalized.lower(): + return member + valid = ", ".join(repr(m.value) for m in cls) + raise ValueError(f"Invalid camera quality: {value!r}. Expected one of {valid}.") + + +@dataclass +class CameraTurnServer(RoborockBase): + """Ephemeral TURN server credentials for WebRTC peer connection relay.""" + + urls: list[str] + username: str | None = None + credential: str | None = field(default=None, repr=False) + + @classmethod + def from_dict(cls, data: dict[str, Any] | list[dict[str, Any]]) -> Self: + """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'). + """ + if isinstance(data, list): + if not data: + raise ValueError("Empty TURN server data list") + item = data[0] + if not isinstance(item, dict): + raise TypeError(f"Expected dict inside TURN server list, got {type(item).__name__}") + data = item + if not isinstance(data, dict): + raise TypeError(f"Expected dict or list of dicts for TURN server, got {type(data).__name__}") + + if "urls" in data: + urls_val = data["urls"] + elif "url" in data: + urls_val = data["url"] + else: + raise ValueError("Missing 'url' or 'urls' in TURN server response") + + urls: list[str] = [] + if isinstance(urls_val, str): + urls_clean = urls_val.strip() + if not urls_clean: + raise ValueError("TURN server URL cannot be empty") + urls = [urls_clean] + elif isinstance(urls_val, list): + if not urls_val: + raise ValueError("TURN server 'urls' list cannot be empty") + for u in urls_val: + if not isinstance(u, str) or not u.strip(): + raise ValueError(f"Each URL in 'urls' must be a non-empty string, got {u!r}") + urls.append(u.strip()) + else: + raise TypeError(f"Expected str or list for urls, got {type(urls_val).__name__}") + + # Validate URL scheme (stun:, stuns:, turn:, turns:) + for u in urls: + if not any(u.lower().startswith(scheme) for scheme in _VALID_TURN_SCHEMES): + raise ValueError(f"Invalid ICE server URL scheme in {u!r}. Expected turn:, turns:, stun:, or stuns:") + + user_val = data.get("username") if "username" in data else data.get("user") + pwd_val = data.get("credential") if "credential" in data else data.get("pwd") + + if user_val is not None and not isinstance(user_val, str): + raise TypeError(f"Expected str for username, got {type(user_val).__name__}") + if pwd_val is not None and not isinstance(pwd_val, str): + raise TypeError(f"Expected str for credential, got {type(pwd_val).__name__}") + + return cls( + urls=urls, + username=user_val, + credential=pwd_val, + ) + + +@dataclass +class CameraIceCandidate(RoborockBase): + """Parsed WebRTC ICE candidate representation (RFC 5245 / RFC 8839).""" + + 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] = field(default_factory=dict) + + def to_sdp(self) -> str: + """Format candidate as an SDP attribute line value (RFC 5245 / RFC 8839).""" + parts = [ + f"candidate:{self.foundation}", + str(self.component), + self.protocol.lower(), + str(self.priority), + self.ip, + str(self.port), + "typ", + self.candidate_type.lower(), + ] + if self.related_address is not None and self.related_port is not None: + parts.extend(["raddr", self.related_address, "rport", str(self.related_port)]) + if self.tcp_type is not None: + parts.extend(["tcptype", self.tcp_type.lower()]) + for ext_k, ext_v in self.extensions.items(): + parts.extend([ext_k, ext_v]) + return " ".join(parts) + + def to_signaling_dict(self) -> dict[str, str | int]: + """Convert to Roborock camera signaling dictionary format.""" + return { + "candidate": self.to_sdp(), + "sdpMid": self.sdp_mid if self.sdp_mid is not None else "0", + "sdpMLineIndex": self.sdp_mline_index if self.sdp_mline_index is not None else 0, + } + + def __str__(self) -> str: + """Return the SDP candidate string.""" + return self.to_sdp() + + +@dataclass +class CameraSdpDescription(RoborockBase): + """WebRTC session description exchanged during camera signaling.""" + + sdp: str + type: str diff --git a/roborock/protocols/camera_signaling.py b/roborock/protocols/camera_signaling.py new file mode 100644 index 000000000..a72a280db --- /dev/null +++ b/roborock/protocols/camera_signaling.py @@ -0,0 +1,311 @@ +"""V1 camera WebRTC signaling, ICE parsing, and payload serialization.""" + +import base64 +import hashlib +import json +import logging +import re +from typing import Any + +from roborock.data.camera import CameraIceCandidate, CameraSdpDescription +from roborock.exceptions import RoborockParsingException +from roborock.roborock_typing import RoborockCommand + +_LOGGER = logging.getLogger(__name__) + +__all__ = [ + "decode_ice_candidates", + "decode_sdp_answer", + "encode_ice_candidate", + "encode_sdp_offer", + "hash_camera_password", + "parse_ice_candidate", +] + +# RFC 5245 / RFC 8839: foundation is 1 to 32 ice characters. +_ICE_FOUNDATION_PATTERN = re.compile(r"^[A-Za-z0-9+/_\-]{1,32}$") +_VALID_TCP_TYPES = frozenset({"active", "passive", "so"}) + + +def parse_ice_candidate( + candidate_str: str, + sdp_mid: str | None = None, + sdp_mline_index: int | None = None, +) -> CameraIceCandidate: + """Parse a raw SDP candidate string into a CameraIceCandidate object. + + Handles standard candidate strings with optional 'a=' or 'candidate:' prefixes, + IPv4, IPv6, and FQDN/mDNS addresses, transport protocols, candidate types, and + optional extension attributes such as raddr, rport, tcptype, generation, etc. + """ + cand_clean = candidate_str.rstrip("\r\n").strip() + if not cand_clean: + raise ValueError("Empty ICE candidate string") + + tokens_str = cand_clean + if tokens_str.startswith("a="): + tokens_str = tokens_str[2:].strip() + if tokens_str.startswith("candidate:"): + tokens_str = tokens_str[10:].strip() + + tokens = tokens_str.split() + if len(tokens) < 8: + raise ValueError(f"Malformed ICE candidate string (expected at least 8 tokens): {candidate_str!r}") + + foundation = tokens[0] + if not _ICE_FOUNDATION_PATTERN.match(foundation): + raise ValueError(f"Invalid ICE foundation {foundation!r}: must be 1-32 ice characters") + + component_str = tokens[1] + if not (component_str.isascii() and component_str.isdigit()): + raise ValueError(f"Invalid ICE component {component_str!r}: must be ASCII decimal digits") + component = int(component_str) + if not (1 <= component <= 256): + raise ValueError(f"ICE component {component} out of valid RFC 8839 range [1, 256]") + + protocol = tokens[2].lower() + + priority_str = tokens[3] + if not (priority_str.isascii() and priority_str.isdigit()): + raise ValueError(f"Invalid ICE priority {priority_str!r}: must be ASCII decimal digits") + priority = int(priority_str) + if not (1 <= priority <= 2147483647): + raise ValueError(f"ICE priority {priority} out of valid RFC 8445 range [1, 2147483647]") + + ip = tokens[4] + + port_str = tokens[5] + if not (port_str.isascii() and port_str.isdigit()): + raise ValueError(f"Invalid ICE port {port_str!r}: must be ASCII decimal digits") + port = int(port_str) + if not (0 <= port <= 65535): + raise ValueError(f"ICE port {port} out of valid range [0, 65535]") + + typ_keyword = tokens[6].lower() + if typ_keyword != "typ": + raise ValueError( + f"Malformed ICE candidate string (expected 'typ' keyword at position 7, got {tokens[6]!r}): " + f"{candidate_str!r}" + ) + + cand_type = tokens[7].lower() + + related_address: str | None = None + related_port: int | None = None + tcp_type: str | None = None + extensions: dict[str, str] = {} + + if (len(tokens) - 8) % 2 != 0: + raise ValueError( + f"Malformed ICE candidate extension attributes: odd number of tokens ({len(tokens) - 8}) " + f"after candidate type: {candidate_str!r}" + ) + + i = 8 + while i < len(tokens): + attr_name = tokens[i].lower() + attr_val = tokens[i + 1] + if attr_name == "raddr": + related_address = attr_val + elif attr_name == "rport": + if not (attr_val.isascii() and attr_val.isdigit()): + raise ValueError(f"Invalid ICE related port {attr_val!r}: must be ASCII decimal digits") + related_port = int(attr_val) + if not (0 <= related_port <= 65535): + raise ValueError(f"ICE related port {related_port} out of valid range [0, 65535]") + elif attr_name == "tcptype": + tcp_type = attr_val.lower() + if tcp_type not in _VALID_TCP_TYPES: + raise ValueError(f"Invalid ICE TCP type {attr_val!r}: expected 'active', 'passive', or 'so'") + else: + extensions[tokens[i]] = attr_val + i += 2 + + # Invariants for related address / related port: + if (related_address is not None and related_port is None) or (related_address is None and related_port is not None): + raise ValueError("ICE candidate must have both 'raddr' and 'rport' specified together") + + # Invariants for TCP candidates: + if protocol == "tcp" and tcp_type is None: + raise ValueError("TCP ICE candidate must specify 'tcptype' attribute (RFC 6544)") + + return CameraIceCandidate( + foundation=foundation, + component=component, + protocol=protocol, + priority=priority, + ip=ip, + port=port, + candidate_type=cand_type, + related_address=related_address, + related_port=related_port, + tcp_type=tcp_type, + sdp_mid=sdp_mid, + sdp_mline_index=sdp_mline_index, + extensions=extensions, + ) + + +def hash_camera_password(password: str) -> str: + """Derive the lowercase hexadecimal MD5 digest of camera password digits. + + The Roborock camera password is a PIN or 3x3 pattern lock represented as + a sequence of numeric ASCII digits (e.g. '1234'). + """ + password_str = password.strip() + if not password_str: + raise ValueError("Camera password cannot be empty") + if not (password_str.isascii() and password_str.isdigit()): + raise ValueError("Camera password must contain only ASCII digits") + + return hashlib.md5(password_str.encode("utf-8"), usedforsecurity=False).hexdigest() + + +def encode_sdp_offer(sdp: str) -> str: + """Encode an SDP offer for the ``send_sdp_to_robot`` app_sdp parameter.""" + if not sdp.strip(): + raise ValueError("SDP offer string cannot be empty") + payload = CameraSdpDescription(sdp=sdp, type="offer") + json_bytes = json.dumps(payload.as_dict(), separators=(",", ":")).encode("utf-8") + return base64.b64encode(json_bytes).decode("ascii") + + +def decode_sdp_answer(payload: str | dict[str, Any] | list[Any]) -> CameraSdpDescription | None: + """Decode a get_device_sdp response, returning None for a pending ``retry``. + + Accept a base64 JSON string, its dev_sdp RPC envelope, or an already decoded + description. Preserve the SDP exactly, including CRLF line endings. + Malformed responses raise RoborockParsingException with redacted context. + """ + context = {"type": type(payload).__name__} + stage = "response envelope" + try: + if isinstance(payload, list): + if not payload: + raise ValueError("Empty response list for SDP answer") + item = payload[0] + if not isinstance(item, dict): + raise TypeError(f"Expected dict inside SDP answer list, got {type(item).__name__}") + payload = item + + data: Any + if isinstance(payload, dict) and "sdp" in payload and "dev_sdp" not in payload: + data = payload + else: + raw_b64: str + if isinstance(payload, dict): + if "dev_sdp" not in payload: + raise ValueError("Dictionary missing 'dev_sdp' or 'sdp' key") + raw = payload["dev_sdp"] + if not isinstance(raw, str): + raise TypeError(f"Expected str for 'dev_sdp', got {type(raw).__name__}") + raw_b64 = raw + elif isinstance(payload, str): + raw_b64 = payload + else: + raise TypeError(f"Expected str, dict, or list for SDP answer payload, got {type(payload).__name__}") + + raw_b64 = raw_b64.strip() + if raw_b64 == "retry": + return None + stage = "base64 JSON payload" + data = json.loads(base64.b64decode(raw_b64, validate=True).decode("utf-8")) + + stage = "SDP description fields" + if not isinstance(data, dict): + raise ValueError(f"Decoded SDP answer JSON must be an object, got {type(data).__name__}") + sdp = data.get("sdp") + if not isinstance(sdp, str) or not sdp.strip(): + raise ValueError("SDP answer missing or empty 'sdp' field") + answer_type = data.get("type", "answer") + if not isinstance(answer_type, str) or answer_type.lower() not in ("answer", "pranswer"): + raise ValueError("SDP answer has invalid 'type': expected answer or pranswer") + return CameraSdpDescription(sdp=sdp, type=answer_type.lower()) + except (ValueError, TypeError) as err: + raise RoborockParsingException( + trait_name="CameraSignaling", + command=RoborockCommand.GET_DEVICE_SDP, + payload=context, + inner_error=f"Invalid {stage} ({type(err).__name__})", + ) from err + + +def encode_ice_candidate(candidate: CameraIceCandidate) -> str: + """Encode a candidate for the ``send_ice_to_robot`` app_ice parameter. + + Set SDP media identifiers on the candidate before encoding. Missing identifiers + use the device defaults, sdpMid="0" and sdpMLineIndex=0. + """ + json_bytes = json.dumps(candidate.to_signaling_dict(), separators=(",", ":")).encode("utf-8") + return base64.b64encode(json_bytes).decode("ascii") + + +def decode_ice_candidates( + payload: str | list[Any] | dict[str, Any], + *, + ignore_invalid: bool = False, +) -> list[CameraIceCandidate]: + """Decode get_device_ice base64 JSON candidates or their dev_ice RPC envelope. + + Raw SDP belongs in parse_ice_candidate(). With ignore_invalid=True, skip + malformed candidate entries; malformed response envelopes still raise. + Malformed responses raise RoborockParsingException with redacted context. + """ + context: dict[str, str | int] = {"type": type(payload).__name__} + stage = "response envelope" + try: + if isinstance(payload, list) and len(payload) == 1 and isinstance(payload[0], dict) and "dev_ice" in payload[0]: + payload = payload[0] + + raw_list: list[Any] + if isinstance(payload, dict): + if "dev_ice" not in payload: + raise ValueError("ICE response dictionary missing 'dev_ice' list") + entries = payload["dev_ice"] + if not isinstance(entries, list): + raise TypeError(f"Expected list in 'dev_ice', got {type(entries).__name__}") + raw_list = entries + elif isinstance(payload, list): + raw_list = payload + elif isinstance(payload, str): + raw_list = [payload] + else: + raise TypeError(f"Expected str, list, or dict for ICE candidates payload, got {type(payload).__name__}") + + candidates: list[CameraIceCandidate] = [] + for index, item in enumerate(raw_list): + context["candidate_index"] = index + stage = "base64 JSON candidate" + try: + if not isinstance(item, str): + raise TypeError(f"Expected base64 JSON ICE candidate string, got {type(item).__name__}") + data = json.loads(base64.b64decode(item.strip(), validate=True).decode("utf-8")) + stage = "ICE candidate fields" + if not isinstance(data, dict): + raise ValueError("Decoded ICE candidate JSON must be an object") + candidate = data.get("candidate") + if not isinstance(candidate, str) or not candidate.strip(): + raise ValueError("Decoded ICE candidate JSON missing or empty 'candidate' string") + mid = data.get("sdpMid") + if mid is not None and not isinstance(mid, str): + raise TypeError("ICE candidate sdpMid must be a string or null") + mline = data.get("sdpMLineIndex") + if mline is not None and (not isinstance(mline, int) or isinstance(mline, bool)): + raise TypeError("ICE candidate sdpMLineIndex must be an integer or null") + if mline is not None and mline < 0: + raise ValueError("ICE candidate sdpMLineIndex cannot be negative") + stage = "SDP candidate string" + candidates.append(parse_ice_candidate(candidate, sdp_mid=mid, sdp_mline_index=mline)) + except (ValueError, TypeError) as err: + if not ignore_invalid: + raise + _LOGGER.warning("Skipping invalid ICE candidate at index %d (%s)", index, type(err).__name__) + + return candidates + except (ValueError, TypeError) as err: + raise RoborockParsingException( + trait_name="CameraSignaling", + command=RoborockCommand.GET_DEVICE_ICE, + payload=context, + inner_error=f"Invalid {stage} ({type(err).__name__})", + ) from err diff --git a/tests/data/test_camera.py b/tests/data/test_camera.py new file mode 100644 index 000000000..18d15c0d1 --- /dev/null +++ b/tests/data/test_camera.py @@ -0,0 +1,277 @@ +"""Tests for camera data models.""" + +import pytest + +from roborock.data.camera import CameraIceCandidate, CameraQuality, CameraSdpDescription, CameraTurnServer +from roborock.data.containers import RoborockBase + + +def test_camera_quality_values() -> None: + """Test CameraQuality enum values and string behavior.""" + assert CameraQuality.HD == "HD" + assert CameraQuality.SD == "SD" + assert CameraQuality.FHD == "FHD" + assert CameraQuality.AUTO == "auto" + assert str(CameraQuality.HD) == "HD" + assert CameraQuality("HD") is CameraQuality.HD + assert CameraQuality("SD") is CameraQuality.SD + assert CameraQuality("FHD") is CameraQuality.FHD + assert CameraQuality("auto") is CameraQuality.AUTO + + +@pytest.mark.parametrize( + ("input_str", "expected"), + [ + ("hd", CameraQuality.HD), + ("HD", CameraQuality.HD), + ("sd", CameraQuality.SD), + (" SD ", CameraQuality.SD), + ("fhd", CameraQuality.FHD), + ("FHD", CameraQuality.FHD), + ("auto", CameraQuality.AUTO), + ("AUTO", CameraQuality.AUTO), + (" auto ", CameraQuality.AUTO), + ], +) +def test_camera_quality_from_str(input_str: str, expected: CameraQuality) -> None: + """Test CameraQuality.from_str case insensitivity and whitespace trimming.""" + assert CameraQuality.from_str(input_str) is expected + + +def test_camera_quality_from_str_invalid() -> None: + """Test CameraQuality.from_str error handling for invalid values.""" + with pytest.raises(ValueError, match="Invalid camera quality"): + CameraQuality.from_str("4K") + + +# ============================================================================ +# CameraTurnServer tests +# ============================================================================ + + +def test_camera_turn_server_from_device_rpc_list() -> None: + """Test parsing get_turn_server response from single-item list.""" + payload = [{"url": "turn:turn-us.roborock.com:3478", "user": "roborock_user", "pwd": "secret_password"}] + turn = CameraTurnServer.from_dict(payload) + + assert turn.urls == ["turn:turn-us.roborock.com:3478"] + assert turn.username == "roborock_user" + assert turn.credential == "secret_password" + + # Verify credential redaction in repr + assert "secret_password" not in repr(turn) + + rtc_dict = turn.as_dict() + assert rtc_dict == { + "urls": ["turn:turn-us.roborock.com:3478"], + "username": "roborock_user", + "credential": "secret_password", + } + + +def test_camera_turn_server_from_device_rpc_dict() -> None: + """Test parsing get_turn_server response from bare dict.""" + payload = {"url": "turn:turn-eu.roborock.com:3478", "user": "eu_user", "pwd": "eu_password"} + turn = CameraTurnServer.from_dict(payload) + + assert turn.urls == ["turn:turn-eu.roborock.com:3478"] + assert turn.username == "eu_user" + assert turn.credential == "eu_password" + assert "eu_password" not in repr(turn) + + +def test_camera_turn_server_from_standard_webrtc_dict() -> None: + """Test parsing standard WebRTC RTCIceServer dict format.""" + payload = { + "urls": ["stun:stun.l.google.com:19302", "turns:turn.example.com:443"], + "username": "user123", + "credential": "pass123", + } + turn = CameraTurnServer.from_dict(payload) + + assert turn.urls == ["stun:stun.l.google.com:19302", "turns:turn.example.com:443"] + assert turn.username == "user123" + assert turn.credential == "pass123" + + +def test_camera_turn_server_without_credentials() -> None: + """Test TURN server with optional credentials absent.""" + payload = {"urls": "turn:turn.example.com:3478"} + turn = CameraTurnServer.from_dict(payload) + + assert turn.urls == ["turn:turn.example.com:3478"] + assert turn.username is None + assert turn.credential is None + assert turn.as_dict() == {"urls": ["turn:turn.example.com:3478"]} + + +def test_camera_turn_server_invalid_payloads() -> None: + """Test invalid payloads raise appropriate errors.""" + with pytest.raises(ValueError, match="Empty TURN server data list"): + CameraTurnServer.from_dict([]) + + with pytest.raises(ValueError, match="Missing 'url' or 'urls'"): + CameraTurnServer.from_dict({"user": "user_only"}) + + with pytest.raises(TypeError, match="Expected str or list for urls"): + CameraTurnServer.from_dict({"urls": 12345}) + + with pytest.raises(ValueError, match="non-empty string"): + CameraTurnServer.from_dict({"urls": [""]}) + + with pytest.raises(ValueError, match="non-empty string"): + CameraTurnServer.from_dict({"urls": [None]}) + + with pytest.raises(ValueError, match="Invalid ICE server URL scheme"): + CameraTurnServer.from_dict({"url": "http://invalidscheme.com"}) + + with pytest.raises(ValueError, match="TURN server URL cannot be empty"): + CameraTurnServer.from_dict({"url": ""}) + + with pytest.raises(ValueError, match="TURN server 'urls' list cannot be empty"): + CameraTurnServer.from_dict({"urls": []}) + + with pytest.raises(TypeError, match="Expected str for username"): + CameraTurnServer.from_dict({"url": "turn:example.com", "user": 123}) + + with pytest.raises(TypeError, match="Expected str for credential"): + CameraTurnServer.from_dict({"url": "turn:example.com", "pwd": 456}) + + +@pytest.mark.parametrize("model", [CameraIceCandidate, CameraQuality, CameraSdpDescription, CameraTurnServer]) +def test_camera_models_defined_in_data_module(model: type) -> None: + """Camera models are defined in their discoverable data module.""" + assert model.__module__ == "roborock.data.camera" + + +@pytest.mark.parametrize("include_optional_fields", [False, True]) +def test_camera_ice_candidate_model_roundtrip(include_optional_fields: bool) -> None: + """The inherited serializer preserves optional values and arbitrary ICE extensions.""" + candidate = CameraIceCandidate( + foundation="1", + component=1, + protocol="tcp" if include_optional_fields else "udp", + priority=100, + ip="1.2.3.4", + port=5000, + candidate_type="srflx" if include_optional_fields else "host", + related_address="10.0.0.1" if include_optional_fields else None, + related_port=4000 if include_optional_fields else None, + tcp_type="active" if include_optional_fields else None, + sdp_mid="video" if include_optional_fields else None, + sdp_mline_index=0 if include_optional_fields else None, + extensions={"generation": "0", "network-id": "1"} if include_optional_fields else {}, + ) + serialized = candidate.as_dict() + assert isinstance(candidate, RoborockBase) + assert serialized["candidateType"] == candidate.candidate_type + assert serialized["extensions"] == candidate.extensions + if include_optional_fields: + assert serialized["relatedAddress"] == "10.0.0.1" + assert serialized["relatedPort"] == 4000 + assert serialized["tcpType"] == "active" + assert serialized["sdpMid"] == "video" + assert serialized["sdpMlineIndex"] == 0 + else: + assert "relatedAddress" not in serialized + assert "relatedPort" not in serialized + assert "tcpType" not in serialized + assert "sdpMid" not in serialized + assert "sdpMlineIndex" not in serialized + assert CameraIceCandidate.from_dict(serialized) == candidate + + +def test_camera_ice_candidate_from_dict_converts_field_types() -> None: + """Inherited deserialization uses concrete annotations for nested and optional values.""" + candidate = CameraIceCandidate.from_dict( + { + "foundation": "1", + "component": "1", + "protocol": "udp", + "priority": "100", + "ip": "1.2.3.4", + "port": "5000", + "candidateType": "host", + "sdpMlineIndex": "0", + "extensions": {"generation": 0}, + } + ) + assert candidate.component == 1 + assert candidate.priority == 100 + assert candidate.port == 5000 + assert candidate.sdp_mline_index == 0 + assert candidate.extensions == {"generation": "0"} + + +@pytest.mark.parametrize("sdp_mid, sdp_mline_index", [(None, None), ("video", 1)]) +def test_camera_ice_candidate_signaling_dict(sdp_mid: str | None, sdp_mline_index: int | None) -> None: + """Signaling uses protocol field names and default media identifiers.""" + candidate = CameraIceCandidate( + foundation="1", + component=1, + protocol="UDP", + priority=100, + ip="1.2.3.4", + port=5000, + candidate_type="HOST", + sdp_mid=sdp_mid, + sdp_mline_index=sdp_mline_index, + ) + assert candidate.to_sdp() == "candidate:1 1 udp 100 1.2.3.4 5000 typ host" + assert str(candidate) == candidate.to_sdp() + assert candidate.to_signaling_dict() == { + "candidate": candidate.to_sdp(), + "sdpMid": sdp_mid if sdp_mid is not None else "0", + "sdpMLineIndex": sdp_mline_index if sdp_mline_index is not None else 0, + } + + +def test_camera_ice_candidate_sdp_optional_attributes() -> None: + """Formatting preserves related address, TCP type, and extension attributes.""" + candidate = CameraIceCandidate( + foundation="1", + component=1, + protocol="tcp", + priority=100, + ip="1.2.3.4", + port=5000, + candidate_type="srflx", + related_address="10.0.0.1", + related_port=4000, + tcp_type="ACTIVE", + extensions={"generation": "0", "network-id": "1"}, + ) + assert candidate.to_sdp() == ( + "candidate:1 1 tcp 100 1.2.3.4 5000 typ srflx " + "raddr 10.0.0.1 rport 4000 tcptype active generation 0 network-id 1" + ) + candidate.port = 9999 + assert "1.2.3.4 9999 typ srflx" in candidate.to_sdp() + + +@pytest.mark.parametrize("description_type", ["offer", "answer"]) +def test_camera_sdp_description_roundtrip(description_type: str) -> None: + """SDP results use the common typed model serialization contract.""" + description = CameraSdpDescription(sdp="v=0\r\n", type=description_type) + assert isinstance(description, RoborockBase) + assert description.as_dict() == {"sdp": "v=0\r\n", "type": description_type} + assert CameraSdpDescription.from_dict(description.as_dict()) == description + + +def test_camera_turn_server_as_dict_roundtrip() -> None: + """The inherited serializer exports standard WebRTC configuration without alias helpers.""" + server = CameraTurnServer( + urls=["turn:turn.example.com:3478"], + username="user", + credential="secret_password", + ) + serialized = server.as_dict() + assert serialized == { + "urls": ["turn:turn.example.com:3478"], + "username": "user", + "credential": "secret_password", + } + assert CameraTurnServer.from_dict(serialized) == server + assert "secret_password" not in repr(server) + serialized["urls"].append("stun:stun.example.com:3478") + assert server.urls == ["turn:turn.example.com:3478"] diff --git a/tests/protocols/test_camera_signaling.py b/tests/protocols/test_camera_signaling.py new file mode 100644 index 000000000..43eddb776 --- /dev/null +++ b/tests/protocols/test_camera_signaling.py @@ -0,0 +1,479 @@ +"""Tests for camera signaling parsing, hashing, and wire payload serialization.""" + +import base64 +import binascii +import json +from typing import Any + +import pytest + +from roborock.data.camera import CameraIceCandidate, CameraSdpDescription +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, + parse_ice_candidate, +) +from roborock.roborock_typing import RoborockCommand + +HOST_CANDIDATE = "candidate:4234997325 1 udp 2122260223 192.168.1.100 54321 typ host" + + +def encode_wire_json(payload: Any) -> str: + """Encode a dynamic device payload independently from the signaling encoder.""" + return base64.b64encode(json.dumps(payload).encode("utf-8")).decode("ascii") + + +def test_parse_ice_candidate_host_udp() -> None: + """Parse the standard host candidate and its media identifiers.""" + candidate = parse_ice_candidate(HOST_CANDIDATE, sdp_mid="video", sdp_mline_index=1) + + assert candidate == CameraIceCandidate( + foundation="4234997325", + component=1, + protocol="udp", + priority=2122260223, + ip="192.168.1.100", + port=54321, + candidate_type="host", + sdp_mid="video", + sdp_mline_index=1, + ) + assert candidate.to_sdp() == HOST_CANDIDATE + + +@pytest.mark.parametrize( + ("candidate_string", "expected"), + [ + ( + "candidate:123456 1 udp 1686052607 203.0.113.1 54321 typ srflx raddr 192.168.1.100 rport 54321", + CameraIceCandidate( + foundation="123456", + component=1, + protocol="udp", + priority=1686052607, + ip="203.0.113.1", + port=54321, + candidate_type="srflx", + related_address="192.168.1.100", + related_port=54321, + ), + ), + ( + "candidate:789012 1 udp 33562367 198.51.100.2 3478 typ relay raddr 203.0.113.1 rport 54321", + CameraIceCandidate( + foundation="789012", + component=1, + protocol="udp", + priority=33562367, + ip="198.51.100.2", + port=3478, + candidate_type="relay", + related_address="203.0.113.1", + related_port=54321, + ), + ), + ( + "candidate:345678 1 tcp 1518280447 192.168.1.100 9 typ host tcptype active", + CameraIceCandidate( + foundation="345678", + component=1, + protocol="tcp", + priority=1518280447, + ip="192.168.1.100", + port=9, + candidate_type="host", + tcp_type="active", + ), + ), + ], +) +def test_parse_ice_candidate_types(candidate_string: str, expected: CameraIceCandidate) -> None: + """Preserve related address and TCP attributes when parsing and serializing.""" + candidate = parse_ice_candidate(candidate_string) + + assert candidate == expected + assert candidate.to_sdp() == candidate_string + + +@pytest.mark.parametrize("address", ["2001:db8::1", "d79c09bf-323e-4610-8f92-5eb3fa7e4a1a.local"]) +def test_parse_ice_candidate_addresses(address: str) -> None: + """Support IPv6 and mDNS names without changing the address.""" + candidate_string = f"candidate:1 1 udp 2122260223 {address} 50000 typ host" + candidate = parse_ice_candidate(candidate_string) + + assert candidate.ip == address + assert candidate.to_sdp() == candidate_string + + +@pytest.mark.parametrize("prefix", ["", "candidate:", "a=candidate:"]) +def test_parse_ice_candidate_prefixes_and_extensions(prefix: str) -> None: + """Allow SDP prefixes and CRLF while preserving extension attributes.""" + candidate = parse_ice_candidate(f"{prefix}1 1 UDP 100 1.2.3.4 5000 typ HOST generation 0 network-id 1\r\n") + + assert candidate.foundation == "1" + assert candidate.protocol == "udp" + assert candidate.candidate_type == "host" + assert candidate.extensions == {"generation": "0", "network-id": "1"} + + +@pytest.mark.parametrize("component", [1, 256]) +@pytest.mark.parametrize("priority", [1, 2147483647]) +@pytest.mark.parametrize("port", [0, 65535]) +def test_parse_ice_candidate_numeric_bounds(component: int, priority: int, port: int) -> None: + """Accept the inclusive component, priority, and port limits.""" + candidate = parse_ice_candidate(f"candidate:1 {component} udp {priority} 1.1.1.1 {port} typ host") + + assert candidate.component == component + assert candidate.priority == priority + assert candidate.port == port + + +@pytest.mark.parametrize( + ("candidate_string", "message"), + [ + ("", "Empty ICE candidate"), + (" \r\n", "Empty ICE candidate"), + ("candidate:1 1 udp", "expected at least 8 tokens"), + ("candidate:1 0 udp 100 1.1.1.1 200 typ host", "RFC 8839 range"), + ("candidate:1 257 udp 100 1.1.1.1 200 typ host", "RFC 8839 range"), + ("candidate:1 +1 udp 100 1.1.1.1 200 typ host", "ASCII decimal digits"), + ("candidate:1 1 udp 100 1.1.1.1 200 typ host", "ASCII decimal digits"), + ("candidate:1 1 udp 0 1.1.1.1 200 typ host", "RFC 8445 range"), + ("candidate:1 1 udp 2147483648 1.1.1.1 200 typ host", "RFC 8445 range"), + ("candidate:1 1 udp 1_000 1.1.1.1 200 typ host", "ASCII decimal digits"), + (f"candidate:{'a' * 33} 1 udp 100 1.1.1.1 200 typ host", "Invalid ICE foundation"), + ("candidate:invalid@char 1 udp 100 1.1.1.1 200 typ host", "Invalid ICE foundation"), + ("candidate:1 1 udp 100 1.1.1.1 -1 typ host", "ASCII decimal digits"), + ("candidate:1 1 udp 100 1.1.1.1 65536 typ host", "port.*out of valid range"), + ("candidate:1 1 udp 100 1.1.1.1 200 other host", "expected 'typ' keyword"), + ("candidate:1 1 udp 100 1.1.1.1 200 typ host dangling_token", "odd number of tokens"), + ("candidate:1 1 tcp 100 1.1.1.1 9 typ host", "TCP ICE candidate must specify 'tcptype'"), + ("candidate:1 1 tcp 100 1.1.1.1 9 typ host tcptype invalid", "Invalid ICE TCP type"), + ("candidate:1 1 udp 100 1.1.1.1 200 typ srflx raddr 2.2.2.2", "must have both 'raddr' and 'rport'"), + ("candidate:1 1 udp 100 1.1.1.1 200 typ srflx rport 5000", "must have both 'raddr' and 'rport'"), + ("candidate:1 1 udp 100 1.1.1.1 200 typ srflx raddr 2.2.2.2 rport x", "ASCII decimal digits"), + ("candidate:1 1 udp 100 1.1.1.1 200 typ srflx raddr 2.2.2.2 rport 65536", "port.*out of valid range"), + ], +) +def test_parse_ice_candidate_invalid_values(candidate_string: str, message: str) -> None: + """Reject invalid candidate values and RFC invariant violations.""" + with pytest.raises(ValueError, match=message): + parse_ice_candidate(candidate_string) + + +@pytest.mark.parametrize( + ("password", "expected"), + [ + ("1234", "81dc9bdb52d04dc20036dbd8313ed055"), + ("9876", "912e79cd13c64069d91da65d62fbb78c"), + ("0123", "eb62f6b9306db575c2d596b1279627a4"), + (" 1234 ", "81dc9bdb52d04dc20036dbd8313ed055"), + ], +) +def test_hash_camera_password_valid(password: str, expected: str) -> None: + """Hash ASCII PIN digits, preserving leading zeros and trimming whitespace.""" + assert hash_camera_password(password) == expected + + +@pytest.mark.parametrize("password", ["", " ", "12a4", "1234", "password", "12 34"]) +def test_hash_camera_password_invalid(password: str) -> None: + """Reject empty passwords and non-ASCII PIN digits.""" + with pytest.raises(ValueError): + hash_camera_password(password) + + +def test_hash_camera_password_error_redacts_password() -> None: + """Keep invalid camera passwords out of error messages and logs.""" + password = "private-camera-password" + with pytest.raises(ValueError) as error: + hash_camera_password(password) + + assert password not in str(error.value) + + +def test_encode_sdp_offer_preserves_crlf() -> None: + """Package the exact SDP string and the offer type as base64 JSON.""" + sdp = "v=0\r\no=- 12345 2 IN IP4 127.0.0.1\r\ns=-\r\nt=0 0\r\nm=video 9 UDP/TLS/RTP/SAVPF 96\r\n" + + assert json.loads(base64.b64decode(encode_sdp_offer(sdp))) == {"sdp": sdp, "type": "offer"} + + +@pytest.mark.parametrize("sdp", ["", " ", "\r\n"]) +def test_encode_sdp_offer_empty(sdp: str) -> None: + """Reject an offer without SDP content.""" + with pytest.raises(ValueError, match="cannot be empty"): + encode_sdp_offer(sdp) + + +@pytest.mark.parametrize("answer_type", ["answer", "pranswer", "ANSWER", "PRANSWER"]) +def test_decode_sdp_answer_supported_formats(answer_type: str) -> None: + """Return a typed description from each supported device response envelope.""" + sdp = "v=0\r\no=- 67890 2 IN IP4 127.0.0.1\r\ns=-\r\nm=video 9 UDP/TLS/RTP/SAVPF 96\r\n" + description = {"sdp": sdp, "type": answer_type} + encoded = encode_wire_json(description) + expected = CameraSdpDescription(sdp=sdp, type=answer_type.lower()) + + assert decode_sdp_answer(encoded) == expected + assert decode_sdp_answer({"dev_sdp": encoded}) == expected + assert decode_sdp_answer([{"dev_sdp": encoded}]) == expected + assert decode_sdp_answer(description) == expected + + +def test_decode_sdp_answer_default_type() -> None: + """Use the answer type when it is absent from the device response.""" + assert decode_sdp_answer({"sdp": "v=0\r\n"}) == CameraSdpDescription(sdp="v=0\r\n", type="answer") + assert decode_sdp_answer(encode_wire_json({"sdp": "v=0\r\n"})) == CameraSdpDescription(sdp="v=0\r\n", type="answer") + + +@pytest.mark.parametrize("payload", ["retry", {"dev_sdp": "retry"}, [{"dev_sdp": "retry"}]]) +def test_decode_sdp_answer_retry(payload: str | dict[str, Any] | list[Any]) -> None: + """Leave retry handling explicit for the caller.""" + assert decode_sdp_answer(payload) is None + + +@pytest.mark.parametrize( + ("payload", "error"), + [ + ([], ValueError), + (["not_dict"], TypeError), + ({"invalid_key": "abc"}, ValueError), + ({"dev_sdp": 12345}, TypeError), + ("invalid_base64!@#$", binascii.Error), + (base64.b64encode(b"not json").decode("ascii"), json.JSONDecodeError), + (base64.b64encode(b"\xff").decode("ascii"), UnicodeDecodeError), + (encode_wire_json([1, 2, 3]), ValueError), + (encode_wire_json({"type": "answer"}), ValueError), + (encode_wire_json({"sdp": ""}), ValueError), + (encode_wire_json({"sdp": 123}), ValueError), + (encode_wire_json({"sdp": "v=0", "type": "offer"}), ValueError), + (encode_wire_json({"sdp": "v=0", "type": 1}), ValueError), + ({"sdp": "v=0", "type": "offer"}, ValueError), + ({"sdp": "", "type": "answer"}, ValueError), + ], +) +def test_decode_sdp_answer_invalid_payloads(payload: str | dict[str, Any] | list[Any], error: type[Exception]) -> None: + """Normalize malformed responses while preserving command context and the cause.""" + with pytest.raises(RoborockParsingException) as raised: + decode_sdp_answer(payload) + + assert isinstance(raised.value, RoborockException) + assert isinstance(raised.value.__cause__, error) + assert RoborockCommand.GET_DEVICE_SDP.name in str(raised.value) + assert "CameraSignaling" in str(raised.value) + assert f"'type': '{type(payload).__name__}'" in str(raised.value) + + +@pytest.mark.parametrize("encoded", [False, True]) +def test_decode_sdp_answer_error_redacts_response(encoded: bool, caplog: pytest.LogCaptureFixture) -> None: + """Do not put SDP credentials or malformed type values in public errors.""" + secret = "private-camera-secret" + description = {"sdp": f"v=0\r\na=ice-pwd:{secret}\r\n", "type": secret} + encoded_description = encode_wire_json(description) + payload: str | dict[str, Any] = encoded_description if encoded else description + + with pytest.raises(RoborockParsingException) as raised: + decode_sdp_answer(payload) + + assert isinstance(raised.value, RoborockException) + assert isinstance(raised.value.__cause__, ValueError) + assert RoborockCommand.GET_DEVICE_SDP.name in str(raised.value) + assert secret not in str(raised.value) + assert secret not in repr(raised.value) + assert secret not in caplog.text + assert encoded_description not in str(raised.value) + + +def test_encode_ice_candidate_roundtrip() -> None: + """Package a typed candidate and decode each supported response envelope.""" + candidate = parse_ice_candidate(HOST_CANDIDATE, sdp_mid="video", sdp_mline_index=1) + encoded = encode_ice_candidate(candidate) + + assert json.loads(base64.b64decode(encoded)) == { + "candidate": HOST_CANDIDATE, + "sdpMid": "video", + "sdpMLineIndex": 1, + } + assert decode_ice_candidates(encoded) == [candidate] + assert decode_ice_candidates([encoded]) == [candidate] + assert decode_ice_candidates({"dev_ice": [encoded]}) == [candidate] + assert decode_ice_candidates([{"dev_ice": [encoded]}]) == [candidate] + + +def test_encode_ice_candidate_default_media_identifiers() -> None: + """Use the protocol's default media identifiers when the model omits them.""" + encoded = encode_ice_candidate(parse_ice_candidate(HOST_CANDIDATE)) + + assert json.loads(base64.b64decode(encoded)) == { + "candidate": HOST_CANDIDATE, + "sdpMid": "0", + "sdpMLineIndex": 0, + } + + +def test_decode_ice_candidates_empty_or_multiple() -> None: + """Preserve order and support empty candidate responses.""" + assert decode_ice_candidates([]) == [] + assert decode_ice_candidates({"dev_ice": []}) == [] + assert decode_ice_candidates([{"dev_ice": []}]) == [] + + first = parse_ice_candidate(HOST_CANDIDATE, sdp_mid="0", sdp_mline_index=0) + second = parse_ice_candidate("candidate:2 1 udp 200 10.0.0.2 5000 typ host", sdp_mid="0", sdp_mline_index=0) + encoded = [encode_ice_candidate(first), encode_ice_candidate(second)] + + assert decode_ice_candidates([{"dev_ice": encoded}]) == [first, second] + + +def test_decode_ice_candidates_no_base64_prefix_heuristic() -> None: + """Decode valid base64 JSON even when its first bytes are whitespace.""" + wire_json = " \n" + json.dumps({"candidate": HOST_CANDIDATE, "sdpMid": "video", "sdpMLineIndex": 1}) + encoded = base64.b64encode(wire_json.encode("utf-8")).decode("ascii") + assert not encoded.startswith("ey") + + assert decode_ice_candidates(encoded) == [parse_ice_candidate(HOST_CANDIDATE, sdp_mid="video", sdp_mline_index=1)] + + +def test_decode_ice_candidates_optional_media_identifiers() -> None: + """Leave omitted media identifiers unset when receiving candidates.""" + encoded = encode_wire_json({"candidate": HOST_CANDIDATE}) + + assert decode_ice_candidates(encoded) == [parse_ice_candidate(HOST_CANDIDATE)] + encoded_nulls = encode_wire_json({"candidate": HOST_CANDIDATE, "sdpMid": None, "sdpMLineIndex": None}) + assert decode_ice_candidates(encoded_nulls) == [parse_ice_candidate(HOST_CANDIDATE)] + + +@pytest.mark.parametrize( + ("payload", "error"), + [ + pytest.param("invalid_base64!@#$", binascii.Error, id="invalid-base64"), + pytest.param("YWJjZA", binascii.Error, id="missing-base64-padding"), + pytest.param(HOST_CANDIDATE, ValueError, id="raw-sdp"), + pytest.param("", ValueError, id="empty-string"), + pytest.param(" ", ValueError, id="whitespace-string"), + pytest.param(base64.b64encode(b"\xff").decode("ascii"), UnicodeDecodeError, id="invalid-utf8"), + pytest.param(base64.b64encode(b"not json").decode("ascii"), json.JSONDecodeError, id="invalid-json"), + pytest.param(base64.b64encode(b'{"candidate":').decode("ascii"), json.JSONDecodeError, id="truncated-json"), + pytest.param(encode_wire_json([1, 2, 3]), ValueError, id="json-list"), + pytest.param(encode_wire_json(None), ValueError, id="json-null"), + pytest.param(encode_wire_json({"sdpMid": "0"}), ValueError, id="missing-candidate"), + pytest.param(encode_wire_json({"candidate": None}), ValueError, id="null-candidate"), + pytest.param(encode_wire_json({"candidate": ""}), ValueError, id="empty-candidate"), + pytest.param(encode_wire_json({"candidate": "not a candidate"}), ValueError, id="malformed-candidate"), + pytest.param(encode_wire_json({"candidate": HOST_CANDIDATE, "sdpMid": 1}), TypeError, id="numeric-mid"), + pytest.param(encode_wire_json({"candidate": HOST_CANDIDATE, "sdpMid": ["0"]}), TypeError, id="list-mid"), + pytest.param( + encode_wire_json({"candidate": HOST_CANDIDATE, "sdpMLineIndex": "1"}), TypeError, id="string-mline" + ), + pytest.param(encode_wire_json({"candidate": HOST_CANDIDATE, "sdpMLineIndex": [1]}), TypeError, id="list-mline"), + pytest.param( + encode_wire_json({"candidate": HOST_CANDIDATE, "sdpMLineIndex": True}), TypeError, id="bool-mline" + ), + pytest.param( + encode_wire_json({"candidate": HOST_CANDIDATE, "sdpMLineIndex": 1.5}), TypeError, id="float-mline" + ), + pytest.param( + encode_wire_json({"candidate": HOST_CANDIDATE, "sdpMLineIndex": -1}), ValueError, id="negative-mline" + ), + ], +) +def test_decode_ice_candidates_invalid_wire_data(payload: str, error: type[Exception]) -> None: + """Normalize invalid base64, UTF-8, JSON, schema, and candidate errors.""" + with pytest.raises(RoborockParsingException) as raised: + decode_ice_candidates(payload) + + assert isinstance(raised.value, RoborockException) + assert isinstance(raised.value.__cause__, error) + assert RoborockCommand.GET_DEVICE_ICE.name in str(raised.value) + assert "CameraSignaling" in str(raised.value) + assert "'type': 'str'" in str(raised.value) + assert "'candidate_index': 0" in str(raised.value) + + +@pytest.mark.parametrize( + "payload", + [ + {"candidate": HOST_CANDIDATE}, + [{"candidate": HOST_CANDIDATE, "sdpMid": "0", "sdpMLineIndex": 0}], + {"dev_ice": "not_a_list"}, + {"dev_ice": [123]}, + [123], + [parse_ice_candidate(HOST_CANDIDATE)], + [{"dev_ice": []}, {"dev_ice": []}], + ], +) +def test_decode_ice_candidates_rejects_non_wire_inputs(payload: dict[str, Any] | list[Any]) -> None: + """Do not accept unencoded models, candidate dictionaries, or malformed envelopes.""" + with pytest.raises(RoborockParsingException) as raised: + decode_ice_candidates(payload) + + assert isinstance(raised.value, RoborockException) + assert isinstance(raised.value.__cause__, (ValueError, TypeError)) + assert RoborockCommand.GET_DEVICE_ICE.name in str(raised.value) + assert f"'type': '{type(payload).__name__}'" in str(raised.value) + + +def test_decode_ice_candidates_ignore_invalid(caplog: pytest.LogCaptureFixture) -> None: + """Skip malformed candidate items and retain all valid candidates in order.""" + first = parse_ice_candidate(HOST_CANDIDATE, sdp_mid="video", sdp_mline_index=1) + second = parse_ice_candidate("candidate:2 1 udp 200 10.0.0.2 5000 typ host", sdp_mid="video", sdp_mline_index=1) + payload = [ + "not base64!", + encode_ice_candidate(first), + base64.b64encode(b"\xff").decode("ascii"), + encode_wire_json([1, 2, 3]), + encode_wire_json({"sdpMid": "0"}), + encode_wire_json({"candidate": "not a candidate"}), + encode_wire_json({"candidate": HOST_CANDIDATE, "sdpMLineIndex": [1]}), + encode_ice_candidate(second), + ] + + assert decode_ice_candidates({"dev_ice": payload}, ignore_invalid=True) == [first, second] + assert any("Skipping" in record.message for record in caplog.records) + + +def test_decode_ice_candidates_ignore_invalid_does_not_hide_envelope_errors() -> None: + """Candidate leniency does not allow an invalid RPC response structure.""" + with pytest.raises(RoborockParsingException) as raised: + decode_ice_candidates({"dev_ice": "not_a_list"}, ignore_invalid=True) + + assert isinstance(raised.value, RoborockException) + assert isinstance(raised.value.__cause__, TypeError) + assert RoborockCommand.GET_DEVICE_ICE.name in str(raised.value) + assert "'type': 'dict'" in str(raised.value) + + +def test_decode_ice_candidates_error_identifies_candidate_and_redacts_response( + caplog: pytest.LogCaptureFixture, +) -> None: + """Locate a malformed candidate without exposing its raw SDP in the error.""" + secret = "private-camera-secret" + invalid = encode_wire_json({"candidate": f"{HOST_CANDIDATE} {secret}"}) + payload = [encode_wire_json({"candidate": HOST_CANDIDATE}), invalid] + + with pytest.raises(RoborockParsingException) as raised: + decode_ice_candidates(payload) + + assert isinstance(raised.value, RoborockException) + assert isinstance(raised.value.__cause__, ValueError) + assert RoborockCommand.GET_DEVICE_ICE.name in str(raised.value) + assert "'type': 'list'" in str(raised.value) + assert "'candidate_index': 1" in str(raised.value) + assert secret not in str(raised.value) + assert secret not in repr(raised.value) + assert invalid not in str(raised.value) + assert secret not in caplog.text + + +def test_decode_ice_candidates_ignore_invalid_log_redacts_response(caplog: pytest.LogCaptureFixture) -> None: + """Keep skipped candidate diagnostics useful without logging candidate secrets.""" + secret = "private-camera-secret" + invalid = encode_wire_json({"candidate": f"{HOST_CANDIDATE} {secret}"}) + valid = encode_wire_json({"candidate": HOST_CANDIDATE}) + + assert decode_ice_candidates([invalid, valid], ignore_invalid=True) == [parse_ice_candidate(HOST_CANDIDATE)] + assert any("Skipping" in record.message for record in caplog.records) + assert secret not in caplog.text + assert invalid not in caplog.text diff --git a/tests/test_camera_signaling.py b/tests/test_camera_signaling.py deleted file mode 100644 index fdcbe8598..000000000 --- a/tests/test_camera_signaling.py +++ /dev/null @@ -1,731 +0,0 @@ -"""Unit tests for camera signaling models, ICE parsing utilities, and payload serialization.""" - -from __future__ import annotations - -import base64 -import json - -import pytest - -from roborock.camera_signaling import ( - CameraIceCandidate, - CameraQuality, - CameraTurnServer, - decode_ice_candidates, - decode_sdp_answer, - encode_ice_candidate, - encode_sdp_offer, - hash_camera_password, - parse_ice_candidate, -) -from roborock.data import ( - CameraIceCandidate as DataCameraIceCandidate, -) -from roborock.data import ( - CameraQuality as DataCameraQuality, -) -from roborock.data import ( - CameraTurnServer as DataCameraTurnServer, -) -from roborock.data.camera import ( - CameraIceCandidate as CameraModIceCandidate, -) -from roborock.data.camera import ( - CameraQuality as CameraModQuality, -) -from roborock.data.camera import ( - CameraTurnServer as CameraModTurnServer, -) - -# ============================================================================ -# Re-export and import smoke tests -# ============================================================================ - - -def test_reexports_match() -> None: - """Verify camera signaling items are correctly re-exported across modules.""" - assert DataCameraQuality is CameraQuality - assert DataCameraTurnServer is CameraTurnServer - assert DataCameraIceCandidate is CameraIceCandidate - - assert CameraModQuality is CameraQuality - assert CameraModTurnServer is CameraTurnServer - assert CameraModIceCandidate is CameraIceCandidate - - -# ============================================================================ -# CameraQuality tests -# ============================================================================ - - -def test_camera_quality_values() -> None: - """Test CameraQuality enum values and string behavior.""" - assert CameraQuality.HD == "HD" - assert CameraQuality.SD == "SD" - assert CameraQuality.FHD == "FHD" - assert CameraQuality.AUTO == "auto" - assert str(CameraQuality.HD) == "HD" - assert CameraQuality("HD") is CameraQuality.HD - assert CameraQuality("SD") is CameraQuality.SD - assert CameraQuality("FHD") is CameraQuality.FHD - assert CameraQuality("auto") is CameraQuality.AUTO - - -@pytest.mark.parametrize( - ("input_str", "expected"), - [ - ("hd", CameraQuality.HD), - ("HD", CameraQuality.HD), - ("sd", CameraQuality.SD), - (" SD ", CameraQuality.SD), - ("fhd", CameraQuality.FHD), - ("FHD", CameraQuality.FHD), - ("auto", CameraQuality.AUTO), - ("AUTO", CameraQuality.AUTO), - (" auto ", CameraQuality.AUTO), - ], -) -def test_camera_quality_from_str(input_str: str, expected: CameraQuality) -> None: - """Test CameraQuality.from_str case insensitivity and whitespace trimming.""" - assert CameraQuality.from_str(input_str) is expected - - -def test_camera_quality_from_str_invalid() -> None: - """Test CameraQuality.from_str error handling for invalid values.""" - with pytest.raises(ValueError, match="Invalid camera quality"): - CameraQuality.from_str("4K") - - with pytest.raises(TypeError, match="Expected str"): - CameraQuality.from_str(123) # type: ignore[arg-type] - - -# ============================================================================ -# CameraTurnServer tests -# ============================================================================ - - -def test_camera_turn_server_from_device_rpc_list() -> None: - """Test parsing get_turn_server response from single-item list.""" - payload = [{"url": "turn:turn-us.roborock.com:3478", "user": "roborock_user", "pwd": "secret_password"}] - turn = CameraTurnServer.from_dict(payload) - - assert turn.urls == ["turn:turn-us.roborock.com:3478"] - assert turn.username == "roborock_user" - assert turn.credential == "secret_password" - - # Verify credential redaction in repr - assert "secret_password" not in repr(turn) - - rtc_dict = turn.to_dict() - assert rtc_dict == { - "urls": ["turn:turn-us.roborock.com:3478"], - "username": "roborock_user", - "credential": "secret_password", - } - - -def test_camera_turn_server_from_device_rpc_dict() -> None: - """Test parsing get_turn_server response from bare dict.""" - payload = {"url": "turn:turn-eu.roborock.com:3478", "user": "eu_user", "pwd": "eu_password"} - turn = CameraTurnServer.from_dict(payload) - - assert turn.urls == ["turn:turn-eu.roborock.com:3478"] - assert turn.username == "eu_user" - assert turn.credential == "eu_password" - assert "eu_password" not in repr(turn) - - -def test_camera_turn_server_from_standard_webrtc_dict() -> None: - """Test parsing standard WebRTC RTCIceServer dict format.""" - payload = { - "urls": ["stun:stun.l.google.com:19302", "turns:turn.example.com:443"], - "username": "user123", - "credential": "pass123", - } - turn = CameraTurnServer.from_dict(payload) - - assert turn.urls == ["stun:stun.l.google.com:19302", "turns:turn.example.com:443"] - assert turn.username == "user123" - assert turn.credential == "pass123" - - -def test_camera_turn_server_without_credentials() -> None: - """Test TURN server with optional credentials absent.""" - payload = {"urls": "turn:turn.example.com:3478"} - turn = CameraTurnServer.from_dict(payload) - - assert turn.urls == ["turn:turn.example.com:3478"] - assert turn.username is None - assert turn.credential is None - assert turn.to_dict() == {"urls": ["turn:turn.example.com:3478"]} - - -def test_camera_turn_server_invalid_payloads() -> None: - """Test invalid payloads raise appropriate errors.""" - with pytest.raises(ValueError, match="Empty TURN server data list"): - CameraTurnServer.from_dict([]) - - with pytest.raises(TypeError, match="Expected dict inside TURN server list"): - CameraTurnServer.from_dict(["not_a_dict"]) # type: ignore[list-item] - - with pytest.raises(TypeError, match="Expected dict or list"): - CameraTurnServer.from_dict("not_a_dict_or_list") # type: ignore[arg-type] - - with pytest.raises(ValueError, match="Missing 'url' or 'urls'"): - CameraTurnServer.from_dict({"user": "user_only"}) - - with pytest.raises(TypeError, match="Expected str or list for urls"): - CameraTurnServer.from_dict({"urls": 12345}) # type: ignore[arg-type] - - with pytest.raises(ValueError, match="non-empty string"): - CameraTurnServer.from_dict({"urls": [""]}) - - with pytest.raises(ValueError, match="non-empty string"): - CameraTurnServer.from_dict({"urls": [None]}) # type: ignore[list-item] - - with pytest.raises(ValueError, match="Invalid ICE server URL scheme"): - CameraTurnServer.from_dict({"url": "http://invalidscheme.com"}) - - with pytest.raises(ValueError, match="TURN server URL cannot be empty"): - CameraTurnServer.from_dict({"url": ""}) - - with pytest.raises(ValueError, match="TURN server 'urls' list cannot be empty"): - CameraTurnServer.from_dict({"urls": []}) - - with pytest.raises(TypeError, match="Expected str for username"): - CameraTurnServer.from_dict({"url": "turn:example.com", "user": 123}) # type: ignore[dict-item] - - with pytest.raises(TypeError, match="Expected str for credential"): - CameraTurnServer.from_dict({"url": "turn:example.com", "pwd": 456}) # type: ignore[dict-item] - - -# ============================================================================ -# parse_ice_candidate tests -# ============================================================================ - - -def test_parse_ice_candidate_host_udp() -> None: - """Test parsing standard host UDP candidate.""" - cand_str = "candidate:4234997325 1 udp 2122260223 192.168.1.100 54321 typ host" - cand = parse_ice_candidate(cand_str, sdp_mid="0", sdp_mline_index=0) - - assert cand.foundation == "4234997325" - assert cand.component == 1 - assert cand.protocol == "udp" - assert cand.priority == 2122260223 - assert cand.ip == "192.168.1.100" - assert cand.port == 54321 - assert cand.candidate_type == "host" - assert cand.related_address is None - assert cand.related_port is None - assert cand.tcp_type is None - assert cand.sdp_mid == "0" - assert cand.sdp_mline_index == 0 - assert cand.to_sdp() == cand_str - assert str(cand) == cand_str - - -def test_parse_ice_candidate_srflx_with_raddr() -> None: - """Test parsing srflx candidate with raddr and rport.""" - cand_str = "candidate:123456 1 udp 1686052607 203.0.113.1 54321 typ srflx raddr 192.168.1.100 rport 54321" - cand = parse_ice_candidate(cand_str) - - assert cand.foundation == "123456" - assert cand.component == 1 - assert cand.protocol == "udp" - assert cand.priority == 1686052607 - assert cand.ip == "203.0.113.1" - assert cand.port == 54321 - assert cand.candidate_type == "srflx" - assert cand.related_address == "192.168.1.100" - assert cand.related_port == 54321 - assert cand.tcp_type is None - assert cand.to_sdp() == cand_str - - -def test_parse_ice_candidate_relay() -> None: - """Test parsing relay candidate.""" - cand_str = "candidate:789012 1 udp 33562367 198.51.100.2 3478 typ relay raddr 203.0.113.1 rport 54321" - cand = parse_ice_candidate(cand_str) - - assert cand.foundation == "789012" - assert cand.protocol == "udp" - assert cand.candidate_type == "relay" - assert cand.ip == "198.51.100.2" - assert cand.port == 3478 - assert cand.related_address == "203.0.113.1" - assert cand.related_port == 54321 - assert cand.to_sdp() == cand_str - - -def test_parse_ice_candidate_tcp_with_tcptype() -> None: - """Test parsing TCP candidate with tcptype.""" - cand_str = "candidate:345678 1 tcp 1518280447 192.168.1.100 9 typ host tcptype active" - cand = parse_ice_candidate(cand_str) - - assert cand.foundation == "345678" - assert cand.protocol == "tcp" - assert cand.candidate_type == "host" - assert cand.port == 9 - assert cand.tcp_type == "active" - assert cand.to_sdp() == cand_str - - -def test_parse_ice_candidate_ipv6() -> None: - """Test parsing IPv6 candidate.""" - cand_str = "candidate:1 1 udp 2122260223 2001:db8::1 50000 typ host" - cand = parse_ice_candidate(cand_str) - - assert cand.ip == "2001:db8::1" - assert cand.port == 50000 - assert cand.to_sdp() == cand_str - - -def test_parse_ice_candidate_mdns_hostname() -> None: - """Test parsing mDNS .local hostname candidate from modern browsers.""" - cand_str = "candidate:1 1 udp 2122260223 d79c09bf-323e-4610-8f92-5eb3fa7e4a1a.local 50000 typ host" - cand = parse_ice_candidate(cand_str) - - assert cand.ip == "d79c09bf-323e-4610-8f92-5eb3fa7e4a1a.local" - assert cand.to_sdp() == cand_str - - -def test_parse_ice_candidate_prefixes_and_extra_attrs() -> None: - """Test candidate with a= prefix, no prefix, CRLF, and extra SDP attributes like generation.""" - cand1 = parse_ice_candidate("a=candidate:1 1 udp 100 1.2.3.4 5000 typ host generation 0 network-id 1\r\n") - assert cand1.foundation == "1" - assert cand1.candidate_type == "host" - assert cand1.ip == "1.2.3.4" - assert cand1.extensions == {"generation": "0", "network-id": "1"} - - cand2 = parse_ice_candidate("1 1 udp 100 1.2.3.4 5000 typ host") - assert cand2.foundation == "1" - assert cand2.candidate_type == "host" - - -def test_parse_ice_candidate_numeric_and_lexical_bounds() -> None: - """Test strict RFC 8839 / RFC 8445 component, priority, and foundation bounds.""" - c1 = parse_ice_candidate("candidate:1 1 udp 100 1.1.1.1 200 typ host") - assert c1.component == 1 - c256 = parse_ice_candidate("candidate:1 256 udp 100 1.1.1.1 200 typ host") - assert c256.component == 256 - - with pytest.raises(ValueError, match="RFC 8839 range"): - parse_ice_candidate("candidate:1 0 udp 100 1.1.1.1 200 typ host") - with pytest.raises(ValueError, match="RFC 8839 range"): - parse_ice_candidate("candidate:1 257 udp 100 1.1.1.1 200 typ host") - - p1 = parse_ice_candidate("candidate:1 1 udp 1 1.1.1.1 200 typ host") - assert p1.priority == 1 - p_max = parse_ice_candidate("candidate:1 1 udp 2147483647 1.1.1.1 200 typ host") - assert p_max.priority == 2147483647 - - with pytest.raises(ValueError, match="RFC 8445 range"): - parse_ice_candidate("candidate:1 1 udp 0 1.1.1.1 200 typ host") - with pytest.raises(ValueError, match="RFC 8445 range"): - parse_ice_candidate("candidate:1 1 udp 2147483648 1.1.1.1 200 typ host") - - with pytest.raises(ValueError, match="ASCII decimal digits"): - parse_ice_candidate("candidate:1 +1 udp 100 1.1.1.1 200 typ host") - with pytest.raises(ValueError, match="ASCII decimal digits"): - parse_ice_candidate("candidate:1 1 udp 1_000 1.1.1.1 200 typ host") - - with pytest.raises(ValueError, match="Invalid ICE foundation"): - parse_ice_candidate(f"candidate:{'a' * 33} 1 udp 100 1.1.1.1 200 typ host") - with pytest.raises(ValueError, match="Invalid ICE foundation"): - parse_ice_candidate("candidate:invalid@char 1 udp 100 1.1.1.1 200 typ host") - - -def test_parse_ice_candidate_invariants() -> None: - """Test RFC 6544 TCP and RFC 8839 related address invariants.""" - with pytest.raises(ValueError, match="TCP ICE candidate must specify 'tcptype'"): - parse_ice_candidate("candidate:1 1 tcp 100 1.1.1.1 9 typ host") - - with pytest.raises(ValueError, match="Invalid ICE TCP type"): - parse_ice_candidate("candidate:1 1 tcp 100 1.1.1.1 9 typ host tcptype invalid") - - with pytest.raises(ValueError, match="must have both 'raddr' and 'rport' specified together"): - parse_ice_candidate("candidate:1 1 udp 100 1.1.1.1 200 typ srflx raddr 2.2.2.2") - - with pytest.raises(ValueError, match="must have both 'raddr' and 'rport' specified together"): - parse_ice_candidate("candidate:1 1 udp 100 1.1.1.1 200 typ srflx rport 5000") - - -def test_candidate_to_signaling_dict() -> None: - """Test converting CameraIceCandidate to signaling dict.""" - cand = CameraIceCandidate( - foundation="1", - component=1, - protocol="udp", - priority=100, - ip="1.2.3.4", - port=5000, - candidate_type="host", - sdp_mid="video", - sdp_mline_index=1, - ) - sig_dict = cand.to_signaling_dict() - assert sig_dict == { - "candidate": "candidate:1 1 udp 100 1.2.3.4 5000 typ host", - "sdpMid": "video", - "sdpMLineIndex": 1, - } - - -def test_candidate_mutation_reflected_in_to_sdp() -> None: - """Verify that mutating a parsed candidate updates to_sdp output (no stale raw cached).""" - cand = parse_ice_candidate("candidate:1 1 udp 100 192.168.1.100 5000 typ host") - assert cand.port == 5000 - assert cand.to_sdp() == "candidate:1 1 udp 100 192.168.1.100 5000 typ host" - - cand.port = 9999 - assert cand.to_sdp() == "candidate:1 1 udp 100 192.168.1.100 9999 typ host" - - -def test_candidate_equality_and_to_sdp_consistency() -> None: - """Verify candidates that compare equal produce identical to_sdp output.""" - cand1 = parse_ice_candidate("candidate:1 1 udp 100 1.2.3.4 5000 typ host") - cand2 = parse_ice_candidate("candidate:1 1 udp 100 1.2.3.4 5000 typ host") - assert cand1 == cand2 - assert cand1.to_sdp() == cand2.to_sdp() - - -# ============================================================================ -# hash_camera_password tests -# ============================================================================ - - -def test_hash_camera_password_valid() -> None: - """Test deriving lowercase hex MD5 of digits.""" - # 1234 -> 81dc9bdb52d04dc20036dbd8313ed055 - assert hash_camera_password("1234") == "81dc9bdb52d04dc20036dbd8313ed055" - # 9876 -> 912e79cd13c64069d91da65d62fbb78c - assert hash_camera_password("9876") == "912e79cd13c64069d91da65d62fbb78c" - # Leading zero in digits preserved - assert hash_camera_password("0123") == "eb62f6b9306db575c2d596b1279627a4" - - -def test_hash_camera_password_invalid() -> None: - """Test invalid password formats raise ValueError or TypeError.""" - with pytest.raises(ValueError, match="cannot be empty"): - hash_camera_password("") - - with pytest.raises(ValueError, match="only ASCII digits"): - hash_camera_password("12a4") - - # Full-width digits should be rejected - with pytest.raises(ValueError, match="only ASCII digits"): - hash_camera_password("1234") - - with pytest.raises(ValueError, match="only ASCII digits"): - hash_camera_password("password") - - with pytest.raises(TypeError, match="must be a str"): - hash_camera_password(None) # type: ignore[arg-type] - - with pytest.raises(TypeError, match="must be a str"): - hash_camera_password(1234) # type: ignore[arg-type] - - with pytest.raises(TypeError, match="must be a str"): - hash_camera_password(["1", "2", "3", "4"]) # type: ignore[arg-type] - - -# ============================================================================ -# SDP offer/answer packaging tests -# ============================================================================ - - -def test_encode_sdp_offer_and_decode_sdp_answer_roundtrip() -> None: - """Test round-trip SDP offer encoding and answer decoding.""" - raw_sdp = "v=0\r\no=- 12345 2 IN IP4 127.0.0.1\r\ns=-\r\nt=0 0\r\nm=video 9 UDP/TLS/RTP/SAVPF 96\r\n" - encoded_offer = encode_sdp_offer(raw_sdp) - - decoded_json = json.loads(base64.b64decode(encoded_offer).decode("utf-8")) - assert decoded_json == {"sdp": raw_sdp, "type": "offer"} - - answer_payload = { - "sdp": "v=0\r\no=- 67890 2 IN IP4 127.0.0.1\r\ns=-\r\nm=video 9 UDP/TLS/RTP/SAVPF 96\r\n", - "type": "answer", - } - answer_b64 = base64.b64encode(json.dumps(answer_payload).encode("utf-8")).decode("ascii") - - res_str = decode_sdp_answer(answer_b64) - assert res_str == answer_payload - - res_dict = decode_sdp_answer({"dev_sdp": answer_b64}) - assert res_dict == answer_payload - - res_list = decode_sdp_answer([{"dev_sdp": answer_b64}]) - assert res_list == answer_payload - - -def test_encode_sdp_offer_from_dict() -> None: - """Test encoding SDP offer from dictionary.""" - offer_dict = {"sdp": "v=0\r\no=- 1 1 IN IP4 0.0.0.0", "type": "offer"} - b64_str = encode_sdp_offer(offer_dict) - decoded = json.loads(base64.b64decode(b64_str).decode("utf-8")) - assert decoded["sdp"] == offer_dict["sdp"] - assert decoded["type"] == "offer" - - -def test_decode_sdp_answer_retry() -> None: - """Test that retry string returns None.""" - assert decode_sdp_answer("retry") is None - assert decode_sdp_answer({"dev_sdp": "retry"}) is None - assert decode_sdp_answer([{"dev_sdp": "retry"}]) is None - - -def test_encode_sdp_offer_errors() -> None: - """Test error handling in encode_sdp_offer.""" - with pytest.raises(ValueError, match="cannot be empty"): - encode_sdp_offer("") - - with pytest.raises(ValueError, match="must contain a non-empty string 'sdp' key"): - encode_sdp_offer({}) - - with pytest.raises(ValueError, match="must contain a non-empty string 'sdp' key"): - encode_sdp_offer({"sdp": None}) # type: ignore[dict-item] - - with pytest.raises(ValueError, match="cannot be empty"): - encode_sdp_offer({"sdp": " "}) - - with pytest.raises(ValueError, match="type must be 'offer'"): - encode_sdp_offer({"sdp": "v=0...", "type": "answer"}) - - with pytest.raises(TypeError, match="Expected str or dict"): - encode_sdp_offer(12345) # type: ignore[arg-type] - - -def test_decode_sdp_answer_already_decoded_dict() -> None: - """Test decoding when already a dict with sdp key.""" - res = decode_sdp_answer({"sdp": "v=0...", "type": "answer"}) - assert res == {"sdp": "v=0...", "type": "answer"} - - with pytest.raises(ValueError, match="invalid 'type'"): - decode_sdp_answer({"sdp": "v=0...", "type": "offer"}) - - with pytest.raises(ValueError, match="missing or empty 'sdp'"): - decode_sdp_answer({"sdp": "", "type": "answer"}) - - -def test_decode_sdp_answer_errors_and_edge_cases() -> None: - """Test invalid payloads in decode_sdp_answer raise ValueError or TypeError.""" - with pytest.raises(ValueError, match="Empty response list"): - decode_sdp_answer([]) - - with pytest.raises(TypeError, match="Expected dict inside SDP answer list"): - decode_sdp_answer(["not_dict"]) # type: ignore[list-item] - - with pytest.raises(ValueError, match="missing 'dev_sdp' or 'sdp'"): - decode_sdp_answer({"invalid_key": "abc"}) - - with pytest.raises(TypeError, match="Expected str for 'dev_sdp'"): - decode_sdp_answer({"dev_sdp": 12345}) # type: ignore[dict-item] - - with pytest.raises(TypeError, match="Expected str, dict, or list"): - decode_sdp_answer(12345) # type: ignore[arg-type] - - with pytest.raises(ValueError, match="Failed to decode base64 JSON"): - decode_sdp_answer("invalid_base64!@#$") - - invalid_json_b64 = base64.b64encode(b"not json").decode("ascii") - with pytest.raises(ValueError, match="Failed to decode base64 JSON"): - decode_sdp_answer(invalid_json_b64) - - not_obj_b64 = base64.b64encode(b"[1, 2, 3]").decode("ascii") - with pytest.raises(ValueError, match="must be an object"): - decode_sdp_answer(not_obj_b64) - - missing_sdp_b64 = base64.b64encode(b'{"type": "answer"}').decode("ascii") - with pytest.raises(ValueError, match="missing or empty 'sdp' field"): - decode_sdp_answer(missing_sdp_b64) - - -# ============================================================================ -# ICE candidate packaging tests -# ============================================================================ - - -def test_encode_ice_candidate_and_decode_roundtrip() -> None: - """Test round-trip encoding and decoding of ICE candidates.""" - cand = CameraIceCandidate( - foundation="cand1", - component=1, - protocol="udp", - priority=2122260223, - ip="192.168.1.50", - port=55555, - candidate_type="host", - sdp_mid="0", - sdp_mline_index=0, - ) - - encoded = encode_ice_candidate(cand) - - obj = json.loads(base64.b64decode(encoded).decode("utf-8")) - assert obj["candidate"] == cand.to_sdp() - assert obj["sdpMid"] == "0" - assert obj["sdpMLineIndex"] == 0 - - decoded_list = decode_ice_candidates([encoded]) - assert len(decoded_list) == 1 - assert decoded_list[0] == cand - - decoded_dict = decode_ice_candidates({"dev_ice": [encoded]}) - assert len(decoded_dict) == 1 - assert decoded_dict[0] == cand - - decoded_resp = decode_ice_candidates([{"dev_ice": [encoded]}]) - assert len(decoded_resp) == 1 - assert decoded_resp[0] == cand - - -def test_encode_ice_candidate_from_str_and_dict() -> None: - """Test encoding from raw candidate string and dictionary.""" - raw_sdp = "candidate:1 1 udp 100 1.1.1.1 200 typ host" - b64_str = encode_ice_candidate(raw_sdp, sdp_mid="1", sdp_mline_index=1) - obj = json.loads(base64.b64decode(b64_str).decode("utf-8")) - assert obj["candidate"] == raw_sdp - assert obj["sdpMid"] == "1" - assert obj["sdpMLineIndex"] == 1 - - b64_str2 = encode_ice_candidate("1 1 udp 100 1.1.1.1 200 typ host") - obj2 = json.loads(base64.b64decode(b64_str2).decode("utf-8")) - assert obj2["candidate"] == raw_sdp - - dict_cand = {"candidate": raw_sdp, "sdpMid": "0", "sdpMLineIndex": 0} - b64_dict = encode_ice_candidate(dict_cand) - obj3 = json.loads(base64.b64decode(b64_dict).decode("utf-8")) - assert obj3["candidate"] == raw_sdp - - -def test_decode_ice_candidates_empty_or_multiple() -> None: - """Test decoding empty list and multiple candidates.""" - assert decode_ice_candidates([]) == [] - assert decode_ice_candidates({"dev_ice": []}) == [] - assert decode_ice_candidates([{"dev_ice": []}]) == [] - - cand1 = "candidate:1 1 udp 100 10.0.0.1 5000 typ host" - cand2 = "candidate:2 1 udp 200 10.0.0.2 5000 typ host" - enc1 = encode_ice_candidate(cand1, sdp_mid="0", sdp_mline_index=0) - enc2 = encode_ice_candidate(cand2, sdp_mid="0", sdp_mline_index=0) - - results = decode_ice_candidates([{"dev_ice": [enc1, enc2]}]) - assert len(results) == 2 - assert results[0].foundation == "1" - assert results[1].foundation == "2" - - -def test_decode_ice_candidates_errors() -> None: - """Test invalid payloads in decode_ice_candidates raise TypeError or ValueError.""" - with pytest.raises(TypeError, match="Expected str, list, or dict"): - decode_ice_candidates(12345) # type: ignore[arg-type] - - with pytest.raises(TypeError, match="Expected list in 'dev_ice'"): - decode_ice_candidates({"dev_ice": "not_a_list"}) - - with pytest.raises(TypeError, match="Unexpected candidate item type"): - decode_ice_candidates([12345]) # type: ignore[list-item] - - with pytest.raises(ValueError, match="missing 'candidate' string"): - decode_ice_candidates([{"sdpMid": "0"}]) - - -def test_encode_ice_candidate_errors() -> None: - """Test error handling in encode_ice_candidate.""" - with pytest.raises(ValueError, match="cannot be empty"): - encode_ice_candidate("") - - with pytest.raises(ValueError, match="missing or invalid 'candidate'"): - encode_ice_candidate({}) - - with pytest.raises(TypeError, match="Expected CameraIceCandidate, dict, or str"): - encode_ice_candidate(12345) # type: ignore[arg-type] - - -def test_decode_ice_candidates_edge_cases() -> None: - """Test edge cases in decode_ice_candidates.""" - cand_str = "candidate:1 1 udp 100 1.2.3.4 5000 typ host" - res1 = decode_ice_candidates(cand_str) - assert len(res1) == 1 - assert res1[0].foundation == "1" - - res2 = decode_ice_candidates({"candidate": cand_str, "sdpMid": "0", "sdpMLineIndex": 0}) - assert len(res2) == 1 - assert res2[0].foundation == "1" - - existing_cand = parse_ice_candidate(cand_str) - res3 = decode_ice_candidates([existing_cand]) - assert res3 == [existing_cand] - - res4 = decode_ice_candidates([{"candidate": cand_str, "sdpMid": "video", "sdpMLineIndex": 1}]) - assert len(res4) == 1 - assert res4[0].sdp_mid == "video" - assert res4[0].sdp_mline_index == 1 - - res5 = decode_ice_candidates(["", " "]) - assert res5 == [] - - res6 = decode_ice_candidates([cand_str]) - assert len(res6) == 1 - assert res6[0].foundation == "1" - - bad_base64_json = "eyJjYW5kaWRhdGUiOiA=" - with pytest.raises(ValueError, match="Failed to decode base64 JSON"): - decode_ice_candidates([bad_base64_json]) - - res7 = decode_ice_candidates([bad_base64_json, cand_str], ignore_invalid=True) - assert len(res7) == 1 - assert res7[0].foundation == "1" - - -def test_parse_ice_candidate_dangling_token_rejected() -> None: - """Test candidate with an odd trailing token raises ValueError.""" - with pytest.raises(ValueError, match="odd number of tokens"): - parse_ice_candidate("candidate:1 1 udp 100 1.2.3.4 5000 typ host dangling_token") - - -def test_encode_ice_candidate_mline_string_and_errors() -> None: - """Test sdp_mline_index coercion and error handling.""" - raw_sdp = "candidate:1 1 udp 100 1.1.1.1 200 typ host" - b64_str = encode_ice_candidate(raw_sdp, sdp_mline_index="1") # type: ignore[arg-type] - obj = json.loads(base64.b64decode(b64_str).decode("utf-8")) - assert obj["sdpMLineIndex"] == 1 - - with pytest.raises(ValueError, match="Invalid sdpMLineIndex"): - encode_ice_candidate(raw_sdp, sdp_mline_index="not_a_number") # type: ignore[arg-type] - - -def test_decode_ice_candidates_ignore_invalid_variations(caplog: pytest.LogCaptureFixture) -> None: - """Test ignore_invalid handles bad candidate dicts, bad strings, and TypeError from malformed mline.""" - res1 = decode_ice_candidates([{"sdpMid": "0"}], ignore_invalid=True) - assert res1 == [] - - res2 = decode_ice_candidates([{"candidate": "not a candidate"}], ignore_invalid=True) - assert res2 == [] - - res3 = decode_ice_candidates(["not a candidate"], ignore_invalid=True) - assert res3 == [] - - # TypeError from non-int coercible sdpMLineIndex (e.g. list) - res4 = decode_ice_candidates( - [{"candidate": "candidate:1 1 udp 100 1.2.3.4 5000 typ host", "sdpMLineIndex": [1]}], - ignore_invalid=True, - ) - assert res4 == [] - - # Valid base64 that decodes to non-dict JSON (e.g. JSON list) - non_dict_json_b64 = base64.b64encode(b"[1, 2, 3]").decode("ascii") - with pytest.raises(ValueError, match="Failed to decode base64 JSON"): - decode_ice_candidates([non_dict_json_b64]) - - res5 = decode_ice_candidates([non_dict_json_b64], ignore_invalid=True) - assert res5 == [] - - # Base64 JSON missing candidate key - no_cand_json = base64.b64encode(b'{"sdpMid": "0"}').decode("ascii") - with pytest.raises(ValueError, match="Failed to decode base64 JSON"): - decode_ice_candidates([no_cand_json]) - - # Check that caplog captured warnings - assert any("Skipping" in record.message for record in caplog.records)