Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add camera platform to tplink integration #129180

Merged
merged 36 commits into from
Dec 22, 2024
Merged
Show file tree
Hide file tree
Changes from 24 commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
590509d
Add camera entity to tplink integration
sdb9696 Oct 25, 2024
8b790a1
Merge remote-tracking branch 'upstream/dev' into tplink/feat/camera
sdb9696 Nov 21, 2024
46901c6
Set camera module earlier
sdb9696 Nov 21, 2024
69cc6ef
Merge branch 'dev' into tplink/feat/camera
sdb9696 Nov 26, 2024
c04af52
Fix smartcam namespace
sdb9696 Nov 27, 2024
c4331ec
Update post review
sdb9696 Nov 27, 2024
e1be0c1
Add tests
sdb9696 Nov 27, 2024
f23da7a
Update post review
sdb9696 Nov 28, 2024
aae5066
Missed a docstring fix
sdb9696 Nov 28, 2024
476e9ae
Exclude pan/tilt features
sdb9696 Nov 28, 2024
06070a0
Add optionsflow for tplink camera account
sdb9696 Dec 2, 2024
13a405e
Set video url in init
sdb9696 Dec 2, 2024
4d474fd
Configure camera account as part of main ConfigFlow
sdb9696 Dec 3, 2024
9082907
Cleanup and add camera auth flow to all discovery flows
sdb9696 Dec 3, 2024
0ac0256
Fix tests
sdb9696 Dec 3, 2024
db982ae
Merge remote-tracking branch 'upstream/dev' into tplink/feat/camera
sdb9696 Dec 3, 2024
5943282
Add tests
sdb9696 Dec 4, 2024
fd1f601
Merge remote-tracking branch 'upstream/dev' into tplink/feat/camera
sdb9696 Dec 4, 2024
3962423
Apply spacing suggestions from code review
sdb9696 Dec 4, 2024
0c3907e
Apply wording/strings suggestions from code review
sdb9696 Dec 4, 2024
914ba65
Reduce nesting and function size
sdb9696 Dec 4, 2024
5503b4f
Finish test coverage
sdb9696 Dec 4, 2024
f395dcf
Update post review
sdb9696 Dec 4, 2024
2ca2544
Revert conftest change
sdb9696 Dec 4, 2024
347c03b
Merge branch 'dev' into tplink/feat/camera
sdb9696 Dec 10, 2024
f7e6d41
Merge remote-tracking branch 'upstream/dev' into tplink/feat/camera
sdb9696 Dec 20, 2024
6684173
Merge remote-tracking branch 'origin/tplink/feat/camera' into tplink/…
sdb9696 Dec 20, 2024
a6e8701
Use stream.async_check_stream_client_error
sdb9696 Dec 20, 2024
c2b878a
Try mpeg stream on av.open error not 401
sdb9696 Dec 21, 2024
71d9ed1
Try mpeg stream on all errors
sdb9696 Dec 22, 2024
3da886b
Add test for mpeg fallback
sdb9696 Dec 22, 2024
f9f671f
Merge branch 'dev' into tplink/feat/camera
sdb9696 Dec 22, 2024
25e71dd
Remove uv.lock
sdb9696 Dec 22, 2024
7c0c12f
Merge branch 'dev' into tplink/feat/camera
sdb9696 Dec 22, 2024
378cc2f
Use time.mononic
sdb9696 Dec 22, 2024
950f662
Merge remote-tracking branch 'origin/tplink/feat/camera' into tplink/…
sdb9696 Dec 22, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion homeassistant/components/tplink/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@
import asyncio
from collections.abc import Iterable
from datetime import timedelta
from functools import partial
import logging
from typing import Any

from aiohttp import ClientSession
import av
from kasa import (
AuthenticationError,
Credentials,
Expand Down Expand Up @@ -47,10 +49,12 @@

from .const import (
CONF_AES_KEYS,
CONF_CAMERA_CREDENTIALS,
CONF_CONFIG_ENTRY_MINOR_VERSION,
CONF_CONNECTION_PARAMETERS,
CONF_CREDENTIALS_HASH,
CONF_DEVICE_CONFIG,
CONF_LIVE_VIEW,
CONF_USES_HTTP,
CONNECT_TIMEOUT,
DISCOVERY_TIMEOUT,
Expand Down Expand Up @@ -226,7 +230,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: TPLinkConfigEntry) -> bo
for child in device.children
]

