Skip to content

feat(camera): Add camera signaling models, ICE parsing utilities, and payload serialization - #981

Open
Lash-L wants to merge 2 commits into
Python-roborock:mainfrom
Lash-L:camera-signaling
Open

Lash-L wants to merge 2 commits into
Python-roborock:mainfrom
Lash-L:camera-signaling

Conversation

@Lash-L

@Lash-L Lash-L commented Oct 12, 2026

Copy link
Copy Markdown
Collaborator

Summary

Implements PR 2 of the decoupled Roborock vacuum live camera streaming series (following #980, #764, #738).

This PR introduces pure Python data structures, RFC-compliant ICE candidate parsing/formatting utilities, password hashing, and WebRTC signaling serializers for Roborock camera live streaming. It adds zero external runtime dependencies (aiortc is deferred to PR 4).

Scope & Components

  1. Camera Signaling Models:

    • CameraQuality (StrEnum: HD, SD).
    • CameraTurnServer dataclass (urls, username, credential with repr=False for security; includes from_dict helper validating TURN/STUN schemes).
    • CameraIceCandidate dataclass (encapsulates foundation, component, protocol, priority, ip, port, candidate_type, related_address, related_port, tcp_type, sdp_mid, sdp_mline_index, and arbitrary SDP extensions).
  2. RFC-Compliant ICE Candidate Parser & Formatter:

    • parse_ice_candidate: RFC 5245 / 8839 / 8445 compliant parser for SDP candidate attribute lines (candidate:...).
      • Supports IPv4, IPv6, and browser mDNS (.local) addresses.
      • Supports RFC 6544 TCP types (active, passive, so).
      • Validates component ranges (1 <= component <= 256), priority bounds (1 <= priority <= 2^31 - 1), and foundation syntax.
      • Preserves unrecognized SDP extensions (ufrag, generation, network-id).
    • Formatter methods: CameraIceCandidate.to_sdp() and to_signaling_dict().
  3. Security & Password Hashing:

    • hash_camera_password: Derives the lowercase hexadecimal MD5 digest of numeric camera PIN/pattern digits with strict ASCII validation and FIPS-compliant usedforsecurity=False.
  4. WebRTC Payload Serialization:

    • encode_sdp_offer & decode_sdp_answer: Compact base64-encoded UTF-8 JSON packaging for send_sdp_to_robot / get_device_sdp, handling "retry" pending states while preserving SDP CRLF line integrity.
    • encode_ice_candidate & decode_ice_candidates: Base64-encoded UTF-8 JSON packaging for send_ice_to_robot / get_device_ice, with schema validation and optional ignore_invalid resilience for remote candidate streams.
  5. Re-exports & Packaging:

    • Exposed under roborock.camera_signaling, roborock.data.camera, and roborock.data.

Validation

  • Unit tests: 37 tests covering happy paths, RFC compliance, boundary checks, edge cases, malformed payloads, and re-exports (uv run pytest tests/test_camera_signaling.py).
  • Code Quality: Passes uv run ruff check and uv run ruff format --check.
  • Typing: Passes uv run mypy.
  • Review: Refined through automated review iterations (Codex sol 6.1 and Claude opus 5.5).

@allenporter allenporter left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Automated code review by Gemini 4 Argon in Antigravity using the /review skill (.agents/skills/review/SKILL.md / AGENTS.md).

Summary

Great work breaking the camera livestream work into focused, zero-dependency increments! The RFC 5245/8839/8445 ICE candidate parsing, repr=False credential redaction on CameraTurnServer, and test coverage are well thought out.

Below are a few architectural and typing items to align with our repository conventions and conformance suite:

  1. Priority 1 — Module Placement & RoborockBase Conformance: Defining data models in top-level roborock/camera_signaling.py and re-exporting them in roborock/data/camera.py causes tests/conformance/test_model_conformance.py (discover_dataclasses(roborock.data)) to skip them because cls.__module__ does not start with roborock.data. As a result, CameraIceCandidate does not subclass RoborockBase. Defining the data models directly in roborock/data/ (or roborock/data/v1/) and placing the V1 signaling codecs in roborock/protocols/ keeps the top-level package clean and ensures conformance test coverage.
  2. Priority 1 — Trust Type Annotations & Concrete Signatures: Several functions perform defensive isinstance checks on statically typed parameters (CameraQuality.from_str, parse_ice_candidate, hash_camera_password, encode_ice_candidate), and outgoing encoders accept loose dict[str, Any] union inputs rather than concrete types.
  3. Priority 3 — Linear Wire Decoding in decode_ice_candidates: decode_ice_candidates mixes wire payload decoding (get_device_ice) with raw SDP string parsing via an item_str.startswith("ey") heuristic.

password hashing, and base64-JSON payload encoding/decoding.
"""

from __future__ import annotations

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Avoid from __future__ import annotations (AGENTS.md §1):

Python 3.11+ natively supports Self and X | Y unions at runtime. Including from __future__ import annotations turns dataclasses.fields(cls) .type attributes into strings ('list[str]', 'str | None'), which breaks RoborockBase._convert_to_class_obj runtime reflection when RoborockBase.from_dict is used. Please remove from __future__ import annotations here and in roborock/data/camera.py.

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__}")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Trust Type Annotations (AGENTS.md §1):

Prohibit defensive runtime isinstance checks on statically typed parameters (value: str). The same applies to parse_ice_candidate(candidate_str: str) (line 203) and hash_camera_password(password: str) (line 320).

credential=pwd_val,
)

def to_dict(self) -> dict[str, Any]:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Redundant to_dict() vs RoborockBase.as_dict() (AGENTS.md §1):

CameraTurnServer subclasses RoborockBase, whose inherited as_dict() already returns {"urls": [...], "username": ..., "credential": ...} while omitting None fields. Consider using as_dict() rather than introducing a duplicate to_dict() method.



@dataclass
class CameraIceCandidate:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Subclass RoborockBase & Module Placement (AGENTS.md §1, tests/conformance/test_model_conformance.py):

  1. CameraIceCandidate is re-exported under roborock.data, so it must subclass RoborockBase.
  2. Because CameraIceCandidate, CameraTurnServer, and CameraQuality are defined in roborock.camera_signaling and only re-exported in roborock/data/camera.py, discover_dataclasses(roborock.data) skips them (cls.__module__ is "roborock.camera_signaling").

Please define the domain models directly in roborock/data/camera.py (or roborock/data/v1/) and place the V1 protocol serializers/parsers in roborock/protocols/ (with mirrored tests under tests/data/ and tests/protocols/).

return hashlib.md5(password_str.encode("utf-8"), usedforsecurity=False).hexdigest()


def encode_sdp_offer(sdp: str | dict[str, Any]) -> str:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Strongly Type Public Boundaries (AGENTS.md §1):

Avoid loose dict[str, Any] union parameters on outgoing encoders (encode_sdp_offer and encode_ice_candidate at line 422). Accepting a concrete sdp: str (or a RoborockBase model) for encode_sdp_offer and candidate: CameraIceCandidate for encode_ice_candidate keeps the API strongly typed and eliminates the polymorphic isinstance / int(...) coercion branches.

return base64.b64encode(json_bytes).decode("ascii")


def decode_sdp_answer(payload: str | dict[str, Any] | list[Any]) -> dict[str, str] | None:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Return Concrete Model Instead of Loose dict[str, str] (AGENTS.md §1):

Consider returning a @dataclass subclassing RoborockBase (e.g., CameraSdpDescription(sdp: str, type: str)) or str | None rather than a raw dict[str, str].

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 (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Fragile startswith("ey") Heuristic in decode_ice_candidates (AGENTS.md §2):

decode_ice_candidates attempts to accept both wire payloads from get_device_ice (base64-encoded JSON strings) and raw unencoded SDP candidate strings by guessing whether item_str.startswith("ey").

Additionally, if a raw candidate string happens to have a valid base64 character prefix/length and fails in json.loads() with json.JSONDecodeError, not isinstance(err, binascii.Error) and not isinstance(err, UnicodeDecodeError) is True, so it raises Failed to decode base64 JSON ICE candidate instead of falling back.

Since parse_ice_candidate() already handles raw SDP candidate strings, decode_ice_candidates() should strictly decode the wire format (get_device_ice base64 JSON strings / {"dev_ice": [...]} envelope) without fallback guessing.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants