diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 0a3c9aed8a36..b0f219674f22 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -8,7 +8,6 @@ from dataclasses import asdict, dataclass from datetime import datetime, timedelta from functools import partial import logging -import os from random import SystemRandom import time from typing import Any, Final, final, override @@ -21,14 +20,7 @@ from webrtc_models import RTCIceCandidateInit from homeassistant.components import websocket_api from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView -from homeassistant.components.media_player import ( - ATTR_MEDIA_CONTENT_ID, - ATTR_MEDIA_CONTENT_TYPE, - DOMAIN as MP_DOMAIN, - SERVICE_PLAY_MEDIA, -) from homeassistant.components.stream import ( - FORMAT_CONTENT_TYPE, OUTPUT_FORMATS, Orientation, Stream, @@ -37,8 +29,7 @@ from homeassistant.components.stream import ( from homeassistant.components.web_rtc import async_get_ice_servers from homeassistant.components.websocket_api import ActiveConnection from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_ENTITY_ID, +from homeassistant.const import ( # noqa: F401 CONF_FILENAME, CONTENT_TYPE_MULTIPART, EVENT_HOMEASSISTANT_STARTED, @@ -47,17 +38,18 @@ from homeassistant.const import ( SERVICE_TURN_ON, EntityStateAttribute, ) -from homeassistant.core import Event, HomeAssistant, ServiceCall, callback +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.event import async_track_time_interval -from homeassistant.helpers.network import get_url -from homeassistant.helpers.template import Template -from homeassistant.helpers.typing import ConfigType, VolDictType +from homeassistant.helpers.typing import ConfigType -from .const import ( +from .const import ( # noqa: F401 + ATTR_FILENAME, + ATTR_FORMAT, + ATTR_MEDIA_PLAYER, CAMERA_IMAGE_TIMEOUT, CAMERA_STREAM_SOURCE_TIMEOUT, CONF_DURATION, @@ -67,7 +59,11 @@ from .const import ( DOMAIN, PREF_ORIENTATION, PREF_PRELOAD_STREAM, + SERVICE_DISABLE_MOTION, + SERVICE_ENABLE_MOTION, + SERVICE_PLAY_STREAM, SERVICE_RECORD, + SERVICE_SNAPSHOT, CameraEntityFeature, CameraEntityStateAttribute, CameraState, @@ -87,6 +83,7 @@ from .prefs import ( DynamicStreamSettings, # noqa: F401 get_dynamic_camera_stream_settings, ) +from .services import async_setup_services from .webrtc import ( CameraWebRTCProvider, WebRTCAnswer, # noqa: F401 @@ -108,15 +105,6 @@ PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE SCAN_INTERVAL: Final = timedelta(seconds=30) -SERVICE_ENABLE_MOTION: Final = "enable_motion_detection" -SERVICE_DISABLE_MOTION: Final = "disable_motion_detection" -SERVICE_SNAPSHOT: Final = "snapshot" -SERVICE_PLAY_STREAM: Final = "play_stream" - -ATTR_FILENAME: Final = "filename" -ATTR_MEDIA_PLAYER: Final = "media_player" -ATTR_FORMAT: Final = "format" - DEFAULT_CONTENT_TYPE: Final = "image/jpeg" ENTITY_IMAGE_URL: Final = "/api/camera_proxy/{0}?token={1}" @@ -126,19 +114,6 @@ _RND: Final = SystemRandom() MIN_STREAM_INTERVAL: Final = 0.5 # seconds -CAMERA_SERVICE_SNAPSHOT: VolDictType = {probatio.Required(ATTR_FILENAME): cv.template} - -CAMERA_SERVICE_PLAY_STREAM: VolDictType = { - probatio.Required(ATTR_MEDIA_PLAYER): cv.entities_domain(MP_DOMAIN), - probatio.Optional(ATTR_FORMAT, default="hls"): probatio.In(OUTPUT_FORMATS), -} - -CAMERA_SERVICE_RECORD: VolDictType = { - probatio.Required(CONF_FILENAME): cv.template, - probatio.Optional(CONF_DURATION, default=30): probatio.Coerce(int), - probatio.Optional(CONF_LOOKBACK, default=0): probatio.Coerce(int), -} - class CameraEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes camera entities.""" @@ -352,25 +327,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, unsub_track_time_interval) - component.async_register_entity_service( - SERVICE_ENABLE_MOTION, None, "async_enable_motion_detection" - ) - component.async_register_entity_service( - SERVICE_DISABLE_MOTION, None, "async_disable_motion_detection" - ) - component.async_register_entity_service(SERVICE_TURN_OFF, None, "async_turn_off") - component.async_register_entity_service(SERVICE_TURN_ON, None, "async_turn_on") - component.async_register_entity_service( - SERVICE_SNAPSHOT, CAMERA_SERVICE_SNAPSHOT, async_handle_snapshot_service - ) - component.async_register_entity_service( - SERVICE_PLAY_STREAM, - CAMERA_SERVICE_PLAY_STREAM, - async_handle_play_stream_service, - ) - component.async_register_entity_service( - SERVICE_RECORD, CAMERA_SERVICE_RECORD, async_handle_record_service - ) + async_setup_services(hass) return True @@ -1008,88 +965,3 @@ async def websocket_update_prefs( entity_id, ) connection.send_result(msg["id"], entity_prefs) - - -async def async_handle_snapshot_service( - camera: Camera, service_call: ServiceCall -) -> None: - """Handle snapshot services calls.""" - hass = camera.hass - filename: Template = service_call.data[ATTR_FILENAME] - - snapshot_file = filename.async_render() - - # check if we allow to access to that file - if not hass.config.is_allowed_path(snapshot_file): - raise HomeAssistantError( - f"Cannot write `{snapshot_file}`, no access to path;" - " `allowlist_external_dirs` may need to be adjusted" - " in `configuration.yaml`" - ) - - try: - async with asyncio.timeout(CAMERA_IMAGE_TIMEOUT): - image = ( - await async_get_stream_image(camera, wait_for_next_keyframe=True) - if camera.use_stream_for_stills - else await camera.async_camera_image() - ) - except TimeoutError as err: - raise HomeAssistantError( - f"Unable to get snapshot: Timed out after {CAMERA_IMAGE_TIMEOUT} seconds" - ) from err - - if image is None: - return - - def _write_image(to_file: str, image_data: bytes) -> None: - """Executor helper to write image.""" - os.makedirs(os.path.dirname(to_file), exist_ok=True) - with open(to_file, "wb") as img_file: - img_file.write(image_data) - - try: - await hass.async_add_executor_job(_write_image, snapshot_file, image) - except OSError as err: - raise HomeAssistantError(f"Can't write image to file: {err}") from err - - -async def async_handle_play_stream_service( - camera: Camera, service_call: ServiceCall -) -> None: - """Handle play stream services calls.""" - hass = camera.hass - fmt = service_call.data[ATTR_FORMAT] - url = await async_stream_endpoint_url(camera.hass, camera, fmt) - url = f"{get_url(hass)}{url}" - - await hass.services.async_call( - MP_DOMAIN, - SERVICE_PLAY_MEDIA, - { - ATTR_ENTITY_ID: service_call.data[ATTR_MEDIA_PLAYER], - ATTR_MEDIA_CONTENT_ID: url, - ATTR_MEDIA_CONTENT_TYPE: FORMAT_CONTENT_TYPE[fmt], - }, - blocking=True, - context=service_call.context, - ) - - -async def async_handle_record_service( - camera: Camera, service_call: ServiceCall -) -> None: - """Handle stream recording service calls.""" - stream = await camera.async_create_stream() - - if not stream: - raise HomeAssistantError(f"{camera.entity_id} does not support record service") - - filename = service_call.data[CONF_FILENAME] - video_path = filename.async_render() - - await stream.async_record( - video_path, - duration=service_call.data[CONF_DURATION], - lookback=service_call.data[CONF_LOOKBACK], - ) diff --git a/homeassistant/components/camera/const.py b/homeassistant/components/camera/const.py index 9637cf2b76b2..70d9777fcd08 100644 --- a/homeassistant/components/camera/const.py +++ b/homeassistant/components/camera/const.py @@ -20,6 +20,14 @@ PREF_PRELOAD_STREAM: Final = "preload_stream" PREF_ORIENTATION: Final = "orientation" SERVICE_RECORD: Final = "record" +SERVICE_ENABLE_MOTION: Final = "enable_motion_detection" +SERVICE_DISABLE_MOTION: Final = "disable_motion_detection" +SERVICE_SNAPSHOT: Final = "snapshot" +SERVICE_PLAY_STREAM: Final = "play_stream" + +ATTR_FILENAME: Final = "filename" +ATTR_MEDIA_PLAYER: Final = "media_player" +ATTR_FORMAT: Final = "format" CONF_LOOKBACK: Final = "lookback" CONF_DURATION: Final = "duration" diff --git a/homeassistant/components/camera/services.py b/homeassistant/components/camera/services.py new file mode 100644 index 000000000000..8a39496dfab5 --- /dev/null +++ b/homeassistant/components/camera/services.py @@ -0,0 +1,172 @@ +"""Services for the camera integration.""" + +import asyncio +import os +from typing import TYPE_CHECKING + +import probatio + +from homeassistant.components.media_player import ( + ATTR_MEDIA_CONTENT_ID, + ATTR_MEDIA_CONTENT_TYPE, + DOMAIN as MP_DOMAIN, + SERVICE_PLAY_MEDIA, +) +from homeassistant.components.stream import FORMAT_CONTENT_TYPE, OUTPUT_FORMATS +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_FILENAME, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.network import get_url +from homeassistant.helpers.template import Template +from homeassistant.helpers.typing import VolDictType + +from .const import ( + ATTR_FILENAME, + ATTR_FORMAT, + ATTR_MEDIA_PLAYER, + CAMERA_IMAGE_TIMEOUT, + CONF_DURATION, + CONF_LOOKBACK, + DATA_COMPONENT, + SERVICE_DISABLE_MOTION, + SERVICE_ENABLE_MOTION, + SERVICE_PLAY_STREAM, + SERVICE_RECORD, + SERVICE_SNAPSHOT, +) +from .helper import async_get_stream_image, async_stream_endpoint_url + +if TYPE_CHECKING: + from . import Camera + +CAMERA_SERVICE_SNAPSHOT: VolDictType = {probatio.Required(ATTR_FILENAME): cv.template} + + +CAMERA_SERVICE_PLAY_STREAM: VolDictType = { + probatio.Required(ATTR_MEDIA_PLAYER): cv.entities_domain(MP_DOMAIN), + probatio.Optional(ATTR_FORMAT, default="hls"): probatio.In(OUTPUT_FORMATS), +} + + +CAMERA_SERVICE_RECORD: VolDictType = { + probatio.Required(CONF_FILENAME): cv.template, + probatio.Optional(CONF_DURATION, default=30): probatio.Coerce(int), + probatio.Optional(CONF_LOOKBACK, default=0): probatio.Coerce(int), +} + + +async def _async_handle_snapshot_service( + camera: Camera, service_call: ServiceCall +) -> None: + """Handle snapshot services calls.""" + hass = camera.hass + filename: Template = service_call.data[ATTR_FILENAME] + + snapshot_file = filename.async_render() + + # check if we allow to access to that file + if not hass.config.is_allowed_path(snapshot_file): + raise HomeAssistantError( + f"Cannot write `{snapshot_file}`, no access to path;" + " `allowlist_external_dirs` may need to be adjusted" + " in `configuration.yaml`" + ) + + try: + async with asyncio.timeout(CAMERA_IMAGE_TIMEOUT): + image = ( + await async_get_stream_image(camera, wait_for_next_keyframe=True) + if camera.use_stream_for_stills + else await camera.async_camera_image() + ) + except TimeoutError as err: + raise HomeAssistantError( + f"Unable to get snapshot: Timed out after {CAMERA_IMAGE_TIMEOUT} seconds" + ) from err + + if image is None: + return + + def _write_image(to_file: str, image_data: bytes) -> None: + """Executor helper to write image.""" + os.makedirs(os.path.dirname(to_file), exist_ok=True) + with open(to_file, "wb") as img_file: + img_file.write(image_data) + + try: + await hass.async_add_executor_job(_write_image, snapshot_file, image) + except OSError as err: + raise HomeAssistantError(f"Can't write image to file: {err}") from err + + +async def _async_handle_play_stream_service( + camera: Camera, service_call: ServiceCall +) -> None: + """Handle play stream services calls.""" + hass = camera.hass + fmt = service_call.data[ATTR_FORMAT] + url = await async_stream_endpoint_url(camera.hass, camera, fmt) + url = f"{get_url(hass)}{url}" + + await hass.services.async_call( + MP_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: service_call.data[ATTR_MEDIA_PLAYER], + ATTR_MEDIA_CONTENT_ID: url, + ATTR_MEDIA_CONTENT_TYPE: FORMAT_CONTENT_TYPE[fmt], + }, + blocking=True, + context=service_call.context, + ) + + +async def _async_handle_record_service( + camera: Camera, service_call: ServiceCall +) -> None: + """Handle stream recording service calls.""" + stream = await camera.async_create_stream() + + if not stream: + raise HomeAssistantError(f"{camera.entity_id} does not support record service") + + filename = service_call.data[CONF_FILENAME] + video_path = filename.async_render() + + await stream.async_record( + video_path, + duration=service_call.data[CONF_DURATION], + lookback=service_call.data[CONF_LOOKBACK], + ) + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Register the camera services.""" + component = hass.data[DATA_COMPONENT] + + component.async_register_entity_service( + SERVICE_ENABLE_MOTION, None, "async_enable_motion_detection" + ) + component.async_register_entity_service( + SERVICE_DISABLE_MOTION, None, "async_disable_motion_detection" + ) + component.async_register_entity_service(SERVICE_TURN_OFF, None, "async_turn_off") + component.async_register_entity_service(SERVICE_TURN_ON, None, "async_turn_on") + component.async_register_entity_service( + SERVICE_SNAPSHOT, CAMERA_SERVICE_SNAPSHOT, _async_handle_snapshot_service + ) + component.async_register_entity_service( + SERVICE_PLAY_STREAM, + CAMERA_SERVICE_PLAY_STREAM, + _async_handle_play_stream_service, + ) + component.async_register_entity_service( + SERVICE_RECORD, CAMERA_SERVICE_RECORD, _async_handle_record_service + ) diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py index 4f84c5f2ca8d..b62bb95d3ff5 100644 --- a/tests/components/camera/test_init.py +++ b/tests/components/camera/test_init.py @@ -214,9 +214,9 @@ async def test_snapshot_service(hass: HomeAssistant) -> None: mopen = mock_open() with ( - patch("homeassistant.components.camera.open", mopen, create=True), + patch("homeassistant.components.camera.services.open", mopen, create=True), patch( - "homeassistant.components.camera.os.makedirs", + "homeassistant.components.camera.services.os.makedirs", ), patch.object(hass.config, "is_allowed_path", return_value=True), ): @@ -244,9 +244,9 @@ async def test_snapshot_service_entity_id_variable_removed(hass: HomeAssistant) mopen = mock_open() with ( - patch("homeassistant.components.camera.open", mopen, create=True), + patch("homeassistant.components.camera.services.open", mopen, create=True), patch( - "homeassistant.components.camera.os.makedirs", + "homeassistant.components.camera.services.os.makedirs", ), patch.object(hass.config, "is_allowed_path", return_value=True), ): @@ -269,9 +269,9 @@ async def test_snapshot_service_not_allowed_path(hass: HomeAssistant) -> None: mopen = mock_open() with ( - patch("homeassistant.components.camera.open", mopen, create=True), + patch("homeassistant.components.camera.services.open", mopen, create=True), patch( - "homeassistant.components.camera.os.makedirs", + "homeassistant.components.camera.services.os.makedirs", ), pytest.raises( HomeAssistantError, @@ -293,7 +293,7 @@ async def test_snapshot_service_not_allowed_path(hass: HomeAssistant) -> None: @pytest.mark.parametrize( ("target", "side_effect"), [ - ("homeassistant.components.camera.os.makedirs", OSError), + ("homeassistant.components.camera.services.os.makedirs", OSError), ( "homeassistant.components.demo.camera.DemoCamera.async_camera_image", TimeoutError, @@ -955,7 +955,7 @@ async def test_snapshot_service_webrtc_provider( with ( patch.object(camera_obj, "use_stream_for_stills", return_value=True), - patch("homeassistant.components.camera.open"), + patch("homeassistant.components.camera.services.open"), patch.object( camera_obj.webrtc_provider, "async_get_image", @@ -963,7 +963,7 @@ async def test_snapshot_service_webrtc_provider( ) as webrtc_get_image_mock, patch.object(camera_obj, "stream", AsyncMock()) as stream_mock, patch( - "homeassistant.components.camera.os.makedirs", + "homeassistant.components.camera.services.os.makedirs", ), patch.object(hass.config, "is_allowed_path", return_value=True), ):