entry.runtime_data = TPLinkData(parent_coordinator, child_coordinators)
camera_creds: Credentials | None = None
if camera_creds_dict := entry.data.get(CONF_CAMERA_CREDENTIALS):
camera_creds = Credentials(
camera_creds_dict[CONF_USERNAME], camera_creds_dict[CONF_PASSWORD]
)
live_view = entry.data.get(CONF_LIVE_VIEW)

entry.runtime_data = TPLinkData(
parent_coordinator, child_coordinators, camera_creds, live_view
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)

return True
Expand Down Expand Up @@ -437,3 +450,14 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
"Migration to version %s.%s complete", entry_version, new_minor_version
)
return True


async def async_has_stream_auth_error(hass: HomeAssistant, source: str) -> bool:
"""Return true if rtsp stream raises an HTTPUnauthorizedError error."""
pyav_options = {"rtsp_flags": "prefer_tcp", "timeout": "5000000"}
try:
func = partial(av.open, source, options=pyav_options, timeout=5)
await hass.loop.run_in_executor(None, func)
except av.HTTPUnauthorizedError:
return True
return False
218 changes: 218 additions & 0 deletions homeassistant/components/tplink/camera.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
"""Support for TPLink camera entities."""

import asyncio
from dataclasses import dataclass
import datetime
import logging

from aiohttp import web
from haffmpeg.camera import CameraMjpeg
from kasa import Credentials, Device, Module
from kasa.smartcam.modules import Camera as CameraModule

from homeassistant.components import ffmpeg
from homeassistant.components.camera import (
Camera,
CameraEntityDescription,
CameraEntityFeature,
)
from homeassistant.config_entries import ConfigFlowContext
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream
from homeassistant.helpers.entity_platform import AddEntitiesCallback
import homeassistant.util.dt as dt_util

from . import TPLinkConfigEntry, async_has_stream_auth_error, legacy_device_id
from .const import CONF_CAMERA_CREDENTIALS
from .coordinator import TPLinkDataUpdateCoordinator
from .entity import CoordinatedTPLinkEntity, TPLinkModuleEntityDescription

_LOGGER = logging.getLogger(__name__)


@dataclass(frozen=True, kw_only=True)
class TPLinkCameraEntityDescription(
CameraEntityDescription, TPLinkModuleEntityDescription
):
"""Base class for camera entity description."""
bdraco marked this conversation as resolved.
Show resolved Hide resolved


CAMERA_DESCRIPTIONS: tuple[TPLinkCameraEntityDescription, ...] = (
TPLinkCameraEntityDescription(
key="live_view",
translation_key="live_view",
),
)


