mirror of
https://github.com/home-assistant/core.git
synced 2026-10-06 06:15:47 -04:00
Move camera service registration to services module (#183438)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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],
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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),
|
||||
):
|
||||
|
||||
Reference in New Issue
Block a user