Move camera service registration to services module (#183438)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
epenet
2026-09-28 17:37:28 +03:00
committed by GitHub
co-authored by Claude Opus 5
parent 2a61f79790
commit 12c2d3253d
4 changed files with 202 additions and 150 deletions
+13 -141
View File
@@ -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],
)
+8
View File
@@ -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"
+172
View File
@@ -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
)
+9 -9
View File
@@ -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),
):