async def async_setup_entry(
hass: HomeAssistant,
config_entry: TPLinkConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up camera entities."""
data = config_entry.runtime_data
parent_coordinator = data.parent_coordinator
device = parent_coordinator.device
camera_credentials = data.camera_credentials
live_view = data.live_view
ffmpeg_manager = ffmpeg.get_ffmpeg_manager(hass)

async_add_entities(
TPLinkCameraEntity(
device,
parent_coordinator,
description,
camera_module=camera_module,
parent=None,
ffmpeg_manager=ffmpeg_manager,
camera_credentials=camera_credentials,
)
for description in CAMERA_DESCRIPTIONS
if (camera_module := device.modules.get(Module.Camera)) and live_view
)


class TPLinkCameraEntity(CoordinatedTPLinkEntity, Camera):
"""Representation of a TPLink camera."""

IMAGE_INTERVAL = datetime.timedelta(minutes=5)

_attr_supported_features = CameraEntityFeature.STREAM | CameraEntityFeature.ON_OFF

entity_description: TPLinkCameraEntityDescription

def __init__(
self,
device: Device,
coordinator: TPLinkDataUpdateCoordinator,
description: TPLinkCameraEntityDescription,
*,
camera_module: CameraModule,
parent: Device | None = None,
ffmpeg_manager: ffmpeg.FFmpegManager,
camera_credentials: Credentials | None,
) -> None:
"""Initialize a TPlink camera."""
self.entity_description = description
self._camera_module = camera_module
self._video_url = camera_module.stream_rtsp_url(camera_credentials)
self._image: bytes | None = None
super().__init__(device, coordinator, parent=parent)
sdb9696 marked this conversation as resolved.
Show resolved Hide resolved
Camera.__init__(self)
self._ffmpeg_manager = ffmpeg_manager
self._image_lock = asyncio.Lock()
rytilahti marked this conversation as resolved.
Show resolved Hide resolved
self._last_update = datetime.datetime.min
self._camera_credentials = camera_credentials
self._can_stream = True
self._http_mpeg_stream_running = False

def _get_unique_id(self) -> str:
"""Return unique ID for the entity."""
return f"{legacy_device_id(self._device)}-{self.entity_description}"

sdb9696 marked this conversation as resolved.
Show resolved Hide resolved
@callback
def _async_update_attrs(self) -> None:
"""Update the entity's attributes."""
self._attr_is_on = self._camera_module.is_on

async def stream_source(self) -> str | None:
"""Return the source of the stream."""
return self._video_url

async def _async_check_stream_auth(self, video_url: str) -> None:
"""Check for an auth error and start reauth flow."""
if await async_has_stream_auth_error(self.hass, video_url):
_LOGGER.debug(
"Camera stream failed authentication for %s",
self._device.host,
)
self._can_stream = False
self.coordinator.config_entry.async_start_reauth(
self.hass,
ConfigFlowContext(
reauth_source=CONF_CAMERA_CREDENTIALS, # type: ignore[typeddict-unknown-key]
),
{"device": self._device},
)

async def async_camera_image(
self, width: int | None = None, height: int | None = None
) -> bytes | None:
"""Return a still image response from the camera."""
now = dt_util.utcnow()
sdb9696 marked this conversation as resolved.
Show resolved Hide resolved

if self._image and now - self._last_update < self.IMAGE_INTERVAL:
return self._image

# Don't try to capture a new image if a stream is running
if (self.stream and self.stream.available) or self._http_mpeg_stream_running:
return self._image

if self._can_stream and (video_url := self._video_url):
# Sometimes the front end makes multiple image requests
async with self._image_lock:
if self._image and now - self._last_update < self.IMAGE_INTERVAL:
return self._image

_LOGGER.debug("Updating camera image for %s", self._device.host)
image = await ffmpeg.async_get_image(
self.hass,
video_url,
width=width,
height=height,
)
if image:
self._image = image
self._last_update = now
_LOGGER.debug("Updated camera image for %s", self._device.host)
# This coroutine is called by camera with an asyncio.timeout
# so image could be None whereas an auth issue returns b''
elif image == b"":
_LOGGER.debug(
"Empty camera image returned for %s", self._device.host
)
# image could be empty if a stream is running so check for explicit auth error
await self._async_check_stream_auth(video_url)
else:
_LOGGER.debug(

Check warning on line 178 in homeassistant/components/tplink/camera.py

View check run for this annotation

Codecov / codecov/patch

homeassistant/components/tplink/camera.py#L178

Added line #L178 was not covered by tests
sdb9696 marked this conversation as resolved.
Show resolved Hide resolved
"None camera image returned for %s", self._device.host
)

return self._image

async def handle_async_mjpeg_stream(
self, request: web.Request
) -> web.StreamResponse | None:
"""Generate an HTTP MJPEG stream from the camera.

The frontend falls back to calling this method if the HLS
stream fails.
"""
_LOGGER.debug("Starting http mjpeg stream for %s", self._device.host)
if self._video_url is None or self._can_stream is False:
return None

stream = CameraMjpeg(self._ffmpeg_manager.binary)
await stream.open_camera(self._video_url)
self._http_mpeg_stream_running = True
try:
stream_reader = await stream.get_reader()
return await async_aiohttp_proxy_stream(
self.hass,
request,
stream_reader,
self._ffmpeg_manager.ffmpeg_stream_content_type,
)
finally:
self._http_mpeg_stream_running = False
await stream.close()
_LOGGER.debug("Stopped http mjpeg stream for %s", self._device.host)

async def async_turn_on(self) -> None:
"""Turn on camera."""
await self._camera_module.set_state(True)

async def async_turn_off(self) -> None:
"""Turn off camera."""
await self._camera_module.set_state(False)
Loading