Skip to content

Add native WebRTC streaming support for Abode Cam 2#166662

Open
goabodedevops wants to merge 4 commits intohome-assistant:devfrom
goabodedevops:abode-ac2-camera-support
Open

Add native WebRTC streaming support for Abode Cam 2#166662
goabodedevops wants to merge 4 commits intohome-assistant:devfrom
goabodedevops:abode-ac2-camera-support

Conversation

@goabodedevops
Copy link
Contributor

Breaking change

None.

Proposed change

This PR adds native WebRTC streaming support for Abode Cam 2 (AC2) and other KVS-capable Abode cameras in Home Assistant.

What is included:

  • Add native WebRTC signaling in the Abode camera entity using Abode KVS signaling (/integrations/v1/camera/{camera_id}/kvs/stream).
  • Keep existing stream source support (/integrations/v1/media/getPlaybackUrl) for HLS fallback.
  • Improve runtime resilience for camera image refresh paths:
    • Catch Abode auth/API exceptions during snapshot and timeline refresh attempts.
    • Avoid user-facing unknown-session noise by safely ignoring out-of-order ICE candidates.
    • Cache KVS signaling metadata briefly to reduce repeated signaling metadata calls.
  • Add comprehensive tests for:
    • WebRTC offer/candidate flow and websocket signaling behavior
    • ICE/signaling payload parsing edge cases
    • Signaling cache behavior
    • Failure paths (snapshot/timeline/WebRTC connect/send errors)

This contribution is submitted from the official Abode Systems account.

Type of change

  • Dependency upgrade
  • Bugfix (non-breaking change which fixes an issue)
  • New integration (thank you!)
  • New feature (which adds functionality to an existing integration)
  • Deprecation (breaking change to happen in the future)
  • Breaking change (fix/feature causing existing functionality to break)
  • Code quality improvements to existing code or addition of tests

Additional information

Checklist

  • I understand the code I am submitting and can explain how it works.
  • The code change is tested and works locally.
  • Local tests pass. Your PR cannot be merged unless tests pass
  • There is no commented out code in this PR.
  • I have followed the [development checklist][dev-checklist]
  • I have followed the [perfect PR recommendations][perfect-pr]
  • The code has been formatted using Ruff (ruff format homeassistant tests)
  • Tests have been added to verify that the new code works.
  • Any generated code has been carefully reviewed for correctness and compliance with project standards.

If user exposed functionality or configuration variables are added/changed:

  • Documentation added/updated for [www.home-assistant.io][docs-repository]

If the code communicates with devices, web services, or third-party tools:

  • The [manifest file][manifest-docs] has all fields filled out correctly.
    Updated and included derived files by running: python3 -m script.hassfest.
  • New or updated dependencies have been added to requirements_all.txt.
    Updated by running python3 -m script.gen_requirements_all.
  • For the updated dependencies a diff between library versions and ideally a link to the changelog/release notes is added to the PR description.

To help with the load of incoming pull requests:

  • I have reviewed two other [open pull requests][prs] in this repository.

Local validation

  • PATH=".venv/bin:$PATH" .venv/bin/python -m script.hassfest --action validate --integration-path homeassistant/components/abode
  • .venv/bin/ruff check homeassistant/components/abode/camera.py tests/components/abode/test_camera.py
  • .venv/bin/mypy homeassistant/components/abode
  • .venv/bin/pytest tests/components/abode/test_camera.py --cov=homeassistant.components.abode.camera --cov-report=term-missing -q
  • .venv/bin/pytest tests/components/abode -q --maxfail=1
@home-assistant
Copy link

Hey there @shred86, mind taking a look at this pull request as it has been labeled with an integration (abode) you are listed as a code owner for? Thanks!

Code owner commands

Code owners of abode can trigger bot actions by commenting:

  • @home-assistant close Closes the pull request.
  • @home-assistant rename Awesome new title Renames the pull request.
  • @home-assistant reopen Reopen the pull request.
  • @home-assistant unassign abode Removes the current integration label and assignees on the pull request, add the integration domain after the command.
  • @home-assistant add-label needs-more-information Add a label (needs-more-information, problem in dependency, problem in custom component, problem in config, problem in device, feature-request) to the pull request.
  • @home-assistant remove-label needs-more-information Remove a label (needs-more-information, problem in dependency, problem in custom component, problem in config, problem in device, feature-request) on the pull request.
