diff --git a/homeassistant/components/mobile_app/const.py b/homeassistant/components/mobile_app/const.py index 6e83a08c508..6174e34f57a 100644 --- a/homeassistant/components/mobile_app/const.py +++ b/homeassistant/components/mobile_app/const.py @@ -72,3 +72,5 @@ ATTR_SENSOR_UOM = "unit_of_measurement" SIGNAL_SENSOR_UPDATE = f"{DOMAIN}_sensor_update" SIGNAL_LOCATION_UPDATE = DOMAIN + "_location_update_{}" + +ATTR_CAMERA_ENTITY_ID = "camera_entity_id" diff --git a/homeassistant/components/mobile_app/manifest.json b/homeassistant/components/mobile_app/manifest.json index 0576a466d7e..477acbb2203 100644 --- a/homeassistant/components/mobile_app/manifest.json +++ b/homeassistant/components/mobile_app/manifest.json @@ -5,7 +5,7 @@ "documentation": "https://www.home-assistant.io/integrations/mobile_app", "requirements": ["PyNaCl==1.3.0"], "dependencies": ["http", "webhook", "person"], - "after_dependencies": ["cloud"], + "after_dependencies": ["cloud", "camera"], "codeowners": ["@robbiet480"], "quality_scale": "internal" } diff --git a/homeassistant/components/mobile_app/webhook.py b/homeassistant/components/mobile_app/webhook.py index c71f3699019..ca9c31011ed 100644 --- a/homeassistant/components/mobile_app/webhook.py +++ b/homeassistant/components/mobile_app/webhook.py @@ -11,6 +11,7 @@ import voluptuous as vol from homeassistant.components.binary_sensor import ( DEVICE_CLASSES as BINARY_SENSOR_CLASSES, ) +from homeassistant.components.camera import SUPPORT_STREAM as CAMERA_SUPPORT_STREAM from homeassistant.components.device_tracker import ( ATTR_BATTERY, ATTR_GPS, @@ -29,7 +30,7 @@ from homeassistant.const import ( HTTP_CREATED, ) from homeassistant.core import EventOrigin -from homeassistant.exceptions import ServiceNotFound, TemplateError +from homeassistant.exceptions import HomeAssistantError, ServiceNotFound, TemplateError from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.template import attach @@ -40,6 +41,7 @@ from .const import ( ATTR_ALTITUDE, ATTR_APP_DATA, ATTR_APP_VERSION, + ATTR_CAMERA_ENTITY_ID, ATTR_COURSE, ATTR_DEVICE_ID, ATTR_DEVICE_NAME, @@ -240,6 +242,32 @@ async def webhook_fire_event(hass, config_entry, data): return empty_okay_response() +@WEBHOOK_COMMANDS.register("stream_camera") +@validate_schema({vol.Required(ATTR_CAMERA_ENTITY_ID): cv.string}) +async def webhook_stream_camera(hass, config_entry, data): + """Handle a request to HLS-stream a camera.""" + camera = hass.states.get(data[ATTR_CAMERA_ENTITY_ID]) + + if camera is None: + return webhook_response( + {"success": False}, registration=config_entry.data, status=HTTP_BAD_REQUEST, + ) + + resp = {"mjpeg_path": "/api/camera_proxy_stream/%s" % (camera.entity_id)} + + if camera.attributes["supported_features"] & CAMERA_SUPPORT_STREAM: + try: + resp["hls_path"] = await hass.components.camera.async_request_stream( + camera.entity_id, "hls" + ) + except HomeAssistantError: + resp["hls_path"] = None + else: + resp["hls_path"] = None + + return webhook_response(resp, registration=config_entry.data) + + @WEBHOOK_COMMANDS.register("render_template") @validate_schema( { diff --git a/tests/components/mobile_app/test_webhook.py b/tests/components/mobile_app/test_webhook.py index c0071913035..790ebc56bf6 100644 --- a/tests/components/mobile_app/test_webhook.py +++ b/tests/components/mobile_app/test_webhook.py @@ -3,14 +3,17 @@ import logging import pytest +from homeassistant.components.camera import SUPPORT_STREAM as CAMERA_SUPPORT_STREAM from homeassistant.components.mobile_app.const import CONF_SECRET from homeassistant.components.zone import DOMAIN as ZONE_DOMAIN from homeassistant.const import CONF_WEBHOOK_ID from homeassistant.core import callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component from .const import CALL_SERVICE, FIRE_EVENT, REGISTER_CLEARTEXT, RENDER_TEMPLATE, UPDATE +from tests.async_mock import patch from tests.common import async_mock_service _LOGGER = logging.getLogger(__name__) @@ -303,3 +306,103 @@ async def test_webhook_enable_encryption(hass, webhook_client, create_registrati decrypted_data = decrypt_payload(key, enc_json["encrypted_data"]) assert decrypted_data == {"one": "Hello world"} + + +async def test_webhook_camera_stream_non_existent( + hass, create_registrations, webhook_client +): + """Test fetching camera stream URLs for a non-existent camera.""" + webhook_id = create_registrations[1]["webhook_id"] + + resp = await webhook_client.post( + f"/api/webhook/{webhook_id}", + json={ + "type": "stream_camera", + "data": {"camera_entity_id": "camera.doesnt_exist"}, + }, + ) + + assert resp.status == 400 + webhook_json = await resp.json() + assert webhook_json["success"] is False + + +async def test_webhook_camera_stream_non_hls( + hass, create_registrations, webhook_client +): + """Test fetching camera stream URLs for a non-HLS/stream-supporting camera.""" + hass.states.async_set("camera.non_stream_camera", "idle", {"supported_features": 0}) + + webhook_id = create_registrations[1]["webhook_id"] + + resp = await webhook_client.post( + f"/api/webhook/{webhook_id}", + json={ + "type": "stream_camera", + "data": {"camera_entity_id": "camera.non_stream_camera"}, + }, + ) + + assert resp.status == 200 + webhook_json = await resp.json() + assert webhook_json["hls_path"] is None + assert ( + webhook_json["mjpeg_path"] + == "/api/camera_proxy_stream/camera.non_stream_camera" + ) + + +async def test_webhook_camera_stream_stream_available( + hass, create_registrations, webhook_client +): + """Test fetching camera stream URLs for an HLS/stream-supporting camera.""" + hass.states.async_set( + "camera.stream_camera", "idle", {"supported_features": CAMERA_SUPPORT_STREAM} + ) + + webhook_id = create_registrations[1]["webhook_id"] + + with patch( + "homeassistant.components.camera.async_request_stream", + return_value="/api/streams/some_hls_stream", + ): + resp = await webhook_client.post( + f"/api/webhook/{webhook_id}", + json={ + "type": "stream_camera", + "data": {"camera_entity_id": "camera.stream_camera"}, + }, + ) + + assert resp.status == 200 + webhook_json = await resp.json() + assert webhook_json["hls_path"] == "/api/streams/some_hls_stream" + assert webhook_json["mjpeg_path"] == "/api/camera_proxy_stream/camera.stream_camera" + + +async def test_webhook_camera_stream_stream_available_but_errors( + hass, create_registrations, webhook_client +): + """Test fetching camera stream URLs for an HLS/stream-supporting camera but that streaming errors.""" + hass.states.async_set( + "camera.stream_camera", "idle", {"supported_features": CAMERA_SUPPORT_STREAM} + ) + + webhook_id = create_registrations[1]["webhook_id"] + + with patch( + "homeassistant.components.camera.async_request_stream", + side_effect=HomeAssistantError(), + ): + resp = await webhook_client.post( + f"/api/webhook/{webhook_id}", + json={ + "type": "stream_camera", + "data": {"camera_entity_id": "camera.stream_camera"}, + }, + ) + + assert resp.status == 200 + webhook_json = await resp.json() + assert webhook_json["hls_path"] is None + assert webhook_json["mjpeg_path"] == "/api/camera_proxy_stream/camera.stream_camera"