diff --git a/homeassistant/components/motioneye/camera.py b/homeassistant/components/motioneye/camera.py index f21769b24f21f929dfe8b289976600db7a846776..428e2f31c81b7c302c46b17278fab65b6528e965 100644 --- a/homeassistant/components/motioneye/camera.py +++ b/homeassistant/components/motioneye/camera.py @@ -9,10 +9,20 @@ from jinja2 import Template from motioneye_client.client import MotionEyeClient, MotionEyeClientURLParseError from motioneye_client.const import ( DEFAULT_SURVEILLANCE_USERNAME, + KEY_ACTION_SNAPSHOT, KEY_MOTION_DETECTION, KEY_NAME, KEY_STREAMING_AUTH_MODE, + KEY_TEXT_OVERLAY_CAMERA_NAME, + KEY_TEXT_OVERLAY_CUSTOM_TEXT, + KEY_TEXT_OVERLAY_CUSTOM_TEXT_LEFT, + KEY_TEXT_OVERLAY_CUSTOM_TEXT_RIGHT, + KEY_TEXT_OVERLAY_DISABLED, + KEY_TEXT_OVERLAY_LEFT, + KEY_TEXT_OVERLAY_RIGHT, + KEY_TEXT_OVERLAY_TIMESTAMP, ) +import voluptuous as vol from homeassistant.components.mjpeg.camera import ( CONF_MJPEG_URL, @@ -30,6 +40,7 @@ from homeassistant.const import ( HTTP_DIGEST_AUTHENTICATION, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -40,6 +51,7 @@ from . import ( listen_for_new_cameras, ) from .const import ( + CONF_ACTION, CONF_CLIENT, CONF_COORDINATOR, CONF_STREAM_URL_TEMPLATE, @@ -47,11 +59,40 @@ from .const import ( CONF_SURVEILLANCE_USERNAME, DOMAIN, MOTIONEYE_MANUFACTURER, + SERVICE_ACTION, + SERVICE_SET_TEXT_OVERLAY, + SERVICE_SNAPSHOT, TYPE_MOTIONEYE_MJPEG_CAMERA, ) PLATFORMS = ["camera"] +SCHEMA_TEXT_OVERLAY = vol.In( + [ + KEY_TEXT_OVERLAY_DISABLED, + KEY_TEXT_OVERLAY_TIMESTAMP, + KEY_TEXT_OVERLAY_CUSTOM_TEXT, + KEY_TEXT_OVERLAY_CAMERA_NAME, + ] +) +SCHEMA_SERVICE_SET_TEXT = vol.Schema( + vol.All( + { + vol.Optional(KEY_TEXT_OVERLAY_LEFT): SCHEMA_TEXT_OVERLAY, + vol.Optional(KEY_TEXT_OVERLAY_CUSTOM_TEXT_LEFT): cv.string, + vol.Optional(KEY_TEXT_OVERLAY_RIGHT): SCHEMA_TEXT_OVERLAY, + vol.Optional(KEY_TEXT_OVERLAY_CUSTOM_TEXT_RIGHT): cv.string, + }, + cv.has_at_least_one_key( + KEY_TEXT_OVERLAY_LEFT, + KEY_TEXT_OVERLAY_CUSTOM_TEXT_LEFT, + KEY_TEXT_OVERLAY_RIGHT, + KEY_TEXT_OVERLAY_CUSTOM_TEXT_RIGHT, + ), + ), + extra=vol.ALLOW_EXTRA, +) + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback @@ -80,6 +121,23 @@ async def async_setup_entry( listen_for_new_cameras(hass, entry, camera_add) + platform = entity_platform.async_get_current_platform() + platform.async_register_entity_service( + SERVICE_SET_TEXT_OVERLAY, + SCHEMA_SERVICE_SET_TEXT, + "async_set_text_overlay", + ) + platform.async_register_entity_service( + SERVICE_ACTION, + {vol.Required(CONF_ACTION): cv.string}, + "async_request_action", + ) + platform.async_register_entity_service( + SERVICE_SNAPSHOT, + {}, + "async_request_snapshot", + ) + class MotionEyeMjpegCamera(MotionEyeEntity, MjpegCamera): """motionEye mjpeg camera.""" @@ -201,3 +259,38 @@ class MotionEyeMjpegCamera(MotionEyeEntity, MjpegCamera): def motion_detection_enabled(self) -> bool: """Return the camera motion detection status.""" return self._motion_detection_enabled + + async def async_set_text_overlay( + self, + left_text: str = None, + right_text: str = None, + custom_left_text: str = None, + custom_right_text: str = None, + ) -> None: + """Set text overlay for a camera.""" + # Fetch the very latest camera config to reduce the risk of updating with a + # stale configuration. + camera = await self._client.async_get_camera(self._camera_id) + if not camera: + return + if left_text is not None: + camera[KEY_TEXT_OVERLAY_LEFT] = left_text + if right_text is not None: + camera[KEY_TEXT_OVERLAY_RIGHT] = right_text + if custom_left_text is not None: + camera[KEY_TEXT_OVERLAY_CUSTOM_TEXT_LEFT] = custom_left_text.encode( + "unicode_escape" + ).decode("UTF-8") + if custom_right_text is not None: + camera[KEY_TEXT_OVERLAY_CUSTOM_TEXT_RIGHT] = custom_right_text.encode( + "unicode_escape" + ).decode("UTF-8") + await self._client.async_set_camera(self._camera_id, camera) + + async def async_request_action(self, action: str) -> None: + """Call a motionEye action on a camera.""" + await self._client.async_action(self._camera_id, action) + + async def async_request_snapshot(self) -> None: + """Request a motionEye snapshot be saved.""" + await self.async_request_action(KEY_ACTION_SNAPSHOT) diff --git a/homeassistant/components/motioneye/const.py b/homeassistant/components/motioneye/const.py index 4e30cfb85140ac512814b3bd941fc254ea34ddca..1dbb78f1e03f9d23d262219f1c2b15d17fa9ed51 100644 --- a/homeassistant/components/motioneye/const.py +++ b/homeassistant/components/motioneye/const.py @@ -28,6 +28,7 @@ DOMAIN: Final = "motioneye" ATTR_EVENT_TYPE: Final = "event_type" ATTR_WEBHOOK_ID: Final = "webhook_id" +CONF_ACTION: Final = "action" CONF_CLIENT: Final = "client" CONF_COORDINATOR: Final = "coordinator" CONF_ADMIN_PASSWORD: Final = "admin_password" @@ -81,6 +82,10 @@ EVENT_FILE_STORED_KEYS: Final = [ MOTIONEYE_MANUFACTURER: Final = "motionEye" +SERVICE_SET_TEXT_OVERLAY: Final = "set_text_overlay" +SERVICE_ACTION: Final = "action" +SERVICE_SNAPSHOT: Final = "snapshot" + SIGNAL_CAMERA_ADD: Final = f"{DOMAIN}_camera_add_signal." "{}" SIGNAL_CAMERA_REMOVE: Final = f"{DOMAIN}_camera_remove_signal." "{}" diff --git a/homeassistant/components/motioneye/services.yaml b/homeassistant/components/motioneye/services.yaml new file mode 100644 index 0000000000000000000000000000000000000000..2970124c00065263fe04add9cdbb21067e838f77 --- /dev/null +++ b/homeassistant/components/motioneye/services.yaml @@ -0,0 +1,110 @@ +set_text_overlay: + name: Set Text Overlay + description: Sets the text overlay for a camera. + target: + device: + integration: motioneye + entity: + integration: motioneye + fields: + left_text: + name: Left Text Overlay + description: Text to display on the left + required: false + advanced: false + example: "timestamp" + default: "" + selector: + select: + options: + - "disabled" + - "camera-name" + - "timestamp" + - "custom-text" + custom_left_text: + name: Left Custom Text + description: Custom text to display on the left + required: false + advanced: false + example: "Hello on the left!" + default: "" + selector: + text: + multiline: true + right_text: + name: Right Text Overlay + description: Text to display on the right + required: false + advanced: false + example: "timestamp" + default: "" + selector: + select: + options: + - "disabled" + - "camera-name" + - "timestamp" + - "custom-text" + custom_right_text: + name: Right Custom Text + description: Custom text to display on the right + required: false + advanced: false + example: "Hello on the right!" + default: "" + selector: + text: + multiline: true + +action: + name: Action + description: Trigger a motionEye action + target: + device: + integration: motioneye + entity: + integration: motioneye + fields: + action: + name: Action + description: Action to trigger + required: true + advanced: false + example: "snapshot" + default: "" + selector: + select: + options: + - "snapshot" + - "record_start" + - "record_stop" + - "lock" + - "unlock" + - "light_on" + - "light_off" + - "alarm_on" + - "alarm_off" + - "up" + - "right" + - "down" + - "left" + - "zoom_in" + - "zoom_out" + - "preset1" + - "preset2" + - "preset3" + - "preset4" + - "preset5" + - "preset6" + - "preset7" + - "preset8" + - "preset9" + +snapshot: + name: Snapshot + description: Trigger a motionEye still snapshot + target: + device: + integration: motioneye + entity: + integration: motioneye diff --git a/tests/components/motioneye/test_camera.py b/tests/components/motioneye/test_camera.py index 6a669adc65f4a096bc125ffdb6475bea4ac6dd5d..b3d192371656052383ad99db8baf7e6d33df1bc0 100644 --- a/tests/components/motioneye/test_camera.py +++ b/tests/components/motioneye/test_camera.py @@ -1,7 +1,7 @@ """Test the motionEye camera.""" import copy from typing import Any, cast -from unittest.mock import AsyncMock, Mock +from unittest.mock import AsyncMock, Mock, call from aiohttp import web from aiohttp.web_exceptions import HTTPBadGateway @@ -14,20 +14,31 @@ from motioneye_client.const import ( KEY_CAMERAS, KEY_MOTION_DETECTION, KEY_NAME, + KEY_TEXT_OVERLAY_CUSTOM_TEXT, + KEY_TEXT_OVERLAY_CUSTOM_TEXT_LEFT, + KEY_TEXT_OVERLAY_CUSTOM_TEXT_RIGHT, + KEY_TEXT_OVERLAY_LEFT, + KEY_TEXT_OVERLAY_RIGHT, + KEY_TEXT_OVERLAY_TIMESTAMP, KEY_VIDEO_STREAMING, ) import pytest +import voluptuous as vol from homeassistant.components.camera import async_get_image, async_get_mjpeg_stream from homeassistant.components.motioneye import get_motioneye_device_identifier from homeassistant.components.motioneye.const import ( + CONF_ACTION, CONF_STREAM_URL_TEMPLATE, CONF_SURVEILLANCE_USERNAME, DEFAULT_SCAN_INTERVAL, DOMAIN, MOTIONEYE_MANUFACTURER, + SERVICE_ACTION, + SERVICE_SET_TEXT_OVERLAY, + SERVICE_SNAPSHOT, ) -from homeassistant.const import CONF_URL +from homeassistant.const import ATTR_DEVICE_ID, ATTR_ENTITY_ID, CONF_URL from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -35,6 +46,7 @@ from homeassistant.helpers.device_registry import async_get_registry import homeassistant.util.dt as dt_util from . import ( + TEST_CAMERA, TEST_CAMERA_DEVICE_IDENTIFIER, TEST_CAMERA_ENTITY_ID, TEST_CAMERA_ID, @@ -379,3 +391,155 @@ async def test_get_stream_from_camera_with_broken_host( await hass.async_block_till_done() with pytest.raises(HTTPBadGateway): await async_get_mjpeg_stream(hass, Mock(), TEST_CAMERA_ENTITY_ID) + + +async def test_set_text_overlay_bad_extra_key(hass: HomeAssistant) -> None: + """Test text overlay with incorrect input data.""" + client = create_mock_motioneye_client() + await setup_mock_motioneye_config_entry(hass, client=client) + + data = {ATTR_ENTITY_ID: TEST_CAMERA_ENTITY_ID, "extra_key": "foo"} + with pytest.raises(vol.error.MultipleInvalid): + await hass.services.async_call(DOMAIN, SERVICE_SET_TEXT_OVERLAY, data) + + +async def test_set_text_overlay_bad_entity_identifier(hass: HomeAssistant) -> None: + """Test text overlay with bad entity identifier.""" + client = create_mock_motioneye_client() + await setup_mock_motioneye_config_entry(hass, client=client) + + data = { + ATTR_ENTITY_ID: "some random string", + KEY_TEXT_OVERLAY_LEFT: KEY_TEXT_OVERLAY_TIMESTAMP, + } + + client.reset_mock() + await hass.services.async_call(DOMAIN, SERVICE_SET_TEXT_OVERLAY, data) + await hass.async_block_till_done() + assert not client.async_set_camera.called + + +async def test_set_text_overlay_bad_empty(hass: HomeAssistant) -> None: + """Test text overlay with incorrect input data.""" + client = create_mock_motioneye_client() + await setup_mock_motioneye_config_entry(hass, client=client) + with pytest.raises(vol.error.MultipleInvalid): + await hass.services.async_call(DOMAIN, SERVICE_SET_TEXT_OVERLAY, {}) + await hass.async_block_till_done() + + +async def test_set_text_overlay_bad_no_left_or_right(hass: HomeAssistant) -> None: + """Test text overlay with incorrect input data.""" + client = create_mock_motioneye_client() + await setup_mock_motioneye_config_entry(hass, client=client) + + data = {ATTR_ENTITY_ID: TEST_CAMERA_ENTITY_ID} + with pytest.raises(vol.error.MultipleInvalid): + await hass.services.async_call(DOMAIN, SERVICE_SET_TEXT_OVERLAY, data) + await hass.async_block_till_done() + + +async def test_set_text_overlay_good(hass: HomeAssistant) -> None: + """Test a working text overlay.""" + client = create_mock_motioneye_client() + await setup_mock_motioneye_config_entry(hass, client=client) + + custom_left_text = "one\ntwo\nthree" + custom_right_text = "four\nfive\nsix" + data = { + ATTR_ENTITY_ID: TEST_CAMERA_ENTITY_ID, + KEY_TEXT_OVERLAY_LEFT: KEY_TEXT_OVERLAY_CUSTOM_TEXT, + KEY_TEXT_OVERLAY_RIGHT: KEY_TEXT_OVERLAY_CUSTOM_TEXT, + KEY_TEXT_OVERLAY_CUSTOM_TEXT_LEFT: custom_left_text, + KEY_TEXT_OVERLAY_CUSTOM_TEXT_RIGHT: custom_right_text, + } + client.async_get_camera = AsyncMock(return_value=copy.deepcopy(TEST_CAMERA)) + + await hass.services.async_call(DOMAIN, SERVICE_SET_TEXT_OVERLAY, data) + await hass.async_block_till_done() + assert client.async_get_camera.called + + expected_camera = copy.deepcopy(TEST_CAMERA) + expected_camera[KEY_TEXT_OVERLAY_LEFT] = KEY_TEXT_OVERLAY_CUSTOM_TEXT + expected_camera[KEY_TEXT_OVERLAY_RIGHT] = KEY_TEXT_OVERLAY_CUSTOM_TEXT + expected_camera[KEY_TEXT_OVERLAY_CUSTOM_TEXT_LEFT] = "one\\ntwo\\nthree" + expected_camera[KEY_TEXT_OVERLAY_CUSTOM_TEXT_RIGHT] = "four\\nfive\\nsix" + assert client.async_set_camera.call_args == call(TEST_CAMERA_ID, expected_camera) + + +async def test_set_text_overlay_good_entity_id(hass: HomeAssistant) -> None: + """Test a working text overlay with entity_id.""" + client = create_mock_motioneye_client() + await setup_mock_motioneye_config_entry(hass, client=client) + + data = { + ATTR_ENTITY_ID: TEST_CAMERA_ENTITY_ID, + KEY_TEXT_OVERLAY_LEFT: KEY_TEXT_OVERLAY_TIMESTAMP, + } + client.async_get_camera = AsyncMock(return_value=copy.deepcopy(TEST_CAMERA)) + await hass.services.async_call(DOMAIN, SERVICE_SET_TEXT_OVERLAY, data) + await hass.async_block_till_done() + assert client.async_get_camera.called + + expected_camera = copy.deepcopy(TEST_CAMERA) + expected_camera[KEY_TEXT_OVERLAY_LEFT] = KEY_TEXT_OVERLAY_TIMESTAMP + assert client.async_set_camera.call_args == call(TEST_CAMERA_ID, expected_camera) + + +async def test_set_text_overlay_bad_device(hass: HomeAssistant) -> None: + """Test a working text overlay.""" + client = create_mock_motioneye_client() + await setup_mock_motioneye_config_entry(hass, client=client) + + data = { + ATTR_DEVICE_ID: "not a device", + KEY_TEXT_OVERLAY_LEFT: KEY_TEXT_OVERLAY_TIMESTAMP, + } + client.reset_mock() + client.async_get_camera = AsyncMock(return_value=copy.deepcopy(TEST_CAMERA)) + await hass.services.async_call(DOMAIN, SERVICE_SET_TEXT_OVERLAY, data) + await hass.async_block_till_done() + assert not client.async_get_camera.called + assert not client.async_set_camera.called + + +async def test_set_text_overlay_no_such_camera(hass: HomeAssistant) -> None: + """Test a working text overlay.""" + client = create_mock_motioneye_client() + await setup_mock_motioneye_config_entry(hass, client=client) + + data = { + ATTR_ENTITY_ID: TEST_CAMERA_ENTITY_ID, + KEY_TEXT_OVERLAY_LEFT: KEY_TEXT_OVERLAY_TIMESTAMP, + } + client.reset_mock() + client.async_get_camera = AsyncMock(return_value={}) + await hass.services.async_call(DOMAIN, SERVICE_SET_TEXT_OVERLAY, data) + await hass.async_block_till_done() + assert not client.async_set_camera.called + + +async def test_request_action(hass: HomeAssistant) -> None: + """Test requesting an action.""" + client = create_mock_motioneye_client() + await setup_mock_motioneye_config_entry(hass, client=client) + + data = { + ATTR_ENTITY_ID: TEST_CAMERA_ENTITY_ID, + CONF_ACTION: "foo", + } + await hass.services.async_call(DOMAIN, SERVICE_ACTION, data) + await hass.async_block_till_done() + assert client.async_action.call_args == call(TEST_CAMERA_ID, data[CONF_ACTION]) + + +async def test_request_snapshot(hass: HomeAssistant) -> None: + """Test requesting a snapshot.""" + client = create_mock_motioneye_client() + await setup_mock_motioneye_config_entry(hass, client=client) + + data = {ATTR_ENTITY_ID: TEST_CAMERA_ENTITY_ID} + + await hass.services.async_call(DOMAIN, SERVICE_SNAPSHOT, data) + await hass.async_block_till_done() + assert client.async_action.call_args == call(TEST_CAMERA_ID, "snapshot")