Copy link
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 native WebRTC signaling to the Abode camera entity (targeting Abode Cam 2 / other KVS-capable cameras) while keeping the existing HLS playback URL path as a fallback, and expands the Abode camera test suite to cover the new streaming/signaling and resilience behavior.

Changes:

  • Implement KVS signaling fetch + caching, WebRTC offer/candidate handling, and WebSocket message listening/cleanup in the Abode camera entity.
  • Add snapshot-first image refresh logic with safer exception handling and “keep last good snapshot” behavior.
  • Add extensive tests for snapshot/timeline behavior, HLS stream source requests, WebRTC signaling flows, cache behavior, and failure paths.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
homeassistant/components/abode/camera.py Implements snapshot-based stills, HLS stream source via Abode playback URL, and native WebRTC signaling/session management via Abode KVS endpoints.
tests/components/abode/test_camera.py Adds comprehensive test coverage for the new snapshot, HLS, and native WebRTC signaling behaviors (including error handling and caching).
Comment on lines +299 to +307
try:
message_data = json.loads(msg.data)
except (TypeError, json.JSONDecodeError):
return None

message_type = message_data.get("messageType")
encoded_payload = message_data.get("messagePayload")
if not isinstance(message_type, str) or not isinstance(encoded_payload, str):
return None
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

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

Guard decoded websocket JSON with an isinstance(..., dict) check before calling .get() to avoid crashes on non-object messages.

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Addressed in baa880e: added a guard to ensure decoded signaling JSON is a dict before accessing fields.

