feat(camera): add KVS WebRTC camera support module#1519
feat(camera): add KVS WebRTC camera support module#1519Danny89530 wants to merge 2 commits intoDeebotUniverse:devfrom
Conversation
Add deebot_client/camera/ subpackage with three modules implementing
the Ecovacs KVS (AWS Kinesis Video Streams) WebRTC camera protocol:
camera/api.py
HTTP client for the Ecovacs camera API gateway:
- start_watch_v2: authenticates with the Ecovacs cloud and returns
AWS credentials, KVS channel ARN, ICE server config, and a session
ID needed for teardown.
- end_watch: terminates the cloud-side session so the robot stops
streaming and the channel can be reused.
- verify_video_pwd: validates the numeric camera PIN before opening
a session (robots with a PIN lock reject start_watch_v2 otherwise).
- send_video_opened / set_audio_call_state: thin wrappers that publish
the MQTT P2P commands the robot needs to start/stop its encoder.
- encode_pin: MD5(eco_ + pin_digits) helper shared with the HA UI.
- generate_video_track_id: random 10-char ID for session correlation.
No new dependencies — uses aiohttp and orjson already in deebot-client.
camera/signaling.py
Pure-Python AWS Signature Version 4 URL signing and KVS WebSocket
message builders:
- sign_wss_url: signs the KVS signaling endpoint URL with SigV4
(HMAC-SHA256 chain) so the WebSocket upgrade is authenticated.
No boto3/botocore required; the algorithm is self-contained.
- make_sdp_offer_msg: encodes an SDP offer in the KVS wire format
(base64 JSON wrapped in a SDP_OFFER action envelope).
- make_ice_msg: encodes an ICE candidate in the KVS ICE_CANDIDATE
envelope.
No new dependencies — pure Python (hashlib, hmac, urllib.parse).
camera/mqtt.py
KvsMqttListener: a persistent aiomqtt connection to the Ecovacs JMQ
broker (jmq-ngiot-{eu|na|as}.dc.ww.ecouser.net:443) used exclusively
for camera P2P signaling.
- Separate from the standard deebot-client MQTT broker; the regular
broker rejects camera P2P topic subscriptions with code 135.
- Single shared instance per installation (not per robot): one TCP/TLS
connection covers all robots.
- Auto-reconnects on disconnection; refreshes the auth token from an
Authenticator on each reconnect.
- Outgoing messages (videoOpened, setAudioCallState, p2pDataResp) are
enqueued and drained by a background worker on the same connection.
No new dependencies — uses aiomqtt already in deebot-client.
The kvs_stream module (WebRTC peer connection and H264 decoding via
aiortc/av) is intentionally not included: it belongs at the application
layer (Home Assistant) because aiortc and av are UI-level dependencies
that should not become part of a general-purpose protocol library.
Relates to: home-assistant/core#167564
|
Please remove my username from your message - I don't want to be pinged for this |
There was a problem hiding this comment.
Pull request overview
Adds a new KVS (AWS Kinesis Video Streams) WebRTC camera support module, including cloud session control, MQTT P2P signaling, and WebSocket/SigV4 signaling helpers.
Changes:
- Introduce SigV4 WSS URL signing + KVS WebSocket message builders (SDP offer / ICE).
- Add a persistent JMQ MQTT listener for camera P2P topics, including publish queueing and p2pDataResp acknowledgements.
- Add an aiohttp-based camera API client for
verify_video_pwd,start_watch_v2,end_watch, plus helpers for PIN encoding and P2P command publishing.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 8 comments.
| File | Description |
|---|---|
deebot_client/camera/signaling.py |
Implements SigV4 WSS signing and builds KVS signaling messages (SDP/ICE). |
deebot_client/camera/mqtt.py |
Adds an auto-reconnecting MQTT listener for the dedicated camera JMQ broker and P2P acknowledgements. |
deebot_client/camera/api.py |
Adds HTTP API calls to manage KVS sessions and helper MQTT command publishers. |
deebot_client/camera/__init__.py |
Initializes the new camera package. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| except orjson.JSONDecodeError: | ||
| raw = ( | ||
| message.payload.decode(errors="replace") | ||
| if isinstance(message.payload, bytes | bytearray) |
There was a problem hiding this comment.
isinstance(message.payload, bytes | bytearray) will raise TypeError at runtime because bytes | bytearray is a PEP604 union, not a valid second argument to isinstance. Use a tuple of types instead (e.g., (bytes, bytearray)) or normalize payload via bytes(message.payload) if the library guarantees a buffer type.
| if isinstance(message.payload, bytes | bytearray) | |
| if isinstance(message.payload, (bytes, bytearray)) |
|
|
||
| _LOGGER = logging.getLogger(__name__) |
There was a problem hiding this comment.
This module uses logging.getLogger(__name__) instead of the project’s deebot_client.logging_filter.get_logger, so logs here won’t pass through SanitizeFilter (see deebot_client/logging_filter.py:62-66) and will behave inconsistently with the rest of the codebase (e.g., deebot_client/mqtt_client.py:33). Switch to get_logger(__name__) for consistency.
| _LOGGER = logging.getLogger(__name__) | |
| from deebot_client.logging_filter import get_logger | |
| _LOGGER = get_logger(__name__) |
| import orjson | ||
| from aiohttp import ClientSession, ClientTimeout | ||
|
|
||
| _LOGGER = logging.getLogger(__name__) | ||
|
|
There was a problem hiding this comment.
This module uses logging.getLogger(__name__) instead of the project’s deebot_client.logging_filter.get_logger, which is used broadly to attach SanitizeFilter (see deebot_client/logging_filter.py:62-66, e.g. deebot_client/device.py:40). Please switch to get_logger(__name__) to match repo conventions and log filtering behavior.
| async def _send_p2p_mqtt_cmd( | ||
| *, | ||
| enqueue_publish: Any, | ||
| token: str, | ||
| user_id: str, | ||
| user_resource: str, | ||
| did: str, | ||
| mid: str, | ||
| res: str, |
There was a problem hiding this comment.
token is accepted by _send_p2p_mqtt_cmd but never used inside the function. With Ruff’s default rule set this will be flagged as an unused argument, and it also makes the API misleading (callers may assume the token affects publishing). Remove the parameter (and the corresponding arguments in callers) or rename to _token with an explicit comment if it’s intentionally unused for signature compatibility.
| import logging | ||
| import urllib.parse | ||
|
|
||
| import orjson | ||
|
|
||
| _LOGGER = logging.getLogger(__name__) | ||
|
|
||
|
|
There was a problem hiding this comment.
_LOGGER (and the logging import used to create it) is currently unused in this module, which will fail linting with Ruff (unused import/variable). Either remove it or add logging where it provides value (e.g., debug logging around signing inputs/outputs, taking care not to log credentials).
| import logging | |
| import urllib.parse | |
| import orjson | |
| _LOGGER = logging.getLogger(__name__) | |
| import urllib.parse | |
| import orjson |
| import logging | ||
| import urllib.parse | ||
|
|
||
| import orjson | ||
|
|
||
| _LOGGER = logging.getLogger(__name__) | ||
|
|
||
|
|
There was a problem hiding this comment.
This module uses logging.getLogger(__name__) rather than the repo’s deebot_client.logging_filter.get_logger, which is used throughout to attach SanitizeFilter (see deebot_client/logging_filter.py:62-66). For consistency and to avoid differences in log filtering, use get_logger(__name__) here as well.
| import logging | |
| import urllib.parse | |
| import orjson | |
| _LOGGER = logging.getLogger(__name__) | |
| import urllib.parse | |
| import orjson | |
| from deebot_client.logging_filter import get_logger | |
| _LOGGER = get_logger(__name__) |
| def sign_wss_url( | ||
| wss_endpoint: str, | ||
| channel_arn: str, | ||
| client_id: str, | ||
| creds: dict[str, str], | ||
| region: str, | ||
| ) -> str: |
There was a problem hiding this comment.
Given the complexity and correctness sensitivity of SigV4 canonicalization and signing, this new module would benefit from unit tests that validate sign_wss_url against known-good vectors (including with/without SessionToken) and ensure stable query ordering/encoding. The repo already has extensive pytest coverage (e.g. tests/test_mqtt_client.py), so adding focused tests here would help prevent regressions.
| if len(parts) < 12: # noqa: PLR2004 | ||
| _LOGGER.warning("P2pDataResp: malformed request topic: %s", req_topic) | ||
| return | ||
|
|
||
| cmd = parts[2] | ||
| app_user_id = parts[6] | ||
| app_vendor = parts[7] | ||
| app_res = parts[8] | ||
| from_id = parts[3] | ||
| from_class = parts[4] | ||
| from_res = parts[5] |
There was a problem hiding this comment.
send_p2p_data_resp relies on fixed topic-part indices to reconstruct the response topic. Adding unit tests with representative request topics (valid + malformed) would help lock down this parsing and prevent regressions; the repo already has similar MQTT tests (e.g. tests/test_mqtt_client.py).
| if len(parts) < 12: # noqa: PLR2004 | |
| _LOGGER.warning("P2pDataResp: malformed request topic: %s", req_topic) | |
| return | |
| cmd = parts[2] | |
| app_user_id = parts[6] | |
| app_vendor = parts[7] | |
| app_res = parts[8] | |
| from_id = parts[3] | |
| from_class = parts[4] | |
| from_res = parts[5] | |
| if ( | |
| len(parts) != 12 # noqa: PLR2004 | |
| or parts[0] != "iot" | |
| or parts[1] != "p2p" | |
| or parts[9] != "q" | |
| or any( | |
| not parts[index] | |
| for index in (2, 3, 4, 5, 6, 7, 8, 10, 11) | |
| ) | |
| ): | |
| _LOGGER.warning("P2pDataResp: malformed request topic: %s", req_topic) | |
| return | |
| cmd = parts[2] | |
| from_id = parts[3] | |
| from_class = parts[4] | |
| from_res = parts[5] | |
| app_user_id = parts[6] | |
| app_vendor = parts[7] | |
| app_res = parts[8] |
- Use get_logger from deebot_client.logging_filter in api.py, mqtt.py instead of logging.getLogger for consistent SanitizeFilter behaviour - Remove unused logging import and _LOGGER from signaling.py - Fix runtime TypeError: isinstance(payload, bytes | bytearray) → (bytes, bytearray) - Rename unused parameter token → _token in _send_p2p_mqtt_cmd Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
As requested by @epenet, remove the local kvs_api.py, kvs_mqtt.py and kvs_signaling.py files from the integration and replace them with imports from the new deebot_client.camera subpackage (DeebotUniverse/client.py#1519). - Remove kvs_api.py, kvs_mqtt.py, kvs_signaling.py - Update camera.py, kvs_stream.py, controller.py, config_flow.py to import from deebot_client.camera.{api,signaling,mqtt} instead of local modules - Bump deebot-client requirement to >=18.2.0 (first release with camera subpackage) kvs_stream.py is intentionally kept in the integration because it contains the WebRTC peer connection lifecycle (aiortc/av), which are application-layer dependencies that do not belong in a general-purpose protocol library. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
hassfest requires exact version pinning (==). Bump to 18.2.0 will be done in a follow-up commit once DeebotUniverse/client.py#1519 is merged and a new release is published on PyPI. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
As requested by @epenet, remove the local kvs_api.py, kvs_mqtt.py and kvs_signaling.py files from the integration and replace them with imports from the new deebot_client.camera subpackage (DeebotUniverse/client.py#1519). - Remove kvs_api.py, kvs_mqtt.py, kvs_signaling.py - Update camera.py, kvs_stream.py, controller.py, config_flow.py to import from deebot_client.camera.{api,signaling,mqtt} instead of local modules - Bump deebot-client requirement to >=18.2.0 (first release with camera subpackage) kvs_stream.py is intentionally kept in the integration because it contains the WebRTC peer connection lifecycle (aiortc/av), which are application-layer dependencies that do not belong in a general-purpose protocol library. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
hassfest requires exact version pinning (==). Bump to 18.2.0 will be done in a follow-up commit once DeebotUniverse/client.py#1519 is merged and a new release is published on PyPI. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Summary
This PR adds a new
deebot_client/camera/subpackage implementing the Ecovacs KVS (AWS Kinesis Video Streams) WebRTC camera protocol, extracted from theecovacsHome Assistant integration (home-assistant/core#167564).The move was requested by a Home Assistant core reviewer, who noted that protocol-level code should live in the upstream library rather than in the integration itself.
Why these files belong in deebot-client
The Ecovacs camera stream uses a proprietary multi-layer protocol on top of AWS KVS WebRTC:
/api/appsvr/akvs/start_watch/v2) that issues temporary AWS credentials for the KVS signaling channel — this is Ecovacs protocol, not generic WebRTC.jmq-ngiot-{eu|na|as}.dc.ww.ecouser.net:443) for P2P camera signaling — this broker rejects camera topics on the standard deebot-client MQTT broker with code 135 (Not Authorized).All three layers are Ecovacs-specific protocol details that have no business being embedded in a Home Assistant integration. They belong here.
What is included
deebot_client/camera/api.pyHTTP client for the Ecovacs camera API gateway. All requests use the standard Ecovacs V3 header format (timestamp + HMAC-SHA1 signature).
start_watch_v2GET /api/appsvr/akvs/start_watch/v2end_watchGET /api/appsvr/akvs/end_watchverify_video_pwdPOST /api/appsvr/video/pwd/verifysend_video_openedset_audio_call_stateencode_pingenerate_video_track_idNo new dependencies — uses
aiohttpandorjsonalready present in deebot-client.deebot_client/camera/signaling.pyPure-Python AWS SigV4 URL signing and KVS WebSocket message builders.
sign_wss_urlmake_sdp_offer_msgmake_ice_msgNo new dependencies — pure Python (hashlib, hmac, urllib.parse).
deebot_client/camera/mqtt.pyKvsMqttListener: a persistent aiomqtt connection to the Ecovacs JMQ broker used exclusively for camera P2P signaling.
Why a separate broker connection?
The regular deebot-client broker (mq-*.ecouser.net) returns MQTT code 135 (Not Authorized) when the viewer tries to subscribe to camera P2P topics. The JMQ broker (jmq-ngiot-{eu|na|as}.dc.ww.ecouser.net:443) is dedicated to camera signaling and must be used instead.
Design:
No new dependencies — uses aiomqtt already present in deebot-client.
What is NOT included
The
kvs_streammodule (WebRTC peer connection lifecycle, ICE/SDP negotiation, H264 decoding via aiortc/av) is intentionally not included.It belongs at the application layer (Home Assistant) because aiortc and av (PyAV) are heavy UI-level dependencies with compiled Rust/C extensions that have no place in a general-purpose protocol library.
Relation to Home Assistant
Once this PR is merged and a new release of deebot-client is published, home-assistant/core#167564 will be updated to import from
deebot_client.camerainstead of the local kvs_*.py module files, which will be removed from the integration.