mirror of
https://github.com/home-assistant/core.git
synced 2025-07-23 13:17:32 +00:00
Add camera platform to tplink integration (#129180)
Co-authored-by: Teemu R. <tpr@iki.fi>
This commit is contained in:
parent
475f19c140
commit
b1f6563fb2
@ -47,10 +47,12 @@ from homeassistant.helpers.typing import ConfigType
|
|||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
CONF_AES_KEYS,
|
CONF_AES_KEYS,
|
||||||
|
CONF_CAMERA_CREDENTIALS,
|
||||||
CONF_CONFIG_ENTRY_MINOR_VERSION,
|
CONF_CONFIG_ENTRY_MINOR_VERSION,
|
||||||
CONF_CONNECTION_PARAMETERS,
|
CONF_CONNECTION_PARAMETERS,
|
||||||
CONF_CREDENTIALS_HASH,
|
CONF_CREDENTIALS_HASH,
|
||||||
CONF_DEVICE_CONFIG,
|
CONF_DEVICE_CONFIG,
|
||||||
|
CONF_LIVE_VIEW,
|
||||||
CONF_USES_HTTP,
|
CONF_USES_HTTP,
|
||||||
CONNECT_TIMEOUT,
|
CONNECT_TIMEOUT,
|
||||||
DISCOVERY_TIMEOUT,
|
DISCOVERY_TIMEOUT,
|
||||||
@ -226,7 +228,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: TPLinkConfigEntry) -> bo
|
|||||||
for child in device.children
|
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)
|
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
220
homeassistant/components/tplink/camera.py
Normal file
220
homeassistant/components/tplink/camera.py
Normal file
@ -0,0 +1,220 @@
|
|||||||
|
"""Support for TPLink camera entities."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
|
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, stream
|
||||||
|
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
|
||||||
|
|
||||||
|
from . import TPLinkConfigEntry, 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."""
|
||||||
|
|
||||||
|
|
||||||
|
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 = 5 * 60
|
||||||
|
|
||||||
|
_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)
|
||||||
|
Camera.__init__(self)
|
||||||
|
self._ffmpeg_manager = ffmpeg_manager
|
||||||
|
self._image_lock = asyncio.Lock()
|
||||||
|
self._last_update: float = 0
|
||||||
|
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}"
|
||||||
|
|
||||||
|
@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."""
|
||||||
|
try:
|
||||||
|
await stream.async_check_stream_client_error(self.hass, video_url)
|
||||||
|
except stream.StreamOpenClientError as ex:
|
||||||
|
if ex.stream_client_error is stream.StreamClientError.Unauthorized:
|
||||||
|
_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 = time.monotonic()
|
||||||
|
|
||||||
|
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(
|
||||||
|
"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
|
||||||
|
|
||||||
|
mjpeg_stream = CameraMjpeg(self._ffmpeg_manager.binary)
|
||||||
|
await mjpeg_stream.open_camera(self._video_url)
|
||||||
|
self._http_mpeg_stream_running = True
|
||||||
|
try:
|
||||||
|
stream_reader = await mjpeg_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 mjpeg_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)
|
@ -4,7 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
import logging
|
import logging
|
||||||
from typing import TYPE_CHECKING, Any, Self
|
from typing import TYPE_CHECKING, Any, Self, cast
|
||||||
|
|
||||||
from kasa import (
|
from kasa import (
|
||||||
AuthenticationError,
|
AuthenticationError,
|
||||||
@ -13,13 +13,15 @@ from kasa import (
|
|||||||
DeviceConfig,
|
DeviceConfig,
|
||||||
Discover,
|
Discover,
|
||||||
KasaException,
|
KasaException,
|
||||||
|
Module,
|
||||||
TimeoutError,
|
TimeoutError,
|
||||||
)
|
)
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.components import dhcp
|
from homeassistant.components import dhcp, ffmpeg, stream
|
||||||
from homeassistant.config_entries import (
|
from homeassistant.config_entries import (
|
||||||
SOURCE_REAUTH,
|
SOURCE_REAUTH,
|
||||||
|
SOURCE_RECONFIGURE,
|
||||||
ConfigEntry,
|
ConfigEntry,
|
||||||
ConfigEntryState,
|
ConfigEntryState,
|
||||||
ConfigFlow,
|
ConfigFlow,
|
||||||
@ -31,6 +33,7 @@ from homeassistant.const import (
|
|||||||
CONF_HOST,
|
CONF_HOST,
|
||||||
CONF_MAC,
|
CONF_MAC,
|
||||||
CONF_MODEL,
|
CONF_MODEL,
|
||||||
|
CONF_NAME,
|
||||||
CONF_PASSWORD,
|
CONF_PASSWORD,
|
||||||
CONF_PORT,
|
CONF_PORT,
|
||||||
CONF_USERNAME,
|
CONF_USERNAME,
|
||||||
@ -48,9 +51,11 @@ from . import (
|
|||||||
)
|
)
|
||||||
from .const import (
|
from .const import (
|
||||||
CONF_AES_KEYS,
|
CONF_AES_KEYS,
|
||||||
|
CONF_CAMERA_CREDENTIALS,
|
||||||
CONF_CONFIG_ENTRY_MINOR_VERSION,
|
CONF_CONFIG_ENTRY_MINOR_VERSION,
|
||||||
CONF_CONNECTION_PARAMETERS,
|
CONF_CONNECTION_PARAMETERS,
|
||||||
CONF_CREDENTIALS_HASH,
|
CONF_CREDENTIALS_HASH,
|
||||||
|
CONF_LIVE_VIEW,
|
||||||
CONF_USES_HTTP,
|
CONF_USES_HTTP,
|
||||||
CONNECT_TIMEOUT,
|
CONNECT_TIMEOUT,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
@ -62,6 +67,16 @@ STEP_AUTH_DATA_SCHEMA = vol.Schema(
|
|||||||
{vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str}
|
{vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
STEP_RECONFIGURE_DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str})
|
||||||
|
|
||||||
|
STEP_CAMERA_AUTH_DATA_SCHEMA = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Required(CONF_LIVE_VIEW): bool,
|
||||||
|
vol.Optional(CONF_USERNAME): str,
|
||||||
|
vol.Optional(CONF_PASSWORD): str,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN):
|
class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||||
"""Handle a config flow for tplink."""
|
"""Handle a config flow for tplink."""
|
||||||
@ -227,7 +242,12 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
self.hass.async_create_task(
|
self.hass.async_create_task(
|
||||||
self._async_reload_requires_auth_entries(), eager_start=False
|
self._async_reload_requires_auth_entries(), eager_start=False
|
||||||
)
|
)
|
||||||
return self._async_create_entry_from_device(self._discovered_device)
|
if self._async_supports_camera_credentials(device):
|
||||||
|
return await self.async_step_camera_auth_confirm()
|
||||||
|
|
||||||
|
return self._async_create_or_update_entry_from_device(
|
||||||
|
self._discovered_device
|
||||||
|
)
|
||||||
|
|
||||||
self.context["title_placeholders"] = placeholders
|
self.context["title_placeholders"] = placeholders
|
||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
@ -253,7 +273,12 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
"""Confirm discovery."""
|
"""Confirm discovery."""
|
||||||
assert self._discovered_device is not None
|
assert self._discovered_device is not None
|
||||||
if user_input is not None:
|
if user_input is not None:
|
||||||
return self._async_create_entry_from_device(self._discovered_device)
|
if self._async_supports_camera_credentials(self._discovered_device):
|
||||||
|
return await self.async_step_camera_auth_confirm()
|
||||||
|
|
||||||
|
return self._async_create_or_update_entry_from_device(
|
||||||
|
self._discovered_device
|
||||||
|
)
|
||||||
|
|
||||||
self._set_confirm_only()
|
self._set_confirm_only()
|
||||||
placeholders = self._async_make_placeholders_from_discovery()
|
placeholders = self._async_make_placeholders_from_discovery()
|
||||||
@ -282,6 +307,13 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
|
|
||||||
return host, port
|
return host, port
|
||||||
|
|
||||||
|
def _async_supports_camera_credentials(self, device: Device) -> bool:
|
||||||
|
"""Return True if device could have separate camera credentials."""
|
||||||
|
if camera_module := device.modules.get(Module.Camera):
|
||||||
|
self._discovered_device = device
|
||||||
|
return bool(camera_module.stream_rtsp_url())
|
||||||
|
return False
|
||||||
|
|
||||||
async def async_step_user(
|
async def async_step_user(
|
||||||
self, user_input: dict[str, Any] | None = None
|
self, user_input: dict[str, Any] | None = None
|
||||||
) -> ConfigFlowResult:
|
) -> ConfigFlowResult:
|
||||||
@ -324,7 +356,11 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
else:
|
else:
|
||||||
if not device:
|
if not device:
|
||||||
return await self.async_step_user_auth_confirm()
|
return await self.async_step_user_auth_confirm()
|
||||||
return self._async_create_entry_from_device(device)
|
|
||||||
|
if self._async_supports_camera_credentials(device):
|
||||||
|
return await self.async_step_camera_auth_confirm()
|
||||||
|
|
||||||
|
return self._async_create_or_update_entry_from_device(device)
|
||||||
|
|
||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
step_id="user",
|
step_id="user",
|
||||||
@ -375,7 +411,10 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
self.hass.async_create_task(
|
self.hass.async_create_task(
|
||||||
self._async_reload_requires_auth_entries(), eager_start=False
|
self._async_reload_requires_auth_entries(), eager_start=False
|
||||||
)
|
)
|
||||||
return self._async_create_entry_from_device(device)
|
if self._async_supports_camera_credentials(device):
|
||||||
|
return await self.async_step_camera_auth_confirm()
|
||||||
|
|
||||||
|
return self._async_create_or_update_entry_from_device(device)
|
||||||
|
|
||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
step_id="user_auth_confirm",
|
step_id="user_auth_confirm",
|
||||||
@ -384,6 +423,104 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
description_placeholders=placeholders,
|
description_placeholders=placeholders,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _create_camera_entry(
|
||||||
|
self, device: Device, un: str, pw: str
|
||||||
|
) -> ConfigFlowResult:
|
||||||
|
entry_data: dict[str, bool | dict[str, str]] = {CONF_LIVE_VIEW: True}
|
||||||
|
entry_data[CONF_CAMERA_CREDENTIALS] = {
|
||||||
|
CONF_USERNAME: un,
|
||||||
|
CONF_PASSWORD: pw,
|
||||||
|
}
|
||||||
|
_LOGGER.debug("Creating camera account entry for device %s", device.host)
|
||||||
|
return self._async_create_or_update_entry_from_device(
|
||||||
|
device, camera_data=entry_data
|
||||||
|
)
|
||||||
|
|
||||||
|
async def async_step_camera_auth_confirm(
|
||||||
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
) -> ConfigFlowResult:
|
||||||
|
"""Dialog that gives the user option to set camera credentials."""
|
||||||
|
errors: dict[str, str] = {}
|
||||||
|
placeholders: dict[str, str] = {}
|
||||||
|
device = self._discovered_device
|
||||||
|
assert device
|
||||||
|
|
||||||
|
if user_input:
|
||||||
|
live_view = user_input[CONF_LIVE_VIEW]
|
||||||
|
if not live_view:
|
||||||
|
return self._async_create_or_update_entry_from_device(
|
||||||
|
device, camera_data={CONF_LIVE_VIEW: False}
|
||||||
|
)
|
||||||
|
|
||||||
|
un = user_input.get(CONF_USERNAME)
|
||||||
|
pw = user_input.get(CONF_PASSWORD)
|
||||||
|
|
||||||
|
if user_input and un and pw:
|
||||||
|
camera_creds = Credentials(un, cast(str, pw))
|
||||||
|
|
||||||
|
camera_module = device.modules[Module.Camera]
|
||||||
|
rtsp_url = camera_module.stream_rtsp_url(camera_creds)
|
||||||
|
assert rtsp_url
|
||||||
|
|
||||||
|
# If camera fails to create HLS stream via 'stream' then try
|
||||||
|
# ffmpeg.async_get_image as some cameras do not work with HLS
|
||||||
|
# and the frontend will fallback to mpeg on error
|
||||||
|
try:
|
||||||
|
await stream.async_check_stream_client_error(self.hass, rtsp_url)
|
||||||
|
except stream.StreamOpenClientError as ex:
|
||||||
|
if ex.stream_client_error is stream.StreamClientError.Unauthorized:
|
||||||
|
errors["base"] = "invalid_camera_auth"
|
||||||
|
else:
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Device %s client error checking stream: %s", device.host, ex
|
||||||
|
)
|
||||||
|
if await ffmpeg.async_get_image(self.hass, rtsp_url):
|
||||||
|
return self._create_camera_entry(device, un, pw)
|
||||||
|
|
||||||
|
errors["base"] = "cannot_connect_camera"
|
||||||
|
placeholders["error"] = str(ex)
|
||||||
|
except Exception as ex: # noqa: BLE001
|
||||||
|
_LOGGER.debug("Device %s error checking stream: %s", device.host, ex)
|
||||||
|
if await ffmpeg.async_get_image(self.hass, rtsp_url):
|
||||||
|
return self._create_camera_entry(device, un, pw)
|
||||||
|
|
||||||
|
errors["base"] = "cannot_connect_camera"
|
||||||
|
placeholders["error"] = str(ex)
|
||||||
|
else:
|
||||||
|
return self._create_camera_entry(device, un, pw)
|
||||||
|
|
||||||
|
elif user_input:
|
||||||
|
errors["base"] = "camera_creds"
|
||||||
|
|
||||||
|
entry = None
|
||||||
|
if self.source == SOURCE_RECONFIGURE:
|
||||||
|
entry = self._get_reconfigure_entry()
|
||||||
|
elif self.source == SOURCE_REAUTH:
|
||||||
|
entry = self._get_reauth_entry()
|
||||||
|
|
||||||
|
if entry:
|
||||||
|
placeholders[CONF_NAME] = entry.data[CONF_ALIAS]
|
||||||
|
placeholders[CONF_MODEL] = entry.data[CONF_MODEL]
|
||||||
|
placeholders[CONF_HOST] = entry.data[CONF_HOST]
|
||||||
|
|
||||||
|
if user_input:
|
||||||
|
form_data = {**user_input}
|
||||||
|
elif entry:
|
||||||
|
form_data = {**entry.data.get(CONF_CAMERA_CREDENTIALS, {})}
|
||||||
|
form_data[CONF_LIVE_VIEW] = entry.data.get(CONF_LIVE_VIEW, False)
|
||||||
|
else:
|
||||||
|
form_data = {}
|
||||||
|
|
||||||
|
self.context["title_placeholders"] = placeholders
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="camera_auth_confirm",
|
||||||
|
data_schema=self.add_suggested_values_to_schema(
|
||||||
|
STEP_CAMERA_AUTH_DATA_SCHEMA, form_data
|
||||||
|
),
|
||||||
|
errors=errors,
|
||||||
|
description_placeholders=placeholders,
|
||||||
|
)
|
||||||
|
|
||||||
async def async_step_pick_device(
|
async def async_step_pick_device(
|
||||||
self, user_input: dict[str, Any] | None = None
|
self, user_input: dict[str, Any] | None = None
|
||||||
) -> ConfigFlowResult:
|
) -> ConfigFlowResult:
|
||||||
@ -403,7 +540,11 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
return await self.async_step_user_auth_confirm()
|
return await self.async_step_user_auth_confirm()
|
||||||
except KasaException:
|
except KasaException:
|
||||||
return self.async_abort(reason="cannot_connect")
|
return self.async_abort(reason="cannot_connect")
|
||||||
return self._async_create_entry_from_device(device)
|
|
||||||
|
if self._async_supports_camera_credentials(device):
|
||||||
|
return await self.async_step_camera_auth_confirm()
|
||||||
|
|
||||||
|
return self._async_create_or_update_entry_from_device(device)
|
||||||
|
|
||||||
configured_devices = {
|
configured_devices = {
|
||||||
entry.unique_id for entry in self._async_current_entries()
|
entry.unique_id for entry in self._async_current_entries()
|
||||||
@ -444,11 +585,19 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
_config_entries.flow.async_abort(flow["flow_id"])
|
_config_entries.flow.async_abort(flow["flow_id"])
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def _async_create_entry_from_device(self, device: Device) -> ConfigFlowResult:
|
def _async_create_or_update_entry_from_device(
|
||||||
|
self, device: Device, *, camera_data: dict | None = None
|
||||||
|
) -> ConfigFlowResult:
|
||||||
"""Create a config entry from a smart device."""
|
"""Create a config entry from a smart device."""
|
||||||
# This is only ever called after a successful device update so we know that
|
entry = None
|
||||||
# the credential_hash is correct and should be saved.
|
if self.source == SOURCE_RECONFIGURE:
|
||||||
|
entry = self._get_reconfigure_entry()
|
||||||
|
elif self.source == SOURCE_REAUTH:
|
||||||
|
entry = self._get_reauth_entry()
|
||||||
|
|
||||||
|
if not entry:
|
||||||
self._abort_if_unique_id_configured(updates={CONF_HOST: device.host})
|
self._abort_if_unique_id_configured(updates={CONF_HOST: device.host})
|
||||||
|
|
||||||
data: dict[str, Any] = {
|
data: dict[str, Any] = {
|
||||||
CONF_HOST: device.host,
|
CONF_HOST: device.host,
|
||||||
CONF_ALIAS: device.alias,
|
CONF_ALIAS: device.alias,
|
||||||
@ -456,17 +605,29 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
CONF_CONNECTION_PARAMETERS: device.config.connection_type.to_dict(),
|
CONF_CONNECTION_PARAMETERS: device.config.connection_type.to_dict(),
|
||||||
CONF_USES_HTTP: device.config.uses_http,
|
CONF_USES_HTTP: device.config.uses_http,
|
||||||
}
|
}
|
||||||
|
if camera_data is not None:
|
||||||
|
data[CONF_LIVE_VIEW] = camera_data[CONF_LIVE_VIEW]
|
||||||
|
if camera_creds := camera_data.get(CONF_CAMERA_CREDENTIALS):
|
||||||
|
data[CONF_CAMERA_CREDENTIALS] = camera_creds
|
||||||
|
|
||||||
if device.config.aes_keys:
|
if device.config.aes_keys:
|
||||||
data[CONF_AES_KEYS] = device.config.aes_keys
|
data[CONF_AES_KEYS] = device.config.aes_keys
|
||||||
|
|
||||||
|
# This is only ever called after a successful device update so we know that
|
||||||
|
# the credential_hash is correct and should be saved.
|
||||||
if device.credentials_hash:
|
if device.credentials_hash:
|
||||||
data[CONF_CREDENTIALS_HASH] = device.credentials_hash
|
data[CONF_CREDENTIALS_HASH] = device.credentials_hash
|
||||||
if port := device.config.port_override:
|
if port := device.config.port_override:
|
||||||
data[CONF_PORT] = port
|
data[CONF_PORT] = port
|
||||||
|
|
||||||
|
if not entry:
|
||||||
return self.async_create_entry(
|
return self.async_create_entry(
|
||||||
title=f"{device.alias} {device.model}",
|
title=f"{device.alias} {device.model}",
|
||||||
data=data,
|
data=data,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
return self.async_update_reload_and_abort(entry, data=data)
|
||||||
|
|
||||||
async def _async_try_connect_all(
|
async def _async_try_connect_all(
|
||||||
self,
|
self,
|
||||||
host: str,
|
host: str,
|
||||||
@ -546,6 +707,7 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
credentials: Credentials | None,
|
credentials: Credentials | None,
|
||||||
) -> Device:
|
) -> Device:
|
||||||
"""Try to connect."""
|
"""Try to connect."""
|
||||||
|
if self.source not in {SOURCE_RECONFIGURE, SOURCE_REAUTH}:
|
||||||
self._async_abort_entries_match({CONF_HOST: discovered_device.host})
|
self._async_abort_entries_match({CONF_HOST: discovered_device.host})
|
||||||
|
|
||||||
config = discovered_device.config
|
config = discovered_device.config
|
||||||
@ -566,6 +728,10 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
self, entry_data: Mapping[str, Any]
|
self, entry_data: Mapping[str, Any]
|
||||||
) -> ConfigFlowResult:
|
) -> ConfigFlowResult:
|
||||||
"""Start the reauthentication flow if the device needs updated credentials."""
|
"""Start the reauthentication flow if the device needs updated credentials."""
|
||||||
|
if self.context.get("reauth_source") == CONF_CAMERA_CREDENTIALS:
|
||||||
|
self._discovered_device = entry_data["device"]
|
||||||
|
return await self.async_step_camera_auth_confirm()
|
||||||
|
|
||||||
return await self.async_step_reauth_confirm()
|
return await self.async_step_reauth_confirm()
|
||||||
|
|
||||||
async def async_step_reauth_confirm(
|
async def async_step_reauth_confirm(
|
||||||
@ -634,3 +800,62 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
errors=errors,
|
errors=errors,
|
||||||
description_placeholders=placeholders,
|
description_placeholders=placeholders,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def async_step_reconfigure(
|
||||||
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
) -> ConfigFlowResult:
|
||||||
|
"""Trigger a reconfiguration flow."""
|
||||||
|
errors: dict[str, str] = {}
|
||||||
|
placeholders: dict[str, str] = {}
|
||||||
|
|
||||||
|
reconfigure_entry = self._get_reconfigure_entry()
|
||||||
|
assert reconfigure_entry.unique_id
|
||||||
|
await self.async_set_unique_id(reconfigure_entry.unique_id)
|
||||||
|
|
||||||
|
host = reconfigure_entry.data[CONF_HOST]
|
||||||
|
port = reconfigure_entry.data.get(CONF_PORT)
|
||||||
|
|
||||||
|
if user_input is not None:
|
||||||
|
host, port = self._async_get_host_port(host)
|
||||||
|
|
||||||
|
self.host = host
|
||||||
|
credentials = await get_credentials(self.hass)
|
||||||
|
try:
|
||||||
|
device = await self._async_try_discover_and_update(
|
||||||
|
host,
|
||||||
|
credentials,
|
||||||
|
raise_on_progress=False,
|
||||||
|
raise_on_timeout=False,
|
||||||
|
port=port,
|
||||||
|
) or await self._async_try_connect_all(
|
||||||
|
host,
|
||||||
|
credentials=credentials,
|
||||||
|
raise_on_progress=False,
|
||||||
|
port=port,
|
||||||
|
)
|
||||||
|
except AuthenticationError: # Error from the update()
|
||||||
|
return await self.async_step_user_auth_confirm()
|
||||||
|
except KasaException as ex:
|
||||||
|
errors["base"] = "cannot_connect"
|
||||||
|
placeholders["error"] = str(ex)
|
||||||
|
else:
|
||||||
|
if not device:
|
||||||
|
return await self.async_step_user_auth_confirm()
|
||||||
|
|
||||||
|
if self._async_supports_camera_credentials(device):
|
||||||
|
return await self.async_step_camera_auth_confirm()
|
||||||
|
|
||||||
|
return self._async_create_or_update_entry_from_device(device)
|
||||||
|
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="reconfigure",
|
||||||
|
data_schema=self.add_suggested_values_to_schema(
|
||||||
|
STEP_RECONFIGURE_DATA_SCHEMA,
|
||||||
|
{CONF_HOST: f"{host}:{port}" if port else host},
|
||||||
|
),
|
||||||
|
errors=errors,
|
||||||
|
description_placeholders={
|
||||||
|
**placeholders,
|
||||||
|
CONF_MAC: reconfigure_entry.unique_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
@ -24,12 +24,15 @@ CONF_CREDENTIALS_HASH: Final = "credentials_hash"
|
|||||||
CONF_CONNECTION_PARAMETERS: Final = "connection_parameters"
|
CONF_CONNECTION_PARAMETERS: Final = "connection_parameters"
|
||||||
CONF_USES_HTTP: Final = "uses_http"
|
CONF_USES_HTTP: Final = "uses_http"
|
||||||
CONF_AES_KEYS: Final = "aes_keys"
|
CONF_AES_KEYS: Final = "aes_keys"
|
||||||
|
CONF_CAMERA_CREDENTIALS = "camera_credentials"
|
||||||
|
CONF_LIVE_VIEW = "live_view"
|
||||||
|
|
||||||
CONF_CONFIG_ENTRY_MINOR_VERSION: Final = 5
|
CONF_CONFIG_ENTRY_MINOR_VERSION: Final = 5
|
||||||
|
|
||||||
PLATFORMS: Final = [
|
PLATFORMS: Final = [
|
||||||
Platform.BINARY_SENSOR,
|
Platform.BINARY_SENSOR,
|
||||||
Platform.BUTTON,
|
Platform.BUTTON,
|
||||||
|
Platform.CAMERA,
|
||||||
Platform.CLIMATE,
|
Platform.CLIMATE,
|
||||||
Platform.FAN,
|
Platform.FAN,
|
||||||
Platform.LIGHT,
|
Platform.LIGHT,
|
||||||
|
@ -73,6 +73,13 @@ EXCLUDED_FEATURES = {
|
|||||||
"check_latest_firmware",
|
"check_latest_firmware",
|
||||||
# siren
|
# siren
|
||||||
"alarm",
|
"alarm",
|
||||||
|
# camera
|
||||||
|
"pan_left",
|
||||||
|
"pan_right",
|
||||||
|
"pan_step",
|
||||||
|
"tilt_up",
|
||||||
|
"tilt_down",
|
||||||
|
"tilt_step",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -91,6 +98,13 @@ class TPLinkFeatureEntityDescription(EntityDescription):
|
|||||||
deprecated_info: DeprecatedInfo | None = None
|
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](
|
def async_refresh_after[_T: CoordinatedTPLinkEntity, **_P](
|
||||||
func: Callable[Concatenate[_T, _P], Awaitable[None]],
|
func: Callable[Concatenate[_T, _P], Awaitable[None]],
|
||||||
) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, None]]:
|
) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, None]]:
|
||||||
|
@ -3,7 +3,7 @@
|
|||||||
"name": "TP-Link Smart Home",
|
"name": "TP-Link Smart Home",
|
||||||
"codeowners": ["@rytilahti", "@bdraco", "@sdb9696"],
|
"codeowners": ["@rytilahti", "@bdraco", "@sdb9696"],
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"dependencies": ["network"],
|
"dependencies": ["network", "ffmpeg", "stream"],
|
||||||
"dhcp": [
|
"dhcp": [
|
||||||
{
|
{
|
||||||
"registered_devices": true
|
"registered_devices": true
|
||||||
|
@ -4,6 +4,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from kasa import Credentials
|
||||||
|
|
||||||
from .coordinator import TPLinkDataUpdateCoordinator
|
from .coordinator import TPLinkDataUpdateCoordinator
|
||||||
|
|
||||||
|
|
||||||
@ -13,3 +15,5 @@ class TPLinkData:
|
|||||||
|
|
||||||
parent_coordinator: TPLinkDataUpdateCoordinator
|
parent_coordinator: TPLinkDataUpdateCoordinator
|
||||||
children_coordinators: list[TPLinkDataUpdateCoordinator]
|
children_coordinators: list[TPLinkDataUpdateCoordinator]
|
||||||
|
camera_credentials: Credentials | None
|
||||||
|
live_view: bool | None
|
||||||
|
@ -42,16 +42,36 @@
|
|||||||
"username": "[%key:common::config_flow::data::username%]",
|
"username": "[%key:common::config_flow::data::username%]",
|
||||||
"password": "[%key:common::config_flow::data::password%]"
|
"password": "[%key:common::config_flow::data::password%]"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"reconfigure": {
|
||||||
|
"title": "Reconfigure TPLink entry",
|
||||||
|
"description": "Update your configuration for device {mac}",
|
||||||
|
"data": {
|
||||||
|
"host": "[%key:common::config_flow::data::host%]"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"camera_auth_confirm": {
|
||||||
|
"title": "Set camera account credentials",
|
||||||
|
"description": "Input device camera account credentials. Leave blank if they are the same as your TPLink cloud credentials.",
|
||||||
|
"data": {
|
||||||
|
"live_view": "Enable camera live view",
|
||||||
|
"username": "[%key:common::config_flow::data::username%]",
|
||||||
|
"password": "[%key:common::config_flow::data::password%]"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"cannot_connect": "Connection error: {error}",
|
"cannot_connect": "Connection error: {error}",
|
||||||
"invalid_auth": "Invalid authentication: {error}"
|
"invalid_auth": "Unable to authenticate: {error}",
|
||||||
|
"invalid_camera_auth": "Camera stream authentication failed",
|
||||||
|
"cannot_connect_camera": "Unable to access the camera stream, verify that you have set up the camera account: {error}",
|
||||||
|
"camera_creds": "You have to set both username and password"
|
||||||
},
|
},
|
||||||
"abort": {
|
"abort": {
|
||||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||||
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]",
|
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]",
|
||||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||||
|
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
|
||||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
|
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -102,6 +122,11 @@
|
|||||||
"name": "Stop alarm"
|
"name": "Stop alarm"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"camera": {
|
||||||
|
"live_view": {
|
||||||
|
"name": "Live view"
|
||||||
|
}
|
||||||
|
},
|
||||||
"select": {
|
"select": {
|
||||||
"light_preset": {
|
"light_preset": {
|
||||||
"name": "Light preset"
|
"name": "Light preset"
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
"""Tests for the TP-Link component."""
|
"""Tests for the TP-Link component."""
|
||||||
|
|
||||||
from collections import namedtuple
|
from collections import namedtuple
|
||||||
|
from dataclasses import replace
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
@ -19,15 +20,18 @@ from kasa import (
|
|||||||
)
|
)
|
||||||
from kasa.interfaces import Fan, Light, LightEffect, LightState
|
from kasa.interfaces import Fan, Light, LightEffect, LightState
|
||||||
from kasa.smart.modules.alarm import Alarm
|
from kasa.smart.modules.alarm import Alarm
|
||||||
|
from kasa.smartcam.modules.camera import LOCAL_STREAMING_PORT, Camera
|
||||||
from syrupy import SnapshotAssertion
|
from syrupy import SnapshotAssertion
|
||||||
|
|
||||||
from homeassistant.components.automation import DOMAIN as AUTOMATION_DOMAIN
|
from homeassistant.components.automation import DOMAIN as AUTOMATION_DOMAIN
|
||||||
from homeassistant.components.tplink import (
|
from homeassistant.components.tplink import (
|
||||||
CONF_AES_KEYS,
|
CONF_AES_KEYS,
|
||||||
CONF_ALIAS,
|
CONF_ALIAS,
|
||||||
|
CONF_CAMERA_CREDENTIALS,
|
||||||
CONF_CONNECTION_PARAMETERS,
|
CONF_CONNECTION_PARAMETERS,
|
||||||
CONF_CREDENTIALS_HASH,
|
CONF_CREDENTIALS_HASH,
|
||||||
CONF_HOST,
|
CONF_HOST,
|
||||||
|
CONF_LIVE_VIEW,
|
||||||
CONF_MODEL,
|
CONF_MODEL,
|
||||||
CONF_USES_HTTP,
|
CONF_USES_HTTP,
|
||||||
Credentials,
|
Credentials,
|
||||||
@ -49,14 +53,19 @@ MODULE = "homeassistant.components.tplink"
|
|||||||
MODULE_CONFIG_FLOW = "homeassistant.components.tplink.config_flow"
|
MODULE_CONFIG_FLOW = "homeassistant.components.tplink.config_flow"
|
||||||
IP_ADDRESS = "127.0.0.1"
|
IP_ADDRESS = "127.0.0.1"
|
||||||
IP_ADDRESS2 = "127.0.0.2"
|
IP_ADDRESS2 = "127.0.0.2"
|
||||||
|
IP_ADDRESS3 = "127.0.0.3"
|
||||||
ALIAS = "My Bulb"
|
ALIAS = "My Bulb"
|
||||||
|
ALIAS_CAMERA = "My Camera"
|
||||||
MODEL = "HS100"
|
MODEL = "HS100"
|
||||||
|
MODEL_CAMERA = "C210"
|
||||||
MAC_ADDRESS = "aa:bb:cc:dd:ee:ff"
|
MAC_ADDRESS = "aa:bb:cc:dd:ee:ff"
|
||||||
DEVICE_ID = "123456789ABCDEFGH"
|
DEVICE_ID = "123456789ABCDEFGH"
|
||||||
DEVICE_ID_MAC = "AA:BB:CC:DD:EE:FF"
|
DEVICE_ID_MAC = "AA:BB:CC:DD:EE:FF"
|
||||||
DHCP_FORMATTED_MAC_ADDRESS = MAC_ADDRESS.replace(":", "")
|
DHCP_FORMATTED_MAC_ADDRESS = MAC_ADDRESS.replace(":", "")
|
||||||
MAC_ADDRESS2 = "11:22:33:44:55:66"
|
MAC_ADDRESS2 = "11:22:33:44:55:66"
|
||||||
|
MAC_ADDRESS3 = "66:55:44:33:22:11"
|
||||||
DEFAULT_ENTRY_TITLE = f"{ALIAS} {MODEL}"
|
DEFAULT_ENTRY_TITLE = f"{ALIAS} {MODEL}"
|
||||||
|
DEFAULT_ENTRY_TITLE_CAMERA = f"{ALIAS_CAMERA} {MODEL_CAMERA}"
|
||||||
CREDENTIALS_HASH_LEGACY = ""
|
CREDENTIALS_HASH_LEGACY = ""
|
||||||
CONN_PARAMS_LEGACY = DeviceConnectionParameters(
|
CONN_PARAMS_LEGACY = DeviceConnectionParameters(
|
||||||
DeviceFamily.IotSmartPlugSwitch, DeviceEncryptionType.Xor
|
DeviceFamily.IotSmartPlugSwitch, DeviceEncryptionType.Xor
|
||||||
@ -80,7 +89,26 @@ DEVICE_CONFIG_KLAP = DeviceConfig(
|
|||||||
CONN_PARAMS_AES = DeviceConnectionParameters(
|
CONN_PARAMS_AES = DeviceConnectionParameters(
|
||||||
DeviceFamily.SmartTapoPlug, DeviceEncryptionType.Aes
|
DeviceFamily.SmartTapoPlug, DeviceEncryptionType.Aes
|
||||||
)
|
)
|
||||||
AES_KEYS = {"private": "foo", "public": "bar"}
|
_test_privkey = (
|
||||||
|
"MIICdwIBADANBgkqhkiG9w0BAQEFAASCAmEwggJdAgEAAoGBAKLJKmBWGj6WYo9sewI8vkqar"
|
||||||
|
"Ed5H1JUr8Jj/LEWLTtV6+Mm4mfyEk6YKFHSmIG4AGgrVsGK/EbEkTZk9CwtixNQpBVc36oN2R"
|
||||||
|
"vuWWV38YnP4vI63mNxTA/gQonCsahjN4HfwE87pM7O5z39aeunoYm6Be663t33DbJH1ZUbZjm"
|
||||||
|
"tAgMBAAECgYB1Bn1KaFvRprcQOIJt51E9vNghQbf8rhj0fIEKpdC6mVhNIoUdCO+URNqnh+hP"
|
||||||
|
"SQIx4QYreUlHbsSeABFxOQSDJm6/kqyQsp59nCVDo/bXTtlvcSJ/sU3riqJNxYqEU1iJ0xMvU"
|
||||||
|
"N1VKKTmik89J8e5sN9R0AFfUSJIk7MpdOoD2QJBANTbV27nenyvbqee/ul4frdt2rrPGcGpcV"
|
||||||
|
"QmY87qbbrZgqgL5LMHHD7T/v/I8D1wRog1sBz/AiZGcnv/ox8dHKsCQQDDx8DCGPySSVqKVua"
|
||||||
|
"yUkBNpglN83wiCXZjyEtWIt+aB1A2n5ektE/o8oHnnOuvMdooxvtid7Mdapi2VLHV7VMHAkAE"
|
||||||
|
"d0GjWwnv2cJpk+VnQpbuBEkFiFjS/loZWODZM4Pv2qZqHi3DL9AA5XPBLBcWQufH7dBvG06RP"
|
||||||
|
"QMj5N4oRfUXAkEAuJJkVliqHNvM4OkGewzyFII4+WVYHNqg43dcFuuvtA27AJQ6qYtYXrvp3k"
|
||||||
|
"phI3yzOIhHTNCea1goepSkR5ODFwJBAJCTRbB+P47aEr/xA51ZFHE6VefDBJG9yg6yK4jcOxg"
|
||||||
|
"5ficXEpx8442okNtlzwa+QHpm/L3JOFrHwiEeVqXtiqY="
|
||||||
|
)
|
||||||
|
_test_pubkey = (
|
||||||
|
"MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCiySpgVho+lmKPbHsCPL5KmqxHeR9SVK/CY"
|
||||||
|
"/yxFi07VevjJuJn8hJOmChR0piBuABoK1bBivxGxJE2ZPQsLYsTUKQVXN+qDdkb7llld/GJz+"
|
||||||
|
"LyOt5jcUwP4EKJwrGoYzeB38BPO6TOzuc9/Wnrp6GJugXuut7d9w2yR9WVG2Y5rQIDAQAB"
|
||||||
|
)
|
||||||
|
AES_KEYS = {"private": _test_privkey, "public": _test_pubkey}
|
||||||
DEVICE_CONFIG_AES = DeviceConfig(
|
DEVICE_CONFIG_AES = DeviceConfig(
|
||||||
IP_ADDRESS2,
|
IP_ADDRESS2,
|
||||||
credentials=CREDENTIALS,
|
credentials=CREDENTIALS,
|
||||||
@ -88,6 +116,16 @@ DEVICE_CONFIG_AES = DeviceConfig(
|
|||||||
uses_http=True,
|
uses_http=True,
|
||||||
aes_keys=AES_KEYS,
|
aes_keys=AES_KEYS,
|
||||||
)
|
)
|
||||||
|
CONN_PARAMS_AES_CAMERA = DeviceConnectionParameters(
|
||||||
|
DeviceFamily.SmartIpCamera, DeviceEncryptionType.Aes, https=True, login_version=2
|
||||||
|
)
|
||||||
|
DEVICE_CONFIG_AES_CAMERA = DeviceConfig(
|
||||||
|
IP_ADDRESS3,
|
||||||
|
credentials=CREDENTIALS,
|
||||||
|
connection_type=CONN_PARAMS_AES_CAMERA,
|
||||||
|
uses_http=True,
|
||||||
|
)
|
||||||
|
|
||||||
DEVICE_CONFIG_DICT_KLAP = {
|
DEVICE_CONFIG_DICT_KLAP = {
|
||||||
k: v for k, v in DEVICE_CONFIG_KLAP.to_dict().items() if k != "credentials"
|
k: v for k, v in DEVICE_CONFIG_KLAP.to_dict().items() if k != "credentials"
|
||||||
}
|
}
|
||||||
@ -119,6 +157,22 @@ CREATE_ENTRY_DATA_AES = {
|
|||||||
CONF_USES_HTTP: True,
|
CONF_USES_HTTP: True,
|
||||||
CONF_AES_KEYS: AES_KEYS,
|
CONF_AES_KEYS: AES_KEYS,
|
||||||
}
|
}
|
||||||
|
CREATE_ENTRY_DATA_AES_CAMERA = {
|
||||||
|
CONF_HOST: IP_ADDRESS3,
|
||||||
|
CONF_ALIAS: ALIAS_CAMERA,
|
||||||
|
CONF_MODEL: MODEL_CAMERA,
|
||||||
|
CONF_CREDENTIALS_HASH: CREDENTIALS_HASH_AES,
|
||||||
|
CONF_CONNECTION_PARAMETERS: CONN_PARAMS_AES_CAMERA.to_dict(),
|
||||||
|
CONF_USES_HTTP: True,
|
||||||
|
CONF_LIVE_VIEW: True,
|
||||||
|
CONF_CAMERA_CREDENTIALS: {"username": "camuser", "password": "campass"},
|
||||||
|
}
|
||||||
|
SMALLEST_VALID_JPEG = (
|
||||||
|
"ffd8ffe000104a46494600010101004800480000ffdb00430003020202020203020202030303030406040404040408060"
|
||||||
|
"6050609080a0a090809090a0c0f0c0a0b0e0b09090d110d0e0f101011100a0c12131210130f101010ffc9000b08000100"
|
||||||
|
"0101011100ffcc000600101005ffda0008010100003f00d2cf20ffd9"
|
||||||
|
)
|
||||||
|
SMALLEST_VALID_JPEG_BYTES = bytes.fromhex(SMALLEST_VALID_JPEG)
|
||||||
|
|
||||||
|
|
||||||
def _load_feature_fixtures():
|
def _load_feature_fixtures():
|
||||||
@ -245,6 +299,9 @@ def _mocked_device(
|
|||||||
device.modules = {}
|
device.modules = {}
|
||||||
device.features = {}
|
device.features = {}
|
||||||
|
|
||||||
|
# replace device_config to prevent changes affecting between tests
|
||||||
|
device_config = replace(device_config)
|
||||||
|
|
||||||
if not ip_address:
|
if not ip_address:
|
||||||
ip_address = IP_ADDRESS
|
ip_address = IP_ADDRESS
|
||||||
else:
|
else:
|
||||||
@ -429,6 +486,17 @@ def _mocked_alarm_module(device):
|
|||||||
return alarm
|
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]:
|
def _mocked_strip_children(features=None, alias=None) -> list[Device]:
|
||||||
plug0 = _mocked_device(
|
plug0 = _mocked_device(
|
||||||
alias="Plug0" if alias is None else alias,
|
alias="Plug0" if alias is None else alias,
|
||||||
@ -496,6 +564,7 @@ MODULE_TO_MOCK_GEN = {
|
|||||||
Module.LightEffect: _mocked_light_effect_module,
|
Module.LightEffect: _mocked_light_effect_module,
|
||||||
Module.Fan: _mocked_fan_module,
|
Module.Fan: _mocked_fan_module,
|
||||||
Module.Alarm: _mocked_alarm_module,
|
Module.Alarm: _mocked_alarm_module,
|
||||||
|
Module.Camera: _mocked_camera_module,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -1,63 +1,50 @@
|
|||||||
"""tplink conftest."""
|
"""tplink conftest."""
|
||||||
|
|
||||||
from collections.abc import Generator
|
from collections.abc import Generator
|
||||||
|
from contextlib import contextmanager
|
||||||
from unittest.mock import DEFAULT, AsyncMock, patch
|
from unittest.mock import DEFAULT, AsyncMock, patch
|
||||||
|
|
||||||
from kasa import DeviceConfig
|
from kasa import DeviceConfig, Module
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from homeassistant.components.tplink import DOMAIN
|
from homeassistant.components.tplink import DOMAIN
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
from . import (
|
from . import (
|
||||||
|
ALIAS_CAMERA,
|
||||||
|
CREATE_ENTRY_DATA_AES_CAMERA,
|
||||||
CREATE_ENTRY_DATA_LEGACY,
|
CREATE_ENTRY_DATA_LEGACY,
|
||||||
CREDENTIALS_HASH_AES,
|
CREDENTIALS_HASH_AES,
|
||||||
CREDENTIALS_HASH_KLAP,
|
CREDENTIALS_HASH_KLAP,
|
||||||
DEVICE_CONFIG_AES,
|
DEVICE_CONFIG_AES,
|
||||||
|
DEVICE_CONFIG_AES_CAMERA,
|
||||||
DEVICE_CONFIG_KLAP,
|
DEVICE_CONFIG_KLAP,
|
||||||
IP_ADDRESS,
|
IP_ADDRESS,
|
||||||
IP_ADDRESS2,
|
IP_ADDRESS2,
|
||||||
|
IP_ADDRESS3,
|
||||||
MAC_ADDRESS,
|
MAC_ADDRESS,
|
||||||
MAC_ADDRESS2,
|
MAC_ADDRESS2,
|
||||||
|
MAC_ADDRESS3,
|
||||||
|
MODEL_CAMERA,
|
||||||
_mocked_device,
|
_mocked_device,
|
||||||
)
|
)
|
||||||
|
|
||||||
from tests.common import MockConfigEntry
|
from tests.common import MockConfigEntry
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@contextmanager
|
||||||
def mock_discovery():
|
def override_side_effect(mock: AsyncMock, effect):
|
||||||
"""Mock python-kasa discovery."""
|
"""Temporarily override a mock side effect and replace afterwards."""
|
||||||
with patch.multiple(
|
try:
|
||||||
"homeassistant.components.tplink.Discover",
|
default_side_effect = mock.side_effect
|
||||||
discover=DEFAULT,
|
mock.side_effect = effect
|
||||||
discover_single=DEFAULT,
|
yield mock
|
||||||
try_connect_all=DEFAULT,
|
finally:
|
||||||
) as mock_discovery:
|
mock.side_effect = default_side_effect
|
||||||
device = _mocked_device(
|
|
||||||
device_config=DeviceConfig.from_dict(DEVICE_CONFIG_KLAP.to_dict()),
|
|
||||||
credentials_hash=CREDENTIALS_HASH_KLAP,
|
|
||||||
alias="My Bulb",
|
|
||||||
)
|
|
||||||
devices = {
|
|
||||||
"127.0.0.1": _mocked_device(
|
|
||||||
device_config=DeviceConfig.from_dict(DEVICE_CONFIG_KLAP.to_dict()),
|
|
||||||
credentials_hash=CREDENTIALS_HASH_KLAP,
|
|
||||||
alias=None,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
mock_discovery["discover"].return_value = devices
|
|
||||||
mock_discovery["discover_single"].return_value = device
|
|
||||||
mock_discovery["try_connect_all"].return_value = device
|
|
||||||
mock_discovery["mock_device"] = device
|
|
||||||
yield mock_discovery
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
def _get_mock_devices():
|
||||||
def mock_connect():
|
return {
|
||||||
"""Mock python-kasa connect."""
|
|
||||||
with patch("homeassistant.components.tplink.Device.connect") as mock_connect:
|
|
||||||
devices = {
|
|
||||||
IP_ADDRESS: _mocked_device(
|
IP_ADDRESS: _mocked_device(
|
||||||
device_config=DeviceConfig.from_dict(DEVICE_CONFIG_KLAP.to_dict()),
|
device_config=DeviceConfig.from_dict(DEVICE_CONFIG_KLAP.to_dict()),
|
||||||
credentials_hash=CREDENTIALS_HASH_KLAP,
|
credentials_hash=CREDENTIALS_HASH_KLAP,
|
||||||
@ -69,10 +56,46 @@ def mock_connect():
|
|||||||
mac=MAC_ADDRESS2,
|
mac=MAC_ADDRESS2,
|
||||||
ip_address=IP_ADDRESS2,
|
ip_address=IP_ADDRESS2,
|
||||||
),
|
),
|
||||||
|
IP_ADDRESS3: _mocked_device(
|
||||||
|
device_config=DeviceConfig.from_dict(DEVICE_CONFIG_AES_CAMERA.to_dict()),
|
||||||
|
credentials_hash=CREDENTIALS_HASH_AES,
|
||||||
|
mac=MAC_ADDRESS3,
|
||||||
|
ip_address=IP_ADDRESS3,
|
||||||
|
modules=[Module.Camera],
|
||||||
|
alias=ALIAS_CAMERA,
|
||||||
|
model=MODEL_CAMERA,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_discovery():
|
||||||
|
"""Mock python-kasa discovery."""
|
||||||
|
with patch.multiple(
|
||||||
|
"homeassistant.components.tplink.Discover",
|
||||||
|
discover=DEFAULT,
|
||||||
|
discover_single=DEFAULT,
|
||||||
|
try_connect_all=DEFAULT,
|
||||||
|
) as mock_discovery:
|
||||||
|
devices = _get_mock_devices()
|
||||||
|
|
||||||
|
def get_device(host, **kwargs):
|
||||||
|
return devices[host]
|
||||||
|
|
||||||
|
mock_discovery["discover"].return_value = devices
|
||||||
|
mock_discovery["discover_single"].side_effect = get_device
|
||||||
|
mock_discovery["try_connect_all"].side_effect = get_device
|
||||||
|
mock_discovery["mock_devices"] = devices
|
||||||
|
yield mock_discovery
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_connect():
|
||||||
|
"""Mock python-kasa connect."""
|
||||||
|
with patch("homeassistant.components.tplink.Device.connect") as mock_connect:
|
||||||
|
devices = _get_mock_devices()
|
||||||
|
|
||||||
def get_device(config):
|
def get_device(config):
|
||||||
nonlocal devices
|
|
||||||
return devices[config.host]
|
return devices[config.host]
|
||||||
|
|
||||||
mock_connect.side_effect = get_device
|
mock_connect.side_effect = get_device
|
||||||
@ -117,6 +140,17 @@ def mock_config_entry() -> MockConfigEntry:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_camera_config_entry() -> MockConfigEntry:
|
||||||
|
"""Mock camera ConfigEntry."""
|
||||||
|
return MockConfigEntry(
|
||||||
|
title="TPLink",
|
||||||
|
domain=DOMAIN,
|
||||||
|
data={**CREATE_ENTRY_DATA_AES_CAMERA},
|
||||||
|
unique_id=MAC_ADDRESS3,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
async def mock_added_config_entry(
|
async def mock_added_config_entry(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
|
@ -320,5 +320,35 @@
|
|||||||
"type": "Sensor",
|
"type": "Sensor",
|
||||||
"category": "Info",
|
"category": "Info",
|
||||||
"value": "2024-06-24 10:03:11.046643+01:00"
|
"value": "2024-06-24 10:03:11.046643+01:00"
|
||||||
|
},
|
||||||
|
"pan_left": {
|
||||||
|
"value": "<Action>",
|
||||||
|
"type": "Action",
|
||||||
|
"category": "Config"
|
||||||
|
},
|
||||||
|
"pan_right": {
|
||||||
|
"value": "<Action>",
|
||||||
|
"type": "Action",
|
||||||
|
"category": "Config"
|
||||||
|
},
|
||||||
|
"pan_step": {
|
||||||
|
"value": 10,
|
||||||
|
"type": "Number",
|
||||||
|
"category": "Config"
|
||||||
|
},
|
||||||
|
"tilt_up": {
|
||||||
|
"value": "<Action>",
|
||||||
|
"type": "Action",
|
||||||
|
"category": "Config"
|
||||||
|
},
|
||||||
|
"tilt_down": {
|
||||||
|
"value": "<Action>",
|
||||||
|
"type": "Action",
|
||||||
|
"category": "Config"
|
||||||
|
},
|
||||||
|
"tilt_step": {
|
||||||
|
"value": 10,
|
||||||
|
"type": "Number",
|
||||||
|
"category": "Config"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
87
tests/components/tplink/snapshots/test_camera.ambr
Normal file
87
tests/components/tplink/snapshots/test_camera.ambr
Normal file
@ -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',
|
||||||
|
'66:55:44:33:22:11',
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
'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,
|
||||||
|
})
|
||||||
|
# ---
|
431
tests/components/tplink/test_camera.py
Normal file
431
tests/components/tplink/test_camera.py
Normal file
@ -0,0 +1,431 @@
|
|||||||
|
"""The tests for the tplink camera platform."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
from aiohttp.test_utils import make_mocked_request
|
||||||
|
from freezegun.api import FrozenDateTimeFactory
|
||||||
|
from kasa import Module
|
||||||
|
import pytest
|
||||||
|
from syrupy.assertion import SnapshotAssertion
|
||||||
|
|
||||||
|
from homeassistant.components import stream
|
||||||
|
from homeassistant.components.camera import (
|
||||||
|
CameraEntityFeature,
|
||||||
|
StreamType,
|
||||||
|
async_get_image,
|
||||||
|
async_get_mjpeg_stream,
|
||||||
|
get_camera_from_entity_id,
|
||||||
|
)
|
||||||
|
from homeassistant.components.tplink.camera import TPLinkCameraEntity
|
||||||
|
from homeassistant.components.websocket_api import TYPE_RESULT
|
||||||
|
from homeassistant.const import Platform
|
||||||
|
from homeassistant.core import HomeAssistant, HomeAssistantError
|
||||||
|
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||||
|
|
||||||
|
from . import (
|
||||||
|
IP_ADDRESS3,
|
||||||
|
MAC_ADDRESS3,
|
||||||
|
SMALLEST_VALID_JPEG_BYTES,
|
||||||
|
_mocked_device,
|
||||||
|
setup_platform_for_device,
|
||||||
|
snapshot_platform,
|
||||||
|
)
|
||||||
|
|
||||||
|
from tests.common import MockConfigEntry, async_fire_time_changed
|
||||||
|
from tests.typing import WebSocketGenerator
|
||||||
|
|
||||||
|
|
||||||
|
async def test_states(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_camera_config_entry: MockConfigEntry,
|
||||||
|
entity_registry: er.EntityRegistry,
|
||||||
|
device_registry: dr.DeviceRegistry,
|
||||||
|
snapshot: SnapshotAssertion,
|
||||||
|
) -> None:
|
||||||
|
"""Test states."""
|
||||||
|
mock_camera_config_entry.add_to_hass(hass)
|
||||||
|
|
||||||
|
mock_device = _mocked_device(
|
||||||
|
modules=[Module.Camera],
|
||||||
|
alias="my_camera",
|
||||||
|
ip_address=IP_ADDRESS3,
|
||||||
|
mac=MAC_ADDRESS3,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Patch getrandbits so the access_token doesn't change on camera attributes
|
||||||
|
with patch("random.SystemRandom.getrandbits", return_value=123123123123):
|
||||||
|
await setup_platform_for_device(
|
||||||
|
hass, mock_camera_config_entry, Platform.CAMERA, mock_device
|
||||||
|
)
|
||||||
|
|
||||||
|
await snapshot_platform(
|
||||||
|
hass,
|
||||||
|
entity_registry,
|
||||||
|
device_registry,
|
||||||
|
snapshot,
|
||||||
|
mock_camera_config_entry.entry_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_handle_mjpeg_stream(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_camera_config_entry: MockConfigEntry,
|
||||||
|
freezer: FrozenDateTimeFactory,
|
||||||
|
) -> None:
|
||||||
|
"""Test handle_async_mjpeg_stream."""
|
||||||
|
mock_device = _mocked_device(
|
||||||
|
modules=[Module.Camera],
|
||||||
|
alias="my_camera",
|
||||||
|
ip_address=IP_ADDRESS3,
|
||||||
|
mac=MAC_ADDRESS3,
|
||||||
|
)
|
||||||
|
|
||||||
|
await setup_platform_for_device(
|
||||||
|
hass, mock_camera_config_entry, Platform.CAMERA, mock_device
|
||||||
|
)
|
||||||
|
|
||||||
|
state = hass.states.get("camera.my_camera_live_view")
|
||||||
|
assert state is not None
|
||||||
|
|
||||||
|
mock_request = make_mocked_request("GET", "/", headers={"token": "x"})
|
||||||
|
stream = await async_get_mjpeg_stream(
|
||||||
|
hass, mock_request, "camera.my_camera_live_view"
|
||||||
|
)
|
||||||
|
assert stream is not None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_handle_mjpeg_stream_not_supported(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_camera_config_entry: MockConfigEntry,
|
||||||
|
freezer: FrozenDateTimeFactory,
|
||||||
|
) -> None:
|
||||||
|
"""Test handle_async_mjpeg_stream."""
|
||||||
|
mock_device = _mocked_device(
|
||||||
|
modules=[Module.Camera],
|
||||||
|
alias="my_camera",
|
||||||
|
ip_address=IP_ADDRESS3,
|
||||||
|
mac=MAC_ADDRESS3,
|
||||||
|
)
|
||||||
|
mock_camera = mock_device.modules[Module.Camera]
|
||||||
|
|
||||||
|
mock_camera.stream_rtsp_url.return_value = None
|
||||||
|
|
||||||
|
await setup_platform_for_device(
|
||||||
|
hass, mock_camera_config_entry, Platform.CAMERA, mock_device
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_request = make_mocked_request("GET", "/", headers={"token": "x"})
|
||||||
|
stream = await async_get_mjpeg_stream(
|
||||||
|
hass, mock_request, "camera.my_camera_live_view"
|
||||||
|
)
|
||||||
|
assert stream is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_camera_image(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_camera_config_entry: MockConfigEntry,
|
||||||
|
freezer: FrozenDateTimeFactory,
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
) -> None:
|
||||||
|
"""Test async_get_image."""
|
||||||
|
mock_device = _mocked_device(
|
||||||
|
modules=[Module.Camera],
|
||||||
|
alias="my_camera",
|
||||||
|
ip_address=IP_ADDRESS3,
|
||||||
|
mac=MAC_ADDRESS3,
|
||||||
|
)
|
||||||
|
|
||||||
|
await setup_platform_for_device(
|
||||||
|
hass, mock_camera_config_entry, Platform.CAMERA, mock_device
|
||||||
|
)
|
||||||
|
|
||||||
|
state = hass.states.get("camera.my_camera_live_view")
|
||||||
|
assert state is not None
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.ffmpeg.async_get_image",
|
||||||
|
return_value=SMALLEST_VALID_JPEG_BYTES,
|
||||||
|
) as mock_get_image:
|
||||||
|
image = await async_get_image(hass, "camera.my_camera_live_view")
|
||||||
|
assert image
|
||||||
|
assert image.content == SMALLEST_VALID_JPEG_BYTES
|
||||||
|
mock_get_image.assert_called_once()
|
||||||
|
|
||||||
|
mock_get_image.reset_mock()
|
||||||
|
image = await async_get_image(hass, "camera.my_camera_live_view")
|
||||||
|
mock_get_image.assert_not_called()
|
||||||
|
|
||||||
|
freezer.tick(TPLinkCameraEntity.IMAGE_INTERVAL)
|
||||||
|
async_fire_time_changed(hass)
|
||||||
|
await hass.async_block_till_done(wait_background_tasks=True)
|
||||||
|
|
||||||
|
mock_get_image.reset_mock()
|
||||||
|
image = await async_get_image(hass, "camera.my_camera_live_view")
|
||||||
|
mock_get_image.assert_called_once()
|
||||||
|
|
||||||
|
freezer.tick(TPLinkCameraEntity.IMAGE_INTERVAL)
|
||||||
|
async_fire_time_changed(hass)
|
||||||
|
await hass.async_block_till_done(wait_background_tasks=True)
|
||||||
|
|
||||||
|
# Test image returns None
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.ffmpeg.async_get_image",
|
||||||
|
return_value=None,
|
||||||
|
) as mock_get_image:
|
||||||
|
msg = f"None camera image returned for {IP_ADDRESS3}"
|
||||||
|
assert msg not in caplog.text
|
||||||
|
|
||||||
|
mock_get_image.reset_mock()
|
||||||
|
image = await async_get_image(hass, "camera.my_camera_live_view")
|
||||||
|
mock_get_image.assert_called_once()
|
||||||
|
|
||||||
|
assert msg in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
async def test_no_camera_image_when_streaming(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_camera_config_entry: MockConfigEntry,
|
||||||
|
freezer: FrozenDateTimeFactory,
|
||||||
|
) -> None:
|
||||||
|
"""Test async_get_image."""
|
||||||
|
mock_device = _mocked_device(
|
||||||
|
modules=[Module.Camera],
|
||||||
|
alias="my_camera",
|
||||||
|
ip_address=IP_ADDRESS3,
|
||||||
|
mac=MAC_ADDRESS3,
|
||||||
|
)
|
||||||
|
|
||||||
|
await setup_platform_for_device(
|
||||||
|
hass, mock_camera_config_entry, Platform.CAMERA, mock_device
|
||||||
|
)
|
||||||
|
|
||||||
|
state = hass.states.get("camera.my_camera_live_view")
|
||||||
|
assert state is not None
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.ffmpeg.async_get_image",
|
||||||
|
return_value=SMALLEST_VALID_JPEG_BYTES,
|
||||||
|
) as mock_get_image:
|
||||||
|
await async_get_image(hass, "camera.my_camera_live_view")
|
||||||
|
mock_get_image.assert_called_once()
|
||||||
|
|
||||||
|
freezer.tick(TPLinkCameraEntity.IMAGE_INTERVAL)
|
||||||
|
async_fire_time_changed(hass)
|
||||||
|
await hass.async_block_till_done(wait_background_tasks=True)
|
||||||
|
|
||||||
|
start_event = asyncio.Event()
|
||||||
|
finish_event = asyncio.Event()
|
||||||
|
|
||||||
|
async def _waiter(*_, **__):
|
||||||
|
start_event.set()
|
||||||
|
await finish_event.wait()
|
||||||
|
|
||||||
|
async def _get_stream():
|
||||||
|
mock_request = make_mocked_request("GET", "/", headers={"token": "x"})
|
||||||
|
await async_get_mjpeg_stream(
|
||||||
|
hass, mock_request, "camera.my_camera_live_view"
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_get_image.reset_mock()
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.tplink.camera.async_aiohttp_proxy_stream",
|
||||||
|
new=_waiter,
|
||||||
|
):
|
||||||
|
task = asyncio.create_task(_get_stream())
|
||||||
|
await start_event.wait()
|
||||||
|
await async_get_image(hass, "camera.my_camera_live_view")
|
||||||
|
finish_event.set()
|
||||||
|
await task
|
||||||
|
|
||||||
|
mock_get_image.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_no_concurrent_camera_image(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_camera_config_entry: MockConfigEntry,
|
||||||
|
freezer: FrozenDateTimeFactory,
|
||||||
|
) -> None:
|
||||||
|
"""Test async_get_image."""
|
||||||
|
mock_device = _mocked_device(
|
||||||
|
modules=[Module.Camera],
|
||||||
|
alias="my_camera",
|
||||||
|
ip_address=IP_ADDRESS3,
|
||||||
|
mac=MAC_ADDRESS3,
|
||||||
|
)
|
||||||
|
|
||||||
|
await setup_platform_for_device(
|
||||||
|
hass, mock_camera_config_entry, Platform.CAMERA, mock_device
|
||||||
|
)
|
||||||
|
|
||||||
|
state = hass.states.get("camera.my_camera_live_view")
|
||||||
|
assert state is not None
|
||||||
|
|
||||||
|
finish_event = asyncio.Event()
|
||||||
|
call_count = 0
|
||||||
|
|
||||||
|
async def _waiter(*_, **__):
|
||||||
|
nonlocal call_count
|
||||||
|
call_count += 1
|
||||||
|
await finish_event.wait()
|
||||||
|
return SMALLEST_VALID_JPEG_BYTES
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.ffmpeg.async_get_image",
|
||||||
|
new=_waiter,
|
||||||
|
):
|
||||||
|
tasks = asyncio.gather(
|
||||||
|
async_get_image(hass, "camera.my_camera_live_view"),
|
||||||
|
async_get_image(hass, "camera.my_camera_live_view"),
|
||||||
|
)
|
||||||
|
# Sleep to give both tasks chance to get to th asyncio.Lock()
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
finish_event.set()
|
||||||
|
results = await tasks
|
||||||
|
assert len(results) == 2
|
||||||
|
assert all(img and img.content == SMALLEST_VALID_JPEG_BYTES for img in results)
|
||||||
|
assert call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_camera_image_auth_error(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_camera_config_entry: MockConfigEntry,
|
||||||
|
mock_connect: AsyncMock,
|
||||||
|
mock_discovery: AsyncMock,
|
||||||
|
) -> None:
|
||||||
|
"""Test async_get_image."""
|
||||||
|
mock_device = _mocked_device(
|
||||||
|
modules=[Module.Camera],
|
||||||
|
alias="my_camera",
|
||||||
|
ip_address=IP_ADDRESS3,
|
||||||
|
mac=MAC_ADDRESS3,
|
||||||
|
)
|
||||||
|
|
||||||
|
await setup_platform_for_device(
|
||||||
|
hass, mock_camera_config_entry, Platform.CAMERA, mock_device
|
||||||
|
)
|
||||||
|
|
||||||
|
state = hass.states.get("camera.my_camera_live_view")
|
||||||
|
assert state is not None
|
||||||
|
flows = hass.config_entries.flow.async_progress()
|
||||||
|
assert len(flows) == 0
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"homeassistant.components.ffmpeg.async_get_image",
|
||||||
|
return_value=b"",
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"homeassistant.components.stream.async_check_stream_client_error",
|
||||||
|
side_effect=stream.StreamOpenClientError(
|
||||||
|
stream_client_error=stream.StreamClientError.Unauthorized
|
||||||
|
),
|
||||||
|
),
|
||||||
|
pytest.raises(HomeAssistantError),
|
||||||
|
):
|
||||||
|
await async_get_image(hass, "camera.my_camera_live_view")
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
flows = hass.config_entries.flow.async_progress()
|
||||||
|
assert len(flows) == 1
|
||||||
|
[result] = flows
|
||||||
|
|
||||||
|
assert result["step_id"] == "camera_auth_confirm"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_camera_stream_source(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_camera_config_entry: MockConfigEntry,
|
||||||
|
hass_ws_client: WebSocketGenerator,
|
||||||
|
) -> None:
|
||||||
|
"""Test async_get_image.
|
||||||
|
|
||||||
|
This test would fail if the integration didn't properly
|
||||||
|
put stream in the dependencies.
|
||||||
|
"""
|
||||||
|
mock_device = _mocked_device(
|
||||||
|
modules=[Module.Camera],
|
||||||
|
alias="my_camera",
|
||||||
|
ip_address=IP_ADDRESS3,
|
||||||
|
mac=MAC_ADDRESS3,
|
||||||
|
)
|
||||||
|
|
||||||
|
await setup_platform_for_device(
|
||||||
|
hass, mock_camera_config_entry, Platform.CAMERA, mock_device
|
||||||
|
)
|
||||||
|
|
||||||
|
state = hass.states.get("camera.my_camera_live_view")
|
||||||
|
assert state is not None
|
||||||
|
|
||||||
|
client = await hass_ws_client(hass)
|
||||||
|
await client.send_json_auto_id(
|
||||||
|
{"type": "camera/stream", "entity_id": "camera.my_camera_live_view"}
|
||||||
|
)
|
||||||
|
msg = await client.receive_json()
|
||||||
|
|
||||||
|
# Assert WebSocket response
|
||||||
|
assert msg["type"] == TYPE_RESULT
|
||||||
|
assert msg["success"]
|
||||||
|
assert "url" in msg["result"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_camera_stream_attributes(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_camera_config_entry: MockConfigEntry,
|
||||||
|
) -> None:
|
||||||
|
"""Test stream attributes."""
|
||||||
|
mock_device = _mocked_device(
|
||||||
|
modules=[Module.Camera],
|
||||||
|
alias="my_camera",
|
||||||
|
ip_address=IP_ADDRESS3,
|
||||||
|
mac=MAC_ADDRESS3,
|
||||||
|
)
|
||||||
|
|
||||||
|
await setup_platform_for_device(
|
||||||
|
hass, mock_camera_config_entry, Platform.CAMERA, mock_device
|
||||||
|
)
|
||||||
|
|
||||||
|
state = hass.states.get("camera.my_camera_live_view")
|
||||||
|
assert state is not None
|
||||||
|
|
||||||
|
supported_features = state.attributes.get("supported_features")
|
||||||
|
assert supported_features is CameraEntityFeature.STREAM | CameraEntityFeature.ON_OFF
|
||||||
|
camera = get_camera_from_entity_id(hass, "camera.my_camera_live_view")
|
||||||
|
assert camera.camera_capabilities.frontend_stream_types == {StreamType.HLS}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_camera_turn_on_off(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_camera_config_entry: MockConfigEntry,
|
||||||
|
) -> None:
|
||||||
|
"""Test camera turn on and off."""
|
||||||
|
mock_device = _mocked_device(
|
||||||
|
modules=[Module.Camera],
|
||||||
|
alias="my_camera",
|
||||||
|
ip_address=IP_ADDRESS3,
|
||||||
|
mac=MAC_ADDRESS3,
|
||||||
|
)
|
||||||
|
mock_camera = mock_device.modules[Module.Camera]
|
||||||
|
|
||||||
|
await setup_platform_for_device(
|
||||||
|
hass, mock_camera_config_entry, Platform.CAMERA, mock_device
|
||||||
|
)
|
||||||
|
|
||||||
|
state = hass.states.get("camera.my_camera_live_view")
|
||||||
|
assert state is not None
|
||||||
|
|
||||||
|
await hass.services.async_call(
|
||||||
|
"camera",
|
||||||
|
"turn_on",
|
||||||
|
{"entity_id": "camera.my_camera_live_view"},
|
||||||
|
blocking=True,
|
||||||
|
)
|
||||||
|
mock_camera.set_state.assert_called_with(True)
|
||||||
|
|
||||||
|
await hass.services.async_call(
|
||||||
|
"camera",
|
||||||
|
"turn_off",
|
||||||
|
{"entity_id": "camera.my_camera_live_view"},
|
||||||
|
blocking=True,
|
||||||
|
)
|
||||||
|
mock_camera.set_state.assert_called_with(False)
|
File diff suppressed because it is too large
Load Diff
@ -59,6 +59,7 @@ from . import (
|
|||||||
_patch_discovery,
|
_patch_discovery,
|
||||||
_patch_single_discovery,
|
_patch_single_discovery,
|
||||||
)
|
)
|
||||||
|
from .conftest import override_side_effect
|
||||||
|
|
||||||
from tests.common import MockConfigEntry, async_fire_time_changed
|
from tests.common import MockConfigEntry, async_fire_time_changed
|
||||||
|
|
||||||
@ -70,6 +71,7 @@ async def test_configuring_tplink_causes_discovery(
|
|||||||
with (
|
with (
|
||||||
patch("homeassistant.components.tplink.Discover.discover") as discover,
|
patch("homeassistant.components.tplink.Discover.discover") as discover,
|
||||||
patch("homeassistant.components.tplink.Discover.discover_single"),
|
patch("homeassistant.components.tplink.Discover.discover_single"),
|
||||||
|
patch("homeassistant.components.tplink.Device.connect"),
|
||||||
):
|
):
|
||||||
discover.return_value = {MagicMock(): MagicMock()}
|
discover.return_value = {MagicMock(): MagicMock()}
|
||||||
await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}})
|
await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}})
|
||||||
@ -221,8 +223,12 @@ async def test_config_entry_with_stored_credentials(
|
|||||||
|
|
||||||
hass.data.setdefault(DOMAIN, {})[CONF_AUTHENTICATION] = auth
|
hass.data.setdefault(DOMAIN, {})[CONF_AUTHENTICATION] = auth
|
||||||
mock_config_entry.add_to_hass(hass)
|
mock_config_entry.add_to_hass(hass)
|
||||||
with patch(
|
with (
|
||||||
"homeassistant.components.tplink.async_create_clientsession", return_value="Foo"
|
patch(
|
||||||
|
"homeassistant.components.tplink.async_create_clientsession",
|
||||||
|
return_value="Foo",
|
||||||
|
),
|
||||||
|
override_side_effect(mock_discovery["discover"], lambda *_, **__: {}),
|
||||||
):
|
):
|
||||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
|
Loading…
x
Reference in New Issue
Block a user