Comment on lines +330 to +335
return RTCIceCandidateInit(
candidate,
sdp_mid=sdp_mid if isinstance(sdp_mid, str) else None,
sdp_m_line_index=(
sdp_m_line_index if isinstance(sdp_m_line_index, int) else None
),
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

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

Coerce sdpMLineIndex values that arrive as numeric strings into an int so remote ICE candidates keep their m-line index instead of being dropped to None.

Suggested change
return RTCIceCandidateInit(
candidate,
sdp_mid=sdp_mid if isinstance(sdp_mid, str) else None,
sdp_m_line_index=(
sdp_m_line_index if isinstance(sdp_m_line_index, int) else None
),
sdp_m_line_index_int: int | None
if isinstance(sdp_m_line_index, int):
sdp_m_line_index_int = sdp_m_line_index
elif isinstance(sdp_m_line_index, str):
try:
sdp_m_line_index_int = int(sdp_m_line_index)
except (TypeError, ValueError):
sdp_m_line_index_int = None
else:
sdp_m_line_index_int = None
return RTCIceCandidateInit(
candidate,
sdp_mid=sdp_mid if isinstance(sdp_mid, str) else None,
sdp_m_line_index=sdp_m_line_index_int,
Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Addressed in baa880e: fallback ICE parsing now coerces numeric-string sdpMLineIndex values to int, with tests updated to cover it.

Copilot AI review requested due to automatic review settings March 26, 2026 23:03
Copy link
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

Copilot reviewed 2 out of 2 changed files in this pull request and generated 5 comments.

Comment on lines +420 to +424
def _async_get_webrtc_client_configuration(self) -> WebRTCClientConfiguration:
"""Return WebRTC client configuration for Abode cameras."""
return WebRTCClientConfiguration(
configuration=RTCConfiguration(ice_servers=self._webrtc_ice_servers.copy())
)
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

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

The method is named with a leading underscore (_async_get_webrtc_client_configuration), which makes it a private helper and is unlikely to be discovered by the Camera/WebRTC machinery. If this is intended to provide the entity’s WebRTC client configuration to Home Assistant (and the frontend), it should be exposed with the expected public name (e.g., async_get_webrtc_client_configuration) and signature so it’s actually used.

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

No change needed here: this is intentionally overriding Camera._async_get_webrtc_client_configuration(). The public Camera.async_get_webrtc_client_configuration() calls this override and then appends global ICE servers.

LOGGER.warning("Failed to get camera stream URL: %s", err)
return None

playback_url = response.json().get("playbackUrl")
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

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

response.json() is assumed to return a dict; if it returns a non-dict (or JSON decoding fails), this will raise (AttributeError/ValueError) and break stream source retrieval. Consider validating the decoded JSON is a dict (similar to _get_kvs_signaling_info) and handling JSON decode errors so this method reliably returns None on malformed responses.

Suggested change
playback_url = response.json().get("playbackUrl")
try:
playback_data = response.json()
except (ValueError, json.JSONDecodeError) as err:
LOGGER.warning("Failed to decode camera stream response JSON: %s", err)
return None
if not isinstance(playback_data, dict):
LOGGER.warning(
"Failed to get camera stream URL: unexpected response format: %s",
type(playback_data),
)
return None
playback_url = playback_data.get("playbackUrl")
Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 98ae3ab: stream-source parsing now handles JSON decode errors and verifies the decoded payload is a dict before reading playbackUrl.

Comment on lines +147 to +169
def _refresh_snapshot_image(self) -> bool:
"""Attempt to refresh image using Abode snapshot endpoint."""
try:
if not self._device.snapshot():
LOGGER.debug("Camera snapshot request did not return image data")
return False
except AbodeException as err:
LOGGER.warning("Failed to refresh camera snapshot image: %s", err)
return False

snapshot_data_url = self._device.snapshot_data_url(get_snapshot=False)
_, separator, encoded_snapshot = snapshot_data_url.partition(",")
if not separator:
LOGGER.warning(
"Failed to decode camera snapshot: unexpected snapshot format"
)
return False

try:
self._snapshot_image = base64.b64decode(encoded_snapshot)
except (binascii.Error, ValueError) as err:
LOGGER.warning("Failed to decode camera snapshot: %s", err)
return False
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

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

Two concrete robustness gaps here: (1) snapshot_data_url(...) can raise Abode API/auth exceptions and currently isn’t caught, which can bubble out of refresh_image() despite the new resilience goals; wrap it in the same AbodeException handling as snapshot(). (2) base64.b64decode(...) is called without validate=True, which can silently accept/ignore non-base64 characters; using validate=True makes malformed snapshots fail fast and consistently hit the error path you already test.

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 98ae3ab: snapshot_data_url is now wrapped in AbodeException handling, and snapshot base64 decoding uses validate=True for strict validation.

Comment on lines +353 to +364
async def _async_listen_webrtc_messages(
self,
session_id: str,
ws: ClientWebSocketResponse,
send_message: WebRTCSendMessage,
) -> None:
"""Listen for KVS signaling messages for a session."""
try:
while True:
msg = await ws.receive()
if msg.type in (WSMsgType.CLOSE, WSMsgType.CLOSED, WSMsgType.CLOSING):
break
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

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

The listener loop waits indefinitely on ws.receive() with no timeout/heartbeat. If the websocket stays open but becomes silent (or if the frontend never calls close_webrtc_session), the task and session entry can linger indefinitely. Consider adding an idle timeout (and emitting a WebRTCError / closing the session) or using aiohttp heartbeat/ping configuration to ensure sessions self-clean.

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Kept as-is for this PR. Sessions are currently cleaned up via close_webrtc_session and websocket close/error paths. Adding an idle-timeout/heartbeat policy is a good enhancement, but it is a behavioral change that is better handled in a focused follow-up.

Comment on lines +756 to +759
with pytest.raises(HomeAssistantError, match="does not support WebRTC"):
await camera.async_handle_async_webrtc_offer(
"offer-sdp", "session-1", list.append
)
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

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

These tests pass list.append as the send_message callback. list.append is an unbound method that will raise if it ever gets called (missing self), which makes the tests fragile if control flow changes or if a background listener emits an error event earlier than expected. Prefer passing a safe callable (e.g., events.append after creating events: list[Any] = [], or lambda *_: None) in all offer tests.

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 98ae3ab: replaced unbound list.append callbacks with safe no-op lambdas in the offer-failure tests.

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