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 7 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
156 changes: 156 additions & 0 deletions homeassistant/components/tplink/camera.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
"""Component providing support to the Ring Door Bell camera."""
sdb9696 marked this conversation as resolved.
Show resolved Hide resolved

from __future__ import annotations
sdb9696 marked this conversation as resolved.
Show resolved Hide resolved

from dataclasses import dataclass
from datetime import timedelta
import logging

from aiohttp import web
from haffmpeg.camera import CameraMjpeg
from kasa import 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.core import HomeAssistant, callback
from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream
from homeassistant.helpers.entity_platform import AddEntitiesCallback

from . import TPLinkConfigEntry, legacy_device_id
from .coordinator import TPLinkDataUpdateCoordinator
from .entity import CoordinatedTPLinkEntity, TPLinkModuleEntityDescription

FORCE_REFRESH_INTERVAL = timedelta(minutes=3)
MOTION_DETECTION_CAPABILITY = "motion_detection"
sdb9696 marked this conversation as resolved.
Show resolved Hide resolved

_LOGGER = logging.getLogger(__name__)


@dataclass(frozen=True, kw_only=True)
class TPLinkCameraEntityDescription(
CameraEntityDescription, TPLinkModuleEntityDescription
):
"""Base class for event entity description."""
sdb9696 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 climate entities."""
sdb9696 marked this conversation as resolved.
Show resolved Hide resolved
data = config_entry.runtime_data
parent_coordinator = data.parent_coordinator
device = parent_coordinator.device
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,
)
for description in CAMERA_DESCRIPTIONS
if (camera_module := device.modules.get(Module.Camera))
)


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

_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,
) -> None:
"""Initialize a TPlink camera."""
self.entity_description = description
self._camera_module = camera_module
self._video_url: str | None = None
self._image: bytes | None = None
super().__init__(device, coordinator, parent=parent)
Camera.__init__(self)
self._ffmpeg_manager = ffmpeg_manager

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
self._video_url = self._camera_module.stream_rtsp_url()
sdb9696 marked this conversation as resolved.
Show resolved Hide resolved

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

async def async_camera_image(
self, width: int | None = None, height: int | None = None
) -> bytes | None:
"""Return a still image response from the camera."""
if self._image is None and (video_url := self._video_url):
image = await ffmpeg.async_get_image(
self.hass,
video_url,
width=width,
height=height,
)
if image:
self._image = image
return self._image

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

stream = CameraMjpeg(self._ffmpeg_manager.binary)
await stream.open_camera(self._video_url)

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:
await stream.close()

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)
1 change: 1 addition & 0 deletions homeassistant/components/tplink/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
PLATFORMS: Final = [
Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.CAMERA,
Platform.CLIMATE,
Platform.FAN,
Platform.LIGHT,
Expand Down
7 changes: 7 additions & 0 deletions homeassistant/components/tplink/entity.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,13 @@ class TPLinkFeatureEntityDescription(EntityDescription):
deprecated_info: DeprecatedInfo | None = None


@dataclass(frozen=True, kw_only=True)
class TPLinkModuleEntityDescription(EntityDescription):
"""Base class for a TPLink module based entity description."""

deprecated_info: DeprecatedInfo | None = None


def async_refresh_after[_T: CoordinatedTPLinkEntity, **_P](
func: Callable[Concatenate[_T, _P], Awaitable[None]],
) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, None]]:
Expand Down
2 changes: 1 addition & 1 deletion homeassistant/components/tplink/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"name": "TP-Link Smart Home",
"codeowners": ["@rytilahti", "@bdraco", "@sdb9696"],
"config_flow": true,
"dependencies": ["network"],
"dependencies": ["network", "ffmpeg", "stream"],
sdb9696 marked this conversation as resolved.
Show resolved Hide resolved
"dhcp": [
{
"registered_devices": true
Expand Down
5 changes: 5 additions & 0 deletions homeassistant/components/tplink/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,11 @@
"name": "Stop alarm"
}
},
"camera": {
"live_view": {
"name": "Live view"
}
},
"select": {
"light_preset": {
"name": "Light preset"
Expand Down
13 changes: 13 additions & 0 deletions tests/components/tplink/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
)
from kasa.interfaces import Fan, Light, LightEffect, LightState
from kasa.smart.modules.alarm import Alarm
from kasa.smartcam.modules.camera import LOCAL_STREAMING_PORT, Camera
from syrupy import SnapshotAssertion

from homeassistant.components.automation import DOMAIN as AUTOMATION_DOMAIN
Expand Down Expand Up @@ -425,6 +426,17 @@ def _mocked_alarm_module(device):
return alarm


def _mocked_camera_module(device):
camera = MagicMock(auto_spec=Camera, name="Mocked camera")
camera.is_on = True
camera.set_state = AsyncMock()
camera.stream_rtsp_url.return_value = (
f"rtsp://user:pass@{device.host}:{LOCAL_STREAMING_PORT}/stream1"
)

return camera


def _mocked_strip_children(features=None, alias=None) -> list[Device]:
plug0 = _mocked_device(
alias="Plug0" if alias is None else alias,
Expand Down Expand Up @@ -492,6 +504,7 @@ def _mocked_energy_features(
Module.LightEffect: _mocked_light_effect_module,
Module.Fan: _mocked_fan_module,
Module.Alarm: _mocked_alarm_module,
Module.Camera: _mocked_camera_module,
}


Expand Down
87 changes: 87 additions & 0 deletions tests/components/tplink/snapshots/test_camera.ambr
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# serializer version: 1
# name: test_states[camera.my_camera_live_view-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'camera',
'entity_category': None,
'entity_id': 'camera.my_camera_live_view',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Live view',
'platform': 'tplink',
'previous_unique_id': None,
'supported_features': <CameraEntityFeature: 3>,
'translation_key': 'live_view',
'unique_id': "123456789ABCDEFGH-TPLinkCameraEntityDescription(key='live_view', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name=<UndefinedType._singleton: 0>, translation_key='live_view', translation_placeholders=None, unit_of_measurement=None, deprecated_info=None)",
'unit_of_measurement': None,
})
# ---
# name: test_states[camera.my_camera_live_view-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'access_token': '1caab5c3b3',
'entity_picture': '/api/camera_proxy/camera.my_camera_live_view?token=1caab5c3b3',
'friendly_name': 'my_camera Live view',
'frontend_stream_type': <StreamType.HLS: 'hls'>,
'supported_features': <CameraEntityFeature: 3>,
}),
'context': <ANY>,
'entity_id': 'camera.my_camera_live_view',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'idle',
})
# ---
# name: test_states[my_camera-entry]
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': <ANY>,
'configuration_url': None,
'connections': set({
tuple(
'mac',
'aa:bb:cc:dd:ee:ff',
),
}),
'disabled_by': None,
'entry_type': None,
'hw_version': '1.0.0',
'id': <ANY>,
'identifiers': set({
tuple(
'tplink',
'123456789ABCDEFGH',
),
}),
'is_new': False,
'labels': set({
}),
'manufacturer': 'TP-Link',
'model': 'HS100',
'model_id': None,
'name': 'my_camera',
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': None,
'suggested_area': None,
'sw_version': '1.0.0',
'via_device_id': None,
})
# ---
Loading