Skip to content

feat(camera): add KVS WebRTC camera support module#1519

Open
Danny89530 wants to merge 2 commits intoDeebotUniverse:devfrom
Danny89530:feature/kvs-camera-support
Open

feat(camera): add KVS WebRTC camera support module#1519
Danny89530 wants to merge 2 commits intoDeebotUniverse:devfrom
Danny89530:feature/kvs-camera-support

Conversation

@Danny89530
Copy link
Copy Markdown

@Danny89530 Danny89530 commented Apr 7, 2026

Summary

This PR adds a new deebot_client/camera/ subpackage implementing the Ecovacs KVS (AWS Kinesis Video Streams) WebRTC camera protocol, extracted from the ecovacs Home 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:

  1. A dedicated Ecovacs HTTP API (/api/appsvr/akvs/start_watch/v2) that issues temporary AWS credentials for the KVS signaling channel — this is Ecovacs protocol, not generic WebRTC.
  2. AWS SigV4 URL signing for the KVS WebSocket endpoint — required because the credentials from step 1 must be signed before use; no AWS SDK is needed when implemented in pure Python.
  3. A separate JMQ MQTT broker (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.py

HTTP client for the Ecovacs camera API gateway. All requests use the standard Ecovacs V3 header format (timestamp + HMAC-SHA1 signature).

Function Endpoint Description
start_watch_v2 GET /api/appsvr/akvs/start_watch/v2 Authenticates and returns AWS credentials, KVS channel ARN, ICE server list, client ID, and session ID
end_watch GET /api/appsvr/akvs/end_watch Terminates the cloud-side session so the robot stops streaming
verify_video_pwd POST /api/appsvr/video/pwd/verify Validates the numeric camera PIN before starting a session
send_video_opened MQTT P2P Notifies the robot that the viewer is ready (robot will not start encoding otherwise)
set_audio_call_state MQTT P2P Notifies the robot of viewer connect/disconnect (state 1 / 0)
encode_pin MD5("eco_" + digits) PIN hash helper
generate_video_track_id Random 10-char alphanumeric session correlation ID

No new dependencies — uses aiohttp and orjson already present in deebot-client.


deebot_client/camera/signaling.py

Pure-Python AWS SigV4 URL signing and KVS WebSocket message builders.

Function Description
sign_wss_url Signs the KVS WSS endpoint URL with SigV4 (HMAC-SHA256 key derivation chain). No boto3/botocore required.
make_sdp_offer_msg Encodes an SDP offer in the KVS SDP_OFFER wire format (base64 JSON envelope)
make_ice_msg Encodes an ICE candidate in the KVS ICE_CANDIDATE wire format

No new dependencies — pure Python (hashlib, hmac, urllib.parse).


deebot_client/camera/mqtt.py

KvsMqttListener: 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:

  • One shared instance per installation (not per robot) — a single 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 to avoid multiplexing issues.

No new dependencies — uses aiomqtt already present in deebot-client.


What is NOT included

The kvs_stream module (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.camera instead of the local kvs_*.py module files, which will be removed from the integration.

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
@epenet
Copy link
Copy Markdown

epenet commented Apr 7, 2026

Please remove my username from your message - I don't want to be pinged for this

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

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.

Comment thread deebot_client/camera/mqtt.py Outdated
except orjson.JSONDecodeError:
raw = (
message.payload.decode(errors="replace")
if isinstance(message.payload, bytes | bytearray)
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
if isinstance(message.payload, bytes | bytearray)
if isinstance(message.payload, (bytes, bytearray))

Copilot uses AI. Check for mistakes.
Comment thread deebot_client/camera/mqtt.py Outdated
Comment on lines +55 to +56

_LOGGER = logging.getLogger(__name__)
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
_LOGGER = logging.getLogger(__name__)
from deebot_client.logging_filter import get_logger
_LOGGER = get_logger(__name__)

Copilot uses AI. Check for mistakes.
Comment on lines +37 to +41
import orjson
from aiohttp import ClientSession, ClientTimeout

_LOGGER = logging.getLogger(__name__)

Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Comment on lines +296 to +304
async def _send_p2p_mqtt_cmd(
*,
enqueue_publish: Any,
token: str,
user_id: str,
user_resource: str,
did: str,
mid: str,
res: str,
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Comment thread deebot_client/camera/signaling.py Outdated
Comment on lines +27 to +34
import logging
import urllib.parse

import orjson

_LOGGER = logging.getLogger(__name__)


Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

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

_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).

Suggested change
import logging
import urllib.parse
import orjson
_LOGGER = logging.getLogger(__name__)
import urllib.parse
import orjson

Copilot uses AI. Check for mistakes.
Comment thread deebot_client/camera/signaling.py Outdated
Comment on lines +27 to +34
import logging
import urllib.parse

import orjson

_LOGGER = logging.getLogger(__name__)


Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
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__)

Copilot uses AI. Check for mistakes.
Comment on lines +42 to +48
def sign_wss_url(
wss_endpoint: str,
channel_arn: str,
client_id: str,
creds: dict[str, str],
region: str,
) -> str:
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Comment on lines +268 to +278
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]
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

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

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).

Suggested change
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]

Copilot uses AI. Check for mistakes.
- 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>
Danny89530 pushed a commit to Danny89530/core that referenced this pull request Apr 8, 2026
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>
Danny89530 pushed a commit to Danny89530/core that referenced this pull request Apr 8, 2026
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>
Danny89530 added a commit to Danny89530/core that referenced this pull request Apr 8, 2026
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>
Danny89530 added a commit to Danny89530/core that referenced this pull request Apr 8, 2026
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>
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.

3 participants